@promptctl/rich-js 0.24.0 → 0.25.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 (163) hide show
  1. package/README.md +7 -0
  2. package/THIRD-PARTY-NOTICES +29 -1
  3. package/dist/core/anchor.d.ts +5 -0
  4. package/dist/core/anchor.js +11 -4
  5. package/dist/core/animation.d.ts +243 -0
  6. package/dist/core/animation.js +375 -0
  7. package/dist/core/ansi.js +2 -2
  8. package/dist/core/box.d.ts +7 -7
  9. package/dist/core/cell-widths.d.ts +9 -0
  10. package/dist/core/cell-widths.js +140 -0
  11. package/dist/core/cells.d.ts +40 -24
  12. package/dist/core/cells.js +151 -66
  13. package/dist/core/clock.d.ts +2 -2
  14. package/dist/core/clock.js +6 -4
  15. package/dist/core/color.d.ts +27 -10
  16. package/dist/core/color.js +21 -29
  17. package/dist/core/console.d.ts +28 -17
  18. package/dist/core/console.js +48 -43
  19. package/dist/core/easing.d.ts +140 -10
  20. package/dist/core/easing.js +328 -50
  21. package/dist/core/emoji.d.ts +1 -1
  22. package/dist/core/emoji.js +1 -2
  23. package/dist/core/export-html.js +2 -1
  24. package/dist/core/export-lines.js +3 -3
  25. package/dist/core/highlighter.d.ts +9 -8
  26. package/dist/core/highlighter.js +3 -2
  27. package/dist/core/json.js +3 -2
  28. package/dist/core/markup.js +16 -12
  29. package/dist/core/measure.js +3 -3
  30. package/dist/core/memo.d.ts +11 -2
  31. package/dist/core/memo.js +16 -0
  32. package/dist/core/oklch.js +17 -6
  33. package/dist/core/osc8.d.ts +1 -1
  34. package/dist/core/osc8.js +4 -5
  35. package/dist/core/paint.d.ts +2 -1
  36. package/dist/core/paint.js +4 -3
  37. package/dist/core/pattern.d.ts +21 -0
  38. package/dist/core/pattern.js +21 -0
  39. package/dist/core/place.js +2 -1
  40. package/dist/core/pretty.js +11 -12
  41. package/dist/core/protocol.d.ts +136 -21
  42. package/dist/core/protocol.js +203 -16
  43. package/dist/core/render.d.ts +15 -2
  44. package/dist/core/render.js +27 -6
  45. package/dist/core/segment.d.ts +31 -6
  46. package/dist/core/segment.js +68 -53
  47. package/dist/core/spinnerData.d.ts +1 -1
  48. package/dist/core/state.d.ts +53 -0
  49. package/dist/core/state.js +82 -0
  50. package/dist/core/strip.d.ts +11 -13
  51. package/dist/core/strip.js +126 -113
  52. package/dist/core/style.d.ts +47 -14
  53. package/dist/core/style.js +74 -34
  54. package/dist/core/text.d.ts +5 -8
  55. package/dist/core/text.js +133 -133
  56. package/dist/core/timeline.d.ts +109 -0
  57. package/dist/core/timeline.js +180 -0
  58. package/dist/core/tween.d.ts +49 -0
  59. package/dist/core/tween.js +71 -0
  60. package/dist/core/view.d.ts +92 -0
  61. package/dist/core/view.js +83 -0
  62. package/dist/host/app.d.ts +49 -21
  63. package/dist/host/app.js +72 -38
  64. package/dist/host/input.js +3 -4
  65. package/dist/index.d.ts +30 -15
  66. package/dist/index.js +20 -6
  67. package/dist/node/traceback.js +4 -3
  68. package/dist/renderables/change.d.ts +42 -0
  69. package/dist/renderables/change.js +112 -0
  70. package/dist/renderables/columns.d.ts +1 -1
  71. package/dist/renderables/columns.js +3 -3
  72. package/dist/renderables/constrain.js +2 -1
  73. package/dist/renderables/effect.d.ts +155 -92
  74. package/dist/renderables/effect.js +203 -136
  75. package/dist/renderables/effects.d.ts +137 -24
  76. package/dist/renderables/effects.js +276 -113
  77. package/dist/renderables/embed.d.ts +1 -1
  78. package/dist/renderables/embed.js +18 -9
  79. package/dist/renderables/flexStrip.js +2 -1
  80. package/dist/renderables/frames.d.ts +45 -43
  81. package/dist/renderables/frames.js +57 -45
  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 +1 -1
  87. package/dist/renderables/group.js +2 -2
  88. package/dist/renderables/html-entities.d.ts +8 -0
  89. package/dist/renderables/html-entities.js +386 -0
  90. package/dist/renderables/layout.d.ts +10 -9
  91. package/dist/renderables/layout.js +42 -35
  92. package/dist/renderables/lifecycle.d.ts +77 -0
  93. package/dist/renderables/lifecycle.js +132 -0
  94. package/dist/renderables/list-view.d.ts +2 -2
  95. package/dist/renderables/list-view.js +11 -8
  96. package/dist/renderables/live.d.ts +36 -21
  97. package/dist/renderables/live.js +89 -42
  98. package/dist/renderables/markdown-parse.d.ts +109 -0
  99. package/dist/renderables/markdown-parse.js +1427 -0
  100. package/dist/renderables/markdown-url.d.ts +19 -0
  101. package/dist/renderables/markdown-url.js +314 -0
  102. package/dist/renderables/markdown.d.ts +5 -7
  103. package/dist/renderables/markdown.js +356 -683
  104. package/dist/renderables/padding.d.ts +7 -20
  105. package/dist/renderables/padding.js +29 -50
  106. package/dist/renderables/panel.d.ts +30 -32
  107. package/dist/renderables/panel.js +65 -109
  108. package/dist/renderables/progress.d.ts +83 -32
  109. package/dist/renderables/progress.js +186 -90
  110. package/dist/renderables/progressBar.d.ts +22 -12
  111. package/dist/renderables/progressBar.js +33 -10
  112. package/dist/renderables/prompt.js +3 -2
  113. package/dist/renderables/python-space.d.ts +13 -0
  114. package/dist/renderables/python-space.js +25 -0
  115. package/dist/renderables/scroll.d.ts +50 -0
  116. package/dist/renderables/scroll.js +106 -0
  117. package/dist/renderables/spinner.d.ts +57 -24
  118. package/dist/renderables/spinner.js +101 -49
  119. package/dist/renderables/status.js +1 -1
  120. package/dist/renderables/syntax.d.ts +10 -9
  121. package/dist/renderables/syntax.js +5 -3
  122. package/dist/renderables/table.d.ts +48 -46
  123. package/dist/renderables/table.js +170 -208
  124. package/dist/renderables/traceback.d.ts +3 -3
  125. package/dist/renderables/traceback.js +12 -10
  126. package/dist/renderables/tree.d.ts +28 -25
  127. package/dist/renderables/tree.js +108 -58
  128. package/dist/renderables/viewport.d.ts +77 -38
  129. package/dist/renderables/viewport.js +156 -72
  130. package/dist/template-bindings/animation-funcs.d.ts +78 -9
  131. package/dist/template-bindings/animation-funcs.js +132 -14
  132. package/dist/template-bindings/index.d.ts +5 -5
  133. package/dist/template-bindings/index.js +7 -6
  134. package/dist/themes/ramp.d.ts +62 -4
  135. package/dist/themes/ramp.js +82 -4
  136. package/dist/themes/registry.js +5 -7
  137. package/dist/widgets/button.js +3 -2
  138. package/dist/widgets/checkbox.js +4 -3
  139. package/dist/widgets/dropdown.d.ts +3 -1
  140. package/dist/widgets/dropdown.js +20 -15
  141. package/dist/widgets/event-router.js +4 -2
  142. package/dist/widgets/focus-manager.d.ts +23 -14
  143. package/dist/widgets/focus-manager.js +34 -33
  144. package/dist/widgets/hit.d.ts +6 -0
  145. package/dist/widgets/hit.js +8 -1
  146. package/dist/widgets/index.d.ts +1 -1
  147. package/dist/widgets/observable.d.ts +23 -0
  148. package/dist/widgets/observable.js +43 -0
  149. package/dist/widgets/slider.d.ts +1 -1
  150. package/dist/widgets/slider.js +12 -3
  151. package/dist/widgets/static-item.d.ts +1 -1
  152. package/dist/widgets/static-item.js +6 -7
  153. package/dist/widgets/text-input.d.ts +53 -47
  154. package/dist/widgets/text-input.js +456 -399
  155. package/dist/widgets/themed-widget.js +2 -1
  156. package/dist/widgets/toggle.js +4 -3
  157. package/dist/widgets/types.d.ts +10 -4
  158. package/dist/widgets/types.js +8 -2
  159. package/dist/widgets/widget-app.d.ts +32 -6
  160. package/dist/widgets/widget-app.js +74 -27
  161. package/dist/widgets/widget-base.d.ts +2 -2
  162. package/dist/widgets/widget-base.js +29 -21
  163. package/package.json +9 -5
