From e7f5b2b74f3c8f1dd0e0b8dfb9896f1c3a000169 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 23 Oct 2026 00:08:03 -0500 Subject: [PATCH] refactor: separate renderer initialization from resize --- Makefile | 7 +--- src/clayterm.c | 77 +++++++++++++++++++++----------------- src/clayterm.h | 5 +-- term-native.ts | 43 +++++++++------------ test/pointer-shape.test.ts | 42 ++++++++++++++++++--- test/term.test.ts | 32 +++++++++++++++- 6 files changed, 132 insertions(+), 74 deletions(-) diff --git a/Makefile b/Makefile index c3e7b24..8415960 100644 --- a/Makefile +++ b/Makefile @@ -13,12 +13,9 @@ CFLAGS = --target=wasm32 -nostdlib -O2 \ EXPORTS = \ -Wl,--export=__heap_base \ -Wl,--export=clayterm_size \ - -Wl,--export=init \ + -Wl,--export=clayterm_init \ + -Wl,--export=clayterm_resize \ -Wl,--export=set_capabilities \ - -Wl,--export=pointer_shape \ - -Wl,--export=set_pointer_shape \ - -Wl,--export=pointer_shape \ - -Wl,--export=set_pointer_shape \ -Wl,--export=reduce \ -Wl,--export=output \ -Wl,--export=length \ diff --git a/src/clayterm.c b/src/clayterm.c index 3a91eab..58d8f6d 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -1,14 +1,15 @@ /* clayterm.c — WASM terminal rendering engine for Clay UI * * Public API (exported to WASM): - * clayterm_size — compute arena size for given dimensions - * init — initialize a Clayterm instance in provided memory - * reduce — decode command buffer, run Clay layout, render to ANSI - * output — pointer to output byte buffer - * length — length of output byte buffer - * measure — Clay text measurement callback + * clayterm_size — compute arena size for given dimensions + * clayterm_init — initialize a Clayterm instance in provided memory + * clayterm_resize — rebuild dimension-dependent storage in place + * reduce — decode command buffer, run Clay layout, render to ANSI + * output — pointer to output byte buffer + * length — length of output byte buffer + * measure — Clay text measurement callback * error_count, error_type, error_message_length, error_message_ptr - * — per-render Clay error accessors + * — per-render Clay error accessors */ #include "clayterm.h" @@ -71,8 +72,8 @@ struct Clayterm { Clay_ErrorData errors[MAX_ERRORS]; int error_count; int animating_count; - /* CAP_* bits pushed by the host via set_capabilities(); init() zeroes - * them, so the host re-pushes after every re-init */ + /* RENDER_CAP_* bits pushed by the host via set_capabilities(), retained + * across resize updates. */ uint32_t caps; /* Pointer shape (renderer-spec 7.10): hover_shape is what this frame's * hit test resolved, pointer_shape is what was last emitted. Both index @@ -647,8 +648,8 @@ static int align64(int n) { return (n + 63) & ~63; } * with the grid instead of shipping Clay's fixed 8192-element default. 2x cells * bounds sane trees (leaves >= 1 cell, branching >= 2); the ceiling preserves * today's capacity for large grids, the floor keeps headroom on tiny ones. - * Must run before Clay_MinMemorySize in both clayterm_size and init so the - * size the host queries always matches the arena init consumes. */ + * Must run before Clay_MinMemorySize in both clayterm_size and + * clayterm_resize so the queried size matches the arena setup. */ static void set_clay_capacity(int w, int h) { int elems = w * h * 2; if (elems < 2048) @@ -720,41 +721,55 @@ int error_message_ptr(struct Clayterm *ct, int index) { return (int)ct->errors[index].errorText.chars; } -struct Clayterm *init(void *mem, int w, int h) { - set_clay_capacity(w, h); +struct Clayterm *clayterm_init(void *mem, int w, int h) { struct Clayterm *ct = (struct Clayterm *)mem; + *ct = (struct Clayterm){0}; + clayterm_resize(ct, w, h); + return ct; +} + +/* Rebuild viewport storage without resetting instance state such as + * capabilities and the emitted pointer shape. */ +void clayterm_resize(struct Clayterm *ct, int w, int h) { + set_clay_capacity(w, h); int cell_count = w * h; int cell_bytes = align8(cell_count * (int)sizeof(Cell)); int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD); - char *base = (char *)mem + align8((int)sizeof(struct Clayterm)); + char *base = (char *)ct + align8((int)sizeof(struct Clayterm)); + + ct->w = w; + ct->h = h; + ct->front = (Cell *)base; + ct->back = (Cell *)(base + cell_bytes); + ct->out = (Buffer){base + cell_bytes * 2, 0, + cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD}; + ct->lastfg = 0xffffffff; + ct->lastbg = 0xffffffff; + ct->lastx = -1; + ct->lasty = -1; + + /* Discard results from the old layout. Traversal scratch is reset by + * reduce() before use; the caret text pointer may refer to reused storage. */ + ct->error_count = 0; + ct->animating_count = 0; + ct->caret_text_chars = NULL; + ct->had_caret_last_frame = 0; char *clay_mem = base + cell_bytes * 2 + out_bytes; int clay_bytes = align64((int)Clay_MinMemorySize()); Clay_Arena arena = Clay_CreateArenaWithCapacityAndMemory(clay_bytes, clay_mem); + /* Clay reads its previous context during initialization. Fill cells only + * afterward: the resized cell buffers may overlap that old context. */ Clay_Initialize(arena, (Clay_Dimensions){(float)w, (float)h}, (Clay_ErrorHandler){clay_error, ct}); - *ct = (struct Clayterm){ - .w = w, - .h = h, - .front = (Cell *)base, - .back = (Cell *)(base + cell_bytes), - .out = {base + cell_bytes * 2, 0, - cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD}, - .lastfg = 0xffffffff, - .lastbg = 0xffffffff, - .lastx = -1, - .lasty = -1, - }; - // initialize back buffer with spaces and default fg/bg cells_fill(ct->back, w, h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); // initialize front buffer with zeros. Every cell will be // redrawn on the first render. cells_fill(ct->front, w, h, 0, 0, 0); - return ct; } int set_capabilities(struct Clayterm *ct, uint32_t caps) { @@ -767,12 +782,6 @@ int set_capabilities(struct Clayterm *ct, uint32_t caps) { return restore; } -int pointer_shape(struct Clayterm *ct) { return ct->pointer_shape; } - -void set_pointer_shape(struct Clayterm *ct, int shape) { - ct->pointer_shape = shape; -} - void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime, float px, float py, int pointer) { int i = 0; diff --git a/src/clayterm.h b/src/clayterm.h index 20bee69..cbde52e 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -28,10 +28,9 @@ struct Clayterm; /* WASM exports */ int clayterm_size(int w, int h); -struct Clayterm *init(void *mem, 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); -int pointer_shape(struct Clayterm *ct); -void set_pointer_shape(struct Clayterm *ct, int shape); 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 62f11b3..1b0479f 100644 --- a/term-native.ts +++ b/term-native.ts @@ -44,15 +44,14 @@ export interface Native { 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. + * Rebuild dimension-dependent renderer storage in place (renderer-spec 7.7). + * Reuses this instance and memory; statePtr stays fixed and opsBuf may move. + * Growing memory detaches prior buffer views. */ update(w: number, h: number): void; /** * Push capability evidence into the renderer. The renderer holds it across - * frames; it is re-pushed internally after any re-init so it survives - * resize updates. Returns true when withdrawing pointerShape leaves a + * frames and resize updates. Returns true when withdrawing pointerShape leaves a * non-default shape on screen that the caller must reset. */ setCapabilities(caps: RenderCapabilities): boolean; @@ -115,7 +114,8 @@ export async function createTermNative( let ct = exports as unknown as { __heap_base: WebAssembly.Global; clayterm_size(w: number, h: number): number; - init(mem: number, w: number, h: number): number; + clayterm_init(mem: number, w: number, h: number): number; + clayterm_resize(ct: number, w: number, h: number): void; reduce( ct: number, buf: number, @@ -130,8 +130,6 @@ export async function createTermNative( output(ct: number): number; length(ct: number): number; set_capabilities(ct: number, caps: number): number; - pointer_shape(ct: number): number; - set_pointer_shape(ct: number, shape: number): void; pointer_over_count(): number; pointer_over_id_string_length(index: number): number; pointer_over_id_string_ptr(index: number): number; @@ -151,21 +149,17 @@ export async function createTermNative( let statePtr: number | undefined; let opsBuf = 0; - let capFlags = 0; - - function pushCaps(ptr: number): boolean { - return ct.set_capabilities(ptr, capFlags) !== 0; - } - - function setCaps(next: RenderCapabilities): void { - capFlags = (next.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0) | + function setCaps(next: RenderCapabilities): boolean { + let flags = (next.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0) | (next.pointerShape ? RENDER_CAP_POINTER_SHAPE : 0); + return ct.set_capabilities(statePtr!, flags) !== 0; } // Renderer state and the fixed transfer buffer share linear memory as // [heap: state][opsBuf]; opsBuf moves when the state size changes. Memory is // grown to fit but never reclaimed, so a downsize keeps the high-water mark - // (renderer-spec 7.7). + // (renderer-spec 7.7). The instance is initialized once; resize rebuilds only + // its dimension-dependent storage. function layout(lw: number, lh: number): void { let heap = ct.__heap_base.value as number; let size = ct.clayterm_size(lw, lh); @@ -175,15 +169,15 @@ export async function createTermNative( if (pages > current) { memory.grow(pages - current); } - let shape = statePtr === undefined ? 0 : ct.pointer_shape(statePtr); - let ptr = ct.init(heap, lw, lh); + if (statePtr === undefined) { + statePtr = ct.clayterm_init(heap, lw, lh); + } else { + ct.clayterm_resize(statePtr, lw, lh); + } opsBuf = (heap + size + 3) & ~3; - pushCaps(ptr); - ct.set_pointer_shape(ptr, shape); - statePtr = ptr; } - setCaps(caps); layout(w, h); + setCaps(caps); return { memory, @@ -197,8 +191,7 @@ export async function createTermNative( layout(uw, uh); }, setCapabilities(next: RenderCapabilities): boolean { - setCaps(next); - return pushCaps(statePtr!); + return setCaps(next); }, reduce: ct.reduce, output: ct.output, diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts index c2b918e..3437b18 100644 --- a/test/pointer-shape.test.ts +++ b/test/pointer-shape.test.ts @@ -279,15 +279,47 @@ describe("pointer shape", () => { ); }); - it("keeps the emitted shape across a resize", () => { + it("keeps the emitted shape and capability through growth and shrink", () => { term.render(layout(), LINK); - expect(term.update([{ type: "resize", width: 50, height: 8 }])).toEqual( - new Uint8Array(0), - ); - expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + for (let [width, height] of [[120, 40], [40, 6]]) { + expect(term.update([{ type: "resize", width, height }])).toEqual( + new Uint8Array(0), + ); + expect(term.capabilities.pointerShape).toBe(true); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), FIELD).output)).toEqual([ + osc22("text"), + ]); + expect(sequences(term.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + } expect(sequences(term.render(layout(), PLAIN).output)).toEqual([ osc22("default"), ]); }); + + it("resets once when capability withdrawal is interleaved with resizes", () => { + term.render(layout(), LINK); + let reset = term.update([ + { type: "resize", width: 120, height: 40 }, + WITHDRAWN, + { type: "resize", width: 40, height: 6 }, + WITHDRAWN, + ]); + expect(decode(reset)).toBe(osc22("default")); + expect(term.capabilities.pointerShape).toBe(false); + expect(sequences(term.render(layout(), FIELD).output)).toEqual([]); + + expect(term.update([ + { type: "resize", width: 120, height: 40 }, + SUPPORTED, + { type: "resize", width: 40, height: 6 }, + ])).toEqual(new Uint8Array(0)); + expect(term.capabilities.pointerShape).toBe(true); + expect(sequences(term.render(layout(), FIELD).output)).toEqual([ + osc22("text"), + ]); + }); }); }); diff --git a/test/term.test.ts b/test/term.test.ts index 618e61e..a6a1be7 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -981,10 +981,38 @@ hi expect(out).not.toContain("\x1b[?2026"); }); - it("keeps wrapping after a resize update", () => { + it("keeps wrapping through growth and shrink", () => { term.update([syncOn]); - term.update([{ type: "resize", width: 50, height: 12 }]); + term.render(frame); + for (let [width, height] of [[120, 40], [20, 5]]) { + term.update([{ type: "resize", width, height }]); + expect(term.capabilities.syncOutput).toBe(true); + let out = decode(term.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + } + }); + + it("applies capability changes interleaved with resize updates", () => { + term.update([syncOn]); + term.render(frame); + term.update([ + { type: "resize", width: 120, height: 40 }, + syncOff, + { type: "resize", width: 20, height: 5 }, + ]); + expect(term.capabilities.syncOutput).toBe(false); let out = decode(term.render(frame).output); + expect(out.length).toBeGreaterThan(0); + expect(out).not.toContain("\x1b[?2026"); + + term.update([ + { type: "resize", width: 120, height: 40 }, + syncOn, + { type: "resize", width: 20, height: 5 }, + ]); + expect(term.capabilities.syncOutput).toBe(true); + out = decode(term.render(frame).output); expect(out.startsWith("\x1b[?2026h")).toBe(true); expect(out.endsWith("\x1b[?2026l")).toBe(true); });