diff --git a/AGENTS.md b/AGENTS.md index 2b4a256..e229098 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. diff --git a/examples/text-input/index.ts b/examples/text-input/index.ts new file mode 100644 index 0000000..e0553fb --- /dev/null +++ b/examples/text-input/index.ts @@ -0,0 +1,143 @@ +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 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[] { + return [ + open("root", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + gap: 1, + }, + bg, + }), + open("label", { layout: { height: fixed(1) } }), + text("Name:", { color: label }), + close(), + open("input-box", { + layout: { + width: fixed(40), + height: fixed(1), + padding: { left: 1, right: 1 }, + }, + bg: inputBg, + }), + text(value, { color: label, caret }), + close(), + open("hint", { layout: { height: fixed(1) } }), + text("← → move Backspace delete Esc or Ctrl+C exit", { color: hint }), + close(), + close(), + ]; +} + +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)); +} 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/specs/renderer-spec.md b/specs/renderer-spec.md index 398e78a..04d4efd 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -334,6 +334,75 @@ 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. + +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 +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 +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 +673,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 @@ -613,7 +681,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 diff --git a/src/clayterm.c b/src/clayterm.c index e48091e..2442c40 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -84,6 +84,23 @@ 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, 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: @@ -234,6 +251,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 +329,30 @@ static void render_rect(struct Clayterm *ct, int x0, int y0, int x1, int y1, setcell(ct, x, y, ' ', ATTR_DEFAULT, bg); } +/* 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 (n > rem) { + n = rem; + } + consumed += (uint32_t)n; + p += n; + rem -= n; + } + return consumed; +} + static void render_text(struct Clayterm *ct, int x0, int y0, Clay_RenderCommand *cmd) { @@ -310,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) { @@ -331,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, @@ -553,6 +652,16 @@ 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_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; Clay_BeginLayout(); @@ -673,11 +782,36 @@ 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; + /* 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; + } + + /* Record the FIRST caret declaration per frame for the + * single-hardware-cursor contract; later declarations are + * 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_bytes = utf8_bytes_for_cps(str_chars, caret, str_len); + ct->has_caret = 1; + } + Clay_String text = {.length = (int32_t)str_len, .chars = str_chars}; Clay_TextElementConfig config = {0}; @@ -796,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 121ece2..bb2cb15 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(` @@ -337,6 +338,169 @@ 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$/); + }); + + 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([ + 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( + narrow.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("hello world", { caret: 7 }), + close(), + ]).output, + ); + // "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", () => { + 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([ + 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("shows the cursor at the text origin when content is empty and caret is 0", () => { + // 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", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + text("", { caret: 0 }), + close(), + ]).output, + ); + expect(ansi).toMatch(/\x1b\[1;1H\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 });