Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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) };
}
24 changes: 24 additions & 0 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
104 changes: 103 additions & 1 deletion test/term.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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 });
Expand Down
Loading