@promptctl/rich-js 0.24.1 → 0.25.1

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 (156) hide show
  1. package/README.md +7 -0
  2. package/dist/core/anchor.d.ts +5 -0
  3. package/dist/core/anchor.js +11 -4
  4. package/dist/core/animation.d.ts +175 -10
  5. package/dist/core/animation.js +283 -15
  6. package/dist/core/ansi.js +2 -2
  7. package/dist/core/box.d.ts +7 -7
  8. package/dist/core/cells.d.ts +23 -13
  9. package/dist/core/cells.js +58 -48
  10. package/dist/core/clock.d.ts +2 -2
  11. package/dist/core/clock.js +6 -4
  12. package/dist/core/color.d.ts +27 -10
  13. package/dist/core/color.js +24 -33
  14. package/dist/core/console.d.ts +28 -17
  15. package/dist/core/console.js +44 -39
  16. package/dist/core/easing.d.ts +30 -0
  17. package/dist/core/easing.js +65 -20
  18. package/dist/core/emoji.d.ts +1 -1
  19. package/dist/core/emoji.js +1 -2
  20. package/dist/core/export-html.js +2 -1
  21. package/dist/core/export-lines.js +3 -3
  22. package/dist/core/highlighter.d.ts +9 -8
  23. package/dist/core/highlighter.js +3 -2
  24. package/dist/core/json.js +2 -2
  25. package/dist/core/markup.js +16 -12
  26. package/dist/core/measure.js +2 -2
  27. package/dist/core/memo.d.ts +11 -2
  28. package/dist/core/memo.js +16 -0
  29. package/dist/core/oklch.js +17 -6
  30. package/dist/core/osc8.d.ts +1 -1
  31. package/dist/core/osc8.js +4 -5
  32. package/dist/core/paint.d.ts +2 -1
  33. package/dist/core/paint.js +4 -3
  34. package/dist/core/pattern.d.ts +21 -0
  35. package/dist/core/pattern.js +21 -0
  36. package/dist/core/pretty.js +9 -10
  37. package/dist/core/protocol.d.ts +100 -39
  38. package/dist/core/protocol.js +167 -21
  39. package/dist/core/render.d.ts +15 -2
  40. package/dist/core/render.js +25 -5
  41. package/dist/core/segment.d.ts +13 -5
  42. package/dist/core/segment.js +23 -9
  43. package/dist/core/spinnerData.d.ts +1 -1
  44. package/dist/core/state.d.ts +53 -0
  45. package/dist/core/state.js +82 -0
  46. package/dist/core/strip.d.ts +98 -29
  47. package/dist/core/strip.js +211 -150
  48. package/dist/core/style.d.ts +47 -14
  49. package/dist/core/style.js +57 -28
  50. package/dist/core/text.d.ts +5 -8
  51. package/dist/core/text.js +116 -124
  52. package/dist/core/timeline.d.ts +30 -5
  53. package/dist/core/timeline.js +43 -7
  54. package/dist/core/tween.d.ts +1 -1
  55. package/dist/core/tween.js +5 -3
  56. package/dist/core/view.d.ts +92 -0
  57. package/dist/core/view.js +83 -0
  58. package/dist/host/app.d.ts +49 -21
  59. package/dist/host/app.js +71 -37
  60. package/dist/host/input.js +3 -4
  61. package/dist/index.d.ts +24 -12
  62. package/dist/index.js +9 -4
  63. package/dist/node/traceback.js +4 -3
  64. package/dist/renderables/align.d.ts +2 -3
  65. package/dist/renderables/align.js +0 -3
  66. package/dist/renderables/change.d.ts +42 -0
  67. package/dist/renderables/change.js +112 -0
  68. package/dist/renderables/columns.d.ts +3 -4
  69. package/dist/renderables/columns.js +2 -7
  70. package/dist/renderables/constrain.d.ts +2 -3
  71. package/dist/renderables/constrain.js +0 -3
  72. package/dist/renderables/effect.d.ts +158 -63
  73. package/dist/renderables/effect.js +378 -155
  74. package/dist/renderables/effects.d.ts +122 -11
  75. package/dist/renderables/effects.js +238 -94
  76. package/dist/renderables/embed.d.ts +1 -1
  77. package/dist/renderables/embed.js +16 -8
  78. package/dist/renderables/flexStrip.d.ts +2 -3
  79. package/dist/renderables/flexStrip.js +0 -4
  80. package/dist/renderables/frames.d.ts +35 -54
  81. package/dist/renderables/frames.js +43 -58
  82. package/dist/renderables/glyph-effect.d.ts +158 -0
  83. package/dist/renderables/glyph-effect.js +237 -0
  84. package/dist/renderables/glyph-effects.d.ts +147 -0
  85. package/dist/renderables/glyph-effects.js +283 -0
  86. package/dist/renderables/group.d.ts +3 -4
  87. package/dist/renderables/group.js +0 -3
  88. package/dist/renderables/layout.d.ts +12 -12
  89. package/dist/renderables/layout.js +40 -46
  90. package/dist/renderables/lifecycle.d.ts +77 -0
  91. package/dist/renderables/lifecycle.js +132 -0
  92. package/dist/renderables/list-view.d.ts +4 -5
  93. package/dist/renderables/list-view.js +9 -13
  94. package/dist/renderables/live.d.ts +36 -21
  95. package/dist/renderables/live.js +88 -41
  96. package/dist/renderables/markdown-parse.d.ts +4 -4
  97. package/dist/renderables/markdown-parse.js +52 -46
  98. package/dist/renderables/markdown-url.js +11 -11
  99. package/dist/renderables/padding.d.ts +4 -5
  100. package/dist/renderables/padding.js +0 -3
  101. package/dist/renderables/panel.d.ts +14 -16
  102. package/dist/renderables/panel.js +6 -14
  103. package/dist/renderables/progress.d.ts +83 -34
  104. package/dist/renderables/progress.js +182 -95
  105. package/dist/renderables/progressBar.d.ts +22 -12
  106. package/dist/renderables/progressBar.js +32 -9
  107. package/dist/renderables/prompt.js +3 -2
  108. package/dist/renderables/python-space.js +2 -1
  109. package/dist/renderables/scroll.d.ts +50 -0
  110. package/dist/renderables/scroll.js +106 -0
  111. package/dist/renderables/spinner.d.ts +35 -14
  112. package/dist/renderables/spinner.js +65 -25
  113. package/dist/renderables/status.js +1 -1
  114. package/dist/renderables/syntax.d.ts +10 -9
  115. package/dist/renderables/syntax.js +3 -2
  116. package/dist/renderables/table.d.ts +34 -40
  117. package/dist/renderables/table.js +41 -63
  118. package/dist/renderables/traceback.d.ts +2 -2
  119. package/dist/renderables/traceback.js +6 -5
  120. package/dist/renderables/tree.d.ts +27 -25
  121. package/dist/renderables/tree.js +104 -58
  122. package/dist/renderables/viewport.d.ts +75 -44
  123. package/dist/renderables/viewport.js +145 -82
  124. package/dist/template-bindings/animation-funcs.d.ts +78 -9
  125. package/dist/template-bindings/animation-funcs.js +132 -14
  126. package/dist/template-bindings/index.d.ts +5 -5
  127. package/dist/template-bindings/index.js +5 -5
  128. package/dist/themes/ramp.d.ts +46 -3
  129. package/dist/themes/ramp.js +61 -4
  130. package/dist/themes/registry.js +5 -7
  131. package/dist/widgets/button.js +3 -2
  132. package/dist/widgets/checkbox.js +4 -3
  133. package/dist/widgets/dropdown.d.ts +3 -1
  134. package/dist/widgets/dropdown.js +20 -15
  135. package/dist/widgets/event-router.js +4 -2
  136. package/dist/widgets/focus-manager.d.ts +2 -0
  137. package/dist/widgets/focus-manager.js +6 -4
  138. package/dist/widgets/hit.d.ts +6 -0
  139. package/dist/widgets/hit.js +8 -1
  140. package/dist/widgets/observable.d.ts +23 -0
  141. package/dist/widgets/observable.js +43 -0
  142. package/dist/widgets/slider.d.ts +1 -1
  143. package/dist/widgets/slider.js +12 -3
  144. package/dist/widgets/static-item.d.ts +2 -3
  145. package/dist/widgets/static-item.js +0 -3
  146. package/dist/widgets/text-input.d.ts +28 -32
  147. package/dist/widgets/text-input.js +360 -346
  148. package/dist/widgets/themed-widget.js +2 -1
  149. package/dist/widgets/toggle.js +4 -3
  150. package/dist/widgets/types.d.ts +10 -4
  151. package/dist/widgets/types.js +8 -2
  152. package/dist/widgets/widget-app.d.ts +14 -4
  153. package/dist/widgets/widget-app.js +36 -7
  154. package/dist/widgets/widget-base.d.ts +2 -2
  155. package/dist/widgets/widget-base.js +29 -18
  156. package/package.json +8 -1
