Skip to content

Commit df56324

Browse files
committed
✨ add snapshot() for pre-packing directive subtrees
Introduces a new `snapshot(ops)` constructor that pre-packs a directive array into its transfer encoding. The returned opaque `Op` can be spliced into any directive array, and during packing its bytes are copied directly into the command buffer without re-encoding. This enables higher-level frameworks to implement dirty tracking: unchanged component subtrees can reuse a cached snapshot, skipping the per-frame packing cost entirely.
1 parent a1ae74c commit df56324

3 files changed

Lines changed: 185 additions & 2 deletions

File tree

ops.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
const OP_OPEN_ELEMENT = 0x02;
33
const OP_TEXT = 0x03;
44
const OP_CLOSE_ELEMENT = 0x04;
5+
const OP_SNAPSHOT = 0x05;
56

67
/* Property group masks for OPEN_ELEMENT */
78
const PROP_LAYOUT = 0x01;
@@ -176,6 +177,12 @@ export function pack(
176177
break;
177178
}
178179

180+
case OP_SNAPSHOT: {
181+
new Uint8Array(mem).set(op.data, o);
182+
o += op.data.length;
183+
break;
184+
}
185+
179186
case OP_TEXT: {
180187
view.setUint32(o, OP_TEXT, true);
181188
o += 4;
@@ -195,6 +202,12 @@ export function pack(
195202
o = packString(view, str, o);
196203
break;
197204
}
205+
206+
case OP_SNAPSHOT: {
207+
new Uint8Array(view.buffer).set(op.data, o);
208+
o += op.data.length;
209+
break;
210+
}
198211
}
199212
if (o > end) {
200213
throw new RangeError(
@@ -281,7 +294,12 @@ export interface Text {
281294
attrs?: number;
282295
}
283296

284-
export type Op = OpenElement | Text | CloseElement;
297+
interface Snapshot {
298+
directive: typeof OP_SNAPSHOT;
299+
data: Uint8Array;
300+
}
301+
302+
export type Op = OpenElement | Text | CloseElement | Snapshot;
285303

286304
export function open(
287305
id: string,
@@ -300,3 +318,42 @@ export function text(
300318
export function close(): CloseElement {
301319
return { directive: OP_CLOSE_ELEMENT };
302320
}
321+
322+
function packSize(ops: Op[]): number {
323+
let n = 0;
324+
for (let op of ops) {
325+
switch (op.directive) {
326+
case OP_CLOSE_ELEMENT:
327+
n += 4;
328+
break;
329+
case OP_SNAPSHOT:
330+
n += op.data.length;
331+
break;
332+
case OP_OPEN_ELEMENT: {
333+
n += 4; // opcode
334+
n += 4 + Math.ceil(encoder.encode(op.id).length / 4) * 4; // id string
335+
n += 4; // mask
336+
if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align
337+
if (op.bg !== undefined) n += 4;
338+
if (op.cornerRadius) n += 4;
339+
if (op.border) n += 8;
340+
if (op.clip) n += 4;
341+
if (op.floating) n += 16;
342+
break;
343+
}
344+
case OP_TEXT: {
345+
n += 4 + 4 + 4; // opcode + color + cfg
346+
n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string
347+
break;
348+
}
349+
}
350+
}
351+
return n;
352+
}
353+
354+
export function snapshot(ops: Op[]): Op {
355+
let size = packSize(ops);
356+
let buf = new ArrayBuffer(size);
357+
let words = pack(ops, buf, 0, size);
358+
return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) };
359+
}

specs/renderer-spec.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,26 @@ Text directives MUST appear between a matching open/close pair.
403403
The set of styling properties accepted by `props` is part of the current
404404
implementation surface and may be extended.
405405

406+
#### 8.3.4 snapshot
407+
408+
```
409+
snapshot(ops: Op[]): Op
410+
```
411+
412+
Creates a snapshot by pre-packing the given directive array into its transfer
413+
encoding. The returned value is an `Op` and can appear anywhere in a directive
414+
array where the original ops would have appeared. The internal representation is
415+
opaque.
416+
417+
When the renderer encounters a snapshot during transfer, it copies the
418+
pre-packed bytes directly into the command buffer without re-encoding. The
419+
snapshot's ops MUST be structurally balanced (every `open` matched by a
420+
`close`).
421+
422+
Snapshots enable higher-level frameworks to implement dirty tracking: a
423+
component whose inputs have not changed can reuse a previously created snapshot,
424+
avoiding the cost of re-packing its subtree each frame.
425+
406426
### 8.4 Sizing helpers
407427

408428
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
459479
directive arrays before rendering. The renderer's behavior when given an invalid
460480
directive array is unspecified by this specification.
461481

482+
A snapshot is semantically equivalent to splicing its source ops into the array
483+
at the snapshot's position. The renderer MUST produce identical layout and
484+
output regardless of whether ops are provided directly or via a snapshot.
485+
462486
### 9.2 Transfer to the WASM module
463487

464488
As part of the render transaction, the directive array is transferred into a

test/term.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { beforeEach, describe, expect, it } from "./suite.ts";
22
import { createTerm, type Term } from "../term.ts";
3-
import { close, fixed, grow, open, rgba, text } from "../ops.ts";
3+
import {
4+
close,
5+
fixed,
6+
grow,
7+
type Op,
8+
open,
9+
rgba,
10+
snapshot,
11+
text,
12+
} from "../ops.ts";
413
import { print } from "./print.ts";
514

615
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
@@ -191,6 +200,99 @@ describe("term", () => {
191200
});
192201
});
193202

