diff --git a/.changeset/system-colors-ansi16.md b/.changeset/system-colors-ansi16.md new file mode 100644 index 0000000..efe6f23 --- /dev/null +++ b/.changeset/system-colors-ansi16.md @@ -0,0 +1,19 @@ +--- +"@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. 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 63d4b0c..31e75df 100644 --- a/color.ts +++ b/color.ts @@ -1,18 +1,99 @@ import { convert, parse } from "hueful"; +/** + * 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 | "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: "bg-default", + buttonface: "bg-default", + field: "bg-default", + canvastext: "fg-default", + buttontext: "fg-default", + fieldtext: "fg-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); 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 9206f73..7c0617a 100644 --- a/ops.ts +++ b/ops.ts @@ -1,5 +1,11 @@ import type { names } from "hueful"; -import { resolveColor } from "./color.ts"; +import { + resolveColor, + resolveForField, + type systemColors, + type TerminalColor, + type ThemeColors, +} from "./color.ts"; import { type PointerShape, pointerShapeIndex } from "./pointer-shape.ts"; export type { PointerShape }; @@ -41,6 +47,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(); @@ -87,20 +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 colorValue(color) & 0x00FFFFFF; + 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; - // 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, "bg", theme); } function packString( @@ -132,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; @@ -151,9 +169,13 @@ 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, "bg", theme); 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 +219,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; } @@ -229,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; } } @@ -305,23 +331,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: "fg-default" as const } + : colorValue(op.color, "fg", theme); + 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, "bg", theme), + true, + ); o += 4; view.setUint32( @@ -329,7 +357,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,16 +396,39 @@ 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), 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 { +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 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, + field: "fg" | "bg", + theme: ThemeColors | undefined, +): number { + let value = colorValue(color, field, theme); + return typeof value === "number" ? value & 0x00FFFFFF : terminalWord(value); } export type SizingAxis = @@ -539,9 +590,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 +637,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/specs/renderer-spec.md b/specs/renderer-spec.md index 7b7ff8c..579d1df 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,83 @@ 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) | + +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 +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. 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 @@ -852,11 +927,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 +1144,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 @@ -1310,9 +1397,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. --- 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 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/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 aefa730..adc736c 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,72 @@ 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(); + }); + + 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 3d0ff96..77b602d 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -335,8 +335,244 @@ 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("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" }), + 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..1586fa8 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,119 @@ 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)); + }); +}); + +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" })]), + ); + }); +}); 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, + ]); + } + } + }); +});