diff --git a/.changeset/quiet-updates.md b/.changeset/quiet-updates.md new file mode 100644 index 0000000..c4817da --- /dev/null +++ b/.changeset/quiet-updates.md @@ -0,0 +1,8 @@ +--- +"@bomb.sh/tty": minor +--- + +Make `term.update()` return `void`. Call `term.render()` to reflect dimension and +capability changes on screen; multiple updates contribute to the next render. +Disabling pointer shape support preserves the last emitted shape without emitting +a reset. To reset the pointer, render without a pointer while support is enabled. diff --git a/docs/review-patterns.md b/docs/review-patterns.md index f6c55a2..4eaa8a9 100644 --- a/docs/review-patterns.md +++ b/docs/review-patterns.md @@ -34,8 +34,8 @@ before opening a PR. PR numbers point at the original discussion. overloads that only tests use (#133). - Names carry constraints: `id` when it must be unique, not `name` (#5). - Static facts are fixed at `createTerm()`; dynamic changes arrive as events via - `term.update()`, which returns the bytes to write. Don't defer work to the - next `render()` (#106). + `term.update()`. Apply those state changes synchronously; renderer output is + produced by `render()`. - Front-load configuration at creation so each paint stays one `write(result.output)` (#101). diff --git a/examples/2048/index.ts b/examples/2048/index.ts index 8d29116..6a89524 100644 --- a/examples/2048/index.ts +++ b/examples/2048/index.ts @@ -392,9 +392,7 @@ await main(function* () { if (next.width !== width || next.height !== height) { width = next.width; height = next.height; - Deno.stdout.writeSync( - term.update([{ type: "resize", width, height }]), - ); + term.update([{ type: "resize", width, height }]); Deno.stdout.writeSync(CLEAR_SCREEN); draw(); } @@ -405,9 +403,7 @@ await main(function* () { let e = ev as InputEvent | PointerEvent; if (e.type === "capability") { - Deno.stdout.writeSync(term.update([e])); - yield* each.next(); - continue; + term.update([e]); } if (e.type === "keydown") { diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index 65daf8f..0522054 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -115,7 +115,7 @@ await main(function* () { break; } if (event.type === "capability") { - writeStdout(term.update([event])); + term.update([event]); } if (event.type === "pointerenter") { ctx.entered.add(event.id); diff --git a/examples/keyboard/index.ts b/examples/keyboard/index.ts index cdb8482..bba394a 100644 --- a/examples/keyboard/index.ts +++ b/examples/keyboard/index.ts @@ -123,7 +123,7 @@ await main(function* () { break; } if (event.type === "capability") { - writeStdout(term.update([event])); + term.update([event]); } if (event.type === "pointerenter") { context.entered.add(event.id); diff --git a/examples/text-input/index.ts b/examples/text-input/index.ts index ba24bbd..1741fde 100644 --- a/examples/text-input/index.ts +++ b/examples/text-input/index.ts @@ -105,7 +105,7 @@ await main(function* () { caret++; } } else if (event.type === "capability") { - writeStdout(term.update([event])); + term.update([event]); } else if ("x" in event) { pointer = { x: event.x, diff --git a/pointer-shape.ts b/pointer-shape.ts index 8ed5a88..54d156d 100644 --- a/pointer-shape.ts +++ b/pointer-shape.ts @@ -48,7 +48,3 @@ const INDEX: ReadonlyMap = new Map( export function pointerShapeIndex(shape: unknown): number | undefined { return INDEX.get(shape); } - -export const POINTER_SHAPE_RESET: Uint8Array = new TextEncoder().encode( - "\x1b]22;default\x1b\\", -); diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 2b70afd..4aebb0b 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -426,11 +426,12 @@ values (see [Input Specification](input-spec.md) §5), discriminated by `type`: the transaction MUST throw otherwise. - A `CapabilityEvent` (see [Terminfo Specification](terminfo-spec.md) §6.3) — a capability value delivered by the input parser from a probe response. -- Any other `InputEvent` — a no-op step. It changes no state and contributes no - bytes. +- Any other `InputEvent` — a no-op step. It changes no state. -The Term folds each event in array order. The returned bytes are the -concatenation of each fold's output. An empty array is a no-op. +The Term folds each event in array order. An empty array is a no-op. + +Multiple `update()` calls before the next `render()` accumulate state changes in +call order. That render MUST use the state resulting from all those calls. A resize event whose target dimensions equal the Term's current dimensions MUST be a no-op for that step. @@ -448,15 +449,13 @@ be a no-op for that step. emitted. **Capability semantics.** A `CapabilityEvent` step folds the event's `key` and -`value` into the Term's private `RuntimeCapabilities`. The foundation update -transaction emits bytes or alters renderer output as a consequence of a -capability event only where a capability section of this specification says so -(§7.10). +`value` into the Term's private `RuntimeCapabilities`. The updated capabilities +are observable through `term.capabilities` when the call returns. Their effects +on subsequent render transactions are defined by the capability sections of this +specification (§7.8–§7.10). -**Return value.** `update()` returns a `Uint8Array` of bytes to write to the -terminal immediately. An empty array is valid when the update changes no -rendered state (TINV-5). The caller writes the bytes to its output stream before -the next render. +**Return value.** `update()` returns `void`. The caller must invoke `render()` +to see the effects (TINV-5). **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 @@ -558,17 +557,17 @@ When the resolved shape differs, the render MUST append `ESC ] 22 ; ST` to `output` after the frame's cell bytes and record the new shape. Otherwise it MUST NOT emit OSC 22. -**Restore.** OSC 22 set cannot pop, so the Term restores by setting `default`: - -- A render that resolves `default` emits it per **Output** above. -- An update step that changes `pointerShape` from `true` to `false` while the - emitted shape is not `default` MUST return `ESC ] 22 ; default ST` and record - `default`. -- A resize step leaves the emitted shape unchanged. +**Updates.** Capability changes and resize MUST leave the recorded last emitted +shape unchanged. While `pointerShape` is `false`, renders MUST NOT emit a shape +change, including a reset to `default`. If the capability becomes `true` again, +the next render compares its resolved shape with the recorded last emitted shape +as defined by **Output** above. -To restore before exit, the caller renders a frame without `pointer`, or passes -`{ type: "capability", key: "pointer-shape", value: false }` to `update()`, and -writes the result. +**Reset to default.** OSC 22 set cannot pop, so the Term resets the pointer by +setting `default`. While the capability is `true`, a render that resolves +`default` emits it per **Output** above. To reset before exit, the caller +renders a frame without `pointer` while the capability is `true` and writes the +result's `output`. --- @@ -762,7 +761,7 @@ wherever the directive model expects a color. ### 8.6 Term update ``` -term.update(events: readonly InputEvent[]): Uint8Array +term.update(events: readonly InputEvent[]): void ``` Performs an update transaction as defined in §7.7. `update()` is the universal @@ -775,9 +774,8 @@ Every other `InputEvent` is accepted and ignored, so the full `events` array from `input.scan()` can be passed without filtering. Events are folded in array order. -**Return value.** `update()` always returns a `Uint8Array`. Write it to the -terminal immediately when non-empty. Do not wait for the next `render()`. An -empty array means the update changed no rendered state. +**Return value.** `update()` returns `void`. The caller must invoke `render()` +to see the effects. **Host usage.** Events from `input.scan()` pass through directly. Resizes observed outside the input stream (e.g. `SIGWINCH`) are passed as a constructed @@ -785,13 +783,17 @@ observed outside the input stream (e.g. `SIGWINCH`) are passed as a constructed ``` const { events } = input.scan(bytes); -const out = term.update(events); -if (out.length) stdout.write(out); +term.update(events); +requestRender(); // on SIGWINCH term.update([{ type: "resize", width: cols(), height: rows() }]); +requestRender(); ``` +Here `requestRender()` is the application's scheduling hook. Its render pass +supplies directives to `term.render()` and writes the result's `output`. + A resize to the Term's current dimensions is a no-op for that step. --- diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index 6093ba9..bc9b194 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -89,8 +89,8 @@ renderer and input parser each receive a copy at construction. terminal's responses arrive on the input stream, where the input parser recognizes them and emits `CapabilityEvent` values alongside keys and mouse events. The host loop routes these events to `term.update()`, which folds them -into the renderer's `RuntimeCapabilities` and returns any bytes to write -immediately. +into the renderer's `RuntimeCapabilities` synchronously. Changes are reflected +in the next `render()` call. ### 4.2 Data flow @@ -102,9 +102,11 @@ terminfo file bytes ──▶ terminfo_parse() ─▶ Capabilities (frozen) createInput() createTerm() │ │ probe bytes ──▶ terminal ──▶ stdin │ - scan() ─▶ CapabilityEvent ─▶ update() ─▶ Uint8Array (write now) + scan() ─▶ CapabilityEvent ─▶ update(): void │ RuntimeCapabilities + │ + render(ops).output ─▶ Uint8Array (host writes) ``` - `detectTerminal()` parses raw bytes into `Capabilities`. It performs no IO. @@ -112,6 +114,7 @@ probe bytes ──▶ terminal ──▶ stdin │ nothing; it emits. - The renderer holds its own private capability state. `update()` is the only path that changes it. +- Changes made by `update()` are reflected in the next `render()` call. - The renderer and the input parser share no memory and hold no reference to each other. The host loop is the only place they meet. @@ -154,9 +157,8 @@ missing, oversized, or malformed terminfo file, or abort. Environmental conditions degrade to the §7.1 baseline. The only rejection is caller error (§10.1). -**TINV-5. Immediate update output.** `term.update()` returns a `Uint8Array` of -bytes to write now. It MUST NOT defer output to the next `render()` call. An -empty array is a valid return when the update changes no rendered state. +**TINV-5. Update effects.** `term.update()` returns `void`. The caller must +invoke `render()` to see the effects. **TINV-6. Event-only probe surface.** The input parser MUST NOT write to any shared state when it recognizes a probe response. It surfaces the result as a @@ -324,10 +326,11 @@ not merge; each response yields its own event. **OSC 22.** An OSC 22 reply sets `key: "pointer-shape", value: true`. The shape name in the reply is discarded in v1; only protocol support is recorded. The renderer emits pointer shapes only while `pointerShape` is `true` (Renderer -Specification §7.10). Terminals that implement OSC 22 set but not its query -never reply; `detectTerminal()` supplies that evidence itself from the -known-support table (§6.1), and a caller may still override it with -`term.update([{ type: "capability", key: "pointer-shape", value: true }])`. +Specification §7.10). Setting `pointerShape` to `false` suppresses pointer-shape +output; it does not reset the pointer to `default`. Terminals that implement OSC +22 set but not its query never reply; `detectTerminal()` supplies that evidence +itself from the known-support table (§6.1), and a caller may still override it +with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. **DA1.** The DA1 reply is recognized internally as the probe fence and MUST NOT surface as a `CapabilityEvent`. @@ -476,9 +479,10 @@ and mouse events in arrival order. The host loop routes them to `term.update()`. ### 9.3 Capability change over time A capability event can arrive after the renderer has already emitted frames. -`term.update()` folds it into the runtime snapshot. Any renderer-side output -invalidation or immediate bytes are defined by the renderer section that -consumes that capability (Renderer Specification §7.8). +`term.update()` folds it into the runtime snapshot synchronously. Subsequent +renders consume that snapshot. Any renderer-side output invalidation is defined +by the renderer section that consumes that capability (Renderer Specification +§7.8). --- @@ -566,7 +570,7 @@ When omitted, the parser uses built-in xterm key sequences. ```ts interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; - update(events: readonly InputEvent[]): Uint8Array; + update(events: readonly InputEvent[]): void; readonly capabilities: RuntimeCapabilities; } ``` @@ -574,10 +578,11 @@ interface Term { `update()` accepts an array of `InputEvent` values. Events other than `ResizeEvent` and `CapabilityEvent` are no-op steps, so the full `events` array from `scan()` can be passed without filtering. Events are folded in order: each -event produces the next `RuntimeCapabilities` and any bytes defined by the -consuming feature, and the returned bytes are concatenated. The foundation -defines no such bytes. The return value is always a `Uint8Array`; callers write -it to their output stream when non-empty (TINV-5). +resize updates the Term's dimensions and each capability event updates its +`RuntimeCapabilities`. These changes take effect before `update()` returns. The +method returns `void`; the caller must invoke `render()` to see the effects +(TINV-5). Multiple calls contribute to the next render as defined in Renderer +Specification §7.7. `term.capabilities` is a frozen snapshot of the current `RuntimeCapabilities`. @@ -594,19 +599,28 @@ const input = await createInput({ terminfo }); process.stdout.write(terminfo.probe); +function draw() { + const { output } = term.render(view()); + process.stdout.write(output); +} + process.stdin.on("data", (bytes: Uint8Array) => { const { events } = input.scan(bytes); - const out = term.update(events); - if (out.length) process.stdout.write(out); + term.update(events); for (const event of events) dispatch(event); + requestRender(draw); }); process.on("SIGWINCH", () => { - const out = term.update([{ type: "resize", width: cols(), height: rows() }]); - if (out.length) process.stdout.write(out); + term.update([{ type: "resize", width: cols(), height: rows() }]); + requestRender(draw); }); ``` +Here `view()`, `dispatch()`, and `requestRender()` belong to the host +application. `requestRender()` schedules the supplied rendering callback; the +host can coalesce requests into one render after a batch of events. + `createTerm` and `createInput` each take `terminfo` and build their own private state from it. Passing the same `TerminalInfo` to both passes the same plain value twice. There is no shared memory and no attachment guard. diff --git a/src/clayterm.c b/src/clayterm.c index 58d8f6d..b40f79f 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -772,15 +772,7 @@ void clayterm_resize(struct Clayterm *ct, int w, int h) { cells_fill(ct->front, w, h, 0, 0, 0); } -int set_capabilities(struct Clayterm *ct, uint32_t caps) { - int withdrawn = (ct->caps & RENDER_CAP_POINTER_SHAPE) && - !(caps & RENDER_CAP_POINTER_SHAPE); - int restore = withdrawn && ct->pointer_shape != 0; - if (restore) - ct->pointer_shape = 0; - ct->caps = caps; - return restore; -} +void set_capabilities(struct Clayterm *ct, uint32_t caps) { ct->caps = caps; } void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime, float px, float py, int pointer) { diff --git a/src/clayterm.h b/src/clayterm.h index cbde52e..3c2e898 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -30,7 +30,7 @@ struct Clayterm; int clayterm_size(int w, int h); struct Clayterm *clayterm_init(void *mem, int w, int h); void clayterm_resize(struct Clayterm *ct, int w, int h); -int set_capabilities(struct Clayterm *ct, uint32_t caps); +void set_capabilities(struct Clayterm *ct, uint32_t caps); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime, float px, float py, int pointer); char *output(struct Clayterm *ct); diff --git a/term-native.ts b/term-native.ts index 1b0479f..fba30a8 100644 --- a/term-native.ts +++ b/term-native.ts @@ -51,10 +51,9 @@ export interface Native { update(w: number, h: number): void; /** * Push capability evidence into the renderer. The renderer holds it across - * frames and resize updates. Returns true when withdrawing pointerShape leaves a - * non-default shape on screen that the caller must reset. + * frames and resize updates. */ - setCapabilities(caps: RenderCapabilities): boolean; + setCapabilities(caps: RenderCapabilities): void; reduce( ct: number, buf: number, @@ -129,7 +128,7 @@ export async function createTermNative( ): void; output(ct: number): number; length(ct: number): number; - set_capabilities(ct: number, caps: number): number; + set_capabilities(ct: number, caps: number): void; pointer_over_count(): number; pointer_over_id_string_length(index: number): number; pointer_over_id_string_ptr(index: number): number; @@ -149,10 +148,10 @@ export async function createTermNative( let statePtr: number | undefined; let opsBuf = 0; - function setCaps(next: RenderCapabilities): boolean { + function setCaps(next: RenderCapabilities): void { let flags = (next.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0) | (next.pointerShape ? RENDER_CAP_POINTER_SHAPE : 0); - return ct.set_capabilities(statePtr!, flags) !== 0; + ct.set_capabilities(statePtr!, flags); } // Renderer state and the fixed transfer buffer share linear memory as @@ -190,8 +189,8 @@ export async function createTermNative( update(uw: number, uh: number): void { layout(uw, uh); }, - setCapabilities(next: RenderCapabilities): boolean { - return setCaps(next); + setCapabilities(next: RenderCapabilities): void { + setCaps(next); }, reduce: ct.reduce, output: ct.output, diff --git a/term.ts b/term.ts index 5cc7443..5d3ffd4 100644 --- a/term.ts +++ b/term.ts @@ -8,7 +8,6 @@ import { } from "./term-native.ts"; import type { InputEvent } from "./input.ts"; import type { Capabilities, Rgb, TerminalInfo } from "./terminfo.ts"; -import { POINTER_SHAPE_RESET } from "./pointer-shape.ts"; export type { BoundingBox }; @@ -40,14 +39,14 @@ export interface RuntimeCapabilities extends Capabilities { /** * Fold one InputEvent into the current RuntimeCapabilities and return the next - * snapshot plus any bytes to write now. Pure: performs no IO, no WASM calls. + * snapshot. Pure: performs no IO, no WASM calls. */ function applyUpdate( current: RuntimeCapabilities, event: InputEvent, -): { readonly next: RuntimeCapabilities; readonly bytes: Uint8Array } { +): RuntimeCapabilities { if (event.type !== "capability") { - return { next: current, bytes: new Uint8Array(0) }; + return current; } let next: RuntimeCapabilities; switch (event.key) { @@ -86,7 +85,7 @@ function applyUpdate( default: next = current; } - return { next: Object.freeze(next), bytes: new Uint8Array(0) }; + return Object.freeze(next); } function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { @@ -153,13 +152,12 @@ export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; /** - * Fold InputEvents in order. Returns bytes to write now. An empty array is - * valid when no immediate output is needed (TINV-5). + * Fold InputEvents in order. Invoke render() to see the effects (TINV-5). * * Pass the events array from scan() directly. For an out-of-band resize, * pass [{ type: "resize", width, height }]. */ - update(events: readonly InputEvent[]): Uint8Array; + update(events: readonly InputEvent[]): void; /** Frozen snapshot of the current merged capability state. */ readonly capabilities: RuntimeCapabilities; @@ -296,11 +294,9 @@ export async function createTerm(options: TermOptions): Promise { return { output, events, info, errors, animating }; }, - update(events: readonly InputEvent[]): Uint8Array { - let out: Uint8Array[] = []; - + update(events: readonly InputEvent[]): void { for (let c of events) { - let { next, bytes } = applyUpdate(currentCaps, c); + let next = applyUpdate(currentCaps, c); if (c.type === "resize") { let w = c.width; @@ -326,25 +322,13 @@ export async function createTerm(options: TermOptions): Promise { next.syncOutput !== currentCaps.syncOutput || next.pointerShape !== currentCaps.pointerShape ) { - let reset = native.setCapabilities({ + native.setCapabilities({ syncOutput: next.syncOutput, pointerShape: next.pointerShape, }); - if (reset) out.push(POINTER_SHAPE_RESET.slice()); } currentCaps = next; - if (bytes.length) out.push(bytes); - } - - if (out.length === 0) return new Uint8Array(0); - let total = out.reduce((n, b) => n + b.length, 0); - let result = new Uint8Array(total); - let offset = 0; - for (let b of out) { - result.set(b, offset); - offset += b.length; } - return result; }, }; } diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts index 3437b18..4b07256 100644 --- a/test/pointer-shape.test.ts +++ b/test/pointer-shape.test.ts @@ -100,7 +100,7 @@ describe("pointer shape", () => { it("emits once the capability arrives", async () => { let late = await createTerm({ width: 40, height: 6 }); expect(sequences(late.render(layout(), LINK).output)).toEqual([]); - expect(late.update([SUPPORTED])).toEqual(new Uint8Array(0)); + expect(late.update([SUPPORTED])).toBeUndefined(); expect(sequences(late.render(layout(), LINK).output)).toEqual([ osc22("pointer"), ]); @@ -261,30 +261,57 @@ describe("pointer shape", () => { ]); }); - it("returns the reset from update() when the capability is withdrawn", () => { + it("emits no shape changes or resets while the capability is withdrawn", () => { term.render(layout(), LINK); - expect(decode(term.update([WITHDRAWN]))).toBe(osc22("default")); + expect(term.update([WITHDRAWN])).toBeUndefined(); + expect(term.capabilities.pointerShape).toBe(false); expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), FIELD).output)).toEqual([]); + expect(sequences(term.render(layout()).output)).toEqual([]); + expect(sequences(term.render(layout(), { mode: "line" }).output)).toEqual( + [], + ); }); - it("returns nothing from update() when withdrawn at default", () => { - term.render(layout(), PLAIN); - expect(term.update([WITHDRAWN])).toEqual(new Uint8Array(0)); + it("compares with the last emitted shape after disabled renders", () => { + term.render(layout(), LINK); + term.update([WITHDRAWN]); + term.render(layout(), FIELD); + term.render(layout()); + term.update([SUPPORTED]); + + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), FIELD).output)).toEqual([ + osc22("text"), + ]); }); - it("returns the reset once from a batch that withdraws twice", () => { + it("resets an emitted shape only when an enabled render resolves default", () => { term.render(layout(), LINK); - expect(decode(term.update([WITHDRAWN, SUPPORTED, WITHDRAWN]))).toBe( + term.update([WITHDRAWN]); + expect(sequences(term.render(layout()).output)).toEqual([]); + term.update([SUPPORTED]); + + expect(sequences(term.render(layout()).output)).toEqual([ osc22("default"), - ); + ]); + expect(sequences(term.render(layout()).output)).toEqual([]); + }); + + it("preserves the emitted shape across capability toggles before rendering", () => { + term.render(layout(), LINK); + expect(term.update([WITHDRAWN, SUPPORTED, WITHDRAWN])).toBeUndefined(); + expect(term.capabilities.pointerShape).toBe(false); + expect(term.update([SUPPORTED])).toBeUndefined(); + expect(term.capabilities.pointerShape).toBe(true); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); }); it("keeps the emitted shape and capability through growth and shrink", () => { term.render(layout(), LINK); for (let [width, height] of [[120, 40], [40, 6]]) { - expect(term.update([{ type: "resize", width, height }])).toEqual( - new Uint8Array(0), - ); + expect(term.update([{ type: "resize", width, height }])) + .toBeUndefined(); expect(term.capabilities.pointerShape).toBe(true); expect(sequences(term.render(layout(), LINK).output)).toEqual([]); expect(sequences(term.render(layout(), FIELD).output)).toEqual([ @@ -299,15 +326,14 @@ describe("pointer shape", () => { ]); }); - it("resets once when capability withdrawal is interleaved with resizes", () => { + it("retains the emitted shape when capability changes are interleaved with resizes", () => { term.render(layout(), LINK); - let reset = term.update([ + expect(term.update([ { type: "resize", width: 120, height: 40 }, WITHDRAWN, { type: "resize", width: 40, height: 6 }, WITHDRAWN, - ]); - expect(decode(reset)).toBe(osc22("default")); + ])).toBeUndefined(); expect(term.capabilities.pointerShape).toBe(false); expect(sequences(term.render(layout(), FIELD).output)).toEqual([]); @@ -315,8 +341,9 @@ describe("pointer shape", () => { { type: "resize", width: 120, height: 40 }, SUPPORTED, { type: "resize", width: 40, height: 6 }, - ])).toEqual(new Uint8Array(0)); + ])).toBeUndefined(); expect(term.capabilities.pointerShape).toBe(true); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); expect(sequences(term.render(layout(), FIELD).output)).toEqual([ osc22("text"), ]); diff --git a/test/term.test.ts b/test/term.test.ts index a6a1be7..13c1a0c 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -767,7 +767,8 @@ hi it("emits a complete redraw on the first render after update", () => { term.render(frame); expect(term.render(frame).output.length).toBe(0); - term.update([{ type: "resize", width: 20, height: 5 }]); + expect(term.update([{ type: "resize", width: 20, height: 5 }])) + .toBeUndefined(); let out = decode(term.render(frame).output); expect(trim(print(out, 20, 5))).toContain("Hi"); expect(out.length).toBeGreaterThan(0); @@ -805,11 +806,42 @@ hi }); }); + it("applies updates synchronously and renders the state accumulated across calls", () => { + expect(term.update([ + { type: "resize", width: 30, height: 8 }, + { type: "capability", key: "sync-output", value: true }, + ])).toBeUndefined(); + expect(term.capabilities.syncOutput).toBe(true); + + expect(term.update([ + { type: "capability", key: "sync-output", value: false }, + { type: "resize", width: 12, height: 3 }, + ])).toBeUndefined(); + expect(term.capabilities.syncOutput).toBe(false); + + expect(term.update([ + { type: "capability", key: "sync-output", value: true }, + ])).toBeUndefined(); + expect(term.capabilities.syncOutput).toBe(true); + + let result = term.render(frame); + let output = decode(result.output); + expect(trim(print(output, 12, 3))).toEqual("Hi\n\n"); + expect(result.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 12, + height: 3, + }); + expect(output.startsWith("\x1b[?2026h")).toBe(true); + expect(output.endsWith("\x1b[?2026l")).toBe(true); + }); + it("treats an update array with no resize as a no-op for layout", () => { term.render(frame); term.update([{ type: "capability", key: "sync-output", value: false }]); expect(term.render(frame).output.length).toBe(0); - term.update([]); + expect(term.update([])).toBeUndefined(); expect(term.render(frame).output.length).toBe(0); }); @@ -827,7 +859,7 @@ hi { type: "keydown", key: "a", code: "a", text: "a" }, { type: "mousemove", button: "left", x: 1, y: 1 }, ]); - expect(out).toEqual(new Uint8Array(0)); + expect(out).toBeUndefined(); expect(term.capabilities).toEqual(before); expect(term.render(frame).output.length).toBe(0); }); diff --git a/validate.ts b/validate.ts index b49ee88..8254339 100644 --- a/validate.ts +++ b/validate.ts @@ -242,7 +242,7 @@ export function validated(term: Term): Term { return term.render(ops, options); }, update(events) { - return term.update(events); + term.update(events); }, }; }