diff --git a/examples/inline-regions/index.ts b/examples/inline-regions/index.ts index bc60d7e..ca66bf6 100644 --- a/examples/inline-regions/index.ts +++ b/examples/inline-regions/index.ts @@ -39,7 +39,6 @@ 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); @@ -85,7 +84,7 @@ await main(function* () { ); let first = term.render( - box("Press any key to compile modules.", CYAN, GRAY, DARK_BG), + box("Press any key to compile modules.", CYAN, GRAY), { row }, ); write(new Uint8Array(first.output)); @@ -102,7 +101,6 @@ await main(function* () { `${icon} ${label} ${time}`, done ? GREEN : CYAN, done ? GREEN : GRAY, - DARK_BG, ), { row }, ); @@ -350,7 +348,7 @@ function waitKey(): void { } } -function box(msg: string, fg: number, border: number, bg: number): Op[] { +function box(msg: string, fg: number, border: number, bg?: number): Op[] { return [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, diff --git a/ops.ts b/ops.ts index b514817..329c17c 100644 --- a/ops.ts +++ b/ops.ts @@ -53,6 +53,26 @@ function packAxis(view: DataView, offset: number, axis: SizingAxis): number { return o; } +function sideWidth(side: BorderSide | undefined): number { + return typeof side === "number" ? side : side?.width ?? 0; +} + +function sideFg(side: BorderSide | undefined, shared: number): number { + let color = typeof side === "object" && side.color !== undefined + ? side.color + : shared; + return color & 0x00FFFFFF; +} + +function sideBg( + side: BorderSide | undefined, + shared: number | undefined, +): number { + let bg = typeof side === "object" && side.bg !== undefined ? side.bg : shared; + // ATTR_DEFAULT sentinel (bit 31 set) means "keep the existing cell bg" + return bg === undefined ? 0x80000000 : bg & 0x00FFFFFF; +} + function packString( view: DataView, bytes: Uint8Array, @@ -154,21 +174,23 @@ export function pack( if (op.border) { 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) | - ((b.bottom ?? 0) << 24), + sideWidth(b.left) | (sideWidth(b.right) << 8) | + (sideWidth(b.top) << 16) | (sideWidth(b.bottom) << 24), true, ); o += 4; + + // Resolved per-side attributes (CSS-like fallback expansion done + // here, not in C): fg/bg word pairs in top, right, bottom, left + // order. The C renderer consumes these as explicit values. + for (let side of [b.top, b.right, b.bottom, b.left]) { + view.setUint32(o, sideFg(side, b.color), true); + o += 4; + view.setUint32(o, sideBg(side, b.bg), true); + o += 4; + } } if (op.clip) { @@ -280,6 +302,14 @@ export interface CloseElement { directive: typeof OP_CLOSE_ELEMENT; } +export type BorderSide = + | number + | { + width: number; + color?: number; + bg?: number; + }; + export interface OpenElement { directive: typeof OP_OPEN_ELEMENT; id: string; @@ -297,10 +327,10 @@ export interface OpenElement { border?: { color: number; bg?: number; - left?: number; - right?: number; - top?: number; - bottom?: number; + left?: BorderSide; + right?: BorderSide; + top?: BorderSide; + bottom?: BorderSide; }; clip?: { horizontal?: boolean; vertical?: boolean }; floating?: { @@ -366,7 +396,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 += 12; + if (op.border) n += 36; // widths word + 4 sides × (fg + bg) if (op.clip) n += 4; if (op.floating) n += 16; break; diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index bce858b..7d56bfd 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -644,8 +644,11 @@ 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 configuration. Each side field (`top`, `right`, + `bottom`, `left`) accepts either a scalar width or a structured object + `{ width, color?, bg? }`. The shared `color` field is required and is the + fallback foreground for every side; the optional shared `bg` field is the + fallback border-cell background for every side - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing characters - **`clip`** — clip region configuration for scroll containers @@ -661,12 +664,47 @@ 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 sides.** Each border side is declared independently as either a scalar +width (`top: 1`) or a structured object (`top: { width: 1, color?, bg? }`). The +two forms are equivalent when the object form provides only `width`. A side is +enabled when its resolved width is greater than zero; an omitted side or a side +with width `0` MUST NOT be drawn. Scalar side declarations MUST keep their +pre-existing behavior. + +**Border side colors (fallback resolution).** Side attributes resolve in a +CSS-like shorthand/longhand fashion before rendering: + +- A structured side with `color` MUST render with that foreground color. A + scalar side, or a structured side that omits `color`, MUST fall back to the + shared `border.color`. The shared `color` remains required. +- A structured side with `bg` MUST render border cells of that side with that + background color. A scalar side, or a structured side that omits `bg`, MUST + fall back to the shared `border.bg` when it is provided. +- When neither the side nor the shared border provides `bg`, border rendering + MUST NOT override the background already present in each border cell of that + side; element backgrounds established by `open({ bg })` remain in effect, and + the terminal default remains in effect where no element background applies. + +Fallback resolution is performed on the TypeScript side before the frame is +transferred; the WASM renderer consumes explicit per-side attributes and does +not implement the public fallback rules. + +**Independent sides and corners.** Each enabled side renders as a straight edge +(`─` for horizontal sides, `│` for vertical sides). A corner glyph MUST be +rendered only when both adjacent sides for that corner are enabled; when either +adjacent side is absent, the present side continues straight through the +endpoint with no corner glyph. A left-only border is therefore a plain vertical +line, and a top-plus-bottom border is two plain horizontal rules. + +**Corner styling approximation.** A terminal cell carries a single glyph, +foreground, and background, so CSS-style diagonally split corners cannot be +represented. When corners are rendered: top corners (`┌`, `┐`, and their rounded +variants) MUST use the resolved attributes of the `top` side, and bottom corners +(`└`, `┘`, and their rounded variants) MUST use the resolved attributes of the +`bottom` side. Left and right side attributes apply to vertical edge cells +excluding joined corner cells. Per-side attributes affect only the styling of +corner cells; corner glyph shape selection (including rounded corners via +`cornerRadius`) is unchanged. **Border width and layout interaction.** In the underlying layout engine (Clay), border configuration does not affect layout computation. This is Clay's intended diff --git a/src/clayterm.c b/src/clayterm.c index 96a0034..ce0165c 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -303,44 +303,55 @@ 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_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; + /* userData points at eight words in the command buffer carrying resolved + * per-side attributes as fg/bg pairs in top, right, bottom, left order. + * Fallback resolution (shared color/bg vs side overrides) happens on the + * TypeScript side; this renderer consumes explicit values only. The + * command buffer outlives the render pass within reduce(). */ + const uint32_t *s = (const uint32_t *)cmd->userData; + uint32_t deffg = color(b->color); + uint32_t top_fg = s ? s[0] : deffg, top_bg = s ? s[1] : ATTR_DEFAULT; + uint32_t right_fg = s ? s[2] : deffg, right_bg = s ? s[3] : ATTR_DEFAULT; + uint32_t bot_fg = s ? s[4] : deffg, bot_bg = s ? s[5] : ATTR_DEFAULT; + uint32_t left_fg = s ? s[6] : deffg, left_bg = s ? s[7] : ATTR_DEFAULT; int top = b->width.top > 0; int bot = b->width.bottom > 0; int left = b->width.left > 0; int right = b->width.right > 0; - /* corners — rounded when corner radius > 0 */ + /* corners — rounded when corner radius > 0. Drawn only when both adjacent + * sides are enabled; a terminal cell holds a single fg/bg, so top corners + * take the top side attributes and bottom corners take the bottom side + * attributes (deterministic approximation of CSS split corners). */ uint32_t tl = b->cornerRadius.topLeft > 0 ? 0x256d : 0x250c; uint32_t tr = b->cornerRadius.topRight > 0 ? 0x256e : 0x2510; uint32_t bl = b->cornerRadius.bottomLeft > 0 ? 0x2570 : 0x2514; uint32_t br = b->cornerRadius.bottomRight > 0 ? 0x256f : 0x2518; if (top && left) - setcell(ct, x0, y0, tl, fg, bg); + setcell(ct, x0, y0, tl, top_fg, top_bg); if (top && right) - setcell(ct, x1 - 1, y0, tr, fg, bg); + setcell(ct, x1 - 1, y0, tr, top_fg, top_bg); if (bot && left) - setcell(ct, x0, y1 - 1, bl, fg, bg); + setcell(ct, x0, y1 - 1, bl, bot_fg, bot_bg); if (bot && right) - setcell(ct, x1 - 1, y1 - 1, br, fg, bg); + setcell(ct, x1 - 1, y1 - 1, br, bot_fg, bot_bg); /* horizontal edges */ if (top) for (int x = x0 + left; x < x1 - right; x++) - setcell(ct, x, y0, 0x2500, fg, bg); + setcell(ct, x, y0, 0x2500, top_fg, top_bg); if (bot) for (int x = x0 + left; x < x1 - right; x++) - setcell(ct, x, y1 - 1, 0x2500, fg, bg); + setcell(ct, x, y1 - 1, 0x2500, bot_fg, bot_bg); - /* vertical edges */ + /* vertical edges — excluding joined corner cells owned by top/bottom */ if (left) for (int y = y0 + top; y < y1 - bot; y++) - setcell(ct, x0, y, 0x2502, fg, bg); + setcell(ct, x0, y, 0x2502, left_fg, left_bg); if (right) for (int y = y0 + top; y < y1 - bot; y++) - setcell(ct, x1 - 1, y, 0x2502, fg, bg); + setcell(ct, x1 - 1, y, 0x2502, right_fg, right_bg); } /* ── Command buffer helpers ───────────────────────────────────────── */ @@ -533,15 +544,18 @@ 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; decl.border.width.top = (bw >> 16) & 0xff; decl.border.width.bottom = (bw >> 24) & 0xff; + + /* Resolved per-side fg/bg attribute words (top, right, bottom, + * left). Routed to render_border via userData; the command buffer + * remains valid for the whole render pass. */ + if (i + 8 <= len) + decl.userData = (void *)&buf[i]; + i += 8; } if (mask & PROP_CLIP) { diff --git a/test/border.test.ts b/test/border.test.ts new file mode 100644 index 0000000..a81c5e6 --- /dev/null +++ b/test/border.test.ts @@ -0,0 +1,480 @@ +import { close, fixed, open, rgba } from "../ops.ts"; +import { createTerm } from "../term.ts"; +import { describe, expect, it } from "./suite.ts"; + +const decode = (b: Uint8Array) => new TextDecoder().decode(b); + +/* ── Deterministic test colors ────────────────────────────────────── */ + +const WHITE = rgba(255, 255, 255); +const RED = rgba(255, 0, 0); +const GREEN = rgba(0, 255, 0); +const BLUE = rgba(0, 0, 255); +const YELLOW = rgba(255, 255, 0); +const MAGENTA = rgba(255, 0, 255); +const CYAN = rgba(0, 255, 255); + +const FG = { + white: "\x1b[38;2;255;255;255", + red: "\x1b[38;2;255;0;0", + green: "\x1b[38;2;0;255;0", + blue: "\x1b[38;2;0;0;255", + yellow: "\x1b[38;2;255;255;0", + magenta: "\x1b[38;2;255;0;255", + cyan: "\x1b[38;2;0;255;255", +}; + +const BG = { + white: "\x1b[48;2;255;255;255", + red: "\x1b[48;2;255;0;0", + green: "\x1b[48;2;0;255;0", + blue: "\x1b[48;2;0;0;255", + yellow: "\x1b[48;2;255;255;0", + magenta: "\x1b[48;2;255;0;255", + cyan: "\x1b[48;2;0;255;255", +}; + +/* ── ANSI cell parser ─────────────────────────────────────────────── */ + +type ParsedCell = { + x: number; + y: number; + ch: string; + fg?: string; + bg?: string; +}; + +function cells(ansi: string): ParsedCell[] { + let result: ParsedCell[] = []; + let fg: string | undefined; + let bg: string | undefined; + let x = 0; + let y = 0; + + 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") { + fg = undefined; + bg = undefined; + } else if (seq.startsWith("\x1b[38;2;") && seq.endsWith("m")) { + fg = seq.slice(0, -1); + } else if (seq.startsWith("\x1b[48;2;") && seq.endsWith("m")) { + bg = seq.slice(0, -1); + } + + i = end + 1; + continue; + } + + if (ansi[i] === "\n") { + y++; + x = 0; + i++; + continue; + } + + result.push({ x, y, ch: ansi[i], fg, bg }); + x++; + i++; + } + + return result; +} + +function at(parsed: ParsedCell[], x: number, y: number): ParsedCell { + let cell = parsed.find((c) => c.x === x && c.y === y); + expect(cell).toBeDefined(); + return cell!; +} + +function glyphs(parsed: ParsedCell[], chars: string): ParsedCell[] { + return parsed.filter((c) => chars.includes(c.ch)); +} + +const CORNERS = "┌┐└┘╭╮╰╯"; + +/* ── Render helper ────────────────────────────────────────────────── */ + +// deno-lint-ignore no-explicit-any +type OpenProps = any; + +/** Renders an 8x4 "box" element at the origin of a 12x5 term in line + * mode and parses the full-frame output into cells. Box corners are at + * (0,0), (7,0), (0,3), (7,3). */ +async function renderBox(props: OpenProps): Promise { + let term = await createTerm({ width: 12, height: 5 }); + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(8), height: fixed(4) }, + ...props, + }), + close(), + ], { mode: "line" }).output, + ); + return cells(ansi); +} + +/* ── Tests ────────────────────────────────────────────────────────── */ + +describe("scalar sides", () => { + it("renders a full box from scalar widths with the shared color", async () => { + let parsed = await renderBox({ + border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, + }); + + expect(at(parsed, 0, 0).ch).toBe("┌"); + expect(at(parsed, 7, 0).ch).toBe("┐"); + expect(at(parsed, 0, 3).ch).toBe("└"); + expect(at(parsed, 7, 3).ch).toBe("┘"); + expect(at(parsed, 3, 0).ch).toBe("─"); + expect(at(parsed, 3, 3).ch).toBe("─"); + expect(at(parsed, 0, 1).ch).toBe("│"); + expect(at(parsed, 7, 1).ch).toBe("│"); + + for (let cell of glyphs(parsed, "┌┐└┘─│")) { + expect(cell.fg).toBe(FG.white); + } + }); + + it("applies shared bg to scalar sides", async () => { + let parsed = await renderBox({ + border: { color: WHITE, bg: BLUE, top: 1, right: 1, bottom: 1, left: 1 }, + }); + + for (let cell of glyphs(parsed, "┌┐└┘─│")) { + expect(cell.bg).toBe(BG.blue); + } + }); +}); + +describe("structured sides", () => { + it("accepts every structured side form and resolves fallbacks", async () => { + let parsed = await renderBox({ + border: { + color: WHITE, + bg: MAGENTA, + top: { width: 1 }, + right: { width: 1, color: RED }, + bottom: { width: 1, bg: BLUE }, + left: { width: 1, color: GREEN, bg: YELLOW }, + }, + }); + + // top: shared color, shared bg + expect(at(parsed, 3, 0).ch).toBe("─"); + expect(at(parsed, 3, 0).fg).toBe(FG.white); + expect(at(parsed, 3, 0).bg).toBe(BG.magenta); + + // right: own color, shared bg + expect(at(parsed, 7, 1).ch).toBe("│"); + expect(at(parsed, 7, 1).fg).toBe(FG.red); + expect(at(parsed, 7, 1).bg).toBe(BG.magenta); + + // bottom: shared color, own bg + expect(at(parsed, 3, 3).ch).toBe("─"); + expect(at(parsed, 3, 3).fg).toBe(FG.white); + expect(at(parsed, 3, 3).bg).toBe(BG.blue); + + // left: own color, own bg + expect(at(parsed, 0, 1).ch).toBe("│"); + expect(at(parsed, 0, 1).fg).toBe(FG.green); + expect(at(parsed, 0, 1).bg).toBe(BG.yellow); + }); + + it("overrides shared color per side; omitted colors inherit it", async () => { + let parsed = await renderBox({ + border: { + color: WHITE, + top: { width: 1, color: RED }, + bottom: { width: 1, color: GREEN }, + left: 1, + right: { width: 1 }, + }, + }); + + expect(at(parsed, 3, 0).fg).toBe(FG.red); + expect(at(parsed, 3, 3).fg).toBe(FG.green); + expect(at(parsed, 0, 1).fg).toBe(FG.white); + expect(at(parsed, 7, 1).fg).toBe(FG.white); + }); + + it("overrides shared bg per side; omitted bgs inherit it", async () => { + let parsed = await renderBox({ + border: { + color: WHITE, + bg: BLUE, + top: 1, + bottom: { width: 1 }, + left: { width: 1, bg: RED }, + right: 1, + }, + }); + + expect(at(parsed, 3, 0).bg).toBe(BG.blue); // scalar side, shared bg + expect(at(parsed, 3, 3).bg).toBe(BG.blue); // structured side, shared bg + expect(at(parsed, 0, 1).bg).toBe(BG.red); // structured side, own bg + expect(at(parsed, 7, 1).bg).toBe(BG.blue); // scalar side, shared bg + }); + + it("preserves the element bg when no border bg is provided", async () => { + let parsed = await renderBox({ + bg: CYAN, + border: { + color: WHITE, + top: { width: 1, color: RED }, + right: { width: 1 }, + bottom: 1, + left: 1, + }, + }); + + for (let cell of glyphs(parsed, "┌┐└┘─│")) { + expect(cell.bg).toBe(BG.cyan); + } + }); + + it("emits no border bg when neither side nor shared bg is set", async () => { + let parsed = await renderBox({ + border: { + color: WHITE, + top: { width: 1, color: RED }, + right: 1, + bottom: { width: 1 }, + left: 1, + }, + }); + + for (let cell of glyphs(parsed, "┌┐└┘─│")) { + expect(cell.bg).toBeUndefined(); + } + }); + + it("does not retain a prior frame's side bg", async () => { + let term = await createTerm({ width: 12, height: 5 }); + let frame = (bg?: number) => [ + open("box", { + layout: { width: fixed(8), height: fixed(4) }, + border: { + color: WHITE, + top: bg === undefined ? { width: 1 } : { width: 1, bg }, + right: 1, + bottom: 1, + left: 1, + }, + }), + close(), + ]; + + term.render(frame(BLUE)); + let ansi = decode(term.render(frame()).output); + + expect(ansi).not.toContain(BG.blue); + let top = cells(ansi).find((c) => c.ch === "─"); + expect(top).toBeDefined(); + expect(top!.bg).toBeUndefined(); + }); +}); + +describe("independent sides", () => { + it("draws only sides with resolved width > 0", async () => { + let drawn = await renderBox({ + border: { color: WHITE, top: { width: 1 } }, + }); + expect(glyphs(drawn, "─").length).toBe(8); + + let zeroObject = await renderBox({ + border: { color: WHITE, top: { width: 0 }, left: 1 }, + }); + expect(glyphs(zeroObject, "─").length).toBe(0); + + let zeroScalar = await renderBox({ + border: { color: WHITE, top: 0, left: 1 }, + }); + expect(glyphs(zeroScalar, "─").length).toBe(0); + }); + + it("renders a left-only border as a straight vertical line", async () => { + let parsed = await renderBox({ border: { color: WHITE, left: 1 } }); + expect(glyphs(parsed, "│").length).toBe(4); + expect(glyphs(parsed, "│").every((c) => c.x === 0)).toBe(true); + expect(glyphs(parsed, CORNERS).length).toBe(0); + }); + + it("renders a right-only border as a straight vertical line", async () => { + let parsed = await renderBox({ border: { color: WHITE, right: 1 } }); + expect(glyphs(parsed, "│").length).toBe(4); + expect(glyphs(parsed, "│").every((c) => c.x === 7)).toBe(true); + expect(glyphs(parsed, CORNERS).length).toBe(0); + }); + + it("renders a top-only border as a straight horizontal line", async () => { + let parsed = await renderBox({ border: { color: WHITE, top: 1 } }); + expect(glyphs(parsed, "─").length).toBe(8); + expect(glyphs(parsed, "─").every((c) => c.y === 0)).toBe(true); + expect(glyphs(parsed, CORNERS).length).toBe(0); + }); + + it("renders a bottom-only border as a straight horizontal line", async () => { + let parsed = await renderBox({ border: { color: WHITE, bottom: 1 } }); + expect(glyphs(parsed, "─").length).toBe(8); + expect(glyphs(parsed, "─").every((c) => c.y === 3)).toBe(true); + expect(glyphs(parsed, CORNERS).length).toBe(0); + }); + + it("renders top + bottom as two straight lines without corners", async () => { + let parsed = await renderBox({ + border: { color: WHITE, top: 1, bottom: 1 }, + }); + expect(glyphs(parsed, "─").length).toBe(16); + expect(glyphs(parsed, "│").length).toBe(0); + expect(glyphs(parsed, CORNERS).length).toBe(0); + }); +}); + +describe("corners", () => { + it("creates a corner only where both adjacent sides are enabled", async () => { + let tl = await renderBox({ border: { color: WHITE, top: 1, left: 1 } }); + expect(at(tl, 0, 0).ch).toBe("┌"); + expect(glyphs(tl, CORNERS).length).toBe(1); + + let tr = await renderBox({ border: { color: WHITE, top: 1, right: 1 } }); + expect(at(tr, 7, 0).ch).toBe("┐"); + expect(glyphs(tr, CORNERS).length).toBe(1); + + let bl = await renderBox({ border: { color: WHITE, bottom: 1, left: 1 } }); + expect(at(bl, 0, 3).ch).toBe("└"); + expect(glyphs(bl, CORNERS).length).toBe(1); + + let br = await renderBox({ border: { color: WHITE, bottom: 1, right: 1 } }); + expect(at(br, 7, 3).ch).toBe("┘"); + expect(glyphs(br, CORNERS).length).toBe(1); + }); + + it("draws no corner when an adjacent side has zero width", async () => { + let scalarZero = await renderBox({ + border: { color: WHITE, top: 1, left: 0 }, + }); + expect(glyphs(scalarZero, CORNERS).length).toBe(0); + + let objectZero = await renderBox({ + border: { color: WHITE, bottom: 1, right: { width: 0 } }, + }); + expect(glyphs(objectZero, CORNERS).length).toBe(0); + }); + + it("styles top corners from top and bottom corners from bottom", async () => { + let parsed = await renderBox({ + border: { + color: WHITE, + top: { width: 1, color: RED, bg: MAGENTA }, + right: { width: 1, color: YELLOW }, + bottom: { width: 1, color: GREEN, bg: CYAN }, + left: { width: 1, color: BLUE }, + }, + }); + + // top corners take top attributes + expect(at(parsed, 0, 0).ch).toBe("┌"); + expect(at(parsed, 0, 0).fg).toBe(FG.red); + expect(at(parsed, 0, 0).bg).toBe(BG.magenta); + expect(at(parsed, 7, 0).ch).toBe("┐"); + expect(at(parsed, 7, 0).fg).toBe(FG.red); + expect(at(parsed, 7, 0).bg).toBe(BG.magenta); + + // bottom corners take bottom attributes + expect(at(parsed, 0, 3).ch).toBe("└"); + expect(at(parsed, 0, 3).fg).toBe(FG.green); + expect(at(parsed, 0, 3).bg).toBe(BG.cyan); + expect(at(parsed, 7, 3).ch).toBe("┘"); + expect(at(parsed, 7, 3).fg).toBe(FG.green); + expect(at(parsed, 7, 3).bg).toBe(BG.cyan); + + // horizontal edges remain continuous with their corners + expect(at(parsed, 3, 0).fg).toBe(FG.red); + expect(at(parsed, 3, 3).fg).toBe(FG.green); + + // non-corner vertical edge cells take left/right attributes + expect(at(parsed, 0, 1).fg).toBe(FG.blue); + expect(at(parsed, 0, 2).fg).toBe(FG.blue); + expect(at(parsed, 7, 1).fg).toBe(FG.yellow); + expect(at(parsed, 7, 2).fg).toBe(FG.yellow); + }); + + it("keeps rounded corner glyphs; side attrs only restyle them", async () => { + let parsed = await renderBox({ + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + border: { + color: WHITE, + top: { width: 1, color: RED }, + right: 1, + bottom: { width: 1, color: GREEN }, + left: 1, + }, + }); + + expect(at(parsed, 0, 0).ch).toBe("╭"); + expect(at(parsed, 7, 0).ch).toBe("╮"); + expect(at(parsed, 0, 3).ch).toBe("╰"); + expect(at(parsed, 7, 3).ch).toBe("╯"); + expect(at(parsed, 0, 0).fg).toBe(FG.red); + expect(at(parsed, 7, 0).fg).toBe(FG.red); + expect(at(parsed, 0, 3).fg).toBe(FG.green); + expect(at(parsed, 7, 3).fg).toBe(FG.green); + }); +}); + +describe("directive model", () => { + it("keeps structured side declarations as plain data", () => { + let directive = open("box", { + border: { color: WHITE, top: { width: 1, color: RED } }, + }); + + expect(Object.getPrototypeOf(directive)).toBe(Object.prototype); + expect(directive.border?.top).toEqual({ width: 1, color: RED }); + }); +}); + +describe("instances", () => { + it("does not share side attributes between Term instances", async () => { + let a = await createTerm({ width: 12, height: 5 }); + let b = await createTerm({ width: 12, height: 5 }); + + let frame = (top: number, bottom: number) => [ + open("box", { + layout: { width: fixed(8), height: fixed(4) }, + border: { + color: WHITE, + top: { width: 1, color: top }, + bottom: { width: 1, color: bottom }, + }, + }), + close(), + ]; + + let ansiA = decode(a.render(frame(RED, GREEN), { mode: "line" }).output); + let ansiB = decode(b.render(frame(BLUE, YELLOW), { mode: "line" }).output); + + expect(ansiA).toContain(FG.red); + expect(ansiA).toContain(FG.green); + expect(ansiA).not.toContain(FG.blue); + expect(ansiA).not.toContain(FG.yellow); + + expect(ansiB).toContain(FG.blue); + expect(ansiB).toContain(FG.yellow); + expect(ansiB).not.toContain(FG.red); + expect(ansiB).not.toContain(FG.green); + + // re-rendering A must not affect B's next frame + a.render(frame(MAGENTA, CYAN), { mode: "line" }); + let again = decode(b.render(frame(BLUE, YELLOW), { mode: "line" }).output); + expect(again).not.toContain(FG.magenta); + expect(again).not.toContain(FG.cyan); + }); +}); diff --git a/test/validate.test.ts b/test/validate.test.ts index 96a5516..7ce7702 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -85,6 +85,91 @@ describe("validate", () => { close(), ])).toBe(false); }); + + it("accepts structured border side objects", () => { + expect(validate([ + open("x", { + border: { + color: 0xFF0000, + top: { width: 1 }, + right: { width: 1, color: 0x00FF00 }, + bottom: { width: 1, bg: 0x0000FF }, + left: { width: 1, color: 0x00FF00, bg: 0x0000FF }, + }, + }), + close(), + ])).toBe(true); + }); + + it("rejects structured border side missing width", () => { + expect(validate([ + { directive: 0x02, id: "x", border: { color: 0xFF0000, top: {} } }, + close(), + ])).toBe(false); + expect(validate([ + { + directive: 0x02, + id: "x", + border: { color: 0xFF0000, top: { color: 0x00FF00 } }, + }, + close(), + ])).toBe(false); + }); + + it("rejects invalid structured border side widths", () => { + for (let width of [-1, 1.5, 256]) { + expect(validate([ + open("x", { border: { color: 0xFF0000, top: { width } } }), + close(), + ])).toBe(false); + expect(validate([ + open("x", { border: { color: 0xFF0000, left: { width } } }), + close(), + ])).toBe(false); + } + }); + + it("rejects invalid structured border side colors", () => { + for (let color of [1.5, 0x1FFFFFFFF, -0x80000001]) { + expect(validate([ + open("x", { border: { color: 0xFF0000, top: { width: 1, color } } }), + close(), + ])).toBe(false); + } + }); + + it("rejects invalid structured border side backgrounds", () => { + for (let bg of [1.5, 0x1FFFFFFFF, -0x80000001]) { + expect(validate([ + open("x", { border: { color: 0xFF0000, top: { width: 1, bg } } }), + close(), + ])).toBe(false); + } + }); + + it("still rejects invalid scalar border side widths", () => { + for (let width of [-1, 1.5, 256]) { + expect(validate([ + open("x", { border: { color: 0xFF0000, top: width } }), + close(), + ])).toBe(false); + expect(validate([ + open("x", { border: { color: 0xFF0000, left: width } }), + close(), + ])).toBe(false); + } + }); + + it("rejects a border without shared color even with side colors", () => { + expect(validate([ + { + directive: 0x02, + id: "x", + border: { top: { width: 1, color: 0xFF0000 } }, + }, + close(), + ])).toBe(false); + }); }); describe("validated", () => { @@ -115,4 +200,43 @@ describe("validated", () => { // deno-lint-ignore no-explicit-any expect(() => term.render([{ directive: 0xff }] as any)).toThrow(TypeError); }); + + it("renders valid structured border sides normally", () => { + let out = decode( + term.render([ + open("box", { + layout: { width: grow(), height: grow() }, + border: { + color: 0xFFFFFF, + top: { width: 1, color: 0xFF0000 }, + right: 1, + bottom: { width: 1, bg: 0x0000FF }, + left: { width: 1 }, + }, + }), + close(), + ]).output, + ); + expect(out).toContain("┌"); + }); + + it("throws on a structured border side missing width", () => { + let invalid = [ + { directive: 0x02, id: "x", border: { color: 0xFF0000, top: {} } }, + close(), + ]; + // deno-lint-ignore no-explicit-any + expect(() => term.render(invalid as any)).toThrow(TypeError); + }); + + it("throws on an invalid structured border side color", () => { + expect(() => + term.render([ + open("x", { + border: { color: 0xFF0000, top: { width: 1, color: 1.5 } }, + }), + close(), + ]) + ).toThrow(TypeError); + }); }); diff --git a/validate.ts b/validate.ts index a0a5ef9..8e19d68 100644 --- a/validate.ts +++ b/validate.ts @@ -67,13 +67,22 @@ const CornerRadius = Type.Object({ br: Type.Optional(u8), }); +const BorderSide = Type.Union([ + u8, + Type.Object({ + width: u8, + color: Type.Optional(rgba), + bg: Type.Optional(rgba), + }), +]); + const Border = Type.Object({ color: rgba, bg: Type.Optional(rgba), - left: Type.Optional(u8), - right: Type.Optional(u8), - top: Type.Optional(u8), - bottom: Type.Optional(u8), + left: Type.Optional(BorderSide), + right: Type.Optional(BorderSide), + top: Type.Optional(BorderSide), + bottom: Type.Optional(BorderSide), }); const Clip = Type.Object({