diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 9cad0f0..eba4389 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -288,23 +288,24 @@ The output reflects the complete visual state of the frame. The caller SHOULD write the output to the terminal without modification. The output `Uint8Array` is a view over renderer-owned memory. It is valid until -the next `render()` call on the same Term instance, at which point the buffer -may be reused. Callers who need to retain the output beyond the next render MUST -copy it. +the next `render()` or `update()` call on the same Term instance, at which point +the buffer may be reused or detached (§7.7). Callers who need to retain the +output beyond that point MUST copy it. ### 7.4 Lifecycle A Term instance is created for specific terminal dimensions. The caller provides width and height at creation time. -To handle terminal resize, the caller creates a new Term with the new -dimensions. The previous Term instance becomes stale and SHOULD NOT be used for -further rendering. - Creation of a Term is asynchronous because it may involve WASM module preparation. A Term instance MAY be used for any number of render transactions. The Term retains its cell buffers across frames for diffing purposes. +A Term instance's dimensions MAY be changed after creation through the update +transaction (§7.7). A resized Term remains valid: it retains its `TermInfo` +attachment (§7.6) and continues to accept render transactions at the new +dimensions. Creating a new Term is NOT required to handle terminal resize. + ### 7.5 Clip semantics An element whose `props` include a `clip` group declares a **clip region**: a @@ -393,6 +394,53 @@ invariant (Terminfo Specification TINV-5), truecolor emission requires positive evidence — a terminfo entry, environment evidence, or a probe reply — which supersedes the renderer's historical unconditional truecolor output. +### 7.7 Update transaction (resize) + +The update transaction changes a Term instance's dimensions in place. Like the +render transaction (§7.2), it is synchronous: it MUST NOT yield, suspend, or +require callbacks during execution. + +**Inputs.** The update transaction accepts exactly one of: + +- Explicit dimensions: a target width and height in character cells. Both MUST + be positive integers; the transaction MUST throw otherwise. +- A resize event array: an ordered array of event objects. The transaction reads + objects whose `type` field is `"resize"`, taking `width` and `height` from + them; objects with any other `type` MUST be ignored. When the array contains + multiple resize events, the last one wins (coalescing). An array with no + resize events is a no-op. + +The accepted event shape is defined structurally by this specification: an +object with `type: "resize"` and numeric `width` and `height` fields. It is +intentionally assignable from the input specification's `ResizeEvent`, but the +renderer MUST NOT depend on the input parser (§11.4) — the shape, not the type, +is normative. + +An update transaction whose target dimensions equal the Term's current +dimensions MUST be a no-op. + +**Semantics.** A non-no-op update transaction: + +1. Reallocates renderer state for the new dimensions within the Term's existing + WASM instance and linear memory, growing the memory if required. The renderer + MUST NOT create a new WASM module or instance; when the Term is attached to a + `TermInfo` handle, the shared memory and the handle's region pointers remain + valid throughout (Terminfo Specification's region-model constraints). +2. Discards all diff state. The next render transaction MUST emit the frame as a + complete redraw, exactly as under generation invalidation (§7.6). +3. Discards pointer interaction state (hover and press tracking), since cell + coordinates under the pointer have changed. No synthetic pointer events are + emitted. + +**Memory.** WASM linear memory can only grow. An update to smaller dimensions +retains the high-water-mark allocation; memory is not reclaimed until the Term +itself is discarded. This is accepted behavior, not a defect. + +**Output invalidation.** Growing linear memory detaches existing buffer views. +Output `Uint8Array`s returned by render transactions prior to an update +transaction MUST NOT be used after it (this strengthens the validity window in +§7.3: output is valid until the next `render()` **or** `update()` call). + --- ## 8. Public Rendering API @@ -573,6 +621,34 @@ Packs color channel values (each 0–255) into a single 32-bit integer in ARGB format. Alpha defaults to 255 (fully opaque). The returned value is used wherever the directive model expects a color. +### 8.6 Term update + +``` +term.update(options: + | { width: number; height: number } + | { events: ResizeEvent[] } +): void +``` + +Performs an update transaction as defined in §7.7, changing the Term's +dimensions in place. The options bag is a discriminated union: either explicit +dimensions, or an array of events from which resize events are read (last one +wins; non-resize events are ignored). + +`ResizeEvent` here denotes the structural shape defined in §7.7 — an object with +`type: "resize"` and numeric `width`/`height` — not a type imported from the +input parser. The input specification's `ResizeEvent` is assignable to it, so +events produced by `input.scan()` can be passed through directly: + +``` +const { events } = input.scan(bytes); +const resizes = events.filter((e) => e.type === "resize"); +if (resizes.length > 0) term.update({ events: resizes }); +``` + +The method returns nothing. The next `render()` after a non-no-op update emits a +complete redraw (§7.7). + --- ## 9. Directive Model diff --git a/term-native.ts b/term-native.ts index e84a6ea..6f2eafa 100644 --- a/term-native.ts +++ b/term-native.ts @@ -29,6 +29,12 @@ export interface Native { memory: WebAssembly.Memory; statePtr: number; opsBuf: number; + /** + * Re-initialize renderer state for new dimensions in place + * (renderer-spec 7.7). Reuses this instance and memory; statePtr and + * opsBuf may change. Growing memory detaches prior buffer views. + */ + update(w: number, h: number): void; reduce( ct: number, buf: number, @@ -128,37 +134,57 @@ export async function createTermNative( error_message_ptr(ct: number, index: number): number; }; - let size = ct.clayterm_size(w, h); - // The transfer budget is intentionally fixed: text/id/snapshot 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 + CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES; - let statePtr: number; - let opsBuf: number; - if (attach) { - let arena = attach.alloc(size); - opsBuf = attach.alloc(transferBytes, 4); - statePtr = ct.init(arena, w, h, attach.structPtr); - } else { - // Grow memory once to fit heap + renderer state + fixed transfer buffer. - let heap = ct.__heap_base.value as number; - let needed = heap + size + transferBytes; - let pages = Math.ceil(needed / WASM_PAGE_BYTES); - let current = memory.buffer.byteLength / WASM_PAGE_BYTES; - if (pages > current) { - memory.grow(pages - current); + let statePtr!: number; + // opsBuf is fixed-size: attached mode allocates it exactly once here; + // standalone mode recomputes it in layout() as the state region resizes. + let opsBuf = attach ? attach.alloc(transferBytes, 4) : 0; + let arena: number | undefined; + let arenaCapacity = 0; + + function layout(lw: number, lh: number): void { + let size = ct.clayterm_size(lw, lh); + if (attach) { + // Bump-allocated memory is never reclaimed: reuse the arena when + // it still fits, otherwise allocate a larger one and abandon the + // old (growth-only, renderer-spec 7.7). + if (arena === undefined || size > arenaCapacity) { + arena = attach.alloc(size); + arenaCapacity = size; + } + statePtr = ct.init(arena, lw, lh, attach.structPtr); + } else { + // Standalone layout is [heap: state][opsBuf]; opsBuf moves when + // the state size changes. + let heap = ct.__heap_base.value as number; + let needed = heap + size + transferBytes; + let pages = Math.ceil(needed / WASM_PAGE_BYTES); + let current = memory.buffer.byteLength / WASM_PAGE_BYTES; + if (pages > current) { + memory.grow(pages - current); + } + statePtr = ct.init(heap, lw, lh, 0); + opsBuf = (heap + size + 3) & ~3; } - statePtr = ct.init(heap, w, h, 0); - opsBuf = (heap + size + 3) & ~3; } + layout(w, h); return { memory, - statePtr, - opsBuf, + get statePtr() { + return statePtr; + }, + get opsBuf() { + return opsBuf; + }, + update(uw: number, uh: number): void { + layout(uw, uh); + }, reduce: ct.reduce, output: ct.output, length: ct.length, diff --git a/term.ts b/term.ts index b1f0bf2..f9535ef 100644 --- a/term.ts +++ b/term.ts @@ -22,6 +22,25 @@ export interface TermOptions { terminfo?: TermInfo; } +/** + * Structural resize event accepted by update() (renderer-spec 7.7). + * The input parser's ResizeEvent is assignable to this shape. + */ +export interface TermResizeEvent { + type: "resize"; + width: number; + height: number; +} + +/** + * Options bag for update() (renderer-spec 8.6): explicit dimensions, + * or an event array from which resize events are read (last one wins; + * non-resize events are ignored). + */ +export type UpdateOptions = + | { width: number; height: number } + | { events: ReadonlyArray }; + export interface RenderOptions { mode?: "line"; @@ -87,6 +106,13 @@ export interface RenderResult { export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; + + /** + * Change dimensions in place (renderer-spec 7.7). Synchronous. The + * next render() after a non-no-op update emits a complete redraw, + * and output views from prior renders become invalid. + */ + update(options: UpdateOptions): void; } export async function createTerm(options: TermOptions): Promise { @@ -103,7 +129,7 @@ export async function createTerm(options: TermOptions): Promise { } let native = await createTermNative(width, height, attach); - let { memory, statePtr, opsBuf } = native; + let { memory } = native; let prev = new Set(); let pressed = new Set(); @@ -113,7 +139,12 @@ export async function createTerm(options: TermOptions): Promise { return { render(ops: Op[], options?: RenderOptions): RenderResult { - let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength); + let len = pack( + ops, + memory.buffer, + native.opsBuf, + memory.buffer.byteLength, + ); let mode = options?.mode === "line" ? 1 : 0; let row = options?.row ?? 1; let now = performance.now() / 1000; @@ -126,7 +157,7 @@ export async function createTerm(options: TermOptions): Promise { dt = now - lastRenderAt; } lastRenderAt = now; - native.reduce(statePtr, opsBuf, len, mode, row, dt); + native.reduce(native.statePtr, native.opsBuf, len, mode, row, dt); if (options?.pointer) { let { x, y, down } = options.pointer; @@ -135,8 +166,8 @@ export async function createTerm(options: TermOptions): Promise { let output = new Uint8Array( memory.buffer, - native.output(statePtr), - native.length(statePtr), + native.output(native.statePtr), + native.length(native.statePtr), ); let current = new Set( @@ -185,18 +216,53 @@ export async function createTerm(options: TermOptions): Promise { }; let errors: ClayError[] = []; - let count = native.errorCount(statePtr); + let count = native.errorCount(native.statePtr); for (let i = 0; i < count; i++) { - let code = native.errorType(statePtr, i); + let code = native.errorType(native.statePtr, i); errors.push({ type: ERROR_TYPES[code] ?? `UNKNOWN_${code}`, - message: native.errorMessage(statePtr, i), + message: native.errorMessage(native.statePtr, i), }); } - let animating = native.animating(statePtr) > 0; + let animating = native.animating(native.statePtr) > 0; wasAnimating = animating; return { output, events, info, errors, animating }; }, + update(options: UpdateOptions): void { + let w: number | undefined; + let h: number | undefined; + if ("events" in options) { + for (let e of options.events) { + if (e.type === "resize") { + let r = e as TermResizeEvent; + w = r.width; + h = r.height; + } + } + if (w === undefined || h === undefined) { + return; + } + } else { + w = options.width; + h = options.height; + } + if ( + !Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0 + ) { + throw new RangeError(`invalid terminal dimensions ${w}x${h}`); + } + if (w === width && h === height) { + return; + } + width = w; + height = h; + native.update(w, h); + prev = new Set(); + pressed = new Set(); + wasDown = false; + lastRenderAt = undefined; + wasAnimating = false; + }, }; } diff --git a/test/term.test.ts b/test/term.test.ts index 6d12a91..62faeb4 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; import { createInput } from "../input.ts"; -import { offlineTermInfo } from "./caps.ts"; +import { offlineTermInfo, trueColorTermInfo } from "./caps.ts"; import { close, fixed, @@ -340,6 +340,140 @@ describe("term", () => { }); }); + describe("update", () => { + let frame: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hi"), + close(), + ]; + + it("emits a complete redraw on the first render after update", () => { + term.render(frame); + expect(term.render(frame).output.length).toBe(0); + term.update({ width: 20, height: 5 }); + let out = decode(term.render(frame).output); + expect(trim(print(out, 20, 5))).toContain("Hi"); + expect(out.length).toBeGreaterThan(0); + }); + + it("is a no-op when dimensions are unchanged", () => { + term.render(frame); + term.update({ width: 40, height: 10 }); + expect(term.render(frame).output.length).toBe(0); + }); + + it("throws on non-positive or non-integer dimensions", () => { + expect(() => term.update({ width: 0, height: 10 })).toThrow(RangeError); + expect(() => term.update({ width: 40, height: -1 })).toThrow(RangeError); + expect(() => term.update({ width: 40.5, height: 10 })).toThrow( + RangeError, + ); + }); + + it("accepts an event array, last resize wins, non-resize ignored", () => { + term.update({ + events: [ + { type: "key" }, + { type: "resize", width: 30, height: 8 }, + { type: "paste" }, + { type: "resize", width: 12, height: 4 }, + ], + }); + let result = term.render(frame); + expect(result.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 12, + height: 4, + }); + }); + + it("treats an event array with no resize events as a no-op", () => { + term.render(frame); + term.update({ events: [{ type: "key" }, { type: "paste" }] }); + expect(term.render(frame).output.length).toBe(0); + term.update({ events: [] }); + expect(term.render(frame).output.length).toBe(0); + }); + + it("discards pointer interaction state on resize", () => { + let pointer = { x: 1, y: 0, down: false }; + let first = term.render(frame, { pointer }); + expect(first.events).toContainEqual({ type: "pointerenter", id: "root" }); + expect(term.render(frame, { pointer }).events).toEqual([]); + term.update({ width: 20, height: 5 }); + let after = term.render(frame, { pointer }); + expect(after.events).toContainEqual({ type: "pointerenter", id: "root" }); + }); + + it("resizes in place and lays out at the new dimensions", () => { + term.render(frame); + term.update({ width: 12, height: 4 }); + let result = term.render(frame); + expect(result.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 12, + height: 4, + }); + expect(trim(print(decode(result.output), 12, 4))).toBe( + trim( + [ + "Hi", + "", + "", + "", + ].join("\n"), + ), + ); + }); + + it("keeps the terminfo attachment across resize", async () => { + let attached = await createTerm({ + width: 40, + height: 10, + terminfo: await trueColorTermInfo(), + }); + let red: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + bg: rgba(255, 0, 0), + }), + close(), + ]; + expect(decode(attached.render(red).output)).toContain( + "\x1b[48;2;255;0;0", + ); + attached.update({ width: 20, height: 5 }); + let out = decode(attached.render(red).output); + expect(out).toContain("\x1b[48;2;255;0;0"); + expect(out).not.toContain("\x1b[48;5;196"); + }); + + it("survives downsize then upsize past the original size", () => { + term.render(frame); + term.update({ width: 10, height: 3 }); + let small = term.render(frame); + expect(small.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 10, + height: 3, + }); + term.update({ width: 120, height: 40 }); + let large = term.render(frame); + expect(large.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 120, + height: 40, + }); + expect(trim(print(decode(large.output), 120, 40))).toContain("Hi"); + }); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 }); diff --git a/validate.ts b/validate.ts index 07c3f61..e92c1e6 100644 --- a/validate.ts +++ b/validate.ts @@ -234,5 +234,8 @@ export function validated(term: Term): Term { assert(ops); return term.render(ops, options); }, + update(options) { + return term.update(options); + }, }; }