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