From 011a3c0aa1954777179ac5028a0c4a248f50db52 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 13:59:04 -0500 Subject: [PATCH 1/2] feat: color-encoding tiers derived from capability evidence The renderer resolves an emission tier (truecolor / 256 / 16) from its capability snapshot instead of hardcoding 24-bit truecolor, per specs/color-encoding-spec.md. Capability state is authoritative in the WASM instance; the host pushes it at creation and after capability folds, and a tier change forces a complete redraw. Standard palette colors keep their classic indices when downmapped. Closes #60 --- .changeset/color-encoding-tiers.md | 20 ++ Makefile | 1 + specs/color-encoding-spec.md | 346 +++++++++++++++++++++++++++++ specs/renderer-spec.md | 15 +- src/clayterm.c | 218 ++++++++++++++++-- src/clayterm.h | 1 + term-native.ts | 39 ++++ term.ts | 40 ++-- test/border.test.ts | 67 +++++- test/caps.ts | 24 +- test/color-encoding-modes.test.ts | 319 ++++++++++++++++++++++++++ test/color.test.ts | 37 ++- test/term.test.ts | 13 +- test/width.test.ts | 5 +- 14 files changed, 1083 insertions(+), 62 deletions(-) create mode 100644 .changeset/color-encoding-tiers.md create mode 100644 specs/color-encoding-spec.md create mode 100644 test/color-encoding-modes.test.ts diff --git a/.changeset/color-encoding-tiers.md b/.changeset/color-encoding-tiers.md new file mode 100644 index 0000000..867a1c1 --- /dev/null +++ b/.changeset/color-encoding-tiers.md @@ -0,0 +1,20 @@ +--- +"@bomb.sh/tty": minor +--- + +The renderer now derives its SGR color encoding from the terminal's capability evidence instead of always emitting 24-bit truecolor. Truecolor evidence (`RGB`/`Tc` terminfo extension, `COLORTERM`, direct-color entries with `max_colors ≥ 2²⁴`, or a `colordepth` probe response folded through `term.update()`) keeps the historical byte-identical truecolor output. Without truecolor evidence, cells emit 256-color SGR (`\x1b[38;5;Nm`) when the resolved palette exceeds 16 colors, or 4-bit SGR (`\x1b[31m`) at or below 16. + +Colors are downmapped deterministically: standard palette colors keep their classic indices (`rgba(255,0,0)` → `\x1b[31m` / `\x1b[38;5;1m`), grays use the 240-step grayscale ramp, and everything else rounds to the 6×6×6 cube. A tier change between frames forces a complete redraw so no stale encoding survives on screen. + +**Breaking:** rendering without color evidence now produces 256-color bytes where it previously produced truecolor. Run `detectTerminal()` and pass its `TerminalInfo` to `createTerm` (or fold probe responses with `term.update()`) to keep truecolor output — terminals that support truecolor report it. + +#### Migration + +```diff + import { createTerm } from "@bomb.sh/tty"; ++ import { detectTerminal } from "@bomb.sh/tty"; + +- const term = await createTerm({ width, height }); ++ const terminfo = await detectTerminal(); ++ const term = await createTerm({ width, height, terminfo }); +``` diff --git a/Makefile b/Makefile index 2da950f..b9d0c33 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ EXPORTS = \ -Wl,--export=__heap_base \ -Wl,--export=clayterm_size \ -Wl,--export=init \ + -Wl,--export=set_capabilities \ -Wl,--export=reduce \ -Wl,--export=output \ -Wl,--export=length \ diff --git a/specs/color-encoding-spec.md b/specs/color-encoding-spec.md new file mode 100644 index 0000000..c8bb662 --- /dev/null +++ b/specs/color-encoding-spec.md @@ -0,0 +1,346 @@ +# Clayterm Color Encoding Specification + +**Version:** 0.1 (draft) **Status:** Design specification for a work-in-progress +feature. Normative where it establishes invariants and contract. Descriptive +where surfaces may settle during implementation. + +--- + +## 1. Purpose + +The renderer emits one SGR color encoding for every cell it paints. Historically +that encoding was hardcoded to 24-bit truecolor (`\x1b[38;2;R;G;Bm`), regardless +of what the destination terminal supports. A 16-color terminal, a CI log, or a +pipe receives sequences its destination renders poorly — or not at all — when +`\x1b[31m` would have been both smaller and more faithful. + +This specification defines how the renderer derives its color encoding from the +capability snapshot it already maintains, and how it downmaps truecolor values +into narrower palettes when the evidence calls for it. It resolves the deferred +"color encoding" item in [renderer-spec.md](renderer-spec.md) §7.8 and the open +question from the capability-foundation work about where the renderer's +authoritative capability state lives. + +There is deliberately no caller-facing override. Capability evidence is the +single lever (TINV-3); an override would create a second source of truth that +could contradict probe responses. + +--- + +## 2. Scope + +### In scope (normative) + +- The color tier resolution rule: how `trueColor` and `colors` in the capability + snapshot determine the emitted encoding tier +- Ownership of the renderer's color-capability state inside the WASM module +- Per-tier SGR emission formats for foreground and background colors +- The downmapping algorithms from 24-bit RGB to the 256-color and 16-color + palettes, including the normative nominal palette +- Output invalidation when the resolved tier changes between transactions + +### In scope (non-normative, descriptive) + +- Implementation notes for the C-side emission path +- The relationship between this specification and the failing-test reproduction + that motivated it + +### Out of scope + +- **Terminal palette definition.** The renderer maps to palette _indices_; how a + terminal renders index N is the terminal's business. OSC 4 palette queries + remain deferred (terminfo-spec). +- **An `"8"`-color tier.** The 16-color tier already emits the base + `30–37`/`40–47` codes for indices 0–7. A dedicated 8-color tier would need + bright-index elision for marginal benefit; deferred. +- **88-color precision.** Terminals reporting `17 ≤ colors ≤ 255` resolve to the + 256 tier (§6.1); indices above 87 are the terminal's problem. +- **Per-render or per-call overrides.** Recorded as rejected in §7. +- **Other capability-driven emission** (synchronized output, pointer shape, + Kitty protocols) — each has its own focused specification path. + +--- + +## 3. Terminology + +**Color tier.** One of `"truecolor" | "256" | "16"` — the same three-value +`ColorDepth` type defined in [terminfo-spec.md](terminfo-spec.md) §6.2. The tier +names the _emission encoding_, not the terminal's full capability set. + +**Downmapping.** Producing the narrowest SGR sequence that faithfully represents +a 24-bit RGB color under the active tier. + +**Nominal palette.** The 16-entry RGB table defined in §6.3. It is the +renderer's canonical model of what each 4-bit index _means_ for mapping +purposes. It is not a claim about any terminal's actual palette, which varies +and may be user-configured. + +--- + +## 4. Architectural Model + +### 4.1 Evidence flows one way + +``` +detectTerminal() probe responses update() + terminfo entry → (input.scan) → CapabilityEvents + ↓ ↓ + Capabilities ──────────── fold ────────→ RuntimeCapabilities + │ + push (on change) │ + ↓ + WASM Clayterm instance (authoritative) + │ resolve tier + ↓ + emit_attr formatting +``` + +Static evidence (terminfo entry, `COLORTERM`) and dynamic evidence (`colordepth` +CapabilityEvents) meet in the capability snapshot exactly as terminfo-spec +defines. The renderer consumes that snapshot; it never consults the environment +or the raw terminfo entry itself. + +### 4.2 Capability state ownership + +The renderer's authoritative color-capability state — the `colors` value and the +truecolor flag — resides inside the WASM module, on the Clayterm instance +struct. The host pushes values across the boundary: + +- once at Term creation, seeding from `terminfo.capabilities` or the §7.1 + baseline of [terminfo-spec.md](terminfo-spec.md); +- after each `update()` whose folded events changed either value. + +Render transactions read the in-module state directly; the host does not copy +capability values per render. State belongs to the instance struct — there is no +module-level mutable state. + +A resize update re-initializes renderer state but MUST NOT reset color +capabilities to baseline: the tier is a property of the terminal, not of the +frame geometry. The host re-pushes the current values as part of the update +path, and the values MUST survive intact. + +`term.capabilities` (the frozen `RuntimeCapabilities` snapshot) remains the +host-readable mirror. Its shape is unchanged by this specification. + +### 4.3 The renderer is still a pure formatter + +Nothing here changes the renderer's invariants: it performs no IO, manages no +terminal state, and produces bytes only. Tier resolution and downmapping are +pure functions of (capability state, packed cell color). + +--- + +## 5. Core Invariants + +- **CI-1 — Total, deterministic resolution.** Every capability snapshot state + resolves to exactly one tier, by the rule in §6.1. The same state always + yields the same tier. +- **CI-2 — Truecolor byte-identity.** Under the `"truecolor"` tier, output MUST + be byte-identical to the renderer's historical (pre-this-specification) + output. +- **CI-3 — Deterministic downmapping.** Every downmapping decision in §6.3 is a + pure function of the RGB value and the tier. Ties always resolve to the lowest + index. +- **CI-4 — Palette round-trip by color.** Under the `"256"` tier, every RGB + value that is itself an entry of the 256-color palette maps to an index that + represents that same RGB value. Where the nominal palette and the cube or + grayscale ramp define the same color (eight cube corners plus `#808080`), the + nominal index wins — the emitted index differs, the color does not. Under the + `"16"` tier, every nominal palette entry maps to its own index. +- **CI-5 — Tier change forces full redraw.** If the resolved tier differs from + the previous transaction's tier, the next transaction MUST emit the frame as a + complete redraw. Diff state is cell-based; stale cells from the previous + encoding must not survive. +- **CI-6 — Defaults stay default.** A cell with the default foreground or + background emits no color SGR for that attribute, in every tier. Style + attributes (bold, dim, italic, underline, blink, reverse, strikeout) and the + SGR reset pattern are unchanged in every tier. + +--- + +## 6. Rendering Contract Additions + +### 6.1 Tier resolution + +The renderer resolves the emission tier from the capability snapshot: + +``` +tier = + trueColor ? "truecolor" + : colors <= 16 ? "16" + : "256" +``` + +The `colors ≤ 16` boundary intentionally matches the denial mapping in +terminfo-spec §6.3 (`"16"` when `colors ≤ 16`, `"256"` otherwise), so the +renderer's derived tier and a `colordepth` denial event agree for every static +`colors` value. A `colordepth` probe event folds into `trueColor` (and is the +authoritative evidence for it per TINV-3); no other fold changes the tier. + +Notes: + +- `colors` values below 16 (including a missing `max_colors`, i.e. 0) clamp to + the `"16"` tier. The 16-color tier emits base codes for indices 0–7 and + aixterm bright codes for 8–15; a strictly 8-color destination's handling of + bright codes is the destination's concern. +- Truecolor requires positive evidence (terminfo-spec §7.1): the `RGB`/`Tc` + terminfo extension, `COLORTERM`, a direct-color entry (`max_colors ≥ 2²⁴`), or + a `colordepth` probe response. The baseline — no evidence at all — resolves to + `"256"`. + +**Default-behavior change.** A Term created without any color evidence now emits +256-color SGR, where it previously emitted truecolor. This is the baseline +evidence model applied to emission, not a regression: the renderer's hardcoded +truecolor contradicted terminfo-spec §7.1 ("Truecolor is not assumed at +baseline"). Callers who require truecolor bytes without running +`detectTerminal()` must supply capability evidence themselves. + +### 6.2 Emission formats + +For a non-default foreground/background under each tier: + +| Tier | Foreground | Background | +| ------------- | --------------------------------------------- | ---------------------------------------------- | +| `"truecolor"` | `\x1b[38;2;R;G;Bm` | `\x1b[48;2;R;G;Bm` | +| `"256"` | `\x1b[38;5;Nm`, N ∈ 0–255 | `\x1b[48;5;Nm`, N ∈ 0–255 | +| `"16"` | index 0–7: `\x1b[3Nm`; index 8–15: `\x1b[9Nm` | index 0–7: `\x1b[4Nm`; index 8–15: `\x1b[10Nm` | + +In the `"16"` tier, `N` is the resolved palette index for indices 0–7 and +`index − 8` for indices 8–15. Standard red (index 1) emits `\x1b[31m` foreground +and `\x1b[41m` background. + +### 6.3 Downmapping + +#### 6.3.1 Nominal palette + +The nominal palette defines what each 4-bit index means to the renderer: + +| Index | RGB | Name | Index | RGB | Name | +| ----- | --------- | ------- | ----- | --------- | -------------- | +| 0 | `#000000` | black | 8 | `#808080` | bright black | +| 1 | `#ff0000` | red | 9 | `#ff5555` | bright red | +| 2 | `#00ff00` | green | 10 | `#55ff55` | bright green | +| 3 | `#ffff00` | yellow | 11 | `#ffff55` | bright yellow | +| 4 | `#0000ff` | blue | 12 | `#5555ff` | bright blue | +| 5 | `#ff00ff` | magenta | 13 | `#ff55ff` | bright magenta | +| 6 | `#00ffff` | cyan | 14 | `#55ffff` | bright cyan | +| 7 | `#c0c0c0` | white | 15 | `#ffffff` | bright white | + +The primaries (1–6) are the pure hues; the bright variants are their pastel +counterparts. This choice is what makes "standard red" (`rgba(255, 0, 0)`) +resolve to index 1 — the behavior the motivating issue pins. + +#### 6.3.2 `"16"` tier + +Map the RGB value to the nearest entry of the nominal palette by squared +Euclidean distance over the three channels; on a tie, choose the lowest index. +Exact palette matches are distance zero, so they subsume themselves. + +#### 6.3.3 `"256"` tier + +Resolved in order; the first matching rule wins: + +1. **Exact nominal match.** If the RGB value equals a nominal palette entry, + emit that index (0–15). This is why standard red emits `\x1b[38;5;1m` rather + than the cube corner `\x1b[38;5;196m`: the terminal's index 1 _is_ its red, + and consistency with the `"16"` tier is worth more than exact chroma here. +2. **Exact cube match.** If every channel is one of the six cube values + `{0, 95, 135, 175, 215, 255}`, emit `16 + 36r + 6g + b` for the channel's + cube step. (Exact grayscale cube values such as `#5f5f5f` resolve here.) +3. **Grayscale.** If `r == g == b`, choose the nearest value among the grayscale + ramp `{8 + 10i : 0 ≤ i ≤ 23}` extended by the endpoints `0` (index 16) and + `255` (index 231); on a tie, choose the lower index. Emit `232 + i` for ramp + value `8 + 10i`, `16` for the low endpoint, `231` for the high endpoint. +4. **Cube nearest.** Otherwise, map each channel independently to the nearest + cube value (ties to the smaller value) and emit `16 + 36r + 6g + b`. + +Rules 1–3 are the exact-match tiers: nominal entries match in rule 1, cube +values in rule 2, and every grayscale ramp value matches itself in rule 3 +(distance zero). Eight RGB values are defined by both the nominal palette and +the cube (the pure primaries, black, and white) — for those, rule 1 outranks and +the nominal index is emitted, which still satisfies CI-4 because the emitted +index represents the identical color. + +#### 6.3.4 Default and attribute bytes + +Default foreground/background emit no color SGR (CI-6). The high byte of a +packed color carries style attributes; downmapping consumes only the low 24 bits +and never alters attribute emission. + +### 6.4 Invalidation on tier change + +The update transaction itself still returns no bytes for capability events +(renderer-spec §7.7). This specification defines the consumer behavior the +foundation reserved: when the resolved tier changes, the _next_ render +transaction MUST emit a complete redraw (CI-5). No immediate bytes are required +— the redraw's own output re-encodes every visible cell. + +### 6.5 What does not change + +- The diff model: cells are compared on packed color values, exactly as before. + The tier affects only how a changed cell's colors are _encoded_. +- The SGR reset + attribute prefix pattern in `emit_attr`. +- The output buffer budget; 256-color and 16-color sequences are strictly + shorter than truecolor. + +--- + +## 7. Public API impact + +None. `createTerm`, `render`, `update`, and `RuntimeCapabilities` are unchanged. +This is deliberate. + +**Rejected alternative — a `colorMode` option on `createTerm`.** The motivating +issue sketched `createTerm({ colorMode: "16" })` as an explicit override. It was +rejected: capability evidence is the single lever for renderer output (TINV-3), +and an override creates a second source of truth that can contradict probe +responses — a denial event would silently fight the override. Callers control +the encoding the same way they control every other capability: by supplying +evidence (a terminfo entry, or folding probe responses through `update()`). + +--- + +## 8. Test plan + +One test file (`test/color-encoding-modes.test.ts`) covers exactly this +specification: + +- **16-color tier.** Standard red foreground → `\x1b[31m`; background → + `\x1b[41m`; the 24-bit sequence must not survive; bright index mapping (e.g. + `#ff5555` → `\x1b[91m`). +- **256-color tier.** Standard red foreground → `\x1b[38;5;1m`; background → + `\x1b[48;5;1m`; exact cube round-trip (e.g. `#5f5f5f` → `\x1b[38;5;59m`); + grayscale ramp mapping; nearest-cube mapping for non-palette colors. +- **Truecolor tier.** Byte-identity with the historical output (CI-2), including + background and attribute combinations. +- **Tier resolution.** Baseline (no evidence) → `"256"`; `colors: 8` and + `colors: 16` → `"16"`; `colors: 17` → `"256"`; a granted truecolor flag → + truecolor. (How entries grant the flag — `RGB`/`Tc`, `COLORTERM`, direct-color + `max_colors ≥ 2²⁴` — is terminfo-spec evidence territory, covered by its + tests.) +- **Dynamic evidence.** A `colordepth` CapabilityEvent folded through `update()` + changes the tier of the next render; the render after a tier change is a + complete redraw in the new encoding (CI-5). +- **Defaults.** Default foreground/background emit no color SGR in every tier + (CI-6). +- **Palette round-trip.** Every 256-palette RGB value maps to an index that + represents the identical color under the `"256"` tier (CI-4, exhaustive sweep + over a rendered 16×16 grid). + +Existing test files that pin truecolor byte sequences (`term.test.ts`, +`border.test.ts`, `color.test.ts`) inject truecolor capability evidence at +creation so they continue to exercise the `"truecolor"` tier explicitly; the +default-tier change is pinned once, in the new file. + +--- + +## 9. Deferred / Future Areas + +- **OSC 4 palette queries.** Today the mapping assumes the nominal palette and + standard 256 cube. Querying the terminal's actual palette would permit exact + nearest-color downmapping against reality; deferred (terminfo-spec). +- **Perceptual distance.** Squared Euclidean RGB is deterministic and cheap. A + perceptually weighted metric (e.g. OKLab) could improve downmapping fidelity + for photography-like content; deferred until a use case demands it. +- **Dithering.** Adjacent-cell error diffusion to approximate out-of-palette + colors; out of scope indefinitely. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 0e1d6ff..b0af0d4 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -475,10 +475,13 @@ capability-specific output. Protocols that change emitted bytes MUST add their own renderer section, capability evidence, invalidation rules, and tests in a focused feature specification. -In particular, color encoding, synchronized-output wrapping, pointer-shape -output, Kitty keyboard mode setup, and Kitty graphics emission are deferred to -their respective follow-up PRs. The renderer continues to emit its existing -hardcoded ANSI output until one of those specifications is adopted. +In particular, synchronized-output wrapping, pointer-shape output, Kitty +keyboard mode setup, and Kitty graphics emission are deferred to their +respective follow-up PRs. The renderer continues to emit its existing hardcoded +ANSI output until one of those specifications is adopted. Color encoding is the +exception: it is specified in the +[Color Encoding Specification](color-encoding-spec.md), which the renderer +follows once adopted. --- @@ -504,7 +507,9 @@ parameters specify the terminal dimensions in character cells. The optional `terminfo` value (from `detectTerminal()`; see [Terminfo Specification](terminfo-spec.md) §10.1) initializes the Term's private `RuntimeCapabilities` from the static `Capabilities` it carries. When omitted, -the Term uses the §7.1 baseline. +the Term uses the §7.1 baseline. The renderer resolves its color encoding from +this capability snapshot; the derivation rule and downmapping behavior are +specified in the [Color Encoding Specification](color-encoding-spec.md). ### 8.2 Render invocation diff --git a/src/clayterm.c b/src/clayterm.c index 7e4e62e..80848a0 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -70,6 +70,15 @@ struct Clayterm { Buffer out; uint32_t lastfg, lastbg; int lastx, lasty; + /* Color capability evidence pushed by the host (color-encoding-spec + * §4.2). Authoritative for emission; the host re-pushes after any + * re-init, so the values survive resize updates. */ + uint32_t colors; /* max_colors evidence; §7.1 baseline is 256 */ + int truecolor; /* positive truecolor evidence */ + /* Emission tier, resolved once per reduce. last_tier detects tier + * changes, which force a complete redraw (color-encoding-spec CI-5). */ + int tier; + int last_tier; /* clip region (active top mirrored here so setcell stays unchanged) */ int clipx, clipy, clipw, cliph; int clipping; @@ -179,8 +188,169 @@ static void append_combining(struct Clayterm *ct, int x, int y, uint32_t cp) { report_combining_exceeded(ct); } +/* ── Color tiers and downmapping ──────────────────────────────────── */ + +/* Emission tiers (color-encoding-spec §6.1). */ +#define TIER_TRUECOLOR 0 +#define TIER_256 1 +#define TIER_16 2 + +/* Nominal 16-color palette (color-encoding-spec §6.3.1): the renderer's + * canonical model of what each 4-bit index means for downmapping. Pure + * primaries for 0-7, pastel brights for 8-15. Not a claim about any + * terminal's actual palette. */ +static const uint32_t NOMINAL_PALETTE[16] = { + 0x000000, 0xff0000, 0x00ff00, 0xffff00, 0x0000ff, 0xff00ff, + 0x00ffff, 0xc0c0c0, 0x808080, 0xff5555, 0x55ff55, 0xffff55, + 0x5555ff, 0xff55ff, 0x55ffff, 0xffffff, +}; + +/* 6x6x6 color cube channel values (indices 16-231). */ +static const int CUBE_STEPS[6] = {0, 95, 135, 175, 215, 255}; + +/* Resolve the emission tier from the capability evidence pushed by the + * host (color-encoding-spec §6.1). The colors <= 16 boundary matches the + * colordepth denial mapping in terminfo-spec §6.3. */ +static int resolve_tier(const struct Clayterm *ct) { + if (ct->truecolor) + return TIER_TRUECOLOR; + if (ct->colors <= 16) + return TIER_16; + return TIER_256; +} + +/* Nearest nominal palette entry by squared Euclidean distance; ties + * resolve to the lowest index (color-encoding-spec §6.3.2). */ +static int map_16(uint32_t rgb) { + int r = (rgb >> 16) & 0xff; + int g = (rgb >> 8) & 0xff; + int b = rgb & 0xff; + int best = 0; + int best_dist = -1; + for (int i = 0; i < 16; i++) { + uint32_t c = NOMINAL_PALETTE[i]; + int dr = r - (int)((c >> 16) & 0xff); + int dg = g - (int)((c >> 8) & 0xff); + int db = b - (int)(c & 0xff); + int dist = dr * dr + dg * dg + db * db; + if (best_dist < 0 || dist < best_dist) { + best_dist = dist; + best = i; + } + } + return best; +} + +/* The cube step for an exact cube value, or -1 when the value is not a + * cube channel value. */ +static int cube_exact(int v) { + for (int i = 0; i < 6; i++) { + if (CUBE_STEPS[i] == v) + return i; + } + return -1; +} + +/* Nearest cube step for an arbitrary channel value; ties resolve to the + * smaller value (color-encoding-spec §6.3.3 rule 4). */ +static int cube_step(int v) { + int best = 0; + int best_dist = -1; + for (int i = 0; i < 6; i++) { + int d = v - CUBE_STEPS[i]; + if (d < 0) + d = -d; + if (best_dist < 0 || d < best_dist) { + best_dist = d; + best = i; + } + } + return best; +} + +/* Downmap a 24-bit RGB value to a 256-palette index. Ordered rules; + * the first match wins (color-encoding-spec §6.3.3). Rules 1-3 make + * every 256-palette entry resolve to an index of the identical color + * (CI-4; the nominal index wins where the palettes overlap). */ +static int map_256(uint32_t rgb) { + int r = (rgb >> 16) & 0xff; + int g = (rgb >> 8) & 0xff; + int b = rgb & 0xff; + + /* 1. exact nominal match: standard colors keep their 4-bit indices */ + for (int i = 0; i < 16; i++) { + if (NOMINAL_PALETTE[i] == rgb) + return i; + } + + /* 2. exact cube match */ + int cr = cube_exact(r); + int cg = cube_exact(g); + int cb = cube_exact(b); + if (cr >= 0 && cg >= 0 && cb >= 0) + return 16 + 36 * cr + 6 * cg + cb; + + /* 3. grayscale: nearest of the ramp extended by both endpoints. + * Candidates run in ascending index order (16, 231, 232..255), so a + * strict comparison resolves ties to the lowest index. */ + if (r == g && g == b) { + int best = 16; + int best_dist = r; /* value 0 at index 16 */ + int d = 255 - r; /* value 255 at index 231 */ + if (d < best_dist) { + best = 231; + best_dist = d; + } + for (int i = 0; i < 24; i++) { + int val = 8 + 10 * i; + d = r - val; + if (d < 0) + d = -d; + if (d < best_dist) { + best = 232 + i; + best_dist = d; + } + } + return best; + } + + /* 4. nearest cube value per channel */ + return 16 + 36 * cube_step(r) + 6 * cube_step(g) + cube_step(b); +} + /* ── Escape sequence generation ───────────────────────────────────── */ +/* Emit the SGR color sequence for one packed RGB value under the active + * tier (color-encoding-spec §6.2). `is_fg` selects the foreground + * (38, 3x/9x) or background (48, 4x/10x) code family. */ +static void emit_sgr_color(struct Clayterm *ct, uint32_t rgb, int is_fg) { + switch (ct->tier) { + case TIER_16: { + int idx = map_16(rgb); + int base = is_fg ? 30 : 40; + if (idx >= 8) + base += 60; /* aixterm bright codes: 90-97 / 100-107 */ + buf_str(&ct->out, "\x1b["); + buf_num(&ct->out, base + (idx & 7)); + buf_put(&ct->out, "m", 1); + break; + } + case TIER_256: + buf_str(&ct->out, is_fg ? "\x1b[38;5;" : "\x1b[48;5;"); + buf_num(&ct->out, map_256(rgb)); + buf_put(&ct->out, "m", 1); + break; + default: /* TIER_TRUECOLOR */ + buf_str(&ct->out, is_fg ? "\x1b[38;2;" : "\x1b[48;2;"); + buf_num(&ct->out, (rgb >> 16) & 0xff); + buf_put(&ct->out, ";", 1); + buf_num(&ct->out, (rgb >> 8) & 0xff); + buf_put(&ct->out, ";", 1); + buf_num(&ct->out, rgb & 0xff); + 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; @@ -204,27 +374,13 @@ 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)) { - buf_str(&ct->out, "\x1b[38;2;"); - buf_num(&ct->out, (fg >> 16) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, (fg >> 8) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, fg & 0xff); - buf_put(&ct->out, "m", 1); - } + /* foreground */ + if (!(fg & ATTR_DEFAULT)) + emit_sgr_color(ct, fg & COLOR_MASK, 1); - /* background truecolor */ - if (!(bg & ATTR_DEFAULT)) { - buf_str(&ct->out, "\x1b[48;2;"); - buf_num(&ct->out, (bg >> 16) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, (bg >> 8) & 0xff); - buf_put(&ct->out, ";", 1); - buf_num(&ct->out, bg & 0xff); - buf_put(&ct->out, "m", 1); - } + /* background */ + if (!(bg & ATTR_DEFAULT)) + emit_sgr_color(ct, bg & COLOR_MASK, 0); ct->lastfg = fg; ct->lastbg = bg; @@ -681,6 +837,15 @@ int error_message_ptr(struct Clayterm *ct, int index) { return (int)ct->errors[index].errorText.chars; } +/* Push host-held color capability evidence into the instance + * (color-encoding-spec §4.2). Called once after init and after update() + * folds capability events that change either value; re-pushed by the + * host after any re-init so the values survive resize updates. */ +void set_capabilities(struct Clayterm *ct, uint32_t colors, int truecolor) { + ct->colors = colors; + ct->truecolor = truecolor ? 1 : 0; +} + struct Clayterm *init(void *mem, int w, int h) { set_clay_capacity(w, h); struct Clayterm *ct = (struct Clayterm *)mem; @@ -706,6 +871,8 @@ struct Clayterm *init(void *mem, int w, int h) { .lastbg = 0xffffffff, .lastx = -1, .lasty = -1, + .colors = 256, /* §7.1 baseline until the host pushes evidence */ + .last_tier = -1, }; // initialize back buffer with spaces and default fg/bg @@ -1024,6 +1191,17 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, } } + /* Resolve the emission tier for this frame and force a complete + * redraw when it changed (color-encoding-spec CI-5): diff state is + * cell-based, so unchanged cells would otherwise keep bytes from the + * previous encoding on screen. Zeroing the front buffer makes every + * cell diff as changed. */ + ct->tier = resolve_tier(ct); + if (ct->tier != ct->last_tier) { + cells_fill(ct->front, ct->w, ct->h, 0, 0, 0); + ct->last_tier = ct->tier; + } + if (mode == 1) { present_lines(ct); } else { diff --git a/src/clayterm.h b/src/clayterm.h index 8a24db4..531356f 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -12,6 +12,7 @@ struct Clayterm; /* WASM exports */ int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); +void set_capabilities(struct Clayterm *ct, uint32_t colors, int truecolor); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime); char *output(struct Clayterm *ct); diff --git a/term-native.ts b/term-native.ts index b54549d..f4e7b7f 100644 --- a/term-native.ts +++ b/term-native.ts @@ -25,16 +25,32 @@ const CLAY_DEFAULT_MAX_ELEMENT_COUNT = 8192; // and snapshot payload bytes live in TEXT_TRANSFER_BUFFER_BYTES. const MAX_FIXED_ELEMENT_WIRE_BYTES = 116; +/** Color capability evidence the renderer consumes for emission + * (color-encoding-spec §4.2). */ +export interface ColorCaps { + colors: number; + trueColor: boolean; +} + export interface Native { memory: WebAssembly.Memory; statePtr: number; opsBuf: number; + /** The color capability evidence currently held by the renderer. */ + readonly caps: ColorCaps; /** * Re-initialize renderer state for new dimensions in place * (renderer-spec 7.7). Reuses this instance and memory; statePtr and * opsBuf may change. Growing memory detaches prior buffer views. */ update(w: number, h: number): void; + /** + * Push color capability evidence into the renderer. Called by the + * host after update() folds capability events that change either + * value (color-encoding-spec §4.2); re-pushed internally after any + * re-init so the values survive resize updates. + */ + setCapabilities(colors: number, trueColor: boolean): void; reduce( ct: number, buf: number, @@ -59,6 +75,7 @@ import { compiled } from "./wasm.ts"; export async function createTermNative( w: number, h: number, + caps: ColorCaps, ): Promise { let memory = new WebAssembly.Memory({ initial: 2 }); let exports: Record = {}; @@ -102,6 +119,7 @@ export async function createTermNative( ): void; output(ct: number): number; length(ct: number): number; + set_capabilities(ct: number, colors: number, truecolor: number): void; Clay_SetPointerState(vec: number, down: number): void; pointer_over_count(): number; pointer_over_id_string_length(index: number): number; @@ -123,6 +141,19 @@ export async function createTermNative( let statePtr!: number; let opsBuf = 0; + // Current color capability evidence. init() zeroes instance state, so + // the values are re-pushed after every (re)init — this is what makes + // the evidence survive resize updates (color-encoding-spec §4.2). + let currentCaps: ColorCaps = caps; + + function pushCaps(): void { + ct.set_capabilities( + statePtr, + currentCaps.colors, + currentCaps.trueColor ? 1 : 0, + ); + } + // Renderer state and the fixed transfer buffer share linear memory as // [heap: state][opsBuf]; opsBuf moves when the state size changes. Memory is // grown to fit but never reclaimed, so a downsize keeps the high-water mark @@ -138,6 +169,7 @@ export async function createTermNative( } statePtr = ct.init(heap, lw, lh); opsBuf = (heap + size + 3) & ~3; + pushCaps(); } layout(w, h); @@ -149,9 +181,16 @@ export async function createTermNative( get opsBuf() { return opsBuf; }, + get caps(): ColorCaps { + return currentCaps; + }, update(uw: number, uh: number): void { layout(uw, uh); }, + setCapabilities(colors: number, trueColor: boolean): void { + currentCaps = { colors, trueColor }; + pushCaps(); + }, reduce: ct.reduce, output: ct.output, length: ct.length, diff --git a/term.ts b/term.ts index 5326daa..8d71a77 100644 --- a/term.ts +++ b/term.ts @@ -164,23 +164,23 @@ export interface Term { export async function createTerm(options: TermOptions): Promise { let { width, height, terminfo } = options; - let native = await createTermNative( - width, - height, - ); + let seed = terminfo?.capabilities ?? { + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + }; + + let native = await createTermNative(width, height, { + colors: seed.colors, + trueColor: seed.trueColor, + }); let { memory } = native; - let currentCaps: RuntimeCapabilities = runtimeFromStatic( - terminfo?.capabilities ?? { - colors: 256, - trueColor: false, - bce: true, - autoMargin: true, - xenl: true, - altScreen: true, - styledUnderline: false, - }, - ); + let currentCaps: RuntimeCapabilities = runtimeFromStatic(seed); let prev = new Set(); let pressed = new Set(); @@ -310,6 +310,16 @@ export async function createTerm(options: TermOptions): Promise { } currentCaps = next; + // Folded evidence changed the renderer's color capabilities: + // push it so the next render resolves the new tier + // (color-encoding-spec §4.2). The tier change itself forces a + // complete redraw on the next transaction (§6.4). + if ( + next.colors !== native.caps.colors || + next.trueColor !== native.caps.trueColor + ) { + native.setCapabilities(next.colors, next.trueColor); + } if (bytes.length) out.push(bytes); } diff --git a/test/border.test.ts b/test/border.test.ts index 2e62f13..6ca3a9c 100644 --- a/test/border.test.ts +++ b/test/border.test.ts @@ -8,6 +8,7 @@ import { text, } from "../ops.ts"; import { createTerm } from "../term.ts"; +import { TRUECOLOR_INFO } from "./caps.ts"; import { describe, expect, it } from "./suite.ts"; import { print } from "./print.ts"; @@ -116,7 +117,11 @@ type OpenProps = Omit; * mode and parses the full-frame output into cells. Box corners are at * (0,0), (7,0), (0,3), (7,3). */ async function renderBox(props: OpenProps): Promise { - let term = await createTerm({ width: 12, height: 5 }); + let term = await createTerm({ + width: 12, + height: 5, + terminfo: TRUECOLOR_INFO, + }); let ansi = decode( term.render([ open("box", { @@ -265,7 +270,11 @@ describe("structured sides", () => { }); it("does not retain a prior frame's side bg", async () => { - let term = await createTerm({ width: 12, height: 5 }); + let term = await createTerm({ + width: 12, + height: 5, + terminfo: TRUECOLOR_INFO, + }); let frame = (bg?: number) => [ open("box", { layout: { width: fixed(8), height: fixed(4) }, @@ -451,8 +460,16 @@ describe("directive model", () => { describe("instances", () => { it("does not share side attributes between Term instances", async () => { - let a = await createTerm({ width: 12, height: 5 }); - let b = await createTerm({ width: 12, height: 5 }); + let a = await createTerm({ + width: 12, + height: 5, + terminfo: TRUECOLOR_INFO, + }); + let b = await createTerm({ + width: 12, + height: 5, + terminfo: TRUECOLOR_INFO, + }); let frame = (top: number, bottom: number) => [ open("box", { @@ -491,7 +508,11 @@ const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); describe("box model", () => { it("full border with no padding reserves space: children visible, box is 3 rows", async () => { - let term = await createTerm({ width: 20, height: 10 }); + let term = await createTerm({ + width: 20, + height: 10, + terminfo: TRUECOLOR_INFO, + }); let result = term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, @@ -510,7 +531,11 @@ describe("box model", () => { }); it("partial borders (top+left) reserve only their sides", async () => { - let term = await createTerm({ width: 20, height: 10 }); + let term = await createTerm({ + width: 20, + height: 10, + terminfo: TRUECOLOR_INFO, + }); let result = term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, @@ -530,7 +555,11 @@ describe("box model", () => { }); it("padding is additive: border=1 alone gives height 3; border=1 plus padding=1 gives height 5", async () => { - let nopad = await createTerm({ width: 20, height: 10 }); + let nopad = await createTerm({ + width: 20, + height: 10, + terminfo: TRUECOLOR_INFO, + }); let r1 = nopad.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, @@ -544,7 +573,11 @@ describe("box model", () => { close(), ]); - let withpad = await createTerm({ width: 20, height: 10 }); + let withpad = await createTerm({ + width: 20, + height: 10, + terminfo: TRUECOLOR_INFO, + }); let r2 = withpad.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, @@ -569,7 +602,11 @@ describe("box model", () => { }); it("explicit padding > border width adds breathing room inside the border", async () => { - let term = await createTerm({ width: 20, height: 10 }); + let term = await createTerm({ + width: 20, + height: 10, + terminfo: TRUECOLOR_INFO, + }); let result = term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, @@ -593,7 +630,11 @@ describe("box model", () => { }); it("nested two-tone bevel lays out without manual padding compensation", async () => { - let term = await createTerm({ width: 20, height: 10 }); + let term = await createTerm({ + width: 20, + height: 10, + terminfo: TRUECOLOR_INFO, + }); let result = term.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, @@ -619,7 +660,11 @@ describe("box model", () => { }); it("visual: full border renders border glyphs around content", async () => { - let term = await createTerm({ width: 20, height: 10 }); + let term = await createTerm({ + width: 20, + height: 10, + terminfo: TRUECOLOR_INFO, + }); let out = trim( print( decode( diff --git a/test/caps.ts b/test/caps.ts index ef81681..d4f1915 100644 --- a/test/caps.ts +++ b/test/caps.ts @@ -1,4 +1,8 @@ -import { type DetectOptions, detectTerminal } from "../terminfo.ts"; +import { + type DetectOptions, + detectTerminal, + type TerminalInfo, +} from "../terminfo.ts"; export function offlineDetect(options: Partial = {}) { return detectTerminal({ env: {}, ...options }); @@ -8,3 +12,21 @@ export function offlineDetect(options: Partial = {}) { export function trueColorDetect() { return offlineDetect({ env: { COLORTERM: "truecolor" } }); } + +/** Static truecolor evidence with no probe or key payload. Tests that pin + * truecolor byte sequences pass this at createTerm to select the truecolor + * tier (color-encoding-spec §8): without evidence the renderer resolves the + * 256-color baseline tier. */ +export const TRUECOLOR_INFO: TerminalInfo = Object.freeze({ + capabilities: Object.freeze({ + colors: 256, + trueColor: true, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + }), + probe: new Uint8Array(0), + keys: new Uint8Array(0), +}); diff --git a/test/color-encoding-modes.test.ts b/test/color-encoding-modes.test.ts new file mode 100644 index 0000000..511941e --- /dev/null +++ b/test/color-encoding-modes.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it } from "./suite.ts"; +import { createTerm } from "../term.ts"; +import type { InputEvent } from "../input.ts"; +import type { TerminalInfo } from "../terminfo.ts"; +import { close, fixed, grow, type Op, open, rgba, text } from "../ops.ts"; + +const decode = (b: Uint8Array) => new TextDecoder().decode(b); + +/** Static capability evidence with a chosen `colors` value and optional + * truecolor grant. The renderer resolves its emission tier from these + * fields alone (color-encoding-spec §6.1). */ +function info(colors: number, trueColor = false): TerminalInfo { + return Object.freeze({ + capabilities: Object.freeze({ + colors, + trueColor, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + }), + probe: new Uint8Array(0), + keys: new Uint8Array(0), + }); +} + +/** Frame: one full-screen text node carrying the given color/bg. */ +function coloredText( + color?: number, + bg?: number, +): Op[] { + return [ + open("root", { layout: { width: grow(), height: grow() } }), + text("X", { color, bg }), + close(), + ]; +} + +/** The SGR bytes emitted before the glyph. */ +function beforeGlyph(ansi: string): string { + return ansi.slice(0, ansi.indexOf("X")); +} + +describe("color encoding modes", () => { + describe("16-color tier", () => { + it("downgrades standard red foreground to the 4-bit code", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(16) }); + let ansi = decode(term.render(coloredText(rgba(255, 0, 0))).output); + + let before = beforeGlyph(ansi); + // pins: standard-palette red maps to 4-bit \x1b[31m + expect(before).toContain("\x1b[31m"); + // pins: the 24-bit sequence must not survive the downgrade + expect(before).not.toContain("\x1b[38;2;255;0;0"); + }); + + it("downgrades standard red background to the 4-bit code", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(16) }); + let ansi = decode( + term.render(coloredText(undefined, rgba(255, 0, 0))).output, + ); + + let before = beforeGlyph(ansi); + expect(before).toContain("\x1b[41m"); + expect(before).not.toContain("\x1b[48;2;255;0;0"); + }); + + it("maps bright palette colors to aixterm codes", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(16) }); + let ansi = decode(term.render(coloredText(rgba(255, 85, 85))).output); + + // #ff5555 is nominal index 9 → bright red foreground + expect(beforeGlyph(ansi)).toContain("\x1b[91m"); + }); + + it("maps non-palette colors to the nearest nominal entry", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(16) }); + // #aa0000 is nearer nominal red (index 1) than any other entry + let ansi = decode(term.render(coloredText(rgba(170, 0, 0))).output); + + expect(beforeGlyph(ansi)).toContain("\x1b[31m"); + }); + }); + + describe("256-color tier", () => { + it("downgrades standard red foreground to its palette index", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(256) }); + let ansi = decode(term.render(coloredText(rgba(255, 0, 0))).output); + + // pins: standard-palette red maps to index 1 + expect(beforeGlyph(ansi)).toContain("\x1b[38;5;1m"); + expect(beforeGlyph(ansi)).not.toContain("\x1b[38;2;255;0;0"); + }); + + it("downgrades standard red background to its palette index", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(256) }); + let ansi = decode( + term.render(coloredText(undefined, rgba(255, 0, 0))).output, + ); + + expect(beforeGlyph(ansi)).toContain("\x1b[48;5;1m"); + expect(beforeGlyph(ansi)).not.toContain("\x1b[48;2;255;0;0"); + }); + + it("round-trips exact cube colors", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(256) }); + // #5f5f5f is cube (1,1,1) → index 16 + 36 + 6 + 1 = 59 + let ansi = decode(term.render(coloredText(rgba(95, 95, 95))).output); + + expect(beforeGlyph(ansi)).toContain("\x1b[38;5;59m"); + }); + + it("maps equal-channel grays onto the grayscale ramp", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(256) }); + // (100,100,100): nearest ramp value is 98 (i=9) → index 241 + let ansi = decode(term.render(coloredText(rgba(100, 100, 100))).output); + + expect(beforeGlyph(ansi)).toContain("\x1b[38;5;241m"); + }); + + it("maps non-palette colors to the nearest cube value per channel", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(256) }); + // (250,10,10) → cube steps (5,0,0) → index 196 + let ansi = decode(term.render(coloredText(rgba(250, 10, 10))).output); + + expect(beforeGlyph(ansi)).toContain("\x1b[38;5;196m"); + }); + }); + + describe("truecolor tier", () => { + it("emits byte-identical historical sequences", async () => { + let term = await createTerm({ + width: 4, + height: 1, + terminfo: info(256, true), + }); + let ansi = decode( + term.render(coloredText(rgba(255, 0, 0), rgba(0, 0, 255))).output, + ); + + let before = beforeGlyph(ansi); + expect(before).toContain("\x1b[38;2;255;0;0m"); + expect(before).toContain("\x1b[48;2;0;0;255m"); + expect(before).not.toContain("38;5;"); + }); + }); + + describe("tier resolution", () => { + it("resolves the 256-color baseline without evidence", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let ansi = decode(term.render(coloredText(rgba(255, 0, 0))).output); + + expect(beforeGlyph(ansi)).toContain("\x1b[38;5;1m"); + }); + + it("resolves the 16-color tier at and below 16 colors", async () => { + for (let colors of [8, 16]) { + let term = await createTerm({ + width: 4, + height: 1, + terminfo: info(colors), + }); + let ansi = decode(term.render(coloredText(rgba(255, 0, 0))).output); + expect(beforeGlyph(ansi)).toContain("\x1b[31m"); + } + }); + + it("resolves the 256-color tier above 16 colors", async () => { + let term = await createTerm({ width: 4, height: 1, terminfo: info(17) }); + let ansi = decode(term.render(coloredText(rgba(255, 0, 0))).output); + + expect(beforeGlyph(ansi)).toContain("\x1b[38;5;1m"); + }); + + it("resolves the truecolor tier on a granted truecolor flag", async () => { + let term = await createTerm({ + width: 4, + height: 1, + terminfo: info(16, true), + }); + let ansi = decode(term.render(coloredText(rgba(255, 0, 0))).output); + + expect(beforeGlyph(ansi)).toContain("\x1b[38;2;255;0;0m"); + }); + }); + + describe("dynamic evidence", () => { + it("applies a colordepth event from the next render", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let ops = coloredText(rgba(255, 0, 0)); + + expect(decode(term.render(ops).output)).toContain("\x1b[38;5;1m"); + + // The update transaction itself emits no bytes (renderer-spec §7.7) + let bytes = term.update([ + { type: "capability", key: "colordepth", value: "truecolor" }, + ]); + expect(bytes.length).toBe(0); + + // The next render encodes in the new tier + expect(decode(term.render(ops).output)).toContain("\x1b[38;2;255;0;0m"); + }); + + it("redraws the complete frame after a tier change", async () => { + let term = await createTerm({ width: 4, height: 1 }); + let ops = coloredText(rgba(255, 0, 0)); + + term.render(ops); + // no redraw while the tier is stable + expect(term.render(ops).output.length).toBe(0); + + term.update([ + { type: "capability", key: "colordepth", value: "truecolor" }, + ]); + let out = term.render(ops).output; + // CI-5: the frame after a tier change is a complete redraw + expect(out.length).toBeGreaterThan(0); + expect(decode(out)).toContain("X"); + + // the tier has settled: diffing resumes + expect(term.render(ops).output.length).toBe(0); + }); + + it("downgrades on a colordepth denial event", async () => { + let term = await createTerm({ + width: 4, + height: 1, + terminfo: info(256, true), + }); + let ops = coloredText(rgba(255, 0, 0)); + + expect(decode(term.render(ops).output)).toContain("\x1b[38;2;255;0;0m"); + + term.update([{ type: "capability", key: "colordepth", value: "256" }]); + expect(decode(term.render(ops).output)).toContain("\x1b[38;5;1m"); + }); + }); + + describe("default colors", () => { + it("emits no color SGR for default fg/bg in any tier", async () => { + for (let caps of [info(16), info(256), info(256, true)]) { + let term = await createTerm({ width: 4, height: 1, terminfo: caps }); + let ansi = decode(term.render(coloredText()).output); + + expect(ansi).toContain("X"); + expect(ansi).not.toMatch(/\x1b\[(3\d|4\d|9\d|10\d|38|48)/); + } + }); + }); + + describe("palette round-trip", () => { + it("maps every 256-palette entry to an index of the same color", async () => { + let NOMINAL = [ + 0x000000, + 0xff0000, + 0x00ff00, + 0xffff00, + 0x0000ff, + 0xff00ff, + 0x00ffff, + 0xc0c0c0, + 0x808080, + 0xff5555, + 0x55ff55, + 0xffff55, + 0x5555ff, + 0xff55ff, + 0x55ffff, + 0xffffff, + ]; + let CUBE = [0, 95, 135, 175, 215, 255]; + let rgbOf = (i: number): number => { + if (i < 16) return NOMINAL[i]; + if (i < 232) { + let n = i - 16; + return (CUBE[Math.floor(n / 36)] << 16) | + (CUBE[Math.floor(n / 6) % 6] << 8) | + CUBE[n % 6]; + } + let v = 8 + 10 * (i - 232); + return (v << 16) | (v << 8) | v; + }; + + // A 16x16 grid whose cell k carries palette color k. + let term = await createTerm({ width: 16, height: 16 }); + let ops: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + ]; + for (let row = 0; row < 16; row++) { + ops.push( + open(`row${row}`, { + layout: { width: grow(), height: fixed(1), direction: "ltr" }, + }), + ); + for (let col = 0; col < 16; col++) { + let rgb = rgbOf(row * 16 + col); + ops.push(text(".", { + bg: rgba((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff), + })); + } + ops.push(close()); + } + ops.push(close()); + + let ansi = decode(term.render(ops).output); + let indices = [...ansi.matchAll(/\x1b\[48;5;(\d+)m/g)].map((m) => + Number(m[1]) + ); + expect(indices.length).toBe(256); + for (let k = 0; k < 256; k++) { + // The emitted index must represent the identical color (CI-4). + expect(rgbOf(indices[k])).toBe(rgbOf(k)); + } + }); + }); +}); diff --git a/test/color.test.ts b/test/color.test.ts index 9741243..39713f0 100644 --- a/test/color.test.ts +++ b/test/color.test.ts @@ -1,5 +1,6 @@ import { close, fixed, grow, open, rgba, text } from "../ops.ts"; import { createTerm } from "../term.ts"; +import { TRUECOLOR_INFO } from "./caps.ts"; import { describe, expect, it } from "./suite.ts"; const decode = (b: Uint8Array) => new TextDecoder().decode(b); @@ -72,7 +73,11 @@ function firstCell(cells: Cell[], ch: string): Cell { describe("foreground", () => { it("emits uncolored text with no foreground", async () => { - let term = await createTerm({ width: 12, height: 1 }); + let term = await createTerm({ + width: 12, + height: 1, + terminfo: TRUECOLOR_INFO, + }); let ansi = decode(term.render([text("hi")]).output); expect(ansi).toContain("hi"); @@ -82,7 +87,11 @@ describe("foreground", () => { describe("background", () => { it("fills border cells with the requested border-level bg", async () => { - let term = await createTerm({ width: 12, height: 4 }); + let term = await createTerm({ + width: 12, + height: 4, + terminfo: TRUECOLOR_INFO, + }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -112,7 +121,11 @@ describe("background", () => { }); it("leaves existing border-cell bg unchanged when border bg is omitted", async () => { - let term = await createTerm({ width: 12, height: 4 }); + let term = await createTerm({ + width: 12, + height: 4, + terminfo: TRUECOLOR_INFO, + }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -140,7 +153,11 @@ describe("background", () => { }); it("fills glyph cells with the requested text-level bg", async () => { - let term = await createTerm({ width: 20, height: 1 }); + let term = await createTerm({ + width: 20, + height: 1, + terminfo: TRUECOLOR_INFO, + }); let bg = randomBgColor(); let ansi = decode( term.render([ @@ -155,7 +172,11 @@ describe("background", () => { }); it("resets border bg on subsequent frames without border bg", async () => { - let term = await createTerm({ width: 12, height: 4 }); + let term = await createTerm({ + width: 12, + height: 4, + terminfo: TRUECOLOR_INFO, + }); let bg = randomBgColor(); // Frame 1: border with bg @@ -198,7 +219,11 @@ describe("background", () => { }); it("resets the background before writing trailing cells", async () => { - let term = await createTerm({ width: 20, height: 1 }); + let term = await createTerm({ + width: 20, + height: 1, + terminfo: TRUECOLOR_INFO, + }); let bg = randomBgColor(); let ansi = decode( term.render([ diff --git a/test/term.test.ts b/test/term.test.ts index 7190fd6..93809a0 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -13,7 +13,7 @@ import { text, } from "../ops.ts"; import { print } from "./print.ts"; -import { trueColorDetect } from "./caps.ts"; +import { TRUECOLOR_INFO, trueColorDetect } from "./caps.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); @@ -43,9 +43,16 @@ describe("term", () => { expect(out).toContain("Hello, World!"); }); - it("text inherits parent background", () => { + it("text inherits parent background", async () => { + // Truecolor evidence selects the truecolor tier (color-encoding-spec + // §6.1); this test pins truecolor background bytes. + let local = await createTerm({ + width: 40, + height: 10, + terminfo: TRUECOLOR_INFO, + }); let ansi = decode( - term.render([ + local.render([ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, bg: rgba(255, 0, 0), diff --git a/test/width.test.ts b/test/width.test.ts index 1c54399..267ca64 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -57,7 +57,10 @@ describe("width", () => { }); it("replaces surrogate codepoints from malformed UTF-8 with U+FFFD", async () => { - let native = await createTermNative(10, 3); + let native = await createTermNative(10, 3, { + colors: 256, + trueColor: false, + }); // TextEncoder never produces surrogate bytes, so pack a placeholder // (U+1234 = E1 88 B4) and patch it to CESU-8 U+D800 (ED A0 80) to From 23559eef8155f5a4378fc2d535c55b41f24be851 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 20:13:45 -0500 Subject: [PATCH 2/2] fix: deno lint in the color-encoding test file CI's lint job flagged the unused InputEvent import and the two control-character regexes (the same no-control-regex suppression term.test.ts uses). Local verify runs fmt only, which is why lint slipped; deno lint is now clean on this branch. --- test/color-encoding-modes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/color-encoding-modes.test.ts b/test/color-encoding-modes.test.ts index 511941e..7b11bd7 100644 --- a/test/color-encoding-modes.test.ts +++ b/test/color-encoding-modes.test.ts @@ -1,6 +1,6 @@ +// deno-lint-ignore-file no-control-regex import { describe, expect, it } from "./suite.ts"; import { createTerm } from "../term.ts"; -import type { InputEvent } from "../input.ts"; import type { TerminalInfo } from "../terminfo.ts"; import { close, fixed, grow, type Op, open, rgba, text } from "../ops.ts";