diff --git a/.changeset/element-replay.md b/.changeset/element-replay.md new file mode 100644 index 0000000..918b09f --- /dev/null +++ b/.changeset/element-replay.md @@ -0,0 +1,10 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds `result.info.get(id).replay` to reuse an entire rendered element, including +its descendants, in the next `render()` call without rebuilding or repacking its +directives. Obtain a fresh replay from each completed render; `update()` and +rejected renders preserve existing replays. + +Removes `snapshot()`. Use the rendered element's `replay` directive instead. diff --git a/AGENTS.md b/AGENTS.md index a57d6d9..ee28ec5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,8 @@ Do not include any agent marketing material (e.g. "Generated with...", renderer-spec.md §7.10). - Each frame is a complete snapshot. The renderer carries no UI tree state - between frames — only cell buffers for diffing. + between frames. Retained directives support element replay; cell buffers + support diffing. - Public API values are string literals (`"center-center"`), not numeric constants or enums. Use functions when a value is parameterized. diff --git a/Makefile b/Makefile index 8415960..48557c1 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ EXPORTS = \ -Wl,--export=pointer_over_count \ -Wl,--export=pointer_over_id_string_length \ -Wl,--export=pointer_over_id_string_ptr \ - -Wl,--export=get_element_bounds \ + -Wl,--export=get_element_info \ -Wl,--export=animating \ -Wl,--export=error_count \ -Wl,--export=error_type \ diff --git a/bench/render.bench.ts b/bench/render.bench.ts index 1bad7a6..34220ea 100644 --- a/bench/render.bench.ts +++ b/bench/render.bench.ts @@ -101,6 +101,8 @@ let uiOps: Op[] = [ ]; let bench = withCodSpeed(new Bench({ name: "render" })); +let replayTerm = await createTerm({ width: 80, height: 24 }); +let replayResult = replayTerm.render(dashboardOps); bench .add( @@ -118,6 +120,16 @@ bench term.render(dashboardOps); } }), + ) + .add( + "render steady replay", + sync(() => { + for (let i = 0; i < 250; i++) { + replayResult = replayTerm.render([ + replayResult.info.get("root")!.replay, + ]); + } + }), ); await bench.run(); diff --git a/ops.ts b/ops.ts index 940a4ac..192c10a 100644 --- a/ops.ts +++ b/ops.ts @@ -28,7 +28,7 @@ export interface Transition { const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; const OP_CLOSE_ELEMENT = 0x04; -const OP_SNAPSHOT = 0x05; +const OP_REPLAY = 0x05; /* Property group masks for OPEN_ELEMENT */ const PROP_LAYOUT = 0x01; @@ -294,9 +294,11 @@ export function pack( break; } - case OP_SNAPSHOT: { - new Uint8Array(mem).set(op.data, o); - o += op.data.length; + case OP_REPLAY: { + view.setUint32(o, OP_REPLAY, true); + view.setUint32(o + 4, op.offset, true); + view.setUint32(o + 8, op.length, true); + o += 12; break; } @@ -501,12 +503,13 @@ export interface Text { caret?: number; } -interface Snapshot { - directive: typeof OP_SNAPSHOT; - data: Uint8Array; +interface Replay { + directive: typeof OP_REPLAY; + offset: number; + length: number; } -export type Op = OpenElement | Text | CloseElement | Snapshot; +export type Op = OpenElement | Text | CloseElement | Replay; export function open( id: string, @@ -526,48 +529,6 @@ 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 += 36; // widths word + 4 sides × (fg + bg) - if (op.clip) n += 4; - // x, y, expand width/height, parent, attach/pointer, clip/z - if (op.floating) n += 7 * 4; - if (op.transition) n += 8; - if (pointerShapeIndex(op.cursor) !== undefined) n += 4; - break; - } - case OP_TEXT: { - n += 4 + 4 + 4 + 4 + 4; // opcode + color + bg + cfg + caret - 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) }; -} - const TP_X = 1; const TP_Y = 2; const TP_WIDTH = 4; diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 4aebb0b..375aef1 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -149,19 +149,18 @@ yields, or partial results. Each render transaction operates on a complete, self-contained snapshot of the UI. The renderer MUST NOT maintain an internal component tree or UI state across -frames. The only state the renderer retains between frames is the cell buffer -used for diffing, which is an implementation detail of output minimization and -not observable to the caller except through reduced output size. +frames. Elements from the previous render MAY be supplied through their replay +directives (§8.3.4). The caller still supplies the complete frame on every +render. ### 4.4 Double-buffered diffing The renderer maintains two cell buffers: a front buffer (the previously rendered frame) and a back buffer (the frame being rendered). After populating the back buffer from the current frame's render commands, the renderer compares it -against the front buffer and emits ANSI bytes only for cells that differ. -Changed cells are then copied from the back buffer to the front buffer so that -both buffers are identical at the end of the transaction. This mechanism is -internal to the renderer and not directly observable to the caller. +against the front buffer and emits ANSI bytes only for cells that differ. The +completed frame becomes the comparison baseline for the next render. This +mechanism is internal to the renderer and not directly observable to the caller. --- @@ -218,7 +217,8 @@ complete frame as a directive array and receives ANSI bytes in return. **INV-3. Frame-snapshot independence.** The renderer MUST NOT require the caller to maintain or provide state across frames beyond calling `render()` on the same -Term instance. Each directive array fully describes its frame. +Term instance. Each directive array fully describes its frame, directly or +through valid element replay directives. **INV-4. ANSI byte output.** The output of a render transaction MUST be a byte sequence of valid UTF-8–encoded ANSI escape codes that is directly writable to a @@ -549,8 +549,8 @@ shape: element in `"capture"` mode hides the elements beneath it; one in `"passthrough"` mode lets them decide. -Elements inside a `snapshot()` participate as direct directives would. -Resolution and output are identical in line mode (§8.2.2). +Replayed elements participate as direct directives would. Resolution and output +are identical in line mode (§8.2.2). **Output.** The Term tracks the shape it last emitted, starting at `default`. When the resolved shape differs, the render MUST append `ESC ] 22 ; ST` @@ -704,25 +704,31 @@ occupied by a grapheme cluster with combining marks MUST be treated as changed The set of styling properties accepted by `props` is part of the current implementation surface and may be extended. -#### 8.3.4 snapshot +#### 8.3.4 Element replay -``` -snapshot(ops: Op[]): Op +```ts +let sidebar = previous.info.get("sidebar")!; +let next = term.render([ + open("root", rootProps), + sidebar.replay, + close(), +]); ``` -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. +Each `ElementInfo` MUST contain a `replay` field whose value is an `Op`. It can +be placed directly in a directive array as a substitute for that entire element, +from its `open()` through its matching `close()`, including all descendants. Its +representation is opaque. Callers can reuse unchanged elements without +rebuilding their directives or packing their contents into the transfer buffer. -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`). +A replay is equivalent to supplying the element's original directives in that +position. Layout, pointer handling, caret placement, and transitions use the new +render's context and options. Replayed elements and their descendants provide +fresh `ElementInfo` in the result. -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. +A replay is valid until the next render completes. `update()` and renders that +throw do not expire it. To reuse the element again, obtain its replay from the +new result. Using an expired replay is invalid input (§9.1). ### 8.4 Sizing helpers @@ -805,10 +811,10 @@ _This section is normative._ ### 9.1 Directive-array pattern The rendering input is an ordered array of directive objects. Each directive is -a plain JavaScript/TypeScript object created by a constructor function (Section -8.3). Directives are not classes, do not carry methods, and do not participate -in a prototype chain. They MAY be spread, composed, stored, or inspected as -ordinary objects. +a plain JavaScript/TypeScript object created by a constructor function or +returned as an element's replay (Section 8.3). Directives are not classes, do +not carry methods, and do not participate in a prototype chain. They MAY be +spread, composed, stored, or inspected as ordinary objects. The array is processed in order. Open and close directives form an implicit tree. The renderer processes them sequentially. @@ -818,9 +824,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. +A replay is semantically equivalent to splicing the referenced element's +directives into the array at the replay's position. The renderer MUST produce +identical layout and output for direct and replayed directives given the same +rendering history and options. ### 9.2 Transfer to the WASM module @@ -836,6 +843,9 @@ expose only the raw host-level TypedArray message `"offset is out of bounds"` for this condition. The error message SHOULD direct callers to render a smaller visible slice or reduce frame content. +The same capacity policy applies when replayed content makes the frame exceed +renderer capacity, even if its transfer data fits. + ### 9.3 Directive identity Each element directive carries an `id` provided by the caller via `open()`. @@ -1138,6 +1148,7 @@ interface RenderInfo { interface ElementInfo { bounds: BoundingBox; + replay: Op; } interface BoundingBox { @@ -1153,6 +1164,9 @@ element's computed bounding box in character cells, as determined by the layout engine after the render transaction completes. `x` and `y` are zero-indexed from the top-left corner of the layout root. +The `replay` field substitutes for the entire element as defined in §8.3.4. A +result's `info` MUST NOT return metadata belonging to a later render. + Querying an element with an empty-string id or an id not present in the frame returns `undefined`. @@ -1210,6 +1224,10 @@ acknowledged gaps, and the approach may evolve. structural errors (unbalanced open/close pairs, invalid field types). Exported and used in tests. +**`validated(term)`** — An optional wrapper that validates directives before +rendering, including rejecting expired replay directives. These diagnostics are +not required when calling the unwrapped Term. + **`pack(ops, mem, offset)`** — An internal function that serializes a directive array into the transfer encoding described in Section 12.1. Currently exported but not public API; its exposure is incidental to the module structure. diff --git a/src/clayterm.c b/src/clayterm.c index 46377cc..8c1bc73 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -748,70 +748,104 @@ void clayterm_resize(struct Clayterm *ct, int w, int h) { void set_capabilities(struct Clayterm *ct, uint32_t caps) { ct->caps = caps; } -/* Check packed extents before copying or allowing Clay to borrow any bytes. - * This is a wire-safety check; directive property validation stays on the host. +/* Assemble the current runlist in one walk of the transfer buffer. Fresh + * directives stay in contiguous spans; each replay flushes the preceding span + * and copies its element from the previous frame. Only the reusable runlist is + * written until validation succeeds, preserving the completed frame on error. */ -static int packed_valid(const uint32_t *buf, int len) { - int i = 0; - int depth = 0; - while (i < len) { +static int prepare_current_frame(struct Clayterm *ct, const uint32_t *buf, + int len) { + if (len < 0) + return CLAYTERM_REDUCE_INVALID; + uint32_t capacity = ct->runlist_capacity / 4; + uint32_t end = (uint32_t)len; + if (end > capacity) + return CLAYTERM_REDUCE_CAPACITY; + + Frame *frame = ct->frames.current; + const Frame *previous = ct->frames.previous; + uint32_t i = 0; + uint32_t fresh = 0; + uint32_t written = 0; + while (i < end) { + uint32_t start = i; uint32_t op = buf[i++]; - if (op == CLAYTERM_OP_OPEN_ELEMENT) { - if (i == len) - return 0; + switch (op) { + case CLAYTERM_OP_OPEN_ELEMENT: { + if (i == end) + return CLAYTERM_REDUCE_INVALID; uint32_t bytes = buf[i++]; uint32_t words = bytes / 4 + (bytes % 4 != 0); - if (words >= (uint32_t)(len - i)) - return 0; - i += (int)words; + if (words >= end - i) + return CLAYTERM_REDUCE_INVALID; + i += words; uint32_t mask = buf[i++]; if (mask & ~0xffu) - return 0; - int props = ((mask & CLAYTERM_PROP_LAYOUT) ? 9 : 0) + - ((mask & CLAYTERM_PROP_BG_COLOR) ? 1 : 0) + - ((mask & CLAYTERM_PROP_CORNER_RADIUS) ? 1 : 0) + - ((mask & CLAYTERM_PROP_BORDER) ? 9 : 0) + - ((mask & CLAYTERM_PROP_CLIP) ? 1 : 0) + - ((mask & CLAYTERM_PROP_FLOATING) ? 7 : 0) + - ((mask & CLAYTERM_PROP_TRANSITION) ? 2 : 0) + - ((mask & CLAYTERM_PROP_CURSOR) ? 1 : 0); - if (props > len - i) - return 0; + return CLAYTERM_REDUCE_INVALID; + uint32_t props = ((mask & CLAYTERM_PROP_LAYOUT) ? 9 : 0) + + ((mask & CLAYTERM_PROP_BG_COLOR) ? 1 : 0) + + ((mask & CLAYTERM_PROP_CORNER_RADIUS) ? 1 : 0) + + ((mask & CLAYTERM_PROP_BORDER) ? 9 : 0) + + ((mask & CLAYTERM_PROP_CLIP) ? 1 : 0) + + ((mask & CLAYTERM_PROP_FLOATING) ? 7 : 0) + + ((mask & CLAYTERM_PROP_TRANSITION) ? 2 : 0) + + ((mask & CLAYTERM_PROP_CURSOR) ? 1 : 0); + if (props > end - i) + return CLAYTERM_REDUCE_INVALID; i += props; - depth++; - } else if (op == CLAYTERM_OP_TEXT) { - if (len - i < 5) - return 0; + break; + } + case CLAYTERM_OP_TEXT: { + if (end - i < 5) + return CLAYTERM_REDUCE_INVALID; uint32_t bytes = buf[i + 4]; i += 5; uint32_t words = bytes / 4 + (bytes % 4 != 0); - if (words > (uint32_t)(len - i)) - return 0; - i += (int)words; - } else if (op == CLAYTERM_OP_CLOSE_ELEMENT) { - if (depth == 0) - return 0; - depth--; - } else { - return 0; + if (words > end - i) + return CLAYTERM_REDUCE_INVALID; + i += words; + break; + } + case CLAYTERM_OP_CLOSE_ELEMENT: + break; + case CLAYTERM_OP_REPLAY: { + if (end - i < 2) + return CLAYTERM_REDUCE_INVALID; + uint32_t offset = buf[i++]; + uint32_t length = buf[i++]; + if (((offset | length) & 3u) != 0) + return CLAYTERM_REDUCE_INVALID; + uint32_t replay_start = offset / 4; + uint32_t replay_words = length / 4; + if (replay_start > previous->runlist_length || + replay_words > previous->runlist_length - replay_start) + return CLAYTERM_REDUCE_INVALID; + + uint32_t fresh_words = start - fresh; + if (fresh_words > capacity - written || + replay_words > capacity - written - fresh_words) + return CLAYTERM_REDUCE_CAPACITY; + memcpy(frame->runlist + written, buf + fresh, (size_t)fresh_words * 4); + written += fresh_words; + memcpy(frame->runlist + written, previous->runlist + replay_start, + (size_t)replay_words * 4); + written += replay_words; + fresh = i; + break; + } + default: + return CLAYTERM_REDUCE_INVALID; } } - return depth == 0; -} - -static int prepare_current_frame(struct Clayterm *ct, const uint32_t *buf, - int len) { - if (len < 0) - return CLAYTERM_REDUCE_INVALID; - if ((uint32_t)len > ct->runlist_capacity / 4) + uint32_t fresh_words = end - fresh; + if (fresh_words > capacity - written) return CLAYTERM_REDUCE_CAPACITY; - if (!packed_valid(buf, len)) - return CLAYTERM_REDUCE_INVALID; + memcpy(frame->runlist + written, buf + fresh, (size_t)fresh_words * 4); - Frame *frame = ct->frames.current; - frame_render_init(frame, ct->frames.previous); - memcpy(frame->runlist, buf, (size_t)len * 4); - frame->runlist_length = (uint32_t)len; + /* Assembly succeeded. Reset metadata and shared output without clearing the + * runlist bytes just written, then publish its complete length. */ + frame_render_init(frame, previous); + frame->runlist_length = written + fresh_words; return CLAYTERM_REDUCE_OK; } @@ -1251,8 +1285,8 @@ uint32_t animating(struct Clayterm *ct) { return ct->frames.previous->animating_count; } -int get_element_bounds(struct Clayterm *ct, const char *name, int name_len, - float *out) { +int get_element_info(struct Clayterm *ct, const char *name, int name_len, + uint32_t *out) { Frame *frame = ct->frames.previous; if (!frame->layout_valid) return 0; @@ -1262,14 +1296,14 @@ int get_element_bounds(struct Clayterm *ct, const char *name, int name_len, if (index < 0) return 0; Clay_SetCurrentContext(ct->clay); - Clay_ElementData data = Clay_GetElementData(frame->elements[index].id); + const ElementInfo *info = &frame->elements[index]; + Clay_ElementData data = Clay_GetElementData(info->id); if (!data.found) { return 0; } - out[0] = data.boundingBox.x; - out[1] = data.boundingBox.y; - out[2] = data.boundingBox.width; - out[3] = data.boundingBox.height; + memcpy(out, &data.boundingBox, sizeof(data.boundingBox)); + out[4] = info->offset; + out[5] = info->length; return 1; } diff --git a/src/clayterm.h b/src/clayterm.h index ece781b..59ea8f8 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -17,6 +17,7 @@ struct Clayterm; #define CLAYTERM_OP_OPEN_ELEMENT 0x02 #define CLAYTERM_OP_TEXT 0x03 #define CLAYTERM_OP_CLOSE_ELEMENT 0x04 +#define CLAYTERM_OP_REPLAY 0x05 #define CLAYTERM_PROP_LAYOUT 0x01 #define CLAYTERM_PROP_BG_COLOR 0x02 #define CLAYTERM_PROP_CORNER_RADIUS 0x04 @@ -42,8 +43,9 @@ int length(struct Clayterm *ct); uint32_t animating(struct Clayterm *ct); void measure(int ret, int txt); -int get_element_bounds(struct Clayterm *ct, const char *name, int name_len, - float *out); +/* Output: four float32 bounds followed by uint32 byte offset and length. */ +int get_element_info(struct Clayterm *ct, const char *name, int name_len, + uint32_t *out); int pointer_over_count(struct Clayterm *ct); int pointer_over_id_string_length(struct Clayterm *ct, int index); diff --git a/src/frame.h b/src/frame.h index cc4087d..f599411 100644 --- a/src/frame.h +++ b/src/frame.h @@ -242,7 +242,8 @@ void frame_init(Frame *frame, char *base, uint32_t runlist_capacity, /* Prepare for a new render by clearing out state that will be built up over * the course of the render. * - * Resets the shared output length; call only after validating the directives. + * Resets the shared output length; call after assembling a valid runlist. + * Clears runlist_length but preserves the assembled bytes. * Inherits emitted pointer shape and cursor visibility from previous_frame. */ void frame_render_init(Frame *frame, const Frame *previous_frame); diff --git a/term-native.ts b/term-native.ts index 90c2d0a..e63073e 100644 --- a/term-native.ts +++ b/term-native.ts @@ -1,4 +1,5 @@ import { f32, offsets, struct } from "./typedef.ts"; +import type { Op } from "./ops.ts"; export interface BoundingBox { x: number; @@ -21,8 +22,8 @@ const TEXT_TRANSFER_BUFFER_BYTES = 1024 * 1024; const CLAY_DEFAULT_MAX_ELEMENT_COUNT = 8192; // Conservative fixed wire-format budget per element. This covers the largest -// non-text open/close element encoding we currently support; text, element id, -// and snapshot payload bytes live in TEXT_TRANSFER_BUFFER_BYTES. +// non-text open/close element encoding we currently support; text and element +// id bytes live in TEXT_TRANSFER_BUFFER_BYTES. const MAX_FIXED_ELEMENT_WIRE_BYTES = 120; /** Capability evidence the renderer consumes for emission. */ @@ -69,7 +70,7 @@ export interface Native { output(ct: number): number; length(ct: number): number; getPointerOverIds(): string[]; - getElementBounds(id: string): BoundingBox | undefined; + getElementInfo(id: string): { bounds: BoundingBox; replay: Op } | undefined; animating(ct: number): number; errorCount(ct: number): number; errorType(ct: number, index: number): number; @@ -138,7 +139,7 @@ export async function createTermNative( pointer_over_count(ct: number): number; pointer_over_id_string_length(ct: number, index: number): number; pointer_over_id_string_ptr(ct: number, index: number): number; - get_element_bounds( + get_element_info( ct: number, name: number, len: number, @@ -151,7 +152,7 @@ export async function createTermNative( error_message_ptr(ct: number, index: number): number; }; - // The transfer budget is intentionally fixed: text/id/snapshot payload bytes + // The transfer budget is intentionally fixed: text/id payload bytes // get 1MB, and fixed op overhead gets one max-sized element per Clay element. // Do not grow this dynamically per render; improve the wire format instead. let transferBytes = TEXT_TRANSFER_BUFFER_BYTES + @@ -243,26 +244,35 @@ export async function createTermNative( } return ids; }, - getElementBounds(id: string): BoundingBox | undefined { + getElementInfo( + id: string, + ): { bounds: BoundingBox; replay: Op } | undefined { let enc = new TextEncoder(); let bytes = enc.encode(id); - // Query and result must both fit the transfer workspace. A name this - // large could not have been packed into a retained frame. - let out = (opsBuf + bytes.length + 3) & ~3; - if (out + 16 > opsBuf + transferBytes) { + // Native code consumes the name before writing the result, so the query + // and metadata share storage even when the name fills the workspace. + let out = opsBuf; + if (bytes.length > transferBytes) { return undefined; } new Uint8Array(memory.buffer).set(bytes, opsBuf); - let found = ct.get_element_bounds(statePtr!, opsBuf, bytes.length, out); + let found = ct.get_element_info(statePtr!, opsBuf, bytes.length, out); if (!found) { return undefined; } let view = new DataView(memory.buffer); return { - x: view.getFloat32(out + BOUNDING_BOX.x, true), - y: view.getFloat32(out + BOUNDING_BOX.y, true), - width: view.getFloat32(out + BOUNDING_BOX.width, true), - height: view.getFloat32(out + BOUNDING_BOX.height, true), + bounds: { + x: view.getFloat32(out + BOUNDING_BOX.x, true), + y: view.getFloat32(out + BOUNDING_BOX.y, true), + width: view.getFloat32(out + BOUNDING_BOX.width, true), + height: view.getFloat32(out + BOUNDING_BOX.height, true), + }, + replay: { + directive: 0x05, + offset: view.getUint32(out + 16, true), + length: view.getUint32(out + 20, true), + }, }; }, errorCount(ptr: number): number { diff --git a/term.ts b/term.ts index 5d1b3a9..785ae46 100644 --- a/term.ts +++ b/term.ts @@ -115,6 +115,8 @@ export type PointerEvent = export interface ElementInfo { bounds: BoundingBox; + /** Substitute for this element and its descendants in the next render. */ + replay: Op; } const ERROR_TYPES = [ @@ -140,6 +142,10 @@ export interface RenderInfo { get(id: string): ElementInfo | undefined; } +function expiredLookup(): undefined { + return undefined; +} + export interface RenderResult { output: Uint8Array; events: PointerEvent[]; @@ -191,6 +197,7 @@ export async function createTerm(options: TermOptions): Promise { let wasDown = false; let lastRenderAt: number | undefined; let wasAnimating = false; + let currentInfo: RenderInfo | undefined; return { get capabilities(): RuntimeCapabilities { @@ -215,7 +222,6 @@ export async function createTerm(options: TermOptions): Promise { } else { dt = now - lastRenderAt; } - lastRenderAt = now; let pointer = options?.pointer; native.reduce( native.statePtr, @@ -228,6 +234,7 @@ export async function createTerm(options: TermOptions): Promise { pointer?.y ?? 0, pointer ? (pointer.down ? POINTER_DOWN : POINTER_UP) : POINTER_ABSENT, ); + lastRenderAt = now; let output = new Uint8Array( memory.buffer, @@ -271,13 +278,18 @@ export async function createTerm(options: TermOptions): Promise { prev = current; wasDown = down; + let lookup = (id: string): ElementInfo | undefined => { + if (currentInfo !== info) return undefined; + return native.getElementInfo(id); + }; let info: RenderInfo = { - get(id: string): ElementInfo | undefined { - let bounds = native.getElementBounds(id); - if (bounds) return { bounds }; - return undefined; + // Expiry is observable without mutating metadata handed to callers. + // The lookup itself also guards callers that saved the function. + get get() { + return currentInfo === info ? lookup : expiredLookup; }, }; + currentInfo = info; let errors: ClayError[] = []; let count = native.errorCount(native.statePtr); diff --git a/test/pack.test.ts b/test/pack.test.ts index 4b4a03d..93d6e57 100644 --- a/test/pack.test.ts +++ b/test/pack.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "./suite.ts"; -import { close, open, pack, snapshot, text } from "../ops.ts"; +import { close, open, pack, text } from "../ops.ts"; +import { createTerm } from "../term.ts"; +import { createTermNative } from "../term-native.ts"; describe("pack", () => { it("throws a descriptive RangeError when text exceeds the transfer buffer", () => { @@ -50,26 +52,28 @@ describe("pack", () => { expect((error as Error).message).not.toBe("offset is out of bounds"); }); - it("sizes snapshots with floating elements", () => { - expect(() => - snapshot([ - open("float", { - floating: { - x: 1, - y: 2, - expand: { width: 3, height: 4 }, - attachTo: "root", - attachPoints: { - element: "center-center", - parent: "center-center", - }, - pointerCaptureMode: "passthrough", - clipTo: "attached-parent", - zIndex: -1, - }, - }), - close(), - ]) - ).not.toThrow(); + it("packs a replay without copying its element's text into the transfer buffer", async () => { + let term = await createTerm({ width: 8, height: 1 }); + let ops = [open("element"), text("x".repeat(256)), close()]; + let replay = term.render(ops).info.get("element")!.replay; + let buffer = new ArrayBuffer(64); + expect(() => pack(ops, buffer, 0, buffer.byteLength)).toThrow(RangeError); + expect(() => pack([replay], buffer, 0, buffer.byteLength)).not.toThrow(); + }); + + it("returns metadata and replay for an id that fills the packing budget", async () => { + let native = await createTermNative(8, 1, { + syncOutput: false, + pointerShape: false, + }); + let overhead = pack([open(""), close()], new ArrayBuffer(64), 0) * 4; + let id = "x".repeat(native.opsCapacity - overhead); + let term = await createTerm({ width: 8, height: 1 }); + let first = term.render([open(id), close()]).info.get(id); + expect(first).toBeDefined(); + let next = term.render([first!.replay]); + expect(next.output.length).toBe(0); + expect(next.errors).toEqual([]); + expect(next.info.get(id)?.bounds).toEqual(first!.bounds); }); }); diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts index 45b69bd..ceef6df 100644 --- a/test/pointer-shape.test.ts +++ b/test/pointer-shape.test.ts @@ -7,7 +7,6 @@ import { type Op, open, type PointerShape, - snapshot, text, } from "../ops.ts"; import type { CapabilityEvent } from "../input.ts"; @@ -209,11 +208,26 @@ describe("pointer shape", () => { expect(sequences(output)).toEqual([]); }); - it("sees elements inside a snapshot", () => { - let ops = layout(); - let frozen = snapshot(ops.slice(1, -1)); - let result = term.render([ops[0], frozen, ops[ops.length - 1]], FIELD); - expect(sequences(result.output)).toEqual([osc22("text")]); + it("resolves replayed descendants against each render's pointer", async () => { + let direct = await createTerm({ width: 40, height: 6 }); + direct.update([SUPPORTED]); + direct.render(layout()); + let previous = term.render(layout()); + let steps = [ + [LINK, [osc22("pointer")]], + [FIELD, [osc22("text")]], + [FIELD, []], + [PLAIN, [osc22("default")]], + [undefined, []], + [FIELD, [osc22("text")]], + ] as const; + for (let [options, shapes] of steps) { + let expected = direct.render(layout(), options); + previous = term.render([previous.info.get("root")!.replay], options); + expect(sequences(previous.output)).toEqual(shapes); + expect(decode(previous.output)).toEqual(decode(expected.output)); + expect(previous.events).toEqual(expected.events); + } }); it("lets a capture-mode floating overlay win over what is beneath", () => { diff --git a/test/term.test.ts b/test/term.test.ts index 46ca014..bc1967a 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -2,16 +2,7 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; import type { InputEvent } from "../input.ts"; -import { - close, - fixed, - grow, - type Op, - open, - rgba, - snapshot, - text, -} from "../ops.ts"; +import { close, fixed, grow, type Op, open, rgba, text } from "../ops.ts"; import { print } from "./print.ts"; import { trueColorDetect } from "./caps.ts"; @@ -400,19 +391,23 @@ describe("term", () => { expect(out).toContain("┌──────────┐"); }); - describe("snapshot", () => { - it("produces identical output to direct ops", async () => { + describe("replay", () => { + it("renews a complete styled, clipped tree and then replays a descendant", async () => { + let direct = await createTerm({ width: 12, height: 4 }); + let cached = await createTerm({ width: 12, height: 4 }); + let leafId = "leaf:" + "long-id-".repeat(40); + let leaf = [ + open(leafId, { layout: { width: fixed(8), height: fixed(1) } }), + text("ABCDEFGH", { color: rgba(255, 200, 0), attrs: 1 }), + close(), + ]; 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", - }, + open("panel", { + layout: { width: fixed(8), height: fixed(3), direction: "ttb" }, border: { color: rgba(255, 255, 255), left: 1, @@ -421,74 +416,262 @@ describe("term", () => { bottom: 1, }, }), - text("snapshot test"), + open("clip", { + layout: { width: fixed(4), height: fixed(1) }, + clip: { horizontal: true, vertical: true }, + }), + ...leaf, + close(), close(), + text("tail"), close(), ]; + direct.render(ops); + let previous = cached.render(ops); + let expectedGrid = "┌──────┐\n│ABCD │\n└──────┘\ntail"; + expect(trim(print(decode(previous.output), 12, 4))).toEqual(expectedGrid); + + for (let i = 0; i < 8; i++) { + let replay: Op = previous.info.get("root")!.replay; + expect(previous.info.get(leafId)).toBeDefined(); + expect(previous.info.get("missing:".repeat(100))).toBeUndefined(); + let mode = i % 2 === 0 ? "line" as const : undefined; + let expected = direct.render(ops, { mode }); + previous = cached.render([replay], { mode }); + expect(decode(previous.output)).toEqual(decode(expected.output)); + expect(previous.errors).toEqual([]); + for (let id of ["root", "panel", "clip", leafId]) { + expect(previous.info.get(id)?.bounds).toEqual( + expected.info.get(id)?.bounds, + ); + } + if (mode === "line") { + expect(trim(print(decode(previous.output), 12, 4))).toEqual( + expectedGrid, + ); + } else { + expect(previous.output.length).toBe(0); + } + } - 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)); + let old = previous; + let result = cached.render([ + open("new-parent", { layout: { width: grow(), height: grow() } }), + previous.info.get(leafId)!.replay, + close(), + ], { mode: "line" }); + expect(trim(print(decode(result.output), 12, 4))).toEqual( + "ABCDEFGH\n\n\n", + ); + expect(result.info.get("panel")).toBeUndefined(); + expect(result.info.get(leafId)?.bounds).toEqual({ + x: 0, + y: 0, + width: 8, + height: 1, + }); + expect(old.info.get("new-parent")).toBeUndefined(); }); - it("renders inside another element", async () => { - let child = snapshot([ - open("child", { - layout: { width: grow(), direction: "ttb" }, - }), - text("inner"), + it("relayouts reordered replays alongside fresh siblings and new parents after resize", async () => { + let direct = await createTerm({ width: 10, height: 3 }); + let cached = await createTerm({ width: 10, height: 3 }); + let a = [ + open("a", { layout: { width: fixed(3), height: fixed(1) } }), + text("AAA"), close(), - ]); + ]; + let b = [ + open("b", { layout: { width: grow(), height: fixed(1) } }), + text("B"), + close(), + ]; + let root = open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }); + direct.render([root, ...a, ...b, close()]); + let previous = cached.render([root, ...a, ...b, close()]); - let direct = await createTerm({ width: 20, height: 5 }); - let snapped = await createTerm({ width: 20, height: 5 }); + for ( + let [width, height, left] of [[10, 3, 1], [10, 3, 2], [14, 2, 0], [ + 8, + 2, + 1, + ]] + ) { + let replayA = previous.info.get("a")!.replay; + let replayB = previous.info.get("b")!.replay; + direct.update([{ type: "resize", width, height }]); + cached.update([{ type: "resize", width, height }]); + let frame = (first: Op[], second: Op[]) => [ + root, + open("prefix", { layout: { width: fixed(left), height: fixed(1) } }), + text(".".repeat(left)), + close(), + open(`parent-${width}-${left}`, { + layout: { width: grow(), height: fixed(1) }, + }), + ...first, + close(), + ...second, + close(), + ]; + let expected = direct.render(frame(b, a), { mode: "line" }); + previous = cached.render(frame([replayB], [replayA]), { mode: "line" }); + expect(decode(previous.output)).toEqual(decode(expected.output)); + expect(trim(print(decode(previous.output), width, height))).toEqual( + ".".repeat(left) + "B" + " ".repeat(width - left - 4) + "AAA" + + "\n".repeat(height - 1), + ); + expect(previous.info.get("b")?.bounds).toEqual({ + x: left, + y: 0, + width: width - left - 3, + height: 1, + }); + expect(previous.info.get("a")?.bounds.x).toBe(width - 3); + expect(previous.errors).toEqual([]); + } + }); - let wrapper = (content: Op[]) => [ + it("resolves caret placement and pointer events in the new render context", async () => { + let direct = await createTerm({ width: 8, height: 2 }); + let cached = await createTerm({ width: 8, height: 2 }); + let field = [ + open("field", { layout: { width: fixed(4), height: fixed(1) } }), + text("edit", { caret: 2 }), + close(), + ]; + let frame = (left: number, content: Op[]) => [ open("root", { - layout: { - width: grow(), - height: grow(), - direction: "ttb", - }, - border: { - color: rgba(255, 255, 255), - left: 1, - right: 1, - top: 1, - bottom: 1, - }, + layout: { width: grow(), height: grow(), padding: { left } }, }), ...content, close(), ]; + direct.render(frame(0, field)); + let previous = cached.render(frame(0, field)); + let ansi = decode(previous.output); + let steps = [ + { left: 0, pointer: { x: 1, y: 0, down: true }, event: "pointerenter" }, + { + left: 3, + pointer: { x: 4, y: 0, down: false }, + event: "pointerclick", + }, + { left: 1, pointer: undefined, event: "pointerleave" }, + ]; + for (let { left, pointer, event } of steps) { + let expected = direct.render(frame(left, field), { pointer }); + previous = cached.render( + frame(left, [previous.info.get("field")!.replay]), + { pointer }, + ); + expect(decode(previous.output)).toEqual(decode(expected.output)); + expect(previous.events).toEqual(expected.events); + expect(previous.events).toContainEqual({ type: event, id: "field" }); + ansi += decode(previous.output); + expect(trim(print(ansi, 8, 2))).toEqual(" ".repeat(left) + "edi̲t\n"); + } + }); - 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; + it("preserves the completed frame when a later replay exceeds source bounds", async () => { + let cached = await createTerm({ width: 8, height: 2 }); + let control = await createTerm({ width: 8, height: 2 }); + let older = [open("older"), text("before"), close()]; + let latest = [ + open("root", { layout: { width: grow(), height: grow() } }), + open("kept", { layout: { width: fixed(4), height: fixed(1) } }), + text("KEEP"), + close(), + close(), + ]; + control.render(older); + let ansi = decode(cached.render(older).output); + let down = { pointer: { x: 1, y: 0, down: true } }; + control.render(latest, down); + let previous = cached.render(latest, down); + let output = decode(previous.output); + ansi += output; + let replay = previous.info.get("root")!.replay; + + expect(() => + Reflect.apply(cached.render, cached, [[ + replay, + { ...replay, offset: 0xFFFFFFFC }, + ]]) + ).toThrow(RangeError); + expect(decode(previous.output)).toEqual(output); + expect(previous.info.get("kept")?.bounds).toEqual({ + x: 0, + y: 0, + width: 4, + height: 1, + }); + expect(previous.info.get("older")).toBeUndefined(); + + let up = { pointer: { x: 1, y: 0, down: false } }; + let expected = control.render(latest, up); + let recovered = cached.render([replay], up); + expect(decode(recovered.output)).toEqual(decode(expected.output)); + expect(recovered.events).toEqual(expected.events); + expect(recovered.events).toContainEqual({ + type: "pointerclick", + id: "kept", + }); + expect(recovered.errors).toEqual([]); + expect(recovered.info.get("kept")?.bounds).toEqual( + expected.info.get("kept")?.bounds, + ); + ansi += decode(recovered.output); + expect(trim(print(ansi, 8, 2))).toEqual("KEEP\n"); + }); - expect(decode(actual)).toEqual(decode(expected)); - expect(trim(print(decode(actual), 20, 5))).toEqual(` -┌──────────────────┐ -│inner │ -│ │ -│ │ -└──────────────────┘`.trim()); + it("preserves replay after transfer and expanded-frame capacity failures", async () => { + let cached = await createTerm({ width: 8, height: 1 }); + cached.render([open("older"), text("before"), close()]); + // Each payload fits the transfer budget; two expanded payloads exceed it. + let payload = "X".repeat(1_100_000); + let previous = cached.render([ + open("payload", { layout: { width: fixed(8), height: fixed(1) } }), + open("left", { layout: { width: fixed(4), height: fixed(1) } }), + text(payload.slice(0, payload.length / 2)), + close(), + open("right", { layout: { width: fixed(4), height: fixed(1) } }), + text(payload.slice(payload.length / 2)), + close(), + close(), + ]); + expect(trim(print(decode(previous.output), 8, 1))).toEqual("XXXXXXXX"); + let replay = previous.info.get("payload")!.replay; + expect(() => cached.render([text(payload + payload)])).toThrow( + RangeError, + ); + expect(() => + cached.render([ + open("root", { layout: { width: grow(), height: grow() } }), + replay, + text(payload), + close(), + ]) + ).toThrow(RangeError); + expect(() => + cached.render([ + previous.info.get("left")!.replay, + text(payload), + previous.info.get("right")!.replay, + ]) + ).toThrow(RangeError); + expect(previous.info.get("payload")?.bounds).toEqual({ + x: 0, + y: 0, + width: 8, + height: 1, + }); + let recovered = cached.render([replay]); + expect(recovered.output.length).toBe(0); + expect(recovered.errors).toEqual([]); + expect(recovered.info.get("payload")).toBeDefined(); }); }); diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 3a99c33..3dbd9d2 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -9,6 +9,11 @@ import { rgba, text, } from "../mod.ts"; +import { print } from "./print.ts"; + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); +const trim = (value: string) => + value.split("\n").map((line) => line.trimEnd()).join("\n"); describe("transitions", () => { describe("deltaTime", () => { @@ -36,6 +41,40 @@ describe("transitions", () => { }); describe("lifecycle", () => { + it("advances replayed transition targets with each render's deltaTime", async () => { + let direct = await createTerm({ width: 6, height: 2 }); + let cached = await createTerm({ width: 6, height: 2 }); + let frame = (bg: number) => [ + open("box", { + layout: { width: fixed(4), height: fixed(1) }, + bg, + transition: { duration: 0.2, properties: ["bg"] as const }, + }), + text("T"), + close(), + ]; + let initial = frame(rgba(255, 0, 0)); + let target = frame(rgba(0, 0, 255)); + direct.render(initial, { deltaTime: 0 }); + cached.render(initial, { deltaTime: 0 }); + direct.render(target, { deltaTime: 0 }); + let previous = cached.render(target, { deltaTime: 0 }); + let states: boolean[] = []; + for (let deltaTime of [0.05, 0.05, 0.1, 0.1, 0.1]) { + let expected = direct.render(target, { deltaTime, mode: "line" }); + previous = cached.render([previous.info.get("box")!.replay], { + deltaTime, + mode: "line", + }); + expect(previous.animating).toBe(expected.animating); + expect(decode(previous.output)).toEqual(decode(expected.output)); + expect(trim(print(decode(previous.output), 6, 2))).toEqual("T\n"); + states.push(previous.animating); + } + expect(states).toContain(true); + expect(states.at(-1)).toBe(false); + }); + it("animates bg change between frames", async () => { let term = await createTerm({ width: 20, height: 5 }); let frame = (bg: number): Op[] => [ diff --git a/test/validate.test.ts b/test/validate.test.ts index 0095584..1f5285b 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -5,6 +5,8 @@ import { assert, validate, validated } from "../validate.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); +const trim = (value: string) => + value.split("\n").map((line) => line.trimEnd()).join("\n"); describe("validate", () => { it("accepts valid ops", () => { @@ -19,6 +21,38 @@ describe("validate", () => { expect(validate([])).toBe(true); }); + it("accepts balanced nested elements and siblings", () => { + let ops = [ + open("root"), + open("first"), + text("one"), + close(), + open("second"), + open("child"), + text("two"), + close(), + close(), + close(), + open("peer"), + close(), + ]; + expect(validate(ops)).toBe(true); + expect(() => assert(ops)).not.toThrow(); + }); + + it("rejects missing closes, surplus closes, and a close before its open", () => { + for ( + let ops of [ + [open("root"), text("missing close")], + [open("root"), close(), close()], + [close(), open("root")], + ] + ) { + expect(validate(ops)).toBe(false); + expect(() => assert(ops)).toThrow(TypeError); + } + }); + it("accepts transition ops", () => { expect(validate([ open("x", { @@ -288,6 +322,139 @@ describe("validated", () => { expect(out).toContain("Hello, World!"); }); + it("accepts replay and its object spread from each completed render", () => { + let result = term.render([open("root"), text("hello"), close()]); + for (let i = 0; i < 4; i++) { + let replay = { ...result.info.get("root")!.replay }; + expect(validate([replay])).toBe(true); + result = term.render([replay], { mode: "line" }); + expect(print(decode(result.output), 40, 10).trim()).toBe("hello"); + expect(result.errors).toEqual([]); + } + }); + + it("preserves a saved replay and output after rejecting unbalanced wrappers", async () => { + let checked = validated(await createTerm({ width: 8, height: 2 })); + let previous = checked.render([open("item"), text("saved"), close()]); + let output = decode(previous.output); + expect(trim(print(output, 8, 2))).toEqual("saved\n"); + let item = previous.info.get("item")!; + for ( + let ops of [ + [open("unfinished"), item.replay], + [item.replay, close()], + [close(), item.replay, open("unfinished")], + ] + ) { + expect(() => checked.render(ops)).toThrow(TypeError); + expect(decode(previous.output)).toEqual(output); + expect(previous.info.get("item")?.bounds).toEqual(item.bounds); + } + + let next = checked.render([ + open("wrapper", { + layout: { width: grow(), height: grow(), padding: { left: 2, top: 1 } }, + }), + item.replay, + close(), + ]); + expect(trim(print(output + decode(next.output), 8, 2))).toEqual( + "\n saved", + ); + expect(next.info.get("item")?.bounds).toEqual({ + x: 2, + y: 1, + width: 5, + height: 1, + }); + expect(next.errors).toEqual([]); + }); + + it("rejects stale replay even when the new frame uses the same element range", () => { + let first = term.render([open("root"), text("one"), close()]); + let replay = first.info.get("root")!.replay; + let second = term.render([open("root"), text("two"), close()]); + expect(first.info.get("root")).toBeUndefined(); + expect(() => term.render([replay])).toThrow(/latest completed render/); + let result = term.render([second.info.get("root")!.replay], { + mode: "line", + }); + expect(print(decode(result.output), 40, 10).trim()).toBe("two"); + }); + + it("detects expiry through another reference to the same Term", async () => { + let raw = await createTerm({ width: 8, height: 1 }); + let checked = validated(raw); + let first = checked.render([open("root"), text("one"), close()]); + let replay = first.info.get("root")!.replay; + raw.render([open("root"), text("two"), close()]); + expect(() => checked.render([replay])).toThrow(/latest completed render/); + expect(first.info.get("root")).toBeUndefined(); + }); + + it("preserves replay validation when wrappers are nested", () => { + let checked = validated(term); + let first = checked.render([open("root"), text("hello"), close()]); + let replay = first.info.get("root")!.replay; + let next = checked.render([replay], { mode: "line" }); + expect(print(decode(next.output), 40, 10).trim()).toBe("hello"); + expect(() => checked.render([replay])).toThrow(/latest completed render/); + }); + + it("expires frozen metadata and a saved lookup without mutating either", async () => { + let raw = await createTerm({ width: 8, height: 1 }); + let first = raw.render([open("root"), text("one"), close()]); + let lookup = first.info.get; + Object.freeze(first.info); + let replay = lookup("root")!.replay; + let next = raw.render([replay]); + expect(next.info.get("root")).toBeDefined(); + expect(first.info.get("root")).toBeUndefined(); + expect(lookup("root")).toBeUndefined(); + }); + + it("expires replay after an empty render without a metadata lookup", () => { + let first = term.render([open("root"), close()]); + let replay = first.info.get("root")!.replay; + term.render([]); + expect(() => term.render([replay])).toThrow(/latest completed render/); + }); + + it("expires replay when a completed render reports layout errors", () => { + let first = term.render([open("root"), close()]); + let replay = first.info.get("root")!.replay; + let result = term.render([open("dup"), close(), open("dup"), close()]); + expect(result.errors.some((error) => error.type === "DUPLICATE_ID")).toBe( + true, + ); + expect(() => term.render([replay])).toThrow(/latest completed render/); + }); + + it("preserves replay through resize, capability updates, and rejected renders", () => { + let first = term.render([open("root"), text("hello"), close()]); + let replay = first.info.get("root")!.replay; + term.update([ + { type: "resize", width: 8, height: 1 }, + { type: "capability", key: "sync-output", value: true }, + ]); + + // A valid replay tag with a malformed range reaches native validation. + expect(() => + Reflect.apply(term.render, term, [[{ ...replay, offset: 0xFFFFFFFC }]]) + ).toThrow(RangeError); + expect(() => term.render([text("x".repeat(3 * 1024 * 1024))])) + .toThrow(RangeError); + + let result = term.render([replay], { mode: "line" }); + expect(print(decode(result.output), 8, 1).trim()).toBe("hello"); + expect(result.errors).toEqual([]); + }); + + it("does not accept an untagged replay before the first render", () => { + expect(() => term.render([{ directive: 0x05, offset: 0, length: 20 }])) + .toThrow(/latest completed render/); + }); + it("throws on invalid ops", () => { // Call through Reflect so the runtime guard sees deliberately invalid input // without weakening the TypeScript surface. diff --git a/validate.ts b/validate.ts index 8254339..02ece63 100644 --- a/validate.ts +++ b/validate.ts @@ -2,7 +2,7 @@ import { Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import type { Op } from "./ops.ts"; import { POINTER_SHAPES } from "./pointer-shape.ts"; -import type { RenderOptions, RenderResult, Term } from "./term.ts"; +import type { RenderInfo, RenderOptions, RenderResult, Term } from "./term.ts"; /* ── Range helpers (match bit-packing in pack()) ──────────────────── */ @@ -209,14 +209,33 @@ const TextOp = Type.Object({ attrs: Type.Optional(u8), }); -const Ops = Type.Array(Type.Union([OpenElement, TextOp, CloseElement])); +const Replay = Type.Object({ + directive: Type.Literal(0x05), + offset: Type.Integer({ minimum: 0, maximum: 0xFFFFFFFF, multipleOf: 4 }), + length: Type.Integer({ minimum: 4, maximum: 0xFFFFFFFF, multipleOf: 4 }), +}); + +const Ops = Type.Array(Type.Union([OpenElement, TextOp, CloseElement, Replay])); /* ── Compiled validator ───────────────────────────────────────────── */ const compiled = TypeCompiler.Compile(Ops); +function hasBalancedElements(ops: Op[]): boolean { + let depth = 0; + for (let op of ops) { + if (op.directive === 0x02) { + depth++; + } else if (op.directive === 0x04) { + if (depth === 0) return false; + depth--; + } + } + return depth === 0; +} + export function validate(ops: unknown): ops is Op[] { - return compiled.Check(ops); + return compiled.Check(ops) && hasBalancedElements(ops); } export function assert(ops: unknown): asserts ops is Op[] { @@ -228,10 +247,22 @@ export function assert(ops: unknown): asserts ops is Op[] { .join("\n"); throw new TypeError(`Invalid ops:\n${msg}`); } + if (!hasBalancedElements(ops)) { + throw new TypeError("Invalid ops: unbalanced open()/close() directives."); + } } /* ── Term wrapper ─────────────────────────────────────────────────── */ +// Present only on replays obtained through validated(). The source lookup +// retires when another render completes, even through another Term reference. +const replayFrame = Symbol("replay frame"); + +interface ReplayFrame { + info: RenderInfo; + lookup: RenderInfo["get"]; +} + export function validated(term: Term): Term { return { get capabilities() { @@ -239,7 +270,34 @@ export function validated(term: Term): Term { }, render(ops: Op[], options?: RenderOptions): RenderResult { assert(ops); - return term.render(ops, options); + for (let op of ops) { + if (op.directive !== 0x05) continue; + let frame: ReplayFrame | undefined = Reflect.get(op, replayFrame); + if (!frame || frame.info.get !== frame.lookup) { + throw new TypeError( + "Replay is not from the latest completed render. " + + "Obtain a fresh replay from the latest result.info.get(id).", + ); + } + } + + let result = term.render(ops, options); + let source = result.info; + let frame: ReplayFrame = { info: source, lookup: source.get }; + result.info = { + get(id) { + let element = source.get(id); + if (!element) return undefined; + return { + bounds: element.bounds, + // Preserve the original source if validators are nested. + replay: replayFrame in element.replay + ? element.replay + : { ...element.replay, [replayFrame]: frame }, + }; + }, + }; + return result; }, update(events) { term.update(events);