Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions examples/inline-regions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand All @@ -101,6 +102,7 @@ await main(function* () {
`${icon} ${label} ${time}`,
done ? GREEN : CYAN,
done ? GREEN : GRAY,
DARK_BG,
),
{ row },
);
Expand All @@ -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...)",
Expand Down Expand Up @@ -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" },
Expand All @@ -337,6 +365,7 @@ function box(msg: string, fg: number, border: number): Op[] {
},
border: {
color: border,
bg,
left: 1,
right: 1,
top: 1,
Expand Down
9 changes: 8 additions & 1 deletion ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should leave a comment on top of this, something like "userData is currently exclusively the border-bg word"

int top = b->width.top > 0;
int bot = b->width.bottom > 0;
int left = b->width.left > 0;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
151 changes: 146 additions & 5 deletions test/color.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 });
Expand All @@ -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);
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm here thinking if we should tighten this test a bit more, from what i'm reading this is asserting the bg SGR appears somewhere in the slice(0, corner), which can pass for the wrong reason, the SGR could've been emitted earlier in the stream for something unrelated. better to assert the SGR appears immediately preceding the and ideally check an edge and a second corner too, since the spec explicitly promises corners, horizontal & vertical edges. this is not a blocker btw just a future ref in case we hit a loose bug somewhere.

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() } }),
Expand All @@ -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() } }),
Expand Down
7 changes: 7 additions & 0 deletions test/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down