@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/diff.js ADDED
@@ -0,0 +1,286 @@
1
+ // @flow
2
+ //
3
+ // The difference between two frames, as the bytes that turn one into the other.
4
+ //
5
+ // This is the module the package exists for. Everything else — the tree, the
6
+ // layout, the painter — could be replaced with something else and the result
7
+ // would still be a terminal UI; without this it would be a terminal UI that
8
+ // reprints the screen on every keystroke, which is what makes a slow one feel
9
+ // slow. The bottleneck in a terminal program is not laying out a hundred
10
+ // boxes. It is the bytes handed to a pseudo-terminal, which are copied,
11
+ // parsed, and re-rendered by an emulator that is drawing them with a GPU on
12
+ // the other side of a pipe.
13
+ //
14
+ // So the measurement that matters is bytes per frame, and the mechanism is
15
+ // this: compare the two frames cell by cell, and emit only the cells that
16
+ // differ. Changing one character of a status line in an 80×24 terminal costs
17
+ // this renderer about a dozen bytes. A renderer that diffs *lines* — which is
18
+ // what React Ink does, and what a naive implementation does — reprints from
19
+ // the first changed line to the bottom of the frame, and the same edit costs
20
+ // it however much of the screen is below it. `tui.test.js` measures both
21
+ // numbers rather than asserting the shape of the claim.
22
+ //
23
+ // # Two cursor moves cost more than four spaces
24
+ //
25
+ // A cursor move is `ESC [ row ; col H`, which is six to nine bytes. Two runs
26
+ // of changed cells separated by three unchanged ones are therefore cheaper to
27
+ // write as one run — paying for three characters that did not need writing —
28
+ // than as two runs with a jump between them. `JOIN_GAP` is where that trade
29
+ // turns over. It was not there in the first version, and a progress bar whose
30
+ // filled cells alternate with empty ones produced one cursor move per cell.
31
+ //
32
+ // # Colour is emitted only when it changes
33
+ //
34
+ // The SGR sequence for a truecolour foreground is nineteen bytes, which is
35
+ // longer than most of the runs it introduces. Tracking the terminal's current
36
+ // style across the whole frame and re-emitting only on a change turns a
37
+ // coloured row from nineteen bytes per cell into nineteen bytes per *run of
38
+ // one colour*, which for a syntax-highlighted line is an order of magnitude.
39
+
40
+ import type { Capabilities, ColorLevel } from "./capability.js";
41
+ import type { Color, Frame, Style } from "./cells.js";
42
+ import { Attributes, INHERIT, PLAIN, sameSize } from "./cells.js";
43
+
44
+ /**
45
+ * How many unchanged cells are worth writing through rather than jumping over.
46
+ *
47
+ * Four: a cursor move is at least six bytes and a plain unchanged cell is one,
48
+ * so the break-even is around six — but the cells being written through also
49
+ * have to carry their style, so the real figure is lower. Four is measured on
50
+ * the frames this package's own tests produce rather than derived.
51
+ */
52
+ const JOIN_GAP = 4;
53
+
54
+ /** What one diff produced, and what it cost. */
55
+ export type Update = {
56
+ /** The bytes to write to the terminal. Empty when nothing changed. */
57
+ readonly output: string,
58
+ /** How many cells were re-sent. The number the performance claim is about. */
59
+ readonly cells: number,
60
+ };
61
+
62
+ /**
63
+ * Move the cursor to a zero-based cell.
64
+ *
65
+ * Terminals count rows and columns from one, and every off-by-one bug in a
66
+ * renderer's first week is this one. The escape is written as `\u001b` rather
67
+ * than as a literal control byte because a literal one is invisible in every
68
+ * editor and diff, which is how one gets deleted.
69
+ */
70
+ const moveTo = (x: number, y: number): string => `\u001b[${y + 1};${x + 1}H`;
71
+
72
+ /** Reset every attribute and colour. */
73
+ const RESET = "\u001b[0m";
74
+
75
+ /**
76
+ * The bytes that turn `previous` into `next`.
77
+ *
78
+ * A `null` previous frame, or one of a different size, means a full repaint:
79
+ * the terminal was just entered or has just been resized, and there is nothing
80
+ * on it this renderer can claim to know.
81
+ */
82
+ export function diffFrames(
83
+ previous: Frame | null,
84
+ next: Frame,
85
+ capabilities: Capabilities,
86
+ ): Update {
87
+ const full = previous == null || !sameSize(previous, next);
88
+ let output = "";
89
+ let cells = 0;
90
+ let current: Style = PLAIN;
91
+ let styled = false;
92
+
93
+ for (let y = 0; y < next.height; y += 1) {
94
+ let x = 0;
95
+ while (x < next.width) {
96
+ if (!full && !changed(previous, next, y * next.width + x)) {
97
+ x += 1;
98
+ continue;
99
+ }
100
+ // The end of this run: the last changed cell, plus any unchanged cells
101
+ // close enough behind it that jumping over them would cost more.
102
+ let end = x;
103
+ let scan = x;
104
+ while (scan < next.width) {
105
+ if (full || changed(previous, next, y * next.width + scan)) {
106
+ end = scan;
107
+ scan += 1;
108
+ } else {
109
+ let gap = 0;
110
+ while (scan + gap < next.width && !changedOrFull(full, previous, next, y, scan + gap)) {
111
+ gap += 1;
112
+ }
113
+ if (gap > JOIN_GAP || scan + gap >= next.width) {
114
+ break;
115
+ }
116
+ scan += gap;
117
+ }
118
+ }
119
+
120
+ output += moveTo(x, y);
121
+ for (let column = x; column <= end; column += 1) {
122
+ const index = y * next.width + column;
123
+ const character = next.chars[index];
124
+ if (character === "") {
125
+ // A continuation cell. Its cluster was emitted by the cell to its
126
+ // left, and the terminal has already moved the cursor over it.
127
+ continue;
128
+ }
129
+ const style: Style = {
130
+ fg: next.fg[index],
131
+ bg: next.bg[index],
132
+ attributes: next.attributes[index],
133
+ };
134
+ if (!sameStyle(style, current)) {
135
+ const sequence = sgr(style, capabilities.color);
136
+ if (sequence !== "") {
137
+ output += sequence;
138
+ styled = true;
139
+ } else if (styled) {
140
+ output += RESET;
141
+ styled = false;
142
+ }
143
+ current = style;
144
+ }
145
+ output += character;
146
+ cells += 1;
147
+ }
148
+ x = end + 1;
149
+ }
150
+ }
151
+
152
+ if (styled) {
153
+ output += RESET;
154
+ }
155
+ return { output, cells };
156
+ }
157
+
158
+ const changedOrFull = (
159
+ full: boolean,
160
+ previous: Frame | null,
161
+ next: Frame,
162
+ y: number,
163
+ x: number,
164
+ ): boolean => full || changed(previous, next, y * next.width + x);
165
+
166
+ function changed(previous: Frame | null, next: Frame, index: number): boolean {
167
+ if (previous == null) {
168
+ return true;
169
+ }
170
+ return (
171
+ previous.chars[index] !== next.chars[index] ||
172
+ previous.fg[index] !== next.fg[index] ||
173
+ previous.bg[index] !== next.bg[index] ||
174
+ previous.attributes[index] !== next.attributes[index]
175
+ );
176
+ }
177
+
178
+ const sameStyle = (a: Style, b: Style): boolean =>
179
+ a.fg === b.fg && a.bg === b.bg && a.attributes === b.attributes;
180
+
181
+ /**
182
+ * The escape sequence that selects `style`.
183
+ *
184
+ * Empty at `"none"`, and that is the whole of the no-colour answer: a terminal
185
+ * that cannot carry colour also cannot carry bold or underline, because both
186
+ * are the same SGR mechanism and a `TERM=dumb` terminal prints them as
187
+ * literal text. The frame's *shape* still arrives — the layout, the borders in
188
+ * their ASCII vocabulary, the text — which is the part a reader needs.
189
+ */
190
+ export function sgr(style: Style, level: ColorLevel): string {
191
+ if (level === "none") {
192
+ return "";
193
+ }
194
+ const parts: Array<string> = ["0"];
195
+ const attributes = style.attributes;
196
+ if (attributes & Attributes.BOLD) parts.push("1");
197
+ if (attributes & Attributes.DIM) parts.push("2");
198
+ if (attributes & Attributes.ITALIC) parts.push("3");
199
+ if (attributes & Attributes.UNDERLINE) parts.push("4");
200
+ if (attributes & Attributes.BLINK) parts.push("5");
201
+ if (attributes & Attributes.INVERSE) parts.push("7");
202
+ if (attributes & Attributes.STRIKETHROUGH) parts.push("9");
203
+ if (style.fg !== INHERIT) {
204
+ parts.push(colorParts(style.fg, level, false));
205
+ }
206
+ if (style.bg !== INHERIT) {
207
+ parts.push(colorParts(style.bg, level, true));
208
+ }
209
+ if (parts.length === 1) {
210
+ return "";
211
+ }
212
+ return `\u001b[${parts.join(";")}m`;
213
+ }
214
+
215
+ /**
216
+ * One colour, at the depth this terminal has.
217
+ *
218
+ * The downgrade is deliberate rather than a fallback to "no colour at all". A
219
+ * theme written in 24-bit values still has to mean something on a
220
+ * sixteen-colour terminal, and the nearest colour in the smaller palette is a
221
+ * far better answer than none — this is the same ladder
222
+ * `crates/uf_term/src/style.rs` walks for the CLI's own output.
223
+ */
224
+ function colorParts(color: Color, level: ColorLevel, background: boolean): string {
225
+ const r = (color >> 16) & 0xff;
226
+ const g = (color >> 8) & 0xff;
227
+ const b = color & 0xff;
228
+ if (level === "truecolor") {
229
+ return `${background ? 48 : 38};2;${r};${g};${b}`;
230
+ }
231
+ if (level === "ansi256") {
232
+ return `${background ? 48 : 38};5;${cube(r, g, b)}`;
233
+ }
234
+ const base = nearestBasic(r, g, b);
235
+ const offset = background ? 40 : 30;
236
+ return base < 8 ? `${offset + base}` : `${offset + 60 + (base - 8)}`;
237
+ }
238
+
239
+ /** The 256-colour index nearest to an RGB triple. */
240
+ function cube(r: number, g: number, b: number): number {
241
+ // The 24-step grey ramp is a better match than the colour cube whenever the
242
+ // three channels are close, and grey text is common enough — every dimmed
243
+ // hint in every CLI — that getting it wrong is visible.
244
+ if (Math.abs(r - g) < 8 && Math.abs(g - b) < 8) {
245
+ if (r < 8) return 16;
246
+ if (r > 248) return 231;
247
+ return 232 + Math.round(((r - 8) / 247) * 24);
248
+ }
249
+ const step = (value: number) => Math.round((value / 255) * 5);
250
+ return 16 + 36 * step(r) + 6 * step(g) + step(b);
251
+ }
252
+
253
+ /** The 16 base colours, as RGB, in SGR order. */
254
+ const BASIC: $ReadOnlyArray<[number, number, number]> = [
255
+ [0, 0, 0],
256
+ [205, 0, 0],
257
+ [0, 205, 0],
258
+ [205, 205, 0],
259
+ [0, 0, 238],
260
+ [205, 0, 205],
261
+ [0, 205, 205],
262
+ [229, 229, 229],
263
+ [127, 127, 127],
264
+ [255, 0, 0],
265
+ [0, 255, 0],
266
+ [255, 255, 0],
267
+ [92, 92, 255],
268
+ [255, 0, 255],
269
+ [0, 255, 255],
270
+ [255, 255, 255],
271
+ ];
272
+
273
+ /** The base colour closest to an RGB triple, by squared distance. */
274
+ function nearestBasic(r: number, g: number, b: number): number {
275
+ let best = 0;
276
+ let bestDistance = Number.MAX_SAFE_INTEGER;
277
+ for (let index = 0; index < BASIC.length; index += 1) {
278
+ const [cr, cg, cb] = BASIC[index];
279
+ const distance = (cr - r) ** 2 + (cg - g) ** 2 + (cb - b) ** 2;
280
+ if (distance < bestDistance) {
281
+ bestDistance = distance;
282
+ best = index;
283
+ }
284
+ }
285
+ return best;
286
+ }
package/index.js ADDED
@@ -0,0 +1,206 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/tui`: a React renderer whose host is a terminal.
4
+ //
5
+ // It follows OpenTUI, which is the terminal-UI library uf's declaration named
6
+ // as its standard: the same component vocabulary, the same flexbox defaults
7
+ // (`flexDirection` starts at `"column"`), the same canonical key names
8
+ // (`"return"`, not `"enter"`), the same rule that focus is a prop rather than
9
+ // a Tab traversal the library performs for you, and the same split between a
10
+ // root that owns a React tree and a renderer that owns a terminal. A component
11
+ // written against OpenTUI's documentation behaves the same way here, which is
12
+ // the only thing "compatible with a standard" can usefully mean.
13
+ //
14
+ // # The decision: this is JavaScript, and the declaration used to say Rust
15
+ //
16
+ // The declaration this package replaced said `engine:
17
+ // "uf-native-open-tui-compatible"` and `renderer: "cell-diff-native"`, which
18
+ // is a promise that the renderer would be Rust with a binding. It is not, and
19
+ // this is the argument, because "faster than Ink" and "no native bindings"
20
+ // genuinely pull in opposite directions and the reason to pick one belongs in
21
+ // the source rather than in a pull request nobody will read again.
22
+ //
23
+ // **Rust was rejected for three reasons, in order of weight.**
24
+ //
25
+ // *First, there is no bridge, and building one is a bigger and different
26
+ // project than this.* Every `nativeRuntimeRequired` in `packages/` is a
27
+ // promise of a JavaScript-to-native boundary that this repository does not
28
+ // have: no Node-API addon, no FFI, no prebuilt platform binaries, no loader.
29
+ // Choosing Rust here would have meant that the first deliverable was that
30
+ // bridge and the second was a renderer, and until both existed
31
+ // `@uniflowed/tui` would still have been a declaration. Replacing one
32
+ // declaration with a differently-worded declaration is exactly the outcome
33
+ // ubugeeei-prod/uf#247 exists to prevent.
34
+ //
35
+ // *Second, the rule that keeps Effect and Validator in Flow applies here for
36
+ // its own reason rather than by its letter.* `ubugeeei-redundancy.md` names
37
+ // Effect, Validator, state, immutable updates, forms, hooks and UI, and does
38
+ // not name the TUI — but the reason it names them is that application-facing
39
+ // libraries get *deployed*, to browsers and edge workers where a Rust binary
40
+ // cannot go. A terminal application is the one case where that argument is
41
+ // weakest: it runs where a terminal is, and a machine with a terminal can run
42
+ // a binary. What survives is the smaller version of the same point. A native
43
+ // dependency means prebuilt binaries for every platform uf supports, a
44
+ // fallback for the ones it does not, and an `npm install` that can fail in a
45
+ // way a Flow package cannot — for a library whose whole job runs at human
46
+ // reading speed.
47
+ //
48
+ // *Third, and most concretely: the thing that makes a terminal UI slow is not
49
+ // JavaScript.* The guide's native-hot-path rule is about "repeated,
50
+ // repository-wide or CPU-intensive work", and says the execution phase decides
51
+ // the boundary. This executes in one process, at one terminal's size, at the
52
+ // rate a person presses keys. An 80×24 terminal is 1,920 cells; a large one is
53
+ // 12,000. Laying out and painting that is microseconds in any language. What
54
+ // costs milliseconds is the bytes handed to the terminal, because the emulator
55
+ // on the other end parses and re-renders them — which is why React Ink,
56
+ // written in JavaScript and using WebAssembly Yoga for the part that is
57
+ // supposedly slow, is slow for a reason neither of those explains: it renders
58
+ // to a *string* and reprints from the first changed line to the bottom of the
59
+ // frame. `diff.js` writes the cells that changed and nothing else, and that is
60
+ // an algorithm, not a language.
61
+ //
62
+ // The honest cost of this decision is written down rather than hidden: this
63
+ // package depends on `react-reconciler`, which React publishes for custom
64
+ // renderers and calls experimental, and which pins itself to a React minor.
65
+ // `internal/host.js` says what that means for a React upgrade.
66
+ //
67
+ // # What is here, and what is not
68
+ //
69
+ // Implemented, tested, and true: a component tree, flexbox layout in whole
70
+ // cells, a cell buffer with correct wide-grapheme handling, a diff that emits
71
+ // only changed cells, keyboard input with OpenTUI's key names and propagation
72
+ // rules, bracketed paste, declarative focus, mouse input — press, release,
73
+ // hover, drag with capture, drop and wheel, routed by a hit grid the painter
74
+ // records — text selection by drag, one per renderer, read back with
75
+ // `getSelectedText()`, a scrolling window onto content taller than it,
76
+ // terminal capability and *size* detection that agrees with the CLI's, and an
77
+ // in-memory renderer that runs the same code the terminal one does.
78
+ //
79
+ // Not here: key *release* (which needs the Kitty keyboard protocol), the
80
+ // repeated-press gestures that widen a selection to a word or a line, images,
81
+ // the rich content components, and everything under OpenTUI's "application
82
+ // APIs" — including the clipboard, which is why `getSelectedText()` hands a
83
+ // string back rather than putting it somewhere. They are
84
+ // ubugeeei-prod/uf#314, and they are absent rather than present as functions
85
+ // that throw — because a stub is what this package used to be.
86
+ //
87
+ // Also not here, and worth saying because ubugeeei-prod/uf#247 asked for it:
88
+ // uf's own CLI does not draw through this. It cannot — `crates/uf_term` is
89
+ // Rust, this is JavaScript, and there is no way to run a Flow program in this
90
+ // repository outside `uf test`, `uf dev` and `uf build`. ubugeeei-prod/uf#316
91
+ // is that gap, what would close it, and why the two renderers are each right
92
+ // for their own caller in the meantime. `tools/bench/tui/startup.js` now
93
+ // measures the number that issue says has to exist first: a Flow entry point
94
+ // that draws one frame costs 149 ms with a warm transform cache and 383 ms
95
+ // with a cold one, against 8 ms for the whole of `uf info` and 58 ms for
96
+ // `node -e 0`. A banner cannot be written this way; a session that already
97
+ // starts Node and then runs for minutes can.
98
+ //
99
+ // What the two renderers must not do is silently disagree about the terminal,
100
+ // and there are three guards rather than a promise. `capability.js` reproduces
101
+ // `crates/uf_term/src/capability.rs`'s colour precedence and its size
102
+ // precedence, `widths.js` holds the same Unicode tables as
103
+ // `crates/uf_term/src/text/tables.rs`, and `tests/library/tui.test.js` reads
104
+ // both Rust files and fails when either side is edited alone.
105
+ //
106
+ // # How the package is laid out
107
+ //
108
+ // Bottom to top, each module named for the one question it answers:
109
+ //
110
+ // - `widths.js` — how many columns a grapheme occupies.
111
+ // - `cells.js` — what a frame is: the grid, the colours, the continuation cell.
112
+ // - `layout.js` — flexbox, in whole cells.
113
+ // - `diff.js` — two frames, as the bytes that turn one into the other.
114
+ // - `keys.js` — terminal bytes, as key events, and a paste as one of them.
115
+ // - `mouse.js` — the other half of that stream: what the pointer did.
116
+ // - `selection.js` — what a drag over the frame selected, as two cells.
117
+ // - `capability.js` — what this terminal can render and how big it is, by the
118
+ // CLI's own rules.
119
+ // - `terminal.js` — a real terminal, and the in-memory one tests use.
120
+ // - `components.js` — `Box`, `Text`, `Input`, `ScrollBox`, and the hooks.
121
+ //
122
+ // `internal/` holds the four that a consumer must not be able to reach past:
123
+ // `tree.js` (props become a layout style once, here), `paint.js` (both passes
124
+ // must break lines the same way), `hits.js` (what is under the pointer is only
125
+ // true for the frame that recorded it) and `host.js` (one React root, one
126
+ // terminal, one owner). Each says so in its own header. There is no
127
+ // `internal/util.js`: a module that cannot say what it is about does not
128
+ // belong in this package.
129
+
130
+ export type {
131
+ BorderGlyphs,
132
+ BorderStyle,
133
+ Capabilities,
134
+ ColorChoice,
135
+ ColorLevel,
136
+ GlyphSet,
137
+ TerminalEnv,
138
+ TerminalReport,
139
+ TerminalSize,
140
+ Tty,
141
+ } from "./capability.js";
142
+ export {
143
+ FALLBACK_COLUMNS,
144
+ FALLBACK_ROWS,
145
+ borderGlyphs,
146
+ detectCapabilities,
147
+ detectSize,
148
+ plainCapabilities,
149
+ } from "./capability.js";
150
+
151
+ export type { Color, Frame, Rect, Style } from "./cells.js";
152
+ export { Attributes, INHERIT, frameRow, frameText, parseColor } from "./cells.js";
153
+
154
+ export type {
155
+ AlignItems,
156
+ AlignSelf,
157
+ Dimension,
158
+ FlexDirection,
159
+ JustifyContent,
160
+ LayoutStyle,
161
+ Overflow,
162
+ } from "./layout.js";
163
+
164
+ export type { WrapMode } from "./internal/paint.js";
165
+
166
+ export type { Update } from "./diff.js";
167
+
168
+ export type { InputDecoder, InputEvent, KeyEvent, KeySource } from "./keys.js";
169
+ export { createInputDecoder, decodeInput, decodeKeys } from "./keys.js";
170
+
171
+ export type { MouseEvent, MouseEventType, Scroll, ScrollDirection } from "./mouse.js";
172
+ export { MouseButton } from "./mouse.js";
173
+
174
+ export type { Selection, SelectionPoint } from "./selection.js";
175
+ // `selectionContains` and not `selectionBetween`: the first answers a question
176
+ // a caller has about a selection the renderer handed them, and the second
177
+ // builds one, which is the renderer's job — there is no way to hand a
178
+ // selection *back*, and an export that produced a value nothing accepts would
179
+ // be a promise that there is.
180
+ export { selectionContains } from "./selection.js";
181
+
182
+ export type { Renderer, Root } from "./internal/host.js";
183
+
184
+ export type { Handle, InputStream, OutputStream, RenderOptions, TestHandle } from "./terminal.js";
185
+ export { render, testRender } from "./terminal.js";
186
+
187
+ export type {
188
+ BoxLayoutProps,
189
+ BoxProps,
190
+ ColorValue,
191
+ InputProps,
192
+ MouseProps,
193
+ ScrollBoxProps,
194
+ TextProps,
195
+ TextStyleProps,
196
+ TitleAlignment,
197
+ } from "./components.js";
198
+ export {
199
+ Box,
200
+ Input,
201
+ ScrollBox,
202
+ Text,
203
+ useKeyboard,
204
+ useRenderer,
205
+ useTerminalSize,
206
+ } from "./components.js";
@@ -0,0 +1,156 @@
1
+ // @flow
2
+ //
3
+ // What is under the pointer, and what a drag over it would select.
4
+ //
5
+ // # Internal to `@uniflowed/tui`
6
+ //
7
+ // Absent from `package.json#exports`, and for the same reason `tree.js` is:
8
+ // the answer here is only correct for the frame that produced it. A grid held
9
+ // across a commit points at nodes React has already replaced, and a consumer
10
+ // that could keep one would be asking "what was under the pointer one frame
11
+ // ago" while believing it asked something else.
12
+ //
13
+ // # Why a grid rather than walking the tree
14
+ //
15
+ // The obvious implementation of a hit test is to walk the tree looking for the
16
+ // deepest node whose box contains the point. It gives the wrong answer here,
17
+ // twice. A node that a scrolling ancestor put outside its window still has
18
+ // last frame's geometry on it, so a walk finds boxes that are not on the
19
+ // screen; and `overflow: "hidden"` clips a child to its parent, so a box whose
20
+ // geometry contains the point may have been drawn nowhere near it.
21
+ //
22
+ // Both of those are already solved once — by the painter, whose clip rectangle
23
+ // is exactly "the cells this node was allowed to write". So the hit test is
24
+ // recorded *during* the paint that answers those questions, and reading it is
25
+ // one array index. Overlap resolves the way the picture does: the node painted
26
+ // last is the node on top, because it wrote the cell last.
27
+ //
28
+ // The grid costs one entry per cell of the frame — 1,920 of them on an 80×24
29
+ // terminal — and is built only when a renderer has mouse reporting on, so an
30
+ // application that does not use the mouse pays nothing for it.
31
+ //
32
+ // # Two answers, one walk
33
+ //
34
+ // A second array rides along, and it answers a second question: which cells
35
+ // hold text a reader is allowed to select. It is here rather than in a grid of
36
+ // its own because it is the same question asked of the same walk — *who wrote
37
+ // this cell* — and because both answers are only true of the frame that
38
+ // produced them, which is the whole reason this module is internal. Two grids
39
+ // would be two allocations and two chances to keep one of them a frame too
40
+ // long.
41
+ //
42
+ // They are separate arrays rather than one, because the two answers are about
43
+ // different nodes and resolve differently. A hit is the innermost *box*, and a
44
+ // box claims its whole rectangle whether it painted anything into it. A
45
+ // selectable cell is the `<Text>` whose grapheme is actually in that cell, and
46
+ // only where one is: a box's background is not text, so `recordHit` clears the
47
+ // text array over the area it claims, and the painter fills it back in for
48
+ // each cluster it writes. Last writer wins, exactly as it does in the frame.
49
+
50
+ import type { Rect } from "../cells.js";
51
+ import { intersect } from "../cells.js";
52
+ import type { TuiNode } from "./tree.js";
53
+
54
+ /** The node that owns each cell of one frame. */
55
+ export type HitGrid = {
56
+ readonly width: number,
57
+ readonly height: number,
58
+ /** One entry per cell, row-major, `null` where nothing was drawn. */
59
+ readonly nodes: Array<TuiNode | null>,
60
+ /**
61
+ * The selectable `<Text>` whose grapheme is in each cell, or `null`.
62
+ *
63
+ * `null` covers three different cells and deliberately does not distinguish
64
+ * them: one nothing was drawn in, one a box's background or border owns, and
65
+ * one holding text that said `selectable={false}`. Nothing routed by this
66
+ * array cares which — a drag selects a cell or it does not.
67
+ */
68
+ readonly text: Array<TuiNode | null>,
69
+ };
70
+
71
+ /** An empty grid the size of a frame. */
72
+ export function createHitGrid(width: number, height: number): HitGrid {
73
+ const size = width * height;
74
+ return {
75
+ width,
76
+ height,
77
+ nodes: new Array(size).fill(null),
78
+ text: new Array(size).fill(null),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Claim every cell of `area` that `clip` allows for `node`.
84
+ *
85
+ * Called by the painter as it descends, so a child overwrites its parent and
86
+ * a later sibling overwrites an earlier one — which is the order they are
87
+ * drawn in, and therefore the order a reader sees them stacked.
88
+ *
89
+ * It also *un*claims those cells for selection, because a box is about to
90
+ * paint over them: the text array is filled in afterwards by the clusters this
91
+ * box's descendants write. Without it a panel dropped over a paragraph would
92
+ * leave the paragraph selectable through its background — text a reader can no
93
+ * longer see, in a copy they did not expect it in.
94
+ */
95
+ export function recordHit(grid: HitGrid, node: TuiNode, area: Rect, clip: Rect): void {
96
+ const box = intersect(clip, area);
97
+ const right = Math.min(grid.width, box.x + box.width);
98
+ const bottom = Math.min(grid.height, box.y + box.height);
99
+ for (let y = Math.max(0, box.y); y < bottom; y += 1) {
100
+ const row = y * grid.width;
101
+ for (let x = Math.max(0, box.x); x < right; x += 1) {
102
+ grid.nodes[row + x] = node;
103
+ grid.text[row + x] = null;
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Say that `node`'s text occupies `width` columns from `x`, `y`.
110
+ *
111
+ * The two guards are `writeGrapheme`'s, repeated rather than shared, and the
112
+ * reason is what this array means: a cell a clip refused is a cell the node
113
+ * did not write, so it is not a cell the node owns. Deriving that from the
114
+ * write itself would be better, but `writeGrapheme` reports the columns a
115
+ * caller should advance by whether or not it wrote anything — it has to, or a
116
+ * clipped line would come out with its remaining clusters bunched up.
117
+ *
118
+ * `node` may be `null`, which is how the painter takes a cell back for
119
+ * something that is not selectable text — a scrollbar drawn over a line that
120
+ * ran into its column.
121
+ */
122
+ export function recordText(
123
+ grid: HitGrid,
124
+ node: TuiNode | null,
125
+ x: number,
126
+ y: number,
127
+ width: number,
128
+ clip: Rect,
129
+ ): void {
130
+ if (y < clip.y || y >= clip.y + clip.height || y < 0 || y >= grid.height) {
131
+ return;
132
+ }
133
+ if (x < clip.x || x + width > clip.x + clip.width || x < 0 || x + width > grid.width) {
134
+ return;
135
+ }
136
+ const row = y * grid.width;
137
+ for (let column = x; column < x + width; column += 1) {
138
+ grid.text[row + column] = node;
139
+ }
140
+ }
141
+
142
+ /** The topmost node at a cell, or `null` when the pointer is over nothing. */
143
+ export function hitAt(grid: HitGrid, x: number, y: number): TuiNode | null {
144
+ if (x < 0 || y < 0 || x >= grid.width || y >= grid.height) {
145
+ return null;
146
+ }
147
+ return grid.nodes[y * grid.width + x];
148
+ }
149
+
150
+ /** The selectable text node at a cell, or `null` when there is none. */
151
+ export function textAt(grid: HitGrid, x: number, y: number): TuiNode | null {
152
+ if (x < 0 || y < 0 || x >= grid.width || y >= grid.height) {
153
+ return null;
154
+ }
155
+ return grid.text[y * grid.width + x];
156
+ }