Skip to content

Commit be7e746

Browse files
authored
✨ Add border-cell backgrounds (#94)
* ✨ Add border-cell backgrounds * format the spec file * fix merge conflicts * Adds code comment and validation for bg * Updated the tests as suggests by paul
1 parent b0ded4f commit be7e746

7 files changed

Lines changed: 209 additions & 12 deletions

File tree

examples/inline-regions/index.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const GREEN = rgba(80, 250, 123);
3939
const GREEN_BG = rgba(20, 70, 38);
4040
const GRAY = rgba(100, 100, 100);
4141
const CYAN = rgba(139, 233, 253);
42+
const DARK_BG = rgba(30, 30, 40);
4243

4344
const RED = rgba(255, 0, 0);
4445
const ORANGE = rgba(255, 153, 0);
@@ -84,7 +85,7 @@ await main(function* () {
8485
);
8586

8687
let first = term.render(
87-
box("Press any key to compile modules.", CYAN, GRAY),
88+
box("Press any key to compile modules.", CYAN, GRAY, DARK_BG),
8889
{ row },
8990
);
9091
write(new Uint8Array(first.output));
@@ -101,6 +102,7 @@ await main(function* () {
101102
`${icon} ${label} ${time}`,
102103
done ? GREEN : CYAN,
103104
done ? GREEN : GRAY,
105+
DARK_BG,
104106
),
105107
{ row },
106108
);
@@ -114,6 +116,32 @@ await main(function* () {
114116

115117
yield* sleep(500);
116118

119+
// Demo: border bg
120+
write(encode("\n\n\n"));
121+
122+
let bgPos = yield* queryCursor();
123+
let bgRow = bgPos.row - 2;
124+
write(ESC("7"));
125+
126+
let bgTerm = validated(
127+
yield* until(createTerm({ width: columns, height: 3 })),
128+
);
129+
130+
let PURPLE_BG = rgba(80, 40, 120);
131+
let bgResult = bgTerm.render(
132+
box("Border backgrounds fill border cells.", WHITE, GREEN, PURPLE_BG),
133+
{ row: bgRow },
134+
);
135+
write(new Uint8Array(bgResult.output));
136+
137+
waitKey();
138+
139+
write(ESC("8"));
140+
write(CSI("0m"));
141+
write(encode("\n"));
142+
143+
yield* sleep(200);
144+
117145
write(
118146
encode(
119147
"\nRegions can be multi-line, but they can be a single line too. (continue...)",
@@ -322,7 +350,7 @@ function waitKey(): void {
322350
}
323351
}
324352

325-
function box(msg: string, fg: number, border: number): Op[] {
353+
function box(msg: string, fg: number, border: number, bg: number): Op[] {
326354
return [
327355
open("root", {
328356
layout: { width: grow(), height: grow(), direction: "ttb" },
@@ -337,6 +365,7 @@ function box(msg: string, fg: number, border: number): Op[] {
337365
},
338366
border: {
339367
color: border,
368+
bg,
340369
left: 1,
341370
right: 1,
342371
top: 1,

ops.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ export function pack(
164164
let b = op.border;
165165
view.setUint32(o, b.color, true);
166166
o += 4;
167+
168+
// ATTR_DEFAULT sentinel (bit 31 set) means "use terminal default bg"
169+
let bg = b.bg === undefined ? 0x80000000 : b.bg & 0x00FFFFFF;
170+
view.setUint32(o, bg, true);
171+
o += 4;
172+
167173
view.setUint32(
168174
o,
169175
(b.left ?? 0) | ((b.right ?? 0) << 8) | ((b.top ?? 0) << 16) |
@@ -310,6 +316,7 @@ export interface OpenElement {
310316
cornerRadius?: { tl?: number; tr?: number; bl?: number; br?: number };
311317
border?: {
312318
color: number;
319+
bg?: number;
313320
left?: number;
314321
right?: number;
315322
top?: number;
@@ -446,7 +453,7 @@ function packSize(ops: Op[]): number {
446453
if (op.layout) n += 6 * 4 + 4 + 4 + 4; // 2 axes (3 words each) + pad + gap + align
447454
if (op.bg !== undefined) n += 4;
448455
if (op.cornerRadius) n += 4;
449-
if (op.border) n += 8;
456+
if (op.border) n += 12;
450457
if (op.clip) n += 4;
451458
// x, y, expand width/height, parent, attach/pointer, clip/z
452459
if (op.floating) n += 7 * 4;

specs/renderer-spec.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,8 @@ The `open()` constructor currently accepts the following property groups in its
644644
padding (per-side), alignment (`alignX`: `"left"` | `"center"` | `"right"`;
645645
`alignY`: `"top"` | `"center"` | `"bottom"`, defaulting to left/top when
646646
omitted), direction (top-to-bottom or left-to-right), and gap
647-
- **`border`** — per-side border widths and border color
647+
- **`border`** — per-side border widths, border color, and border background
648+
color
648649
- **`cornerRadius`** — per-corner radius values, producing rounded box-drawing
649650
characters
650651
- **`clip`** — clip region configuration for scroll containers
@@ -708,6 +709,13 @@ The `text()` constructor accepts: `color`, `bg`, `fontSize`, `letterSpacing`,
708709
These property groups represent the current implementation surface. New groups
709710
and fields have been added incrementally and more may follow.
710711

712+
**Border background.** When `border.bg` is provided, the renderer MUST apply
713+
that background color to all cells occupied by border glyphs (corners,
714+
horizontal edges, and vertical edges). When `border.bg` is omitted, border
715+
rendering MUST NOT override the background already present in each border cell;
716+
element backgrounds established by `open({ bg })` remain in effect, and the
717+
terminal default remains in effect where no element background applies.
718+
711719
**Border width and layout interaction.** In the underlying layout engine (Clay),
712720
border configuration does not affect layout computation. This is Clay's intended
713721
behavior. Borders are drawn as visual overlays within the element's bounding

src/clayterm.c

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,11 @@ static void render_text(struct Clayterm *ct, int x0, int y0,
301301
}
302302

303303
static void render_border(struct Clayterm *ct, int x0, int y0, int x1, int y1,
304-
Clay_BorderRenderData *b) {
304+
Clay_RenderCommand *cmd) {
305+
Clay_BorderRenderData *b = &cmd->renderData.border;
305306
uint32_t fg = color(b->color);
306-
uint32_t bg = ATTR_DEFAULT;
307+
/* userData is currently exclusively the packed border-bg word. */
308+
uint32_t bg = (uint32_t)(uintptr_t)cmd->userData;
307309
int top = b->width.top > 0;
308310
int bot = b->width.bottom > 0;
309311
int left = b->width.left > 0;
@@ -533,6 +535,8 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) {
533535
if (mask & PROP_BORDER) {
534536
decl.border.color = unpack_color(rd(buf, len, &i));
535537

538+
decl.userData = (void *)(uintptr_t)rd(buf, len, &i);
539+
536540
uint32_t bw = rd(buf, len, &i);
537541
decl.border.width.left = bw & 0xff;
538542
decl.border.width.right = (bw >> 8) & 0xff;
@@ -627,7 +631,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) {
627631
render_text(ct, x0, y0, cmd);
628632
break;
629633
case CLAY_RENDER_COMMAND_TYPE_BORDER:
630-
render_border(ct, x0, y0, x1, y1, &cmd->renderData.border);
634+
render_border(ct, x0, y0, x1, y1, cmd);
631635
break;
632636
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START:
633637
ct->clipping = 1;

test/color.test.ts

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
import { close, grow, open, rgba, text } from "../ops.ts";
1+
import { close, fixed, grow, open, rgba, text } from "../ops.ts";
22
import { createTerm } from "../term.ts";
33
import { describe, expect, it } from "./suite.ts";
44

55
const decode = (b: Uint8Array) => new TextDecoder().decode(b);
66

7-
type TextBgColor = {
7+
type BgColor = {
88
value: number;
99
sgr: string;
1010
};
1111

12-
function randomTextBgColor(): TextBgColor {
12+
type Cell = {
13+
ch: string;
14+
bg?: string;
15+
};
16+
17+
function randomBgColor(): BgColor {
1318
let r = 0;
1419
let g = 0;
1520
let b = 0;
@@ -30,6 +35,41 @@ function randomTextBgColor(): TextBgColor {
3035
};
3136
}
3237

38+
function cells(ansi: string): Cell[] {
39+
let result: Cell[] = [];
40+
let bg: string | undefined;
41+
42+
for (let i = 0; i < ansi.length;) {
43+
if (ansi[i] === "\x1b" && ansi[i + 1] === "[") {
44+
let end = i + 2;
45+
while (end < ansi.length && !/[A-Za-z]/.test(ansi[end])) {
46+
end++;
47+
}
48+
49+
let seq = ansi.slice(i, end + 1);
50+
if (seq === "\x1b[0m") {
51+
bg = undefined;
52+
} else if (seq.startsWith("\x1b[48;2;") && seq.endsWith("m")) {
53+
bg = seq.slice(0, -1);
54+
}
55+
56+
i = end + 1;
57+
continue;
58+
}
59+
60+
result.push({ ch: ansi[i], bg });
61+
i++;
62+
}
63+
64+
return result;
65+
}
66+
67+
function firstCell(cells: Cell[], ch: string): Cell {
68+
let cell = cells.find((c) => c.ch === ch);
69+
expect(cell).toBeDefined();
70+
return cell!;
71+
}
72+
3373
describe("foreground", () => {
3474
it("emits uncolored text with no foreground", async () => {
3575
let term = await createTerm({ width: 12, height: 1 });
@@ -41,9 +81,67 @@ describe("foreground", () => {
4181
});
4282

4383
describe("background", () => {
84+
it("fills border cells with the requested border-level bg", async () => {
85+
let term = await createTerm({ width: 12, height: 4 });
86+
let bg = randomBgColor();
87+
let ansi = decode(
88+
term.render([
89+
open("box", {
90+
layout: { width: fixed(8), height: fixed(3), direction: "ttb" },
91+
border: {
92+
color: rgba(255, 255, 255),
93+
bg: bg.value,
94+
left: 1,
95+
right: 1,
96+
top: 1,
97+
bottom: 1,
98+
},
99+
}),
100+
text("Hi"),
101+
close(),
102+
], { mode: "line" }).output,
103+
);
104+
105+
expect(ansi).toContain(`${bg.sgr}m┌`);
106+
107+
let rendered = cells(ansi);
108+
expect(firstCell(rendered, "┌").bg).toBe(bg.sgr);
109+
expect(firstCell(rendered, "─").bg).toBe(bg.sgr);
110+
expect(firstCell(rendered, "┐").bg).toBe(bg.sgr);
111+
expect(firstCell(rendered, "│").bg).toBe(bg.sgr);
112+
});
113+
114+
it("leaves existing border-cell bg unchanged when border bg is omitted", async () => {
115+
let term = await createTerm({ width: 12, height: 4 });
116+
let bg = randomBgColor();
117+
let ansi = decode(
118+
term.render([
119+
open("box", {
120+
layout: { width: fixed(8), height: fixed(3), direction: "ttb" },
121+
bg: bg.value,
122+
border: {
123+
color: rgba(255, 255, 255),
124+
left: 1,
125+
right: 1,
126+
top: 1,
127+
bottom: 1,
128+
},
129+
}),
130+
text("Hi"),
131+
close(),
132+
], { mode: "line" }).output,
133+
);
134+
135+
let rendered = cells(ansi);
136+
expect(firstCell(rendered, "┌").bg).toBe(bg.sgr);
137+
expect(firstCell(rendered, "─").bg).toBe(bg.sgr);
138+
expect(firstCell(rendered, "┐").bg).toBe(bg.sgr);
139+
expect(firstCell(rendered, "│").bg).toBe(bg.sgr);
140+
});
141+
44142
it("fills glyph cells with the requested text-level bg", async () => {
45143
let term = await createTerm({ width: 20, height: 1 });
46-
let bg = randomTextBgColor();
144+
let bg = randomBgColor();
47145
let ansi = decode(
48146
term.render([
49147
open("root", { layout: { width: grow(), height: grow() } }),
@@ -56,9 +154,52 @@ describe("background", () => {
56154
expect(beforeH).toContain(bg.sgr);
57155
});
58156

157+
it("resets border bg on subsequent frames without border bg", async () => {
158+
let term = await createTerm({ width: 12, height: 4 });
159+
let bg = randomBgColor();
160+
161+
// Frame 1: border with bg
162+
term.render([
163+
open("box", {
164+
layout: { width: fixed(8), height: fixed(3), direction: "ttb" },
165+
border: {
166+
color: rgba(255, 255, 255),
167+
bg: bg.value,
168+
left: 1,
169+
right: 1,
170+
top: 1,
171+
bottom: 1,
172+
},
173+
}),
174+
text("Hi"),
175+
close(),
176+
]);
177+
178+
// Frame 2: same border, no bg
179+
let ansi = decode(
180+
term.render([
181+
open("box", {
182+
layout: { width: fixed(8), height: fixed(3), direction: "ttb" },
183+
border: {
184+
color: rgba(255, 255, 255),
185+
left: 1,
186+
right: 1,
187+
top: 1,
188+
bottom: 1,
189+
},
190+
}),
191+
text("Hi"),
192+
close(),
193+
]).output,
194+
);
195+
196+
expect(ansi).not.toContain(bg.sgr);
197+
expect(firstCell(cells(ansi), "┌").bg).toBeUndefined();
198+
});
199+
59200
it("resets the background before writing trailing cells", async () => {
60201
let term = await createTerm({ width: 20, height: 1 });
61-
let bg = randomTextBgColor();
202+
let bg = randomBgColor();
62203
let ansi = decode(
63204
term.render([
64205
open("root", { layout: { width: grow(), height: grow() } }),

test/validate.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ describe("validate", () => {
7979
expect(validate([text("hi", { color: 1.5 })])).toBe(false);
8080
});
8181

82+
it("rejects fractional border background color", () => {
83+
expect(validate([
84+
open("x", { border: { color: 0xFF0000, bg: 1.5, left: 1 } }),
85+
close(),
86+
])).toBe(false);
87+
});
88+
8289
it("accepts structured floating attach points", () => {
8390
expect(validate([
8491
open("x", {

validate.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ const CornerRadius = Type.Object({
8282

8383
const Border = Type.Object({
8484
color: rgba,
85+
bg: Type.Optional(rgba),
8586
left: Type.Optional(u8),
8687
right: Type.Optional(u8),
8788
top: Type.Optional(u8),

0 commit comments

Comments
 (0)