@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
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";
|
package/internal/hits.js
ADDED
|
@@ -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
|
+
}
|