@promptctl/rich-js 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +11 -8
  2. package/dist/core/box.d.ts +3 -1
  3. package/dist/core/box.js +6 -13
  4. package/dist/core/cells.d.ts +61 -28
  5. package/dist/core/cells.js +171 -124
  6. package/dist/core/clock.d.ts +48 -0
  7. package/dist/core/clock.js +42 -0
  8. package/dist/core/color.d.ts +99 -18
  9. package/dist/core/color.js +334 -146
  10. package/dist/core/console.d.ts +38 -7
  11. package/dist/core/console.js +159 -90
  12. package/dist/core/easing.d.ts +86 -0
  13. package/dist/core/easing.js +171 -0
  14. package/dist/core/env.d.ts +16 -0
  15. package/dist/core/env.js +16 -0
  16. package/dist/core/export-html.js +1 -4
  17. package/dist/core/export-lines.d.ts +22 -13
  18. package/dist/core/export-lines.js +37 -19
  19. package/dist/core/export-svg.d.ts +49 -0
  20. package/dist/core/export-svg.js +213 -0
  21. package/dist/core/fnv1a.d.ts +10 -0
  22. package/dist/core/fnv1a.js +17 -0
  23. package/dist/core/highlighter.d.ts +2 -2
  24. package/dist/core/highlighter.js +113 -47
  25. package/dist/core/json.js +1 -1
  26. package/dist/core/markup.d.ts +28 -7
  27. package/dist/core/markup.js +235 -312
  28. package/dist/core/measure.d.ts +3 -3
  29. package/dist/core/measure.js +12 -7
  30. package/dist/core/memo.d.ts +19 -0
  31. package/dist/core/memo.js +34 -0
  32. package/dist/core/noise.d.ts +31 -0
  33. package/dist/core/noise.js +94 -0
  34. package/dist/core/osc8.d.ts +6 -6
  35. package/dist/core/osc8.js +9 -21
  36. package/dist/core/paint.d.ts +107 -0
  37. package/dist/core/paint.js +161 -0
  38. package/dist/core/place.d.ts +1 -1
  39. package/dist/core/place.js +4 -6
  40. package/dist/core/pretty.d.ts +126 -2
  41. package/dist/core/pretty.js +571 -105
  42. package/dist/core/protocol.d.ts +43 -3
  43. package/dist/core/protocol.js +23 -2
  44. package/dist/core/render.d.ts +33 -30
  45. package/dist/core/render.js +43 -83
  46. package/dist/core/segment.d.ts +6 -5
  47. package/dist/core/segment.js +11 -15
  48. package/dist/core/selection.d.ts +12 -0
  49. package/dist/core/selection.js +14 -0
  50. package/dist/core/strip.d.ts +116 -74
  51. package/dist/core/strip.js +266 -217
  52. package/dist/core/style.d.ts +27 -7
  53. package/dist/core/style.js +106 -41
  54. package/dist/core/text.d.ts +43 -16
  55. package/dist/core/text.js +180 -59
  56. package/dist/core/wrap.d.ts +5 -0
  57. package/dist/core/wrap.js +16 -3
  58. package/dist/host/app.d.ts +95 -17
  59. package/dist/host/app.js +201 -61
  60. package/dist/host/index.d.ts +4 -1
  61. package/dist/host/index.js +1 -0
  62. package/dist/host/input.d.ts +84 -0
  63. package/dist/host/input.js +498 -0
  64. package/dist/host/terminal-host.d.ts +15 -2
  65. package/dist/host/terminal-host.js +2 -0
  66. package/dist/index.d.ts +26 -14
  67. package/dist/index.js +17 -7
  68. package/dist/node/prompt.d.ts +13 -0
  69. package/dist/node/prompt.js +30 -9
  70. package/dist/node/save.d.ts +4 -3
  71. package/dist/node/save.js +6 -3
  72. package/dist/node/terminal-host.d.ts +10 -2
  73. package/dist/node/terminal-host.js +7 -0
  74. package/dist/node/traceback.d.ts +2 -1
  75. package/dist/node/traceback.js +3 -39
  76. package/dist/renderables/align.js +2 -9
  77. package/dist/renderables/columns.d.ts +45 -29
  78. package/dist/renderables/columns.js +142 -103
  79. package/dist/renderables/constrain.js +6 -12
  80. package/dist/renderables/effect.d.ts +113 -0
  81. package/dist/renderables/effect.js +182 -0
  82. package/dist/renderables/effects.d.ts +252 -0
  83. package/dist/renderables/effects.js +565 -0
  84. package/dist/renderables/embed.d.ts +94 -31
  85. package/dist/renderables/embed.js +141 -45
  86. package/dist/renderables/group.d.ts +6 -3
  87. package/dist/renderables/group.js +5 -0
  88. package/dist/renderables/layout.d.ts +6 -23
  89. package/dist/renderables/layout.js +24 -113
  90. package/dist/renderables/list-view.d.ts +42 -0
  91. package/dist/renderables/list-view.js +78 -0
  92. package/dist/renderables/live.d.ts +40 -16
  93. package/dist/renderables/live.js +96 -83
  94. package/dist/renderables/markdown.d.ts +16 -2
  95. package/dist/renderables/markdown.js +521 -118
  96. package/dist/renderables/padding.d.ts +22 -1
  97. package/dist/renderables/padding.js +30 -24
  98. package/dist/renderables/panel.d.ts +31 -33
  99. package/dist/renderables/panel.js +137 -141
  100. package/dist/renderables/progress.d.ts +110 -16
  101. package/dist/renderables/progress.js +212 -81
  102. package/dist/renderables/progressBar.d.ts +24 -4
  103. package/dist/renderables/progressBar.js +78 -23
  104. package/dist/renderables/prompt.d.ts +37 -15
  105. package/dist/renderables/prompt.js +125 -85
  106. package/dist/renderables/ratio.d.ts +123 -0
  107. package/dist/renderables/ratio.js +213 -0
  108. package/dist/renderables/rule.d.ts +3 -2
  109. package/dist/renderables/rule.js +50 -52
  110. package/dist/renderables/spinner.d.ts +28 -2
  111. package/dist/renderables/spinner.js +52 -19
  112. package/dist/renderables/status.d.ts +13 -7
  113. package/dist/renderables/status.js +15 -29
  114. package/dist/renderables/syntax.d.ts +37 -7
  115. package/dist/renderables/syntax.js +134 -83
  116. package/dist/renderables/table.d.ts +58 -20
  117. package/dist/renderables/table.js +373 -197
  118. package/dist/renderables/traceback.d.ts +5 -4
  119. package/dist/renderables/traceback.js +239 -68
  120. package/dist/renderables/tree.d.ts +10 -2
  121. package/dist/renderables/tree.js +61 -34
  122. package/dist/renderables/viewport.d.ts +30 -0
  123. package/dist/renderables/viewport.js +61 -7
  124. package/dist/template-bindings/color-funcs.d.ts +31 -9
  125. package/dist/template-bindings/color-funcs.js +40 -14
  126. package/dist/template-bindings/helpers.d.ts +4 -2
  127. package/dist/template-bindings/helpers.js +5 -20
  128. package/dist/template-bindings/index.d.ts +29 -17
  129. package/dist/template-bindings/index.js +36 -20
  130. package/dist/template-bindings/palette-funcs.d.ts +3 -1
  131. package/dist/template-bindings/palette-funcs.js +11 -7
  132. package/dist/template-bindings/style-funcs.js +4 -2
  133. package/dist/themes/buildPalette.d.ts +24 -3
  134. package/dist/themes/buildPalette.js +52 -8
  135. package/dist/themes/colorMath.d.ts +29 -17
  136. package/dist/themes/colorMath.js +74 -48
  137. package/dist/themes/colorRef.d.ts +2 -17
  138. package/dist/themes/colorRef.js +13 -31
  139. package/dist/themes/palette.d.ts +24 -0
  140. package/dist/themes/palette.js +46 -3
  141. package/dist/themes/ramp.d.ts +21 -34
  142. package/dist/themes/ramp.js +23 -42
  143. package/dist/themes/registry.js +39 -28
  144. package/dist/widgets/button.d.ts +2 -6
  145. package/dist/widgets/button.js +9 -40
  146. package/dist/widgets/checkbox.d.ts +2 -5
  147. package/dist/widgets/checkbox.js +9 -31
  148. package/dist/widgets/dropdown.d.ts +2 -5
  149. package/dist/widgets/dropdown.js +17 -51
  150. package/dist/widgets/event-router.d.ts +31 -40
  151. package/dist/widgets/event-router.js +52 -483
  152. package/dist/widgets/index.d.ts +2 -1
  153. package/dist/widgets/ink.d.ts +22 -0
  154. package/dist/widgets/ink.js +31 -0
  155. package/dist/widgets/slider.d.ts +2 -5
  156. package/dist/widgets/slider.js +11 -34
  157. package/dist/widgets/text-input.d.ts +19 -10
  158. package/dist/widgets/text-input.js +156 -147
  159. package/dist/widgets/themed-widget.d.ts +20 -0
  160. package/dist/widgets/themed-widget.js +89 -0
  161. package/dist/widgets/toggle.d.ts +2 -5
  162. package/dist/widgets/toggle.js +8 -31
  163. package/dist/widgets/types.d.ts +8 -25
  164. package/dist/widgets/types.js +8 -0
  165. package/dist/widgets/widget-app.d.ts +24 -7
  166. package/dist/widgets/widget-app.js +35 -18
  167. package/dist/widgets/widget-base.d.ts +2 -0
  168. package/dist/widgets/widget-base.js +9 -2
  169. package/package.json +31 -17
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Terminal color representation, parsing, downgrading, and ANSI code generation.
3
3
  */
