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