@promptctl/rich-js 0.16.0 → 0.18.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 (206) hide show
  1. package/README.md +14 -27
  2. package/package.json +7 -3
  3. package/dist/core/box.d.ts +0 -144
  4. package/dist/core/box.js +0 -391
  5. package/dist/core/cells.d.ts +0 -148
  6. package/dist/core/cells.js +0 -266
  7. package/dist/core/color.d.ts +0 -229
  8. package/dist/core/color.js +0 -992
  9. package/dist/core/console.d.ts +0 -142
  10. package/dist/core/console.js +0 -477
  11. package/dist/core/emoji.d.ts +0 -24
  12. package/dist/core/emoji.js +0 -391
  13. package/dist/core/export-html.d.ts +0 -24
  14. package/dist/core/export-html.js +0 -106
  15. package/dist/core/export-lines.d.ts +0 -121
  16. package/dist/core/export-lines.js +0 -141
  17. package/dist/core/highlighter.d.ts +0 -33
  18. package/dist/core/highlighter.js +0 -124
  19. package/dist/core/json.d.ts +0 -27
  20. package/dist/core/json.js +0 -53
  21. package/dist/core/markup.d.ts +0 -97
  22. package/dist/core/markup.js +0 -619
  23. package/dist/core/measure.d.ts +0 -27
  24. package/dist/core/measure.js +0 -61
  25. package/dist/core/oklch.d.ts +0 -161
  26. package/dist/core/oklch.js +0 -312
  27. package/dist/core/osc8.d.ts +0 -68
  28. package/dist/core/osc8.js +0 -90
  29. package/dist/core/pretty.d.ts +0 -141
  30. package/dist/core/pretty.js +0 -472
  31. package/dist/core/protocol.d.ts +0 -137
  32. package/dist/core/protocol.js +0 -101
  33. package/dist/core/render.d.ts +0 -69
  34. package/dist/core/render.js +0 -140
  35. package/dist/core/segment.d.ts +0 -142
  36. package/dist/core/segment.js +0 -433
  37. package/dist/core/spinnerData.d.ts +0 -10
  38. package/dist/core/spinnerData.js +0 -115
  39. package/dist/core/strip.d.ts +0 -139
  40. package/dist/core/strip.js +0 -302
  41. package/dist/core/style.d.ts +0 -149
  42. package/dist/core/style.js +0 -741
  43. package/dist/core/subscription.d.ts +0 -12
  44. package/dist/core/subscription.js +0 -12
  45. package/dist/core/text.d.ts +0 -255
  46. package/dist/core/text.js +0 -990
  47. package/dist/core/wrap.d.ts +0 -41
  48. package/dist/core/wrap.js +0 -127
  49. package/dist/host/host-stream.d.ts +0 -30
  50. package/dist/host/host-stream.js +0 -35
  51. package/dist/host/index.d.ts +0 -3
  52. package/dist/host/index.js +0 -2
  53. package/dist/host/terminal-host.d.ts +0 -143
  54. package/dist/host/terminal-host.js +0 -132
  55. package/dist/index.d.ts +0 -87
  56. package/dist/index.js +0 -104
  57. package/dist/node/prompt.d.ts +0 -21
  58. package/dist/node/prompt.js +0 -30
  59. package/dist/node/save.d.ts +0 -17
  60. package/dist/node/save.js +0 -21
  61. package/dist/node/terminal-host.d.ts +0 -84
  62. package/dist/node/terminal-host.js +0 -160
  63. package/dist/node/traceback.d.ts +0 -27
  64. package/dist/node/traceback.js +0 -112
  65. package/dist/renderables/align.d.ts +0 -16
  66. package/dist/renderables/align.js +0 -46
  67. package/dist/renderables/columns.d.ts +0 -61
  68. package/dist/renderables/columns.js +0 -168
  69. package/dist/renderables/constrain.d.ts +0 -15
  70. package/dist/renderables/constrain.js +0 -37
  71. package/dist/renderables/flexStrip.d.ts +0 -41
  72. package/dist/renderables/flexStrip.js +0 -200
  73. package/dist/renderables/group.d.ts +0 -11
  74. package/dist/renderables/group.js +0 -15
  75. package/dist/renderables/layout.d.ts +0 -92
  76. package/dist/renderables/layout.js +0 -315
  77. package/dist/renderables/live.d.ts +0 -47
  78. package/dist/renderables/live.js +0 -131
  79. package/dist/renderables/markdown.d.ts +0 -24
  80. package/dist/renderables/markdown.js +0 -182
  81. package/dist/renderables/padding.d.ts +0 -38
  82. package/dist/renderables/padding.js +0 -140
  83. package/dist/renderables/panel.d.ts +0 -116
  84. package/dist/renderables/panel.js +0 -322
  85. package/dist/renderables/progress.d.ts +0 -90
  86. package/dist/renderables/progress.js +0 -241
  87. package/dist/renderables/progressBar.d.ts +0 -31
  88. package/dist/renderables/progressBar.js +0 -52
  89. package/dist/renderables/prompt.d.ts +0 -49
  90. package/dist/renderables/prompt.js +0 -125
  91. package/dist/renderables/rule.d.ts +0 -24
  92. package/dist/renderables/rule.js +0 -90
  93. package/dist/renderables/spinner.d.ts +0 -29
  94. package/dist/renderables/spinner.js +0 -64
  95. package/dist/renderables/status.d.ts +0 -23
  96. package/dist/renderables/status.js +0 -60
  97. package/dist/renderables/syntax.d.ts +0 -35
  98. package/dist/renderables/syntax.js +0 -114
  99. package/dist/renderables/table.d.ts +0 -182
  100. package/dist/renderables/table.js +0 -697
  101. package/dist/renderables/traceback.d.ts +0 -31
  102. package/dist/renderables/traceback.js +0 -109
  103. package/dist/renderables/tree.d.ts +0 -54
  104. package/dist/renderables/tree.js +0 -159
  105. package/dist/template-bindings/color-funcs.d.ts +0 -87
  106. package/dist/template-bindings/color-funcs.js +0 -219
  107. package/dist/template-bindings/helpers.d.ts +0 -24
  108. package/dist/template-bindings/helpers.js +0 -48
  109. package/dist/template-bindings/index.d.ts +0 -107
  110. package/dist/template-bindings/index.js +0 -144
  111. package/dist/template-bindings/palette-funcs.d.ts +0 -94
  112. package/dist/template-bindings/palette-funcs.js +0 -151
  113. package/dist/template-bindings/style-funcs.d.ts +0 -28
  114. package/dist/template-bindings/style-funcs.js +0 -151
  115. package/dist/themes/buildPalette.d.ts +0 -36
  116. package/dist/themes/buildPalette.js +0 -56
  117. package/dist/themes/colorMath.d.ts +0 -82
  118. package/dist/themes/colorMath.js +0 -254
  119. package/dist/themes/colorRef.d.ts +0 -51
  120. package/dist/themes/colorRef.js +0 -96
  121. package/dist/themes/data/atom-one-dark.d.ts +0 -3
  122. package/dist/themes/data/atom-one-dark.js +0 -158
  123. package/dist/themes/data/atom-one-light.d.ts +0 -3
  124. package/dist/themes/data/atom-one-light.js +0 -158
  125. package/dist/themes/data/catppuccin-frappe.d.ts +0 -3
  126. package/dist/themes/data/catppuccin-frappe.js +0 -158
  127. package/dist/themes/data/catppuccin-latte.d.ts +0 -3
  128. package/dist/themes/data/catppuccin-latte.js +0 -158
  129. package/dist/themes/data/catppuccin-macchiato.d.ts +0 -3
  130. package/dist/themes/data/catppuccin-macchiato.js +0 -158
  131. package/dist/themes/data/catppuccin-mocha.d.ts +0 -3
  132. package/dist/themes/data/catppuccin-mocha.js +0 -158
  133. package/dist/themes/data/cyberpunk.d.ts +0 -17
  134. package/dist/themes/data/cyberpunk.js +0 -191
  135. package/dist/themes/data/default.d.ts +0 -3
  136. package/dist/themes/data/default.js +0 -158
  137. package/dist/themes/data/dracula.d.ts +0 -3
  138. package/dist/themes/data/dracula.js +0 -158
  139. package/dist/themes/data/flexoki.d.ts +0 -3
  140. package/dist/themes/data/flexoki.js +0 -158
  141. package/dist/themes/data/gruvbox.d.ts +0 -3
  142. package/dist/themes/data/gruvbox.js +0 -158
  143. package/dist/themes/data/index.d.ts +0 -35
  144. package/dist/themes/data/index.js +0 -64
  145. package/dist/themes/data/monokai.d.ts +0 -3
  146. package/dist/themes/data/monokai.js +0 -158
  147. package/dist/themes/data/nord.d.ts +0 -3
  148. package/dist/themes/data/nord.js +0 -158
  149. package/dist/themes/data/rose-pine-dawn.d.ts +0 -3
  150. package/dist/themes/data/rose-pine-dawn.js +0 -158
  151. package/dist/themes/data/rose-pine-moon.d.ts +0 -3
  152. package/dist/themes/data/rose-pine-moon.js +0 -158
  153. package/dist/themes/data/rose-pine.d.ts +0 -3
  154. package/dist/themes/data/rose-pine.js +0 -158
  155. package/dist/themes/data/solarized-dark.d.ts +0 -3
  156. package/dist/themes/data/solarized-dark.js +0 -158
  157. package/dist/themes/data/solarized-light.d.ts +0 -3
  158. package/dist/themes/data/solarized-light.js +0 -158
  159. package/dist/themes/data/svg-export.d.ts +0 -3
  160. package/dist/themes/data/svg-export.js +0 -158
  161. package/dist/themes/data/textual-ansi.d.ts +0 -3
  162. package/dist/themes/data/textual-ansi.js +0 -158
  163. package/dist/themes/data/textual-dark.d.ts +0 -3
  164. package/dist/themes/data/textual-dark.js +0 -158
  165. package/dist/themes/data/textual-light.d.ts +0 -3
  166. package/dist/themes/data/textual-light.js +0 -158
  167. package/dist/themes/data/tokyo-night.d.ts +0 -3
  168. package/dist/themes/data/tokyo-night.js +0 -158
  169. package/dist/themes/data/types.d.ts +0 -15
  170. package/dist/themes/data/types.js +0 -1
  171. package/dist/themes/palette.d.ts +0 -21
  172. package/dist/themes/palette.js +0 -28
  173. package/dist/themes/ramp.d.ts +0 -83
  174. package/dist/themes/ramp.js +0 -122
  175. package/dist/themes/registry.d.ts +0 -43
  176. package/dist/themes/registry.js +0 -82
  177. package/dist/themes/terminalThemes.d.ts +0 -54
  178. package/dist/themes/terminalThemes.js +0 -68
  179. package/dist/themes/transpose.d.ts +0 -87
  180. package/dist/themes/transpose.js +0 -138
  181. package/dist/widgets/button.d.ts +0 -49
  182. package/dist/widgets/button.js +0 -206
  183. package/dist/widgets/checkbox.d.ts +0 -41
  184. package/dist/widgets/checkbox.js +0 -155
  185. package/dist/widgets/dropdown.d.ts +0 -67
  186. package/dist/widgets/dropdown.js +0 -453
  187. package/dist/widgets/event-router.d.ts +0 -78
  188. package/dist/widgets/event-router.js +0 -622
  189. package/dist/widgets/focus-manager.d.ts +0 -26
  190. package/dist/widgets/focus-manager.js +0 -193
  191. package/dist/widgets/index.d.ts +0 -22
  192. package/dist/widgets/index.js +0 -15
  193. package/dist/widgets/screen.d.ts +0 -98
  194. package/dist/widgets/screen.js +0 -542
  195. package/dist/widgets/slider.d.ts +0 -57
  196. package/dist/widgets/slider.js +0 -270
  197. package/dist/widgets/static-item.d.ts +0 -40
  198. package/dist/widgets/static-item.js +0 -60
  199. package/dist/widgets/text-input.d.ts +0 -274
  200. package/dist/widgets/text-input.js +0 -1162
  201. package/dist/widgets/toggle.d.ts +0 -44
  202. package/dist/widgets/toggle.js +0 -179
  203. package/dist/widgets/types.d.ts +0 -108
  204. package/dist/widgets/types.js +0 -31
  205. package/dist/widgets/widget-base.d.ts +0 -42
  206. package/dist/widgets/widget-base.js +0 -173
