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
188 changes: 188 additions & 0 deletions examples/diff/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* Diff demo — renders a code-review style diff with inline text backgrounds.
*
* Shows how text-level `bg` can highlight only changed glyph cells without
* filling the entire row or layout box.
*/

import { Buffer } from "node:buffer";
import process from "node:process";
import {
close,
createTerm,
CSI,
fixed,
grow,
type Op,
open,
rgba,
text,
} from "../../mod.ts";
import { validated } from "../../validate.ts";

const encode = (s: string) => new TextEncoder().encode(s);
const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b));

const BG = rgba(31, 45, 34);
const FG = rgba(220, 220, 210);
const MUTED = rgba(140, 145, 140);
const PATH = rgba(139, 210, 210);
const DELETE = rgba(255, 105, 105);
const DELETE_BG = rgba(184, 92, 85);
const ADD = rgba(190, 210, 100);
const ADD_BG = rgba(190, 210, 100);
const INLINE_FG = rgba(31, 45, 34);

const rows: Row[] = [
{
kind: "title",
segments: [
{ value: "edit ", color: FG },
{ value: "examples/inline-regions/index.ts", color: PATH },
],
},
{ kind: "blank" },
code(" ", MUTED, [{ value: "...", color: MUTED }]),
code(" 35", MUTED, [
{
value:
"const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b));",
},
]),
code(" 36", MUTED, [{ value: "" }]),
code(" 37", MUTED, [{ value: "const WHITE = rgba(255, 255, 255);" }]),
code(" 38", MUTED, [{ value: "const GREEN = rgba(80, 250, 123);" }]),
code("- 39", DELETE, [
{ value: "const " },
mark("AGREEN", DELETE_BG),
{ value: " = rgba(" },
mark("80", DELETE_BG),
{ value: ", " },
mark("250", DELETE_BG),
{ value: ", " },
mark("123, 10", DELETE_BG),
{ value: ");" },
]),
code("+ 39", ADD, [
{ value: "const " },
mark("GREEN_BG", ADD_BG),
{ value: " = rgba(" },
mark("20", ADD_BG),
{ value: ", " },
mark("70", ADD_BG),
{ value: ", " },
mark("38", ADD_BG),
{ value: ");" },
]),
code(" 40", MUTED, [{ value: "const GRAY = rgba(100, 100, 100);" }]),
code(" 41", MUTED, [{ value: "const CYAN = rgba(139, 233, 253);" }]),
code(" 42", MUTED, [{ value: "" }]),
code(" 43", MUTED, [{ value: "const RED = rgba(255, 0, 0);" }]),
code(" ", MUTED, [{ value: "...", color: MUTED }]),
code(" 136", MUTED, [{ value: " height: fixed(1)," }]),
code(" 137", MUTED, [{ value: ' direction: "ltr",' }]),
code(" 138", MUTED, [{ value: " }," }]),
code(" 139", MUTED, [{ value: " })," }]),
code("-140", DELETE, [
{ value: ' text(" ✓ Frobnicated ", { color: WHITE, bg: ' },
mark("AGREEN", DELETE_BG),
{ value: " })," },
]),
code("+140", ADD, [
{ value: ' text(" ✓ Frobnicated ", { color: WHITE, bg: ' },
mark("GREEN_BG", ADD_BG),
{ value: " })," },
]),
code(" 141", MUTED, [{ value: " close()," }]),
code(" 142", MUTED, [{ value: " ];" }]),
code(" 143", MUTED, [{ value: " }" }]),
code(" 144", MUTED, [{ value: " let progress = i / (barFrames - 1);" }]),
code(" ", MUTED, [{ value: "...", color: MUTED }]),
];

let { columns } = terminalSize();
let height = rows.length + 2;
let term = validated(await createTerm({ width: columns, height }));
let result = term.render(renderDiff(columns, height), { mode: "line" });
write(new Uint8Array(result.output));
write(CSI("0m"));
write(encode("\n"));

interface Segment {
value: string;
color?: number;
bg?: number;
}

interface Row {
kind: "blank" | "code" | "title";
gutter?: string;
color?: number;
segments?: Segment[];
}

function code(gutter: string, color: number, segments: Segment[]): Row {
return { kind: "code", gutter, color, segments };
}

function mark(value: string, bg: number): Segment {
return { value, color: INLINE_FG, bg };
}

