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..ae387bb --- /dev/null +++ b/examples/text-input/index.ts @@ -0,0 +1,148 @@ +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, + }), + // 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(), + 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..27d865c 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -334,6 +334,60 @@ 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 +658,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 +666,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..af092a9 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -84,6 +84,18 @@ 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 +246,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 +324,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 +644,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 +770,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 +812,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..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(` @@ -337,6 +338,143 @@ 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, + ); + // 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([ + 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", () => { + // 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([ + 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 });