@promptctl/rich-js 0.27.0 → 0.28.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 (110) hide show
  1. package/README.md +1 -0
  2. package/THIRD-PARTY-NOTICES +42 -0
  3. package/dist/core/animation.d.ts +36 -17
  4. package/dist/core/animation.js +61 -37
  5. package/dist/core/ansi.d.ts +15 -8
  6. package/dist/core/ansi.js +106 -28
  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 +93 -5
  17. package/dist/core/color.d.ts +6 -7
  18. package/dist/core/color.js +11 -13
  19. package/dist/core/console.d.ts +18 -1
  20. package/dist/core/console.js +24 -5
  21. package/dist/core/control-codes.d.ts +61 -0
  22. package/dist/core/control-codes.js +157 -0
  23. package/dist/core/env.d.ts +6 -0
  24. package/dist/core/env.js +9 -1
  25. package/dist/core/export-html.js +4 -3
  26. package/dist/core/export-lines.d.ts +2 -0
  27. package/dist/core/export-lines.js +7 -3
  28. package/dist/core/export-svg.js +6 -5
  29. package/dist/core/json.js +9 -2
  30. package/dist/core/markup.js +4 -3
  31. package/dist/core/orient.d.ts +68 -0
  32. package/dist/core/orient.js +224 -0
  33. package/dist/core/osc8.d.ts +12 -5
  34. package/dist/core/osc8.js +14 -7
  35. package/dist/core/pretty.js +22 -18
  36. package/dist/core/protocol.d.ts +27 -6
  37. package/dist/core/protocol.js +14 -6
  38. package/dist/core/render.d.ts +8 -3
  39. package/dist/core/render.js +18 -5
  40. package/dist/core/seal.d.ts +26 -0
  41. package/dist/core/seal.js +75 -0
  42. package/dist/core/segment.d.ts +9 -1
  43. package/dist/core/segment.js +11 -1
  44. package/dist/core/style.d.ts +29 -1
  45. package/dist/core/style.js +41 -5
  46. package/dist/core/text-order.d.ts +51 -0
  47. package/dist/core/text-order.js +50 -0
  48. package/dist/core/text.d.ts +10 -0
  49. package/dist/core/text.js +133 -87
  50. package/dist/core/timeline.js +6 -6
  51. package/dist/core/view.d.ts +1 -1
  52. package/dist/core/view.js +5 -4
  53. package/dist/core/wrap.d.ts +24 -6
  54. package/dist/core/wrap.js +82 -16
  55. package/dist/host/app.d.ts +44 -3
  56. package/dist/host/app.js +111 -6
  57. package/dist/host/index.d.ts +3 -2
  58. package/dist/host/index.js +1 -0
  59. package/dist/host/input.d.ts +35 -2
  60. package/dist/host/input.js +88 -4
  61. package/dist/host/terminal-colors.d.ts +18 -0
  62. package/dist/host/terminal-colors.js +46 -0
  63. package/dist/host/terminal-host.d.ts +2 -1
  64. package/dist/index.d.ts +5 -4
  65. package/dist/index.js +2 -2
  66. package/dist/renderables/change.d.ts +1 -3
  67. package/dist/renderables/change.js +4 -6
  68. package/dist/renderables/effect.d.ts +60 -59
  69. package/dist/renderables/effect.js +91 -81
  70. package/dist/renderables/effects.d.ts +37 -8
  71. package/dist/renderables/effects.js +185 -52
  72. package/dist/renderables/frames.d.ts +1 -10
  73. package/dist/renderables/frames.js +1 -16
  74. package/dist/renderables/glyph-effect.d.ts +12 -13
  75. package/dist/renderables/glyph-effect.js +34 -32
  76. package/dist/renderables/glyph-effects.d.ts +27 -8
  77. package/dist/renderables/glyph-effects.js +75 -7
  78. package/dist/renderables/lifecycle.d.ts +5 -7
  79. package/dist/renderables/lifecycle.js +6 -7
  80. package/dist/renderables/markdown.js +4 -2
  81. package/dist/renderables/panel.js +6 -4
  82. package/dist/renderables/progressBar.d.ts +3 -3
  83. package/dist/renderables/progressBar.js +16 -13
  84. package/dist/renderables/syntax.js +7 -2
  85. package/dist/renderables/table.js +2 -1
  86. package/dist/template-bindings/animation-funcs.d.ts +49 -18
  87. package/dist/template-bindings/animation-funcs.js +115 -32
  88. package/dist/template-bindings/index.d.ts +7 -5
  89. package/dist/template-bindings/index.js +8 -5
  90. package/dist/template-bindings/palette-funcs.d.ts +1 -1
  91. package/dist/template-bindings/palette-funcs.js +1 -1
  92. package/dist/themes/buildPalette.d.ts +3 -2
  93. package/dist/themes/buildPalette.js +2 -2
  94. package/dist/themes/data/index.js +1 -4
  95. package/dist/themes/data/textual-ansi.js +0 -143
  96. package/dist/themes/ramp.d.ts +1 -1
  97. package/dist/themes/ramp.js +1 -1
  98. package/dist/themes/registry.d.ts +6 -8
  99. package/dist/themes/registry.js +2 -4
  100. package/dist/themes/terminalThemes.d.ts +4 -11
  101. package/dist/themes/terminalThemes.js +31 -47
  102. package/dist/widgets/button.d.ts +1 -1
  103. package/dist/widgets/button.js +6 -6
  104. package/dist/widgets/checkbox.js +4 -4
  105. package/dist/widgets/dropdown.d.ts +1 -1
  106. package/dist/widgets/dropdown.js +7 -5
  107. package/dist/widgets/themed-widget.js +3 -3
  108. package/dist/widgets/toggle.d.ts +1 -1
  109. package/dist/widgets/toggle.js +5 -5
  110. 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 { directed, 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);
