@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/terminal.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The two things you can point a rendered tree at: a terminal, or memory.
|
|
4
|
+
//
|
|
5
|
+
// Everything below this module is a pure function of a tree and a size.
|
|
6
|
+
// Everything a real terminal needs — raw mode, an alternate screen, a resize
|
|
7
|
+
// signal, bytes on a file descriptor — is here and nowhere else, which is what
|
|
8
|
+
// makes the in-memory renderer a first-class way to run an application rather
|
|
9
|
+
// than a mock of one. `testRender` and `render` mount the same tree through
|
|
10
|
+
// the same reconciler and produce the same frames; they differ in where the
|
|
11
|
+
// frames go and in who presses the keys.
|
|
12
|
+
//
|
|
13
|
+
// # Why the cursor is never used to draw
|
|
14
|
+
//
|
|
15
|
+
// `crates/uf_term/src/prompt/draw.rs` ends every line of its frames with
|
|
16
|
+
// `\r\n` and explains why: raw mode turns off the mapping that makes a bare
|
|
17
|
+
// line feed also return to column zero, so a menu drawn with `\n` walks off
|
|
18
|
+
// the right edge one row at a time. This renderer avoids that class of bug by
|
|
19
|
+
// never writing a newline at all. Every cell it writes is preceded by an
|
|
20
|
+
// absolute cursor position, so no frame depends on where the cursor was left,
|
|
21
|
+
// on whether the terminal wraps at the right margin, or on the line-ending
|
|
22
|
+
// translation the mode happens to be in.
|
|
23
|
+
//
|
|
24
|
+
// # A terminal nobody is watching gets text, not escapes
|
|
25
|
+
//
|
|
26
|
+
// Piping a TUI into a file or a CI log has one sensible answer, and it is not
|
|
27
|
+
// "the same escape sequences". A log is read afterwards, in order, by
|
|
28
|
+
// something that does not implement cursor addressing — so an incremental
|
|
29
|
+
// renderer writing into one produces a file full of `ESC[12;40H`. Here, a
|
|
30
|
+
// non-interactive stream gets no escapes and no incremental updates at all:
|
|
31
|
+
// the final frame is written once, as plain lines, when the application stops.
|
|
32
|
+
// That is the same answer `uf_term`'s progress bars give, for the same reason.
|
|
33
|
+
|
|
34
|
+
import * as React from "@uniflowed/react";
|
|
35
|
+
|
|
36
|
+
import type { Capabilities, ColorChoice, TerminalEnv } from "./capability.js";
|
|
37
|
+
import { FALLBACK_COLUMNS, FALLBACK_ROWS, detectCapabilities, detectSize } from "./capability.js";
|
|
38
|
+
import type { Frame } from "./cells.js";
|
|
39
|
+
import { frameText } from "./cells.js";
|
|
40
|
+
import type { Update } from "./diff.js";
|
|
41
|
+
import type { Renderer } from "./internal/host.js";
|
|
42
|
+
import {
|
|
43
|
+
RendererContext,
|
|
44
|
+
createRenderer,
|
|
45
|
+
createRoot,
|
|
46
|
+
nextUpdate,
|
|
47
|
+
pressKey,
|
|
48
|
+
pressMouse,
|
|
49
|
+
renderFrame,
|
|
50
|
+
resize,
|
|
51
|
+
} from "./internal/host.js";
|
|
52
|
+
import type { InputEvent } from "./keys.js";
|
|
53
|
+
import { createInputDecoder } from "./keys.js";
|
|
54
|
+
import type { Selection } from "./selection.js";
|
|
55
|
+
|
|
56
|
+
/** Enter the alternate screen buffer, so the shell's scrollback survives. */
|
|
57
|
+
const ENTER_ALTERNATE = "\u001b[?1049h";
|
|
58
|
+
/** Leave it, putting back whatever the reader was looking at. */
|
|
59
|
+
const LEAVE_ALTERNATE = "\u001b[?1049l";
|
|
60
|
+
/** Hide the terminal's own cursor; the frame draws its own where it wants one. */
|
|
61
|
+
const HIDE_CURSOR = "\u001b[?25l";
|
|
62
|
+
const SHOW_CURSOR = "\u001b[?25h";
|
|
63
|
+
/** Clear the screen and put the cursor at the top left. */
|
|
64
|
+
const CLEAR = "\u001b[2J\u001b[H";
|
|
65
|
+
/**
|
|
66
|
+
* Ask the terminal to bracket pasted text.
|
|
67
|
+
*
|
|
68
|
+
* Without this a paste is indistinguishable from very fast typing, which is
|
|
69
|
+
* how pasting two lines into a prompt runs the first one: the `\r` between
|
|
70
|
+
* them is delivered as Enter. With it the text arrives wrapped in `ESC[200~`
|
|
71
|
+
* and `ESC[201~`, and `keys.js` turns the whole block into one `"paste"`
|
|
72
|
+
* event. Turned off again on the way out, because a terminal left in this mode
|
|
73
|
+
* hands the *shell* its own escape sequences around every paste.
|
|
74
|
+
*/
|
|
75
|
+
const ENABLE_PASTE = "\u001b[?2004h";
|
|
76
|
+
const DISABLE_PASTE = "\u001b[?2004l";
|
|
77
|
+
/**
|
|
78
|
+
* Ask the terminal to report the mouse, in the four modes that answer.
|
|
79
|
+
*
|
|
80
|
+
* `?1000h` turns reporting on at all — presses and releases. `?1002h` adds
|
|
81
|
+
* motion while a button is held, which is what makes a drag a sequence rather
|
|
82
|
+
* than a press and a release somewhere else. `?1003h` adds motion with nothing
|
|
83
|
+
* held, which is the only way `over` and `out` can fire before a reader has
|
|
84
|
+
* clicked anything; it is the expensive one, since crossing the screen is a
|
|
85
|
+
* report per cell, and it is included because a hover that only worked
|
|
86
|
+
* mid-drag would not be a hover. `?1006h` asks for the SGR encoding, which is
|
|
87
|
+
* the one `mouse.js` decodes and the only one that works past column 223.
|
|
88
|
+
*
|
|
89
|
+
* Turned off in the reverse order on the way out, and turned on only when the
|
|
90
|
+
* application asked for the mouse: a terminal in these modes stops doing its
|
|
91
|
+
* own click-and-drag text selection, so an application that does not read the
|
|
92
|
+
* mouse must not take that away from the reader.
|
|
93
|
+
*/
|
|
94
|
+
const ENABLE_MOUSE = "\u001b[?1000h\u001b[?1002h\u001b[?1003h\u001b[?1006h";
|
|
95
|
+
const DISABLE_MOUSE = "\u001b[?1006l\u001b[?1003l\u001b[?1002l\u001b[?1000l";
|
|
96
|
+
|
|
97
|
+
/** What `render` gives back. */
|
|
98
|
+
export type Handle = {
|
|
99
|
+
/** Put the terminal back the way it was found and unmount the tree. */
|
|
100
|
+
stop(): void,
|
|
101
|
+
/** The frame currently on the screen. */
|
|
102
|
+
frame(): Frame,
|
|
103
|
+
/** That frame as text, which is what a snapshot asserts on. */
|
|
104
|
+
text(): string,
|
|
105
|
+
/**
|
|
106
|
+
* What the reader has selected with the mouse, or `""`.
|
|
107
|
+
*
|
|
108
|
+
* The same answer `useRenderer().getSelectedText()` gives a component, from
|
|
109
|
+
* outside the tree — which is where the caller wiring it to a clipboard
|
|
110
|
+
* usually is, since a program that wants to copy on Ctrl+C has a signal
|
|
111
|
+
* handler and not a component. `selection()` is the gesture behind it, for
|
|
112
|
+
* an application that wants to say something about how far it reaches.
|
|
113
|
+
*/
|
|
114
|
+
selectedText(): string,
|
|
115
|
+
/** The reader's selection, or `null` when there is none. */
|
|
116
|
+
selection(): Selection | null,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** What `testRender` gives back: a `Handle`, plus the terminal's side. */
|
|
120
|
+
export type TestHandle = {
|
|
121
|
+
...Handle,
|
|
122
|
+
/** Feed raw terminal input, as a terminal would deliver it. */
|
|
123
|
+
press(input: string): void,
|
|
124
|
+
/** Draw the next frame and report what writing it would cost. */
|
|
125
|
+
update(): Update,
|
|
126
|
+
/** Resize the terminal, discarding what was on it. */
|
|
127
|
+
resize(width: number, height: number): void,
|
|
128
|
+
/** Every update produced since mounting, in order. */
|
|
129
|
+
updates(): $ReadOnlyArray<Update>,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Anything that can be written to; `process.stdout`, or a string collector. */
|
|
133
|
+
export type OutputStream = {
|
|
134
|
+
write(chunk: string): mixed,
|
|
135
|
+
readonly columns?: number,
|
|
136
|
+
readonly rows?: number,
|
|
137
|
+
readonly isTTY?: boolean,
|
|
138
|
+
/** A real `process.stdout` emits `"resize"`; a string collector does not. */
|
|
139
|
+
on?: (event: string, listener: () => mixed) => mixed,
|
|
140
|
+
off?: (event: string, listener: () => mixed) => mixed,
|
|
141
|
+
...
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Anything keys arrive from; `process.stdin`. */
|
|
145
|
+
export type InputStream = {
|
|
146
|
+
readonly isTTY?: boolean,
|
|
147
|
+
setRawMode?: (raw: boolean) => mixed,
|
|
148
|
+
resume?: () => mixed,
|
|
149
|
+
pause?: () => mixed,
|
|
150
|
+
setEncoding?: (encoding: string) => mixed,
|
|
151
|
+
on?: (event: string, listener: (chunk: string) => mixed) => mixed,
|
|
152
|
+
off?: (event: string, listener: (chunk: string) => mixed) => mixed,
|
|
153
|
+
...
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** How to mount onto a real terminal. */
|
|
157
|
+
export type RenderOptions = {
|
|
158
|
+
readonly stdin?: InputStream,
|
|
159
|
+
readonly stdout?: OutputStream,
|
|
160
|
+
/** `--color`, when the application has such a flag. */
|
|
161
|
+
readonly color?: ColorChoice,
|
|
162
|
+
/** The environment to detect from. Defaults to the process's. */
|
|
163
|
+
readonly env?: TerminalEnv,
|
|
164
|
+
/**
|
|
165
|
+
* Whether to take over the whole screen.
|
|
166
|
+
*
|
|
167
|
+
* On by default because a full-screen application that scrolls the shell's
|
|
168
|
+
* history away has destroyed something it cannot put back. Off for an
|
|
169
|
+
* application that wants to leave its last frame in the scrollback, which is
|
|
170
|
+
* what a progress display wants.
|
|
171
|
+
*/
|
|
172
|
+
readonly alternateScreen?: boolean,
|
|
173
|
+
/**
|
|
174
|
+
* Whether to ask the terminal to report the mouse.
|
|
175
|
+
*
|
|
176
|
+
* Off by default, which is a deliberate difference from OpenTUI's renderer.
|
|
177
|
+
* Mouse reporting is not free to a *reader*: a terminal in it stops handling
|
|
178
|
+
* click-and-drag itself, so selecting a line to copy out of an application
|
|
179
|
+
* that ignores the mouse anyway needs a modifier key the reader has to know
|
|
180
|
+
* about. An application that handles the mouse is trading that away on
|
|
181
|
+
* purpose; one that does not should not trade it away by default.
|
|
182
|
+
*
|
|
183
|
+
* What it trades it away *for* is this renderer's own selection, which
|
|
184
|
+
* arrives with the mouse and not separately: a drag over selectable text
|
|
185
|
+
* highlights it and `getSelectedText()` reads it back. That is a smaller
|
|
186
|
+
* promise than the terminal's, because the text it can offer is the text on
|
|
187
|
+
* the screen — but it is the same gesture, so a reader does not have to be
|
|
188
|
+
* told that this window is the one where dragging does nothing.
|
|
189
|
+
*/
|
|
190
|
+
readonly mouse?: boolean,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Hand one decoded event to the renderer.
|
|
195
|
+
*
|
|
196
|
+
* The two drivers — a terminal and a test — read the same decoder and so face
|
|
197
|
+
* the same union, and routing it in one place is what keeps them from drifting
|
|
198
|
+
* into two answers about what a mouse report does.
|
|
199
|
+
*/
|
|
200
|
+
function deliver(renderer: Renderer, event: InputEvent): void {
|
|
201
|
+
if (event.kind === "mouse") {
|
|
202
|
+
pressMouse(renderer, event);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
pressKey(renderer, event);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Mount a tree into a renderer and return the pieces both drivers need. */
|
|
209
|
+
function mount(element: React.Node, renderer: Renderer) {
|
|
210
|
+
const root = createRoot(renderer);
|
|
211
|
+
root.render(React.createElement(RendererContext.Provider, { value: renderer }, element));
|
|
212
|
+
return root;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Render into memory.
|
|
217
|
+
*
|
|
218
|
+
* The way an application is *tested*, and the way one is rendered anywhere
|
|
219
|
+
* that is not a terminal. No environment is read, no stream is touched, and
|
|
220
|
+
* the capabilities are the caller's to choose — which is the point: a test
|
|
221
|
+
* asserting how a box degrades on a terminal with no colour should not have to
|
|
222
|
+
* arrange for the machine running it to have no colour.
|
|
223
|
+
*/
|
|
224
|
+
export function testRender(
|
|
225
|
+
element: React.Node,
|
|
226
|
+
options: {
|
|
227
|
+
readonly width?: number,
|
|
228
|
+
readonly height?: number,
|
|
229
|
+
readonly capabilities?: Capabilities,
|
|
230
|
+
readonly mouse?: boolean,
|
|
231
|
+
} = {},
|
|
232
|
+
): TestHandle {
|
|
233
|
+
const width = options.width ?? FALLBACK_COLUMNS;
|
|
234
|
+
const height = options.height ?? FALLBACK_ROWS;
|
|
235
|
+
const capabilities: Capabilities = options.capabilities ?? {
|
|
236
|
+
color: "truecolor",
|
|
237
|
+
glyphs: "unicode",
|
|
238
|
+
tty: "interactive",
|
|
239
|
+
};
|
|
240
|
+
// On by default here and off in `render`, and the difference is the whole
|
|
241
|
+
// reason the option exists: what `render` weighs is a terminal it would take
|
|
242
|
+
// click-and-drag selection away from, and there is no terminal here. A test
|
|
243
|
+
// that presses the mouse should not have to remember to enable it, and one
|
|
244
|
+
// that wants to assert an application ignores the mouse can say so.
|
|
245
|
+
const renderer = createRenderer(width, height, capabilities, options.mouse ?? true);
|
|
246
|
+
const root = mount(element, renderer);
|
|
247
|
+
const produced: Array<Update> = [];
|
|
248
|
+
|
|
249
|
+
// The same decoder a real terminal driver holds, for the same reason: a
|
|
250
|
+
// test that delivers a paste in two `press` calls is testing what an
|
|
251
|
+
// operating system does to a large one.
|
|
252
|
+
const decoder = createInputDecoder();
|
|
253
|
+
|
|
254
|
+
const handle: TestHandle = {
|
|
255
|
+
press(input: string) {
|
|
256
|
+
for (const event of decoder.push(input)) {
|
|
257
|
+
deliver(renderer, event);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
update() {
|
|
261
|
+
const next = nextUpdate(renderer);
|
|
262
|
+
produced.push(next);
|
|
263
|
+
return next;
|
|
264
|
+
},
|
|
265
|
+
updates() {
|
|
266
|
+
return produced;
|
|
267
|
+
},
|
|
268
|
+
resize(nextWidth: number, nextHeight: number) {
|
|
269
|
+
resize(renderer, nextWidth, nextHeight);
|
|
270
|
+
},
|
|
271
|
+
frame() {
|
|
272
|
+
return renderFrame(renderer);
|
|
273
|
+
},
|
|
274
|
+
text() {
|
|
275
|
+
return frameText(renderFrame(renderer));
|
|
276
|
+
},
|
|
277
|
+
selectedText() {
|
|
278
|
+
return renderer.getSelectedText();
|
|
279
|
+
},
|
|
280
|
+
selection() {
|
|
281
|
+
return renderer.getSelection();
|
|
282
|
+
},
|
|
283
|
+
stop() {
|
|
284
|
+
root.unmount();
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
return handle;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Render onto a terminal.
|
|
292
|
+
*
|
|
293
|
+
* Returns as soon as the first frame is on the screen; the application keeps
|
|
294
|
+
* running because stdin is open, and stops when the caller calls `stop()`.
|
|
295
|
+
* That is deliberate — a `render` that never returned would make the calling
|
|
296
|
+
* program unable to do anything else, including install the signal handler
|
|
297
|
+
* that has to call `stop()`.
|
|
298
|
+
*/
|
|
299
|
+
export function render(element: React.Node, options: RenderOptions = {}): Handle {
|
|
300
|
+
// Three casts, and the same reason for all of them: Flow's library
|
|
301
|
+
// definition for `process` describes Node's classes, and these types
|
|
302
|
+
// describe the three things this renderer actually needs — so that a test
|
|
303
|
+
// can pass a string collector, and so that a runtime whose streams are not
|
|
304
|
+
// Node's is not excluded by a type. The narrowing is checked at run time by
|
|
305
|
+
// the `!= null` guards below rather than trusted.
|
|
306
|
+
const stdout: OutputStream = options.stdout ?? (process.stdout: $FlowFixMe);
|
|
307
|
+
const stdin: InputStream = options.stdin ?? (process.stdin: $FlowFixMe);
|
|
308
|
+
const env: TerminalEnv = options.env ?? (process.env: $FlowFixMe);
|
|
309
|
+
const capabilities = detectCapabilities(
|
|
310
|
+
options.color ?? "auto",
|
|
311
|
+
stdout.isTTY === true ? "interactive" : "piped",
|
|
312
|
+
env,
|
|
313
|
+
);
|
|
314
|
+
const interactive = capabilities.tty === "interactive";
|
|
315
|
+
const alternateScreen = (options.alternateScreen ?? true) && interactive;
|
|
316
|
+
|
|
317
|
+
// How big the terminal is, by the same rules `uf`'s own CLI resolves it
|
|
318
|
+
// with: `COLUMNS`/`LINES` first, then what the stream reports, then 80 by
|
|
319
|
+
// 24. Reading `stdout.columns` alone was one of the two renderers deciding
|
|
320
|
+
// the terminal's shape its own way — the thing the other duplications in
|
|
321
|
+
// this package exist to prevent.
|
|
322
|
+
const size = detectSize(env, stdout);
|
|
323
|
+
const mouse = (options.mouse ?? false) && interactive;
|
|
324
|
+
const renderer = createRenderer(size.columns, size.rows, capabilities, mouse);
|
|
325
|
+
|
|
326
|
+
let stopped = false;
|
|
327
|
+
let scheduled = false;
|
|
328
|
+
|
|
329
|
+
const draw = () => {
|
|
330
|
+
if (stopped || !interactive) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const update = nextUpdate(renderer);
|
|
334
|
+
if (update.output !== "") {
|
|
335
|
+
stdout.write(update.output);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// One draw per turn of the event loop, however many commits happened in it.
|
|
340
|
+
// A component that sets three pieces of state in one handler commits three
|
|
341
|
+
// times, and drawing three frames means writing two of them to a terminal
|
|
342
|
+
// nobody ever saw.
|
|
343
|
+
renderer.onCommit = () => {
|
|
344
|
+
if (scheduled || stopped) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
scheduled = true;
|
|
348
|
+
queueMicrotask(() => {
|
|
349
|
+
scheduled = false;
|
|
350
|
+
draw();
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
if (interactive) {
|
|
355
|
+
stdout.write(
|
|
356
|
+
(alternateScreen ? ENTER_ALTERNATE : "") +
|
|
357
|
+
HIDE_CURSOR +
|
|
358
|
+
ENABLE_PASTE +
|
|
359
|
+
(mouse ? ENABLE_MOUSE : "") +
|
|
360
|
+
CLEAR,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const decoder = createInputDecoder();
|
|
365
|
+
const onData = (chunk: string) => {
|
|
366
|
+
for (const event of decoder.push(String(chunk))) {
|
|
367
|
+
deliver(renderer, event);
|
|
368
|
+
}
|
|
369
|
+
draw();
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const onResize = () => {
|
|
373
|
+
// Resolved again rather than read off the stream, so that an application
|
|
374
|
+
// told its size explicitly keeps it. `process.env` is a snapshot taken
|
|
375
|
+
// when the process started and a shell does not export `COLUMNS` anyway,
|
|
376
|
+
// so this only pins the size for somebody who set it on purpose.
|
|
377
|
+
const next = detectSize(env, stdout);
|
|
378
|
+
resize(renderer, next.columns, next.rows);
|
|
379
|
+
draw();
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const root = mount(element, renderer);
|
|
383
|
+
draw();
|
|
384
|
+
|
|
385
|
+
if (interactive && stdin.on != null) {
|
|
386
|
+
if (stdin.isTTY === true && stdin.setRawMode != null) {
|
|
387
|
+
stdin.setRawMode(true);
|
|
388
|
+
}
|
|
389
|
+
if (stdin.setEncoding != null) {
|
|
390
|
+
stdin.setEncoding("utf8");
|
|
391
|
+
}
|
|
392
|
+
if (stdin.resume != null) {
|
|
393
|
+
stdin.resume();
|
|
394
|
+
}
|
|
395
|
+
stdin.on("data", onData);
|
|
396
|
+
}
|
|
397
|
+
if (interactive && stdout.on != null) {
|
|
398
|
+
stdout.on("resize", onResize);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
stop() {
|
|
403
|
+
if (stopped) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
stopped = true;
|
|
407
|
+
// The last frame, read *before* the tree comes down. Unmounting empties
|
|
408
|
+
// the tree, and a frame rendered from an empty tree is a rectangle of
|
|
409
|
+
// spaces — which is exactly what a redirected stream received until this
|
|
410
|
+
// line existed, and exactly what no test that only drove a terminal
|
|
411
|
+
// would have noticed.
|
|
412
|
+
const farewell = interactive ? "" : `${frameText(renderFrame(renderer))}\n`;
|
|
413
|
+
// The tree comes down before the terminal is restored, so that effect
|
|
414
|
+
// cleanups run while the terminal is still in the state they were set up
|
|
415
|
+
// in. Restoring first is how a cleanup that writes a farewell line ends
|
|
416
|
+
// up writing it into the alternate screen, a millisecond before that
|
|
417
|
+
// screen is thrown away.
|
|
418
|
+
root.unmount();
|
|
419
|
+
if (stdin.off != null) {
|
|
420
|
+
stdin.off("data", onData);
|
|
421
|
+
}
|
|
422
|
+
if (stdout.off != null) {
|
|
423
|
+
stdout.off("resize", onResize);
|
|
424
|
+
}
|
|
425
|
+
if (interactive) {
|
|
426
|
+
if (stdin.isTTY === true && stdin.setRawMode != null) {
|
|
427
|
+
stdin.setRawMode(false);
|
|
428
|
+
}
|
|
429
|
+
if (stdin.pause != null) {
|
|
430
|
+
stdin.pause();
|
|
431
|
+
}
|
|
432
|
+
stdout.write(
|
|
433
|
+
(mouse ? DISABLE_MOUSE : "") +
|
|
434
|
+
DISABLE_PASTE +
|
|
435
|
+
SHOW_CURSOR +
|
|
436
|
+
(alternateScreen ? LEAVE_ALTERNATE : "\n"),
|
|
437
|
+
);
|
|
438
|
+
} else {
|
|
439
|
+
// Nobody was watching, so nothing has been written yet. The last frame
|
|
440
|
+
// goes out once, as text, which is what a log can carry.
|
|
441
|
+
stdout.write(farewell);
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
frame() {
|
|
445
|
+
return renderFrame(renderer);
|
|
446
|
+
},
|
|
447
|
+
text() {
|
|
448
|
+
return frameText(renderFrame(renderer));
|
|
449
|
+
},
|
|
450
|
+
selectedText() {
|
|
451
|
+
return renderer.getSelectedText();
|
|
452
|
+
},
|
|
453
|
+
selection() {
|
|
454
|
+
return renderer.getSelection();
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
package/widths.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// How many terminal columns a piece of text occupies.
|
|
4
|
+
//
|
|
5
|
+
// This is the first thing a terminal renderer has to get right and the easiest
|
|
6
|
+
// one to get wrong, because JavaScript offers a number that looks like the
|
|
7
|
+
// answer and is not: `"A界B".length` and `"ABC".length` are both `3`, and only
|
|
8
|
+
// one of those strings fits in three columns. Everything downstream — where a
|
|
9
|
+
// border's right edge lands, where a line wraps, which cell the cursor moves
|
|
10
|
+
// to — is computed from the answer here, so a width that is one column out
|
|
11
|
+
// does not produce a slightly wrong frame. It produces a frame whose every
|
|
12
|
+
// subsequent row is shifted.
|
|
13
|
+
//
|
|
14
|
+
// # Two units, not one
|
|
15
|
+
//
|
|
16
|
+
// A *grapheme cluster* is what a reader calls a character: a base scalar plus
|
|
17
|
+
// whatever combines onto it, up to and including a family emoji built from
|
|
18
|
+
// four people and three joiners. A *cell* is one column of one row. The
|
|
19
|
+
// mapping between them is many-to-many, and this module is the only place in
|
|
20
|
+
// the package that knows it. `Intl.Segmenter` does the clustering — it is in
|
|
21
|
+
// every runtime uf supports and it implements UAX #29, which is a standard
|
|
22
|
+
// nobody should be reimplementing.
|
|
23
|
+
//
|
|
24
|
+
// # Why the tables are copied rather than shared
|
|
25
|
+
//
|
|
26
|
+
// `crates/uf_term/src/text/tables.rs` holds exactly these two range lists, and
|
|
27
|
+
// `crates/uf_term/src/text.rs` implements exactly these rules, because the uf
|
|
28
|
+
// CLI has to answer the same question in Rust before any JavaScript is
|
|
29
|
+
// running. Two copies of a Unicode table is a thing to be uncomfortable about,
|
|
30
|
+
// so the discomfort is made mechanical instead of moral: `tui.test.js` parses
|
|
31
|
+
// the Rust file and asserts the ranges below are identical to it, and fails
|
|
32
|
+
// when either side is edited alone. The copy is therefore checked, and the
|
|
33
|
+
// alternative — shipping a native binding to a published Flow package so that
|
|
34
|
+
// `@uniflowed/tui` can ask Rust how wide `界` is — is a far larger price for
|
|
35
|
+
// the same answer.
|
|
36
|
+
|
|
37
|
+
const ZERO_WIDTH: $ReadOnlyArray<number> = [
|
|
38
|
+
0x00ad, 0x00ad, 0x0300, 0x036f, 0x0483, 0x0489, 0x0591, 0x05bd, 0x05bf, 0x05bf, 0x05c1, 0x05c2,
|
|
39
|
+
0x05c4, 0x05c5, 0x05c7, 0x05c7, 0x0610, 0x061a, 0x064b, 0x065f, 0x0670, 0x0670, 0x06d6, 0x06dc,
|
|
40
|
+
0x06df, 0x06e4, 0x06e7, 0x06e8, 0x06ea, 0x06ed, 0x0711, 0x0711, 0x0730, 0x074a, 0x07a6, 0x07b0,
|
|
41
|
+
0x07eb, 0x07f3, 0x0816, 0x0819, 0x081b, 0x0823, 0x0825, 0x0827, 0x0829, 0x082d, 0x0859, 0x085b,
|
|
42
|
+
0x08e3, 0x0902, 0x093a, 0x093a, 0x093c, 0x093c, 0x0941, 0x0948, 0x094d, 0x094d, 0x0951, 0x0957,
|
|
43
|
+
0x0962, 0x0963, 0x0981, 0x0981, 0x09bc, 0x09bc, 0x09c1, 0x09c4, 0x09cd, 0x09cd, 0x09e2, 0x09e3,
|
|
44
|
+
0x0a01, 0x0a02, 0x0a3c, 0x0a3c, 0x0a41, 0x0a42, 0x0a47, 0x0a48, 0x0a4b, 0x0a4d, 0x0a70, 0x0a71,
|
|
45
|
+
0x0abc, 0x0abc, 0x0ac1, 0x0ac5, 0x0ac7, 0x0ac8, 0x0acd, 0x0acd, 0x0b01, 0x0b01, 0x0b3c, 0x0b3c,
|
|
46
|
+
0x0b3f, 0x0b3f, 0x0b41, 0x0b44, 0x0b4d, 0x0b4d, 0x0bc0, 0x0bc0, 0x0bcd, 0x0bcd, 0x0c00, 0x0c00,
|
|
47
|
+
0x0c3e, 0x0c40, 0x0c46, 0x0c48, 0x0c4a, 0x0c4d, 0x0cbc, 0x0cbc, 0x0ccc, 0x0ccd, 0x0d41, 0x0d44,
|
|
48
|
+
0x0d4d, 0x0d4d, 0x0dca, 0x0dca, 0x0e31, 0x0e31, 0x0e34, 0x0e3a, 0x0e47, 0x0e4e, 0x0eb1, 0x0eb1,
|
|
49
|
+
0x0eb4, 0x0ebc, 0x0ec8, 0x0ecd, 0x0f35, 0x0f35, 0x0f37, 0x0f37, 0x0f39, 0x0f39, 0x0f71, 0x0f7e,
|
|
50
|
+
0x0f80, 0x0f84, 0x0f86, 0x0f87, 0x102d, 0x1030, 0x1032, 0x1037, 0x1039, 0x103a, 0x1058, 0x1059,
|
|
51
|
+
0x135d, 0x135f, 0x1712, 0x1714, 0x17b4, 0x17b5, 0x17b7, 0x17bd, 0x17c6, 0x17c6, 0x17c9, 0x17d3,
|
|
52
|
+
0x180b, 0x180e, 0x18a9, 0x18a9, 0x1a17, 0x1a18, 0x1ab0, 0x1aff, 0x1b00, 0x1b03, 0x1b34, 0x1b34,
|
|
53
|
+
0x1b6b, 0x1b73, 0x1dc0, 0x1dff, 0x200b, 0x200f, 0x202a, 0x202e, 0x2060, 0x2064, 0x206a, 0x206f,
|
|
54
|
+
0x20d0, 0x20f0, 0x2cef, 0x2cf1, 0x302a, 0x302d, 0x3099, 0x309a, 0xa66f, 0xa672, 0xa674, 0xa67d,
|
|
55
|
+
0xa69e, 0xa69f, 0xa806, 0xa806, 0xa8c4, 0xa8c5, 0xa8e0, 0xa8f1, 0xfb1e, 0xfb1e, 0xfe00, 0xfe0f,
|
|
56
|
+
0xfe20, 0xfe2f, 0xfeff, 0xfeff, 0xfff9, 0xfffb, 0x101fd, 0x101fd, 0x1d167, 0x1d169, 0x1d17b,
|
|
57
|
+
0x1d182, 0x1d185, 0x1d18b, 0x1d1aa, 0x1d1ad, 0x1d242, 0x1d244, 0xe0100, 0xe01ef,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const WIDE: $ReadOnlyArray<number> = [
|
|
61
|
+
0x1100, 0x115f, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec, 0x23f0, 0x23f0, 0x23f3, 0x23f3,
|
|
62
|
+
0x25fd, 0x25fe, 0x2614, 0x2615, 0x2648, 0x2653, 0x267f, 0x267f, 0x2693, 0x2693, 0x26a1, 0x26a1,
|
|
63
|
+
0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26ce, 0x26d4, 0x26d4, 0x26ea, 0x26ea,
|
|
64
|
+
0x26f2, 0x26f3, 0x26f5, 0x26f5, 0x26fa, 0x26fa, 0x26fd, 0x26fd, 0x2705, 0x2705, 0x270a, 0x270b,
|
|
65
|
+
0x2728, 0x2728, 0x274c, 0x274c, 0x274e, 0x274e, 0x2753, 0x2755, 0x2757, 0x2757, 0x2795, 0x2797,
|
|
66
|
+
0x27b0, 0x27b0, 0x27bf, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b50, 0x2b55, 0x2b55, 0x2e80, 0x2e99,
|
|
67
|
+
0x2e9b, 0x2ef3, 0x2f00, 0x2fd5, 0x2ff0, 0x2ffb, 0x3000, 0x303e, 0x3041, 0x3096, 0x309b, 0x30ff,
|
|
68
|
+
0x3105, 0x312f, 0x3131, 0x318e, 0x3190, 0x31e3, 0x31f0, 0x321e, 0x3220, 0x3247, 0x3250, 0x4dbf,
|
|
69
|
+
0x4e00, 0xa48c, 0xa490, 0xa4c6, 0xa960, 0xa97c, 0xac00, 0xd7a3, 0xf900, 0xfaff, 0xfe10, 0xfe19,
|
|
70
|
+
0xfe30, 0xfe52, 0xfe54, 0xfe66, 0xfe68, 0xfe6b, 0xff01, 0xff60, 0xffe0, 0xffe6, 0x16fe0, 0x16fe4,
|
|
71
|
+
0x16ff0, 0x16ff1, 0x17000, 0x187f7, 0x18800, 0x18cd5, 0x1b000, 0x1b152, 0x1b164, 0x1b167, 0x1b170,
|
|
72
|
+
0x1b2fb, 0x1f004, 0x1f004, 0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e, 0x1f191, 0x1f19a, 0x1f200, 0x1f320,
|
|
73
|
+
0x1f32d, 0x1f335, 0x1f337, 0x1f37c, 0x1f37e, 0x1f393, 0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3, 0x1f3e0,
|
|
74
|
+
0x1f3f0, 0x1f3f4, 0x1f3f4, 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440, 0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d,
|
|
75
|
+
0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a, 0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4, 0x1f5fb,
|
|
76
|
+
0x1f64f, 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d7, 0x1f6eb, 0x1f6ec,
|
|
77
|
+
0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb, 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f978, 0x1f97a,
|
|
78
|
+
0x1f9cb, 0x1f9cd, 0x1f9ff, 0x1fa70, 0x1fa74, 0x1fa78, 0x1fa7a, 0x1fa80, 0x1fa86, 0x1fa90, 0x1faa8,
|
|
79
|
+
0x1fab0, 0x1fab6, 0x1fac0, 0x1fac2, 0x1fad0, 0x1fad6, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Zero-width joiner: the scalar after it continues the cluster before it. */
|
|
83
|
+
const ZWJ = 0x200d;
|
|
84
|
+
|
|
85
|
+
/** Variation selector 16, which asks for the emoji presentation of a scalar. */
|
|
86
|
+
const VS16 = 0xfe0f;
|
|
87
|
+
|
|
88
|
+
/** One shared segmenter; constructing one costs more than using it. */
|
|
89
|
+
const GRAPHEMES: Intl$Segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether `code` falls inside a sorted, non-overlapping flat range table.
|
|
93
|
+
*
|
|
94
|
+
* The table is `[low, high, low, high, …]` rather than an array of pairs
|
|
95
|
+
* because bisection over one flat array of numbers is both shorter to write
|
|
96
|
+
* and the shape a JavaScript engine keeps unboxed.
|
|
97
|
+
*/
|
|
98
|
+
function inRanges(table: $ReadOnlyArray<number>, code: number): boolean {
|
|
99
|
+
let low = 0;
|
|
100
|
+
let high = table.length / 2 - 1;
|
|
101
|
+
while (low <= high) {
|
|
102
|
+
const middle = (low + high) >> 1;
|
|
103
|
+
if (code < table[middle * 2]) {
|
|
104
|
+
high = middle - 1;
|
|
105
|
+
} else if (code > table[middle * 2 + 1]) {
|
|
106
|
+
low = middle + 1;
|
|
107
|
+
} else {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The columns one Unicode scalar occupies.
|
|
116
|
+
*
|
|
117
|
+
* Control characters and combining marks occupy none, East Asian Wide and
|
|
118
|
+
* Fullwidth characters and the default-emoji-presentation ranges occupy two,
|
|
119
|
+
* and everything else occupies one.
|
|
120
|
+
*/
|
|
121
|
+
export function scalarWidth(code: number): number {
|
|
122
|
+
if (code < 0x20 || (code >= 0x7f && code < 0xa0)) {
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
if (inRanges(ZERO_WIDTH, code)) {
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
return inRanges(WIDE, code) ? 2 : 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The columns one grapheme cluster occupies.
|
|
133
|
+
*
|
|
134
|
+
* A cluster is as wide as its widest scalar rather than the sum of them: the
|
|
135
|
+
* marks and joiners that make a cluster longer than one scalar are precisely
|
|
136
|
+
* the ones that draw on top of what came before. The exception is the emoji
|
|
137
|
+
* variation selector, which does not draw at all and instead widens the
|
|
138
|
+
* narrow scalar in front of it — `❤` is one column and `❤️` is two, and they
|
|
139
|
+
* differ by a code point that is invisible in every editor.
|
|
140
|
+
*/
|
|
141
|
+
export function graphemeWidth(cluster: string): number {
|
|
142
|
+
let width = 0;
|
|
143
|
+
let previousNarrow = false;
|
|
144
|
+
for (const character of cluster) {
|
|
145
|
+
const code = character.codePointAt(0) ?? 0;
|
|
146
|
+
if (code === ZWJ) {
|
|
147
|
+
previousNarrow = false;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (code === VS16) {
|
|
151
|
+
if (previousNarrow) {
|
|
152
|
+
width += 1;
|
|
153
|
+
previousNarrow = false;
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const scalar = scalarWidth(code);
|
|
158
|
+
width = Math.max(width, scalar);
|
|
159
|
+
previousNarrow = scalar === 1;
|
|
160
|
+
}
|
|
161
|
+
return width;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One grapheme cluster, with the columns it will occupy. */
|
|
165
|
+
export type Grapheme = {
|
|
166
|
+
/** The cluster itself, as a string. */
|
|
167
|
+
readonly text: string,
|
|
168
|
+
/** How many columns it occupies: 0, 1, or 2. */
|
|
169
|
+
readonly width: number,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Split text into grapheme clusters, each carrying its width.
|
|
174
|
+
*
|
|
175
|
+
* Zero-width clusters are dropped rather than kept: a renderer that writes
|
|
176
|
+
* them has to decide which cell they belong to, and the answer — "the one
|
|
177
|
+
* before, which has already been written" — means the only correct handling is
|
|
178
|
+
* to have merged them into that cluster, which `Intl.Segmenter` already did.
|
|
179
|
+
* A lone combining mark with nothing to combine with is the one case this
|
|
180
|
+
* loses, and losing it is better than reserving a column for something the
|
|
181
|
+
* terminal will not advance the cursor over.
|
|
182
|
+
*/
|
|
183
|
+
export function graphemes(text: string): Array<Grapheme> {
|
|
184
|
+
const out: Array<Grapheme> = [];
|
|
185
|
+
for (const segment of GRAPHEMES.segment(text)) {
|
|
186
|
+
const width = graphemeWidth(segment.segment);
|
|
187
|
+
if (width > 0) {
|
|
188
|
+
out.push({ text: segment.segment, width });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The columns a whole string occupies. */
|
|
195
|
+
export function displayWidth(text: string): number {
|
|
196
|
+
let width = 0;
|
|
197
|
+
for (const segment of GRAPHEMES.segment(text)) {
|
|
198
|
+
width += graphemeWidth(segment.segment);
|
|
199
|
+
}
|
|
200
|
+
return width;
|
|
201
|
+
}
|