@promptctl/rich-js 0.8.0 → 0.9.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.
- package/README.md +68 -1
- package/dist/core/box.d.ts +93 -39
- package/dist/core/box.d.ts.map +1 -1
- package/dist/core/box.js +348 -490
- package/dist/core/box.js.map +1 -1
- package/dist/core/color.d.ts.map +1 -1
- package/dist/core/color.js +22 -8
- package/dist/core/color.js.map +1 -1
- package/dist/core/console.d.ts +2 -4
- package/dist/core/console.d.ts.map +1 -1
- package/dist/core/console.js +35 -24
- package/dist/core/console.js.map +1 -1
- package/dist/core/highlighter.d.ts +2 -1
- package/dist/core/highlighter.d.ts.map +1 -1
- package/dist/core/highlighter.js +28 -6
- package/dist/core/highlighter.js.map +1 -1
- package/dist/{renderables → core}/json.d.ts +8 -3
- package/dist/core/json.d.ts.map +1 -0
- package/dist/{renderables → core}/json.js +7 -2
- package/dist/core/json.js.map +1 -0
- package/dist/core/markup.d.ts +6 -7
- package/dist/core/markup.d.ts.map +1 -1
- package/dist/core/markup.js +124 -32
- package/dist/core/markup.js.map +1 -1
- package/dist/core/pretty.d.ts +56 -21
- package/dist/core/pretty.d.ts.map +1 -1
- package/dist/core/pretty.js +210 -116
- package/dist/core/pretty.js.map +1 -1
- package/dist/core/protocol.d.ts +16 -0
- package/dist/core/protocol.d.ts.map +1 -1
- package/dist/core/protocol.js +13 -0
- package/dist/core/protocol.js.map +1 -1
- package/dist/core/segment.d.ts +5 -0
- package/dist/core/segment.d.ts.map +1 -1
- package/dist/core/segment.js +9 -3
- package/dist/core/segment.js.map +1 -1
- package/dist/core/strip.d.ts +9 -5
- package/dist/core/strip.d.ts.map +1 -1
- package/dist/core/strip.js +39 -41
- package/dist/core/strip.js.map +1 -1
- package/dist/core/style.d.ts +19 -0
- package/dist/core/style.d.ts.map +1 -1
- package/dist/core/style.js +46 -38
- package/dist/core/style.js.map +1 -1
- package/dist/core/text.d.ts +102 -4
- package/dist/core/text.d.ts.map +1 -1
- package/dist/core/text.js +322 -184
- package/dist/core/text.js.map +1 -1
- package/dist/core/wrap.d.ts +42 -0
- package/dist/core/wrap.d.ts.map +1 -0
- package/dist/core/wrap.js +128 -0
- package/dist/core/wrap.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/renderables/markdown.d.ts +1 -1
- package/dist/renderables/markdown.d.ts.map +1 -1
- package/dist/renderables/markdown.js +7 -14
- package/dist/renderables/markdown.js.map +1 -1
- package/dist/renderables/padding.d.ts +1 -1
- package/dist/renderables/padding.d.ts.map +1 -1
- package/dist/renderables/padding.js +5 -11
- package/dist/renderables/padding.js.map +1 -1
- package/dist/renderables/panel.d.ts +4 -4
- package/dist/renderables/panel.d.ts.map +1 -1
- package/dist/renderables/panel.js +39 -39
- package/dist/renderables/panel.js.map +1 -1
- package/dist/renderables/progress.d.ts +3 -3
- package/dist/renderables/progress.d.ts.map +1 -1
- package/dist/renderables/progress.js +11 -9
- package/dist/renderables/progress.js.map +1 -1
- package/dist/renderables/progressBar.d.ts +3 -3
- package/dist/renderables/progressBar.d.ts.map +1 -1
- package/dist/renderables/progressBar.js +9 -15
- package/dist/renderables/progressBar.js.map +1 -1
- package/dist/renderables/prompt.js +1 -1
- package/dist/renderables/prompt.js.map +1 -1
- package/dist/renderables/rule.d.ts +1 -1
- package/dist/renderables/rule.d.ts.map +1 -1
- package/dist/renderables/rule.js +5 -10
- package/dist/renderables/rule.js.map +1 -1
- package/dist/renderables/spinner.d.ts +2 -2
- package/dist/renderables/spinner.d.ts.map +1 -1
- package/dist/renderables/spinner.js +6 -11
- package/dist/renderables/spinner.js.map +1 -1
- package/dist/renderables/status.d.ts.map +1 -1
- package/dist/renderables/status.js +5 -11
- package/dist/renderables/status.js.map +1 -1
- package/dist/renderables/table.d.ts +54 -16
- package/dist/renderables/table.d.ts.map +1 -1
- package/dist/renderables/table.js +239 -99
- package/dist/renderables/table.js.map +1 -1
- package/dist/renderables/traceback.d.ts +1 -1
- package/dist/renderables/traceback.d.ts.map +1 -1
- package/dist/renderables/traceback.js +9 -8
- package/dist/renderables/traceback.js.map +1 -1
- package/dist/renderables/tree.d.ts +2 -2
- package/dist/renderables/tree.d.ts.map +1 -1
- package/dist/renderables/tree.js +16 -22
- package/dist/renderables/tree.js.map +1 -1
- package/dist/template-bindings/helpers.d.ts.map +1 -1
- package/dist/template-bindings/helpers.js +19 -1
- package/dist/template-bindings/helpers.js.map +1 -1
- package/package.json +20 -4
- package/dist/renderables/json.d.ts.map +0 -1
- package/dist/renderables/json.js.map +0 -1
- package/dist/renderables/pretty.d.ts +0 -29
- package/dist/renderables/pretty.d.ts.map +0 -1
- package/dist/renderables/pretty.js +0 -141
- package/dist/renderables/pretty.js.map +0 -1
- package/dist/themes/paletteResolver.d.ts +0 -35
- package/dist/themes/paletteResolver.d.ts.map +0 -1
- package/dist/themes/paletteResolver.js +0 -88
- package/dist/themes/paletteResolver.js.map +0 -1
- package/dist/widgets/host-stream.d.ts +0 -31
- package/dist/widgets/host-stream.d.ts.map +0 -1
- package/dist/widgets/host-stream.js +0 -36
- package/dist/widgets/host-stream.js.map +0 -1
- package/dist/widgets/terminal-host.d.ts +0 -138
- package/dist/widgets/terminal-host.d.ts.map +0 -1
- package/dist/widgets/terminal-host.js +0 -127
- 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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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] `
|
|
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 =
|
|
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
|
-
|
|
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
|
|
240
|
+
return base;
|
|
206
241
|
const pos = side === "left" ? 0 : this._text.length - 1;
|
|
207
|
-
let result =
|
|
242
|
+
let result = base;
|
|
208
243
|
for (const span of this._spans) {
|
|
209
244
|
if (span.start <= pos && pos < span.end) {
|
|
210
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
705
|
+
const base = this.resolvedStyle(options);
|
|
706
|
+
const allSegments = this._buildSegments(text, base, options);
|
|
678
707
|
const logicalLines = Segment.splitLines(allSegments);
|
|
679
|
-
// [LAW:
|
|
680
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
for (
|
|
755
|
-
const
|
|
756
|
-
const
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
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
|
|
828
|
+
return styles.map((style, piece) => new Segment(text.slice(boundaries[piece], boundaries[piece + 1]), style.isNull ? undefined : style));
|
|
771
829
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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*
|
|
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 "
|
|
787
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|