203+
describe("snapshot", () => {
204+
it("produces identical output to direct ops", async () => {
205+
let ops = [
206+
open("root", {
207+
layout: { width: grow(), height: grow(), direction: "ttb" },
208+
bg: rgba(0, 0, 128),
209+
}),
210+
open("child", {
211+
layout: {
212+
width: grow(),
213+
padding: { left: 1 },
214+
direction: "ttb",
215+
},
216+
border: {
217+
color: rgba(255, 255, 255),
218+
left: 1,
219+
right: 1,
220+
top: 1,
221+
bottom: 1,
222+
},
223+
}),
224+
text("snapshot test"),
225+
close(),
226+
close(),
227+
];
228+
229+
let direct = await createTerm({ width: 40, height: 10 });
230+
let snapped = await createTerm({ width: 40, height: 10 });
231+
232+
let expected = direct.render(ops, { mode: "line" }).output;
233+
let actual = snapped.render([snapshot(ops)], { mode: "line" }).output;
234+
235+
expect(decode(actual)).toEqual(decode(expected));
236+
});
237+
238+
it("renders inside another element", async () => {
239+
let child = snapshot([
240+
open("child", {
241+
layout: { width: grow(), direction: "ttb" },
242+
}),
243+
text("inner"),
244+
close(),
245+
]);
246+
247+
let direct = await createTerm({ width: 20, height: 5 });
248+
let snapped = await createTerm({ width: 20, height: 5 });
249+
250+
let wrapper = (content: Op[]) => [
251+
open("root", {
252+
layout: {
253+
width: grow(),
254+
height: grow(),
255+
direction: "ttb",
256+
padding: { left: 1, top: 1 },
257+
},
258+
border: {
259+
color: rgba(255, 255, 255),
260+
left: 1,
261+
right: 1,
262+
top: 1,
263+
bottom: 1,
264+
},
265+
}),
266+
...content,
267+
close(),
268+
];
269+
270+
let expected = direct.render(
271+
wrapper([
272+
open("child", {
273+
layout: { width: grow(), direction: "ttb" },
274+
}),
275+
text("inner"),
276+
close(),
277+
]),
278+
{ mode: "line" },
279+
).output;
280+
281+
let actual = snapped.render(
282+
wrapper([child]),
283+
{ mode: "line" },
284+
).output;
285+
286+
expect(decode(actual)).toEqual(decode(expected));
287+
expect(trim(print(decode(actual), 20, 5))).toEqual(`
288+
┌──────────────────┐
289+
│inner │
290+
│ │
291+
│ │
292+
└──────────────────┘`.trim());
293+
});
294+
});
295+
194296
describe("row offset", () => {
195297
it("renders two frames at the offset position", async () => {
196298
let term = await createTerm({ width: 20, height: 5 });

0 commit comments

Comments
 (0)