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