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

✨ Add border-cell backgrounds (#94)

* ✨ Add border-cell backgrounds

* format the spec file

* fix merge conflicts

* Adds code comment and validation for bg

* Updated the tests as suggests by paul

authored by

Ryan Rauh and committed by
GitHub
(Jun 14, 2026, 10:15 AM EDT) be7e746d b0ded4ff

+209 -12
+31 -2
examples/inline-regions/index.ts
··· 39 39 const GREEN_BG = rgba(20, 70, 38); 40 40 const GRAY = rgba(100, 100, 100); 41 41 const CYAN = rgba(139, 233, 253); 42 + const DARK_BG = rgba(30, 30, 40); 42 43 43 44 const RED = rgba(255, 0, 0); 44 45 const ORANGE = rgba(255, 153, 0); ··· 84 85 ); 85 86 86 87 let first = term.render( 87 - box("Press any key to compile modules.", CYAN, GRAY), 88 + box("Press any key to compile modules.", CYAN, GRAY, DARK_BG), 88 89 { row }, 89 90 ); 90 91 write(new Uint8Array(first.output)); ··· 101 102 `${icon} ${label} ${time}`, 102 103 done ? GREEN : CYAN, 103 104 done ? GREEN : GRAY, 105 + DARK_BG, 104 106 ), 105 107 { row }, 106 108 ); ··· 113 115 write(encode("\n")); 114 116 115 117 yield* sleep(500); 118 + 119 + // Demo: border bg 120 + write(encode("\n\n\n")); 121 + 122 + let bgPos = yield* queryCursor(); 123 + let bgRow = bgPos.row - 2; 124 + write(ESC("7")); 125 + 126 + let bgTerm = validated( 127 + yield* until(createTerm({ width: columns, height: 3 })), 128 + ); 129 + 130 + let PURPLE_BG = rgba(80, 40, 120); 131 + let bgResult = bgTerm.render( 132 + box("Border backgrounds fill border cells.", WHITE, GREEN, PURPLE_BG), 133 + { row: bgRow }, 134 + ); 135 + write(new Uint8Array(bgResult.output)); 136 + 137 + waitKey(); 138 + 139 + write(ESC("8")); 140 + write(CSI("0m")); 141 + write(encode("\n")); 142 + 143 + yield* sleep(200); 116 144 117 145 write( 118 146 encode( ··· 322 350 } 323 351 } 324 352 325 - function box(msg: string, fg: number, border: number): Op[] { 353 + function box(msg: string, fg: number, border: number, bg: number): Op[] { 326 354 return [ 327 355 open("root", { 328 356 layout: { width: grow(), height: grow(), direction: "ttb" }, ··· 337 365 }, 338 366 border: { 339 367 color: border, 368 + bg, 340 369 left: 1, 341 370 right: 1, 342 371 top: 1,
+8 -1
ops.ts
··· 164 164 let b = op.border; 165 165 view.setUint32(o, b.color, true); 166 166 o += 4; 167 + 168 + // ATTR_DEFAULT sentinel (bit 31 set) means "use terminal default bg" 169 + let bg = b.bg === undefined ? 0x80000000 : b.bg & 0x00FFFFFF; 170 + view.setUint32(o, bg, true); 171 + o += 4; 172 + 167 173 view.setUint32( 168 174 o, 169 175 (b.left ?? 0) | ((b.right ?? 0) << 8) | ((b.top ?? 0) << 16) | ··· 310 316 cornerRadius?: { tl?: number; tr?: number; bl?: number; br?: number }; 311 317 border?: { 312 318 color: number; 319 + bg?: number; 313 320 left?: number; 314 321 right?: number; 315 322 top?: number; ··· 446 453 if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align 447 454 if (op.bg !== undefined) n += 4; 448 455 if (op.cornerRadius) n += 4; 449 - if (op.border) n += 8; 456 + if (op.border) n += 12; 450 457 if (op.clip) n += 4; 451 458 // x, y, expand width/height, parent, attach/pointer, clip/z 452 459 if (op.floating) n += 7 * 4;
+9 -1
specs/renderer-spec.md
··· 644 644 padding (per-side), alignment (`alignX`: `"left"` | `"center"` | `"right"`; 645 645 `alignY`: `"top"` | `"center"` | `"bottom"`, defaulting to left/top when 646 646 omitted), direction (top-to-bottom or left-to-right), and gap 647 - - **`border`** — per-side border widths and border color 647 + - **`border`** — per-side border widths, border color, and border background 648 + color 648 649 - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing 649 650 characters 650 651 - **`clip`** — clip region configuration for scroll containers ··· 707 708 708 709 These property groups represent the current implementation surface. New groups 709 710 and fields have been added incrementally and more may follow. 711 + 712 + **Border background.** When `border.bg` is provided, the renderer MUST apply 713 + that background color to all cells occupied by border glyphs (corners, 714 + horizontal edges, and vertical edges). When `border.bg` is omitted, border 715 + rendering MUST NOT override the background already present in each border cell; 716 + element backgrounds established by `open({ bg })` remain in effect, and the 717 + terminal default remains in effect where no element background applies. 710 718 711 719 **Border width and layout interaction.** In the underlying layout engine (Clay), 712 720 border configuration does not affect layout computation. This is Clay's intended
+7 -3
src/clayterm.c
··· 301 301 } 302 302 303 303 static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1, 304 - Clay_BorderRenderData *b) { 304 + Clay_RenderCommand *cmd) { 305 + Clay_BorderRenderData *b = &cmd->renderData.border; 305 306 uint32_t fg = color(b->color); 306 - uint32_t bg = ATTR_DEFAULT; 307 + /* userData is currently exclusively the packed border-bg word. */ 308 + uint32_t bg = (uint32_t)(uintptr_t)cmd->userData; 307 309 int top = b->width.top > 0; 308 310 int bot = b->width.bottom > 0; 309 311 int left = b->width.left > 0; ··· 533 535 if (mask & PROP_BORDER) { 534 536 decl.border.color = unpack_color(rd(buf, len, &i)); 535 537 538 + decl.userData = (void *)(uintptr_t)rd(buf, len, &i); 539 + 536 540 uint32_t bw = rd(buf, len, &i); 537 541 decl.border.width.left = bw & 0xff; 538 542 decl.border.width.right = (bw >> 8) & 0xff; ··· 627 631 render_text(ct, x0, y0, cmd); 628 632 break; 629 633 case CLAY_RENDER_COMMAND_TYPE_BORDER: 630 - render_border(ct, x0, y0, x1, y1, &cmd->renderData.border); 634 + render_border(ct, x0, y0, x1, y1, cmd); 631 635 break; 632 636 case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: 633 637 ct->clipping = 1;
+146 -5
test/color.test.ts
··· 1 - import { close, grow, open, rgba, text } from "../ops.ts"; 1 + import { close, fixed, grow, open, rgba, text } from "../ops.ts"; 2 2 import { createTerm } from "../term.ts"; 3 3 import { describe, expect, it } from "./suite.ts"; 4 4 5 5 const decode = (b: Uint8Array) => new TextDecoder().decode(b); 6 6 7 - type TextBgColor = { 7 + type BgColor = { 8 8 value: number; 9 9 sgr: string; 10 10 }; 11 11 12 - function randomTextBgColor(): TextBgColor { 12 + type Cell = { 13 + ch: string; 14 + bg?: string; 15 + }; 16 + 17 + function randomBgColor(): BgColor { 13 18 let r = 0; 14 19 let g = 0; 15 20 let b = 0; ··· 30 35 }; 31 36 } 32 37 38 + function cells(ansi: string): Cell[] { 39 + let result: Cell[] = []; 40 + let bg: string | undefined; 41 + 42 + for (let i = 0; i < ansi.length;) { 43 + if (ansi[i] === "\x1b" && ansi[i + 1] === "[") { 44 + let end = i + 2; 45 + while (end < ansi.length && !/[A-Za-z]/.test(ansi[end])) { 46 + end++; 47 + } 48 + 49 + let seq = ansi.slice(i, end + 1); 50 + if (seq === "\x1b[0m") { 51 + bg = undefined; 52 + } else if (seq.startsWith("\x1b[48;2;") && seq.endsWith("m")) { 53 + bg = seq.slice(0, -1); 54 + } 55 + 56 + i = end + 1; 57 + continue; 58 + } 59 + 60 + result.push({ ch: ansi[i], bg }); 61 + i++; 62 + } 63 + 64 + return result; 65 + } 66 + 67 + function firstCell(cells: Cell[], ch: string): Cell { 68 + let cell = cells.find((c) => c.ch === ch); 69 + expect(cell).toBeDefined(); 70 + return cell!; 71 + } 72 + 33 73 describe("foreground", () => { 34 74 it("emits uncolored text with no foreground", async () => { 35 75 let term = await createTerm({ width: 12, height: 1 }); ··· 41 81 }); 42 82 43 83 describe("background", () => { 84 + it("fills border cells with the requested border-level bg", async () => { 85 + let term = await createTerm({ width: 12, height: 4 }); 86 + let bg = randomBgColor(); 87 + let ansi = decode( 88 + term.render([ 89 + open("box", { 90 + layout: { width: fixed(8), height: fixed(3), direction: "ttb" }, 91 + border: { 92 + color: rgba(255, 255, 255), 93 + bg: bg.value, 94 + left: 1, 95 + right: 1, 96 + top: 1, 97 + bottom: 1, 98 + }, 99 + }), 100 + text("Hi"), 101 + close(), 102 + ], { mode: "line" }).output, 103 + ); 104 + 105 + expect(ansi).toContain(`${bg.sgr}m┌`); 106 + 107 + let rendered = cells(ansi); 108 + expect(firstCell(rendered, "┌").bg).toBe(bg.sgr); 109 + expect(firstCell(rendered, "─").bg).toBe(bg.sgr); 110 + expect(firstCell(rendered, "┐").bg).toBe(bg.sgr); 111 + expect(firstCell(rendered, "│").bg).toBe(bg.sgr); 112 + }); 113 + 114 + it("leaves existing border-cell bg unchanged when border bg is omitted", async () => { 115 + let term = await createTerm({ width: 12, height: 4 }); 116 + let bg = randomBgColor(); 117 + let ansi = decode( 118 + term.render([ 119 + open("box", { 120 + layout: { width: fixed(8), height: fixed(3), direction: "ttb" }, 121 + bg: bg.value, 122 + border: { 123 + color: rgba(255, 255, 255), 124 + left: 1, 125 + right: 1, 126 + top: 1, 127 + bottom: 1, 128 + }, 129 + }), 130 + text("Hi"), 131 + close(), 132 + ], { mode: "line" }).output, 133 + ); 134 + 135 + let rendered = cells(ansi); 136 + expect(firstCell(rendered, "┌").bg).toBe(bg.sgr); 137 + expect(firstCell(rendered, "─").bg).toBe(bg.sgr); 138 + expect(firstCell(rendered, "┐").bg).toBe(bg.sgr); 139 + expect(firstCell(rendered, "│").bg).toBe(bg.sgr); 140 + }); 141 + 44 142 it("fills glyph cells with the requested text-level bg", async () => { 45 143 let term = await createTerm({ width: 20, height: 1 }); 46 - let bg = randomTextBgColor(); 144 + let bg = randomBgColor(); 47 145 let ansi = decode( 48 146 term.render([ 49 147 open("root", { layout: { width: grow(), height: grow() } }), ··· 56 154 expect(beforeH).toContain(bg.sgr); 57 155 }); 58 156 157 + it("resets border bg on subsequent frames without border bg", async () => { 158 + let term = await createTerm({ width: 12, height: 4 }); 159 + let bg = randomBgColor(); 160 + 161 + // Frame 1: border with bg 162 + term.render([ 163 + open("box", { 164 + layout: { width: fixed(8), height: fixed(3), direction: "ttb" }, 165 + border: { 166 + color: rgba(255, 255, 255), 167 + bg: bg.value, 168 + left: 1, 169 + right: 1, 170 + top: 1, 171 + bottom: 1, 172 + }, 173 + }), 174 + text("Hi"), 175 + close(), 176 + ]); 177 + 178 + // Frame 2: same border, no bg 179 + let ansi = decode( 180 + term.render([ 181 + open("box", { 182 + layout: { width: fixed(8), height: fixed(3), direction: "ttb" }, 183 + border: { 184 + color: rgba(255, 255, 255), 185 + left: 1, 186 + right: 1, 187 + top: 1, 188 + bottom: 1, 189 + }, 190 + }), 191 + text("Hi"), 192 + close(), 193 + ]).output, 194 + ); 195 + 196 + expect(ansi).not.toContain(bg.sgr); 197 + expect(firstCell(cells(ansi), "┌").bg).toBeUndefined(); 198 + }); 199 + 59 200 it("resets the background before writing trailing cells", async () => { 60 201 let term = await createTerm({ width: 20, height: 1 }); 61 - let bg = randomTextBgColor(); 202 + let bg = randomBgColor(); 62 203 let ansi = decode( 63 204 term.render([ 64 205 open("root", { layout: { width: grow(), height: grow() } }),
+7
test/validate.test.ts
··· 79 79 expect(validate([text("hi", { color: 1.5 })])).toBe(false); 80 80 }); 81 81 82 + it("rejects fractional border background color", () => { 83 + expect(validate([ 84 + open("x", { border: { color: 0xFF0000, bg: 1.5, left: 1 } }), 85 + close(), 86 + ])).toBe(false); 87 + }); 88 + 82 89 it("accepts structured floating attach points", () => { 83 90 expect(validate([ 84 91 open("x", {
+1
validate.ts
··· 82 82 83 83 const Border = Type.Object({ 84 84 color: rgba, 85 + bg: Type.Optional(rgba), 85 86 left: Type.Optional(u8), 86 87 right: Type.Optional(u8), 87 88 top: Type.Optional(u8),