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