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 579d1df..824a53b 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,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`, -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 @@ -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. --- 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),