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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
77 changes: 43 additions & 34 deletions src/clayterm.c
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions src/clayterm.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
43 changes: 18 additions & 25 deletions term-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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,
Expand Down
42 changes: 37 additions & 5 deletions test/pointer-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]);
});
});
});
32 changes: 30 additions & 2 deletions test/term.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading