From 696014635b33b86e74813e43af97bf21af490710 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 10 Jun 2026 08:40:08 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20per-side=20border=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expands per-side border attributes to allow for fg, width, bg properties for each side of the border --- examples/inline-regions/index.ts | 6 +- ops.ts | 60 +++- specs/renderer-spec.md | 54 +++- src/clayterm.c | 48 ++-- test/border.test.ts | 480 +++++++++++++++++++++++++++++++ test/validate.test.ts | 124 ++++++++ validate.ts | 17 +- 7 files changed, 741 insertions(+), 48 deletions(-) create mode 100644 test/border.test.ts diff --git a/examples/inline-regions/index.ts b/examples/inline-regions/index.ts index 8bd56bb..3c22fcd 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 131a796..cb93969 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, @@ -162,21 +182,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) { @@ -300,6 +322,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; @@ -317,10 +347,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?: { @@ -453,7 +483,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; // x, y, expand width/height, parent, attach/pointer, clip/z if (op.floating) n += 7 * 4; diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index f808abe..f435cb4 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 (`alignX`: `"left"` | `"center"` | `"right"`; `alignY`: `"top"` | `"center"` | `"bottom"`, defaulting to left/top when omitted), 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 @@ -709,12 +712,47 @@ The `text()` constructor accepts: `color`, `bg`, `fontSize`, `letterSpacing`, These property groups represent the current implementation surface. New groups and fields have been added incrementally and more may follow. -**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 c21db3f..42b478d 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 cbe97c5..2392218 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -132,6 +132,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", () => { @@ -164,4 +249,43 @@ describe("validated", () => { expect(() => Reflect.apply(term.render, term, [[{ directive: 0xff }]])) .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 f41c0d7..ac0d8f3 100644 --- a/validate.ts +++ b/validate.ts @@ -80,13 +80,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({ From bf025d78c0e45fa9b8d8395aa290a0dd6fbeafe8 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sun, 14 Jun 2026 07:02:03 -0400 Subject: [PATCH 2/4] removes the unneeded logic of undefined color fallbacks as its not possible --- ops.ts | 8 +++++--- src/clayterm.c | 29 +++++++++++++++++++---------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/ops.ts b/ops.ts index cb93969..4f80c41 100644 --- a/ops.ts +++ b/ops.ts @@ -190,9 +190,11 @@ export function pack( ); 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. + // Must match render_border() in src/clayterm.c. + // Resolve CSS-like side fallbacks here, then write eight required + // attribute words: fg/bg pairs in top, right, bottom, left order. + // C treats the presence and order of these words as a wire-format + // invariant and only consumes the explicit values. for (let side of [b.top, b.right, b.bottom, b.left]) { view.setUint32(o, sideFg(side, b.color), true); o += 4; diff --git a/src/clayterm.c b/src/clayterm.c index 42b478d..e701790 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -303,17 +303,26 @@ 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; - /* 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(). */ + /* Must match border packing in ops.ts. + * userData points at eight required words in the command buffer: resolved + * 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(). Missing userData is a wire-format violation. + */ 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; + if (s == NULL) { + __builtin_trap(); + } + + uint32_t top_fg = s[0]; + uint32_t top_bg = s[1]; + uint32_t right_fg = s[2]; + uint32_t right_bg = s[3]; + uint32_t bot_fg = s[4]; + uint32_t bot_bg = s[5]; + uint32_t left_fg = s[6]; + uint32_t left_bg = s[7]; int top = b->width.top > 0; int bot = b->width.bottom > 0; int left = b->width.left > 0; From 9116d1fb3d0951f68cbff73e6b9b9fed59f3204a Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sun, 14 Jun 2026 07:25:15 -0400 Subject: [PATCH 3/4] Adjust memory allocation to allocate based on max element wire size As we add features to the wire format, this makes is simplier to adjust the memory allocation of the wasm For example, the PR can add 24 new bytes of memory to an element operation that defines border properties probably not a big deal, but its nice to keep it in mind --- term-native.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/term-native.ts b/term-native.ts index 40e646d..4f32dbf 100644 --- a/term-native.ts +++ b/term-native.ts @@ -16,6 +16,15 @@ const BoundingBoxStruct = struct({ const BOUNDING_BOX = offsets(BoundingBoxStruct); +const WASM_PAGE_BYTES = 65536; +const TEXT_TRANSFER_BUFFER_BYTES = 1024 * 1024; +const CLAY_DEFAULT_MAX_ELEMENT_COUNT = 8192; + +// Conservative fixed wire-format budget per element. This covers the largest +// non-text open/close element encoding we currently support; text, element id, +// and snapshot payload bytes live in TEXT_TRANSFER_BUFFER_BYTES. +const MAX_FIXED_ELEMENT_WIRE_BYTES = 116; + export interface Native { memory: WebAssembly.Memory; statePtr: number; @@ -92,10 +101,15 @@ export async function createTermNative( let heap = ct.__heap_base.value as number; let size = ct.clayterm_size(w, h); - // grow memory to fit heap + state + ops buffer (1MB headroom for ops) - let needed = heap + size + 1024 * 1024; - let pages = Math.ceil(needed / 65536); - let current = memory.buffer.byteLength / 65536; + // Grow memory once to fit heap + renderer state + fixed transfer buffer. + // The transfer budget is intentionally fixed: text/id/snapshot payload bytes + // get 1MB, and fixed op overhead gets one max-sized element per Clay element. + // Do not grow this dynamically per render; improve the wire format instead. + let transferBytes = TEXT_TRANSFER_BUFFER_BYTES + + CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES; + let needed = heap + size + transferBytes; + let pages = Math.ceil(needed / WASM_PAGE_BYTES); + let current = memory.buffer.byteLength / WASM_PAGE_BYTES; if (pages > current) { memory.grow(pages - current); } From ea06359b356f25756d2100a58e8257e6162778d7 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sun, 14 Jun 2026 10:32:13 -0400 Subject: [PATCH 4/4] Remove explicit any from border tests --- test/border.test.ts | 5 ++--- test/validate.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/border.test.ts b/test/border.test.ts index a81c5e6..3b78100 100644 --- a/test/border.test.ts +++ b/test/border.test.ts @@ -1,4 +1,4 @@ -import { close, fixed, open, rgba } from "../ops.ts"; +import { close, fixed, open, type OpenElement, rgba } from "../ops.ts"; import { createTerm } from "../term.ts"; import { describe, expect, it } from "./suite.ts"; @@ -101,8 +101,7 @@ const CORNERS = "┌┐└┘╭╮╰╯"; /* ── Render helper ────────────────────────────────────────────────── */ -// deno-lint-ignore no-explicit-any -type OpenProps = any; +type OpenProps = Omit; /** 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 diff --git a/test/validate.test.ts b/test/validate.test.ts index 2392218..5f2afec 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -274,8 +274,9 @@ describe("validated", () => { { directive: 0x02, id: "x", border: { color: 0xFF0000, top: {} } }, close(), ]; - // deno-lint-ignore no-explicit-any - expect(() => term.render(invalid as any)).toThrow(TypeError); + expect(() => Reflect.apply(term.render, term, [invalid])).toThrow( + TypeError, + ); }); it("throws on an invalid structured border side color", () => {