Skip to content

Commit e926977

Browse files
committed
🐛 fix(term): resolve caret cell during the render walk
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.
1 parent a9f8c72 commit e926977

3 files changed

Lines changed: 153 additions & 94 deletions

File tree

specs/renderer-spec.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -354,13 +354,13 @@ offset `N`:
354354
- If `N > [...content].length` or `N < 0`, behavior is unspecified; callers must
355355
keep offsets within bounds.
356356

357-
When the layout engine consumes whitespace at a wrap boundary — dropping it
358-
from the rendered text so that neither wrapped line contains a display cell for
359-
those code points — the caret has no display position of its own for any offset
360-
that falls in that dropped run. In that case the caret's cell is the origin of
361-
the following wrapped line (the display position of the first code point on
362-
that line). This rule keeps the caret on-screen at the point where subsequent
363-
input will land, rather than orphaning it past the end of the previous line.
357+
When the layout engine consumes whitespace at a wrap boundary — dropping it from
358+
the rendered text so that neither wrapped line contains a display cell for those
359+
code points — the caret has no display position of its own for any offset that
360+
falls in that dropped run. In that case the caret's cell is the origin of the
361+
following wrapped line (the display position of the first code point on that
362+
line). This rule keeps the caret on-screen at the point where subsequent input
363+
will land, rather than orphaning it past the end of the previous line.
364364

365365
When `content` is empty, the only in-range offset is `0`. In that case the
366366
caret's cell is the text element's origin — the cell at which the first code

src/clayterm.c

Lines changed: 107 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,22 @@ struct Clayterm {
8585
int error_count;
8686
int animating_count;
8787
/* Caret state for hardware-cursor management. The renderer records the
88-
* first text node carrying a caret declaration per frame, then locates
89-
* the corresponding cell after Clay layout. had_caret_last_frame is the
90-
* only cross-frame bit retained. */
88+
* first text node carrying a caret declaration per frame, precomputes the
89+
* caret's byte offset into that node's content, then places the cell as
90+
* a side effect of render_text's walk. had_caret_last_frame is the only
91+
* cross-frame bit retained. */
9192
const char
9293
*caret_text_chars; /* start of caret-bearing text node's bytes, or NULL */
9394
int caret_text_length; /* byte length of that text node */
94-
uint32_t caret_offset; /* code-point offset within that text node */
95-
int caret_x, caret_y; /* resolved cell (column, row), valid only when
96-
caret_text_chars != NULL */
97-
int has_caret; /* 1 when the current frame placed a caret */
98-
int had_caret_last_frame; /* 1 when the previous frame placed a caret */
95+
uint32_t caret_offset_bytes; /* target byte offset within that text node */
96+
int caret_placed; /* 1 once the render walk has recorded a cell */
97+
int caret_tail_valid; /* 1 when caret_tail_x/y hold a fallback cell */
98+
int caret_tail_x,
99+
caret_tail_y; /* trailing cell of the most recently walked caret slice */
100+
int caret_x,
101+
caret_y; /* resolved cell (column, row); -1 sentinel until placed */
102+
int has_caret; /* 1 when the current frame will emit a cursor */
103+
int had_caret_last_frame; /* 1 when the previous frame emitted a cursor */
99104
};
100105