function renderDiff(width: number, height: number): Op[] {
let ops: Op[] = [
open("root", {
layout: {
width: fixed(width),
height: fixed(height),
direction: "ttb",
padding: { left: 1, top: 1 },
},
bg: BG,
}),
];

rows.forEach((row, index) => {
ops.push(
open(`row-${index}`, {
layout: { width: grow(), height: fixed(1), direction: "ltr" },
}),
);

if (row.kind === "blank") {
ops.push(close());
return;
}

if (row.kind === "title") {
for (let segment of row.segments ?? []) {
ops.push(text(segment.value, { color: segment.color }));
}
ops.push(close());
return;
}

ops.push(text(`${row.gutter ?? ""} `, { color: row.color }));
for (let segment of row.segments ?? []) {
ops.push(
text(segment.value, {
color: segment.color ?? row.color,
bg: segment.bg,
}),
);
}
ops.push(close());
});

ops.push(close());
return ops;
}

function terminalSize(): { columns: number; rows: number } {
return process.stdout.isTTY
? {
columns: process.stdout.columns ?? 100,
rows: process.stdout.rows ?? 24,
}
: { columns: 100, rows: 24 };
}
4 changes: 3 additions & 1 deletion examples/inline-regions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ import { validated } from "../../validate.ts";
const encode = (s: string) => new TextEncoder().encode(s);
const write = (b: Uint8Array) => process.stdout.write(Buffer.from(b));

const WHITE = rgba(255, 255, 255);
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);

