From 8d0f379c6899303fd355211c239e5beb6b1d0680 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 11:49:40 +0300 Subject: [PATCH 01/19] spec(renderer): add hardware cursor visibility and positioning --- specs/renderer-spec.md | 61 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 398e78a..e28253c 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -334,6 +334,66 @@ nests clip regions more deeply than the renderer can track: (see §12.3) before returning, so the caller can detect that some clipping was not applied. +### 7.6 Hardware cursor visibility and positioning + +A `text()` directive MAY declare a `caret` property whose value is a +non-negative integer code-point offset into the directive's `content`. +`0` means "before the first code point"; `[...content].length` means +"after the last." Offsets in between sit immediately before the cell +where the corresponding code point would render. + +The cell where a declared caret sits is the cell at which the code +point at the caret's offset would be drawn given the layout engine's +text wrapping. For an offset `N`: + +- If `N < [...content].length`, the caret's cell is the display + position of the `N`-th code point (zero-indexed) within the rendered + text. +- If `N == [...content].length`, the caret's cell is one display + position past the last rendered code point: on the same wrapped line + if there is room, or at the start of the next line if the layout + wraps at the end. +- If `N > [...content].length` or `N < 0`, behavior is unspecified; + callers must keep offsets within bounds. + +Display position accounts for code points wider than one cell (CJK, +fullwidth forms, some emoji): such code points occupy two cells, and +the caret sits at the first cell of the pair. Cell widths are +determined by the same measurement the renderer uses to lay out the +text itself. + +The renderer manages the terminal's hardware cursor based on the +presence of `caret:` declarations: + +- When the current frame contains one or more `caret:` declarations, + the rendered `output` MUST, when written to the terminal, leave the + hardware cursor visible at the cell where the first declared caret + (in directive order) sits. + +- When the current frame contains no `caret:` declarations: + - If the previous frame contained one, the rendered `output` MUST + leave the hardware cursor hidden. + - Otherwise, the rendered `output` MUST NOT include + cursor-positioning or cursor-visibility bytes; the caller's + cursor state is preserved. + +The byte-level path the renderer takes to satisfy these outcomes is +implementation-defined. The renderer MAY hide the cursor before cell +writes to prevent flicker, MAY omit such a hide for in-place edits +where flicker is acceptable, MAY emit only a CUP when the caret has +moved within an already-visible state, MAY use whatever escape-sequence +path it judges appropriate. Only the post-frame cursor state above is +normative. + +If more than one `caret:` declaration is present in a frame, the +renderer SHOULD use the first in directive order for the hardware +cursor. Behavior for additional declarations is intentionally +unspecified, leaving room for a future multi-cursor extension without +breaking this contract. + +This responsibility is limited to the hardware cursor's position and +visibility. Cursor shape and blink rate remain caller-managed. + --- ## 8. Public Rendering API @@ -604,7 +664,6 @@ The renderer MUST NOT emit escape sequences for any of the following terminal-management operations: - Entering or leaving the alternate screen buffer -- Hiding or showing the cursor - Setting the cursor shape or blink state - Enabling or disabling mouse reporting - Enabling or disabling keyboard protocol modes (e.g., Kitty progressive From 05a07ab96b853c40cfb97fab7b08b7eecfe9d7a1 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 11:57:02 +0300 Subject: [PATCH 02/19] =?UTF-8?q?=F0=9F=93=9D=20spec(renderer):=20note=20?= =?UTF-8?q?=C2=A77.6=20carve-out=20in=20=C2=A711.2=20output=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/renderer-spec.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index e28253c..f6bd485 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -672,7 +672,9 @@ terminal-management operations: These are the caller's responsibility. The renderer's output contains only the escape sequences needed to render the frame content (cursor positioning for cell -writes, SGR attributes for styling, and UTF-8 text). +writes, SGR attributes for styling, and UTF-8 text) and, when a `caret` +declaration is present, the cursor-positioning and cursor-visibility sequences +specified in §7.6. ### 11.3 The renderer does not own application lifecycle From 7d5ad19611e62e5c9546ee2ef9500a10a3a5eb1e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 12:08:53 +0300 Subject: [PATCH 03/19] feat(term): emit hardware cursor at text() caret offset --- ops.ts | 7 ++- src/clayterm.c | 109 ++++++++++++++++++++++++++++++++++++++++++++++ test/term.test.ts | 15 +++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/ops.ts b/ops.ts index 3fd1d04..e89b291 100644 --- a/ops.ts +++ b/ops.ts @@ -315,6 +315,10 @@ export function pack( ); o += 4; + // Caret offset (code points), or 0xFFFFFFFF when absent. + view.setUint32(o, op.caret ?? 0xFFFFFFFF, true); + o += 4; + let str = encoder.encode(op.content); o = packString(view, str, o, end, "text content"); break; @@ -481,6 +485,7 @@ export interface Text { fontId?: number; wrap?: number; attrs?: number; + caret?: number; } interface Snapshot { @@ -533,7 +538,7 @@ function packSize(ops: Op[]): number { break; } case OP_TEXT: { - n += 4 + 4 + 4 + 4; // opcode + color + bg + cfg + n += 4 + 4 + 4 + 4 + 4; // opcode + color + bg + cfg + caret n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string break; } diff --git a/src/clayterm.c b/src/clayterm.c index e48091e..9a8fa6a 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -84,6 +84,16 @@ struct Clayterm { Clay_ErrorData errors[MAX_ERRORS]; int error_count; int animating_count; + /* Caret state for hardware-cursor management. The renderer records the + * first text node carrying a caret declaration per frame, then locates + * the corresponding cell after Clay layout. 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; /* code-point offset within that text node */ + int caret_x, caret_y; /* resolved cell (column, row), valid only when caret_text_chars != NULL */ + int has_caret; /* 1 when the current frame placed a caret */ + int had_caret_last_frame; /* 1 when the previous frame placed a caret */ }; /* Memory layout inside the arena provided by the host: @@ -234,6 +244,19 @@ static void present_cups(struct Clayterm *ct, int row) { 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"); + } + ct->had_caret_last_frame = ct->has_caret; } /** @@ -299,6 +322,72 @@ static void render_rect(struct Clayterm *ct, int x0, int y0, int x1, int y1, setcell(ct, x, y, ' ', ATTR_DEFAULT, bg); } +/** + * Locate the cell where the caret should be rendered given the per-line + * text commands produced by Clay's wrap pass. Iterates render commands + * in order, accumulating code points consumed across slices that belong + * to the caret text node, until the caret's code-point offset is reached. + */ +static void locate_caret(struct Clayterm *ct, Clay_RenderCommandArray *cmds) { + if (ct->caret_text_chars == NULL) { + return; + } + const char *node_start = ct->caret_text_chars; + const char *node_end = node_start + ct->caret_text_length; + uint32_t target = ct->caret_offset; + uint32_t accumulated = 0; + + for (int32_t j = 0; j < cmds->length; j++) { + Clay_RenderCommand *cmd = Clay_RenderCommandArray_Get(cmds, j); + if (cmd->commandType != CLAY_RENDER_COMMAND_TYPE_TEXT) { + continue; + } + Clay_TextRenderData *t = &cmd->renderData.text; + const char *slice = t->stringContents.chars; + int slice_len = t->stringContents.length; + if (slice < node_start || slice >= node_end) { + continue; + } + /* count code points in this slice */ + uint32_t slice_cps = 0; + int x_cells = 0; + const char *p = slice; + int rem = slice_len; + while (rem > 0) { + uint32_t cp; + int n = utf8_decode(&cp, p); + if (n <= 0) { + n = 1; + cp = 0xfffd; + } + if (accumulated + slice_cps == target) { + ct->caret_x = (int)cmd->boundingBox.x + x_cells; + ct->caret_y = (int)cmd->boundingBox.y; + return; + } + int cw = wcwidth(cp); + if (cw < 0) { + cw = 1; + } + x_cells += cw; + slice_cps++; + p += n; + rem -= n; + } + if (accumulated + slice_cps == target) { + /* caret sits just after this slice's last code point */ + ct->caret_x = (int)cmd->boundingBox.x + x_cells; + ct->caret_y = (int)cmd->boundingBox.y; + return; + } + accumulated += slice_cps; + } + /* offset out of range: behavior is unspecified by the spec; leave + * caret_x/caret_y at their sentinel -1 values and let the emission + * step suppress visibility. */ + ct->has_caret = 0; +} + static void render_text(struct Clayterm *ct, int x0, int y0, Clay_RenderCommand *cmd) { @@ -553,6 +642,12 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, 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 = 0; + ct->caret_x = -1; + ct->caret_y = -1; + ct->has_caret = 0; Clay_BeginLayout(); @@ -673,11 +768,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 str_len = rd(buf, len, &i); int str_words = (str_len + 3) / 4; char *str_chars = (char *)&buf[i]; i += str_words; + /* Record the FIRST caret declaration per frame for the + * single-hardware-cursor contract; later declarations are + * intentionally ignored (multi-cursor is unspecified). */ + if (caret != 0xFFFFFFFF && ct->caret_text_chars == NULL) { + ct->caret_text_chars = str_chars; + ct->caret_text_length = (int)str_len; + ct->caret_offset = caret; + ct->has_caret = 1; + } + Clay_String text = {.length = (int32_t)str_len, .chars = str_chars}; Clay_TextElementConfig config = {0}; @@ -704,6 +810,9 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); + /* resolve caret cell from this frame's text commands */ + locate_caret(ct, &cmds); + /* reset output state */ ct->out.length = 0; ct->lastfg = ct->lastbg = 0xffffffff; diff --git a/test/term.test.ts b/test/term.test.ts index 121ece2..17bf6e1 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -337,6 +337,21 @@ describe("term", () => { }); }); + it("emits CUP and DECTCEM-show when a caret is declared", () => { + let ansi = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hello", { caret: 2 }), + close(), + ]).output, + ); + // Caret at code-point offset 2 → cell after "He" → terminal column 3, row 1. + // CUP: ESC [ row ; col H. DECTCEM-show: ESC [ ? 25 h. + expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 }); From 0a4898aa232a250b2ad060aedfb47f5a79f8c5de Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 12:29:25 +0300 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=A7=AA=20test(term):=20cursor=20hid?= =?UTF-8?q?es=20on=20caret-present=20=E2=86=92=20caret-absent=20transition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/term.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/term.test.ts b/test/term.test.ts index 17bf6e1..b81a000 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -352,6 +352,28 @@ describe("term", () => { expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); }); + it("hides the cursor when transitioning from caret-present to caret-absent", () => { + // First frame: caret declared. + term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hi", { caret: 1 }), + close(), + ]); + // Second frame: no caret. Output must include DECTCEM-hide. + let ansi = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hi"), + close(), + ]).output, + ); + expect(ansi).toContain("\x1b[?25l"); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 }); From e36b91258e4b5be3b5dd8250be79875fb8ca641d Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 12:30:05 +0300 Subject: [PATCH 05/19] =?UTF-8?q?=F0=9F=A7=AA=20test(term):=20no=20cursor?= =?UTF-8?q?=20bytes=20when=20no=20caret=20ever=20declared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/term.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/term.test.ts b/test/term.test.ts index b81a000..11d2c20 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -352,6 +352,20 @@ describe("term", () => { expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); }); + it("emits no cursor bytes when no caret has ever been declared", () => { + let ansi = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hi"), + close(), + ]).output, + ); + expect(ansi).not.toContain("\x1b[?25h"); + expect(ansi).not.toContain("\x1b[?25l"); + }); + it("hides the cursor when transitioning from caret-present to caret-absent", () => { // First frame: caret declared. term.render([ From aef64d98a2efa4b46da1170766839598e97eabe3 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 12:34:23 +0300 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=A7=AA=20test(term):=20caret=20at?= =?UTF-8?q?=20end-of-content=20positions=20one=20cell=20past=20last=20char?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/term.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/term.test.ts b/test/term.test.ts index 11d2c20..60c3d24 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -352,6 +352,20 @@ describe("term", () => { expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); }); + it("places the caret one cell past the last character when offset == length", () => { + let ansi = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("Hi", { caret: 2 }), + close(), + ]).output, + ); + // After "Hi": column 3, row 1. + expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); + }); + it("emits no cursor bytes when no caret has ever been declared", () => { let ansi = decode( term.render([ From 322166753a91e212244a1433e9300efc923128b4 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 12:34:56 +0300 Subject: [PATCH 07/19] =?UTF-8?q?=F0=9F=A7=AA=20test(term):=20caret=20land?= =?UTF-8?q?s=20on=20correct=20line=20after=20text=20wrapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/term.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/term.test.ts b/test/term.test.ts index 60c3d24..dce398c 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -352,6 +352,28 @@ describe("term", () => { expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); }); + it("places the caret on the correct wrapped line", async () => { + let narrow = await createTerm({ width: 5, height: 4 }); + let ansi = decode( + narrow.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("hello world", { caret: 7 }), + close(), + ]).output, + ); + // Caret at code-point 7 of "hello world" → after "hello w" → between + // "w" and "o" on the second wrapped line. Exact column depends on Clay's + // wrap point: assert row 2 and column at least 2. + let cupMatch = ansi.match(/\x1b\[(\d+);(\d+)H\x1b\[\?25h$/); + expect(cupMatch).not.toBeNull(); + let row = parseInt(cupMatch![1], 10); + let col = parseInt(cupMatch![2], 10); + expect(row).toBe(2); + expect(col).toBeGreaterThanOrEqual(2); + }); + it("places the caret one cell past the last character when offset == length", () => { let ansi = decode( term.render([ From 63085133886603cd00c401b4a73b777ddc9b8a9c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 12:35:21 +0300 Subject: [PATCH 08/19] =?UTF-8?q?=F0=9F=A7=AA=20test(term):=20caret=20acco?= =?UTF-8?q?unts=20for=20wide-character=20cell=20widths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/term.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/term.test.ts b/test/term.test.ts index dce398c..f00a76c 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -352,6 +352,21 @@ describe("term", () => { expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); }); + it("accounts for wide characters when positioning the caret", () => { + let ansi = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + // 中 is a wide character (2 cells). Caret at offset 1 means + // "after 中", i.e. column 3 (terminal cols are 1-based). + text("中hi", { caret: 1 }), + close(), + ]).output, + ); + expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); + }); + it("places the caret on the correct wrapped line", async () => { let narrow = await createTerm({ width: 5, height: 4 }); let ansi = decode( From b0597fcc62718007d7c1d761b007e896e29498ae Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 12:35:37 +0300 Subject: [PATCH 09/19] =?UTF-8?q?=F0=9F=A7=AA=20test(term):=20first=20care?= =?UTF-8?q?t=20declaration=20wins=20when=20multiple=20present?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/term.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/term.test.ts b/test/term.test.ts index f00a76c..31b4ded 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -352,6 +352,21 @@ describe("term", () => { expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); }); + it("uses the first caret declaration when multiple are present", () => { + let ansi = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("AA", { caret: 1 }), + text("BB", { caret: 2 }), + close(), + ]).output, + ); + // First caret: offset 1 of "AA" → column 2, row 1. + expect(ansi).toMatch(/\x1b\[1;2H\x1b\[\?25h$/); + }); + it("accounts for wide characters when positioning the caret", () => { let ansi = decode( term.render([ From 00d0286bef43d1ace8e6233aa79b686c3bbe5b02 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 16:15:52 +0300 Subject: [PATCH 10/19] =?UTF-8?q?=E2=9C=A8=20example(term):=20interactive?= =?UTF-8?q?=20text=20input=20demonstrating=20caret?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/text-input/index.ts | 192 +++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 examples/text-input/index.ts diff --git a/examples/text-input/index.ts b/examples/text-input/index.ts new file mode 100644 index 0000000..8db5ca2 --- /dev/null +++ b/examples/text-input/index.ts @@ -0,0 +1,192 @@ +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { each, ensure, main, until } from "effection"; +import { + close, + createTerm, + fixed, + grow, + type KeyEvent, + type Op, + open, + rgba, + text, +} from "../../mod.ts"; +import { + alternateBuffer, + progressiveInput, + settings, +} from "../../settings.ts"; +import { useInput } from "../use-input.ts"; +import { useStdin } from "../use-stdin.ts"; + +const bg = rgba(20, 20, 30); +const inputBg = rgba(35, 35, 50); +const border = rgba(80, 100, 160); +const label = rgba(180, 180, 200); +const hint = rgba(80, 80, 100); + +await main(function* () { + let { columns, rows } = terminalSize(); + + setRawMode(true); + + let stdin = yield* useStdin(); + let input = useInput(stdin); + + let term = yield* until(createTerm({ width: columns, height: rows })); + + let tty = settings(alternateBuffer(), progressiveInput(1)); + writeStdout(tty.apply); + + let value = ""; + let caret = 0; + + yield* ensure(() => { + setRawMode(false); + writeStdout(tty.revert); + }); + + let { output } = term.render(frame(value, caret)); + writeStdout(output); + + for (let event of yield* each(input)) { + if (event.type === "keydown") { + let key = event as KeyEvent; + + if (key.ctrl && key.key === "c") { + break; + } + + if (key.key === "Escape") { + break; + } + + if (key.key === "ArrowLeft") { + if (caret > 0) { + caret--; + } + } else if (key.key === "ArrowRight") { + if (caret < [...value].length) { + caret++; + } + } else if (key.key === "Backspace") { + if (caret > 0) { + let chars = [...value]; + chars.splice(caret - 1, 1); + value = chars.join(""); + caret--; + } + } else if ( + key.key.length === 1 && + !key.ctrl && + !key.alt + ) { + let chars = [...value]; + chars.splice(caret, 0, key.key); + value = chars.join(""); + caret++; + } + + ({ output } = term.render(frame(value, caret))); + writeStdout(output); + } + + yield* each.next(); + } +}); + +function frame(value: string, caret: number): Op[] { + let ops: Op[] = []; + + ops.push( + open("root", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + alignX: "center", + alignY: "center", + padding: { left: 4, right: 4, top: 2, bottom: 2 }, + }, + bg, + }), + ); + + // Input row: "Name:" label + input box + ops.push( + open("input-row", { + layout: { + direction: "ltr", + gap: 2, + height: fixed(3), + alignY: "center", + }, + }), + ); + + ops.push( + open("label", { + layout: { + width: fixed(6), + height: fixed(1), + alignX: "right", + alignY: "center", + }, + }), + text("Name:", { color: label }), + close(), + ); + + ops.push( + open("input-box", { + layout: { + width: fixed(40), + height: fixed(1), + padding: { left: 1, right: 1 }, + alignY: "center", + }, + bg: inputBg, + border: { color: border, left: 1, right: 1, top: 1, bottom: 1 }, + }), + text(value, { color: label, caret }), + close(), + ); + + ops.push(close()); // input-row + + // Hint line + ops.push( + open("hint", { + layout: { + height: fixed(1), + padding: { top: 1 }, + }, + }), + text("← → move Backspace delete Esc or Ctrl+C exit", { color: hint }), + close(), + ); + + ops.push(close()); // root + + return ops; +} + +function terminalSize(): { columns: number; rows: number } { + return process.stdout.isTTY + ? { + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + } + : { columns: 80, rows: 24 }; +} + +function setRawMode(enabled: boolean): void { + if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(enabled); + } +} + +function writeStdout(bytes: Uint8Array): void { + process.stdout.write(Buffer.from(bytes)); +} From 0d3afa9d740abc3735dd010d75af8bbd32063361 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 17:17:37 +0300 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=93=9D=20AGENTS.md:=20reflect=20?= =?UTF-8?q?=C2=A77.6=20cursor=20management=20exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b4a256..e77131a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,8 +30,10 @@ Do not include any agent marketing material (e.g. "Generated with...", - The renderer MUST NOT perform IO. It produces bytes; the caller writes them. -- The renderer MUST NOT manage terminal state (alternate buffer, cursor - visibility, mouse reporting, keyboard protocol modes). +- The renderer MUST NOT manage terminal state (alternate buffer, mouse + reporting, keyboard protocol modes). Cursor visibility and positioning + are renderer-managed only when a `caret` declaration is present on + a `text()` directive (see renderer-spec.md §7.6). - Each frame is a complete snapshot. The renderer carries no UI tree state between frames — only cell buffers for diffing. From a7d47a0584ad2d8e673ae2f2e8796925c05f182e Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 17:18:40 +0300 Subject: [PATCH 12/19] =?UTF-8?q?=F0=9F=A7=AA=20test(term):=20document=20v?= =?UTF-8?q?1=20limitation=20for=20caret=20on=20empty=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/term.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/term.test.ts b/test/term.test.ts index 31b4ded..f72d726 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -432,6 +432,26 @@ describe("term", () => { expect(ansi).not.toContain("\x1b[?25l"); }); + it("shows the cursor at the text origin when content is empty and caret is 0", () => { + // v1 known limitation: Clay does not emit a text render command for an + // empty string, so locate_caret never finds a matching slice and suppresses + // cursor visibility. The proper fix requires looking up the element's + // bounding box separately (e.g. via get_element_bounds) rather than + // scanning text render commands. Until that is implemented, no cursor + // appears when the caret-bearing text node is empty. + let ansi = decode( + term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("", { caret: 0 }), + close(), + ]).output, + ); + expect(ansi).not.toContain("\x1b[?25h"); + expect(ansi).not.toContain("\x1b[?25l"); + }); + it("hides the cursor when transitioning from caret-present to caret-absent", () => { // First frame: caret declared. term.render([ From ddd8e9df255edf160de3daebc7089a4139c10447 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 17:45:35 +0300 Subject: [PATCH 13/19] =?UTF-8?q?=F0=9F=8E=A8=20example(term):=20simplify?= =?UTF-8?q?=20layout=20and=20fall=20back=20to=20space=20for=20empty=20inpu?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/text-input/index.ts | 72 +++++++----------------------------- 1 file changed, 14 insertions(+), 58 deletions(-) diff --git a/examples/text-input/index.ts b/examples/text-input/index.ts index 8db5ca2..ae387bb 100644 --- a/examples/text-input/index.ts +++ b/examples/text-input/index.ts @@ -12,17 +12,12 @@ import { rgba, text, } from "../../mod.ts"; -import { - alternateBuffer, - progressiveInput, - settings, -} from "../../settings.ts"; +import { alternateBuffer, progressiveInput, settings } from "../../settings.ts"; import { useInput } from "../use-input.ts"; import { useStdin } from "../use-stdin.ts"; const bg = rgba(20, 20, 30); const inputBg = rgba(35, 35, 50); -const border = rgba(80, 100, 160); const label = rgba(180, 180, 200); const hint = rgba(80, 80, 100); @@ -97,79 +92,40 @@ await main(function* () { }); function frame(value: string, caret: number): Op[] { - let ops: Op[] = []; - - ops.push( + return [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb", - alignX: "center", - alignY: "center", - padding: { left: 4, right: 4, top: 2, bottom: 2 }, + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + gap: 1, }, bg, }), - ); - - // Input row: "Name:" label + input box - ops.push( - open("input-row", { - layout: { - direction: "ltr", - gap: 2, - height: fixed(3), - alignY: "center", - }, - }), - ); - - ops.push( - open("label", { - layout: { - width: fixed(6), - height: fixed(1), - alignX: "right", - alignY: "center", - }, - }), + open("label", { layout: { height: fixed(1) } }), text("Name:", { color: label }), close(), - ); - - ops.push( open("input-box", { layout: { width: fixed(40), height: fixed(1), padding: { left: 1, right: 1 }, - alignY: "center", }, bg: inputBg, - border: { color: border, left: 1, right: 1, top: 1, bottom: 1 }, }), - text(value, { color: label, caret }), + // A single space stands in for an empty value: Clay does not emit a + // text render command for an empty string, which means the renderer + // cannot resolve the caret's cell when the field is empty. The space + // is invisible against the input background. When the renderer + // gains a fallback for empty-text carets, drop the `|| " "`. + text(value || " ", { color: label, caret }), close(), - ); - - ops.push(close()); // input-row - - // Hint line - ops.push( - open("hint", { - layout: { - height: fixed(1), - padding: { top: 1 }, - }, - }), + open("hint", { layout: { height: fixed(1) } }), text("← → move Backspace delete Esc or Ctrl+C exit", { color: hint }), close(), - ); - - ops.push(close()); // root - - return ops; + close(), + ]; } function terminalSize(): { columns: number; rows: number } { From 9d23833960e61393e32bfacb12d16f2015ee741c Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Tue, 30 Jun 2026 18:16:09 +0300 Subject: [PATCH 14/19] linting & formatting --- AGENTS.md | 6 +-- specs/renderer-spec.md | 98 ++++++++++++++++++++---------------------- src/clayterm.c | 14 +++--- test/term.test.ts | 3 +- 4 files changed, 59 insertions(+), 62 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e77131a..e229098 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,9 @@ Do not include any agent marketing material (e.g. "Generated with...", - The renderer MUST NOT perform IO. It produces bytes; the caller writes them. - The renderer MUST NOT manage terminal state (alternate buffer, mouse - reporting, keyboard protocol modes). Cursor visibility and positioning - are renderer-managed only when a `caret` declaration is present on - a `text()` directive (see renderer-spec.md §7.6). + reporting, keyboard protocol modes). Cursor visibility and positioning are + renderer-managed only when a `caret` declaration is present on a `text()` + directive (see renderer-spec.md §7.6). - Each frame is a complete snapshot. The renderer carries no UI tree state between frames — only cell buffers for diffing. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index f6bd485..27d865c 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -337,62 +337,56 @@ nests clip regions more deeply than the renderer can track: ### 7.6 Hardware cursor visibility and positioning A `text()` directive MAY declare a `caret` property whose value is a -non-negative integer code-point offset into the directive's `content`. -`0` means "before the first code point"; `[...content].length` means -"after the last." Offsets in between sit immediately before the cell -where the corresponding code point would render. - -The cell where a declared caret sits is the cell at which the code -point at the caret's offset would be drawn given the layout engine's -text wrapping. For an offset `N`: - -- If `N < [...content].length`, the caret's cell is the display - position of the `N`-th code point (zero-indexed) within the rendered - text. -- If `N == [...content].length`, the caret's cell is one display - position past the last rendered code point: on the same wrapped line - if there is room, or at the start of the next line if the layout - wraps at the end. -- If `N > [...content].length` or `N < 0`, behavior is unspecified; - callers must keep offsets within bounds. - -Display position accounts for code points wider than one cell (CJK, -fullwidth forms, some emoji): such code points occupy two cells, and -the caret sits at the first cell of the pair. Cell widths are -determined by the same measurement the renderer uses to lay out the -text itself. - -The renderer manages the terminal's hardware cursor based on the -presence of `caret:` declarations: - -- When the current frame contains one or more `caret:` declarations, - the rendered `output` MUST, when written to the terminal, leave the - hardware cursor visible at the cell where the first declared caret - (in directive order) sits. +non-negative integer code-point offset into the directive's `content`. `0` means +"before the first code point"; `[...content].length` means "after the last." +Offsets in between sit immediately before the cell where the corresponding code +point would render. + +The cell where a declared caret sits is the cell at which the code point at the +caret's offset would be drawn given the layout engine's text wrapping. For an +offset `N`: + +- If `N < [...content].length`, the caret's cell is the display position of the + `N`-th code point (zero-indexed) within the rendered text. +- If `N == [...content].length`, the caret's cell is one display position past + the last rendered code point: on the same wrapped line if there is room, or at + the start of the next line if the layout wraps at the end. +- If `N > [...content].length` or `N < 0`, behavior is unspecified; callers must + keep offsets within bounds. + +Display position accounts for code points wider than one cell (CJK, fullwidth +forms, some emoji): such code points occupy two cells, and the caret sits at the +first cell of the pair. Cell widths are determined by the same measurement the +renderer uses to lay out the text itself. + +The renderer manages the terminal's hardware cursor based on the presence of +`caret:` declarations: + +- When the current frame contains one or more `caret:` declarations, the + rendered `output` MUST, when written to the terminal, leave the hardware + cursor visible at the cell where the first declared caret (in directive order) + sits. - When the current frame contains no `caret:` declarations: - - If the previous frame contained one, the rendered `output` MUST - leave the hardware cursor hidden. - - Otherwise, the rendered `output` MUST NOT include - cursor-positioning or cursor-visibility bytes; the caller's - cursor state is preserved. + - If the previous frame contained one, the rendered `output` MUST leave the + hardware cursor hidden. + - Otherwise, the rendered `output` MUST NOT include cursor-positioning or + cursor-visibility bytes; the caller's cursor state is preserved. The byte-level path the renderer takes to satisfy these outcomes is -implementation-defined. The renderer MAY hide the cursor before cell -writes to prevent flicker, MAY omit such a hide for in-place edits -where flicker is acceptable, MAY emit only a CUP when the caret has -moved within an already-visible state, MAY use whatever escape-sequence -path it judges appropriate. Only the post-frame cursor state above is -normative. - -If more than one `caret:` declaration is present in a frame, the -renderer SHOULD use the first in directive order for the hardware -cursor. Behavior for additional declarations is intentionally -unspecified, leaving room for a future multi-cursor extension without -breaking this contract. - -This responsibility is limited to the hardware cursor's position and -visibility. Cursor shape and blink rate remain caller-managed. +implementation-defined. The renderer MAY hide the cursor before cell writes to +prevent flicker, MAY omit such a hide for in-place edits where flicker is +acceptable, MAY emit only a CUP when the caret has moved within an +already-visible state, MAY use whatever escape-sequence path it judges +appropriate. Only the post-frame cursor state above is normative. + +If more than one `caret:` declaration is present in a frame, the renderer SHOULD +use the first in directive order for the hardware cursor. Behavior for +additional declarations is intentionally unspecified, leaving room for a future +multi-cursor extension without breaking this contract. + +This responsibility is limited to the hardware cursor's position and visibility. +Cursor shape and blink rate remain caller-managed. --- diff --git a/src/clayterm.c b/src/clayterm.c index 9a8fa6a..af092a9 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -88,12 +88,14 @@ struct Clayterm { * first text node carrying a caret declaration per frame, then locates * the corresponding cell after Clay layout. 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; /* code-point offset within that text node */ - int caret_x, caret_y; /* resolved cell (column, row), valid only when caret_text_chars != NULL */ - int has_caret; /* 1 when the current frame placed a caret */ - int had_caret_last_frame; /* 1 when the previous frame placed a caret */ + 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; /* code-point offset within that text node */ + int caret_x, caret_y; /* resolved cell (column, row), valid only when + caret_text_chars != NULL */ + int has_caret; /* 1 when the current frame placed a caret */ + int had_caret_last_frame; /* 1 when the previous frame placed a caret */ }; /* Memory layout inside the arena provided by the host: diff --git a/test/term.test.ts b/test/term.test.ts index f72d726..dfafd35 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file no-control-regex import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; import { @@ -130,7 +131,7 @@ describe("term", () => { let out = decode( term.render(box("hello world"), { mode: "line" }).output, ); - // deno-lint-ignore no-control-regex + expect(out).not.toMatch(/\x1b\[\d+;\d+H/); expect(out.split("\n").length).toBe(5); expect(trim(print(out, 20, 5))).toEqual(` From 895565d44f7213b1b57acc343e2376fa83b08a0d Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 8 Jul 2026 14:16:37 +0300 Subject: [PATCH 15/19] =?UTF-8?q?=F0=9F=93=9D=20spec(renderer):=20specify?= =?UTF-8?q?=20caret=20behavior=20on=20empty=20text=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a normative paragraph to §7.6 stating that a `caret` declaration on empty content places the cursor at the text element's origin — the cell where the first code point of `content` would be drawn if it were a single space. Frames the empty-content case as a rendering commitment the renderer must honor, without prescribing mechanism. --- specs/renderer-spec.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 27d865c..1aea6c7 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -354,6 +354,13 @@ offset `N`: - If `N > [...content].length` or `N < 0`, behavior is unspecified; callers must keep offsets within bounds. +When `content` is empty, the only in-range offset is `0`. In that case the +caret's cell is the text element's origin — the cell at which the first code +point of `content` would be drawn if `content` were a single space. This is a +rendering commitment implied by the presence of a `caret` declaration, and the +renderer must synthesize whatever minimal geometry is required to honor it. How +this outcome is achieved is implementation-defined. + Display position accounts for code points wider than one cell (CJK, fullwidth forms, some emoji): such code points occupy two cells, and the caret sits at the first cell of the pair. Cell widths are determined by the same measurement the From 8d47badc5efed3fd8533489d95056a2a9d1f81da Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 8 Jul 2026 14:16:43 +0300 Subject: [PATCH 16/19] =?UTF-8?q?=E2=9C=A8=20feat(term):=20resolve=20caret?= =?UTF-8?q?=20cell=20on=20empty=20text=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substitute a single-space slice for empty text nodes carrying a caret declaration so the layout engine emits one cell of geometry at the text element's origin. Replaces the v1-limitation test with one asserting the spec's outcome (CUP to row 1, col 1 + DECTCEM-show). --- src/clayterm.c | 10 ++++++++++ test/term.test.ts | 12 ++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/clayterm.c b/src/clayterm.c index af092a9..7889909 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -776,6 +776,16 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, char *str_chars = (char *)&buf[i]; i += str_words; + /* A caret on empty content is a rendering commitment the layout + * engine cannot satisfy on its own — zero cells give locate_caret + * 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; + } + /* Record the FIRST caret declaration per frame for the * single-hardware-cursor contract; later declarations are * intentionally ignored (multi-cursor is unspecified). */ diff --git a/test/term.test.ts b/test/term.test.ts index dfafd35..db2ceb9 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -434,12 +434,9 @@ describe("term", () => { }); it("shows the cursor at the text origin when content is empty and caret is 0", () => { - // v1 known limitation: Clay does not emit a text render command for an - // empty string, so locate_caret never finds a matching slice and suppresses - // cursor visibility. The proper fix requires looking up the element's - // bounding box separately (e.g. via get_element_bounds) rather than - // scanning text render commands. Until that is implemented, no cursor - // appears when the caret-bearing text node is empty. + // Declaring a `caret` on empty content is a rendering commitment: the + // renderer places the cursor at the cell the caret would occupy if the + // content were a single space. Origin of the text element is (row 1, col 1). let ansi = decode( term.render([ open("root", { @@ -449,8 +446,7 @@ describe("term", () => { close(), ]).output, ); - expect(ansi).not.toContain("\x1b[?25h"); - expect(ansi).not.toContain("\x1b[?25l"); + expect(ansi).toMatch(/\x1b\[1;1H\x1b\[\?25h$/); }); it("hides the cursor when transitioning from caret-present to caret-absent", () => { From 49f5508769c38cd022c015ab99d585eb1c0ec36a Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Wed, 8 Jul 2026 14:16:47 +0300 Subject: [PATCH 17/19] =?UTF-8?q?=F0=9F=94=A5=20example(term):=20drop=20em?= =?UTF-8?q?pty-value=20workaround=20now=20handled=20by=20renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `value || " "` fallback previously worked around the renderer's inability to attach a caret to an empty text node. That case is now handled directly by the renderer, so the example can pass the raw value through and let the caret resolve at the input box's origin. --- examples/text-input/index.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/examples/text-input/index.ts b/examples/text-input/index.ts index ae387bb..e0553fb 100644 --- a/examples/text-input/index.ts +++ b/examples/text-input/index.ts @@ -114,12 +114,7 @@ function frame(value: string, caret: number): Op[] { }, bg: inputBg, }), - // A single space stands in for an empty value: Clay does not emit a - // text render command for an empty string, which means the renderer - // cannot resolve the caret's cell when the field is empty. The space - // is invisible against the input background. When the renderer - // gains a fallback for empty-text carets, drop the `|| " "`. - text(value || " ", { color: label, caret }), + text(value, { color: label, caret }), close(), open("hint", { layout: { height: fixed(1) } }), text("← → move Backspace delete Esc or Ctrl+C exit", { color: hint }), From a9f8c7248d291d9644d72e0da07f0c915f605015 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 9 Jul 2026 19:25:15 +0300 Subject: [PATCH 18/19] =?UTF-8?q?=F0=9F=93=9D=20spec(renderer):=20specify?= =?UTF-8?q?=20caret=20behavior=20at=20wrap=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/renderer-spec.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 1aea6c7..42e262b 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -354,6 +354,14 @@ offset `N`: - If `N > [...content].length` or `N < 0`, behavior is unspecified; callers must keep offsets within bounds. +When the layout engine consumes whitespace at a wrap boundary — dropping it +from the rendered text so that neither wrapped line contains a display cell for +those code points — the caret has no display position of its own for any offset +that falls in that dropped run. In that case the caret's cell is the origin of +the following wrapped line (the display position of the first code point on +that line). This rule keeps the caret on-screen at the point where subsequent +input will land, rather than orphaning it past the end of the previous line. + When `content` is empty, the only in-range offset is `0`. In that case the caret's cell is the text element's origin — the cell at which the first code point of `content` would be drawn if `content` were a single space. This is a From e92697728d2cd4791705aa09f9f2f99c56b7c063 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Thu, 9 Jul 2026 19:38:15 +0300 Subject: [PATCH 19/19] =?UTF-8?q?=F0=9F=90=9B=20fix(term):=20resolve=20car?= =?UTF-8?q?et=20cell=20during=20the=20render=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Precompute the caret's byte offset within its text node at OP_TEXT decode time, then place the caret cell as a side effect of render_text's existing walk: at the top of each iteration, if the current pointer matches the target byte offset, record the cell. Two edge cases fall out of the same mechanism. A slice whose first byte is already past the target (Clay dropped whitespace at the wrap seam) snaps the caret to the slice's origin — the start of the next wrapped line — rather than orphaning it off the end of the previous line. And the trailing cell of the last walked caret slice is remembered as the end-of-content fallback for offset == content-length. Deletes locate_caret, which walked slices with a code-point accumulator that diverged from the caller's original offset whenever the layout engine's wrap pass either dropped or retained-off-screen the seam whitespace. Adds tests covering both wrap-boundary cases and tightens the previously-lax "correct wrapped line" assertion to an exact cell. --- specs/renderer-spec.md | 14 ++-- src/clayterm.c | 185 ++++++++++++++++++++++++----------------- test/term.test.ts | 48 +++++++++-- 3 files changed, 153 insertions(+), 94 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 42e262b..04d4efd 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -354,13 +354,13 @@ offset `N`: - If `N > [...content].length` or `N < 0`, behavior is unspecified; callers must keep offsets within bounds. -When the layout engine consumes whitespace at a wrap boundary — dropping it -from the rendered text so that neither wrapped line contains a display cell for -those code points — the caret has no display position of its own for any offset -that falls in that dropped run. In that case the caret's cell is the origin of -the following wrapped line (the display position of the first code point on -that line). This rule keeps the caret on-screen at the point where subsequent -input will land, rather than orphaning it past the end of the previous line. +When the layout engine consumes whitespace at a wrap boundary — dropping it from +the rendered text so that neither wrapped line contains a display cell for those +code points — the caret has no display position of its own for any offset that +falls in that dropped run. In that case the caret's cell is the origin of the +following wrapped line (the display position of the first code point on that +line). This rule keeps the caret on-screen at the point where subsequent input +will land, rather than orphaning it past the end of the previous line. When `content` is empty, the only in-range offset is `0`. In that case the caret's cell is the text element's origin — the cell at which the first code diff --git a/src/clayterm.c b/src/clayterm.c index 7889909..2442c40 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -85,17 +85,22 @@ struct Clayterm { int error_count; int animating_count; /* Caret state for hardware-cursor management. The renderer records the - * first text node carrying a caret declaration per frame, then locates - * the corresponding cell after Clay layout. had_caret_last_frame is the - * only cross-frame bit retained. */ + * 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; /* code-point offset within that text node */ - int caret_x, caret_y; /* resolved cell (column, row), valid only when - caret_text_chars != NULL */ - int has_caret; /* 1 when the current frame placed a caret */ - int had_caret_last_frame; /* 1 when the previous frame placed a caret */ + 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: @@ -324,70 +329,28 @@ static void render_rect(struct Clayterm *ct, int x0, int y0, int x1, int y1, setcell(ct, x, y, ' ', ATTR_DEFAULT, bg); } -/** - * Locate the cell where the caret should be rendered given the per-line - * text commands produced by Clay's wrap pass. Iterates render commands - * in order, accumulating code points consumed across slices that belong - * to the caret text node, until the caret's code-point offset is reached. - */ -static void locate_caret(struct Clayterm *ct, Clay_RenderCommandArray *cmds) { - if (ct->caret_text_chars == NULL) { - return; - } - const char *node_start = ct->caret_text_chars; - const char *node_end = node_start + ct->caret_text_length; - uint32_t target = ct->caret_offset; - uint32_t accumulated = 0; - - for (int32_t j = 0; j < cmds->length; j++) { - Clay_RenderCommand *cmd = Clay_RenderCommandArray_Get(cmds, j); - if (cmd->commandType != CLAY_RENDER_COMMAND_TYPE_TEXT) { - continue; - } - Clay_TextRenderData *t = &cmd->renderData.text; - const char *slice = t->stringContents.chars; - int slice_len = t->stringContents.length; - if (slice < node_start || slice >= node_end) { - continue; - } - /* count code points in this slice */ - uint32_t slice_cps = 0; - int x_cells = 0; - const char *p = slice; - int rem = slice_len; - while (rem > 0) { - uint32_t cp; - int n = utf8_decode(&cp, p); - if (n <= 0) { - n = 1; - cp = 0xfffd; - } - if (accumulated + slice_cps == target) { - ct->caret_x = (int)cmd->boundingBox.x + x_cells; - ct->caret_y = (int)cmd->boundingBox.y; - return; - } - int cw = wcwidth(cp); - if (cw < 0) { - cw = 1; - } - x_cells += cw; - slice_cps++; - p += n; - rem -= n; +/* Return the byte length of the first `cps` code points of `start`, + * clamped to at most `max_bytes`. Used at decode time to convert the + * caller's code-point caret offset into a stable byte offset. */ +static uint32_t utf8_bytes_for_cps(const char *start, uint32_t cps, + uint32_t max_bytes) { + uint32_t consumed = 0; + const char *p = start; + int rem = (int)max_bytes; + for (uint32_t k = 0; k < cps && rem > 0; k++) { + uint32_t cp; + int n = utf8_decode(&cp, p); + if (n <= 0) { + n = 1; } - if (accumulated + slice_cps == target) { - /* caret sits just after this slice's last code point */ - ct->caret_x = (int)cmd->boundingBox.x + x_cells; - ct->caret_y = (int)cmd->boundingBox.y; - return; + if (n > rem) { + n = rem; } - accumulated += slice_cps; + consumed += (uint32_t)n; + p += n; + rem -= n; } - /* offset out of range: behavior is unspecified by the spec; leave - * caret_x/caret_y at their sentinel -1 values and let the emission - * step suppress visibility. */ - ct->has_caret = 0; + return consumed; } static void render_text(struct Clayterm *ct, int x0, int y0, @@ -401,11 +364,46 @@ static void render_text(struct Clayterm *ct, int x0, int y0, uint32_t attrs = ((uint32_t)(uint8_t)t->textColor.a) << 24; fg |= attrs; - const char *p = t->stringContents.chars; - int rem = t->stringContents.length; + const char *slice = t->stringContents.chars; + int slice_len = t->stringContents.length; + + /* 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 && + slice >= node_start && slice < node_end); + + /* Pre-check: if the slice's first byte is already past the target, + * whitespace at the wrap seam was dropped by the layout engine. Snap + * 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; + caret_relevant = 0; + } + } + + const char *p = slice; + int rem = slice_len; int x = x0; while (rem > 0) { + /* 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; + caret_relevant = 0; + } + uint32_t cp; int n = utf8_decode(&cp, p); if (n <= 0) { @@ -422,6 +420,16 @@ static void render_text(struct Clayterm *ct, int x0, int y0, p += n; rem -= n; } + + /* Remember the trailing cell of this slice as the end-of-content + * fallback. If no later slice claims the target and the caret still + * 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; + } } static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1, @@ -646,7 +654,11 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, ct->animating_count = 0; ct->caret_text_chars = NULL; ct->caret_text_length = 0; - ct->caret_offset = 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; @@ -777,7 +789,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, i += str_words; /* A caret on empty content is a rendering commitment the layout - * engine cannot satisfy on its own — zero cells give locate_caret + * 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. */ @@ -788,11 +800,15 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, /* Record the FIRST caret declaration per frame for the * single-hardware-cursor contract; later declarations are - * intentionally ignored (multi-cursor is unspecified). */ + * intentionally ignored (multi-cursor is unspecified). + * + * 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 = caret; + ct->caret_offset_bytes = utf8_bytes_for_cps(str_chars, caret, str_len); ct->has_caret = 1; } @@ -822,9 +838,6 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); - /* resolve caret cell from this frame's text commands */ - locate_caret(ct, &cmds); - /* reset output state */ ct->out.length = 0; ct->lastfg = ct->lastbg = 0xffffffff; @@ -917,6 +930,22 @@ 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 (mode == 1) { present_lines(ct); } else { diff --git a/test/term.test.ts b/test/term.test.ts index db2ceb9..bb2cb15 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -394,15 +394,45 @@ describe("term", () => { close(), ]).output, ); - // Caret at code-point 7 of "hello world" → after "hello w" → between - // "w" and "o" on the second wrapped line. Exact column depends on Clay's - // wrap point: assert row 2 and column at least 2. - let cupMatch = ansi.match(/\x1b\[(\d+);(\d+)H\x1b\[\?25h$/); - expect(cupMatch).not.toBeNull(); - let row = parseInt(cupMatch![1], 10); - let col = parseInt(cupMatch![2], 10); - expect(row).toBe(2); - expect(col).toBeGreaterThanOrEqual(2); + // "hello world" wraps to "hello" / "world" at width 5. Code-point 7 is + // the second 'o' of "world" (h=0,e=1,l=2,l=3,o=4,' '=5,w=6,o=7). The + // caret sits before that 'o' on the second wrapped line — row 2, col 2. + expect(ansi).toMatch(/\x1b\[2;2H\x1b\[\?25h$/); + }); + + it("snaps to the next wrapped line for a caret at the wrap seam", async () => { + let narrow = await createTerm({ width: 5, height: 4 }); + let ansi = decode( + narrow.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + // "hello world" wraps to "hello" / "world" at width 5. The space + // between them sits on the wrap seam. Code-point 6 is 'w' at the + // start of the second wrapped line; the caret must appear at row 2, + // col 1 rather than off-screen past "hello". + text("hello world", { caret: 6 }), + close(), + ]).output, + ); + expect(ansi).toMatch(/\x1b\[2;1H\x1b\[\?25h$/); + }); + + it("places the caret at the start of a wrapped line when whitespace is consumed", async () => { + let term2 = await createTerm({ width: 6, height: 3 }); + let ansi = decode( + term2.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + // "abc def" wraps to "abc" / "def" at width 6 — the space between + // words is dropped by the wrap pass. Code-point 4 is 'd' at the + // start of the second wrapped line; caret lands at row 2, col 1. + text("abc def", { caret: 4 }), + close(), + ]).output, + ); + expect(ansi).toMatch(/\x1b\[2;1H\x1b\[\?25h$/); }); it("places the caret one cell past the last character when offset == length", () => {