4
+ import { Memo } from "./memo.js";
4
5
  // --- ColorRgba ---
5
6
  function hex2(byte) {
6
7
  return byte.toString(16).padStart(2, "0");
@@ -73,8 +74,9 @@ export class ColorRgba {
73
74
  /**
74
75
  * The surface a terminal draws a translucent colour over. A terminal cannot
75
76
  * know what lies under its cells, so the SGR writer (`Style.toSgrCodes`), the
76
- * strip's seam test and the contrast choosers (`themes/colorMath`) all
77
- * composite over this one colour. [LAW:one-source-of-truth] One constant, so
77
+ * exports that picture its output (`resolveLook`), the strip's seam test and
78
+ * the contrast choosers (`themes/colorMath`) all composite over this one
79
+ * colour. [LAW:one-source-of-truth] One constant, so
78
80
  * the colour text is chosen against is the colour the writer draws.
79
81
  */
80
82
  export const SURFACE_BLACK = new ColorRgba(0, 0, 0);
@@ -110,36 +112,27 @@ function luminanceRatio(la, lb) {
110
112
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
111
113
  }
112
114
  // --- ColorTable ---
113
- /**
114
- * An indexed palette: entry `i` of `colors` is terminal index `firstIndex + i`.
115
- * `firstIndex` lets a table hold only the part of a palette a downgrade may
116
- * choose (the 256-colour cube and grey ramp start at 16) while every index it
117
- * reports is the terminal's own.
118
- */
119
- // [LAW:single-enforcer] The one size policy for ColorTable's memos: a key is
120
- // derived from colours a long-running host computes without end (ramp stops,
121
- // mixes), so an unbounded map grows with every render. Clearing at the cap is
122
- // the policy `cellLen` already uses; a refill costs one table scan per key.
123
- const TABLE_CACHE_MAX = 4096;
124
- function remember(cache, key, index) {
125
- if (cache.size >= TABLE_CACHE_MAX)
126
- cache.clear();
127
- cache.set(key, index);
128
- return index;
129
- }
130
115
  // [LAW:one-source-of-truth] The one distance every table scan ranks by, so
131
- // `matchWhere`'s "nearest by the distance `match` uses" cannot drift.
132
- function rgbDistance(a, b) {
116
+ // `matchWhere`'s "nearest by the distance `match` uses" cannot drift, and
117
+ // `Effected` weighs a written colour against `ColorSpec.matchOn` by it too.
118
+ export function rgbDistance(a, b) {
133
119
  const dr = a.red - b.red;
134
120
  const dg = a.green - b.green;
135
121
  const db = a.blue - b.blue;
136
122
  return dr * dr + dg * dg + db * db;
137
123
  }
124
+ /**
125
+ * An indexed palette: entry `i` of `colors` is terminal index `firstIndex + i`.
126
+ * `firstIndex` lets a table hold only the part of a palette a downgrade may
127
+ * choose (the 256-colour cube and grey ramp start at 16) while every index it
128
+ * reports is the terminal's own.
129
+ */
138
130
  export class ColorTable {
139
131
  colors;
140
132
  firstIndex;
141
- matchCache = new Map();
142
- readableCache = new Map();
133
+ // Keyed by colours a host computes without end (ramp stops, mixes), so bounded.
134
+ matchMemo = new Memo();
135
+ readableMemo = new Memo();
143
136
  constructor(colors, firstIndex = 0) {
144
137
  this.colors = colors;
145
138
  this.firstIndex = firstIndex;
@@ -164,9 +157,9 @@ export class ColorTable {
164
157
  */
165
158
  match(value) {
166
159
  const key = `${value.red},${value.green},${value.blue},${value.alpha}`;
167
- const cached = this.matchCache.get(key);
168
- if (cached !== undefined)
169
- return cached;
160
+ return this.matchMemo.get(key, () => this.nearest(value));
161
+ }
162
+ nearest(value) {
170
163
  let bestIndex = 0;
171
164
  let bestDist = Infinity;
172
165
  for (let i = 0; i < this.colors.length; i++) {
@@ -177,7 +170,7 @@ export class ColorTable {
177
170
  bestIndex = i;
178
171
  }
179
172
  }
180
- return remember(this.matchCache, key, this.firstIndex + bestIndex);
173
+ return this.firstIndex + bestIndex;
181
174
  }
182
175
  /**
183
176
  * The nearest entry to `value` (the distance `match` uses) among those that
@@ -190,9 +183,9 @@ export class ColorTable {
190
183
  */
191
184
  matchReadable(value, on, minRatio) {
192
185
  const key = `${value.red},${value.green},${value.blue}|${on.red},${on.green},${on.blue}|${minRatio}`;
193
- const cached = this.readableCache.get(key);
194
- if (cached !== undefined)
195
- return cached;
186
+ return this.readableMemo.get(key, () => this.nearestReadable(value, on, minRatio));
187
+ }
188
+ nearestReadable(value, on, minRatio) {
196
189
  const lOn = relativeLuminance(on);
197
190
  let best = 0;
198
191
  let bestPasses = false;
@@ -212,7 +205,7 @@ export class ColorTable {
212
205
  bestScore = score;
213
206
  }
214
207
  }
215
- return remember(this.readableCache, key, this.firstIndex + best);
208
+ return this.firstIndex + best;
216
209
  }
217
210
  /**
218
211
  * The nearest entry to `value` (the distance `match` uses) among those
@@ -238,38 +231,62 @@ export var ColorDepth;
238
231
  ColorDepth[ColorDepth["TRUECOLOR"] = 3] = "TRUECOLOR";
239
232
  ColorDepth[ColorDepth["WINDOWS"] = 4] = "WINDOWS";
240
233
  })(ColorDepth || (ColorDepth = {}));
241
- // [LAW:one-source-of-truth] One table covers every recognized string form
242
- // that resolves to a ColorDepth: user-facing CLI/config specs, FORCE_COLOR
243
- // values (the chalk/supports-color convention), TERM_PROGRAM identifiers,
244
- // and exact TERM names whose color capability is known. Keys are disjoint
245
- // across these sources, so one table is honest. `null` means "no color".
246
- // "auto" is intentionally absent — it routes to env detection.
247
- const STRING_TO_DEPTH = {
248
- // CLI/config specs
234
+ /**
235
+ * How many colours a depth can name, as a rank. WINDOWS is a sixteen-colour
236
+ * depth whose enum value sits above TRUECOLOR, so the enum's order is not this.
237
+ * STANDARD and WINDOWS share a rank: the same sixteen slots, drawn by
238
+ * different terminals, so each converts to the other by keeping the slot.
239
+ */
240
+ function fidelity(depth) {
241
+ switch (depth) {
242
+ case ColorDepth.DEFAULT:
243
+ return 0;
244
+ case ColorDepth.STANDARD:
245
+ case ColorDepth.WINDOWS:
246
+ return 1;
247
+ case ColorDepth.EIGHT_BIT:
248
+ return 2;
249
+ case ColorDepth.TRUECOLOR:
250
+ return 3;
251
+ }
252
+ }
253
+ // The depths a colour system is named for. Its keys and "auto" are the whole
254
+ // vocabulary: `ColorSystemName` is derived from them, so the type and the
255
+ // table cannot disagree. [LAW:one-source-of-truth]
256
+ const COLOR_SYSTEM_DEPTHS = {
249
257
  truecolor: ColorDepth.TRUECOLOR,
250
258
  "256": ColorDepth.EIGHT_BIT,
251
259
  ansi: ColorDepth.STANDARD,
252
260
  none: null,
261
+ };
262
+ // [LAW:one-source-of-truth] What the environment says about colour, by the
263
+ // value it says it with: FORCE_COLOR values (the chalk/supports-color
264
+ // convention), TERM_PROGRAM identifiers, and exact TERM names whose colour
265
+ // capability is known. A different vocabulary from `ColorSystemName`, so a
266
+ // different table: `FORCE_COLOR=none` is not a request for no colour. A `Map`,
267
+ // so a value naming an `Object.prototype` member is unknown, not a function.
268
+ // `null` means "no color".
269
+ const ENV_DEPTHS = new Map([
253
270
  // FORCE_COLOR values
254
- "0": null,
255
- false: null,
256
- "1": ColorDepth.STANDARD,
257
- true: ColorDepth.STANDARD,
258
- "2": ColorDepth.EIGHT_BIT,
259
- "3": ColorDepth.TRUECOLOR,
271
+ ["0", null],
272
+ ["false", null],
273
+ ["1", ColorDepth.STANDARD],
274
+ ["true", ColorDepth.STANDARD],
275
+ ["2", ColorDepth.EIGHT_BIT],
276
+ ["3", ColorDepth.TRUECOLOR],
260
277
  // TERM_PROGRAM identifiers
261
- "iTerm.app": ColorDepth.TRUECOLOR,
262
- Apple_Terminal: ColorDepth.EIGHT_BIT,
263
- vscode: ColorDepth.TRUECOLOR,
264
- Tabby: ColorDepth.TRUECOLOR,
278
+ ["iTerm.app", ColorDepth.TRUECOLOR],
279
+ ["Apple_Terminal", ColorDepth.EIGHT_BIT],
280
+ ["vscode", ColorDepth.TRUECOLOR],
281
+ ["Tabby", ColorDepth.TRUECOLOR],
265
282
  // Known truecolor TERM names
266
- "xterm-kitty": ColorDepth.TRUECOLOR,
267
- "xterm-ghostty": ColorDepth.TRUECOLOR,
268
- wezterm: ColorDepth.TRUECOLOR,
269
- alacritty: ColorDepth.TRUECOLOR,
270
- foot: ColorDepth.TRUECOLOR,
271
- contour: ColorDepth.TRUECOLOR,
272
- };
283
+ ["xterm-kitty", ColorDepth.TRUECOLOR],
284
+ ["xterm-ghostty", ColorDepth.TRUECOLOR],
285
+ ["wezterm", ColorDepth.TRUECOLOR],
286
+ ["alacritty", ColorDepth.TRUECOLOR],
287
+ ["foot", ColorDepth.TRUECOLOR],
288
+ ["contour", ColorDepth.TRUECOLOR],
289
+ ]);
273
290
  /**
274
291
  * Detect the terminal's color capability from env + TTY state.
275
292
  *
@@ -296,12 +313,21 @@ function forcedColorSystem(env) {
296
313
  const force = env["FORCE_COLOR"];
297
314
  if (force === undefined || force === "")
298
315
  return undefined;
299
- const mapped = STRING_TO_DEPTH[force];
316
+ const mapped = ENV_DEPTHS.get(force);
300
317
  return mapped !== undefined ? mapped : ColorDepth.STANDARD;
301
318
  }
302
319
  function envOf(options) {
303
320
  return options.env ?? (typeof process !== "undefined" ? process.env : {});
304
321
  }
322
+ /**
323
+ * A terminal that says it cannot be drawn on: `TERM` is `dumb` or `unknown`,
324
+ * as Emacs' shell mode sets it. It takes no colour and nothing that moves the
325
+ * cursor.
326
+ */
327
+ export function isDumbTerminal(env) {
328
+ const term = env["TERM"];
329
+ return term === "dumb" || term === "unknown";
330
+ }
305
331
  // What the destination can draw, preferences aside: TTY presence,
306
332
  // TERM=dumb/unknown, COLORTERM, known terminal names, TERM patterns. `null`
307
333
  // here means the destination takes no escapes at all.
@@ -311,19 +337,19 @@ function terminalColorSystem(options) {
311
337
  (typeof process !== "undefined" ? (process.stdout?.isTTY ?? false) : false);
312
338
  if (!isTTY)
313
339
  return null;
314
- const term = env["TERM"] ?? "";
315
- if (term === "dumb" || term === "unknown")
340
+ if (isDumbTerminal(env))
316
341
  return null;
342
+ const term = env["TERM"] ?? "";
317
343
  const colorterm = env["COLORTERM"];
318
344
  if (colorterm === "truecolor" || colorterm === "24bit") {
319
345
  return ColorDepth.TRUECOLOR;
320
346
  }
321
- const termDepth = STRING_TO_DEPTH[term];
347
+ const termDepth = ENV_DEPTHS.get(term);
322
348
  if (termDepth !== undefined)
323
349
  return termDepth;
324
350
  const termProgram = env["TERM_PROGRAM"];
325
351
  if (termProgram !== undefined) {
326
- const mapped = STRING_TO_DEPTH[termProgram];
352
+ const mapped = ENV_DEPTHS.get(termProgram);
327
353
  if (mapped !== undefined)
328
354
  return mapped;
329
355
  }
@@ -364,20 +390,20 @@ export function resolveDestination(spec, options) {
364
390
  };
365
391
  }
366
392
  /**
367
- * Resolve a string spec into a `ColorDepth` (or `null` for no color).
393
+ * Resolve a colour system name into a `ColorDepth` (or `null` for no color).
368
394
  *
369
- * `"auto"` triggers env-based detection; all other recognized specs are
370
- * direct table lookups against `STRING_TO_DEPTH`. Throws on unknown specs
371
- * — silent fallback would mask user typos.
395
+ * `"auto"` triggers env-based detection; every other name is a fixed depth.
396
+ * A name outside the vocabulary — reachable only from untyped JavaScript —
397
+ * throws: silent fallback would mask a typo.
372
398
  *
373
- * [LAW:single-enforcer] All string→ColorDepth resolution flows through here.
399
+ * [LAW:single-enforcer] All name→ColorDepth resolution flows through here.
374
400
  */
375
401
  export function resolveColorSystem(spec, options) {
376
402
  if (spec === "auto")
377
403
  return detectColorSystem(options);
378
- if (Object.hasOwn(STRING_TO_DEPTH, spec))
379
- return STRING_TO_DEPTH[spec];
380
- throw new ColorParseError(`Unknown color depth spec: ${JSON.stringify(spec)} (expected "auto", "truecolor", "256", "ansi", or "none")`);
404
+ if (Object.hasOwn(COLOR_SYSTEM_DEPTHS, spec))
405
+ return COLOR_SYSTEM_DEPTHS[spec];
406
+ throw new ColorParseError(`Unknown color system name: ${JSON.stringify(spec)} (expected "auto", "truecolor", "256", "ansi", or "none")`);
381
407
  }
382
408
  // --- ColorSpec ---
383
409
  export class ColorParseError extends Error {
@@ -386,8 +412,8 @@ export class ColorParseError extends Error {
386
412
  this.name = "ColorParseError";
387
413
  }
388
414
  }
389
- // [LAW:one-source-of-truth] Parse cache is the single source for parsed ColorSpec instances
390
- const parseCache = new Map();
415
+ // [LAW:one-source-of-truth] The single source for parsed ColorSpec instances.
416
+ const colorSpecParseMemo = new Memo();
391
417
  export class ColorSpec {
392
418
  name;
393
419
  type;
@@ -408,8 +434,9 @@ export class ColorSpec {
408
434
  * any alpha is flattened, so a translucent value is drawn composited over
409
435
  * its ground (`flattenAlpha`) and should be measured that way: a
410
436
  * truecolor value, or a 256-colour cube or grey-ramp entry (xterm fixes
411
- * indices 16–255, and `fromAnsi` types only those as EIGHT_BIT). ANSI 0–15
412
- * and the default colour are the terminal theme's own, so they have none.
437
+ * indices 16–255, and `fromAnsi` types only those as EIGHT_BIT). ANSI 0–15,
438
+ * the default colour and a blend reaching either are the terminal theme's
439
+ * own, so they have none.
413
440
  */
414
441
  get fixedValue() {
415
442
  switch (this.type) {
@@ -489,7 +516,7 @@ export class ColorSpec {
489
516
  downgrade(targetSystem) {
490
517
  if (this.type === ColorDepth.DEFAULT)
491
518
  return this;
492
- if (this.type <= targetSystem)
519
+ if (this.type === targetSystem || fidelity(this.type) < fidelity(targetSystem))
493
520
  return this;
494
521
  const cached = this.downgradeCache.get(targetSystem);
495
522
  if (cached)
@@ -507,19 +534,14 @@ export class ColorSpec {
507
534
  return this.value;
508
535
  case ColorDepth.EIGHT_BIT:
509
536
  // Slots 0–15 are the theme's own whatever depth names them, as `fixedValue` says.
510
- return this.number < 16 ? (theme ?? INTERNAL_DEFAULT_THEME).ansiColors.get(this.number) : EIGHT_BIT_TABLE.get(this.number);
511
- case ColorDepth.STANDARD: {
512
- const t = theme ?? INTERNAL_DEFAULT_THEME;
513
- return t.ansiColors.get(this.number);
514
- }
537
+ return this.number < 16 ? resolveTerminal(theme, this.type).ansiColors.get(this.number) : EIGHT_BIT_TABLE.get(this.number);
538
+ case ColorDepth.STANDARD:
539
+ case ColorDepth.WINDOWS:
540
+ return resolveTerminal(theme, this.type).ansiColors.get(this.number);
515
541
  case ColorDepth.DEFAULT: {
516
- const t = theme ?? INTERNAL_DEFAULT_THEME;
542
+ const t = resolveTerminal(theme, this.type);
517
543
  return foreground ? t.foregroundColor : t.backgroundColor;
518
544
  }
519
- case ColorDepth.WINDOWS: {
520
- const t = theme ?? INTERNAL_DEFAULT_THEME;
521
- return t.ansiColors.get(this.number);
522
- }
523
545
  }
524
546
  }
525
547
  // --- Static factories ---
@@ -537,34 +559,83 @@ export class ColorSpec {
537
559
  return ColorSpec.fromRgba(new ColorRgba(r, g, b));
538
560
  }
539
561
  /**
540
- * Parse a color string. Cached — identical strings return the same instance.
562
+ * The colour `t` of the way from `from` to `to`: what `blend(from,to,t)`
563
+ * parses to. Two fixed colours mix to a fixed colour. A mix reaching a
564
+ * theme's own colour stays a mix until it is drawn, so whatever theme draws
565
+ * an end draws the mix from it: exported under a theme whose `blue` is
566
+ * #61afef, a mix with `blue` starts from #61afef. A terminal is told its RGB
567
+ * under the standard table, the only one a render has.
568
+ *
569
+ * Each end is one opaque colour: `default` is no colour to mix, and a
570
+ * translucent end has no one shade until it is laid on a ground.
571
+ */
572
+ static blend(from, to, t) {
573
+ for (const end of [from, to]) {
574
+ if (end.isDefault)
575
+ throw new ColorParseError(`A blend cannot mix "default", which is no colour`);
576
+ if (end.value !== undefined && end.value.alpha < 1) {
577
+ throw new ColorParseError(`A blend's ends are opaque; "${end.name}" is translucent`);
578
+ }
579
+ }
580
+ if (!(t >= 0 && t <= 1))
581
+ throw new ColorParseError(`A blend's fraction is in [0, 1]; got ${t}`);
582
+ const a = from.fixedValue;
583
+ const b = to.fixedValue;
584
+ return a !== undefined && b !== undefined ? ColorSpec.fromRgba(blendRgb(a, b, t)) : new ColorBlend(from, to, t);
585
+ }
586
+ /**
587
+ * The spec that draws `value` nearest to it at `depth` on a terminal known
588
+ * to show `theme`, as the colour of the glyph (`foreground`) or of the
589
+ * ground. Where `downgrade` knows no terminal, and so rounds only against
590
+ * colours every terminal fixes (or, at sixteen colours, the stock
591
+ * sixteen), this rounds against every colour the depth can write whose
592
+ * look `theme` fixes: the default colour, the theme's sixteen, and at 256
593
+ * colours the cube and grey ramp too. A colour read off any of those and
594
+ * moved a little is drawn as the colour it came from — or, where the theme
595
+ * gives two slots one colour, as the lower slot, which looks the same.
596
+ *
597
+ * `value` is opaque: a translucent colour has no one look until it is laid
598
+ * on a ground (`flattenAlpha`).
599
+ */
600
+ static matchOn(value, depth, theme, foreground) {
601
+ if (value.alpha < 1)
602
+ throw new RangeError(`matchOn rounds an opaque colour; got alpha ${value.alpha}`);
603
+ if (depth === ColorDepth.TRUECOLOR)
604
+ return ColorSpec.fromRgba(value);
605
+ if (depth === ColorDepth.DEFAULT)
606
+ return ColorSpec.default();
607
+ const slot = theme.ansiColors.match(value);
608
+ // [LAW:dataflow-not-control-flow] Every candidate is scored; the depth
609
+ // only decides whether the cube is among them. Ties go to the earlier.
610
+ const candidates = [
611
+ [ColorSpec.default(), foreground ? theme.foregroundColor : theme.backgroundColor],
612
+ [new ColorSpec(`color(${slot})`, depth === ColorDepth.WINDOWS ? depth : ColorDepth.STANDARD, slot), theme.ansiColors.get(slot)],
613
+ ...(depth === ColorDepth.EIGHT_BIT ? [cubeCandidate(value)] : []),
614
+ ];
615
+ return candidates.reduce((best, next) => (rgbDistance(next[1], value) < rgbDistance(best[1], value) ? next : best))[0];
616
+ }
617
+ /**
618
+ * Parse a color string. Memoized (`Memo`): a string parsed again while
619
+ * remembered returns the same instance.
541
620
  */
542
621
  static parse(colorString) {
543
- const key = colorString.toLowerCase().trim();
544
- const cached = parseCache.get(key);
545
- if (cached)
546
- return cached;
547
- const result = parseSingle(key);
548
- parseCache.set(key, result);
549
- return result;
622
+ return colorSpecParseMemo.get(colorString.toLowerCase().trim(), parseSingle);
550
623
  }
551
624
  // --- private ---
552
625
  performDowngrade(targetSystem) {
553
- // Get the true RGB of this color
554
- const triplet = this.getTruecolor();
626
+ // The colour as the terminal this depth assumes draws it: a blend's named
627
+ // ends are the Windows console's own at WINDOWS.
628
+ const triplet = this.getTruecolor(resolveTerminal(undefined, targetSystem));
555
629
  switch (targetSystem) {
556
- case ColorDepth.EIGHT_BIT: {
557
- const index = EIGHT_BIT_DOWNGRADE_TABLE.match(triplet);
558
- return ColorSpec.fromAnsi(index);
559
- }
560
- case ColorDepth.STANDARD: {
561
- const index = STANDARD_TABLE.match(triplet);
562
- return new ColorSpec(`color(${index})`, ColorDepth.STANDARD, index);
630
+ case ColorDepth.EIGHT_BIT:
631
+ return ColorSpec.fromAnsi(downgradeTable(targetSystem).match(triplet));
632
+ case ColorDepth.STANDARD:
633
+ case ColorDepth.WINDOWS: {
634
+ // An ANSI slot is already one of the sixteen either depth writes; only
635
+ // a fixed colour is matched against the depth's table, as Rich does.
636
+ const index = this.number !== undefined && this.number < 16 ? this.number : downgradeTable(targetSystem).match(triplet);
637
+ return new ColorSpec(`color(${index})`, targetSystem, index);
563
638
  }
564
- // WINDOWS is a detection result (max enum value), not a downgrade target.
565
- // The `this.type <= targetSystem` guard in downgrade() always short-circuits
566
- // before reaching this method with WINDOWS, so this case is unreachable.
567
- case ColorDepth.WINDOWS:
568
639
  case ColorDepth.TRUECOLOR:
569
640
  return this;
570
641
  case ColorDepth.DEFAULT:
@@ -572,11 +643,47 @@ export class ColorSpec {
572
643
  }
573
644
  }
574
645
  }
646
+ /** The cube or grey-ramp entry nearest `value`, and its colour. */
647
+ function cubeCandidate(value) {
648
+ const index = EIGHT_BIT_DOWNGRADE_TABLE.match(value);
649
+ return [ColorSpec.fromAnsi(index), EIGHT_BIT_DOWNGRADE_TABLE.get(index)];
650
+ }
651
+ function mix(from, to, t, theme) {
652
+ return blendRgb(from.getTruecolor(theme), to.getTruecolor(theme), t);
653
+ }
654
+ // How a blend spells an end: by what it draws as, so every spelling of one
655
+ // colour ("blue", "color(4)", an EIGHT_BIT 4) gives the blend one name.
656
+ function blendTerm(end) {
657
+ return end.fixedValue?.hex ?? (end.number !== undefined ? `color(${end.number})` : end.name);
658
+ }
659
+ /**
660
+ * `ColorSpec.blend` with an end the theme draws. A terminal is told it as a
661
+ * truecolor value, so it is one, and `value` is what it is told; a theme is
662
+ * asked of its two ends instead. Its name is what `ColorSpec.parse` reads back.
663
+ */
664
+ class ColorBlend extends ColorSpec {
665
+ from;
666
+ to;
667
+ t;
668
+ constructor(from, to, t) {
669
+ super(`blend(${blendTerm(from)},${blendTerm(to)},${t})`, ColorDepth.TRUECOLOR, undefined, mix(from, to, t));
670
+ this.from = from;
671
+ this.to = to;
672
+ this.t = t;
673
+ }
674
+ get fixedValue() {
675
+ return undefined;
676
+ }
677
+ getTruecolor(theme) {
678
+ return mix(this.from, this.to, this.t, theme);
679
+ }
680
+ }
575
681
  // --- Parsing internals ---
576
- const HEX_RE = /^#([0-9a-f]{6})$/;
577
- const HEX_RGBA_RE = /^#([0-9a-f]{8})$/;
578
682
  const RGB_RE = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/;
579
683
  const COLOR_NUMBER_RE = /^color\((\d+)\)$/;
684
+ const BLEND_RE = /^blend\((.*)\)$/;
685
+ // A decimal as `String(number)` spells one, exponent included; `blend` owns its range.
686
+ const FRACTION_RE = /^\s*(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?\s*$/;
580
687
  function parseSingle(key) {
581
688
  if (key === "default" || key === "") {
582
689
  return ColorSpec.default();
@@ -587,15 +694,8 @@ function parseSingle(key) {
587
694
  const type = namedIndex < 16 ? ColorDepth.STANDARD : ColorDepth.EIGHT_BIT;
588
695
  return new ColorSpec(key, type, namedIndex);
589
696
  }
590
- // Hex (8-char with alpha takes precedence over 6-char to avoid the 6-char
591
- // regex matching a prefix of an 8-char string).
592
- const hexRgbaMatch = HEX_RGBA_RE.exec(key);
593
- if (hexRgbaMatch) {
594
- return new ColorSpec(key, ColorDepth.TRUECOLOR, undefined, parseRgbaHex(hexRgbaMatch[1]));
595
- }
596
- const hexMatch = HEX_RE.exec(key);
597
- if (hexMatch) {
598
- return new ColorSpec(key, ColorDepth.TRUECOLOR, undefined, parseRgbHex(hexMatch[1]));
697
+ if (key.startsWith("#")) {
698
+ return new ColorSpec(key, ColorDepth.TRUECOLOR, undefined, parseHexColor(key));
599
699
  }
600
700
  // rgb()
601
701
  const rgbMatch = RGB_RE.exec(key);
@@ -612,8 +712,38 @@ function parseSingle(key) {
612
712
  const type = n < 16 ? ColorDepth.STANDARD : ColorDepth.EIGHT_BIT;
613
713
  return new ColorSpec(key, type, n);
614
714
  }
715
+ // blend(a,b,t)
716
+ const blendMatch = BLEND_RE.exec(key);
717
+ if (blendMatch) {
718
+ const terms = blendTerms(blendMatch[1]);
719
+ const fraction = terms[2] ?? "";
720
+ if (terms.length !== 3 || !FRACTION_RE.test(fraction)) {
721
+ throw new ColorParseError(`ColorSpec "${key}": a blend is blend(<color>,<color>,<fraction>)`);
722
+ }
723
+ return ColorSpec.blend(ColorSpec.parse(terms[0]), ColorSpec.parse(terms[1]), Number(fraction));
724
+ }
615
725
  throw new ColorParseError(`Failed to parse color: "${key}"`);
616
726
  }
727
+ // A blend's terms, split at the commas outside parentheses so an `rgb(…)` or a
728
+ // nested `blend(…)` stays one term.
729
+ function blendTerms(inner) {
730
+ const terms = [];
731
+ let depth = 0;
732
+ let start = 0;
733
+ for (let i = 0; i < inner.length; i++) {
734
+ const c = inner[i];
735
+ if (c === "(")
736
+ depth++;
737
+ else if (c === ")")
738
+ depth--;
739
+ else if (c === "," && depth === 0) {
740
+ terms.push(inner.slice(start, i));
741
+ start = i + 1;
742
+ }
743
+ }
744
+ terms.push(inner.slice(start));
745
+ return terms;
746
+ }
617
747
  // [LAW:single-enforcer] The regexes admit any run of digits and leave the
618
748
  // range to this check alone, so every out-of-range byte is a ColorParseError
619
749
  // naming the field, never the generic parse failure or the constructor's RangeError.
@@ -625,26 +755,45 @@ function parseByte(key, digits, field) {
625
755
  return n;
626
756
  }
627
757
  // --- Utility functions ---
758
+ // [LAW:single-enforcer] The hex colour grammar, checked here and nowhere else:
759
+ // every string spelling a colour in hex — a ColorSpec, a theme's data, a colour
760
+ // reference, a template argument — becomes one through the three parsers below,
761
+ // and each is this check at the lengths it admits. Each digit pair is a byte, so
762
+ // the grammar holding is what keeps `ColorRgba`'s channels in range.
763
+ const HEX_DIGITS_RE = /^[0-9a-fA-F]+$/;
764
+ // Each admitted digit count, by the grammar it spells: the message a failure
765
+ // carries is read off the lengths a parser admits, so the two cannot disagree.
766
+ const HEX_GRAMMARS = { 6: "RRGGBB", 8: "RRGGBBAA" };
767
+ function hexColor(input, lead, digits, lengths) {
768
+ if (!lengths.includes(digits.length) || !HEX_DIGITS_RE.test(digits)) {
769
+ const expected = lengths.map((n) => lead + HEX_GRAMMARS[n]).join(" or ");
770
+ throw new ColorParseError(`Invalid hex colour ${JSON.stringify(input)} (expected ${expected})`);
771
+ }
772
+ const byte = (at) => parseInt(digits.slice(at, at + 2), 16);
773
+ return new ColorRgba(byte(0), byte(2), byte(4), digits.length === 8 ? byte(6) / 255 : 1);
774
+ }
775
+ /** Six hex digits, no `#`, as an opaque colour. @throws {ColorParseError} on anything else. */
628
776
  export function parseRgbHex(hex) {
629
- const r = parseInt(hex.slice(0, 2), 16);
630
- const g = parseInt(hex.slice(2, 4), 16);
631
- const b = parseInt(hex.slice(4, 6), 16);
632
- return new ColorRgba(r, g, b);
777
+ return hexColor(hex, "", hex, [6]);
633
778
  }
779
+ /** Eight hex digits, no `#`, the last pair alpha. @throws {ColorParseError} on anything else. */
634
780
  export function parseRgbaHex(hex) {
635
- const r = parseInt(hex.slice(0, 2), 16);
636
- const g = parseInt(hex.slice(2, 4), 16);
637
- const b = parseInt(hex.slice(4, 6), 16);
638
- const a = parseInt(hex.slice(6, 8), 16) / 255;
639
- return new ColorRgba(r, g, b, a);
781
+ return hexColor(hex, "", hex, [8]);
782
+ }
783
+ /**
784
+ * A `#RRGGBB` or `#RRGGBBAA` literal, exactly — no surrounding whitespace.
785
+ * @throws {ColorParseError} on anything else.
786
+ */
787
+ export function parseHexColor(literal) {
788
+ return hexColor(literal, "#", literal.startsWith("#") ? literal.slice(1) : "", [6, 8]);
640
789
  }
641
790
  export function blendRgb(color1, color2, crossFade = 0.5) {
642
791
  return new ColorRgba(Math.round(color1.red + (color2.red - color1.red) * crossFade), Math.round(color1.green + (color2.green - color1.green) * crossFade), Math.round(color1.blue + (color2.blue - color1.blue) * crossFade), color1.alpha + (color2.alpha - color1.alpha) * crossFade);
643
792
  }
644
793
  // --- TerminalTheme ---
645
794
  /**
646
- * A terminal theme — surface/foreground baseline, the ANSI 16/256 LUT, and a
647
- * semantic palette.
795
+ * A terminal theme — surface/foreground baseline, the sixteen ANSI colours,
796
+ * and a semantic palette.
648
797
  *
649
798
  * **`ansiColors` is the theme's own sixteen colours**, the shades a terminal
650
799
  * showing this theme draws `red`, `blue` and the rest in. `ColorSpec.parse("red")`
@@ -661,6 +810,11 @@ export class TerminalTheme {
661
810
  this.foregroundColor = foregroundColor;
662
811
  this.ansiColors = ansiColors;
663
812
  this.palette = palette;
813
+ // [LAW:parse-dont-validate] Every reader indexes `ansiColors` 0–15 as the
814
+ // terminal's sixteen, so a theme is the proof it holds exactly those.
815
+ if (ansiColors.firstIndex !== 0 || ansiColors.size !== 16) {
816
+ throw new RangeError(`a TerminalTheme's ansiColors are the sixteen ANSI colours, indices 0–15; got ${ansiColors.size} from index ${ansiColors.firstIndex}`);
817
+ }
664
818
  }
665
819
  }
666
820
  // --- ColorTable data ---
@@ -702,24 +856,29 @@ function build256Table() {
702
856
  }
703
857
  return colors;
704
858
  }
859
+ /**
860
+ * The Windows console's own sixteen (its default Campbell scheme), in ANSI
861
+ * index order: a WINDOWS colour is written as the same `30+n`/`90+n` SGR a
862
+ * STANDARD one is, so slot `n` here is the colour the console draws for it.
863
+ */
705
864
  function buildWindowsTable() {
706
865
  return [
707
- new ColorRgba(0, 0, 0), // 0
708
- new ColorRgba(0, 0, 128), // 1
709
- new ColorRgba(0, 128, 0), // 2
710
- new ColorRgba(0, 128, 128), // 3
711
- new ColorRgba(128, 0, 0), // 4
712
- new ColorRgba(128, 0, 128), // 5
713
- new ColorRgba(128, 128, 0), // 6
714
- new ColorRgba(192, 192, 192), // 7
715
- new ColorRgba(128, 128, 128), // 8
716
- new ColorRgba(0, 0, 255), // 9
717
- new ColorRgba(0, 255, 0), // 10
718
- new ColorRgba(0, 255, 255), // 11
719
- new ColorRgba(255, 0, 0), // 12
720
- new ColorRgba(255, 0, 255), // 13
721
- new ColorRgba(255, 255, 0), // 14
722
- new ColorRgba(255, 255, 255), // 15
866
+ new ColorRgba(12, 12, 12), // 0 black
867
+ new ColorRgba(197, 15, 31), // 1 red
868
+ new ColorRgba(19, 161, 14), // 2 green
869
+ new ColorRgba(193, 156, 0), // 3 yellow
870
+ new ColorRgba(0, 55, 218), // 4 blue
871
+ new ColorRgba(136, 23, 152), // 5 magenta
872
+ new ColorRgba(58, 150, 221), // 6 cyan
873
+ new ColorRgba(204, 204, 204), // 7 white
874
+ new ColorRgba(118, 118, 118), // 8 bright_black
875
+ new ColorRgba(231, 72, 86), // 9 bright_red
876
+ new ColorRgba(22, 198, 12), // 10 bright_green
877
+ new ColorRgba(249, 241, 165), // 11 bright_yellow
878
+ new ColorRgba(59, 120, 255), // 12 bright_blue
879
+ new ColorRgba(180, 0, 158), // 13 bright_magenta
880
+ new ColorRgba(97, 214, 214), // 14 bright_cyan
881
+ new ColorRgba(242, 242, 242), // 15 bright_white
723
882
  ];
724
883
  }
725
884
  export const STANDARD_TABLE = new ColorTable(buildStandard16());
@@ -739,14 +898,43 @@ export const WINDOWS_TABLE = new ColorTable(buildWindowsTable());
739
898
  // `themes/`. Keeping them out of `core/color.ts` makes the dependency strictly
740
899
  // `themes/* -> core/color`, with no runtime back-edge.
741
900
  //
742
- // `getTruecolor()` retains a fallback for callers that omit `theme`. The
743
- // fallback uses STANDARD_TABLE + black/white + an empty Palette — sufficient
744
- // for STANDARD/DEFAULT/WINDOWS lookups, which never read `palette`.
901
+ // `getTruecolor()` retains a fallback for callers that omit `theme`: the
902
+ // terminal a colour's depth assumes, built from that depth's own sixteen with
903
+ // an empty Palette — sufficient for the lookups it serves, which never read
904
+ // `palette`.
745
905
  //
746
906
  // Runtime-importing `Palette` from `themes/palette.ts` is safe: that module
747
907
  // only `import type`s from `core/color.ts`, so there is no runtime cycle.
748
908
  import { Palette } from "../themes/palette.js";
749
909
  const INTERNAL_DEFAULT_THEME = new TerminalTheme(new ColorRgba(0, 0, 0), new ColorRgba(255, 255, 255), STANDARD_TABLE, new Palette("default", true, new Map()));
910
+ /** The Windows console in its default Campbell scheme: a WINDOWS colour's terminal. */
911
+ const WINDOWS_CONSOLE_THEME = new TerminalTheme(WINDOWS_TABLE.get(0), WINDOWS_TABLE.get(7), WINDOWS_TABLE, new Palette("windows", true, new Map()));
912
+ /**
913
+ * The terminal a colour at `depth` is drawn by: `theme`, or when a caller names
914
+ * none, the one the depth assumes — the Windows console at WINDOWS, the VGA
915
+ * sixteen otherwise — for `ColorSpec.getTruecolor` and the contrast measurement
916
+ * that reads it. (Widgets fall back to `DEFAULT_TERMINAL_THEME`, which carries a
917
+ * palette this does not.)
918
+ */
919
+ export function resolveTerminal(theme, depth) {
920
+ return theme ?? (depth === ColorDepth.WINDOWS ? WINDOWS_CONSOLE_THEME : INTERNAL_DEFAULT_THEME);
921
+ }
922
+ /**
923
+ * The table a downgrade to `depth` rounds a fixed colour against.
924
+ * [LAW:one-source-of-truth] `ColorSpec.downgrade` picks the index a colour is
925
+ * written as from it, and colorMath hands back its entries as colours the
926
+ * writer rounds to those same indices.
927
+ */
928
+ export function downgradeTable(depth) {
929
+ switch (depth) {
930
+ case ColorDepth.EIGHT_BIT:
931
+ return EIGHT_BIT_DOWNGRADE_TABLE;
932
+ case ColorDepth.STANDARD:
933
+ return STANDARD_TABLE;
934
+ case ColorDepth.WINDOWS:
935
+ return WINDOWS_TABLE;
936
+ }
937
+ }
750
938
  // --- ANSI ColorSpec Names ---
751
939
  // [LAW:one-source-of-truth] Single canonical mapping from name → palette index
752
940
  /**