@promptctl/rich-js 0.8.0 → 0.10.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 (152) hide show
  1. package/README.md +68 -1
  2. package/dist/core/box.d.ts +93 -39
  3. package/dist/core/box.d.ts.map +1 -1
  4. package/dist/core/box.js +348 -490
  5. package/dist/core/box.js.map +1 -1
  6. package/dist/core/color.d.ts.map +1 -1
  7. package/dist/core/color.js +22 -8
  8. package/dist/core/color.js.map +1 -1
  9. package/dist/core/console.d.ts +2 -4
  10. package/dist/core/console.d.ts.map +1 -1
  11. package/dist/core/console.js +35 -24
  12. package/dist/core/console.js.map +1 -1
  13. package/dist/core/highlighter.d.ts +2 -1
  14. package/dist/core/highlighter.d.ts.map +1 -1
  15. package/dist/core/highlighter.js +28 -6
  16. package/dist/core/highlighter.js.map +1 -1
  17. package/dist/{renderables → core}/json.d.ts +8 -3
  18. package/dist/core/json.d.ts.map +1 -0
  19. package/dist/{renderables → core}/json.js +7 -2
  20. package/dist/core/json.js.map +1 -0
  21. package/dist/core/markup.d.ts +6 -7
  22. package/dist/core/markup.d.ts.map +1 -1
  23. package/dist/core/markup.js +124 -32
  24. package/dist/core/markup.js.map +1 -1
  25. package/dist/core/pretty.d.ts +56 -21
  26. package/dist/core/pretty.d.ts.map +1 -1
  27. package/dist/core/pretty.js +210 -116
  28. package/dist/core/pretty.js.map +1 -1
  29. package/dist/core/protocol.d.ts +16 -0
  30. package/dist/core/protocol.d.ts.map +1 -1
  31. package/dist/core/protocol.js +13 -0
  32. package/dist/core/protocol.js.map +1 -1
  33. package/dist/core/segment.d.ts +5 -0
  34. package/dist/core/segment.d.ts.map +1 -1
  35. package/dist/core/segment.js +9 -3
  36. package/dist/core/segment.js.map +1 -1
  37. package/dist/core/strip.d.ts +9 -5
  38. package/dist/core/strip.d.ts.map +1 -1
  39. package/dist/core/strip.js +39 -41
  40. package/dist/core/strip.js.map +1 -1
  41. package/dist/core/style.d.ts +19 -0
  42. package/dist/core/style.d.ts.map +1 -1
  43. package/dist/core/style.js +46 -38
  44. package/dist/core/style.js.map +1 -1
  45. package/dist/core/text.d.ts +102 -4
  46. package/dist/core/text.d.ts.map +1 -1
  47. package/dist/core/text.js +322 -184
  48. package/dist/core/text.js.map +1 -1
  49. package/dist/core/wrap.d.ts +42 -0
  50. package/dist/core/wrap.d.ts.map +1 -0
  51. package/dist/core/wrap.js +128 -0
  52. package/dist/core/wrap.js.map +1 -0
  53. package/dist/index.d.ts +3 -3
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +2 -1
  56. package/dist/index.js.map +1 -1
  57. package/dist/renderables/markdown.d.ts +1 -1
  58. package/dist/renderables/markdown.d.ts.map +1 -1
  59. package/dist/renderables/markdown.js +7 -14
  60. package/dist/renderables/markdown.js.map +1 -1
  61. package/dist/renderables/padding.d.ts +1 -1
  62. package/dist/renderables/padding.d.ts.map +1 -1
  63. package/dist/renderables/padding.js +5 -11
  64. package/dist/renderables/padding.js.map +1 -1
  65. package/dist/renderables/panel.d.ts +4 -4
  66. package/dist/renderables/panel.d.ts.map +1 -1
  67. package/dist/renderables/panel.js +39 -39
  68. package/dist/renderables/panel.js.map +1 -1
  69. package/dist/renderables/progress.d.ts +3 -3
  70. package/dist/renderables/progress.d.ts.map +1 -1
  71. package/dist/renderables/progress.js +11 -9
  72. package/dist/renderables/progress.js.map +1 -1
  73. package/dist/renderables/progressBar.d.ts +3 -3
  74. package/dist/renderables/progressBar.d.ts.map +1 -1
  75. package/dist/renderables/progressBar.js +9 -15
  76. package/dist/renderables/progressBar.js.map +1 -1
  77. package/dist/renderables/prompt.js +1 -1
  78. package/dist/renderables/prompt.js.map +1 -1
  79. package/dist/renderables/rule.d.ts +1 -1
  80. package/dist/renderables/rule.d.ts.map +1 -1
  81. package/dist/renderables/rule.js +5 -10
  82. package/dist/renderables/rule.js.map +1 -1
  83. package/dist/renderables/spinner.d.ts +2 -2
  84. package/dist/renderables/spinner.d.ts.map +1 -1
  85. package/dist/renderables/spinner.js +6 -11
  86. package/dist/renderables/spinner.js.map +1 -1
  87. package/dist/renderables/status.d.ts.map +1 -1
  88. package/dist/renderables/status.js +5 -11
  89. package/dist/renderables/status.js.map +1 -1
  90. package/dist/renderables/table.d.ts +54 -16
  91. package/dist/renderables/table.d.ts.map +1 -1
  92. package/dist/renderables/table.js +239 -99
  93. package/dist/renderables/table.js.map +1 -1
  94. package/dist/renderables/traceback.d.ts +1 -1
  95. package/dist/renderables/traceback.d.ts.map +1 -1
  96. package/dist/renderables/traceback.js +9 -8
  97. package/dist/renderables/traceback.js.map +1 -1
  98. package/dist/renderables/tree.d.ts +2 -2
  99. package/dist/renderables/tree.d.ts.map +1 -1
  100. package/dist/renderables/tree.js +16 -22
  101. package/dist/renderables/tree.js.map +1 -1
  102. package/dist/template-bindings/helpers.d.ts.map +1 -1
  103. package/dist/template-bindings/helpers.js +19 -1
  104. package/dist/template-bindings/helpers.js.map +1 -1
  105. package/dist/widgets/button.d.ts.map +1 -1
  106. package/dist/widgets/button.js +4 -5
  107. package/dist/widgets/button.js.map +1 -1
  108. package/dist/widgets/checkbox.d.ts.map +1 -1
  109. package/dist/widgets/checkbox.js +3 -4
  110. package/dist/widgets/checkbox.js.map +1 -1
  111. package/dist/widgets/dropdown.d.ts.map +1 -1
  112. package/dist/widgets/dropdown.js +4 -5
  113. package/dist/widgets/dropdown.js.map +1 -1
  114. package/dist/widgets/focus-manager.d.ts +0 -1
  115. package/dist/widgets/focus-manager.d.ts.map +1 -1
  116. package/dist/widgets/focus-manager.js +3 -7
  117. package/dist/widgets/focus-manager.js.map +1 -1
  118. package/dist/widgets/screen.d.ts.map +1 -1
  119. package/dist/widgets/screen.js +2 -4
  120. package/dist/widgets/screen.js.map +1 -1
  121. package/dist/widgets/slider.d.ts.map +1 -1
  122. package/dist/widgets/slider.js +7 -8
  123. package/dist/widgets/slider.js.map +1 -1
  124. package/dist/widgets/text-input.d.ts.map +1 -1
  125. package/dist/widgets/text-input.js +5 -6
  126. package/dist/widgets/text-input.js.map +1 -1
  127. package/dist/widgets/toggle.d.ts.map +1 -1
  128. package/dist/widgets/toggle.js +4 -5
  129. package/dist/widgets/toggle.js.map +1 -1
  130. package/dist/widgets/widget-base.d.ts +3 -3
  131. package/dist/widgets/widget-base.d.ts.map +1 -1
  132. package/dist/widgets/widget-base.js +4 -6
  133. package/dist/widgets/widget-base.js.map +1 -1
  134. package/package.json +26 -10
  135. package/dist/renderables/json.d.ts.map +0 -1
  136. package/dist/renderables/json.js.map +0 -1
  137. package/dist/renderables/pretty.d.ts +0 -29
  138. package/dist/renderables/pretty.d.ts.map +0 -1
  139. package/dist/renderables/pretty.js +0 -141
  140. package/dist/renderables/pretty.js.map +0 -1
  141. package/dist/themes/paletteResolver.d.ts +0 -35
  142. package/dist/themes/paletteResolver.d.ts.map +0 -1
  143. package/dist/themes/paletteResolver.js +0 -88
  144. package/dist/themes/paletteResolver.js.map +0 -1
  145. package/dist/widgets/host-stream.d.ts +0 -31
  146. package/dist/widgets/host-stream.d.ts.map +0 -1
  147. package/dist/widgets/host-stream.js +0 -36
  148. package/dist/widgets/host-stream.js.map +0 -1
  149. package/dist/widgets/terminal-host.d.ts +0 -138
  150. package/dist/widgets/terminal-host.d.ts.map +0 -1
  151. package/dist/widgets/terminal-host.js +0 -127
  152. package/dist/widgets/terminal-host.js.map +0 -1
