From a3908f43bd868d0c16ac72bcd0dc283e47e1d449 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 18 Oct 2026 18:36:33 -0400 Subject: [PATCH 1/4] =?UTF-8?q?spec:=20CSS=20system=20colors=20map=20to=20?= =?UTF-8?q?the=20terminal's=20ANSI=2016-color=20palette=20(=C2=A78.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System color keywords stop being reserved and resolve to terminal-themed colors: the terminal default foreground/background, or palette entries 0-15, per a fixed keyword table. currentcolor stays reserved. The six default keywords (Canvas, CanvasText, ButtonFace, ButtonText, Field, FieldText) all mean "the terminal default for this attribute". CanvasText in a bg field is the default background, and Canvas in a fg field the default foreground. SGR has no way to paint the default foreground as a background, and approximating it with a palette entry is wrong on either light or dark themes, so the field decides. Rejecting the cross-field keywords was the alternative; it would need a role-aware resolver, validator, and literal checker for little gain. Encoding (§12.1, descriptive). Cells and the text/border color words share one encoding: bit 31 clear is 0x00RRGGBB, bit 31 set is terminal-themed, with 0x80000000 the default and 0x80000100|n palette n. bit 31 is already ATTR_DEFAULT and the fg high byte has no free bits, so reusing the low bits under it costs no cell width and keeps diffing an exact compare (each color has one canonical word). It forces the wire-only "keep existing bg" sentinel off 0x80000000 (now 0x40000000), since an explicit Canvas bg must overwrite while an omitted bg keeps. Element bg keeps its full ARGB word, since alpha 0 suppresses the fill and every bit is in use, so a terminal-themed element bg sets a new property-mask bit instead. sRGB colors stay bit-identical on the wire. Widening the cell with a separate flags word was the other option; it grows every cell by 4 bytes for information that fits in unused bits. Transitions can't interpolate a color the renderer has no RGB for, so a terminal-themed endpoint switches discretely at eased progress 0.5, matching CSS discrete animation. --- specs/renderer-spec.md | 94 +++++++++++++++++++++++++++++++++------ specs/transitions-spec.md | 5 +++ 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 7b7ff8c..4a488d8 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -802,10 +802,11 @@ wherever the directive model expects a color. `open({ bg })`, `border.color`, `border.bg`, the `color` and `bg` fields of structured border sides, and `text({ color, bg })` — accepts both forms. Each color string is resolved to a packed ARGB value at the transfer-encoding -boundary (§9.2), so color strings MUST NOT appear in the transfer encoding. The -wire format, snapshot encoding, and cell representation are unchanged by this -acceptance: packed sRGB ARGB remains the only color representation the renderer -sees. +boundary (§9.2), so color strings MUST NOT appear in the transfer encoding. +Every color string except a system color resolves to packed sRGB ARGB, and its +wire, snapshot, and cell encoding is bit-identical to the equivalent `rgba()` +value. System colors resolve to terminal-themed colors instead (see **System +colors** below). **Color string grammar.** A color string is one of the following CSS Color Module Level 4 forms. Function names and keywords are case-insensitive. Leading @@ -817,6 +818,8 @@ or trailing whitespace is not permitted. pair (or digit, doubled). - **Named colors.** The CSS named color keywords (`red`, `rebeccapurple`, ...), and `transparent`, which is black with alpha 0. +- **System colors.** The CSS Color 4 system color keywords listed under **System + colors** below. - **`rgb()` / `rgba()`.** Modern space-separated syntax with optional `/ alpha`, and the legacy comma-separated syntax. - **`hsl()` / `hsla()`.** Modern and legacy syntax. @@ -828,11 +831,62 @@ channel permits them, angles (`deg`, `rad`, `grad`, `turn`) for hues, and the relative color syntax, and `device-cmyk()` are not supported and are invalid color strings. -CSS system colors (`Canvas`, `CanvasText`, `LinkText`, ...) and `currentcolor` -are reserved. They are invalid color strings in this version. System colors are -reserved for mapping to the terminal's themable ANSI 16-color palette, which a -packed sRGB value cannot express; that mapping, and `currentcolor`, will be -specified separately. +`currentcolor` is reserved. It is an invalid color string in this version and +will be specified separately. + +**System colors.** The CSS Color 4 system color keywords resolve to colors the +terminal's theme controls rather than to sRGB values: either the terminal's +default foreground or background color, or an entry of the terminal's ANSI +16-color palette. The renderer never learns the RGB value of a terminal-themed +color; the terminal applies its own theme when it draws the cell. + +| Keyword | Resolves to | +| --------------------------------------- | --------------------------- | +| `Canvas`, `ButtonFace`, `Field` | terminal default background | +| `CanvasText`, `ButtonText`, `FieldText` | terminal default foreground | +| `ActiveText` | palette 1 (red) | +| `Mark` | palette 3 (yellow) | +| `LinkText`, `Highlight`, `SelectedItem` | palette 4 (blue) | +| `VisitedText` | palette 5 (magenta) | +| `AccentColor` | palette 6 (cyan) | +| `GrayText`, `ButtonBorder` | palette 8 (bright black) | +| `HighlightText`, `SelectedItemText` | palette 15 (bright white) | +| `MarkText`, `AccentColorText` | palette 0 (black) | + +The six default keywords name the terminal default color of the attribute the +field sets, whichever keyword is used. In a foreground field (`text({ color })`, +`border.color`, a border side's `color`) each of them resolves to the terminal +default foreground; in a background field (`open({ bg })`, `text({ bg })`, +`border.bg`, a border side's `bg`) each resolves to the terminal default +background. `text({ bg: "CanvasText" })` therefore renders the default +background, not the default foreground color painted as a background: a terminal +offers no way to paint its default foreground color as a cell background, and +the renderer does not guess one. + +A terminal-themed color is opaque. A palette color in a foreground field is +emitted as SGR `30`–`37` for palette 0–7 and `90`–`97` for palette 8–15; in a +background field, as SGR `40`–`47` and `100`–`107`. A terminal default color is +emitted as SGR `39` (foreground) or `49` (background), or by omitting the +foreground or background SGR after an SGR reset, which leaves the same default +in effect. + +A terminal default background is a color, not the absence of one. An explicit +`Canvas` background (or `ButtonFace`, `Field`, or any default keyword in a +background field) MUST overwrite cells with the terminal default background, +including cells an enclosing element painted. This is distinct from an omitted +`bg`, which keeps the background already present in each cell (§12.2). + +Cell diffing treats terminal-themed colors like any other attribute: a cell +whose color changes between a palette color, a terminal default, and an sRGB +color in consecutive frames MUST be re-emitted, and an unchanged cell MUST NOT +be. The renderer does not resolve a palette color to RGB to compare it with an +sRGB color. + +A transition (see the Transitions Specification) cannot interpolate between a +terminal-themed color and any other color. When either endpoint of an +interpolated color is terminal-themed, the color changes discretely: it holds +the initial value while eased progress is below 0.5 and takes the target value +from 0.5 on. **Resolution.** A color string resolves to sRGB, then to a packed ARGB value: each of R, G, B is clamped to [0, 255] and rounded to the nearest integer; alpha @@ -852,11 +906,11 @@ emitting a partially decoded or garbage color value. The directive constructors check string literals at compile time: a literal beginning with `#` must match the hex form (the type-level equivalent of `#${string{3,4,6,8}}`); a literal containing `(` must begin with a supported -function name; any other literal must be a named color keyword or `transparent`. -Function arguments are not checked statically. Strings whose value is only known -at runtime (colors read from a theme or configuration, for example) pass the -type check as `string` and are checked by `validate()` and the packer at render -time. +function name; any other literal must be a named color keyword, `transparent`, +or a system color keyword. Function arguments are not checked statically. +Strings whose value is only known at runtime (colors read from a theme or +configuration, for example) pass the type check as `string` and are checked by +`validate()` and the packer at render time. ### 8.6 Term update @@ -1069,6 +1123,18 @@ likely to remain stable in structure while continuing to grow. However, specific opcode values, mask definitions, and field layouts are implementation details and are not locked down by this specification. +**Color words.** Text colors and border side colors travel as one 32-bit +attribute word each, in the same encoding the renderer stores in its cells. When +bit 31 is clear, the low 24 bits are an sRGB color (`0x00RRGGBB`). When bit 31 +is set, the color is terminal-themed: `0x80000000` is the terminal default, and +`0x80000100 | n` is ANSI palette entry `n` (0–15). Text foreground carries bit +31 in the text attribute byte rather than in the color word. A background word +of `0x40000000` means "keep the existing cell background" and is used for an +omitted `bg`; it never appears in a cell. Element backgrounds keep their full +packed ARGB word, because alpha 0 suppresses the fill; a terminal-themed element +background sets an additional property-mask bit that marks its background word +as a color word. + ### 12.2 Directive property groups The `open()` constructor currently accepts the following property groups in its diff --git a/specs/transitions-spec.md b/specs/transitions-spec.md index 3387d39..3c89eb9 100644 --- a/specs/transitions-spec.md +++ b/specs/transitions-spec.md @@ -322,6 +322,11 @@ Group names expand as follows: - `size` → `width`, `height` - `all` → every individual property above +A color property whose initial or target value is terminal-themed (a CSS system +color, renderer-spec §8.5) changes discretely instead of interpolating: it holds +the initial value while eased progress is below 0.5 and takes the target value +from 0.5 on. + --- ## 8. Wire Encoding From 9c742ac716fcd99f0039b9a38483e29509ed3ed0 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 18 Oct 2026 18:45:12 -0400 Subject: [PATCH 2/4] feat: CSS system colors render with the terminal's ANSI 16-color palette resolveColor() checks a lowercase keyword table before hueful and returns a TerminalColor ({ ansi: index | "default" }) for system colors, so validate() accepts them through the existing css-color format and the CheckedColor literal check gains them via the same table. The packer turns a TerminalColor into a color word: 0x80000000 for the default, 0x80000100|n for palette n. Text fg keeps bit 31 in the attrs byte as before, so an omitted color and a default keyword pack identically. The omitted-bg sentinel for text and border words moves to BG_KEEP (0x40000000) so an explicit Canvas bg can be 0x80000000 and overwrite. Element bg keeps its ARGB word; PROP_BG_TERMINAL (0x100) marks the word as a color word instead. Element bg reaches render_rect through Clay_Color floats, so the C side carries a terminal color as r = -1 with g = palette index (or -1 for the default) and a = 255, which survives the float round trip and keeps Clay's a > 0 rectangle check. color() maps it back to the cell word. lerp_color() switches discretely at 0.5 when either endpoint carries that marker, instead of interpolating a meaningless channel. emit_attr() emits SGR 30-37/90-97 and 40-47/100-107 for palette words and leaves defaults to the leading SGR reset, as before. Cells compare with the same integer equality; each color has one canonical word. --- .changeset/system-colors-ansi16.md | 14 +++ color.ts | 43 ++++++- ops.ts | 86 +++++++++----- src/cell.h | 6 +- src/clayterm.c | 39 ++++++- src/transitions.c | 2 + src/transitions.h | 4 + test/border.test.ts | 49 ++++++++ test/color.test.ts | 178 ++++++++++++++++++++++++++++- test/pack.test.ts | 60 +++++++++- test/transitions.test.ts | 42 +++++++ test/validate.test.ts | 55 ++++++++- 12 files changed, 535 insertions(+), 43 deletions(-) create mode 100644 .changeset/system-colors-ansi16.md diff --git a/.changeset/system-colors-ansi16.md b/.changeset/system-colors-ansi16.md new file mode 100644 index 0000000..8e11140 --- /dev/null +++ b/.changeset/system-colors-ansi16.md @@ -0,0 +1,14 @@ +--- +"@bomb.sh/tty": minor +--- + +Color fields accept the CSS system color keywords (`Canvas`, `CanvasText`, +`LinkText`, `Highlight`, `GrayText`, `Mark`, `AccentColor`, ...), +case-insensitively. Instead of a fixed RGB value, each one renders with the +terminal's own theme: the default foreground or background, or an entry of the +ANSI 16-color palette (`LinkText` is blue, `GrayText` is bright black, and so +on), so output follows the user's light or dark terminal theme. The +default-color keywords mean the default of the attribute they set, so +`text({ bg: "Canvas" })` paints the terminal default background over any +enclosing element background, while an omitted `bg` still keeps it. Packed +`rgba()` colors and other color strings render exactly as before. diff --git a/color.ts b/color.ts index 63d4b0c..f93c797 100644 --- a/color.ts +++ b/color.ts @@ -1,15 +1,52 @@ import { convert, parse } from "hueful"; +/** + * A §8.5 terminal-themed color: an ANSI palette index (0–15), or the + * terminal default color of whichever attribute the field sets. + */ +export interface TerminalColor { + ansi: number | "default"; +} + +export const systemColors = { + canvas: "default", + buttonface: "default", + field: "default", + canvastext: "default", + buttontext: "default", + fieldtext: "default", + activetext: 1, + mark: 3, + linktext: 4, + highlight: 4, + selecteditem: 4, + visitedtext: 5, + accentcolor: 6, + graytext: 8, + buttonborder: 8, + highlighttext: 15, + selecteditemtext: 15, + marktext: 0, + accentcolortext: 0, +} as const satisfies Record; + function byte(unit: number): number { return Math.round(Math.min(Math.max(unit, 0), 1) * 255); } /** * Resolves a §8.5 CSS color string to the packed ARGB number the transfer - * encoding carries, or `undefined` when the string is not a supported color. - * Channels clamp to the sRGB range, so out-of-gamut colors clip per channel. + * encoding carries, a terminal-themed color for a system color keyword, or + * `undefined` when the string is not a supported color. Channels clamp to + * the sRGB range, so out-of-gamut colors clip per channel. */ -export function resolveColor(input: string): number | undefined { +export function resolveColor( + input: string, +): number | TerminalColor | undefined { + let lower = input.toLowerCase(); + if (Object.hasOwn(systemColors, lower)) { + return { ansi: systemColors[lower as keyof typeof systemColors] }; + } let parsed = parse(input); if (parsed === null || parsed.space === "cmyk") return undefined; let [r, g, b, alpha = 1] = convert(parsed.space, "rgb", parsed.value); diff --git a/ops.ts b/ops.ts index 9206f73..5635d90 100644 --- a/ops.ts +++ b/ops.ts @@ -1,5 +1,9 @@ import type { names } from "hueful"; -import { resolveColor } from "./color.ts"; +import { + resolveColor, + type systemColors, + type TerminalColor, +} from "./color.ts"; import { type PointerShape, pointerShapeIndex } from "./pointer-shape.ts"; export type { PointerShape }; @@ -41,6 +45,13 @@ const PROP_CLIP = 0x10; const PROP_FLOATING = 0x20; const PROP_TRANSITION = 0x40; const PROP_CURSOR = 0x80; +const PROP_BG_TERMINAL = 0x100; + +/* Color words (§12.1): bit 31 marks a terminal-themed color, either the + * default (no low bits) or a palette entry (PALETTE | index). */ +const TERMINAL = 0x80000000; +const PALETTE = 0x100; +const BG_KEEP = 0x40000000; const encoder = new TextEncoder(); @@ -91,7 +102,7 @@ function sideFg(side: BorderSide | undefined, shared: Color): number { let color = typeof side === "object" && side.color !== undefined ? side.color : shared; - return colorValue(color) & 0x00FFFFFF; + return colorWord(color); } function sideBg( @@ -99,8 +110,7 @@ function sideBg( shared: Color | 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 : colorValue(bg) & 0x00FFFFFF; + return bg === undefined ? BG_KEEP : colorWord(bg); } function packString( @@ -151,9 +161,11 @@ export function pack( let bytes = encoder.encode(op.id); o = packString(view, bytes, o, end, "element id"); + let bg = op.bg === undefined ? undefined : colorValue(op.bg); let mask = 0; if (op.layout) mask |= PROP_LAYOUT; - if (op.bg !== undefined) mask |= PROP_BG_COLOR; + if (typeof bg === "number") mask |= PROP_BG_COLOR; + if (typeof bg === "object") mask |= PROP_BG_COLOR | PROP_BG_TERMINAL; if (op.cornerRadius) mask |= PROP_CORNER_RADIUS; if (op.border) mask |= PROP_BORDER; if (op.clip) mask |= PROP_CLIP; @@ -197,8 +209,12 @@ export function pack( o += 4; } - if (op.bg !== undefined) { - view.setUint32(o, colorValue(op.bg), true); + if (bg !== undefined) { + view.setUint32( + o, + typeof bg === "number" ? bg : terminalWord(bg), + true, + ); o += 4; } @@ -305,23 +321,25 @@ export function pack( case OP_TEXT: { view.setUint32(o, OP_TEXT, true); o += 4; - // No explicit color: leave the terminal default foreground by writing - // 0 and setting ATTR_DEFAULT (0x80 in the attrs byte). The C path ORs - // it into fg and emit_attr skips the foreground SGR (mirrors unset bg). - let textDefault = op.color === undefined; + // The fg word's bit 31 travels in the attrs byte (0x80); the C path + // ORs the attrs byte back over the high byte. No color is the + // terminal default. + let color = op.color === undefined + ? { ansi: "default" as const } + : colorValue(op.color); + let terminal = typeof color === "object"; view.setUint32( o, - op.color === undefined ? 0 : colorValue(op.color), + typeof color === "number" ? color : terminalWord(color) & 0x00FFFFFF, 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 : colorValue(op.bg) & - 0x00FFFFFF; - view.setUint32(o, bg, true); + view.setUint32( + o, + op.bg === undefined ? BG_KEEP : colorWord(op.bg), + true, + ); o += 4; view.setUint32( @@ -329,7 +347,7 @@ export function pack( (op.fontSize ?? 1) | ((op.fontId ?? 0) << 8) | ((op.wrap ?? 0) << 16) | - (((op.attrs ?? 0) | (textDefault ? 0x80 : 0)) << 24), + (((op.attrs ?? 0) | (terminal ? 0x80 : 0)) << 24), true, ); o += 4; @@ -368,18 +386,27 @@ export function rgba(r: number, g: number, b: number, a = 255): number { export type Color = number | string; /** - * Resolves a color value to the packed ARGB number the transfer encoding - * carries (§8.5). Numbers pass through; strings resolve through the CSS - * color grammar. Invalid strings throw rather than decoding to a garbage - * color. + * Resolves a color value to a packed ARGB number or a terminal-themed color + * (§8.5). Numbers pass through; strings resolve through the CSS color + * grammar. Invalid strings throw rather than decoding to a garbage color. */ -function colorValue(color: Color): number { +function colorValue(color: Color): number | TerminalColor { if (typeof color === "number") return color; let value = resolveColor(color); if (value === undefined) throw new TypeError(`invalid color: "${color}"`); return value; } +function terminalWord(color: TerminalColor): number { + return color.ansi === "default" ? TERMINAL : TERMINAL | PALETTE | color.ansi; +} + +/* The color word text bg and border attributes carry; alpha is dropped. */ +function colorWord(color: Color): number { + let value = colorValue(color); + return typeof value === "number" ? value & 0x00FFFFFF : terminalWord(value); +} + export type SizingAxis = | { type: "fit"; min?: number; max?: number } | { type: "grow"; min?: number; max?: number } @@ -539,9 +566,9 @@ export type Op = OpenElement | Text | CloseElement | Snapshot; * Compile-time color checking (§8.5). A string literal assigned to a color * field must be a hex color (the type-level equivalent of * `#${string{3,4,6,8}}`), start with a supported color function, or be a - * named color. Strings that are not literals (computed colors) and values - * already narrowed to `#${string}` pass through; the runtime grammar check - * in validate()/pack() is normative for them. Plain unions like + * named or system color. Strings that are not literals (computed colors) and + * values already narrowed to `#${string}` pass through; the runtime grammar + * check in validate()/pack() is normative for them. Plain unions like * `HexRun` would need ~16^6 union members, which TypeScript cannot * represent (TS2590), so hex checking runs per-character via recursion. */ @@ -586,7 +613,10 @@ type ColorFunction = | "oklab" | "oklch"; -type NamedColor = keyof typeof names | "transparent"; +type NamedColor = + | keyof typeof names + | keyof typeof systemColors + | "transparent"; type CheckedColor = T extends number ? T : T extends string ? string extends T ? T diff --git a/src/cell.h b/src/cell.h index 1755b2c..74de5a6 100644 --- a/src/cell.h +++ b/src/cell.h @@ -25,7 +25,11 @@ typedef struct { #define ATTR_BLINK 0x10000000 #define ATTR_REVERSE 0x20000000 #define ATTR_STRIKEOUT 0x40000000 -#define ATTR_DEFAULT 0x80000000 /* use terminal default color */ +#define ATTR_DEFAULT 0x80000000 /* terminal-themed: default, or palette */ +#define ATTR_PALETTE 0x00000100 /* with ATTR_DEFAULT: low byte is the index */ + +/* Wire-only bg word: keep the existing cell bg. Never stored in a cell. */ +#define BG_KEEP 0x40000000 #define ATTR_MASK 0xFF000000 #define COLOR_MASK 0x00FFFFFF diff --git a/src/clayterm.c b/src/clayterm.c index dea1c84..06dd8c3 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -44,6 +44,7 @@ struct Clayterm *ct_active_context = NULL; #define PROP_FLOATING 0x20 #define PROP_TRANSITION 0x40 #define PROP_CURSOR 0x80 +#define PROP_BG_TERMINAL 0x100 /* ── Instance state ───────────────────────────────────────────────── */ @@ -173,7 +174,7 @@ static int setcell(struct Clayterm *ct, int x, int y, uint32_t ch, uint32_t fg, Cell *c = cell_at(ct, ct->back, x, y); c->ch = ch; c->fg = fg; - if (!(bg & ATTR_DEFAULT)) { + if (!(bg & BG_KEEP)) { c->bg = bg; } for (int i = 0; i < CELL_MAX_COMBINING; i++) @@ -219,6 +220,12 @@ static void append_combining(struct Clayterm *ct, int x, int y, uint32_t cp) { /* ── Escape sequence generation ───────────────────────────────────── */ +static void emit_palette(struct Clayterm *ct, int index, int base, int bright) { + buf_str(&ct->out, "\x1b["); + buf_num(&ct->out, index < 8 ? base + index : bright + index - 8); + buf_put(&ct->out, "m", 1); +} + static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) { if (fg == ct->lastfg && bg == ct->lastbg) return; @@ -242,8 +249,11 @@ static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) { if (fg & ATTR_STRIKEOUT) buf_str(&ct->out, "\x1b[9m"); - /* foreground truecolor */ - if (!(fg & ATTR_DEFAULT)) { + /* foreground: palette, truecolor, or default (left by the reset) */ + if (fg & ATTR_DEFAULT) { + if (fg & ATTR_PALETTE) + emit_palette(ct, (int)(fg & 0xff), 30, 90); + } else { buf_str(&ct->out, "\x1b[38;2;"); buf_num(&ct->out, (fg >> 16) & 0xff); buf_put(&ct->out, ";", 1); @@ -253,8 +263,11 @@ static void emit_attr(struct Clayterm *ct, uint32_t fg, uint32_t bg) { buf_put(&ct->out, "m", 1); } - /* background truecolor */ - if (!(bg & ATTR_DEFAULT)) { + /* background: same */ + if (bg & ATTR_DEFAULT) { + if (bg & ATTR_PALETTE) + emit_palette(ct, (int)(bg & 0xff), 40, 100); + } else { buf_str(&ct->out, "\x1b[48;2;"); buf_num(&ct->out, (bg >> 16) & 0xff); buf_put(&ct->out, ";", 1); @@ -420,6 +433,8 @@ static void present_lines(struct Clayterm *ct) { /* ── Color conversion ─────────────────────────────────────────────── */ static uint32_t color(Clay_Color c) { + if (ct_color_is_terminal(c)) + return c.g < 0 ? ATTR_DEFAULT : ATTR_DEFAULT | ATTR_PALETTE | (uint8_t)c.g; uint8_t r = (uint8_t)c.r; uint8_t g = (uint8_t)c.g; uint8_t b = (uint8_t)c.b; @@ -621,6 +636,15 @@ static float rdf(uint32_t *buf, int len, int *i) { return f; } +static Clay_Color unpack_terminal_color(uint32_t c) { + return (Clay_Color){ + .r = -1, + .g = (c & ATTR_PALETTE) ? (float)(c & 0xff) : -1, + .b = 0, + .a = 255, + }; +} + static Clay_Color unpack_color(uint32_t c) { return (Clay_Color){ .r = (float)((c >> 16) & 0xff), @@ -854,7 +878,10 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, } if (mask & PROP_BG_COLOR) { - decl.backgroundColor = unpack_color(rd(buf, len, &i)); + uint32_t bg = rd(buf, len, &i); + decl.backgroundColor = (mask & PROP_BG_TERMINAL) + ? unpack_terminal_color(bg) + : unpack_color(bg); } if (mask & PROP_CORNER_RADIUS) { diff --git a/src/transitions.c b/src/transitions.c index 7c6837b..09c5506 100644 --- a/src/transitions.c +++ b/src/transitions.c @@ -32,6 +32,8 @@ static float ease_in_out(float t) { static float lerpf(float a, float b, float t) { return a + (b - a) * t; } static Clay_Color lerp_color(Clay_Color a, Clay_Color b, float t) { + if (ct_color_is_terminal(a) || ct_color_is_terminal(b)) + return t < 0.5f ? a : b; Clay_Color out; out.r = lerpf(a.r, b.r, t); out.g = lerpf(a.g, b.g, t); diff --git a/src/transitions.h b/src/transitions.h index 4a68e43..172b663 100644 --- a/src/transitions.h +++ b/src/transitions.h @@ -9,6 +9,10 @@ #define CT_EASING_EASE_OUT 2 #define CT_EASING_EASE_IN_OUT 3 +/* A Clay_Color with a negative red channel carries a terminal-themed color: + * green is the ANSI palette index, or negative for the terminal default. */ +static inline bool ct_color_is_terminal(Clay_Color c) { return c.r < 0; } + bool ct_handler_linear(Clay_TransitionCallbackArguments args); bool ct_handler_ease_in(Clay_TransitionCallbackArguments args); bool ct_handler_ease_out(Clay_TransitionCallbackArguments args); diff --git a/test/border.test.ts b/test/border.test.ts index aefa730..0f064e0 100644 --- a/test/border.test.ts +++ b/test/border.test.ts @@ -75,6 +75,10 @@ function cells(ansi: string): ParsedCell[] { fg = seq.slice(0, -1); } else if (seq.startsWith("\x1b[48;2;") && seq.endsWith("m")) { bg = seq.slice(0, -1); + } else if (/^\[(3[0-7]|9[0-7])m$/.test(seq.slice(1))) { + fg = seq.slice(0, -1); + } else if (/^\[(4[0-7]|10[0-7])m$/.test(seq.slice(1))) { + bg = seq.slice(0, -1); } i = end + 1; @@ -675,3 +679,48 @@ describe("hex colors", () => { expect(at(parsed, 0, 1).bg).toBe(BG.blue); }); }); + +describe("system colors", () => { + it("renders side colors from system colors with shared fallback", async () => { + let parsed = await renderBox({ + border: { + color: "LinkText", + bg: "Mark", + top: { width: 1, color: "GrayText" }, + right: 1, + bottom: { width: 1, bg: "HighlightText" }, + left: 1, + }, + }); + + expect(at(parsed, 0, 0).fg).toBe("\x1b[90"); + expect(at(parsed, 3, 0).fg).toBe("\x1b[90"); + expect(at(parsed, 3, 0).bg).toBe("\x1b[43"); + expect(at(parsed, 7, 1).fg).toBe("\x1b[34"); + expect(at(parsed, 7, 1).bg).toBe("\x1b[43"); + expect(at(parsed, 3, 3).fg).toBe("\x1b[34"); + expect(at(parsed, 3, 3).bg).toBe("\x1b[107"); + expect(at(parsed, 0, 1).fg).toBe("\x1b[34"); + }); + + it("overwrites the element bg with an explicit Canvas border bg", async () => { + let parsed = await renderBox({ + bg: RED, + border: { color: WHITE, top: { width: 1, bg: "Canvas" }, left: 1 }, + }); + + expect(at(parsed, 3, 0).bg).toBeUndefined(); + expect(at(parsed, 0, 1).bg).toBe(BG.red); + expect(at(parsed, 3, 1).bg).toBe(BG.red); + }); + + it("renders the default foreground for a default keyword in border color", async () => { + let parsed = await renderBox({ + border: { color: "Canvas", top: 1 }, + }); + + expect(at(parsed, 3, 0).ch).toBe("─"); + expect(at(parsed, 3, 0).fg).toBeUndefined(); + expect(at(parsed, 3, 0).bg).toBeUndefined(); + }); +}); diff --git a/test/color.test.ts b/test/color.test.ts index 3d0ff96..5dbcbcc 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -335,8 +335,182 @@ describe("CSS color strings", () => { text("a", { color: "rebeccapurpel" }); // @ts-expect-error unsupported color function text("a", { color: "color(srgb 1 0 0)" }); - // @ts-expect-error reserved system color - open("a", { bg: "Canvas" }); + // @ts-expect-error reserved currentcolor + open("a", { bg: "currentcolor" }); expect(valid).toHaveLength(6); }); }); + +describe("system colors", () => { + let palette: [string, number | "default"][] = [ + ["Canvas", "default"], + ["ButtonFace", "default"], + ["Field", "default"], + ["CanvasText", "default"], + ["ButtonText", "default"], + ["FieldText", "default"], + ["ActiveText", 1], + ["Mark", 3], + ["LinkText", 4], + ["Highlight", 4], + ["SelectedItem", 4], + ["VisitedText", 5], + ["AccentColor", 6], + ["GrayText", 8], + ["ButtonBorder", 8], + ["HighlightText", 15], + ["SelectedItemText", 15], + ["MarkText", 0], + ["AccentColorText", 0], + ]; + + let sgr = (index: number, base: number, bright: number) => + `\x1b[${index < 8 ? base + index : bright + index - 8}m`; + + async function textFrame(props: Parameters[1]) { + let term = await createTerm({ width: 8, height: 1 }); + return decode(term.render([text("Hi", props)], { mode: "line" }).output); + } + + it("maps each keyword to its palette entry or the default in fg and bg", async () => { + let plain = await textFrame({}); + for (let [keyword, index] of palette) { + let fg = await textFrame({ color: keyword }); + let bg = await textFrame({ bg: keyword }); + if (index === "default") { + expect([keyword, fg]).toEqual([keyword, plain]); + expect([keyword, bg]).toEqual([keyword, plain]); + } else { + expect([keyword, fg]).toEqual([ + keyword, + `\x1b[0m${sgr(index, 30, 90)}Hi\x1b[0m `, + ]); + expect([keyword, bg]).toEqual([ + keyword, + `\x1b[0m${sgr(index, 40, 100)}Hi\x1b[0m `, + ]); + } + } + }); + + it("matches keywords case-insensitively", async () => { + expect(await textFrame({ color: "linktext", bg: "MARK" })).toBe( + "\x1b[0m\x1b[34m\x1b[43mHi\x1b[0m ", + ); + expect(await textFrame({ color: "LINKTEXT", bg: "graytext" })).toBe( + "\x1b[0m\x1b[34m\x1b[100mHi\x1b[0m ", + ); + }); + + it("combines palette colors with text attributes", async () => { + expect(await textFrame({ color: "VisitedText", attrs: 0x01 })).toBe( + "\x1b[0m\x1b[1m\x1b[35mHi\x1b[0m ", + ); + }); + + it("fills element bg from a palette keyword", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(4), height: fixed(1) }, + bg: "Highlight", + }), + close(), + ], { mode: "line" }).output, + ); + + expect(ansi).toBe("\x1b[0m\x1b[44m "); + }); + + it("overwrites an enclosing bg with an explicit Canvas element bg", async () => { + let term = await createTerm({ width: 8, height: 1 }); + let ansi = decode( + term.render([ + open("outer", { + layout: { width: fixed(8), height: fixed(1) }, + bg: "#ff0000", + }), + open("inner", { + layout: { width: fixed(4), height: fixed(1) }, + bg: "Canvas", + }), + close(), + close(), + ], { mode: "line" }).output, + ); + + expect(ansi).toBe("\x1b[0m \x1b[0m\x1b[48;2;255;0;0m "); + }); + + it("overwrites an enclosing bg with a default keyword in a text bg", async () => { + let render = async (bg?: string) => { + let term = await createTerm({ width: 4, height: 1 }); + return decode( + term.render([ + open("box", { + layout: { width: fixed(4), height: fixed(1) }, + bg: "Mark", + }), + text("Hi", bg === undefined ? {} : { bg }), + close(), + ], { mode: "line" }).output, + ); + }; + + expect(await render()).toBe("\x1b[0m\x1b[43mHi "); + expect(await render("Canvas")).toBe("\x1b[0mHi\x1b[0m\x1b[43m "); + expect(await render("CanvasText")).toBe("\x1b[0mHi\x1b[0m\x1b[43m "); + }); + + it("re-emits cells whose color changes between palette, default, and sRGB", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let frame = (color: number | string) => + decode(term.render([text("Hi", { color })]).output); + + expect(frame("LinkText")).toContain("\x1b[34m\x1b[1;1HHi"); + expect(frame("LinkText")).not.toContain("Hi"); + expect(frame(rgba(0, 1, 4))).toContain("\x1b[38;2;0;1;4m\x1b[1;1HHi"); + expect(frame("LinkText")).toContain("\x1b[34m\x1b[1;1HHi"); + expect(frame("Mark")).toContain("\x1b[33m\x1b[1;1HHi"); + expect(frame("CanvasText")).toContain("\x1b[0m\x1b[1;1HHi"); + expect(frame("CanvasText")).not.toContain("Hi"); + expect(frame("GrayText")).toContain("\x1b[90m\x1b[1;1HHi"); + }); + + it("re-emits cells whose bg changes between palette, default, and sRGB", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let frame = (bg: number | string) => + decode( + term.render([ + open("box", { + layout: { width: fixed(4), height: fixed(1) }, + bg, + }), + close(), + ]).output, + ); + + expect(frame("Highlight")).toContain("\x1b[44m\x1b[1;1H "); + expect(frame("Highlight")).not.toContain(" "); + expect(frame(rgba(0, 0, 0, 128))).toContain( + "\x1b[48;2;0;0;0m\x1b[1;1H ", + ); + expect(frame("Canvas")).toContain("\x1b[0m\x1b[1;1H "); + expect(frame("Canvas")).not.toContain(" "); + expect(frame("HighlightText")).toContain("\x1b[107m\x1b[1;1H "); + }); + + it("checks system color literals at compile time", () => { + let valid = [ + text("a", { color: "LinkText", bg: "canvas" }), + open("a", { + bg: "HIGHLIGHT", + border: { color: "GrayText", top: { width: 1, bg: "Mark" } }, + }), + ]; + // @ts-expect-error misspelled system color + text("a", { color: "Canvass" }); + expect(valid).toHaveLength(2); + }); +}); diff --git a/test/pack.test.ts b/test/pack.test.ts index e6d938c..6038b7d 100644 --- a/test/pack.test.ts +++ b/test/pack.test.ts @@ -197,8 +197,6 @@ describe("CSS color strings", () => { "color-mix(in srgb, red, blue)", "rgb(from red r g b)", "device-cmyk(0 0 0 1)", - "Canvas", - "CanvasText", "currentcolor", " red", "red ", @@ -217,3 +215,61 @@ describe("CSS color strings", () => { } }); }); + +describe("system colors", () => { + function packed(ops: Parameters[0]): Uint8Array { + let memory = new ArrayBuffer(512); + let n = pack(ops, memory, 0); + return new Uint8Array(memory, 0, n * 4); + } + + let box = (bg: string | number) => [open("box", { bg }), close()]; + let border = (color: string | number, bg?: string | number) => [ + open("box", { border: { color, bg, top: 1 } }), + close(), + ]; + + it("packs keywords case-insensitively and aliases identically", () => { + expect(packed([text("Hi", { color: "linktext", bg: "MARK" })])).toEqual( + packed([text("Hi", { color: "LinkText", bg: "Mark" })]), + ); + expect(packed(box("Highlight"))).toEqual(packed(box("SelectedItem"))); + expect(packed(border("GrayText"))).toEqual(packed(border("ButtonBorder"))); + }); + + it("packs a default keyword in a text fg like an omitted color", () => { + for (let color of ["Canvas", "CanvasText", "Field"]) { + expect([color, packed([text("Hi", { color })])]).toEqual([ + color, + packed([text("Hi")]), + ]); + } + }); + + it("packs a default keyword in a bg differently from an omitted bg", () => { + expect(packed([text("Hi", { bg: "Canvas" })])).not.toEqual( + packed([text("Hi")]), + ); + expect(packed(border("red", "CanvasText"))).not.toEqual( + packed(border("red")), + ); + }); + + it("never collides with an sRGB color", () => { + expect(packed([text("Hi", { color: "LinkText" })])).not.toEqual( + packed([text("Hi", { color: rgba(0, 1, 4) })]), + ); + expect(packed(box("Canvas"))).not.toEqual(packed(box(rgba(0, 0, 0, 128)))); + expect(packed(box("LinkText"))).not.toEqual( + packed(box(rgba(0, 1, 4, 128))), + ); + expect(packed(border("LinkText", "Mark"))).not.toEqual( + packed(border(rgba(0, 1, 4), rgba(0, 1, 3))), + ); + }); + + it("snapshots system colors like the directives they came from", () => { + let ops = [open("box", { bg: "Mark" }), text("Hi", { color: "Mark" })]; + expect((snapshot(ops) as { data: Uint8Array }).data).toEqual(packed(ops)); + }); +}); diff --git a/test/transitions.test.ts b/test/transitions.test.ts index 3a99c33..1bee410 100644 --- a/test/transitions.test.ts +++ b/test/transitions.test.ts @@ -80,6 +80,48 @@ describe("transitions", () => { }); }); + describe("system colors", () => { + it("switches between sRGB and a system color at half progress", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let frame = (bg: number | string, deltaTime: number) => + new TextDecoder().decode( + term.render([ + open("box", { + layout: { width: fixed(4), height: fixed(1) }, + bg, + transition: { + duration: 0.2, + easing: "linear", + properties: ["bg"], + }, + }), + close(), + ], { deltaTime }).output, + ); + + let steps = [0, 0.05, 0.05, 0.05, 0.05, 0.05]; + expect(frame(rgba(255, 0, 0), 0)).toBe( + "\x1b[0m\x1b[48;2;255;0;0m\x1b[1;1H ", + ); + expect(steps.map((dt) => frame("Highlight", dt))).toEqual([ + "", + "", + "", + "\x1b[0m\x1b[44m\x1b[1;1H ", + "", + "", + ]); + expect(steps.map((dt) => frame(rgba(0, 255, 0), dt))).toEqual([ + "", + "", + "", + "\x1b[0m\x1b[48;2;0;255;0m\x1b[1;1H ", + "", + "", + ]); + }); + }); + describe("line mode", () => { it("runs color transitions in line mode", async () => { let term = await createTerm({ width: 20, height: 5 }); diff --git a/test/validate.test.ts b/test/validate.test.ts index 858467c..80a3d75 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -397,7 +397,7 @@ describe("CSS color fields", () => { let spec of [ "color(srgb 1 0 0)", "device-cmyk(0 0 0 1)", - "Canvas", + "Canvass", "currentcolor", " red", "rgb(1 2)", @@ -410,3 +410,56 @@ describe("CSS color fields", () => { } }); }); + +describe("system color fields", () => { + it("accepts system color keywords at every color field", () => { + expect(validate([ + open("x", { + bg: "Canvas", + border: { + color: "LinkText", + bg: "Mark", + top: { width: 1, color: "GrayText", bg: "HighlightText" }, + right: 1, + }, + }), + text("hi", { color: "CanvasText", bg: "SelectedItem" }), + close(), + ])).toBe(true); + }); + + it("accepts every CSS Color 4 system color case-insensitively", () => { + for ( + let keyword of [ + "AccentColor", + "AccentColorText", + "ActiveText", + "ButtonBorder", + "ButtonFace", + "ButtonText", + "Canvas", + "CanvasText", + "Field", + "FieldText", + "GrayText", + "Highlight", + "HighlightText", + "LinkText", + "Mark", + "MarkText", + "SelectedItem", + "SelectedItemText", + "VisitedText", + ] + ) { + for ( + let spec of [keyword, keyword.toLowerCase(), keyword.toUpperCase()] + ) { + expect([spec, validate([text("hi", { color: spec })])]).toEqual([ + spec, + true, + ]); + } + } + }); +}); From 7ba0b65997a8c489d06c1afc4112945223e395cf Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 18 Oct 2026 22:22:51 -0400 Subject: [PATCH 3/4] =?UTF-8?q?spec:=20cross-field=20default=20keywords=20?= =?UTF-8?q?resolve=20to=20probed=20theme=20colors=20(=C2=A78.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A default keyword named in the other attribute's field (CanvasText in a bg field, Canvas in a fg field, including border fg) now resolves to the OSC 10/11-probed theme color for the attribute it names, as an ordinary opaque sRGB color that packs and interpolates like rgba(). Falls back to the field's own terminal default when the theme hasn't reported that color. pack() gains an optional trailing theme parameter; term.render() passes it. --- specs/renderer-spec.md | 68 +++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 4a488d8..6b1cf03 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -853,22 +853,42 @@ color; the terminal applies its own theme when it draws the cell. | `HighlightText`, `SelectedItemText` | palette 15 (bright white) | | `MarkText`, `AccentColorText` | palette 0 (black) | -The six default keywords name the terminal default color of the attribute the -field sets, whichever keyword is used. In a foreground field (`text({ color })`, -`border.color`, a border side's `color`) each of them resolves to the terminal -default foreground; in a background field (`open({ bg })`, `text({ bg })`, -`border.bg`, a border side's `bg`) each resolves to the terminal default -background. `text({ bg: "CanvasText" })` therefore renders the default -background, not the default foreground color painted as a background: a terminal -offers no way to paint its default foreground color as a cell background, and -the renderer does not guess one. - -A terminal-themed color is opaque. A palette color in a foreground field is -emitted as SGR `30`–`37` for palette 0–7 and `90`–`97` for palette 8–15; in a -background field, as SGR `40`–`47` and `100`–`107`. A terminal default color is -emitted as SGR `39` (foreground) or `49` (background), or by omitting the -foreground or background SGR after an SGR reset, which leaves the same default -in effect. +Each default keyword names the terminal default color of one specific +attribute: `Canvas`, `ButtonFace`, and `Field` name the default background; +`CanvasText`, `ButtonText`, and `FieldText` name the default foreground. +**Used in its own field** — a background-default keyword in a background +field (`open({ bg })`, `text({ bg })`, `border.bg`, a border side's `bg`), or +a foreground-default keyword in a foreground field (`text({ color })`, +`border.color`, a border side's `color`) — it resolves to that field's +terminal default, unchanged from the table above. + +**Used in the other field** — a foreground-default keyword in a background +field, or a background-default keyword in a foreground field, including a +border side's `color` — it resolves to the terminal's probed color for the +attribute it names, not to that field's default. The renderer reads this from +the OSC 10/11 theme probe, surfaced as `RuntimeCapabilities.theme.foreground` +/ `.background` (terminfo-spec §6.4) and passed into `pack()` as the theme +snapshot `term.render()` holds at render time (§12.5). The result is an +ordinary opaque sRGB color (alpha 255): it packs exactly like the equivalent +`rgba()` value, renders as truecolor, and transitions interpolate it like any +other RGB color — unlike the terminal-themed colors described below. When the +theme has not yet reported that color, the keyword falls back to its field's +own terminal default instead (the same-field behavior above). A theme report +that arrives after a frame renders takes effect starting with the next +render. + +For example, once the terminal has reported its default foreground color, +`text({ bg: "CanvasText" })` paints that color as an opaque truecolor +background. Until then it falls back to the default background: a terminal +offers no way to paint its default foreground color as a cell background +without knowing its RGB value, and the renderer never guesses one. + +A terminal-themed color (a same-field default, or a palette entry) is opaque. +A palette color in a foreground field is emitted as SGR `30`–`37` for palette +0–7 and `90`–`97` for palette 8–15; in a background field, as SGR `40`–`47` +and `100`–`107`. A terminal default color is emitted as SGR `39` (foreground) +or `49` (background), or by omitting the foreground or background SGR after +an SGR reset, which leaves the same default in effect. A terminal default background is a color, not the absence of one. An explicit `Canvas` background (or `ButtonFace`, `Field`, or any default keyword in a @@ -886,7 +906,9 @@ A transition (see the Transitions Specification) cannot interpolate between a terminal-themed color and any other color. When either endpoint of an interpolated color is terminal-themed, the color changes discretely: it holds the initial value while eased progress is below 0.5 and takes the target value -from 0.5 on. +from 0.5 on. A cross-field default keyword resolved to a probed theme color is +not terminal-themed — it is an ordinary opaque sRGB color — so it interpolates +normally; only the unprobed fallback and same-field defaults are discrete. **Resolution.** A color string resolves to sRGB, then to a packed ARGB value: each of R, G, B is clamped to [0, 255] and rounded to the nearest integer; alpha @@ -1376,9 +1398,15 @@ structural errors (unbalanced open/close pairs, invalid field types — includin color fields, which must be a packed ARGB number or a §8.5 CSS color string). Exported and used in tests. -**`pack(ops, mem, offset)`** — An internal function that serializes a directive -array into the transfer encoding described in Section 12.1. Currently exported -but not public API; its exposure is incidental to the module structure. +**`pack(ops, mem, offset, limit?, theme?)`** — An internal function that +serializes a directive array into the transfer encoding described in Section +12.1. Currently exported but not public API; its exposure is incidental to the +module structure. The optional trailing `theme` carries the probed default +foreground/background colors (`RuntimeCapabilities.theme`, terminfo-spec +§6.4), used to resolve a cross-field default keyword (§8.5) to the terminal's +actual default color. `term.render()` passes its current capability +snapshot's `theme`; a caller that omits it gets the field-default fallback for +every cross-field keyword. --- From d9caccc8ba00535cfdd909468980766876290f90 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 18 Oct 2026 22:26:34 -0400 Subject: [PATCH 4/4] feat: resolve cross-field default keywords against the probed theme pack() gains an optional trailing theme parameter (RuntimeCapabilities.theme shape); term.render() passes currentCaps.theme every frame. color.ts's systemColors now tags each default keyword with which attribute it names (fg-default/bg-default) instead of a single undifferentiated "default", and resolveForField() picks the terminal default for a same-field keyword or the probed theme color for a cross-field one, falling back to the field's own default when that color hasn't been probed yet. Covers pack-level byte identity against rgba(), the no-theme fallback, and render-through-Term cases (text bg, element bg, border side fg) that pick up a theme color after term.update() folds in an OSC 10/11 CapabilityEvent. --- .changeset/system-colors-ansi16.md | 9 +++- color.ts | 62 +++++++++++++++++++++++---- ops.ts | 56 ++++++++++++++++++------- specs/renderer-spec.md | 67 +++++++++++++++--------------- term.ts | 1 + test/border.test.ts | 24 +++++++++++ test/color.test.ts | 62 +++++++++++++++++++++++++++ test/pack.test.ts | 58 ++++++++++++++++++++++++++ 8 files changed, 278 insertions(+), 61 deletions(-) diff --git a/.changeset/system-colors-ansi16.md b/.changeset/system-colors-ansi16.md index 8e11140..efe6f23 100644 --- a/.changeset/system-colors-ansi16.md +++ b/.changeset/system-colors-ansi16.md @@ -10,5 +10,10 @@ ANSI 16-color palette (`LinkText` is blue, `GrayText` is bright black, and so on), so output follows the user's light or dark terminal theme. The default-color keywords mean the default of the attribute they set, so `text({ bg: "Canvas" })` paints the terminal default background over any -enclosing element background, while an omitted `bg` still keeps it. Packed -`rgba()` colors and other color strings render exactly as before. +enclosing element background, while an omitted `bg` still keeps it. Named in +the other field instead, a default keyword now paints the terminal's actual +default color once it's known: `text({ bg: "CanvasText" })` renders the +terminal's real default foreground color as an opaque truecolor background, +learned from the terminal's OSC 10/11 theme report, falling back to the +default background until that report arrives. Packed `rgba()` colors and +other color strings render exactly as before. diff --git a/color.ts b/color.ts index f93c797..31e75df 100644 --- a/color.ts +++ b/color.ts @@ -1,20 +1,36 @@ import { convert, parse } from "hueful"; /** - * A §8.5 terminal-themed color: an ANSI palette index (0–15), or the - * terminal default color of whichever attribute the field sets. + * A §8.5 terminal-themed color: an ANSI palette index (0–15), or a default + * keyword naming the terminal default foreground ("fg-default") or + * background ("bg-default"). Which default it names is fixed by the + * keyword; whether it stays terminal-themed or resolves to a probed sRGB + * color depends on the field it's packed into (see resolveForField). */ export interface TerminalColor { - ansi: number | "default"; + ansi: number | "fg-default" | "bg-default"; +} + +/** An 8-bit RGB color, structurally compatible with terminfo.ts's Rgb. */ +export interface ThemeRgb { + r: number; + g: number; + b: number; +} + +/** The theme colors `pack()` resolves cross-field default keywords against. */ +export interface ThemeColors { + readonly foreground?: ThemeRgb; + readonly background?: ThemeRgb; } export const systemColors = { - canvas: "default", - buttonface: "default", - field: "default", - canvastext: "default", - buttontext: "default", - fieldtext: "default", + canvas: "bg-default", + buttonface: "bg-default", + field: "bg-default", + canvastext: "fg-default", + buttontext: "fg-default", + fieldtext: "fg-default", activetext: 1, mark: 3, linktext: 4, @@ -53,3 +69,31 @@ export function resolveColor( return (byte(alpha) << 24) | (byte(r / 255) << 16) | (byte(g / 255) << 8) | byte(b / 255); } + +/** + * Resolves a terminal-themed color for the field it was packed into (§8.5). + * A same-field default keyword and a palette entry stay terminal-themed. A + * cross-field default keyword (a fg-default keyword in a bg field, or a + * bg-default keyword in a fg field) resolves to the probed theme color for + * the attribute it names, packed as an ordinary opaque ARGB number; absent + * that probe, it falls back to the field's own terminal default. + */ +export function resolveForField( + color: TerminalColor, + field: "fg" | "bg", + theme: ThemeColors | undefined, +): number | TerminalColor { + let { ansi } = color; + if (ansi !== "fg-default" && ansi !== "bg-default") return color; + if ( + (ansi === "fg-default" && field === "fg") || + (ansi === "bg-default" && field === "bg") + ) { + return color; + } + let rgb = ansi === "fg-default" ? theme?.foreground : theme?.background; + if (rgb === undefined) { + return { ansi: field === "fg" ? "fg-default" : "bg-default" }; + } + return (255 << 24) | (rgb.r << 16) | (rgb.g << 8) | rgb.b; +} diff --git a/ops.ts b/ops.ts index 5635d90..7c0617a 100644 --- a/ops.ts +++ b/ops.ts @@ -1,8 +1,10 @@ import type { names } from "hueful"; import { resolveColor, + resolveForField, type systemColors, type TerminalColor, + type ThemeColors, } from "./color.ts"; import { type PointerShape, pointerShapeIndex } from "./pointer-shape.ts"; @@ -98,19 +100,24 @@ function sideWidth(side: BorderSide | undefined): number { return typeof side === "number" ? side : side?.width ?? 0; } -function sideFg(side: BorderSide | undefined, shared: Color): number { +function sideFg( + side: BorderSide | undefined, + shared: Color, + theme: ThemeColors | undefined, +): number { let color = typeof side === "object" && side.color !== undefined ? side.color : shared; - return colorWord(color); + return colorWord(color, "fg", theme); } function sideBg( side: BorderSide | undefined, shared: Color | undefined, + theme: ThemeColors | undefined, ): number { let bg = typeof side === "object" && side.bg !== undefined ? side.bg : shared; - return bg === undefined ? BG_KEEP : colorWord(bg); + return bg === undefined ? BG_KEEP : colorWord(bg, "bg", theme); } function packString( @@ -142,6 +149,7 @@ export function pack( mem: ArrayBufferLike, offset: number, limit?: number, + theme?: ThemeColors, ): number { let view = new DataView(mem); let end = limit ?? mem.byteLength; @@ -161,7 +169,9 @@ export function pack( let bytes = encoder.encode(op.id); o = packString(view, bytes, o, end, "element id"); - let bg = op.bg === undefined ? undefined : colorValue(op.bg); + let bg = op.bg === undefined + ? undefined + : colorValue(op.bg, "bg", theme); let mask = 0; if (op.layout) mask |= PROP_LAYOUT; if (typeof bg === "number") mask |= PROP_BG_COLOR; @@ -245,9 +255,9 @@ export function pack( // 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); + view.setUint32(o, sideFg(side, b.color, theme), true); o += 4; - view.setUint32(o, sideBg(side, b.bg), true); + view.setUint32(o, sideBg(side, b.bg, theme), true); o += 4; } } @@ -325,8 +335,8 @@ export function pack( // ORs the attrs byte back over the high byte. No color is the // terminal default. let color = op.color === undefined - ? { ansi: "default" as const } - : colorValue(op.color); + ? { ansi: "fg-default" as const } + : colorValue(op.color, "fg", theme); let terminal = typeof color === "object"; view.setUint32( o, @@ -337,7 +347,7 @@ export function pack( view.setUint32( o, - op.bg === undefined ? BG_KEEP : colorWord(op.bg), + op.bg === undefined ? BG_KEEP : colorWord(op.bg, "bg", theme), true, ); o += 4; @@ -387,23 +397,37 @@ export type Color = number | string; /** * Resolves a color value to a packed ARGB number or a terminal-themed color - * (§8.5). Numbers pass through; strings resolve through the CSS color - * grammar. Invalid strings throw rather than decoding to a garbage color. + * (§8.5), for the field it's packed into. Numbers pass through; strings + * resolve through the CSS color grammar. A cross-field default keyword + * resolves against `theme` to a packed ARGB number, falling back to the + * field's own terminal default when `theme` lacks that color. Invalid + * strings throw rather than decoding to a garbage color. */ -function colorValue(color: Color): number | TerminalColor { +function colorValue( + color: Color, + field: "fg" | "bg", + theme: ThemeColors | undefined, +): number | TerminalColor { if (typeof color === "number") return color; let value = resolveColor(color); if (value === undefined) throw new TypeError(`invalid color: "${color}"`); - return value; + if (typeof value === "number") return value; + return resolveForField(value, field, theme); } function terminalWord(color: TerminalColor): number { - return color.ansi === "default" ? TERMINAL : TERMINAL | PALETTE | color.ansi; + return typeof color.ansi === "number" + ? TERMINAL | PALETTE | color.ansi + : TERMINAL; } /* The color word text bg and border attributes carry; alpha is dropped. */ -function colorWord(color: Color): number { - let value = colorValue(color); +function colorWord( + color: Color, + field: "fg" | "bg", + theme: ThemeColors | undefined, +): number { + let value = colorValue(color, field, theme); return typeof value === "number" ? value & 0x00FFFFFF : terminalWord(value); } diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 6b1cf03..579d1df 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -853,42 +853,41 @@ color; the terminal applies its own theme when it draws the cell. | `HighlightText`, `SelectedItemText` | palette 15 (bright white) | | `MarkText`, `AccentColorText` | palette 0 (black) | -Each default keyword names the terminal default color of one specific -attribute: `Canvas`, `ButtonFace`, and `Field` name the default background; -`CanvasText`, `ButtonText`, and `FieldText` name the default foreground. -**Used in its own field** — a background-default keyword in a background -field (`open({ bg })`, `text({ bg })`, `border.bg`, a border side's `bg`), or -a foreground-default keyword in a foreground field (`text({ color })`, -`border.color`, a border side's `color`) — it resolves to that field's -terminal default, unchanged from the table above. +Each default keyword names the terminal default color of one specific attribute: +`Canvas`, `ButtonFace`, and `Field` name the default background; `CanvasText`, +`ButtonText`, and `FieldText` name the default foreground. **Used in its own +field** — a background-default keyword in a background field (`open({ bg })`, +`text({ bg })`, `border.bg`, a border side's `bg`), or a foreground-default +keyword in a foreground field (`text({ color })`, `border.color`, a border +side's `color`) — it resolves to that field's terminal default, unchanged from +the table above. **Used in the other field** — a foreground-default keyword in a background -field, or a background-default keyword in a foreground field, including a -border side's `color` — it resolves to the terminal's probed color for the -attribute it names, not to that field's default. The renderer reads this from -the OSC 10/11 theme probe, surfaced as `RuntimeCapabilities.theme.foreground` -/ `.background` (terminfo-spec §6.4) and passed into `pack()` as the theme -snapshot `term.render()` holds at render time (§12.5). The result is an -ordinary opaque sRGB color (alpha 255): it packs exactly like the equivalent -`rgba()` value, renders as truecolor, and transitions interpolate it like any -other RGB color — unlike the terminal-themed colors described below. When the -theme has not yet reported that color, the keyword falls back to its field's -own terminal default instead (the same-field behavior above). A theme report -that arrives after a frame renders takes effect starting with the next -render. +field, or a background-default keyword in a foreground field, including a border +side's `color` — it resolves to the terminal's probed color for the attribute it +names, not to that field's default. The renderer reads this from the OSC 10/11 +theme probe, surfaced as `RuntimeCapabilities.theme.foreground` / `.background` +(terminfo-spec §6.4) and passed into `pack()` as the theme snapshot +`term.render()` holds at render time (§12.5). The result is an ordinary opaque +sRGB color (alpha 255): it packs exactly like the equivalent `rgba()` value, +renders as truecolor, and transitions interpolate it like any other RGB color — +unlike the terminal-themed colors described below. When the theme has not yet +reported that color, the keyword falls back to its field's own terminal default +instead (the same-field behavior above). A theme report that arrives after a +frame renders takes effect starting with the next render. For example, once the terminal has reported its default foreground color, `text({ bg: "CanvasText" })` paints that color as an opaque truecolor background. Until then it falls back to the default background: a terminal -offers no way to paint its default foreground color as a cell background -without knowing its RGB value, and the renderer never guesses one. +offers no way to paint its default foreground color as a cell background without +knowing its RGB value, and the renderer never guesses one. -A terminal-themed color (a same-field default, or a palette entry) is opaque. -A palette color in a foreground field is emitted as SGR `30`–`37` for palette -0–7 and `90`–`97` for palette 8–15; in a background field, as SGR `40`–`47` -and `100`–`107`. A terminal default color is emitted as SGR `39` (foreground) -or `49` (background), or by omitting the foreground or background SGR after -an SGR reset, which leaves the same default in effect. +A terminal-themed color (a same-field default, or a palette entry) is opaque. A +palette color in a foreground field is emitted as SGR `30`–`37` for palette 0–7 +and `90`–`97` for palette 8–15; in a background field, as SGR `40`–`47` and +`100`–`107`. A terminal default color is emitted as SGR `39` (foreground) or +`49` (background), or by omitting the foreground or background SGR after an SGR +reset, which leaves the same default in effect. A terminal default background is a color, not the absence of one. An explicit `Canvas` background (or `ButtonFace`, `Field`, or any default keyword in a @@ -1402,11 +1401,11 @@ Exported and used in tests. serializes a directive array into the transfer encoding described in Section 12.1. Currently exported but not public API; its exposure is incidental to the module structure. The optional trailing `theme` carries the probed default -foreground/background colors (`RuntimeCapabilities.theme`, terminfo-spec -§6.4), used to resolve a cross-field default keyword (§8.5) to the terminal's -actual default color. `term.render()` passes its current capability -snapshot's `theme`; a caller that omits it gets the field-default fallback for -every cross-field keyword. +foreground/background colors (`RuntimeCapabilities.theme`, terminfo-spec §6.4), +used to resolve a cross-field default keyword (§8.5) to the terminal's actual +default color. `term.render()` passes its current capability snapshot's `theme`; +a caller that omits it gets the field-default fallback for every cross-field +keyword. --- diff --git a/term.ts b/term.ts index 5cc7443..e35690a 100644 --- a/term.ts +++ b/term.ts @@ -205,6 +205,7 @@ export async function createTerm(options: TermOptions): Promise { memory.buffer, native.opsBuf, memory.buffer.byteLength, + currentCaps.theme, ); let mode = options?.mode === "line" ? 1 : 0; let row = options?.row ?? 1; diff --git a/test/border.test.ts b/test/border.test.ts index 0f064e0..adc736c 100644 --- a/test/border.test.ts +++ b/test/border.test.ts @@ -723,4 +723,28 @@ describe("system colors", () => { expect(at(parsed, 3, 0).fg).toBeUndefined(); expect(at(parsed, 3, 0).bg).toBeUndefined(); }); + + it("renders a probed default background as a truecolor border side fg once the theme reports it", async () => { + let term = await createTerm({ width: 12, height: 5 }); + term.update([ + { + type: "capability", + key: "background-color", + value: { r: 200, g: 210, b: 220 }, + }, + ]); + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(8), height: fixed(4) }, + border: { color: "Canvas", top: 1 }, + }), + close(), + ], { mode: "line" }).output, + ); + let parsed = cells(ansi); + + expect(at(parsed, 3, 0).ch).toBe("─"); + expect(at(parsed, 3, 0).fg).toBe("\x1b[38;2;200;210;220"); + }); }); diff --git a/test/color.test.ts b/test/color.test.ts index 5dbcbcc..77b602d 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -501,6 +501,68 @@ describe("system colors", () => { expect(frame("HighlightText")).toContain("\x1b[107m\x1b[1;1H "); }); + it("paints a probed default foreground as a truecolor text bg once the theme reports it", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let before = decode( + term.render([text("Hi", { bg: "CanvasText" })]).output, + ); + expect(before).not.toContain("48;2;"); + + term.update([ + { + type: "capability", + key: "foreground-color", + value: { r: 10, g: 20, b: 30 }, + }, + ]); + let after = decode( + term.render([text("Hi", { bg: "CanvasText" })]).output, + ); + expect(after).toContain("48;2;10;20;30"); + }); + + it("paints a probed default foreground as a truecolor element bg once the theme reports it", async () => { + let term = await createTerm({ width: 4, height: 1 }); + term.update([ + { + type: "capability", + key: "foreground-color", + value: { r: 10, g: 20, b: 30 }, + }, + ]); + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(4), height: fixed(1) }, + bg: "CanvasText", + }), + close(), + ], { mode: "line" }).output, + ); + expect(ansi).toContain("48;2;10;20;30"); + }); + + it("paints a probed default background as a truecolor border side fg", async () => { + let term = await createTerm({ width: 4, height: 1 }); + term.update([ + { + type: "capability", + key: "background-color", + value: { r: 200, g: 210, b: 220 }, + }, + ]); + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(4), height: fixed(1) }, + border: { color: "Canvas", top: { width: 1 } }, + }), + close(), + ], { mode: "line" }).output, + ); + expect(ansi).toContain("38;2;200;210;220"); + }); + it("checks system color literals at compile time", () => { let valid = [ text("a", { color: "LinkText", bg: "canvas" }), diff --git a/test/pack.test.ts b/test/pack.test.ts index 6038b7d..1586fa8 100644 --- a/test/pack.test.ts +++ b/test/pack.test.ts @@ -273,3 +273,61 @@ describe("system colors", () => { expect((snapshot(ops) as { data: Uint8Array }).data).toEqual(packed(ops)); }); }); + +describe("cross-field default keywords with a theme", () => { + function packed( + ops: Parameters[0], + theme?: Parameters[4], + ): Uint8Array { + let memory = new ArrayBuffer(512); + let n = pack(ops, memory, 0, memory.byteLength, theme); + return new Uint8Array(memory, 0, n * 4); + } + + let theme = { + foreground: { r: 10, g: 20, b: 30 }, + background: { r: 200, g: 210, b: 220 }, + }; + + it("packs a fg-default keyword in a bg field as the probed foreground", () => { + expect(packed([text("Hi", { bg: "CanvasText" })], theme)).toEqual( + packed([text("Hi", { bg: rgba(10, 20, 30) })]), + ); + expect(packed([open("box", { bg: "ButtonText" }), close()], theme)) + .toEqual( + packed([open("box", { bg: rgba(10, 20, 30) }), close()]), + ); + }); + + it("packs a bg-default keyword in a fg field as the probed background", () => { + expect(packed([text("Hi", { color: "Canvas" })], theme)).toEqual( + packed([text("Hi", { color: rgba(200, 210, 220) })]), + ); + expect( + packed( + [open("box", { border: { color: "Field", top: 1 } }), close()], + theme, + ), + ).toEqual( + packed([ + open("box", { border: { color: rgba(200, 210, 220), top: 1 } }), + close(), + ]), + ); + }); + + it("falls back to the field's own default without a theme", () => { + expect(packed([text("Hi", { bg: "CanvasText" })])).toEqual( + packed([text("Hi", { bg: "Canvas" })]), + ); + expect(packed([text("Hi", { color: "Canvas" })])).toEqual( + packed([text("Hi", { color: "CanvasText" })]), + ); + }); + + it("falls back to the field's own default when the theme omits that color", () => { + expect(packed([text("Hi", { bg: "CanvasText" })], {})).toEqual( + packed([text("Hi", { bg: "Canvas" })]), + ); + }); +});