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

Merge pull request #96 from bombshell-dev/per-side-border-69

✨ per-side border support

authored by

paul valladares and committed by
GitHub
(Jun 24, 2026, 12:24 PM -0500) 12cb0a42 83d04751

+770 -52
+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" },
+47 -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 + // Must match render_border() in src/clayterm.c. 194 + // Resolve CSS-like side fallbacks here, then write eight required 195 + // attribute words: fg/bg pairs in top, right, bottom, left order. 196 + // C treats the presence and order of these words as a wire-format 197 + // invariant and only consumes the explicit values. 198 + for (let side of [b.top, b.right, b.bottom, b.left]) { 199 + view.setUint32(o, sideFg(side, b.color), true); 200 + o += 4; 201 + view.setUint32(o, sideBg(side, b.bg), true); 202 + o += 4; 203 + } 180 204 } 181 205 182 206 if (op.clip) { ··· 300 324 directive: typeof OP_CLOSE_ELEMENT; 301 325 } 302 326 327 + export type BorderSide = 328 + | number 329 + | { 330 + width: number; 331 + color?: number; 332 + bg?: number; 333 + }; 334 + 303 335 export interface OpenElement { 304 336 directive: typeof OP_OPEN_ELEMENT; 305 337 id: string; ··· 317 349 border?: { 318 350 color: number; 319 351 bg?: number; 320 - left?: number; 321 - right?: number; 322 - top?: number; 323 - bottom?: number; 352 + left?: BorderSide; 353 + right?: BorderSide; 354 + top?: BorderSide; 355 + bottom?: BorderSide; 324 356 }; 325 357 clip?: { horizontal?: boolean; vertical?: boolean }; 326 358 floating?: { ··· 453 485 if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align 454 486 if (op.bg !== undefined) n += 4; 455 487 if (op.cornerRadius) n += 4; 456 - if (op.border) n += 12; 488 + if (op.border) n += 36; // widths word + 4 sides × (fg + bg) 457 489 if (op.clip) n += 4; 458 490 // x, y, expand width/height, parent, attach/pointer, clip/z 459 491 if (op.floating) n += 7 * 4;
+46 -8
specs/renderer-spec.md
··· 681 681 padding (per-side), alignment (`alignX`: `"left"` | `"center"` | `"right"`; 682 682 `alignY`: `"top"` | `"center"` | `"bottom"`, defaulting to left/top when 683 683 omitted), direction (top-to-bottom or left-to-right), and gap 684 - - **`border`** — per-side border widths, border color, and border background 685 - color 684 + - **`border`** — per-side border configuration. Each side field (`top`, `right`, 685 + `bottom`, `left`) accepts either a scalar width or a structured object 686 + `{ width, color?, bg? }`. The shared `color` field is required and is the 687 + fallback foreground for every side; the optional shared `bg` field is the 688 + fallback border-cell background for every side 686 689 - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing 687 690 characters 688 691 - **`clip`** — Declares the element as a clip region (see §7.5). Currently ··· 749 752 These property groups represent the current implementation surface. New groups 750 753 and fields have been added incrementally and more may follow. 751 754 752 - **Border background.** When `border.bg` is provided, the renderer MUST apply 753 - that background color to all cells occupied by border glyphs (corners, 754 - horizontal edges, and vertical edges). When `border.bg` is omitted, border 755 - rendering MUST NOT override the background already present in each border cell; 756 - element backgrounds established by `open({ bg })` remain in effect, and the 757 - terminal default remains in effect where no element background applies. 755 + **Border sides.** Each border side is declared independently as either a scalar 756 + width (`top: 1`) or a structured object (`top: { width: 1, color?, bg? }`). The 757 + two forms are equivalent when the object form provides only `width`. A side is 758 + enabled when its resolved width is greater than zero; an omitted side or a side 759 + with width `0` MUST NOT be drawn. Scalar side declarations MUST keep their 760 + pre-existing behavior. 761 + 762 + **Border side colors (fallback resolution).** Side attributes resolve in a 763 + CSS-like shorthand/longhand fashion before rendering: 764 + 765 + - A structured side with `color` MUST render with that foreground color. A 766 + scalar side, or a structured side that omits `color`, MUST fall back to the 767 + shared `border.color`. The shared `color` remains required. 768 + - A structured side with `bg` MUST render border cells of that side with that 769 + background color. A scalar side, or a structured side that omits `bg`, MUST 770 + fall back to the shared `border.bg` when it is provided. 771 + - When neither the side nor the shared border provides `bg`, border rendering 772 + MUST NOT override the background already present in each border cell of that 773 + side; element backgrounds established by `open({ bg })` remain in effect, and 774 + the terminal default remains in effect where no element background applies. 775 + 776 + Fallback resolution is performed on the TypeScript side before the frame is 777 + transferred; the WASM renderer consumes explicit per-side attributes and does 778 + not implement the public fallback rules. 779 + 780 + **Independent sides and corners.** Each enabled side renders as a straight edge 781 + (`─` for horizontal sides, `│` for vertical sides). A corner glyph MUST be 782 + rendered only when both adjacent sides for that corner are enabled; when either 783 + adjacent side is absent, the present side continues straight through the 784 + endpoint with no corner glyph. A left-only border is therefore a plain vertical 785 + line, and a top-plus-bottom border is two plain horizontal rules. 786 + 787 + **Corner styling approximation.** A terminal cell carries a single glyph, 788 + foreground, and background, so CSS-style diagonally split corners cannot be 789 + represented. When corners are rendered: top corners (`┌`, `┐`, and their rounded 790 + variants) MUST use the resolved attributes of the `top` side, and bottom corners 791 + (`└`, `┘`, and their rounded variants) MUST use the resolved attributes of the 792 + `bottom` side. Left and right side attributes apply to vertical edge cells 793 + excluding joined corner cells. Per-side attributes affect only the styling of 794 + corner cells; corner glyph shape selection (including rounded corners via 795 + `cornerRadius`) is unchanged. 758 796 759 797 **Border width and layout interaction.** In the underlying layout engine (Clay), 760 798 border configuration does not affect layout computation. This is Clay's intended
+40 -17
src/clayterm.c
··· 325 325 static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1, 326 326 Clay_RenderCommand *cmd) { 327 327 Clay_BorderRenderData *b = &cmd->renderData.border; 328 - uint32_t fg = color(b->color); 329 - /* userData is currently exclusively the packed border-bg word. */ 330 - uint32_t bg = (uint32_t)(uintptr_t)cmd->userData; 328 + /* Must match border packing in ops.ts. 329 + * userData points at eight required words in the command buffer: resolved 330 + * fg/bg pairs in top, right, bottom, left order. Fallback resolution 331 + * (shared color/bg vs side overrides) happens on the TypeScript side; this 332 + * renderer consumes explicit values only. The command buffer outlives the 333 + * render pass within reduce(). Missing userData is a wire-format violation. 334 + */ 335 + const uint32_t *s = (const uint32_t *)cmd->userData; 336 + if (s == NULL) { 337 + __builtin_trap(); 338 + } 339 + 340 + uint32_t top_fg = s[0]; 341 + uint32_t top_bg = s[1]; 342 + uint32_t right_fg = s[2]; 343 + uint32_t right_bg = s[3]; 344 + uint32_t bot_fg = s[4]; 345 + uint32_t bot_bg = s[5]; 346 + uint32_t left_fg = s[6]; 347 + uint32_t left_bg = s[7]; 331 348 int top = b->width.top > 0; 332 349 int bot = b->width.bottom > 0; 333 350 int left = b->width.left > 0; 334 351 int right = b->width.right > 0; 335 352 336 - /* corners — rounded when corner radius > 0 */ 353 + /* corners — rounded when corner radius > 0. Drawn only when both adjacent 354 + * sides are enabled; a terminal cell holds a single fg/bg, so top corners 355 + * take the top side attributes and bottom corners take the bottom side 356 + * attributes (deterministic approximation of CSS split corners). */ 337 357 uint32_t tl = b->cornerRadius.topLeft > 0 ? 0x256d : 0x250c; 338 358 uint32_t tr = b->cornerRadius.topRight > 0 ? 0x256e : 0x2510; 339 359 uint32_t bl = b->cornerRadius.bottomLeft > 0 ? 0x2570 : 0x2514; 340 360 uint32_t br = b->cornerRadius.bottomRight > 0 ? 0x256f : 0x2518; 341 361 342 362 if (top && left) 343 - setcell(ct, x0, y0, tl, fg, bg); 363 + setcell(ct, x0, y0, tl, top_fg, top_bg); 344 364 if (top && right) 345 - setcell(ct, x1 - 1, y0, tr, fg, bg); 365 + setcell(ct, x1 - 1, y0, tr, top_fg, top_bg); 346 366 if (bot && left) 347 - setcell(ct, x0, y1 - 1, bl, fg, bg); 367 + setcell(ct, x0, y1 - 1, bl, bot_fg, bot_bg); 348 368 if (bot && right) 349 - setcell(ct, x1 - 1, y1 - 1, br, fg, bg); 369 + setcell(ct, x1 - 1, y1 - 1, br, bot_fg, bot_bg); 350 370 351 371 /* horizontal edges */ 352 372 if (top) 353 373 for (int x = x0 + left; x < x1 - right; x++) 354 - setcell(ct, x, y0, 0x2500, fg, bg); 374 + setcell(ct, x, y0, 0x2500, top_fg, top_bg); 355 375 if (bot) 356 376 for (int x = x0 + left; x < x1 - right; x++) 357 - setcell(ct, x, y1 - 1, 0x2500, fg, bg); 377 + setcell(ct, x, y1 - 1, 0x2500, bot_fg, bot_bg); 358 378 359 - /* vertical edges */ 379 + /* vertical edges — excluding joined corner cells owned by top/bottom */ 360 380 if (left) 361 381 for (int y = y0 + top; y < y1 - bot; y++) 362 - setcell(ct, x0, y, 0x2502, fg, bg); 382 + setcell(ct, x0, y, 0x2502, left_fg, left_bg); 363 383 if (right) 364 384 for (int y = y0 + top; y < y1 - bot; y++) 365 - setcell(ct, x1 - 1, y, 0x2502, fg, bg); 385 + setcell(ct, x1 - 1, y, 0x2502, right_fg, right_bg); 366 386 } 367 387 368 388 /* ── Command buffer helpers ───────────────────────────────────────── */ ··· 577 597 } 578 598 579 599 if (mask & PROP_BORDER) { 580 - decl.border.color = unpack_color(rd(buf, len, &i)); 581 - 582 - decl.userData = (void *)(uintptr_t)rd(buf, len, &i); 583 - 584 600 uint32_t bw = rd(buf, len, &i); 585 601 decl.border.width.left = bw & 0xff; 586 602 decl.border.width.right = (bw >> 8) & 0xff; 587 603 decl.border.width.top = (bw >> 16) & 0xff; 588 604 decl.border.width.bottom = (bw >> 24) & 0xff; 605 + 606 + /* Resolved per-side fg/bg attribute words (top, right, bottom, 607 + * left). Routed to render_border via userData; the command buffer 608 + * remains valid for the whole render pass. */ 609 + if (i + 8 <= len) 610 + decl.userData = (void *)&buf[i]; 611 + i += 8; 589 612 } 590 613 591 614 if (mask & PROP_CLIP) {
+18 -4
term-native.ts
··· 16 16 17 17 const BOUNDING_BOX = offsets(BoundingBoxStruct); 18 18 19 + const WASM_PAGE_BYTES = 65536; 20 + const TEXT_TRANSFER_BUFFER_BYTES = 1024 * 1024; 21 + const CLAY_DEFAULT_MAX_ELEMENT_COUNT = 8192; 22 + 23 + // Conservative fixed wire-format budget per element. This covers the largest 24 + // non-text open/close element encoding we currently support; text, element id, 25 + // and snapshot payload bytes live in TEXT_TRANSFER_BUFFER_BYTES. 26 + const MAX_FIXED_ELEMENT_WIRE_BYTES = 116; 27 + 19 28 export interface Native { 20 29 memory: WebAssembly.Memory; 21 30 statePtr: number; ··· 92 101 let heap = ct.__heap_base.value as number; 93 102 let size = ct.clayterm_size(w, h); 94 103 95 - // grow memory to fit heap + state + ops buffer (1MB headroom for ops) 96 - let needed = heap + size + 1024 * 1024; 97 - let pages = Math.ceil(needed / 65536); 98 - let current = memory.buffer.byteLength / 65536; 104 + // Grow memory once to fit heap + renderer state + fixed transfer buffer. 105 + // The transfer budget is intentionally fixed: text/id/snapshot payload bytes 106 + // get 1MB, and fixed op overhead gets one max-sized element per Clay element. 107 + // Do not grow this dynamically per render; improve the wire format instead. 108 + let transferBytes = TEXT_TRANSFER_BUFFER_BYTES + 109 + CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES; 110 + let needed = heap + size + transferBytes; 111 + let pages = Math.ceil(needed / WASM_PAGE_BYTES); 112 + let current = memory.buffer.byteLength / WASM_PAGE_BYTES; 99 113 if (pages > current) { 100 114 memory.grow(pages - current); 101 115 }
+479
test/border.test.ts
··· 1 + import { close, fixed, open, type OpenElement, 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 + type OpenProps = Omit<OpenElement, "directive" | "id">; 105 + 106 + /** Renders an 8x4 "box" element at the origin of a 12x5 term in line 107 + * mode and parses the full-frame output into cells. Box corners are at 108 + * (0,0), (7,0), (0,3), (7,3). */ 109 + async function renderBox(props: OpenProps): Promise<ParsedCell[]> { 110 + let term = await createTerm({ width: 12, height: 5 }); 111 + let ansi = decode( 112 + term.render([ 113 + open("box", { 114 + layout: { width: fixed(8), height: fixed(4) }, 115 + ...props, 116 + }), 117 + close(), 118 + ], { mode: "line" }).output, 119 + ); 120 + return cells(ansi); 121 + } 122 + 123 + /* ── Tests ────────────────────────────────────────────────────────── */ 124 + 125 + describe("scalar sides", () => { 126 + it("renders a full box from scalar widths with the shared color", async () => { 127 + let parsed = await renderBox({ 128 + border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, 129 + }); 130 + 131 + expect(at(parsed, 0, 0).ch).toBe("┌"); 132 + expect(at(parsed, 7, 0).ch).toBe("┐"); 133 + expect(at(parsed, 0, 3).ch).toBe("└"); 134 + expect(at(parsed, 7, 3).ch).toBe("┘"); 135 + expect(at(parsed, 3, 0).ch).toBe("─"); 136 + expect(at(parsed, 3, 3).ch).toBe("─"); 137 + expect(at(parsed, 0, 1).ch).toBe("│"); 138 + expect(at(parsed, 7, 1).ch).toBe("│"); 139 + 140 + for (let cell of glyphs(parsed, "┌┐└┘─│")) { 141 + expect(cell.fg).toBe(FG.white); 142 + } 143 + }); 144 + 145 + it("applies shared bg to scalar sides", async () => { 146 + let parsed = await renderBox({ 147 + border: { color: WHITE, bg: BLUE, top: 1, right: 1, bottom: 1, left: 1 }, 148 + }); 149 + 150 + for (let cell of glyphs(parsed, "┌┐└┘─│")) { 151 + expect(cell.bg).toBe(BG.blue); 152 + } 153 + }); 154 + }); 155 + 156 + describe("structured sides", () => { 157 + it("accepts every structured side form and resolves fallbacks", async () => { 158 + let parsed = await renderBox({ 159 + border: { 160 + color: WHITE, 161 + bg: MAGENTA, 162 + top: { width: 1 }, 163 + right: { width: 1, color: RED }, 164 + bottom: { width: 1, bg: BLUE }, 165 + left: { width: 1, color: GREEN, bg: YELLOW }, 166 + }, 167 + }); 168 + 169 + // top: shared color, shared bg 170 + expect(at(parsed, 3, 0).ch).toBe("─"); 171 + expect(at(parsed, 3, 0).fg).toBe(FG.white); 172 + expect(at(parsed, 3, 0).bg).toBe(BG.magenta); 173 + 174 + // right: own color, shared bg 175 + expect(at(parsed, 7, 1).ch).toBe("│"); 176 + expect(at(parsed, 7, 1).fg).toBe(FG.red); 177 + expect(at(parsed, 7, 1).bg).toBe(BG.magenta); 178 + 179 + // bottom: shared color, own bg 180 + expect(at(parsed, 3, 3).ch).toBe("─"); 181 + expect(at(parsed, 3, 3).fg).toBe(FG.white); 182 + expect(at(parsed, 3, 3).bg).toBe(BG.blue); 183 + 184 + // left: own color, own bg 185 + expect(at(parsed, 0, 1).ch).toBe("│"); 186 + expect(at(parsed, 0, 1).fg).toBe(FG.green); 187 + expect(at(parsed, 0, 1).bg).toBe(BG.yellow); 188 + }); 189 + 190 + it("overrides shared color per side; omitted colors inherit it", async () => { 191 + let parsed = await renderBox({ 192 + border: { 193 + color: WHITE, 194 + top: { width: 1, color: RED }, 195 + bottom: { width: 1, color: GREEN }, 196 + left: 1, 197 + right: { width: 1 }, 198 + }, 199 + }); 200 + 201 + expect(at(parsed, 3, 0).fg).toBe(FG.red); 202 + expect(at(parsed, 3, 3).fg).toBe(FG.green); 203 + expect(at(parsed, 0, 1).fg).toBe(FG.white); 204 + expect(at(parsed, 7, 1).fg).toBe(FG.white); 205 + }); 206 + 207 + it("overrides shared bg per side; omitted bgs inherit it", async () => { 208 + let parsed = await renderBox({ 209 + border: { 210 + color: WHITE, 211 + bg: BLUE, 212 + top: 1, 213 + bottom: { width: 1 }, 214 + left: { width: 1, bg: RED }, 215 + right: 1, 216 + }, 217 + }); 218 + 219 + expect(at(parsed, 3, 0).bg).toBe(BG.blue); // scalar side, shared bg 220 + expect(at(parsed, 3, 3).bg).toBe(BG.blue); // structured side, shared bg 221 + expect(at(parsed, 0, 1).bg).toBe(BG.red); // structured side, own bg 222 + expect(at(parsed, 7, 1).bg).toBe(BG.blue); // scalar side, shared bg 223 + }); 224 + 225 + it("preserves the element bg when no border bg is provided", async () => { 226 + let parsed = await renderBox({ 227 + bg: CYAN, 228 + border: { 229 + color: WHITE, 230 + top: { width: 1, color: RED }, 231 + right: { width: 1 }, 232 + bottom: 1, 233 + left: 1, 234 + }, 235 + }); 236 + 237 + for (let cell of glyphs(parsed, "┌┐└┘─│")) { 238 + expect(cell.bg).toBe(BG.cyan); 239 + } 240 + }); 241 + 242 + it("emits no border bg when neither side nor shared bg is set", async () => { 243 + let parsed = await renderBox({ 244 + border: { 245 + color: WHITE, 246 + top: { width: 1, color: RED }, 247 + right: 1, 248 + bottom: { width: 1 }, 249 + left: 1, 250 + }, 251 + }); 252 + 253 + for (let cell of glyphs(parsed, "┌┐└┘─│")) { 254 + expect(cell.bg).toBeUndefined(); 255 + } 256 + }); 257 + 258 + it("does not retain a prior frame's side bg", async () => { 259 + let term = await createTerm({ width: 12, height: 5 }); 260 + let frame = (bg?: number) => [ 261 + open("box", { 262 + layout: { width: fixed(8), height: fixed(4) }, 263 + border: { 264 + color: WHITE, 265 + top: bg === undefined ? { width: 1 } : { width: 1, bg }, 266 + right: 1, 267 + bottom: 1, 268 + left: 1, 269 + }, 270 + }), 271 + close(), 272 + ]; 273 + 274 + term.render(frame(BLUE)); 275 + let ansi = decode(term.render(frame()).output); 276 + 277 + expect(ansi).not.toContain(BG.blue); 278 + let top = cells(ansi).find((c) => c.ch === "─"); 279 + expect(top).toBeDefined(); 280 + expect(top!.bg).toBeUndefined(); 281 + }); 282 + }); 283 + 284 + describe("independent sides", () => { 285 + it("draws only sides with resolved width > 0", async () => { 286 + let drawn = await renderBox({ 287 + border: { color: WHITE, top: { width: 1 } }, 288 + }); 289 + expect(glyphs(drawn, "─").length).toBe(8); 290 + 291 + let zeroObject = await renderBox({ 292 + border: { color: WHITE, top: { width: 0 }, left: 1 }, 293 + }); 294 + expect(glyphs(zeroObject, "─").length).toBe(0); 295 + 296 + let zeroScalar = await renderBox({ 297 + border: { color: WHITE, top: 0, left: 1 }, 298 + }); 299 + expect(glyphs(zeroScalar, "─").length).toBe(0); 300 + }); 301 + 302 + it("renders a left-only border as a straight vertical line", async () => { 303 + let parsed = await renderBox({ border: { color: WHITE, left: 1 } }); 304 + expect(glyphs(parsed, "│").length).toBe(4); 305 + expect(glyphs(parsed, "│").every((c) => c.x === 0)).toBe(true); 306 + expect(glyphs(parsed, CORNERS).length).toBe(0); 307 + }); 308 + 309 + it("renders a right-only border as a straight vertical line", async () => { 310 + let parsed = await renderBox({ border: { color: WHITE, right: 1 } }); 311 + expect(glyphs(parsed, "│").length).toBe(4); 312 + expect(glyphs(parsed, "│").every((c) => c.x === 7)).toBe(true); 313 + expect(glyphs(parsed, CORNERS).length).toBe(0); 314 + }); 315 + 316 + it("renders a top-only border as a straight horizontal line", async () => { 317 + let parsed = await renderBox({ border: { color: WHITE, top: 1 } }); 318 + expect(glyphs(parsed, "─").length).toBe(8); 319 + expect(glyphs(parsed, "─").every((c) => c.y === 0)).toBe(true); 320 + expect(glyphs(parsed, CORNERS).length).toBe(0); 321 + }); 322 + 323 + it("renders a bottom-only border as a straight horizontal line", async () => { 324 + let parsed = await renderBox({ border: { color: WHITE, bottom: 1 } }); 325 + expect(glyphs(parsed, "─").length).toBe(8); 326 + expect(glyphs(parsed, "─").every((c) => c.y === 3)).toBe(true); 327 + expect(glyphs(parsed, CORNERS).length).toBe(0); 328 + }); 329 + 330 + it("renders top + bottom as two straight lines without corners", async () => { 331 + let parsed = await renderBox({ 332 + border: { color: WHITE, top: 1, bottom: 1 }, 333 + }); 334 + expect(glyphs(parsed, "─").length).toBe(16); 335 + expect(glyphs(parsed, "│").length).toBe(0); 336 + expect(glyphs(parsed, CORNERS).length).toBe(0); 337 + }); 338 + }); 339 + 340 + describe("corners", () => { 341 + it("creates a corner only where both adjacent sides are enabled", async () => { 342 + let tl = await renderBox({ border: { color: WHITE, top: 1, left: 1 } }); 343 + expect(at(tl, 0, 0).ch).toBe("┌"); 344 + expect(glyphs(tl, CORNERS).length).toBe(1); 345 + 346 + let tr = await renderBox({ border: { color: WHITE, top: 1, right: 1 } }); 347 + expect(at(tr, 7, 0).ch).toBe("┐"); 348 + expect(glyphs(tr, CORNERS).length).toBe(1); 349 + 350 + let bl = await renderBox({ border: { color: WHITE, bottom: 1, left: 1 } }); 351 + expect(at(bl, 0, 3).ch).toBe("└"); 352 + expect(glyphs(bl, CORNERS).length).toBe(1); 353 + 354 + let br = await renderBox({ border: { color: WHITE, bottom: 1, right: 1 } }); 355 + expect(at(br, 7, 3).ch).toBe("┘"); 356 + expect(glyphs(br, CORNERS).length).toBe(1); 357 + }); 358 + 359 + it("draws no corner when an adjacent side has zero width", async () => { 360 + let scalarZero = await renderBox({ 361 + border: { color: WHITE, top: 1, left: 0 }, 362 + }); 363 + expect(glyphs(scalarZero, CORNERS).length).toBe(0); 364 + 365 + let objectZero = await renderBox({ 366 + border: { color: WHITE, bottom: 1, right: { width: 0 } }, 367 + }); 368 + expect(glyphs(objectZero, CORNERS).length).toBe(0); 369 + }); 370 + 371 + it("styles top corners from top and bottom corners from bottom", async () => { 372 + let parsed = await renderBox({ 373 + border: { 374 + color: WHITE, 375 + top: { width: 1, color: RED, bg: MAGENTA }, 376 + right: { width: 1, color: YELLOW }, 377 + bottom: { width: 1, color: GREEN, bg: CYAN }, 378 + left: { width: 1, color: BLUE }, 379 + }, 380 + }); 381 + 382 + // top corners take top attributes 383 + expect(at(parsed, 0, 0).ch).toBe("┌"); 384 + expect(at(parsed, 0, 0).fg).toBe(FG.red); 385 + expect(at(parsed, 0, 0).bg).toBe(BG.magenta); 386 + expect(at(parsed, 7, 0).ch).toBe("┐"); 387 + expect(at(parsed, 7, 0).fg).toBe(FG.red); 388 + expect(at(parsed, 7, 0).bg).toBe(BG.magenta); 389 + 390 + // bottom corners take bottom attributes 391 + expect(at(parsed, 0, 3).ch).toBe("└"); 392 + expect(at(parsed, 0, 3).fg).toBe(FG.green); 393 + expect(at(parsed, 0, 3).bg).toBe(BG.cyan); 394 + expect(at(parsed, 7, 3).ch).toBe("┘"); 395 + expect(at(parsed, 7, 3).fg).toBe(FG.green); 396 + expect(at(parsed, 7, 3).bg).toBe(BG.cyan); 397 + 398 + // horizontal edges remain continuous with their corners 399 + expect(at(parsed, 3, 0).fg).toBe(FG.red); 400 + expect(at(parsed, 3, 3).fg).toBe(FG.green); 401 + 402 + // non-corner vertical edge cells take left/right attributes 403 + expect(at(parsed, 0, 1).fg).toBe(FG.blue); 404 + expect(at(parsed, 0, 2).fg).toBe(FG.blue); 405 + expect(at(parsed, 7, 1).fg).toBe(FG.yellow); 406 + expect(at(parsed, 7, 2).fg).toBe(FG.yellow); 407 + }); 408 + 409 + it("keeps rounded corner glyphs; side attrs only restyle them", async () => { 410 + let parsed = await renderBox({ 411 + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, 412 + border: { 413 + color: WHITE, 414 + top: { width: 1, color: RED }, 415 + right: 1, 416 + bottom: { width: 1, color: GREEN }, 417 + left: 1, 418 + }, 419 + }); 420 + 421 + expect(at(parsed, 0, 0).ch).toBe("╭"); 422 + expect(at(parsed, 7, 0).ch).toBe("╮"); 423 + expect(at(parsed, 0, 3).ch).toBe("╰"); 424 + expect(at(parsed, 7, 3).ch).toBe("╯"); 425 + expect(at(parsed, 0, 0).fg).toBe(FG.red); 426 + expect(at(parsed, 7, 0).fg).toBe(FG.red); 427 + expect(at(parsed, 0, 3).fg).toBe(FG.green); 428 + expect(at(parsed, 7, 3).fg).toBe(FG.green); 429 + }); 430 + }); 431 + 432 + describe("directive model", () => { 433 + it("keeps structured side declarations as plain data", () => { 434 + let directive = open("box", { 435 + border: { color: WHITE, top: { width: 1, color: RED } }, 436 + }); 437 + 438 + expect(Object.getPrototypeOf(directive)).toBe(Object.prototype); 439 + expect(directive.border?.top).toEqual({ width: 1, color: RED }); 440 + }); 441 + }); 442 + 443 + describe("instances", () => { 444 + it("does not share side attributes between Term instances", async () => { 445 + let a = await createTerm({ width: 12, height: 5 }); 446 + let b = await createTerm({ width: 12, height: 5 }); 447 + 448 + let frame = (top: number, bottom: number) => [ 449 + open("box", { 450 + layout: { width: fixed(8), height: fixed(4) }, 451 + border: { 452 + color: WHITE, 453 + top: { width: 1, color: top }, 454 + bottom: { width: 1, color: bottom }, 455 + }, 456 + }), 457 + close(), 458 + ]; 459 + 460 + let ansiA = decode(a.render(frame(RED, GREEN), { mode: "line" }).output); 461 + let ansiB = decode(b.render(frame(BLUE, YELLOW), { mode: "line" }).output); 462 + 463 + expect(ansiA).toContain(FG.red); 464 + expect(ansiA).toContain(FG.green); 465 + expect(ansiA).not.toContain(FG.blue); 466 + expect(ansiA).not.toContain(FG.yellow); 467 + 468 + expect(ansiB).toContain(FG.blue); 469 + expect(ansiB).toContain(FG.yellow); 470 + expect(ansiB).not.toContain(FG.red); 471 + expect(ansiB).not.toContain(FG.green); 472 + 473 + // re-rendering A must not affect B's next frame 474 + a.render(frame(MAGENTA, CYAN), { mode: "line" }); 475 + let again = decode(b.render(frame(BLUE, YELLOW), { mode: "line" }).output); 476 + expect(again).not.toContain(FG.magenta); 477 + expect(again).not.toContain(FG.cyan); 478 + }); 479 + });
+125
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 + expect(() => Reflect.apply(term.render, term, [invalid])).toThrow( 278 + TypeError, 279 + ); 280 + }); 281 + 282 + it("throws on an invalid structured border side color", () => { 283 + expect(() => 284 + term.render([ 285 + open("x", { 286 + border: { color: 0xFF0000, top: { width: 1, color: 1.5 } }, 287 + }), 288 + close(), 289 + ]) 290 + ).toThrow(TypeError); 166 291 }); 167 292 });
+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({