@@ -113,6 +113,108 @@ export function withCellWidth(options) {
113
113
  export function drawsNothing(options) {
114
114
  return cellCount(options.maxWidth) < 1;
115
115
  }
116
+ // [LAW:no-shared-mutable-globals] The render phase, owned here and nowhere
117
+ // else: only `render` and `measure` below open or enter a pass, and the rest of
118
+ // the library reads it through `activePass` and `afterPass`. Set and restored
119
+ // like a stack, so "not rendering" is `undefined` and needs no counter.
120
+ let active;
121
+ function runIn(phase, run) {
122
+ const outer = active;
123
+ active = phase;
124
+ try {
125
+ return run();
126
+ }
127
+ finally {
128
+ active = outer;
129
+ }
130
+ }
131
+ /**
132
+ * `handler` run as if between passes: a callback a render makes to user code to
133
+ * report something it met, such as `onStyleError`, is an event handler, and may
134
+ * write what one may — print to a console, set a `State` — as React lets an
135
+ * event handler set state.
136
+ */
137
+ export function betweenPasses(handler) {
138
+ return runIn(undefined, handler);
139
+ }
140
+ /** The pass a render or measure in progress belongs to, or `undefined` between passes. */
141
+ export function activePass() {
142
+ return active?.pass;
143
+ }
144
+ /**
145
+ * `effect` run once the render pass in progress has drawn its last segment,
146
+ * between passes, where it may write what an event handler may: React's commit
147
+ * phase. A render that learns something only drawing can tell it — the lines
148
+ * content shown through a `Viewport` runs to at the width it was handed —
149
+ * draws from what it learned and hands the commit here, rather than writing it
150
+ * as it draws.
151
+ *
152
+ * Effects run in the order they were handed over, which is document order. A
153
+ * measurement commits nothing, and neither does a pass that throws or whose
154
+ * caller stops iterating it early: nobody saw what it drew. Outside a pass — a
155
+ * renderable's `render` called directly — there is nothing to wait for, and
156
+ * `effect` runs now.
157
+ */
158
+ export function afterPass(effect) {
159
+ if (active === undefined)
160
+ effect();
161
+ else
162
+ active.effects.push(effect);
163
+ }
164
+ /** The pass in progress, or a new one when this call is the outermost. */
165
+ const phaseHere = () => active ?? { pass: {}, effects: [] };
166
+ /**
167
+ * Every effect `phase`'s renders handed it, run now that it has ended — between
168
+ * passes even when the iteration that ended it was stepped inside another.
169
+ */
170
+ function commit(phase) {
171
+ runIn(undefined, () => {
172
+ for (const effect of phase.effects.splice(0))
173
+ effect();
174
+ });
175
+ }
176
+ /**
177
+ * `drawn` with every step of its iteration run in `phase`, and its effects
178
+ * committed when the last step is done. A render is often a generator, whose
179
+ * body runs as its caller iterates — after `render` has returned — so the pass
180
+ * is re-entered on each `next()`, however many `yield`s a write sits behind,
181
+ * and ends only when the iteration does. An array has nothing left to run, so
182
+ * its pass has already ended.
183
+ *
184
+ * Only the call that opens the pass is wrapped. A nested render's steps run
185
+ * inside its parent's, which are already in the pass; a nested iterable could
186
+ * only be stepped outside it by being kept somewhere past the render, which
187
+ * takes a writable location `test/seam/render-purity.test.ts` refuses. Wrapping
188
+ * every level cost a third of the render time, once per segment per level.
189
+ */
190
+ function withinPass(phase, drawn) {
191
+ if (Array.isArray(drawn)) {
192
+ commit(phase);
193
+ return drawn;
194
+ }
195
+ return {
196
+ [Symbol.iterator]: () => {
197
+ const steps = runIn(phase, () => drawn[Symbol.iterator]());
198
+ // On `%IteratorPrototype%`, as a generator's iterator is: iterable
199
+ // itself, and carrying the iterator helpers where the engine has them.
200
+ return Object.assign(Object.create(ITERATOR_PROTOTYPE), {
201
+ next: () => {
202
+ const step = runIn(phase, () => steps.next());
203
+ if (step.done === true)
204
+ commit(phase);
205
+ return step;
206
+ },
207
+ return: (value) => runIn(phase, () => steps.return?.(value) ?? { done: true, value: undefined }),
208
+ throw: (error) => runIn(phase, () => {
209
+ if (steps.throw)
210
+ return steps.throw(error);
211
+ throw error;
212
+ }),
213
+ });
214
+ },
215
+ };
216
+ }
217
+ const ITERATOR_PROTOTYPE = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]()));
116
218
  /**
117
219
  * `renderable` drawn under `options`: Rich's `Console.render`, which every
118
220
  * renderable there is drawn through, so under one column nothing is.
@@ -124,34 +226,81 @@ export function drawsNothing(options) {
124
226
  * this, each container asked `drawsNothing` itself, and the ones that forgot
125
227
  * drew a child handed no column anyway.
126
228
  *
229
+ * It is where a render pass is entered (`Pass`), so it is the one place a
230
+ * write during render is refused at: a `State` set in a pass that did not make
231
+ * it throws (`./state.ts`).
232
+ *
127
233
  * It is also the one reporter of what the view holds (`onPresent`). Under one
128
- * column a renderable is held, not drawn: what it holds (`Composite`) is
129
- * walked and reported held in turn, and nothing renders, so no renderable
130
- * that keeps state while it renders — a `Viewport`'s offset, a `TextInput`'s
131
- * scroll window — ever commits it at a width that is not shown. The walk runs
132
- * whether or not anyone listens. Drawing the squeezed subtree at one column
133
- * instead, and dropping what it drew, did commit that state.
234
+ * column a renderable is held: in the view, with nothing of it on the frame.
235
+ * It is drawn all the same and what it drew dropped (`holding`), so what a held
236
+ * container is reported holding is what it draws, under the options it hands
237
+ * each child — one description of a container's traversal, not a second
238
+ * kept beside it.
134
239
  */
