@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.
@@ -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
+ }