@uniflowed/tui 0.0.0-alpha.18 → 0.15.0
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 +15 -12
- package/cells.js +1 -1
- package/clipboard.js +61 -0
- package/components.js +766 -57
- package/index.js +41 -22
- package/internal/hits.js +2 -2
- package/internal/host.js +63 -22
- package/internal/paint.js +43 -2
- package/internal/tree.js +168 -38
- package/internal/widgets.js +771 -0
- package/keys.js +214 -15
- package/layout.js +653 -132
- package/package.json +5 -4
- package/terminal.js +49 -19
- package/widths.js +4 -1
package/components.js
CHANGED
|
@@ -2,18 +2,21 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The components a caller writes, and the hooks they reach for.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
5
|
+
// Seven components. The first four were most of the argument of the first
|
|
6
|
+
// two releases: `Box` is a flex container that can draw a frame around itself;
|
|
7
|
+
// `Text` is a run of styled characters that knows how to wrap; `Input` is a
|
|
8
|
+
// line a reader types into; `ScrollBox` is a window onto content taller than
|
|
9
|
+
// it. Much of what else OpenTUI offers — a table, a diff view — is those plus
|
|
10
|
+
// state, and shipping them badly is worse than not shipping them, so they are
|
|
11
|
+
// ubugeeei-prod/uf#314 rather than stubs that throw.
|
|
12
12
|
//
|
|
13
13
|
// `ScrollBox` is the exception to "plus state", which is why it is a component
|
|
14
14
|
// here rather than something a caller writes: which children are laid out and
|
|
15
15
|
// painted depends on where the window is, and nothing above the renderer can
|
|
16
|
-
// decide that.
|
|
16
|
+
// decide that. The last three — `Select`, `TabSelect` and `Textarea` — are the
|
|
17
|
+
// same exception for the same reason: which items a select shows and where a
|
|
18
|
+
// textarea's lines break depend on the size layout gives them, so they draw
|
|
19
|
+
// themselves after layout. `internal/widgets.js` argues it out.
|
|
17
20
|
//
|
|
18
21
|
// # Why these are `component`s and not intrinsic elements
|
|
19
22
|
//
|
|
@@ -35,11 +38,12 @@
|
|
|
35
38
|
//
|
|
36
39
|
// Nothing that `ubugeeei-redundancy.md` forbids. Nothing below mutates during
|
|
37
40
|
// render, reads a ref during render, or depends on a render happening exactly
|
|
38
|
-
// once. `Input`
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
41
|
+
// once. `Input` and `Textarea` keep their cursors in state, not in a ref that
|
|
42
|
+
// a render reads — `Textarea` reads its node through a ref only inside a key
|
|
43
|
+
// handler, to know how wide its lines were drawn; `useTerminalSize` subscribes
|
|
44
|
+
// with `useSyncExternalStore` and returns a snapshot that is stable between
|
|
45
|
+
// resizes; `ScrollBox` owns no scroll state at all. All seven are safe under
|
|
46
|
+
// Strict Mode's double invocation and under the React Compiler's memoization.
|
|
43
47
|
|
|
44
48
|
import * as React from "@uniflowed/react";
|
|
45
49
|
import {
|
|
@@ -52,20 +56,35 @@ import {
|
|
|
52
56
|
} from "@uniflowed/react";
|
|
53
57
|
|
|
54
58
|
import type { BorderStyle } from "./capability.js";
|
|
59
|
+
import type { Clipboard } from "./clipboard.js";
|
|
55
60
|
import type { KeyEvent } from "./keys.js";
|
|
56
61
|
import type { MouseEvent } from "./mouse.js";
|
|
57
62
|
import type {
|
|
63
|
+
AlignContent,
|
|
58
64
|
AlignItems,
|
|
59
65
|
AlignSelf,
|
|
60
66
|
Dimension,
|
|
61
67
|
FlexDirection,
|
|
68
|
+
FlexWrap,
|
|
62
69
|
JustifyContent,
|
|
63
70
|
LayoutStyle,
|
|
71
|
+
Margin,
|
|
64
72
|
Overflow,
|
|
73
|
+
Position,
|
|
65
74
|
} from "./layout.js";
|
|
66
75
|
import type { WrapMode } from "./internal/paint.js";
|
|
67
76
|
import type { Renderer } from "./internal/host.js";
|
|
68
77
|
import { RendererContext } from "./internal/host.js";
|
|
78
|
+
import type { TuiNode } from "./internal/tree.js";
|
|
79
|
+
import type { EditWrapMode, SelectOption } from "./internal/widgets.js";
|
|
80
|
+
import {
|
|
81
|
+
contentBox,
|
|
82
|
+
editLines,
|
|
83
|
+
lineEnd,
|
|
84
|
+
locate,
|
|
85
|
+
offsetAt,
|
|
86
|
+
tabSelectHeight,
|
|
87
|
+
} from "./internal/widgets.js";
|
|
69
88
|
|
|
70
89
|
/** A colour, as `"#rrggbb"`, one of the sixteen names, or a packed number. */
|
|
71
90
|
export type ColorValue = string | number;
|
|
@@ -82,8 +101,10 @@ export type TitleAlignment = "left" | "center" | "right";
|
|
|
82
101
|
*/
|
|
83
102
|
export type BoxLayoutProps = {
|
|
84
103
|
readonly flexDirection?: FlexDirection,
|
|
104
|
+
readonly flexWrap?: FlexWrap,
|
|
85
105
|
readonly justifyContent?: JustifyContent,
|
|
86
106
|
readonly alignItems?: AlignItems,
|
|
107
|
+
readonly alignContent?: AlignContent,
|
|
87
108
|
readonly alignSelf?: AlignSelf,
|
|
88
109
|
readonly flexGrow?: number,
|
|
89
110
|
readonly flexShrink?: number,
|
|
@@ -101,15 +122,37 @@ export type BoxLayoutProps = {
|
|
|
101
122
|
readonly paddingRight?: number,
|
|
102
123
|
readonly paddingBottom?: number,
|
|
103
124
|
readonly paddingLeft?: number,
|
|
104
|
-
readonly margin?:
|
|
105
|
-
readonly marginTop?:
|
|
106
|
-
readonly marginRight?:
|
|
107
|
-
readonly marginBottom?:
|
|
108
|
-
readonly marginLeft?:
|
|
125
|
+
readonly margin?: Margin,
|
|
126
|
+
readonly marginTop?: Margin,
|
|
127
|
+
readonly marginRight?: Margin,
|
|
128
|
+
readonly marginBottom?: Margin,
|
|
129
|
+
readonly marginLeft?: Margin,
|
|
109
130
|
readonly gap?: number,
|
|
110
131
|
readonly rowGap?: number,
|
|
111
132
|
readonly columnGap?: number,
|
|
112
133
|
readonly overflow?: Overflow,
|
|
134
|
+
/**
|
|
135
|
+
* In its parent's flex line (`"relative"`, the default) or out of it
|
|
136
|
+
* (`"absolute"`). `"static"` is in the line too, but is not what an
|
|
137
|
+
* absolute box under it is placed against: that is the nearest ancestor
|
|
138
|
+
* that is not static.
|
|
139
|
+
*/
|
|
140
|
+
readonly position?: Position,
|
|
141
|
+
/**
|
|
142
|
+
* Offsets: where an absolutely positioned box sits against the inside of
|
|
143
|
+
* its containing block's border, or how far a relative one is nudged from
|
|
144
|
+
* where its line put it. A static box ignores them. Cells or a percentage;
|
|
145
|
+
* negative is allowed.
|
|
146
|
+
*/
|
|
147
|
+
readonly top?: Dimension,
|
|
148
|
+
readonly right?: Dimension,
|
|
149
|
+
readonly bottom?: Dimension,
|
|
150
|
+
readonly left?: Dimension,
|
|
151
|
+
/**
|
|
152
|
+
* Which of two overlapping siblings is on top: the higher one, painted
|
|
153
|
+
* later. Ties, and siblings that give none, stack in tree order.
|
|
154
|
+
*/
|
|
155
|
+
readonly zIndex?: number,
|
|
113
156
|
};
|
|
114
157
|
|
|
115
158
|
/** Everything that paints a node's text. Inherited by nested `Text`. */
|
|
@@ -173,23 +216,23 @@ export type TextStyleProps = {
|
|
|
173
216
|
*/
|
|
174
217
|
export type MouseProps = {
|
|
175
218
|
/** Every mouse event, after the handler for its own type. */
|
|
176
|
-
readonly onMouse?: (event: MouseEvent) =>
|
|
177
|
-
readonly onMouseDown?: (event: MouseEvent) =>
|
|
178
|
-
readonly onMouseUp?: (event: MouseEvent) =>
|
|
219
|
+
readonly onMouse?: (event: MouseEvent) => mixed,
|
|
220
|
+
readonly onMouseDown?: (event: MouseEvent) => mixed,
|
|
221
|
+
readonly onMouseUp?: (event: MouseEvent) => mixed,
|
|
179
222
|
/** The pointer moved over this box with nothing held down. */
|
|
180
|
-
readonly onMouseMove?: (event: MouseEvent) =>
|
|
223
|
+
readonly onMouseMove?: (event: MouseEvent) => mixed,
|
|
181
224
|
/** The pointer moved with a button held, since it was pressed on this box. */
|
|
182
|
-
readonly onMouseDrag?: (event: MouseEvent) =>
|
|
225
|
+
readonly onMouseDrag?: (event: MouseEvent) => mixed,
|
|
183
226
|
/** That drag ended, wherever the pointer had reached. */
|
|
184
|
-
readonly onMouseDragEnd?: (event: MouseEvent) =>
|
|
227
|
+
readonly onMouseDragEnd?: (event: MouseEvent) => mixed,
|
|
185
228
|
/** A drag that began somewhere else ended here; `event.source` says where. */
|
|
186
|
-
readonly onMouseDrop?: (event: MouseEvent) =>
|
|
229
|
+
readonly onMouseDrop?: (event: MouseEvent) => mixed,
|
|
187
230
|
/** The pointer entered this box, or a box inside it. */
|
|
188
|
-
readonly onMouseOver?: (event: MouseEvent) =>
|
|
231
|
+
readonly onMouseOver?: (event: MouseEvent) => mixed,
|
|
189
232
|
/** And left it. */
|
|
190
|
-
readonly onMouseOut?: (event: MouseEvent) =>
|
|
233
|
+
readonly onMouseOut?: (event: MouseEvent) => mixed,
|
|
191
234
|
/** The wheel turned; `event.scroll` says which way. */
|
|
192
|
-
readonly onMouseScroll?: (event: MouseEvent) =>
|
|
235
|
+
readonly onMouseScroll?: (event: MouseEvent) => mixed,
|
|
193
236
|
};
|
|
194
237
|
|
|
195
238
|
/** Everything a `Box` accepts beyond its children. */
|
|
@@ -221,12 +264,12 @@ export type BoxProps = {
|
|
|
221
264
|
/** Whether it holds focus now. Focus is state, as it is in OpenTUI. */
|
|
222
265
|
readonly focused?: boolean,
|
|
223
266
|
/** Keys delivered to this box while it holds focus. */
|
|
224
|
-
readonly onKeyDown?: (key: KeyEvent) =>
|
|
267
|
+
readonly onKeyDown?: (key: KeyEvent) => mixed,
|
|
225
268
|
};
|
|
226
269
|
|
|
227
270
|
/** A flex container that can draw a background, a border, and two titles. */
|
|
228
271
|
export component Box(children?: React.Node, ...props: BoxProps) {
|
|
229
|
-
return
|
|
272
|
+
return <uf-box {...props}>{children}</uf-box>;
|
|
230
273
|
}
|
|
231
274
|
|
|
232
275
|
/** Everything a `Text` accepts beyond its children. */
|
|
@@ -247,7 +290,7 @@ export type TextProps = {
|
|
|
247
290
|
* and overrides only what it names.
|
|
248
291
|
*/
|
|
249
292
|
export component Text(children?: React.Node, ...props: TextProps) {
|
|
250
|
-
return
|
|
293
|
+
return <uf-text {...props}>{children}</uf-text>;
|
|
251
294
|
}
|
|
252
295
|
|
|
253
296
|
/** Everything a `ScrollBox` accepts beyond its children. */
|
|
@@ -365,17 +408,17 @@ export component ScrollBox(
|
|
|
365
408
|
own.padding ??
|
|
366
409
|
0;
|
|
367
410
|
const paddingRight = scrollbar ? asked + 1 : props.paddingRight;
|
|
368
|
-
return
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
411
|
+
return (
|
|
412
|
+
<uf-box
|
|
413
|
+
{...props}
|
|
414
|
+
paddingRight={paddingRight}
|
|
415
|
+
overflow="scroll"
|
|
416
|
+
scrollTop={scrollTop}
|
|
417
|
+
scrollbar={scrollbar}
|
|
418
|
+
scrollbarColor={scrollbarColor}
|
|
419
|
+
>
|
|
420
|
+
{children}
|
|
421
|
+
</uf-box>
|
|
379
422
|
);
|
|
380
423
|
}
|
|
381
424
|
|
|
@@ -412,7 +455,7 @@ export function useRenderer(): Renderer {
|
|
|
412
455
|
* run in — which OpenTUI specifies as registration order — would silently
|
|
413
456
|
* become "whichever component rendered last".
|
|
414
457
|
*/
|
|
415
|
-
export function useKeyboard(handler: (key: KeyEvent) =>
|
|
458
|
+
export function useKeyboard(handler: (key: KeyEvent) => mixed): void {
|
|
416
459
|
const renderer = useRenderer();
|
|
417
460
|
const latest = useRef(handler);
|
|
418
461
|
useEffect(() => {
|
|
@@ -446,6 +489,20 @@ export function useTerminalSize(): { readonly width: number, readonly height: nu
|
|
|
446
489
|
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
447
490
|
}
|
|
448
491
|
|
|
492
|
+
/**
|
|
493
|
+
* The terminal clipboard.
|
|
494
|
+
*
|
|
495
|
+
* Interactive terminals get OSC 52: `copy(text)` writes the escape sequence
|
|
496
|
+
* that asks the terminal emulator to put `text` on the system clipboard.
|
|
497
|
+
* Redirected output and in-memory renders report `supported: false` and
|
|
498
|
+
* return `false`, because a log file cannot carry an operating-system
|
|
499
|
+
* clipboard side effect. OSC 52 has no acknowledgement, so a `true` return
|
|
500
|
+
* means the request was written, not that the terminal policy accepted it.
|
|
501
|
+
*/
|
|
502
|
+
export function useClipboard(): Clipboard {
|
|
503
|
+
return useRenderer().clipboard;
|
|
504
|
+
}
|
|
505
|
+
|
|
449
506
|
/** Everything an `Input` accepts. */
|
|
450
507
|
export type InputProps = {
|
|
451
508
|
...BoxLayoutProps,
|
|
@@ -513,6 +570,9 @@ export component Input(
|
|
|
513
570
|
|
|
514
571
|
const onKeyDown = useCallback(
|
|
515
572
|
(key: KeyEvent) => {
|
|
573
|
+
if (key.eventType === "release") {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
516
576
|
if (key.name === "return") {
|
|
517
577
|
if (onSubmit != null) {
|
|
518
578
|
onSubmit(text);
|
|
@@ -574,29 +634,678 @@ export component Input(
|
|
|
574
634
|
const body = useMemo(() => {
|
|
575
635
|
if (showPlaceholder) {
|
|
576
636
|
if (!focused) {
|
|
577
|
-
return
|
|
637
|
+
return (
|
|
638
|
+
<Text fg={placeholderColor} wrap="none">
|
|
639
|
+
{placeholder}
|
|
640
|
+
</Text>
|
|
641
|
+
);
|
|
578
642
|
}
|
|
579
|
-
return
|
|
580
|
-
Text
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
643
|
+
return (
|
|
644
|
+
<Text fg={placeholderColor} wrap="none">
|
|
645
|
+
<Text inverse>{placeholder.slice(0, 1)}</Text>
|
|
646
|
+
{placeholder.slice(1)}
|
|
647
|
+
</Text>
|
|
584
648
|
);
|
|
585
649
|
}
|
|
586
650
|
if (!focused) {
|
|
587
|
-
return
|
|
651
|
+
return (
|
|
652
|
+
<Text bg={bg} fg={fg} wrap="none">
|
|
653
|
+
{text}
|
|
654
|
+
</Text>
|
|
655
|
+
);
|
|
588
656
|
}
|
|
589
657
|
// Three runs: what is before the cursor, the cell under it, and what is
|
|
590
658
|
// after. The cell under the cursor is a space when the cursor sits past
|
|
591
659
|
// the end of the text, which is where it is while somebody is typing.
|
|
592
|
-
return
|
|
593
|
-
Text
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
660
|
+
return (
|
|
661
|
+
<Text bg={bg} fg={fg} wrap="none">
|
|
662
|
+
{text.slice(0, at)}
|
|
663
|
+
<Text inverse>{at < text.length ? text.slice(at, at + 1) : " "}</Text>
|
|
664
|
+
{text.slice(at + 1)}
|
|
665
|
+
</Text>
|
|
598
666
|
);
|
|
599
667
|
}, [at, bg, fg, focused, placeholder, placeholderColor, showPlaceholder, text]);
|
|
600
668
|
|
|
601
|
-
return
|
|
669
|
+
return (
|
|
670
|
+
<Box {...layout} focusable focused={focused} onKeyDown={onKeyDown}>
|
|
671
|
+
{body}
|
|
672
|
+
</Box>
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Whether `key` is exactly `name` with exactly these modifiers held.
|
|
678
|
+
*
|
|
679
|
+
* Exactly, because that is how OpenTUI matches a binding: `up` and `shift+up`
|
|
680
|
+
* are two bindings that do two different things in a `Select`, and a match
|
|
681
|
+
* that ignored Shift would make the second unreachable.
|
|
682
|
+
*/
|
|
683
|
+
function bound(
|
|
684
|
+
key: KeyEvent,
|
|
685
|
+
name: string,
|
|
686
|
+
ctrl: boolean = false,
|
|
687
|
+
shift: boolean = false,
|
|
688
|
+
meta: boolean = false,
|
|
689
|
+
): boolean {
|
|
690
|
+
return key.name === name && key.ctrl === ctrl && key.shift === shift && key.meta === meta;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* An index the caller owns the initial value of, and the component moves.
|
|
695
|
+
*
|
|
696
|
+
* OpenTUI's `selectedIndex` is a property the component starts at and keeps
|
|
697
|
+
* moving on its own; setting it again moves it again. That is neither React's
|
|
698
|
+
* controlled nor its uncontrolled pattern, and it is reproduced as it is: the
|
|
699
|
+
* index is this component's state, and a *change* to the prop replaces it. The
|
|
700
|
+
* previous prop is kept beside the state so the change can be seen during the
|
|
701
|
+
* render that brings it — React's documented way of adjusting state to a prop,
|
|
702
|
+
* which runs no effect and draws no frame with the stale index in it.
|
|
703
|
+
*/
|
|
704
|
+
hook useMovingIndex(requested: number): [number, (next: number) => void] {
|
|
705
|
+
const [state, setState] = useState<{ index: number, requested: number }>({
|
|
706
|
+
index: requested,
|
|
707
|
+
requested,
|
|
708
|
+
});
|
|
709
|
+
let index = state.index;
|
|
710
|
+
if (state.requested !== requested) {
|
|
711
|
+
index = requested;
|
|
712
|
+
setState({ index: requested, requested });
|
|
713
|
+
}
|
|
714
|
+
const move = (next: number) => {
|
|
715
|
+
setState({ index: next, requested });
|
|
716
|
+
};
|
|
717
|
+
return [index, move];
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** The colours both selects accept, under OpenTUI's names. */
|
|
721
|
+
export type SelectColorProps = {
|
|
722
|
+
/** Behind the whole list. Unset is the terminal's own background. */
|
|
723
|
+
readonly backgroundColor?: ColorValue,
|
|
724
|
+
/** Every name but the selected one. Unset is the terminal's own colour. */
|
|
725
|
+
readonly textColor?: ColorValue,
|
|
726
|
+
/** `backgroundColor` while the select has focus. */
|
|
727
|
+
readonly focusedBackgroundColor?: ColorValue,
|
|
728
|
+
/** `textColor` while the select has focus. */
|
|
729
|
+
readonly focusedTextColor?: ColorValue,
|
|
730
|
+
/** Behind the selected item. OpenTUI's `#334455` by default. */
|
|
731
|
+
readonly selectedBackgroundColor?: ColorValue,
|
|
732
|
+
/** The selected item's name. OpenTUI's `#FFFF00` by default. */
|
|
733
|
+
readonly selectedTextColor?: ColorValue,
|
|
734
|
+
/** The selected item's description. OpenTUI's `#CCCCCC` by default. */
|
|
735
|
+
readonly selectedDescriptionColor?: ColorValue,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
/** Everything a `Select` accepts. */
|
|
739
|
+
export type SelectProps = {
|
|
740
|
+
...BoxLayoutProps,
|
|
741
|
+
...SelectColorProps,
|
|
742
|
+
readonly id?: string,
|
|
743
|
+
/** The items, in order. OpenTUI's `SelectOption`: a name, a description, a value. */
|
|
744
|
+
readonly options?: $ReadOnlyArray<SelectOption>,
|
|
745
|
+
/** The item selected to begin with, and whenever this prop changes. */
|
|
746
|
+
readonly selectedIndex?: number,
|
|
747
|
+
/** Whether this select has focus, and so receives the keys. */
|
|
748
|
+
readonly focused?: boolean,
|
|
749
|
+
/** The selection moved: up, down, or by `fastScrollStep` with Shift. */
|
|
750
|
+
readonly onChange?: (index: number, option: SelectOption | null) => void,
|
|
751
|
+
/** Enter was pressed on an item. */
|
|
752
|
+
readonly onSelect?: (index: number, option: SelectOption | null) => void,
|
|
753
|
+
/** Whether moving past either end comes round to the other. Off by default. */
|
|
754
|
+
readonly wrapSelection?: boolean,
|
|
755
|
+
/** Whether each item has its description under it. On by default. */
|
|
756
|
+
readonly showDescription?: boolean,
|
|
757
|
+
/** Whether the selected item has `▶ ` in front of it. On by default. */
|
|
758
|
+
readonly showSelectionIndicator?: boolean,
|
|
759
|
+
/** Whether a `█` down the right edge says where in the list the window is. */
|
|
760
|
+
readonly showScrollIndicator?: boolean,
|
|
761
|
+
/** Blank rows after each item. */
|
|
762
|
+
readonly itemSpacing?: number,
|
|
763
|
+
/** How many items Shift+Up and Shift+Down move. Five by default. */
|
|
764
|
+
readonly fastScrollStep?: number,
|
|
765
|
+
/** Every description but the selected one's. OpenTUI's `#888888` by default. */
|
|
766
|
+
readonly descriptionColor?: ColorValue,
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const EMPTY_OPTIONS: $ReadOnlyArray<SelectOption> = [];
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* A vertical list a reader moves through and chooses from.
|
|
773
|
+
*
|
|
774
|
+
* OpenTUI's `select`, with its keys: Up or `k` and Down or `j` move one item,
|
|
775
|
+
* Shift+Up and Shift+Down move `fastScrollStep`, and Enter chooses. Moving
|
|
776
|
+
* calls `onChange(index, option)` and choosing calls `onSelect(index, option)`
|
|
777
|
+
* — the two events OpenTUI's React binding exposes, under the same names and
|
|
778
|
+
* with the same arguments.
|
|
779
|
+
*
|
|
780
|
+
* ```js
|
|
781
|
+
* <Select
|
|
782
|
+
* focused={true}
|
|
783
|
+
* height={6}
|
|
784
|
+
* options={[
|
|
785
|
+
* { name: "build", description: "Compile the project" },
|
|
786
|
+
* { name: "test", description: "Run the suite" },
|
|
787
|
+
* ]}
|
|
788
|
+
* onSelect={(index, option) => run(option?.name)}
|
|
789
|
+
* />
|
|
790
|
+
* ```
|
|
791
|
+
*
|
|
792
|
+
* Give it a height to make it scroll. The selected item then stays in the
|
|
793
|
+
* middle of the rows there are, and the window stops at either end of the
|
|
794
|
+
* list, which is OpenTUI's rule. Without one it is as tall as all its items —
|
|
795
|
+
* a box here is as tall as its content, where OpenTUI's is as tall as its
|
|
796
|
+
* style and no taller.
|
|
797
|
+
*
|
|
798
|
+
* # What is not here
|
|
799
|
+
*
|
|
800
|
+
* `font`, which draws each name in one of OpenTUI's ASCII-art fonts: this
|
|
801
|
+
* package has no fonts, and `AsciiFont` is still ubugeeei-prod/uf#314.
|
|
802
|
+
* `keyBindings` and `keyAliasMap`, which rebind the keys: the keys above are
|
|
803
|
+
* the only ones, and a caller who wants others binds them with `useKeyboard`
|
|
804
|
+
* and moves `selectedIndex`. And the default colours for text that is *not*
|
|
805
|
+
* selected: OpenTUI draws it white, on `#1a1a1a` when focused, which is
|
|
806
|
+
* unreadable on a light terminal, so unset here means the terminal's own
|
|
807
|
+
* colours. The selected item's colours are OpenTUI's, because "selected" has
|
|
808
|
+
* to be visible whatever the theme.
|
|
809
|
+
*/
|
|
810
|
+
export component Select(...props: SelectProps) {
|
|
811
|
+
const {
|
|
812
|
+
options = EMPTY_OPTIONS,
|
|
813
|
+
selectedIndex = 0,
|
|
814
|
+
onChange,
|
|
815
|
+
onSelect,
|
|
816
|
+
wrapSelection = false,
|
|
817
|
+
fastScrollStep = 5,
|
|
818
|
+
...rest
|
|
819
|
+
} = props;
|
|
820
|
+
const [index, move] = useMovingIndex(selectedIndex);
|
|
821
|
+
const current = options.length > 0 ? Math.min(Math.max(0, index), options.length - 1) : 0;
|
|
822
|
+
|
|
823
|
+
const onKeyDown = (key: KeyEvent) => {
|
|
824
|
+
if (key.eventType === "release") {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const moveBy = (steps: number) => {
|
|
828
|
+
const target = current + steps;
|
|
829
|
+
let next: number;
|
|
830
|
+
if (target >= 0 && target < options.length) {
|
|
831
|
+
next = target;
|
|
832
|
+
} else if (wrapSelection && options.length > 0) {
|
|
833
|
+
next = steps < 0 ? options.length - 1 : 0;
|
|
834
|
+
} else {
|
|
835
|
+
next = steps < 0 ? 0 : Math.max(0, options.length - 1);
|
|
836
|
+
}
|
|
837
|
+
move(next);
|
|
838
|
+
// Reported even when the index did not change, at either end of a list
|
|
839
|
+
// that does not wrap. OpenTUI does the same.
|
|
840
|
+
if (onChange != null) {
|
|
841
|
+
onChange(next, options[next] ?? null);
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
if (bound(key, "up") || bound(key, "k")) {
|
|
845
|
+
moveBy(-1);
|
|
846
|
+
} else if (bound(key, "down") || bound(key, "j")) {
|
|
847
|
+
moveBy(1);
|
|
848
|
+
} else if (bound(key, "up", false, true)) {
|
|
849
|
+
moveBy(-fastScrollStep);
|
|
850
|
+
} else if (bound(key, "down", false, true)) {
|
|
851
|
+
moveBy(fastScrollStep);
|
|
852
|
+
} else if (bound(key, "return")) {
|
|
853
|
+
const option = options[current];
|
|
854
|
+
if (option != null && onSelect != null) {
|
|
855
|
+
onSelect(current, option);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
return (
|
|
861
|
+
<uf-select
|
|
862
|
+
{...rest}
|
|
863
|
+
options={options}
|
|
864
|
+
selectedIndex={current}
|
|
865
|
+
focusable
|
|
866
|
+
onKeyDown={onKeyDown}
|
|
867
|
+
// What a select draws is a list of choices, not a paragraph: a drag over
|
|
868
|
+
// one is not a copy of its names, which is also true of OpenTUI's.
|
|
869
|
+
selectable={false}
|
|
870
|
+
/>
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** Everything a `TabSelect` accepts. */
|
|
875
|
+
export type TabSelectProps = {
|
|
876
|
+
...BoxLayoutProps,
|
|
877
|
+
...SelectColorProps,
|
|
878
|
+
readonly id?: string,
|
|
879
|
+
/** The tabs, in order. The selected one's description is drawn under them. */
|
|
880
|
+
readonly options?: $ReadOnlyArray<SelectOption>,
|
|
881
|
+
/** The tab selected to begin with, and whenever this prop changes. */
|
|
882
|
+
readonly selectedIndex?: number,
|
|
883
|
+
/** Whether this select has focus, and so receives the keys. */
|
|
884
|
+
readonly focused?: boolean,
|
|
885
|
+
/** The selection moved left or right. */
|
|
886
|
+
readonly onChange?: (index: number, option: SelectOption | null) => void,
|
|
887
|
+
/** Enter was pressed on a tab. */
|
|
888
|
+
readonly onSelect?: (index: number, option: SelectOption | null) => void,
|
|
889
|
+
/** Cells each tab is given. Twenty by default, as in OpenTUI. */
|
|
890
|
+
readonly tabWidth?: number,
|
|
891
|
+
/** Whether moving past either end comes round to the other. Off by default. */
|
|
892
|
+
readonly wrapSelection?: boolean,
|
|
893
|
+
/** Whether the selected tab's description is drawn. On by default. */
|
|
894
|
+
readonly showDescription?: boolean,
|
|
895
|
+
/** Whether a `▬` rule is drawn under the selected tab. On by default. */
|
|
896
|
+
readonly showUnderline?: boolean,
|
|
897
|
+
/** Whether `‹` and `›` say there are tabs off either edge. On by default. */
|
|
898
|
+
readonly showScrollArrows?: boolean,
|
|
899
|
+
};
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* A row of tabs a reader moves along and chooses from.
|
|
903
|
+
*
|
|
904
|
+
* OpenTUI's `tab-select`, with its keys: Left or `[` and Right or `]` move,
|
|
905
|
+
* and Enter chooses; `onChange` and `onSelect` are called as a `Select`'s are.
|
|
906
|
+
* Unlike a `Select`, moving past an end of a row that does not wrap is not
|
|
907
|
+
* reported — the index did not move, and OpenTUI says nothing either.
|
|
908
|
+
*
|
|
909
|
+
* Its height is not a prop. It is one row for the names, one for the rule
|
|
910
|
+
* under the selected tab and one for its description, less whichever of the
|
|
911
|
+
* two is turned off — which is OpenTUI's rule, and the reason a `height` given
|
|
912
|
+
* to one is overridden rather than obeyed. As many tabs as fit across its
|
|
913
|
+
* width are shown, the selected one kept in the middle.
|
|
914
|
+
*
|
|
915
|
+
* The same things are missing as from `Select`, for the same reasons:
|
|
916
|
+
* `keyBindings`, `keyAliasMap`, and OpenTUI's default colours for the tabs
|
|
917
|
+
* that are not selected.
|
|
918
|
+
*/
|
|
919
|
+
export component TabSelect(...props: TabSelectProps) {
|
|
920
|
+
const {
|
|
921
|
+
options = EMPTY_OPTIONS,
|
|
922
|
+
selectedIndex = 0,
|
|
923
|
+
onChange,
|
|
924
|
+
onSelect,
|
|
925
|
+
wrapSelection = false,
|
|
926
|
+
showUnderline = true,
|
|
927
|
+
showDescription = true,
|
|
928
|
+
...rest
|
|
929
|
+
} = props;
|
|
930
|
+
const [index, move] = useMovingIndex(selectedIndex);
|
|
931
|
+
const current = options.length > 0 ? Math.min(Math.max(0, index), options.length - 1) : 0;
|
|
932
|
+
|
|
933
|
+
const onKeyDown = (key: KeyEvent) => {
|
|
934
|
+
if (key.eventType === "release") {
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
let next = -1;
|
|
938
|
+
if (bound(key, "left") || bound(key, "[")) {
|
|
939
|
+
if (current > 0) {
|
|
940
|
+
next = current - 1;
|
|
941
|
+
} else if (wrapSelection && options.length > 0) {
|
|
942
|
+
next = options.length - 1;
|
|
943
|
+
}
|
|
944
|
+
} else if (bound(key, "right") || bound(key, "]")) {
|
|
945
|
+
if (current < options.length - 1) {
|
|
946
|
+
next = current + 1;
|
|
947
|
+
} else if (wrapSelection && options.length > 0) {
|
|
948
|
+
next = 0;
|
|
949
|
+
}
|
|
950
|
+
} else if (bound(key, "return")) {
|
|
951
|
+
const option = options[current];
|
|
952
|
+
if (option != null && onSelect != null) {
|
|
953
|
+
onSelect(current, option);
|
|
954
|
+
}
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
if (next < 0) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
move(next);
|
|
961
|
+
if (onChange != null) {
|
|
962
|
+
onChange(next, options[next] ?? null);
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
return (
|
|
967
|
+
<uf-tab-select
|
|
968
|
+
{...rest}
|
|
969
|
+
options={options}
|
|
970
|
+
selectedIndex={current}
|
|
971
|
+
showUnderline={showUnderline}
|
|
972
|
+
showDescription={showDescription}
|
|
973
|
+
height={tabSelectHeight(showUnderline, showDescription)}
|
|
974
|
+
focusable
|
|
975
|
+
onKeyDown={onKeyDown}
|
|
976
|
+
selectable={false}
|
|
977
|
+
/>
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Everything a `Textarea` accepts. */
|
|
982
|
+
export type TextareaProps = {
|
|
983
|
+
...BoxLayoutProps,
|
|
984
|
+
readonly id?: string,
|
|
985
|
+
/** The text, when the caller controls it. */
|
|
986
|
+
readonly value?: string,
|
|
987
|
+
/** The text to begin with, when it does not. OpenTUI's name for it. */
|
|
988
|
+
readonly initialValue?: string,
|
|
989
|
+
/** What to show while there is no text. */
|
|
990
|
+
readonly placeholder?: string,
|
|
991
|
+
/** OpenTUI's `#666666` by default. */
|
|
992
|
+
readonly placeholderColor?: ColorValue,
|
|
993
|
+
/** Whether this textarea has focus, and so receives the keys. */
|
|
994
|
+
readonly focused?: boolean,
|
|
995
|
+
/** How long lines break: at a word, anywhere, or not at all. `"word"` by default. */
|
|
996
|
+
readonly wrapMode?: EditWrapMode,
|
|
997
|
+
readonly textColor?: ColorValue,
|
|
998
|
+
readonly backgroundColor?: ColorValue,
|
|
999
|
+
readonly focusedTextColor?: ColorValue,
|
|
1000
|
+
readonly focusedBackgroundColor?: ColorValue,
|
|
1001
|
+
/** Called with the new text on every edit. */
|
|
1002
|
+
readonly onContentChange?: (value: string) => void,
|
|
1003
|
+
/** Called with the text when Meta+Enter is pressed. */
|
|
1004
|
+
readonly onSubmit?: (value: string) => void,
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
/** An edit the undo history can take back: the text and cursor before it. */
|
|
1008
|
+
type Snapshot = { readonly text: string, readonly cursor: number };
|
|
1009
|
+
|
|
1010
|
+
/** How many edits `Ctrl+-` can take back. */
|
|
1011
|
+
const UNDO_DEPTH = 200;
|
|
1012
|
+
|
|
1013
|
+
const GRAPHEME_BREAKS = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
1014
|
+
|
|
1015
|
+
/** The grapheme boundary before `at`, so a cursor never stands inside one. */
|
|
1016
|
+
function previousBoundary(text: string, at: number): number {
|
|
1017
|
+
let previous = 0;
|
|
1018
|
+
for (const segment of GRAPHEME_BREAKS.segment(text)) {
|
|
1019
|
+
if (segment.index >= at) {
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
previous = segment.index;
|
|
1023
|
+
}
|
|
1024
|
+
return previous;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** The grapheme boundary after `at`. */
|
|
1028
|
+
function nextBoundary(text: string, at: number): number {
|
|
1029
|
+
for (const segment of GRAPHEME_BREAKS.segment(text)) {
|
|
1030
|
+
if (segment.index > at) {
|
|
1031
|
+
return segment.index;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return text.length;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const isSpace = (character: string | void): boolean => character != null && /\s/.test(character);
|
|
1038
|
+
|
|
1039
|
+
/** Where Meta+F lands: past the whitespace ahead, then past the word after it. */
|
|
1040
|
+
function wordForward(text: string, at: number): number {
|
|
1041
|
+
let cursor = at;
|
|
1042
|
+
while (cursor < text.length && isSpace(text[cursor])) {
|
|
1043
|
+
cursor += 1;
|
|
1044
|
+
}
|
|
1045
|
+
while (cursor < text.length && !isSpace(text[cursor])) {
|
|
1046
|
+
cursor += 1;
|
|
1047
|
+
}
|
|
1048
|
+
return cursor;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/** Where Meta+B lands: back over whitespace, then to the start of the word before it. */
|
|
1052
|
+
function wordBackward(text: string, at: number): number {
|
|
1053
|
+
let cursor = at;
|
|
1054
|
+
while (cursor > 0 && isSpace(text[cursor - 1])) {
|
|
1055
|
+
cursor -= 1;
|
|
1056
|
+
}
|
|
1057
|
+
while (cursor > 0 && !isSpace(text[cursor - 1])) {
|
|
1058
|
+
cursor -= 1;
|
|
1059
|
+
}
|
|
1060
|
+
return cursor;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** The start of the logical line `at` is on. */
|
|
1064
|
+
function lineStart(text: string, at: number): number {
|
|
1065
|
+
return at === 0 ? 0 : text.lastIndexOf("\n", at - 1) + 1;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** The end of the logical line `at` is on, before its newline. */
|
|
1069
|
+
function lineFinish(text: string, at: number): number {
|
|
1070
|
+
const newline = text.indexOf("\n", at);
|
|
1071
|
+
return newline < 0 ? text.length : newline;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Pasted text, as it can go into a textarea.
|
|
1076
|
+
*
|
|
1077
|
+
* Line endings become `\n`, which is the only one this component draws, and
|
|
1078
|
+
* escape sequences and control characters other than newline and tab are
|
|
1079
|
+
* dropped — OpenTUI strips ANSI from a paste for the same reason `Input` drops
|
|
1080
|
+
* a pasted bell: text copied out of another terminal carries its colours with
|
|
1081
|
+
* it, and pasting them would draw them.
|
|
1082
|
+
*/
|
|
1083
|
+
function cleanPaste(text: string): string {
|
|
1084
|
+
return text
|
|
1085
|
+
.replace(/\r\n?/g, "\n")
|
|
1086
|
+
.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
1087
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Several lines a reader types into.
|
|
1092
|
+
*
|
|
1093
|
+
* OpenTUI's `textarea`, and its keys. The arrows move a character or a line —
|
|
1094
|
+
* a line as it is *drawn*, so Down in a wrapped paragraph goes to the next row
|
|
1095
|
+
* of it rather than the next paragraph. Home and End go to the start and end
|
|
1096
|
+
* of the whole text, `Ctrl+A` and `Ctrl+E` to the start and end of the line,
|
|
1097
|
+
* and `Meta+A` and `Meta+E` to the start and end of the row it is wrapped
|
|
1098
|
+
* onto. `Meta+F`/`Meta+B`, `Meta+→`/`Meta+←` and `Ctrl+→`/`Ctrl+←` move by
|
|
1099
|
+
* word; `Ctrl+F` and `Ctrl+B` by character. Backspace and Delete (and
|
|
1100
|
+
* `Ctrl+D`) delete a character; `Ctrl+W`, `Meta+Backspace` and
|
|
1101
|
+
* `Ctrl+Backspace` the word before the cursor, `Meta+D`, `Meta+Delete` and
|
|
1102
|
+
* `Ctrl+Delete` the word after it; `Ctrl+K` to the end of the line, `Ctrl+U`
|
|
1103
|
+
* to its start, and `Ctrl+Shift+D` the whole line. Enter is a newline and
|
|
1104
|
+
* `Meta+Enter` submits. `Ctrl+-` undoes and `Ctrl+.` redoes. A paste goes in
|
|
1105
|
+
* whole, newlines and all.
|
|
1106
|
+
*
|
|
1107
|
+
* Some of those only exist where the terminal can say them. A terminal
|
|
1108
|
+
* without the Kitty protocol sends the same byte for Backspace and
|
|
1109
|
+
* Ctrl+Backspace, has no Ctrl+Shift+D at all, and sends `Ctrl+-` as a control
|
|
1110
|
+
* character nothing can tell from `Ctrl+_`; on one that has it, all of them
|
|
1111
|
+
* arrive as themselves.
|
|
1112
|
+
*
|
|
1113
|
+
* Controlled when `value` is given and uncontrolled otherwise, as `Input` is;
|
|
1114
|
+
* OpenTUI's is always uncontrolled and is read through a ref, which this
|
|
1115
|
+
* package does not hand out, so `onContentChange` and `onSubmit` carry the
|
|
1116
|
+
* text rather than an empty event. The cursor is this component's own state
|
|
1117
|
+
* for the reason `Input` gives, and is drawn the way `Input` draws it.
|
|
1118
|
+
*
|
|
1119
|
+
* Give it a height and it scrolls to keep the cursor in view, moving no
|
|
1120
|
+
* further than it has to. Without one it is as tall as its text.
|
|
1121
|
+
*
|
|
1122
|
+
* # What is not here
|
|
1123
|
+
*
|
|
1124
|
+
* Selection inside the text with Shift and the arrows, and the `select-*`
|
|
1125
|
+
* bindings that go with it: a drag over a textarea selects its cells as it
|
|
1126
|
+
* does any other text, but there is no range inside the buffer for an edit to
|
|
1127
|
+
* replace. The `Super` bindings, which need a modifier `KeyEvent` does not
|
|
1128
|
+
* carry. `keyBindings` and `keyAliasMap`. Syntax styles, extmarks and line
|
|
1129
|
+
* numbers, which are OpenTUI's `Code` and `LineNumbers` territory and still
|
|
1130
|
+
* ubugeeei-prod/uf#314. And a column the cursor remembers: moving up through a
|
|
1131
|
+
* short line and on to a long one lands at the short line's end, not at the
|
|
1132
|
+
* column the cursor started in.
|
|
1133
|
+
*/
|
|
1134
|
+
export component Textarea(...props: TextareaProps) {
|
|
1135
|
+
const {
|
|
1136
|
+
value,
|
|
1137
|
+
initialValue = "",
|
|
1138
|
+
focused = false,
|
|
1139
|
+
wrapMode = "word",
|
|
1140
|
+
onContentChange,
|
|
1141
|
+
onSubmit,
|
|
1142
|
+
...rest
|
|
1143
|
+
} = props;
|
|
1144
|
+
const [internal, setInternal] = useState<string>(initialValue);
|
|
1145
|
+
const text = value ?? internal;
|
|
1146
|
+
const [cursor, setCursor] = useState<number>(text.length);
|
|
1147
|
+
const at = Math.min(cursor, text.length);
|
|
1148
|
+
const [history, setHistory] = useState<{
|
|
1149
|
+
undo: $ReadOnlyArray<Snapshot>,
|
|
1150
|
+
redo: $ReadOnlyArray<Snapshot>,
|
|
1151
|
+
}>({ undo: [], redo: [] });
|
|
1152
|
+
const node = useRef<TuiNode | null>(null);
|
|
1153
|
+
|
|
1154
|
+
const change = (next: string, nextCursor: number) => {
|
|
1155
|
+
if (value == null) {
|
|
1156
|
+
setInternal(next);
|
|
1157
|
+
}
|
|
1158
|
+
setCursor(nextCursor);
|
|
1159
|
+
if (onContentChange != null) {
|
|
1160
|
+
onContentChange(next);
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
const onKeyDown = (key: KeyEvent) => {
|
|
1165
|
+
if (key.eventType === "release") {
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
const replace = (from: number, to: number, insert: string) => {
|
|
1169
|
+
if (from === to && insert === "") {
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
setHistory({
|
|
1173
|
+
undo: [...history.undo, { text, cursor: at }].slice(-UNDO_DEPTH),
|
|
1174
|
+
redo: [],
|
|
1175
|
+
});
|
|
1176
|
+
change(text.slice(0, from) + insert + text.slice(to), from + insert.length);
|
|
1177
|
+
};
|
|
1178
|
+
// The lines as they were drawn: the node is read here, in an event, and
|
|
1179
|
+
// never while rendering. Before the first frame it has no width, and the
|
|
1180
|
+
// lines are then the logical ones.
|
|
1181
|
+
const lines = () => {
|
|
1182
|
+
const drawn = node.current;
|
|
1183
|
+
return editLines(text, drawn == null ? 0 : contentBox(drawn).width, wrapMode);
|
|
1184
|
+
};
|
|
1185
|
+
const vertical = (direction: number) => {
|
|
1186
|
+
const drawn = lines();
|
|
1187
|
+
const { row, column } = locate(drawn, at);
|
|
1188
|
+
const target = drawn[row + direction];
|
|
1189
|
+
if (target != null) {
|
|
1190
|
+
setCursor(offsetAt(target, column));
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
if (bound(key, "left") || bound(key, "b", true)) {
|
|
1195
|
+
setCursor(previousBoundary(text, at));
|
|
1196
|
+
} else if (bound(key, "right") || bound(key, "f", true)) {
|
|
1197
|
+
setCursor(nextBoundary(text, at));
|
|
1198
|
+
} else if (bound(key, "up")) {
|
|
1199
|
+
vertical(-1);
|
|
1200
|
+
} else if (bound(key, "down")) {
|
|
1201
|
+
vertical(1);
|
|
1202
|
+
} else if (bound(key, "home")) {
|
|
1203
|
+
setCursor(0);
|
|
1204
|
+
} else if (bound(key, "end")) {
|
|
1205
|
+
setCursor(text.length);
|
|
1206
|
+
} else if (bound(key, "a", true)) {
|
|
1207
|
+
setCursor(lineStart(text, at));
|
|
1208
|
+
} else if (bound(key, "e", true)) {
|
|
1209
|
+
setCursor(lineFinish(text, at));
|
|
1210
|
+
} else if (bound(key, "a", false, false, true) || bound(key, "e", false, false, true)) {
|
|
1211
|
+
const drawn = lines();
|
|
1212
|
+
const line = drawn[locate(drawn, at).row];
|
|
1213
|
+
if (line != null) {
|
|
1214
|
+
setCursor(key.name === "a" ? line.start : lineEnd(line));
|
|
1215
|
+
}
|
|
1216
|
+
} else if (
|
|
1217
|
+
bound(key, "f", false, false, true) ||
|
|
1218
|
+
bound(key, "right", false, false, true) ||
|
|
1219
|
+
bound(key, "right", true)
|
|
1220
|
+
) {
|
|
1221
|
+
setCursor(wordForward(text, at));
|
|
1222
|
+
} else if (
|
|
1223
|
+
bound(key, "b", false, false, true) ||
|
|
1224
|
+
bound(key, "left", false, false, true) ||
|
|
1225
|
+
bound(key, "left", true)
|
|
1226
|
+
) {
|
|
1227
|
+
setCursor(wordBackward(text, at));
|
|
1228
|
+
} else if (
|
|
1229
|
+
bound(key, "w", true) ||
|
|
1230
|
+
bound(key, "backspace", true) ||
|
|
1231
|
+
bound(key, "backspace", false, false, true)
|
|
1232
|
+
) {
|
|
1233
|
+
replace(wordBackward(text, at), at, "");
|
|
1234
|
+
} else if (
|
|
1235
|
+
bound(key, "d", false, false, true) ||
|
|
1236
|
+
bound(key, "delete", false, false, true) ||
|
|
1237
|
+
bound(key, "delete", true)
|
|
1238
|
+
) {
|
|
1239
|
+
replace(at, wordForward(text, at), "");
|
|
1240
|
+
} else if (bound(key, "d", true, true)) {
|
|
1241
|
+
const start = lineStart(text, at);
|
|
1242
|
+
const finish = lineFinish(text, at);
|
|
1243
|
+
// The line and one of the newlines beside it, so no empty line is left
|
|
1244
|
+
// where it was: the one after it, or the one before the last line.
|
|
1245
|
+
if (finish < text.length) {
|
|
1246
|
+
replace(start, finish + 1, "");
|
|
1247
|
+
} else {
|
|
1248
|
+
replace(Math.max(0, start - 1), finish, "");
|
|
1249
|
+
}
|
|
1250
|
+
} else if (bound(key, "k", true)) {
|
|
1251
|
+
replace(at, lineFinish(text, at), "");
|
|
1252
|
+
} else if (bound(key, "u", true)) {
|
|
1253
|
+
replace(lineStart(text, at), at, "");
|
|
1254
|
+
} else if (bound(key, "backspace") || bound(key, "backspace", false, true)) {
|
|
1255
|
+
replace(previousBoundary(text, at), at, "");
|
|
1256
|
+
} else if (bound(key, "delete") || bound(key, "d", true) || bound(key, "delete", false, true)) {
|
|
1257
|
+
replace(at, nextBoundary(text, at), "");
|
|
1258
|
+
} else if (bound(key, "return", false, false, true)) {
|
|
1259
|
+
if (onSubmit != null) {
|
|
1260
|
+
onSubmit(text);
|
|
1261
|
+
}
|
|
1262
|
+
} else if (bound(key, "return")) {
|
|
1263
|
+
replace(at, at, "\n");
|
|
1264
|
+
} else if (bound(key, "-", true)) {
|
|
1265
|
+
const previous = history.undo[history.undo.length - 1];
|
|
1266
|
+
if (previous != null) {
|
|
1267
|
+
setHistory({
|
|
1268
|
+
undo: history.undo.slice(0, -1),
|
|
1269
|
+
redo: [...history.redo, { text, cursor: at }],
|
|
1270
|
+
});
|
|
1271
|
+
change(previous.text, previous.cursor);
|
|
1272
|
+
}
|
|
1273
|
+
} else if (bound(key, ".", true)) {
|
|
1274
|
+
const next = history.redo[history.redo.length - 1];
|
|
1275
|
+
if (next != null) {
|
|
1276
|
+
setHistory({
|
|
1277
|
+
undo: [...history.undo, { text, cursor: at }],
|
|
1278
|
+
redo: history.redo.slice(0, -1),
|
|
1279
|
+
});
|
|
1280
|
+
change(next.text, next.cursor);
|
|
1281
|
+
}
|
|
1282
|
+
} else if (key.name === "paste") {
|
|
1283
|
+
replace(at, at, cleanPaste(key.sequence));
|
|
1284
|
+
} else if (!key.ctrl && !key.meta) {
|
|
1285
|
+
if (key.name === "space") {
|
|
1286
|
+
replace(at, at, " ");
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
// Anything else with printable text behind it. The same test as
|
|
1290
|
+
// OpenTUI's: a first character below a space or at DEL is a control
|
|
1291
|
+
// key that happens to carry one, such as Tab.
|
|
1292
|
+
const code = key.sequence.charCodeAt(0);
|
|
1293
|
+
if (key.sequence !== "" && code >= 0x20 && code !== 0x7f) {
|
|
1294
|
+
replace(at, at, key.sequence);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
return (
|
|
1300
|
+
<uf-textarea
|
|
1301
|
+
{...rest}
|
|
1302
|
+
ref={node}
|
|
1303
|
+
value={text}
|
|
1304
|
+
cursor={at}
|
|
1305
|
+
focused={focused}
|
|
1306
|
+
wrapMode={wrapMode}
|
|
1307
|
+
focusable
|
|
1308
|
+
onKeyDown={onKeyDown}
|
|
1309
|
+
/>
|
|
1310
|
+
);
|
|
602
1311
|
}
|