feat; intro mono display file

pull/1/head
flop 4 weeks ago
parent 30afeb4dad
commit 704b3a1024
  1. 9
      ts/src/index.ts
  2. 170
      ts/src/library.ts
  3. 97
      ts/test/library.test.ts

@ -14,6 +14,10 @@ export {
// Driver (primary browser-facing API) // Driver (primary browser-facing API)
MonoDisplayDriver, MonoDisplayDriver,
// File builder — JSON descriptor → binary .bin buffer
MonoDisplayFile,
ElementType,
// Parser — useful when you want to parse without rendering // Parser — useful when you want to parse without rendering
MonoDisplayParser, MonoDisplayParser,
@ -29,6 +33,11 @@ export {
} from "./library"; } from "./library";
export type { export type {
// File builder types
MonoDisplayFileDescriptor,
DisplayElement,
Image2DElement,
// Union type for all display content variants // Union type for all display content variants
DisplayContent, DisplayContent,
DisplayContentType, DisplayContentType,

@ -86,6 +86,176 @@ export interface ScrollText {
export type DisplayContent = StaticFrame | Animation | TextDisplay | 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 */ /** Optional constructor arguments for MonoDisplayDriver */
export interface MonoDisplayDriverOptions { export interface MonoDisplayDriverOptions {
/** CSS colour for "on" pixels; default "#ffffff" */ /** CSS colour for "on" pixels; default "#ffffff" */

@ -11,6 +11,8 @@ import { test, expect, describe } from "bun:test";
import { import {
BinaryReader, BinaryReader,
MonoDisplayParser, MonoDisplayParser,
MonoDisplayFile,
ElementType,
buildBinBuffer, buildBinBuffer,
type StaticFrame, type StaticFrame,
type Animation, type Animation,
@ -332,3 +334,98 @@ describe("buildBinBuffer", () => {
expect(() => buildBinBuffer(anim)).toThrow(/not yet implemented/); 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)
});
});

Loading…
Cancel
Save