From 217857d383f20ef03f13fe9997766a83610a72c2 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 18 Oct 2026 22:32:33 -0400 Subject: [PATCH 1/2] =?UTF-8?q?spec:=20currentcolor=20and=20open()=20foreg?= =?UTF-8?q?round=20inheritance=20(=C2=A78.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the "currentcolor is reserved" wording. open() gains a color field, inherited by descendants unless overridden; currentcolor means "inherit" in a color field and "this directive's own resolved color, field-resolved like any other color" everywhere else. border.color becomes optional, defaulting to currentcolor. Inheritance is a pack()-time stack scoped to one frame's directive array. --- specs/renderer-spec.md | 64 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 579d1df..057ff6d 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -831,8 +831,10 @@ 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. -`currentcolor` is reserved. It is an invalid color string in this version and -will be specified separately. +**`currentcolor`.** The keyword `currentcolor` means "the directive's own +resolved foreground color" (see **Foreground inheritance and `currentcolor`** +below). It is accepted at every color field, including `open({ color })` and +`text({ color })`, where it means "inherit" — the same as omitting the field. **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 @@ -917,6 +919,43 @@ clipped per channel; no perceptual gamut mapping is performed. Alpha defaults to 255 when a form does not supply it. A hex string and the equivalent `rgba()` call produce bit-identical packed values. +**Foreground inheritance and `currentcolor`.** `open()` accepts a `color` +field: the element's own foreground color. `color` is inherited: an element +with no explicit `color` (or with `color: "currentcolor"`) uses the nearest +ancestor `open()`'s resolved color; an explicit `color` other than +`currentcolor` overrides it for that element and, by the same inheritance +rule, every descendant that does not itself override it. `text()` resolves its +own `color` field (and, when absent, inherits) the same way; a `text()` has no +descendants, so its resolved color only affects itself. With no `color` +anywhere in the enclosing chain, the inherited color is the terminal default +foreground — the same color a directive gets today when `color` is omitted. + +`currentcolor` used in any field other than `color` — `open({ bg })`, +`text({ bg })`, `border.color`, `border.bg`, or a structured border side's +`color` or `bg` — resolves to that directive's own resolved color (its +explicit `color`, or the color it inherits). That resolved color is then +subject to the same field resolution as any other color value: a same-field +default keyword or palette entry stays terminal-themed, a cross-field default +keyword resolves against the probed theme (falling back to the field's own +default when unprobed), and an sRGB color passes through unchanged. For +example, an element with no `color` set anywhere above it has an inherited +color of the terminal default foreground; `bg: "currentcolor"` on that element +resolves exactly like `bg: "CanvasText"` — the probed foreground when +available, the default background otherwise — while `border.color` left at its +default resolves like `border.color: "CanvasText"` used in a foreground field, +which stays terminal-themed (SGR `39`). + +`border.color` is optional and defaults to `currentcolor`, matching CSS. A +structured border side without its own `color` falls back to the shared +`border.color` as before, and so, transitively, to the inherited color when +`border.color` is omitted. + +Foreground inheritance is resolved by `pack()` with a stack: pushed on each +element open (the newly resolved color), popped on the matching close. It is +scoped to the single pass over one frame's directive array — a sibling that +follows a closed element does not see that element's color, and no +inheritance state carries across frames (§6, §10.4). + **Invalid color strings.** A string that does not match the color string grammar is invalid directive input (§9.1). `validate()` reports it as an invalid field value. The packer MUST reject a directive array containing an invalid color @@ -928,7 +967,7 @@ 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, `transparent`, -or a system color keyword. Function arguments are not checked statically. +`currentcolor`, 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. @@ -1161,16 +1200,21 @@ as a color word. The `open()` constructor currently accepts the following property groups in its `props` parameter: +- **`color`** — the element's own foreground color, inherited by descendants + that do not override it (see **Foreground inheritance and `currentcolor`**, + §8.5). Accepts the color values of §8.5, including `currentcolor` (meaning + "inherit", same as omitting the field). - **`layout`** — sizing (width and height, specified via sizing helpers), padding (per-side), alignment (`alignX`: `"left"` | `"center"` | `"right"`; `alignY`: `"top"` | `"center"` | `"bottom"`, defaulting to left/top when omitted), direction (top-to-bottom or left-to-right), and gap - **`border`** — per-side border configuration. Each side field (`top`, `right`, `bottom`, `left`) accepts either a scalar width or a structured object - `{ width, color?, bg? }`. The shared `color` field is required and is the - fallback foreground for every side; the optional shared `bg` field is the - fallback border-cell background for every side. Color fields accept the color - values of §8.5 (packed ARGB number or CSS color string) + `{ width, color?, bg? }`. The shared `color` field is optional, defaulting to + `currentcolor` (§8.5), and is the fallback foreground for every side; the + optional shared `bg` field is the fallback border-cell background for every + side. Color fields accept the color values of §8.5 (packed ARGB number or CSS + color string) - **`cornerRadius`** — per-corner radius values, producing rounded box-drawing characters - **`clip`** — Declares the element as a clip region (see §7.5). Currently @@ -1251,7 +1295,8 @@ CSS-like shorthand/longhand fashion before rendering: - A structured side with `color` MUST render with that foreground color. A scalar side, or a structured side that omits `color`, MUST fall back to the - shared `border.color`. The shared `color` remains required. + shared `border.color`, which itself defaults to `currentcolor` (§8.5) when + omitted. - A structured side with `bg` MUST render border cells of that side with that background color. A scalar side, or a structured side that omits `bg`, MUST fall back to the shared `border.bg` when it is provided. @@ -1405,7 +1450,8 @@ 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. +keyword. `pack()` also resolves `color` inheritance and `currentcolor` (§8.5) +with a stack scoped to the single call. --- From 735cb315b9f760705824747329505a5b6f6a03a4 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 18 Oct 2026 22:38:41 -0400 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20currentcolor=20and=20open()=20foreg?= =?UTF-8?q?round=20inheritance=20(=C2=A78.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit open() gains a color field: the element's own foreground, inherited by descendants via a pack()-time stack pushed on open and popped on close. currentcolor means "inherit" in a color field and "this directive's own resolved color" everywhere else, going through the same field resolution (resolveForField) as any other color. border.color is now optional and defaults to currentcolor. resolveColor() returns a CURRENT_COLOR sentinel for the keyword; ops.ts substitutes the inherited/own color before field resolution via resolveOwn()/colorValue(), both newly threading a `current` parameter through colorWord/sideFg/sideBg. No wire-format change: open()'s own color has no direct rendering, only TS-side inheritance. --- .changeset/currentcolor.md | 16 ++++ color.ts | 25 +++++- ops.ts | 95 +++++++++++++++----- specs/renderer-spec.md | 56 ++++++------ test/border.test.ts | 49 +++++++++++ test/color.test.ts | 173 ++++++++++++++++++++++++++++++++++++- test/pack.test.ts | 139 ++++++++++++++++++++++++++++- test/validate.test.ts | 33 ++++++- validate.ts | 3 +- 9 files changed, 529 insertions(+), 60 deletions(-) create mode 100644 .changeset/currentcolor.md diff --git a/.changeset/currentcolor.md b/.changeset/currentcolor.md new file mode 100644 index 0000000..9aa9c26 --- /dev/null +++ b/.changeset/currentcolor.md @@ -0,0 +1,16 @@ +--- +"@bomb.sh/tty": minor +--- + +`open()` accepts a `color` field: the element's own foreground color, inherited +by every descendant that doesn't set its own. `currentcolor` is now a valid +color string everywhere, including `color` itself, where it means "inherit" +(the same as omitting the field). Used in any other color field — +`open({ bg })`, `text({ bg })`, `border.color`, `border.bg`, or a structured +border side's `color`/`bg` — it resolves to the directive's own resolved +color (its explicit `color`, or the color it inherits), then follows the same +field resolution as any other color, so it probes the terminal theme and +transitions normally when that resolved color is an sRGB value. `border.color` +is now optional and defaults to `currentcolor`, matching CSS; a border with no +`color` set anywhere picks up the element's own foreground, same as before +this feature existed. diff --git a/color.ts b/color.ts index 31e75df..12cd8be 100644 --- a/color.ts +++ b/color.ts @@ -50,16 +50,33 @@ function byte(unit: number): number { return Math.round(Math.min(Math.max(unit, 0), 1) * 255); } +/** + * A §8.5 `currentcolor` sentinel. In a `color` field it means "inherit"; in + * any other field it means "this directive's own resolved color." Both + * substitutions happen in `pack()`'s inheritance stack (ops.ts), not here. + */ +export const CURRENT_COLOR: { readonly current: true } = { current: true }; +export type CurrentColor = typeof CURRENT_COLOR; + +/** Narrows a `resolveColor()` result to the `currentcolor` sentinel. */ +export function isCurrentColor( + value: number | TerminalColor | CurrentColor, +): value is CurrentColor { + return typeof value === "object" && "current" in value; +} + /** * Resolves a §8.5 CSS color string to the packed ARGB number the transfer - * 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. + * encoding carries, a terminal-themed color for a system color keyword, the + * `currentcolor` sentinel, 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 | TerminalColor | undefined { +): number | TerminalColor | CurrentColor | undefined { let lower = input.toLowerCase(); + if (lower === "currentcolor") return CURRENT_COLOR; if (Object.hasOwn(systemColors, lower)) { return { ansi: systemColors[lower as keyof typeof systemColors] }; } diff --git a/ops.ts b/ops.ts index 7c0617a..bfeefcd 100644 --- a/ops.ts +++ b/ops.ts @@ -1,5 +1,6 @@ import type { names } from "hueful"; import { + isCurrentColor, resolveColor, resolveForField, type systemColors, @@ -104,20 +105,22 @@ function sideFg( side: BorderSide | undefined, shared: Color, theme: ThemeColors | undefined, + current: number | TerminalColor, ): number { let color = typeof side === "object" && side.color !== undefined ? side.color : shared; - return colorWord(color, "fg", theme); + return colorWord(color, "fg", theme, current); } function sideBg( side: BorderSide | undefined, shared: Color | undefined, theme: ThemeColors | undefined, + current: number | TerminalColor, ): number { let bg = typeof side === "object" && side.bg !== undefined ? side.bg : shared; - return bg === undefined ? BG_KEEP : colorWord(bg, "bg", theme); + return bg === undefined ? BG_KEEP : colorWord(bg, "bg", theme, current); } function packString( @@ -155,11 +158,16 @@ export function pack( let end = limit ?? mem.byteLength; let o = offset; + // §8.5 foreground inheritance: pushed on open (the element's own resolved + // color), popped on the matching close. Scoped to this single pass. + let colorStack: (number | TerminalColor)[] = [DEFAULT_FG]; + for (let op of ops) { switch (op.directive) { case OP_CLOSE_ELEMENT: view.setUint32(o, op.directive, true); o += 4; + if (colorStack.length > 1) colorStack.pop(); break; case OP_OPEN_ELEMENT: { @@ -169,9 +177,15 @@ export function pack( let bytes = encoder.encode(op.id); o = packString(view, bytes, o, end, "element id"); + let ownColor = resolveOwn( + op.color, + colorStack[colorStack.length - 1], + ); + colorStack.push(ownColor); + let bg = op.bg === undefined ? undefined - : colorValue(op.bg, "bg", theme); + : colorValue(op.bg, "bg", theme, ownColor); let mask = 0; if (op.layout) mask |= PROP_LAYOUT; if (typeof bg === "number") mask |= PROP_BG_COLOR; @@ -254,10 +268,11 @@ export function pack( // attribute words: fg/bg pairs in top, right, bottom, left order. // C treats the presence and order of these words as a wire-format // invariant and only consumes the explicit values. + let sharedColor: Color = b.color ?? "currentcolor"; for (let side of [b.top, b.right, b.bottom, b.left]) { - view.setUint32(o, sideFg(side, b.color, theme), true); + view.setUint32(o, sideFg(side, sharedColor, theme, ownColor), true); o += 4; - view.setUint32(o, sideBg(side, b.bg, theme), true); + view.setUint32(o, sideBg(side, b.bg, theme, ownColor), true); o += 4; } } @@ -332,11 +347,15 @@ export function pack( view.setUint32(o, OP_TEXT, true); o += 4; // 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); + // ORs the attrs byte back over the high byte. No color inherits the + // nearest ancestor open()'s color (§8.5), terminal default at root. + let ownColor = resolveOwn( + op.color, + colorStack[colorStack.length - 1], + ); + let color = typeof ownColor === "number" + ? ownColor + : resolveForField(ownColor, "fg", theme); let terminal = typeof color === "object"; view.setUint32( o, @@ -347,7 +366,9 @@ export function pack( view.setUint32( o, - op.bg === undefined ? BG_KEEP : colorWord(op.bg, "bg", theme), + op.bg === undefined + ? BG_KEEP + : colorWord(op.bg, "bg", theme, ownColor), true, ); o += 4; @@ -398,21 +419,45 @@ export type Color = number | string; /** * 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. + * resolve through the CSS color grammar. `currentcolor` substitutes `current` + * — the directive's own resolved color — before field resolution runs, same + * as any other color. 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, field: "fg" | "bg", theme: ThemeColors | undefined, + current: number | TerminalColor, ): number | TerminalColor { if (typeof color === "number") return color; let value = resolveColor(color); if (value === undefined) throw new TypeError(`invalid color: "${color}"`); - if (typeof value === "number") return value; - return resolveForField(value, field, theme); + let resolved = isCurrentColor(value) ? current : value; + if (typeof resolved === "number") return resolved; + return resolveForField(resolved, field, theme); +} + +/** + * Resolves the canonical (pre field-resolution) color a directive's own + * `color` property names — its inheritance value for §8.5's foreground + * inheritance. `undefined` or `currentcolor` inherits `parent`; any other + * value resolves through the CSS color grammar, same as `colorValue`, but + * without field resolution — that happens where the value is later consumed. + */ +function resolveOwn( + explicit: Color | undefined, + parent: number | TerminalColor, +): number | TerminalColor { + if (explicit === undefined) return parent; + if (typeof explicit === "number") return explicit; + let value = resolveColor(explicit); + if (value === undefined) { + throw new TypeError(`invalid color: "${explicit}"`); + } + return isCurrentColor(value) ? parent : value; } function terminalWord(color: TerminalColor): number { @@ -426,11 +471,15 @@ function colorWord( color: Color, field: "fg" | "bg", theme: ThemeColors | undefined, + current: number | TerminalColor, ): number { - let value = colorValue(color, field, theme); + let value = colorValue(color, field, theme, current); return typeof value === "number" ? value & 0x00FFFFFF : terminalWord(value); } +/* The directive-chain default when no ancestor `open()` sets `color`. */ +const DEFAULT_FG: TerminalColor = { ansi: "fg-default" }; + export type SizingAxis = | { type: "fit"; min?: number; max?: number } | { type: "grow"; min?: number; max?: number } @@ -468,6 +517,10 @@ export type BorderSide = export interface OpenElement { directive: typeof OP_OPEN_ELEMENT; id: string; + /** The element's own foreground color (§8.5). Inherited by descendants + * that do not override it; `currentcolor` means "inherit," same as + * omitting this field. */ + color?: Color; layout?: { width?: SizingAxis; height?: SizingAxis; @@ -480,7 +533,8 @@ export interface OpenElement { bg?: Color; cornerRadius?: { tl?: number; tr?: number; bl?: number; br?: number }; border?: { - color: Color; + /** Defaults to `currentcolor` (§8.5): the element's own resolved color. */ + color?: Color; bg?: Color; left?: BorderSide; right?: BorderSide; @@ -640,7 +694,8 @@ type ColorFunction = type NamedColor = | keyof typeof names | keyof typeof systemColors - | "transparent"; + | "transparent" + | "currentcolor"; 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 057ff6d..824a53b 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -919,31 +919,31 @@ clipped per channel; no perceptual gamut mapping is performed. Alpha defaults to 255 when a form does not supply it. A hex string and the equivalent `rgba()` call produce bit-identical packed values. -**Foreground inheritance and `currentcolor`.** `open()` accepts a `color` -field: the element's own foreground color. `color` is inherited: an element -with no explicit `color` (or with `color: "currentcolor"`) uses the nearest -ancestor `open()`'s resolved color; an explicit `color` other than -`currentcolor` overrides it for that element and, by the same inheritance -rule, every descendant that does not itself override it. `text()` resolves its -own `color` field (and, when absent, inherits) the same way; a `text()` has no -descendants, so its resolved color only affects itself. With no `color` -anywhere in the enclosing chain, the inherited color is the terminal default -foreground — the same color a directive gets today when `color` is omitted. +**Foreground inheritance and `currentcolor`.** `open()` accepts a `color` field: +the element's own foreground color. `color` is inherited: an element with no +explicit `color` (or with `color: "currentcolor"`) uses the nearest ancestor +`open()`'s resolved color; an explicit `color` other than `currentcolor` +overrides it for that element and, by the same inheritance rule, every +descendant that does not itself override it. `text()` resolves its own `color` +field (and, when absent, inherits) the same way; a `text()` has no descendants, +so its resolved color only affects itself. With no `color` anywhere in the +enclosing chain, the inherited color is the terminal default foreground — the +same color a directive gets today when `color` is omitted. `currentcolor` used in any field other than `color` — `open({ bg })`, `text({ bg })`, `border.color`, `border.bg`, or a structured border side's -`color` or `bg` — resolves to that directive's own resolved color (its -explicit `color`, or the color it inherits). That resolved color is then -subject to the same field resolution as any other color value: a same-field -default keyword or palette entry stays terminal-themed, a cross-field default -keyword resolves against the probed theme (falling back to the field's own -default when unprobed), and an sRGB color passes through unchanged. For -example, an element with no `color` set anywhere above it has an inherited -color of the terminal default foreground; `bg: "currentcolor"` on that element -resolves exactly like `bg: "CanvasText"` — the probed foreground when -available, the default background otherwise — while `border.color` left at its -default resolves like `border.color: "CanvasText"` used in a foreground field, -which stays terminal-themed (SGR `39`). +`color` or `bg` — resolves to that directive's own resolved color (its explicit +`color`, or the color it inherits). That resolved color is then subject to the +same field resolution as any other color value: a same-field default keyword or +palette entry stays terminal-themed, a cross-field default keyword resolves +against the probed theme (falling back to the field's own default when +unprobed), and an sRGB color passes through unchanged. For example, an element +with no `color` set anywhere above it has an inherited color of the terminal +default foreground; `bg: "currentcolor"` on that element resolves exactly like +`bg: "CanvasText"` — the probed foreground when available, the default +background otherwise — while `border.color` left at its default resolves like +`border.color: "CanvasText"` used in a foreground field, which stays +terminal-themed (SGR `39`). `border.color` is optional and defaults to `currentcolor`, matching CSS. A structured border side without its own `color` falls back to the shared @@ -953,8 +953,8 @@ structured border side without its own `color` falls back to the shared Foreground inheritance is resolved by `pack()` with a stack: pushed on each element open (the newly resolved color), popped on the matching close. It is scoped to the single pass over one frame's directive array — a sibling that -follows a closed element does not see that element's color, and no -inheritance state carries across frames (§6, §10.4). +follows a closed element does not see that element's color, and no inheritance +state carries across frames (§6, §10.4). **Invalid color strings.** A string that does not match the color string grammar is invalid directive input (§9.1). `validate()` reports it as an invalid field @@ -967,10 +967,10 @@ 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, `transparent`, -`currentcolor`, 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. +`currentcolor`, 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 diff --git a/test/border.test.ts b/test/border.test.ts index adc736c..4b90081 100644 --- a/test/border.test.ts +++ b/test/border.test.ts @@ -748,3 +748,52 @@ describe("system colors", () => { expect(at(parsed, 3, 0).fg).toBe("\x1b[38;2;200;210;220"); }); }); + +describe("currentcolor", () => { + it("defaults border.color to currentcolor, inherited from an ancestor open", async () => { + let term = await createTerm({ width: 12, height: 5 }); + let ansi = decode( + term.render([ + open("outer", { color: RED }), + open("box", { + layout: { width: fixed(8), height: fixed(4) }, + border: { top: 1, right: 1, bottom: 1, left: 1 }, + }), + close(), + close(), + ], { mode: "line" }).output, + ); + let parsed = cells(ansi); + + for (let cell of glyphs(parsed, "┌┐└┘─│")) { + expect(cell.fg).toBe(FG.red); + } + }); + + it("resolves border.color: currentcolor to the element's own explicit color", async () => { + let parsed = await renderBox({ + color: GREEN, + border: { color: "currentcolor", top: 1 }, + }); + + expect(at(parsed, 3, 0).fg).toBe(FG.green); + }); + + it("resolves border.bg: currentcolor to the element's own resolved color", async () => { + let parsed = await renderBox({ + color: BLUE, + border: { color: WHITE, bg: "currentcolor", top: 1 }, + }); + + expect(at(parsed, 3, 0).bg).toBe(BG.blue); + }); + + it("resolves a structured side's color: currentcolor to the element's own color", async () => { + let parsed = await renderBox({ + color: YELLOW, + border: { color: WHITE, top: { width: 1, color: "currentcolor" } }, + }); + + expect(at(parsed, 3, 0).fg).toBe(FG.yellow); + }); +}); diff --git a/test/color.test.ts b/test/color.test.ts index 77b602d..6eaaf41 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -328,6 +328,9 @@ describe("CSS color strings", () => { text("a", { color: "oklch(0.7 0.1 30)" }), text("a", { color: "RGB(255 0 0)" }), open("a", { bg: "hsl(0 0% 0%)", border: { color: "red" } }), + open("a", { color: "currentcolor" }), + open("a", { bg: "CurrentColor", border: {} }), + text("a", { color: "currentColor", bg: "currentcolor" }), ]; // @ts-expect-error invalid hex digit count text("a", { color: "#ff8800a" }); @@ -335,9 +338,9 @@ 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 currentcolor - open("a", { bg: "currentcolor" }); - expect(valid).toHaveLength(6); + // @ts-expect-error misspelled currentcolor + open("a", { bg: "currentcolour" }); + expect(valid).toHaveLength(9); }); }); @@ -576,3 +579,167 @@ describe("system colors", () => { expect(valid).toHaveLength(2); }); }); + +describe("foreground inheritance and currentcolor", () => { + it("inherits an ancestor open's color through nested opens to text", async () => { + let term = await createTerm({ width: 10, height: 1 }); + let ansi = decode( + term.render([ + open("outer", { color: "#ff0000" }), + open("inner", { layout: { width: grow(), height: grow() } }), + text("Hi"), + close(), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[38;2;255;0;0m"); + }); + + it("overrides the inherited color for itself and its descendants", async () => { + let term = await createTerm({ width: 10, height: 1 }); + let ansi = decode( + term.render([ + open("outer", { color: "#ff0000" }), + open("inner", { + color: "#00ff00", + layout: { width: grow(), height: grow() }, + }), + text("Hi"), + close(), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[38;2;0;255;0m"); + expect(ansi).not.toContain("\x1b[38;2;255;0;0m"); + }); + + it("does not carry a closed sibling's color into the next one", async () => { + let term = await createTerm({ width: 10, height: 1 }); + let ansi = decode( + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + open("a", { color: "#ff0000" }), + text("Hi"), + close(), + open("b", { layout: { width: grow(), height: grow() } }), + text("Yo"), + close(), + close(), + ]).output, + ); + + let betweenHiAndYo = ansi.slice(ansi.indexOf("Hi") + 2, ansi.indexOf("Yo")); + expect(betweenHiAndYo).not.toContain("\x1b[38;2;255;0;0m"); + }); + + it("treats color: currentcolor as inherit, same as omitting color", async () => { + let withKeyword = decode( + (await createTerm({ width: 10, height: 1 })).render([ + open("outer", { color: "#ff0000" }), + open("inner", { color: "currentcolor" }), + text("Hi"), + close(), + close(), + ]).output, + ); + let omitted = decode( + (await createTerm({ width: 10, height: 1 })).render([ + open("outer", { color: "#ff0000" }), + open("inner"), + text("Hi"), + close(), + close(), + ]).output, + ); + + expect(withKeyword).toBe(omitted); + }); + + it("resolves text bg currentcolor to the inherited color", async () => { + let term = await createTerm({ width: 10, height: 1 }); + let ansi = decode( + term.render([ + open("outer", { color: "#ff0000" }), + text("Hi", { bg: "currentcolor" }), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[48;2;255;0;0m"); + }); + + it("resolves text bg currentcolor to the element's own explicit color over an ancestor's", async () => { + let term = await createTerm({ width: 10, height: 1 }); + let ansi = decode( + term.render([ + open("outer", { color: "#ff0000" }), + text("Hi", { color: "#00ff00", bg: "currentcolor" }), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[48;2;0;255;0m"); + }); + + it("resolves open bg currentcolor to its own color", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let ansi = decode( + term.render([ + open("box", { + layout: { width: fixed(4), height: fixed(1) }, + color: "#ff0000", + bg: "currentcolor", + }), + close(), + ], { mode: "line" }).output, + ); + + expect(ansi).toContain("\x1b[48;2;255;0;0m"); + }); + + it("packs text bg currentcolor like the field's own default without an ancestor color or theme", async () => { + let currentcolor = decode( + (await createTerm({ width: 4, height: 1 })).render([ + text("Hi", { bg: "currentcolor" }), + ]).output, + ); + let fieldDefault = decode( + (await createTerm({ width: 4, height: 1 })).render([ + text("Hi", { bg: "CanvasText" }), + ]).output, + ); + + expect(currentcolor).toBe(fieldDefault); + }); + + it("resolves text bg currentcolor to the probed theme foreground once reported", 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([text("Hi", { bg: "currentcolor" })]).output, + ); + + expect(ansi).toContain("48;2;10;20;30"); + }); + + it("resolves text bg currentcolor to an inherited palette color unchanged", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let ansi = decode( + term.render([ + open("outer", { color: "Mark" }), + text("Hi", { bg: "currentcolor" }), + close(), + ]).output, + ); + + expect(ansi).toContain("\x1b[43m"); + }); +}); diff --git a/test/pack.test.ts b/test/pack.test.ts index 1586fa8..d6c016e 100644 --- a/test/pack.test.ts +++ b/test/pack.test.ts @@ -197,7 +197,7 @@ describe("CSS color strings", () => { "color-mix(in srgb, red, blue)", "rgb(from red r g b)", "device-cmyk(0 0 0 1)", - "currentcolor", + "currentcolour", " red", "red ", "rgb(1 2)", @@ -331,3 +331,140 @@ describe("cross-field default keywords with a theme", () => { ); }); }); + +describe("currentcolor", () => { + 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); + } + + it("packs a text bg currentcolor identically to the inherited ancestor rgba() color", () => { + expect( + packed([ + open("a", { color: rgba(10, 20, 30) }), + text("Hi", { bg: "currentcolor" }), + close(), + ]), + ).toEqual( + packed([ + open("a", { color: rgba(10, 20, 30) }), + text("Hi", { bg: rgba(10, 20, 30) }), + close(), + ]), + ); + }); + + it("packs a text color currentcolor identically to omitting color", () => { + expect(packed([text("Hi", { color: "currentcolor" })])).toEqual( + packed([text("Hi")]), + ); + }); + + it("packs open bg currentcolor identically to its own explicit color", () => { + expect( + packed([ + open("a", { color: rgba(1, 2, 3), bg: "currentcolor" }), + close(), + ]), + ).toEqual( + packed([open("a", { color: rgba(1, 2, 3), bg: rgba(1, 2, 3) }), close()]), + ); + }); + + it("packs border.color currentcolor identically to the inherited color", () => { + expect( + packed([ + open("a", { color: rgba(4, 5, 6) }), + open("b", { border: { top: 1 } }), + close(), + close(), + ]), + ).toEqual( + packed([ + open("a", { color: rgba(4, 5, 6) }), + open("b", { border: { color: "currentcolor", top: 1 } }), + close(), + close(), + ]), + ); + }); + + it("packs border.bg currentcolor identically to the directive's own resolved color", () => { + expect( + packed([ + open("a", { + color: rgba(7, 8, 9), + border: { bg: "currentcolor", top: 1 }, + }), + close(), + ]), + ).toEqual( + packed([ + open("a", { + color: rgba(7, 8, 9), + border: { bg: rgba(7, 8, 9), top: 1 }, + }), + close(), + ]), + ); + }); + + it("packs a structured side color currentcolor identically to its own resolved color", () => { + expect( + packed([ + open("a", { + color: rgba(1, 1, 1), + border: { + color: rgba(9, 9, 9), + top: { width: 1, color: "currentcolor" }, + }, + }), + close(), + ]), + ).toEqual( + packed([ + open("a", { + color: rgba(1, 1, 1), + border: { + color: rgba(9, 9, 9), + top: { width: 1, color: rgba(1, 1, 1) }, + }, + }), + close(), + ]), + ); + }); + + it("packs a same-field default inherited via currentcolor like the default keyword", () => { + expect(packed([text("Hi", { bg: "currentcolor" })])).toEqual( + packed([text("Hi", { bg: "CanvasText" })]), + ); + }); + + it("packs a cross-field default inherited via currentcolor against a probed theme", () => { + let theme = { foreground: { r: 10, g: 20, b: 30 } }; + expect(packed([text("Hi", { bg: "currentcolor" })], theme)).toEqual( + packed([text("Hi", { bg: rgba(10, 20, 30) })]), + ); + }); + + it("packs a palette color inherited via currentcolor unchanged", () => { + expect( + packed([ + open("a", { color: "Mark" }), + text("Hi", { bg: "currentcolor" }), + close(), + ]), + ).toEqual( + packed([ + open("a", { color: "Mark" }), + text("Hi", { bg: "Mark" }), + close(), + ]), + ); + }); +}); diff --git a/test/validate.test.ts b/test/validate.test.ts index 80a3d75..3ba5b3a 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -252,7 +252,7 @@ describe("validate", () => { } }); - it("rejects a border without shared color even with side colors", () => { + it("accepts a border without shared color even with side colors", () => { expect(validate([ { directive: 0x02, @@ -260,7 +260,34 @@ describe("validate", () => { border: { top: { width: 1, color: 0xFF0000 } }, }, close(), - ])).toBe(false); + ])).toBe(true); + }); + + it("accepts open({ color }), including currentcolor", () => { + expect(validate([ + open("x", { color: 0xFF0000 }), + close(), + ])).toBe(true); + expect(validate([ + open("x", { color: "currentcolor" }), + close(), + ])).toBe(true); + }); + + it("accepts currentcolor at every color field", () => { + expect(validate([ + open("x", { + color: "currentcolor", + bg: "currentcolor", + border: { + color: "currentcolor", + bg: "currentcolor", + top: { width: 1, color: "currentcolor", bg: "currentcolor" }, + }, + }), + text("hi", { color: "currentcolor", bg: "currentcolor" }), + close(), + ])).toBe(true); }); }); @@ -398,7 +425,7 @@ describe("CSS color fields", () => { "color(srgb 1 0 0)", "device-cmyk(0 0 0 1)", "Canvass", - "currentcolor", + "currentcolour", " red", "rgb(1 2)", ] diff --git a/validate.ts b/validate.ts index ec6b6fd..37d91c6 100644 --- a/validate.ts +++ b/validate.ts @@ -99,7 +99,7 @@ const BorderSide = Type.Union([ ]); const Border = Type.Object({ - color, + color: Type.Optional(color), bg: Type.Optional(color), left: Type.Optional(BorderSide), right: Type.Optional(BorderSide), @@ -196,6 +196,7 @@ const CloseElement = Type.Object({ directive: Type.Literal(0x04) }); const OpenElement = Type.Object({ directive: Type.Literal(0x02), id: Type.String(), + color: Type.Optional(color), layout: Type.Optional(Layout), bg: Type.Optional(color), cornerRadius: Type.Optional(CornerRadius),