@@ -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));
@@ -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 } = directed(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
@@ -84,8 +84,8 @@ function played(animation, { origin, time, rate }) {
84
84
  // change where the value began, and the value began where the animation
85
85
  // says it ends. A hold is `[lastChange, nextChange)`, 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.nextChange(t, moves) : (t, moves) => animation.lastChange(t, moves);
88
+ const behind = forward ? (t, moves) => animation.lastChange(t, moves) : (t, moves) => animation.nextChange(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
+ nextChange(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
+ lastChange(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 }),
@@ -33,7 +33,7 @@
33
33
  * auto-refresh, polls every frame.
34
34
  *
35
35
  * Tier 7 of `src/core/`: `./animation.ts`'s `Animation`, `./timeline.ts`,
36
- * `./segment.ts`, `./protocol.ts` and `./style.ts`'s `Hold`, nothing else.
36
+ * `./segment.ts`, `./protocol.ts` and `./style.ts`'s holds, nothing else.
37
37
  * [LAW:one-way-deps]
38
38
  */
39
39
  import { type HostFrame, type Renderable, type RenderOptions } from "./protocol.js";
package/dist/core/view.js CHANGED
@@ -33,11 +33,12 @@
33
33
  * auto-refresh, polls every frame.
34
34
  *
35
35
  * Tier 7 of `src/core/`: `./animation.ts`'s `Animation`, `./timeline.ts`,
36
- * `./segment.ts`, `./protocol.ts` and `./style.ts`'s `Hold`, nothing else.
36
+ * `./segment.ts`, `./protocol.ts` and `./style.ts`'s holds, nothing else.
37
37
  * [LAW:one-way-deps]
38
38
  */
39
39
  import { activePass, render } from "./protocol.js";
40
40
  import { Segment } from "./segment.js";
41
+ import { intersection, NEVER_CHANGES } from "./style.js";
41
42
  import { Timeline } from "./timeline.js";
42
43
  /** `view` as the renderable a host holds: a function is asked each time it is drawn. */
43
44
  export function hostView(view) {
@@ -79,7 +80,7 @@ export function frameNow(host, time) {
79
80
  /** What a frame not yet drawn at host time `t` says: nothing. */
80
81
  export const undrawn = (t) => ({ t, nextChange: Number.POSITIVE_INFINITY });
81
82
  /** The hold of the frame `lines` draw, told `told` besides: the intersection of its cells' holds and it. */
82
- const frameHold = (lines, told) => lines.reduce((hold, row) => ({ lastChange: Math.max(hold.lastChange, Segment.lastChange(row)), nextChange: Math.min(hold.nextChange, Segment.nextChange(row)) }), told);
83
+ const frameHold = (lines, told) => lines.reduce((hold, row) => intersection(hold, { lastChange: Segment.lastChange(row), nextChange: Segment.nextChange(row) }), told);
83
84
  /**
84
85
  * `view` drawn by `drawnBy` at `time` under `options`, its lines shaped by
85
86
  * `draw` — the host's own render under those options, with its height and
@@ -91,9 +92,9 @@ const frameHold = (lines, told) => lines.reduce((hold, row) => ({ lastChange: Ma
91
92
  */
92
93
  export function drawView(view, time, drawnBy, options, draw) {
93
94
  const { now: host, timeline } = time;
94
- let told = { lastChange: Number.NEGATIVE_INFINITY, nextChange: Number.POSITIVE_INFINITY };
95
+ let told = NEVER_CHANGES;
95
96
  const onHold = (hold) => {
96
- told = { lastChange: Math.max(told.lastChange, hold.lastChange), nextChange: Math.min(told.nextChange, hold.nextChange) };
97
+ told = intersection(told, hold);
97
98
  };
98
99
  // Made before the render opens: the one time the frame has.
99
100
  const frame = frameNow(drawnBy, time);
@@ -18,16 +18,30 @@
18
18
  * line still too wide once wrapping is done. That order is the whole reason a
19
19
  * long sentence in a table cell grows the row while an unbreakable word in the
20
20
  * same cell still ellipsizes.
21
+ *
22
+ * Rich breaks only at whitespace, and that is what happens to any word holding
23
+ * nothing below: `test/core/text-wrap.golden.txt` pins it. Past that, a word
24
+ * breaks where its text says it may (UAX #14): after ZWSP, at a soft hyphen, and
25
+ * between the words of a script written without spaces. U+2060 and U+FEFF forbid
26
+ * a break, which here needs nothing but not being one.
21
27
  */
22
- import { type CellCol } from "./cells.js";
28
+ import { type CellCol, type CodePoint } from "./cells.js";
29
+ /** One place a line of text was cut. */
30
+ export interface LineBreak {
31
+ /** Where the line is cut, in cells. */
32
+ readonly cell: CellCol;
33
+ /** The soft hyphen this break draws as `-`. Absent at any other break, and where no hyphen fits. */
34
+ readonly hyphen: CodePoint | undefined;
35
+ }
23
36
  /**
24
- * The cells each word of `text` needs, in order — the widest is the narrowest
25
- * width `divideLine` can wrap it to without cutting a word.
37
+ * The cells each piece of `text` needs, in order — the widest is the narrowest
38
+ * width `divideLine` can wrap it to without cutting a word. A hyphen is not in
39
+ * them: where it does not fit, the break goes without it.
26
40
  */
27
41
  export declare function wordWidths(text: string): number[];
28
42
  /**
29
- * The cell offsets `text` should be cut at to wrap it into lines of at most
30
- * `width` cells, for `Segment.divide` to apply to the styled line.
43
+ * The breaks that wrap `text` into lines of at most `width` cells, for
44
+ * `Segment.divide` to apply to the styled line.
31
45
  *
32
46
  * `fold` decides what happens to a word that fits on no line at all: folded,
33
47
  * it is chopped across as many lines as it needs; unfolded, it is left whole
@@ -36,6 +50,10 @@ export declare function wordWidths(text: string): number[];
36
50
  * can truncate it, which is what makes an unbreakable word ellipsize while the
37
51
  * sentence around it merely wraps.
38
52
  *
53
+ * A piece that ends in a soft hyphen stays on a line only with room for the
54
+ * hyphen, so a line it closes holds it, and a word that fits whole needs none. A
55
+ * piece as wide as the line breaks with no hyphen rather than lose a letter to one.
56
+ *
39
57
  * Requires `width >= 1`. A zero-cell canvas gets no cuts, because "no cut
40
58
  * fits" and "no cell fits" are different facts and only one of them is a list
41
59
  * of offsets — the caller answers the second one by cropping the single
@@ -43,4 +61,4 @@ export declare function wordWidths(text: string): number[];
43
61
  */
44
62
  export declare function divideLine(text: string, width: CellCol, options: {
45
63
  fold: boolean;
46
- }): CellCol[];
64
+ }): LineBreak[];