@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/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
- // 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.
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` 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.
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?: number,
105
- readonly marginTop?: number,
106
- readonly marginRight?: number,
107
- readonly marginBottom?: number,
108
- readonly marginLeft?: number,
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) => void,
177
- readonly onMouseDown?: (event: MouseEvent) => void,
178
- readonly onMouseUp?: (event: MouseEvent) => void,
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) => void,
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) => void,
225
+ readonly onMouseDrag?: (event: MouseEvent) => mixed,
183
226
  /** That drag ended, wherever the pointer had reached. */
184
- readonly onMouseDragEnd?: (event: MouseEvent) => void,
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) => void,
229
+ readonly onMouseDrop?: (event: MouseEvent) => mixed,
187
230
  /** The pointer entered this box, or a box inside it. */
188
- readonly onMouseOver?: (event: MouseEvent) => void,
231
+ readonly onMouseOver?: (event: MouseEvent) => mixed,
189
232
  /** And left it. */
190
- readonly onMouseOut?: (event: MouseEvent) => void,
233
+ readonly onMouseOut?: (event: MouseEvent) => mixed,
191
234
  /** The wheel turned; `event.scroll` says which way. */
192
- readonly onMouseScroll?: (event: MouseEvent) => void,
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) => void,
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 React.createElement("uf-box", props, children);
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 React.createElement("uf-text", props, children);
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 React.createElement(
369
- "uf-box",
370
- {
371
- ...props,
372
- paddingRight,
373
- overflow: "scroll",
374
- scrollTop,
375
- scrollbar,
376
- scrollbarColor,
377
- },
378
- children,
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) => void): void {
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 React.createElement(Text, { fg: placeholderColor, wrap: "none" }, placeholder);
637
+ return (
638
+ <Text fg={placeholderColor} wrap="none">
639
+ {placeholder}
640
+ </Text>
641
+ );
578
642
  }
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),
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 React.createElement(Text, { fg, bg, wrap: "none" }, text);
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 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),
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 React.createElement(Box, { ...layout, focusable: true, focused, onKeyDown }, body);
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
  }