135
240
  export function render(renderable, options) {
241
+ const opens = active === undefined;
136
242
  const presence = drawsNothing(options) ? "held" : "drawn";
137
- options.onPresent?.(renderable, presence);
138
- if (presence === "drawn")
139
- return renderable.render(options);
140
- if (isComposite(renderable))
141
- for (const content of renderable.contents())
142
- render(content, options);
143
- return [];
243
+ const { phase, drawing } = presence === "drawn" ? { phase: phaseHere(), drawing: options } : holding(options);
244
+ const drawn = runIn(phase, () => {
245
+ options.onPresent?.(renderable, presence);
246
+ return renderable.render(drawing);
247
+ });
248
+ if (presence === "held") {
249
+ runIn(phase, () => {
250
+ for (const _ of drawn)
251
+ ;
252
+ });
253
+ return [];
254
+ }
255
+ return opens ? withinPass(phase, drawn) : drawn;
256
+ }
257
+ /**
258
+ * Where a held renderable is drawn, and the options it is drawn under: what
259
+ * it draws is dropped, and everything it draws is reported held, whatever
260
+ * width it hands it. What it tells of its hold (`onHold`) still reaches the
261
+ * frame: an element not yet entered is held at no width, and must say when it
262
+ * enters and takes some. A motion held at no width therefore wakes its host
263
+ * for frames that look the same: when it next takes room is not a moment its
264
+ * hold can name, and an early answer costs a frame where a late one hides the
265
+ * element. It commits nothing, as a measurement commits nothing —
266
+ * its effects (`afterPass`) are learned at a width nobody sees, so a
267
+ * `Viewport` it shows content through keeps the scroll the last frame shown
268
+ * left it.
269
+ *
270
+ * [LAW:parse-dont-validate] It is drawn at no column, 0, whatever width under
271
+ * one its container worked out: `drawsNothing` reads a negative or NaN width
272
+ * as none, so none reaches a `render`, where it would be a count no string
273
+ * can repeat.
274
+ */
275
+ function holding(options) {
276
+ const { onPresent } = options;
277
+ return {
278
+ phase: { pass: phaseHere().pass, effects: [] },
279
+ drawing: { ...options, maxWidth: 0, onPresent: onPresent && ((content) => onPresent(content, "held")) },
280
+ };
281
+ }
282
+ /**
283
+ * What `measurable` reports under `options`: asked in a render pass, as
284
+ * `render` draws in one, with `measuring`'s options.
285
+ *
286
+ * [LAW:single-enforcer] Every call of a `Measurable`'s `measure` in `src/`
287
+ * goes through here, as every `render` goes through `render`;
288
+ * `test/seam/render-calls.test.ts` holds both.
289
+ */
290
+ export function measure(measurable, options) {
291
+ // The pass's identity, with effects nothing runs: a measurement commits nothing.
292
+ return runIn({ pass: phaseHere().pass, effects: [] }, () => measurable.measure(measuring(options)));
144
293
  }
