ink-cartridge 3.6.1

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.
Files changed (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -0
  3. package/dist/binary-storage/BinaryStorage.d.ts +308 -0
  4. package/dist/binary-storage/BinaryStorage.js +548 -0
  5. package/dist/binary-storage/StreamingReader.d.ts +53 -0
  6. package/dist/binary-storage/StreamingReader.js +530 -0
  7. package/dist/binary-storage/index.d.ts +91 -0
  8. package/dist/binary-storage/index.js +91 -0
  9. package/dist/binary-storage/types.d.ts +400 -0
  10. package/dist/binary-storage/types.js +15 -0
  11. package/dist/cli/index.d.ts +2 -0
  12. package/dist/cli/index.js +214 -0
  13. package/dist/cli/initTheme.d.ts +14 -0
  14. package/dist/cli/initTheme.js +124 -0
  15. package/dist/cli/makeLanguageType.d.ts +46 -0
  16. package/dist/cli/makeLanguageType.js +264 -0
  17. package/dist/cli/makeThemeType.d.ts +43 -0
  18. package/dist/cli/makeThemeType.js +222 -0
  19. package/dist/components/badge/Badge.d.ts +7 -0
  20. package/dist/components/badge/Badge.js +9 -0
  21. package/dist/components/dialog/ConfirmDialog.d.ts +23 -0
  22. package/dist/components/dialog/ConfirmDialog.js +57 -0
  23. package/dist/components/dialog/types.d.ts +20 -0
  24. package/dist/components/dialog/types.js +1 -0
  25. package/dist/components/divider/Divider.d.ts +8 -0
  26. package/dist/components/divider/Divider.js +10 -0
  27. package/dist/components/fold/Fold.d.ts +3 -0
  28. package/dist/components/fold/Fold.js +44 -0
  29. package/dist/components/fold/types.d.ts +28 -0
  30. package/dist/components/fold/types.js +1 -0
  31. package/dist/components/form/Field.d.ts +30 -0
  32. package/dist/components/form/Field.js +59 -0
  33. package/dist/components/form/Form.d.ts +32 -0
  34. package/dist/components/form/Form.js +163 -0
  35. package/dist/components/form/context.d.ts +14 -0
  36. package/dist/components/form/context.js +20 -0
  37. package/dist/components/form/types.d.ts +115 -0
  38. package/dist/components/form/types.js +1 -0
  39. package/dist/components/key-hint/KeyHint.d.ts +10 -0
  40. package/dist/components/key-hint/KeyHint.js +12 -0
  41. package/dist/components/multi-select/MultiSelectInput.d.ts +125 -0
  42. package/dist/components/multi-select/MultiSelectInput.js +287 -0
  43. package/dist/components/multi-select/types.d.ts +100 -0
  44. package/dist/components/multi-select/types.js +1 -0
  45. package/dist/components/number-input/NumberInput.d.ts +11 -0
  46. package/dist/components/number-input/NumberInput.js +44 -0
  47. package/dist/components/progress-bar/ProgressBar.d.ts +11 -0
  48. package/dist/components/progress-bar/ProgressBar.js +9 -0
  49. package/dist/components/search-input/SearchInput.d.ts +10 -0
  50. package/dist/components/search-input/SearchInput.js +15 -0
  51. package/dist/components/select/SelectInput.d.ts +100 -0
  52. package/dist/components/select/SelectInput.js +147 -0
  53. package/dist/components/select/types.d.ts +63 -0
  54. package/dist/components/select/types.js +1 -0
  55. package/dist/components/spinner/Spinner.d.ts +12 -0
  56. package/dist/components/spinner/Spinner.js +24 -0
  57. package/dist/components/tabs/Tabs.d.ts +3 -0
  58. package/dist/components/tabs/Tabs.js +57 -0
  59. package/dist/components/tabs/types.d.ts +32 -0
  60. package/dist/components/tabs/types.js +1 -0
  61. package/dist/components/text/TextInput.d.ts +39 -0
  62. package/dist/components/text/TextInput.js +233 -0
  63. package/dist/components/text/types.d.ts +62 -0
  64. package/dist/components/text/types.js +1 -0
  65. package/dist/dev/dev-screen.d.ts +36 -0
  66. package/dist/dev/dev-screen.js +142 -0
  67. package/dist/dev/entrance.d.ts +51 -0
  68. package/dist/dev/entrance.js +67 -0
  69. package/dist/dev/globalKey-display.d.ts +2 -0
  70. package/dist/dev/globalKey-display.js +8 -0
  71. package/dist/dev/types.d.ts +9 -0
  72. package/dist/dev/types.js +1 -0
  73. package/dist/index.d.ts +46 -0
  74. package/dist/index.js +50 -0
  75. package/dist/keyboard/check-global-key.d.ts +18 -0
  76. package/dist/keyboard/check-global-key.js +52 -0
  77. package/dist/keyboard/context.d.ts +398 -0
  78. package/dist/keyboard/context.js +9 -0
  79. package/dist/keyboard/global-key-processor/index.d.ts +16 -0
  80. package/dist/keyboard/global-key-processor/index.js +75 -0
  81. package/dist/keyboard/global-sequence-processor/index.d.ts +14 -0
  82. package/dist/keyboard/global-sequence-processor/index.js +165 -0
  83. package/dist/keyboard/hook.d.ts +51 -0
  84. package/dist/keyboard/hook.js +105 -0
  85. package/dist/keyboard/index.d.ts +5 -0
  86. package/dist/keyboard/index.js +3 -0
  87. package/dist/keyboard/keyNormalizer.d.ts +37 -0
  88. package/dist/keyboard/keyNormalizer.js +119 -0
  89. package/dist/keyboard/layer-handler.d.ts +57 -0
  90. package/dist/keyboard/layer-handler.js +274 -0
  91. package/dist/keyboard/modal-processor/index.d.ts +16 -0
  92. package/dist/keyboard/modal-processor/index.js +97 -0
  93. package/dist/keyboard/overlay-processor/index.d.ts +16 -0
  94. package/dist/keyboard/overlay-processor/index.js +30 -0
  95. package/dist/keyboard/pipeline/chain.d.ts +10 -0
  96. package/dist/keyboard/pipeline/chain.js +45 -0
  97. package/dist/keyboard/pipeline/context.d.ts +34 -0
  98. package/dist/keyboard/pipeline/context.js +42 -0
  99. package/dist/keyboard/pipeline/index.d.ts +3 -0
  100. package/dist/keyboard/pipeline/index.js +2 -0
  101. package/dist/keyboard/provider.d.ts +32 -0
  102. package/dist/keyboard/provider.js +985 -0
  103. package/dist/keyboard/screen-stack-processor/index.d.ts +14 -0
  104. package/dist/keyboard/screen-stack-processor/index.js +32 -0
  105. package/dist/keyboard/types.d.ts +632 -0
  106. package/dist/keyboard/types.js +1 -0
  107. package/dist/language/context.d.ts +7 -0
  108. package/dist/language/context.js +7 -0
  109. package/dist/language/hook.d.ts +19 -0
  110. package/dist/language/hook.js +26 -0
  111. package/dist/language/index.d.ts +3 -0
  112. package/dist/language/index.js +2 -0
  113. package/dist/language/provider.d.ts +30 -0
  114. package/dist/language/provider.js +155 -0
  115. package/dist/language/types.d.ts +124 -0
  116. package/dist/language/types.js +1 -0
  117. package/dist/screen/ModalContext.d.ts +11 -0
  118. package/dist/screen/ModalContext.js +12 -0
  119. package/dist/screen/OverlayContext.d.ts +9 -0
  120. package/dist/screen/OverlayContext.js +10 -0
  121. package/dist/screen/context.d.ts +61 -0
  122. package/dist/screen/context.js +8 -0
  123. package/dist/screen/current-screen.d.ts +16 -0
  124. package/dist/screen/current-screen.js +38 -0
  125. package/dist/screen/hook.d.ts +13 -0
  126. package/dist/screen/hook.js +20 -0
  127. package/dist/screen/index.d.ts +7 -0
  128. package/dist/screen/index.js +5 -0
  129. package/dist/screen/provider.d.ts +112 -0
  130. package/dist/screen/provider.js +577 -0
  131. package/dist/screen/registry.d.ts +27 -0
  132. package/dist/screen/registry.js +71 -0
  133. package/dist/screen/types.d.ts +227 -0
  134. package/dist/screen/types.js +1 -0
  135. package/dist/storage/index.d.ts +45 -0
  136. package/dist/storage/index.js +46 -0
  137. package/dist/storage/storage.d.ts +139 -0
  138. package/dist/storage/storage.js +236 -0
  139. package/dist/storage/types.d.ts +145 -0
  140. package/dist/storage/types.js +1 -0
  141. package/dist/theme/context.d.ts +7 -0
  142. package/dist/theme/context.js +7 -0
  143. package/dist/theme/hook.d.ts +11 -0
  144. package/dist/theme/hook.js +18 -0
  145. package/dist/theme/index.d.ts +4 -0
  146. package/dist/theme/index.js +2 -0
  147. package/dist/theme/provider.d.ts +18 -0
  148. package/dist/theme/provider.js +308 -0
  149. package/dist/theme/types.d.ts +53 -0
  150. package/dist/theme/types.js +1 -0
  151. package/package.json +71 -0
@@ -0,0 +1,147 @@
1
+ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { useKeyboard, useFocusState } from '../../keyboard/hook.js';
4
+ function defaultIndicator({ isSelected }) {
5
+ return React.createElement(Box, { marginRight: 1 }, isSelected
6
+ ? React.createElement(Text, { color: 'blue' }, '\u276F')
7
+ : React.createElement(Text, null, ' '));
8
+ }
9
+ function defaultItem(props) {
10
+ return React.createElement(Text, { color: props.isSelected ? 'blue' : undefined }, props.label);
11
+ }
12
+ function clamp(n, min, max) {
13
+ return Math.max(min, Math.min(n, max));
14
+ }
15
+ /**
16
+ * A single-select list component integrated with the ink-cartridge keyboard and
17
+ * focus system.
18
+ *
19
+ * Each instance registers a focus target identified by {@link SelectInputProps.focusId}
20
+ * on the current screen's keyboard layer. Users navigate between multiple
21
+ * SelectInputs on the same screen with Tab / Shift+Tab. Within the active
22
+ * component, arrow keys (or vim-style j/k) move the highlight, and Enter
23
+ * confirms the selection. Number keys 1-9 directly select an item.
24
+ *
25
+ * When the component is not the active focus target, its items are visually
26
+ * dimmed and no key events are delivered to it.
27
+ *
28
+ * @typeParam T - The type of the value associated with each item.
29
+ * @typeParam I - The extended item type, must extend Item<T>. Defaults to Item<T>.
30
+ */
31
+ export function SelectInput({ items = [], onSelect, itemComponent, indicatorComponent, focusId, limit: limitProp = 10, storage, storageKey, }) {
32
+ const isFocused = useFocusState(focusId);
33
+ const { boundKeyboard, focusUnregister } = useKeyboard();
34
+ const IndicatorComp = indicatorComponent ?? defaultIndicator;
35
+ const ItemComp = (itemComponent ??
36
+ defaultItem);
37
+ const persistKey = storageKey ?? `select:${focusId}`;
38
+ const hasLimit = items.length > limitProp;
39
+ const limit = hasLimit ? limitProp : items.length;
40
+ const [selectedIndex, setSelectedIndex] = useState(0);
41
+ const [scrollOffset, setScrollOffset] = useState(0);
42
+ useEffect(() => {
43
+ if (!storage || items.length === 0)
44
+ return;
45
+ let cancelled = false;
46
+ storage.read.num(persistKey, 0).then((absIdx) => {
47
+ if (cancelled)
48
+ return;
49
+ const clamped = clamp(absIdx, 0, Math.max(0, items.length - 1));
50
+ if (hasLimit) {
51
+ const newScroll = Math.max(0, Math.min(clamped, items.length - limit));
52
+ setScrollOffset(newScroll);
53
+ setSelectedIndex(clamped - newScroll);
54
+ }
55
+ else {
56
+ setSelectedIndex(clamped);
57
+ }
58
+ });
59
+ return () => { cancelled = true; };
60
+ }, [storage, persistKey, items.length, hasLimit, limit]);
61
+ useEffect(() => {
62
+ if (!storage || items.length === 0)
63
+ return;
64
+ const absIdx = scrollOffset + selectedIndex;
65
+ storage.write.num(persistKey, absIdx);
66
+ }, [storage, persistKey, selectedIndex, scrollOffset, items.length]);
67
+ const selectedIndexRef = useRef(selectedIndex);
68
+ selectedIndexRef.current = selectedIndex;
69
+ const onSelectRef = useRef(onSelect);
70
+ onSelectRef.current = onSelect;
71
+ const visibleItems = useMemo(() => {
72
+ if (!hasLimit)
73
+ return items;
74
+ return items.slice(scrollOffset, scrollOffset + limit);
75
+ }, [items, hasLimit, limit, scrollOffset]);
76
+ const visibleItemsRef = useRef(visibleItems);
77
+ visibleItemsRef.current = visibleItems;
78
+ useEffect(() => {
79
+ if (items.length === 0) {
80
+ setSelectedIndex(0);
81
+ setScrollOffset(0);
82
+ return;
83
+ }
84
+ if (scrollOffset + limit > items.length) {
85
+ setScrollOffset(Math.max(0, items.length - limit));
86
+ }
87
+ if (selectedIndex >= limit || selectedIndex >= visibleItems.length) {
88
+ setSelectedIndex(clamp(items.length > 0 ? items.length - 1 : 0, 0, limit - 1));
89
+ }
90
+ }, [items.length, scrollOffset, limit, selectedIndex, visibleItems.length]);
91
+ const moveHighlight = useCallback((delta) => {
92
+ setSelectedIndex((prev) => {
93
+ if (!hasLimit) {
94
+ return clamp(prev + delta, 0, Math.max(0, items.length - 1));
95
+ }
96
+ const absIdx = scrollOffset + prev;
97
+ const newAbs = clamp(absIdx + delta, 0, Math.max(0, items.length - 1));
98
+ if (newAbs < scrollOffset) {
99
+ setScrollOffset(newAbs);
100
+ return 0;
101
+ }
102
+ if (newAbs >= scrollOffset + limit) {
103
+ setScrollOffset(newAbs - limit + 1);
104
+ return limit - 1;
105
+ }
106
+ return newAbs - scrollOffset;
107
+ });
108
+ }, [hasLimit, items.length, limit, scrollOffset]);
109
+ // Unregister old focus target when focusId changes
110
+ useEffect(() => {
111
+ return () => focusUnregister(focusId);
112
+ }, [focusId]);
113
+ useEffect(() => {
114
+ const fid = focusId;
115
+ const unUp = boundKeyboard(['up', 'k'], () => moveHighlight(-1), { focusId: fid });
116
+ const unDown = boundKeyboard(['down', 'j'], () => moveHighlight(1), { focusId: fid });
117
+ const unReturn = boundKeyboard(['return'], () => {
118
+ const item = visibleItemsRef.current[selectedIndexRef.current];
119
+ if (item)
120
+ onSelectRef.current(item);
121
+ }, { focusId: fid });
122
+ const numUnbinds = [];
123
+ for (let i = 1; i <= Math.min(9, visibleItems.length); i++) {
124
+ const idx = i - 1;
125
+ numUnbinds.push(boundKeyboard([String(i)], () => {
126
+ const item = visibleItemsRef.current[idx];
127
+ if (item)
128
+ onSelectRef.current(item);
129
+ }, { focusId: fid }));
130
+ }
131
+ return () => {
132
+ unUp();
133
+ unDown();
134
+ unReturn();
135
+ numUnbinds.forEach((fn) => fn());
136
+ };
137
+ }, [
138
+ focusId,
139
+ boundKeyboard,
140
+ moveHighlight,
141
+ visibleItems.length,
142
+ ]);
143
+ return React.createElement(Box, { flexDirection: 'column' }, ...visibleItems.map((item, index) => {
144
+ const isItemSelected = index === selectedIndex && isFocused;
145
+ return React.createElement(Box, { key: item.Key ?? String(item.value) }, React.createElement(IndicatorComp, { isSelected: isItemSelected }), React.createElement(ItemComp, { ...item, isSelected: isItemSelected }));
146
+ }));
147
+ }
@@ -0,0 +1,63 @@
1
+ import React from "react";
2
+ import type { StorageAPI } from "../../storage/index.js";
3
+ /**
4
+ * The SelectInput component is required to pass in a type interface that must be included to act as a constraint
5
+ */
6
+ export interface Item<T> {
7
+ /**
8
+ * Something that is used to show the user
9
+ */
10
+ label: string;
11
+ /**
12
+ * What is the actual value of the selected item
13
+ */
14
+ value: T;
15
+ /**
16
+ * Key convenient for dynamic addition and deletion
17
+ */
18
+ Key?: string;
19
+ }
20
+ export interface SelectInputProps<T, I extends Item<T> = Item<T>> {
21
+ /**
22
+ * Array to be passed in for UI display, etc.
23
+ */
24
+ items: I[];
25
+ /**
26
+ * What happens when the user presses Enter
27
+ * Accepts a parameter that represents the currently selected item
28
+ */
29
+ onSelect: (item: I) => void;
30
+ /**
31
+ * UI components for custom rendering
32
+ */
33
+ itemComponent?: React.ComponentType<I & {
34
+ isSelected: boolean;
35
+ }>;
36
+ /**
37
+ * Custom Indicator UI Component
38
+ */
39
+ indicatorComponent?: React.ComponentType<{
40
+ isSelected: boolean;
41
+ }>;
42
+ /**
43
+ * Focus, string type, controls whether this component is active
44
+ * Effectively prevents two SelectInputs from competing with each other
45
+ * Is not a Boolean type Because of the integration of the keyboard system.
46
+ */
47
+ focusId: string;
48
+ /**
49
+ * When the number of items exceeds what?
50
+ * The list will be scrolled
51
+ */
52
+ limit?: number;
53
+ /**
54
+ * Optional persistence instance. When provided, the cursor position
55
+ * (selectedIndex) is automatically saved and restored across sessions.
56
+ */
57
+ storage?: StorageAPI;
58
+ /**
59
+ * Storage key used for persistence. Defaults to `"select:<focusId>"` when
60
+ * not provided.
61
+ */
62
+ storageKey?: string;
63
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ declare const FRAMES: Record<string, string[]>;
3
+ export type SpinnerType = keyof typeof FRAMES;
4
+ interface SpinnerProps {
5
+ type?: SpinnerType;
6
+ label?: string;
7
+ color?: string;
8
+ speed?: number;
9
+ active?: boolean;
10
+ }
11
+ export declare function Spinner({ type, label, color, speed, active, }: SpinnerProps): React.JSX.Element;
12
+ export {};
@@ -0,0 +1,24 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Text } from 'ink';
3
+ const FRAMES = {
4
+ dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
5
+ line: ['─', '━', '─', '━', '─', '━', '─', '━'],
6
+ simple: ['|', '/', '-', '\\'],
7
+ triangle: ['◢', '◣', '◤', '◥'],
8
+ arc: ['◜', '◝', '◞', '◟'],
9
+ };
10
+ export function Spinner({ type = 'dots', label, color, speed = 80, active = true, }) {
11
+ const [index, setIndex] = useState(0);
12
+ const frames = FRAMES[type] ?? FRAMES.dots;
13
+ useEffect(() => {
14
+ if (!active || frames.length <= 1)
15
+ return;
16
+ const timer = setInterval(() => {
17
+ setIndex((prev) => (prev + 1) % frames.length);
18
+ }, speed);
19
+ return () => clearInterval(timer);
20
+ }, [active, speed, frames.length]);
21
+ const displayIndex = active ? index : 0;
22
+ const content = label ? `${frames[displayIndex]} ${label}` : frames[displayIndex];
23
+ return React.createElement(Text, { color: color }, content);
24
+ }
@@ -0,0 +1,3 @@
1
+ import React from 'react';
2
+ import type { TabsProps } from './types.js';
3
+ export declare function Tabs({ focusId, tabs, activeTab: controlledActive, onChange, defaultActiveTab, storage, storageKey, }: TabsProps): React.JSX.Element;
@@ -0,0 +1,57 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { useKeyboard, useFocusState } from '../../keyboard/index.js';
4
+ export function Tabs({ focusId, tabs, activeTab: controlledActive, onChange, defaultActiveTab, storage, storageKey, }) {
5
+ const isFocused = useFocusState(focusId);
6
+ const { boundKeyboard, focusUnregister } = useKeyboard();
7
+ const isControlled = controlledActive !== undefined;
8
+ const [internalActive, setInternalActive] = useState(defaultActiveTab ?? tabs[0]?.id);
9
+ const persistKey = storageKey ?? `tabs:${focusId}`;
10
+ useEffect(() => {
11
+ if (!storage)
12
+ return;
13
+ let cancelled = false;
14
+ storage.read.str(persistKey, defaultActiveTab ?? tabs[0]?.id ?? '').then((v) => {
15
+ if (!cancelled)
16
+ setInternalActive(v);
17
+ });
18
+ return () => { cancelled = true; };
19
+ }, [storage, persistKey, defaultActiveTab, tabs]);
20
+ const activeId = isControlled ? controlledActive : internalActive;
21
+ const activeIndex = tabs.findIndex((t) => t.id === activeId);
22
+ const prev = useCallback(() => {
23
+ const nextIdx = activeIndex <= 0 ? tabs.length - 1 : activeIndex - 1;
24
+ const id = tabs[nextIdx].id;
25
+ if (isControlled) {
26
+ onChange?.(id);
27
+ }
28
+ else {
29
+ setInternalActive(id);
30
+ storage?.write.str(persistKey, id);
31
+ }
32
+ }, [activeIndex, tabs, isControlled, onChange, storage, persistKey]);
33
+ const next = useCallback(() => {
34
+ const nextIdx = (activeIndex + 1) % tabs.length;
35
+ const id = tabs[nextIdx].id;
36
+ if (isControlled) {
37
+ onChange?.(id);
38
+ }
39
+ else {
40
+ setInternalActive(id);
41
+ storage?.write.str(persistKey, id);
42
+ }
43
+ }, [activeIndex, tabs, isControlled, onChange, storage, persistKey]);
44
+ useEffect(() => {
45
+ const unLeft = boundKeyboard(['left'], () => prev(), { focusId });
46
+ const unRight = boundKeyboard(['right'], () => next(), { focusId });
47
+ return () => {
48
+ unLeft();
49
+ unRight();
50
+ focusUnregister(focusId);
51
+ };
52
+ }, [focusId, boundKeyboard, focusUnregister, prev, next]);
53
+ return (React.createElement(Box, { flexDirection: "column" },
54
+ React.createElement(Box, null, tabs.map((tab, i) => (React.createElement(Box, { key: tab.id, marginRight: 1, paddingX: 1 },
55
+ React.createElement(Text, { color: i === activeIndex ? 'cyan' : 'grey', bold: i === activeIndex, underline: i === activeIndex && isFocused }, tab.label))))),
56
+ React.createElement(Box, { marginTop: 1 }, tabs[activeIndex]?.content)));
57
+ }
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import type { StorageAPI } from "../../storage/index.js";
3
+ export interface Tab {
4
+ /** Unique identifier for this tab. */
5
+ id: string;
6
+ /** Display label shown in the tab bar. */
7
+ label: string;
8
+ /** Content rendered when this tab is active. */
9
+ content: React.ReactNode;
10
+ }
11
+ export interface TabsProps {
12
+ /** Focus target for the tab bar. */
13
+ focusId: string;
14
+ /** Array of tab definitions. */
15
+ tabs: Tab[];
16
+ /** Controlled: currently active tab id. */
17
+ activeTab?: string;
18
+ /** Controlled: called when active tab changes. */
19
+ onChange?: (id: string) => void;
20
+ /** Uncontrolled: initial active tab id (defaults to first tab). */
21
+ defaultActiveTab?: string;
22
+ /**
23
+ * Optional persistence instance. When provided, the active tab is
24
+ * automatically saved and restored across sessions.
25
+ */
26
+ storage?: StorageAPI;
27
+ /**
28
+ * Storage key used for persistence. Defaults to `"tabs:<focusId>"` when
29
+ * not provided.
30
+ */
31
+ storageKey?: string;
32
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import type { TextInputProps, UncontrolledTextInputProps } from './types.js';
3
+ /**
4
+ * Controlled text input component integrated with the keyboard focus system.
5
+ *
6
+ * Supports:
7
+ * - Arrow keys to move cursor
8
+ * - Backspace/Delete to remove characters
9
+ * - Regular character input (via wildcard '*')
10
+ * - Optional mask (password mode)
11
+ * - Paste highlighting (highlight the whole pasted block)
12
+ * - Placeholder
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * const [name, setName] = useState('');
17
+ * <TextInput
18
+ * focusId="name-field"
19
+ * value={name}
20
+ * onChange={setName}
21
+ * placeholder="Enter your name"
22
+ * showCursor
23
+ * />
24
+ * ```
25
+ */
26
+ export declare function TextInput({ placeholder, mask, showCursor, highlightPastedText, value: originalValue, onChange, onSubmit, focusId, }: TextInputProps): React.FunctionComponentElement<import("ink").TextProps>;
27
+ /**
28
+ * Uncontrolled text input component that manages its own internal state.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <UncontrolledTextInput
33
+ * focusId="search"
34
+ * initialValue="default"
35
+ * onSubmit={(val) => console.log(val)}
36
+ * />
37
+ * ```
38
+ */
39
+ export declare function UncontrolledTextInput({ initialValue, storage, storageKey, ...props }: UncontrolledTextInputProps): React.FunctionComponentElement<TextInputProps>;
@@ -0,0 +1,233 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { Text } from 'ink';
3
+ import chalk from 'chalk';
4
+ import { useKeyboard, useFocusState } from '../../keyboard/index.js';
5
+ /**
6
+ * 将给定字符重复多次
7
+ */
8
+ function repeatChar(char, count) {
9
+ return Array(count + 1).join(char);
10
+ }
11
+ /**
12
+ * 计算光标在可见字符串中的偏移量,并生成带高亮(反色)的渲染结果
13
+ */
14
+ function renderWithCursor(value, placeholder, mask, showCursor, isFocused, cursorOffset, cursorWidth, highlightPastedText) {
15
+ // 显示用的值:如果设置了掩码则每个字符替换为掩码字符
16
+ const displayValue = mask ? repeatChar(mask, value.length) : value;
17
+ // 未聚焦或不需要显示光标 → 直接返回纯文本(空时显示占位符)
18
+ if (!showCursor || !isFocused) {
19
+ if (displayValue.length === 0 && placeholder) {
20
+ return chalk.grey(placeholder);
21
+ }
22
+ return displayValue;
23
+ }
24
+ // 聚焦且显示光标时的处理
25
+ // 空值 + 有占位符 → 占位符第一个字符反色,其余灰色
26
+ if (displayValue.length === 0 && placeholder) {
27
+ if (placeholder.length === 0)
28
+ return chalk.inverse(' ');
29
+ return chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1));
30
+ }
31
+ // 实际高亮宽度(粘贴高亮时可能大于1)
32
+ const actualHighlightWidth = highlightPastedText ? cursorWidth : 0;
33
+ let result = '';
34
+ for (let i = 0; i < displayValue.length; i++) {
35
+ const isInHighlight = highlightPastedText
36
+ ? i >= cursorOffset - actualHighlightWidth && i < cursorOffset
37
+ : i >= cursorOffset - actualHighlightWidth && i <= cursorOffset;
38
+ result += isInHighlight ? chalk.inverse(displayValue[i]) : displayValue[i];
39
+ }
40
+ // 光标在末尾时追加一个反色空格
41
+ if (cursorOffset === displayValue.length) {
42
+ result += chalk.inverse(' ');
43
+ }
44
+ return result;
45
+ }
46
+ /**
47
+ * Controlled text input component integrated with the keyboard focus system.
48
+ *
49
+ * Supports:
50
+ * - Arrow keys to move cursor
51
+ * - Backspace/Delete to remove characters
52
+ * - Regular character input (via wildcard '*')
53
+ * - Optional mask (password mode)
54
+ * - Paste highlighting (highlight the whole pasted block)
55
+ * - Placeholder
56
+ *
57
+ * @example
58
+ * ```tsx
59
+ * const [name, setName] = useState('');
60
+ * <TextInput
61
+ * focusId="name-field"
62
+ * value={name}
63
+ * onChange={setName}
64
+ * placeholder="Enter your name"
65
+ * showCursor
66
+ * />
67
+ * ```
68
+ */
69
+ export function TextInput({ placeholder = '', mask, showCursor = true, highlightPastedText = false, value: originalValue, onChange, onSubmit, focusId, }) {
70
+ const isFocused = useFocusState(focusId);
71
+ const { boundKeyboard, focusUnregister, enableWildcardPriority } = useKeyboard();
72
+ // 光标位置(字符索引)
73
+ const [cursorOffset, setCursorOffset] = useState(originalValue.length);
74
+ // 粘贴高亮宽度(一次插入的字符数)
75
+ const [cursorWidth, setCursorWidth] = useState(0);
76
+ // 通配符优先模式的 disable 函数
77
+ const disablePriorityRef = useRef(null);
78
+ // 当外部 value 缩短时,修正光标位置避免越界
79
+ useEffect(() => {
80
+ setCursorOffset((prev) => Math.min(prev, originalValue.length));
81
+ }, [originalValue]);
82
+ /**
83
+ * 移动光标(左右箭头调用)
84
+ */
85
+ const moveCursor = useCallback((delta) => {
86
+ if (!showCursor)
87
+ return;
88
+ setCursorOffset((prev) => {
89
+ const next = prev + delta;
90
+ if (next < 0)
91
+ return 0;
92
+ if (next > originalValue.length)
93
+ return originalValue.length;
94
+ return next;
95
+ });
96
+ // 光标移动后清除粘贴高亮
97
+ setCursorWidth(0);
98
+ }, [showCursor, originalValue.length]);
99
+ /**
100
+ * 插入文本或删除字符
101
+ * @param insertion 要插入的字符串,undefined 表示执行删除操作
102
+ * @param isForwardDelete true 表示 Delete(删光标右侧),false/undefined 表示 Backspace(删光标左侧)
103
+ */
104
+ const modifyText = useCallback((insertion, isForwardDelete = false) => {
105
+ let newValue = originalValue;
106
+ let newOffset = cursorOffset;
107
+ if (insertion === undefined) {
108
+ // 删除操作
109
+ if (isForwardDelete && cursorOffset < originalValue.length) {
110
+ // Delete:删除光标右侧一个字符
111
+ newValue =
112
+ originalValue.slice(0, cursorOffset) +
113
+ originalValue.slice(cursorOffset + 1);
114
+ // 光标位置不变
115
+ }
116
+ else if (!isForwardDelete && cursorOffset > 0) {
117
+ // Backspace:删除光标左侧一个字符
118
+ newValue =
119
+ originalValue.slice(0, cursorOffset - 1) +
120
+ originalValue.slice(cursorOffset);
121
+ newOffset = cursorOffset - 1;
122
+ }
123
+ else {
124
+ return;
125
+ }
126
+ }
127
+ else {
128
+ // 插入
129
+ newValue =
130
+ originalValue.slice(0, cursorOffset) +
131
+ insertion +
132
+ originalValue.slice(cursorOffset);
133
+ newOffset = cursorOffset + insertion.length;
134
+ }
135
+ // 边界保护
136
+ newOffset = Math.max(0, Math.min(newOffset, newValue.length));
137
+ setCursorOffset(newOffset);
138
+ // 如果一次插入了多个字符且开启高亮,记录高亮宽度
139
+ if (insertion && insertion.length > 1 && highlightPastedText) {
140
+ setCursorWidth(insertion.length);
141
+ }
142
+ else {
143
+ setCursorWidth(0);
144
+ }
145
+ if (newValue !== originalValue) {
146
+ onChange(newValue);
147
+ }
148
+ }, [originalValue, cursorOffset, onChange, highlightPastedText]);
149
+ // 焦点目标生命周期 — focusId 变化时注销旧的,新的由绑定 effect 创建
150
+ useEffect(() => {
151
+ return () => focusUnregister(focusId);
152
+ }, [focusId]);
153
+ // 注册键盘绑定(仅在获得焦点时生效)
154
+ useEffect(() => {
155
+ const fid = focusId;
156
+ const unbindList = [];
157
+ // 开启通配符最高优先级,确保所有普通字符输入优先被 TextInput 捕获
158
+ disablePriorityRef.current?.();
159
+ const disablePriority = enableWildcardPriority();
160
+ disablePriorityRef.current = disablePriority;
161
+ // 左右移动光标
162
+ unbindList.push(boundKeyboard(['left'], () => moveCursor(-1), { focusId: fid }));
163
+ unbindList.push(boundKeyboard(['right'], () => moveCursor(1), { focusId: fid }));
164
+ // 退格 / 删除
165
+ unbindList.push(boundKeyboard(['backspace'], () => modifyText(), { focusId: fid }));
166
+ unbindList.push(boundKeyboard(['delete'], () => modifyText(undefined, true), { focusId: fid }));
167
+ // 回车提交 — 提交时解除通配符最高优先级
168
+ if (onSubmit) {
169
+ unbindList.push(boundKeyboard(['return'], () => {
170
+ // 用户提交后解除通配符优先级,恢复正常按键分发
171
+ disablePriority();
172
+ disablePriorityRef.current = null;
173
+ onSubmit(originalValue);
174
+ }, { focusId: fid }));
175
+ }
176
+ // 通配符 '*':捕获所有普通字符输入
177
+ unbindList.push(boundKeyboard(['*'], (input) => modifyText(input), { focusId: fid }));
178
+ // 清理:解绑所有键盘回调并恢复通配符优先级
179
+ return () => {
180
+ unbindList.forEach((fn) => fn());
181
+ if (disablePriorityRef.current) {
182
+ disablePriorityRef.current();
183
+ disablePriorityRef.current = null;
184
+ }
185
+ };
186
+ }, [
187
+ focusId,
188
+ boundKeyboard,
189
+ moveCursor,
190
+ modifyText,
191
+ onSubmit,
192
+ originalValue,
193
+ enableWildcardPriority,
194
+ ]);
195
+ // 渲染最终显示的文本
196
+ const rendered = renderWithCursor(originalValue, placeholder, mask, showCursor, isFocused, cursorOffset, cursorWidth, highlightPastedText);
197
+ return React.createElement(Text, null, rendered);
198
+ }
199
+ /**
200
+ * Uncontrolled text input component that manages its own internal state.
201
+ *
202
+ * @example
203
+ * ```tsx
204
+ * <UncontrolledTextInput
205
+ * focusId="search"
206
+ * initialValue="default"
207
+ * onSubmit={(val) => console.log(val)}
208
+ * />
209
+ * ```
210
+ */
211
+ export function UncontrolledTextInput({ initialValue = '', storage, storageKey, ...props }) {
212
+ const [value, setValue] = useState(initialValue);
213
+ const persistKey = storageKey ?? `text:${props.focusId}`;
214
+ useEffect(() => {
215
+ if (!storage)
216
+ return;
217
+ let cancelled = false;
218
+ storage.read.str(persistKey, initialValue).then((v) => {
219
+ if (!cancelled)
220
+ setValue(v);
221
+ });
222
+ return () => { cancelled = true; };
223
+ }, [storage, persistKey, initialValue]);
224
+ const handleChange = (newVal) => {
225
+ setValue(newVal);
226
+ storage?.write.str(persistKey, newVal);
227
+ };
228
+ return React.createElement(TextInput, {
229
+ ...props,
230
+ value,
231
+ onChange: handleChange,
232
+ });
233
+ }
@@ -0,0 +1,62 @@
1
+ import type { StorageAPI } from "../../storage/index.js";
2
+ /**
3
+ * Props for the controlled TextInput component.
4
+ * @template T - The type of the value (typically string).
5
+ */
6
+ export type TextInputProps = {
7
+ /**
8
+ * Placeholder text shown when value is empty.
9
+ */
10
+ readonly placeholder?: string;
11
+ /**
12
+ * Replace all characters with this mask string (e.g. '*' for passwords).
13
+ */
14
+ readonly mask?: string;
15
+ /**
16
+ * Whether to show a visual cursor and allow arrow key navigation.
17
+ * @default true
18
+ */
19
+ readonly showCursor?: boolean;
20
+ /**
21
+ * Highlight the last pasted text block (multiple characters inserted at once).
22
+ * @default false
23
+ */
24
+ readonly highlightPastedText?: boolean;
25
+ /**
26
+ * Current value of the input (controlled).
27
+ */
28
+ readonly value: string;
29
+ /**
30
+ * Called when the value changes.
31
+ */
32
+ readonly onChange: (value: string) => void;
33
+ /**
34
+ * Called when the Enter key is pressed.
35
+ */
36
+ readonly onSubmit?: (value: string) => void;
37
+ /**
38
+ * Focus identifier used by the keyboard system.
39
+ * Must be unique on the current screen.
40
+ */
41
+ readonly focusId: string;
42
+ };
43
+ /**
44
+ * Props for the uncontrolled TextInput component.
45
+ */
46
+ export type UncontrolledTextInputProps = {
47
+ /**
48
+ * Initial value when the component mounts.
49
+ * @default ''
50
+ */
51
+ readonly initialValue?: string;
52
+ /**
53
+ * Optional persistence instance. When provided, the text value is
54
+ * automatically saved and restored across sessions.
55
+ */
56
+ readonly storage?: StorageAPI;
57
+ /**
58
+ * Storage key used for persistence. Defaults to `"text:<focusId>"` when
59
+ * not provided.
60
+ */
61
+ readonly storageKey?: string;
62
+ } & Omit<TextInputProps, 'value' | 'onChange'>;