@promptctl/rich-js 0.27.0 → 0.29.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 (185) hide show
  1. package/README.md +7 -6
  2. package/THIRD-PARTY-NOTICES +42 -0
  3. package/dist/core/animation.d.ts +65 -46
  4. package/dist/core/animation.js +91 -67
  5. package/dist/core/ansi.d.ts +19 -12
  6. package/dist/core/ansi.js +114 -36
  7. package/dist/core/bidi-data.d.ts +14 -0
  8. package/dist/core/bidi-data.js +385 -0
  9. package/dist/core/bidi.d.ts +89 -0
  10. package/dist/core/bidi.js +615 -0
  11. package/dist/core/box.d.ts +9 -1
  12. package/dist/core/box.js +11 -0
  13. package/dist/core/cell-widths.d.ts +3 -2
  14. package/dist/core/cell-widths.js +93 -93
  15. package/dist/core/cells.d.ts +20 -0
  16. package/dist/core/cells.js +94 -6
  17. package/dist/core/clock.d.ts +1 -1
  18. package/dist/core/color.d.ts +56 -57
  19. package/dist/core/color.js +79 -81
  20. package/dist/core/console.d.ts +24 -7
  21. package/dist/core/console.js +27 -8
  22. package/dist/core/control-codes.d.ts +61 -0
  23. package/dist/core/control-codes.js +157 -0
  24. package/dist/core/easing.d.ts +40 -40
  25. package/dist/core/easing.js +40 -40
  26. package/dist/core/env.d.ts +6 -0
  27. package/dist/core/env.js +9 -1
  28. package/dist/core/export-html.js +5 -4
  29. package/dist/core/export-lines.d.ts +13 -11
  30. package/dist/core/export-lines.js +19 -15
  31. package/dist/core/export-svg.d.ts +3 -3
  32. package/dist/core/export-svg.js +10 -9
  33. package/dist/core/json.js +9 -2
  34. package/dist/core/markup.js +5 -4
  35. package/dist/core/memo.d.ts +1 -1
  36. package/dist/core/memo.js +1 -1
  37. package/dist/core/oklch.d.ts +14 -14
  38. package/dist/core/oklch.js +10 -10
  39. package/dist/core/orient.d.ts +68 -0
  40. package/dist/core/orient.js +224 -0
  41. package/dist/core/osc8.d.ts +12 -5
  42. package/dist/core/osc8.js +14 -7
  43. package/dist/core/paint.d.ts +9 -9
  44. package/dist/core/paint.js +3 -3
  45. package/dist/core/pretty.d.ts +1 -1
  46. package/dist/core/pretty.js +23 -19
  47. package/dist/core/protocol.d.ts +47 -26
  48. package/dist/core/protocol.js +31 -23
  49. package/dist/core/render.d.ts +19 -14
  50. package/dist/core/render.js +30 -17
  51. package/dist/core/seal.d.ts +26 -0
  52. package/dist/core/seal.js +75 -0
  53. package/dist/core/segment.d.ts +12 -4
  54. package/dist/core/segment.js +16 -6
  55. package/dist/core/strip.d.ts +83 -83
  56. package/dist/core/strip.js +70 -70
  57. package/dist/core/style.d.ts +46 -18
  58. package/dist/core/style.js +92 -56
  59. package/dist/core/text-order.d.ts +51 -0
  60. package/dist/core/text-order.js +50 -0
  61. package/dist/core/text.d.ts +12 -2
  62. package/dist/core/text.js +136 -90
  63. package/dist/core/timeline.d.ts +5 -5
  64. package/dist/core/timeline.js +11 -11
  65. package/dist/core/tween.d.ts +6 -6
  66. package/dist/core/tween.js +10 -10
  67. package/dist/core/view.d.ts +5 -5
  68. package/dist/core/view.js +15 -14
  69. package/dist/core/wrap.d.ts +24 -6
  70. package/dist/core/wrap.js +82 -16
  71. package/dist/host/app.d.ts +50 -9
  72. package/dist/host/app.js +120 -15
  73. package/dist/host/host-environment.d.ts +2 -2
  74. package/dist/host/host-environment.js +2 -2
  75. package/dist/host/index.d.ts +3 -2
  76. package/dist/host/index.js +1 -0
  77. package/dist/host/input.d.ts +42 -9
  78. package/dist/host/input.js +91 -7
  79. package/dist/host/terminal-colors.d.ts +18 -0
  80. package/dist/host/terminal-colors.js +46 -0
  81. package/dist/host/terminal-host.d.ts +4 -3
  82. package/dist/host/terminal-host.js +1 -1
  83. package/dist/index.d.ts +17 -16
  84. package/dist/index.js +11 -11
  85. package/dist/node/prompt.d.ts +1 -1
  86. package/dist/node/prompt.js +1 -1
  87. package/dist/node/terminal-host.d.ts +1 -1
  88. package/dist/node/traceback.js +2 -2
  89. package/dist/renderables/change.d.ts +1 -3
  90. package/dist/renderables/change.js +6 -8
  91. package/dist/renderables/effect.d.ts +88 -87
  92. package/dist/renderables/effect.js +124 -114
  93. package/dist/renderables/effects.d.ts +105 -76
  94. package/dist/renderables/effects.js +309 -176
  95. package/dist/renderables/embed.d.ts +1 -1
  96. package/dist/renderables/embed.js +2 -2
  97. package/dist/renderables/flexStrip.d.ts +7 -7
  98. package/dist/renderables/flexStrip.js +15 -15
  99. package/dist/renderables/frames.d.ts +7 -16
  100. package/dist/renderables/frames.js +12 -27
  101. package/dist/renderables/glyph-effect.d.ts +22 -23
  102. package/dist/renderables/glyph-effect.js +43 -41
  103. package/dist/renderables/glyph-effects.d.ts +33 -14
  104. package/dist/renderables/glyph-effects.js +90 -22
  105. package/dist/renderables/html-entities.d.ts +1 -1
  106. package/dist/renderables/html-entities.js +1 -1
  107. package/dist/renderables/lifecycle.d.ts +8 -10
  108. package/dist/renderables/lifecycle.js +17 -18
  109. package/dist/renderables/live.d.ts +2 -2
  110. package/dist/renderables/live.js +7 -7
  111. package/dist/renderables/markdown-parse.js +3 -3
  112. package/dist/renderables/markdown.js +4 -2
  113. package/dist/renderables/panel.js +7 -5
  114. package/dist/renderables/progress.d.ts +1 -1
  115. package/dist/renderables/progress.js +1 -1
  116. package/dist/renderables/progressBar.d.ts +3 -3
  117. package/dist/renderables/progressBar.js +21 -18
  118. package/dist/renderables/prompt.d.ts +1 -1
  119. package/dist/renderables/scroll.d.ts +1 -1
  120. package/dist/renderables/scroll.js +3 -3
  121. package/dist/renderables/spinner.d.ts +4 -4
  122. package/dist/renderables/spinner.js +10 -10
  123. package/dist/renderables/syntax.d.ts +2 -2
  124. package/dist/renderables/syntax.js +9 -4
  125. package/dist/renderables/table.js +5 -4
  126. package/dist/renderables/traceback.d.ts +1 -1
  127. package/dist/renderables/traceback.js +1 -1
  128. package/dist/renderables/tree.js +3 -3
  129. package/dist/template-bindings/animation-funcs.d.ts +60 -29
  130. package/dist/template-bindings/animation-funcs.js +126 -43
  131. package/dist/template-bindings/color-funcs.d.ts +14 -14
  132. package/dist/template-bindings/color-funcs.js +25 -25
  133. package/dist/template-bindings/index.d.ts +16 -14
  134. package/dist/template-bindings/index.js +17 -14
  135. package/dist/template-bindings/palette-funcs.d.ts +2 -2
  136. package/dist/template-bindings/palette-funcs.js +4 -4
  137. package/dist/template-bindings/style-funcs.d.ts +5 -5
  138. package/dist/template-bindings/style-funcs.js +16 -16
  139. package/dist/themes/{transpose.d.ts → adjust.d.ts} +23 -23
  140. package/dist/themes/{transpose.js → adjust.js} +28 -28
  141. package/dist/themes/buildPalette.d.ts +6 -5
  142. package/dist/themes/buildPalette.js +22 -22
  143. package/dist/themes/colorMath.d.ts +16 -16
  144. package/dist/themes/colorMath.js +32 -32
  145. package/dist/themes/data/ansi-tables.d.ts +2 -2
  146. package/dist/themes/data/ansi-tables.js +2 -2
  147. package/dist/themes/data/index.js +1 -4
  148. package/dist/themes/data/textual-ansi.js +0 -143
  149. package/dist/themes/data/types.d.ts +4 -4
  150. package/dist/themes/data/types.js +2 -2
  151. package/dist/themes/palette.d.ts +12 -12
  152. package/dist/themes/palette.js +16 -16
  153. package/dist/themes/ramp.d.ts +17 -17
  154. package/dist/themes/ramp.js +12 -12
  155. package/dist/themes/registry.d.ts +6 -8
  156. package/dist/themes/registry.js +6 -8
  157. package/dist/themes/terminalThemes.d.ts +4 -11
  158. package/dist/themes/terminalThemes.js +31 -47
  159. package/dist/widgets/button.d.ts +3 -3
  160. package/dist/widgets/button.js +8 -8
  161. package/dist/widgets/checkbox.d.ts +2 -2
  162. package/dist/widgets/checkbox.js +4 -4
  163. package/dist/widgets/dropdown.d.ts +3 -3
  164. package/dist/widgets/dropdown.js +7 -5
  165. package/dist/widgets/event-router.d.ts +6 -6
  166. package/dist/widgets/event-router.js +2 -2
  167. package/dist/widgets/focus-manager.d.ts +3 -3
  168. package/dist/widgets/focus-manager.js +3 -3
  169. package/dist/widgets/index.d.ts +2 -2
  170. package/dist/widgets/index.js +1 -1
  171. package/dist/widgets/ink.d.ts +3 -3
  172. package/dist/widgets/ink.js +4 -4
  173. package/dist/widgets/slider.d.ts +2 -2
  174. package/dist/widgets/static-item.d.ts +2 -2
  175. package/dist/widgets/text-input.d.ts +2 -2
  176. package/dist/widgets/text-input.js +5 -5
  177. package/dist/widgets/themed-widget.js +4 -4
  178. package/dist/widgets/toggle.d.ts +4 -4
  179. package/dist/widgets/toggle.js +6 -6
  180. package/dist/widgets/types.d.ts +6 -6
  181. package/dist/widgets/types.js +2 -2
  182. package/dist/widgets/widget-app.d.ts +10 -10
  183. package/dist/widgets/widget-app.js +8 -8
  184. package/dist/widgets/widget-base.d.ts +2 -2
  185. package/package.json +5 -4
