From a5ef071cc48c552f36f30309aab5b6663ede01ec Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Tue, 9 Jun 2026 09:53:41 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20Add=20border-cell=20backgrounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/inline-regions/index.ts | 33 +++++++++++++- ops.ts | 9 +++- specs/renderer-spec.md | 7 ++- src/clayterm.c | 9 ++-- test/color.test.ts | 76 +++++++++++++++++++++++++++++++- 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/examples/inline-regions/index.ts b/examples/inline-regions/index.ts index b122de8..bc60d7e 100644 --- a/examples/inline-regions/index.ts +++ b/examples/inline-regions/index.ts @@ -39,6 +39,7 @@ const GREEN = rgba(80, 250, 123); const GREEN_BG = rgba(20, 70, 38); const GRAY = rgba(100, 100, 100); const CYAN = rgba(139, 233, 253); +const DARK_BG = rgba(30, 30, 40); const RED = rgba(255, 0, 0); const ORANGE = rgba(255, 153, 0); @@ -84,7 +85,7 @@ await main(function* () { ); let first = term.render( - box("Press any key to compile modules.", CYAN, GRAY), + box("Press any key to compile modules.", CYAN, GRAY, DARK_BG), { row }, ); write(new Uint8Array(first.output)); @@ -101,6 +102,7 @@ await main(function* () { `${icon} ${label} ${time}`, done ? GREEN : CYAN, done ? GREEN : GRAY, + DARK_BG, ), { row }, ); @@ -114,6 +116,32 @@ await main(function* () { yield* sleep(500); + // Demo: border bg + write(encode("\n\n\n")); + + let bgPos = yield* queryCursor(); + let bgRow = bgPos.row - 2; + write(ESC("7")); + + let bgTerm = validated( + yield* until(createTerm({ width: columns, height: 3 })), + ); + + let PURPLE_BG = rgba(80, 40, 120); + let bgResult = bgTerm.render( + box("Border backgrounds fill border cells.", WHITE, GREEN, PURPLE_BG), + { row: bgRow }, + ); + write(new Uint8Array(bgResult.output)); + + waitKey(); + + write(ESC("8")); + write(CSI("0m")); + write(encode("\n")); + + yield* sleep(200); + write( encode( "\nRegions can be multi-line, but they can be a single line too. (continue...)", @@ -322,7 +350,7 @@ function waitKey(): void { } } -function box(msg: string, fg: number, border: number): Op[] { +function box(msg: string, fg: number, border: number, bg: number): Op[] { return [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, @@ -337,6 +365,7 @@ function box(msg: string, fg: number, border: number): Op[] { }, border: { color: border, + bg, left: 1, right: 1, top: 1, diff --git a/ops.ts b/ops.ts index 6dc45a2..b514817 100644 --- a/ops.ts +++ b/ops.ts @@ -156,6 +156,12 @@ export function pack( let b = op.border; view.setUint32(o, b.color, true); o += 4; + + // ATTR_DEFAULT sentinel (bit 31 set) means "use terminal default bg" + let bg = b.bg === undefined ? 0x80000000 : b.bg & 0x00FFFFFF; + view.setUint32(o, bg, true); + o += 4; + view.setUint32( o, (b.left ?? 0) | ((b.right ?? 0) << 8) | ((b.top ?? 0) << 16) | @@ -290,6 +296,7 @@ export interface OpenElement { cornerRadius?: { tl?: number; tr?: number; bl?: number; br?: number }; border?: { color: number; + bg?: number; left?: number; right?: number; top?: number; @@ -359,7 +366,7 @@ function packSize(ops: Op[]): number { if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align if (op.bg !== undefined) n += 4; if (op.cornerRadius) n += 4; - if (op.border) n += 8; + if (op.border) n += 12; if (op.clip) n += 4; if (op.floating) n += 16; break; diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index b7da314..8b84936 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -644,7 +644,7 @@ The `open()` constructor currently accepts the following property groups in its padding (per-side), alignment (currently numeric enum values, with a planned transition to string literals), direction (top-to-bottom or left-to-right), and gap -- **`border`** — per-side border widths and border color +- **`border`** — per-side border widths, border color, and border background color - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing characters - **`clip`** — clip region configuration for scroll containers @@ -660,6 +660,11 @@ These property groups represent the current implementation surface. New groups and fields have been added incrementally and more may follow. Alignment values are expected to transition from numeric to string-literal form. +**Border background.** When `border.bg` is provided, the renderer MUST apply +that background color to all cells occupied by border glyphs (corners, horizontal +edges, and vertical edges). When `border.bg` is omitted, the renderer MUST NOT +override the background in border cells; the terminal default background applies. + **Border width and layout interaction.** In the underlying layout engine (Clay), border configuration does not affect layout computation. This is Clay's intended behavior. Borders are drawn as visual overlays within the element's bounding diff --git a/src/clayterm.c b/src/clayterm.c index 93084e5..3e93b90 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -301,9 +301,10 @@ static void render_text(struct Clayterm *ct, int x0, int y0, } static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1, - Clay_BorderRenderData *b) { + Clay_RenderCommand *cmd) { + Clay_BorderRenderData *b = &cmd->renderData.border; uint32_t fg = color(b->color); - uint32_t bg = ATTR_DEFAULT; + uint32_t bg = (uint32_t)(uintptr_t)cmd->userData; int top = b->width.top > 0; int bot = b->width.bottom > 0; int left = b->width.left > 0; @@ -533,6 +534,8 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { if (mask & PROP_BORDER) { decl.border.color = unpack_color(rd(buf, len, &i)); + decl.userData = (void *)(uintptr_t)rd(buf, len, &i); + uint32_t bw = rd(buf, len, &i); decl.border.width.left = bw & 0xff; decl.border.width.right = (bw >> 8) & 0xff; @@ -621,7 +624,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { render_text(ct, x0, y0, cmd); break; case CLAY_RENDER_COMMAND_TYPE_BORDER: - render_border(ct, x0, y0, x1, y1, &cmd->renderData.border); + render_border(ct, x0, y0, x1, y1, cmd); break; case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: ct->clipping = 1; diff --git a/test/color.test.ts b/test/color.test.ts index fc640d4..cd32b55 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -1,4 +1,4 @@ -import { close, grow, open, rgba, text } from "../ops.ts"; +import { close, fixed, grow, open, rgba, text } from "../ops.ts"; import { createTerm } from "../term.ts"; import { describe, expect, it } from "./suite.ts"; @@ -41,6 +41,34 @@ describe("foreground", () => { }); describe("background", () => { + it("fills border cells with the requested border-level bg", async () => { + let term = await createTerm({ width: 12, height: 4 }); + let bg = randomTextBgColor(); + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(8), height: fixed(3), direction: "ttb" }, + border: { + color: rgba(255, 255, 255), + bg: bg.value, + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text("Hi"), + close(), + ]).output, + ); + + let corner = ansi.indexOf("┌"); + expect(corner).toBeGreaterThanOrEqual(0); + + let beforeCorner = ansi.slice(0, corner); + expect(beforeCorner).toContain(bg.sgr); + }); + it("fills glyph cells with the requested text-level bg", async () => { let term = await createTerm({ width: 20, height: 1 }); let bg = randomTextBgColor(); @@ -56,6 +84,52 @@ describe("background", () => { expect(beforeH).toContain(bg.sgr); }); + it("resets border bg on subsequent frames without border bg", async () => { + let term = await createTerm({ width: 12, height: 4 }); + let bg = randomTextBgColor(); + + // Frame 1: border with bg + term.render([ + open("box", { + layout: { width: fixed(8), height: fixed(3), direction: "ttb" }, + border: { + color: rgba(255, 255, 255), + bg: bg.value, + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text("Hi"), + close(), + ]); + + // Frame 2: same border, no bg + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(8), height: fixed(3), direction: "ttb" }, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text("Hi"), + close(), + ]).output, + ); + + let corner = ansi.indexOf("┌"); + expect(corner).toBeGreaterThanOrEqual(0); + + let beforeCorner = ansi.slice(0, corner); + expect(beforeCorner).not.toContain(bg.sgr); + }); + it("resets the background before writing trailing cells", async () => { let term = await createTerm({ width: 20, height: 1 }); let bg = randomTextBgColor(); From ad2cdc76155b98824435fa3117732485cfe44c12 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Tue, 9 Jun 2026 11:17:15 -0400 Subject: [PATCH 2/5] format the spec file --- specs/renderer-spec.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 8b84936..1ccf12c 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -644,7 +644,8 @@ The `open()` constructor currently accepts the following property groups in its padding (per-side), alignment (currently numeric enum values, with a planned transition to string literals), direction (top-to-bottom or left-to-right), and gap -- **`border`** — per-side border widths, border color, and border background color +- **`border`** — per-side border widths, border color, and border background + color - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing characters - **`clip`** — clip region configuration for scroll containers @@ -661,9 +662,10 @@ and fields have been added incrementally and more may follow. Alignment values are expected to transition from numeric to string-literal form. **Border background.** When `border.bg` is provided, the renderer MUST apply -that background color to all cells occupied by border glyphs (corners, horizontal -edges, and vertical edges). When `border.bg` is omitted, the renderer MUST NOT -override the background in border cells; the terminal default background applies. +that background color to all cells occupied by border glyphs (corners, +horizontal edges, and vertical edges). When `border.bg` is omitted, the renderer +MUST NOT override the background in border cells; the terminal default +background applies. **Border width and layout interaction.** In the underlying layout engine (Clay), border configuration does not affect layout computation. This is Clay's intended From 717be85075cb8c37884e9ac033ed48bbacce927b Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 10 Jun 2026 07:11:31 -0400 Subject: [PATCH 3/5] fix merge conflicts --- specs/renderer-spec.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 1ccf12c..bce858b 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -663,9 +663,10 @@ are expected to transition from numeric to string-literal form. **Border background.** When `border.bg` is provided, the renderer MUST apply that background color to all cells occupied by border glyphs (corners, -horizontal edges, and vertical edges). When `border.bg` is omitted, the renderer -MUST NOT override the background in border cells; the terminal default -background applies. +horizontal edges, and vertical edges). When `border.bg` is omitted, border +rendering MUST NOT override the background already present in each border cell; +element backgrounds established by `open({ bg })` remain in effect, and the +terminal default remains in effect where no element background applies. **Border width and layout interaction.** In the underlying layout engine (Clay), border configuration does not affect layout computation. This is Clay's intended From 3a9454cea80136459482a5d67b17045384207d95 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 10 Jun 2026 07:11:53 -0400 Subject: [PATCH 4/5] Adds code comment and validation for bg --- src/clayterm.c | 1 + test/validate.test.ts | 7 +++++++ validate.ts | 1 + 3 files changed, 9 insertions(+) diff --git a/src/clayterm.c b/src/clayterm.c index 3e93b90..96a0034 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -304,6 +304,7 @@ static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1, Clay_RenderCommand *cmd) { Clay_BorderRenderData *b = &cmd->renderData.border; uint32_t fg = color(b->color); + /* userData is currently exclusively the packed border-bg word. */ uint32_t bg = (uint32_t)(uintptr_t)cmd->userData; int top = b->width.top > 0; int bot = b->width.bottom > 0; diff --git a/test/validate.test.ts b/test/validate.test.ts index 8db4af9..96a5516 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -78,6 +78,13 @@ describe("validate", () => { it("rejects fractional color", () => { expect(validate([text("hi", { color: 1.5 })])).toBe(false); }); + + it("rejects fractional border background color", () => { + expect(validate([ + open("x", { border: { color: 0xFF0000, bg: 1.5, left: 1 } }), + close(), + ])).toBe(false); + }); }); describe("validated", () => { diff --git a/validate.ts b/validate.ts index 3010a4b..a0a5ef9 100644 --- a/validate.ts +++ b/validate.ts @@ -69,6 +69,7 @@ const CornerRadius = Type.Object({ const Border = Type.Object({ color: rgba, + bg: Type.Optional(rgba), left: Type.Optional(u8), right: Type.Optional(u8), top: Type.Optional(u8), From b342f52cef72a804fe352fd31954a2a1eaeee225 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 10 Jun 2026 07:12:30 -0400 Subject: [PATCH 5/5] Updated the tests as suggests by paul --- test/color.test.ts | 99 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 16 deletions(-) diff --git a/test/color.test.ts b/test/color.test.ts index cd32b55..9741243 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -4,12 +4,17 @@ import { describe, expect, it } from "./suite.ts"; const decode = (b: Uint8Array) => new TextDecoder().decode(b); -type TextBgColor = { +type BgColor = { value: number; sgr: string; }; -function randomTextBgColor(): TextBgColor { +type Cell = { + ch: string; + bg?: string; +}; + +function randomBgColor(): BgColor { let r = 0; let g = 0; let b = 0; @@ -30,6 +35,41 @@ function randomTextBgColor(): TextBgColor { }; } +function cells(ansi: string): Cell[] { + let result: Cell[] = []; + let bg: string | undefined; + + for (let i = 0; i < ansi.length;) { + if (ansi[i] === "\x1b" && ansi[i + 1] === "[") { + let end = i + 2; + while (end < ansi.length && !/[A-Za-z]/.test(ansi[end])) { + end++; + } + + let seq = ansi.slice(i, end + 1); + if (seq === "\x1b[0m") { + bg = undefined; + } else if (seq.startsWith("\x1b[48;2;") && seq.endsWith("m")) { + bg = seq.slice(0, -1); + } + + i = end + 1; + continue; + } + + result.push({ ch: ansi[i], bg }); + i++; + } + + return result; +} + +function firstCell(cells: Cell[], ch: string): Cell { + let cell = cells.find((c) => c.ch === ch); + expect(cell).toBeDefined(); + return cell!; +} + describe("foreground", () => { it("emits uncolored text with no foreground", async () => { let term = await createTerm({ width: 12, height: 1 }); @@ -43,7 +83,7 @@ describe("foreground", () => { describe("background", () => { it("fills border cells with the requested border-level bg", async () => { let term = await createTerm({ width: 12, height: 4 }); - let bg = randomTextBgColor(); + let bg = randomBgColor(); let ansi = decode( term.render([ open("box", { @@ -59,19 +99,49 @@ describe("background", () => { }), text("Hi"), close(), - ]).output, + ], { mode: "line" }).output, ); - let corner = ansi.indexOf("┌"); - expect(corner).toBeGreaterThanOrEqual(0); + expect(ansi).toContain(`${bg.sgr}m┌`); + + let rendered = cells(ansi); + expect(firstCell(rendered, "┌").bg).toBe(bg.sgr); + expect(firstCell(rendered, "─").bg).toBe(bg.sgr); + expect(firstCell(rendered, "┐").bg).toBe(bg.sgr); + expect(firstCell(rendered, "│").bg).toBe(bg.sgr); + }); + + it("leaves existing border-cell bg unchanged when border bg is omitted", async () => { + let term = await createTerm({ width: 12, height: 4 }); + let bg = randomBgColor(); + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(8), height: fixed(3), direction: "ttb" }, + bg: bg.value, + border: { + color: rgba(255, 255, 255), + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + }), + text("Hi"), + close(), + ], { mode: "line" }).output, + ); - let beforeCorner = ansi.slice(0, corner); - expect(beforeCorner).toContain(bg.sgr); + let rendered = cells(ansi); + expect(firstCell(rendered, "┌").bg).toBe(bg.sgr); + expect(firstCell(rendered, "─").bg).toBe(bg.sgr); + expect(firstCell(rendered, "┐").bg).toBe(bg.sgr); + expect(firstCell(rendered, "│").bg).toBe(bg.sgr); }); it("fills glyph cells with the requested text-level bg", async () => { let term = await createTerm({ width: 20, height: 1 }); - let bg = randomTextBgColor(); + let bg = randomBgColor(); let ansi = decode( term.render([ open("root", { layout: { width: grow(), height: grow() } }), @@ -86,7 +156,7 @@ describe("background", () => { it("resets border bg on subsequent frames without border bg", async () => { let term = await createTerm({ width: 12, height: 4 }); - let bg = randomTextBgColor(); + let bg = randomBgColor(); // Frame 1: border with bg term.render([ @@ -123,16 +193,13 @@ describe("background", () => { ]).output, ); - let corner = ansi.indexOf("┌"); - expect(corner).toBeGreaterThanOrEqual(0); - - let beforeCorner = ansi.slice(0, corner); - expect(beforeCorner).not.toContain(bg.sgr); + expect(ansi).not.toContain(bg.sgr); + expect(firstCell(cells(ansi), "┌").bg).toBeUndefined(); }); it("resets the background before writing trailing cells", async () => { let term = await createTerm({ width: 20, height: 1 }); - let bg = randomTextBgColor(); + let bg = randomBgColor(); let ansi = decode( term.render([ open("root", { layout: { width: grow(), height: grow() } }),