package/dist/core/text.js CHANGED
@@ -6,17 +6,28 @@ import { divideLine, wordWidths } from "./wrap.js";
6
6
  import { Segment } from "./segment.js";
7
7
  import { Style, NULL_STYLE, StyleSyntaxError } from "./style.js";
8
8
  import { stripOscTerminators } from "./osc8.js";
9
- import { drawable, getStyle, withBoundedWidth } from "./protocol.js";
9
+ import { betweenPasses, drawable, getStyle, withBoundedWidth } from "./protocol.js";
10
+ import { pattern } from "./pattern.js";
11
+ import { ListState, State } from "./state.js";
10
12
  // Every C0 control and DEL except \t and \n. Rich's `Text` strips only
11
13
  // `STRIP_CONTROL_CODES` (BEL, BS, VT, FF, CR); the rest of the set is the
12
- // port's extension, so no C0 byte of content can start an escape sequence or
13
- // move the cursor on the wire. C1 controls (U+0080–U+009F) pass, as in Rich.
14
- // [LAW:single-enforcer] Single place where control chars are sanitized
15
- const CONTROL_CHARS_RE = /[\x00-\x08\x0B-\x1F\x7F]/g;
14
+ // port's extension, so no byte of content can start an escape sequence or move
15
+ // the cursor on the wire: the C1 controls (U+0080–U+009F) go as well, since a
16
+ // terminal reads U+009B as CSI and U+009D as OSC (TextInput refuses them for
17
+ // the same reason). So do the format controls that hold state past the text,
18
+ // also the port's extension: the bidi embeddings and overrides with their
19
+ // closer PDF (U+202A–U+202E), the isolates with their closer PDI
20
+ // (U+2066–U+2069), and the deprecated shaping and swapping switches
21
+ // (U+206A–U+206F). An opener left unclosed in content reverses whatever is
22
+ // drawn after it on the line, and a closer whose opener is gone closes
23
+ // nothing of the text's own. The bidi marks (LRM, RLM, ALM) hold no state and
24
+ // pass, since right-to-left text needs them.
25
+ // [LAW:single-enforcer] Single place where control chars are sanitized: every
26
+ // string that becomes part of a Text's characters passes here.
16
27
  export function stripControlChars(text) {
17
- return text.replace(CONTROL_CHARS_RE, "");
28
+ return text.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F\u202A-\u202E\u2066-\u206F]/g, "");
18
29
  }
