diff --git a/.changeset/stable-render-frames.md b/.changeset/stable-render-frames.md new file mode 100644 index 0000000..52d2d41 --- /dev/null +++ b/.changeset/stable-render-frames.md @@ -0,0 +1,9 @@ +--- +"@bomb.sh/tty": patch +--- + +Fixes stale element bounds in `@bomb.sh/tty`: `result.info.get(id)` returns +`undefined` for elements absent from the latest render. + +Fixes the caret cursor remaining visible after a resize when the next render in +the default mode omits the caret. diff --git a/specs/transitions-spec.md b/specs/transitions-spec.md index 3387d39..9946c81 100644 --- a/specs/transitions-spec.md +++ b/specs/transitions-spec.md @@ -460,26 +460,24 @@ Each handler: 2. Applies its easing curve to progress. 3. Lerps each property named in the `properties` bitmask from `initial` to `target`. -4. Increments the Term context's `animating_count` unless progress is 1.0. +4. Increments the current frame's `animating_count` unless progress is 1.0. 5. Returns `true` if progress is 1.0 (transition complete), `false` otherwise. -At the start of each `render()`, the Term resets `animating_count` to zero. At -the end, the value is copied into the result struct as the `animating` flag -(`true` if count > 0). +At the start of each `render()`, the current frame's `animating_count` is reset +to zero. At the end, the value is copied into the result struct as the +`animating` flag (`true` if count > 0). ### 12.3 Per-Term isolation -The `animating_count` lives on the Term's C-side context, not as module-level -state. Multiple Terms created in the same process remain isolated. +The `animating_count` belongs to a frame owned by the Term's C-side context. +Multiple Terms created in the same process remain isolated. ### 12.4 Resolving the active Term inside the handler Clay's transition-handler signature does not carry a `userData` pointer or -element ID. Each `reduce()` call records the currently-active Term pointer in a -module-level variable (`ct_active_context`) and clears it at the end. The -handler reads this variable to reach the Term's `animating_count`. A single -render pass cannot overlap with another (renders are synchronous), so there is -no concurrency concern. +element ID. The handler obtains the active Clay context through +`Clay_GetCurrentContext()` and reads the owning Term from the context's error +handler `userData`. It increments that Term's current frame counter. --- diff --git a/src/clayterm.c b/src/clayterm.c index b40f79f..46377cc 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -17,25 +17,13 @@ #include "../clay/clay.h" #include "buffer.h" #include "cell.h" +#include "frame.h" #include "mem.h" #include "utf8.h" #include "wcwidth.h" -/* Module-level pointer to the Term currently executing reduce(). - * Set/cleared around each render pass so transition handlers (which Clay - * invokes with no userData — see Clay_TransitionCallbackArguments) can - * report back to the right Term's animating_count. Revisit once - * nicbarker/clay#603 lands userData on transition callbacks; then the - * handler can resolve its Term from args directly and this can go away. */ -struct Clayterm *ct_active_context = NULL; - /* ── Instance state ───────────────────────────────────────────────── */ -#define MAX_ERRORS 32 - -/* clip stack depth: nesting beyond this clamps to the deepest rect */ -#define CLIP_STACK_MAX 16 - /* Clayterm-specific error codes, numbered past Clay's error enum (0..8). * Mirrored by ERROR_TYPES in term.ts. */ #define CLAYTERM_ERR_CLIP_DEPTH_EXCEEDED 9 @@ -44,64 +32,16 @@ struct Clayterm *ct_active_context = NULL; #define CLAYTERM_STR_(x) #x #define CLAYTERM_STR(x) CLAYTERM_STR_(x) -typedef struct { - int x, y, w, h; -} ClipRect; - struct Clayterm { int w, h; - Cell *front; - Cell *back; - Buffer out; - uint32_t lastfg, lastbg; - int lastx, lasty; - /* clip region (active top mirrored here so setcell stays unchanged) */ - int clipx, clipy, clipw, cliph; - int clipping; - /* clip stack: nesting pushes intersected rects, leaving pops to restore */ - ClipRect clipstack[CLIP_STACK_MAX]; - int clipdepth; - /* untracked clip levels open beyond CLIP_STACK_MAX; popped without - * touching the tracked stack so push/pop stays symmetric */ - int clipoverflow; - /* set once per frame when nesting first exceeds the tracked depth */ - int clip_depth_exceeded; - /* set once per frame when a cell first runs out of combining-mark slots */ - int combining_exceeded; - /* error collection */ - Clay_ErrorData errors[MAX_ERRORS]; - int error_count; - int animating_count; + uint32_t runlist_capacity; + FrameContext frames; + Clay_Context *clay; /* 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 - * POINTER_SHAPES; 0 is "default". */ - int hover_shape; - int pointer_shape; - /* Caret state for hardware-cursor management. The renderer records the - * first text node carrying a caret declaration per frame, precomputes the - * caret's byte offset into that node's content, then places the cell as - * a side effect of render_text's walk. had_caret_last_frame is the only - * cross-frame bit retained. */ - const char - *caret_text_chars; /* start of caret-bearing text node's bytes, or NULL */ - int caret_text_length; /* byte length of that text node */ - uint32_t caret_offset_bytes; /* target byte offset within that text node */ - int caret_placed; /* 1 once the render walk has recorded a cell */ - int caret_tail_valid; /* 1 when caret_tail_x/y hold a fallback cell */ - int caret_tail_x, - caret_tail_y; /* trailing cell of the most recently walked caret slice */ - int caret_x, - caret_y; /* resolved cell (column, row); -1 sentinel until placed */ - int has_caret; /* 1 when the current frame will emit a cursor */ - int had_caret_last_frame; /* 1 when the previous frame emitted a cursor */ }; -/* Memory layout inside the arena provided by the host: - * [Clayterm struct] [front cells] [back cells] [output buffer] - */ /* 128 bytes per cell: ~84 bytes worst-case for CUP + SGR sequences, plus up * to 4 (base SMP char) + 8×4 (combining marks) = 36 bytes of cluster text. */ #define OUT_BYTES_PER_CELL 128 @@ -132,7 +72,9 @@ static void cursor_hover(Clay_ElementId id, Clay_PointerData pointer, void *shape) { (void)id; (void)pointer; - ct_active_context->hover_shape = (int)(uintptr_t)shape; + struct Clayterm *ct = Clay_GetCurrentContext()->errorHandler.userData; + Frame *frame = ct->frames.current; + frame->hover_shape = (uint8_t)(uintptr_t)shape; } /* ── Cell buffer ops ──────────────────────────────────────────────── */ @@ -143,15 +85,18 @@ static Cell *cell_at(struct Clayterm *ct, Cell *buf, int x, int y) { static int setcell(struct Clayterm *ct, int x, int y, uint32_t ch, uint32_t fg, uint32_t bg) { + Frame *frame = ct->frames.current; if (x < 0 || x >= ct->w || y < 0 || y >= ct->h) return 0; - if (ct->clipping) { - if (x < ct->clipx || x >= ct->clipx + ct->clipw) + if (frame->clip.enabled) { + if (x < frame->clip.active.x || + x >= frame->clip.active.x + frame->clip.active.w) return 0; - if (y < ct->clipy || y >= ct->clipy + ct->cliph) + if (y < frame->clip.active.y || + y >= frame->clip.active.y + frame->clip.active.h) return 0; } - Cell *c = cell_at(ct, ct->back, x, y); + Cell *c = cell_at(ct, frame->cells, x, y); c->ch = ch; c->fg = fg; if (!(bg & ATTR_DEFAULT)) { @@ -164,15 +109,16 @@ static int setcell(struct Clayterm *ct, int x, int y, uint32_t ch, uint32_t fg, /* Surface a COMBINING_MARKS_EXCEEDED error once per frame. */ static void report_combining_exceeded(struct Clayterm *ct) { - if (ct->combining_exceeded) + Frame *frame = ct->frames.current; + if (frame->combining_exceeded) return; - ct->combining_exceeded = 1; - if (ct->error_count >= MAX_ERRORS) + frame->combining_exceeded = true; + if (frame->error_count >= FRAME_MAX_ERRORS) return; static const char msg[] = "cell exceeds combining-mark limit of " CLAYTERM_STR( CELL_MAX_COMBINING) "; excess marks truncated"; - ct->errors[ct->error_count++] = (Clay_ErrorData){ + frame->errors[frame->error_count++] = (Clay_ErrorData){ .errorType = (Clay_ErrorType)CLAYTERM_ERR_COMBINING_MARKS_EXCEEDED, .errorText = {.isStaticallyAllocated = true, .length = (int32_t)(sizeof(msg) - 1), @@ -188,7 +134,7 @@ static void report_combining_exceeded(struct Clayterm *ct) { static void append_combining(struct Clayterm *ct, int x, int y, uint32_t cp) { if (x < 0 || x >= ct->w || y < 0 || y >= ct->h) return; - Cell *c = cell_at(ct, ct->back, x, y); + Cell *c = cell_at(ct, ct->frames.current->cells, x, y); for (int i = 0; i < CELL_MAX_COMBINING; i++) { if (c->combining[i] == 0) { c->combining[i] = cp; @@ -201,84 +147,91 @@ static void append_combining(struct Clayterm *ct, int x, int y, uint32_t cp) { /* ── Escape sequence generation ───────────────────────────────────── */ static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) { - if (fg == ct->lastfg && bg == ct->lastbg) + Frame *frame = ct->frames.current; + Buffer *out = &frame->context->output; + if (fg == frame->emission.lastfg && bg == frame->emission.lastbg) return; /* SGR reset */ - buf_str(&ct->out, "\x1b[0m"); + buf_str(out, "\x1b[0m"); /* style attributes from fg high byte */ if (fg & ATTR_BOLD) - buf_str(&ct->out, "\x1b[1m"); + buf_str(out, "\x1b[1m"); if (fg & ATTR_DIM) - buf_str(&ct->out, "\x1b[2m"); + buf_str(out, "\x1b[2m"); if (fg & ATTR_ITALIC) - buf_str(&ct->out, "\x1b[3m"); + buf_str(out, "\x1b[3m"); if (fg & ATTR_UNDERLINE) - buf_str(&ct->out, "\x1b[4m"); + buf_str(out, "\x1b[4m"); if (fg & ATTR_BLINK) - buf_str(&ct->out, "\x1b[5m"); + buf_str(out, "\x1b[5m"); if (fg & ATTR_REVERSE) - buf_str(&ct->out, "\x1b[7m"); + buf_str(out, "\x1b[7m"); if (fg & ATTR_STRIKEOUT) - buf_str(&ct->out, "\x1b[9m"); + buf_str(out, "\x1b[9m"); /* foreground truecolor */ if (!(fg & ATTR_DEFAULT)) { - buf_str(&ct->out, "\x1b[38;2;"); - buf_num(&ct->out, (fg >> 16) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, (fg >> 8) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, fg & 0xff); - buf_put(&ct->out, "m", 1); + buf_str(out, "\x1b[38;2;"); + buf_num(out, (fg >> 16) & 0xff); + buf_put(out, ";", 1); + buf_num(out, (fg >> 8) & 0xff); + buf_put(out, ";", 1); + buf_num(out, fg & 0xff); + buf_put(out, "m", 1); } /* background truecolor */ if (!(bg & ATTR_DEFAULT)) { - buf_str(&ct->out, "\x1b[48;2;"); - buf_num(&ct->out, (bg >> 16) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, (bg >> 8) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, bg & 0xff); - buf_put(&ct->out, "m", 1); + buf_str(out, "\x1b[48;2;"); + buf_num(out, (bg >> 16) & 0xff); + buf_put(out, ";", 1); + buf_num(out, (bg >> 8) & 0xff); + buf_put(out, ";", 1); + buf_num(out, bg & 0xff); + buf_put(out, "m", 1); } - ct->lastfg = fg; - ct->lastbg = bg; + frame->emission.lastfg = fg; + frame->emission.lastbg = bg; } /** Emit CUP sequence. `row` is the 1-based terminal row of the region. */ static void emit_cursor(struct Clayterm *ct, int x, int y, int row) { - buf_str(&ct->out, "\x1b["); - buf_num(&ct->out, y + row); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, x + 1); - buf_put(&ct->out, "H", 1); + Frame *frame = ct->frames.current; + Buffer *out = &frame->context->output; + buf_str(out, "\x1b["); + buf_num(out, y + row); + buf_put(out, ";", 1); + buf_num(out, x + 1); + buf_put(out, "H", 1); } static void emit_ch(struct Clayterm *ct, int x, int y, int row, uint32_t ch) { - if (ct->lastx != x - 1 || ct->lasty != y) { + Frame *frame = ct->frames.current; + Buffer *out = &frame->context->output; + if (frame->emission.lastx != x - 1 || frame->emission.lasty != y) { emit_cursor(ct, x, y, row); } - ct->lastx = x; - ct->lasty = y; + frame->emission.lastx = x; + frame->emission.lasty = y; if (!iswprint(ch)) ch = 0xfffd; - buf_char(&ct->out, ch); + buf_char(out, ch); } /** - * Diff back buffer against front buffer and emit only changed cells + * Diff the current frame's cells against the last frame and emit changed cells * using absolute CUP positioning (\x1b[row;colH). Unchanged cells are * skipped, making this efficient for subsequent frames where most of * the screen is static. Derived from termbox2 tb_present. */ static void present_cups(struct Clayterm *ct, int row) { - ct->lastx = -1; - ct->lasty = -1; + Frame *frame = ct->frames.current; + Buffer *out = &frame->context->output; + CaretState *caret = &frame->caret; /* Synchronized Output (DEC mode 2026): the terminal buffers everything * between BSU and ESU and presents it in one atomic repaint, so a frame @@ -286,26 +239,23 @@ static void present_cups(struct Clayterm *ct, int row) { * whether we emit the wrap at all is decided by the sync-output * capability. */ int sync = ct->caps & RENDER_CAP_SYNC_OUTPUT; - int start = ct->out.length; + int start = out->length; if (sync) - buf_str(&ct->out, "\x1b[?2026h"); + buf_str(out, "\x1b[?2026h"); /* Body start: the no-op check below compares against this so the BSU * itself never counts as frame content. */ - int body = ct->out.length; + int body = out->length; for (int y = 0; y < ct->h; y++) { for (int x = 0; x < ct->w;) { - Cell *back = cell_at(ct, ct->back, x, y); - Cell *front = cell_at(ct, ct->front, x, y); + Cell *back = cell_at(ct, frame->cells, x, y); + Cell *front = cell_at(ct, ct->frames.previous->cells, x, y); int w = wcwidth(back->ch); if (w < 1) w = 1; if (cell_cmp(back, front)) { - /* copy to front */ - *front = *back; - emit_attr(ct, back->fg, back->bg); if (w > 1 && x >= ct->w - (w - 1)) { @@ -315,79 +265,79 @@ static void present_cups(struct Clayterm *ct, int row) { } else { emit_ch(ct, x, y, row, back->ch); for (int ci = 0; ci < CELL_MAX_COMBINING && back->combining[ci]; ci++) - buf_char(&ct->out, back->combining[ci]); - /* mark trailing cells of wide char as invalid in front - * so they'll diff when overwritten by narrow chars */ - for (int i = 1; i < w; i++) { - Cell *fw = cell_at(ct, ct->front, x + i, y); - fw->ch = 0xffffffff; - fw->fg = 0xffffffff; - fw->bg = 0xffffffff; - } + buf_char(out, back->combining[ci]); + } + } + /* The completed frame becomes the next diff baseline. Mark every + * fitting wide glyph, including unchanged ones, so a later narrow + * glyph redraws the terminal's continuation cell. */ + if (w > 1 && x < ct->w - (w - 1)) { + for (int i = 1; i < w; i++) { + Cell *tail = cell_at(ct, frame->cells, x + i, y); + tail->ch = 0xffffffff; + tail->fg = 0xffffffff; + tail->bg = 0xffffffff; } } x += w; } } - /* Hardware cursor management: per the spec's outcome contract, - * leave the cursor visible at the caret cell if a caret was declared - * this frame; otherwise hide it if and only if a caret was declared - * last frame. When neither this frame nor any prior frame declared - * a caret, emit nothing. */ - if (ct->has_caret) { - emit_cursor(ct, ct->caret_x, ct->caret_y, row); - buf_str(&ct->out, "\x1b[?25h"); - } else if (ct->had_caret_last_frame) { - buf_str(&ct->out, "\x1b[?25l"); + /* Show a declared caret once its cell is resolved. Otherwise, hide the + * cursor if the renderer previously showed it. */ + bool show_caret = caret->declared && caret->placed; + if (show_caret) { + emit_cursor(ct, caret->x, caret->y, row); + buf_str(out, "\x1b[?25h"); + } else if (frame->cursor_visible) { + buf_str(out, "\x1b[?25l"); } - ct->had_caret_last_frame = ct->has_caret; + frame->cursor_visible = show_caret; - if (ct->out.length == body) { + if (out->length == body) { /* Nothing was emitted — no cell changes and no caret transition. * Rewind the BSU so a no-op frame still produces zero bytes. */ - ct->out.length = start; + out->length = start; } else if (sync) { - buf_str(&ct->out, "\x1b[?2026l"); /* ESU: end synchronized update */ + buf_str(out, "\x1b[?2026l"); /* ESU: end synchronized update */ } } /** - * Emit back buffer as newline-separated rows without CUP positioning. - * Every cell is written (no diffing), and the front buffer is primed - * so that a subsequent present() call can diff efficiently. This is + * Emit current cells as newline-separated rows without CUP positioning. + * Every cell is written (no diffing), and the completed frame becomes + * the baseline for the next render. This is * used for inline "region" rendering where the caller manages cursor * positioning externally and the output must work in pipes. */ static void present_lines(struct Clayterm *ct) { + Frame *frame = ct->frames.current; + Buffer *out = &frame->context->output; for (int y = 0; y < ct->h; y++) { if (y > 0) - buf_put(&ct->out, "\n", 1); + buf_put(out, "\n", 1); for (int x = 0; x < ct->w;) { - Cell *back = cell_at(ct, ct->back, x, y); - Cell *front = cell_at(ct, ct->front, x, y); + Cell *back = cell_at(ct, frame->cells, x, y); int w = wcwidth(back->ch); if (w < 1) w = 1; - *front = *back; - emit_attr(ct, back->fg, back->bg); if (w > 1 && x >= ct->w - (w - 1)) { for (int i = x; i < ct->w; i++) { - buf_char(&ct->out, ' '); + buf_char(out, ' '); } } else { uint32_t ch = back->ch; if (!iswprint(ch)) ch = 0xfffd; - buf_char(&ct->out, ch); + buf_char(out, ch); for (int ci = 0; ci < CELL_MAX_COMBINING && back->combining[ci]; ci++) - buf_char(&ct->out, back->combining[ci]); + buf_char(out, back->combining[ci]); for (int i = 1; i < w; i++) { - Cell *fw = cell_at(ct, ct->front, x + i, y); + Cell *fw = cell_at(ct, frame->cells, x + i, y); fw->ch = 0xffffffff; fw->fg = 0xffffffff; fw->bg = 0xffffffff; @@ -443,7 +393,7 @@ static uint32_t utf8_bytes_for_cps(const char *start, uint32_t cps, static void render_text(struct Clayterm *ct, int x0, int y0, Clay_RenderCommand *cmd) { - + CaretState *caret = &ct->frames.current->caret; Clay_TextRenderData *t = &cmd->renderData.text; uint32_t bg = (uint32_t)(uintptr_t)cmd->userData; uint32_t fg = color(t->textColor); @@ -458,9 +408,9 @@ static void render_text(struct Clayterm *ct, int x0, int y0, /* Determine whether this slice belongs to the caret's text node and * whether the caret still needs to be placed. If so, resolve the cell * as a side effect of the walk instead of doing a separate pass. */ - const char *node_start = ct->caret_text_chars; - const char *node_end = node_start ? node_start + ct->caret_text_length : NULL; - int caret_relevant = (node_start != NULL && !ct->caret_placed && + const char *node_start = caret->text.chars; + const char *node_end = node_start ? node_start + caret->text.length : NULL; + int caret_relevant = (node_start != NULL && !caret->placed && slice >= node_start && slice < node_end); /* Pre-check: if the slice's first byte is already past the target, @@ -468,10 +418,10 @@ static void render_text(struct Clayterm *ct, int x0, int y0, * the caret to this slice's origin. */ if (caret_relevant) { uint32_t slice_start_bytes = (uint32_t)(slice - node_start); - if (slice_start_bytes > ct->caret_offset_bytes) { - ct->caret_x = x0; - ct->caret_y = y0; - ct->caret_placed = 1; + if (slice_start_bytes > caret->byte_offset) { + caret->x = x0; + caret->y = y0; + caret->placed = 1; caret_relevant = 0; } } @@ -485,11 +435,10 @@ static void render_text(struct Clayterm *ct, int x0, int y0, /* Check at the top of each iteration: if the pointer we are about to * consume matches the target byte offset, the caret sits at the * current cell — right before this code point. */ - if (caret_relevant && - (uint32_t)(p - node_start) == ct->caret_offset_bytes) { - ct->caret_x = x; - ct->caret_y = y0; - ct->caret_placed = 1; + if (caret_relevant && (uint32_t)(p - node_start) == caret->byte_offset) { + caret->x = x; + caret->y = y0; + caret->placed = 1; caret_relevant = 0; } @@ -518,9 +467,9 @@ static void render_text(struct Clayterm *ct, int x0, int y0, * hasn't been placed by the end of the render command loop, this cell * is where a caret at offset == content-length lands. */ if (caret_relevant) { - ct->caret_tail_x = x; - ct->caret_tail_y = y0; - ct->caret_tail_valid = 1; + caret->tail_x = x; + caret->tail_y = y0; + caret->tail_valid = 1; } } @@ -589,13 +538,12 @@ static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1, /* ── Command buffer helpers ───────────────────────────────────────── */ -static uint32_t rd(uint32_t *buf, int len, int *i) { - if (*i < len) - return buf[(*i)++]; - return 0; +static uint32_t rd(uint32_t *buf, uint32_t len, uint32_t *i) { + (void)len; /* assemble_frame checked every directive's extent. */ + return buf[(*i)++]; } -static float rdf(uint32_t *buf, int len, int *i) { +static float rdf(uint32_t *buf, uint32_t len, uint32_t *i) { uint32_t v = rd(buf, len, i); float f; memcpy(&f, &v, 4); @@ -611,7 +559,7 @@ static Clay_Color unpack_color(uint32_t c) { }; } -static Clay_SizingAxis decode_axis(uint32_t *buf, int len, int *i) { +static Clay_SizingAxis decode_axis(uint32_t *buf, uint32_t len, uint32_t *i) { uint32_t type = rd(buf, len, i); float a = rdf(buf, len, i); float b = rdf(buf, len, i); @@ -644,6 +592,11 @@ static Clay_SizingAxis decode_axis(uint32_t *buf, int len, int *i) { static int align64(int n) { return (n + 63) & ~63; } +static uint32_t persistent_size(uint32_t runlist_capacity) { + return align8(sizeof(struct Clayterm)) + + 2u * frame_storage_size(runlist_capacity); +} + /* Clay's element capacity drives nearly all of Clay_MinMemorySize, so scale it * 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 @@ -651,31 +604,42 @@ static int align64(int n) { return (n + 63) & ~63; } * 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; + int64_t elems = (int64_t)w * h * 2; if (elems < 2048) elems = 2048; - if (elems > 8192) - elems = 8192; - Clay_SetMaxElementCount(elems); - Clay_SetMaxMeasureTextCacheWordCount(elems * 2); + if (elems > FRAME_ELEMENT_CAPACITY) + elems = FRAME_ELEMENT_CAPACITY; + Clay_SetMaxElementCount((int)elems); + Clay_SetMaxMeasureTextCacheWordCount((int)elems * 2); } -int clayterm_size(int w, int h) { +int clayterm_size(int w, int h, int runlist_capacity) { + if (w <= 0 || h <= 0 || runlist_capacity < 0 || + runlist_capacity > INT32_MAX / 4) + return 0; + int64_t cell_count = (int64_t)w * h; + if (cell_count > INT32_MAX / (2 * sizeof(Cell) + OUT_BYTES_PER_CELL)) + return 0; + /* Sizing must not reconfigure a live arena: the host can still fail to + * grow memory after this query. Clay only needs the two capacity fields. */ + Clay_Context sizing = {0}; + Clay_Context *previous = Clay_GetCurrentContext(); + Clay_SetCurrentContext(&sizing); set_clay_capacity(w, h); - int cell_count = w * h; - int cell_bytes = cell_count * (int)sizeof(Cell); - int out_bytes = cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD; - int clay_bytes = (int)Clay_MinMemorySize(); - return align8((int)sizeof(struct Clayterm)) + align8(cell_bytes) /* front */ - + align8(cell_bytes) /* back */ - + align8(out_bytes) /* output buffer */ - + align64(clay_bytes); /* Clay arena */ + int clay_bytes = align64((int)Clay_MinMemorySize()); + Clay_SetCurrentContext(previous); + int64_t size = + persistent_size((uint32_t)runlist_capacity) + + 2 * ((cell_count * sizeof(Cell) + 7) & ~7LL) + + ((cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD + 7) & ~7LL) + + clay_bytes; + return size <= INT32_MAX ? (int)size : 0; } static void clay_error(Clay_ErrorData err) { struct Clayterm *ct = (struct Clayterm *)err.userData; - if (ct->error_count < MAX_ERRORS) { - ct->errors[ct->error_count++] = err; + if (ct->frames.current->error_count < FRAME_MAX_ERRORS) { + ct->frames.current->errors[ct->frames.current->error_count++] = err; } } @@ -683,16 +647,17 @@ static void clay_error(Clay_ErrorData err) { * literal, so its pointer lives in WASM linear memory and is readable by the * host via error_message_ptr/length. */ static void report_clip_depth_exceeded(struct Clayterm *ct) { - if (ct->clip_depth_exceeded) + Frame *frame = ct->frames.current; + if (frame->clip_depth_exceeded) return; - ct->clip_depth_exceeded = 1; - if (ct->error_count >= MAX_ERRORS) + frame->clip_depth_exceeded = true; + if (frame->error_count >= FRAME_MAX_ERRORS) return; static const char msg[] = "clip nesting exceeds tracked depth limit of " CLAYTERM_STR( - CLIP_STACK_MAX) "; over-deep clips coalesced into the deepest " - "tracked region"; - ct->errors[ct->error_count++] = (Clay_ErrorData){ + FRAME_CLIP_STACK_MAX) "; over-deep clips coalesced into the deepest " + "tracked region"; + frame->errors[frame->error_count++] = (Clay_ErrorData){ .errorType = (Clay_ErrorType)CLAYTERM_ERR_CLIP_DEPTH_EXCEEDED, .errorText = {.isStaticallyAllocated = true, .length = (int32_t)(sizeof(msg) - 1), @@ -701,59 +666,68 @@ static void report_clip_depth_exceeded(struct Clayterm *ct) { }; } -int error_count(struct Clayterm *ct) { return ct->error_count; } +int error_count(struct Clayterm *ct) { + return ct->frames.previous->error_count; +} int error_type(struct Clayterm *ct, int index) { - if (index < 0 || index >= ct->error_count) + if (index < 0 || index >= ct->frames.previous->error_count) return -1; - return (int)ct->errors[index].errorType; + return (int)ct->frames.previous->errors[index].errorType; } int error_message_length(struct Clayterm *ct, int index) { - if (index < 0 || index >= ct->error_count) + if (index < 0 || index >= ct->frames.previous->error_count) return 0; - return ct->errors[index].errorText.length; + return ct->frames.previous->errors[index].errorText.length; } int error_message_ptr(struct Clayterm *ct, int index) { - if (index < 0 || index >= ct->error_count) + if (index < 0 || index >= ct->frames.previous->error_count) return 0; - return (int)ct->errors[index].errorText.chars; + return (int)ct->frames.previous->errors[index].errorText.chars; } -struct Clayterm *clayterm_init(void *mem, int w, int h) { +struct Clayterm *clayterm_init(void *mem, int w, int h, int runlist_capacity) { struct Clayterm *ct = (struct Clayterm *)mem; *ct = (struct Clayterm){0}; + ct->runlist_capacity = (uint32_t)runlist_capacity; + char *base = (char *)ct + align8(sizeof(struct Clayterm)); + frame_context_init(&ct->frames, base, ct->runlist_capacity); clayterm_resize(ct, w, h); return ct; } /* Rebuild viewport storage without resetting instance state such as - * capabilities and the emitted pointer shape. */ + * capabilities, retained directives, and emitted terminal state. */ void clayterm_resize(struct Clayterm *ct, int w, int h) { + if (ct->clay) + Clay_SetCurrentContext(ct->clay); 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 *)ct + align8((int)sizeof(struct Clayterm)); + uint32_t cell_bytes = align8((uint32_t)cell_count * sizeof(Cell)); + uint32_t out_bytes = + align8((uint32_t)cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD); + char *base = (char *)ct + persistent_size(ct->runlist_capacity); 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; + ct->frames.frames[0].cells = (Cell *)base; + ct->frames.frames[1].cells = (Cell *)(base + cell_bytes); + ct->frames.output = + (Buffer){base + cell_bytes * 2, 0, + cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD}; /* 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; + * reduce() before use. */ + for (int i = 0; i < 2; i++) { + Frame *frame = &ct->frames.frames[i]; + frame->layout_valid = 0; + frame->pointer_count = 0; + frame->error_count = 0; + frame->animating_count = 0; + frame_reset_caret(frame); + } char *clay_mem = base + cell_bytes * 2 + out_bytes; int clay_bytes = align64((int)Clay_MinMemorySize()); @@ -761,56 +735,176 @@ void clayterm_resize(struct Clayterm *ct, int w, int h) { 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->clay = Clay_Initialize(arena, (Clay_Dimensions){(float)w, (float)h}, + (Clay_ErrorHandler){clay_error, ct}); // initialize back buffer with spaces and default fg/bg - cells_fill(ct->back, w, h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); + cells_fill(ct->frames.current->cells, 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); + cells_fill(ct->frames.previous->cells, w, h, 0, 0, 0); } 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) { +/* 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. + */ +static int packed_valid(const uint32_t *buf, int len) { int i = 0; - ct_active_context = ct; - ct->error_count = 0; - ct->animating_count = 0; - ct->caret_text_chars = NULL; - ct->caret_text_length = 0; - ct->caret_offset_bytes = 0; - ct->caret_placed = 0; - ct->caret_tail_valid = 0; - ct->caret_tail_x = -1; - ct->caret_tail_y = -1; - ct->caret_x = -1; - ct->caret_y = -1; - ct->has_caret = 0; + int depth = 0; + while (i < len) { + uint32_t op = buf[i++]; + if (op == CLAYTERM_OP_OPEN_ELEMENT) { + if (i == len) + return 0; + uint32_t bytes = buf[i++]; + uint32_t words = bytes / 4 + (bytes % 4 != 0); + if (words >= (uint32_t)(len - i)) + return 0; + i += (int)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; + i += props; + depth++; + } else if (op == CLAYTERM_OP_TEXT) { + if (len - i < 5) + return 0; + 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; + } + } + 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) + return CLAYTERM_REDUCE_CAPACITY; + if (!packed_valid(buf, len)) + return CLAYTERM_REDUCE_INVALID; + + 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; + return CLAYTERM_REDUCE_OK; +} + +static void report_element_capacity_exceeded(struct Clayterm *ct) { + Frame *frame = ct->frames.current; + if (frame->metadata_exceeded) + return; + frame->metadata_exceeded = true; + clay_error((Clay_ErrorData){ + .errorType = CLAY_ERROR_TYPE_ELEMENTS_CAPACITY_EXCEEDED, + .errorText = CLAY_STRING("Frame element capacity exceeded."), + .userData = ct, + }); +} + +static void record_open(struct Clayterm *ct, Clay_ElementId id, + uint32_t offset) { + Frame *frame = ct->frames.current; + if (frame->open_depth == FRAME_ELEMENT_CAPACITY) { + frame->open_overflow++; + report_element_capacity_exceeded(ct); + return; + } + + int index = -1; + /* Clay keeps the first declaration of a duplicate ID. Its error callback + * reports the duplicate; give every open its own stack slot so a duplicate + * close cannot change the first declaration's range. */ + if (id.stringId.length && frame_element_index(frame, id.id) < 0) { + if (frame->element_count < FRAME_ELEMENT_CAPACITY) { + index = (int)frame->element_count++; + uint32_t bucket = id.id % FRAME_ELEMENT_CAPACITY; + frame->elements[index] = (ElementInfo){ + .id = id, + .offset = offset, + .next = frame->element_buckets[bucket], + }; + frame->element_buckets[bucket] = (uint32_t)index + 1; + } else { + report_element_capacity_exceeded(ct); + } + } + frame->context->open_elements[frame->open_depth++] = index; +} + +static void record_close(struct Clayterm *ct, uint32_t end) { + Frame *frame = ct->frames.current; + if (frame->open_overflow) { + frame->open_overflow--; + return; + } + if (frame->open_depth == 0) + return; + int index = frame->context->open_elements[--frame->open_depth]; + if (index >= 0) { + ElementInfo *info = &frame->elements[index]; + info->length = end - info->offset; + } +} + +static Clay_RenderCommandArray execute_current_runlist(struct Clayterm *ct, + float deltaTime) { + CaretState *caret = &ct->frames.current->caret; + uint32_t *buf = ct->frames.current->runlist; + uint32_t len = ct->frames.current->runlist_length; + uint32_t i = 0; + Clay_SetCurrentContext(ct->clay); Clay_BeginLayout(); while (i < len) { + uint32_t offset = i * 4; uint32_t op = rd(buf, len, &i); switch (op) { case CLAYTERM_OP_OPEN_ELEMENT: { /* read id string */ uint32_t id_len = rd(buf, len, &i); - int id_words = (id_len + 3) / 4; + uint32_t id_words = (id_len + 3) / 4; char *id_chars = (char *)&buf[i]; i += id_words; + Clay_ElementId eid = {0}; + int open_depth = ct->clay->openLayoutElementStack.length; if (id_len > 0) { Clay_String str = {.length = (int32_t)id_len, .chars = id_chars}; - Clay_ElementId eid = Clay__HashString(str, 0); + eid = Clay__HashString(str, 0); Clay__OpenElementWithId(eid); } else { Clay__OpenElement(); } + int opened = ct->clay->openLayoutElementStack.length > open_depth; + record_open(ct, opened ? eid : (Clay_ElementId){0}, offset); /* read property mask */ uint32_t mask = rd(buf, len, &i); @@ -913,6 +1007,8 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, if (mask & CLAYTERM_PROP_CURSOR) cursor = rd(buf, len, &i); + if (!opened) + break; Clay__ConfigureOpenElement(decl); if (cursor < POINTER_SHAPE_COUNT && (ct->caps & RENDER_CAP_POINTER_SHAPE)) @@ -924,20 +1020,22 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, uint32_t col = rd(buf, len, &i); uint32_t bg = rd(buf, len, &i); uint32_t cfg = rd(buf, len, &i); - uint32_t caret = rd(buf, len, &i); + uint32_t caret_offset = rd(buf, len, &i); uint32_t str_len = rd(buf, len, &i); - int str_words = (str_len + 3) / 4; + uint32_t str_words = (str_len + 3) / 4; char *str_chars = (char *)&buf[i]; i += str_words; + Clay_String text = {.isStaticallyAllocated = false, + .length = (int32_t)str_len, + .chars = str_chars}; /* A caret on empty content is a rendering commitment the layout * engine cannot satisfy on its own — zero cells give render_text * nothing to attach the cursor to. Substitute a single space so * the caret lands at the text element's origin, per the spec's * "as if the content were a single space" outcome. */ - if (caret != 0xFFFFFFFF && str_len == 0) { - str_chars = (char *)" "; - str_len = 1; + if (caret_offset != 0xFFFFFFFF && str_len == 0) { + text = CLAY_STRING(" "); } /* Record the FIRST caret declaration per frame for the @@ -947,15 +1045,13 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, * Convert the caller's code-point offset into a byte offset once * here so the render walk can identify the caret cell by simple * pointer arithmetic against the slice's chars pointer. */ - if (caret != 0xFFFFFFFF && ct->caret_text_chars == NULL) { - ct->caret_text_chars = str_chars; - ct->caret_text_length = (int)str_len; - ct->caret_offset_bytes = utf8_bytes_for_cps(str_chars, caret, str_len); - ct->has_caret = 1; + if (caret_offset != 0xFFFFFFFF && !caret->declared) { + caret->text = text; + caret->byte_offset = + utf8_bytes_for_cps(text.chars, caret_offset, text.length); + caret->declared = true; } - Clay_String text = {.length = (int32_t)str_len, .chars = str_chars}; - Clay_TextElementConfig config = {0}; config.userData = (void *)(uintptr_t)bg; config.textColor = unpack_color(col); @@ -971,6 +1067,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, case CLAYTERM_OP_CLOSE_ELEMENT: Clay__CloseElement(); + record_close(ct, i * 4); break; default: @@ -978,25 +1075,43 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, } } - Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); + return Clay_EndLayout(deltaTime); +} + +static void commit_current_frame(struct Clayterm *ct, float px, float py, + int pointer) { + Frame *frame = ct->frames.current; + /* Clay skips layout and leaves old hit-test results on element overflow. + * Those bounds and strings do not describe this frame. */ + if (ct->clay->booleanWarnings.maxElementsExceeded) { + report_element_capacity_exceeded(ct); + return; + } + frame->layout_valid = 1; /* pointer: 0 = absent, 1 = up, 2 = down. The hit test runs against this * frame's layout and fires cursor_hover for declaring elements. */ - ct->hover_shape = 0; - if (pointer) + if (pointer) { Clay_SetPointerState((Clay_Vector2){px, py}, pointer == 2); + Clay_ElementIdArray ids = Clay_GetPointerOverIds(); + for (int i = 0; i < ids.length; i++) { + Clay_String id = ids.internalArray[i].stringId; + if (!id.length) + continue; + if (frame->pointer_count == FRAME_ELEMENT_CAPACITY) { + report_element_capacity_exceeded(ct); + break; + } + frame->pointer_ids[frame->pointer_count++] = id; + } + } +} - /* reset output state */ - ct->out.length = 0; - ct->lastfg = ct->lastbg = 0xffffffff; - ct->lastx = ct->lasty = -1; - ct->clipdepth = 0; - ct->clipoverflow = 0; - ct->clip_depth_exceeded = 0; - ct->combining_exceeded = 0; - ct->clipping = 0; - - cells_fill(ct->back, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); +static void draw_current_frame(struct Clayterm *ct, + Clay_RenderCommandArray cmds) { + Frame *frame = ct->frames.current; + CaretState *caret = &frame->caret; + cells_fill(frame->cells, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); /* walk Clay render commands into back buffer */ for (int32_t j = 0; j < cmds.length; j++) { @@ -1020,8 +1135,8 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: { /* intersect the child box with the current active rect (if any) */ int nx0 = x0, ny0 = y0, nx1 = x1, ny1 = y1; - if (ct->clipdepth > 0) { - ClipRect top = ct->clipstack[ct->clipdepth - 1]; + if (frame->clip.depth > 0) { + ClipRect top = frame->clip.stack[frame->clip.depth - 1]; if (top.x > nx0) nx0 = top.x; if (top.y > ny0) @@ -1037,41 +1152,35 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, nw = 0; if (nh < 0) nh = 0; - if (ct->clipdepth < CLIP_STACK_MAX) { + if (frame->clip.depth < FRAME_CLIP_STACK_MAX) { ClipRect r = {nx0, ny0, nw, nh}; - ct->clipstack[ct->clipdepth++] = r; - ct->clipping = 1; - ct->clipx = nx0; - ct->clipy = ny0; - ct->clipw = nw; - ct->cliph = nh; + frame->clip.stack[frame->clip.depth++] = r; + frame->clip.enabled = true; + frame->clip.active = r; } else { /* Out of tracked slots: coalesce this level into the deepest tracked * region (leave the active rect untouched) and remember to pop it * without disturbing the tracked stack. */ - ct->clipoverflow++; + frame->clip.overflow++; report_clip_depth_exceeded(ct); } break; } case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END: - if (ct->clipoverflow > 0) { + if (frame->clip.overflow > 0) { /* Closing an untracked level: nothing was pushed, so leave the tracked * stack and active rect alone. */ - ct->clipoverflow--; + frame->clip.overflow--; break; } - if (ct->clipdepth > 0) - ct->clipdepth--; - if (ct->clipdepth > 0) { - ClipRect top = ct->clipstack[ct->clipdepth - 1]; - ct->clipping = 1; - ct->clipx = top.x; - ct->clipy = top.y; - ct->clipw = top.w; - ct->cliph = top.h; + if (frame->clip.depth > 0) + frame->clip.depth--; + if (frame->clip.depth > 0) { + ClipRect top = frame->clip.stack[frame->clip.depth - 1]; + frame->clip.enabled = true; + frame->clip.active = top; } else { - ct->clipping = 0; + frame->clip.enabled = false; } break; default: @@ -1082,19 +1191,17 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, /* End-of-content fallback: if a caret was declared and its target byte * offset is one past the last byte of the caret text node, the trailing * cell of the last walked caret slice is where it lands. */ - if (ct->has_caret && !ct->caret_placed) { - if (ct->caret_tail_valid && - ct->caret_offset_bytes == (uint32_t)ct->caret_text_length) { - ct->caret_x = ct->caret_tail_x; - ct->caret_y = ct->caret_tail_y; - ct->caret_placed = 1; - } else { - /* Offset out of range or otherwise unresolvable. Suppress the - * cursor for this frame. */ - ct->has_caret = 0; - } + if (caret->declared && !caret->placed && caret->tail_valid && + caret->byte_offset == (uint32_t)caret->text.length) { + caret->x = caret->tail_x; + caret->y = caret->tail_y; + caret->placed = 1; } +} +static void write_current_frame(struct Clayterm *ct, int mode, int row) { + Frame *frame = ct->frames.current; + Buffer *out = &frame->context->output; if (mode == 1) { present_lines(ct); } else { @@ -1102,26 +1209,60 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, } if ((ct->caps & RENDER_CAP_POINTER_SHAPE) && - ct->hover_shape != ct->pointer_shape) { - ct->pointer_shape = ct->hover_shape; - buf_str(&ct->out, "\x1b]22;"); - buf_str(&ct->out, POINTER_SHAPES[ct->pointer_shape]); - buf_str(&ct->out, "\x1b\\"); + frame->hover_shape != frame->pointer_shape) { + frame->pointer_shape = frame->hover_shape; + buf_str(out, "\x1b]22;"); + buf_str(out, POINTER_SHAPES[frame->pointer_shape]); + buf_str(out, "\x1b\\"); } +} + +int reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, + float deltaTime, float px, float py, int pointer) { + + // set up the current frame's runlist + int status = prepare_current_frame(ct, buf, len); + if (status != CLAYTERM_REDUCE_OK) + return status; - ct_active_context = NULL; + // run directives to receive render commands + Clay_RenderCommandArray commands = execute_current_runlist(ct, deltaTime); + + // compute final book keeping + commit_current_frame(ct, px, py, pointer); + + // draw the cells for this frame + draw_current_frame(ct, commands); + + // perform diff and copy any changes to output buffer + write_current_frame(ct, mode, row); + + // render complete, current becomes previous + frame_context_swap_frames(&ct->frames); + + return CLAYTERM_REDUCE_OK; } -char *output(struct Clayterm *ct) { return ct->out.data; } +char *output(struct Clayterm *ct) { return ct->frames.output.data; } -int length(struct Clayterm *ct) { return ct->out.length; } +int length(struct Clayterm *ct) { return ct->frames.output.length; } -int animating(struct Clayterm *ct) { return ct->animating_count; } +uint32_t animating(struct Clayterm *ct) { + return ct->frames.previous->animating_count; +} -int get_element_bounds(const char *name, int name_len, float *out) { +int get_element_bounds(struct Clayterm *ct, const char *name, int name_len, + float *out) { + Frame *frame = ct->frames.previous; + if (!frame->layout_valid) + return 0; Clay_String str = {.length = name_len, .chars = name}; Clay_ElementId eid = Clay__HashString(str, 0); - Clay_ElementData data = Clay_GetElementData(eid); + int index = frame_element_index(frame, eid.id); + if (index < 0) + return 0; + Clay_SetCurrentContext(ct->clay); + Clay_ElementData data = Clay_GetElementData(frame->elements[index].id); if (!data.found) { return 0; } @@ -1132,20 +1273,22 @@ int get_element_bounds(const char *name, int name_len, float *out) { return 1; } -int pointer_over_count(void) { return Clay_GetPointerOverIds().length; } +int pointer_over_count(struct Clayterm *ct) { + return ct->frames.previous->pointer_count; +} -int pointer_over_id_string_length(int index) { - Clay_ElementIdArray ids = Clay_GetPointerOverIds(); - if (index >= ids.length) +int pointer_over_id_string_length(struct Clayterm *ct, int index) { + Frame *frame = ct->frames.previous; + if (index < 0 || index >= frame->pointer_count) return 0; - return ids.internalArray[index].stringId.length; + return frame->pointer_ids[index].length; } -int pointer_over_id_string_ptr(int index) { - Clay_ElementIdArray ids = Clay_GetPointerOverIds(); - if (index >= ids.length) +int pointer_over_id_string_ptr(struct Clayterm *ct, int index) { + Frame *frame = ct->frames.previous; + if (index < 0 || index >= frame->pointer_count) return 0; - return (int)ids.internalArray[index].stringId.chars; + return (int)frame->pointer_ids[index].chars; } void measure(int ret, int txt) { diff --git a/src/clayterm.h b/src/clayterm.h index 3c2e898..ece781b 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -26,22 +26,27 @@ struct Clayterm; #define CLAYTERM_PROP_TRANSITION 0x40 #define CLAYTERM_PROP_CURSOR 0x80 +#define CLAYTERM_REDUCE_OK 0 +#define CLAYTERM_REDUCE_CAPACITY 1 +#define CLAYTERM_REDUCE_INVALID 2 + /* WASM exports */ -int clayterm_size(int w, int h); -struct Clayterm *clayterm_init(void *mem, int w, int h); +int clayterm_size(int w, int h, int runlist_capacity); +struct Clayterm *clayterm_init(void *mem, int w, int h, int runlist_capacity); void clayterm_resize(struct Clayterm *ct, int w, int h); 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); +int 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); int length(struct Clayterm *ct); -int animating(struct Clayterm *ct); +uint32_t animating(struct Clayterm *ct); void measure(int ret, int txt); -int get_element_bounds(const char *name, int name_len, float *out); +int get_element_bounds(struct Clayterm *ct, const char *name, int name_len, + float *out); -int pointer_over_count(void); -int pointer_over_id_string_length(int index); -int pointer_over_id_string_ptr(int index); +int pointer_over_count(struct Clayterm *ct); +int pointer_over_id_string_length(struct Clayterm *ct, int index); +int pointer_over_id_string_ptr(struct Clayterm *ct, int index); #endif diff --git a/src/frame.c b/src/frame.c new file mode 100644 index 0000000..d32836b --- /dev/null +++ b/src/frame.c @@ -0,0 +1,84 @@ +/* frame.c — retained render frame storage and lookup */ + +#include "frame.h" +#include "mem.h" + +uint32_t frame_storage_size(uint32_t runlist_capacity) { + return align8(runlist_capacity) + + align8(FRAME_ELEMENT_CAPACITY * sizeof(ElementInfo)) + + align8(FRAME_ELEMENT_CAPACITY * sizeof(uint32_t)) + + align8(FRAME_ELEMENT_CAPACITY * sizeof(Clay_String)); +} + +void frame_init(Frame *frame, char *base, uint32_t runlist_capacity, + FrameContext *context) { + frame->context = context; + frame->runlist = (uint32_t *)base; + base += align8(runlist_capacity); + frame->elements = (ElementInfo *)base; + base += align8(FRAME_ELEMENT_CAPACITY * sizeof(*frame->elements)); + frame->element_buckets = (uint32_t *)base; + base += align8(FRAME_ELEMENT_CAPACITY * sizeof(*frame->element_buckets)); + frame->pointer_ids = (Clay_String *)base; + memset(frame->element_buckets, 0, + FRAME_ELEMENT_CAPACITY * sizeof(*frame->element_buckets)); +} + +void frame_context_init(FrameContext *context, char *base, + uint32_t runlist_capacity) { + *context = (FrameContext){0}; + context->previous = &context->frames[0]; + context->current = &context->frames[1]; + frame_init(context->previous, base, runlist_capacity, context); + frame_init(context->current, base + frame_storage_size(runlist_capacity), + runlist_capacity, context); +} + +void frame_context_swap_frames(FrameContext *context) { + Frame *completed = context->current; + context->current = context->previous; + context->previous = completed; +} + +void frame_reset_caret(Frame *frame) { + frame->caret = (CaretState){.tail_x = -1, .tail_y = -1, .x = -1, .y = -1}; +} + +void frame_render_init(Frame *frame, const Frame *previous_frame) { + for (uint32_t i = 0; i < frame->element_count; i++) { + uint32_t bucket = frame->elements[i].id.id % FRAME_ELEMENT_CAPACITY; + frame->element_buckets[bucket] = 0; + } + frame->runlist_length = 0; + frame->element_count = 0; + frame->open_depth = 0; + frame->open_overflow = 0; + frame->layout_valid = false; + frame->pointer_count = 0; + frame->error_count = 0; + frame->metadata_exceeded = false; + frame->clip_depth_exceeded = false; + frame->combining_exceeded = false; + frame->animating_count = 0; + frame->hover_shape = 0; + frame->clip.depth = 0; + frame->clip.overflow = 0; + frame->clip.enabled = false; + frame->emission.lastfg = frame->emission.lastbg = 0xffffffff; + frame->emission.lastx = frame->emission.lasty = -1; + frame->context->output.length = 0; + frame_reset_caret(frame); + frame->pointer_shape = previous_frame->pointer_shape; + frame->cursor_visible = previous_frame->cursor_visible; +} + +int frame_element_index(Frame *frame, uint32_t id) { + uint32_t index = frame->element_buckets[id % FRAME_ELEMENT_CAPACITY]; + while (index) { + ElementInfo *info = &frame->elements[index - 1]; + if (info->id.id == id) + return (int)(index - 1); + index = info->next; + } + return -1; +} diff --git a/src/frame.h b/src/frame.h new file mode 100644 index 0000000..cc4087d --- /dev/null +++ b/src/frame.h @@ -0,0 +1,255 @@ +/* frame.h — retained render frame state and storage */ + +#ifndef FRAME_H +#define FRAME_H + +#include +#include + +#include "../clay/clay.h" +#include "buffer.h" +#include "cell.h" + +#define FRAME_ELEMENT_CAPACITY 8192 +#define FRAME_MAX_ERRORS 32 + +/* Clip nesting beyond this clamps to the deepest tracked rectangle. */ +#define FRAME_CLIP_STACK_MAX 16 + +typedef struct { + int x, y, w, h; +} ClipRect; + +typedef struct FrameContext FrameContext; + +/* A named element's identity and packed subtree range within its frame. + * Duplicate IDs keep the first declaration's entry, matching Clay's lookup. + */ +typedef struct { + /* Clay's hashed ID; stringId borrows the name bytes Frame.runlist. */ + Clay_ElementId id; + + /* Byte offset from the start of the runlist to this element's open. */ + uint32_t offset; + + /* Byte length from open through its matching close, including children. */ + uint32_t length; + + /* Next element in this hash bucket: index + 1, or 0 to end the chain. */ + uint32_t next; +} ElementInfo; + +/** + * What we know about the caret in this frame + */ +typedef struct { + /** + * Did a text directive declared a caret in this frame + */ + bool declared; + + /** + * + * Was a specific cell found for the caret. + */ + int placed; + + /** + * The run of text that contains the caret + */ + Clay_String text; + + /** + * Caret position is specified in Unicode code points. For example, to place + * the caret before the letter "B" in "A中B", you would would place it at `2`: + * + * text("A中B", { caret: 2 }) + * + * "A" occupies 1 byte and "中" occupies 3 bytes, so the caret’s byte offset + * is 4. + */ + uint32_t byte_offset; + + /* Whether tail_x and tail_y hold an end-of-content fallback cell. */ + int tail_valid; + + /* Column after the last walked caret text slice; -1 until recorded. */ + int tail_x; + + /* Row of the last walked caret text slice; -1 until recorded. */ + int tail_y; + + /* Resolved zero-based column; -1 until placed. */ + int x; + + /* Resolved zero-based row; -1 until placed. */ + int y; +} CaretState; + +/** + * Data and working state for one render. The context alternates two frames: + * current receives directives, metadata, and rasterized cells, then becomes + * previous after presentation. previous supplies the diff baseline and + * metadata such as element bounds, pointer hits, and errors encountered. + */ +typedef struct { + /* Owner of this frame, its sibling, and their shared storage. */ + FrameContext *context; + + /** + * Pointer to the list of directives that run when rendering this frame. + * OPEN/CLOSE/TEXT, etc.. + * + * Storage and directives are aligned for 32-bit reads. + */ + uint32_t *runlist; + + /* Number of populated uint32_t words in runlist. */ + uint32_t runlist_length; + + /* Named element entries in opening order; capacity FRAME_ELEMENT_CAPACITY. */ + ElementInfo *elements; + + /* Hash bucket heads, indexed by id.id % FRAME_ELEMENT_CAPACITY. Each head + * stores an element index + 1; 0 means empty. Chains follow ElementInfo.next. + */ + uint32_t *element_buckets; + + /* Number of populated entries in elements. */ + uint32_t element_count; + + /* Number of populated entries in context->open_elements. */ + uint32_t open_depth; + + /* Untracked opens beyond the stack capacity, balanced by later closes. */ + uint32_t open_overflow; + + /* Set after layout completes without element overflow; cleared on reuse or + * resize. Allows lazy bounds queries while this frame is previous, when + * the shared Clay context still holds its layout. */ + bool layout_valid; + + /* Named IDs under the pointer, in Clay hit-test order; capacity + * FRAME_ELEMENT_CAPACITY. String bytes borrow runlist storage or static + * Clay strings, including Clay's internal root ID. */ + Clay_String *pointer_ids; + + /* Number of populated pointer_ids; 0 when no pointer hit test was run. */ + uint32_t pointer_count; + + /* Row-major viewport cells. Presentation adds wide-character continuation + * markers so this buffer can serve as the next render's diff baseline. */ + Cell *cells; + + /* Clay and renderer errors collected during this render. */ + Clay_ErrorData errors[FRAME_MAX_ERRORS]; + + /* Number of populated errors, capped at FRAME_MAX_ERRORS. */ + uint8_t error_count; + + /* Whether element-index capacity overflow has been reported this render. */ + bool metadata_exceeded; + + /* Whether clip-stack overflow has been reported this render. */ + bool clip_depth_exceeded; + + /* Whether combining-mark overflow has been reported this render. */ + bool combining_exceeded; + + /* Number of transition callbacks that reported unfinished work this render. + * A nonzero count sets the host's animating result. */ + uint32_t animating_count; + + /* Shape resolved by this frame's hit test; 0 is "default". */ + uint8_t hover_shape; + + /* Last emitted pointer shape's index in POINTER_SHAPES, initialized to 0 + * (default). Inherited from previous until a shape change is emitted. */ + uint8_t pointer_shape; + + /* Caret declaration and placement state, reset on frame reuse or resize. */ + CaretState caret; + + /* Whether the renderer has shown a caret cursor without later hiding it. + * Initially 0; inherited when no cursor control is emitted. This tracks + * renderer output, not the terminal's initial cursor visibility. */ + bool cursor_visible; + + /* Clipping state while walking this frame's drawing commands. */ + struct { + /* Current intersection, consulted only while enabled. */ + ClipRect active; + + /* Intersected rectangles restored as nested clips close. */ + ClipRect stack[FRAME_CLIP_STACK_MAX]; + + /* Number of populated rectangles in stack. */ + uint32_t depth; + + /* Untracked clip levels, popped without disturbing stack. */ + uint32_t overflow; + + /* Whether active restricts drawing. */ + bool enabled; + } clip; + + /* Bookkeeping within this frame's output stream, reset on every render. */ + struct { + /* Last emitted attributes; 0xffffffff forces the initial SGR. */ + uint32_t lastfg, lastbg; + + /* Last character position used to elide CUP; -1 forces positioning. */ + int lastx, lasty; + } emission; +} Frame; + +/* Owns the frame pair, their render roles, and shared scratch storage. + * Initialize in place: both frames point back to this context. */ +struct FrameContext { + /* Stable frame storage; swapping changes roles without copying frames. */ + Frame frames[2]; + + /* Frame being prepared for the next completed render. */ + Frame *current; + + /* Most recently completed frame, used for diffing and metadata queries. */ + Frame *previous; + + /* Metadata entry for each open element; -1 when there is no entry. + * Reused by current; contents are not retained for previous. */ + int open_elements[FRAME_ELEMENT_CAPACITY]; + + /* Shared terminal bytes from the latest render; replaced by the next render. + * Resize rebinds this buffer to the dimension-dependent storage. */ + Buffer output; +}; + +/* Initialize both frames and shared scratch in place. base provides two + * frame_storage_size() regions; resize binds cells and output afterward. */ +void frame_context_init(FrameContext *context, char *base, + uint32_t runlist_capacity); + +/* Make the completed current frame previous, recycling the old previous. */ +void frame_context_swap_frames(FrameContext *context); + +/* Bytes for the runlist, element index, and pointer IDs; excludes cells. */ +uint32_t frame_storage_size(uint32_t runlist_capacity); + +/* Bind a zero-initialized frame to its retained storage and owning context. */ +void frame_init(Frame *frame, char *base, uint32_t runlist_capacity, + FrameContext *context); + +/* 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. + * Inherits emitted pointer shape and cursor visibility from previous_frame. */ +void frame_render_init(Frame *frame, const Frame *previous_frame); + +/* Clear caret placement state while preserving emitted cursor visibility. */ +void frame_reset_caret(Frame *frame); + +/* Return the element's index, or -1 if the ID is absent from the frame. */ +int frame_element_index(Frame *frame, uint32_t id); + +#endif diff --git a/src/mem.c b/src/mem.c index 3a0895a..090d71a 100644 --- a/src/mem.c +++ b/src/mem.c @@ -24,4 +24,4 @@ size_t strlen(const char *s) { return n; } -int align8(int n) { return (n + 7) & ~7; } +uint32_t align8(uint32_t n) { return (n + 7u) & ~7u; } diff --git a/src/mem.h b/src/mem.h index 0a11d96..0ee6676 100644 --- a/src/mem.h +++ b/src/mem.h @@ -3,11 +3,13 @@ #ifndef MEM_H #define MEM_H +#include + typedef __SIZE_TYPE__ size_t; void *memcpy(void *dst, const void *src, size_t n); void *memset(void *dst, int c, size_t n); size_t strlen(const char *s); -int align8(int n); +uint32_t align8(uint32_t n); #endif diff --git a/src/module.c b/src/module.c index 5438e13..eccb10c 100644 --- a/src/module.c +++ b/src/module.c @@ -8,6 +8,7 @@ #include "cell.c" #include "utf8.c" #include "wcwidth.c" +#include "frame.c" #include "clayterm.c" #include "transitions.c" #include "trie.c" diff --git a/src/transitions.c b/src/transitions.c index 7c6837b..a86630d 100644 --- a/src/transitions.c +++ b/src/transitions.c @@ -1,8 +1,6 @@ #include "transitions.h" #include "clayterm.h" -extern struct Clayterm *ct_active_context; - static float clampf(float v, float lo, float hi) { if (v < lo) { return lo; @@ -83,8 +81,9 @@ static bool apply(Clay_TransitionCallbackArguments args, float eased, (uint16_t)lerpf(args.initial.borderWidth.betweenChildren, args.target.borderWidth.betweenChildren, eased); } - if (ct_active_context && !done) { - ct_active_context->animating_count++; + if (!done) { + struct Clayterm *ct = Clay_GetCurrentContext()->errorHandler.userData; + ct->frames.current->animating_count++; } return done; } diff --git a/term-native.ts b/term-native.ts index fba30a8..90c2d0a 100644 --- a/term-native.ts +++ b/term-native.ts @@ -43,6 +43,7 @@ export interface Native { memory: WebAssembly.Memory; statePtr: number; opsBuf: number; + opsCapacity: number; /** * Rebuild dimension-dependent renderer storage in place (renderer-spec 7.7). * Reuses this instance and memory; statePtr stays fixed and opsBuf may move. @@ -112,8 +113,13 @@ export async function createTermNative( let ct = exports as unknown as { __heap_base: WebAssembly.Global; - clayterm_size(w: number, h: number): number; - clayterm_init(mem: number, w: number, h: number): number; + clayterm_size(w: number, h: number, runlistCapacity: number): number; + clayterm_init( + mem: number, + w: number, + h: number, + runlistCapacity: number, + ): number; clayterm_resize(ct: number, w: number, h: number): void; reduce( ct: number, @@ -125,14 +131,19 @@ export async function createTermNative( px: number, py: number, pointer: number, - ): void; + ): number; output(ct: number): number; length(ct: 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; - get_element_bounds(name: number, len: number, out: number): number; + 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( + ct: number, + name: number, + len: number, + out: number, + ): number; animating(ct: number): number; error_count(ct: number): number; error_type(ct: number, index: number): number; @@ -154,14 +165,17 @@ export async function createTermNative( ct.set_capabilities(statePtr!, flags); } - // 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). The instance is initialized once; resize rebuilds only - // its dimension-dependent storage. + // Native storage keeps both frames' runlists and metadata before the cells, + // output buffer, and Clay arena rebuilt on resize. The transfer buffer comes + // last and may move; native execution borrows strings from the retained + // runlist, never from this transfer/query workspace. Memory keeps its + // high-water mark when the viewport shrinks (renderer-spec 7.7). function layout(lw: number, lh: number): void { let heap = ct.__heap_base.value as number; - let size = ct.clayterm_size(lw, lh); + let size = ct.clayterm_size(lw, lh, transferBytes); + if (size <= 0) { + throw new RangeError("Terminal dimensions exceed renderer capacity."); + } let needed = heap + size + transferBytes; let pages = Math.ceil(needed / WASM_PAGE_BYTES); let current = memory.buffer.byteLength / WASM_PAGE_BYTES; @@ -169,7 +183,7 @@ export async function createTermNative( memory.grow(pages - current); } if (statePtr === undefined) { - statePtr = ct.clayterm_init(heap, lw, lh); + statePtr = ct.clayterm_init(heap, lw, lh, transferBytes); } else { ct.clayterm_resize(statePtr, lw, lh); } @@ -186,24 +200,45 @@ export async function createTermNative( get opsBuf() { return opsBuf; }, + opsCapacity: transferBytes, update(uw: number, uh: number): void { layout(uw, uh); }, setCapabilities(next: RenderCapabilities): void { setCaps(next); }, - reduce: ct.reduce, + reduce(ptr, buf, len, mode, row, deltaTime, px, py, pointer): void { + let status = ct.reduce( + ptr, + buf, + len, + mode, + row, + deltaTime, + px, + py, + pointer, + ); + if (status === 1) { + throw new RangeError( + "Frame exceeds retained runlist capacity; render a smaller visible slice or reduce frame content.", + ); + } + if (status !== 0) { + throw new RangeError("Malformed packed render directives."); + } + }, output: ct.output, length: ct.length, animating: ct.animating, getPointerOverIds(): string[] { let decoder = new TextDecoder(); - let count = ct.pointer_over_count(); + let count = ct.pointer_over_count(statePtr!); let ids: string[] = []; for (let i = 0; i < count; i++) { - let len = ct.pointer_over_id_string_length(i); + let len = ct.pointer_over_id_string_length(statePtr!, i); if (len === 0) continue; - let ptr = ct.pointer_over_id_string_ptr(i); + let ptr = ct.pointer_over_id_string_ptr(statePtr!, i); ids.push(decoder.decode(new Uint8Array(memory.buffer, ptr, len))); } return ids; @@ -211,9 +246,14 @@ export async function createTermNative( getElementBounds(id: string): BoundingBox | 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) { + return undefined; + } new Uint8Array(memory.buffer).set(bytes, opsBuf); - let out = opsBuf + 256; - let found = ct.get_element_bounds(opsBuf, bytes.length, out); + let found = ct.get_element_bounds(statePtr!, opsBuf, bytes.length, out); if (!found) { return undefined; } diff --git a/term.ts b/term.ts index 5d3ffd4..5d1b3a9 100644 --- a/term.ts +++ b/term.ts @@ -202,7 +202,7 @@ export async function createTerm(options: TermOptions): Promise { ops, memory.buffer, native.opsBuf, - memory.buffer.byteLength, + native.opsBuf + native.opsCapacity, ); let mode = options?.mode === "line" ? 1 : 0; let row = options?.row ?? 1; @@ -307,9 +307,9 @@ export async function createTerm(options: TermOptions): Promise { throw new RangeError(`invalid terminal dimensions ${w}x${h}`); } if (w !== width || h !== height) { + native.update(w, h); width = w; height = h; - native.update(width, height); prev = new Set(); pressed = new Set(); wasDown = false; diff --git a/test/clip.test.ts b/test/clip.test.ts index 648a343..eed621f 100644 --- a/test/clip.test.ts +++ b/test/clip.test.ts @@ -134,7 +134,7 @@ describe("clip", () => { } let unnest = nest.map(() => close()); - let result = term.render([ + let frame = [ open("root", { layout: { width: grow(), height: grow(), direction: "ltr" }, }), @@ -161,14 +161,31 @@ describe("clip", () => { text("│\n│\n│"), close(), close(), - ]); + ]; - expect(trim(print(decode(result.output), 14, 6)).trim()).toEqual(` + let clean = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("unclipped text"), + close(), + ]; + let ansi = ""; + for (let clipped of [true, false, true, false]) { + let result = term.render(clipped ? frame : clean); + ansi += decode(result.output); + expect(trim(print(ansi, 14, 6)).trim()).toEqual( + clipped + ? ` ┌─┐┌────│ │ ││clip│ └─┘└────│ -`.trim()); - - expect(result.errors.map((e) => e.type)).toContain("CLIP_DEPTH_EXCEEDED"); +`.trim() + : "unclipped text", + ); + expect(result.errors.map((e) => e.type)).toEqual( + clipped ? ["CLIP_DEPTH_EXCEEDED"] : [], + ); + } }); }); diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts index 4b07256..45b69bd 100644 --- a/test/pointer-shape.test.ts +++ b/test/pointer-shape.test.ts @@ -127,11 +127,21 @@ describe("pointer shape", () => { }); it("emits only when the resolved shape changes", () => { - expect(sequences(term.render(layout(), LINK).output)).toEqual([ - osc22("pointer"), - ]); - expect(sequences(term.render(layout(), LINK).output)).toEqual([]); - expect(sequences(term.render(layout(), over(3, 5)).output)).toEqual([]); + for (let i = 0; i < 3; i++) { + expect(sequences(term.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), over(3, 5)).output)).toEqual([]); + expect(sequences(term.render(layout(), FIELD).output)).toEqual([ + osc22("text"), + ]); + expect(sequences(term.render(layout(), FIELD).output)).toEqual([]); + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([ + osc22("default"), + ]); + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([]); + } }); it("emits in line mode", () => { diff --git a/test/term.test.ts b/test/term.test.ts index 13c1a0c..46ca014 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -200,6 +200,161 @@ describe("term", () => { expect(result.info.get("nonexistent")).toBeUndefined(); expect(result.info.get("")).toBeUndefined(); }); + + it("finds only rendered elements when ids disappear and return after empty frames", async () => { + let term = await createTerm({ width: 8, height: 1 }); + // These distinct Clay string hashes (308121343 and 573026047) share + // bucket 3839 in the 8192-bucket element index, exercising colliding IDs. + let a = "item-20"; + let b = "item-73"; + let cases: [string[], string][] = [ + [[a, b], "A B"], + [[b, a], "B A"], + [[a], "A"], + [[], ""], + [[b], "B"], + [[], ""], + [[a, b], "A B"], + [[b, a], "B A"], + ]; + + for (let [ids, expected] of cases) { + let ops = ids.length + ? [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + ...ids.flatMap((id) => [ + open(id, { layout: { width: fixed(3), height: fixed(1) } }), + text(id === a ? "A" : "B"), + close(), + ]), + close(), + ] + : []; + let result = term.render(ops, { mode: "line" }); + expect(trim(print(decode(result.output), 8, 1))).toEqual(expected); + for (let id of [a, b]) { + let position = ids.indexOf(id); + if (position === -1) { + expect(result.info.get(id)).toBeUndefined(); + } else { + expect(result.info.get(id)?.bounds).toEqual({ + x: position * 3, + y: 0, + width: 3, + height: 1, + }); + } + } + if (!ids.length) expect(result.info.get("root")).toBeUndefined(); + expect(result.errors).toEqual([]); + } + }); + + it("looks up fresh bounds for reused ids after changing layout and resizing", async () => { + let term = await createTerm({ width: 12, height: 3 }); + let frame = (left: number, top: number) => [ + open("root", { + layout: { + width: grow(), + height: grow(), + padding: { left, top }, + }, + }), + open("child", { layout: { width: grow(), height: grow() } }), + text("X"), + close(), + close(), + ]; + + for (let i = 0; i < 4; i++) { + let shifted = i % 2 === 1; + let result = term.render(shifted ? frame(3, 1) : frame(1, 0), { + mode: "line", + }); + expect(trim(print(decode(result.output), 12, 3))).toEqual( + shifted ? "\n X\n" : " X\n\n", + ); + expect(result.info.get("missing-id:".repeat(80))).toBeUndefined(); + expect(result.info.get("child")?.bounds).toEqual( + shifted + ? { x: 3, y: 1, width: 9, height: 2 } + : { x: 1, y: 0, width: 11, height: 3 }, + ); + } + + term.update([{ type: "resize", width: 20, height: 4 }]); + let result = term.render(frame(2, 1), { mode: "line" }); + expect(trim(print(decode(result.output), 20, 4))).toEqual("\n X\n\n"); + expect(result.info.get("missing-id:".repeat(80))).toBeUndefined(); + expect(result.info.get("child")?.bounds).toEqual({ + x: 2, + y: 1, + width: 18, + height: 3, + }); + expect(result.info.get("root")?.bounds).toEqual({ + x: 0, + y: 0, + width: 20, + height: 4, + }); + }); + + it("keeps metadata and pointer ids intact across lookups and changing frames", async () => { + let term = await createTerm({ width: 8, height: 1 }); + let previousId: string | undefined; + let firstEvents; + for (let i = 0; i < 4; i++) { + let id = `item:α:${i}`; + let result = term.render([ + open("root:metadata", { + layout: { width: grow(), height: grow() }, + }), + open(id, { layout: { width: fixed(4), height: fixed(1) } }), + text(`row${i}`), + close(), + close(), + ], { pointer: { x: 1, y: 0, down: false }, mode: "line" }); + + expect(trim(print(decode(result.output), 8, 1))).toEqual(`row${i}`); + expect(result.info.get("missing-id:".repeat(80))).toBeUndefined(); + expect(result.info.get("root:metadata")?.bounds).toEqual({ + x: 0, + y: 0, + width: 8, + height: 1, + }); + expect(result.info.get(id)?.bounds).toEqual({ + x: 0, + y: 0, + width: 4, + height: 1, + }); + expect(result.info.get("")).toBeUndefined(); + expect(result.info.get(id)?.bounds.width).toBe(4); + expect(result.events).toContainEqual({ type: "pointerenter", id }); + if (previousId) { + expect(result.info.get(previousId)).toBeUndefined(); + expect(result.events).toContainEqual({ + type: "pointerleave", + id: previousId, + }); + } else { + firstEvents = result.events; + } + previousId = id; + } + expect(firstEvents).toContainEqual({ + type: "pointerenter", + id: "root:metadata", + }); + expect(firstEvents).toContainEqual({ + type: "pointerenter", + id: "item:α:0", + }); + }); }); it("renders a floating frame with structured attach points", () => { @@ -680,6 +835,100 @@ hi // otherwise the caret marker would linger at frame 1's cell. expect(trim(print(frame1 + frame2, 3, 1))).toEqual("Hi"); }); + + it("tracks caret movement and hiding across repeated frames", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let ansi = ""; + for ( + let [caret, expected] of [ + [0, "A̲BC"], + [0, "A̲BC"], + [1, "AB̲C"], + [undefined, "ABC"], + [undefined, "ABC"], + [2, "ABC̲"], + [undefined, "ABC"], + ] as const + ) { + ansi += decode( + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + text("ABC", { caret }), + close(), + ]).output, + ); + expect(trim(print(ansi, 4, 1))).toEqual(expected); + } + }); + + it("resolves caret tails afresh when text and its directive position change", async () => { + let term = await createTerm({ width: 6, height: 3 }); + let frames = [ + { + content: [text("Hi", { caret: 2 })], + expected: "Hi ̲\n\n", + }, + { + content: [text("none")], + expected: "none\n\n", + }, + { + content: [text("prefix"), text("abc def", { caret: 7 })], + expected: "prefix\nabc\ndef ̲", + }, + { + content: [text("z", { caret: 1 })], + expected: "z ̲\n\n", + }, + { + content: [text("", { caret: 0 })], + expected: " ̲\n\n", + }, + ]; + let ansi = ""; + for (let { content, expected } of [...frames, ...frames]) { + ansi += decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + ...content, + close(), + ]).output, + ); + expect(trim(print(ansi, 6, 3))).toEqual(expected); + } + }); + + it("hides a visible caret after resize when the next frame has no caret", async () => { + let term = await createTerm({ width: 3, height: 1 }); + let frame = (caret?: number) => [ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi", { caret }), + close(), + ]; + let ansi = decode(term.render(frame(1)).output); + term.update([{ type: "resize", width: 5, height: 1 }]); + ansi += decode(term.render(frame()).output); + expect(trim(print(ansi, 5, 1))).toEqual("Hi"); + expect(term.render(frame()).output.length).toBe(0); + }); + + it("hides a visible caret after an intervening line-mode frame", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let frame = (caret?: number) => [ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi", { caret }), + close(), + ]; + let ansi = decode(term.render(frame(1)).output); + // The host positions line-mode output at the beginning of its region. + ansi += "\x1b[1;1H" + + decode(term.render(frame(), { mode: "line" }).output); + ansi += decode(term.render(frame()).output); + expect(trim(print(ansi, 4, 1))).toEqual("Hi"); + expect(term.render(frame()).output.length).toBe(0); + }); }); describe("row offset", () => { diff --git a/test/width.test.ts b/test/width.test.ts index 94a2083..99223bf 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "./suite.ts"; import { createTerm } from "../term.ts"; -import { close, grow, open, pack, text } from "../ops.ts"; +import { close, fixed, grow, open, pack, text } from "../ops.ts"; import { createTermNative } from "../term-native.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); +const trim = (s: string) => + s.split("\n").map((line) => line.trimEnd()).join("\n"); function indexOfSeq(haystack: Uint8Array, needle: number[]): number { outer: for (let i = 0; i + needle.length <= haystack.length; i++) { @@ -17,6 +19,85 @@ function indexOfSeq(haystack: Uint8Array, needle: number[]): number { } describe("width", () => { + let frame = (content: string) => [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text(content), + close(), + ]; + + it("cleans up wide cells after unchanged frames and repeated narrow replacements", async () => { + let term = await createTerm({ width: 6, height: 1 }); + let ansi = ""; + for (let i = 0; i < 3; i++) { + ansi += decode(term.render(frame("中中")).output); + let unchanged = term.render(frame("中中")); + expect(unchanged.output.length).toBe(0); + ansi += decode(term.render(frame("ab")).output); + expect(trim(print(ansi, 6, 1))).toEqual("ab"); + expect(term.render(frame("ab")).output.length).toBe(0); + } + }); + + it("uses a wide line-mode frame as the baseline for CUP diffs", async () => { + let term = await createTerm({ width: 6, height: 1 }); + let ansi = decode(term.render(frame("中中"), { mode: "line" }).output); + expect(term.render(frame("中中")).output.length).toBe(0); + ansi += decode(term.render(frame("abcd")).output); + expect(trim(print(ansi, 6, 1))).toEqual("abcd"); + + ansi += decode(term.render(frame("a")).output); + expect(trim(print(ansi, 6, 1))).toEqual("a"); + expect(term.render(frame("a")).output.length).toBe(0); + }); + + it("blanks a wide glyph at the terminal edge and accepts its narrow replacement", async () => { + for (let mode of [undefined, "line"] as const) { + let term = await createTerm({ width: 3, height: 1 }); + let edge = (content: string) => [ + open("root", { + layout: { width: fixed(8), height: fixed(1) }, + }), + text(content), + close(), + ]; + let ansi = decode(term.render(edge("ab中"), { mode }).output); + expect(trim(print(ansi, 3, 1))).toEqual("ab"); + expect(term.render(edge("ab中")).output.length).toBe(0); + ansi += decode(term.render(edge("abc")).output); + expect(trim(print(ansi, 3, 1))).toEqual("abc"); + } + }); + + it("keeps a wide glyph outside a clip from overwriting its sibling", async () => { + let term = await createTerm({ width: 6, height: 1 }); + let clipped = (content: string) => [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("clip", { + layout: { width: fixed(2), height: fixed(1) }, + clip: { horizontal: true, vertical: true }, + }), + open("content", { + layout: { width: fixed(8), height: fixed(1) }, + }), + text(content), + close(), + close(), + open("sibling", { layout: { width: fixed(1), height: fixed(1) } }), + text("!"), + close(), + close(), + ]; + let ansi = decode(term.render(clipped("ab中")).output); + expect(trim(print(ansi, 6, 1))).toEqual("ab!"); + expect(term.render(clipped("ab中")).output.length).toBe(0); + ansi += decode(term.render(clipped("x")).output); + expect(trim(print(ansi, 6, 1))).toEqual("x !"); + }); + it("measures non-printable codepoints as one replacement cell", async () => { let term = await createTerm({ width: 20, height: 4 });