Skip to content

Commit a66eaca

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 a66eaca

2 files changed

Lines changed: 148 additions & 90 deletions

File tree

src/clayterm.c

Lines changed: 109 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,19 @@ 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. */
91-
const char
92-
*caret_text_chars; /* start of caret-bearing text node's bytes, or NULL */
93-
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 */
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. */
92+
const char *caret_text_chars; /* start of caret-bearing text node's bytes, or NULL */
93+
int caret_text_length; /* byte length of that text node */
94+
uint32_t caret_offset_bytes; /* target byte offset within that text node */
95+
int caret_placed; /* 1 once the render walk has recorded a cell */
96+
int caret_tail_valid; /* 1 when caret_tail_x/y hold a fallback cell */
97+
int caret_tail_x, caret_tail_y; /* trailing cell of the most recently walked caret slice */
98+
int caret_x, caret_y; /* resolved cell (column, row); -1 sentinel until placed */
99+
int has_caret; /* 1 when the current frame will emit a cursor */
100+
int had_caret_last_frame; /* 1 when the previous frame emitted a cursor */
99101
};
100102

101103
/* Memory layout inside the arena provided by the host:
@@ -324,70 +326,28 @@ static void render_rect(struct Clayterm *ct, int x0, int y0, int x1, int y1,
324326
setcell(ct, x, y, ' ', ATTR_DEFAULT, bg);
325327
}
326328

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;
329+
/* Return the byte length of the first `cps` code points of `start`,
330+
* clamped to at most `max_bytes`. Used at decode time to convert the
331+
* caller's code-point caret offset into a stable byte offset. */
332+
static uint32_t utf8_bytes_for_cps(const char *start, uint32_t cps,
333+
uint32_t max_bytes) {
334+
uint32_t consumed = 0;
335+
const char *p = start;
336+
int rem = (int)max_bytes;
337+
for (uint32_t k = 0; k < cps && rem > 0; k++) {
338+
uint32_t cp;
339+
int n = utf8_decode(&cp, p);
340+
if (n <= 0) {
341+
n = 1;
378342
}
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;
343+
if (n > rem) {
344+
n = rem;
384345
}
385-
accumulated += slice_cps;
346+
consumed += (uint32_t)n;
347+
p += n;
348+
rem -= n;
386349
}
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;
350+
return consumed;
391351
}
392352

393353
static void render_text(struct Clayterm *ct, int x0, int y0,
@@ -401,11 +361,47 @@ static void render_text(struct Clayterm *ct, int x0, int y0,
401361
uint32_t attrs = ((uint32_t)(uint8_t)t->textColor.a) << 24;
402362
fg |= attrs;
403363

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

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

427433
static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1,
@@ -646,7 +652,11 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
646652
ct->animating_count = 0;
647653
ct->caret_text_chars = NULL;
648654
ct->caret_text_length = 0;
649-
ct->caret_offset = 0;
655+
ct->caret_offset_bytes = 0;
656+
ct->caret_placed = 0;
657+
ct->caret_tail_valid = 0;
658+
ct->caret_tail_x = -1;
659+
ct->caret_tail_y = -1;
650660
ct->caret_x = -1;
651661
ct->caret_y = -1;
652662
ct->has_caret = 0;
@@ -777,7 +787,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
777787
i += str_words;
778788

779789
/* A caret on empty content is a rendering commitment the layout
780-
* engine cannot satisfy on its own — zero cells give locate_caret
790+
* engine cannot satisfy on its own — zero cells give render_text
781791
* nothing to attach the cursor to. Substitute a single space so
782792
* the caret lands at the text element's origin, per the spec's
783793
* "as if the content were a single space" outcome. */
@@ -788,11 +798,16 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
788798

789799
/* Record the FIRST caret declaration per frame for the
790800
* single-hardware-cursor contract; later declarations are
791-
* intentionally ignored (multi-cursor is unspecified). */
801+
* intentionally ignored (multi-cursor is unspecified).
802+
*
803+
* Convert the caller's code-point offset into a byte offset once
804+
* here so the render walk can identify the caret cell by simple
805+
* pointer arithmetic against the slice's chars pointer. */
792806
if (caret != 0xFFFFFFFF && ct->caret_text_chars == NULL) {
793807
ct->caret_text_chars = str_chars;
794808
ct->caret_text_length = (int)str_len;
795-
ct->caret_offset = caret;
809+
ct->caret_offset_bytes =
810+
utf8_bytes_for_cps(str_chars, caret, str_len);
796811
ct->has_caret = 1;
797812
}
798813

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

823838
Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime);
824839

825-
/* resolve caret cell from this frame's text commands */
826-
locate_caret(ct, &cmds);
827-
828840
/* reset output state */
829841
ct->out.length = 0;
830842
ct->lastfg = ct->lastbg = 0xffffffff;
@@ -917,6 +929,22 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row,
917929
}
918930
}
919931

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