ink-cartridge 3.8.2 → 3.8.3
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/README.md +3 -2
- package/dist/components/search-bar/SearchBar.d.ts +3 -0
- package/dist/components/search-bar/SearchBar.js +62 -0
- package/dist/components/search-bar/search-bar-types.d.ts +39 -0
- package/dist/components/search-bar/search-bar-types.js +1 -0
- package/dist/components/search-input/SearchInput.js +6 -3
- package/dist/components/text/TextInput.d.ts +2 -34
- package/dist/components/text/TextInput.js +132 -84
- package/dist/components/text/types.d.ts +11 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/keyboard/modal-processor/index.js +10 -4
- package/dist/keyboard/provider.js +1 -1
- package/dist/keyboard/types.d.ts +2 -0
- package/dist/screen/context.d.ts +2 -0
- package/dist/screen/current-screen.js +4 -3
- package/dist/screen/provider.d.ts +2 -1
- package/dist/screen/provider.js +3 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -109,6 +109,7 @@ npx ink-cartridge init my-tui
|
|
|
109
109
|
- [TextInput](docs/components/TextInput/TextInput-API.md)
|
|
110
110
|
- [UncontrolledTextInput](docs/components/TextInput/UncontrolledTextInput-API.md)
|
|
111
111
|
- [NumberInput](docs/components/NumberInput/NumberInput-API.md)
|
|
112
|
+
- [SearchBar](docs/components/SearchBar/SearchBar-API.md)
|
|
112
113
|
- [SearchInput](docs/components/SearchInput/SearchInput-API.md)
|
|
113
114
|
- [ConfirmDialog](docs/components/ConfirmDialog/ConfirmDialog-API.md)
|
|
114
115
|
- [Spinner](docs/components/Spinner/Spinner-API.md)
|
|
@@ -164,9 +165,9 @@ npx ink-cartridge init my-tui
|
|
|
164
165
|
- [closeDevTool](docs/dev-tool/closeDevTool-API.md)
|
|
165
166
|
</details>
|
|
166
167
|
|
|
167
|
-
##
|
|
168
|
+
## Examples
|
|
168
169
|
|
|
169
|
-
|
|
170
|
+
Runnable demos for every component. See [examples/README.md](examples/README.md) for the full list and run commands.
|
|
170
171
|
|
|
171
172
|
## License
|
|
172
173
|
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { SearchBarItem, SearchBarProps } from "./search-bar-types.js";
|
|
3
|
+
export default function SearchBar<T, I extends SearchBarItem<T> = SearchBarItem<T>>({ focusId, width, items, onSubmit, selectBar: SelectBar, }: SearchBarProps<T, I>): React.JSX.Element;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
2
|
+
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
|
3
|
+
import { TextInput } from "../text/TextInput.js";
|
|
4
|
+
import { useKeyboard } from "../../keyboard/hook.js";
|
|
5
|
+
/**
|
|
6
|
+
* Filter and sort items by query against their labels.
|
|
7
|
+
* Priority: exact match → prefix match → earlier substring position.
|
|
8
|
+
*/
|
|
9
|
+
function filterAndSort(items, query) {
|
|
10
|
+
if (!query.trim())
|
|
11
|
+
return items;
|
|
12
|
+
const lower = query.toLowerCase();
|
|
13
|
+
return items
|
|
14
|
+
.filter((item) => item.label.toLowerCase().includes(lower))
|
|
15
|
+
.sort((a, b) => {
|
|
16
|
+
const aLower = a.label.toLowerCase();
|
|
17
|
+
const bLower = b.label.toLowerCase();
|
|
18
|
+
if (aLower === lower && bLower !== lower)
|
|
19
|
+
return -1;
|
|
20
|
+
if (aLower !== lower && bLower === lower)
|
|
21
|
+
return 1;
|
|
22
|
+
const aStarts = aLower.startsWith(lower);
|
|
23
|
+
const bStarts = bLower.startsWith(lower);
|
|
24
|
+
if (aStarts && !bStarts)
|
|
25
|
+
return -1;
|
|
26
|
+
if (!aStarts && bStarts)
|
|
27
|
+
return 1;
|
|
28
|
+
return aLower.indexOf(lower) - bLower.indexOf(lower);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export default function SearchBar({ focusId, width, items = [], onSubmit, selectBar: SelectBar, }) {
|
|
32
|
+
const [value, setValue] = useState("");
|
|
33
|
+
const { columns } = useWindowSize();
|
|
34
|
+
const { focusSet, boundKeyboard } = useKeyboard();
|
|
35
|
+
const inputWidth = width ?? Math.max(1, columns - 4);
|
|
36
|
+
const resultsFocusId = `${focusId}-results`;
|
|
37
|
+
// Filter and sort results whenever the query or items change.
|
|
38
|
+
const results = useMemo(() => filterAndSort(items, value), [items, value]);
|
|
39
|
+
/**
|
|
40
|
+
* When selectBar confirms a selection, fire onSubmit and return
|
|
41
|
+
* focus to the TextInput.
|
|
42
|
+
*/
|
|
43
|
+
const handleSelect = useCallback((item) => {
|
|
44
|
+
onSubmit?.(item);
|
|
45
|
+
focusSet(focusId);
|
|
46
|
+
}, [onSubmit, focusSet, focusId]);
|
|
47
|
+
// Enter in TextInput switches focus to the selectBar.
|
|
48
|
+
// Registered at screen level so it fires after TextInput's focus-level
|
|
49
|
+
// bindings (TextInput does not bind 'return' when onSubmit is not passed).
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const unReturn = boundKeyboard(['return'], () => {
|
|
52
|
+
focusSet(resultsFocusId);
|
|
53
|
+
});
|
|
54
|
+
return unReturn;
|
|
55
|
+
}, [boundKeyboard, focusSet, resultsFocusId]);
|
|
56
|
+
return (React.createElement(Box, { flexDirection: "column", width: "100%", height: "100%", borderColor: "white", borderStyle: "bold" },
|
|
57
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
58
|
+
React.createElement(Text, { bold: true }, "> "),
|
|
59
|
+
React.createElement(TextInput, { focusId: focusId, value: value, onChange: setValue, width: inputWidth })),
|
|
60
|
+
React.createElement(Text, { bold: true }, "-".repeat(inputWidth)),
|
|
61
|
+
React.createElement(SelectBar, { items: results, onSelect: handleSelect, focusId: resultsFocusId, query: value })));
|
|
62
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
export interface SearchBarItem<T> {
|
|
3
|
+
/** Display text shown in the results list */
|
|
4
|
+
label: string;
|
|
5
|
+
/** Value returned to onSubmit when this item is selected */
|
|
6
|
+
value: T;
|
|
7
|
+
/** Optional stable key for React reconciliation */
|
|
8
|
+
Key?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SearchBarProps<T, I extends SearchBarItem<T> = SearchBarItem<T>> {
|
|
11
|
+
/** Focus identifier for the TextInput in the keyboard system */
|
|
12
|
+
focusId: string;
|
|
13
|
+
/**
|
|
14
|
+
* Input area width in characters. When omitted, auto-detected from
|
|
15
|
+
* terminal dimensions (columns - 4 for border + prompt).
|
|
16
|
+
*/
|
|
17
|
+
width?: number;
|
|
18
|
+
/** Items to search through. Filtered and sorted by label as the user types. */
|
|
19
|
+
items?: I[];
|
|
20
|
+
/**
|
|
21
|
+
* Called when the user confirms a selection via the selectBar.
|
|
22
|
+
* Receives the selected item.
|
|
23
|
+
*/
|
|
24
|
+
onSubmit?: (item: I) => void;
|
|
25
|
+
/**
|
|
26
|
+
* Component that renders the filtered results and handles selection.
|
|
27
|
+
* Receives filtered items, an onSelect callback, a focusId for keyboard
|
|
28
|
+
* integration, and the current query string.
|
|
29
|
+
*
|
|
30
|
+
* Must be a component that registers its own keyboard bindings under the
|
|
31
|
+
* given focusId (e.g. SelectInput).
|
|
32
|
+
*/
|
|
33
|
+
selectBar: React.ComponentType<{
|
|
34
|
+
items: I[];
|
|
35
|
+
onSelect: (item: I) => void;
|
|
36
|
+
focusId: string;
|
|
37
|
+
query: string;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -3,11 +3,14 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { TextInput } from '../text/TextInput.js';
|
|
4
4
|
import { useKeyboard } from '../../keyboard/index.js';
|
|
5
5
|
export function SearchInput({ focusId, value, onChange, placeholder, onSubmit, }) {
|
|
6
|
-
const { boundKeyboard } = useKeyboard();
|
|
6
|
+
const { boundKeyboard, focusUnregister } = useKeyboard();
|
|
7
7
|
useEffect(() => {
|
|
8
8
|
const unEsc = boundKeyboard(['escape'], () => onChange(''), { focusId });
|
|
9
|
-
return () => {
|
|
10
|
-
|
|
9
|
+
return () => {
|
|
10
|
+
unEsc();
|
|
11
|
+
focusUnregister(focusId);
|
|
12
|
+
};
|
|
13
|
+
}, [focusId, onChange, boundKeyboard, focusUnregister]);
|
|
11
14
|
return (React.createElement(Box, null,
|
|
12
15
|
React.createElement(Text, { color: "blue" }, "Search "),
|
|
13
16
|
React.createElement(TextInput, { focusId: focusId, value: value, onChange: onChange, placeholder: placeholder, onSubmit: onSubmit }),
|
|
@@ -1,39 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
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>;
|
|
3
|
+
export declare function TextInput({ placeholder, mask, showCursor, highlightPastedText, value: originalValue, onChange, onSubmit, focusId, wrap, width, }: TextInputProps): React.JSX.Element;
|
|
27
4
|
/**
|
|
28
5
|
* 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
6
|
*/
|
|
39
|
-
export declare function UncontrolledTextInput({ initialValue, storage, storageKey, ...props }: UncontrolledTextInputProps): React.
|
|
7
|
+
export declare function UncontrolledTextInput({ initialValue, storage, storageKey, ...props }: UncontrolledTextInputProps): React.JSX.Element;
|
|
@@ -1,34 +1,53 @@
|
|
|
1
1
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
-
import { Text } from 'ink';
|
|
2
|
+
import { Text, Box, useWindowSize } from 'ink';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { useKeyboard, useFocusState } from '../../keyboard/index.js';
|
|
5
|
-
/**
|
|
6
|
-
* 将给定字符重复多次
|
|
7
|
-
*/
|
|
8
5
|
function repeatChar(char, count) {
|
|
9
6
|
return Array(count + 1).join(char);
|
|
10
7
|
}
|
|
11
8
|
/**
|
|
12
|
-
*
|
|
9
|
+
* Split text into wrapped lines at maxWidth boundaries.
|
|
13
10
|
*/
|
|
11
|
+
function wrapLines(text, maxWidth) {
|
|
12
|
+
if (maxWidth <= 0 || text.length === 0)
|
|
13
|
+
return [''];
|
|
14
|
+
const lines = [];
|
|
15
|
+
for (let i = 0; i < text.length; i += maxWidth) {
|
|
16
|
+
lines.push(text.slice(i, i + maxWidth));
|
|
17
|
+
}
|
|
18
|
+
return lines;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Convert a flat cursor offset to {line, col} in wrap mode.
|
|
22
|
+
*/
|
|
23
|
+
function offsetToLineCol(offset, maxWidth) {
|
|
24
|
+
if (maxWidth <= 0)
|
|
25
|
+
return { line: 0, col: 0 };
|
|
26
|
+
return {
|
|
27
|
+
line: Math.floor(offset / maxWidth),
|
|
28
|
+
col: offset % maxWidth,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Convert {line, col} back to a flat cursor offset, clamped to [0, textLength].
|
|
33
|
+
*/
|
|
34
|
+
function lineColToOffset(line, col, maxWidth, textLength) {
|
|
35
|
+
const offset = line * maxWidth + col;
|
|
36
|
+
return Math.max(0, Math.min(offset, textLength));
|
|
37
|
+
}
|
|
14
38
|
function renderWithCursor(value, placeholder, mask, showCursor, isFocused, cursorOffset, cursorWidth, highlightPastedText) {
|
|
15
|
-
// 显示用的值:如果设置了掩码则每个字符替换为掩码字符
|
|
16
39
|
const displayValue = mask ? repeatChar(mask, value.length) : value;
|
|
17
|
-
// 未聚焦或不需要显示光标 → 直接返回纯文本(空时显示占位符)
|
|
18
40
|
if (!showCursor || !isFocused) {
|
|
19
41
|
if (displayValue.length === 0 && placeholder) {
|
|
20
42
|
return chalk.grey(placeholder);
|
|
21
43
|
}
|
|
22
44
|
return displayValue;
|
|
23
45
|
}
|
|
24
|
-
// 聚焦且显示光标时的处理
|
|
25
|
-
// 空值 + 有占位符 → 占位符第一个字符反色,其余灰色
|
|
26
46
|
if (displayValue.length === 0 && placeholder) {
|
|
27
47
|
if (placeholder.length === 0)
|
|
28
48
|
return chalk.inverse(' ');
|
|
29
49
|
return chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1));
|
|
30
50
|
}
|
|
31
|
-
// 实际高亮宽度(粘贴高亮时可能大于1)
|
|
32
51
|
const actualHighlightWidth = highlightPastedText ? cursorWidth : 0;
|
|
33
52
|
let result = '';
|
|
34
53
|
for (let i = 0; i < displayValue.length; i++) {
|
|
@@ -37,84 +56,87 @@ function renderWithCursor(value, placeholder, mask, showCursor, isFocused, curso
|
|
|
37
56
|
: i >= cursorOffset - actualHighlightWidth && i <= cursorOffset;
|
|
38
57
|
result += isInHighlight ? chalk.inverse(displayValue[i]) : displayValue[i];
|
|
39
58
|
}
|
|
40
|
-
// 光标在末尾时追加一个反色空格
|
|
41
59
|
if (cursorOffset === displayValue.length) {
|
|
42
60
|
result += chalk.inverse(' ');
|
|
43
61
|
}
|
|
44
62
|
return result;
|
|
45
63
|
}
|
|
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, }) {
|
|
64
|
+
export function TextInput({ placeholder = '', mask, showCursor = true, highlightPastedText = false, value: originalValue, onChange, onSubmit, focusId, wrap = false, width, }) {
|
|
70
65
|
const isFocused = useFocusState(focusId);
|
|
71
66
|
const { boundKeyboard, focusUnregister, enableWildcardPriority } = useKeyboard();
|
|
72
|
-
|
|
67
|
+
const { columns: terminalColumns } = useWindowSize();
|
|
68
|
+
// Available width: prop override or terminal width.
|
|
69
|
+
const availableWidth = Math.max(1, (width ?? terminalColumns) || 80);
|
|
70
|
+
// Virtual-scroll offset (only used when wrap=false).
|
|
71
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
72
|
+
// Cursor position (flat character index).
|
|
73
73
|
const [cursorOffset, setCursorOffset] = useState(originalValue.length);
|
|
74
|
-
//
|
|
74
|
+
// Paste highlight width.
|
|
75
75
|
const [cursorWidth, setCursorWidth] = useState(0);
|
|
76
|
-
//
|
|
76
|
+
// Wildcard priority disable function.
|
|
77
77
|
const disablePriorityRef = useRef(null);
|
|
78
|
-
//
|
|
78
|
+
// Clamp cursor when external value shortens.
|
|
79
79
|
useEffect(() => {
|
|
80
80
|
setCursorOffset((prev) => Math.min(prev, originalValue.length));
|
|
81
81
|
}, [originalValue]);
|
|
82
|
+
// When wrap mode changes or text is cleared externally, reset scroll.
|
|
83
|
+
const isTextEmpty = originalValue.length === 0;
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
setScrollOffset(0);
|
|
86
|
+
}, [wrap, isTextEmpty]);
|
|
82
87
|
/**
|
|
83
|
-
*
|
|
88
|
+
* Ensure the cursor is visible within the virtual-scroll window.
|
|
89
|
+
* No-op when wrap is enabled.
|
|
84
90
|
*/
|
|
91
|
+
const ensureVisible = useCallback((newOffset) => {
|
|
92
|
+
if (wrap)
|
|
93
|
+
return;
|
|
94
|
+
// Reserve 1 char per side for scroll indicators.
|
|
95
|
+
const visibleW = Math.max(1, availableWidth - 2);
|
|
96
|
+
const maxScroll = Math.max(0, originalValue.length - visibleW);
|
|
97
|
+
setScrollOffset((prev) => {
|
|
98
|
+
let next = prev;
|
|
99
|
+
if (newOffset < next)
|
|
100
|
+
next = newOffset;
|
|
101
|
+
if (newOffset >= next + visibleW)
|
|
102
|
+
next = newOffset - visibleW + 1;
|
|
103
|
+
return Math.max(0, Math.min(next, maxScroll));
|
|
104
|
+
});
|
|
105
|
+
}, [wrap, availableWidth, originalValue.length]);
|
|
85
106
|
const moveCursor = useCallback((delta) => {
|
|
86
107
|
if (!showCursor)
|
|
87
108
|
return;
|
|
88
109
|
setCursorOffset((prev) => {
|
|
89
110
|
const next = prev + delta;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return originalValue.length;
|
|
94
|
-
return next;
|
|
111
|
+
const clamped = Math.max(0, Math.min(next, originalValue.length));
|
|
112
|
+
ensureVisible(clamped);
|
|
113
|
+
return clamped;
|
|
95
114
|
});
|
|
96
|
-
// 光标移动后清除粘贴高亮
|
|
97
115
|
setCursorWidth(0);
|
|
98
|
-
}, [showCursor, originalValue.length]);
|
|
116
|
+
}, [showCursor, originalValue.length, ensureVisible]);
|
|
99
117
|
/**
|
|
100
|
-
*
|
|
101
|
-
* @param insertion 要插入的字符串,undefined 表示执行删除操作
|
|
102
|
-
* @param isForwardDelete true 表示 Delete(删光标右侧),false/undefined 表示 Backspace(删光标左侧)
|
|
118
|
+
* Vertical cursor movement for wrap mode.
|
|
103
119
|
*/
|
|
120
|
+
const moveCursorVertical = useCallback((delta) => {
|
|
121
|
+
if (!showCursor || !wrap)
|
|
122
|
+
return;
|
|
123
|
+
const { line, col } = offsetToLineCol(cursorOffset, availableWidth);
|
|
124
|
+
const targetLine = Math.max(0, line + delta);
|
|
125
|
+
const newOffset = lineColToOffset(targetLine, col, availableWidth, originalValue.length);
|
|
126
|
+
setCursorOffset(newOffset);
|
|
127
|
+
setCursorWidth(0);
|
|
128
|
+
}, [showCursor, wrap, cursorOffset, availableWidth, originalValue.length]);
|
|
104
129
|
const modifyText = useCallback((insertion, isForwardDelete = false) => {
|
|
105
130
|
let newValue = originalValue;
|
|
106
131
|
let newOffset = cursorOffset;
|
|
107
132
|
if (insertion === undefined) {
|
|
108
|
-
//
|
|
133
|
+
// Delete.
|
|
109
134
|
if (isForwardDelete && cursorOffset < originalValue.length) {
|
|
110
|
-
// Delete:删除光标右侧一个字符
|
|
111
135
|
newValue =
|
|
112
136
|
originalValue.slice(0, cursorOffset) +
|
|
113
137
|
originalValue.slice(cursorOffset + 1);
|
|
114
|
-
// 光标位置不变
|
|
115
138
|
}
|
|
116
139
|
else if (!isForwardDelete && cursorOffset > 0) {
|
|
117
|
-
// Backspace:删除光标左侧一个字符
|
|
118
140
|
newValue =
|
|
119
141
|
originalValue.slice(0, cursorOffset - 1) +
|
|
120
142
|
originalValue.slice(cursorOffset);
|
|
@@ -125,17 +147,16 @@ export function TextInput({ placeholder = '', mask, showCursor = true, highlight
|
|
|
125
147
|
}
|
|
126
148
|
}
|
|
127
149
|
else {
|
|
128
|
-
//
|
|
150
|
+
// Insert.
|
|
129
151
|
newValue =
|
|
130
152
|
originalValue.slice(0, cursorOffset) +
|
|
131
153
|
insertion +
|
|
132
154
|
originalValue.slice(cursorOffset);
|
|
133
155
|
newOffset = cursorOffset + insertion.length;
|
|
134
156
|
}
|
|
135
|
-
// 边界保护
|
|
136
157
|
newOffset = Math.max(0, Math.min(newOffset, newValue.length));
|
|
137
158
|
setCursorOffset(newOffset);
|
|
138
|
-
|
|
159
|
+
ensureVisible(newOffset);
|
|
139
160
|
if (insertion && insertion.length > 1 && highlightPastedText) {
|
|
140
161
|
setCursorWidth(insertion.length);
|
|
141
162
|
}
|
|
@@ -145,37 +166,39 @@ export function TextInput({ placeholder = '', mask, showCursor = true, highlight
|
|
|
145
166
|
if (newValue !== originalValue) {
|
|
146
167
|
onChange(newValue);
|
|
147
168
|
}
|
|
148
|
-
}, [originalValue, cursorOffset, onChange, highlightPastedText]);
|
|
149
|
-
//
|
|
169
|
+
}, [originalValue, cursorOffset, onChange, highlightPastedText, ensureVisible]);
|
|
170
|
+
// Focus lifecycle.
|
|
150
171
|
useEffect(() => {
|
|
151
172
|
return () => focusUnregister(focusId);
|
|
152
173
|
}, [focusId, focusUnregister]);
|
|
153
|
-
//
|
|
174
|
+
// Keyboard bindings.
|
|
154
175
|
useEffect(() => {
|
|
155
176
|
const fid = focusId;
|
|
156
177
|
const unbindList = [];
|
|
157
|
-
// 开启通配符最高优先级,确保所有普通字符输入优先被 TextInput 捕获
|
|
158
178
|
disablePriorityRef.current?.();
|
|
159
179
|
const disablePriority = enableWildcardPriority();
|
|
160
180
|
disablePriorityRef.current = disablePriority;
|
|
161
|
-
//
|
|
181
|
+
// Left/right.
|
|
162
182
|
unbindList.push(boundKeyboard(['left'], () => moveCursor(-1), { focusId: fid }));
|
|
163
183
|
unbindList.push(boundKeyboard(['right'], () => moveCursor(1), { focusId: fid }));
|
|
164
|
-
//
|
|
184
|
+
// Up/down (wrap mode only).
|
|
185
|
+
if (wrap) {
|
|
186
|
+
unbindList.push(boundKeyboard(['up'], () => moveCursorVertical(-1), { focusId: fid }));
|
|
187
|
+
unbindList.push(boundKeyboard(['down'], () => moveCursorVertical(1), { focusId: fid }));
|
|
188
|
+
}
|
|
189
|
+
// Backspace / Delete.
|
|
165
190
|
unbindList.push(boundKeyboard(['backspace'], () => modifyText(), { focusId: fid }));
|
|
166
191
|
unbindList.push(boundKeyboard(['delete'], () => modifyText(undefined, true), { focusId: fid }));
|
|
167
|
-
//
|
|
192
|
+
// Enter — submit.
|
|
168
193
|
if (onSubmit) {
|
|
169
194
|
unbindList.push(boundKeyboard(['return'], () => {
|
|
170
|
-
// 用户提交后解除通配符优先级,恢复正常按键分发
|
|
171
195
|
disablePriority();
|
|
172
196
|
disablePriorityRef.current = null;
|
|
173
197
|
onSubmit(originalValue);
|
|
174
198
|
}, { focusId: fid }));
|
|
175
199
|
}
|
|
176
|
-
//
|
|
200
|
+
// Wildcard: capture all character input.
|
|
177
201
|
unbindList.push(boundKeyboard(['*'], (input) => modifyText(input), { focusId: fid }));
|
|
178
|
-
// 清理:解绑所有键盘回调并恢复通配符优先级
|
|
179
202
|
return () => {
|
|
180
203
|
unbindList.forEach((fn) => fn());
|
|
181
204
|
if (disablePriorityRef.current) {
|
|
@@ -187,26 +210,55 @@ export function TextInput({ placeholder = '', mask, showCursor = true, highlight
|
|
|
187
210
|
focusId,
|
|
188
211
|
boundKeyboard,
|
|
189
212
|
moveCursor,
|
|
213
|
+
moveCursorVertical,
|
|
190
214
|
modifyText,
|
|
191
215
|
onSubmit,
|
|
192
216
|
originalValue,
|
|
193
217
|
enableWildcardPriority,
|
|
218
|
+
wrap,
|
|
194
219
|
]);
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
220
|
+
// ── Render: wrap mode ──────────────────────────────────────────
|
|
221
|
+
if (wrap) {
|
|
222
|
+
const baseLines = wrapLines(originalValue, availableWidth);
|
|
223
|
+
const displayLines = baseLines.length === 0 ? [''] : [...baseLines];
|
|
224
|
+
const { line: cursorLine, col: cursorCol } = offsetToLineCol(cursorOffset, availableWidth);
|
|
225
|
+
// When empty and placeholder is set, show placeholder on first line.
|
|
226
|
+
if (originalValue.length === 0 && placeholder) {
|
|
227
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
228
|
+
React.createElement(Text, null, renderWithCursor('', placeholder, mask, showCursor, isFocused, 0, 0, false))));
|
|
229
|
+
}
|
|
230
|
+
// Ensure the cursor always has a line to render on,
|
|
231
|
+
// even when it sits at the exact end of the last wrapped line.
|
|
232
|
+
while (displayLines.length <= cursorLine) {
|
|
233
|
+
displayLines.push('');
|
|
234
|
+
}
|
|
235
|
+
return (React.createElement(Box, { flexDirection: "column" }, displayLines.map((line, i) => {
|
|
236
|
+
if (i === cursorLine) {
|
|
237
|
+
return (React.createElement(Text, { key: i }, renderWithCursor(line, '', mask, showCursor, isFocused, cursorCol, cursorWidth, highlightPastedText)));
|
|
238
|
+
}
|
|
239
|
+
return React.createElement(Text, { key: i }, line);
|
|
240
|
+
})));
|
|
241
|
+
}
|
|
242
|
+
// ── Render: virtual-scroll mode ────────────────────────────────
|
|
243
|
+
if (originalValue.length === 0 && placeholder) {
|
|
244
|
+
return (React.createElement(Text, null, renderWithCursor('', placeholder, mask, showCursor, isFocused, 0, 0, false)));
|
|
245
|
+
}
|
|
246
|
+
if (originalValue.length <= availableWidth) {
|
|
247
|
+
return (React.createElement(Text, null, renderWithCursor(originalValue, '', mask, showCursor, isFocused, cursorOffset, cursorWidth, highlightPastedText)));
|
|
248
|
+
}
|
|
249
|
+
// Text is wider than available width — virtual scroll.
|
|
250
|
+
const visibleW = Math.max(1, availableWidth - 2);
|
|
251
|
+
const maxScroll = Math.max(0, originalValue.length - visibleW);
|
|
252
|
+
const windowStart = Math.max(0, Math.min(scrollOffset, maxScroll));
|
|
253
|
+
const windowText = originalValue.slice(windowStart, windowStart + visibleW);
|
|
254
|
+
const localCursor = cursorOffset - windowStart;
|
|
255
|
+
const textRendered = renderWithCursor(windowText, '', mask, showCursor, isFocused, localCursor, cursorWidth, highlightPastedText);
|
|
256
|
+
const leftIndicator = windowStart > 0 ? chalk.grey('←') : '';
|
|
257
|
+
const rightIndicator = windowStart + visibleW < originalValue.length ? chalk.grey('→') : '';
|
|
258
|
+
return React.createElement(Text, null, leftIndicator + textRendered + rightIndicator);
|
|
198
259
|
}
|
|
199
260
|
/**
|
|
200
261
|
* 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
262
|
*/
|
|
211
263
|
export function UncontrolledTextInput({ initialValue = '', storage, storageKey, ...props }) {
|
|
212
264
|
const [value, setValue] = useState(initialValue);
|
|
@@ -225,9 +277,5 @@ export function UncontrolledTextInput({ initialValue = '', storage, storageKey,
|
|
|
225
277
|
setValue(newVal);
|
|
226
278
|
storage?.write.str(persistKey, newVal);
|
|
227
279
|
};
|
|
228
|
-
return React.createElement(TextInput, {
|
|
229
|
-
...props,
|
|
230
|
-
value,
|
|
231
|
-
onChange: handleChange,
|
|
232
|
-
});
|
|
280
|
+
return (React.createElement(TextInput, { ...props, value: value, onChange: handleChange }));
|
|
233
281
|
}
|
|
@@ -39,6 +39,17 @@ export type TextInputProps = {
|
|
|
39
39
|
* Must be unique on the current screen.
|
|
40
40
|
*/
|
|
41
41
|
readonly focusId: string;
|
|
42
|
+
/**
|
|
43
|
+
* When true, text wraps to the next line instead of virtual-scrolling.
|
|
44
|
+
* Up/down arrow keys navigate between wrapped lines.
|
|
45
|
+
* @default false
|
|
46
|
+
*/
|
|
47
|
+
readonly wrap?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Available width in characters. When omitted, auto-detected from
|
|
50
|
+
* terminal dimensions via useWindowSize.
|
|
51
|
+
*/
|
|
52
|
+
readonly width?: number;
|
|
42
53
|
};
|
|
43
54
|
/**
|
|
44
55
|
* Props for the uncontrolled TextInput component.
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { KeyboardProvider, useKeyboard } from "./keyboard/index.js";
|
|
|
4
4
|
export { normalizeKeyNames, isNormalCharacter } from "./keyboard/index.js";
|
|
5
5
|
export type { KeyHandler, BoundKeyboardOptions, BoundKeyEntry, ScreenKeyboardLayer, KeyboardProviderProps, GlobalKeyEntry, GlobalSequenceEntry, } from "./keyboard/index.js";
|
|
6
6
|
export type { PenetrationOptions, AllowModalOptions, StopOptions, LayerKind, FocusTarget, SequenceOptions, ShortcutOperationEntry, SequenceOperationEntry, ModalMissEvent, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, } from "./keyboard/index.js";
|
|
7
|
-
export { useFocusState } from "./keyboard/index.js";
|
|
7
|
+
export { useFocusState, useModalMissListener } from "./keyboard/index.js";
|
|
8
8
|
export { SelectInput } from "./components/select/SelectInput.js";
|
|
9
9
|
export type { Item } from "./components/select/types.js";
|
|
10
10
|
export type { SelectInputProps } from "./components/select/types.js";
|
|
@@ -24,6 +24,8 @@ export { Badge } from "./components/badge/Badge.js";
|
|
|
24
24
|
export { KeyHint } from "./components/key-hint/KeyHint.js";
|
|
25
25
|
export { NumberInput } from "./components/number-input/NumberInput.js";
|
|
26
26
|
export { SearchInput } from "./components/search-input/SearchInput.js";
|
|
27
|
+
export { default as SearchBar } from "./components/search-bar/SearchBar.js";
|
|
28
|
+
export type { SearchBarItem, SearchBarProps } from "./components/search-bar/search-bar-types.js";
|
|
27
29
|
export { Tabs } from "./components/tabs/Tabs.js";
|
|
28
30
|
export type { Tab, TabsProps } from "./components/tabs/types.js";
|
|
29
31
|
export { Fold } from "./components/fold/Fold.js";
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ export { registerComponent, ScenarioManagementProvider, CurrentScreen, skip, bac
|
|
|
3
3
|
// ── Keyboard System ────────────────────────────────────────
|
|
4
4
|
export { KeyboardProvider, useKeyboard } from "./keyboard/index.js";
|
|
5
5
|
export { normalizeKeyNames, isNormalCharacter } from "./keyboard/index.js";
|
|
6
|
-
export { useFocusState } from "./keyboard/index.js";
|
|
6
|
+
export { useFocusState, useModalMissListener } from "./keyboard/index.js";
|
|
7
7
|
// Components — SelectInput
|
|
8
8
|
export { SelectInput } from "./components/select/SelectInput.js";
|
|
9
9
|
// Components — SelectRow
|
|
@@ -28,6 +28,8 @@ export { KeyHint } from "./components/key-hint/KeyHint.js";
|
|
|
28
28
|
export { NumberInput } from "./components/number-input/NumberInput.js";
|
|
29
29
|
// Components — SearchInput
|
|
30
30
|
export { SearchInput } from "./components/search-input/SearchInput.js";
|
|
31
|
+
// Components — SearchBar
|
|
32
|
+
export { default as SearchBar } from "./components/search-bar/SearchBar.js";
|
|
31
33
|
// Components — Tabs
|
|
32
34
|
export { Tabs } from "./components/tabs/Tabs.js";
|
|
33
35
|
// Components — Fold
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { handleLayer } from '../layer-handler.js';
|
|
2
|
+
function passAllowedKeys(allowedKeys, blockedKeys, eventNames) {
|
|
3
|
+
return allowedKeys.some((k) => !blockedKeys.includes(k.key) && eventNames.includes(k.key));
|
|
4
|
+
}
|
|
2
5
|
/**
|
|
3
6
|
* Check whether a key event matches any entry in the allow-list of
|
|
4
7
|
* the active focus target or the layer itself.
|
|
5
8
|
*/
|
|
6
9
|
function isAllowed(layer, eventNames) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
const blockedKeys = layer.allowedKeys
|
|
11
|
+
.filter((r) => r.when?.() === false)
|
|
12
|
+
.map((r) => r.key);
|
|
10
13
|
if (layer.currentFocusId) {
|
|
11
14
|
const ft = layer.focusTargets.get(layer.currentFocusId);
|
|
12
|
-
if (ft
|
|
15
|
+
if (ft && passAllowedKeys(ft.allowedKeys, blockedKeys, eventNames)) {
|
|
13
16
|
return true;
|
|
14
17
|
}
|
|
15
18
|
}
|
|
19
|
+
if (passAllowedKeys(layer.allowedKeys, blockedKeys, eventNames)) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
16
22
|
return false;
|
|
17
23
|
}
|
|
18
24
|
/**
|
|
@@ -434,7 +434,7 @@ export function KeyboardProvider({ children }) {
|
|
|
434
434
|
const container = options?.focusId
|
|
435
435
|
? getOrCreateFocusTarget(layer, options.focusId)
|
|
436
436
|
: layer;
|
|
437
|
-
return pushKeyEntries(container, 'allowedKeys', keys, (key) => ({ key }));
|
|
437
|
+
return pushKeyEntries(container, 'allowedKeys', keys, (key) => ({ key, when: options?.when }));
|
|
438
438
|
}, [getCurrentOwner, getLayer, getOrCreateFocusTarget]);
|
|
439
439
|
/**
|
|
440
440
|
* Bind keys on the current layer (screen or overlay).
|
package/dist/keyboard/types.d.ts
CHANGED
|
@@ -385,6 +385,8 @@ export interface PenetrationOptions {
|
|
|
385
385
|
export interface AllowModalOptions {
|
|
386
386
|
/** If provided, allows only within the named focus target. */
|
|
387
387
|
focusId?: string;
|
|
388
|
+
/** Optional condition callback. When provided, the key is only allowed through when this returns `true`. When `false`, the allow rule is ignored and the key is blocked. */
|
|
389
|
+
when?: () => boolean;
|
|
388
390
|
}
|
|
389
391
|
/**
|
|
390
392
|
* A single global key definition.
|
package/dist/screen/context.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface ScreenSystemContextValue {
|
|
|
51
51
|
closeModal: CloseModalFn;
|
|
52
52
|
/** Close all open modals at once. */
|
|
53
53
|
closeAllModals: CloseAllModalsFn;
|
|
54
|
+
/** Whether to turn on full screen effect */
|
|
55
|
+
fullScreen?: boolean;
|
|
54
56
|
}
|
|
55
57
|
/**
|
|
56
58
|
* React context for the screen navigation system.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Box } from 'ink';
|
|
2
|
+
import { Box, useWindowSize } from 'ink';
|
|
3
3
|
import { useScreenSystem } from './hook.js';
|
|
4
4
|
import { OverlayContext } from './OverlayContext.js';
|
|
5
5
|
import { ModalContext } from './ModalContext.js';
|
|
@@ -18,7 +18,8 @@ import { ModalContext } from './ModalContext.js';
|
|
|
18
18
|
* Architecturally symmetric between overlays and modals.
|
|
19
19
|
*/
|
|
20
20
|
export function CurrentScreen() {
|
|
21
|
-
const { currentScreen, currentOverlays, displayedOverlays, currentModals, renderedModalEntries } = useScreenSystem();
|
|
21
|
+
const { currentScreen, currentOverlays, displayedOverlays, currentModals, renderedModalEntries, fullScreen } = useScreenSystem();
|
|
22
|
+
const { rows } = useWindowSize();
|
|
22
23
|
// Build overlay elements with OverlayContext wrappers
|
|
23
24
|
const wrappedOverlays = currentOverlays.map((overlayNode, i) => {
|
|
24
25
|
const entry = displayedOverlays[i];
|
|
@@ -34,7 +35,7 @@ export function CurrentScreen() {
|
|
|
34
35
|
return modalNode;
|
|
35
36
|
return React.createElement(ModalContext.Provider, { value: { id: entry.id, originComponent: entry.originComponent }, key: `mdl-ctx-${entry.id}` }, modalNode);
|
|
36
37
|
});
|
|
37
|
-
return (React.createElement(Box, { flexDirection: 'column', width: '100%', height: '100%' },
|
|
38
|
+
return (React.createElement(Box, { flexDirection: 'column', width: '100%', height: fullScreen ? rows : '100%' },
|
|
38
39
|
currentScreen,
|
|
39
40
|
wrappedOverlays.map((w) => w),
|
|
40
41
|
wrappedModals.map((w) => w)));
|
|
@@ -111,6 +111,7 @@ export interface ScenarioManagementProviderProps {
|
|
|
111
111
|
defaultScreen: React.ComponentType<any>;
|
|
112
112
|
/** 默认参数(可选,未传则使用注册时的模板参数) */
|
|
113
113
|
defaultParams?: Record<string, unknown>;
|
|
114
|
+
fullScreen?: boolean;
|
|
114
115
|
}
|
|
115
116
|
/**
|
|
116
117
|
* Screen-management context provider.
|
|
@@ -118,4 +119,4 @@ export interface ScenarioManagementProviderProps {
|
|
|
118
119
|
* Wraps the application and enables tree-based screen navigation, overlays,
|
|
119
120
|
* and module-level navigation functions.
|
|
120
121
|
*/
|
|
121
|
-
export declare function ScenarioManagementProvider({ children, defaultScreen, defaultParams, }: ScenarioManagementProviderProps): React.JSX.Element;
|
|
122
|
+
export declare function ScenarioManagementProvider({ children, defaultScreen, defaultParams, fullScreen }: ScenarioManagementProviderProps): React.JSX.Element;
|
package/dist/screen/provider.js
CHANGED
|
@@ -483,7 +483,7 @@ function screenReducer(state, action) {
|
|
|
483
483
|
* Wraps the application and enables tree-based screen navigation, overlays,
|
|
484
484
|
* and module-level navigation functions.
|
|
485
485
|
*/
|
|
486
|
-
export function ScenarioManagementProvider({ children, defaultScreen, defaultParams, }) {
|
|
486
|
+
export function ScenarioManagementProvider({ children, defaultScreen, defaultParams, fullScreen }) {
|
|
487
487
|
if (!hasComponent(defaultScreen)) {
|
|
488
488
|
throw new Error(`[Ink-Cartridge] defaultScreen "${defaultScreen.displayName || defaultScreen.name || 'anonymous'}" is not registered. Please call registerComponent() first.`);
|
|
489
489
|
}
|
|
@@ -614,6 +614,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
|
|
|
614
614
|
openModal: openModalInContext,
|
|
615
615
|
closeModal: closeModalInContext,
|
|
616
616
|
closeAllModals: closeAllModalsInContext,
|
|
617
|
+
fullScreen
|
|
617
618
|
}), [
|
|
618
619
|
currentScreen,
|
|
619
620
|
currentOverlays,
|
|
@@ -636,6 +637,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
|
|
|
636
637
|
closeModalInContext,
|
|
637
638
|
closeAllModalsInContext,
|
|
638
639
|
state.activeModalId,
|
|
640
|
+
fullScreen
|
|
639
641
|
]);
|
|
640
642
|
return (React.createElement(ScreenSystemContext.Provider, { value: value }, children));
|
|
641
643
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ink-cartridge",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.3",
|
|
4
4
|
"description": "Ready-to-use Ink components and screen management system for building terminal UIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"eslint": "^9.39.4",
|
|
63
63
|
"eslint-plugin-react": "^7.37.5",
|
|
64
64
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
65
|
-
"ink": "^7.0
|
|
65
|
+
"ink": "^7.1.0",
|
|
66
66
|
"ink-testing-library": "^4.0.0",
|
|
67
67
|
"jsdom": "^26.0.0",
|
|
68
68
|
"react": "^19.2.4",
|