package/dist/core/text.js CHANGED
@@ -2,40 +2,54 @@
2
2
  * RichText — styled text with spans. The primary text type for the library.
3
3
  */
4
4
  import { cellLen, cellCount } from "./cells.js";
5
+ import { divideLine } from "./wrap.js";
5
6
  import { Segment } from "./segment.js";
6
7
  import { Style, NULL_STYLE, StyleSyntaxError } from "./style.js";
7
8
  import { stripOscTerminators } from "./sanitize.js";
9
+ import { getStyle, withBoundedWidth } from "./protocol.js";
8
10
  // Strip control characters except \t and \n
9
11
  // [LAW:single-enforcer] Single place where control chars are sanitized
10
12
  const CONTROL_CHARS_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
11
13
  function stripControlChars(text) {
12
14
  return text.replace(CONTROL_CHARS_RE, "");
13
15
  }
16
+ const TRAILING_WHITESPACE_RE = /\s+$/;
14
17
  /**
15
- * The offsets a line of `lineWidth` cells is cut at to fold it into pieces of
16
- * at most `maxWidth`. Requires `maxWidth >= 1`: the caller answers the
17
- * zero-cell canvas, because "no cut fits" and "no cell fits" are different
18
- * facts and only one of them is a list of offsets.
18
+ * The plain text of one line of segments, in the coordinate system
19
+ * `divideLine` and `Segment.divide` share: cell offsets into the styled line
20
+ * are cell offsets into this string. [LAW:one-source-of-truth] for that
21
+ * correspondence — the offsets are found in this text and applied to the
22
+ * segments it came from.
19
23
  */