145
294
  /**
146
295
  * `options` as a measurement is asked with. A `measure` that renders to learn
147
296
  * its shape draws nothing on the frame, so it reports nothing to `onPresent` —
148
297
  * one that did would put a widget in the view at the moment a container
149
- * measured it, and keep one the frame then cropped away.
298
+ * measured it, and keep one the frame then cropped away. It still tells its
299
+ * hold (`onHold`): a layout decided by a measurement changes when it does.
150
300
  *
151
- * [LAW:single-enforcer] Every measurement starts here: `Measurement.get` and
152
- * `withBoundedWidth` ask through it, and each `measure` passes it on.
301
+ * [LAW:single-enforcer] Every measurement starts here: `measure` asks with it.
153
302
  */
154
- export function measuring(options) {
303
+ function measuring(options) {
155
304
  return { ...options, onPresent: undefined };
156
305
  }
157
306
  /**
@@ -193,7 +342,7 @@ export function withBoundedWidth(options, self) {
193
342
  const parsed = withCellWidth(options);
194
343
  if (Number.isFinite(parsed.maxWidth))
195
344
  return parsed;
196
- const natural = self.measure(measuring(parsed)).maximum;
345
+ const natural = measure(self, parsed).maximum;
197
346
  if (!Number.isFinite(natural)) {
198
347
  throw new RangeError("maxWidth is unbounded and this renderable has no natural width to fall " +
199
348
  "back on: its content does not implement measure(), or something inside " +
@@ -220,6 +369,3 @@ export function isScrollable(obj) {
220
369
  "scrollBy" in obj &&
221
370
  typeof obj.scrollBy === "function");
222
371
  }
223
- export function isComposite(obj) {
224
- return "contents" in obj && typeof obj.contents === "function";
225
- }
@@ -40,13 +40,16 @@
40
40
  * removes a link — a hyperlink is not a colour, and a NO_COLOR terminal still
41
41
  * follows OSC 8. This is where the port departs from the reference, whose
42
42
  * `Style.render` returns bare text for a null colour system.
43
+ *
44
+ * The third fact, `reducedMotion`, changes the segment rather than its
45
+ * encoding: `shownOn` takes the style's blink out before it is encoded.
43
46
  */
44
47
  import { ColorDepth } from "./color.js";
45
48
  import type { ColorSystemName, Destination } from "./color.js";
46
49
  import type { Env } from "./env.js";
47
- import type { Segment } from "./segment.js";
50
+ import { Segment } from "./segment.js";
48
51
  import type { Renderable, RenderOptions } from "./protocol.js";
49
- export interface RenderToStringOptions extends Pick<RenderOptions, "asciiOnly" | "theme" | "onStyleError" | "highlight" | "highlighter"> {
52
+ export interface RenderToStringOptions extends Pick<RenderOptions, "asciiOnly" | "reducedMotion" | "theme" | "onStyleError" | "highlight" | "highlighter"> {
50
53
  /** Cell width to render into. Default 80. */
51
54
  width?: number;
52
55
  /**
@@ -77,6 +80,16 @@ export interface RenderToStringOptions extends Pick<RenderOptions, "asciiOnly" |
77
80
  */
78
81
  hyperlinks?: boolean;
79
82
  }
83
+ /**
84
+ * `segment` as `destination` shows it: under reduced motion its style
85
+ * `reduced()`, so the terminal is never asked to blink it.
86
+ *
87
+ * [LAW:single-enforcer] The one place reduced motion reaches a style's own
88
+ * motion. Drawing cannot do it: a console lays its print and base styles over
89
+ * what was drawn, after `render`. The encoder writes what this returns, and a
90
+ * recording console records it, so every export replays what was written.
91
+ */
92
+ export declare function shownOn(segment: Segment, destination: Destination): Segment;
80
93
  /**
81
94
  * Encodes one segment as ANSI bytes for `destination`: the reference's
82
95
  * `Style.render` for that segment, and nothing for a control segment.
@@ -40,11 +40,28 @@
40
40
  * removes a link — a hyperlink is not a colour, and a NO_COLOR terminal still
41
41
  * follows OSC 8. This is where the port departs from the reference, whose
42
42
  * `Style.render` returns bare text for a null colour system.
43
+ *
44
+ * The third fact, `reducedMotion`, changes the segment rather than its
45
+ * encoding: `shownOn` takes the style's blink out before it is encoded.
43
46
  */
44
47
  import { ColorDepth, resolveDestination } from "./color.js";
48
+ import { Segment } from "./segment.js";
45
49
  import { render } from "./protocol.js";
46
50
  import { OSC8_CLOSE, osc8Open } from "./osc8.js";
47
51
  const DEFAULT_WIDTH = 80;
52
+ /**
53
+ * `segment` as `destination` shows it: under reduced motion its style
54
+ * `reduced()`, so the terminal is never asked to blink it.
55
+ *
56
+ * [LAW:single-enforcer] The one place reduced motion reaches a style's own
57
+ * motion. Drawing cannot do it: a console lays its print and base styles over
58
+ * what was drawn, after `render`. The encoder writes what this returns, and a
59
+ * recording console records it, so every export replays what was written.
60
+ */
61
+ export function shownOn(segment, destination) {
62
+ const style = destination.reducedMotion ? segment.style?.reduced() : segment.style;
63
+ return style === segment.style ? segment : new Segment(segment.text, style, segment.control);
64
+ }
48
65
  /**
49
66
  * Encodes one segment as ANSI bytes for `destination`: the reference's
50
67
  * `Style.render` for that segment, and nothing for a control segment.
@@ -52,7 +69,7 @@ const DEFAULT_WIDTH = 80;
52
69
  export function segmentToString(segment, destination) {
53
70
  if (segment.isControl)
54
71
  return "";
55
- const { text, style } = segment;
72
+ const { text, style } = shownOn(segment, destination);
56
73
  // As the reference's `if not text` and `if style:` — no pair around nothing.
57
74
  if (text.length === 0 || style === undefined)
58
75
  return text;
@@ -75,18 +92,19 @@ export function renderToString(renderable, options) {
75
92
  // [LAW:dataflow-not-control-flow] An absent spec is truecolor; an explicit
76
93
  // `null` stays null (`??` would collapse it).
77
94
  // [LAW:single-enforcer] Specs resolve through `resolveDestination`.
78
- const detectOptions = {};
95
+ const destinationOptions = { reducedMotion: options?.reducedMotion };
79
96
  if (options?.env !== undefined)
80
- detectOptions.env = options.env;
97
+ destinationOptions.env = options.env;
81
98
  if (options?.isTTY !== undefined)
82
- detectOptions.isTTY = options.isTTY;
99
+ destinationOptions.isTTY = options.isTTY;
83
100
  const rawSpec = options?.colorSystem;
84
- const resolved = resolveDestination(rawSpec === undefined ? ColorDepth.TRUECOLOR : rawSpec, detectOptions);
101
+ const resolved = resolveDestination(rawSpec === undefined ? ColorDepth.TRUECOLOR : rawSpec, destinationOptions);
85
102
  // [LAW:single-enforcer] This call's overrides are applied here, once, to
86
103
  // make the one destination everything below is drawn for. `noColor` is a
87
104
  // colour choice, so it overrides the depth and leaves hyperlinks to the
88
105
  // destination; an explicit `hyperlinks` overrides what the destination takes.
89
106
  const destination = {
107
+ ...resolved,
90
108
  colorSystem: options?.noColor ? null : resolved.colorSystem,
91
109
  hyperlinks: options?.hyperlinks ?? resolved.hyperlinks,
92
110
  };
@@ -94,6 +112,8 @@ export function renderToString(renderable, options) {
94
112
  maxWidth: width,
95
113
  isTerminal: false,
96
114
  asciiOnly: options?.asciiOnly ?? false,
115
+ // [LAW:one-source-of-truth] What is drawn and how it is encoded take one preference.
116
+ reducedMotion: destination.reducedMotion,
97
117
  theme: options?.theme,
98
118
  onStyleError: options?.onStyleError,
99
119
  highlight: options?.highlight,
@@ -23,12 +23,12 @@ export declare enum ControlType {
23
23
  ERASE_IN_LINE = "erase_in_line",
24
24
  SET_WINDOW_TITLE = "set_window_title"
25
25
  }
26
- export type ControlCode = [ControlType, ...unknown[]];
26
+ export type ControlCode = readonly [ControlType, ...unknown[]];
27
27
  export declare class Segment {
28
28
  readonly text: string;
29
29
  readonly style: Style | undefined;
30
- readonly control: ControlCode[] | undefined;
31
- constructor(text: string, style?: Style, control?: ControlCode[]);
30
+ readonly control: readonly ControlCode[] | undefined;
31
+ constructor(text: string, style?: Style, control?: readonly ControlCode[]);
32
32
  get cellLength(): number;
33
33
  get hasText(): boolean;
34
34
  get isControl(): boolean;
@@ -51,7 +51,7 @@ export declare class Segment {
51
51
  * further into whatever drew it, and its anchor says so; see `./anchor.ts`.
52
52
  */
53
53
  private cutAt;
54
- private static _line;
54
+ private static readonly _line;
55
55
  static line(): Segment;
56
56
  /**
57
57
  * `lines`, as drawn by `owner`: every segment stamped with the row and
@@ -62,7 +62,7 @@ export declare class Segment {
62
62
  static anchorLines(lines: Segment[][], owner: object, firstRow?: number): Segment[][];
63
63
  /**
64
64
  * When what `segments` show next changes, in seconds on the timeline of
65
- * whoever drew them: the soonest `Style.nextChange` among them, read off
65
+ * whoever drew them: the soonest `Style.hold` among them, read off
66
66
  * the composed frame the way `anchorAt` reads an owner — `t` itself for a
67
67
  * frame something in moves every frame, `Infinity` for one that never
68
68
  * changes. What a host that drew the frame at `t` asks before drawing
@@ -70,6 +70,14 @@ export declare class Segment {
70
70
  * `t` until it is done, still content never.
71
71
  */
72
72
  static nextChange(segments: Iterable<Segment>): number;
73
+ /**
74
+ * Since when what `segments` show has been what it is, on the same
75
+ * timeline: the latest `Style.hold` among them, the other end of the
76
+ * hold `nextChange` reads — `t` for a frame something in moves every
77
+ * frame, `-Infinity` for one that always was. What a host running its
78
+ * timeline backwards asks before drawing again.
79
+ */
80
+ static lastChange(segments: Iterable<Segment>): number;
73
81
  /**
74
82
  * The anchor of the cell at column `x` of row `y` in a composed frame, or
75
83
  * `undefined` when that cell was drawn by no owner or lies outside the frame.
@@ -65,9 +65,9 @@ export class Segment {
65
65
  return cutCells(this.text, positions).map((text, piece) => new Segment(text, piece === 0 ? this.style : this.style?.shiftedBy(positions[piece - 1])));
66
66
  }
67
67
  // --- Static factories ---
68
- static _line;
68
+ static _line = new Segment("\n");
69
69
  static line() {
70
- return (Segment._line ??= new Segment("\n"));
70
+ return Segment._line;
71
71
  }
72
72
  // --- Static transformations ---
73
73
  /**
@@ -92,7 +92,7 @@ export class Segment {
92
92
  }
93
93
  /**
94
94
  * When what `segments` show next changes, in seconds on the timeline of
95
- * whoever drew them: the soonest `Style.nextChange` among them, read off
95
+ * whoever drew them: the soonest `Style.hold` among them, read off
96
96
  * the composed frame the way `anchorAt` reads an owner — `t` itself for a
97
97
  * frame something in moves every frame, `Infinity` for one that never
98
98
  * changes. What a host that drew the frame at `t` asks before drawing
@@ -102,9 +102,22 @@ export class Segment {
102
102
  static nextChange(segments) {
103
103
  let soonest = Number.POSITIVE_INFINITY;
104
104
  for (const segment of segments)
105
- soonest = Math.min(soonest, segment.style?.nextChange ?? Number.POSITIVE_INFINITY);
105
+ soonest = Math.min(soonest, segment.style?.hold.nextChange ?? Number.POSITIVE_INFINITY);
106
106
  return soonest;
107
107
  }
108
+ /**
109
+ * Since when what `segments` show has been what it is, on the same
110
+ * timeline: the latest `Style.hold` among them, the other end of the
111
+ * hold `nextChange` reads — `t` for a frame something in moves every
112
+ * frame, `-Infinity` for one that always was. What a host running its
113
+ * timeline backwards asks before drawing again.
114
+ */
115
+ static lastChange(segments) {
116
+ let latest = Number.NEGATIVE_INFINITY;
117
+ for (const segment of segments)
118
+ latest = Math.max(latest, segment.style?.hold.lastChange ?? Number.NEGATIVE_INFINITY);
119
+ return latest;
120
+ }
108
121
  /**
109
122
  * The anchor of the cell at column `x` of row `y` in a composed frame, or
110
123
  * `undefined` when that cell was drawn by no owner or lies outside the frame.
@@ -186,9 +199,8 @@ export class Segment {
186
199
  }
187
200
  }
188
201
  }
189
- if (currentLine.length > 0) {
202
+ if (currentLine.length > 0)
190
203
  lines.push(currentLine);
191
- }
192
204
  return lines;
193
205
  }
194
206
  /**
@@ -203,7 +215,10 @@ export class Segment {
203
215
  return line;
204
216
  return [...line, new Segment(" ".repeat(width - currentWidth), style)];
205
217
  }
206
- // Crop to width
218
+ // Crop to width: the cells past it go, and every piece of no cells stays —
219
+ // a control, or an empty piece of text, writes nothing there, and may be
220
+ // all that says when its frame changes (`Style.hold`). Not the
221
+ // reference, which stops at the cut.
207
222
  const result = [];
208
223
  let remaining = width;
209
224
  for (const segment of line) {
@@ -216,11 +231,10 @@ export class Segment {
216
231
  result.push(segment);
217
232
  remaining -= segWidth;
218
233
  }
219
- else {
234
+ else if (remaining > 0) {
220
235
  const [left] = segment.splitCells(asCellCol(remaining));
221
236
  result.push(left);
222
237
  remaining = 0;
223
- break;
224
238
  }
225
239
  }
226
240
  return result;
@@ -8,5 +8,5 @@ export interface SpinnerData {
8
8
  }
9
9
  /** The spinner an ASCII-only output draws in place of any other. */
10
10
  export declare const LINE_SPINNER: SpinnerData;
11
- export declare const SPINNERS: Record<string, SpinnerData>;
11
+ export declare const SPINNERS: Readonly<Record<string, SpinnerData>>;
12
12
  export declare const DEFAULT_SPINNER = "dots";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * state — `State<T>`, the one place a renderable keeps state that changes after
3
+ * it is built, and the refusal of a write to it while something is drawn.
4
+ *
5
+ * A render changes nothing. React refuses a state update during render because
6
+ * state changes only through a setter, and the setter knows the render phase;
7
+ * this is that setter. `set` throws while a render or measure is in progress
8
+ * (`activePass`, owned by `render` and `measure` in `./protocol.ts`).
9
+ *
10
+ * Ownership is React's line too: a state made during a pass belongs to that
11
+ * pass, and the pass may set it. A render that builds a fresh value and fills
12
+ * it as it draws — a `Table` it adds rows to — changes nothing anyone held
13
+ * before it began. Only a state that existed before the pass is refused.
14
+ *
15
+ * `ListState` is the same for a list a renderable appends to — a table's rows,
16
+ * a text's spans — which a `State<readonly T[]>` would copy on every append.
17
+ *
18
+ * `refuseRenderWrite` is that refusal, written once: a widget's observables
19
+ * refuse a write through it too (`src/widgets/observable.ts`).
20
+ *
21
+ * `test/seam/render-purity.test.ts` is the static half: nothing else reachable
22
+ * from a renderable, or from module scope, may be writable.
23
+ */
24
+ import { type Pass } from "./protocol.js";
25
+ /** State was written during a render or measure that did not make it. */
26
+ export declare class RenderWriteError extends Error {
27
+ constructor();
28
+ }
29
+ export declare class State<T> {
30
+ #private;
31
+ constructor(value: T);
32
+ get(): T;
33
+ set(value: T): void;
34
+ }
35
+ /**
36
+ * A list kept as `State` keeps a value, appended to in place: a `State` of a
37
+ * readonly array copies it on every append, which makes filling a 10,000-row
38
+ * table quadratic. `get` hands out the list itself, typed readonly, so a
39
+ * reader that keeps it sees later appends; `set` replaces it, leaving a kept
40
+ * one as it was.
41
+ */
42
+ export declare class ListState<T> {
43
+ #private;
44
+ constructor(items?: Iterable<T>);
45
+ get(): readonly T[];
46
+ push(item: T): void;
47
+ set(items: Iterable<T>): void;
48
+ }
49
+ /**
50
+ * Throws unless state made in `owner` — the pass in progress when it was made,
51
+ * if any — may be written now: between passes, or in the pass that made it.
52
+ */
53
+ export declare function refuseRenderWrite(owner: Pass | undefined): void;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * state — `State<T>`, the one place a renderable keeps state that changes after
3
+ * it is built, and the refusal of a write to it while something is drawn.
4
+ *
5
+ * A render changes nothing. React refuses a state update during render because
6
+ * state changes only through a setter, and the setter knows the render phase;
7
+ * this is that setter. `set` throws while a render or measure is in progress
8
+ * (`activePass`, owned by `render` and `measure` in `./protocol.ts`).
9
+ *
10
+ * Ownership is React's line too: a state made during a pass belongs to that
11
+ * pass, and the pass may set it. A render that builds a fresh value and fills
12
+ * it as it draws — a `Table` it adds rows to — changes nothing anyone held
13
+ * before it began. Only a state that existed before the pass is refused.
14
+ *
15
+ * `ListState` is the same for a list a renderable appends to — a table's rows,
16
+ * a text's spans — which a `State<readonly T[]>` would copy on every append.
17
+ *
18
+ * `refuseRenderWrite` is that refusal, written once: a widget's observables
19
+ * refuse a write through it too (`src/widgets/observable.ts`).
20
+ *
21
+ * `test/seam/render-purity.test.ts` is the static half: nothing else reachable
22
+ * from a renderable, or from module scope, may be writable.
23
+ */
24
+ import { activePass } from "./protocol.js";
25
+ /** State was written during a render or measure that did not make it. */
26
+ export class RenderWriteError extends Error {
27
+ constructor() {
28
+ super("State was written during a render or measure. A render changes nothing: " +
29
+ "change state in an event handler, an update, or between frames.");
30
+ this.name = "RenderWriteError";
31
+ }
32
+ }
33
+ export class State {
34
+ #value;
35
+ #owner = activePass();
36
+ constructor(value) {
37
+ this.#value = value;
38
+ }
39
+ get() {
40
+ return this.#value;
41
+ }
42
+ set(value) {
43
+ refuseRenderWrite(this.#owner);
44
+ this.#value = value;
45
+ }
46
+ }
47
+ /**
48
+ * A list kept as `State` keeps a value, appended to in place: a `State` of a
49
+ * readonly array copies it on every append, which makes filling a 10,000-row
50
+ * table quadratic. `get` hands out the list itself, typed readonly, so a
51
+ * reader that keeps it sees later appends; `set` replaces it, leaving a kept
52
+ * one as it was.
53
+ */
54
+ export class ListState {
55
+ #items;
56
+ #owner = activePass();
57
+ constructor(items = []) {
58
+ this.#items = [...items];
59
+ }
60
+ get() {
61
+ return this.#items;
62
+ }
63
+ // One item, not a rest parameter: a caller spreading a long list into one overflows the call stack.
64
+ push(item) {
65
+ refuseRenderWrite(this.#owner);
66
+ this.#items.push(item);
67
+ }
68
+ set(items) {
69
+ refuseRenderWrite(this.#owner);
70
+ this.#items = [...items];
71
+ }
72
+ }
73
+ /**
74
+ * Throws unless state made in `owner` — the pass in progress when it was made,
75
+ * if any — may be written now: between passes, or in the pass that made it.
76
+ */
77
+ export function refuseRenderWrite(owner) {
78
+ const pass = activePass();
79
+ // [LAW:no-silent-failure] Refused loudly, as React refuses a setState in render.
80
+ if (pass !== undefined && pass !== owner)
81
+ throw new RenderWriteError();
82
+ }