[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(junction): resolve border junctions via per-cell direction accumulation

Nate Moore (May 28, 2026, 12:53 AM EDT) d415cc5c e8c95f82

+199 -44
+172 -44
src/clayterm.c
··· 298 298 } 299 299 } 300 300 301 + /* ── Junction character lookup ────────────────────────────────────── */ 302 + /* Bit layout: [1:0]=left, [3:2]=right, [5:4]=top, [7:6]=bottom style. 303 + * Style values: 0=none 1=single 2=bold 3=double. 304 + * Bit 8: rounded-corner hint (single-style 2-dir corner only). */ 305 + #define _JK(l,r,t,b) \ 306 + (((l)&3)|(((r)&3)<<2)|(((t)&3)<<4)|(((b)&3)<<6)) 307 + 308 + static const uint32_t junction_chars[512] = { 309 + /* ── single ─────────────────────────────────────────── */ 310 + [_JK(1,1,0,0)] = 0x2500, /* ─ */ 311 + [_JK(0,0,1,1)] = 0x2502, /* │ */ 312 + [_JK(0,1,0,1)] = 0x250C, /* ┌ */ 313 + [_JK(1,0,0,1)] = 0x2510, /* ┐ */ 314 + [_JK(0,1,1,0)] = 0x2514, /* └ */ 315 + [_JK(1,0,1,0)] = 0x2518, /* ┘ */ 316 + [_JK(1,1,0,1)] = 0x252C, /* ┬ */ 317 + [_JK(1,1,1,0)] = 0x2534, /* ┴ */ 318 + [_JK(0,1,1,1)] = 0x251C, /* ├ */ 319 + [_JK(1,0,1,1)] = 0x2524, /* ┤ */ 320 + [_JK(1,1,1,1)] = 0x253C, /* ┼ */ 321 + 322 + /* ── bold ────────────────────────────────────────────── */ 323 + [_JK(2,2,0,0)] = 0x2501, /* ━ */ 324 + [_JK(0,0,2,2)] = 0x2503, /* ┃ */ 325 + [_JK(0,2,0,2)] = 0x250F, /* ┏ */ 326 + [_JK(2,0,0,2)] = 0x2513, /* ┓ */ 327 + [_JK(0,2,2,0)] = 0x2517, /* ┗ */ 328 + [_JK(2,0,2,0)] = 0x251B, /* ┛ */ 329 + [_JK(2,2,0,2)] = 0x2533, /* ┳ */ 330 + [_JK(2,2,2,0)] = 0x253B, /* ┻ */ 331 + [_JK(0,2,2,2)] = 0x2523, /* ┣ */ 332 + [_JK(2,0,2,2)] = 0x252B, /* ┫ */ 333 + [_JK(2,2,2,2)] = 0x254B, /* ╋ */ 334 + 335 + /* ── double ──────────────────────────────────────────── */ 336 + [_JK(3,3,0,0)] = 0x2550, /* ═ */ 337 + [_JK(0,0,3,3)] = 0x2551, /* ║ */ 338 + [_JK(0,3,0,3)] = 0x2554, /* ╔ */ 339 + [_JK(3,0,0,3)] = 0x2557, /* ╗ */ 340 + [_JK(0,3,3,0)] = 0x255A, /* ╚ */ 341 + [_JK(3,0,3,0)] = 0x255D, /* ╝ */ 342 + [_JK(3,3,0,3)] = 0x2566, /* ╦ */ 343 + [_JK(3,3,3,0)] = 0x2569, /* ╩ */ 344 + [_JK(0,3,3,3)] = 0x2560, /* ╠ */ 345 + [_JK(3,0,3,3)] = 0x2563, /* ╣ */ 346 + [_JK(3,3,3,3)] = 0x256C, /* ╬ */ 347 + 348 + /* ── single+double mixed corners ─────────────────────── */ 349 + [_JK(0,3,0,1)] = 0x2552, /* ╒ right=dbl bot=sgl */ 350 + [_JK(0,1,0,3)] = 0x2553, /* ╓ right=sgl bot=dbl */ 351 + [_JK(3,0,0,1)] = 0x2555, /* ╕ left=dbl bot=sgl */ 352 + [_JK(1,0,0,3)] = 0x2556, /* ╖ left=sgl bot=dbl */ 353 + [_JK(0,3,1,0)] = 0x2558, /* ╘ right=dbl top=sgl */ 354 + [_JK(0,1,3,0)] = 0x2559, /* ╙ right=sgl top=dbl */ 355 + [_JK(3,0,1,0)] = 0x255B, /* ╛ left=dbl top=sgl */ 356 + [_JK(1,0,3,0)] = 0x255C, /* ╜ left=sgl top=dbl */ 357 + /* mixed T-junctions */ 358 + [_JK(0,3,1,1)] = 0x255E, /* ╞ right=dbl v=sgl */ 359 + [_JK(0,1,3,3)] = 0x255F, /* ╟ right=sgl v=dbl */ 360 + [_JK(3,0,1,1)] = 0x2561, /* ╡ left=dbl v=sgl */ 361 + [_JK(1,0,3,3)] = 0x2562, /* ╢ left=sgl v=dbl */ 362 + [_JK(3,3,0,1)] = 0x2564, /* ╤ h=dbl bot=sgl */ 363 + [_JK(1,1,0,3)] = 0x2565, /* ╥ h=sgl bot=dbl */ 364 + [_JK(3,3,1,0)] = 0x2567, /* ╧ h=dbl top=sgl */ 365 + [_JK(1,1,3,0)] = 0x2568, /* ╨ h=sgl top=dbl */ 366 + /* mixed cross */ 367 + [_JK(3,3,1,1)] = 0x256A, /* ╪ h=dbl v=sgl */ 368 + [_JK(1,1,3,3)] = 0x256B, /* ╫ h=sgl v=dbl */ 369 + 370 + /* ── rounded single corners (bit 8 set) ──────────────── */ 371 + [_JK(0,1,0,1)|0x100] = 0x256D, /* ╭ */ 372 + [_JK(1,0,0,1)|0x100] = 0x256E, /* ╮ */ 373 + [_JK(0,1,1,0)|0x100] = 0x2570, /* ╰ */ 374 + [_JK(1,0,1,0)|0x100] = 0x256F, /* ╯ */ 375 + }; 376 + 377 + static void mark_junction(struct Clayterm *ct, int x, int y, 378 + uint16_t bits, uint32_t fg) { 379 + if (x < 0 || x >= ct->w || y < 0 || y >= ct->h) 380 + return; 381 + if (ct->clipping) { 382 + if (x < ct->clipx || x >= ct->clipx + ct->clipw) 383 + return; 384 + if (y < ct->clipy || y >= ct->clipy + ct->cliph) 385 + return; 386 + } 387 + ct->junctions[y * ct->w + x] |= bits; 388 + cell_at(ct, ct->back, x, y)->fg = fg; 389 + } 390 + 301 391 static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1, 302 392 Clay_BorderRenderData *b) { 303 393 uint32_t fg = color(b->color); 304 - uint32_t bg = ATTR_DEFAULT; 305 - int top = b->width.top > 0; 306 - int bot = b->width.bottom > 0; 307 - int left = b->width.left > 0; 308 - int right = b->width.right > 0; 394 + int top = b->width.top > 0; 395 + int bot = b->width.bottom > 0; 396 + int left = b->width.left > 0; 397 + int right = b->width.right > 0; 309 398 uint8_t style = (uint8_t)b->width.betweenChildren; 310 399 311 - uint32_t h_char, v_char, tl, tr, bl, br; 312 - switch (style) { 313 - case 1: /* double */ 314 - tl = 0x2554; tr = 0x2557; bl = 0x255a; br = 0x255d; 315 - h_char = 0x2550; v_char = 0x2551; 316 - break; 317 - case 2: /* bold */ 318 - tl = 0x250f; tr = 0x2513; bl = 0x2517; br = 0x251b; 319 - h_char = 0x2501; v_char = 0x2503; 320 - break; 321 - default: /* single — corners rounded when cornerRadius > 0 */ 322 - tl = b->cornerRadius.topLeft > 0 ? 0x256d : 0x250c; 323 - tr = b->cornerRadius.topRight > 0 ? 0x256e : 0x2510; 324 - bl = b->cornerRadius.bottomLeft > 0 ? 0x2570 : 0x2514; 325 - br = b->cornerRadius.bottomRight > 0 ? 0x256f : 0x2518; 326 - h_char = 0x2500; v_char = 0x2502; 327 - break; 400 + /* map border style (0=single,1=double,2=bold) → junction style (1=single,3=double,2=bold) */ 401 + static const uint8_t smap[] = {1, 3, 2}; 402 + uint8_t js = style < 3 ? smap[style] : 1; 403 + 404 + int rounded = (style == 0) && 405 + (b->cornerRadius.topLeft > 0 || b->cornerRadius.topRight > 0 || 406 + b->cornerRadius.bottomLeft > 0 || b->cornerRadius.bottomRight > 0); 407 + 408 + #define JL(s) ((uint16_t)((s)&3)) 409 + #define JR(s) ((uint16_t)(((s)&3)<<2)) 410 + #define JT(s) ((uint16_t)(((s)&3)<<4)) 411 + #define JB(s) ((uint16_t)(((s)&3)<<6)) 412 + #define JROUND ((uint16_t)0x100) 413 + 414 + /* corners */ 415 + if (top && left) { 416 + uint16_t r = (rounded && b->cornerRadius.topLeft > 0) ? JROUND : 0; 417 + mark_junction(ct, x0, y0, JR(js)|JB(js)|r, fg); 418 + } 419 + if (top && right) { 420 + uint16_t r = (rounded && b->cornerRadius.topRight > 0) ? JROUND : 0; 421 + mark_junction(ct, x1-1, y0, JL(js)|JB(js)|r, fg); 422 + } 423 + if (bot && left) { 424 + uint16_t r = (rounded && b->cornerRadius.bottomLeft > 0) ? JROUND : 0; 425 + mark_junction(ct, x0, y1-1, JR(js)|JT(js)|r, fg); 426 + } 427 + if (bot && right) { 428 + uint16_t r = (rounded && b->cornerRadius.bottomRight > 0) ? JROUND : 0; 429 + mark_junction(ct, x1-1, y1-1, JL(js)|JT(js)|r, fg); 328 430 } 329 431 330 - if (top && left) 331 - setcell(ct, x0, y0, tl, fg, bg); 332 - if (top && right) 333 - setcell(ct, x1 - 1, y0, tr, fg, bg); 334 - if (bot && left) 335 - setcell(ct, x0, y1 - 1, bl, fg, bg); 336 - if (bot && right) 337 - setcell(ct, x1 - 1, y1 - 1, br, fg, bg); 432 + /* horizontal edges — trim endpoint bits when no corner caps the edge */ 433 + if (top) { 434 + for (int x = x0 + left; x < x1 - right; x++) { 435 + uint16_t hb = JL(js)|JR(js); 436 + if (!left && x == x0) hb = (uint16_t)(hb & ~JL(js)); 437 + if (!right && x == x1 - 1) hb = (uint16_t)(hb & ~JR(js)); 438 + mark_junction(ct, x, y0, hb, fg); 439 + } 440 + } 441 + if (bot) { 442 + for (int x = x0 + left; x < x1 - right; x++) { 443 + uint16_t hb = JL(js)|JR(js); 444 + if (!left && x == x0) hb = (uint16_t)(hb & ~JL(js)); 445 + if (!right && x == x1 - 1) hb = (uint16_t)(hb & ~JR(js)); 446 + mark_junction(ct, x, y1-1, hb, fg); 447 + } 448 + } 338 449 339 - /* horizontal edges */ 340 - if (top) 341 - for (int x = x0 + left; x < x1 - right; x++) 342 - setcell(ct, x, y0, h_char, fg, bg); 343 - if (bot) 344 - for (int x = x0 + left; x < x1 - right; x++) 345 - setcell(ct, x, y1 - 1, h_char, fg, bg); 450 + /* vertical edges — trim endpoint bits when no corner caps the edge */ 451 + if (left) { 452 + for (int y = y0 + top; y < y1 - bot; y++) { 453 + uint16_t vb = JT(js)|JB(js); 454 + if (!top && y == y0) vb = (uint16_t)(vb & ~JT(js)); 455 + if (!bot && y == y1 - 1) vb = (uint16_t)(vb & ~JB(js)); 456 + mark_junction(ct, x0, y, vb, fg); 457 + } 458 + } 459 + if (right) { 460 + for (int y = y0 + top; y < y1 - bot; y++) { 461 + uint16_t vb = JT(js)|JB(js); 462 + if (!top && y == y0) vb = (uint16_t)(vb & ~JT(js)); 463 + if (!bot && y == y1 - 1) vb = (uint16_t)(vb & ~JB(js)); 464 + mark_junction(ct, x1-1, y, vb, fg); 465 + } 466 + } 346 467 347 - /* vertical edges */ 348 - if (left) 349 - for (int y = y0 + top; y < y1 - bot; y++) 350 - setcell(ct, x0, y, v_char, fg, bg); 351 - if (right) 352 - for (int y = y0 + top; y < y1 - bot; y++) 353 - setcell(ct, x1 - 1, y, v_char, fg, bg); 468 + #undef JL 469 + #undef JR 470 + #undef JT 471 + #undef JB 472 + #undef JROUND 354 473 } 355 474 356 475 /* ── Command buffer helpers ───────────────────────────────────────── */ ··· 617 736 ct->lastx = ct->lasty = -1; 618 737 619 738 cells_fill(ct->back, ct->w, ct->h, ' ', ATTR_DEFAULT, ATTR_DEFAULT); 739 + memset(ct->junctions, 0, (size_t)(ct->w * ct->h) * sizeof(uint16_t)); 620 740 621 741 /* walk Clay render commands into back buffer */ 622 742 for (int32_t j = 0; j < cmds.length; j++) { ··· 650 770 default: 651 771 break; 652 772 } 773 + } 774 + 775 + /* junction resolution: write correct Unicode glyph for each accumulated cell */ 776 + for (int j = 0; j < ct->w * ct->h; j++) { 777 + uint16_t bits = ct->junctions[j]; 778 + if (!bits) continue; 779 + uint32_t ch = junction_chars[bits & 0x1ff]; 780 + if (ch) ct->back[j].ch = ch; 653 781 } 654 782 655 783 if (mode == 1) {
+27
test/term.test.ts
··· 160 160 ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛`.trim()); 161 161 }); 162 162 163 + it("renders T-junction where inner separator meets outer border (single)", () => { 164 + let out = print( 165 + decode( 166 + term.render([ 167 + open("root", { 168 + layout: { width: grow(), height: grow(), direction: "ltr" }, 169 + border: { color: rgba(255, 255, 255), left: 1, right: 1, top: 1, bottom: 1 }, 170 + }), 171 + open("left", { 172 + layout: { width: fixed(10), height: grow() }, 173 + border: { color: rgba(255, 255, 255), right: 1 }, 174 + }), 175 + close(), 176 + open("right", { layout: { width: grow(), height: grow() } }), 177 + close(), 178 + close(), 179 + ]).output, 180 + ), 181 + 40, 182 + 10, 183 + ); 184 + 185 + expect(out).toContain("┬"); 186 + expect(out).toContain("┴"); 187 + expect(out).toMatch(/^┌─+┬/m); 188 + }); 189 + 163 190 describe("line mode", () => { 164 191 let box = (msg: string) => [ 165 192 open("root", {