20
- function foldCuts(lineWidth, maxWidth) {
21
- const cuts = [];
22
- for (let w = maxWidth; w < lineWidth; w += maxWidth)
23
- cuts.push(w);
24
- return cuts;
24
+ function plainOf(line) {
25
+ return line.map((segment) => segment.text).join("");
25
26
  }
26
- // [LAW:single-enforcer] RichText is the *data-model* trust boundary for
27
- // link URLs — any Style carrying a link that enters a RichText is sanitized
28
- // in place, so callers that inspect `richText.style.link` or
29
- // `richText.spans[].style.link` see the same clean URL the renderer will
30
- // emit. Wire-byte safety is enforced separately in render.ts and style.ts
31
- // via the same shared `stripOscTerminators` helper (one rule, applied at
32
- // both seams). Together those layers guarantee the dirty bytes can neither
33
- // live in the in-memory model nor escape on the wire — even if a Style is
34
- // constructed and rendered via a path that bypasses RichText entirely.
35
- //
36
- // Co-located inside `resolveStyle` so any current or future RichText method
37
- // that normalizes a `string | Style` argument inherits sanitization
38
- // automatically; no per-callsite wrap to forget.
27
+ /**
28
+ * The cells of trailing whitespace on a wrapped line.
29
+ *
30
+ * A wrap cuts before a word, so the line it closes ends with the whitespace
31
+ * that followed *its* last word — padding the break created, not content the
32
+ * author wrote. Measuring it is what lets the overflow method tell "this line
33
+ * was cut" from "this line ends in spaces" and stamp an ellipsis only on the
34
+ * first, and what keeps a centred line from drifting half a space off true.
35
+ *
36
+ * Asked of the line's text rather than walked back through its segments,
37
+ * because where a line ends is a fact about the characters and not about how
38
+ * they were split. Walking the segments made it a fact about both: it read a
39
+ * segment with no trailing whitespace as content and stopped there, which an
40
+ * empty segment also looks like — and a crop leaves one behind. A styled line
41
+ * whose last cells were cropped therefore reported no hanging whitespace at
42
+ * all and was centred as though it were content to the edge, so a span landing
43
+ * anywhere in a wrap's whitespace un-centred the line it closed.
44
+ */
45
+ function hangingWhitespace(line) {
46
+ return cellLen(TRAILING_WHITESPACE_RE.exec(plainOf(line))?.[0] ?? "");
47
+ }
48
+ // [LAW:single-enforcer] RichText is the data-model trust boundary for link
49
+ // URLs: a `Style` is sanitized as it enters (`admitStyle`), and the `Style` a
50
+ // stored string resolves to is sanitized as it leaves for a render
51
+ // (`resolveStyle`). Wire-byte safety is enforced separately in render.ts and
52
+ // style.ts through the same `stripOscTerminators`.
39
53
  function sanitizeStyleLink(style) {
40
54
  const link = style.link;
41
55
  if (!link)
@@ -45,28 +59,39 @@ function sanitizeStyleLink(style) {
45
59
  return style;
46
60
  return style.withLink(cleaned);
47
61
  }
48
- function resolveStyle(style) {
49
- if (style === undefined)
50
- return NULL_STYLE;
51
- let resolved;
52
- if (typeof style === "string") {
53
- try {
54
- resolved = Style.parse(style);
55
- }
56
- catch (err) {
57
- // [LAW:single-enforcer] Styling is non-critical — an unrecognized style
58
- // name (typo, missing theme key, bad concatenation) degrades to unstyled
59
- // rather than crashing. Absorb only StyleSyntaxError at this trust
60
- // boundary; other errors are genuine bugs and must surface.
61
- if (err instanceof StyleSyntaxError)
62
- return NULL_STYLE;
63
- throw err;
64
- }
65
- }
66
- else {
67
- resolved = style;
68
- }
69
- return sanitizeStyleLink(resolved);
62
+ /**
63
+ * A style as a RichText keeps it: as given. A name stays a name because what
64
+ * it stands for depends on the theme of the render that draws it, which no
65
+ * RichText knows when the name arrives — the reference stores span styles the
66
+ * same way. A string holds no link until it is parsed, so only a `Style` has
67
+ * one to sanitize here.
68
+ */
69
+ function admitStyle(style) {
70
+ return style instanceof Style ? sanitizeStyleLink(style) : style;
71
+ }
72
+ /** A style that adds nothing: the empty definition, or a null `Style`. */
73
+ function isEmptyStyle(style) {
74
+ return style instanceof Style ? style.isNull : style === "";
75
+ }
76
+ /**
77
+ * The style a stored `string | Style` stands for in this render.
78
+ *
79
+ * [LAW:single-enforcer] Styling is non-critical — an unrecognized style name
80
+ * (typo, missing theme key, bad concatenation) degrades to unstyled rather
81
+ * than crashing, as the reference's `Text.render` resolves with a null
82
+ * default. Absorb only StyleSyntaxError here; other errors are genuine bugs
83
+ * and must surface. A parsed string can carry a link, so the result is
84
+ * sanitized on the way out as well.
85
+ */
86
+ function resolveStyle(options, style) {
87
+ try {
88
+ return sanitizeStyleLink(getStyle(options, style));
89
+ }
90
+ catch (err) {
91
+ if (err instanceof StyleSyntaxError)
92
+ return NULL_STYLE;
93
+ throw err;
94
+ }
70
95
  }
71
96
  // --- Span ---
72
97
  export class Span {
@@ -116,9 +141,9 @@ export class RichText {
116
141
  constructor(text, options) {
117
142
  this._text = text ? stripControlChars(text) : "";
118
143
  this._spans = [];
119
- // [LAW:single-enforcer] `resolveStyle` is the boundary that sanitizes
144
+ // [LAW:single-enforcer] `admitStyle` is the boundary that sanitizes
120
145
  // any link URL crossing into a RichText; downstream trusts the invariant.
121
- this._style = resolveStyle(options?.style);
146
+ this._style = admitStyle(options?.style ?? NULL_STYLE);
122
147
  this._justify = options?.justify;
123
148
  this._overflow = options?.overflow;
124
149
  this._end = options?.end ?? "\n";
@@ -147,14 +172,12 @@ export class RichText {
147
172
  get hasContent() {
148
173
  return this._text.length > 0;
149
174
  }
175
+ /** The base style every span layers over: a `Style`, or a name resolved at render. */
150
176
  get style() {
151
177
  return this._style;
152
178
  }
153
179
  set style(value) {
154
- // [LAW:single-enforcer] The setter is the only entry that doesn't pass
155
- // through `resolveStyle` (its argument is already a Style); sanitize
156
- // directly so the boundary contract holds for every Style assignment.
157
- this._style = sanitizeStyleLink(value);
180
+ this._style = admitStyle(value);
158
181
  }
159
182
  get justify() {
160
183
  return this._justify;
@@ -183,6 +206,14 @@ export class RichText {
183
206
  get spans() {
184
207
  return this._spans;
185
208
  }
209
+ /**
210
+ * This text's own style, spans aside, as the render drawing it resolves it.
211
+ * A name the render's theme does not define resolves to no style, because
212
+ * text forgives a missing name.
213
+ */
214
+ resolvedStyle(options) {
215
+ return resolveStyle(options, this._style);
216
+ }
186
217
  /**
187
218
  * The style of the cell-column at the named edge — base style merged with
188
219
  * any spans covering the leftmost (side="left") or rightmost (side="right")
@@ -199,16 +230,19 @@ export class RichText {
199
230
  * text, the last character occupies the rightmost cell column — the bg
200
231
  * of that character covers both columns, so character-index lookup gives
201
232
  * the correct edge color.
233
+ *
234
+ * Takes the render's options because the edge is reported as it will be
235
+ * drawn, and a style name draws as whatever the render's theme says.
202
236
  */
203
- edgeStyle(side) {
237
+ edgeStyle(side, options) {
238
+ const base = this.resolvedStyle(options);
204
239
  if (this._text.length === 0)
205
- return this._style;
240
+ return base;
206
241
  const pos = side === "left" ? 0 : this._text.length - 1;
207
- let result = this._style;
242
+ let result = base;
208
243
  for (const span of this._spans) {
209
244
  if (span.start <= pos && pos < span.end) {
210
- const spanStyle = typeof span.style === "string" ? Style.parse(span.style) : span.style;
211
- result = result.add(spanStyle);
245
+ result = result.add(resolveStyle(options, span.style));
212
246
  }
213
247
  }
214
248
  return result;
@@ -229,12 +263,7 @@ export class RichText {
229
263
  const sanitized = stripControlChars(content);
230
264
  const start = this._text.length;
231
265
  this._text += sanitized;
232
- if (style !== undefined) {
233
- const resolved = resolveStyle(style);
234
- if (!resolved.isNull) {
235
- this._spans.push(new Span(start, this._text.length, resolved));
236
- }
237
- }
266
+ this._addSpan(start, this._text.length, style ?? "");
238
267
  return this;
239
268
  }
240
269
  contains(needle) {
@@ -270,10 +299,17 @@ export class RichText {
270
299
  return result;
271
300
  }
272
301
  // --- Styling Operations ---
302
+ /**
303
+ * The one way a span enters this text. [LAW:single-enforcer] An empty style
304
+ * adds nothing, as the reference's `if style:` has it, and every other style
305
+ * is admitted as given.
306
+ */
307
+ _addSpan(start, end, style) {
308
+ if (isEmptyStyle(style))
309
+ return;
310
+ this._spans.push(new Span(start, end, admitStyle(style)));
311
+ }
273
312
  stylize(style, start, end) {
274
- const resolved = resolveStyle(style);
275
- if (resolved.isNull)
276
- return this;
277
313
  const len = this._text.length;
278
314
  const s = start !== undefined ? (start < 0 ? len + start : start) : 0;
279
315
  const e = end !== undefined ? (end < 0 ? len + end : end) : len;
@@ -281,7 +317,7 @@ export class RichText {
281
317
  return this;
282
318
  const clampedStart = Math.max(0, s);
283
319
  const clampedEnd = Math.min(len, e);
284
- this._spans.push(new Span(clampedStart, clampedEnd, resolved));
320
+ this._addSpan(clampedStart, clampedEnd, style);
285
321
  return this;
286
322
  }
287
323
  highlightRegex(pattern, style) {
@@ -304,7 +340,7 @@ export class RichText {
304
340
  const posInMatch = match[0].indexOf(groupValue, searchFrom);
305
341
  if (posInMatch >= 0) {
306
342
  const groupStart = match.index + posInMatch;
307
- this._spans.push(new Span(groupStart, groupStart + groupValue.length, groupName));
343
+ this._addSpan(groupStart, groupStart + groupValue.length, groupName);
308
344
  searchFrom = posInMatch + groupValue.length;
309
345
  }
310
346
  }
@@ -312,19 +348,13 @@ export class RichText {
312
348
  count++;
313
349
  continue;
314
350
  }
315
- const resolvedStyle = style !== undefined ? resolveStyle(style) : NULL_STYLE;
316
- if (!resolvedStyle.isNull) {
317
- this._spans.push(new Span(match.index, match.index + match[0].length, resolvedStyle));
318
- }
351
+ this._addSpan(match.index, match.index + match[0].length, style ?? "");
319
352
  count++;
320
353
  }
321
354
  return count;
322
355
  }
323
356
  highlightWords(words, style, options) {
324
357
  const caseSensitive = options?.caseSensitive !== false;
325
- const resolved = resolveStyle(style);
326
- if (resolved.isNull)
327
- return 0;
328
358
  let count = 0;
329
359
  for (const word of words) {
330
360
  if (word.length === 0)
@@ -334,7 +364,7 @@ export class RichText {
334
364
  const re = new RegExp(`\\b${escaped}\\b`, flags);
335
365
  let match;
336
366
  while ((match = re.exec(this._text)) !== null) {
337
- this._spans.push(new Span(match.index, match.index + match[0].length, resolved));
367
+ this._addSpan(match.index, match.index + match[0].length, style);
338
368
  count++;
339
369
  }
340
370
  }
@@ -657,9 +687,7 @@ export class RichText {
657
687
  for (const frag of fragments) {
658
688
  const start = result.length;
659
689
  result.append(frag.plain);
660
- if (!frag.style.isNull) {
661
- result.stylize(frag.style, start, result.length);
662
- }
690
+ result.stylize(frag.style, start, result.length);
663
691
  for (const span of frag.spans) {
664
692
  result.stylize(span.style, start + span.start, start + span.end);
665
693
  }
@@ -674,32 +702,47 @@ export class RichText {
674
702
  yield new Segment(this._end);
675
703
  return;
676
704
  }
677
- const allSegments = this._buildSegments(text);
705
+ const base = this.resolvedStyle(options);
706
+ const allSegments = this._buildSegments(text, base, options);
678
707
  const logicalLines = Segment.splitLines(allSegments);
679
- // [LAW:parse-dont-validate] The one crossing for this renderable's width.
680
- // Unparsed, a NaN width made `lineWidth <= maxWidth` false and every
681
- // overflow arm a no-op, so the text emitted its full natural width and
682
- // silently overflowed whatever asked for it.
683
- const maxWidth = cellCount(options.maxWidth);
708
+ // [LAW:single-enforcer] The one crossing for this renderable's width, and
709
+ // the call every other renderable already makes. A bare `cellCount` stood
710
+ // here doing half of it: it caught a NaN width, which had made every
711
+ // overflow arm a no-op, but passed an unbounded one through to `justify`,
712
+ // which pads — and `" ".repeat(Infinity)` throws.
713
+ const maxWidth = cellCount(withBoundedWidth(options, this).maxWidth);
684
714
  const overflow = this._overflow ?? options.overflow ?? "fold";
685
715
  const justify = this._justify ?? options.justify;
686
716
  const noWrap = this._noWrap || (options.noWrap ?? false);
717
+ // The width a line is cut to, which is not always the width it is
718
+ // justified in. `noWrap` means the line is not bounded at all: it leaves at
719
+ // its natural width and whatever asked for it decides about the overhang —
720
+ // `Console`'s soft wrap and `FlexStrip`'s too-wide fallback both want the
721
+ // text intact rather than cropped.
722
+ //
723
+ // [LAW:dataflow-not-control-flow] It reaches the pipeline as a width, not
724
+ // as a step to skip: an unbounded budget has no edge to break at, so
725
+ // `divideLine` finds no cuts and `_fitLine` finds nothing past the edge,
726
+ // and every line runs the same three steps. `Infinity` is already this
727
+ // library's spelling of an unbounded width offer — `withBoundedWidth` in
728
+ // protocol.ts parses one on the way in.
729
+ const budget = noWrap ? cellCount(Infinity) : maxWidth;
687
730
  const endsWithNewline = text.endsWith("\n");
688
731
  for (let index = 0; index < logicalLines.length; index += 1) {
689
732
  const line = logicalLines[index];
690
- const lineWidth = Segment.getLineLength(line);
691
733
  const terminateLine = index < logicalLines.length - 1 || endsWithNewline;
692
- if (noWrap || lineWidth <= maxWidth) {
693
- // Line fits — apply justification
694
- yield* this._justifyLine(line, maxWidth, justify);
695
- if (terminateLine) {
734
+ // Wrap first, overflow last — the reference's order, and the reason a
735
+ // long sentence grows a table row while an unbreakable word in the same
736
+ // column still ellipsizes.
737
+ const cuts = divideLine(plainOf(line), budget, { fold: overflow === "fold" });
738
+ const wrapped = Segment.divide(line, cuts);
739
+ const placed = this._justifyLines(wrapped.map((piece) => [...this._fitLine(piece, budget, overflow)]), maxWidth, base, justify);
740
+ for (let piece = 0; piece < placed.length; piece += 1) {
741
+ yield* placed[piece];
742
+ if (piece < placed.length - 1 || terminateLine) {
696
743
  yield Segment.line();
697
744
  }
698
745
  }
699
- else {
700
- // Line too long — handle overflow
701
- yield* this._overflowLine(line, lineWidth, maxWidth, overflow, terminateLine);
702
- }
703
746
  }
704
747
  if (this._end && this._end !== "\n") {
705
748
  yield new Segment(this._end);
@@ -737,119 +780,214 @@ export class RichText {
737
780
  return text;
738
781
  return text.replace(/\t/g, " ".repeat(this._tabSize));
739
782
  }
740
- _buildSegments(text) {
741
- if (text.length === 0)
742
- return [];
743
- // Collect all unique boundary positions
783
+ /**
784
+ * The rendered text cut at every span edge, each piece carrying the base
785
+ * style plus every span covering it.
786
+ *
787
+ * Walked span-first rather than piece-first, and that direction is the whole
788
+ * performance argument. The pieces are cut at the span edges themselves, so
789
+ * a span covers a piece exactly when it covers the piece's first character —
790
+ * which makes each span's run of pieces a contiguous range it can be written
791
+ * into once, instead of a question every piece asks of every span. The
792
+ * piece-first form charged `spans x pieces`, and the pieces are themselves
793
+ * cut by the spans, so anything styling densely paid the square in ordinary
794
+ * use: 8,000 one-character spans took 211ms where 1,000 took 3.2ms. Every
795
+ * `Highlighter` over a large value reaches that, and so does `Pretty`, whose
796
+ * indent guides emit a span per indent character.
797
+ *
798
+ * Span-first is also what keeps the composition honest, for free. `Style.add`
799
+ * is order-dependent and the last writer wins, so the pieces have to fold
800
+ * their styles in `_spans` order — which iterating `_spans` is, and which a
801
+ * sweep ordered by position would have had to reconstruct.
802
+ *
803
+ * [LAW:dataflow-not-control-flow] A span covering nothing — empty, reversed,
804
+ * or entirely past the text — still cuts the text where its edges land, as it
805
+ * always did, and then folds into no piece at all: its range comes out empty
806
+ * and no case handles it.
807
+ */
808
+ _buildSegments(text, base, options) {
809
+ const clamp = (offset) => Math.max(0, Math.min(offset, text.length));
744
810
  const positions = new Set([0, text.length]);
745
811
  for (const span of this._spans) {
746
- const start = Math.max(0, Math.min(span.start, text.length));
747
- const end = Math.max(0, Math.min(span.end, text.length));
748
- positions.add(start);
749
- positions.add(end);
750
- }
751
- const sorted = [...positions].sort((a, b) => a - b);
752
- const segments = [];
753
- // [LAW:dataflow-not-control-flow] Always iterate all regions; empty ones produce nothing
754
- for (let i = 0; i < sorted.length - 1; i++) {
755
- const regionStart = sorted[i];
756
- const regionEnd = sorted[i + 1];
757
- const regionText = text.slice(regionStart, regionEnd);
758
- if (regionText.length === 0)
759
- continue;
760
- // Combine base style with all active span styles
761
- let style = this._style;
762
- for (const span of this._spans) {
763
- if (span.start <= regionStart && span.end >= regionEnd) {
764
- const spanStyle = resolveStyle(span.style);
765
- style = style.add(spanStyle);
766
- }
812
+ positions.add(clamp(span.start));
813
+ positions.add(clamp(span.end));
814
+ }
815
+ const boundaries = [...positions].sort((a, b) => a - b);
816
+ // Every span edge is a boundary, so where a span's range opens is a lookup
817
+ // rather than a search.
818
+ const pieceAt = new Map(boundaries.map((position, piece) => [position, piece]));
819
+ const styles = boundaries.slice(0, -1).map(() => base);
820
+ for (const span of this._spans) {
821
+ const end = clamp(span.end);
822
+ const style = resolveStyle(options, span.style);
823
+ const opensAt = pieceAt.get(clamp(span.start));
824
+ for (let piece = opensAt; boundaries[piece] < end; piece++) {
825
+ styles[piece] = styles[piece].add(style);
767
826
  }
768
- segments.push(new Segment(regionText, style.isNull ? undefined : style));
769
827
  }
770
- return segments;
828
+ return styles.map((style, piece) => new Segment(text.slice(boundaries[piece], boundaries[piece + 1]), style.isNull ? undefined : style));
771
829
  }
772
- *_justifyLine(line, maxWidth, justify) {
773
- const lineWidth = Segment.getLineLength(line);
774
- const gap = maxWidth - lineWidth;
830
+ /**
831
+ * The pieces one logical line wrapped into, each placed in a canvas
832
+ * `maxWidth` wide, as Rich's `Lines.justify` places them — pinned block for
833
+ * block against the reference in `test/core/text-justify.test.ts`.
834
+ *
835
+ * It is handed the whole wrapped line because `full` is the one mode a
836
+ * piece cannot answer alone: the reference leaves a paragraph's last line
837
+ * ragged, so where the piece sits decides its answer where the other three
838
+ * modes need only the piece itself. The set that decides "last" is this one
839
+ * and not the whole render — `Text.wrap` calls `Lines.justify` once per
840
+ * *logical* line — and `Segment.divide` already handed it over whole.
841
+ */
842
+ _justifyLines(lines, maxWidth, base, justify) {
843
+ if (justify !== "full") {
844
+ return lines.map((line) => [...this._justifyLine(line, maxWidth, justify)]);
845
+ }
846
+ return lines.map((line, index) => index === lines.length - 1 ? line : this._fillLine(line, maxWidth, base));
847
+ }
848
+ /**
849
+ * One line placed in a canvas `maxWidth` wide.
850
+ *
851
+ * Centre and right align on the line's *content*. The whitespace a wrap
852
+ * leaves on the end of the line it closed is the break's own padding, not
853
+ * text, and aligning around it pushes the text half a gap off true — a
854
+ * centred title that wraps drifts left on every line that happens to end in
855
+ * a space. Left keeps that whitespace, because there it is already on the
856
+ * side the padding goes.
857
+ *
858
+ * `undefined` is not `"left"`: it is Rich's `"default"`, which places the
859
+ * line without padding it at all. That distinction is what lets a soft-wrapped
860
+ * `Console.print` leave its lines at their natural width.
861
+ */
862
+ *_justifyLine(line, maxWidth,
863
+ // [LAW:types-are-the-program] `full` is absent rather than ignored: it
864
+ // needs the lines either side of this one, so the type refuses it here
865
+ // instead of a branch quietly rendering it as `left`, which is the bug
866
+ // this signature replaces (rich-justify-0cr.1).
867
+ justify) {
775
868
  switch (justify) {
776
- case "center": {
777
- const leftPad = Math.floor(gap / 2);
869
+ case "center":
870
+ case "right": {
871
+ const body = Segment.adjustLineLength(line, Segment.getLineLength(line) - hangingWhitespace(line), undefined, false);
872
+ const gap = Math.max(maxWidth - Segment.getLineLength(body), 0);
873
+ const leftPad = justify === "center" ? Math.floor(gap / 2) : gap;
778
874
  if (leftPad > 0)
779
875
  yield new Segment(" ".repeat(leftPad));
780
- yield* line;
876
+ yield* body;
781
877
  const rightPad = gap - leftPad;
782
878
  if (rightPad > 0)
783
879
  yield new Segment(" ".repeat(rightPad));
784
880
  break;
785
881
  }
786
- case "right": {
787
- if (gap > 0)
788
- yield new Segment(" ".repeat(gap));
789
- yield* line;
790
- break;
791
- }
792
- case "full": {
793
- // Full justification: distribute spaces between words
794
- // For now, fall through to left alignment
795
- yield* line;
882
+ case "left":
883
+ yield* Segment.adjustLineLength(line, Math.max(maxWidth, Segment.getLineLength(line)));
796
884
  break;
797
- }
798
885
  default:
799
- // "left" or undefined — just yield the line as-is
800
886
  yield* line;
801
887
  break;
802
888
  }
803
889
  }
804
- *_overflowLine(line, lineWidth, maxWidth, overflow, terminateLine) {
805
- switch (overflow) {
806
- case "fold": {
807
- // Split at maxWidth boundaries. A cell cannot be split, so a zero-cell
808
- // canvas holds no piece of the line and the fold is one empty piece —
809
- // the same thing `crop` and `ellipsis` yield there, which is what makes
810
- // the three overflow modes agree at the bottom of the range instead of
811
- // this loop stepping by zero forever. It did exactly that before:
812
- // ~2^27 pushes and about a gigabyte before a `RangeError`, reachable
813
- // from any `Columns` or `Layout` squeezed to no width at all.
814
- const foldedLines = maxWidth === 0
815
- ? [Segment.adjustLineLength(line, 0, undefined, false)]
816
- : Segment.divide(line, foldCuts(lineWidth, maxWidth));
817
- for (let index = 0; index < foldedLines.length; index += 1) {
818
- const fLine = foldedLines[index];
819
- yield* fLine;
820
- if (index < foldedLines.length - 1 || terminateLine) {
821
- yield Segment.line();
822
- }
823
- }
824
- break;
825
- }
826
- case "crop": {
827
- const cropped = Segment.adjustLineLength(line, maxWidth, undefined, false);
828
- yield* cropped;
829
- if (terminateLine) {
830
- yield Segment.line();
831
- }
832
- break;
833
- }
834
- case "ellipsis": {
835
- // The marker takes the last cell and the text keeps the rest. At
836
- // maxWidth 1 that is zero cells of text and the marker alone, which is
837
- // the honest rendering of "all of this was cut"; the `maxWidth > 1`
838
- // guard that used to stand here emitted no line at all, so every table
839
- // column squeezed to a single cell rendered blank rather than
840
- // truncated — `ellipsis` being the default column overflow, a
841
- // hard-squeezed table looked like an empty frame.
842
- yield* Segment.adjustLineLength(line, Math.max(0, maxWidth - 1), undefined, false);
843
- // No cell to put it in at maxWidth 0, where every mode emits the bare
844
- // line terminator.
845
- if (maxWidth > 0)
846
- yield new Segment("\u2026");
847
- if (terminateLine) {
848
- yield Segment.line();
849
- }
850
- break;
890
+ /**
891
+ * One line of a wrapped paragraph, its gaps widened until it fills the
892
+ * canvas — what `justify: "full"` promises, and what the reference's
893
+ * `Lines.justify` does to every line of a paragraph but the last.
894
+ *
895
+ * Slack goes in a cell at a time, starting at the rightmost gap and walking
896
+ * left, round and round until the line is full. That order is the
897
+ * reference's own, and what it decides is where an odd cell lands when the
898
+ * slack will not divide evenly: the right-hand gaps take it.
899
+ *
900
+ * The words come from the line's *text* split on a single space, with the
901
+ * blank a trailing separator leaves behind dropped — `Text.split` and
902
+ * `String.prototype.split` agree on that list, empty words and all. Two
903
+ * consequences read as bugs until you know whose they are: a run of n
904
+ * spaces is n gaps rather than one, so spacing an author widened stretches
905
+ * instead of collapsing, and the whitespace a wrap left hanging is a gap
906
+ * like the rest, which is why a filled line does not keep it. Both are
907
+ * pinned in `text-justify.golden.txt`, by the `uneven` and `sentence`
908
+ * blocks respectively.
909
+ */
910
+ _fillLine(line, maxWidth, base) {
911
+ const plain = plainOf(line);
912
+ const words = plain.split(" ");
913
+ if (plain.endsWith(" "))
914
+ words.pop();
915
+ const gaps = words.length - 1;
916
+ const spaces = new Array(gaps).fill(1);
917
+ let filled = words.reduce((total, word) => total + cellLen(word), 0) + gaps;
918
+ for (let turn = 0; filled < maxWidth && gaps > 0; turn = (turn + 1) % gaps) {
919
+ spaces[gaps - 1 - turn] += 1;
920
+ filled += 1;
921
+ }
922
+ // Cut at both edges of every gap, so a word is an even piece and the
923
+ // separator that followed it is the odd piece after it. Measured in cells
924
+ // because that is the coordinate system `Segment.divide` reads, which a
925
+ // line of wide glyphs is the only thing that notices.
926
+ const cuts = [];
927
+ let edge = 0;
928
+ for (let index = 0; index < words.length; index += 1) {
929
+ edge += cellLen(words[index]);
930
+ cuts.push(edge);
931
+ edge += 1;
932
+ if (index < gaps)
933
+ cuts.push(edge);
934
+ }
935
+ const pieces = Segment.divide(line, cuts);
936
+ // A widened gap takes the style the words either side of it agree on, and
937
+ // the line's own where they disagree — the reference reads that off the
938
+ // character each side turns towards the gap, which is the offset `at` is
939
+ // given here. A word with no characters turns none and answers with the
940
+ // line's style, which is what two adjacent separators leave between them.
941
+ const edgeStyle = (word, at) => word.filter((segment) => segment.hasText).at(at)?.style ?? base;
942
+ const result = [];
943
+ for (let index = 0; index < words.length; index += 1) {
944
+ result.push(...pieces[index * 2]);
945
+ if (index < gaps) {
946
+ const before = edgeStyle(pieces[index * 2], -1);
947
+ const after = edgeStyle(pieces[index * 2 + 2], 0);
948
+ const style = before.equals(after) ? before : base;
949
+ result.push(new Segment(" ".repeat(spaces[index]), style.isNull ? undefined : style));
851
950
  }
852
951
  }
952
+ return result;
953
+ }
954
+ /**
955
+ * One wrapped line cut to the canvas.
956
+ *
957
+ * Everything reaching here already survived wrapping, so the only text still
958
+ * too wide is text no break could help: a word longer than the canvas under
959
+ * a non-folding overflow method, a glyph wider than the budget, or a canvas
960
+ * with no cells at all. That is what makes the overflow method a last
961
+ * resort rather than the first thing a long cell meets.
962
+ */
963
+ *_fitLine(line, maxWidth, overflow) {
964
+ const lineWidth = Segment.getLineLength(line);
965
+ const contentWidth = lineWidth - hangingWhitespace(line);
966
+ // Whitespace hanging past the edge is the wrap's own padding: cropping it
967
+ // away is not truncation, so it earns no marker. Without this an ellipsis
968
+ // landed on any break that fell a space past the column — the common case
969
+ // in a table, not an edge one.
970
+ if (contentWidth <= maxWidth) {
971
+ yield* Segment.adjustLineLength(line, Math.min(lineWidth, maxWidth), undefined, false);
972
+ return;
973
+ }
974
+ // The marker takes the last cell and the text keeps the rest. At maxWidth 1
975
+ // that is zero cells of text and the marker alone, which is the honest
976
+ // rendering of "all of this was cut"; the `maxWidth > 1` guard that used to
977
+ // stand here emitted no line at all, so every table column squeezed to a
978
+ // single cell rendered blank rather than truncated — `ellipsis` being the
979
+ // default column overflow, a hard-squeezed table looked like an empty frame.
980
+ //
981
+ // At maxWidth 0 there is no cell to put the marker in, so every method
982
+ // yields the same bare empty line. Agreement there is what keeps a
983
+ // `Columns` or `Layout` squeezed to no width at all from rendering
984
+ // three different kinds of nothing.
985
+ if (overflow === "ellipsis" && maxWidth > 0) {
986
+ yield* Segment.adjustLineLength(line, maxWidth - 1, undefined, false);
987
+ yield new Segment("\u2026");
988
+ return;
989
+ }
990
+ yield* Segment.adjustLineLength(line, maxWidth, undefined, false);
853
991
  }
854
992
  }
855
993
  //# sourceMappingURL=text.js.map