package/dist/core/text.js CHANGED
@@ -2,74 +2,56 @@
2
2
  * RichText — styled text with spans. The primary text type for the library.
3
3
  */
4
4
  import { cellLen, cellCount, cellFit, cellFitEnd, asCellCol, expandTabs, parseTabSize } from "./cells.js";
5
+ import { closed, closing } from "./bidi.js";
6
+ import { controlClass, dispositionOf, pictureOf } from "./control-codes.js";
7
+ import { reorder, drawnTail, edges, hangingWhitespace, measured } from "./orient.js";
5
8
  import { divideLine, wordWidths } from "./wrap.js";
6
9
  import { Segment } from "./segment.js";
7
10
  import { Style, NULL_STYLE, StyleSyntaxError } from "./style.js";
8
- import { stripOscTerminators } from "./osc8.js";
11
+ import { stripUrlControls } from "./osc8.js";
9
12
  import { betweenPasses, drawable, getStyle, withBoundedWidth } from "./protocol.js";
10
13
  import { pattern } from "./pattern.js";
11
14
  import { ListState, State } from "./state.js";
12
- // Every C0 control and DEL except \t and \n. Rich's `Text` strips only
13
- // `STRIP_CONTROL_CODES` (BEL, BS, VT, FF, CR); the rest of the set is the
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.
15
+ const STRIPPED = pattern(new RegExp(controlClass((disposition) => disposition === "strip"), "u"));
16
+ const STOOD_IN_FOR = pattern(new RegExp(controlClass((disposition) => disposition === "line-break" || disposition === "pictured"), "u"));
17
+ /** What content holds in place of a `line-break` or `pictured` code point. */
18
+ function standIn(character) {
19
+ const codePoint = character.codePointAt(0);
20
+ return dispositionOf(codePoint) === "pictured" ? pictureOf(codePoint) : "\n";
21
+ }
22
+ // `text` without its `strip` code points, all that `sanitized` deletes.
25
23
  // [LAW:single-enforcer] Single place where control chars are sanitized: every
