parent
10c1cc6c6c
commit
27deb4cbf8
@ -0,0 +1,106 @@ |
||||
|
||||
Default to using Bun instead of Node.js. |
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>` |
||||
- Use `bun test` instead of `jest` or `vitest` |
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` |
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` |
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` |
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>` |
||||
- Bun automatically loads .env, so don't use dotenv. |
||||
|
||||
## APIs |
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. |
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`. |
||||
- `Bun.redis` for Redis. Don't use `ioredis`. |
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. |
||||
- `WebSocket` is built-in. Don't use `ws`. |
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile |
||||
- Bun.$`ls` instead of execa. |
||||
|
||||
## Testing |
||||
|
||||
Use `bun test` to run tests. |
||||
|
||||
```ts#index.test.ts |
||||
import { test, expect } from "bun:test"; |
||||
|
||||
test("hello world", () => { |
||||
expect(1).toBe(1); |
||||
}); |
||||
``` |
||||
|
||||
## Frontend |
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. |
||||
|
||||
Server: |
||||
|
||||
```ts#index.ts |
||||
import index from "./index.html" |
||||
|
||||
Bun.serve({ |
||||
routes: { |
||||
"/": index, |
||||
"/api/users/:id": { |
||||
GET: (req) => { |
||||
return new Response(JSON.stringify({ id: req.params.id })); |
||||
}, |
||||
}, |
||||
}, |
||||
// optional websocket support |
||||
websocket: { |
||||
open: (ws) => { |
||||
ws.send("Hello, world!"); |
||||
}, |
||||
message: (ws, message) => { |
||||
ws.send(message); |
||||
}, |
||||
close: (ws) => { |
||||
// handle close |
||||
} |
||||
}, |
||||
development: { |
||||
hmr: true, |
||||
console: true, |
||||
} |
||||
}) |
||||
``` |
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. |
||||
|
||||
```html#index.html |
||||
<html> |
||||
<body> |
||||
<h1>Hello, world!</h1> |
||||
<script type="module" src="./frontend.tsx"></script> |
||||
</body> |
||||
</html> |
||||
``` |
||||
|
||||
With the following `frontend.tsx`: |
||||
|
||||
```tsx#frontend.tsx |
||||
import React from "react"; |
||||
import { createRoot } from "react-dom/client"; |
||||
|
||||
// import .css files directly and it works |
||||
import './index.css'; |
||||
|
||||
const root = createRoot(document.body); |
||||
|
||||
export default function Frontend() { |
||||
return <h1>Hello, world!</h1>; |
||||
} |
||||
|
||||
root.render(<Frontend />); |
||||
``` |
||||
|
||||
Then, run index.ts |
||||
|
||||
```sh |
||||
bun --hot ./index.ts |
||||
``` |
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. |
||||
@ -0,0 +1,26 @@ |
||||
{ |
||||
"lockfileVersion": 1, |
||||
"configVersion": 1, |
||||
"workspaces": { |
||||
"": { |
||||
"name": "libmonoformat", |
||||
"devDependencies": { |
||||
"@types/bun": "latest", |
||||
}, |
||||
"peerDependencies": { |
||||
"typescript": "^5", |
||||
}, |
||||
}, |
||||
}, |
||||
"packages": { |
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], |
||||
|
||||
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], |
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], |
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], |
||||
|
||||
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@ |
||||
{ |
||||
"name": "libmonoformat", |
||||
"module": "index.ts", |
||||
"type": "module", |
||||
"devDependencies": { |
||||
"@types/bun": "latest" |
||||
}, |
||||
"peerDependencies": { |
||||
"typescript": "^5" |
||||
} |
||||
} |
||||
@ -0,0 +1,45 @@ |
||||
// =============================================================================
|
||||
// index.ts — public entry point
|
||||
//
|
||||
// Re-exports everything from library.ts so browser bundlers and module users
|
||||
// get a single clean import path:
|
||||
//
|
||||
// import { MonoDisplayDriver, loadBinFile } from "./index.ts";
|
||||
//
|
||||
// PLAN: if the project splits into sub-packages (parser / renderer / utils),
|
||||
// this file will aggregate them here so callers never need to update imports.
|
||||
// =============================================================================
|
||||
|
||||
export { |
||||
// Driver (primary browser-facing API)
|
||||
MonoDisplayDriver, |
||||
|
||||
// Parser — useful when you want to parse without rendering
|
||||
MonoDisplayParser, |
||||
|
||||
// Low-level binary reader — exposed so power users can parse custom sections
|
||||
BinaryReader, |
||||
|
||||
// Renderer — exposed for embedding into custom canvas setups
|
||||
MonoDisplayRenderer, |
||||
|
||||
// Convenience helpers
|
||||
loadBinFile, |
||||
buildBinBuffer, |
||||
} from "./library"; |
||||
|
||||
export type { |
||||
// Union type for all display content variants
|
||||
DisplayContent, |
||||
DisplayContentType, |
||||
|
||||
// Concrete content types
|
||||
StaticFrame, |
||||
AnimFrame, |
||||
Animation, |
||||
TextDisplay, |
||||
ScrollText, |
||||
|
||||
// Driver options bag
|
||||
MonoDisplayDriverOptions, |
||||
} from "./library"; |
||||
@ -0,0 +1,592 @@ |
||||
// =============================================================================
|
||||
// MonoDisplay Library — binary format parser + canvas renderer
|
||||
//
|
||||
// Binary format (little-endian throughout):
|
||||
// [FILE HEADER]
|
||||
// 4 bytes magic "MONO" (0x4D 0x4F 0x4E 0x4F)
|
||||
// 1 byte version format version (currently 1)
|
||||
// 1 byte content_type 0=static 1=animation 2=text 3=scrolltext
|
||||
// 2 bytes width display width in pixels
|
||||
// 2 bytes height display height in pixels
|
||||
//
|
||||
// [CONTENT — varies by content_type]
|
||||
// static:
|
||||
// (width*height) bytes raw 1bpp pixel rows, MSB-first within each byte
|
||||
//
|
||||
// animation:
|
||||
// 2 bytes frame_count
|
||||
// per frame:
|
||||
// 4 bytes duration_ms
|
||||
// (width*height) bytes pixel rows same as static
|
||||
//
|
||||
// text:
|
||||
// 2 bytes text_len
|
||||
// text_len bytes UTF-8 string (no null terminator)
|
||||
// 1 byte font_id reserved, ignored for now
|
||||
// 1 byte align 0=left 1=center 2=right
|
||||
//
|
||||
// scrolltext:
|
||||
// 2 bytes text_len
|
||||
// text_len bytes UTF-8 string
|
||||
// 2 bytes speed_pps pixels per second (scroll rate)
|
||||
// 1 byte direction 0=left 1=right
|
||||
//
|
||||
// PLAN: once the real spec arrives, swap out header offsets / field widths here.
|
||||
// All parsing is centralised in MonoDisplayParser.parse() so callers never
|
||||
// touch raw offsets.
|
||||
// =============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types — all exported so browser users can import them
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Supported content types embedded in a .bin display file */ |
||||
export type DisplayContentType = "static" | "animation" | "text" | "scrolltext"; |
||||
|
||||
/** A single monochrome bitmap. Pixels are row-major, 1 byte per pixel (0/1). */ |
||||
export interface StaticFrame { |
||||
type: "static"; |
||||
width: number; |
||||
height: number; |
||||
/** Unpacked: 1 byte per pixel, 0 = off, 1 = on */ |
||||
pixels: Uint8Array; |
||||
} |
||||
|
||||
/** One frame inside an animation */ |
||||
export interface AnimFrame { |
||||
/** How long this frame is displayed */ |
||||
durationMs: number; |
||||
pixels: Uint8Array; |
||||
} |
||||
|
||||
export interface Animation { |
||||
type: "animation"; |
||||
width: number; |
||||
height: number; |
||||
frames: AnimFrame[]; |
||||
} |
||||
|
||||
export interface TextDisplay { |
||||
type: "text"; |
||||
text: string; |
||||
/** 0=left 1=center 2=right — used by renderer */ |
||||
align: 0 | 1 | 2; |
||||
/** Reserved font index; renderer maps this to an embedded bitmap font */ |
||||
fontId: number; |
||||
} |
||||
|
||||
export interface ScrollText { |
||||
type: "scrolltext"; |
||||
text: string; |
||||
/** Scroll speed in pixels per second */ |
||||
speedPps: number; |
||||
/** 0=left 1=right */ |
||||
direction: 0 | 1; |
||||
} |
||||
|
||||
export type DisplayContent = StaticFrame | Animation | TextDisplay | ScrollText; |
||||
|
||||
/** Optional constructor arguments for MonoDisplayDriver */ |
||||
export interface MonoDisplayDriverOptions { |
||||
/** CSS colour for "on" pixels; default "#ffffff" */ |
||||
onColor?: string; |
||||
/** CSS colour for "off" pixels / background; default "#000000" */ |
||||
offColor?: string; |
||||
/** |
||||
* Scale factor: each logical pixel becomes NxN canvas pixels. |
||||
* Default 1. When CSS stretches the canvas for display, keep this at 1 |
||||
* and let the browser handle upscaling (use image-rendering: pixelated). |
||||
*/ |
||||
scale?: number; |
||||
/** |
||||
* Logical display width in pixels. Used as canvas resolution baseline for |
||||
* content types that have no inherent size (text, scrolltext). |
||||
* Also used when static/animation content overflows this width — clipped. |
||||
* Default 160. |
||||
*/ |
||||
displayWidth?: number; |
||||
/** |
||||
* Logical display height in pixels. Default 80. |
||||
*/ |
||||
displayHeight?: number; |
||||
/** |
||||
* If true, driver loops animations forever. Default true. |
||||
* Set false to stop after one pass. |
||||
*/ |
||||
loop?: boolean; |
||||
/** |
||||
* Callback fired whenever the driver encounters a parse or render error. |
||||
* If omitted, errors are thrown. |
||||
*/ |
||||
onError?: (err: Error) => void; |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BinaryReader — little-endian cursor over an ArrayBuffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** |
||||
* Stateful cursor that reads little-endian primitives from an ArrayBuffer. |
||||
* |
||||
* PLAN: if the spec ever needs signed types (int16, int32) or floats, add |
||||
* readInt16 / readFloat32 etc. here — all other code stays unchanged. |
||||
*/ |
||||
export class BinaryReader { |
||||
private view: DataView; |
||||
private pos: number = 0; |
||||
|
||||
constructor(buffer: ArrayBuffer) { |
||||
this.view = new DataView(buffer); |
||||
} |
||||
|
||||
get offset(): number { |
||||
return this.pos; |
||||
} |
||||
|
||||
get byteLength(): number { |
||||
return this.view.byteLength; |
||||
} |
||||
|
||||
get remaining(): number { |
||||
return this.view.byteLength - this.pos; |
||||
} |
||||
|
||||
/** Read one unsigned 8-bit integer */ |
||||
readByte(): number { |
||||
this.#assertRemaining(1); |
||||
return this.view.getUint8(this.pos++); |
||||
} |
||||
|
||||
/** Read unsigned 16-bit integer, little-endian */ |
||||
readUint16(): number { |
||||
this.#assertRemaining(2); |
||||
const v = this.view.getUint16(this.pos, true); |
||||
this.pos += 2; |
||||
return v; |
||||
} |
||||
|
||||
/** Alias kept for API clarity ("short" in spec language) */ |
||||
readShort(): number { |
||||
return this.readUint16(); |
||||
} |
||||
|
||||
/** Read unsigned 32-bit integer, little-endian */ |
||||
readUint32(): number { |
||||
this.#assertRemaining(4); |
||||
const v = this.view.getUint32(this.pos, true); |
||||
this.pos += 4; |
||||
return v; |
||||
} |
||||
|
||||
/** |
||||
* Read unsigned 64-bit integer, little-endian. |
||||
* Returns bigint because JS number cannot represent all uint64 values. |
||||
*/ |
||||
readUint64(): bigint { |
||||
this.#assertRemaining(8); |
||||
const lo = BigInt(this.view.getUint32(this.pos, true)); |
||||
const hi = BigInt(this.view.getUint32(this.pos + 4, true)); |
||||
this.pos += 8; |
||||
return (hi << 32n) | lo; |
||||
} |
||||
|
||||
/** Read N raw bytes as a Uint8Array (zero-copy view) */ |
||||
readBytes(n: number): Uint8Array { |
||||
this.#assertRemaining(n); |
||||
const slice = new Uint8Array(this.view.buffer, this.pos, n); |
||||
this.pos += n; |
||||
return slice; |
||||
} |
||||
|
||||
/** Read N bytes and decode as UTF-8 */ |
||||
readUtf8(n: number): string { |
||||
return new TextDecoder().decode(this.readBytes(n)); |
||||
} |
||||
|
||||
/** Move cursor to absolute byte position */ |
||||
seek(pos: number): void { |
||||
if (pos < 0 || pos > this.view.byteLength) { |
||||
throw new RangeError(`seek(${pos}) out of bounds [0, ${this.view.byteLength}]`); |
||||
} |
||||
this.pos = pos; |
||||
} |
||||
|
||||
#assertRemaining(n: number): void { |
||||
if (this.remaining < n) { |
||||
throw new RangeError( |
||||
`BinaryReader: need ${n} byte(s) at offset ${this.pos}, only ${this.remaining} remain` |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File format constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAGIC = 0x4f4e4f4d; // "MONO" as little-endian uint32
|
||||
|
||||
const ContentTypeByte: Record<number, DisplayContentType> = { |
||||
0: "static", |
||||
1: "animation", |
||||
2: "text", |
||||
3: "scrolltext", |
||||
}; |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayParser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** |
||||
* Parses a .bin ArrayBuffer into a strongly-typed DisplayContent value. |
||||
* |
||||
* PLAN: when the real spec lands, update ONLY the parse* private methods. |
||||
* The public surface (parse → DisplayContent) stays identical. |
||||
*/ |
||||
export class MonoDisplayParser { |
||||
parse(buffer: ArrayBuffer): DisplayContent { |
||||
const r = new BinaryReader(buffer); |
||||
|
||||
// --- header ---
|
||||
const magic = r.readUint32(); |
||||
if (magic !== MAGIC) { |
||||
throw new Error( |
||||
`Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x4F4E4F4D ("MONO")` |
||||
); |
||||
} |
||||
|
||||
const version = r.readByte(); |
||||
if (version !== 1) { |
||||
// PLAN: add version dispatch here when v2 spec exists
|
||||
throw new Error(`Unsupported format version ${version}`); |
||||
} |
||||
|
||||
const contentTypeByte = r.readByte(); |
||||
const contentType = ContentTypeByte[contentTypeByte]; |
||||
if (!contentType) { |
||||
throw new Error(`Unknown content type byte 0x${contentTypeByte.toString(16)}`); |
||||
} |
||||
|
||||
const width = r.readUint16(); |
||||
const height = r.readUint16(); |
||||
|
||||
// --- content ---
|
||||
switch (contentType) { |
||||
case "static": return this.#parseStatic(r, width, height); |
||||
case "animation": return this.#parseAnimation(r, width, height); |
||||
case "text": return this.#parseText(r); |
||||
case "scrolltext": return this.#parseScrollText(r); |
||||
} |
||||
} |
||||
|
||||
#parseStatic(r: BinaryReader, width: number, height: number): StaticFrame { |
||||
// PLAN: spec may use packed 1bpp; for now each byte = 1 pixel (0 or 1)
|
||||
const raw = r.readBytes(width * height); |
||||
return { type: "static", width, height, pixels: new Uint8Array(raw) }; |
||||
} |
||||
|
||||
#parseAnimation(r: BinaryReader, width: number, height: number): Animation { |
||||
const frameCount = r.readUint16(); |
||||
const frames: AnimFrame[] = []; |
||||
|
||||
for (let i = 0; i < frameCount; i++) { |
||||
const durationMs = r.readUint32(); |
||||
const raw = r.readBytes(width * height); |
||||
frames.push({ durationMs, pixels: new Uint8Array(raw) }); |
||||
} |
||||
|
||||
return { type: "animation", width, height, frames }; |
||||
} |
||||
|
||||
#parseText(r: BinaryReader): TextDisplay { |
||||
const textLen = r.readUint16(); |
||||
const text = r.readUtf8(textLen); |
||||
const fontId = r.readByte(); |
||||
const alignByte = r.readByte(); |
||||
const align = (alignByte === 1 ? 1 : alignByte === 2 ? 2 : 0) as 0 | 1 | 2; |
||||
return { type: "text", text, fontId, align }; |
||||
} |
||||
|
||||
#parseScrollText(r: BinaryReader): ScrollText { |
||||
const textLen = r.readUint16(); |
||||
const text = r.readUtf8(textLen); |
||||
const speedPps = r.readUint16(); |
||||
const dirByte = r.readByte(); |
||||
const direction = dirByte === 1 ? 1 : 0 as 0 | 1; |
||||
return { type: "scrolltext", text, speedPps, direction }; |
||||
} |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayRenderer — draws DisplayContent onto an HTMLCanvasElement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** |
||||
* Internal renderer; MonoDisplayDriver owns one instance. |
||||
* |
||||
* PLAN: text/scrolltext rendering currently draws a placeholder box. |
||||
* Replace #renderText / #renderScrollText with a real embedded bitmap font |
||||
* (5x7 or similar) once the font spec is finalised. |
||||
*/ |
||||
export class MonoDisplayRenderer { |
||||
private ctx: CanvasRenderingContext2D; |
||||
private opts: Required<MonoDisplayDriverOptions>; |
||||
|
||||
// Animation state
|
||||
private animFrame: number = 0; |
||||
private animTimer: number | null = null; |
||||
// ScrollText state
|
||||
private scrollX: number = 0; |
||||
private scrollRaf: number | null = null; |
||||
private scrollLastTs: number | null = null; |
||||
|
||||
constructor(canvas: HTMLCanvasElement, opts: Required<MonoDisplayDriverOptions>) { |
||||
const ctx = canvas.getContext("2d"); |
||||
if (!ctx) throw new Error("Cannot get 2D canvas context"); |
||||
this.ctx = ctx; |
||||
this.opts = opts; |
||||
} |
||||
|
||||
/** Stop any running animation / scroll before loading new content */ |
||||
stop(): void { |
||||
if (this.animTimer !== null) { |
||||
clearTimeout(this.animTimer); |
||||
this.animTimer = null; |
||||
} |
||||
if (this.scrollRaf !== null) { |
||||
cancelAnimationFrame(this.scrollRaf); |
||||
this.scrollRaf = null; |
||||
} |
||||
} |
||||
|
||||
render(content: DisplayContent): void { |
||||
this.stop(); |
||||
switch (content.type) { |
||||
case "static": this.#renderStatic(content); break; |
||||
case "animation": this.#startAnimation(content); break; |
||||
case "text": this.#renderText(content); break; |
||||
case "scrolltext": this.#startScrollText(content); break; |
||||
} |
||||
} |
||||
|
||||
#resizeCanvas(w: number, h: number): void { |
||||
const s = this.opts.scale; |
||||
this.ctx.canvas.width = w * s; |
||||
this.ctx.canvas.height = h * s; |
||||
} |
||||
|
||||
#renderPixels(pixels: Uint8Array, width: number, height: number): void { |
||||
const { onColor, offColor, scale: s } = this.opts; |
||||
const ctx = this.ctx; |
||||
ctx.fillStyle = offColor; |
||||
ctx.fillRect(0, 0, width * s, height * s); |
||||
ctx.fillStyle = onColor; |
||||
for (let y = 0; y < height; y++) { |
||||
for (let x = 0; x < width; x++) { |
||||
if (pixels[y * width + x]) { |
||||
ctx.fillRect(x * s, y * s, s, s); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
#renderStatic(content: StaticFrame): void { |
||||
this.#resizeCanvas(content.width, content.height); |
||||
this.#renderPixels(content.pixels, content.width, content.height); |
||||
} |
||||
|
||||
#startAnimation(content: Animation): void { |
||||
this.#resizeCanvas(content.width, content.height); |
||||
this.animFrame = 0; |
||||
|
||||
const tick = () => { |
||||
if (content.frames.length === 0) return; |
||||
const frame = content.frames[this.animFrame]; |
||||
this.#renderPixels(frame.pixels, content.width, content.height); |
||||
|
||||
this.animFrame++; |
||||
if (this.animFrame >= content.frames.length) { |
||||
if (!this.opts.loop) return; |
||||
this.animFrame = 0; |
||||
} |
||||
this.animTimer = setTimeout(tick, frame.durationMs) as unknown as number; |
||||
}; |
||||
tick(); |
||||
} |
||||
|
||||
#renderText(content: TextDisplay): void { |
||||
// PLAN: replace with bitmap font renderer; align/fontId will be honoured then
|
||||
const ctx = this.ctx; |
||||
const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor } = this.opts; |
||||
ctx.canvas.width = dw * s; |
||||
ctx.canvas.height = dh * s; |
||||
ctx.fillStyle = offColor; |
||||
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); |
||||
ctx.fillStyle = onColor; |
||||
// Scale font to roughly fill display height; real bitmap font will replace this
|
||||
const fontSize = Math.max(8, Math.floor(dh * s * 0.6)); |
||||
ctx.font = `${fontSize}px monospace`; |
||||
ctx.textBaseline = "middle"; |
||||
const padding = 4 * s; |
||||
ctx.fillText(content.text, padding, ctx.canvas.height / 2); |
||||
} |
||||
|
||||
#startScrollText(content: ScrollText): void { |
||||
// PLAN: replace inner draw with bitmap font; direction/speed wired in
|
||||
const ctx = this.ctx; |
||||
const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts; |
||||
ctx.canvas.width = dw * s; |
||||
ctx.canvas.height = dh * s; |
||||
// Start position: off-screen on the entry side
|
||||
this.scrollX = content.direction === 1 ? -(content.text.length * 8 * s) : ctx.canvas.width; |
||||
|
||||
const tick = (ts: number) => { |
||||
const dt = this.scrollLastTs !== null ? (ts - this.scrollLastTs) / 1000 : 0; |
||||
this.scrollLastTs = ts; |
||||
|
||||
const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor } = this.opts; |
||||
// speedPps is in logical pixels/s; scale to canvas pixels
|
||||
const delta = content.speedPps * s * dt * (content.direction === 1 ? 1 : -1); |
||||
this.scrollX += delta; |
||||
|
||||
// codepoint count (not UTF-16 units) for width estimate
|
||||
const cpCount = [...content.text].length; |
||||
const textWidth = cpCount * 8 * s; // rough 8px/cp until bitmap font lands
|
||||
if (content.direction === 0 && this.scrollX < -textWidth) { |
||||
this.scrollX = ctx.canvas.width; |
||||
} else if (content.direction === 1 && this.scrollX > ctx.canvas.width) { |
||||
this.scrollX = -textWidth; |
||||
} |
||||
|
||||
ctx.fillStyle = offColor; |
||||
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); |
||||
ctx.fillStyle = onColor; |
||||
const fontSize = Math.max(8, Math.floor(dh * s * 0.6)); |
||||
ctx.font = `${fontSize}px monospace`; |
||||
ctx.textBaseline = "middle"; |
||||
ctx.fillText(content.text, this.scrollX, ctx.canvas.height / 2); |
||||
|
||||
this.scrollRaf = requestAnimationFrame(tick); |
||||
}; |
||||
|
||||
this.scrollRaf = requestAnimationFrame(tick); |
||||
} |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayDriver — public API surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** |
||||
* Top-level driver. Attach to a canvas element by ID, then call load() with |
||||
* a function that returns a Promise<ArrayBuffer> of the .bin file. |
||||
* |
||||
* @example |
||||
* ```ts
|
||||
* const driver = new MonoDisplayDriver("canvas_root"); |
||||
* driver.load(() => loadBinFile("default_test.bin")); |
||||
* ``` |
||||
* |
||||
* PLAN: add driver.stop() / driver.play() / driver.setContent(DisplayContent) |
||||
* to allow external playback control without reloading. |
||||
*/ |
||||
export class MonoDisplayDriver { |
||||
private canvas: HTMLCanvasElement; |
||||
private opts: Required<MonoDisplayDriverOptions>; |
||||
private parser: MonoDisplayParser; |
||||
private renderer: MonoDisplayRenderer | null = null; |
||||
|
||||
constructor(canvasId: string, options: MonoDisplayDriverOptions = {}) { |
||||
const el = document.getElementById(canvasId); |
||||
if (!el || el.tagName !== "CANVAS") { |
||||
throw new Error(`MonoDisplayDriver: element #${canvasId} is not a <canvas>`); |
||||
} |
||||
this.canvas = el as HTMLCanvasElement; |
||||
|
||||
// Apply defaults
|
||||
this.opts = { |
||||
onColor: options.onColor ?? "#ffffff", |
||||
offColor: options.offColor ?? "#000000", |
||||
scale: options.scale ?? 1, |
||||
displayWidth: options.displayWidth ?? 160, |
||||
displayHeight: options.displayHeight ?? 80, |
||||
loop: options.loop ?? true, |
||||
onError: options.onError ?? ((e: Error) => { throw e; }), |
||||
}; |
||||
|
||||
this.parser = new MonoDisplayParser(); |
||||
} |
||||
|
||||
/** |
||||
* Load binary data from the provided async factory, parse, and render. |
||||
* Calling load() again stops any current playback and starts fresh. |
||||
* |
||||
* @param loader Async function returning the raw .bin ArrayBuffer |
||||
*/ |
||||
async load(loader: () => Promise<ArrayBuffer>): Promise<void> { |
||||
try { |
||||
const buffer = await loader(); |
||||
const content = this.parser.parse(buffer); |
||||
|
||||
// Lazy-create renderer on first load, reuse after
|
||||
if (!this.renderer) { |
||||
this.renderer = new MonoDisplayRenderer(this.canvas, this.opts); |
||||
} |
||||
this.renderer.render(content); |
||||
} catch (err) { |
||||
this.opts.onError(err instanceof Error ? err : new Error(String(err))); |
||||
} |
||||
} |
||||
|
||||
/** Stop current animation / scroll without unloading */ |
||||
stop(): void { |
||||
this.renderer?.stop(); |
||||
} |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers exposed for browser convenience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** |
||||
* Fetch a .bin file by URL and return its ArrayBuffer. |
||||
* Intended as the `loader` argument to driver.load(). |
||||
* |
||||
* @example |
||||
* ```ts
|
||||
* driver.load(() => loadBinFile("/assets/default_test.bin")); |
||||
* ``` |
||||
*/ |
||||
export async function loadBinFile(url: string): Promise<ArrayBuffer> { |
||||
const res = await fetch(url); |
||||
if (!res.ok) throw new Error(`loadBinFile: HTTP ${res.status} for ${url}`); |
||||
return res.arrayBuffer(); |
||||
} |
||||
|
||||
/** |
||||
* Build a minimal valid .bin ArrayBuffer in code — useful for tests and demos. |
||||
* |
||||
* PLAN: expand this into a full BinaryWriter helper once the spec is final, |
||||
* so integration tests can construct every content type without raw byte arrays. |
||||
*/ |
||||
export function buildBinBuffer(content: DisplayContent): ArrayBuffer { |
||||
// PLAN: implement encoding for all four content types
|
||||
// For now only "static" is written out (enough to round-trip in tests)
|
||||
if (content.type !== "static") { |
||||
throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`); |
||||
} |
||||
|
||||
const headerSize = 4 + 1 + 1 + 2 + 2; // magic + version + type + w + h
|
||||
const pixelSize = content.width * content.height; |
||||
const buf = new ArrayBuffer(headerSize + pixelSize); |
||||
const view = new DataView(buf); |
||||
|
||||
view.setUint32(0, MAGIC, true); |
||||
view.setUint8(4, 1); // version
|
||||
view.setUint8(5, 0); // content type = static
|
||||
view.setUint16(6, content.width, true); |
||||
view.setUint16(8, content.height, true); |
||||
new Uint8Array(buf, headerSize).set(content.pixels); |
||||
|
||||
return buf; |
||||
} |
||||
@ -0,0 +1,268 @@ |
||||
// =============================================================================
|
||||
// library.test.ts — bun test suite for MonoDisplay library
|
||||
//
|
||||
// run: bun test src/library.test.ts
|
||||
//
|
||||
// Tests are grouped by component. Canvas-dependent renderer tests use a
|
||||
// minimal mock so they run in Bun (no DOM). Parser + BinaryReader tests are
|
||||
// pure TypeScript with no mocks needed.
|
||||
// =============================================================================
|
||||
|
||||
import { test, expect, describe, mock, beforeEach } from "bun:test"; |
||||
import { |
||||
BinaryReader, |
||||
MonoDisplayParser, |
||||
buildBinBuffer, |
||||
type StaticFrame, |
||||
type Animation, |
||||
type TextDisplay, |
||||
type ScrollText, |
||||
} from "../src/library"; |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers to build raw binary buffers for each content type
|
||||
// (mirrors the format defined in library.ts header comment)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAGIC_BYTES = new Uint8Array([0x4d, 0x4f, 0x4e, 0x4f]); // "MONO"
|
||||
|
||||
function makeHeader(contentType: number, width: number, height: number): Uint8Array { |
||||
const h = new Uint8Array(10); |
||||
h.set(MAGIC_BYTES, 0); |
||||
h[4] = 1; // version
|
||||
h[5] = contentType; |
||||
new DataView(h.buffer).setUint16(6, width, true); |
||||
new DataView(h.buffer).setUint16(8, height, true); |
||||
return h; |
||||
} |
||||
|
||||
function concat(...parts: Uint8Array[]): ArrayBuffer { |
||||
const total = parts.reduce((s, p) => s + p.byteLength, 0); |
||||
const out = new Uint8Array(total); |
||||
let off = 0; |
||||
for (const p of parts) { out.set(p, off); off += p.byteLength; } |
||||
return out.buffer; |
||||
} |
||||
|
||||
function u16le(n: number): Uint8Array { |
||||
const b = new Uint8Array(2); |
||||
new DataView(b.buffer).setUint16(0, n, true); |
||||
return b; |
||||
} |
||||
|
||||
function u32le(n: number): Uint8Array { |
||||
const b = new Uint8Array(4); |
||||
new DataView(b.buffer).setUint32(0, n, true); |
||||
return b; |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BinaryReader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("BinaryReader", () => { |
||||
test("readByte reads single byte", () => { |
||||
const r = new BinaryReader(new Uint8Array([0xAB]).buffer); |
||||
expect(r.readByte()).toBe(0xAB); |
||||
expect(r.remaining).toBe(0); |
||||
}); |
||||
|
||||
test("readUint16 little-endian", () => { |
||||
const r = new BinaryReader(new Uint8Array([0x34, 0x12]).buffer); |
||||
expect(r.readUint16()).toBe(0x1234); |
||||
}); |
||||
|
||||
test("readShort is alias for readUint16", () => { |
||||
const r = new BinaryReader(new Uint8Array([0x01, 0x00]).buffer); |
||||
expect(r.readShort()).toBe(1); |
||||
}); |
||||
|
||||
test("readUint32 little-endian", () => { |
||||
const r = new BinaryReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]).buffer); |
||||
expect(r.readUint32()).toBe(0x12345678); |
||||
}); |
||||
|
||||
test("readUint64 little-endian", () => { |
||||
const bytes = new Uint8Array(8); |
||||
new DataView(bytes.buffer).setBigUint64(0, 0x0102030405060708n, true); |
||||
const r = new BinaryReader(bytes.buffer); |
||||
expect(r.readUint64()).toBe(0x0102030405060708n); |
||||
}); |
||||
|
||||
test("offset advances correctly", () => { |
||||
const r = new BinaryReader(new Uint8Array([1, 2, 3, 4, 5, 6]).buffer); |
||||
r.readByte(); |
||||
expect(r.offset).toBe(1); |
||||
r.readUint16(); |
||||
expect(r.offset).toBe(3); |
||||
expect(() => r.readUint32()).toThrow(RangeError); // 3 bytes remain, need 4
|
||||
}); |
||||
|
||||
test("RangeError when buffer exhausted", () => { |
||||
const r = new BinaryReader(new Uint8Array([0x01]).buffer); |
||||
r.readByte(); |
||||
expect(() => r.readByte()).toThrow(RangeError); |
||||
}); |
||||
|
||||
test("seek moves cursor", () => { |
||||
const r = new BinaryReader(new Uint8Array([10, 20, 30]).buffer); |
||||
r.seek(2); |
||||
expect(r.readByte()).toBe(30); |
||||
}); |
||||
|
||||
test("seek out of bounds throws", () => { |
||||
const r = new BinaryReader(new Uint8Array([1, 2]).buffer); |
||||
expect(() => r.seek(5)).toThrow(RangeError); |
||||
}); |
||||
|
||||
test("readUtf8 decodes ASCII", () => { |
||||
const str = "hello"; |
||||
const enc = new TextEncoder().encode(str); |
||||
const r = new BinaryReader(enc.buffer); |
||||
expect(r.readUtf8(5)).toBe("hello"); |
||||
}); |
||||
}); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayParser — static content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MonoDisplayParser — static", () => { |
||||
const parser = new MonoDisplayParser(); |
||||
|
||||
test("parses 2x2 static frame", () => { |
||||
const header = makeHeader(0, 2, 2); |
||||
const pixels = new Uint8Array([1, 0, 0, 1]); // diagonal
|
||||
const buf = concat(header, pixels); |
||||
|
||||
const result = parser.parse(buf) as StaticFrame; |
||||
expect(result.type).toBe("static"); |
||||
expect(result.width).toBe(2); |
||||
expect(result.height).toBe(2); |
||||
expect(result.pixels[0]).toBe(1); |
||||
expect(result.pixels[3]).toBe(1); |
||||
}); |
||||
|
||||
test("rejects wrong magic", () => { |
||||
const bad = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF, 1, 0, 4, 0, 4, 0]); |
||||
expect(() => parser.parse(bad.buffer)).toThrow(/magic/i); |
||||
}); |
||||
|
||||
test("rejects unsupported version", () => { |
||||
const buf = makeHeader(0, 1, 1); |
||||
buf[4] = 99; // version 99
|
||||
expect(() => parser.parse(concat(buf, new Uint8Array([0])))).toThrow(/version/i); |
||||
}); |
||||
|
||||
test("rejects unknown content type", () => { |
||||
const buf = makeHeader(0xFF, 1, 1); |
||||
expect(() => parser.parse(concat(buf, new Uint8Array([0])))).toThrow(/content type/i); |
||||
}); |
||||
}); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayParser — animation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MonoDisplayParser — animation", () => { |
||||
const parser = new MonoDisplayParser(); |
||||
|
||||
test("parses 2-frame animation", () => { |
||||
const header = makeHeader(1, 2, 2); // type=1 animation
|
||||
const frameCount = u16le(2); |
||||
const frame1 = concat(u32le(100), new Uint8Array([1, 1, 1, 1])); |
||||
const frame2 = concat(u32le(200), new Uint8Array([0, 0, 0, 0])); |
||||
const buf = concat(header, frameCount, new Uint8Array(frame1), new Uint8Array(frame2)); |
||||
|
||||
const result = parser.parse(buf) as Animation; |
||||
expect(result.type).toBe("animation"); |
||||
expect(result.frames.length).toBe(2); |
||||
expect(result.frames[0].durationMs).toBe(100); |
||||
expect(result.frames[1].durationMs).toBe(200); |
||||
}); |
||||
|
||||
test("empty animation (0 frames) is valid", () => { |
||||
const header = makeHeader(1, 4, 4); |
||||
const buf = concat(header, u16le(0)); |
||||
const result = parser.parse(buf) as Animation; |
||||
expect(result.frames.length).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayParser — text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MonoDisplayParser — text", () => { |
||||
const parser = new MonoDisplayParser(); |
||||
|
||||
test("parses text content", () => { |
||||
const header = makeHeader(2, 0, 0); // w/h unused for text
|
||||
const str = "HELLO"; |
||||
const enc = new TextEncoder().encode(str); |
||||
const payload = concat( |
||||
u16le(enc.byteLength), |
||||
enc, |
||||
new Uint8Array([0]), // fontId
|
||||
new Uint8Array([1]), // align = center
|
||||
); |
||||
const buf = concat(header, new Uint8Array(payload)); |
||||
|
||||
const result = parser.parse(buf) as TextDisplay; |
||||
expect(result.type).toBe("text"); |
||||
expect(result.text).toBe("HELLO"); |
||||
expect(result.align).toBe(1); |
||||
expect(result.fontId).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayParser — scrolltext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MonoDisplayParser — scrolltext", () => { |
||||
const parser = new MonoDisplayParser(); |
||||
|
||||
test("parses scrolltext content", () => { |
||||
const header = makeHeader(3, 0, 0); |
||||
const str = "SCROLL ME"; |
||||
const enc = new TextEncoder().encode(str); |
||||
const payload = concat( |
||||
u16le(enc.byteLength), |
||||
enc, |
||||
u16le(60), // speedPps = 60
|
||||
new Uint8Array([0]), // direction = left
|
||||
); |
||||
const buf = concat(header, new Uint8Array(payload)); |
||||
|
||||
const result = parser.parse(buf) as ScrollText; |
||||
expect(result.type).toBe("scrolltext"); |
||||
expect(result.text).toBe("SCROLL ME"); |
||||
expect(result.speedPps).toBe(60); |
||||
expect(result.direction).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildBinBuffer round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildBinBuffer", () => { |
||||
const parser = new MonoDisplayParser(); |
||||
|
||||
test("static round-trip", () => { |
||||
const pixels = new Uint8Array([0, 1, 1, 0]); |
||||
const original: StaticFrame = { type: "static", width: 2, height: 2, pixels }; |
||||
const buf = buildBinBuffer(original); |
||||
const parsed = parser.parse(buf) as StaticFrame; |
||||
expect(parsed.type).toBe("static"); |
||||
expect(parsed.width).toBe(2); |
||||
expect(parsed.height).toBe(2); |
||||
expect(Array.from(parsed.pixels)).toEqual([0, 1, 1, 0]); |
||||
}); |
||||
|
||||
test("non-static throws (not implemented yet)", () => { |
||||
const anim: Animation = { type: "animation", width: 1, height: 1, frames: [] }; |
||||
expect(() => buildBinBuffer(anim)).toThrow(/not yet implemented/); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,29 @@ |
||||
{ |
||||
"compilerOptions": { |
||||
// Environment setup & latest features |
||||
"lib": ["ESNext"], |
||||
"target": "ESNext", |
||||
"module": "Preserve", |
||||
"moduleDetection": "force", |
||||
"jsx": "react-jsx", |
||||
"allowJs": true, |
||||
|
||||
// Bundler mode |
||||
"moduleResolution": "bundler", |
||||
"allowImportingTsExtensions": true, |
||||
"verbatimModuleSyntax": true, |
||||
"noEmit": true, |
||||
|
||||
// Best practices |
||||
"strict": true, |
||||
"skipLibCheck": true, |
||||
"noFallthroughCasesInSwitch": true, |
||||
"noUncheckedIndexedAccess": true, |
||||
"noImplicitOverride": true, |
||||
|
||||
// Some stricter flags (disabled by default) |
||||
"noUnusedLocals": false, |
||||
"noUnusedParameters": false, |
||||
"noPropertyAccessFromIndexSignature": false |
||||
} |
||||
} |
||||
Loading…
Reference in new issue