diff --git a/ops.ts b/ops.ts index 3344eea..9c4c6ff 100644 --- a/ops.ts +++ b/ops.ts @@ -2,6 +2,7 @@ const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; const OP_CLOSE_ELEMENT = 0x04; +const OP_SNAPSHOT = 0x05; /* Property group masks for OPEN_ELEMENT */ const PROP_LAYOUT = 0x01; @@ -176,6 +177,12 @@ export function pack( break; } + case OP_SNAPSHOT: { + new Uint8Array(mem).set(op.data, o); + o += op.data.length; + break; + } + case OP_TEXT: { view.setUint32(o, OP_TEXT, true); o += 4; @@ -281,7 +288,12 @@ export interface Text { attrs?: number; } -export type Op = OpenElement | Text | CloseElement; +interface Snapshot { + directive: typeof OP_SNAPSHOT; + data: Uint8Array; +} + +export type Op = OpenElement | Text | CloseElement | Snapshot; export function open( id: string, @@ -300,3 +312,42 @@ export function text( export function close(): CloseElement { return { directive: OP_CLOSE_ELEMENT }; } + +function packSize(ops: Op[]): number { + let n = 0; + for (let op of ops) { + switch (op.directive) { + case OP_CLOSE_ELEMENT: + n += 4; + break; + case OP_SNAPSHOT: + n += op.data.length; + break; + case OP_OPEN_ELEMENT: { + n += 4; // opcode + n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string + n += 4; // mask + if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align + if (op.bg !== undefined) n += 4; + if (op.cornerRadius) n += 4; + if (op.border) n += 8; + if (op.clip) n += 4; + if (op.floating) n += 16; + break; + } + case OP_TEXT: { + n += 4 + 4 + 4; // opcode + color + cfg + n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string + break; + } + } + } + return n; +} + +export function snapshot(ops: Op[]): Op { + let size = packSize(ops); + let buf = new ArrayBuffer(size); + let words = pack(ops, buf, 0, size); + return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; +} diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index ac9c5f3..48bd3b0 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -403,6 +403,26 @@ Text directives MUST appear between a matching open/close pair. The set of styling properties accepted by `props` is part of the current implementation surface and may be extended. +#### 8.3.4 snapshot + +``` +snapshot(ops: Op[]): Op +``` + +Creates a snapshot by pre-packing the given directive array into its transfer +encoding. The returned value is an `Op` and can appear anywhere in a directive +array where the original ops would have appeared. The internal representation is +opaque. + +When the renderer encounters a snapshot during transfer, it copies the +pre-packed bytes directly into the command buffer without re-encoding. The +snapshot's ops MUST be structurally balanced (every `open` matched by a +`close`). + +Snapshots enable higher-level frameworks to implement dirty tracking: a +component whose inputs have not changed can reuse a previously created snapshot, +avoiding the cost of re-packing its subtree each frame. + ### 8.4 Sizing helpers These functions produce sizing-axis values for use in element layout @@ -459,6 +479,10 @@ that do not match a preceding open, is invalid input. Callers SHOULD validate directive arrays before rendering. The renderer's behavior when given an invalid directive array is unspecified by this specification. +A snapshot is semantically equivalent to splicing its source ops into the array +at the snapshot's position. The renderer MUST produce identical layout and +output regardless of whether ops are provided directly or via a snapshot. + ### 9.2 Transfer to the WASM module As part of the render transaction, the directive array is transferred into a diff --git a/test/term.test.ts b/test/term.test.ts index 47d8c94..adcd10a 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,6 +1,15 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; -import { close, fixed, grow, open, rgba, text } from "../ops.ts"; +import { + close, + fixed, + grow, + type Op, + open, + rgba, + snapshot, + text, +} from "../ops.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); @@ -191,6 +200,99 @@ describe("term", () => { }); }); + describe("snapshot", () => { + it("produces identical output to direct ops", async () => { + let ops = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + bg: rgba(0, 0, 128), + }), + open("child", { + layout: { + width: grow(), + padding: { left: 1 }, + direction: "ttb", + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text("snapshot test"), + close(), + close(), + ]; + + let direct = await createTerm({ width: 40, height: 10 }); + let snapped = await createTerm({ width: 40, height: 10 }); + + let expected = direct.render(ops, { mode: "line" }).output; + let actual = snapped.render([snapshot(ops)], { mode: "line" }).output; + + expect(decode(actual)).toEqual(decode(expected)); + }); + + it("renders inside another element", async () => { + let child = snapshot([ + open("child", { + layout: { width: grow(), direction: "ttb" }, + }), + text("inner"), + close(), + ]); + + let direct = await createTerm({ width: 20, height: 5 }); + let snapped = await createTerm({ width: 20, height: 5 }); + + let wrapper = (content: Op[]) => [ + open("root", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 1, top: 1 }, + }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + ...content, + close(), + ]; + + let expected = direct.render( + wrapper([ + open("child", { + layout: { width: grow(), direction: "ttb" }, + }), + text("inner"), + close(), + ]), + { mode: "line" }, + ).output; + + let actual = snapped.render( + wrapper([child]), + { mode: "line" }, + ).output; + + expect(decode(actual)).toEqual(decode(expected)); + expect(trim(print(decode(actual), 20, 5))).toEqual(` +┌──────────────────┐ +│inner │ +│ │ +│ │ +└──────────────────┘`.trim()); + }); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 });