26
- // string that becomes part of a Text's characters passes here.
24
+ // string that becomes part of a Text's characters passes through `sanitized`.
25
+ // Which ones, and why, is `CONTROL_CODES`. A code point at a time and never by its
26
+ // neighbours, because it runs again on every slice and fragment: what depends on
27
+ // context is `wireText`'s. `match` and `split` need no cursor, so `STRIPPED` can
28
+ // be held. `match` first because nearly every string is clean, and not `test`,
29
+ // which coerces: a template's unlifted scope value reaches here typed as a
30
+ // string, and has to throw rather than read as `[object Object]`.
27
31
  export function stripControlChars(text) {
28
- return text.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F\u202A-\u202E\u2066-\u206F]/g, "");
29
- }
30
- const TRAILING_WHITESPACE_RE = pattern(/\s+$/);
31
- /**
32
- * The plain text of one line of segments, in the coordinate system
33
- * `divideLine` and `Segment.divide` share: cell offsets into the styled line
34
- * are cell offsets into this string. [LAW:one-source-of-truth] for that
35
- * correspondence — the offsets are found in this text and applied to the
36
- * segments it came from.
37
- */
38
- function plainOf(line) {
39
- return line.map((segment) => segment.text).join("");
32
+ return text.match(STRIPPED) === null ? text : text.split(STRIPPED).join("");
40
33
  }
41
34
  /**
42
- * The cells of trailing whitespace on a wrapped line.
43
- *
44
- * A wrap cuts before a word, so the line it closes ends with the whitespace
45
- * that followed *its* last word — padding the break created, not content the
46
- * author wrote. Measuring it is what lets the overflow method tell "this line
47
- * was cut" from "this line ends in spaces" and stamp an ellipsis only on the
48
- * first, and what keeps a centred line from drifting half a space off true.
49
- *
50
- * Asked of the line's text rather than walked back through its segments,
51
- * because where a line ends is a fact about the characters and not about how
52
- * they were split. Walking the segments made it a fact about both: it read a
53
- * segment with no trailing whitespace as content and stopped there, which an
54
- * empty segment also looks like — and a crop leaves one behind. A styled line
55
- * whose last cells were cropped therefore reported no hanging whitespace at
56
- * all and was centred as though it were content to the edge, so a span landing
57
- * anywhere in a wrap's whitespace un-centred the line it closed.
35
+ * `text` as a Text's characters hold it: stripped, every line ending an LF, and
36
+ * every control its picture. One code unit for one, so an offset into stripped
37
+ * text holds, and every reader of a Text finds one line break without having to
38
+ * know the others.
58
39
  */
