[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

๐Ÿ› 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.

Charles Lowell (Jul 9, 2026, 7:38 PM +0300) a66eaca2 a9f8c724

+148 -90
+109 -81
src/clayterm.c
··· 85 85 int error_count; 86 86 int animating_count; 87 87 /* 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 */ 99 101 }; 100 102 101 103 /* Memory layout inside the arena provided by the host: ··· 324 326 setcell(ct, x, y, ' ', ATTR_DEFAULT, bg); 325 327 } 326 328 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; 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; 346 342 } 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; 378 - } 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; 384 345 } 385 - accumulated += slice_cps; 346 + consumed += (uint32_t)n; 347 + p += n; 348 + rem -= n; 386 349 } 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; 391 351 } 392 352 393 353 static void render_text(struct Clayterm *ct, int x0, int y0, ··· 401 361 uint32_t attrs = ((uint32_t)(uint8_t)t->textColor.a) << 24; 402 362 fg |= attrs; 403 363 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; 406 391 int x = x0; 407 392 408 393 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 + 409 405 uint32_t cp; 410 406 int n = utf8_decode(&cp, p); 411 407 if (n <= 0) { ··· 421 417 } 422 418 p += n; 423 419 rem -= n; 420 + } 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; 424 430 } 425 431 } 426 432 ··· 646 652 ct->animating_count = 0; 647 653 ct->caret_text_chars = NULL; 648 654 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; 650 660 ct->caret_x = -1; 651 661 ct->caret_y = -1; 652 662 ct->has_caret = 0; ··· 777 787 i += str_words; 778 788 779 789 /* 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 781 791 * nothing to attach the cursor to. Substitute a single space so 782 792 * the caret lands at the text element's origin, per the spec's 783 793 * "as if the content were a single space" outcome. */ ··· 788 798 789 799 /* Record the FIRST caret declaration per frame for the 790 800 * 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. */ 792 806 if (caret != 0xFFFFFFFF && ct->caret_text_chars == NULL) { 793 807 ct->caret_text_chars = str_chars; 794 808 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); 796 811 ct->has_caret = 1; 797 812 } 798 813 ··· 821 836 } 822 837 823 838 Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); 824 - 825 - /* resolve caret cell from this frame's text commands */ 826 - locate_caret(ct, &cmds); 827 839 828 840 /* reset output state */ 829 841 ct->out.length = 0; ··· 914 926 break; 915 927 default: 916 928 break; 929 + } 930 + } 931 + 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; 917 945 } 918 946 } 919 947
+39 -9
test/term.test.ts
··· 394 394 close(), 395 395 ]).output, 396 396 ); 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$/); 406 436 }); 407 437 408 438 it("places the caret one cell past the last character when offset == length", () => {