[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 glyph-cell text backgrounds (#93)

* ✨ Add glyph-cell text backgrounds

Packs a bg color into the pack string

after decoding the bg color, its pass through to the render text method
properly

* chore: update the render-spec

* Add a very usage of text bg in the examples

* Adds a demo that looks like a code diff to show all the different colors features in action

* adds test to verify the bg color is reset have the directive

* move our tests into color.test.ts

authored by

Ryan Rauh and committed by
GitHub
(Jun 9, 2026, 8:40 AM EDT) 9c1f6cd2 0c13b1e1

+287 -9
+188
examples/diff/index.ts
··· 1 + /** 2 + * Diff demo — renders a code-review style diff with inline text backgrounds. 3 + * 4 + * Shows how text-level `bg` can highlight only changed glyph cells without 5 + * filling the entire row or layout box. 6 + */ 7 + 8 + import { Buffer } from "node:buffer"; 9 + import process from "node:process"; 10 + import { 11 + close, 12 + createTerm, 13 + CSI, 14 + fixed, 15 + grow, 16 + type Op, 17 + open, 18 + rgba, 19 + text, 20 + } from "../../mod.ts"; 21 + import { validated } from "../../validate.ts"; 22 + 23 + const encode = (s: string) => new TextEncoder().encode(s); 24 + const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b)); 25 + 26 + const BG = rgba(31, 45, 34); 27 + const FG = rgba(220, 220, 210); 28 + const MUTED = rgba(140, 145, 140); 29 + const PATH = rgba(139, 210, 210); 30 + const DELETE = rgba(255, 105, 105); 31 + const DELETE_BG = rgba(184, 92, 85); 32 + const ADD = rgba(190, 210, 100); 33 + const ADD_BG = rgba(190, 210, 100); 34 + const INLINE_FG = rgba(31, 45, 34); 35 + 36 + const rows: Row[] = [ 37 + { 38 + kind: "title", 39 + segments: [ 40 + { value: "edit ", color: FG }, 41 + { value: "examples/inline-regions/index.ts", color: PATH }, 42 + ], 43 + }, 44 + { kind: "blank" }, 45 + code(" ", MUTED, [{ value: "...", color: MUTED }]), 46 + code(" 35", MUTED, [ 47 + { 48 + value: 49 + "const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b));", 50 + }, 51 + ]), 52 + code(" 36", MUTED, [{ value: "" }]), 53 + code(" 37", MUTED, [{ value: "const WHITE = rgba(255, 255, 255);" }]), 54 + code(" 38", MUTED, [{ value: "const GREEN = rgba(80, 250, 123);" }]), 55 + code("- 39", DELETE, [ 56 + { value: "const " }, 57 + mark("AGREEN", DELETE_BG), 58 + { value: " = rgba(" }, 59 + mark("80", DELETE_BG), 60 + { value: ", " }, 61 + mark("250", DELETE_BG), 62 + { value: ", " }, 63 + mark("123, 10", DELETE_BG), 64 + { value: ");" }, 65 + ]), 66 + code("+ 39", ADD, [ 67 + { value: "const " }, 68 + mark("GREEN_BG", ADD_BG), 69 + { value: " = rgba(" }, 70 + mark("20", ADD_BG), 71 + { value: ", " }, 72 + mark("70", ADD_BG), 73 + { value: ", " }, 74 + mark("38", ADD_BG), 75 + { value: ");" }, 76 + ]), 77 + code(" 40", MUTED, [{ value: "const GRAY = rgba(100, 100, 100);" }]), 78 + code(" 41", MUTED, [{ value: "const CYAN = rgba(139, 233, 253);" }]), 79 + code(" 42", MUTED, [{ value: "" }]), 80 + code(" 43", MUTED, [{ value: "const RED = rgba(255, 0, 0);" }]), 81 + code(" ", MUTED, [{ value: "...", color: MUTED }]), 82 + code(" 136", MUTED, [{ value: " height: fixed(1)," }]), 83 + code(" 137", MUTED, [{ value: ' direction: "ltr",' }]), 84 + code(" 138", MUTED, [{ value: " }," }]), 85 + code(" 139", MUTED, [{ value: " })," }]), 86 + code("-140", DELETE, [ 87 + { value: ' text(" ✓ Frobnicated ", { color: WHITE, bg: ' }, 88 + mark("AGREEN", DELETE_BG), 89 + { value: " })," }, 90 + ]), 91 + code("+140", ADD, [ 92 + { value: ' text(" ✓ Frobnicated ", { color: WHITE, bg: ' }, 93 + mark("GREEN_BG", ADD_BG), 94 + { value: " })," }, 95 + ]), 96 + code(" 141", MUTED, [{ value: " close()," }]), 97 + code(" 142", MUTED, [{ value: " ];" }]), 98 + code(" 143", MUTED, [{ value: " }" }]), 99 + code(" 144", MUTED, [{ value: " let progress = i / (barFrames - 1);" }]), 100 + code(" ", MUTED, [{ value: "...", color: MUTED }]), 101 + ]; 102 + 103 + let { columns } = terminalSize(); 104 + let height = rows.length + 2; 105 + let term = validated(await createTerm({ width: columns, height })); 106 + let result = term.render(renderDiff(columns, height), { mode: "line" }); 107 + write(new Uint8Array(result.output)); 108 + write(CSI("0m")); 109 + write(encode("\n")); 110 + 111 + interface Segment { 112 + value: string; 113 + color?: number; 114 + bg?: number; 115 + } 116 + 117 + interface Row { 118 + kind: "blank" | "code" | "title"; 119 + gutter?: string; 120 + color?: number; 121 + segments?: Segment[]; 122 + } 123 + 124 + function code(gutter: string, color: number, segments: Segment[]): Row { 125 + return { kind: "code", gutter, color, segments }; 126 + } 127 + 128 + function mark(value: string, bg: number): Segment { 129 + return { value, color: INLINE_FG, bg }; 130 + } 131 + 132 + function renderDiff(width: number, height: number): Op[] { 133 + let ops: Op[] = [ 134 + open("root", { 135 + layout: { 136 + width: fixed(width), 137 + height: fixed(height), 138 + direction: "ttb", 139 + padding: { left: 1, top: 1 }, 140 + }, 141 + bg: BG, 142 + }), 143 + ]; 144 + 145 + rows.forEach((row, index) => { 146 + ops.push( 147 + open(`row-${index}`, { 148 + layout: { width: grow(), height: fixed(1), direction: "ltr" }, 149 + }), 150 + ); 151 + 152 + if (row.kind === "blank") { 153 + ops.push(close()); 154 + return; 155 + } 156 + 157 + if (row.kind === "title") { 158 + for (let segment of row.segments ?? []) { 159 + ops.push(text(segment.value, { color: segment.color })); 160 + } 161 + ops.push(close()); 162 + return; 163 + } 164 + 165 + ops.push(text(`${row.gutter ?? ""} `, { color: row.color })); 166 + for (let segment of row.segments ?? []) { 167 + ops.push( 168 + text(segment.value, { 169 + color: segment.color ?? row.color, 170 + bg: segment.bg, 171 + }), 172 + ); 173 + } 174 + ops.push(close()); 175 + }); 176 + 177 + ops.push(close()); 178 + return ops; 179 + } 180 + 181 + function terminalSize(): { columns: number; rows: number } { 182 + return process.stdout.isTTY 183 + ? { 184 + columns: process.stdout.columns ?? 100, 185 + rows: process.stdout.rows ?? 24, 186 + } 187 + : { columns: 100, rows: 24 }; 188 + }
+3 -1
examples/inline-regions/index.ts
··· 34 34 const encode = (s: string) => new TextEncoder().encode(s); 35 35 const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b)); 36 36 37 + const WHITE = rgba(255, 255, 255); 37 38 const GREEN = rgba(80, 250, 123); 39 + const GREEN_BG = rgba(20, 70, 38); 38 40 const GRAY = rgba(100, 100, 100); 39 41 const CYAN = rgba(139, 233, 253); 40 42 ··· 135 137 direction: "ltr", 136 138 }, 137 139 }), 138 - text("✓ Frobnicated", { color: GREEN }), 140 + text(" ✓ Frobnicated ", { color: WHITE, bg: GREEN_BG }), 139 141 close(), 140 142 ]; 141 143 }
+10 -1
ops.ts
··· 208 208 let textDefault = op.color === undefined; 209 209 view.setUint32(o, op.color ?? 0, true); 210 210 o += 4; 211 + 212 + // No explicit bg: leave the terminal default bg by writing 213 + // 0 and setting ATTR_DEFAULT (0x80 in the attrs byte). The C path ORs 214 + // it into bg and emit_attr skips the background SGR 215 + let bg = op.bg === undefined ? 0x80000000 : op.bg & 0x00FFFFFF; 216 + view.setUint32(o, bg, true); 217 + o += 4; 218 + 211 219 view.setUint32( 212 220 o, 213 221 (op.fontSize ?? 1) | ··· 302 310 directive: typeof OP_TEXT; 303 311 content: string; 304 312 color?: number; 313 + bg?: number; 305 314 fontSize?: number; 306 315 fontId?: number; 307 316 wrap?: number; ··· 356 365 break; 357 366 } 358 367 case OP_TEXT: { 359 - n += 4 + 4 + 4; // opcode + color + cfg 368 + n += 4 + 4 + 4 + 4; // opcode + color + bg + cfg 360 369 n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string 361 370 break; 362 371 }
+11 -3
specs/renderer-spec.md
··· 400 400 401 401 Text directives MUST appear between a matching open/close pair. 402 402 403 + When `props.bg` is provided, the renderer MUST apply that background color only 404 + to cells occupied by glyphs emitted by that text directive. It MUST NOT fill 405 + trailing cells or other cells in the text element's bounding rectangle that are 406 + not occupied by emitted glyphs. When `props.bg` is omitted, text rendering MUST 407 + NOT override the background already present in each glyph cell; element 408 + backgrounds established by `open({ bg })` remain in effect, and the terminal 409 + default remains in effect where no element background applies. 410 + 403 411 The set of styling properties accepted by `props` is part of the current 404 412 implementation surface and may be extended. 405 413 ··· 644 652 attach points, z-index) 645 653 - **`scroll`** — scroll container configuration 646 654 647 - The `text()` constructor currently accepts: `color`, `fontSize`, 648 - `letterSpacing`, `lineHeight`, and attribute flags (`bold`, `italic`, 649 - `underline`, `strikethrough`). 655 + The `text()` constructor accepts: `color`, `bg`, `fontSize`, `letterSpacing`, 656 + `lineHeight`, and attribute flags (`bold`, `italic`, `underline`, 657 + `strikethrough`). 650 658 651 659 These property groups represent the current implementation surface. New groups 652 660 and fields have been added incrementally and more may follow. Alignment values
+8 -3
src/clayterm.c
··· 267 267 } 268 268 269 269 static void render_text(struct Clayterm *ct, int x0, int y0, 270 - Clay_TextRenderData *t) { 270 + Clay_RenderCommand *cmd) { 271 + 272 + Clay_TextRenderData *t = &cmd->renderData.text; 273 + uint32_t bg = (uint32_t)(uintptr_t)cmd->userData; 271 274 uint32_t fg = color(t->textColor); 272 275 273 276 /* text attrs are packed into the alpha channel by reduce() */ ··· 289 292 if (cw < 0) 290 293 cw = 1; 291 294 if (cw > 0) { 292 - setcell(ct, x, y0, cp, fg, ATTR_DEFAULT); 295 + setcell(ct, x, y0, cp, fg, bg); 293 296 x += cw; 294 297 } 295 298 p += n; ··· 561 564 562 565 case OP_TEXT: { 563 566 uint32_t col = rd(buf, len, &i); 567 + uint32_t bg = rd(buf, len, &i); 564 568 uint32_t cfg = rd(buf, len, &i); 565 569 uint32_t str_len = rd(buf, len, &i); 566 570 int str_words = (str_len + 3) / 4; ··· 570 574 Clay_String text = {.length = (int32_t)str_len, .chars = str_chars}; 571 575 572 576 Clay_TextElementConfig config = {0}; 577 + config.userData = (void *)(uintptr_t)bg; 573 578 config.textColor = unpack_color(col); 574 579 config.fontSize = cfg & 0xff; 575 580 config.fontId = (cfg >> 8) & 0xff; ··· 613 618 render_rect(ct, x0, y0, x1, y1, &cmd->renderData.rectangle); 614 619 break; 615 620 case CLAY_RENDER_COMMAND_TYPE_TEXT: 616 - render_text(ct, x0, y0, &cmd->renderData.text); 621 + render_text(ct, x0, y0, cmd); 617 622 break; 618 623 case CLAY_RENDER_COMMAND_TYPE_BORDER: 619 624 render_border(ct, x0, y0, x1, y1, &cmd->renderData.border);
+66 -1
test/color.test.ts
··· 1 - import { text } from "../ops.ts"; 1 + import { close, 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 = { 8 + value: number; 9 + sgr: string; 10 + }; 11 + 12 + function randomTextBgColor(): TextBgColor { 13 + let r = 0; 14 + let g = 0; 15 + let b = 0; 16 + 17 + do { 18 + r = Math.floor(Math.random() * 256); 19 + g = Math.floor(Math.random() * 256); 20 + b = Math.floor(Math.random() * 256); 21 + } while ( 22 + (r === 255 && g === 0 && b === 0) || 23 + (r === 0 && g === 255 && b === 0) || 24 + (r === 0 && g === 0 && b === 255) 25 + ); 26 + 27 + return { 28 + value: rgba(r, g, b), 29 + sgr: `\x1b[48;2;${r};${g};${b}`, 30 + }; 31 + } 32 + 7 33 describe("foreground", () => { 8 34 it("emits uncolored text with no foreground", async () => { 9 35 let term = await createTerm({ width: 12, height: 1 }); ··· 13 39 expect(ansi).not.toContain("\x1b[38;2;255;255;255"); 14 40 }); 15 41 }); 42 + 43 + describe("background", () => { 44 + it("fills glyph cells with the requested text-level bg", async () => { 45 + let term = await createTerm({ width: 20, height: 1 }); 46 + let bg = randomTextBgColor(); 47 + let ansi = decode( 48 + term.render([ 49 + open("root", { layout: { width: grow(), height: grow() } }), 50 + text("Hi", { bg: bg.value }), 51 + close(), 52 + ]).output, 53 + ); 54 + 55 + let beforeH = ansi.slice(0, ansi.indexOf("H")); 56 + expect(beforeH).toContain(bg.sgr); 57 + }); 58 + 59 + it("resets the background before writing trailing cells", async () => { 60 + let term = await createTerm({ width: 20, height: 1 }); 61 + let bg = randomTextBgColor(); 62 + let ansi = decode( 63 + term.render([ 64 + open("root", { layout: { width: grow(), height: grow() } }), 65 + text("Hi", { bg: bg.value }), 66 + close(), 67 + ]).output, 68 + ); 69 + 70 + let beforeH = ansi.slice(0, ansi.indexOf("H")); 71 + expect(beforeH).toContain(bg.sgr); 72 + 73 + let hi = ansi.indexOf("Hi"); 74 + expect(hi).toBeGreaterThanOrEqual(0); 75 + 76 + let afterHi = ansi.slice(hi + 2); 77 + expect(afterHi).not.toContain(bg.sgr); 78 + expect(afterHi.startsWith("\x1b[0m ")).toBe(true); 79 + }); 80 + });
+1
validate.ts
··· 108 108 directive: Type.Literal(0x03), 109 109 content: Type.String(), 110 110 color: Type.Optional(rgba), 111 + bg: Type.Optional(rgba), 111 112 fontSize: Type.Optional(u8), 112 113 fontId: Type.Optional(u8), 113 114 wrap: Type.Optional(u8),