19
- const TRAILING_WHITESPACE_RE = /\s+$/;
30
+ const TRAILING_WHITESPACE_RE = pattern(/\s+$/);
20
31
  /**
21
32
  * The plain text of one line of segments, in the coordinate system
22
33
  * `divideLine` and `Segment.divide` share: cell offsets into the styled line
@@ -74,9 +85,9 @@ function sanitizeStyleLink(style) {
74
85
  * lays its text out anew, so a stamp naming where a cell sat in the output it
75
86
  * came from would name the wrong cell once it wraps, slices or overlaps; the
76
87
  * owner that renders the RichText is the one that stamps its cells. A
77
- * `nextChange` stays: when a span's cells next change is true of them
78
- * wherever the text lays them, so a frame appended into a line still says
79
- * when it turns.
88
+ * hold stays — `lastChange` and `nextChange`: when a span's cells began and
89
+ * next change is true of them wherever the text lays them, so a frame
90
+ * appended into a line still says when it turns.
80
91
  */
81
92
  function admitStyle(style) {
82
93
  if (typeof style === "string")
@@ -119,7 +130,7 @@ function resolveDefinition(options, style) {
119
130
  catch (err) {
120
131
  if (!(err instanceof StyleSyntaxError))
121
132
  throw err;
122
- options.onStyleError?.(err, style);
133
+ betweenPasses(() => options.onStyleError?.(err, style));
123
134
  return NULL_STYLE;
124
135
  }
125
136
  }
@@ -160,41 +171,45 @@ export class Span {
160
171
  }
161
172
  }
