diff --git a/ts/index.html b/ts/index.html index a2b3345..30896d1 100644 --- a/ts/index.html +++ b/ts/index.html @@ -184,6 +184,22 @@ }).toBuffer(); }, }, + { + label: "Time", + make() { + return new MonoDisplayFile({ + elements_always: { + flags: { drawFront: true, clearBuffer: true }, + elements: [{ + type: ElementType.CurrentTime, + xOffset: 0, yOffset: 32, + width: W, height: 16, + utcOffsetMinutes: 120, + }], + }, + }).toBuffer(); + }, + }, ]; const controls = document.getElementById("controls"); diff --git a/ts/src/driver.ts b/ts/src/driver.ts index ec99d6d..4f424e2 100644 --- a/ts/src/driver.ts +++ b/ts/src/driver.ts @@ -30,6 +30,12 @@ export interface MonoDisplayDriverOptions { loop?: boolean; /** Error handler. Default: throw. */ onError?: (err: Error) => void; + /** + * Clock source for CurrentTime elements. Called once per render tick. + * Default: `() => new Date()` (system time). + * Override to freeze or mock the clock, e.g. `() => new Date('2024-01-01T12:00:00Z')`. + */ + now?: () => Date; } @@ -66,6 +72,7 @@ export class MonoDisplayDriver { fps: options.fps ?? 25, loop: options.loop ?? true, onError: options.onError ?? ((e: Error) => { throw e; }), + now: options.now ?? (() => new Date()), }; this.parser = new MonoDisplayParser(); } diff --git a/ts/src/file.ts b/ts/src/file.ts index 1d240e0..8ea4431 100644 --- a/ts/src/file.ts +++ b/ts/src/file.ts @@ -4,7 +4,7 @@ import { packedSize, packPixels, pad32 } from "./helper"; import { MONOFORMAT_MAGIC_HEADER } from "./parser"; -import { ElementType, SectionType, type AnimationElement, type ClippedTextElement, type DrawElement, type ElementsAlwaysDescriptor, type HScrollElement, type HScrollTextElement, type Image2DElement, type LineElement, type MonoDisplayFileDescriptor, type ScrollElementFlags, type SectionFlags, type VScrollElement } from "./types"; +import { ElementType, SectionType, type AnimationElement, type ClippedTextElement, type CurrentTimeElement, type DrawElement, type ElementsAlwaysDescriptor, type HScrollElement, type HScrollTextElement, type Image2DElement, type LineElement, type MonoDisplayFileDescriptor, type ScrollElementFlags, type SectionFlags, type VScrollElement } from "./types"; /** * Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer. @@ -85,6 +85,7 @@ export class MonoDisplayFile { case ElementType.Line: return this.#encodeLine(el); case ElementType.ClippedText: return this.#encodeClippedText(el); case ElementType.HScrollText: return this.#encodeHScrollText(el); + case ElementType.CurrentTime: return this.#encodeCurrentTime(el); default: return new Uint8Array(); } } @@ -230,6 +231,28 @@ export class MonoDisplayFile { return out; } + #encodeCurrentTime(el: CurrentTimeElement): Uint8Array { + // Fixed 16 bytes, already 32-bit aligned + const out = new Uint8Array(16); + const v = new DataView(out.buffer); + const f = el.flags; + const flagsByte = + (f?.clock12h ? 0x01 : 0) | + (f?.showHours ?? true ? 0x02 : 0) | + (f?.showMinutes ?? true ? 0x04 : 0) | + (f?.showSeconds ? 0x08 : 0); + v.setUint16( 0, ElementType.CurrentTime, true); + v.setUint16( 2, el.xOffset ?? 0, true); + v.setUint16( 4, el.yOffset ?? 0, true); + v.setUint16( 6, el.width, true); + v.setUint16( 8, el.height, true); + v.setUint16(10, el.fontIndex ?? 0, true); + v.setInt16( 12, el.utcOffsetMinutes ?? 0, true); + v.setUint8( 14, flagsByte); + v.setUint8( 15, 0); // reserved + return out; + } + // --- utilities --- #concat(...parts: Uint8Array[]): Uint8Array { diff --git a/ts/src/parser.ts b/ts/src/parser.ts index 536609a..4b8106f 100644 --- a/ts/src/parser.ts +++ b/ts/src/parser.ts @@ -5,7 +5,7 @@ import { packedSize, packPixels, pad32, unpackPixels, BinaryReader } from "./helper"; import { type MonoFormatFile } from "./types"; -import { ElementType, SectionType, type MonoFormatAnimation, type MonoFormatClippedText, type MonoFormatCustomFont, type MonoFormatElement, type MonoFormatElementsAlways, type MonoFormatElementsTimespan, type MonoFormatHScroll, type MonoFormatHScrollText, type MonoFormatImage2D, type MonoFormatLine, type MonoFormatPixelImage, type MonoFormatSection, type MonoFormatSectionFlags, type MonoFormatVScroll } from "./types"; +import { ElementType, SectionType, type MonoFormatAnimation, type MonoFormatClippedText, type MonoFormatCurrentTime, type MonoFormatCustomFont, type MonoFormatElement, type MonoFormatElementsAlways, type MonoFormatElementsTimespan, type MonoFormatHScroll, type MonoFormatHScrollText, type MonoFormatImage2D, type MonoFormatLine, type MonoFormatPixelImage, type MonoFormatSection, type MonoFormatSectionFlags, type MonoFormatVScroll } from "./types"; // Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE export const MONOFORMAT_MAGIC_HEADER = 0x632B7EAF; @@ -116,6 +116,7 @@ export class MonoDisplayParser { case ElementType.Line: return this.#parseLine(r); case ElementType.ClippedText: return this.#parseClippedText(r, elementStart); case ElementType.HScrollText: return this.#parseHScrollText(r, elementStart); + case ElementType.CurrentTime: return this.#parseCurrentTime(r); default: // Unknown element type - cannot safely skip without knowing size; stop parsing section elements return null; @@ -238,6 +239,28 @@ export class MonoDisplayParser { return { type: ElementType.ClippedText, xOffset, yOffset, width, height, fontIndex, text }; } + #parseCurrentTime(r: BinaryReader): MonoFormatCurrentTime { + // Layout after type field (already consumed): 14 bytes, total element = 16 bytes (aligned) + const xOffset = r.readUint16(); + const yOffset = r.readUint16(); + const width = r.readUint16(); + const height = r.readUint16(); + const fontIndex = r.readUint16(); + const utcOffsetMinutes = r.readInt16(); + const flagsByte = r.readByte(); + r.readByte(); // reserved + return { + type: ElementType.CurrentTime, + xOffset, yOffset, width, height, fontIndex, utcOffsetMinutes, + flags: { + clock12h: !!(flagsByte & 0x01), + showHours: !!(flagsByte & 0x02), + showMinutes: !!(flagsByte & 0x04), + showSeconds: !!(flagsByte & 0x08), + }, + }; + } + #parseHScrollText(r: BinaryReader, start: number): MonoFormatHScrollText { const xOffset = r.readUint16(); const yOffset = r.readUint16(); diff --git a/ts/src/renderer.ts b/ts/src/renderer.ts index b1c5a82..c2a1e13 100644 --- a/ts/src/renderer.ts +++ b/ts/src/renderer.ts @@ -4,6 +4,7 @@ import { SectionType, type MonoFormatAnimation, type MonoFormatClippedText, + type MonoFormatCurrentTime, type MonoFormatElement, type MonoFormatElementsAlways, type MonoFormatElementsTimespan, @@ -82,7 +83,7 @@ export class MonoDisplayRenderer { } #renderFile(file: MonoFormatFile): void { - const now = BigInt(Math.floor(Date.now() / 1000)); + const now = BigInt(Math.floor(this.opts.now().getTime() / 1000)); let cleared = false; let elementId = 0; @@ -124,6 +125,7 @@ export class MonoDisplayRenderer { case ElementType.Line: this.#renderLine(el); break; case ElementType.ClippedText: this.#renderClippedText(el); break; case ElementType.HScrollText: this.#renderHScrollText(el, id); break; + case ElementType.CurrentTime: this.#renderCurrentTime(el); break; } } @@ -252,4 +254,23 @@ export class MonoDisplayRenderer { } this.hScrollPos.set(id, pos); } + + #renderCurrentTime(el: MonoFormatCurrentTime): void { + const d = new Date(this.opts.now().getTime() + el.utcOffsetMinutes * 60_000); + const { clock12h, showHours, showMinutes, showSeconds } = el.flags; + + const parts: string[] = []; + if (showHours) { + const h = d.getUTCHours(); + parts.push(String(clock12h ? (h % 12 || 12) : h).padStart(2, '0')); + } + if (showMinutes) parts.push(String(d.getUTCMinutes()).padStart(2, '0')); + if (showSeconds) parts.push(String(d.getUTCSeconds()).padStart(2, '0')); + + const text = parts.join(':'); + if (!text) return; + + const img = this.#getTextImage(text, el.height, el.fontIndex); + this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, 0, 0); + } } diff --git a/ts/src/types.ts b/ts/src/types.ts index 168bc48..ba6a322 100644 --- a/ts/src/types.ts +++ b/ts/src/types.ts @@ -31,12 +31,6 @@ export enum ElementType { Line = 5, ClippedText = 16, HScrollText = 17, - /** - * Current time display. - * NOTE: the spec diagram labels this element "Type: 16", which collides with - * ClippedText. This is assumed to be a spec typo; 18 is used here as the - * probable intended value until the spec is corrected. - */ CurrentTime = 18, }