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

✨ per-side border support

expands per-side border attributes to allow for fg, width, bg properties
for each side of the border

Ryan Rauh (Jun 14, 2026, 10:30 AM EDT) 69601463 be7e746d

+741 -48
+2 -4
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); 43 42 44 43 const RED = rgba(255, 0, 0); 45 44 const ORANGE = rgba(255, 153, 0); ··· 85 84 ); 86 85 87 86 let first = term.render( 88 - box("Press any key to compile modules.", CYAN, GRAY, DARK_BG), 87 + box("Press any key to compile modules.", CYAN, GRAY), 89 88 { row }, 90 89 ); 91 90 write(new Uint8Array(first.output)); ··· 102 101 `${icon} ${label} ${time}`, 103 102 done ? GREEN : CYAN, 104 103 done ? GREEN : GRAY, 105 - DARK_BG, 106 104 ), 107 105 { row }, 108 106 ); ··· 350 348 } 351 349 } 352 350 353 - function box(msg: string, fg: number, border: number, bg: number): Op[] { 351 + function box(msg: string, fg: number, border: number, bg?: number): Op[] { 354 352 return [ 355 353 open("root", { 356 354 layout: { width: grow(), height: grow(), direction: "ttb" },
+45 -15
ops.ts
··· 53 53 return o; 54 54 } 55 55 56 + function sideWidth(side: BorderSide | undefined): number { 57 + return typeof side === "number" ? side : side?.width ?? 0; 58 + } 59 + 60 + function sideFg(side: BorderSide | undefined, shared: number): number { 61 + let color = typeof side === "object" && side.color !== undefined 62 + ? side.color 63 + : shared; 64 + return color & 0x00FFFFFF; 65 + } 66 + 67 + function sideBg( 68 + side: BorderSide | undefined, 69 + shared: number | undefined, 70 + ): number { 71 + let bg = typeof side === "object" && side.bg !== undefined ? side.bg : shared; 72 + // ATTR_DEFAULT sentinel (bit 31 set) means "keep the existing cell bg" 73 + return bg === undefined ? 0x80000000 : bg & 0x00FFFFFF; 74 + } 75 + 56 76 function packString( 57 77 view: DataView, 58 78 bytes: Uint8Array, ··· 162 182 163 183 if (op.border) { 164 184 let b = op.border; 165 - view.setUint32(o, b.color, true); 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 - 173 185 view.setUint32( 174 186 o, 175 - (b.left ?? 0) | ((b.right ?? 0) << 8) | ((b.top ?? 0) << 16) | 176 - ((b.bottom ?? 0) << 24), 187 + sideWidth(b.left) | (sideWidth(b.right) << 8) | 188 + (sideWidth(b.top) << 16) | (sideWidth(b.bottom) << 24), 177 189 true, 178 190 ); 179 191 o += 4; 192 + 193 + // Resolved per-side attributes (CSS-like fallback expansion done 194 + // here, not in C): fg/bg word pairs in top, right, bottom, left 195 + // order. The C renderer consumes these as explicit values. 196 + for (let side of [b.top, b.right, b.bottom, b.left]) { 197 + view.setUint32(o, sideFg(side, b.color), true); 198 + o += 4; 199 + view.setUint32(o, sideBg(side, b.bg), true); 200 + o += 4; 201 + } 180 202 } 181 203 182 204 if (op.clip) { ··· 300 322 directive: typeof OP_CLOSE_ELEMENT; 301 323 } 302 324 325 + export type BorderSide = 326 + | number 327 + | { 328 + width: number; 329 + color?: number; 330 + bg?: number; 331 + }; 332 + 303 333 export interface OpenElement { 304 334 directive: typeof OP_OPEN_ELEMENT; 305 335 id: string; ··· 317 347 border?: { 318 348 color: number; 319 349 bg?: number; 320 - left?: number; 321 - right?: number; 322 - top?: number; 323 - bottom?: number; 350 + left?: BorderSide; 351 + right?: BorderSide; 352 + top?: BorderSide; 353 + bottom?: BorderSide; 324 354 }; 325 355 clip?: { horizontal?: boolean; vertical?: boolean }; 326 356 floating?: { ··· 453 483 if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align 454 484 if (op.bg !== undefined) n += 4; 455 485 if (op.cornerRadius) n += 4; 456 - if (op.border) n += 12; 486 + if (op.border) n += 36; // widths word + 4 sides × (fg + bg) 457 487 if (op.clip) n += 4; 458 488 // x, y, expand width/height, parent, attach/pointer, clip/z 459 489 if (op.floating) n += 7 * 4;
+46 -8
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, border color, and border background 648 - color 647 + - **`border`** — per-side border configuration. Each side field (`top`, `right`, 648 + `bottom`, `left`) accepts either a scalar width or a structured object 649 + `{ width, color?, bg? }`. The shared `color` field is required and is the 650 + fallback foreground for every side; the optional shared `bg` field is the 651 + fallback border-cell background for every side 649 652 - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing 650 653 characters 651 654 - **`clip`** — clip region configuration for scroll containers ··· 709 712 These property groups represent the current implementation surface. New groups 710 713 and fields have been added incrementally and more may follow. 711 714 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. 715 + **Border sides.** Each border side is declared independently as either a scalar 716 + width (`top: 1`) or a structured object (`top: { width: 1, color?, bg? }`). The 717 + two forms are equivalent when the object form provides only `width`. A side is 718 + enabled when its resolved width is greater than zero; an omitted side or a side 719 + with width `0` MUST NOT be drawn. Scalar side declarations MUST keep their 720 + pre-existing behavior. 721 + 722 + **Border side colors (fallback resolution).** Side attributes resolve in a 723 + CSS-like shorthand/longhand fashion before rendering: 724 + 725 + - A structured side with `color` MUST render with that foreground color. A 726 + scalar side, or a structured side that omits `color`, MUST fall back to the 727 + shared `border.color`. The shared `color` remains required. 728 + - A structured side with `bg` MUST render border cells of that side with that 729 + background color. A scalar side, or a structured side that omits `bg`, MUST 730 + fall back to the shared `border.bg` when it is provided. 731 + - When neither the side nor the shared border provides `bg`, border rendering 732 + MUST NOT override the background already present in each border cell of that 733 + side; element backgrounds established by `open({ bg })` remain in effect, and 734 + the terminal default remains in effect where no element background applies. 735 + 736 + Fallback resolution is performed on the TypeScript side before the frame is 737 + transferred; the WASM renderer consumes explicit per-side attributes and does 738 + not implement the public fallback rules. 739 + 740 + **Independent sides and corners.** Each enabled side renders as a straight edge 741 + (`─` for horizontal sides, `│` for vertical sides). A corner glyph MUST be 742 + rendered only when both adjacent sides for that corner are enabled; when either 743 + adjacent side is absent, the present side continues straight through the 744 + endpoint with no corner glyph. A left-only border is therefore a plain vertical 745 + line, and a top-plus-bottom border is two plain horizontal rules. 746 + 747 + **Corner styling approximation.** A terminal cell carries a single glyph, 748 + foreground, and background, so CSS-style diagonally split corners cannot be 749 + represented. When corners are rendered: top corners (`┌`, `┐`, and their rounded 750 + variants) MUST use the resolved attributes of the `top` side, and bottom corners 751 + (`└`, `┘`, and their rounded variants) MUST use the resolved attributes of the 752 + `bottom` side. Left and right side attributes apply to vertical edge cells 753 + excluding joined corner cells. Per-side attributes affect only the styling of 754 + corner cells; corner glyph shape selection (including rounded corners via 755 + `cornerRadius`) is unchanged. 718 756 719 757 **Border width and layout interaction.** In the underlying layout engine (Clay), 720 758 border configuration does not affect layout computation. This is Clay's intended
+31 -17
src/clayterm.c
··· 303 303 static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1, 304 304 Clay_RenderCommand *cmd) { 305 305 Clay_BorderRenderData *b = &cmd->renderData.border; 306 - uint32_t fg = color(b->color); 307 - /* userData is currently exclusively the packed border-bg word. */ 308 - uint32_t bg = (uint32_t)(uintptr_t)cmd->userData; 306 + /* userData points at eight words in the command buffer carrying resolved 307 + * per-side attributes as fg/bg pairs in top, right, bottom, left order. 308 + * Fallback resolution (shared color/bg vs side overrides) happens on the 309 + * TypeScript side; this renderer consumes explicit values only. The 310 + * command buffer outlives the render pass within reduce(). */ 311 + const uint32_t *s = (const uint32_t *)cmd->userData; 312 + uint32_t deffg = color(b->color); 313 + uint32_t top_fg = s ? s[0] : deffg, top_bg = s ? s[1] : ATTR_DEFAULT; 314 + uint32_t right_fg = s ? s[2] : deffg, right_bg = s ? s[3] : ATTR_DEFAULT; 315 + uint32_t bot_fg = s ? s[4] : deffg, bot_bg = s ? s[5] : ATTR_DEFAULT; 316 + uint32_t left_fg = s ? s[6] : deffg, left_bg = s ? s[7] : ATTR_DEFAULT; 309 317 int top = b->width.top > 0; 310 318 int bot = b->width.bottom > 0; 311 319 int left = b->width.left > 0; 312 320 int right = b->width.right > 0; 313 321 314 - /* corners — rounded when corner radius > 0 */ 322 + /* corners — rounded when corner radius > 0. Drawn only when both adjacent 323 + * sides are enabled; a terminal cell holds a single fg/bg, so top corners 324 + * take the top side attributes and bottom corners take the bottom side 325 + * attributes (deterministic approximation of CSS split corners). */ 315 326 uint32_t tl = b->cornerRadius.topLeft > 0 ? 0x256d : 0x250c; 316 327 uint32_t tr = b->cornerRadius.topRight > 0 ? 0x256e : 0x2510; 317 328 uint32_t bl = b->cornerRadius.bottomLeft > 0 ? 0x2570 : 0x2514; 318 329 uint32_t br = b->cornerRadius.bottomRight > 0 ? 0x256f : 0x2518; 319 330 320 331 if (top && left) 321 - setcell(ct, x0, y0, tl, fg, bg); 332 + setcell(ct, x0, y0, tl, top_fg, top_bg); 322 333 if (top && right) 323 - setcell(ct, x1 - 1, y0, tr, fg, bg); 334 + setcell(ct, x1 - 1, y0, tr, top_fg, top_bg); 324 335 if (bot && left) 325 - setcell(ct, x0, y1 - 1, bl, fg, bg); 336 + setcell(ct, x0, y1 - 1, bl, bot_fg, bot_bg); 326 337 if (bot && right) 327 - setcell(ct, x1 - 1, y1 - 1, br, fg, bg); 338 + setcell(ct, x1 - 1, y1 - 1, br, bot_fg, bot_bg); 328 339 329 340 /* horizontal edges */ 330 341 if (top) 331 342 for (int x = x0 + left; x < x1 - right; x++) 332 - setcell(ct, x, y0, 0x2500, fg, bg); 343 + setcell(ct, x, y0, 0x2500, top_fg, top_bg); 333 344 if (bot) 334 345 for (int x = x0 + left; x < x1 - right; x++) 335 - setcell(ct, x, y1 - 1, 0x2500, fg, bg); 346 + setcell(ct, x, y1 - 1, 0x2500, bot_fg, bot_bg); 336 347 337 - /* vertical edges */ 348 + /* vertical edges — excluding joined corner cells owned by top/bottom */ 338 349 if (left) 339 350 for (int y = y0 + top; y < y1 - bot; y++) 340 - setcell(ct, x0, y, 0x2502, fg, bg); 351 + setcell(ct, x0, y, 0x2502, left_fg, left_bg); 341 352 if (right) 342 353 for (int y = y0 + top; y < y1 - bot; y++) 343 - setcell(ct, x1 - 1, y, 0x2502, fg, bg); 354 + setcell(ct, x1 - 1, y, 0x2502, right_fg, right_bg); 344 355 } 345 356 346 357 /* ── Command buffer helpers ───────────────────────────────────────── */ ··· 533 544 } 534 545 535 546 if (mask & PROP_BORDER) { 536 - decl.border.color = unpack_color(rd(buf, len, &i)); 537 - 538 - decl.userData = (void *)(uintptr_t)rd(buf, len, &i); 539 - 540 547 uint32_t bw = rd(buf, len, &i); 541 548 decl.border.width.left = bw & 0xff; 542 549 decl.border.width.right = (bw >> 8) & 0xff; 543 550 decl.border.width.top = (bw >> 16) & 0xff; 544 551 decl.border.width.bottom = (bw >> 24) & 0xff; 552 + 553 + /* Resolved per-side fg/bg attribute words (top, right, bottom, 554 + * left). Routed to render_border via userData; the command buffer 555 + * remains valid for the whole render pass. */ 556 + if (i + 8 <= len) 557 + decl.userData = (void *)&buf[i]; 558 + i += 8; 545 559 } 546 560 547 561 if (mask & PROP_CLIP) {
+480
test/border.test.ts
··· 1 + import { close, fixed, open, rgba } from "../ops.ts"; 2 + import { createTerm } from "../term.ts"; 3 + import { describe, expect, it } from "./suite.ts"; 4 + 5 + const decode = (b: Uint8Array) => new TextDecoder().decode(b); 6 + 7 + /* ── Deterministic test colors ────────────────────────────────────── */ 8 + 9 + const WHITE = rgba(255, 255, 255); 10 + const RED = rgba(255, 0, 0); 11 + const GREEN = rgba(0, 255, 0); 12 + const BLUE = rgba(0, 0, 255); 13 + const YELLOW = rgba(255, 255, 0); 14 + const MAGENTA = rgba(255, 0, 255); 15 + const CYAN = rgba(0, 255, 255); 16 + 17 + const FG = { 18 + white: "\x1b[38;2;255;255;255", 19 + red: "\x1b[38;2;255;0;0", 20 + green: "\x1b[38;2;0;255;0", 21 + blue: "\x1b[38;2;0;0;255", 22 + yellow: "\x1b[38;2;255;255;0", 23 + magenta: "\x1b[38;2;255;0;255", 24 + cyan: "\x1b[38;2;0;255;255", 25 + }; 26 + 27 + const BG = { 28 + white: "\x1b[48;2;255;255;255", 29 + red: "\x1b[48;2;255;0;0", 30 + green: "\x1b[48;2;0;255;0", 31 + blue: "\x1b[48;2;0;0;255", 32 + yellow: "\x1b[48;2;255;255;0", 33 + magenta: "\x1b[48;2;255;0;255", 34 + cyan: "\x1b[48;2;0;255;255", 35 + }; 36 + 37 + /* ── ANSI cell parser ─────────────────────────────────────────────── */ 38 + 39 + type ParsedCell = { 40 + x: number; 41 + y: number; 42 + ch: string; 43 + fg?: string; 44 + bg?: string; 45 + }; 46 + 47 + function cells(ansi: string): ParsedCell[] { 48 + let result: ParsedCell[] = []; 49 + let fg: string | undefined; 50 + let bg: string | undefined; 51 + let x = 0; 52 + let y = 0; 53 + 54 + for (let i = 0; i < ansi.length;) { 55 + if (ansi[i] === "\x1b" && ansi[i + 1] === "[") { 56 + let end = i + 2; 57 + while (end < ansi.length && !/[A-Za-z]/.test(ansi[end])) { 58 + end++; 59 + } 60 + 61 + let seq = ansi.slice(i, end + 1); 62 + if (seq === "\x1b[0m") { 63 + fg = undefined; 64 + bg = undefined; 65 + } else if (seq.startsWith("\x1b[38;2;") && seq.endsWith("m")) { 66 + fg = seq.slice(0, -1); 67 + } else if (seq.startsWith("\x1b[48;2;") && seq.endsWith("m")) { 68 + bg = seq.slice(0, -1); 69 + } 70 + 71 + i = end + 1; 72 + continue; 73 + } 74 + 75 + if (ansi[i] === "\n") { 76 + y++; 77 + x = 0; 78 + i++; 79 + continue; 80 + } 81 + 82 + result.push({ x, y, ch: ansi[i], fg, bg }); 83 + x++; 84 + i++; 85 + } 86 + 87 + return result; 88 + } 89 + 90 + function at(parsed: ParsedCell[], x: number, y: number): ParsedCell { 91 + let cell = parsed.find((c) => c.x === x && c.y === y); 92 + expect(cell).toBeDefined(); 93 + return cell!; 94 + } 95 + 96 + function glyphs(parsed: ParsedCell[], chars: string): ParsedCell[] { 97 + return parsed.filter((c) => chars.includes(c.ch)); 98 + } 99 + 100 + const CORNERS = "┌┐└┘╭╮╰╯"; 101 + 102 + /* ── Render helper ────────────────────────────────────────────────── */ 103 + 104 + // deno-lint-ignore no-explicit-any 105 + type OpenProps = any; 106 + 107 + /** Renders an 8x4 "box" element at the origin of a 12x5 term in line 108 + * mode and parses the full-frame output into cells. Box corners are at 109 + * (0,0), (7,0), (0,3), (7,3). */ 110 + async function renderBox(props: OpenProps): Promise<ParsedCell[]> { 111 + let term = await createTerm({ width: 12, height: 5 }); 112 + let ansi = decode( 113 + term.render([ 114 + open("box", { 115 + layout: { width: fixed(8), height: fixed(4) }, 116 + ...props, 117 + }), 118 + close(), 119 + ], { mode: "line" }).output, 120 + ); 121 + return cells(ansi); 122 + } 123 + 124 + /* ── Tests ────────────────────────────────────────────────────────── */ 125 + 126 + describe("scalar sides", () => { 127 + it("renders a full box from scalar widths with the shared color", async () => { 128 + let parsed = await renderBox({ 129 + border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, 130 + }); 131 + 132 + expect(at(parsed, 0, 0).ch).toBe("┌"); 133 + expect(at(parsed, 7, 0).ch).toBe("┐"); 134 + expect(at(parsed, 0, 3).ch).toBe("└"); 135 + expect(at(parsed, 7, 3).ch).toBe("┘"); 136 + expect(at(parsed, 3, 0).ch).toBe("─"); 137 + expect(at(parsed, 3, 3).ch).toBe("─"); 138 + expect(at(parsed, 0, 1).ch).toBe("│"); 139 + expect(at(parsed, 7, 1).ch).toBe("│"); 140 + 141 + for (let cell of glyphs(parsed, "┌┐└┘─│")) { 142 + expect(cell.fg).toBe(FG.white); 143 + } 144 + }); 145 + 146 + it("applies shared bg to scalar sides", async () => { 147 + let parsed = await renderBox({ 148 + border: { color: WHITE, bg: BLUE, top: 1, right: 1, bottom: 1, left: 1 }, 149 + }); 150 + 151 + for (let cell of glyphs(parsed, "┌┐└┘─│")) { 152 + expect(cell.bg).toBe(BG.blue); 153 + } 154 + }); 155 + }); 156 + 157 + describe("structured sides", () => { 158 + it("accepts every structured side form and resolves fallbacks", async () => { 159 + let parsed = await renderBox({ 160 + border: { 161 + color: WHITE, 162 + bg: MAGENTA, 163 + top: { width: 1 }, 164 + right: { width: 1, color: RED }, 165 + bottom: { width: 1, bg: BLUE }, 166 + left: { width: 1, color: GREEN, bg: YELLOW }, 167 + }, 168 + }); 169 + 170 + // top: shared color, shared bg 171 + expect(at(parsed, 3, 0).ch).toBe("─"); 172 + expect(at(parsed, 3, 0).fg).toBe(FG.white); 173 + expect(at(parsed, 3, 0).bg).toBe(BG.magenta); 174 + 175 + // right: own color, shared bg 176 + expect(at(parsed, 7, 1).ch).toBe("│"); 177 + expect(at(parsed, 7, 1).fg).toBe(FG.red); 178 + expect(at(parsed, 7, 1).bg).toBe(BG.magenta); 179 + 180 + // bottom: shared color, own bg 181 + expect(at(parsed, 3, 3).ch).toBe("─"); 182 + expect(at(parsed, 3, 3).fg).toBe(FG.white); 183 + expect(at(parsed, 3, 3).bg).toBe(BG.blue); 184 + 185 + // left: own color, own bg 186 + expect(at(parsed, 0, 1).ch).toBe("│"); 187 + expect(at(parsed, 0, 1).fg).toBe(FG.green); 188 + expect(at(parsed, 0, 1).bg).toBe(BG.yellow); 189 + }); 190 + 191 + it("overrides shared color per side; omitted colors inherit it", async () => { 192 + let parsed = await renderBox({ 193 + border: { 194 + color: WHITE, 195 + top: { width: 1, color: RED }, 196 + bottom: { width: 1, color: GREEN }, 197 + left: 1, 198 + right: { width: 1 }, 199 + }, 200 + }); 201 + 202 + expect(at(parsed, 3, 0).fg).toBe(FG.red); 203 + expect(at(parsed, 3, 3).fg).toBe(FG.green); 204 + expect(at(parsed, 0, 1).fg).toBe(FG.white); 205 + expect(at(parsed, 7, 1).fg).toBe(FG.white); 206 + }); 207 + 208 + it("overrides shared bg per side; omitted bgs inherit it", async () => { 209 + let parsed = await renderBox({ 210 + border: { 211 + color: WHITE, 212 + bg: BLUE, 213 + top: 1, 214 + bottom: { width: 1 }, 215 + left: { width: 1, bg: RED }, 216 + right: 1, 217 + }, 218 + }); 219 + 220 + expect(at(parsed, 3, 0).bg).toBe(BG.blue); // scalar side, shared bg 221 + expect(at(parsed, 3, 3).bg).toBe(BG.blue); // structured side, shared bg 222 + expect(at(parsed, 0, 1).bg).toBe(BG.red); // structured side, own bg 223 + expect(at(parsed, 7, 1).bg).toBe(BG.blue); // scalar side, shared bg 224 + }); 225 + 226 + it("preserves the element bg when no border bg is provided", async () => { 227 + let parsed = await renderBox({ 228 + bg: CYAN, 229 + border: { 230 + color: WHITE, 231 + top: { width: 1, color: RED }, 232 + right: { width: 1 }, 233 + bottom: 1, 234 + left: 1, 235 + }, 236 + }); 237 + 238 + for (let cell of glyphs(parsed, "┌┐└┘─│")) { 239 + expect(cell.bg).toBe(BG.cyan); 240 + } 241 + }); 242 + 243 + it("emits no border bg when neither side nor shared bg is set", async () => { 244 + let parsed = await renderBox({ 245 + border: { 246 + color: WHITE, 247 + top: { width: 1, color: RED }, 248 + right: 1, 249 + bottom: { width: 1 }, 250 + left: 1, 251 + }, 252 + }); 253 + 254 + for (let cell of glyphs(parsed, "┌┐└┘─│")) { 255 + expect(cell.bg).toBeUndefined(); 256 + } 257 + }); 258 + 259 + it("does not retain a prior frame's side bg", async () => { 260 + let term = await createTerm({ width: 12, height: 5 }); 261 + let frame = (bg?: number) => [ 262 + open("box", { 263 + layout: { width: fixed(8), height: fixed(4) }, 264 + border: { 265 + color: WHITE, 266 + top: bg === undefined ? { width: 1 } : { width: 1, bg }, 267 + right: 1, 268 + bottom: 1, 269 + left: 1, 270 + }, 271 + }), 272 + close(), 273 + ]; 274 + 275 + term.render(frame(BLUE)); 276 + let ansi = decode(term.render(frame()).output); 277 + 278 + expect(ansi).not.toContain(BG.blue); 279 + let top = cells(ansi).find((c) => c.ch === "─"); 280 + expect(top).toBeDefined(); 281 + expect(top!.bg).toBeUndefined(); 282 + }); 283 + }); 284 + 285 + describe("independent sides", () => { 286 + it("draws only sides with resolved width > 0", async () => { 287 + let drawn = await renderBox({ 288 + border: { color: WHITE, top: { width: 1 } }, 289 + }); 290 + expect(glyphs(drawn, "─").length).toBe(8); 291 + 292 + let zeroObject = await renderBox({ 293 + border: { color: WHITE, top: { width: 0 }, left: 1 }, 294 + }); 295 + expect(glyphs(zeroObject, "─").length).toBe(0); 296 + 297 + let zeroScalar = await renderBox({ 298 + border: { color: WHITE, top: 0, left: 1 }, 299 + }); 300 + expect(glyphs(zeroScalar, "─").length).toBe(0); 301 + }); 302 + 303 + it("renders a left-only border as a straight vertical line", async () => { 304 + let parsed = await renderBox({ border: { color: WHITE, left: 1 } }); 305 + expect(glyphs(parsed, "│").length).toBe(4); 306 + expect(glyphs(parsed, "│").every((c) => c.x === 0)).toBe(true); 307 + expect(glyphs(parsed, CORNERS).length).toBe(0); 308 + }); 309 + 310 + it("renders a right-only border as a straight vertical line", async () => { 311 + let parsed = await renderBox({ border: { color: WHITE, right: 1 } }); 312 + expect(glyphs(parsed, "│").length).toBe(4); 313 + expect(glyphs(parsed, "│").every((c) => c.x === 7)).toBe(true); 314 + expect(glyphs(parsed, CORNERS).length).toBe(0); 315 + }); 316 + 317 + it("renders a top-only border as a straight horizontal line", async () => { 318 + let parsed = await renderBox({ border: { color: WHITE, top: 1 } }); 319 + expect(glyphs(parsed, "─").length).toBe(8); 320 + expect(glyphs(parsed, "─").every((c) => c.y === 0)).toBe(true); 321 + expect(glyphs(parsed, CORNERS).length).toBe(0); 322 + }); 323 + 324 + it("renders a bottom-only border as a straight horizontal line", async () => { 325 + let parsed = await renderBox({ border: { color: WHITE, bottom: 1 } }); 326 + expect(glyphs(parsed, "─").length).toBe(8); 327 + expect(glyphs(parsed, "─").every((c) => c.y === 3)).toBe(true); 328 + expect(glyphs(parsed, CORNERS).length).toBe(0); 329 + }); 330 + 331 + it("renders top + bottom as two straight lines without corners", async () => { 332 + let parsed = await renderBox({ 333 + border: { color: WHITE, top: 1, bottom: 1 }, 334 + }); 335 + expect(glyphs(parsed, "─").length).toBe(16); 336 + expect(glyphs(parsed, "│").length).toBe(0); 337 + expect(glyphs(parsed, CORNERS).length).toBe(0); 338 + }); 339 + }); 340 + 341 + describe("corners", () => { 342 + it("creates a corner only where both adjacent sides are enabled", async () => { 343 + let tl = await renderBox({ border: { color: WHITE, top: 1, left: 1 } }); 344 + expect(at(tl, 0, 0).ch).toBe("┌"); 345 + expect(glyphs(tl, CORNERS).length).toBe(1); 346 + 347 + let tr = await renderBox({ border: { color: WHITE, top: 1, right: 1 } }); 348 + expect(at(tr, 7, 0).ch).toBe("┐"); 349 + expect(glyphs(tr, CORNERS).length).toBe(1); 350 + 351 + let bl = await renderBox({ border: { color: WHITE, bottom: 1, left: 1 } }); 352 + expect(at(bl, 0, 3).ch).toBe("└"); 353 + expect(glyphs(bl, CORNERS).length).toBe(1); 354 + 355 + let br = await renderBox({ border: { color: WHITE, bottom: 1, right: 1 } }); 356 + expect(at(br, 7, 3).ch).toBe("┘"); 357 + expect(glyphs(br, CORNERS).length).toBe(1); 358 + }); 359 + 360 + it("draws no corner when an adjacent side has zero width", async () => { 361 + let scalarZero = await renderBox({ 362 + border: { color: WHITE, top: 1, left: 0 }, 363 + }); 364 + expect(glyphs(scalarZero, CORNERS).length).toBe(0); 365 + 366 + let objectZero = await renderBox({ 367 + border: { color: WHITE, bottom: 1, right: { width: 0 } }, 368 + }); 369 + expect(glyphs(objectZero, CORNERS).length).toBe(0); 370 + }); 371 + 372 + it("styles top corners from top and bottom corners from bottom", async () => { 373 + let parsed = await renderBox({ 374 + border: { 375 + color: WHITE, 376 + top: { width: 1, color: RED, bg: MAGENTA }, 377 + right: { width: 1, color: YELLOW }, 378 + bottom: { width: 1, color: GREEN, bg: CYAN }, 379 + left: { width: 1, color: BLUE }, 380 + }, 381 + }); 382 + 383 + // top corners take top attributes 384 + expect(at(parsed, 0, 0).ch).toBe("┌"); 385 + expect(at(parsed, 0, 0).fg).toBe(FG.red); 386 + expect(at(parsed, 0, 0).bg).toBe(BG.magenta); 387 + expect(at(parsed, 7, 0).ch).toBe("┐"); 388 + expect(at(parsed, 7, 0).fg).toBe(FG.red); 389 + expect(at(parsed, 7, 0).bg).toBe(BG.magenta); 390 + 391 + // bottom corners take bottom attributes 392 + expect(at(parsed, 0, 3).ch).toBe("└"); 393 + expect(at(parsed, 0, 3).fg).toBe(FG.green); 394 + expect(at(parsed, 0, 3).bg).toBe(BG.cyan); 395 + expect(at(parsed, 7, 3).ch).toBe("┘"); 396 + expect(at(parsed, 7, 3).fg).toBe(FG.green); 397 + expect(at(parsed, 7, 3).bg).toBe(BG.cyan); 398 + 399 + // horizontal edges remain continuous with their corners 400 + expect(at(parsed, 3, 0).fg).toBe(FG.red); 401 + expect(at(parsed, 3, 3).fg).toBe(FG.green); 402 + 403 + // non-corner vertical edge cells take left/right attributes 404 + expect(at(parsed, 0, 1).fg).toBe(FG.blue); 405 + expect(at(parsed, 0, 2).fg).toBe(FG.blue); 406 + expect(at(parsed, 7, 1).fg).toBe(FG.yellow); 407 + expect(at(parsed, 7, 2).fg).toBe(FG.yellow); 408 + }); 409 + 410 + it("keeps rounded corner glyphs; side attrs only restyle them", async () => { 411 + let parsed = await renderBox({ 412 + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, 413 + border: { 414 + color: WHITE, 415 + top: { width: 1, color: RED }, 416 + right: 1, 417 + bottom: { width: 1, color: GREEN }, 418 + left: 1, 419 + }, 420 + }); 421 + 422 + expect(at(parsed, 0, 0).ch).toBe("╭"); 423 + expect(at(parsed, 7, 0).ch).toBe("╮"); 424 + expect(at(parsed, 0, 3).ch).toBe("╰"); 425 + expect(at(parsed, 7, 3).ch).toBe("╯"); 426 + expect(at(parsed, 0, 0).fg).toBe(FG.red); 427 + expect(at(parsed, 7, 0).fg).toBe(FG.red); 428 + expect(at(parsed, 0, 3).fg).toBe(FG.green); 429 + expect(at(parsed, 7, 3).fg).toBe(FG.green); 430 + }); 431 + }); 432 + 433 + describe("directive model", () => { 434 + it("keeps structured side declarations as plain data", () => { 435 + let directive = open("box", { 436 + border: { color: WHITE, top: { width: 1, color: RED } }, 437 + }); 438 + 439 + expect(Object.getPrototypeOf(directive)).toBe(Object.prototype); 440 + expect(directive.border?.top).toEqual({ width: 1, color: RED }); 441 + }); 442 + }); 443 + 444 + describe("instances", () => { 445 + it("does not share side attributes between Term instances", async () => { 446 + let a = await createTerm({ width: 12, height: 5 }); 447 + let b = await createTerm({ width: 12, height: 5 }); 448 + 449 + let frame = (top: number, bottom: number) => [ 450 + open("box", { 451 + layout: { width: fixed(8), height: fixed(4) }, 452 + border: { 453 + color: WHITE, 454 + top: { width: 1, color: top }, 455 + bottom: { width: 1, color: bottom }, 456 + }, 457 + }), 458 + close(), 459 + ]; 460 + 461 + let ansiA = decode(a.render(frame(RED, GREEN), { mode: "line" }).output); 462 + let ansiB = decode(b.render(frame(BLUE, YELLOW), { mode: "line" }).output); 463 + 464 + expect(ansiA).toContain(FG.red); 465 + expect(ansiA).toContain(FG.green); 466 + expect(ansiA).not.toContain(FG.blue); 467 + expect(ansiA).not.toContain(FG.yellow); 468 + 469 + expect(ansiB).toContain(FG.blue); 470 + expect(ansiB).toContain(FG.yellow); 471 + expect(ansiB).not.toContain(FG.red); 472 + expect(ansiB).not.toContain(FG.green); 473 + 474 + // re-rendering A must not affect B's next frame 475 + a.render(frame(MAGENTA, CYAN), { mode: "line" }); 476 + let again = decode(b.render(frame(BLUE, YELLOW), { mode: "line" }).output); 477 + expect(again).not.toContain(FG.magenta); 478 + expect(again).not.toContain(FG.cyan); 479 + }); 480 + });
+124
test/validate.test.ts
··· 132 132 close(), 133 133 ])).toBe(false); 134 134 }); 135 + 136 + it("accepts structured border side objects", () => { 137 + expect(validate([ 138 + open("x", { 139 + border: { 140 + color: 0xFF0000, 141 + top: { width: 1 }, 142 + right: { width: 1, color: 0x00FF00 }, 143 + bottom: { width: 1, bg: 0x0000FF }, 144 + left: { width: 1, color: 0x00FF00, bg: 0x0000FF }, 145 + }, 146 + }), 147 + close(), 148 + ])).toBe(true); 149 + }); 150 + 151 + it("rejects structured border side missing width", () => { 152 + expect(validate([ 153 + { directive: 0x02, id: "x", border: { color: 0xFF0000, top: {} } }, 154 + close(), 155 + ])).toBe(false); 156 + expect(validate([ 157 + { 158 + directive: 0x02, 159 + id: "x", 160 + border: { color: 0xFF0000, top: { color: 0x00FF00 } }, 161 + }, 162 + close(), 163 + ])).toBe(false); 164 + }); 165 + 166 + it("rejects invalid structured border side widths", () => { 167 + for (let width of [-1, 1.5, 256]) { 168 + expect(validate([ 169 + open("x", { border: { color: 0xFF0000, top: { width } } }), 170 + close(), 171 + ])).toBe(false); 172 + expect(validate([ 173 + open("x", { border: { color: 0xFF0000, left: { width } } }), 174 + close(), 175 + ])).toBe(false); 176 + } 177 + }); 178 + 179 + it("rejects invalid structured border side colors", () => { 180 + for (let color of [1.5, 0x1FFFFFFFF, -0x80000001]) { 181 + expect(validate([ 182 + open("x", { border: { color: 0xFF0000, top: { width: 1, color } } }), 183 + close(), 184 + ])).toBe(false); 185 + } 186 + }); 187 + 188 + it("rejects invalid structured border side backgrounds", () => { 189 + for (let bg of [1.5, 0x1FFFFFFFF, -0x80000001]) { 190 + expect(validate([ 191 + open("x", { border: { color: 0xFF0000, top: { width: 1, bg } } }), 192 + close(), 193 + ])).toBe(false); 194 + } 195 + }); 196 + 197 + it("still rejects invalid scalar border side widths", () => { 198 + for (let width of [-1, 1.5, 256]) { 199 + expect(validate([ 200 + open("x", { border: { color: 0xFF0000, top: width } }), 201 + close(), 202 + ])).toBe(false); 203 + expect(validate([ 204 + open("x", { border: { color: 0xFF0000, left: width } }), 205 + close(), 206 + ])).toBe(false); 207 + } 208 + }); 209 + 210 + it("rejects a border without shared color even with side colors", () => { 211 + expect(validate([ 212 + { 213 + directive: 0x02, 214 + id: "x", 215 + border: { top: { width: 1, color: 0xFF0000 } }, 216 + }, 217 + close(), 218 + ])).toBe(false); 219 + }); 135 220 }); 136 221 137 222 describe("validated", () => { ··· 163 248 // without weakening the TypeScript surface. 164 249 expect(() => Reflect.apply(term.render, term, [[{ directive: 0xff }]])) 165 250 .toThrow(TypeError); 251 + }); 252 + 253 + it("renders valid structured border sides normally", () => { 254 + let out = decode( 255 + term.render([ 256 + open("box", { 257 + layout: { width: grow(), height: grow() }, 258 + border: { 259 + color: 0xFFFFFF, 260 + top: { width: 1, color: 0xFF0000 }, 261 + right: 1, 262 + bottom: { width: 1, bg: 0x0000FF }, 263 + left: { width: 1 }, 264 + }, 265 + }), 266 + close(), 267 + ]).output, 268 + ); 269 + expect(out).toContain("┌"); 270 + }); 271 + 272 + it("throws on a structured border side missing width", () => { 273 + let invalid = [ 274 + { directive: 0x02, id: "x", border: { color: 0xFF0000, top: {} } }, 275 + close(), 276 + ]; 277 + // deno-lint-ignore no-explicit-any 278 + expect(() => term.render(invalid as any)).toThrow(TypeError); 279 + }); 280 + 281 + it("throws on an invalid structured border side color", () => { 282 + expect(() => 283 + term.render([ 284 + open("x", { 285 + border: { color: 0xFF0000, top: { width: 1, color: 1.5 } }, 286 + }), 287 + close(), 288 + ]) 289 + ).toThrow(TypeError); 166 290 }); 167 291 });
+13 -4
validate.ts
··· 80 80 br: Type.Optional(u8), 81 81 }); 82 82 83 + const BorderSide = Type.Union([ 84 + u8, 85 + Type.Object({ 86 + width: u8, 87 + color: Type.Optional(rgba), 88 + bg: Type.Optional(rgba), 89 + }), 90 + ]); 91 + 83 92 const Border = Type.Object({ 84 93 color: rgba, 85 94 bg: Type.Optional(rgba), 86 - left: Type.Optional(u8), 87 - right: Type.Optional(u8), 88 - top: Type.Optional(u8), 89 - bottom: Type.Optional(u8), 95 + left: Type.Optional(BorderSide), 96 + right: Type.Optional(BorderSide), 97 + top: Type.Optional(BorderSide), 98 + bottom: Type.Optional(BorderSide), 90 99 }); 91 100 92 101 const Clip = Type.Object({