@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/components.js
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The components a caller writes, and the hooks they reach for.
|
|
4
|
+
//
|
|
5
|
+
// Four components, and the choice of which four is most of the argument of the
|
|
6
|
+
// first two releases. `Box` is a flex container that can draw a frame around
|
|
7
|
+
// itself; `Text` is a run of styled characters that knows how to wrap; `Input`
|
|
8
|
+
// is a line a reader types into; `ScrollBox` is a window onto content taller
|
|
9
|
+
// than it. Everything else OpenTUI offers — a select, a table, a diff view — is
|
|
10
|
+
// those plus state, and shipping them badly is worse than not shipping them, so
|
|
11
|
+
// they are ubugeeei-prod/uf#314 rather than stubs that throw.
|
|
12
|
+
//
|
|
13
|
+
// `ScrollBox` is the exception to "plus state", which is why it is a component
|
|
14
|
+
// here rather than something a caller writes: which children are laid out and
|
|
15
|
+
// painted depends on where the window is, and nothing above the renderer can
|
|
16
|
+
// decide that.
|
|
17
|
+
//
|
|
18
|
+
// # Why these are `component`s and not intrinsic elements
|
|
19
|
+
//
|
|
20
|
+
// OpenTUI's React binding gives you `<box>` and `<text>` — lowercase JSX
|
|
21
|
+
// intrinsics, resolved by the renderer. That is not available here and should
|
|
22
|
+
// not be: Flow resolves a lowercase JSX name against React's *DOM* intrinsics,
|
|
23
|
+
// so `<box>` is either an error or, worse, silently the HTML element of that
|
|
24
|
+
// name. Flow has a lint for exactly this collision — `react-intrinsic-overlap`
|
|
25
|
+
// — which is a good sign that the collision is real and a bad way to live with
|
|
26
|
+
// it.
|
|
27
|
+
//
|
|
28
|
+
// So the public surface is capitalised `component`s, and the host element
|
|
29
|
+
// names they create — `"uf-box"`, `"uf-text"` — are an implementation detail
|
|
30
|
+
// nothing outside this package writes. The gain is the one Flow exists for:
|
|
31
|
+
// `<Box padding="2">` is a type error at the call site rather than a padding
|
|
32
|
+
// of `NaN` at run time.
|
|
33
|
+
//
|
|
34
|
+
// # What a component here may assume about React
|
|
35
|
+
//
|
|
36
|
+
// Nothing that `ubugeeei-redundancy.md` forbids. Nothing below mutates during
|
|
37
|
+
// render, reads a ref during render, or depends on a render happening exactly
|
|
38
|
+
// once. `Input` keeps its cursor in state, not in a ref that a render reads;
|
|
39
|
+
// `useTerminalSize` subscribes with `useSyncExternalStore` and returns a
|
|
40
|
+
// snapshot that is stable between resizes; `ScrollBox` owns no scroll state at
|
|
41
|
+
// all. All four are safe under Strict Mode's double invocation and under the
|
|
42
|
+
// React Compiler's memoization.
|
|
43
|
+
|
|
44
|
+
import * as React from "@uniflowed/react";
|
|
45
|
+
import {
|
|
46
|
+
useCallback,
|
|
47
|
+
useEffect,
|
|
48
|
+
useMemo,
|
|
49
|
+
useRef,
|
|
50
|
+
useState,
|
|
51
|
+
useSyncExternalStore,
|
|
52
|
+
} from "@uniflowed/react";
|
|
53
|
+
|
|
54
|
+
import type { BorderStyle } from "./capability.js";
|
|
55
|
+
import type { KeyEvent } from "./keys.js";
|
|
56
|
+
import type { MouseEvent } from "./mouse.js";
|
|
57
|
+
import type {
|
|
58
|
+
AlignItems,
|
|
59
|
+
AlignSelf,
|
|
60
|
+
Dimension,
|
|
61
|
+
FlexDirection,
|
|
62
|
+
JustifyContent,
|
|
63
|
+
LayoutStyle,
|
|
64
|
+
Overflow,
|
|
65
|
+
} from "./layout.js";
|
|
66
|
+
import type { WrapMode } from "./internal/paint.js";
|
|
67
|
+
import type { Renderer } from "./internal/host.js";
|
|
68
|
+
import { RendererContext } from "./internal/host.js";
|
|
69
|
+
|
|
70
|
+
/** A colour, as `"#rrggbb"`, one of the sixteen names, or a packed number. */
|
|
71
|
+
export type ColorValue = string | number;
|
|
72
|
+
|
|
73
|
+
/** Where a border title sits along its edge. */
|
|
74
|
+
export type TitleAlignment = "left" | "center" | "right";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Everything that positions a node.
|
|
78
|
+
*
|
|
79
|
+
* Accepted both as individual props and inside `style`, exactly as OpenTUI
|
|
80
|
+
* accepts them, because both spellings are in its documentation and a caller
|
|
81
|
+
* copying an example should not have to translate.
|
|
82
|
+
*/
|
|
83
|
+
export type BoxLayoutProps = {
|
|
84
|
+
readonly flexDirection?: FlexDirection,
|
|
85
|
+
readonly justifyContent?: JustifyContent,
|
|
86
|
+
readonly alignItems?: AlignItems,
|
|
87
|
+
readonly alignSelf?: AlignSelf,
|
|
88
|
+
readonly flexGrow?: number,
|
|
89
|
+
readonly flexShrink?: number,
|
|
90
|
+
readonly flexBasis?: Dimension,
|
|
91
|
+
readonly width?: Dimension,
|
|
92
|
+
readonly height?: Dimension,
|
|
93
|
+
readonly minWidth?: Dimension,
|
|
94
|
+
readonly minHeight?: Dimension,
|
|
95
|
+
readonly maxWidth?: Dimension,
|
|
96
|
+
readonly maxHeight?: Dimension,
|
|
97
|
+
readonly padding?: number,
|
|
98
|
+
readonly paddingX?: number,
|
|
99
|
+
readonly paddingY?: number,
|
|
100
|
+
readonly paddingTop?: number,
|
|
101
|
+
readonly paddingRight?: number,
|
|
102
|
+
readonly paddingBottom?: number,
|
|
103
|
+
readonly paddingLeft?: number,
|
|
104
|
+
readonly margin?: number,
|
|
105
|
+
readonly marginTop?: number,
|
|
106
|
+
readonly marginRight?: number,
|
|
107
|
+
readonly marginBottom?: number,
|
|
108
|
+
readonly marginLeft?: number,
|
|
109
|
+
readonly gap?: number,
|
|
110
|
+
readonly rowGap?: number,
|
|
111
|
+
readonly columnGap?: number,
|
|
112
|
+
readonly overflow?: Overflow,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Everything that paints a node's text. Inherited by nested `Text`. */
|
|
116
|
+
export type TextStyleProps = {
|
|
117
|
+
readonly fg?: ColorValue,
|
|
118
|
+
readonly bg?: ColorValue,
|
|
119
|
+
readonly bold?: boolean,
|
|
120
|
+
readonly dim?: boolean,
|
|
121
|
+
readonly italic?: boolean,
|
|
122
|
+
readonly underline?: boolean,
|
|
123
|
+
readonly blink?: boolean,
|
|
124
|
+
readonly inverse?: boolean,
|
|
125
|
+
readonly strikethrough?: boolean,
|
|
126
|
+
/**
|
|
127
|
+
* Whether a reader may select this text with the mouse.
|
|
128
|
+
*
|
|
129
|
+
* `true` unless something says otherwise, which is OpenTUI's default for
|
|
130
|
+
* text and a terminal's for everything. It is inherited the way a colour
|
|
131
|
+
* is, so `selectable={false}` on a `Box` covers everything inside it — which
|
|
132
|
+
* is how a status bar or a decorative frame stays out of a copy without
|
|
133
|
+
* every `Text` in it repeating the prop.
|
|
134
|
+
*/
|
|
135
|
+
readonly selectable?: boolean,
|
|
136
|
+
/**
|
|
137
|
+
* How a selection over this text looks.
|
|
138
|
+
*
|
|
139
|
+
* Both default to unset, and unset means inverse video: `SGR 7` exists on
|
|
140
|
+
* terminals with no colour at all, and it is what the terminal's own
|
|
141
|
+
* selection would have looked like. Naming either colour replaces that
|
|
142
|
+
* rather than adding to it.
|
|
143
|
+
*/
|
|
144
|
+
readonly selectionFg?: ColorValue,
|
|
145
|
+
readonly selectionBg?: ColorValue,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The mouse handlers a node can carry, under OpenTUI's names.
|
|
150
|
+
*
|
|
151
|
+
* Every one of them receives events that started on this box *or on anything
|
|
152
|
+
* inside it*, because a mouse event bubbles: a panel can handle a click
|
|
153
|
+
* anywhere in it without every child forwarding one. `event.target` says which
|
|
154
|
+
* box the pointer is over and `event.currentTarget` says which box is handling
|
|
155
|
+
* it, exactly as they do in a browser, and `event.stopPropagation()` is how a
|
|
156
|
+
* child keeps one to itself.
|
|
157
|
+
*
|
|
158
|
+
* A captured drag is the one case where those two are not on the same path:
|
|
159
|
+
* the event goes to the box the press landed on — `event.source` — while
|
|
160
|
+
* `event.target` keeps naming what the pointer has since moved over, because
|
|
161
|
+
* that is what a drag handler needs in order to know what it would drop onto.
|
|
162
|
+
*
|
|
163
|
+
* Nothing arrives unless the application asked `render` for the mouse. A tree
|
|
164
|
+
* with handlers on it and `mouse: false` is not an error and is not silently
|
|
165
|
+
* broken either — it is an application that has not turned the device on, and
|
|
166
|
+
* `testRender` turns it on by default so that a test does not have to.
|
|
167
|
+
*
|
|
168
|
+
* `event.preventDefault()` in an `onMouseDown` keeps the press from clearing
|
|
169
|
+
* the reader's selection and from starting a new one. That is the renderer's
|
|
170
|
+
* only default, so it is the only thing that method does; a box that means
|
|
171
|
+
* something else by a drag — a slider, a splitter, a canvas — is what it is
|
|
172
|
+
* for.
|
|
173
|
+
*/
|
|
174
|
+
export type MouseProps = {
|
|
175
|
+
/** Every mouse event, after the handler for its own type. */
|
|
176
|
+
readonly onMouse?: (event: MouseEvent) => void,
|
|
177
|
+
readonly onMouseDown?: (event: MouseEvent) => void,
|
|
178
|
+
readonly onMouseUp?: (event: MouseEvent) => void,
|
|
179
|
+
/** The pointer moved over this box with nothing held down. */
|
|
180
|
+
readonly onMouseMove?: (event: MouseEvent) => void,
|
|
181
|
+
/** The pointer moved with a button held, since it was pressed on this box. */
|
|
182
|
+
readonly onMouseDrag?: (event: MouseEvent) => void,
|
|
183
|
+
/** That drag ended, wherever the pointer had reached. */
|
|
184
|
+
readonly onMouseDragEnd?: (event: MouseEvent) => void,
|
|
185
|
+
/** A drag that began somewhere else ended here; `event.source` says where. */
|
|
186
|
+
readonly onMouseDrop?: (event: MouseEvent) => void,
|
|
187
|
+
/** The pointer entered this box, or a box inside it. */
|
|
188
|
+
readonly onMouseOver?: (event: MouseEvent) => void,
|
|
189
|
+
/** And left it. */
|
|
190
|
+
readonly onMouseOut?: (event: MouseEvent) => void,
|
|
191
|
+
/** The wheel turned; `event.scroll` says which way. */
|
|
192
|
+
readonly onMouseScroll?: (event: MouseEvent) => void,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** Everything a `Box` accepts beyond its children. */
|
|
196
|
+
export type BoxProps = {
|
|
197
|
+
...MouseProps,
|
|
198
|
+
...BoxLayoutProps,
|
|
199
|
+
...TextStyleProps,
|
|
200
|
+
/**
|
|
201
|
+
* A name for this box, carried by the mouse events it is involved in.
|
|
202
|
+
*
|
|
203
|
+
* `event.target`, `event.currentTarget` and `event.source` are ids rather
|
|
204
|
+
* than nodes, so a box that a drop has to be able to name needs one. Nothing
|
|
205
|
+
* else reads it, and two boxes with the same id are not an error — the
|
|
206
|
+
* events simply cannot tell them apart.
|
|
207
|
+
*/
|
|
208
|
+
readonly id?: string,
|
|
209
|
+
readonly style?: BoxLayoutProps,
|
|
210
|
+
readonly backgroundColor?: ColorValue,
|
|
211
|
+
readonly border?: boolean,
|
|
212
|
+
readonly borderStyle?: BorderStyle,
|
|
213
|
+
readonly borderColor?: ColorValue,
|
|
214
|
+
readonly title?: string,
|
|
215
|
+
readonly titleColor?: ColorValue,
|
|
216
|
+
readonly titleAlignment?: TitleAlignment,
|
|
217
|
+
readonly bottomTitle?: string,
|
|
218
|
+
readonly bottomTitleAlignment?: TitleAlignment,
|
|
219
|
+
/** Whether this box may hold focus at all. */
|
|
220
|
+
readonly focusable?: boolean,
|
|
221
|
+
/** Whether it holds focus now. Focus is state, as it is in OpenTUI. */
|
|
222
|
+
readonly focused?: boolean,
|
|
223
|
+
/** Keys delivered to this box while it holds focus. */
|
|
224
|
+
readonly onKeyDown?: (key: KeyEvent) => void,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** A flex container that can draw a background, a border, and two titles. */
|
|
228
|
+
export component Box(children?: React.Node, ...props: BoxProps) {
|
|
229
|
+
return React.createElement("uf-box", props, children);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Everything a `Text` accepts beyond its children. */
|
|
233
|
+
export type TextProps = {
|
|
234
|
+
...BoxLayoutProps,
|
|
235
|
+
...TextStyleProps,
|
|
236
|
+
readonly id?: string,
|
|
237
|
+
readonly style?: BoxLayoutProps,
|
|
238
|
+
/** How lines break: at word boundaries, anywhere, or not at all. */
|
|
239
|
+
readonly wrap?: WrapMode,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A run of styled text.
|
|
244
|
+
*
|
|
245
|
+
* Nest one inside another to change part of a line without repeating the
|
|
246
|
+
* style of the rest: the inner one inherits every attribute the outer one set
|
|
247
|
+
* and overrides only what it names.
|
|
248
|
+
*/
|
|
249
|
+
export component Text(children?: React.Node, ...props: TextProps) {
|
|
250
|
+
return React.createElement("uf-text", props, children);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Everything a `ScrollBox` accepts beyond its children. */
|
|
254
|
+
export type ScrollBoxProps = {
|
|
255
|
+
...BoxProps,
|
|
256
|
+
/**
|
|
257
|
+
* The first content row to show.
|
|
258
|
+
*
|
|
259
|
+
* Clamped by layout to the range the content actually has, which is what
|
|
260
|
+
* makes `Number.MAX_SAFE_INTEGER` mean "the bottom" — a log that has just
|
|
261
|
+
* grown by a line does not have to know how long it is to keep following
|
|
262
|
+
* it.
|
|
263
|
+
*/
|
|
264
|
+
readonly scrollTop?: number,
|
|
265
|
+
/** Whether to draw the bar. On by default; it costs a column. */
|
|
266
|
+
readonly scrollbar?: boolean,
|
|
267
|
+
/** The bar's colour. Falls back to `borderColor`. */
|
|
268
|
+
readonly scrollbarColor?: ColorValue,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A window onto content taller than itself.
|
|
273
|
+
*
|
|
274
|
+
* Give it a height — an explicit one, or `flexGrow` inside a parent that has
|
|
275
|
+
* one. A `ScrollBox` with neither is as tall as its content and scrolls
|
|
276
|
+
* nothing, which is flexbox behaving correctly and not what anybody meant; and
|
|
277
|
+
* between a header and a footer those two want `flexShrink={0}`, because a
|
|
278
|
+
* box asking for ten thousand rows shrinks whatever is allowed to shrink.
|
|
279
|
+
*
|
|
280
|
+
* ```js
|
|
281
|
+
* const [top, setTop] = useState<number>(Number.MAX_SAFE_INTEGER);
|
|
282
|
+
* useKeyboard((key) => {
|
|
283
|
+
* if (key.name === "up") setTop((row) => Math.max(0, row - 1));
|
|
284
|
+
* if (key.name === "down") setTop((row) => row + 1);
|
|
285
|
+
* });
|
|
286
|
+
* return (
|
|
287
|
+
* <ScrollBox height={10} scrollTop={top}>
|
|
288
|
+
* {lines.map((line) => <Text key={line.id}>{line.text}</Text>)}
|
|
289
|
+
* </ScrollBox>
|
|
290
|
+
* );
|
|
291
|
+
* ```
|
|
292
|
+
*
|
|
293
|
+
* # Why the offset is the caller's and the keys are not bound
|
|
294
|
+
*
|
|
295
|
+
* OpenTUI's rule for focus is that it is a prop rather than something the
|
|
296
|
+
* library moves for you, and scrolling is the same question one level down:
|
|
297
|
+
* what an arrow key should do inside a scrolling region is the application's
|
|
298
|
+
* business — a log follows its tail, a file viewer does not, and a list moves
|
|
299
|
+
* a selection and lets the box follow *that*. A component that owned the
|
|
300
|
+
* offset would also have to own "how far is a page", which is the viewport's
|
|
301
|
+
* height, which it does not know until after layout has run. Clamping in
|
|
302
|
+
* layout is what lets the caller ask for the bottom without knowing where the
|
|
303
|
+
* bottom is.
|
|
304
|
+
*
|
|
305
|
+
* # The wheel is an event, not a behaviour
|
|
306
|
+
*
|
|
307
|
+
* A wheel over this box arrives as `onMouseScroll`, and moving the offset is
|
|
308
|
+
* still the caller's — the same rule as the keys, for the same reason. Three
|
|
309
|
+
* lines is the whole of it:
|
|
310
|
+
*
|
|
311
|
+
* ```js
|
|
312
|
+
* <ScrollBox
|
|
313
|
+
* height={10}
|
|
314
|
+
* scrollTop={top}
|
|
315
|
+
* onMouseScroll={(event) => {
|
|
316
|
+
* setTop((row) => Math.max(0, row + (event.scroll?.direction === "up" ? -3 : 3)));
|
|
317
|
+
* }}
|
|
318
|
+
* />
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* Three rows a notch is this example's choice, not this component's: how far a
|
|
322
|
+
* notch goes is a question about the content — a log, a form, a picture — and
|
|
323
|
+
* a component that answered it would be answering it for all three.
|
|
324
|
+
*
|
|
325
|
+
* There is still no horizontal scrolling: a terminal column is not a pixel,
|
|
326
|
+
* and content wider than the window is nearly always content that should have
|
|
327
|
+
* wrapped.
|
|
328
|
+
*
|
|
329
|
+
* # What it costs
|
|
330
|
+
*
|
|
331
|
+
* The window, and not the content. Moving the offset over a hundred thousand
|
|
332
|
+
* rows measures none of them, lays out and paints the ones on the screen, and
|
|
333
|
+
* never visits the rest; appending a line to that log measures the line. The
|
|
334
|
+
* first frame is the exception and has to be: the height of the content is
|
|
335
|
+
* what `Number.MAX_SAFE_INTEGER` is clamped against, so every row is asked its
|
|
336
|
+
* height once, and after that the answer is kept until something under the row
|
|
337
|
+
* changes.
|
|
338
|
+
*
|
|
339
|
+
* That is a property of `layout.js` rather than of this component, which is
|
|
340
|
+
* why this is a component at all — a caller cannot decide which of their
|
|
341
|
+
* children to render, because which ones are visible is not known until after
|
|
342
|
+
* layout has run.
|
|
343
|
+
*/
|
|
344
|
+
export component ScrollBox(
|
|
345
|
+
children?: React.Node,
|
|
346
|
+
scrollTop?: number = 0,
|
|
347
|
+
scrollbar?: boolean = true,
|
|
348
|
+
scrollbarColor?: ColorValue,
|
|
349
|
+
...props: BoxProps
|
|
350
|
+
) {
|
|
351
|
+
// The bar is drawn in the column the box reserves for it, so wrapped content
|
|
352
|
+
// never reaches it. Reserving it here rather than in the painter is what
|
|
353
|
+
// keeps layout and paint agreeing about how wide a row is.
|
|
354
|
+
//
|
|
355
|
+
// The caller's own right padding is read through both spellings a `Box`
|
|
356
|
+
// accepts, and added to rather than replaced: a `ScrollBox` with `padding={1}`
|
|
357
|
+
// is padded by one and has a bar, not padded by nothing and has a bar.
|
|
358
|
+
const own = props.style ?? props;
|
|
359
|
+
const asked =
|
|
360
|
+
props.paddingRight ??
|
|
361
|
+
own.paddingRight ??
|
|
362
|
+
props.paddingX ??
|
|
363
|
+
own.paddingX ??
|
|
364
|
+
props.padding ??
|
|
365
|
+
own.padding ??
|
|
366
|
+
0;
|
|
367
|
+
const paddingRight = scrollbar ? asked + 1 : props.paddingRight;
|
|
368
|
+
return React.createElement(
|
|
369
|
+
"uf-box",
|
|
370
|
+
{
|
|
371
|
+
...props,
|
|
372
|
+
paddingRight,
|
|
373
|
+
overflow: "scroll",
|
|
374
|
+
scrollTop,
|
|
375
|
+
scrollbar,
|
|
376
|
+
scrollbarColor,
|
|
377
|
+
},
|
|
378
|
+
children,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The renderer this tree is mounted in.
|
|
384
|
+
*
|
|
385
|
+
* Raises rather than returning `null` when there is none, because every way to
|
|
386
|
+
* reach this hook goes through a mounted root and a `null` here means the
|
|
387
|
+
* component is being rendered by something else — `react-dom`, say, which will
|
|
388
|
+
* then fail much further away with a message about `uf-box` not being a valid
|
|
389
|
+
* HTML element.
|
|
390
|
+
*/
|
|
391
|
+
export function useRenderer(): Renderer {
|
|
392
|
+
const renderer = React.useContext(RendererContext);
|
|
393
|
+
if (renderer == null) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
"@uniflowed/tui: a component was rendered outside a TUI root. " +
|
|
396
|
+
"Mount it with `render()` from @uniflowed/tui.",
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
return renderer;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Handle keys before the focused node sees them.
|
|
404
|
+
*
|
|
405
|
+
* Registered in mount order and removed on cleanup, so a handler belonging to
|
|
406
|
+
* a component that has unmounted cannot receive a key — the leak that makes an
|
|
407
|
+
* application respond to a shortcut belonging to a screen it has left.
|
|
408
|
+
*
|
|
409
|
+
* The handler is kept in a ref and the subscription depends on nothing, which
|
|
410
|
+
* is deliberate: a caller who writes `useKeyboard((key) => …)` with an inline
|
|
411
|
+
* arrow would otherwise re-subscribe on every render, and the order handlers
|
|
412
|
+
* run in — which OpenTUI specifies as registration order — would silently
|
|
413
|
+
* become "whichever component rendered last".
|
|
414
|
+
*/
|
|
415
|
+
export function useKeyboard(handler: (key: KeyEvent) => void): void {
|
|
416
|
+
const renderer = useRenderer();
|
|
417
|
+
const latest = useRef(handler);
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
latest.current = handler;
|
|
420
|
+
});
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
const listener = (key: KeyEvent) => latest.current(key);
|
|
423
|
+
renderer.keyHandlers.push(listener);
|
|
424
|
+
return () => {
|
|
425
|
+
const at = renderer.keyHandlers.indexOf(listener);
|
|
426
|
+
if (at >= 0) {
|
|
427
|
+
renderer.keyHandlers.splice(at, 1);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}, [renderer]);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The terminal's current size, re-rendering the caller when it changes. */
|
|
434
|
+
export function useTerminalSize(): { readonly width: number, readonly height: number } {
|
|
435
|
+
const renderer = useRenderer();
|
|
436
|
+
const subscribe = useCallback(
|
|
437
|
+
(notify: () => void) => {
|
|
438
|
+
renderer.sizeListeners.add(notify);
|
|
439
|
+
return () => {
|
|
440
|
+
renderer.sizeListeners.delete(notify);
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
[renderer],
|
|
444
|
+
);
|
|
445
|
+
const snapshot = useCallback(() => renderer.size, [renderer]);
|
|
446
|
+
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Everything an `Input` accepts. */
|
|
450
|
+
export type InputProps = {
|
|
451
|
+
...BoxLayoutProps,
|
|
452
|
+
/** The current text, when the caller controls it. */
|
|
453
|
+
readonly value?: string,
|
|
454
|
+
/** The initial text, when it does not. */
|
|
455
|
+
readonly defaultValue?: string,
|
|
456
|
+
/** What to show when the value is empty. */
|
|
457
|
+
readonly placeholder?: string,
|
|
458
|
+
/** Whether this input has focus. */
|
|
459
|
+
readonly focused?: boolean,
|
|
460
|
+
/** Called with the new text on every edit. */
|
|
461
|
+
readonly onInput?: (value: string) => void,
|
|
462
|
+
/** Called with the text when Enter is pressed. */
|
|
463
|
+
readonly onSubmit?: (value: string) => void,
|
|
464
|
+
readonly fg?: ColorValue,
|
|
465
|
+
readonly bg?: ColorValue,
|
|
466
|
+
readonly placeholderColor?: ColorValue,
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* One line of text a reader types into.
|
|
471
|
+
*
|
|
472
|
+
* Controlled when `value` is given and uncontrolled otherwise, which is
|
|
473
|
+
* React's convention and the one a caller expects. The cursor is always this
|
|
474
|
+
* component's own state: it is a property of the editing session and not of
|
|
475
|
+
* the value, and a controlled input whose parent re-sends the same string
|
|
476
|
+
* must not have its cursor jump to the end — the single most common bug in
|
|
477
|
+
* hand-written terminal inputs.
|
|
478
|
+
*
|
|
479
|
+
* The cursor is drawn as an inverse-video cell rather than by moving the
|
|
480
|
+
* terminal's real cursor. A real cursor is one per terminal and this renderer
|
|
481
|
+
* has no idea whether the application wants it here, over a list selection, or
|
|
482
|
+
* hidden; an inverse cell is a property of the frame, so it composes.
|
|
483
|
+
*/
|
|
484
|
+
export component Input(
|
|
485
|
+
value?: string,
|
|
486
|
+
defaultValue?: string = "",
|
|
487
|
+
placeholder?: string = "",
|
|
488
|
+
focused?: boolean = false,
|
|
489
|
+
onInput?: (value: string) => void,
|
|
490
|
+
onSubmit?: (value: string) => void,
|
|
491
|
+
fg?: ColorValue,
|
|
492
|
+
bg?: ColorValue,
|
|
493
|
+
placeholderColor?: ColorValue = "gray",
|
|
494
|
+
...layout: BoxLayoutProps
|
|
495
|
+
) {
|
|
496
|
+
const [internal, setInternal] = useState<string>(defaultValue);
|
|
497
|
+
const text = value ?? internal;
|
|
498
|
+
const [cursor, setCursor] = useState<number>(text.length);
|
|
499
|
+
const at = Math.min(cursor, text.length);
|
|
500
|
+
|
|
501
|
+
const change = useCallback(
|
|
502
|
+
(next: string, nextCursor: number) => {
|
|
503
|
+
if (value == null) {
|
|
504
|
+
setInternal(next);
|
|
505
|
+
}
|
|
506
|
+
setCursor(nextCursor);
|
|
507
|
+
if (onInput != null) {
|
|
508
|
+
onInput(next);
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
[onInput, value],
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const onKeyDown = useCallback(
|
|
515
|
+
(key: KeyEvent) => {
|
|
516
|
+
if (key.name === "return") {
|
|
517
|
+
if (onSubmit != null) {
|
|
518
|
+
onSubmit(text);
|
|
519
|
+
}
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (key.name === "backspace") {
|
|
523
|
+
if (at > 0) {
|
|
524
|
+
change(text.slice(0, at - 1) + text.slice(at), at - 1);
|
|
525
|
+
}
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (key.name === "delete") {
|
|
529
|
+
if (at < text.length) {
|
|
530
|
+
change(text.slice(0, at) + text.slice(at + 1), at);
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (key.name === "left") {
|
|
535
|
+
setCursor(Math.max(0, at - 1));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (key.name === "right") {
|
|
539
|
+
setCursor(Math.min(text.length, at + 1));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (key.name === "home") {
|
|
543
|
+
setCursor(0);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (key.name === "end") {
|
|
547
|
+
setCursor(text.length);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
// A paste is text somebody had on a clipboard, which is why it is worth
|
|
551
|
+
// knowing it was a paste: this is one line and the clipboard is not, so
|
|
552
|
+
// the first line goes in and the rest is dropped rather than pasted as a
|
|
553
|
+
// series of Enters. Control characters go with it — a pasted `\u0007`
|
|
554
|
+
// is a bell somebody would otherwise hear every time the frame redrew.
|
|
555
|
+
if (key.name === "paste") {
|
|
556
|
+
const first = key.sequence.split(/\r\n|\r|\n/)[0] ?? "";
|
|
557
|
+
const clean = first.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
558
|
+
if (clean !== "") {
|
|
559
|
+
change(text.slice(0, at) + clean + text.slice(at), at + clean.length);
|
|
560
|
+
}
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Anything with text behind it is an insertion. `sequence` rather than
|
|
564
|
+
// `name`, because `name` is lowercased — typing a capital letter through
|
|
565
|
+
// `name` produces a lowercase one.
|
|
566
|
+
if (!key.ctrl && !key.meta && key.sequence !== "" && key.sequence !== "\r") {
|
|
567
|
+
change(text.slice(0, at) + key.sequence + text.slice(at), at + key.sequence.length);
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
[at, change, onSubmit, text],
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const showPlaceholder = text === "" && placeholder !== "";
|
|
574
|
+
const body = useMemo(() => {
|
|
575
|
+
if (showPlaceholder) {
|
|
576
|
+
if (!focused) {
|
|
577
|
+
return React.createElement(Text, { fg: placeholderColor, wrap: "none" }, placeholder);
|
|
578
|
+
}
|
|
579
|
+
return React.createElement(
|
|
580
|
+
Text,
|
|
581
|
+
{ fg: placeholderColor, wrap: "none" },
|
|
582
|
+
React.createElement(Text, { inverse: true }, placeholder.slice(0, 1)),
|
|
583
|
+
placeholder.slice(1),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (!focused) {
|
|
587
|
+
return React.createElement(Text, { fg, bg, wrap: "none" }, text);
|
|
588
|
+
}
|
|
589
|
+
// Three runs: what is before the cursor, the cell under it, and what is
|
|
590
|
+
// after. The cell under the cursor is a space when the cursor sits past
|
|
591
|
+
// the end of the text, which is where it is while somebody is typing.
|
|
592
|
+
return React.createElement(
|
|
593
|
+
Text,
|
|
594
|
+
{ fg, bg, wrap: "none" },
|
|
595
|
+
text.slice(0, at),
|
|
596
|
+
React.createElement(Text, { inverse: true }, at < text.length ? text.slice(at, at + 1) : " "),
|
|
597
|
+
text.slice(at + 1),
|
|
598
|
+
);
|
|
599
|
+
}, [at, bg, fg, focused, placeholder, placeholderColor, showPlaceholder, text]);
|
|
600
|
+
|
|
601
|
+
return React.createElement(Box, { ...layout, focusable: true, focused, onKeyDown }, body);
|
|
602
|
+
}
|