From 704b3a102427f9b07e09733d6b697ce73adcec40 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH] feat; intro mono display file --- ts/src/index.ts | 9 +++ ts/src/library.ts | 170 ++++++++++++++++++++++++++++++++++++++++ ts/test/library.test.ts | 97 +++++++++++++++++++++++ 3 files changed, 276 insertions(+) diff --git a/ts/src/index.ts b/ts/src/index.ts index 63f73c0..eaaeec7 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -14,6 +14,10 @@ export { // Driver (primary browser-facing API) MonoDisplayDriver, + // File builder — JSON descriptor → binary .bin buffer + MonoDisplayFile, + ElementType, + // Parser — useful when you want to parse without rendering MonoDisplayParser, @@ -29,6 +33,11 @@ export { } from "./library"; export type { + // File builder types + MonoDisplayFileDescriptor, + DisplayElement, + Image2DElement, + // Union type for all display content variants DisplayContent, DisplayContentType, diff --git a/ts/src/library.ts b/ts/src/library.ts index bcffb12..6f19119 100644 --- a/ts/src/library.ts +++ b/ts/src/library.ts @@ -86,6 +86,176 @@ export interface ScrollText { export type DisplayContent = StaticFrame | Animation | TextDisplay | ScrollText; +// --------------------------------------------------------------------------- +// MonoDisplayFile — JSON-descriptor → binary builder +// --------------------------------------------------------------------------- + +/** + * Element types that can appear in a display descriptor. + * PLAN: extend with Text, ScrollText, Animation once section spec is complete. + */ +export enum ElementType { + /** A 1bpp rectangular bitmap placed at an (xoffset, yoffset) position */ + Image2D = "Image2D", + // PLAN: Text = "Text", + // PLAN: ScrollText = "ScrollText", + // PLAN: Animation = "Animation", +} + +/** A positioned 1bpp image placed on the display canvas */ +export interface Image2DElement { + type: ElementType.Image2D; + /** Raw pixel data — 1 byte per pixel (0=off 1=on), row-major */ + pixels: Uint8Array; + width: number; + height: number; + /** Horizontal offset from display left edge. Default 0. */ + xoffset?: number; + /** Vertical offset from display top edge. Default 0. */ + yoffset?: number; +} + +// Union for future element types +export type DisplayElement = Image2DElement; +// PLAN: | TextElement | ScrollTextElement | AnimationElement + +/** + * JSON descriptor passed to MonoDisplayFile. + * + * elements_always — rendered on every frame regardless of state. + * PLAN: elements_conditional — rendered only when a named condition is active + * (e.g. for state-machine-driven displays, ticker overrides, alerts). + * + * width / height — display canvas size in pixels. + * If omitted, inferred as the bounding box of all elements. + * Explicit values are required if elements don't fill the display. + */ +export interface MonoDisplayFileDescriptor { + width?: number; + height?: number; + elements_always?: DisplayElement[]; +} + +/** + * Builds a binary .bin file from a JSON descriptor. + * + * Usage: + * ```ts + * const file = new MonoDisplayFile({ + * width: 160, height: 80, + * elements_always: [ + * { type: ElementType.Image2D, pixels: myUint8Array, width: 120, height: 80, xoffset: 20 } + * ] + * }); + * driver.load(() => Promise.resolve(file.toBuffer())); + * ``` + * + * PLAN: toBuffer() currently composites all elements_always into one static frame. + * When animated elements are added, it will produce an animation section instead. + */ +export class MonoDisplayFile { + private desc: MonoDisplayFileDescriptor; + + constructor(descriptor: MonoDisplayFileDescriptor) { + this.desc = descriptor; + } + + /** + * Serialize to a valid .bin ArrayBuffer that MonoDisplayParser can read. + * Composites all elements_always onto a single static frame. + */ + toBuffer(): ArrayBuffer { + const { width, height } = this.#resolveSize(); + const canvas = this.#composite(width, height); + + // Encode as static section (0x01): width(u16) height(u16) pixels[w*h] + const sectionDataSize = 2 + 2 + width * height; + const totalSize = 12 + 4 + sectionDataSize; // fileHeader + sectionHeader + data + + const buf = new ArrayBuffer(totalSize); + const view = new DataView(buf); + let off = 0; + + // File header + view.setUint32(off, MAGIC, true); off += 4; // magic + view.setUint32(off, 1, true); off += 4; // version = 1 + view.setUint16(off, 1, true); off += 2; // numSections = 1 + view.setUint16(off, 0, true); off += 2; // reserved = 0 + + // Section header: type(1) + size uint24(3) + view.setUint8(off, 0x01); off += 1; // static section + view.setUint8(off, sectionDataSize & 0xFF); + view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF); + view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF); + off += 3; + + // Section data + view.setUint16(off, width, true); off += 2; + view.setUint16(off, height, true); off += 2; + new Uint8Array(buf, off).set(canvas); + + return buf; + } + + /** Determine display dimensions: explicit > inferred from element bounding box > defaults */ + #resolveSize(): { width: number; height: number } { + const elements = this.desc.elements_always ?? []; + + let width = this.desc.width; + let height = this.desc.height; + + if (width === undefined || height === undefined) { + let maxX = 0, maxY = 0; + for (const el of elements) { + maxX = Math.max(maxX, (el.xoffset ?? 0) + el.width); + maxY = Math.max(maxY, (el.yoffset ?? 0) + el.height); + } + width ??= maxX || 160; + height ??= maxY || 80; + } + + return { width, height }; + } + + /** Composite all elements_always onto a blank canvas (0=off, 1=on) */ + #composite(width: number, height: number): Uint8Array { + const canvas = new Uint8Array(width * height); // starts all-off + + for (const el of this.desc.elements_always ?? []) { + this.#blitElement(el, canvas, width, height); + } + + return canvas; + } + + /** Blit one element onto the canvas, clipping to display bounds */ + #blitElement(el: DisplayElement, canvas: Uint8Array, canvasW: number, canvasH: number): void { + if (el.type !== ElementType.Image2D) { + // PLAN: dispatch other element types here + throw new Error(`MonoDisplayFile: element type "${el.type}" not yet implemented`); + } + + const ox = el.xoffset ?? 0; + const oy = el.yoffset ?? 0; + + for (let row = 0; row < el.height; row++) { + const destY = oy + row; + if (destY < 0 || destY >= canvasH) continue; // clip top/bottom + + for (let col = 0; col < el.width; col++) { + const destX = ox + col; + if (destX < 0 || destX >= canvasW) continue; // clip left/right + + const srcIdx = row * el.width + col; + const destIdx = destY * canvasW + destX; + // OR-blend: any element can turn a pixel on; none can turn it off + // PLAN: add blend mode (OR / XOR / replace) to Image2DElement once spec covers it + if (el.pixels[srcIdx]) canvas[destIdx] = 1; + } + } + } +} + /** Optional constructor arguments for MonoDisplayDriver */ export interface MonoDisplayDriverOptions { /** CSS colour for "on" pixels; default "#ffffff" */ diff --git a/ts/test/library.test.ts b/ts/test/library.test.ts index b491ee2..3041ee4 100644 --- a/ts/test/library.test.ts +++ b/ts/test/library.test.ts @@ -11,6 +11,8 @@ import { test, expect, describe } from "bun:test"; import { BinaryReader, MonoDisplayParser, + MonoDisplayFile, + ElementType, buildBinBuffer, type StaticFrame, type Animation, @@ -332,3 +334,98 @@ describe("buildBinBuffer", () => { expect(() => buildBinBuffer(anim)).toThrow(/not yet implemented/); }); }); + +// --------------------------------------------------------------------------- +// MonoDisplayFile — JSON descriptor builder +// --------------------------------------------------------------------------- + +describe("MonoDisplayFile", () => { + const parser = new MonoDisplayParser(); + + test("single Image2D fills full display", () => { + const pixels = new Uint8Array(4 * 2).fill(1); // 4×2 all-on + const file = new MonoDisplayFile({ + width: 4, height: 2, + elements_always: [{ type: ElementType.Image2D, pixels, width: 4, height: 2 }], + }); + const result = parser.parse(file.toBuffer()) as StaticFrame; + expect(result.type).toBe("static"); + expect(result.width).toBe(4); + expect(result.height).toBe(2); + expect(result.pixels.every(p => p === 1)).toBe(true); + }); + + test("xoffset / yoffset positions element", () => { + // 6×2 display, 2×2 all-on block placed at xoffset=2 + const block = new Uint8Array(4).fill(1); + const file = new MonoDisplayFile({ + width: 6, height: 2, + elements_always: [{ type: ElementType.Image2D, pixels: block, width: 2, height: 2, xoffset: 2 }], + }); + const result = parser.parse(file.toBuffer()) as StaticFrame; + // cols 0,1 off — cols 2,3 on — cols 4,5 off + expect(result.pixels[0]).toBe(0); // (0,0) off + expect(result.pixels[2]).toBe(1); // (2,0) on + expect(result.pixels[3]).toBe(1); // (3,0) on + expect(result.pixels[4]).toBe(0); // (4,0) off + }); + + test("two overlapping elements OR-blend", () => { + // 4×1 display: element A sets pixels [0,1,0,0], element B sets [0,0,0,1] + const a = new Uint8Array([0, 1, 0, 0]); + const b = new Uint8Array([0, 0, 0, 1]); + const file = new MonoDisplayFile({ + width: 4, height: 1, + elements_always: [ + { type: ElementType.Image2D, pixels: a, width: 4, height: 1 }, + { type: ElementType.Image2D, pixels: b, width: 4, height: 1 }, + ], + }); + const result = parser.parse(file.toBuffer()) as StaticFrame; + expect(Array.from(result.pixels)).toEqual([0, 1, 0, 1]); + }); + + test("element clipped at right/bottom edge", () => { + // 4×2 display, 2×2 block placed at xoffset=3 — only 1 col fits + const block = new Uint8Array(4).fill(1); + const file = new MonoDisplayFile({ + width: 4, height: 2, + elements_always: [{ type: ElementType.Image2D, pixels: block, width: 2, height: 2, xoffset: 3 }], + }); + const result = parser.parse(file.toBuffer()) as StaticFrame; + // col 3 on, col 4+ clipped + expect(result.pixels[3]).toBe(1); // (3,0) on + expect(result.width).toBe(4); // canvas didn't grow + }); + + test("size inferred from element bounding box when omitted", () => { + const pixels = new Uint8Array(3 * 2).fill(1); + const file = new MonoDisplayFile({ + elements_always: [{ type: ElementType.Image2D, pixels, width: 3, height: 2, xoffset: 1, yoffset: 1 }], + }); + const result = parser.parse(file.toBuffer()) as StaticFrame; + expect(result.width).toBe(4); // 1 + 3 + expect(result.height).toBe(3); // 1 + 2 + }); + + test("empty descriptor produces 160×80 blank static frame", () => { + const file = new MonoDisplayFile({}); + const result = parser.parse(file.toBuffer()) as StaticFrame; + expect(result.width).toBe(160); + expect(result.height).toBe(80); + expect(result.pixels.every(p => p === 0)).toBe(true); + }); + + test("toBuffer round-trips through parser", () => { + const pixels = new Uint8Array(120 * 80); + for (let i = 0; i < pixels.length; i++) pixels[i] = i % 2; + const file = new MonoDisplayFile({ + width: 160, height: 80, + elements_always: [{ type: ElementType.Image2D, pixels, width: 120, height: 80, xoffset: 20 }], + }); + const result = parser.parse(file.toBuffer()) as StaticFrame; + expect(result.width).toBe(160); + expect(result.pixels[20]).toBe(0); // first pixel of element (i=0 → 0) + expect(result.pixels[21]).toBe(1); // second pixel (i=1 → 1) + }); +});