101106
/* 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,
324329
setcell(ct, x, y, ' ', ATTR_DEFAULT, bg);
325330
}
326331

327-
/**
328-
* Locate the cell where the caret should be rendered given the per-line
329-
* text commands produced by Clay's wrap pass. Iterates render commands
330-
* in order, accumulating code points consumed across slices that belong
331-
* to the caret text node, until the caret's code-point offset is reached.
332-
*/
333-
static void locate_caret(struct Clayterm *ct, Clay_RenderCommandArray *cmds) {
334-
if (ct->caret_text_chars == NULL) {
335-
return;
336-
}
337-
const char *node_start = ct->caret_text_chars;
338-
const char *node_end = node_start + ct->caret_text_length;
339-
uint32_t target = ct->caret_offset;
340-
uint32_t accumulated = 0;
341-
342-
for (int32_t j = 0; j < cmds->length; j++) {
343-
Clay_RenderCommand *cmd = Clay_RenderCommandArray_Get(cmds, j);
344-
if (cmd->commandType != CLAY_RENDER_COMMAND_TYPE_TEXT) {
345-
continue;
346-
}
347-
Clay_TextRenderData *t = &cmd->renderData.text;
348-
const char *slice = t->stringContents.chars;
349-
int slice_len = t->stringContents.length;
350-
if (slice < node_start || slice >= node_end) {
351-
continue;
352-
}
353-
/* count code points in this slice */
354-
uint32_t slice_cps = 0;
355-
int x_cells = 0;
356-
const char *p = slice;
357-
int rem = slice_len;
358-
while (rem > 0) {
359-
uint32_t cp;
360-
int n = utf8_decode(&cp, p);
361-
if (n <= 0) {
362-
n = 1;
363-
cp = 0xfffd;
364-
}
365-
if (accumulated + slice_cps == target) {
366-
ct->caret_x = (int)cmd->boundingBox.x + x_cells;
367-
ct->caret_y = (int)cmd->boundingBox.y;
368-
return;
369-
}
370-
int cw = wcwidth(cp);
371-
if (cw < 0) {
372-
cw = 1;
373-
}
374-
x_cells += cw;
375-
slice_cps++;
376-
p += n;
377-
rem -= n;
332+
/* Return the byte length of the first `cps` code points of `start`,
333+
* clamped to at most `max_bytes`. Used at decode time to convert the
334+
* caller's code-point caret offset into a stable byte offset. */
335+
static uint32_t utf8_bytes_for_cps(const char *start, uint32_t cps,
336+
uint32_t max_bytes) {
337+
uint32_t consumed = 0;
338+
const char *p = start;
339+
int rem = (int)max_bytes;
340+
for (uint32_t k = 0; k < cps && rem > 0; k++) {
341+
uint32_t cp;
342+
int n = utf8_decode(&cp, p);
343+
if (n <= 0) {
344+
n = 1;
378345
}
379-
if (accumulated + slice_cps == target) {
380-
/* caret sits just after this slice's last code point */
381-
ct->caret_x = (int)cmd->boundingBox.x + x_cells;
382-
ct->caret_y = (int)cmd->boundingBox.y;
383-
return;
346+
if (n > rem) {
347+
n = rem;
384348
}
385-
accumulated += slice_cps;
349+
consumed += (uint32_t)n;
350+
p += n;
351+
rem -= n;
386352
}
387-
/* offset out of range: behavior is unspecified by the spec; leave
388-
* caret_x/caret_y at their sentinel -1 values and let the emission
389-
* step suppress visibility. */
390-
ct->has_caret = 0;
353+
return consumed;
391354
}
392355

393356
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,
401364
uint32_t attrs = ((uint32_t)(uint8_t)t->textColor.a) << 24;
402365
fg |= attrs;
403366

404-
const char *p = t->stringContents.chars;
405-
int rem = t->stringContents.length;
367+
const char *slice = t->stringContents.chars;
368+
int slice_len = t->stringContents.length;
369+
370+
/* Determine whether this slice belongs to the caret's text node and
371+
* whether the caret still needs to be placed. If so, resolve the cell
372+
* as a side effect of the walk instead of doing a separate pass. */
373+
const char *node_start = ct->caret_text_chars;
374+
const char *node_end = node_start ? node_start + ct->caret_text_length : NULL;
375+
int caret_relevant = (node_start != NULL && !ct->caret_placed &&
376+
slice >= node_start && slice < node_end);
377+
378+
/* Pre-check: if the slice's first byte is already past the target,
379+
* whitespace at the wrap seam was dropped by the layout engine. Snap
380+
* the caret to this slice's origin. */
381+
if (caret_relevant) {
382+
uint32_t slice_start_bytes = (uint32_t)(slice - node_start);
383+
if (slice_start_bytes > ct->caret_offset_bytes) {
384+
ct->caret_x = x0;
385+
ct->caret_y = y0;
386+
ct->caret_placed = 1;
387+
caret_relevant = 0;
388+
}
389+
}
390+
391+
const char *p = slice;
392+
int rem = slice_len;
406393
int x = x0;
407394

408395
while (rem > 0) {
396+
/* Check at the top of each iteration: if the pointer we are about to
397+
* consume matches the target byte offset, the caret sits at the
398+
* current cell — right before this code point. */
399+
if (caret_relevant &&
400+
(uint32_t)(p - node_start) == ct->caret_offset_bytes) {
401+
ct->caret_x = x;
402+
ct->caret_y = y0;
403+
ct->caret_placed = 1;
404+
caret_relevant = 0;
405+
}
406+
409407
uint32_t cp;
410408
int n = utf8_decode(&cp, p);
411409
if (n <= 0) {
@@ -422,6 +420,16 @@ static void render_text(struct Clayterm *ct, int x0, int y0,
422420
p += n;
423421
rem -= n;
424422
}
423+
424+
/* Remember the trailing cell of this slice as the end-of-content
425+
* fallback. If no later slice claims the target and the caret still
426+
* hasn't been placed by the end of the render command loop, this cell
427+
* is where a caret at offset == content-length lands. */
428+
if (caret_relevant) {
429+
ct->caret_tail_x = x;
430+
ct->caret_tail_y = y0;
431+
ct->caret_tail_valid = 1;
432+
}
425433
}
426434

427435
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,
646654
ct->animating_count = 0;
647655
ct->caret_text_chars = NULL;
648656
ct->caret_text_length = 0;
649-
ct->caret_offset = 0;
657+
ct->caret_offset_bytes = 0;
658+
ct->caret_placed = 0;
659+
ct->caret_tail_valid = 0;
660+
ct->caret_tail_x = -1;
661+
ct->caret_tail_y = -1;
650662
ct->caret_x = -1;
651663
ct->caret_y = -1;
652664
ct->has_caret = 0;
@@ -777,7 +789,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
777789
i += str_words;
778790

779791
/* A caret on empty content is a rendering commitment the layout
780-
* engine cannot satisfy on its own — zero cells give locate_caret
792+
* engine cannot satisfy on its own — zero cells give render_text
781793
* nothing to attach the cursor to. Substitute a single space so
782794
* the caret lands at the text element's origin, per the spec's
783795
* "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,
788800

789801
/* Record the FIRST caret declaration per frame for the
790802
* single-hardware-cursor contract; later declarations are
791-
* intentionally ignored (multi-cursor is unspecified). */
803+
* intentionally ignored (multi-cursor is unspecified).
804+
*
805+
* Convert the caller's code-point offset into a byte offset once
806+
* here so the render walk can identify the caret cell by simple
807+
* pointer arithmetic against the slice's chars pointer. */
792808
if (caret != 0xFFFFFFFF && ct->caret_text_chars == NULL) {
793809
ct->caret_text_chars = str_chars;
794810
ct->caret_text_length = (int)str_len;
795-
ct->caret_offset = caret;
811+
ct->caret_offset_bytes = utf8_bytes_for_cps(str_chars, caret, str_len);
796812
ct->has_caret = 1;
797813
}
798814

@@ -822,9 +838,6 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
822838

823839
Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime);
824840

825-
/* resolve caret cell from this frame's text commands */
826-
locate_caret(ct, &cmds);
827-
828841
/* reset output state */
829842
ct->out.length = 0;
830843
ct->lastfg = ct->lastbg = 0xffffffff;
@@ -917,6 +930,22 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
917930
}
918931
}
919932

933+
/* End-of-content fallback: if a caret was declared and its target byte
934+
* offset is one past the last byte of the caret text node, the trailing
935+
* cell of the last walked caret slice is where it lands. */
936+
if (ct->has_caret && !ct->caret_placed) {
937+
if (ct->caret_tail_valid &&
938+
ct->caret_offset_bytes == (uint32_t)ct->caret_text_length) {
939+
ct->caret_x = ct->caret_tail_x;
940+
ct->caret_y = ct->caret_tail_y;
941+
ct->caret_placed = 1;
942+
} else {
943+
/* Offset out of range or otherwise unresolvable. Suppress the
944+
* cursor for this frame. */
945+
ct->has_caret = 0;
946+
}
947+
}
948+
920949
if (mode == 1) {
921950
present_lines(ct);
922951
} else {

test/term.test.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -394,15 +394,45 @@ describe("term", () => {
394394
close(),
395395
]).output,
396396
);
397-
// Caret at code-point 7 of "hello world" → after "hello w" → between
398-
// "w" and "o" on the second wrapped line. Exact column depends on Clay's
399-
// wrap point: assert row 2 and column at least 2.
400-
let cupMatch = ansi.match(/\x1b\[(\d+);(\d+)H\x1b\[\?25h$/);
401-
expect(cupMatch).not.toBeNull();
402-
let row = parseInt(cupMatch![1], 10);
403-
let col = parseInt(cupMatch![2], 10);
404-
expect(row).toBe(2);
405-
expect(col).toBeGreaterThanOrEqual(2);
397+
// "hello world" wraps to "hello" / "world" at width 5. Code-point 7 is
398+
// the second 'o' of "world" (h=0,e=1,l=2,l=3,o=4,' '=5,w=6,o=7). The
399+
// caret sits before that 'o' on the second wrapped line — row 2, col 2.
400+
expect(ansi).toMatch(/\x1b\[2;2H\x1b\[\?25h$/);
401+
});
402+
403+
it("snaps to the next wrapped line for a caret at the wrap seam", async () => {
404+
let narrow = await createTerm({ width: 5, height: 4 });
405+
let ansi = decode(
406+
narrow.render([
407+
open("root", {
408+
layout: { width: grow(), height: grow(), direction: "ttb" },
409+
}),
410+
// "hello world" wraps to "hello" / "world" at width 5. The space
411+
// between them sits on the wrap seam. Code-point 6 is 'w' at the
412+
// start of the second wrapped line; the caret must appear at row 2,
413+
// col 1 rather than off-screen past "hello".
414+
text("hello world", { caret: 6 }),
415+
close(),
416+
]).output,
417+
);
418+
expect(ansi).toMatch(/\x1b\[2;1H\x1b\[\?25h$/);
419+
});
420+
421+
it("places the caret at the start of a wrapped line when whitespace is consumed", async () => {
422+
let term2 = await createTerm({ width: 6, height: 3 });
423+
let ansi = decode(
424+
term2.render([
425+
open("root", {
426+
layout: { width: grow(), height: grow(), direction: "ttb" },
427+
}),
428+
// "abc def" wraps to "abc" / "def" at width 6 — the space between
429+
// words is dropped by the wrap pass. Code-point 4 is 'd' at the
430+
// start of the second wrapped line; caret lands at row 2, col 1.
431+
text("abc def", { caret: 4 }),
432+
close(),
433+
]).output,
434+
);
435+
expect(ansi).toMatch(/\x1b\[2;1H\x1b\[\?25h$/);
406436
});
407437

408438
it("places the caret one cell past the last character when offset == length", () => {

0 commit comments

Comments
 (0)