@uniflowed/tui 0.0.0-alpha.18
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/capability.js +366 -0
- package/cells.js +332 -0
- package/components.js +602 -0
- package/diff.js +286 -0
- package/index.js +206 -0
- package/internal/hits.js +156 -0
- package/internal/host.js +928 -0
- package/internal/paint.js +664 -0
- package/internal/tree.js +373 -0
- package/keys.js +585 -0
- package/layout.js +833 -0
- package/mouse.js +333 -0
- package/package.json +36 -0
- package/selection.js +101 -0
- package/terminal.js +457 -0
- package/widths.js +201 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Turning a laid-out tree into cells.
|
|
4
|
+
//
|
|
5
|
+
// # Internal to `@uniflowed/tui`
|
|
6
|
+
//
|
|
7
|
+
// Absent from `package.json#exports`, because line breaking has to be the
|
|
8
|
+
// same computation in both passes. Layout asks this module how tall a
|
|
9
|
+
// paragraph is and the painter asks it what is on line three; a consumer who
|
|
10
|
+
// could call `wrapRuns` with a different mode than the node carries would get
|
|
11
|
+
// a frame that disagrees with the layout that made room for it.
|
|
12
|
+
//
|
|
13
|
+
//
|
|
14
|
+
// Layout has already decided where every node is; this decides what is in it.
|
|
15
|
+
// The two are separate passes because a node's size is a question about its
|
|
16
|
+
// content and a node's appearance is a question about its size, and running
|
|
17
|
+
// them together is how a renderer ends up measuring text twice — once to find
|
|
18
|
+
// out how tall the box is, and again to draw it.
|
|
19
|
+
//
|
|
20
|
+
// # Line breaking lives here, not in layout
|
|
21
|
+
//
|
|
22
|
+
// Wrapping is the one thing both passes need: layout asks "how tall is this
|
|
23
|
+
// paragraph at 40 columns" and paint asks "what is on line three". They are
|
|
24
|
+
// the same computation with two different questions asked of the answer, so
|
|
25
|
+
// it is written once, here, and layout reaches it through the `measure`
|
|
26
|
+
// callback a text node carries.
|
|
27
|
+
//
|
|
28
|
+
// # Painting is destructive, and that is the point
|
|
29
|
+
//
|
|
30
|
+
// A cell holds one grapheme, so drawing a box's background over the text
|
|
31
|
+
// underneath it *erases* that text. This is what a terminal does and what a
|
|
32
|
+
// caller means by a background — but it is worth stating, because the obvious
|
|
33
|
+
// alternative (compositing, with transparency) is what a browser does, and a
|
|
34
|
+
// reader coming from CSS will assume it. A cell has no alpha channel. The
|
|
35
|
+
// last writer wins.
|
|
36
|
+
|
|
37
|
+
import type { BorderStyle, Capabilities } from "../capability.js";
|
|
38
|
+
import { borderGlyphs } from "../capability.js";
|
|
39
|
+
import type { Frame, Rect, Style } from "../cells.js";
|
|
40
|
+
import {
|
|
41
|
+
Attributes,
|
|
42
|
+
INHERIT,
|
|
43
|
+
PLAIN,
|
|
44
|
+
fillRect,
|
|
45
|
+
intersect,
|
|
46
|
+
parseColor,
|
|
47
|
+
writeGrapheme,
|
|
48
|
+
} from "../cells.js";
|
|
49
|
+
import type { Selection } from "../selection.js";
|
|
50
|
+
import type { HitGrid } from "./hits.js";
|
|
51
|
+
import { recordHit, recordText } from "./hits.js";
|
|
52
|
+
import type { TuiNode } from "./tree.js";
|
|
53
|
+
import { ROOT_TEXT_STYLE, borderOf, textRuns, textStyleFromProps } from "./tree.js";
|
|
54
|
+
import type { Grapheme } from "../widths.js";
|
|
55
|
+
import { graphemes } from "../widths.js";
|
|
56
|
+
|
|
57
|
+
/** How a run of text breaks when it does not fit. OpenTUI's three modes. */
|
|
58
|
+
export type WrapMode = "word" | "char" | "none";
|
|
59
|
+
|
|
60
|
+
/** One grapheme, its width, and how it is painted. */
|
|
61
|
+
type Cluster = {
|
|
62
|
+
readonly text: string,
|
|
63
|
+
readonly width: number,
|
|
64
|
+
readonly style: Style,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** A laid-out line of clusters, with the columns it occupies. */
|
|
68
|
+
type Line = {
|
|
69
|
+
readonly clusters: Array<Cluster>,
|
|
70
|
+
readonly width: number,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Break styled runs into lines that fit `available` columns.
|
|
75
|
+
*
|
|
76
|
+
* A `\n` always breaks, in every mode: it is the one instruction in the text
|
|
77
|
+
* itself, and a `wrapMode: "none"` that ignored it would turn a three-line
|
|
78
|
+
* message into one line that runs off the screen.
|
|
79
|
+
*
|
|
80
|
+
* `"word"` breaks at the last space that fits and drops that space, which is
|
|
81
|
+
* what makes the next line start at column zero instead of one column in.
|
|
82
|
+
* A word longer than the whole line falls back to breaking mid-word, because
|
|
83
|
+
* the alternative is a line that overflows no matter what, and a URL is a word.
|
|
84
|
+
*/
|
|
85
|
+
export function wrapRuns(
|
|
86
|
+
runs: $ReadOnlyArray<{ readonly text: string, readonly style: Style }>,
|
|
87
|
+
available: number,
|
|
88
|
+
mode: WrapMode,
|
|
89
|
+
): Array<Line> {
|
|
90
|
+
const lines: Array<Line> = [];
|
|
91
|
+
let current: Array<Cluster> = [];
|
|
92
|
+
let width = 0;
|
|
93
|
+
// Where the last space in `current` is, so a word break can rewind to it.
|
|
94
|
+
let lastBreak = -1;
|
|
95
|
+
|
|
96
|
+
const flush = () => {
|
|
97
|
+
lines.push({ clusters: current, width });
|
|
98
|
+
current = [];
|
|
99
|
+
width = 0;
|
|
100
|
+
lastBreak = -1;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (const run of runs) {
|
|
104
|
+
const segments = run.text.split("\n");
|
|
105
|
+
for (let s = 0; s < segments.length; s += 1) {
|
|
106
|
+
if (s > 0) {
|
|
107
|
+
flush();
|
|
108
|
+
}
|
|
109
|
+
for (const grapheme of graphemes(segments[s])) {
|
|
110
|
+
if (mode !== "none" && available > 0 && width + grapheme.width > available) {
|
|
111
|
+
if (mode === "word" && lastBreak >= 0) {
|
|
112
|
+
const tail = current.slice(lastBreak + 1);
|
|
113
|
+
const head = current.slice(0, lastBreak);
|
|
114
|
+
const headWidth = head.reduce((total, cluster) => total + cluster.width, 0);
|
|
115
|
+
lines.push({ clusters: head, width: headWidth });
|
|
116
|
+
current = tail;
|
|
117
|
+
width = tail.reduce((total, cluster) => total + cluster.width, 0);
|
|
118
|
+
lastBreak = -1;
|
|
119
|
+
} else {
|
|
120
|
+
flush();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (grapheme.text === " ") {
|
|
124
|
+
lastBreak = current.length;
|
|
125
|
+
}
|
|
126
|
+
current.push({ text: grapheme.text, width: grapheme.width, style: run.style });
|
|
127
|
+
width += grapheme.width;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
flush();
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The size a text node wants, given the width it may use. */
|
|
136
|
+
export function measureText(
|
|
137
|
+
node: TuiNode,
|
|
138
|
+
available: number,
|
|
139
|
+
mode: WrapMode,
|
|
140
|
+
): { readonly width: number, readonly height: number } {
|
|
141
|
+
const runs = textRuns(node, textStyleFromProps(node.props, ROOT_TEXT_STYLE));
|
|
142
|
+
const lines = wrapRuns(runs, available, mode);
|
|
143
|
+
let width = 0;
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
width = Math.max(width, line.width);
|
|
146
|
+
}
|
|
147
|
+
return { width, height: lines.length };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The wrap mode a text node's props ask for; OpenTUI's default is `"word"`. */
|
|
151
|
+
export function wrapModeOf(node: TuiNode): WrapMode {
|
|
152
|
+
const raw = node.props.wrap ?? node.props.wrapMode;
|
|
153
|
+
return raw === "char" || raw === "none" ? raw : "word";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Draw a whole tree into `frame`.
|
|
158
|
+
*
|
|
159
|
+
* `clip` starts as the frame itself and narrows as the walk descends through
|
|
160
|
+
* boxes that hide their overflow. Nothing is ever written outside it, which is
|
|
161
|
+
* both how `overflow: "hidden"` is implemented and how a child that layout
|
|
162
|
+
* placed off the bottom of an 80×24 terminal fails to corrupt the frame.
|
|
163
|
+
*
|
|
164
|
+
* `hits` is the grid the mouse is routed with, or `null` for a renderer that
|
|
165
|
+
* has no mouse. It is filled here rather than by a second walk because the
|
|
166
|
+
* question it answers — which node was allowed to draw this cell — is the
|
|
167
|
+
* question `clip` is already the answer to; see `hits.js`.
|
|
168
|
+
*
|
|
169
|
+
* `selectable` descends the same way `clip` does, and for the same reason it
|
|
170
|
+
* is a parameter rather than something read back off a node: whether a cell
|
|
171
|
+
* may be selected is a fact about the whole chain above it, and walking up
|
|
172
|
+
* from each `<Text>` to find out would ask the same question of the same
|
|
173
|
+
* ancestors once per leaf. It starts `true`, which is OpenTUI's default for
|
|
174
|
+
* text, and a `selectable={false}` anywhere on the way down turns it off for
|
|
175
|
+
* everything under that node.
|
|
176
|
+
*/
|
|
177
|
+
export function paint(
|
|
178
|
+
node: TuiNode,
|
|
179
|
+
frame: Frame,
|
|
180
|
+
capabilities: Capabilities,
|
|
181
|
+
clip: Rect,
|
|
182
|
+
hits: HitGrid | null = null,
|
|
183
|
+
selectable: boolean = true,
|
|
184
|
+
): void {
|
|
185
|
+
// `hideInstance` — React's for a Suspense fallback and for `<Activity>` —
|
|
186
|
+
// sets `width: 0, height: 0, hidden: true`. The zero size is not enough on
|
|
187
|
+
// its own: `overflow` defaults to `"visible"`, so a child laid out inside a
|
|
188
|
+
// 0x0 box still draws over its edge, and the walk would also record hits for
|
|
189
|
+
// a subtree the reader cannot see. The flag is the thing that says the
|
|
190
|
+
// subtree is not here; read it before anything is drawn.
|
|
191
|
+
if (node.props.hidden === true) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const inherited = selectableOf(node, selectable);
|
|
195
|
+
switch (node.type) {
|
|
196
|
+
case "root":
|
|
197
|
+
for (const child of node.children) {
|
|
198
|
+
paint(child, frame, capabilities, clip, hits, inherited);
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
case "box":
|
|
202
|
+
paintBox(node, frame, capabilities, clip, hits, inherited);
|
|
203
|
+
return;
|
|
204
|
+
case "text":
|
|
205
|
+
paintText(node, frame, clip, hits, inherited);
|
|
206
|
+
return;
|
|
207
|
+
default:
|
|
208
|
+
// A `"chars"` node is only ever reached through its `"text"` parent,
|
|
209
|
+
// which paints it as part of a wrapped line. One outside a `<Text>` has
|
|
210
|
+
// no style, no wrap mode and no line to belong to, so it draws nothing —
|
|
211
|
+
// deliberately, rather than by omission.
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether text under `node` may be selected.
|
|
218
|
+
*
|
|
219
|
+
* A boolean prop wins over what was inherited; anything else — including the
|
|
220
|
+
* prop being absent — leaves the answer where its ancestors put it. Only the
|
|
221
|
+
* direct prop is read, and not `style.selectable`: `style` is where OpenTUI
|
|
222
|
+
* puts the things that *paint* a node, and whether a reader may copy a line
|
|
223
|
+
* out of it is not one of them.
|
|
224
|
+
*/
|
|
225
|
+
function selectableOf(node: TuiNode, inherited: boolean): boolean {
|
|
226
|
+
const own = node.props.selectable;
|
|
227
|
+
return typeof own === "boolean" ? own : inherited;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function paintBox(
|
|
231
|
+
node: TuiNode,
|
|
232
|
+
frame: Frame,
|
|
233
|
+
capabilities: Capabilities,
|
|
234
|
+
clip: Rect,
|
|
235
|
+
hits: HitGrid | null,
|
|
236
|
+
selectable: boolean,
|
|
237
|
+
): void {
|
|
238
|
+
const area = { x: node.x, y: node.y, width: node.width, height: node.height };
|
|
239
|
+
// Before the children, so that a child overwrites its parent — a click on a
|
|
240
|
+
// button inside a panel is a click on the button. A box claims its whole
|
|
241
|
+
// rectangle whether or not it painted anything into it: a box is a region,
|
|
242
|
+
// and one without a background is still the thing a reader is pointing at.
|
|
243
|
+
if (hits != null) {
|
|
244
|
+
recordHit(hits, node, area, clip);
|
|
245
|
+
}
|
|
246
|
+
const background = parseColor(readColor(node.props, ["backgroundColor", "bg"]));
|
|
247
|
+
const style = textStyleFromProps(node.props, PLAIN);
|
|
248
|
+
if (background !== INHERIT) {
|
|
249
|
+
fillRect(frame, area, { fg: style.fg, bg: background, attributes: 0 }, clip);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const border = borderOf(node.props);
|
|
253
|
+
if (border != null && node.width >= 2 && node.height >= 1) {
|
|
254
|
+
paintBorder(node, frame, capabilities, clip, border, background);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// `overflow: "hidden"` clips children to what is inside the border and
|
|
258
|
+
// padding. `"visible"` — the default — lets them draw over the border,
|
|
259
|
+
// which is how a badge sits on a box's top edge. `"scroll"` clips like
|
|
260
|
+
// `"hidden"`: a row half in the window has to be half drawn.
|
|
261
|
+
const clipped = node.style.overflow === "hidden" || node.style.overflow === "scroll";
|
|
262
|
+
const childClip = clipped
|
|
263
|
+
? intersect(clip, {
|
|
264
|
+
x: node.x + node.borderWidth,
|
|
265
|
+
y: node.y + node.borderWidth,
|
|
266
|
+
width: Math.max(0, node.width - node.borderWidth * 2),
|
|
267
|
+
height: Math.max(0, node.height - node.borderWidth * 2),
|
|
268
|
+
})
|
|
269
|
+
: clip;
|
|
270
|
+
|
|
271
|
+
if (node.style.overflow === "scroll") {
|
|
272
|
+
// The children a scrolling box laid out, and only those. The rest were
|
|
273
|
+
// never given a position this frame, so their geometry is from whichever
|
|
274
|
+
// frame last showed them and drawing it would put those rows back on the
|
|
275
|
+
// screen. Reading the range rather than a flag per child is also what
|
|
276
|
+
// keeps the walk proportional to the window: a box holding ten thousand
|
|
277
|
+
// rows is not visited ten thousand times to be told nine thousand nine
|
|
278
|
+
// hundred and seventy-six of them are elsewhere.
|
|
279
|
+
const end = node.scrollFirst + node.scrollCount;
|
|
280
|
+
for (let index = node.scrollFirst; index < end; index += 1) {
|
|
281
|
+
paint(node.children[index], frame, capabilities, childClip, hits, selectable);
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
for (const child of node.children) {
|
|
285
|
+
paint(child, frame, capabilities, childClip, hits, selectable);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (node.style.overflow === "scroll" && node.props.scrollbar === true) {
|
|
290
|
+
// `childClip` rather than `clip`: the bar belongs to this box and must be
|
|
291
|
+
// cut by the same rectangle its rows are.
|
|
292
|
+
paintScrollbar(node, frame, capabilities, childClip, style, hits);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The bar down the right-hand edge of a scrolling box.
|
|
298
|
+
*
|
|
299
|
+
* It goes in the column `ScrollBox` reserved for it by adding one to the box's
|
|
300
|
+
* right padding, which is why wrapped content never reaches it — and why
|
|
301
|
+
* turning the bar off gives that column back to the content instead of leaving
|
|
302
|
+
* a gap. Text with `wrap="none"` can still run into the column, since padding
|
|
303
|
+
* is not a clip anywhere in this renderer; the bar is drawn after the children
|
|
304
|
+
* and wins.
|
|
305
|
+
*
|
|
306
|
+
* Nothing is drawn when everything fits. The bar is only reached when there is
|
|
307
|
+
* more content than window, and a thumb is then always at least one row and
|
|
308
|
+
* never the whole bar — a full-height thumb would say "all of it is showing",
|
|
309
|
+
* which is the one thing that is not true here.
|
|
310
|
+
*/
|
|
311
|
+
function paintScrollbar(
|
|
312
|
+
node: TuiNode,
|
|
313
|
+
frame: Frame,
|
|
314
|
+
capabilities: Capabilities,
|
|
315
|
+
clip: Rect,
|
|
316
|
+
style: Style,
|
|
317
|
+
hits: HitGrid | null,
|
|
318
|
+
): void {
|
|
319
|
+
const top = node.scrollViewTop;
|
|
320
|
+
const viewport = node.scrollViewRows;
|
|
321
|
+
const column = node.scrollBarColumn;
|
|
322
|
+
if (viewport <= 0 || node.scrollHeight <= viewport) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const ascii = capabilities.glyphs === "ascii";
|
|
327
|
+
const trackGlyph = ascii ? "|" : "│";
|
|
328
|
+
const thumbGlyph = ascii ? "#" : "█";
|
|
329
|
+
const trackStyle: Style = {
|
|
330
|
+
fg: parseColor(readColor(node.props, ["scrollbarColor", "borderColor"])),
|
|
331
|
+
bg: style.bg,
|
|
332
|
+
attributes: 0,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const thumb = Math.max(1, Math.round((viewport / node.scrollHeight) * viewport));
|
|
336
|
+
const travel = viewport - thumb;
|
|
337
|
+
const scrolled = node.scrollHeight - viewport;
|
|
338
|
+
const start = scrolled === 0 ? 0 : Math.round((node.scrollOffset / scrolled) * travel);
|
|
339
|
+
|
|
340
|
+
for (let row = 0; row < viewport; row += 1) {
|
|
341
|
+
const glyph = row >= start && row < start + thumb ? thumbGlyph : trackGlyph;
|
|
342
|
+
writeGrapheme(frame, column, top + row, glyph, 1, trackStyle, clip);
|
|
343
|
+
// The bar is drawn after the children, so a line with `wrap="none"` that
|
|
344
|
+
// ran into this column has already claimed it. It is not that line any
|
|
345
|
+
// more, and a selection dragged over the bar must not copy one.
|
|
346
|
+
if (hits != null) {
|
|
347
|
+
recordText(hits, null, column, top + row, 1, clip);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function paintBorder(
|
|
353
|
+
node: TuiNode,
|
|
354
|
+
frame: Frame,
|
|
355
|
+
capabilities: Capabilities,
|
|
356
|
+
clip: Rect,
|
|
357
|
+
border: BorderStyle,
|
|
358
|
+
background: number,
|
|
359
|
+
): void {
|
|
360
|
+
const glyphs = borderGlyphs(border, capabilities.glyphs);
|
|
361
|
+
const color = parseColor(readColor(node.props, ["borderColor"]));
|
|
362
|
+
const style: Style = { fg: color, bg: background, attributes: 0 };
|
|
363
|
+
const { x, y, width, height } = node;
|
|
364
|
+
const right = x + width - 1;
|
|
365
|
+
const bottom = y + height - 1;
|
|
366
|
+
|
|
367
|
+
for (let column = x + 1; column < right; column += 1) {
|
|
368
|
+
writeGrapheme(frame, column, y, glyphs.top, 1, style, clip);
|
|
369
|
+
if (height > 1) {
|
|
370
|
+
writeGrapheme(frame, column, bottom, glyphs.bottom, 1, style, clip);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
for (let row = y + 1; row < bottom; row += 1) {
|
|
374
|
+
writeGrapheme(frame, x, row, glyphs.left, 1, style, clip);
|
|
375
|
+
writeGrapheme(frame, right, row, glyphs.right, 1, style, clip);
|
|
376
|
+
}
|
|
377
|
+
writeGrapheme(frame, x, y, glyphs.topLeft, 1, style, clip);
|
|
378
|
+
writeGrapheme(frame, right, y, glyphs.topRight, 1, style, clip);
|
|
379
|
+
if (height > 1) {
|
|
380
|
+
writeGrapheme(frame, x, bottom, glyphs.bottomLeft, 1, style, clip);
|
|
381
|
+
writeGrapheme(frame, right, bottom, glyphs.bottomRight, 1, style, clip);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
paintTitle(node, frame, clip, style, "title", "titleAlignment", y);
|
|
385
|
+
if (height > 1) {
|
|
386
|
+
paintTitle(node, frame, clip, style, "bottomTitle", "bottomTitleAlignment", bottom);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Write a title into a border row.
|
|
392
|
+
*
|
|
393
|
+
* Titles are never wrapped and never widen a box: a title longer than the
|
|
394
|
+
* edge it sits on is truncated, because the alternative is a box whose size
|
|
395
|
+
* depends on a string somebody typed. The available run is the edge minus its
|
|
396
|
+
* two corners.
|
|
397
|
+
*/
|
|
398
|
+
function paintTitle(
|
|
399
|
+
node: TuiNode,
|
|
400
|
+
frame: Frame,
|
|
401
|
+
clip: Rect,
|
|
402
|
+
style: Style,
|
|
403
|
+
titleProp: string,
|
|
404
|
+
alignProp: string,
|
|
405
|
+
row: number,
|
|
406
|
+
): void {
|
|
407
|
+
const raw = node.props[titleProp];
|
|
408
|
+
if (typeof raw !== "string" || raw === "") {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const titleStyle: Style = {
|
|
412
|
+
fg:
|
|
413
|
+
parseColor(readColor(node.props, ["titleColor"])) === INHERIT
|
|
414
|
+
? style.fg
|
|
415
|
+
: parseColor(readColor(node.props, ["titleColor"])),
|
|
416
|
+
bg: style.bg,
|
|
417
|
+
attributes: style.attributes,
|
|
418
|
+
};
|
|
419
|
+
const available = Math.max(0, node.width - 2);
|
|
420
|
+
const clusters: Array<Grapheme> = [];
|
|
421
|
+
let used = 0;
|
|
422
|
+
for (const grapheme of graphemes(raw)) {
|
|
423
|
+
if (used + grapheme.width > available) {
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
clusters.push(grapheme);
|
|
427
|
+
used += grapheme.width;
|
|
428
|
+
}
|
|
429
|
+
const alignment = node.props[alignProp];
|
|
430
|
+
let start = node.x + 1;
|
|
431
|
+
if (alignment === "center") {
|
|
432
|
+
start += Math.floor((available - used) / 2);
|
|
433
|
+
} else if (alignment === "right") {
|
|
434
|
+
start += available - used;
|
|
435
|
+
}
|
|
436
|
+
let column = start;
|
|
437
|
+
for (const grapheme of clusters) {
|
|
438
|
+
writeGrapheme(frame, column, row, grapheme.text, grapheme.width, titleStyle, clip);
|
|
439
|
+
column += grapheme.width;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function paintText(
|
|
444
|
+
node: TuiNode,
|
|
445
|
+
frame: Frame,
|
|
446
|
+
clip: Rect,
|
|
447
|
+
hits: HitGrid | null,
|
|
448
|
+
selectable: boolean,
|
|
449
|
+
): void {
|
|
450
|
+
const own = textStyleFromProps(node.props, ROOT_TEXT_STYLE);
|
|
451
|
+
const runs = textRuns(node, own);
|
|
452
|
+
const lines = wrapRuns(runs, node.width, wrapModeOf(node));
|
|
453
|
+
// The outermost `<Text>` of a nest is the one recorded, because it is the
|
|
454
|
+
// one that paints: `textRuns` has already flattened its children into runs,
|
|
455
|
+
// so a `<Text bold>` inside it never reaches the frame under its own name.
|
|
456
|
+
// That is the right owner anyway — `selectionBg` is inherited like every
|
|
457
|
+
// other text style, and a nested run has no separate existence to select.
|
|
458
|
+
for (let index = 0; index < lines.length && index < node.height; index += 1) {
|
|
459
|
+
let column = node.x;
|
|
460
|
+
for (const cluster of lines[index].clusters) {
|
|
461
|
+
writeGrapheme(
|
|
462
|
+
frame,
|
|
463
|
+
column,
|
|
464
|
+
node.y + index,
|
|
465
|
+
cluster.text,
|
|
466
|
+
cluster.width,
|
|
467
|
+
cluster.style,
|
|
468
|
+
clip,
|
|
469
|
+
);
|
|
470
|
+
if (hits != null && selectable) {
|
|
471
|
+
recordText(hits, node, column, node.y + index, cluster.width, clip);
|
|
472
|
+
}
|
|
473
|
+
column += cluster.width;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* One row of a selection: the cells of it a reader would read across.
|
|
480
|
+
*
|
|
481
|
+
* `from` is after `to` for a row the selection covers but that holds no
|
|
482
|
+
* selectable text — a gap between two paragraphs, the padding of a box, the
|
|
483
|
+
* blank half of a half-filled screen. Those rows are still rows of the
|
|
484
|
+
* selection, which is why they are reported rather than dropped: the text
|
|
485
|
+
* copied out of a selection has a line for each of them, the same way dragging
|
|
486
|
+
* across a blank line in a terminal gives you the blank line.
|
|
487
|
+
*/
|
|
488
|
+
type SelectedRow = {
|
|
489
|
+
readonly y: number,
|
|
490
|
+
readonly from: number,
|
|
491
|
+
readonly to: number,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Which cells of each row a selection covers.
|
|
496
|
+
*
|
|
497
|
+
* A row's span runs from its first selectable cell to its last, and *includes
|
|
498
|
+
* whatever is between them*, selectable or not. That is the one rule this
|
|
499
|
+
* module applies twice — once to draw the highlight and once to read the text
|
|
500
|
+
* back out — and it exists because of what the alternative does to a layout.
|
|
501
|
+
* Two `<Text>`s in a row with a gap between them are `left` and `right` on the
|
|
502
|
+
* screen; taking only the cells they own would copy `leftright`, and drawing
|
|
503
|
+
* the highlight only over them would leave a hole in the middle of a selection
|
|
504
|
+
* a reader dragged straight through. Cells before the first and after the last
|
|
505
|
+
* are not part of it: trailing blanks are the shape of the box, not something
|
|
506
|
+
* anyone selected.
|
|
507
|
+
*
|
|
508
|
+
* Spans are snapped outwards onto whole graphemes. A selection that begins on
|
|
509
|
+
* the right-hand cell of a two-column character would otherwise style half of
|
|
510
|
+
* it, and the two halves would then differ in a comparison the diff makes per
|
|
511
|
+
* cell — which is a repaint of a character nobody selected.
|
|
512
|
+
*/
|
|
513
|
+
function selectedRows(frame: Frame, grid: HitGrid, selection: Selection): Array<SelectedRow> {
|
|
514
|
+
const rows: Array<SelectedRow> = [];
|
|
515
|
+
const top = Math.max(0, selection.start.y);
|
|
516
|
+
const bottom = Math.min(frame.height - 1, selection.end.y);
|
|
517
|
+
for (let y = top; y <= bottom; y += 1) {
|
|
518
|
+
const base = y * frame.width;
|
|
519
|
+
const last = frame.width - 1;
|
|
520
|
+
const left = y === selection.start.y ? Math.max(0, selection.start.x) : 0;
|
|
521
|
+
const right = y === selection.end.y ? Math.min(last, selection.end.x) : last;
|
|
522
|
+
let from = -1;
|
|
523
|
+
let to = -2;
|
|
524
|
+
for (let x = left; x <= right; x += 1) {
|
|
525
|
+
if (grid.text[base + x] != null) {
|
|
526
|
+
if (from < 0) {
|
|
527
|
+
from = x;
|
|
528
|
+
}
|
|
529
|
+
to = x;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (from >= 0) {
|
|
533
|
+
while (from > 0 && frame.chars[base + from] === "") {
|
|
534
|
+
from -= 1;
|
|
535
|
+
}
|
|
536
|
+
while (to + 1 < frame.width && frame.chars[base + to + 1] === "") {
|
|
537
|
+
to += 1;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
rows.push({ y, from, to });
|
|
541
|
+
}
|
|
542
|
+
return rows;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Show a selection in a frame that has already been painted.
|
|
547
|
+
*
|
|
548
|
+
* A pass over the selected rows rather than something the walk above knows
|
|
549
|
+
* about, and that is the whole reason it is cheap and the reason it is
|
|
550
|
+
* correct. A selection is two cells of the *frame*; the tree does not have it
|
|
551
|
+
* and could not apply it without every node asking whether each of its cells
|
|
552
|
+
* is selected. Here the answer is already on the screen.
|
|
553
|
+
*
|
|
554
|
+
* The default is inverse video, toggled rather than set. A terminal with no
|
|
555
|
+
* colour at all still has it — this is `SGR 7`, not a palette entry — and
|
|
556
|
+
* toggling is what makes a selection dragged over something already inverse,
|
|
557
|
+
* such as the cell an `Input` draws its cursor in, show as a hole in the
|
|
558
|
+
* highlight instead of vanishing into it. A `selectionBg` or `selectionFg` on
|
|
559
|
+
* the text, or on anything above it, replaces that with the colours it names
|
|
560
|
+
* and leaves the attributes alone: an application that has said how a
|
|
561
|
+
* selection looks has said it.
|
|
562
|
+
*/
|
|
563
|
+
export function paintSelection(frame: Frame, grid: HitGrid, selection: Selection): void {
|
|
564
|
+
for (const row of selectedRows(frame, grid, selection)) {
|
|
565
|
+
if (row.from > row.to) {
|
|
566
|
+
// A row of the selection with no selectable text on it. It is a line in
|
|
567
|
+
// what gets copied and nothing at all in what gets drawn.
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
const base = row.y * frame.width;
|
|
571
|
+
let owner = grid.text[base + row.from];
|
|
572
|
+
let style = selectionStyleOf(owner);
|
|
573
|
+
for (let x = row.from; x <= row.to; x += 1) {
|
|
574
|
+
const at = grid.text[base + x];
|
|
575
|
+
if (at != null && at !== owner) {
|
|
576
|
+
owner = at;
|
|
577
|
+
style = selectionStyleOf(owner);
|
|
578
|
+
}
|
|
579
|
+
const index = base + x;
|
|
580
|
+
if (style.fg === INHERIT && style.bg === INHERIT) {
|
|
581
|
+
frame.attributes[index] ^= Attributes.INVERSE;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (style.fg !== INHERIT) {
|
|
585
|
+
frame.fg[index] = style.fg;
|
|
586
|
+
}
|
|
587
|
+
if (style.bg !== INHERIT) {
|
|
588
|
+
frame.bg[index] = style.bg;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The colours a node's selection is drawn in, or `INHERIT` for both.
|
|
596
|
+
*
|
|
597
|
+
* `selectionFg` and `selectionBg` inherit the way `fg` and `bg` do, and
|
|
598
|
+
* independently of each other, so one prop on a panel covers everything inside
|
|
599
|
+
* it. They are resolved by walking *up* from the node that owns the cell,
|
|
600
|
+
* rather than threaded down the paint the way `selectable` is, because the two
|
|
601
|
+
* are asked about at different times: `selectable` decides whether a cell is
|
|
602
|
+
* recorded at all and so is needed for every cell of every frame, while these
|
|
603
|
+
* are needed only for the handful of cells a selection covers — and only when
|
|
604
|
+
* there is one. A walk bounded by the depth of a terminal's tree, taken once
|
|
605
|
+
* per run of one owner, is cheaper than a lookup nothing usually reads.
|
|
606
|
+
*/
|
|
607
|
+
function selectionStyleOf(node: TuiNode | null): { fg: number, bg: number } {
|
|
608
|
+
let fg = INHERIT;
|
|
609
|
+
let bg = INHERIT;
|
|
610
|
+
let current = node;
|
|
611
|
+
while (current != null && (fg === INHERIT || bg === INHERIT)) {
|
|
612
|
+
if (fg === INHERIT) {
|
|
613
|
+
fg = parseColor(readColor(current.props, ["selectionFg"]));
|
|
614
|
+
}
|
|
615
|
+
if (bg === INHERIT) {
|
|
616
|
+
bg = parseColor(readColor(current.props, ["selectionBg"]));
|
|
617
|
+
}
|
|
618
|
+
current = current.parent;
|
|
619
|
+
}
|
|
620
|
+
return { fg, bg };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The text a selection covers, as a reader would copy it.
|
|
625
|
+
*
|
|
626
|
+
* Read out of the frame rather than out of the tree, which is what makes it
|
|
627
|
+
* agree with the highlight down to the cell: a wide grapheme contributes its
|
|
628
|
+
* cluster once and its continuation cell contributes the empty string, a line
|
|
629
|
+
* that was wrapped comes back wrapped, and a row that was clipped comes back
|
|
630
|
+
* clipped. Rows are joined top to bottom with `\n`, which is OpenTUI's
|
|
631
|
+
* documented order and the only thing a clipboard can do with two rows.
|
|
632
|
+
*/
|
|
633
|
+
export function selectionText(frame: Frame, grid: HitGrid, selection: Selection): string {
|
|
634
|
+
const lines: Array<string> = [];
|
|
635
|
+
for (const row of selectedRows(frame, grid, selection)) {
|
|
636
|
+
const base = row.y * frame.width;
|
|
637
|
+
let line = "";
|
|
638
|
+
for (let x = row.from; x <= row.to; x += 1) {
|
|
639
|
+
line += frame.chars[base + x];
|
|
640
|
+
}
|
|
641
|
+
lines.push(line);
|
|
642
|
+
}
|
|
643
|
+
return lines.join("\n");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function readColor(
|
|
647
|
+
props: { readonly [string]: mixed },
|
|
648
|
+
names: $ReadOnlyArray<string>,
|
|
649
|
+
): string | number | void {
|
|
650
|
+
for (const name of names) {
|
|
651
|
+
const direct = props[name];
|
|
652
|
+
if (typeof direct === "string" || typeof direct === "number") {
|
|
653
|
+
return direct;
|
|
654
|
+
}
|
|
655
|
+
const style = props.style;
|
|
656
|
+
if (style != null && typeof style === "object") {
|
|
657
|
+
const nested = style[name];
|
|
658
|
+
if (typeof nested === "string" || typeof nested === "number") {
|
|
659
|
+
return nested;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|