diff --git a/examples/diff/index.ts b/examples/diff/index.ts new file mode 100644 index 0000000..1c4b364 --- /dev/null +++ b/examples/diff/index.ts @@ -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 }; +} diff --git a/examples/inline-regions/index.ts b/examples/inline-regions/index.ts index 6968ff4..b122de8 100644 --- a/examples/inline-regions/index.ts +++ b/examples/inline-regions/index.ts @@ -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); @@ -135,7 +137,7 @@ await main(function* () { direction: "ltr", }, }), - text("✓ Frobnicated", { color: GREEN }), + text(" ✓ Frobnicated ", { color: WHITE, bg: GREEN_BG }), close(), ]; } diff --git a/ops.ts b/ops.ts index 776bfed..6dc45a2 100644 --- a/ops.ts +++ b/ops.ts @@ -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) | @@ -302,6 +310,7 @@ export interface Text { directive: typeof OP_TEXT; content: string; color?: number; + bg?: number; fontSize?: number; fontId?: number; wrap?: number; @@ -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; } diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 6daced8..b7da314 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -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. @@ -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 diff --git a/src/clayterm.c b/src/clayterm.c index 2af9afd..93084e5 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -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() */ @@ -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; @@ -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; @@ -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; @@ -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); diff --git a/test/text-background.test.ts b/test/text-background.test.ts new file mode 100644 index 0000000..a597404 --- /dev/null +++ b/test/text-background.test.ts @@ -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); + }); +}); diff --git a/validate.ts b/validate.ts index 248ea48..3010a4b 100644 --- a/validate.ts +++ b/validate.ts @@ -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),