162
173
  export class RichText {
174
+ // Rich's `Text` is changed in place — appended to, stylized, padded — so its
175
+ // characters, spans and format are state, and a write to one while
176
+ // something is drawn throws. Three holders rather than one per field: a
177
+ // `RichText` is the most-made object in the library.
163
178
  _text;
164
- _spans;
165
- _style;
166
- _justify;
167
- _overflow;
168
- _end;
179
+ _spans = new ListState();
180
+ _format;
169
181
  _tabSize;
170
- _noWrap;
171
182
  constructor(text, options) {
172
- this._text = text ? stripControlChars(text) : "";
173
- this._spans = [];
174
- // [LAW:single-enforcer] `admitStyle` is the boundary that sanitizes
175
- // any link URL crossing into a RichText; downstream trusts the invariant.
176
- this._style = admitStyle(options?.style ?? NULL_STYLE);
177
- this._justify = options?.justify;
178
- this._overflow = options?.overflow;
179
- this._end = options?.end ?? "\n";
183
+ this._text = new State(text ? stripControlChars(text) : "");
184
+ this._format = new State({
185
+ // [LAW:single-enforcer] `admitStyle` is the boundary that sanitizes
186
+ // any link URL crossing into a RichText; downstream trusts the invariant.
187
+ style: admitStyle(options?.style ?? NULL_STYLE),
188
+ justify: options?.justify,
189
+ overflow: options?.overflow,
190
+ end: options?.end ?? "\n",
191
+ noWrap: options?.noWrap ?? false,
192
+ });
180
193
  this._tabSize = parseTabSize(options?.tabSize ?? 8);
181
- this._noWrap = options?.noWrap ?? false;
194
+ }
195
+ _reformat(change) {
196
+ this._format.set({ ...this._format.get(), ...change });
182
197
  }
183
198
  // --- Properties ---
184
199
  get plain() {
185
- return this._text;
200
+ return this._text.get();
186
201
  }
187
202
  set plain(value) {
188
203
  const sanitized = stripControlChars(value);
189
- this._text = sanitized;
204
+ this._text.set(sanitized);
190
205
  // Trim spans that extend beyond new length
191
206
  const len = sanitized.length;
192
- this._spans = this._spans
207
+ this._spans.set(this._spans.get()
193
208
  .map((s) => (s.end > len ? new Span(s.start, Math.min(s.end, len), s.style) : s))
194
- .filter((s) => s.start < len);
209
+ .filter((s) => s.start < len));
195
210
  }
196
211
  get length() {
197
- return this._text.length;
212
+ return this._text.get().length;
198
213
  }
199
214
  /**
200
215
  * The cells of the plain text with each tab widened to its stop counted from
@@ -205,44 +220,44 @@ export class RichText {
205
220
  * no render draws.
206
221
  */
207
222
  get cellLength() {
208
- return cellLen(expandTabs(this._text, this._tabSize).text);
223
+ return cellLen(expandTabs(this._text.get(), this._tabSize).text);
209
224
  }
210
225
  get hasContent() {
211
- return this._text.length > 0;
226
+ return this._text.get().length > 0;
212
227
  }
213
228
  /** The base style every span layers over, resolved at render unless it is a `Style`. */
214
229
  get style() {
215
- return this._style;
230
+ return this._format.get().style;
216
231
  }
217
232
  set style(value) {
218
- this._style = admitStyle(value);
233
+ this._reformat({ style: admitStyle(value) });
219
234
  }
220
235
  get justify() {
221
- return this._justify;
236
+ return this._format.get().justify;
222
237
  }
223
238
  set justify(value) {
224
- this._justify = value;
239
+ this._reformat({ justify: value });
225
240
  }
226
241
  get overflow() {
227
- return this._overflow;
242
+ return this._format.get().overflow;
228
243
  }
229
244
  set overflow(value) {
230
- this._overflow = value;
245
+ this._reformat({ overflow: value });
231
246
  }
232
247
  get end() {
233
- return this._end;
248
+ return this._format.get().end;
234
249
  }
235
250
  set end(value) {
236
- this._end = value;
251
+ this._reformat({ end: value });
237
252
  }
238
253
  get noWrap() {
239
- return this._noWrap;
254
+ return this._format.get().noWrap;
240
255
  }
241
256
  set noWrap(value) {
242
- this._noWrap = value;
257
+ this._reformat({ noWrap: value });
243
258
  }
244
259
  get spans() {
245
- return this._spans;
260
+ return this._spans.get();
246
261
  }
247
262
  /**
248
263
  * This text's own style, spans aside, as the render drawing it resolves it.
@@ -250,7 +265,7 @@ export class RichText {
250
265
  * text forgives a missing name.
251
266
  */
252
267
  resolvedStyle(options) {
253
- return resolveStyle(options, this._style);
268
+ return resolveStyle(options, this._format.get().style);
254
269
  }
255
270
  /**
256
271
  * The style of the cell-column at the named edge — base style merged with
@@ -274,11 +289,11 @@ export class RichText {
274
289
  */
275
290
  edgeStyle(side, options) {
276
291
  const base = this.resolvedStyle(options);
277
- if (this._text.length === 0)
292
+ if (this._text.get().length === 0)
278
293
  return base;
279
- const pos = side === "left" ? 0 : this._text.length - 1;
294
+ const pos = side === "left" ? 0 : this._text.get().length - 1;
280
295
  let result = base;
281
- for (const span of this._spans) {
296
+ for (const span of this._spans.get()) {
282
297
  if (span.start <= pos && pos < span.end) {
283
298
  result = result.add(resolveStyle(options, span.style));
284
299
  }
@@ -291,35 +306,36 @@ export class RichText {
291
306
  if (style !== undefined) {
292
307
  throw new Error("Style argument must not be provided when appending RichText");
293
308
  }
294
- const offset = this._text.length;
295
- this._text += content._text;
309
+ const offset = this._text.get().length;
310
+ // Read before anything is appended, so a text appended to itself takes its spans as they were.
311
+ const moved = content._spans.get().map((span) => span.move(offset));
312
+ this._text.set(this._text.get() + content._text.get());
296
313
  // Its base style becomes a span under its own spans, as the reference's
297
314
  // `append` does, so appended text looks as it did alone.
298
- this._addSpan(offset, this._text.length, content._style);
299
- for (const span of content._spans) {
300
- this._spans.push(span.move(offset));
301
- }
315
+ this._addSpan(offset, this._text.get().length, content._format.get().style);
316
+ for (const span of moved)
317
+ this._spans.push(span);
302
318
  return this;
303
319
  }
304
320
  const sanitized = stripControlChars(content);
305
- const start = this._text.length;
306
- this._text += sanitized;
307
- this._addSpan(start, this._text.length, style ?? "");
321
+ const start = this._text.get().length;
322
+ this._text.set(this._text.get() + sanitized);
323
+ this._addSpan(start, this._text.get().length, style ?? "");
308
324
  return this;
309
325
  }
310
326
  contains(needle) {
311
- const searchText = needle instanceof RichText ? needle._text : needle;
312
- return this._text.includes(searchText);
327
+ const searchText = needle instanceof RichText ? needle._text.get() : needle;
328
+ return this._text.get().includes(searchText);
313
329
  }
314
330
  at(index) {
315
- const resolved = index < 0 ? this._text.length + index : index;
316
- const char = this._text[resolved];
331
+ const resolved = index < 0 ? this._text.get().length + index : index;
332
+ const char = this._text.get()[resolved];
317
333
  if (char === undefined)
318
334
  return new RichText("");
319
335
  return this.slice(resolved, resolved + 1);
320
336
  }
321
337
  slice(start, end) {
322
- const text = this._text;
338
+ const text = this._text.get();
323
339
  const len = text.length;
324
340
  const s = start ?? 0;
325
341
  const e = end ?? len;
@@ -330,7 +346,7 @@ export class RichText {
330
346
  }
331
347
  const slicedText = text.slice(resolvedStart, resolvedEnd);
332
348
  const result = this.blankCopy(slicedText);
333
- for (const span of this._spans) {
349
+ for (const span of this._spans.get()) {
334
350
  const spanStart = Math.max(span.start, resolvedStart) - resolvedStart;
335
351
  const spanEnd = Math.min(span.end, resolvedEnd) - resolvedStart;
336
352
  if (spanStart < spanEnd) {
@@ -352,7 +368,7 @@ export class RichText {
352
368
  this._spans.push(new Span(start, end, admitted));
353
369
  }
354
370
  stylize(style, start, end) {
355
- const len = this._text.length;
371
+ const len = this._text.get().length;
356
372
  const s = start !== undefined ? (start < 0 ? len + start : start) : 0;
357
373
  const e = end !== undefined ? (end < 0 ? len + end : end) : len;
358
374
  if (s >= e || s >= len || e <= 0)
@@ -363,7 +379,7 @@ export class RichText {
363
379
  return this;
364
380
  }
365
381
  highlightRegex(pattern, style) {
366
- const text = this._text;
382
+ const text = this._text.get();
367
383
  let count = 0;
368
384
  // Ensure global flag
369
385
  const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
@@ -405,7 +421,7 @@ export class RichText {
405
421
  const flags = caseSensitive ? "g" : "gi";
406
422
  const re = new RegExp(`\\b${escaped}\\b`, flags);
407
423
  let match;
408
- while ((match = re.exec(this._text)) !== null) {
424
+ while ((match = re.exec(this._text.get())) !== null) {
409
425
  this._addSpan(match.index, match.index + match[0].length, style);
410
426
  count++;
411
427
  }
@@ -414,31 +430,17 @@ export class RichText {
414
430
  }
415
431
  // --- Copy Operations ---
416
432
  copy() {
417
- const result = new RichText(this._text, {
418
- style: this._style,
419
- justify: this._justify,
420
- overflow: this._overflow,
421
- end: this._end,
422
- tabSize: this._tabSize,
423
- noWrap: this._noWrap,
424
- });
425
- result._spans = this._spans.slice();
433
+ const result = this.blankCopy(this._text.get());
434
+ result._spans.set(this._spans.get());
426
435
  return result;
427
436
  }
428
437
  blankCopy(text) {
429
- return new RichText(text ?? "", {
430
- style: this._style,
431
- justify: this._justify,
432
- overflow: this._overflow,
433
- end: this._end,
434
- tabSize: this._tabSize,
435
- noWrap: this._noWrap,
436
- });
438
+ return new RichText(text ?? "", { ...this._format.get(), tabSize: this._tabSize });
437
439
  }
438
440
  // --- Splitting ---
439
441
  split(separator) {
440
442
  const sep = separator ?? "\n";
441
- const text = this._text;
443
+ const text = this._text.get();
442
444
  const parts = [];
443
445
  let start = 0;
444
446
  while (true) {
@@ -466,18 +468,17 @@ export class RichText {
466
468
  }
467
469
  // --- Whitespace Operations ---
468
470
  rstrip() {
469
- const trimmed = this._text.replace(/\s+$/, "");
470
- if (trimmed.length < this._text.length) {
471
+ const trimmed = this._text.get().replace(/\s+$/, "");
472
+ if (trimmed.length < this._text.get().length) {
471
473
  this.plain = trimmed;
472
474
  }
473
475
  return this;
474
476
  }
475
477
  pad(count, char) {
476
- const c = char ?? " ";
477
- const padding = c.repeat(count);
478
- // Shift all spans to the right
479
- this._spans = this._spans.map((s) => s.move(count));
480
- this._text = padding + this._text + padding;
478
+ const padding = stripControlChars(char ?? " ").repeat(count);
479
+ // Shift all spans right by what the left side gained.
480
+ this._spans.set(this._spans.get().map((s) => s.move(padding.length)));
481
+ this._text.set(padding + this._text.get() + padding);
481
482
  return this;
482
483
  }
483
484
  /**
@@ -486,36 +487,35 @@ export class RichText {
486
487
  * the text measures what it will draw.
487
488
  */
488
489
  expandTabs() {
489
- const expanded = expandTabs(this._text, this._tabSize);
490
- this._spans = this._spans.map((s) => new Span(expanded.offsetOf(s.start), expanded.offsetOf(s.end), s.style));
491
- this._text = expanded.text;
490
+ const expanded = expandTabs(this._text.get(), this._tabSize);
491
+ this._spans.set(this._spans.get().map((s) => new Span(expanded.offsetOf(s.start), expanded.offsetOf(s.end), s.style)));
492
+ this._text.set(expanded.text);
492
493
  return this;
493
494
  }
494
495
  padLeft(count, char) {
495
- const c = char ?? " ";
496
- this._spans = this._spans.map((s) => s.move(count));
497
- this._text = c.repeat(count) + this._text;
496
+ const padding = stripControlChars(char ?? " ").repeat(count);
497
+ this._spans.set(this._spans.get().map((s) => s.move(padding.length)));
498
+ this._text.set(padding + this._text.get());
498
499
  return this;
499
500
  }
500
501
  padRight(count, char) {
501
- const c = char ?? " ";
502
- this._text += c.repeat(count);
502
+ this._text.set(this._text.get() + stripControlChars(char ?? " ").repeat(count));
503
503
  return this;
504
504
  }
505
505
  setLength(length) {
506
- if (this._text.length < length) {
507
- this._text += " ".repeat(length - this._text.length);
506
+ if (this._text.get().length < length) {
507
+ this._text.set(this._text.get() + " ".repeat(length - this._text.get().length));
508
508
  }
509
- else if (this._text.length > length) {
510
- this.plain = this._text.slice(0, length);
509
+ else if (this._text.get().length > length) {
510
+ this.plain = this._text.get().slice(0, length);
511
511
  }
512
512
  return this;
513
513
  }
514
514
  extendStyle(count) {
515
- const oldLen = this._text.length;
516
- this._text += " ".repeat(count);
515
+ const oldLen = this._text.get().length;
516
+ this._text.set(this._text.get() + " ".repeat(count));
517
517
  // Extend existing spans to cover new spaces
518
- this._spans = this._spans.map((s) => s.end === oldLen ? s.extend(count) : s);
518
+ this._spans.set(this._spans.get().map((s) => s.end === oldLen ? s.extend(count) : s));
519
519
  return this;
520
520
  }
521
521
  // --- Truncation ---
@@ -558,19 +558,19 @@ export class RichText {
558
558
  // value, so width 0 and a marker wider than the width run the same walk
559
559
  // as every other call: what is drawn plus what is kept is `cells` wide.
560
560
  const cells = cellCount(width);
561
- const marker = cellFit(options?.marker ?? "\u2026", cells);
561
+ const marker = cellFit(stripControlChars(options?.marker ?? "\u2026"), cells);
562
562
  const budget = asCellCol(cells - cellLen(marker));
563
563
  if (mode === "right") {
564
564
  this._cropRightTo(budget);
565
565
  if (marker)
566
- this._text += marker;
566
+ this._text.set(this._text.get() + marker);
567
567
  return this;
568
568
  }
569
569
  if (mode === "left") {
570
570
  this._cropLeftTo(budget);
571
571
  if (marker) {
572
- this._spans = this._spans.map((s) => s.move(marker.length));
573
- this._text = marker + this._text;
572
+ this._spans.set(this._spans.get().map((s) => s.move(marker.length)));
573
+ this._text.set(marker + this._text.get());
574
574
  }
575
575
  return this;
576
576
  }
@@ -578,17 +578,17 @@ export class RichText {
578
578
  const leftBudget = asCellCol(Math.floor(budget / 2));
579
579
  const rightBudget = asCellCol(budget - leftBudget);
580
580
  // Find the char-index ranges to keep from each side.
581
- const leftEndCharIdx = cellFit(this._text, leftBudget).length;
582
- const rightStartCharIdx = this._text.length - cellFitEnd(this._text, rightBudget).length;
583
- const leftText = this._text.slice(0, leftEndCharIdx);
584
- const rightText = this._text.slice(rightStartCharIdx);
581
+ const leftEndCharIdx = cellFit(this._text.get(), leftBudget).length;
582
+ const rightStartCharIdx = this._text.get().length - cellFitEnd(this._text.get(), rightBudget).length;
583
+ const leftText = this._text.get().slice(0, leftEndCharIdx);
584
+ const rightText = this._text.get().slice(rightStartCharIdx);
585
585
  const droppedStart = leftEndCharIdx;
586
586
  const droppedEnd = rightStartCharIdx;
587
587
  const shift = marker.length - (droppedEnd - droppedStart);
588
588
  // Spans falling entirely before the drop stay; entirely after shift by
589
589
  // (marker.length - dropped chars); spans crossing the drop are clipped.
590
590
  const newSpans = [];
591
- for (const s of this._spans) {
591
+ for (const s of this._spans.get()) {
592
592
  if (s.end <= droppedStart) {
593
593
  newSpans.push(s);
594
594
  }
@@ -605,18 +605,18 @@ export class RichText {
605
605
  }
606
606
  }
607
607
  }
608
- this._text = leftText + marker + rightText;
609
- this._spans = newSpans;
608
+ this._text.set(leftText + marker + rightText);
609
+ this._spans.set(newSpans);
610
610
  return this;
611
611
  }
612
612
  _cropRightTo(targetWidth) {
613
- this.plain = cellFit(this._text, targetWidth);
613
+ this.plain = cellFit(this._text.get(), targetWidth);
614
614
  }
615
615
  _cropLeftTo(targetWidth) {
616
- const charIdx = this._text.length - cellFitEnd(this._text, targetWidth).length;
616
+ const charIdx = this._text.get().length - cellFitEnd(this._text.get(), targetWidth).length;
617
617
  // Shift spans left by charIdx; clip spans that started before.
618
618
  const shift = -charIdx;
619
- this._spans = this._spans
619
+ this._spans.set(this._spans.get()
620
620
  .map((s) => {
621
621
  if (s.end <= charIdx)
622
622
  return undefined;
@@ -624,8 +624,8 @@ export class RichText {
624
624
  const newEnd = s.end + shift;
625
625
  return new Span(newStart, newEnd, s.style);
626
626
  })
627
- .filter((s) => s !== undefined);
628
- this._text = this._text.slice(charIdx);
627
+ .filter((s) => s !== undefined));
628
+ this._text.set(this._text.get().slice(charIdx));
629
629
  }
630
630
  // --- Alignment ---
631
631
  align(justify, width) {
@@ -655,8 +655,8 @@ export class RichText {
655
655
  }
656
656
  // --- Suffix Removal ---
657
657
  removeSuffix(suffix) {
658
- if (this._text.endsWith(suffix)) {
659
- this.plain = this._text.slice(0, -suffix.length);
658
+ if (this._text.get().endsWith(suffix)) {
659
+ this.plain = this._text.get().slice(0, -suffix.length);
660
660
  }
661
661
  return this;
662
662
  }
@@ -713,7 +713,7 @@ export class RichText {
713
713
  }
714
714
  // --- Renderable ---
715
715
  *render(options) {
716
- const expanded = expandTabs(this._text, this._tabSize);
716
+ const expanded = expandTabs(this._text.get(), this._tabSize);
717
717
  const base = this.resolvedStyle(options);
718
718
  const { segments: allSegments, covered } = this._buildSegments(expanded, base, options);
719
719
  const spanFree = spanFreeLines(allSegments, covered);
@@ -731,8 +731,8 @@ export class RichText {
731
731
  // overflow arm a no-op, but passed an unbounded one through to `justify`,
732
732
  // which pads — and `" ".repeat(Infinity)` throws.
733
733
  const maxWidth = cellCount(withBoundedWidth(options, this).maxWidth);
734
- const overflow = this._overflow ?? options.overflow ?? "fold";
735
- const noWrap = this._noWrap || (options.noWrap ?? false);
734
+ const overflow = this._format.get().overflow ?? options.overflow ?? "fold";
735
+ const noWrap = this._format.get().noWrap || (options.noWrap ?? false);
736
736
  // Two widths, and they are the reference's `no_wrap` and `"ignore"` told
737
737
  // apart. `edge` is where a line is cut: gone under `"ignore"`, so the line
738
738
  // leaves at its natural width and whatever drew it decides about the
@@ -751,7 +751,7 @@ export class RichText {
751
751
  // A line with no edge has no width to be justified in, so under `"ignore"`
752
752
  // it is not justified — the reference's `Text.wrap` returns that arm before
753
753
  // `justify`. `undefined` is the placement that leaves a line as it is.
754
- const justify = overflow === "ignore" ? undefined : this._justify ?? options.justify;
754
+ const justify = overflow === "ignore" ? undefined : this._format.get().justify ?? options.justify;
755
755
  // One cell either way, so the marker's stand-in leaves the cut where it was.
756
756
  const ellipsis = drawable(options, "\u2026", ".");
757
757
  for (let index = 0; index < logicalLines.length; index += 1) {
@@ -779,13 +779,13 @@ export class RichText {
779
779
  // (truthy) and again for non-empty text (truthy and not the default
780
780
  // "\n"), which gave `end` two meanings depending on whether the text was
781
781
  // empty (rich-text-5ai).
782
- if (this._end) {
783
- yield new Segment(this._end);
782
+ if (this._format.get().end) {
783
+ yield new Segment(this._format.get().end);
784
784
  }
785
785
  }
786
786
  // --- Measurable ---
787
787
  measure(options) {
788
- const lines = expandTabs(this._text, this._tabSize).text.split("\n");
788
+ const lines = expandTabs(this._text.get(), this._tabSize).text.split("\n");
789
789
  const maxLineWidth = lines.reduce((widest, line) => Math.max(widest, cellLen(line)), 0);
790
790
  // [LAW:one-source-of-truth] The minimum is the widest piece the wrapper
791
791
  // never breaks, asked of the wrapper — so a line's indent, which wraps
@@ -840,7 +840,7 @@ export class RichText {
840
840
  // and it is what makes empty text one line to everything that splits a
841
841
  // render into lines — a panel's body, a table cell — where Rich draws one.
842
842
  const inner = new Set();
843
- for (const span of this._spans) {
843
+ for (const span of this._spans.get()) {
844
844
  inner.add(clamp(span.start));
845
845
  inner.add(clamp(span.end));
846
846
  }
@@ -854,7 +854,7 @@ export class RichText {
854
854
  const pieceAt = new Map(boundaries.map((position, piece) => [position, piece]).reverse());
855
855
  const styles = boundaries.slice(0, -1).map(() => base);
856
856
  const covered = styles.map(() => false);
857
- for (const span of this._spans) {
857
+ for (const span of this._spans.get()) {
858
858
  const end = clamp(span.end);
859
859
  const style = resolveStyle(options, span.style);
860
860
  const opensAt = pieceAt.get(clamp(span.start));
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Timeline — the map from the host's clock to the time an animation is
3
+ * asked for: playback control, and nothing else.
4
+ *
5
+ * Every animation is a pure function of its time (`./animation.ts`), so
6
+ * pausing, reversing, scrubbing and speeding one up are not things done to
7
+ * it but a change in which time it is asked for. A timeline is that map, as
8
+ * a value: an animation time at a host time, a rate — animation seconds per
9
+ * host second, negative running it backwards — and whether it is held. Each
10
+ * operation is a value in and a value out, so a paused timeline resumed at a
11
+ * new host time is one that maps that host time to the time it froze at, and
12
+ * two hosts holding equal timelines ask every animation for the same time.
13
+ *
14
+ * A paused timeline carries no host anchor: it maps every host time to the
15
+ * one time it holds, so there is no origin for it to be wrong about.
16
+ * [LAW:types-are-the-program]
17
+ *
18
+ * `run` is an animation on host time. Its `nextChange` is the host moment
19
+ * the mapped time reaches the animation's own answer, which is what
20
+ * `Spinner`'s speed scaled its holds for before this existed: an answer a
21
+ * host reads is on the host's timeline, and a beat over scaled time answers
22
+ * on the scaled one. Run backwards, the host next sees a change when the
23
+ * mapped time drops under where the value began — the animation's
24
+ * `lastChange` — so a reversed loop turns exactly at its hold boundaries,
25
+ * and a reversed one-shot changes at its switch and never before its start.
26
+ * Paused, nothing changes: never, and never did. The host moment is the
27
+ * first host float the map carries past the edge, searched out from the sum
28
+ * `origin + (edge - time) / rate` (`firstReaching`): a host float is a step
29
+ * of `rate` on the animation's clock, so the floats between that sum and
30
+ * the first one past the edge are as many as the two clocks' resolutions
31
+ * are apart — unbounded, far from the origin or at a slow rate — which is
32
+ * why it is a search and not the ulp walk a sum on one clock takes. A value
33
+ * moving at the mapped time changes at the host time itself.
34
+ *
35
+ * A stagger — a Group's children pulsing in sequence, a light walking down
36
+ * a Table's rows — is the same map once per child: `stagger` runs each
37
+ * child's animation on a timeline playing from its index times the delay,
38
+ * and the group changes when the soonest child does, each child's answer
39
+ * carried onto the group's clock by `run`. Before its turn a child is asked
40
+ * for a time before 0, which is its own business: a loop is shifted in
41
+ * phase. Tree, Table, Group and Layout take the children's frames as they
42
+ * take any renderables, through `Animation.map`, so none of them knows a
43
+ * stagger exists. [LAW:composability] A lifecycle is not staggered here: it
44
+ * carries its turn as `entered`, on the host's clock its `leave` and `gone`
45
+ * take, so moving it onto a shifted clock would lose them
46
+ * ([LAW:one-source-of-truth] for when it arrives).
47
+ *
48
+ * [LAW:no-ambient-temporal-coupling] Nothing here reads a clock. The host
49
+ * time is an argument everywhere, and a timeline is built from one the host
50
+ * hands in, so the same host time maps to the same animation time at one
51
+ * sample a second or sixty.
52
+ */
53
+ import { Animation, type Timed } from "./animation.js";
54
+ /**
55
+ * A map from host time to animation time. Playing, it is `time` at `origin`
56
+ * moving `rate` animation seconds a host second; paused, it is `time` at
57
+ * every host time, with the rate it resumes at.
58
+ */
59
+ export type Timeline = {
60
+ readonly playing: true;
61
+ /** The host time, in seconds, at which the animation time is `time`. */
62
+ readonly origin: number;
63
+ /** The animation time, in seconds, at `origin`. */
64
+ readonly time: number;
65
+ /** Animation seconds per host second; negative runs the animation backwards. Never zero: a held timeline is paused. */
66
+ readonly rate: number;
67
+ } | {
68
+ readonly playing: false;
69
+ /** The animation time, in seconds, held at every host time. */
70
+ readonly time: number;
71
+ /** The rate the timeline resumes at. */
72
+ readonly rate: number;
73
+ };
74
+ export declare const Timeline: {
75
+ /** Playing from host time `origin`, at which the animation time is 0, at `rate` animation seconds a host second. */
76
+ play(origin: number, rate?: number): Timeline;
77
+ /** The animation time at host time `host`. */
78
+ at(timeline: Timeline, host: number): number;
79
+ /** `timeline` held at the animation time it maps `host` to. Pausing a paused timeline holds the same time. */
80
+ pause(timeline: Timeline, host: number): Timeline;
81
+ /** `timeline` playing from `host` at the animation time it maps `host` to, at its rate. Resuming a playing timeline is the same map. */
82
+ resume(timeline: Timeline, host: number): Timeline;
83
+ /** `timeline` with the animation at `time` at host time `host`, playing or held as it was. */
84
+ seek(timeline: Timeline, host: number, time: number): Timeline;
85
+ /** `timeline` at `rate` from host time `host` on, with no jump: the animation time at `host` is what it was. */
86
+ withRate(timeline: Timeline, host: number, rate: number): Timeline;
87
+ /**
88
+ * `animation` on host time: its value at the time `timeline` maps each
89
+ * host time to, changing at the host moment the mapped time reaches the
90
+ * animation's own answer — backwards, when it drops under where the value
91
+ * began. Paused, the value never changes and never did. The value is the
92
+ * animation's own, on no clock: placed, it is stamped at the frame's time
93
+ * (`placedRender`, `./animation.ts`) with this animation's answers, so a
94
+ * value's contents follow the frame's clock and not this map.
95
+ * Reduced, it is the animation reduced on the same timeline: what shows
96
+ * still follows the timeline, and only the motion is gone.
97
+ */
98
+ run<T>(timeline: Timeline, animation: Timed<T>): Animation<T>;
99
+ /**
100
+ * Every one of `animations` at once, each on a timeline playing from its
101
+ * index times `delay` seconds after the earliest, their values in order, on
102
+ * a clock of the stagger's own from 0. It changes when the soonest of them
103
+ * does, on that clock (`Animation.all` of `run`s). A delay of 0 starts them
104
+ * together and a negative one starts the last at 0 and the first last.
105
+ * Reduced, the offsets are the cascade's choreography, which is motion: every
106
+ * child reduced at once, so from 0 everyone has arrived.
107
+ */
108
+ stagger<T>(animations: readonly Timed<T>[], delay: number): Animation<readonly T[]>;
109
+ };