59
- function hangingWhitespace(line) {
60
- return cellLen(TRAILING_WHITESPACE_RE.exec(plainOf(line))?.[0] ?? "");
40
+ export function sanitized(text) {
41
+ const stripped = stripControlChars(text);
42
+ return stripped.match(STOOD_IN_FOR) === null ? stripped : stripped.replace(new RegExp(STOOD_IN_FOR.source, "gu"), standIn);
61
43
  }
62
44
  // [LAW:single-enforcer] RichText is the data-model trust boundary for link
63
45
  // URLs: a `Style` is sanitized as it enters (`admitStyle`), and the `Style` a
64
46
  // stored string resolves to is sanitized as it leaves for a render
65
47
  // (`resolveStyle`). Wire-byte safety is enforced separately by `osc8Open`
66
48
  // (core/osc8.ts), which every link producer calls and which applies the same
67
- // `stripOscTerminators`.
49
+ // `stripUrlControls`.
68
50
  function sanitizeStyleLink(style) {
69
51
  const link = style.link;
70
52
  if (!link)
71
53
  return style;
72
- const cleaned = stripOscTerminators(link);
54
+ const cleaned = stripUrlControls(link);
73
55
  if (cleaned === link)
74
56
  return style;
75
57
  return style.withLink(cleaned);
@@ -85,7 +67,7 @@ function sanitizeStyleLink(style) {
85
67
  * lays its text out anew, so a stamp naming where a cell sat in the output it
86
68
  * came from would name the wrong cell once it wraps, slices or overlaps; the
87
69
  * owner that renders the RichText is the one that stamps its cells. A
88
- * hold stays — `lastChange` and `nextChange`: when a span's cells began and
70
+ * hold stays — `validFrom` and `validUntil`: when a span's cells began and
89
71
  * next change is true of them wherever the text lays them, so a frame
90
72
  * appended into a line still says when it turns.
91
73
  */
@@ -173,14 +155,18 @@ export class Span {
173
155
  export class RichText {
174
156
  // Rich's `Text` is changed in place — appended to, stylized, padded — so its
175
157
  // 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
158
+ // something is drawn throws. Four holders rather than one per field: a
177
159
  // `RichText` is the most-made object in the library.
178
160
  _text;
179
161
  _spans = new ListState();
162
+ // What `append` has added to `text` since it last closed it, so closing reads
163
+ // no more than that. Any other write leaves `text` naming an older string, and
164
+ // the whole is read instead.
165
+ _closure = new State({ text: "", tail: "" });
180
166
  _format;
181
167
  _tabSize;
182
168
  constructor(text, options) {
183
- this._text = new State(text ? stripControlChars(text) : "");
169
+ this._text = new State(text ? sanitized(text) : "");
184
170
  this._format = new State({
185
171
  // [LAW:single-enforcer] `admitStyle` is the boundary that sanitizes
186
172
  // any link URL crossing into a RichText; downstream trusts the invariant.
@@ -192,6 +178,12 @@ export class RichText {
192
178
  });
193
179
  this._tabSize = parseTabSize(options?.tabSize ?? 8);
194
180
  }
181
+ /** The part of the text whose direction state nothing has closed. */
182
+ _unclosed() {
183
+ const text = this._text.get();
184
+ const closure = this._closure.get();
185
+ return closure.text === text ? closure.tail : text;
186
+ }
195
187
  _reformat(change) {
196
188
  this._format.set({ ...this._format.get(), ...change });
197
189
  }
@@ -200,10 +192,10 @@ export class RichText {
200
192
  return this._text.get();
201
193
  }
202
194
  set plain(value) {
203
- const sanitized = stripControlChars(value);
204
- this._text.set(sanitized);
195
+ const clean = sanitized(value);
196
+ this._text.set(clean);
205
197
  // Trim spans that extend beyond new length
206
- const len = sanitized.length;
198
+ const len = clean.length;
207
199
  this._spans.set(this._spans.get()
208
200
  .map((s) => (s.end > len ? new Span(s.start, Math.min(s.end, len), s.style) : s))
209
201
  .filter((s) => s.start < len));
@@ -272,8 +264,8 @@ export class RichText {
272
264
  * any spans covering the leftmost (side="left") or rightmost (side="right")
273
265
  * character.
274
266
  *
275
- * [LAW:locality-or-seam] Used by `Joiner`s to paint the transition between
276
- * adjacent `Strip` items. Joiners only ever need the column adjacent to
267
+ * [LAW:locality-or-seam] Used by `Separator`s to paint the transition between
268
+ * adjacent `Strip` items. Separators only ever need the column adjacent to
277
269
  * them, so the cell type exposes that column rather than constraining its
278
270
  * interior to be uniform. For RichText with uniform styling, both edges
279
271
  * return the same style; for RichText with edge variation, each edge
@@ -291,7 +283,8 @@ export class RichText {
291
283
  const base = this.resolvedStyle(options);
292
284
  if (this._text.get().length === 0)
293
285
  return base;
294
- const pos = side === "left" ? 0 : this._text.get().length - 1;
286
+ const shown = edges(this._text.get(), options.textOrder ?? "plain");
287
+ const pos = side === "left" ? shown.left : shown.right;
295
288
  let result = base;
296
289
  for (const span of this._spans.get()) {
297
290
  if (span.start <= pos && pos < span.end) {
@@ -306,10 +299,15 @@ export class RichText {
306
299
  if (style !== undefined) {
307
300
  throw new Error("Style argument must not be provided when appending RichText");
308
301
  }
309
- const offset = this._text.get().length;
302
+ // [LAW:single-enforcer] Reached only when separate Texts are set on one
303
+ // line, so here direction state ends with the content holding it. The
304
+ // string arm below builds one content, and an embedding spans it.
305
+ const own = this._text.get() + closing(this._unclosed());
306
+ const offset = own.length;
310
307
  // Read before anything is appended, so a text appended to itself takes its spans as they were.
311
308
  const moved = content._spans.get().map((span) => span.move(offset));
312
- this._text.set(this._text.get() + content._text.get());
309
+ this._text.set(own + closed(content._text.get()));
310
+ this._closure.set({ text: this._text.get(), tail: "" });
313
311
  // Its base style becomes a span under its own spans, as the reference's
314
312
  // `append` does, so appended text looks as it did alone.
315
313
  this._addSpan(offset, this._text.get().length, content._format.get().style);
@@ -317,9 +315,11 @@ export class RichText {
317
315
  this._spans.push(span);
318
316
  return this;
319
317
  }
320
- const sanitized = stripControlChars(content);
318
+ const clean = sanitized(content);
321
319
  const start = this._text.get().length;
322
- this._text.set(this._text.get() + sanitized);
320
+ const tail = this._unclosed() + clean;
321
+ this._text.set(this._text.get() + clean);
322
+ this._closure.set({ text: this._text.get(), tail });
323
323
  this._addSpan(start, this._text.get().length, style ?? "");
324
324
  return this;
325
325
  }
@@ -475,7 +475,7 @@ export class RichText {
475
475
  return this;
476
476
  }
477
477
  pad(count, char) {
478
- const padding = stripControlChars(char ?? " ").repeat(count);
478
+ const padding = sanitized(char ?? " ").repeat(count);
479
479
  // Shift all spans right by what the left side gained.
480
480
  this._spans.set(this._spans.get().map((s) => s.move(padding.length)));
481
481
  this._text.set(padding + this._text.get() + padding);
@@ -493,13 +493,13 @@ export class RichText {
493
493
  return this;
494
494
  }
495
495
  padLeft(count, char) {
496
- const padding = stripControlChars(char ?? " ").repeat(count);
496
+ const padding = sanitized(char ?? " ").repeat(count);
497
497
  this._spans.set(this._spans.get().map((s) => s.move(padding.length)));
498
498
  this._text.set(padding + this._text.get());
499
499
  return this;
500
500
  }
501
501
  padRight(count, char) {
502
- this._text.set(this._text.get() + stripControlChars(char ?? " ").repeat(count));
502
+ this._text.set(this._text.get() + sanitized(char ?? " ").repeat(count));
503
503
  return this;
504
504
  }
505
505
  setLength(length) {
@@ -558,7 +558,7 @@ 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(stripControlChars(options?.marker ?? "\u2026"), cells);
561
+ const marker = cellFit(sanitized(options?.marker ?? "\u2026"), cells);
562
562
  const budget = asCellCol(cells - cellLen(marker));
563
563
  if (mode === "right") {
564
564
  this._cropRightTo(budget);
@@ -662,25 +662,24 @@ export class RichText {
662
662
  }
663
663
  // --- Token Appending ---
664
664
  appendTokens(tokens) {
665
+ // Each token is its own content, as `assemble`'s parts are.
665
666
  for (const [text, style] of tokens) {
666
- this.append(text, style);
667
+ this.append(new RichText(text, { style }));
667
668
  }
668
669
  return this;
669
670
  }
670
671
  // --- Static Factories ---
671
672
  static assemble(parts, options) {
672
673
  const result = new RichText("", { style: options?.style });
674
+ // Each part is its own content, a string's included, so each takes
675
+ // `append`'s RichText arm.
673
676
  for (const part of parts) {
674
- if (typeof part === "string") {
675
- result.append(part);
676
- }
677
- else if (part instanceof RichText) {
677
+ if (part instanceof RichText)
678
678
  result.append(part);
679
- }
680
- else {
681
- const [text, style] = part;
682
- result.append(text, style);
683
- }
679
+ else if (typeof part === "string")
680
+ result.append(new RichText(part));
681
+ else
682
+ result.append(new RichText(part[0], { style: part[1] }));
684
683
  }
685
684
  return result;
686
685
  }
@@ -754,19 +753,32 @@ export class RichText {
754
753
  const justify = overflow === "ignore" ? undefined : this._format.get().justify ?? options.justify;
755
754
  // One cell either way, so the marker's stand-in leaves the cut where it was.
756
755
  const ellipsis = drawable(options, "\u2026", ".");
756
+ const order = options.textOrder ?? "plain";
757
757
  for (let index = 0; index < logicalLines.length; index += 1) {
758
- const line = logicalLines[index];
758
+ // [LAW:dataflow-not-control-flow] Every line runs the same steps; the
759
+ // order is the value `directed` and `orient` take.
760
+ const { line, oriented, orient } = reorder(logicalLines[index], order);
759
761
  const free = spanFree[index];
760
762
  const terminateLine = index < logicalLines.length - 1;
761
763
  // Wrap first, overflow last — the reference's order, and the reason a
762
764
  // long sentence grows a table row while an unbreakable word in the same
763
765
  // column still ellipsizes.
764
- const cuts = divideLine(plainOf(line), wrapAt, { fold: overflow === "fold" });
765
- const wrapped = Segment.divide(line, cuts);
766
- const placed = this._justifyLines(wrapped.map((piece, at) => ({
767
- segments: [...this._fitLine(piece, edge, overflow, ellipsis)],
768
- free: (cell) => free(at === 0 ? cell : cuts[at - 1] + cell),
769
- })), maxWidth, base, justify);
766
+ const breaks = divideLine(Segment.plainOf(line), wrapAt, { fold: overflow === "fold" });
767
+ const wrapped = Segment.divide(hyphenated(line, breaks), drawnCuts(breaks));
768
+ let start = 0;
769
+ const placed = this._justifyLines(wrapped.map((piece, at) => {
770
+ const source = Segment.plainOf(piece);
771
+ const from = start;
772
+ start += source.length;
773
+ return {
774
+ segments: [...this._fitLine(piece, edge, overflow, ellipsis)],
775
+ // An oriented row's cells are not where `free` counted them, and a
776
+ // pad joined to one would land inside its isolate. A hyphen's cell
777
+ // was counted by nobody.
778
+ free: oriented || breaks[at]?.hyphen !== undefined ? NEVER_FREE : (cell) => free(at === 0 ? cell : breaks[at - 1].cell + cell),
779
+ orient: (segments) => orient(segments, from, source),
780
+ };
781
+ }), maxWidth, base, justify);
770
782
  for (let piece = 0; piece < placed.length; piece += 1) {
771
783
  yield* placed[piece];
772
784
  if (piece < placed.length - 1 || terminateLine) {
@@ -785,7 +797,7 @@ export class RichText {
785
797
  }
786
798
  // --- Measurable ---
787
799
  measure(options) {
788
- const lines = expandTabs(this._text.get(), this._tabSize).text.split("\n");
800
+ const lines = measured(expandTabs(this._text.get(), this._tabSize).text, options.textOrder ?? "plain").split("\n");
789
801
  const maxLineWidth = lines.reduce((widest, line) => Math.max(widest, cellLen(line)), 0);
790
802
  // [LAW:one-source-of-truth] The minimum is the widest piece the wrapper
791
803
  // never breaks, asked of the wrapper — so a line's indent, which wraps
@@ -882,7 +894,7 @@ export class RichText {
882
894
  if (justify !== "full") {
883
895
  return lines.map((line) => [...this._justifyLine(line, maxWidth, base, justify)]);
884
896
  }
885
- return lines.map(({ segments }, index) => index === lines.length - 1 ? segments : this._fillLine(segments, maxWidth, base));
897
+ return lines.map(({ segments, orient }, index) => orient(index === lines.length - 1 ? segments : this._fillLine(segments, maxWidth, base)));
886
898
  }
887
899
  /**
888
900
  * One line placed in a canvas `maxWidth` wide.
@@ -901,7 +913,7 @@ export class RichText {
901
913
  * The padding is drawn in the text's base style, as the reference pads
902
914
  * inside the `Text` it justifies: a title "on red" fills its row in red.
903
915
  */
904
- *_justifyLine({ segments: line, free }, maxWidth, base,
916
+ *_justifyLine({ segments: line, free, orient }, maxWidth, base,
905
917
  // [LAW:types-are-the-program] `full` is absent rather than ignored: it
906
918
  // needs the lines either side of this one, so the type refuses it here
907
919
  // instead of a branch quietly rendering it as `left`, which is the bug
@@ -914,14 +926,16 @@ export class RichText {
914
926
  const body = Segment.adjustLineLength(line, Segment.getLineLength(line) - hangingWhitespace(line), undefined, false);
915
927
  const gap = Math.max(maxWidth - Segment.getLineLength(body), 0);
916
928
  const leftPad = justify === "center" ? Math.floor(gap / 2) : gap;
917
- yield* padded(body, free, leftPad, gap - leftPad, padStyle);
929
+ yield* padded(orient(body), free, leftPad, gap - leftPad, padStyle);
918
930
  break;
919
931
  }
920
- case "left":
921
- yield* padded(line, free, 0, Math.max(maxWidth - Segment.getLineLength(line), 0), padStyle);
932
+ case "left": {
933
+ const row = orient(line);
934
+ yield* padded(row, free, 0, Math.max(maxWidth - Segment.getLineLength(row), 0), padStyle);
922
935
  break;
936
+ }
923
937
  default:
924
- yield* line;
938
+ yield* orient(line);
925
939
  break;
926
940
  }
927
941
  }
@@ -946,9 +960,9 @@ export class RichText {
946
960
  * blocks respectively.
947
961
  */
948
962
  _fillLine(line, maxWidth, base) {
949
- const plain = plainOf(line);
963
+ const plain = Segment.plainOf(line);
950
964
  const words = plain.split(" ");
951
- if (plain.endsWith(" "))
965
+ if (drawnTail(plain).endsWith(" "))
952
966
  words.pop();
953
967
  const gaps = words.length - 1;
954
968
  const spaces = new Array(gaps).fill(1);
@@ -1030,6 +1044,38 @@ export class RichText {
1030
1044
  yield* Segment.adjustLineLength(line, maxWidth, undefined, false);
1031
1045
  }
1032
1046
  }
1047
+ const NEVER_FREE = () => false;
1048
+ /**
1049
+ * `line` with the soft hyphen each of `breaks` took turned into `-`, in its own
1050
+ * segment and so its own style. One code unit for one, so an offset into the line
1051
+ * still reads the logical line `orient` was built from.
1052
+ */
1053
+ function hyphenated(line, breaks) {
1054
+ const taken = breaks.flatMap(({ hyphen }) => hyphen ?? []);
1055
+ let next = 0;
1056
+ let end = 0;
1057
+ return line.map((segment) => {
1058
+ const start = end;
1059
+ end += segment.text.length;
1060
+ let text = "";
1061
+ let from = 0;
1062
+ for (; next < taken.length && taken[next] < end; next += 1) {
1063
+ const at = taken[next] - start;
1064
+ text += segment.text.slice(from, at) + "-";
1065
+ from = at + 1;
1066
+ }
1067
+ text += segment.text.slice(from);
1068
+ return text === segment.text ? segment : new Segment(text, segment.style, segment.control);
1069
+ });
1070
+ }
1071
+ /** Where `breaks` cut once `hyphenated` has given each hyphen its cell, which the line above it ends in. */
1072
+ function drawnCuts(breaks) {
1073
+ let hyphens = 0;
1074
+ return breaks.map(({ cell, hyphen }) => {
1075
+ hyphens += hyphen === undefined ? 0 : 1;
1076
+ return asCellCol(cell + hyphens);
1077
+ });
1078
+ }
1033
1079
  /**
1034
1080
  * For each logical line of a render, whether the cell at an offset is covered
1035
1081
  * by no span. `covered` is `_buildSegments`'s answer per piece; a piece split
@@ -15,13 +15,13 @@
15
15
  * one time it holds, so there is no origin for it to be wrong about.
16
16
  * [LAW:types-are-the-program]
17
17
  *
18
- * `run` is an animation on host time. Its `nextChange` is the host moment
18
+ * `run` is an animation on host time. Its `validUntil` is the host moment
19
19
  * the mapped time reaches the animation's own answer, which is what
20
20
  * `Spinner`'s speed scaled its holds for before this existed: an answer a
21
21
  * host reads is on the host's timeline, and a beat over scaled time answers
22
22
  * on the scaled one. Run backwards, the host next sees a change when the
23
23
  * mapped time drops under where the value began — the animation's
24
- * `lastChange` — so a reversed loop turns exactly at its hold boundaries,
24
+ * `validFrom` — so a reversed loop turns exactly at its hold boundaries,
25
25
  * and a reversed one-shot changes at its switch and never before its start.
26
26
  * Paused, nothing changes: never, and never did. The host moment is the
27
27
  * first host float the map carries past the edge, searched out from the sum
@@ -50,7 +50,7 @@
50
50
  * hands in, so the same host time maps to the same animation time at one
51
51
  * sample a second or sixty.
52
52
  */
53
- import { Animation, type Timed } from "./animation.js";
53
+ import { Animation, type Behavior } from "./animation.js";
54
54
  /**
55
55
  * A map from host time to animation time. Playing, it is `time` at `origin`
56
56
  * moving `rate` animation seconds a host second; paused, it is `time` at
@@ -95,7 +95,7 @@ export declare const Timeline: {
95
95
  * Reduced, it is the animation reduced on the same timeline: what shows
96
96
  * still follows the timeline, and only the motion is gone.
97
97
  */
98
- run<T>(timeline: Timeline, animation: Timed<T>): Animation<T>;
98
+ run<T>(timeline: Timeline, animation: Behavior<T>): Animation<T>;
99
99
  /**
100
100
  * Every one of `animations` at once, each on a timeline playing from its
101
101
  * index times `delay` seconds after the earliest, their values in order, on
@@ -105,5 +105,5 @@ export declare const Timeline: {
105
105
  * Reduced, the offsets are the cascade's choreography, which is motion: every
106
106
  * child reduced at once, so from 0 everyone has arrived.
107
107
  */
108
- stagger<T>(animations: readonly Timed<T>[], delay: number): Animation<readonly T[]>;
108
+ stagger<T>(animations: readonly Behavior<T>[], delay: number): Animation<readonly T[]>;
109
109
  };
@@ -15,13 +15,13 @@
15
15
  * one time it holds, so there is no origin for it to be wrong about.
16
16
  * [LAW:types-are-the-program]
17
17
  *
18
- * `run` is an animation on host time. Its `nextChange` is the host moment
18
+ * `run` is an animation on host time. Its `validUntil` is the host moment
19
19
  * the mapped time reaches the animation's own answer, which is what
20
20
  * `Spinner`'s speed scaled its holds for before this existed: an answer a
21
21
  * host reads is on the host's timeline, and a beat over scaled time answers
22
22
  * on the scaled one. Run backwards, the host next sees a change when the
23
23
  * mapped time drops under where the value began — the animation's
24
- * `lastChange` — so a reversed loop turns exactly at its hold boundaries,
24
+ * `validFrom` — so a reversed loop turns exactly at its hold boundaries,
25
25
  * and a reversed one-shot changes at its switch and never before its start.
26
26
  * Paused, nothing changes: never, and never did. The host moment is the
27
27
  * first host float the map carries past the edge, searched out from the sum
@@ -82,10 +82,10 @@ function played(animation, { origin, time, rate }) {
82
82
  // Forwards, the host next sees the animation's own next change, and the
83
83
  // value began when the animation says it did; backwards, it next sees a
84
84
  // change where the value began, and the value began where the animation
85
- // says it ends. A hold is `[lastChange, nextChange)`, so the far side of
85
+ // says it ends. A hold is `[validFrom, validUntil)`, so the far side of
86
86
  // an edge is at it going up and strictly under it coming down.
87
- const ahead = forward ? (t) => animation.nextChange(t) : (t) => animation.lastChange(t);
88
- const behind = forward ? (t) => animation.lastChange(t) : (t) => animation.nextChange(t);
87
+ const ahead = forward ? (t, moves) => animation.validUntil(t, moves) : (t, moves) => animation.validFrom(t, moves);
88
+ const behind = forward ? (t, moves) => animation.validFrom(t, moves) : (t, moves) => animation.validUntil(t, moves);
89
89
  const past = forward ? (t, edge) => t >= edge : (t, edge) => t < edge;
90
90
  // The first host moment the map carries past `edge`, an animation time.
91
91
  // An edge at infinity, or one so far off at this rate that no host float
@@ -97,16 +97,16 @@ function played(animation, { origin, time, rate }) {
97
97
  };
98
98
  return Animation.of({
99
99
  at: (host) => animation.at(mapped(hostTime(host))),
100
- nextChange(host) {
100
+ validUntil(host, moves) {
101
101
  const h = hostTime(host);
102
102
  const t = mapped(h);
103
- const edge = ahead(t);
103
+ const edge = ahead(t, moves);
104
104
  return edge === t ? h : Math.max(h, hostOf(edge));
105
105
  },
106
- lastChange(host) {
106
+ validFrom(host, moves) {
107
107
  const h = hostTime(host);
108
108
  const t = mapped(h);
109
- const edge = behind(t);
109
+ const edge = behind(t, moves);
110
110
  return edge === t ? h : Math.min(h, hostOf(edge));
111
111
  },
112
112
  reduced: () => played(animation.reduced(), { playing: true, origin, time, rate }),
@@ -154,8 +154,8 @@ export const Timeline = {
154
154
  return played(animation, timeline);
155
155
  return Animation.of({
156
156
  at: (host) => animation.at(Timeline.at(timeline, host)),
157
- nextChange: (host) => (hostTime(host), Number.POSITIVE_INFINITY),
158
- lastChange: (host) => (hostTime(host), Number.NEGATIVE_INFINITY),
157
+ validUntil: (host) => (hostTime(host), Number.POSITIVE_INFINITY),
158
+ validFrom: (host) => (hostTime(host), Number.NEGATIVE_INFINITY),
159
159
  reduced: () => Timeline.run(timeline, animation.reduced()),
160
160
  });
161
161
  },
@@ -2,11 +2,11 @@
2
2
  * Tween — a value eased from one number to another over seconds, then held:
3
3
  * the animation a progress bar's fill, a counter or a width is handed so it
4
4
  * moves to its new value instead of jumping. The first `Animation` of a
5
- * value that is never drawn as cells, so its `nextChange` is its only way of
5
+ * value that is never drawn as cells, so its `validUntil` is its only way of
6
6
  * saying when to look again (`./animation.ts`).
7
7
  *
8
8
  * The value at `t` is the `Timing`'s ease of the way from `from` to `to`
9
- * through `Phase.once(0, seconds)`: `from` at `t ≤ 0` whenever the ease
9
+ * through `WrapMode.once(0, seconds)`: `from` at `t ≤ 0` whenever the ease
10
10
  * starts at 0, as every built-in does but `step-start` and the steps that
11
11
  * jump at the start of their interval, which show their first jump before
12
12
  * the tween begins, as `ColorRamp.at` does; `to` from `seconds` on. It is
@@ -25,9 +25,9 @@
25
25
  * tween(old.at(t₁), to, timing))` — and starts from where it is because
26
26
  * that is where it was built from. [LAW:no-ambient-temporal-coupling]
27
27
  *
28
- * Over colours it is a `ColorRamp` read over time (`../themes/ramp.ts`,
29
- * `over`): the ramp is already the one place two colours are blended, in
30
- * OKLCH, and a colour tween is a two-stop ramp over its seconds, not a
28
+ * Over colors it is a `ColorRamp` read over time (`../themes/ramp.ts`,
29
+ * `over`): the ramp is already the one place two colors are blended, in
30
+ * OKLCH, and a color tween is a two-stop ramp over its seconds, not a
31
31
  * second blender. [LAW:one-source-of-truth]
32
32
  *
33
33
  * Tier 6 of `src/core/`: `./easing.ts` and `./animation.ts`, nothing else.
@@ -41,7 +41,7 @@ import { type Timing } from "./easing.js";
41
41
  * every ease that starts at 0), `to` after, and so
42
42
  * seen at its end, never mid-way, by a host whose frames are further apart
43
43
  * than it is long. `from` and `to` must be finite; the seconds are checked
44
- * where every one-shot's are, in `Phase.once`. A tween to where it already
44
+ * where every one-shot's are, in `WrapMode.once`. A tween to where it already
45
45
  * is is `Animation.constant(from)`, decided here once: the blend would land
46
46
  * one ulp off `from` at some `p`, and a value that never moves is one that
47
47
  * never changes, so a host is told not to look again. [LAW:parse-dont-validate]
@@ -2,11 +2,11 @@
2
2
  * Tween — a value eased from one number to another over seconds, then held:
3
3
  * the animation a progress bar's fill, a counter or a width is handed so it
4
4
  * moves to its new value instead of jumping. The first `Animation` of a
5
- * value that is never drawn as cells, so its `nextChange` is its only way of
5
+ * value that is never drawn as cells, so its `validUntil` is its only way of
6
6
  * saying when to look again (`./animation.ts`).
7
7
  *
8
8
  * The value at `t` is the `Timing`'s ease of the way from `from` to `to`
9
- * through `Phase.once(0, seconds)`: `from` at `t ≤ 0` whenever the ease
9
+ * through `WrapMode.once(0, seconds)`: `from` at `t ≤ 0` whenever the ease
10
10
  * starts at 0, as every built-in does but `step-start` and the steps that
11
11
  * jump at the start of their interval, which show their first jump before
12
12
  * the tween begins, as `ColorRamp.at` does; `to` from `seconds` on. It is
@@ -25,23 +25,23 @@
25
25
  * tween(old.at(t₁), to, timing))` — and starts from where it is because
26
26
  * that is where it was built from. [LAW:no-ambient-temporal-coupling]
27
27
  *
28
- * Over colours it is a `ColorRamp` read over time (`../themes/ramp.ts`,
29
- * `over`): the ramp is already the one place two colours are blended, in
30
- * OKLCH, and a colour tween is a two-stop ramp over its seconds, not a
28
+ * Over colors it is a `ColorRamp` read over time (`../themes/ramp.ts`,
29
+ * `over`): the ramp is already the one place two colors are blended, in
30
+ * OKLCH, and a color tween is a two-stop ramp over its seconds, not a
31
31
  * second blender. [LAW:one-source-of-truth]
32
32
  *
33
33
  * Tier 6 of `src/core/`: `./easing.ts` and `./animation.ts`, nothing else.
34
34
  * [LAW:one-way-deps]
35
35
  */
36
36
  import { Animation } from "./animation.js";
37
- import { Phase } from "./easing.js";
37
+ import { WrapMode } from "./easing.js";
38
38
  /**
39
39
  * `from` eased to `to` over `timing`, as an `Animation<number>`: moving from
40
40
  * 0 to `timing.seconds` on its own clock, the ease of 0 before (`from`, for
41
41
  * every ease that starts at 0), `to` after, and so
42
42
  * seen at its end, never mid-way, by a host whose frames are further apart
43
43
  * than it is long. `from` and `to` must be finite; the seconds are checked
44
- * where every one-shot's are, in `Phase.once`. A tween to where it already
44
+ * where every one-shot's are, in `WrapMode.once`. A tween to where it already
45
45
  * is is `Animation.constant(from)`, decided here once: the blend would land
46
46
  * one ulp off `from` at some `p`, and a value that never moves is one that
47
47
  * never changes, so a host is told not to look again. [LAW:parse-dont-validate]
@@ -52,7 +52,7 @@ export function tween(from, to, timing) {
52
52
  throw new RangeError(`tween ${name} must be finite, got ${v}`);
53
53
  }
54
54
  const { ease, seconds } = timing;
55
- const phase = Phase.once(0, seconds);
55
+ const phase = WrapMode.once(0, seconds);
56
56
  if (from === to)
57
57
  return Animation.constant(from);
58
58
  return Animation.of({
@@ -63,8 +63,8 @@ export function tween(from, to, timing) {
63
63
  // Before it begins the value is held until 0; while it moves, every
64
64
  // frame; from `seconds` on, never again — and it became what it is at
65
65
  // `seconds`, or always was before it began.
66
- nextChange: (t) => (t < 0 ? 0 : t < seconds ? t : Number.POSITIVE_INFINITY),
67
- lastChange: (t) => (t < 0 ? Number.NEGATIVE_INFINITY : t < seconds ? t : seconds),
66
+ validUntil: (t) => (t < 0 ? 0 : t < seconds ? t : Number.POSITIVE_INFINITY),
67
+ validFrom: (t) => (t < 0 ? Number.NEGATIVE_INFINITY : t < seconds ? t : seconds),
68
68
  // A one-shot, reduced, cuts from where it starts to where it arrives.
69
69
  reduced: () => Animation.cut(from, to),
70
70
  });