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..bce858b 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 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 +661,13 @@ 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, 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 behavior. Borders are drawn as visual overlays within the element's bounding diff --git a/src/clayterm.c b/src/clayterm.c index 93084e5..96a0034 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -301,9 +301,11 @@ 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; + /* 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; int left = b->width.left > 0; @@ -533,6 +535,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 +625,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..9741243 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -1,15 +1,20 @@ -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"; 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 }); @@ -41,9 +81,67 @@ 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 = randomBgColor(); + 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(), + ], { mode: "line" }).output, + ); + + 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 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() } }), @@ -56,9 +154,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 = randomBgColor(); + + // 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, + ); + + 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() } }), 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),