@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/internal/host.js
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The React binding: what React mutates, and how a frame comes out of it.
|
|
4
|
+
//
|
|
5
|
+
// # Internal to `@uniflowed/tui`
|
|
6
|
+
//
|
|
7
|
+
// Absent from `package.json#exports`. A renderer holds one React root and one
|
|
8
|
+
// terminal's worth of state, and every operation here assumes it is the only
|
|
9
|
+
// one touching them: a second `createRoot` over the same renderer, or a
|
|
10
|
+
// `pressKey` that bypasses the driver, produces a frame the terminal is never
|
|
11
|
+
// told about. `render` and `testRender` in `terminal.js` are the two supported
|
|
12
|
+
// ways in.
|
|
13
|
+
//
|
|
14
|
+
// A renderer here is a plain object holding a node tree, the terminal's
|
|
15
|
+
// capabilities, its size, and the frame last drawn. React never sees any of
|
|
16
|
+
// that. It sees a *host config* — a table of about thirty functions that say
|
|
17
|
+
// how to make a node, put a node inside another, and change a node's props —
|
|
18
|
+
// and it does the rest. `createInstance`, `appendChild`, `removeChild`: the
|
|
19
|
+
// whole binding is those, and the reason it is worth writing rather than
|
|
20
|
+
// avoiding is that everything above it is then real React. Hooks, effects,
|
|
21
|
+
// context, Suspense, `memo`, the React Compiler's output — all of it works
|
|
22
|
+
// because none of it knows the host is a terminal.
|
|
23
|
+
//
|
|
24
|
+
// # Why `react-reconciler` and not a small reconciler of our own
|
|
25
|
+
//
|
|
26
|
+
// The alternative was to walk the element tree, call the function components
|
|
27
|
+
// and interpret what they return, which is about two hundred lines and looks
|
|
28
|
+
// tempting until the second question: what does `useState` do? A component
|
|
29
|
+
// model without hooks is not React, and the moment hooks are added the two
|
|
30
|
+
// hundred lines are a second React with its own bugs — which is precisely what
|
|
31
|
+
// `ubugeeei-redundancy.md` means by not building approximate replacements for
|
|
32
|
+
// upstream semantics.
|
|
33
|
+
//
|
|
34
|
+
// `react-reconciler` is the package React ships for this, and React Native,
|
|
35
|
+
// Ink and react-three-fiber are all built on it. It is versioned separately
|
|
36
|
+
// and its own README calls it experimental, which is a real cost and is
|
|
37
|
+
// recorded here rather than discovered at the next React minor: this package
|
|
38
|
+
// pins `^0.33.0`, which peers on React 19.2, and a React upgrade is a change
|
|
39
|
+
// that has to be tested against this binding.
|
|
40
|
+
//
|
|
41
|
+
// # Rendering is synchronous, deliberately
|
|
42
|
+
//
|
|
43
|
+
// React's concurrent scheduler exists to keep a browser's main thread
|
|
44
|
+
// responsive while it renders — to let a 60 Hz paint and a user's typing
|
|
45
|
+
// interrupt a long tree. A terminal has neither problem and one the browser
|
|
46
|
+
// does not: a keystroke must produce a frame *now*, because the reader is
|
|
47
|
+
// looking at a cursor that has not moved yet. So key dispatch runs at
|
|
48
|
+
// `DiscreteEventPriority` and the frame is flushed synchronously afterwards,
|
|
49
|
+
// which is what `react-dom` does for a click for the same reason.
|
|
50
|
+
//
|
|
51
|
+
// This also makes tests deterministic without `act()` and without waiting on
|
|
52
|
+
// timers: press a key, read the frame.
|
|
53
|
+
|
|
54
|
+
import * as React from "@uniflowed/react";
|
|
55
|
+
import Reconciler from "react-reconciler";
|
|
56
|
+
// `constants.js` with the extension: `react-reconciler` ships no `exports`
|
|
57
|
+
// map, so Node resolves its subpaths as plain files and an extensionless
|
|
58
|
+
// specifier fails only under ESM — which is to say, only in the runtime this
|
|
59
|
+
// package actually runs in.
|
|
60
|
+
import { DefaultEventPriority, DiscreteEventPriority } from "react-reconciler/constants.js";
|
|
61
|
+
|
|
62
|
+
import type { Capabilities } from "../capability.js";
|
|
63
|
+
import type { Frame, Rect } from "../cells.js";
|
|
64
|
+
import { createFrame } from "../cells.js";
|
|
65
|
+
import type { Update } from "../diff.js";
|
|
66
|
+
import { diffFrames } from "../diff.js";
|
|
67
|
+
import type { KeyEvent } from "../keys.js";
|
|
68
|
+
import { layout } from "../layout.js";
|
|
69
|
+
import type { MouseEvent } from "../mouse.js";
|
|
70
|
+
import { MouseButton, derive } from "../mouse.js";
|
|
71
|
+
import type { Selection, SelectionPoint } from "../selection.js";
|
|
72
|
+
import { selectionBetween } from "../selection.js";
|
|
73
|
+
import type { HitGrid } from "./hits.js";
|
|
74
|
+
import { createHitGrid, hitAt, textAt } from "./hits.js";
|
|
75
|
+
import { measureText, paint, paintSelection, selectionText, wrapModeOf } from "./paint.js";
|
|
76
|
+
import type { TuiNode, TuiProps } from "./tree.js";
|
|
77
|
+
import { applyProps, createNode, invalidate } from "./tree.js";
|
|
78
|
+
|
|
79
|
+
/** A global key handler, as `useKeyboard` registers one. */
|
|
80
|
+
export type KeyHandler = (key: KeyEvent) => void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Everything one mounted application owns.
|
|
84
|
+
*
|
|
85
|
+
* Mutable, and owned by exactly one React root. The fields React writes
|
|
86
|
+
* (`root`) and the fields the terminal writes (`width`, `height`) are
|
|
87
|
+
* deliberately in the same object: a resize has to invalidate the previous
|
|
88
|
+
* frame, and putting the two in separate places is how a renderer ends up
|
|
89
|
+
* diffing an 80-column frame against a 120-column one.
|
|
90
|
+
*/
|
|
91
|
+
export type Renderer = {
|
|
92
|
+
root: TuiNode,
|
|
93
|
+
capabilities: Capabilities,
|
|
94
|
+
width: number,
|
|
95
|
+
height: number,
|
|
96
|
+
/** The frame currently on the terminal, or `null` before the first draw. */
|
|
97
|
+
previous: Frame | null,
|
|
98
|
+
/** Global key handlers, in registration order, as OpenTUI orders them. */
|
|
99
|
+
keyHandlers: Array<KeyHandler>,
|
|
100
|
+
/** Called after every commit, so a driver knows to draw. */
|
|
101
|
+
onCommit: (() => void) | null,
|
|
102
|
+
/**
|
|
103
|
+
* The current size, as one object that is replaced rather than mutated.
|
|
104
|
+
*
|
|
105
|
+
* `useSyncExternalStore` requires a snapshot that is referentially stable
|
|
106
|
+
* between changes — it compares the value it is given with `Object.is` and
|
|
107
|
+
* re-renders forever if a fresh object comes back each time. Keeping the
|
|
108
|
+
* snapshot here, and replacing it only in `resize`, is what makes that
|
|
109
|
+
* true; a `getSnapshot` that returned `{ width, height }` would be the
|
|
110
|
+
* infinite-loop bug that hook's documentation warns about.
|
|
111
|
+
*/
|
|
112
|
+
size: { readonly width: number, readonly height: number },
|
|
113
|
+
/** Who to tell when the terminal is resized. */
|
|
114
|
+
sizeListeners: Set<() => void>,
|
|
115
|
+
/**
|
|
116
|
+
* Whether this renderer routes mouse reports.
|
|
117
|
+
*
|
|
118
|
+
* False by default, and the hit grid is not built when it is: a keyboard
|
|
119
|
+
* application should not pay a per-frame cost for a device it never reads.
|
|
120
|
+
* `terminal.js` sets it from `render`'s `mouse` option, which is also what
|
|
121
|
+
* decides whether the terminal is asked to report the mouse at all — the two
|
|
122
|
+
* must agree, or an application receives reports it has no grid to route.
|
|
123
|
+
*/
|
|
124
|
+
mouseEnabled: boolean,
|
|
125
|
+
/** Which node owned each cell of the last frame, or `null`. */
|
|
126
|
+
hits: HitGrid | null,
|
|
127
|
+
/** The node the pointer was last over, so `over`/`out` can be derived. */
|
|
128
|
+
hovered: TuiNode | null,
|
|
129
|
+
/** The node a left-button drag started on, while one is in progress. */
|
|
130
|
+
dragSource: TuiNode | null,
|
|
131
|
+
/** Whether that press has actually moved yet: a click is not a drag. */
|
|
132
|
+
dragging: boolean,
|
|
133
|
+
/**
|
|
134
|
+
* The reader's selection, or `null`.
|
|
135
|
+
*
|
|
136
|
+
* One per renderer, which is OpenTUI's rule and a terminal's: a frame has
|
|
137
|
+
* one way of showing that a cell is selected, so a second selection would
|
|
138
|
+
* have nowhere to be.
|
|
139
|
+
*/
|
|
140
|
+
selection: Selection | null,
|
|
141
|
+
/**
|
|
142
|
+
* Where the press that is building a selection landed, while it still is.
|
|
143
|
+
*
|
|
144
|
+
* Separate from `selection.anchor` because a press that has not moved yet
|
|
145
|
+
* has an anchor and no selection: a click is not a selection of one cell,
|
|
146
|
+
* it is a click. It is also separate from `dragSource`, which is a node —
|
|
147
|
+
* this is a cell, because that is what a selection is made of.
|
|
148
|
+
*/
|
|
149
|
+
selectionAnchor: SelectionPoint | null,
|
|
150
|
+
/** What OpenTUI calls a selection, and the four things a caller does with one. */
|
|
151
|
+
getSelection(): Selection | null,
|
|
152
|
+
hasSelection(): boolean,
|
|
153
|
+
clearSelection(): void,
|
|
154
|
+
getSelectedText(): string,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A renderer that draws into a `width` by `height` rectangle.
|
|
159
|
+
*
|
|
160
|
+
* The four selection functions are closures over the object rather than
|
|
161
|
+
* methods with a `this`, for the reason `mouseEvent` builds its events the
|
|
162
|
+
* same way: a caller reaches them through `useRenderer()` and may hold one in
|
|
163
|
+
* a variable, and a `this` would make `const copy = renderer.getSelectedText`
|
|
164
|
+
* a different function from the one it was read off.
|
|
165
|
+
*
|
|
166
|
+
* `hasSelection` is a call and not a field, which is the one place this
|
|
167
|
+
* deliberately spells an OpenTUI name differently. OpenTUI reads
|
|
168
|
+
* `renderer.hasSelection`; a plain boolean field here would be a second copy
|
|
169
|
+
* of `selection != null` that something has to remember to keep true, and a
|
|
170
|
+
* getter is what `flow/unsafe-getters-setters` warns about — a property whose
|
|
171
|
+
* read runs code.
|
|
172
|
+
*/
|
|
173
|
+
export function createRenderer(
|
|
174
|
+
width: number,
|
|
175
|
+
height: number,
|
|
176
|
+
capabilities: Capabilities,
|
|
177
|
+
mouseEnabled: boolean = false,
|
|
178
|
+
): Renderer {
|
|
179
|
+
const renderer: Renderer = {
|
|
180
|
+
root: createNode("root", {}),
|
|
181
|
+
capabilities,
|
|
182
|
+
width,
|
|
183
|
+
height,
|
|
184
|
+
previous: null,
|
|
185
|
+
keyHandlers: [],
|
|
186
|
+
onCommit: null,
|
|
187
|
+
size: { width, height },
|
|
188
|
+
sizeListeners: new Set(),
|
|
189
|
+
mouseEnabled,
|
|
190
|
+
hits: null,
|
|
191
|
+
hovered: null,
|
|
192
|
+
dragSource: null,
|
|
193
|
+
dragging: false,
|
|
194
|
+
selection: null,
|
|
195
|
+
selectionAnchor: null,
|
|
196
|
+
getSelection() {
|
|
197
|
+
return renderer.selection;
|
|
198
|
+
},
|
|
199
|
+
hasSelection() {
|
|
200
|
+
return renderer.selection != null;
|
|
201
|
+
},
|
|
202
|
+
clearSelection() {
|
|
203
|
+
renderer.selection = null;
|
|
204
|
+
renderer.selectionAnchor = null;
|
|
205
|
+
},
|
|
206
|
+
getSelectedText() {
|
|
207
|
+
return selectedText(renderer);
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
return renderer;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The text of this renderer's selection, or `""` when there is none.
|
|
215
|
+
*
|
|
216
|
+
* It draws a frame to answer, and that is not a shortcut around some cheaper
|
|
217
|
+
* path — it is the only correct one. The selection is two cells, and what is
|
|
218
|
+
* *in* a cell is a fact about a painted frame; the last one drawn is thrown
|
|
219
|
+
* away by every commit, because a commit is exactly the thing that can have
|
|
220
|
+
* moved the text. Laying out and painting a terminal is microseconds, and the
|
|
221
|
+
* caller of this is a reader who has just pressed a key to copy something.
|
|
222
|
+
*
|
|
223
|
+
* It lives beside the renderer rather than on `Selection` for the same reason.
|
|
224
|
+
* A selection is two points and knows nothing about a frame; a value that
|
|
225
|
+
* could answer this would have to hold the renderer that draws them, and then
|
|
226
|
+
* an application could keep one across a commit and read it back as if it
|
|
227
|
+
* were still true.
|
|
228
|
+
*/
|
|
229
|
+
function selectedText(renderer: Renderer): string {
|
|
230
|
+
const selection = renderer.selection;
|
|
231
|
+
if (selection == null) {
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
const frame = renderFrame(renderer);
|
|
235
|
+
const grid = renderer.hits;
|
|
236
|
+
return grid == null ? "" : selectionText(frame, grid, selection);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The context every hook in this package reads to find its renderer. */
|
|
240
|
+
export const RendererContext: React.Context<Renderer | null> = React.createContext<Renderer | null>(
|
|
241
|
+
null,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Lay the tree out and paint it.
|
|
246
|
+
*
|
|
247
|
+
* Produces a new frame each time rather than mutating the last one, because
|
|
248
|
+
* the last one is what the diff compares against: painting over it would make
|
|
249
|
+
* every frame identical to its predecessor and the terminal would never
|
|
250
|
+
* change. The allocation is one frame per draw, which is the one allocation
|
|
251
|
+
* this design cannot avoid.
|
|
252
|
+
*/
|
|
253
|
+
export function renderFrame(renderer: Renderer): Frame {
|
|
254
|
+
const { root, width, height } = renderer;
|
|
255
|
+
layout(root, 0, 0, width, height);
|
|
256
|
+
const frame = createFrame(width, height);
|
|
257
|
+
const full: Rect = { x: 0, y: 0, width, height };
|
|
258
|
+
// The hit grid belongs to the frame that produced it, so it is replaced
|
|
259
|
+
// whole rather than updated. A grid kept from an earlier frame would route a
|
|
260
|
+
// click to a node that has moved, which is the bug that makes a terminal
|
|
261
|
+
// menu act on the row above the one that was clicked.
|
|
262
|
+
const hits = renderer.mouseEnabled ? createHitGrid(width, height) : null;
|
|
263
|
+
paint(root, frame, renderer.capabilities, full, hits);
|
|
264
|
+
if (hits != null) {
|
|
265
|
+
renderer.hits = hits;
|
|
266
|
+
// After the walk, because a selection is not something a node has: it is
|
|
267
|
+
// two cells of the frame the walk just produced, and the cells between
|
|
268
|
+
// them are only known once everything that could have painted over them
|
|
269
|
+
// has. Doing it here rather than in the painter is also what keeps a
|
|
270
|
+
// re-render honest — the highlight is recomputed from the current picture,
|
|
271
|
+
// so text that moved under a selection is shown selected where it is now.
|
|
272
|
+
if (renderer.selection != null) {
|
|
273
|
+
paintSelection(frame, hits, renderer.selection);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return frame;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Draw the next frame and report what it costs to put on the terminal.
|
|
281
|
+
*
|
|
282
|
+
* Advances `previous`, so a caller that does not write the returned bytes has
|
|
283
|
+
* lied to the renderer about what is on the screen. That is why this returns
|
|
284
|
+
* the bytes instead of writing them: the one place that knows how to write to
|
|
285
|
+
* a terminal is `terminal.js`, and the one place that knows what to write is
|
|
286
|
+
* here.
|
|
287
|
+
*/
|
|
288
|
+
export function nextUpdate(renderer: Renderer): Update {
|
|
289
|
+
const frame = renderFrame(renderer);
|
|
290
|
+
const update = diffFrames(renderer.previous, frame, renderer.capabilities);
|
|
291
|
+
renderer.previous = frame;
|
|
292
|
+
return update;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The node that currently has focus, or `null`.
|
|
297
|
+
*
|
|
298
|
+
* Focus is declarative: a node has it when its props say `focused`, which
|
|
299
|
+
* makes it ordinary React state and means an application moves focus the same
|
|
300
|
+
* way it changes anything else. This is OpenTUI's model — it has no automatic
|
|
301
|
+
* Tab traversal either — and the reason to follow it rather than to add
|
|
302
|
+
* traversal is that "what does Tab do" is an application's question. In a form
|
|
303
|
+
* it moves to the next field; in an editor it inserts a tab.
|
|
304
|
+
*
|
|
305
|
+
* The *first* such node wins when an application marks two, rather than the
|
|
306
|
+
* last or an error. A terminal renderer that throws because a state update
|
|
307
|
+
* briefly marked two fields focused is a renderer that crashes during the one
|
|
308
|
+
* frame between "blur that" and "focus this".
|
|
309
|
+
*/
|
|
310
|
+
export function focusedNode(renderer: Renderer): TuiNode | null {
|
|
311
|
+
let found: TuiNode | null = null;
|
|
312
|
+
const walk = (node: TuiNode) => {
|
|
313
|
+
if (found != null) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (node.type === "box" && node.props.focused === true && node.props.focusable !== false) {
|
|
317
|
+
found = node;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
for (const child of node.children) {
|
|
321
|
+
walk(child);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
walk(renderer.root);
|
|
325
|
+
return found;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Deliver one key press.
|
|
330
|
+
*
|
|
331
|
+
* Global handlers first, in registration order, then the focused node — which
|
|
332
|
+
* is OpenTUI's order, and the reason a global quit key works even while a text
|
|
333
|
+
* input has focus. The two ways to interrupt that are not severities of one
|
|
334
|
+
* another and are documented on `KeyEvent`.
|
|
335
|
+
*/
|
|
336
|
+
export function dispatchKey(renderer: Renderer, key: KeyEvent): void {
|
|
337
|
+
for (const handler of renderer.keyHandlers.slice()) {
|
|
338
|
+
handler(key);
|
|
339
|
+
if (key.propagationStopped) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (key.defaultPrevented) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const target = focusedNode(renderer);
|
|
347
|
+
if (target == null) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const handler = target.props.onKeyDown;
|
|
351
|
+
if (typeof handler === "function") {
|
|
352
|
+
handler(key);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The prop each mouse event type is delivered through.
|
|
358
|
+
*
|
|
359
|
+
* OpenTUI's handler names, exactly: a component copied from its interaction
|
|
360
|
+
* page finds its handler called here. `onMouse` is not in this table because
|
|
361
|
+
* it is called for every type, after the specific one.
|
|
362
|
+
*/
|
|
363
|
+
const MOUSE_HANDLERS: { readonly [string]: string } = {
|
|
364
|
+
down: "onMouseDown",
|
|
365
|
+
up: "onMouseUp",
|
|
366
|
+
move: "onMouseMove",
|
|
367
|
+
drag: "onMouseDrag",
|
|
368
|
+
"drag-end": "onMouseDragEnd",
|
|
369
|
+
drop: "onMouseDrop",
|
|
370
|
+
over: "onMouseOver",
|
|
371
|
+
out: "onMouseOut",
|
|
372
|
+
scroll: "onMouseScroll",
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/** The `id` a box was given, which is the only name an event can carry. */
|
|
376
|
+
function nodeId(node: TuiNode | null): string | null {
|
|
377
|
+
if (node == null) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const id = node.props.id;
|
|
381
|
+
return typeof id === "string" ? id : null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Whether a node is still part of the tree this renderer draws.
|
|
386
|
+
*
|
|
387
|
+
* A hovered node and a drag source are held across events, and React can
|
|
388
|
+
* unmount either of them in between — a menu that closes while the pointer is
|
|
389
|
+
* over it, a list row that a state update removed. Delivering `out` or
|
|
390
|
+
* `drag-end` to a node that has left the tree is the same leak `useKeyboard`
|
|
391
|
+
* avoids by unsubscribing: a component that is gone acts on an event about a
|
|
392
|
+
* screen the reader has left.
|
|
393
|
+
*/
|
|
394
|
+
function attached(renderer: Renderer, node: TuiNode): boolean {
|
|
395
|
+
let current: TuiNode | null = node;
|
|
396
|
+
while (current != null) {
|
|
397
|
+
if (current === renderer.root) {
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
current = current.parent;
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Deliver one mouse event to a node, then to its ancestors.
|
|
407
|
+
*
|
|
408
|
+
* OpenTUI's propagation: the event starts at a node and bubbles up the parent
|
|
409
|
+
* chain until something calls `stopPropagation()` or the root is reached.
|
|
410
|
+
* There is no capture phase — OpenTUI documents one direction, and a phase
|
|
411
|
+
* nothing can register for would be a field in an event rather than a feature.
|
|
412
|
+
*
|
|
413
|
+
* `currentTarget` is rewritten at each step and `target` is not, which is the
|
|
414
|
+
* DOM's rule and the reason both exist: a panel's handler needs to know that
|
|
415
|
+
* the click was on the button inside it.
|
|
416
|
+
*/
|
|
417
|
+
function bubble(
|
|
418
|
+
from: TuiNode,
|
|
419
|
+
event: MouseEvent,
|
|
420
|
+
target: TuiNode | null,
|
|
421
|
+
source: TuiNode | null,
|
|
422
|
+
): void {
|
|
423
|
+
event.target = nodeId(target);
|
|
424
|
+
event.source = nodeId(source);
|
|
425
|
+
let current: TuiNode | null = from;
|
|
426
|
+
while (current != null) {
|
|
427
|
+
if (current.type === "box") {
|
|
428
|
+
event.currentTarget = nodeId(current);
|
|
429
|
+
const specific = current.props[MOUSE_HANDLERS[event.type]];
|
|
430
|
+
if (typeof specific === "function") {
|
|
431
|
+
specific(event);
|
|
432
|
+
}
|
|
433
|
+
const catchAll = current.props.onMouse;
|
|
434
|
+
if (typeof catchAll === "function") {
|
|
435
|
+
catchAll(event);
|
|
436
|
+
}
|
|
437
|
+
if (event.propagationStopped) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
current = current.parent;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Take the selection out to where the pointer has reached.
|
|
447
|
+
*
|
|
448
|
+
* Only while a press armed one, which is what makes a drag that began on a
|
|
449
|
+
* border or on a slider's handle a drag and not a selection. The focus is
|
|
450
|
+
* wherever the pointer is now, selectable or not: a reader dragging down a
|
|
451
|
+
* paragraph passes over the blank end of every short line, and a selection
|
|
452
|
+
* that stopped at the last character it recognised would jump backwards under
|
|
453
|
+
* their hand.
|
|
454
|
+
*/
|
|
455
|
+
function extendSelection(renderer: Renderer, event: MouseEvent): void {
|
|
456
|
+
const anchor = renderer.selectionAnchor;
|
|
457
|
+
if (anchor == null) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
renderer.selection = selectionBetween(anchor, { x: event.x, y: event.y });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Deliver one mouse report.
|
|
465
|
+
*
|
|
466
|
+
* The node under the pointer comes from the hit grid the last paint recorded,
|
|
467
|
+
* so it is the node a reader can *see* there rather than the node whose
|
|
468
|
+
* geometry contains the point — those differ under `overflow: "hidden"` and
|
|
469
|
+
* inside a `ScrollBox`, which is most of the reason the grid exists.
|
|
470
|
+
*
|
|
471
|
+
* Three things happen here that a terminal does not report and OpenTUI
|
|
472
|
+
* specifies:
|
|
473
|
+
*
|
|
474
|
+
* * **`over` and `out`.** A terminal reports positions; a hover is a change of
|
|
475
|
+
* topmost node, so it is computed by comparing this report's node with the
|
|
476
|
+
* last one's. They are delivered before the report that caused them, so that
|
|
477
|
+
* a handler which highlights on `over` has already run when the `down` that
|
|
478
|
+
* follows arrives.
|
|
479
|
+
* * **Drag capture.** A left press remembers the node it landed on, and every
|
|
480
|
+
* later motion goes to *that* node rather than to whatever is under the
|
|
481
|
+
* pointer now. Without it, dragging a slider's handle stops working the
|
|
482
|
+
* moment the pointer leaves the handle — which is every drag.
|
|
483
|
+
* * **The release.** A press that never moved is a click and produces one
|
|
484
|
+
* `up`. A press that did produces `drag-end` and `up` at the source, then
|
|
485
|
+
* `drop` at whatever is under the pointer carrying `event.source`, and an
|
|
486
|
+
* `up` there too unless that is the source again — one release is one `up`
|
|
487
|
+
* per node.
|
|
488
|
+
*
|
|
489
|
+
* A fourth thing happens that OpenTUI also specifies, and it is the renderer's
|
|
490
|
+
* *default* rather than something delivered: a left press clears the selection
|
|
491
|
+
* and, when it landed on selectable text, arms a new one that the drag then
|
|
492
|
+
* extends. It runs after the handlers, so that `event.preventDefault()` on the
|
|
493
|
+
* `down` can suppress it — a box that means its own thing by a drag keeps the
|
|
494
|
+
* reader's selection instead of wiping it on the way past.
|
|
495
|
+
*
|
|
496
|
+
* A renderer with `mouseEnabled` false has no grid and drops the report. That
|
|
497
|
+
* is not a silent failure to guard against: nothing turns mouse reporting on
|
|
498
|
+
* in the terminal either, so a report can only arrive from a caller who
|
|
499
|
+
* assembled one by hand.
|
|
500
|
+
*/
|
|
501
|
+
export function dispatchMouse(renderer: Renderer, event: MouseEvent): void {
|
|
502
|
+
if (renderer.hits == null) {
|
|
503
|
+
// A report before the first draw. `render` draws immediately after
|
|
504
|
+
// mounting, so this is the in-memory renderer's path: a test that presses
|
|
505
|
+
// the mouse before it asks for a frame.
|
|
506
|
+
renderFrame(renderer);
|
|
507
|
+
}
|
|
508
|
+
const grid = renderer.hits;
|
|
509
|
+
if (grid == null) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const hit = hitAt(grid, event.x, event.y);
|
|
514
|
+
// Read before anything is delivered, because a handler can commit — a click
|
|
515
|
+
// that opens a menu — and a commit drops the grid this came out of. What is
|
|
516
|
+
// selectable under a press is a fact about the frame the reader pressed on.
|
|
517
|
+
const overText = textAt(grid, event.x, event.y) != null;
|
|
518
|
+
if (hit !== renderer.hovered) {
|
|
519
|
+
const left = renderer.hovered;
|
|
520
|
+
renderer.hovered = hit;
|
|
521
|
+
if (left != null && attached(renderer, left)) {
|
|
522
|
+
bubble(left, derive(event, "out"), left, renderer.dragSource);
|
|
523
|
+
}
|
|
524
|
+
if (hit != null) {
|
|
525
|
+
bubble(hit, derive(event, "over"), hit, renderer.dragSource);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (event.type === "down") {
|
|
530
|
+
if (event.button === MouseButton.LEFT) {
|
|
531
|
+
renderer.dragSource = hit;
|
|
532
|
+
renderer.dragging = false;
|
|
533
|
+
}
|
|
534
|
+
if (hit != null) {
|
|
535
|
+
bubble(hit, event, hit, null);
|
|
536
|
+
}
|
|
537
|
+
if (event.button === MouseButton.LEFT && !event.defaultPrevented) {
|
|
538
|
+
// A press ends the selection the reader had, whether or not it starts
|
|
539
|
+
// one: that is what clicking somewhere else means, and it is why a
|
|
540
|
+
// click that never moves leaves nothing selected. The new anchor is
|
|
541
|
+
// only armed over selectable text, so a drag from a border or from the
|
|
542
|
+
// gap between two panels moves nothing but the pointer.
|
|
543
|
+
renderer.selection = null;
|
|
544
|
+
renderer.selectionAnchor = overText ? { x: event.x, y: event.y } : null;
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (event.type === "drag") {
|
|
550
|
+
const source = renderer.dragSource;
|
|
551
|
+
if (source != null && attached(renderer, source)) {
|
|
552
|
+
renderer.dragging = true;
|
|
553
|
+
bubble(source, event, hit, source);
|
|
554
|
+
extendSelection(renderer, event);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (hit != null) {
|
|
558
|
+
bubble(hit, event, hit, null);
|
|
559
|
+
}
|
|
560
|
+
extendSelection(renderer, event);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (event.type === "up") {
|
|
565
|
+
const source = renderer.dragSource;
|
|
566
|
+
const dragged = renderer.dragging;
|
|
567
|
+
renderer.dragSource = null;
|
|
568
|
+
renderer.dragging = false;
|
|
569
|
+
// The gesture is over; the selection it made is not. Dropping the anchor
|
|
570
|
+
// rather than the selection is the difference between "the reader has
|
|
571
|
+
// stopped dragging" and "the reader has stopped selecting".
|
|
572
|
+
renderer.selectionAnchor = null;
|
|
573
|
+
if (source != null && dragged && attached(renderer, source)) {
|
|
574
|
+
// Each of these is its own event object: they are four separate
|
|
575
|
+
// deliveries, and one handler calling `stopPropagation()` must not
|
|
576
|
+
// silence the next node's.
|
|
577
|
+
bubble(source, derive(event, "drag-end"), hit, source);
|
|
578
|
+
bubble(source, derive(event, "up"), hit, source);
|
|
579
|
+
if (hit != null) {
|
|
580
|
+
bubble(hit, derive(event, "drop"), hit, source);
|
|
581
|
+
if (hit !== source) {
|
|
582
|
+
bubble(hit, derive(event, "up"), hit, source);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (hit != null) {
|
|
588
|
+
bubble(hit, event, hit, null);
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (hit != null) {
|
|
594
|
+
bubble(hit, event, hit, renderer.dragSource);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The current update priority.
|
|
600
|
+
*
|
|
601
|
+
* React asks for this to decide which lane an update belongs to. It is module
|
|
602
|
+
* state rather than renderer state because React asks without saying which
|
|
603
|
+
* renderer it is asking for, and a process draws one terminal.
|
|
604
|
+
*/
|
|
605
|
+
let currentPriority: number = DefaultEventPriority;
|
|
606
|
+
|
|
607
|
+
const noop = () => {};
|
|
608
|
+
|
|
609
|
+
const hostConfig = {
|
|
610
|
+
supportsMutation: true,
|
|
611
|
+
supportsPersistence: false,
|
|
612
|
+
supportsHydration: false,
|
|
613
|
+
supportsMicrotasks: true,
|
|
614
|
+
isPrimaryRenderer: true,
|
|
615
|
+
warnsIfNotActing: true,
|
|
616
|
+
noTimeout: -1,
|
|
617
|
+
scheduleTimeout: setTimeout,
|
|
618
|
+
cancelTimeout: clearTimeout,
|
|
619
|
+
scheduleMicrotask: queueMicrotask,
|
|
620
|
+
rendererPackageName: "@uniflowed/tui",
|
|
621
|
+
rendererVersion: "0.0.0-alpha.5",
|
|
622
|
+
|
|
623
|
+
getRootHostContext: (): {} => ({}),
|
|
624
|
+
getChildHostContext: (parent: {}): {} => parent,
|
|
625
|
+
getPublicInstance: (instance: TuiNode): TuiNode => instance,
|
|
626
|
+
prepareForCommit: (): null => null,
|
|
627
|
+
resetAfterCommit: (renderer: Renderer): void => {
|
|
628
|
+
// The picture has changed, so what is under the pointer may have. The grid
|
|
629
|
+
// is dropped rather than rebuilt: the next draw builds one anyway, and a
|
|
630
|
+
// mouse report that arrives before that draw builds its own. Keeping it
|
|
631
|
+
// would route the click after a state update by the frame before it — a
|
|
632
|
+
// menu that moved under the pointer acting on the row it used to show.
|
|
633
|
+
renderer.hits = null;
|
|
634
|
+
if (renderer.onCommit != null) {
|
|
635
|
+
renderer.onCommit();
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
preparePortalMount: noop,
|
|
639
|
+
|
|
640
|
+
createInstance: (type: string, props: TuiProps): TuiNode => {
|
|
641
|
+
const node = createNode(type === "uf-text" ? "text" : "box", props);
|
|
642
|
+
if (node.type === "text") {
|
|
643
|
+
// A text node is the only leaf that knows its own size, and it only
|
|
644
|
+
// knows it once it is told how wide it may be. This is Yoga's measure
|
|
645
|
+
// callback; `layout.js` calls it and never learns what text is.
|
|
646
|
+
node.measure = (available: number) => measureText(node, available, wrapModeOf(node));
|
|
647
|
+
}
|
|
648
|
+
return node;
|
|
649
|
+
},
|
|
650
|
+
createTextInstance: (text: string): TuiNode => {
|
|
651
|
+
const node = createNode("chars", {});
|
|
652
|
+
node.text = text;
|
|
653
|
+
return node;
|
|
654
|
+
},
|
|
655
|
+
appendInitialChild: (parent: TuiNode, child: TuiNode): void => {
|
|
656
|
+
child.parent = parent;
|
|
657
|
+
invalidate(parent, parent.children.length);
|
|
658
|
+
parent.children.push(child);
|
|
659
|
+
},
|
|
660
|
+
finalizeInitialChildren: (): boolean => false,
|
|
661
|
+
// Never: a `<Text>`'s children are nodes, because React has to be able to
|
|
662
|
+
// move and replace them individually. Answering `true` here would collapse
|
|
663
|
+
// them into one string and lose the styles the nested ones carry.
|
|
664
|
+
shouldSetTextContent: (): boolean => false,
|
|
665
|
+
clearContainer: (renderer: Renderer): void => {
|
|
666
|
+
invalidate(renderer.root, 0);
|
|
667
|
+
renderer.root.children = [];
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
// An append is the one mutation whose position is known without looking for
|
|
671
|
+
// it, and it is the one a log makes: every child above the new one is where
|
|
672
|
+
// it was, so a scrolling parent has to re-measure exactly the child that
|
|
673
|
+
// arrived. Every other mutation moves a child that could be anywhere, so it
|
|
674
|
+
// invalidates the stack whole.
|
|
675
|
+
appendChild: (parent: TuiNode, child: TuiNode): void => {
|
|
676
|
+
child.parent = parent;
|
|
677
|
+
invalidate(parent, parent.children.length);
|
|
678
|
+
parent.children.push(child);
|
|
679
|
+
},
|
|
680
|
+
appendChildToContainer: (renderer: Renderer, child: TuiNode): void => {
|
|
681
|
+
child.parent = renderer.root;
|
|
682
|
+
invalidate(renderer.root, renderer.root.children.length);
|
|
683
|
+
renderer.root.children.push(child);
|
|
684
|
+
},
|
|
685
|
+
insertBefore: (parent: TuiNode, child: TuiNode, before: TuiNode): void => {
|
|
686
|
+
child.parent = parent;
|
|
687
|
+
invalidate(parent, 0);
|
|
688
|
+
remove(parent.children, child);
|
|
689
|
+
const at = parent.children.indexOf(before);
|
|
690
|
+
parent.children.splice(at < 0 ? parent.children.length : at, 0, child);
|
|
691
|
+
},
|
|
692
|
+
insertInContainerBefore: (renderer: Renderer, child: TuiNode, before: TuiNode): void => {
|
|
693
|
+
hostConfig.insertBefore(renderer.root, child, before);
|
|
694
|
+
},
|
|
695
|
+
removeChild: (parent: TuiNode, child: TuiNode): void => {
|
|
696
|
+
invalidate(parent, 0);
|
|
697
|
+
remove(parent.children, child);
|
|
698
|
+
child.parent = null;
|
|
699
|
+
},
|
|
700
|
+
removeChildFromContainer: (renderer: Renderer, child: TuiNode): void => {
|
|
701
|
+
invalidate(renderer.root, 0);
|
|
702
|
+
remove(renderer.root.children, child);
|
|
703
|
+
child.parent = null;
|
|
704
|
+
},
|
|
705
|
+
commitUpdate: (node: TuiNode, _type: string, _previous: TuiProps, next: TuiProps): void => {
|
|
706
|
+
applyProps(node, next);
|
|
707
|
+
},
|
|
708
|
+
commitTextUpdate: (node: TuiNode, _previous: string, next: string): void => {
|
|
709
|
+
node.text = next;
|
|
710
|
+
invalidate(node);
|
|
711
|
+
},
|
|
712
|
+
resetTextContent: noop,
|
|
713
|
+
commitMount: noop,
|
|
714
|
+
// Hiding is how React implements a Suspense fallback and `<Activity>`. A
|
|
715
|
+
// hidden node keeps its place in the tree and draws nothing, which layout
|
|
716
|
+
// reads as a zero-size node rather than as an absent one.
|
|
717
|
+
hideInstance: (node: TuiNode): void => {
|
|
718
|
+
applyProps(node, { ...node.props, width: 0, height: 0, hidden: true });
|
|
719
|
+
},
|
|
720
|
+
unhideInstance: (node: TuiNode, props: TuiProps): void => {
|
|
721
|
+
applyProps(node, props);
|
|
722
|
+
},
|
|
723
|
+
hideTextInstance: (node: TuiNode): void => {
|
|
724
|
+
node.text = "";
|
|
725
|
+
invalidate(node);
|
|
726
|
+
},
|
|
727
|
+
unhideTextInstance: (node: TuiNode, text: string): void => {
|
|
728
|
+
node.text = text;
|
|
729
|
+
invalidate(node);
|
|
730
|
+
},
|
|
731
|
+
detachDeletedInstance: noop,
|
|
732
|
+
|
|
733
|
+
getCurrentUpdatePriority: (): number => currentPriority,
|
|
734
|
+
setCurrentUpdatePriority: (priority: number): void => {
|
|
735
|
+
currentPriority = priority;
|
|
736
|
+
},
|
|
737
|
+
resolveUpdatePriority: (): number =>
|
|
738
|
+
currentPriority === 0 ? DefaultEventPriority : currentPriority,
|
|
739
|
+
shouldAttemptEagerTransition: (): boolean => false,
|
|
740
|
+
requestPostPaintCallback: noop,
|
|
741
|
+
maySuspendCommit: (): boolean => false,
|
|
742
|
+
preloadInstance: (): boolean => true,
|
|
743
|
+
startSuspendingCommit: noop,
|
|
744
|
+
suspendInstance: noop,
|
|
745
|
+
waitForCommitToBeReady: (): null => null,
|
|
746
|
+
NotPendingTransition: null,
|
|
747
|
+
HostTransitionContext: React.createContext(null),
|
|
748
|
+
resetFormInstance: noop,
|
|
749
|
+
trackSchedulerEvent: noop,
|
|
750
|
+
resolveEventType: (): null => null,
|
|
751
|
+
resolveEventTimeStamp: (): number => -1.1,
|
|
752
|
+
beforeActiveInstanceBlur: noop,
|
|
753
|
+
afterActiveInstanceBlur: noop,
|
|
754
|
+
prepareScopeUpdate: noop,
|
|
755
|
+
getInstanceFromScope: (): null => null,
|
|
756
|
+
getInstanceFromNode: (): null => null,
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
function remove(children: Array<TuiNode>, child: TuiNode): void {
|
|
760
|
+
const at = children.indexOf(child);
|
|
761
|
+
if (at >= 0) {
|
|
762
|
+
children.splice(at, 1);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const reconciler = Reconciler(hostConfig);
|
|
767
|
+
|
|
768
|
+
/** A mounted React tree, and the two things a caller does with one. */
|
|
769
|
+
export type Root = {
|
|
770
|
+
/** Render an element into this renderer, synchronously. */
|
|
771
|
+
render(node: React.Node): void,
|
|
772
|
+
/** Unmount it, running every effect cleanup. */
|
|
773
|
+
unmount(): void,
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Mount React into a renderer.
|
|
778
|
+
*
|
|
779
|
+
* The renderer is not owned by the root: unmounting the tree leaves the
|
|
780
|
+
* renderer able to draw the empty frame, and tearing the terminal down is the
|
|
781
|
+
* job of whoever set it up. That is OpenTUI's split between `root.unmount()`
|
|
782
|
+
* and `renderer.destroy()`, and it exists because the two failure paths are
|
|
783
|
+
* different — a component that throws should not leave a terminal in raw mode.
|
|
784
|
+
*/
|
|
785
|
+
export function createRoot(renderer: Renderer): Root {
|
|
786
|
+
const container = reconciler.createContainer(
|
|
787
|
+
renderer,
|
|
788
|
+
// A concurrent root, driven synchronously. The alternative — a legacy
|
|
789
|
+
// root — also renders synchronously but opts out of every React 19
|
|
790
|
+
// behaviour that is tested against concurrent roots, which is a strange
|
|
791
|
+
// thing for a new renderer to inherit.
|
|
792
|
+
1,
|
|
793
|
+
null,
|
|
794
|
+
false,
|
|
795
|
+
null,
|
|
796
|
+
"uf-tui",
|
|
797
|
+
(error: mixed) => {
|
|
798
|
+
throw error;
|
|
799
|
+
},
|
|
800
|
+
noop,
|
|
801
|
+
noop,
|
|
802
|
+
null,
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
const flush = () => {
|
|
806
|
+
// Passive effects can schedule more work — a `useEffect` that sets state
|
|
807
|
+
// is how an application reacts to being mounted — so this settles rather
|
|
808
|
+
// than flushing once. The bound is not a safety net for a well-written
|
|
809
|
+
// application; it is what turns an effect loop into a clear failure
|
|
810
|
+
// instead of a terminal that stops responding.
|
|
811
|
+
for (let pass = 0; pass < 50; pass += 1) {
|
|
812
|
+
reconciler.flushSyncWork();
|
|
813
|
+
if (!reconciler.flushPassiveEffects()) {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
throw new Error(
|
|
818
|
+
"@uniflowed/tui: an effect kept scheduling work after 50 passes; " +
|
|
819
|
+
"a `useEffect` is setting state that re-triggers it.",
|
|
820
|
+
);
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
render(node: React.Node) {
|
|
825
|
+
withPriority(DiscreteEventPriority, () => {
|
|
826
|
+
reconciler.updateContainerSync(node, container, null, null);
|
|
827
|
+
flush();
|
|
828
|
+
});
|
|
829
|
+
},
|
|
830
|
+
unmount() {
|
|
831
|
+
withPriority(DiscreteEventPriority, () => {
|
|
832
|
+
reconciler.updateContainerSync(null, container, null, null);
|
|
833
|
+
flush();
|
|
834
|
+
});
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Run `work` at a given React update priority, and flush what it schedules.
|
|
841
|
+
*
|
|
842
|
+
* This is how a key press becomes a frame before the function returns. React
|
|
843
|
+
* assigns an update to a lane from the priority in effect when `setState` is
|
|
844
|
+
* called, and only the sync lane is flushed by `flushSyncWork()` — so a
|
|
845
|
+
* handler that runs at the default priority schedules work for a later task,
|
|
846
|
+
* and the terminal shows the previous frame until that task runs.
|
|
847
|
+
*/
|
|
848
|
+
export function withPriority<T>(priority: number, work: () => T): T {
|
|
849
|
+
const previous = currentPriority;
|
|
850
|
+
currentPriority = priority;
|
|
851
|
+
try {
|
|
852
|
+
return work();
|
|
853
|
+
} finally {
|
|
854
|
+
currentPriority = previous;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Deliver a key and settle everything it caused.
|
|
860
|
+
*
|
|
861
|
+
* The entry point a driver — the real terminal, or a test — uses. It exists so
|
|
862
|
+
* that "press a key" and "the frame that results" are one call rather than a
|
|
863
|
+
* call and a hope.
|
|
864
|
+
*/
|
|
865
|
+
export function pressKey(renderer: Renderer, key: KeyEvent): void {
|
|
866
|
+
withPriority(DiscreteEventPriority, () => {
|
|
867
|
+
dispatchKey(renderer, key);
|
|
868
|
+
settle();
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Deliver a mouse report and settle everything it caused.
|
|
874
|
+
*
|
|
875
|
+
* The counterpart of {@link pressKey}, at the same priority and for the same
|
|
876
|
+
* reason: a reader who clicked is looking at a frame that has not changed yet.
|
|
877
|
+
*/
|
|
878
|
+
export function pressMouse(renderer: Renderer, event: MouseEvent): void {
|
|
879
|
+
withPriority(DiscreteEventPriority, () => {
|
|
880
|
+
dispatchMouse(renderer, event);
|
|
881
|
+
settle();
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Tell the renderer the terminal is a different size, and settle the redraw.
|
|
887
|
+
*
|
|
888
|
+
* `previous` is discarded rather than kept. A resized terminal has already
|
|
889
|
+
* reflowed whatever was on it — the emulator moved the text itself, in a way
|
|
890
|
+
* this renderer neither performed nor can predict — so the frame it thought
|
|
891
|
+
* was on the screen describes nothing, and diffing against it produces an
|
|
892
|
+
* update that repairs a screen that does not exist. The next draw is a full
|
|
893
|
+
* repaint, which is correct and is the one case where a full repaint is.
|
|
894
|
+
*/
|
|
895
|
+
export function resize(renderer: Renderer, width: number, height: number): void {
|
|
896
|
+
if (renderer.width === width && renderer.height === height) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
renderer.width = width;
|
|
900
|
+
renderer.height = height;
|
|
901
|
+
renderer.size = { width, height };
|
|
902
|
+
renderer.previous = null;
|
|
903
|
+
// The same reasoning as `previous`, one axis further: a grid is a rectangle
|
|
904
|
+
// of the old size, and indexing it with a coordinate from the new one reads
|
|
905
|
+
// the wrong row.
|
|
906
|
+
renderer.hits = null;
|
|
907
|
+
renderer.hovered = null;
|
|
908
|
+
// And a selection is two cells of a frame that has been reflowed by
|
|
909
|
+
// something this renderer did not perform. It survives a re-render, where
|
|
910
|
+
// the cells still mean what they meant; it cannot survive a resize, where
|
|
911
|
+
// they do not.
|
|
912
|
+
renderer.selection = null;
|
|
913
|
+
renderer.selectionAnchor = null;
|
|
914
|
+
withPriority(DiscreteEventPriority, () => {
|
|
915
|
+
for (const listener of Array.from(renderer.sizeListeners)) {
|
|
916
|
+
listener();
|
|
917
|
+
}
|
|
918
|
+
settle();
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Flush everything React has scheduled, including the effects it runs. */
|
|
923
|
+
function settle(): void {
|
|
924
|
+
reconciler.flushSyncWork();
|
|
925
|
+
for (let pass = 0; pass < 50 && reconciler.flushPassiveEffects(); pass += 1) {
|
|
926
|
+
reconciler.flushSyncWork();
|
|
927
|
+
}
|
|
928
|
+
}
|