[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.

feat(term): emit hardware cursor at text() caret offset

Charles Lowell (Jul 2, 2026, 6:29 PM +0300) 7d5ad196 05a07ab9

+130 -1
+6 -1
ops.ts
··· 315 315 ); 316 316 o += 4; 317 317 318 + // Caret offset (code points), or 0xFFFFFFFF when absent. 319 + view.setUint32(o, op.caret ?? 0xFFFFFFFF, true); 320 + o += 4; 321 + 318 322 let str = encoder.encode(op.content); 319 323 o = packString(view, str, o, end, "text content"); 320 324 break; ··· 481 485 fontId?: number; 482 486 wrap?: number; 483 487 attrs?: number; 488 + caret?: number; 484 489 } 485 490 486 491 interface Snapshot { ··· 533 538 break; 534 539 } 535 540 case OP_TEXT: { 536 - n += 4 + 4 + 4 + 4; // opcode + color + bg + cfg 541 + n += 4 + 4 + 4 + 4 + 4; // opcode + color + bg + cfg + caret 537 542 n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string 538 543 break; 539 544 }
+109
src/clayterm.c
··· 84 84 Clay_ErrorData errors[MAX_ERRORS]; 85 85 int error_count; 86 86 int animating_count; 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 *caret_text_chars; /* start of caret-bearing text node's bytes, or NULL */ 92 + int caret_text_length; /* byte length of that text node */ 93 + uint32_t caret_offset; /* code-point offset within that text node */ 94 + int caret_x, caret_y; /* resolved cell (column, row), valid only when caret_text_chars != NULL */ 95 + int has_caret; /* 1 when the current frame placed a caret */ 96 + int had_caret_last_frame; /* 1 when the previous frame placed a caret */ 87 97 }; 88 98 89 99 /* Memory layout inside the arena provided by the host: ··· 234 244 x += w; 235 245 } 236 246 } 247 + 248 + /* Hardware cursor management: per the spec's outcome contract, 249 + * leave the cursor visible at the caret cell if a caret was declared 250 + * this frame; otherwise hide it if and only if a caret was declared 251 + * last frame. When neither this frame nor any prior frame declared 252 + * a caret, emit nothing. */ 253 + if (ct->has_caret) { 254 + emit_cursor(ct, ct->caret_x, ct->caret_y, row); 255 + buf_str(&ct->out, "\x1b[?25h"); 256 + } else if (ct->had_caret_last_frame) { 257 + buf_str(&ct->out, "\x1b[?25l"); 258 + } 259 + ct->had_caret_last_frame = ct->has_caret; 237 260 } 238 261 239 262 /** ··· 299 322 setcell(ct, x, y, ' ', ATTR_DEFAULT, bg); 300 323 } 301 324 325 + /** 326 + * Locate the cell where the caret should be rendered given the per-line 327 + * text commands produced by Clay's wrap pass. Iterates render commands 328 + * in order, accumulating code points consumed across slices that belong 329 + * to the caret text node, until the caret's code-point offset is reached. 330 + */ 331 + static void locate_caret(struct Clayterm *ct, Clay_RenderCommandArray *cmds) { 332 + if (ct->caret_text_chars == NULL) { 333 + return; 334 + } 335 + const char *node_start = ct->caret_text_chars; 336 + const char *node_end = node_start + ct->caret_text_length; 337 + uint32_t target = ct->caret_offset; 338 + uint32_t accumulated = 0; 339 + 340 + for (int32_t j = 0; j < cmds->length; j++) { 341 + Clay_RenderCommand *cmd = Clay_RenderCommandArray_Get(cmds, j); 342 + if (cmd->commandType != CLAY_RENDER_COMMAND_TYPE_TEXT) { 343 + continue; 344 + } 345 + Clay_TextRenderData *t = &cmd->renderData.text; 346 + const char *slice = t->stringContents.chars; 347 + int slice_len = t->stringContents.length; 348 + if (slice < node_start || slice >= node_end) { 349 + continue; 350 + } 351 + /* count code points in this slice */ 352 + uint32_t slice_cps = 0; 353 + int x_cells = 0; 354 + const char *p = slice; 355 + int rem = slice_len; 356 + while (rem > 0) { 357 + uint32_t cp; 358 + int n = utf8_decode(&cp, p); 359 + if (n <= 0) { 360 + n = 1; 361 + cp = 0xfffd; 362 + } 363 + if (accumulated + slice_cps == target) { 364 + ct->caret_x = (int)cmd->boundingBox.x + x_cells; 365 + ct->caret_y = (int)cmd->boundingBox.y; 366 + return; 367 + } 368 + int cw = wcwidth(cp); 369 + if (cw < 0) { 370 + cw = 1; 371 + } 372 + x_cells += cw; 373 + slice_cps++; 374 + p += n; 375 + rem -= n; 376 + } 377 + if (accumulated + slice_cps == target) { 378 + /* caret sits just after this slice's last code point */ 379 + ct->caret_x = (int)cmd->boundingBox.x + x_cells; 380 + ct->caret_y = (int)cmd->boundingBox.y; 381 + return; 382 + } 383 + accumulated += slice_cps; 384 + } 385 + /* offset out of range: behavior is unspecified by the spec; leave 386 + * caret_x/caret_y at their sentinel -1 values and let the emission 387 + * step suppress visibility. */ 388 + ct->has_caret = 0; 389 + } 390 + 302 391 static void render_text(struct Clayterm *ct, int x0, int y0, 303 392 Clay_RenderCommand *cmd) { 304 393 ··· 553 642 ct_active_context = ct; 554 643 ct->error_count = 0; 555 644 ct->animating_count = 0; 645 + ct->caret_text_chars = NULL; 646 + ct->caret_text_length = 0; 647 + ct->caret_offset = 0; 648 + ct->caret_x = -1; 649 + ct->caret_y = -1; 650 + ct->has_caret = 0; 556 651 557 652 Clay_BeginLayout(); 558 653 ··· 673 768 uint32_t col = rd(buf, len, &i); 674 769 uint32_t bg = rd(buf, len, &i); 675 770 uint32_t cfg = rd(buf, len, &i); 771 + uint32_t caret = rd(buf, len, &i); 676 772 uint32_t str_len = rd(buf, len, &i); 677 773 int str_words = (str_len + 3) / 4; 678 774 char *str_chars = (char *)&buf[i]; 679 775 i += str_words; 776 + 777 + /* Record the FIRST caret declaration per frame for the 778 + * single-hardware-cursor contract; later declarations are 779 + * intentionally ignored (multi-cursor is unspecified). */ 780 + if (caret != 0xFFFFFFFF && ct->caret_text_chars == NULL) { 781 + ct->caret_text_chars = str_chars; 782 + ct->caret_text_length = (int)str_len; 783 + ct->caret_offset = caret; 784 + ct->has_caret = 1; 785 + } 680 786 681 787 Clay_String text = {.length = (int32_t)str_len, .chars = str_chars}; 682 788 ··· 703 809 } 704 810 705 811 Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); 812 + 813 + /* resolve caret cell from this frame's text commands */ 814 + locate_caret(ct, &cmds); 706 815 707 816 /* reset output state */ 708 817 ct->out.length = 0;
+15
test/term.test.ts
··· 337 337 }); 338 338 }); 339 339 340 + it("emits CUP and DECTCEM-show when a caret is declared", () => { 341 + let ansi = decode( 342 + term.render([ 343 + open("root", { 344 + layout: { width: grow(), height: grow(), direction: "ttb" }, 345 + }), 346 + text("Hello", { caret: 2 }), 347 + close(), 348 + ]).output, 349 + ); 350 + // Caret at code-point offset 2 → cell after "He" → terminal column 3, row 1. 351 + // CUP: ESC [ row ; col H. DECTCEM-show: ESC [ ? 25 h. 352 + expect(ansi).toMatch(/\x1b\[1;3H\x1b\[\?25h$/); 353 + }); 354 + 340 355 describe("row offset", () => { 341 356 it("renders two frames at the offset position", async () => { 342 357 let term = await createTerm({ width: 20, height: 5 });