@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
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Easing and phase: the one vocabulary for "how far along" — every function
3
+ * here returns progress in [0, 1].
4
+ *
5
+ * Two kinds of curve, composed as `ease(phase(t))`:
6
+ *
7
+ * - A **phase** turns a time `t` (seconds) into raw progress: `once` runs
8
+ * 0 → 1 over a span and holds, `loop` wraps 0 → 1 every period,
9
+ * `pingPong` goes 0 → 1 → 0 every period. A per-cell offset is added to `t`
10
+ * before the phase sees it — `phase(t + offset)` — so the cells of one
11
+ * effect run the same curve shifted in time.
12
+ * - An **ease** reshapes that progress: any `(x) => y` on [0, 1]. The
13
+ * built-ins are CSS's easing functions (CSS Easing Functions Level 1):
14
+ * `linear`, `cubic-bezier()` with its named curves, and `steps()` with its
15
+ * jump terms, plus `sine` for pulses — a half-cosine, so a `pingPong`
16
+ * through it brightens and dims with no corner at either end.
17
+ *
18
+ * [LAW:no-ambient-temporal-coupling] Nothing here reads a clock. `t` is an
19
+ * argument; whoever draws frames owns the clock and samples these at
20
+ * whatever rate it likes, so the same `t` always gives the same progress.
21
+ *
22
+ * [LAW:one-type-per-behavior] An ease is a plain function value. CSS's named
23
+ * curves are four instances of `cubicBezier`, not four implementations, and
24
+ * `ColorRamp` and every effect take the same `Ease`.
25
+ *
26
+ * Tier 0 of `src/core/`: imports nothing, so `themes/` and every later
27
+ * `core/` module reach it downhill. [LAW:one-way-deps]
28
+ */
29
+ /**
30
+ * CSS `cubic-bezier(x1, y1, x2, y2)`: the curve from (0, 0) to (1, 1) with
31
+ * those two control points, read as y at a given x. `x1` and `x2` must lie in
32
+ * [0, 1] — as in CSS — which keeps x monotonic in the curve parameter, so
33
+ * every input has exactly one output. `y1` and `y2` may leave [0, 1]; the
34
+ * curve then overshoots, as a CSS "back" curve does.
35
+ */
36
+ export function cubicBezier(x1, y1, x2, y2) {
37
+ for (const [name, v] of [["x1", x1], ["y1", y1], ["x2", x2], ["y2", y2]]) {
38
+ if (!Number.isFinite(v))
39
+ throw new RangeError(`cubicBezier ${name} must be finite, got ${v}`);
40
+ }
41
+ if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1) {
42
+ throw new RangeError(`cubicBezier x1 and x2 must lie in [0, 1], got ${x1} and ${x2}`);
43
+ }
44
+ // The Bernstein form in power basis: x(s) = ((ax·s + bx)·s + cx)·s, same for y.
45
+ const cx = 3 * x1;
46
+ const bx = 3 * (x2 - x1) - cx;
47
+ const ax = 1 - cx - bx;
48
+ const cy = 3 * y1;
49
+ const by = 3 * (y2 - y1) - cy;
50
+ const ay = 1 - cy - by;
51
+ const xAt = (s) => ((ax * s + bx) * s + cx) * s;
52
+ const yAt = (s) => ((ay * s + by) * s + cy) * s;
53
+ const dxAt = (s) => (3 * ax * s + 2 * bx) * s + cx;
54
+ const EPSILON = 1e-9;
55
+ // The parameter s with x(s) = x: Newton's method from s = x, which converges
56
+ // in a few steps on every curve CSS names; bisection when the slope is too
57
+ // flat for Newton to trust. x(s) is monotonic on [0, 1], so bisection always
58
+ // lands.
59
+ const solve = (x) => {
60
+ let s = x;
61
+ for (let i = 0; i < 8; i++) {
62
+ const err = xAt(s) - x;
63
+ if (Math.abs(err) < EPSILON)
64
+ return s;
65
+ const slope = dxAt(s);
66
+ if (Math.abs(slope) < 1e-6)
67
+ break;
68
+ s -= err / slope;
69
+ }
70
+ let lo = 0;
71
+ let hi = 1;
72
+ s = x;
73
+ while (hi - lo > EPSILON) {
74
+ if (xAt(s) < x)
75
+ lo = s;
76
+ else
77
+ hi = s;
78
+ s = (lo + hi) / 2;
79
+ }
80
+ return s;
81
+ };
82
+ return (x) => yAt(solve(Math.min(1, Math.max(0, x))));
83
+ }
84
+ /**
85
+ * CSS `steps(n, position)`: progress held flat in `n` equal intervals, jumping
86
+ * between them. The output takes `n` distinct values between the jumps
87
+ * `position` names — CSS's step algorithm, so `steps(1, "end")` is 0 until
88
+ * the very end.
89
+ */
90
+ export function steps(n, position = "jump-end") {
91
+ const minimum = position === "jump-none" ? 2 : 1;
92
+ if (!Number.isInteger(n) || n < minimum) {
93
+ throw new RangeError(`steps(${n}, "${position}") needs a whole number of steps ≥ ${minimum}`);
94
+ }
95
+ const jumpsAtStart = position === "jump-start" || position === "start" || position === "jump-both";
96
+ // How many jumps the curve makes in all, which is what each step is a fraction of.
97
+ const jumps = {
98
+ "jump-start": n,
99
+ start: n,
100
+ "jump-end": n,
101
+ end: n,
102
+ "jump-both": n + 1,
103
+ "jump-none": n - 1,
104
+ }[position];
105
+ const lift = jumpsAtStart ? 1 : 0;
106
+ return (x) => {
107
+ const p = Math.min(1, Math.max(0, x));
108
+ return Math.min(jumps, Math.floor(p * n) + lift) / jumps;
109
+ };
110
+ }
111
+ /** The identity ease: progress unchanged. */
112
+ const linear = (x) => x;
113
+ /** CSS `step-end`: 0 until the very end. */
114
+ const stepEnd = steps(1, "jump-end");
115
+ /** A half-cosine, flat at both ends: the curve a breathing pulse wants. */
116
+ const sine = (x) => (1 - Math.cos(Math.PI * x)) / 2;
117
+ /**
118
+ * Every built-in ease by the name a template spells it with — CSS's keyword
119
+ * names, plus `sine`, plus `step`: the spelling `ColorRamp` used before this
120
+ * vocabulary existed, kept so every template written against it renders the
121
+ * same. It is CSS's `step-end`, the same function value.
122
+ */
123
+ export const EASES = {
124
+ linear,
125
+ ease: cubicBezier(0.25, 0.1, 0.25, 1),
126
+ "ease-in": cubicBezier(0.42, 0, 1, 1),
127
+ "ease-out": cubicBezier(0, 0, 0.58, 1),
128
+ "ease-in-out": cubicBezier(0.42, 0, 0.58, 1),
129
+ "step-start": steps(1, "jump-start"),
130
+ "step-end": stepEnd,
131
+ step: stepEnd,
132
+ sine,
133
+ };
134
+ /**
135
+ * The gate a spelled ease crosses. [LAW:parse-dont-validate] — a name in, the
136
+ * function out, so nothing downstream holds a string it must check again.
137
+ * Unknown names throw naming every legal one. [LAW:no-silent-failure]
138
+ */
139
+ export function parseEase(name) {
140
+ if (!Object.hasOwn(EASES, name)) {
141
+ throw new RangeError(`unknown ease ${JSON.stringify(name)}; expected one of ` +
142
+ Object.keys(EASES).map((n) => JSON.stringify(n)).join(", "));
143
+ }
144
+ return EASES[name];
145
+ }
146
+ function positiveSeconds(what, seconds) {
147
+ if (!Number.isFinite(seconds) || seconds <= 0) {
148
+ throw new RangeError(`${what} must be a positive number of seconds, got ${seconds}`);
149
+ }
150
+ }
151
+ /** The three phase shapes. Each takes seconds and returns a `Phase`. */
152
+ export const Phase = {
153
+ /** 0 before `start`, rising to 1 over `duration` seconds, then held at 1. */
154
+ once(start, duration) {
155
+ if (!Number.isFinite(start))
156
+ throw new RangeError(`Phase.once start must be finite, got ${start}`);
157
+ positiveSeconds("Phase.once duration", duration);
158
+ return (t) => Math.min(1, Math.max(0, (t - start) / duration));
159
+ },
160
+ /** 0 → 1 every `period` seconds, wrapping back to 0; negative `t` wraps too. */
161
+ loop(period) {
162
+ positiveSeconds("Phase.loop period", period);
163
+ return (t) => (((t % period) + period) % period) / period;
164
+ },
165
+ /** 0 → 1 → 0 every `period` seconds: there in the first half, back in the second. */
166
+ pingPong(period) {
167
+ positiveSeconds("Phase.pingPong period", period);
168
+ const wrap = Phase.loop(period);
169
+ return (t) => 1 - Math.abs(2 * wrap(t) - 1);
170
+ },
171
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Env — the environment variables a program running on a terminal sees, as
3
+ * every public type that carries them names it.
4
+ *
5
+ * [LAW:types-are-the-program] The strongest true theorem about what this
6
+ * library does with an environment is "reads a variable by name, which may be
7
+ * unset." It never writes one, and it never touches the rest of node's
8
+ * `NodeJS.ProcessEnv`. Node's `process.env` satisfies this as-is, and so does
9
+ * a frozen literal in a browser, where there is no `process` at all.
10
+ *
11
+ * It lives here rather than being spelled `NodeJS.ProcessEnv` because a public
12
+ * signature is emitted into the `.d.ts` verbatim, and a TypeScript project
13
+ * without `@types/node` — the ordinary browser project — cannot resolve the
14
+ * `NodeJS` namespace. `test/seam/browser-types.test.ts` holds that line.
15
+ */
16
+ export type Env = Readonly<Record<string, string | undefined>>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Env — the environment variables a program running on a terminal sees, as
3
+ * every public type that carries them names it.
4
+ *
5
+ * [LAW:types-are-the-program] The strongest true theorem about what this
6
+ * library does with an environment is "reads a variable by name, which may be
7
+ * unset." It never writes one, and it never touches the rest of node's
8
+ * `NodeJS.ProcessEnv`. Node's `process.env` satisfies this as-is, and so does
9
+ * a frozen literal in a browser, where there is no `process` at all.
10
+ *
11
+ * It lives here rather than being spelled `NodeJS.ProcessEnv` because a public
12
+ * signature is emitted into the `.d.ts` verbatim, and a TypeScript project
13
+ * without `@types/node` — the ordinary browser project — cannot resolve the
14
+ * `NodeJS` namespace. `test/seam/browser-types.test.ts` holds that line.
15
+ */
16
+ export {};
@@ -13,10 +13,7 @@
13
13
  * is narrower than the terminal was.
14
14
  */
15
15
  import { cellLen, graphemes } from "./cells.js";
16
- import { exportCanvas, exportLines } from "./export-lines.js";
17
- const ENTITIES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
18
- const escapeText = (text) => text.replace(/[&<>]/g, (c) => ENTITIES[c]);
19
- const escapeAttribute = (value) => value.replace(/[&"<>]/g, (c) => ENTITIES[c]);
16
+ import { escapeAttribute, escapeText, exportCanvas, exportLines, } from "./export-lines.js";
20
17
  const BLINK_KEYFRAMES = "rich-blink";
21
18
  // [LAW:dataflow-not-control-flow] Each enum-valued field of a look is a table
22
19
  // from its values to the declarations it adds, so every value is spelled once
@@ -77,6 +77,14 @@ export type ExportLine = readonly ExportRun[];
77
77
  * it.
78
78
  */
79
79
  export declare function parseHref(link: string): Href | null;
80
+ /**
81
+ * Text as character data in HTML or SVG markup. Both exporters write markup,
82
+ * and [LAW:single-enforcer] one escaper is what keeps a title or a run that is
83
+ * safe in one format from being live markup, or no markup at all, in the other.
84
+ */
85
+ export declare const escapeText: (text: string) => string;
86
+ /** A value inside a double-quoted attribute in HTML or SVG markup. */
87
+ export declare const escapeAttribute: (value: string) => string;
80
88
  /** What an unstyled cell shows under a theme: the canvas, and ink on it. */
81
89
  export interface ExportCanvas {
82
90
  readonly background: ColorRgba;
@@ -85,25 +93,26 @@ export interface ExportCanvas {
85
93
  /**
86
94
  * The ground every export is drawn on under `theme`.
87
95
  *
88
- * An exporter paints its page or window with this; `resolveLook` flattens
89
- * every run over the same `background`, so a run showing the canvas and the
90
- * page around it cannot come out different colours.
96
+ * An exporter paints its page or window with this, and a run with no
97
+ * background of its own resolves to `"canvas"` rather than a colour, so a run
98
+ * showing the canvas and the page around it cannot come out different colours.
91
99
  */
92
100
  export declare function exportCanvas(theme?: TerminalTheme): ExportCanvas;
93
101
  /**
94
102
  * A `Style` as it appears on screen under `theme`.
95
103
  *
96
- * The order is the terminal's: colours resolve through the theme and lose
97
- * their alpha — paper over the canvas, ink over the paper, as `toSgrCodes`
98
- * flattens them — `reverse` swaps them, `dim` fades the glyph toward whatever
99
- * background it ended up on, and `conceal` finally paints the glyph in that
100
- * background. Every step after flattening works on opaque colour, so no
101
- * exporter ever draws a translucent one. Text under `conceal` is still
102
- * present, and still selectable, in both formats.
104
+ * The order is the terminal's: the colours are `Style.drawnColors`, the ones
105
+ * `toSgrCodes` writes, resolved through the theme — `reverse` swaps them,
106
+ * `dim` fades the glyph toward whatever background it ended up on, and
107
+ * `conceal` finally paints the glyph in that background. Every step works on
108
+ * opaque colour, so no exporter ever draws a translucent one. Text under
109
+ * `conceal` is still present, and still selectable, in both formats.
103
110
  *
104
- * The one difference from `toSgrCodes` is the substrate: a terminal cannot
105
- * know what lies under its cells and assumes black, while an export draws its
106
- * own canvas and flattens over that.
111
+ * A translucent colour is flattened where the writer flattens it, over
112
+ * `SURFACE_BLACK`, not over this export's canvas. The writer's truecolor
113
+ * bytes are absolute — no terminal theme retints them — so a terminal under
114
+ * `theme` shows that colour, and an export that flattened over its own canvas
115
+ * would picture a colour no terminal draws.
107
116
  *
108
117
  * `theme` omitted is `ColorSpec.getTruecolor`'s own fallback — black canvas,
109
118
  * white ink, the standard ANSI table. Choosing it there rather than naming a
@@ -55,6 +55,22 @@ export function parseHref(link) {
55
55
  const url = new URL(link);
56
56
  return LINKABLE_SCHEMES.has(url.protocol) ? url.href : null;
57
57
  }
58
+ const ENTITIES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
59
+ /**
60
+ * The characters XML 1.0 admits nowhere in a document: C0 controls but tab,
61
+ * newline and return, the two noncharacters U+FFFE and U+FFFF, and unpaired
62
+ * surrogates. One of them leaves a whole SVG unparsable, not one glyph missing.
63
+ * Each takes no cell, so dropping it moves nothing on the grid.
64
+ */
65
+ const NOT_XML = /[\x00-\x08\x0B\x0C\x0E-\x1F￾￿]|\p{Cs}/gu;
66
+ /**
67
+ * Text as character data in HTML or SVG markup. Both exporters write markup,
68
+ * and [LAW:single-enforcer] one escaper is what keeps a title or a run that is
69
+ * safe in one format from being live markup, or no markup at all, in the other.
70
+ */
71
+ export const escapeText = (text) => text.replace(NOT_XML, "").replace(/[&<>]/g, (c) => ENTITIES[c]);
72
+ /** A value inside a double-quoted attribute in HTML or SVG markup. */
73
+ export const escapeAttribute = (value) => value.replace(NOT_XML, "").replace(/[&"<>]/g, (c) => ENTITIES[c]);
58
74
  /**
59
75
  * How far a dim glyph moves toward its background — Rich's own factor, so an
60
76
  * export of the same program matches the library this ports.
@@ -64,9 +80,9 @@ const DEFAULT_COLOR = ColorSpec.default();
64
80
  /**
65
81
  * The ground every export is drawn on under `theme`.
66
82
  *
67
- * An exporter paints its page or window with this; `resolveLook` flattens
68
- * every run over the same `background`, so a run showing the canvas and the
69
- * page around it cannot come out different colours.
83
+ * An exporter paints its page or window with this, and a run with no
84
+ * background of its own resolves to `"canvas"` rather than a colour, so a run
85
+ * showing the canvas and the page around it cannot come out different colours.
70
86
  */
71
87
  export function exportCanvas(theme) {
72
88
  return {
@@ -77,29 +93,31 @@ export function exportCanvas(theme) {
77
93
  /**
78
94
  * A `Style` as it appears on screen under `theme`.
79
95
  *
80
- * The order is the terminal's: colours resolve through the theme and lose
81
- * their alpha — paper over the canvas, ink over the paper, as `toSgrCodes`
82
- * flattens them — `reverse` swaps them, `dim` fades the glyph toward whatever
83
- * background it ended up on, and `conceal` finally paints the glyph in that
84
- * background. Every step after flattening works on opaque colour, so no
85
- * exporter ever draws a translucent one. Text under `conceal` is still
86
- * present, and still selectable, in both formats.
96
+ * The order is the terminal's: the colours are `Style.drawnColors`, the ones
97
+ * `toSgrCodes` writes, resolved through the theme — `reverse` swaps them,
98
+ * `dim` fades the glyph toward whatever background it ended up on, and
99
+ * `conceal` finally paints the glyph in that background. Every step works on
100
+ * opaque colour, so no exporter ever draws a translucent one. Text under
101
+ * `conceal` is still present, and still selectable, in both formats.
87
102
  *
88
- * The one difference from `toSgrCodes` is the substrate: a terminal cannot
89
- * know what lies under its cells and assumes black, while an export draws its
90
- * own canvas and flattens over that.
103
+ * A translucent colour is flattened where the writer flattens it, over
104
+ * `SURFACE_BLACK`, not over this export's canvas. The writer's truecolor
105
+ * bytes are absolute — no terminal theme retints them — so a terminal under
106
+ * `theme` shows that colour, and an export that flattened over its own canvas
107
+ * would picture a colour no terminal draws.
91
108
  *
92
109
  * `theme` omitted is `ColorSpec.getTruecolor`'s own fallback — black canvas,
93
110
  * white ink, the standard ANSI table. Choosing it there rather than naming a
94
111
  * preset here is what keeps `core/` from a third upward edge into `themes/`.
95
112
  */
96
113
  export function resolveLook(style, theme) {
97
- const inkSpec = style.color ?? DEFAULT_COLOR;
98
- const paperSpec = style.bgcolor ?? DEFAULT_COLOR;
99
- const canvas = exportCanvas(theme).background;
100
- // [LAW:single-enforcer] `flattenAlpha` is the one place alpha is composited.
101
- const paper = paperSpec.flattenAlpha(canvas).getTruecolor(theme, false);
102
- const ink = inkSpec.flattenAlpha(paper).getTruecolor(theme, true);
114
+ // [LAW:one-source-of-truth] `drawnColors` is the one account of what a style
115
+ // puts on screen; the export reads it rather than flattening a second way.
116
+ const drawn = style.drawnColors();
117
+ const inkSpec = drawn.color ?? DEFAULT_COLOR;
118
+ const paperSpec = drawn.bgcolor ?? DEFAULT_COLOR;
119
+ const paper = paperSpec.getTruecolor(theme, false);
120
+ const ink = inkSpec.getTruecolor(theme, true);
103
121
  // A reversed run always paints: its background is the ink, which is never
104
122
  // the canvas, even when the ink is the theme's default foreground.
105
123
  const glyph = style.reverse ? paper : ink;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * export-svg — recorded segments drawn as an SVG terminal window: rounded
3
+ * chrome with a title and three window buttons, the rows inside it, every
4
+ * glyph where its terminal cell was, and the text still selectable.
5
+ *
6
+ * An encoding of `export-lines` and nothing more, the same as `export-html`.
7
+ * What a run looks like under a theme is decided there; this module decides
8
+ * only where SVG puts it. It never sees a `Style`.
9
+ *
10
+ * The geometry is Rich's — a 20px character, a cell 0.61 of that wide, a row
11
+ * 1.22 of it tall, the same margin and padding — so a screenshot of the same
12
+ * program matches the library this ports. The frame's colour is not Rich's:
13
+ * Rich strokes it white at 35% whatever the theme, which vanishes around a
14
+ * light canvas. Here it is the theme's ink blended 35% into the canvas — over
15
+ * a black canvas the colour Rich draws — and opaque, so the half of the stroke
16
+ * lying outside the canvas does not take on the colour of the page around it.
17
+ *
18
+ * Nor is how a glyph lands on its cell. The exporter has no font engine and
19
+ * does not know which font the viewer will draw with, so no advance width can
20
+ * be trusted: a CJK character drawn from a fallback font is rarely two cells,
21
+ * and every glyph after it on the row drifts. So every chunk of a run is
22
+ * anchored at its own column and stretched to its own cells with
23
+ * `textLength`, and alignment is guaranteed by the numbers written here rather
24
+ * than by the font. Measured in headless Chromium on `"ab漢字cd😀ef é̂x"`:
25
+ * Rich's `textLength` (cell width × string length) was off by up to 12px, one
26
+ * `<text>` a run by 3.9px, this chunking by 0.
27
+ *
28
+ * Decorations are rectangles, not CSS `text-decoration`: SVG has no reliable
29
+ * double underline, and one `text-decoration` property cannot hold an
30
+ * underline and a strike with different styles.
31
+ */
32
+ import { type TerminalTheme } from "./color.js";
33
+ import type { Segment } from "./segment.js";
34
+ export interface SvgOptions {
35
+ readonly theme?: TerminalTheme;
36
+ /** The window title, centred in the chrome. */
37
+ readonly title: string;
38
+ /** The console width in cells; a wider row widens the window rather than being clipped. */
39
+ readonly width: number;
40
+ }
41
+ /**
42
+ * `segments` under `theme` as a standalone SVG document.
43
+ *
44
+ * Every background is painted before any glyph, so no run's paint covers its
45
+ * neighbour's text. Whitespace is emitted as real spaces under
46
+ * `xml:space="preserve"`, never as `&#160;`, so a selection copies what the
47
+ * terminal showed.
48
+ */
49
+ export declare function encodeSvg(segments: Iterable<Segment>, { theme, title, width }: SvgOptions): string;
@@ -0,0 +1,213 @@
1
+ /**
2
+ * export-svg — recorded segments drawn as an SVG terminal window: rounded
3
+ * chrome with a title and three window buttons, the rows inside it, every
4
+ * glyph where its terminal cell was, and the text still selectable.
5
+ *
6
+ * An encoding of `export-lines` and nothing more, the same as `export-html`.
7
+ * What a run looks like under a theme is decided there; this module decides
8
+ * only where SVG puts it. It never sees a `Style`.
9
+ *
10
+ * The geometry is Rich's — a 20px character, a cell 0.61 of that wide, a row
11
+ * 1.22 of it tall, the same margin and padding — so a screenshot of the same
12
+ * program matches the library this ports. The frame's colour is not Rich's:
13
+ * Rich strokes it white at 35% whatever the theme, which vanishes around a
14
+ * light canvas. Here it is the theme's ink blended 35% into the canvas — over
15
+ * a black canvas the colour Rich draws — and opaque, so the half of the stroke
16
+ * lying outside the canvas does not take on the colour of the page around it.
17
+ *
18
+ * Nor is how a glyph lands on its cell. The exporter has no font engine and
19
+ * does not know which font the viewer will draw with, so no advance width can
20
+ * be trusted: a CJK character drawn from a fallback font is rarely two cells,
21
+ * and every glyph after it on the row drifts. So every chunk of a run is
22
+ * anchored at its own column and stretched to its own cells with
23
+ * `textLength`, and alignment is guaranteed by the numbers written here rather
24
+ * than by the font. Measured in headless Chromium on `"ab漢字cd😀ef é̂x"`:
25
+ * Rich's `textLength` (cell width × string length) was off by up to 12px, one
26
+ * `<text>` a run by 3.9px, this chunking by 0.
27
+ *
28
+ * Decorations are rectangles, not CSS `text-decoration`: SVG has no reliable
29
+ * double underline, and one `text-decoration` property cannot hold an
30
+ * underline and a strike with different styles.
31
+ */
32
+ import { cellLen, graphemes } from "./cells.js";
33
+ import { blendRgb } from "./color.js";
34
+ import { fnv1a } from "./fnv1a.js";
35
+ import { escapeAttribute, escapeText, exportCanvas, exportLines, } from "./export-lines.js";
36
+ const CHAR_HEIGHT = 20;
37
+ const CELL_WIDTH = CHAR_HEIGHT * 0.61;
38
+ const LINE_HEIGHT = CHAR_HEIGHT * 1.22;
39
+ const MARGIN = 1;
40
+ const PADDING = { top: 40, right: 8, bottom: 8, left: 8 };
41
+ /** A painted background sits 1.5px into its row and overlaps the next by a hair, so rows show no seam. */
42
+ const CELL_TOP = 1.5;
43
+ const CELL_HEIGHT = LINE_HEIGHT + 0.25;
44
+ const LINE_THICKNESS = 1.5;
45
+ const FONT_FAMILY = `"Fira Code", Menlo, Consolas, "DejaVu Sans Mono", monospace`;
46
+ const FRAME_FADE = 0.35;
47
+ const WINDOW_BUTTONS = ["#ff5f57", "#febc2e", "#28c840"];
48
+ /** Coordinates to two decimals, so `3 × 12.2` is written `36.6` and not `36.599999999999994`. */
49
+ const n = (value) => String(Math.round(value * 100) / 100);
50
+ /**
51
+ * The blink keyframes every SVG defines identically, so one name serves them
52
+ * all: two inlined in one page define the same rule twice, and neither changes
53
+ * the other.
54
+ */
55
+ const BLINK_KEYFRAMES = "rich-svg-blink";
56
+ /**
57
+ * `text` cut into the pieces that are positioned one by one, starting at
58
+ * `column`: consecutive one-cell graphemes share a chunk, since the font's
59
+ * own advance is right for them to within what `textLength` corrects; every
60
+ * wider grapheme is a chunk of its own, since that advance is the one that
61
+ * drifts. A zero-cell grapheme joins the chunk before it so it can still
62
+ * combine, and only when a run opens with one does it stand as a chunk of no
63
+ * cells. Graphemes rather than code points, because `cellLen` measures a
64
+ * joined emoji as one glyph.
65
+ */
66
+ function chunk(text, column) {
67
+ const pieces = [];
68
+ for (const grapheme of graphemes(text)) {
69
+ const cells = cellLen(grapheme);
70
+ const last = pieces[pieces.length - 1];
71
+ if (last !== undefined && (cells === 0 || (cells === 1 && last.narrow))) {
72
+ last.text += grapheme;
73
+ last.cells += cells;
74
+ }
75
+ else {
76
+ pieces.push({ text: grapheme, cells, narrow: cells === 1 });
77
+ }
78
+ }
79
+ let at = column;
80
+ return pieces.map(({ text, cells }) => {
81
+ const placed = { text, column: at, cells };
82
+ at += cells;
83
+ return placed;
84
+ });
85
+ }
86
+ /** Every run of every row at the column the runs before it on its row end at. */
87
+ function place(rows) {
88
+ return rows.flatMap((runs, row) => {
89
+ let column = 0;
90
+ return runs.map(({ text, look }) => {
91
+ const chunks = chunk(text, column);
92
+ const cells = chunks.reduce((sum, c) => sum + c.cells, 0);
93
+ const placed = { row, column, cells, look, chunks };
94
+ column += cells;
95
+ return placed;
96
+ });
97
+ });
98
+ }
99
+ // [LAW:dataflow-not-control-flow] Each enum-valued field of a look is a table
100
+ // from its values to what it draws, so `Record` makes a new value a compile
101
+ // error until it has a row.
102
+ const UNDERLINE_BELOW_BASELINE = {
103
+ none: [],
104
+ single: [2],
105
+ double: [2, 5],
106
+ };
107
+ const BLINK_PERIOD = {
108
+ none: null,
109
+ slow: "1s",
110
+ fast: "0.5s",
111
+ };
112
+ const OUTLINE_RADIUS = {
113
+ none: null,
114
+ frame: 0,
115
+ encircle: CELL_WIDTH,
116
+ };
117
+ /** The CSS a look's class carries: what a glyph's `fill` and font say, and its blink. */
118
+ function lookCss(look) {
119
+ const period = BLINK_PERIOD[look.blink];
120
+ return [
121
+ `fill:${look.foreground.hex}`,
122
+ ...(look.bold ? ["font-weight:bold"] : []),
123
+ ...(look.italic ? ["font-style:italic"] : []),
124
+ ...(period === null ? [] : [`animation:${BLINK_KEYFRAMES} ${period} step-end infinite`]),
125
+ ].join(";");
126
+ }
127
+ const cellBox = (run) => `x="${n(run.column * CELL_WIDTH)}" y="${n(run.row * LINE_HEIGHT + CELL_TOP)}" ` +
128
+ `width="${n(run.cells * CELL_WIDTH)}" height="${n(CELL_HEIGHT)}"`;
129
+ /** A run's painted background; a run showing the canvas paints nothing. */
130
+ function backgroundSvg(run) {
131
+ return run.look.background === "canvas"
132
+ ? []
133
+ : [`<rect fill="${run.look.background.hex}" ${cellBox(run)} shape-rendering="crispEdges"/>`];
134
+ }
135
+ function textSvg({ text, column, cells }, baseline) {
136
+ // A chunk of no cells has no length to stretch to; it takes the font's.
137
+ const length = cells === 0 ? "" : ` textLength="${n(cells * CELL_WIDTH)}" lengthAdjust="spacingAndGlyphs"`;
138
+ return `<text x="${n(column * CELL_WIDTH)}" y="${n(baseline)}"${length} xml:space="preserve">${escapeText(text)}</text>`;
139
+ }
140
+ /**
141
+ * One run: its glyphs, then its lines and outline over them, in a group whose
142
+ * class carries the look — so the lines take the glyph's `fill` and blink
143
+ * with it. The outline is a stroke, so it names its colour itself.
144
+ */
145
+ function runSvg(run, className) {
146
+ const { look } = run;
147
+ const top = run.row * LINE_HEIGHT;
148
+ const baseline = top + CHAR_HEIGHT;
149
+ const x = n(run.column * CELL_WIDTH);
150
+ const width = n(run.cells * CELL_WIDTH);
151
+ const lineYs = [
152
+ ...UNDERLINE_BELOW_BASELINE[look.underline].map((offset) => baseline + offset),
153
+ ...(look.strike ? [baseline - 6] : []),
154
+ ...(look.overline ? [top + CELL_TOP] : []),
155
+ ];
156
+ const radius = OUTLINE_RADIUS[look.outline];
157
+ const drawn = [
158
+ ...run.chunks.map((c) => textSvg(c, baseline)),
159
+ ...lineYs.map((y) => `<rect x="${x}" y="${n(y)}" width="${width}" height="${LINE_THICKNESS}"/>`),
160
+ ...(radius === null
161
+ ? []
162
+ : [`<rect ${cellBox(run)} rx="${n(radius)}" fill="none" stroke="${look.foreground.hex}" stroke-width="1"/>`]),
163
+ ].join("");
164
+ const group = `<g class="${className}">${drawn}</g>`;
165
+ return look.href === null ? group : `<a href="${escapeAttribute(look.href)}">${group}</a>`;
166
+ }
167
+ /**
168
+ * `segments` under `theme` as a standalone SVG document.
169
+ *
170
+ * Every background is painted before any glyph, so no run's paint covers its
171
+ * neighbour's text. Whitespace is emitted as real spaces under
172
+ * `xml:space="preserve"`, never as `&#160;`, so a selection copies what the
173
+ * terminal showed.
174
+ */
175
+ export function encodeSvg(segments, { theme, title, width }) {
176
+ const canvas = exportCanvas(theme);
177
+ const rows = exportLines(segments, theme);
178
+ const runs = place(rows);
179
+ // The prefix every class name carries is a hash of the rules those classes
180
+ // define as well as of what is drawn, since a class is page-wide once the
181
+ // SVG is inlined: two exports that share a prefix define every rule alike, so
182
+ // the same recording under another theme cannot restyle this one.
183
+ const styled = runs.map((run) => ({ run, css: lookCss(run.look) }));
184
+ const rules = [...new Set(styled.map(({ css }) => css))];
185
+ const text = rows.map((row) => row.map((run) => run.text).join("")).join("\n");
186
+ const id = `terminal-${fnv1a(`${title.length}:${title}${text.length}:${text}${rules.join("\n")}`)}`;
187
+ const classOf = (css) => `${id}-r${rules.indexOf(css) + 1}`;
188
+ const drawnRuns = styled.map(({ run, css }) => runSvg(run, classOf(css)));
189
+ const columns = runs.reduce((widest, run) => Math.max(widest, run.column + run.cells), width);
190
+ const terminalWidth = Math.ceil(columns * CELL_WIDTH + PADDING.left + PADDING.right);
191
+ const terminalHeight = rows.length * LINE_HEIGHT + PADDING.top + PADDING.bottom;
192
+ const stylesheet = [
193
+ `.${id}-matrix{font-family:${FONT_FAMILY};font-size:${CHAR_HEIGHT}px}`,
194
+ `.${id}-title{font-size:18px;font-weight:bold;font-family:arial}`,
195
+ ...rules.map((css) => `.${classOf(css)}{${css}}`),
196
+ // Blink only for a reader who has not asked for reduced motion, as in HTML.
197
+ `@media (prefers-reduced-motion:no-preference){@keyframes ${BLINK_KEYFRAMES}{50%{fill:transparent}}}`,
198
+ ].join("\n");
199
+ return [
200
+ `<svg class="rich-terminal" viewBox="0 0 ${n(terminalWidth + 2 * MARGIN)} ${n(terminalHeight + 2 * MARGIN)}" xmlns="http://www.w3.org/2000/svg">`,
201
+ `<style>\n${stylesheet}\n</style>`,
202
+ `<rect fill="${canvas.background.hex}" stroke="${blendRgb(canvas.background, canvas.foreground, FRAME_FADE).hex}" stroke-width="1" ` +
203
+ `x="${MARGIN}" y="${MARGIN}" width="${n(terminalWidth)}" height="${n(terminalHeight)}" rx="8"/>`,
204
+ `<text class="${id}-title" fill="${canvas.foreground.hex}" text-anchor="middle" ` +
205
+ `x="${n(MARGIN + terminalWidth / 2)}" y="${MARGIN + CHAR_HEIGHT + 6}">${escapeText(title)}</text>`,
206
+ `<g transform="translate(26,22)">${WINDOW_BUTTONS.map((fill, i) => `<circle cx="${i * 22}" cy="0" r="7" fill="${fill}"/>`).join("")}</g>`,
207
+ `<g class="${id}-matrix" transform="translate(${MARGIN + PADDING.left},${MARGIN + PADDING.top})">`,
208
+ ...runs.flatMap(backgroundSvg),
209
+ ...drawnRuns,
210
+ `</g>`,
211
+ `</svg>`,
212
+ ].join("\n");
213
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * fnv1a — FNV-1a, 32-bit, over a string's UTF-8 bytes, as 8 lowercase hex
3
+ * digits: the short deterministic name this library derives wherever output
4
+ * needs an identifier that is a function of what it names — an OSC 8 link id,
5
+ * an SVG's class prefix — rather than of a counter or a clock.
6
+ *
7
+ * UTF-8 rather than UTF-16 code units, so any other runtime computing standard
8
+ * FNV-1a over the same text gets the same digits.
9
+ */
10
+ export declare function fnv1a(text: string): string;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * fnv1a — FNV-1a, 32-bit, over a string's UTF-8 bytes, as 8 lowercase hex
3
+ * digits: the short deterministic name this library derives wherever output
4
+ * needs an identifier that is a function of what it names — an OSC 8 link id,
5
+ * an SVG's class prefix — rather than of a counter or a clock.
6
+ *
7
+ * UTF-8 rather than UTF-16 code units, so any other runtime computing standard
8
+ * FNV-1a over the same text gets the same digits.
9
+ */
10
+ const utf8 = new TextEncoder();
11
+ export function fnv1a(text) {
12
+ let hash = 0x811c9dc5;
13
+ for (const byte of utf8.encode(text)) {
14
+ hash = Math.imul(hash ^ byte, 0x01000193);
15
+ }
16
+ return (hash >>> 0).toString(16).padStart(8, "0");
17
+ }