package/dist/core/text.js DELETED
@@ -1,990 +0,0 @@
1
- /**
2
- * RichText — styled text with spans. The primary text type for the library.
3
- */
4
- import { cellLen, cellCount } from "./cells.js";
5
- import { divideLine } from "./wrap.js";
6
- import { Segment } from "./segment.js";
7
- import { Style, NULL_STYLE, StyleSyntaxError } from "./style.js";
8
- import { stripOscTerminators } from "./osc8.js";
9
- import { getStyle, withBoundedWidth } from "./protocol.js";
10
- // Strip control characters except \t and \n
11
- // [LAW:single-enforcer] Single place where control chars are sanitized
12
- const CONTROL_CHARS_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
13
- function stripControlChars(text) {
14
- return text.replace(CONTROL_CHARS_RE, "");
15
- }
16
- const TRAILING_WHITESPACE_RE = /\s+$/;
17
- /**
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.
23
- */
24
- function plainOf(line) {
25
- return line.map((segment) => segment.text).join("");
26
- }
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 by `osc8Open`
52
- // (core/osc8.ts), which every link producer calls and which applies the same
53
- // `stripOscTerminators`.
54
- function sanitizeStyleLink(style) {
55
- const link = style.link;
56
- if (!link)
57
- return style;
58
- const cleaned = stripOscTerminators(link);
59
- if (cleaned === link)
60
- return style;
61
- return style.withLink(cleaned);
62
- }
63
- /**
64
- * A style as a RichText keeps it: as given. A name stays a name because what
65
- * it stands for depends on the theme of the render that draws it, which no
66
- * RichText knows when the name arrives — the reference stores span styles the
67
- * same way. A string holds no link until it is parsed, so only a `Style` has
68
- * one to sanitize here.
69
- */
70
- function admitStyle(style) {
71
- return style instanceof Style ? sanitizeStyleLink(style) : style;
72
- }
73
- /** A style that adds nothing: the empty definition, or a null `Style`. */
74
- function isEmptyStyle(style) {
75
- return style instanceof Style ? style.isNull : style === "";
76
- }
77
- /**
78
- * The style a stored `string | Style` stands for in this render.
79
- *
80
- * A parsed string can carry a link, so the result is sanitized on the way out
81
- * as well.
82
- */
83
- function resolveStyle(options, style) {
84
- return sanitizeStyleLink(style instanceof Style ? style : resolveDefinition(options, style));
85
- }
86
- /**
87
- * [LAW:single-enforcer] Styling is non-critical — an unrecognized style name
88
- * (typo, missing theme key, bad concatenation) degrades to unstyled rather
89
- * than crashing, as the reference's `Text.render` resolves with a null
90
- * default. Absorb only StyleSyntaxError here; other errors are genuine bugs
91
- * and must surface. The render's `onStyleError` hears about each one first,
92
- * and a handler that throws turns the degrade into a failure.
93
- */
94
- function resolveDefinition(options, style) {
95
- try {
96
- return getStyle(options, style);
97
- }
98
- catch (err) {
99
- if (!(err instanceof StyleSyntaxError))
100
- throw err;
101
- options.onStyleError?.(err, style);
102
- return NULL_STYLE;
103
- }
104
- }
105
- // --- Span ---
106
- export class Span {
107
- start;
108
- end;
109
- style;
110
- constructor(start, end, style) {
111
- this.start = start;
112
- this.end = end;
113
- this.style = style;
114
- }
115
- get hasLength() {
116
- return this.end > this.start;
117
- }
118
- toString() {
119
- return `Span(${this.start}, ${this.end})`;
120
- }
121
- split(offset) {
122
- if (offset <= this.start || offset >= this.end)
123
- return [this, undefined];
124
- return [
125
- new Span(this.start, offset, this.style),
126
- new Span(offset, this.end, this.style),
127
- ];
128
- }
129
- move(delta) {
130
- return new Span(this.start + delta, this.end + delta, this.style);
131
- }
132
- rightCrop(offset) {
133
- if (offset >= this.end)
134
- return this;
135
- return new Span(this.start, Math.min(this.end, offset), this.style);
136
- }
137
- extend(count) {
138
- return new Span(this.start, this.end + count, this.style);
139
- }
140
- }
141
- export class RichText {
142
- _text;
143
- _spans;
144
- _style;
145
- _justify;
146
- _overflow;
147
- _end;
148
- _tabSize;
149
- _noWrap;
150
- constructor(text, options) {
151
- this._text = text ? stripControlChars(text) : "";
152
- this._spans = [];
153
- // [LAW:single-enforcer] `admitStyle` is the boundary that sanitizes
154
- // any link URL crossing into a RichText; downstream trusts the invariant.
155
- this._style = admitStyle(options?.style ?? NULL_STYLE);
156
- this._justify = options?.justify;
157
- this._overflow = options?.overflow;
158
- this._end = options?.end ?? "\n";
159
- this._tabSize = options?.tabSize ?? 8;
160
- this._noWrap = options?.noWrap ?? false;
161
- }
162
- // --- Properties ---
163
- get plain() {
164
- return this._text;
165
- }
166
- set plain(value) {
167
- const sanitized = stripControlChars(value);
168
- this._text = sanitized;
169
- // Trim spans that extend beyond new length
170
- const len = sanitized.length;
171
- this._spans = this._spans
172
- .map((s) => (s.end > len ? new Span(s.start, Math.min(s.end, len), s.style) : s))
173
- .filter((s) => s.start < len);
174
- }
175
- get length() {
176
- return this._text.length;
177
- }
178
- get cellLength() {
179
- return cellLen(this._text);
180
- }
181
- get hasContent() {
182
- return this._text.length > 0;
183
- }
184
- /** The base style every span layers over: a `Style`, or a name resolved at render. */
185
- get style() {
186
- return this._style;
187
- }
188
- set style(value) {
189
- this._style = admitStyle(value);
190
- }
191
- get justify() {
192
- return this._justify;
193
- }
194
- set justify(value) {
195
- this._justify = value;
196
- }
197
- get overflow() {
198
- return this._overflow;
199
- }
200
- set overflow(value) {
201
- this._overflow = value;
202
- }
203
- get end() {
204
- return this._end;
205
- }
206
- set end(value) {
207
- this._end = value;
208
- }
209
- get noWrap() {
210
- return this._noWrap;
211
- }
212
- set noWrap(value) {
213
- this._noWrap = value;
214
- }
215
- get spans() {
216
- return this._spans;
217
- }
218
- /**
219
- * This text's own style, spans aside, as the render drawing it resolves it.
220
- * A name the render's theme does not define resolves to no style, because
221
- * text forgives a missing name.
222
- */
223
- resolvedStyle(options) {
224
- return resolveStyle(options, this._style);
225
- }
226
- /**
227
- * The style of the cell-column at the named edge — base style merged with
228
- * any spans covering the leftmost (side="left") or rightmost (side="right")
229
- * character.
230
- *
231
- * [LAW:locality-or-seam] Used by `Joiner`s to paint the transition between
232
- * adjacent `Strip` items. Joiners only ever need the column adjacent to
233
- * them, so the cell type exposes that column rather than constraining its
234
- * interior to be uniform. For RichText with uniform styling, both edges
235
- * return the same style; for RichText with edge variation, each edge
236
- * accurately reports the column the joiner actually meets.
237
- *
238
- * Position is by character index (not cell column). For wide-character
239
- * text, the last character occupies the rightmost cell column — the bg
240
- * of that character covers both columns, so character-index lookup gives
241
- * the correct edge color.
242
- *
243
- * Takes the render's options because the edge is reported as it will be
244
- * drawn, and a style name draws as whatever the render's theme says.
245
- */
246
- edgeStyle(side, options) {
247
- const base = this.resolvedStyle(options);
248
- if (this._text.length === 0)
249
- return base;
250
- const pos = side === "left" ? 0 : this._text.length - 1;
251
- let result = base;
252
- for (const span of this._spans) {
253
- if (span.start <= pos && pos < span.end) {
254
- result = result.add(resolveStyle(options, span.style));
255
- }
256
- }
257
- return result;
258
- }
259
- // --- Content Operations ---
260
- append(content, style) {
261
- if (content instanceof RichText) {
262
- if (style !== undefined) {
263
- throw new Error("Style argument must not be provided when appending RichText");
264
- }
265
- const offset = this._text.length;
266
- this._text += content._text;
267
- for (const span of content._spans) {
268
- this._spans.push(span.move(offset));
269
- }
270
- return this;
271
- }
272
- const sanitized = stripControlChars(content);
273
- const start = this._text.length;
274
- this._text += sanitized;
275
- this._addSpan(start, this._text.length, style ?? "");
276
- return this;
277
- }
278
- contains(needle) {
279
- const searchText = needle instanceof RichText ? needle._text : needle;
280
- return this._text.includes(searchText);
281
- }
282
- at(index) {
283
- const resolved = index < 0 ? this._text.length + index : index;
284
- const char = this._text[resolved];
285
- if (char === undefined)
286
- return new RichText("");
287
- return this.slice(resolved, resolved + 1);
288
- }
289
- slice(start, end) {
290
- const text = this._text;
291
- const len = text.length;
292
- const s = start ?? 0;
293
- const e = end ?? len;
294
- const resolvedStart = s < 0 ? Math.max(0, len + s) : Math.min(s, len);
295
- const resolvedEnd = e < 0 ? Math.max(0, len + e) : Math.min(e, len);
296
- if (resolvedStart >= resolvedEnd) {
297
- return this.blankCopy();
298
- }
299
- const slicedText = text.slice(resolvedStart, resolvedEnd);
300
- const result = this.blankCopy(slicedText);
301
- for (const span of this._spans) {
302
- const spanStart = Math.max(span.start, resolvedStart) - resolvedStart;
303
- const spanEnd = Math.min(span.end, resolvedEnd) - resolvedStart;
304
- if (spanStart < spanEnd) {
305
- result._spans.push(new Span(spanStart, spanEnd, span.style));
306
- }
307
- }
308
- return result;
309
- }
310
- // --- Styling Operations ---
311
- /**
312
- * The one way a span enters this text. [LAW:single-enforcer] An empty style
313
- * adds nothing, as the reference's `if style:` has it, and every other style
314
- * is admitted as given.
315
- */
316
- _addSpan(start, end, style) {
317
- if (isEmptyStyle(style))
318
- return;
319
- this._spans.push(new Span(start, end, admitStyle(style)));
320
- }
321
- stylize(style, start, end) {
322
- const len = this._text.length;
323
- const s = start !== undefined ? (start < 0 ? len + start : start) : 0;
324
- const e = end !== undefined ? (end < 0 ? len + end : end) : len;
325
- if (s >= e || s >= len || e <= 0)
326
- return this;
327
- const clampedStart = Math.max(0, s);
328
- const clampedEnd = Math.min(len, e);
329
- this._addSpan(clampedStart, clampedEnd, style);
330
- return this;
331
- }
332
- highlightRegex(pattern, style) {
333
- const text = this._text;
334
- let count = 0;
335
- // Ensure global flag
336
- const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
337
- const re = new RegExp(pattern.source, flags);
338
- let match;
339
- while ((match = re.exec(text)) !== null) {
340
- if (match[0].length === 0) {
341
- re.lastIndex++;
342
- continue;
343
- }
344
- // Named capture groups are applied as style names
345
- if (match.groups) {
346
- let searchFrom = 0;
347
- for (const [groupName, groupValue] of Object.entries(match.groups)) {
348
- if (groupValue !== undefined) {
349
- const posInMatch = match[0].indexOf(groupValue, searchFrom);
350
- if (posInMatch >= 0) {
351
- const groupStart = match.index + posInMatch;
352
- this._addSpan(groupStart, groupStart + groupValue.length, groupName);
353
- searchFrom = posInMatch + groupValue.length;
354
- }
355
- }
356
- }
357
- count++;
358
- continue;
359
- }
360
- this._addSpan(match.index, match.index + match[0].length, style ?? "");
361
- count++;
362
- }
363
- return count;
364
- }
365
- highlightWords(words, style, options) {
366
- const caseSensitive = options?.caseSensitive !== false;
367
- let count = 0;
368
- for (const word of words) {
369
- if (word.length === 0)
370
- continue;
371
- const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
372
- const flags = caseSensitive ? "g" : "gi";
373
- const re = new RegExp(`\\b${escaped}\\b`, flags);
374
- let match;
375
- while ((match = re.exec(this._text)) !== null) {
376
- this._addSpan(match.index, match.index + match[0].length, style);
377
- count++;
378
- }
379
- }
380
- return count;
381
- }
382
- // --- Copy Operations ---
383
- copy() {
384
- const result = new RichText(this._text, {
385
- style: this._style,
386
- justify: this._justify,
387
- overflow: this._overflow,
388
- end: this._end,
389
- tabSize: this._tabSize,
390
- noWrap: this._noWrap,
391
- });
392
- result._spans = this._spans.slice();
393
- return result;
394
- }
395
- blankCopy(text) {
396
- return new RichText(text ?? "", {
397
- style: this._style,
398
- justify: this._justify,
399
- overflow: this._overflow,
400
- end: this._end,
401
- tabSize: this._tabSize,
402
- noWrap: this._noWrap,
403
- });
404
- }
405
- // --- Splitting ---
406
- split(separator) {
407
- const sep = separator ?? "\n";
408
- const text = this._text;
409
- const parts = [];
410
- let start = 0;
411
- while (true) {
412
- const idx = text.indexOf(sep, start);
413
- if (idx === -1) {
414
- parts.push(this.slice(start));
415
- break;
416
- }
417
- parts.push(this.slice(start, idx));
418
- start = idx + sep.length;
419
- }
420
- return parts;
421
- }
422
- divide(offsets) {
423
- if (offsets.length === 0)
424
- return [this.copy()];
425
- const parts = [];
426
- let prev = 0;
427
- for (const offset of offsets) {
428
- parts.push(this.slice(prev, offset));
429
- prev = offset;
430
- }
431
- parts.push(this.slice(prev));
432
- return parts;
433
- }
434
- // --- Whitespace Operations ---
435
- rstrip() {
436
- const trimmed = this._text.replace(/\s+$/, "");
437
- if (trimmed.length < this._text.length) {
438
- this.plain = trimmed;
439
- }
440
- return this;
441
- }
442
- pad(count, char) {
443
- const c = char ?? " ";
444
- const padding = c.repeat(count);
445
- // Shift all spans to the right
446
- this._spans = this._spans.map((s) => s.move(count));
447
- this._text = padding + this._text + padding;
448
- return this;
449
- }
450
- padLeft(count, char) {
451
- const c = char ?? " ";
452
- this._spans = this._spans.map((s) => s.move(count));
453
- this._text = c.repeat(count) + this._text;
454
- return this;
455
- }
456
- padRight(count, char) {
457
- const c = char ?? " ";
458
- this._text += c.repeat(count);
459
- return this;
460
- }
461
- setLength(length) {
462
- if (this._text.length < length) {
463
- this._text += " ".repeat(length - this._text.length);
464
- }
465
- else if (this._text.length > length) {
466
- this.plain = this._text.slice(0, length);
467
- }
468
- return this;
469
- }
470
- extendStyle(count) {
471
- const oldLen = this._text.length;
472
- this._text += " ".repeat(count);
473
- // Extend existing spans to cover new spaces
474
- this._spans = this._spans.map((s) => s.end === oldLen ? s.extend(count) : s);
475
- return this;
476
- }
477
- // --- Truncation ---
478
- /**
479
- * Truncate to a fixed cell-column width, in one of three modes.
480
- *
481
- * - `mode: "right"` (default) \u2014 drop characters from the right; append
482
- * `marker` (if any) at the cut.
483
- * - `mode: "left"` \u2014 drop characters from the left; prepend `marker` at
484
- * the cut.
485
- * - `mode: "middle"` \u2014 keep equal halves from both ends; place `marker`
486
- * in the middle.
487
- *
488
- * Marker default is `"\u2026"`. Pass `marker: ""` for raw cropping without an
489
- * indicator glyph.
490
- *
491
- * Spans are preserved through the cut: characters that survive keep their
492
- * styling; the marker (if any) is inserted as plain text with no span.
493
- * Use `stylize(...)` on the result to color the marker if needed.
494
- *
495
- * [LAW:dataflow-not-control-flow] mode/marker/width all flow as values;
496
- * the walk is the same shape regardless. No "if truncated then rebuild"
497
- * branch \u2014 the unchanged path just early-returns when content fits.
498
- */
499
- truncate(width, options) {
500
- if (this.cellLength <= width)
501
- return this;
502
- const mode = options?.mode ?? "right";
503
- const marker = options?.marker ?? "\u2026";
504
- const markerWidth = cellLen(marker);
505
- if (width <= 0) {
506
- this.plain = "";
507
- return this;
508
- }
509
- const budget = Math.max(0, width - markerWidth);
510
- if (mode === "right") {
511
- this._cropRightTo(budget);
512
- if (marker)
513
- this._text += marker;
514
- return this;
515
- }
516
- if (mode === "left") {
517
- this._cropLeftTo(budget);
518
- if (marker) {
519
- this._spans = this._spans.map((s) => s.move(marker.length));
520
- this._text = marker + this._text;
521
- }
522
- return this;
523
- }
524
- // middle
525
- const leftBudget = Math.floor(budget / 2);
526
- const rightBudget = budget - leftBudget;
527
- // Find the char-index ranges to keep from each side.
528
- const leftEndCharIdx = this._cellPrefixCharLength(leftBudget);
529
- const rightStartCharIdx = this._cellSuffixStartCharIndex(rightBudget);
530
- const leftText = this._text.slice(0, leftEndCharIdx);
531
- const rightText = this._text.slice(rightStartCharIdx);
532
- const droppedStart = leftEndCharIdx;
533
- const droppedEnd = rightStartCharIdx;
534
- const shift = marker.length - (droppedEnd - droppedStart);
535
- // Spans falling entirely before the drop stay; entirely after shift by
536
- // (marker.length - dropped chars); spans crossing the drop are clipped.
537
- const newSpans = [];
538
- for (const s of this._spans) {
539
- if (s.end <= droppedStart) {
540
- newSpans.push(s);
541
- }
542
- else if (s.start >= droppedEnd) {
543
- newSpans.push(s.move(shift));
544
- }
545
- else {
546
- // crosses \u2014 clip to left side and to right side as two spans
547
- if (s.start < droppedStart) {
548
- newSpans.push(new Span(s.start, droppedStart, s.style));
549
- }
550
- if (s.end > droppedEnd) {
551
- newSpans.push(new Span(droppedEnd + shift, s.end + shift, s.style));
552
- }
553
- }
554
- }
555
- this._text = leftText + marker + rightText;
556
- this._spans = newSpans;
557
- return this;
558
- }
559
- _cropRightTo(targetWidth) {
560
- const charIdx = this._cellPrefixCharLength(targetWidth);
561
- this.plain = this._text.slice(0, charIdx);
562
- }
563
- _cropLeftTo(targetWidth) {
564
- const charIdx = this._cellSuffixStartCharIndex(targetWidth);
565
- // Shift spans left by charIdx; clip spans that started before.
566
- const shift = -charIdx;
567
- this._spans = this._spans
568
- .map((s) => {
569
- if (s.end <= charIdx)
570
- return undefined;
571
- const newStart = Math.max(0, s.start + shift);
572
- const newEnd = s.end + shift;
573
- return new Span(newStart, newEnd, s.style);
574
- })
575
- .filter((s) => s !== undefined);
576
- this._text = this._text.slice(charIdx);
577
- }
578
- /** Number of char-index code units that fit within `targetWidth` cell columns from the left. */
579
- _cellPrefixCharLength(targetWidth) {
580
- let width = 0;
581
- let charIndex = 0;
582
- for (const char of this._text) {
583
- const charWidth = cellLen(char);
584
- if (width + charWidth > targetWidth)
585
- break;
586
- width += charWidth;
587
- charIndex += char.length;
588
- }
589
- return charIndex;
590
- }
591
- /** Char-index at which the suffix of `targetWidth` cell columns starts. */
592
- _cellSuffixStartCharIndex(targetWidth) {
593
- // Walk from right: accumulate widths of trailing chars until we hit the budget.
594
- const chars = [...this._text];
595
- let width = 0;
596
- let kept = 0;
597
- for (let i = chars.length - 1; i >= 0; i--) {
598
- const w = cellLen(chars[i]);
599
- if (width + w > targetWidth)
600
- break;
601
- width += w;
602
- kept += chars[i].length;
603
- }
604
- return this._text.length - kept;
605
- }
606
- // --- Alignment ---
607
- align(justify, width) {
608
- const currentWidth = this.cellLength;
609
- if (currentWidth >= width)
610
- return this;
611
- const gap = width - currentWidth;
612
- switch (justify) {
613
- case "left":
614
- this.padRight(gap);
615
- break;
616
- case "right":
617
- this.padLeft(gap);
618
- break;
619
- case "center": {
620
- const leftPad = Math.floor(gap / 2);
621
- const rightPad = gap - leftPad;
622
- this.padLeft(leftPad);
623
- this.padRight(rightPad);
624
- break;
625
- }
626
- }
627
- return this;
628
- }
629
- // --- Suffix Removal ---
630
- removeSuffix(suffix) {
631
- if (this._text.endsWith(suffix)) {
632
- this.plain = this._text.slice(0, -suffix.length);
633
- }
634
- return this;
635
- }
636
- // --- Token Appending ---
637
- appendTokens(tokens) {
638
- for (const [text, style] of tokens) {
639
- this.append(text, style);
640
- }
641
- return this;
642
- }
643
- // --- Static Factories ---
644
- static assemble(parts, options) {
645
- const result = new RichText("", { style: options?.style });
646
- for (const part of parts) {
647
- if (typeof part === "string") {
648
- result.append(part);
649
- }
650
- else if (part instanceof RichText) {
651
- result.append(part);
652
- }
653
- else {
654
- const [text, style] = part;
655
- result.append(text, style);
656
- }
657
- }
658
- return result;
659
- }
660
- static styled(text, style) {
661
- const result = new RichText(text);
662
- result.stylize(style);
663
- return result;
664
- }
665
- /**
666
- * Concatenate a sequence of `RichText` fragments into a single `RichText`,
667
- * flattening each fragment's wrapping `style` onto a span over that
668
- * fragment's range so downstream rendering preserves the original styling.
669
- *
670
- * Designed for the template engine's `RichText[]` output: a top-level
671
- * `{{ red "x" }}{{ blue "y" }}` evaluates to two fragments — one with
672
- * wrapping `style = red`, one with `blue` — and consumers that want a
673
- * single styled string for downstream rendering need both styles
674
- * preserved as spans on the concatenated result. The plain `append()`
675
- * propagates spans only, so this static does the additional work of
676
- * lifting `frag.style` into a span before appending.
677
- *
678
- * Empty input → empty `RichText` with `end: ""`. Caller can override
679
- * `end` (defaults to `""` — the engine-output case rarely wants a
680
- * trailing newline added by the container).
681
- */
682
- static fromFragments(fragments, options) {
683
- const result = new RichText("", { end: options?.end ?? "" });
684
- for (const frag of fragments) {
685
- const start = result.length;
686
- result.append(frag.plain);
687
- result.stylize(frag.style, start, result.length);
688
- for (const span of frag.spans) {
689
- result.stylize(span.style, start + span.start, start + span.end);
690
- }
691
- }
692
- return result;
693
- }
694
- // --- Renderable ---
695
- *render(options) {
696
- const text = this._expandTabs(this._text);
697
- const base = this.resolvedStyle(options);
698
- const allSegments = this._buildSegments(text, base, options);
699
- const logicalLines = Segment.splitLines(allSegments);
700
- // [LAW:single-enforcer] The one crossing for this renderable's width, and
701
- // the call every other renderable already makes. A bare `cellCount` stood
702
- // here doing half of it: it caught a NaN width, which had made every
703
- // overflow arm a no-op, but passed an unbounded one through to `justify`,
704
- // which pads — and `" ".repeat(Infinity)` throws.
705
- const maxWidth = cellCount(withBoundedWidth(options, this).maxWidth);
706
- const overflow = this._overflow ?? options.overflow ?? "fold";
707
- const justify = this._justify ?? options.justify;
708
- const noWrap = this._noWrap || (options.noWrap ?? false);
709
- // The width a line is cut to, which is not always the width it is
710
- // justified in. `noWrap` means the line is not bounded at all: it leaves at
711
- // its natural width and whatever asked for it decides about the overhang —
712
- // `Console`'s soft wrap and `FlexStrip`'s too-wide fallback both want the
713
- // text intact rather than cropped.
714
- //
715
- // [LAW:dataflow-not-control-flow] It reaches the pipeline as a width, not
716
- // as a step to skip: an unbounded budget has no edge to break at, so
717
- // `divideLine` finds no cuts and `_fitLine` finds nothing past the edge,
718
- // and every line runs the same three steps. `Infinity` is already this
719
- // library's spelling of an unbounded width offer — `withBoundedWidth` in
720
- // protocol.ts parses one on the way in.
721
- const budget = noWrap ? cellCount(Infinity) : maxWidth;
722
- const endsWithNewline = text.endsWith("\n");
723
- for (let index = 0; index < logicalLines.length; index += 1) {
724
- const line = logicalLines[index];
725
- const terminateLine = index < logicalLines.length - 1 || endsWithNewline;
726
- // Wrap first, overflow last — the reference's order, and the reason a
727
- // long sentence grows a table row while an unbreakable word in the same
728
- // column still ellipsizes.
729
- const cuts = divideLine(plainOf(line), budget, { fold: overflow === "fold" });
730
- const wrapped = Segment.divide(line, cuts);
731
- const placed = this._justifyLines(wrapped.map((piece) => [...this._fitLine(piece, budget, overflow)]), maxWidth, base, justify);
732
- for (let piece = 0; piece < placed.length; piece += 1) {
733
- yield* placed[piece];
734
- if (piece < placed.length - 1 || terminateLine) {
735
- yield Segment.line();
736
- }
737
- }
738
- }
739
- // [LAW:single-enforcer] The one place `end` is emitted, for empty and
740
- // non-empty text alike — the pre-fix code checked it once for empty text
741
- // (truthy) and again for non-empty text (truthy and not the default
742
- // "\n"), which gave `end` two meanings depending on whether the text was
743
- // empty (rich-text-5ai). Empty text takes zero iterations of the loop
744
- // above, so this is reached either way with nothing yielded but this.
745
- if (this._end) {
746
- yield new Segment(this._end);
747
- }
748
- }
749
- // --- Measurable ---
750
- measure(options) {
751
- const text = this._expandTabs(this._text);
752
- const lines = text.split("\n");
753
- let maxLineWidth = 0;
754
- let maxWordWidth = 0;
755
- for (const line of lines) {
756
- const lineWidth = cellLen(line);
757
- maxLineWidth = Math.max(maxLineWidth, lineWidth);
758
- // Minimum is the longest word
759
- const words = line.split(/\s+/);
760
- for (const word of words) {
761
- if (word.length > 0) {
762
- maxWordWidth = Math.max(maxWordWidth, cellLen(word));
763
- }
764
- }
765
- }
766
- // Parsed for the same reason `render` parses it: an unparsed NaN ceiling
767
- // makes both `Math.min` calls NaN, and a range of NaN..NaN is one no parent
768
- // layout can divide.
769
- const ceiling = cellCount(options.maxWidth);
770
- return {
771
- minimum: Math.min(maxWordWidth, ceiling),
772
- maximum: Math.min(maxLineWidth, ceiling),
773
- };
774
- }
775
- // --- Internal ---
776
- _expandTabs(text) {
777
- if (!text.includes("\t"))
778
- return text;
779
- return text.replace(/\t/g, " ".repeat(this._tabSize));
780
- }
781
- /**
782
- * The rendered text cut at every span edge, each piece carrying the base
783
- * style plus every span covering it.
784
- *
785
- * Walked span-first rather than piece-first, and that direction is the whole
786
- * performance argument. The pieces are cut at the span edges themselves, so
787
- * a span covers a piece exactly when it covers the piece's first character —
788
- * which makes each span's run of pieces a contiguous range it can be written
789
- * into once, instead of a question every piece asks of every span. The
790
- * piece-first form charged `spans x pieces`, and the pieces are themselves
791
- * cut by the spans, so anything styling densely paid the square in ordinary
792
- * use: 8,000 one-character spans took 211ms where 1,000 took 3.2ms. Every
793
- * `Highlighter` over a large value reaches that, and so does `Pretty`, whose
794
- * indent guides emit a span per indent character.
795
- *
796
- * Span-first is also what keeps the composition honest, for free. `Style.add`
797
- * is order-dependent and the last writer wins, so the pieces have to fold
798
- * their styles in `_spans` order — which iterating `_spans` is, and which a
799
- * sweep ordered by position would have had to reconstruct.
800
- *
801
- * [LAW:dataflow-not-control-flow] A span covering nothing — empty, reversed,
802
- * or entirely past the text — still cuts the text where its edges land, as it
803
- * always did, and then folds into no piece at all: its range comes out empty
804
- * and no case handles it.
805
- */
806
- _buildSegments(text, base, options) {
807
- const clamp = (offset) => Math.max(0, Math.min(offset, text.length));
808
- const positions = new Set([0, text.length]);
809
- for (const span of this._spans) {
810
- positions.add(clamp(span.start));
811
- positions.add(clamp(span.end));
812
- }
813
- const boundaries = [...positions].sort((a, b) => a - b);
814
- // Every span edge is a boundary, so where a span's range opens is a lookup
815
- // rather than a search.
816
- const pieceAt = new Map(boundaries.map((position, piece) => [position, piece]));
817
- const styles = boundaries.slice(0, -1).map(() => base);
818
- for (const span of this._spans) {
819
- const end = clamp(span.end);
820
- const style = resolveStyle(options, span.style);
821
- const opensAt = pieceAt.get(clamp(span.start));
822
- for (let piece = opensAt; boundaries[piece] < end; piece++) {
823
- styles[piece] = styles[piece].add(style);
824
- }
825
- }
826
- return styles.map((style, piece) => new Segment(text.slice(boundaries[piece], boundaries[piece + 1]), style.isNull ? undefined : style));
827
- }
828
- /**
829
- * The pieces one logical line wrapped into, each placed in a canvas
830
- * `maxWidth` wide, as Rich's `Lines.justify` places them — pinned block for
831
- * block against the reference in `test/core/text-justify.test.ts`.
832
- *
833
- * It is handed the whole wrapped line because `full` is the one mode a
834
- * piece cannot answer alone: the reference leaves a paragraph's last line
835
- * ragged, so where the piece sits decides its answer where the other three
836
- * modes need only the piece itself. The set that decides "last" is this one
837
- * and not the whole render — `Text.wrap` calls `Lines.justify` once per
838
- * *logical* line — and `Segment.divide` already handed it over whole.
839
- */
840
- _justifyLines(lines, maxWidth, base, justify) {
841
- if (justify !== "full") {
842
- return lines.map((line) => [...this._justifyLine(line, maxWidth, justify)]);
843
- }
844
- return lines.map((line, index) => index === lines.length - 1 ? line : this._fillLine(line, maxWidth, base));
845
- }
846
- /**
847
- * One line placed in a canvas `maxWidth` wide.
848
- *
849
- * Centre and right align on the line's *content*. The whitespace a wrap
850
- * leaves on the end of the line it closed is the break's own padding, not
851
- * text, and aligning around it pushes the text half a gap off true — a
852
- * centred title that wraps drifts left on every line that happens to end in
853
- * a space. Left keeps that whitespace, because there it is already on the
854
- * side the padding goes.
855
- *
856
- * `undefined` is not `"left"`: it is Rich's `"default"`, which places the
857
- * line without padding it at all. That distinction is what lets a soft-wrapped
858
- * `Console.print` leave its lines at their natural width.
859
- */
860
- *_justifyLine(line, maxWidth,
861
- // [LAW:types-are-the-program] `full` is absent rather than ignored: it
862
- // needs the lines either side of this one, so the type refuses it here
863
- // instead of a branch quietly rendering it as `left`, which is the bug
864
- // this signature replaces (rich-justify-0cr.1).
865
- justify) {
866
- switch (justify) {
867
- case "center":
868
- case "right": {
869
- const body = Segment.adjustLineLength(line, Segment.getLineLength(line) - hangingWhitespace(line), undefined, false);
870
- const gap = Math.max(maxWidth - Segment.getLineLength(body), 0);
871
- const leftPad = justify === "center" ? Math.floor(gap / 2) : gap;
872
- if (leftPad > 0)
873
- yield new Segment(" ".repeat(leftPad));
874
- yield* body;
875
- const rightPad = gap - leftPad;
876
- if (rightPad > 0)
877
- yield new Segment(" ".repeat(rightPad));
878
- break;
879
- }
880
- case "left":
881
- yield* Segment.adjustLineLength(line, Math.max(maxWidth, Segment.getLineLength(line)));
882
- break;
883
- default:
884
- yield* line;
885
- break;
886
- }
887
- }
888
- /**
889
- * One line of a wrapped paragraph, its gaps widened until it fills the
890
- * canvas — what `justify: "full"` promises, and what the reference's
891
- * `Lines.justify` does to every line of a paragraph but the last.
892
- *
893
- * Slack goes in a cell at a time, starting at the rightmost gap and walking
894
- * left, round and round until the line is full. That order is the
895
- * reference's own, and what it decides is where an odd cell lands when the
896
- * slack will not divide evenly: the right-hand gaps take it.
897
- *
898
- * The words come from the line's *text* split on a single space, with the
899
- * blank a trailing separator leaves behind dropped — `Text.split` and
900
- * `String.prototype.split` agree on that list, empty words and all. Two
901
- * consequences read as bugs until you know whose they are: a run of n
902
- * spaces is n gaps rather than one, so spacing an author widened stretches
903
- * instead of collapsing, and the whitespace a wrap left hanging is a gap
904
- * like the rest, which is why a filled line does not keep it. Both are
905
- * pinned in `text-justify.golden.txt`, by the `uneven` and `sentence`
906
- * blocks respectively.
907
- */
908
- _fillLine(line, maxWidth, base) {
909
- const plain = plainOf(line);
910
- const words = plain.split(" ");
911
- if (plain.endsWith(" "))
912
- words.pop();
913
- const gaps = words.length - 1;
914
- const spaces = new Array(gaps).fill(1);
915
- let filled = words.reduce((total, word) => total + cellLen(word), 0) + gaps;
916
- for (let turn = 0; filled < maxWidth && gaps > 0; turn = (turn + 1) % gaps) {
917
- spaces[gaps - 1 - turn] += 1;
918
- filled += 1;
919
- }
920
- // Cut at both edges of every gap, so a word is an even piece and the
921
- // separator that followed it is the odd piece after it. Measured in cells
922
- // because that is the coordinate system `Segment.divide` reads, which a
923
- // line of wide glyphs is the only thing that notices.
924
- const cuts = [];
925
- let edge = 0;
926
- for (let index = 0; index < words.length; index += 1) {
927
- edge += cellLen(words[index]);
928
- cuts.push(edge);
929
- edge += 1;
930
- if (index < gaps)
931
- cuts.push(edge);
932
- }
933
- const pieces = Segment.divide(line, cuts);
934
- // A widened gap takes the style the words either side of it agree on, and
935
- // the line's own where they disagree — the reference reads that off the
936
- // character each side turns towards the gap, which is the offset `at` is
937
- // given here. A word with no characters turns none and answers with the
938
- // line's style, which is what two adjacent separators leave between them.
939
- const edgeStyle = (word, at) => word.filter((segment) => segment.hasText).at(at)?.style ?? base;
940
- const result = [];
941
- for (let index = 0; index < words.length; index += 1) {
942
- result.push(...pieces[index * 2]);
943
- if (index < gaps) {
944
- const before = edgeStyle(pieces[index * 2], -1);
945
- const after = edgeStyle(pieces[index * 2 + 2], 0);
946
- const style = before.equals(after) ? before : base;
947
- result.push(new Segment(" ".repeat(spaces[index]), style.isNull ? undefined : style));
948
- }
949
- }
950
- return result;
951
- }
952
- /**
953
- * One wrapped line cut to the canvas.
954
- *
955
- * Everything reaching here already survived wrapping, so the only text still
956
- * too wide is text no break could help: a word longer than the canvas under
957
- * a non-folding overflow method, a glyph wider than the budget, or a canvas
958
- * with no cells at all. That is what makes the overflow method a last
959
- * resort rather than the first thing a long cell meets.
960
- */
961
- *_fitLine(line, maxWidth, overflow) {
962
- const lineWidth = Segment.getLineLength(line);
963
- const contentWidth = lineWidth - hangingWhitespace(line);
964
- // Whitespace hanging past the edge is the wrap's own padding: cropping it
965
- // away is not truncation, so it earns no marker. Without this an ellipsis
966
- // landed on any break that fell a space past the column — the common case
967
- // in a table, not an edge one.
968
- if (contentWidth <= maxWidth) {
969
- yield* Segment.adjustLineLength(line, Math.min(lineWidth, maxWidth), undefined, false);
970
- return;
971
- }
972
- // The marker takes the last cell and the text keeps the rest. At maxWidth 1
973
- // that is zero cells of text and the marker alone, which is the honest
974
- // rendering of "all of this was cut"; the `maxWidth > 1` guard that used to
975
- // stand here emitted no line at all, so every table column squeezed to a
976
- // single cell rendered blank rather than truncated — `ellipsis` being the
977
- // default column overflow, a hard-squeezed table looked like an empty frame.
978
- //
979
- // At maxWidth 0 there is no cell to put the marker in, so every method
980
- // yields the same bare empty line. Agreement there is what keeps a
981
- // `Columns` or `Layout` squeezed to no width at all from rendering
982
- // three different kinds of nothing.
983
- if (overflow === "ellipsis" && maxWidth > 0) {
984
- yield* Segment.adjustLineLength(line, maxWidth - 1, undefined, false);
985
- yield new Segment("\u2026");
986
- return;
987
- }
988
- yield* Segment.adjustLineLength(line, maxWidth, undefined, false);
989
- }
990
- }