Expand Down Expand Up @@ -135,7 +137,7 @@ await main(function* () {
direction: "ltr",
},
}),
text("✓ Frobnicated", { color: GREEN }),
text(" ✓ Frobnicated ", { color: WHITE, bg: GREEN_BG }),
close(),
];
}
Expand Down
11 changes: 10 additions & 1 deletion ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ export function pack(
let textDefault = op.color === undefined;
view.setUint32(o, op.color ?? 0, true);
o += 4;

// No explicit bg: leave the terminal default bg by writing
// 0 and setting ATTR_DEFAULT (0x80 in the attrs byte). The C path ORs
// it into bg and emit_attr skips the background SGR
let bg = op.bg === undefined ? 0x80000000 : op.bg & 0x00FFFFFF;
view.setUint32(o, bg, true);
o += 4;

view.setUint32(
o,
(op.fontSize ?? 1) |
Expand Down Expand Up @@ -302,6 +310,7 @@ export interface Text {
directive: typeof OP_TEXT;
content: string;
color?: number;
bg?: number;
fontSize?: number;
fontId?: number;
wrap?: number;
Expand Down Expand Up @@ -356,7 +365,7 @@ function packSize(ops: Op[]): number {
break;
}
case OP_TEXT: {
n += 4 + 4 + 4; // opcode + color + cfg
n += 4 + 4 + 4 + 4; // opcode + color + bg + cfg
n += 4 + Math.ceil(encoder.encode(op.content).length / 4) * 4; // string
break;
}
Expand Down
14 changes: 11 additions & 3 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,14 @@ render. The optional `props` parameter carries text styling configuration.

Text directives MUST appear between a matching open/close pair.

When `props.bg` is provided, the renderer MUST apply that background color only
to cells occupied by glyphs emitted by that text directive. It MUST NOT fill
trailing cells or other cells in the text element's bounding rectangle that are
not occupied by emitted glyphs. When `props.bg` is omitted, text rendering MUST
NOT override the background already present in each glyph cell; element
backgrounds established by `open({ bg })` remain in effect, and the terminal
default remains in effect where no element background applies.

The set of styling properties accepted by `props` is part of the current
implementation surface and may be extended.

Expand Down Expand Up @@ -644,9 +652,9 @@ The `open()` constructor currently accepts the following property groups in its
attach points, z-index)
- **`scroll`** — scroll container configuration

The `text()` constructor currently accepts: `color`, `fontSize`,
`letterSpacing`, `lineHeight`, and attribute flags (`bold`, `italic`,
`underline`, `strikethrough`).
The `text()` constructor accepts: `color`, `bg`, `fontSize`, `letterSpacing`,
`lineHeight`, and attribute flags (`bold`, `italic`, `underline`,
`strikethrough`).

These property groups represent the current implementation surface. New groups
and fields have been added incrementally and more may follow. Alignment values
Expand Down
11 changes: 8 additions & 3 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,10 @@ static void render_rect(struct Clayterm *ct, int x0, int y0, int x1, int y1,
}

static void render_text(struct Clayterm *ct, int x0, int y0,
Clay_TextRenderData *t) {
Clay_RenderCommand *cmd) {

Clay_TextRenderData *t = &cmd->renderData.text;
uint32_t bg = (uint32_t)(uintptr_t)cmd->userData;
uint32_t fg = color(t->textColor);

/* text attrs are packed into the alpha channel by reduce() */
Expand All @@ -289,7 +292,7 @@ static void render_text(struct Clayterm *ct, int x0, int y0,
if (cw < 0)
cw = 1;
if (cw > 0) {
setcell(ct, x, y0, cp, fg, ATTR_DEFAULT);
setcell(ct, x, y0, cp, fg, bg);
x += cw;
}
p += n;
Expand Down Expand Up @@ -561,6 +564,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) {

case OP_TEXT: {
uint32_t col = rd(buf, len, &i);
uint32_t bg = rd(buf, len, &i);
uint32_t cfg = rd(buf, len, &i);
uint32_t str_len = rd(buf, len, &i);
int str_words = (str_len + 3) / 4;
Expand All @@ -570,6 +574,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) {
Clay_String text = {.length = (int32_t)str_len, .chars = str_chars};

Clay_TextElementConfig config = {0};
config.userData = (void *)(uintptr_t)bg;
config.textColor = unpack_color(col);
config.fontSize = cfg & 0xff;
config.fontId = (cfg >> 8) & 0xff;
Expand Down Expand Up @@ -613,7 +618,7 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) {
render_rect(ct, x0, y0, x1, y1, &cmd->renderData.rectangle);
break;
case CLAY_RENDER_COMMAND_TYPE_TEXT:
render_text(ct, x0, y0, &cmd->renderData.text);
render_text(ct, x0, y0, cmd);
break;
case CLAY_RENDER_COMMAND_TYPE_BORDER:
render_border(ct, x0, y0, x1, y1, &cmd->renderData.border);
Expand Down
74 changes: 74 additions & 0 deletions test/text-background.test.ts

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're consolidating on test/color.test.ts for foreground/background behavior. feel free to rename this

Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it } from "./suite.ts";
import { createTerm, type Term } from "../term.ts";
import { close, grow, open, rgba, text } from "../ops.ts";

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

type TextBgColor = {
value: number;
sgr: string;
};

function randomTextBgColor(): TextBgColor {
let r = 0;
let g = 0;
let b = 0;

do {
r = Math.floor(Math.random() * 256);
g = Math.floor(Math.random() * 256);
b = Math.floor(Math.random() * 256);
} while (
(r === 255 && g === 0 && b === 0) ||
(r === 0 && g === 255 && b === 0) ||
(r === 0 && g === 0 && b === 255)
);

return {
value: rgba(r, g, b),
sgr: `\x1b[48;2;${r};${g};${b}`,
};
}

describe("text background color", () => {
let term: Term;

beforeEach(async () => {
term = await createTerm({ width: 20, height: 1 });
});

it("fills glyph cells with the requested text-level bg", () => {
let bg = randomTextBgColor();
let ansi = decode(
term.render([
open("root", { layout: { width: grow(), height: grow() } }),
text("Hi", { bg: bg.value }),
close(),
]).output,
);

let beforeH = ansi.slice(0, ansi.indexOf("H"));
expect(beforeH).toContain(bg.sgr);
});

it("resets the background before writing trailing cells", () => {
let bg = randomTextBgColor();
let ansi = decode(
term.render([
open("root", { layout: { width: grow(), height: grow() } }),
text("Hi", { bg: bg.value }),
close(),
]).output,
);

let beforeH = ansi.slice(0, ansi.indexOf("H"));
expect(beforeH).toContain(bg.sgr);

let hi = ansi.indexOf("Hi");
expect(hi).toBeGreaterThanOrEqual(0);

let afterHi = ansi.slice(hi + 2);
expect(afterHi).not.toContain(bg.sgr);
expect(afterHi.startsWith("\x1b[0m ")).toBe(true);
});
});
1 change: 1 addition & 0 deletions validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const TextOp = Type.Object({
directive: Type.Literal(0x03),
content: Type.String(),
color: Type.Optional(rgba),
bg: Type.Optional(rgba),
fontSize: Type.Optional(u8),
fontId: Type.Optional(u8),
wrap: Type.Optional(u8),
Expand Down
Loading