ink-cartridge 3.8.1 → 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 +4 -3
- 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/dev/dev-screen.d.ts +1 -1
- package/dist/dev/dev-screen.js +6 -6
- package/dist/dev/layerKey-display.js +13 -13
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/keyboard/context.d.ts +2 -2
- package/dist/keyboard/hook.d.ts +1 -1
- package/dist/keyboard/hook.js +1 -1
- package/dist/keyboard/index.d.ts +1 -1
- package/dist/keyboard/layer-handler.d.ts +3 -3
- package/dist/keyboard/layer-handler.js +22 -22
- package/dist/keyboard/modal-processor/index.js +12 -6
- package/dist/keyboard/provider.d.ts +1 -1
- package/dist/keyboard/provider.js +7 -7
- package/dist/keyboard/types.d.ts +11 -9
- 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 +6 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ npx ink-cartridge init my-tui
|
|
|
56
56
|
- [useKeyboard](docs/keyboard/useKeyboard-API.md)
|
|
57
57
|
- [boundKeyboard](docs/keyboard/boundKeyboard-API.md)
|
|
58
58
|
- [boundSequence](docs/keyboard/boundSequence-API.md)
|
|
59
|
-
- [
|
|
59
|
+
- [penetration](docs/keyboard/penetration-API.md)
|
|
60
60
|
- [stop](docs/keyboard/stop-API.md)
|
|
61
61
|
- [globalKeys](docs/keyboard/globalKeys-API.md)
|
|
62
62
|
- [globalSequence](docs/keyboard/globalSequence-API.md)
|
|
@@ -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/dev/dev-screen.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { DevProps } from "./types.js";
|
|
|
18
18
|
* The panel registers itself via `registerComponent` so it participates
|
|
19
19
|
* in the modal keyboard layer automatically.
|
|
20
20
|
*
|
|
21
|
-
* As a modal, DevScreen
|
|
21
|
+
* As a modal, DevScreen consumes all keyboard events from reaching overlays
|
|
22
22
|
* and screens while open.
|
|
23
23
|
*
|
|
24
24
|
* @param top - Initial vertical position in rows (0 = top of terminal).
|
package/dist/dev/dev-screen.js
CHANGED
|
@@ -92,7 +92,7 @@ function AllFocusSummary({ currentPath, activeOverlayIds, activeModalId, readLay
|
|
|
92
92
|
}
|
|
93
93
|
/**
|
|
94
94
|
* Renders a compact summary of the keyboard layer for the top screen
|
|
95
|
-
* component: bindings, sequences, stopped keys, and
|
|
95
|
+
* component: bindings, sequences, stopped keys, and penetration keys.
|
|
96
96
|
*
|
|
97
97
|
* Focus targets are displayed separately by {@link AllFocusSummary}
|
|
98
98
|
* so that all layers' targets are visible at once.
|
|
@@ -107,7 +107,7 @@ function LayerSummary({ topComponent, readLayer }) {
|
|
|
107
107
|
const seqCount = [...layer.sequences.values()].reduce((s, a) => s + a.length, 0);
|
|
108
108
|
const seqFirstKeys = [...layer.sequences.keys()].join(' ');
|
|
109
109
|
const stopped = layer.stoppedKeys.map(r => Array.isArray(r.key) ? r.key.join(',') : r.key).join(' ');
|
|
110
|
-
const
|
|
110
|
+
const penetrated = layer.penetrationKeys.map(r => Array.isArray(r.key) ? r.key.join(',') : r.key).join(' ');
|
|
111
111
|
return (React.createElement(Box, { flexDirection: "column", paddingY: 1 },
|
|
112
112
|
React.createElement(Box, { flexDirection: "row" },
|
|
113
113
|
React.createElement(Text, { color: "cyan" },
|
|
@@ -128,9 +128,9 @@ function LayerSummary({ topComponent, readLayer }) {
|
|
|
128
128
|
stopped && (React.createElement(Box, { flexDirection: "row" },
|
|
129
129
|
React.createElement(Text, { color: "red" }, "Stopped: "),
|
|
130
130
|
React.createElement(Text, { dimColor: true }, stopped))),
|
|
131
|
-
|
|
132
|
-
React.createElement(Text, { color: "gray" }, "
|
|
133
|
-
React.createElement(Text, { dimColor: true },
|
|
131
|
+
penetrated && (React.createElement(Box, { flexDirection: "row" },
|
|
132
|
+
React.createElement(Text, { color: "gray" }, "Penetr: "),
|
|
133
|
+
React.createElement(Text, { dimColor: true }, penetrated)))));
|
|
134
134
|
}
|
|
135
135
|
/**
|
|
136
136
|
* Developer debugging modal for the ink-cartridge screen system.
|
|
@@ -150,7 +150,7 @@ function LayerSummary({ topComponent, readLayer }) {
|
|
|
150
150
|
* The panel registers itself via `registerComponent` so it participates
|
|
151
151
|
* in the modal keyboard layer automatically.
|
|
152
152
|
*
|
|
153
|
-
* As a modal, DevScreen
|
|
153
|
+
* As a modal, DevScreen consumes all keyboard events from reaching overlays
|
|
154
154
|
* and screens while open.
|
|
155
155
|
*
|
|
156
156
|
* @param top - Initial vertical position in rows (0 = top of terminal).
|
|
@@ -116,8 +116,8 @@ function FocusTargetDetail({ focusId, target }) {
|
|
|
116
116
|
React.createElement(Text, { color: "white" }, focusId)),
|
|
117
117
|
React.createElement(Row, { label: "Bindings" },
|
|
118
118
|
React.createElement(Text, { color: "white" }, target.bindings.length)),
|
|
119
|
-
React.createElement(Row, { label: "
|
|
120
|
-
React.createElement(Text, { color: "white" }, target.
|
|
119
|
+
React.createElement(Row, { label: "Penetr." },
|
|
120
|
+
React.createElement(Text, { color: "white" }, target.penetrationKeys.length)),
|
|
121
121
|
React.createElement(Row, { label: "Stopped" },
|
|
122
122
|
React.createElement(Text, { color: "white" }, target.stoppedKeys.length))),
|
|
123
123
|
target.bindings.length > 0 && (React.createElement(Box, { paddingTop: 1, flexDirection: "column" },
|
|
@@ -126,9 +126,9 @@ function FocusTargetDetail({ focusId, target }) {
|
|
|
126
126
|
" [",
|
|
127
127
|
b.keys.join(', '),
|
|
128
128
|
"]"))))),
|
|
129
|
-
target.
|
|
130
|
-
React.createElement(Text, { color: "gray", dimColor: true }, "
|
|
131
|
-
target.
|
|
129
|
+
target.penetrationKeys.length > 0 && (React.createElement(Box, { paddingTop: 1, flexDirection: "column" },
|
|
130
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Penetration:"),
|
|
131
|
+
target.penetrationKeys.map((r, i) => (React.createElement(Text, { key: i, color: "gray" },
|
|
132
132
|
" ",
|
|
133
133
|
fmtKeys(r.key)))))),
|
|
134
134
|
target.stoppedKeys.length > 0 && (React.createElement(Box, { paddingTop: 1, flexDirection: "column" },
|
|
@@ -213,18 +213,18 @@ export default function LayerKeyDisplayBox({ top: initialTop, left, screenCompon
|
|
|
213
213
|
Key: `stop-${i}`,
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
|
-
//
|
|
217
|
-
layer.
|
|
216
|
+
// Penetration keys
|
|
217
|
+
layer.penetrationKeys.forEach((rule, i) => {
|
|
218
218
|
items.push({
|
|
219
|
-
label: `[
|
|
220
|
-
value: { kind: '
|
|
219
|
+
label: `[Pen] ${keyRuleLabel(rule)}`,
|
|
220
|
+
value: { kind: 'penetrated', idx: i, rule },
|
|
221
221
|
Key: `blk-${i}`,
|
|
222
222
|
});
|
|
223
223
|
});
|
|
224
224
|
// Focus targets
|
|
225
225
|
for (const [focusId, target] of layer.focusTargets) {
|
|
226
226
|
items.push({
|
|
227
|
-
label: `[Foc] ${focusId} (bind:${target.bindings.length}
|
|
227
|
+
label: `[Foc] ${focusId} (bind:${target.bindings.length} pen:${target.penetrationKeys.length} stp:${target.stoppedKeys.length})`,
|
|
228
228
|
value: { kind: 'focusTarget', focusId, target },
|
|
229
229
|
Key: `focus-${focusId}`,
|
|
230
230
|
});
|
|
@@ -249,8 +249,8 @@ export default function LayerKeyDisplayBox({ top: initialTop, left, screenCompon
|
|
|
249
249
|
return React.createElement(SequenceDetail, { firstKey: expandedEntry.firstKey, entry: expandedEntry.entry });
|
|
250
250
|
case 'stopped':
|
|
251
251
|
return React.createElement(KeyRuleDetail, { label: "Stopped Key", rule: expandedEntry.rule });
|
|
252
|
-
case '
|
|
253
|
-
return React.createElement(KeyRuleDetail, { label: "
|
|
252
|
+
case 'penetrated':
|
|
253
|
+
return React.createElement(KeyRuleDetail, { label: "Penetration Key (pass-through)", rule: expandedEntry.rule });
|
|
254
254
|
case 'focusTarget':
|
|
255
255
|
return React.createElement(FocusTargetDetail, { focusId: expandedEntry.focusId, target: expandedEntry.target });
|
|
256
256
|
}
|
|
@@ -266,7 +266,7 @@ export default function LayerKeyDisplayBox({ top: initialTop, left, screenCompon
|
|
|
266
266
|
layer.currentFocusId ?? 'none')),
|
|
267
267
|
React.createElement(Sep, null),
|
|
268
268
|
expandedEntry ? (renderDetail()) : items.length === 0 ? (React.createElement(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center" },
|
|
269
|
-
React.createElement(Text, { color: "gray" }, "No bindings, sequences, stopped/
|
|
269
|
+
React.createElement(Text, { color: "gray" }, "No bindings, sequences, stopped/penetration keys, or focus targets."))) : (React.createElement(SelectInput, { items: items, onSelect: handleSelect, focusId: "layerKey-list", limit: 9 })),
|
|
270
270
|
React.createElement(Sep, null),
|
|
271
271
|
React.createElement(Box, { paddingTop: 1 },
|
|
272
272
|
React.createElement(Text, { dimColor: true }, expandedEntry
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,8 @@ export type { SkipOptions, SkipFn, BackFn, GotoScreenFn, OpenOverlayFn, CloseOve
|
|
|
3
3
|
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
|
-
export type {
|
|
7
|
-
export { useFocusState } from "./keyboard/index.js";
|
|
6
|
+
export type { PenetrationOptions, AllowModalOptions, StopOptions, LayerKind, FocusTarget, SequenceOptions, ShortcutOperationEntry, SequenceOperationEntry, ModalMissEvent, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, } 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,4 +1,4 @@
|
|
|
1
|
-
import type { KeyHandler, BoundKeyboardOptions,
|
|
1
|
+
import type { KeyHandler, BoundKeyboardOptions, PenetrationOptions, StopOptions, AllowModalOptions, GlobalKeyEntry, GlobalSequenceEntry, ShortcutOperationEntry, SequenceOperationEntry, SequenceOptions, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, ResolvedGlobalSequenceEntry, GlobalPendingSequence, ScreenKeyboardLayer } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* Type for the owner stack used to track overlay context.
|
|
4
4
|
* Can be a component type (for screens) or a string (for overlay IDs).
|
|
@@ -45,7 +45,7 @@ export interface KeyboardContextValue {
|
|
|
45
45
|
* @param options If `focusId` is provided, marks transparent only
|
|
46
46
|
* within that focus target.
|
|
47
47
|
*/
|
|
48
|
-
|
|
48
|
+
penetration: (keys: string[], options?: PenetrationOptions) => () => void;
|
|
49
49
|
/**
|
|
50
50
|
* Prevent one or more keys from propagating to layers below.
|
|
51
51
|
*
|
package/dist/keyboard/hook.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ModalMissCallback, ModalMissOptions } from "./types.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Access the keyboard API from within a React component.
|
|
5
5
|
*
|
|
6
|
-
* Returns `{ boundKeyboard,
|
|
6
|
+
* Returns `{ boundKeyboard, penetration, stop, globalKeys, ... }`.
|
|
7
7
|
*
|
|
8
8
|
* When called inside an overlay component (wrapped in OverlayContext.Provider),
|
|
9
9
|
* keyboard bindings are automatically isolated to the overlay's own layer,
|
package/dist/keyboard/hook.js
CHANGED
|
@@ -6,7 +6,7 @@ import { useScreenSystem } from "../screen/hook.js";
|
|
|
6
6
|
/**
|
|
7
7
|
* Access the keyboard API from within a React component.
|
|
8
8
|
*
|
|
9
|
-
* Returns `{ boundKeyboard,
|
|
9
|
+
* Returns `{ boundKeyboard, penetration, stop, globalKeys, ... }`.
|
|
10
10
|
*
|
|
11
11
|
* When called inside an overlay component (wrapped in OverlayContext.Provider),
|
|
12
12
|
* keyboard bindings are automatically isolated to the overlay's own layer,
|
package/dist/keyboard/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { KeyboardProvider } from "./provider.js";
|
|
|
2
2
|
export type { KeyboardProviderProps } from "./provider.js";
|
|
3
3
|
export { useKeyboard, useFocusState, useModalMissListener } from "./hook.js";
|
|
4
4
|
export { normalizeKeyNames, isNormalCharacter } from "./keyNormalizer.js";
|
|
5
|
-
export type { KeyHandler, BoundKeyboardOptions,
|
|
5
|
+
export type { KeyHandler, BoundKeyboardOptions, PenetrationOptions, AllowModalOptions, StopOptions, BoundKeyEntry, ScreenKeyboardLayer, LayerKind, FocusTarget, GlobalKeyEntry, GlobalSequenceEntry, ShortcutOperationEntry, SequenceOperationEntry, SequenceOptions, SequenceBinding, PendingSequence, ModalMissEvent, ModalMissCallback, ModalMissOptions, ResolvedGlobalKeyEntry, } from "./types.js";
|
|
@@ -25,14 +25,14 @@ export declare function keyMatchesRule(keyName: string, rules: KeyRule[]): boole
|
|
|
25
25
|
* all must pass for the binding to fire.
|
|
26
26
|
*
|
|
27
27
|
* @param bindings Ordered list of key bindings to try.
|
|
28
|
-
* @param
|
|
28
|
+
* @param availableKeys Normalized key names available for matching at this layer.
|
|
29
29
|
* @param input Raw character from Ink's useInput.
|
|
30
30
|
* @param key Full Key descriptor from Ink.
|
|
31
31
|
* @param skipBinding Optional predicate to skip individual bindings
|
|
32
32
|
* (used for `onlyThis` enforcement).
|
|
33
33
|
* @returns `true` if a binding matched and consumed the event.
|
|
34
34
|
*/
|
|
35
|
-
export declare function tryMatchBindings(bindings: BoundKeyEntry[],
|
|
35
|
+
export declare function tryMatchBindings(bindings: BoundKeyEntry[], availableKeys: string[], input: string, key: Key, skipBinding?: (binding: BoundKeyEntry) => boolean): boolean;
|
|
36
36
|
/**
|
|
37
37
|
* Built-in Tab / Shift+Tab focus rotation for a given layer.
|
|
38
38
|
*
|
|
@@ -46,7 +46,7 @@ export declare function handleTabNavigation(layer: ScreenKeyboardLayer, eventNam
|
|
|
46
46
|
/**
|
|
47
47
|
* Handle a keyboard event against a single layer.
|
|
48
48
|
*
|
|
49
|
-
* Evaluates tab navigation,
|
|
49
|
+
* Evaluates tab navigation, penetration keys, wildcard priority, sequence
|
|
50
50
|
* matching, focus-target bindings, layer-level bindings, and stopped
|
|
51
51
|
* keys — in that order.
|
|
52
52
|
*
|
|
@@ -33,22 +33,22 @@ export function keyMatchesRule(keyName, rules) {
|
|
|
33
33
|
* all must pass for the binding to fire.
|
|
34
34
|
*
|
|
35
35
|
* @param bindings Ordered list of key bindings to try.
|
|
36
|
-
* @param
|
|
36
|
+
* @param availableKeys Normalized key names available for matching at this layer.
|
|
37
37
|
* @param input Raw character from Ink's useInput.
|
|
38
38
|
* @param key Full Key descriptor from Ink.
|
|
39
39
|
* @param skipBinding Optional predicate to skip individual bindings
|
|
40
40
|
* (used for `onlyThis` enforcement).
|
|
41
41
|
* @returns `true` if a binding matched and consumed the event.
|
|
42
42
|
*/
|
|
43
|
-
export function tryMatchBindings(bindings,
|
|
44
|
-
if (
|
|
43
|
+
export function tryMatchBindings(bindings, availableKeys, input, key, skipBinding) {
|
|
44
|
+
if (availableKeys.length === 0)
|
|
45
45
|
return false;
|
|
46
46
|
for (const binding of bindings) {
|
|
47
47
|
if (skipBinding && skipBinding(binding))
|
|
48
48
|
continue;
|
|
49
49
|
if (binding.when?.() === false)
|
|
50
50
|
continue;
|
|
51
|
-
if (binding.keys.some((k) =>
|
|
51
|
+
if (binding.keys.some((k) => availableKeys.includes(k))) {
|
|
52
52
|
binding.handler(input, key);
|
|
53
53
|
return true;
|
|
54
54
|
}
|
|
@@ -91,7 +91,7 @@ export function handleTabNavigation(layer, eventNames, shift, notifyFocusChange)
|
|
|
91
91
|
/**
|
|
92
92
|
* Handle a keyboard event against a single layer.
|
|
93
93
|
*
|
|
94
|
-
* Evaluates tab navigation,
|
|
94
|
+
* Evaluates tab navigation, penetration keys, wildcard priority, sequence
|
|
95
95
|
* matching, focus-target bindings, layer-level bindings, and stopped
|
|
96
96
|
* keys — in that order.
|
|
97
97
|
*
|
|
@@ -105,8 +105,8 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
|
|
|
105
105
|
// they can also be bound to business-specific keys.
|
|
106
106
|
if (isTop && handleTabNavigation(layer, eventNames, key.shift, notifyFocusChange))
|
|
107
107
|
return true;
|
|
108
|
-
const
|
|
109
|
-
const
|
|
108
|
+
const penetrated = layer.penetrationKeys;
|
|
109
|
+
const available = eventNames.filter((n) => !keyMatchesRule(n, penetrated));
|
|
110
110
|
// onlyThis semantics differ between screens and overlays:
|
|
111
111
|
// - Screen: skip when any overlay is active (activeOverlayCount > 0)
|
|
112
112
|
// - Overlay: skip only when multiple overlays compete (activeOverlayCount > 1)
|
|
@@ -120,14 +120,14 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
|
|
|
120
120
|
// Wildcard priority pre-check: when enabled, wildcard `*` bindings
|
|
121
121
|
// are evaluated before sequences, exact matches, and everything else.
|
|
122
122
|
// Only normal characters are affected — special keys fall through.
|
|
123
|
-
if (isTop && wildcardFirst &&
|
|
123
|
+
if (isTop && wildcardFirst && available.length > 0) {
|
|
124
124
|
// Check focus-target wildcard first
|
|
125
125
|
if (layer.currentFocusId) {
|
|
126
126
|
const ft = layer.focusTargets.get(layer.currentFocusId);
|
|
127
127
|
if (ft) {
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
128
|
+
const fPenetrated = ft.penetrationKeys;
|
|
129
|
+
const fAvailable = available.filter(n => !keyMatchesRule(n, fPenetrated));
|
|
130
|
+
if (fAvailable.length > 0) {
|
|
131
131
|
const wb = ft.bindings.find(b => b.keys.includes('*'));
|
|
132
132
|
if (wb && isNormalCharacter(input, key)) {
|
|
133
133
|
if (wb.when?.() === false) { /* skip */ }
|
|
@@ -151,7 +151,7 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
|
|
|
151
151
|
}
|
|
152
152
|
// Sequence matching: only for the top layer (isTop).
|
|
153
153
|
// Sequences have priority over ordinary boundKeyboard bindings.
|
|
154
|
-
if (isTop &&
|
|
154
|
+
if (isTop && available.length > 0) {
|
|
155
155
|
const pending = layer.pendingSequence;
|
|
156
156
|
// We already have a pending sequence in progress.
|
|
157
157
|
if (pending !== null) {
|
|
@@ -164,14 +164,14 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
|
|
|
164
164
|
}
|
|
165
165
|
else {
|
|
166
166
|
const expectedKey = pending.sequences[pending.nextIndex];
|
|
167
|
-
if (
|
|
167
|
+
if (available.includes(expectedKey)) {
|
|
168
168
|
// Matched the next key in the sequence.
|
|
169
169
|
clearTimeout(pending.timer);
|
|
170
170
|
pending.nextIndex++;
|
|
171
171
|
// Narrow candidates to only those whose next key also matches.
|
|
172
172
|
if (pending.candidates && pending.candidates.length > 1) {
|
|
173
173
|
const nextIdx = pending.nextIndex - 1;
|
|
174
|
-
const narrowed = pending.candidates.filter(c => c.keys.length > nextIdx &&
|
|
174
|
+
const narrowed = pending.candidates.filter(c => c.keys.length > nextIdx && available.includes(c.keys[nextIdx]));
|
|
175
175
|
pending.candidates = narrowed.length <= 1 ? undefined : narrowed;
|
|
176
176
|
}
|
|
177
177
|
if (pending.nextIndex === pending.sequences.length) {
|
|
@@ -198,7 +198,7 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
|
|
|
198
198
|
// Non-exclusive with multiple candidates: try the current key
|
|
199
199
|
// against every candidate's next expected key to disambiguate.
|
|
200
200
|
const nextIdx = pending.nextIndex;
|
|
201
|
-
const stillPossible = pending.candidates.filter(c => c.keys.length > nextIdx &&
|
|
201
|
+
const stillPossible = pending.candidates.filter(c => c.keys.length > nextIdx && available.includes(c.keys[nextIdx]));
|
|
202
202
|
if (stillPossible.length === 0) {
|
|
203
203
|
// No candidate matches — cancel all and fall through.
|
|
204
204
|
clearTimeout(pending.timer);
|
|
@@ -240,11 +240,11 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
|
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
|
-
// No pending sequence — try to start a new one from the first
|
|
243
|
+
// No pending sequence — try to start a new one from the first available key.
|
|
244
244
|
if (layer.pendingSequence === null) {
|
|
245
|
-
// Check each
|
|
245
|
+
// Check each available key name (not just the first) to handle
|
|
246
246
|
// modifier combinations like 'ctrl+w' which appear after 'w'.
|
|
247
|
-
for (const keyName of
|
|
247
|
+
for (const keyName of available) {
|
|
248
248
|
// When ctrl/meta modifier is held (but not shift), a bare key name
|
|
249
249
|
// (without '+') does not represent the keystroke the user intended.
|
|
250
250
|
// normalizeKeyNames expands ctrl+d into ['d', 'ctrl+d']; matching 'd'
|
|
@@ -311,16 +311,16 @@ export function handleLayer(layer, eventNames, input, key, isTop, notifyFocusCha
|
|
|
311
311
|
if (isTop && layer.currentFocusId) {
|
|
312
312
|
const ft = layer.focusTargets.get(layer.currentFocusId);
|
|
313
313
|
if (ft) {
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
if (tryMatchBindings(ft.bindings,
|
|
314
|
+
const fPenetrated = ft.penetrationKeys;
|
|
315
|
+
const fAvailable = available.filter((n) => !keyMatchesRule(n, fPenetrated));
|
|
316
|
+
if (tryMatchBindings(ft.bindings, fAvailable, input, key, shouldSkipOnlyThis))
|
|
317
317
|
return true;
|
|
318
318
|
if (eventNames.some((n) => keyMatchesRule(n, ft.stoppedKeys))) {
|
|
319
319
|
return true;
|
|
320
320
|
}
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
|
-
if (tryMatchBindings(layer.bindings,
|
|
323
|
+
if (tryMatchBindings(layer.bindings, available, input, key, shouldSkipOnlyThis))
|
|
324
324
|
return true;
|
|
325
325
|
if (isTop && eventNames.some((n) => keyMatchesRule(n, layer.stoppedKeys))) {
|
|
326
326
|
return true;
|
|
@@ -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
|
/**
|
|
@@ -46,9 +52,9 @@ function invokeMissIfNeeded(layer, handled, key, input, eventNames) {
|
|
|
46
52
|
if (!layer.onMiss)
|
|
47
53
|
return false;
|
|
48
54
|
const opts = layer.onMissOptions ?? {};
|
|
49
|
-
// fix: The stop API and
|
|
55
|
+
// fix: The stop API and penetration API cases are no longer handled.
|
|
50
56
|
// Instead, it is left to handlerLayer to handle natural
|
|
51
|
-
// So the expectation is that, So the Stop API returns miss: false, but the
|
|
57
|
+
// So the expectation is that, So the Stop API returns miss: false, but the penetration API returns miss: true
|
|
52
58
|
// TODO: You need to modify the corresponding test and do it later.
|
|
53
59
|
// @2026-06-23 3.6.1
|
|
54
60
|
if (handled) {
|
|
@@ -17,7 +17,7 @@ export interface KeyboardProviderProps {
|
|
|
17
17
|
/**
|
|
18
18
|
* Keyboard context provider for layered key handling.
|
|
19
19
|
*
|
|
20
|
-
* Manages per-screen-layer key bindings, transparent keys (`
|
|
20
|
+
* Manages per-screen-layer key bindings, transparent keys (`penetration`),
|
|
21
21
|
* key-stop propagation barriers (`stop`), and global keys (`globalKeys`).
|
|
22
22
|
* Handles the full event priority chain:
|
|
23
23
|
* 1. Global keys with `affectOverlay: true`
|
|
@@ -196,7 +196,7 @@ function finalizeBoundKeyboard(bindingsArray, actionKeysMap, layer, entry, handl
|
|
|
196
196
|
/**
|
|
197
197
|
* Keyboard context provider for layered key handling.
|
|
198
198
|
*
|
|
199
|
-
* Manages per-screen-layer key bindings, transparent keys (`
|
|
199
|
+
* Manages per-screen-layer key bindings, transparent keys (`penetration`),
|
|
200
200
|
* key-stop propagation barriers (`stop`), and global keys (`globalKeys`).
|
|
201
201
|
* Handles the full event priority chain:
|
|
202
202
|
* 1. Global keys with `affectOverlay: true`
|
|
@@ -346,7 +346,7 @@ export function KeyboardProvider({ children }) {
|
|
|
346
346
|
layer = {
|
|
347
347
|
kind,
|
|
348
348
|
bindings: [],
|
|
349
|
-
|
|
349
|
+
penetrationKeys: [],
|
|
350
350
|
stoppedKeys: [],
|
|
351
351
|
allowedKeys: [],
|
|
352
352
|
globalKeyOverrides: new Set(),
|
|
@@ -408,7 +408,7 @@ export function KeyboardProvider({ children }) {
|
|
|
408
408
|
if (!target) {
|
|
409
409
|
target = {
|
|
410
410
|
bindings: [],
|
|
411
|
-
|
|
411
|
+
penetrationKeys: [],
|
|
412
412
|
stoppedKeys: [],
|
|
413
413
|
allowedKeys: [],
|
|
414
414
|
actionKeysMap: new Map(),
|
|
@@ -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).
|
|
@@ -567,14 +567,14 @@ export function KeyboardProvider({ children }) {
|
|
|
567
567
|
const penetration = useCallback((keys, options) => {
|
|
568
568
|
const owner = getCurrentOwner();
|
|
569
569
|
if (!owner) {
|
|
570
|
-
throw new Error('[Ink-Cartridge]
|
|
570
|
+
throw new Error('[Ink-Cartridge] penetration() must be called inside a screen component or overlay.');
|
|
571
571
|
}
|
|
572
572
|
const layer = getLayer(owner);
|
|
573
573
|
const compiledWhen = options?.when;
|
|
574
574
|
const container = options?.focusId
|
|
575
575
|
? getOrCreateFocusTarget(layer, options.focusId)
|
|
576
576
|
: layer;
|
|
577
|
-
return pushKeyEntries(container, '
|
|
577
|
+
return pushKeyEntries(container, 'penetrationKeys', keys, (key) => ({
|
|
578
578
|
key,
|
|
579
579
|
when: compiledWhen,
|
|
580
580
|
}));
|
|
@@ -970,7 +970,7 @@ export function KeyboardProvider({ children }) {
|
|
|
970
970
|
const readLayer = useCallback((owner) => layersRef.current.get(owner), []);
|
|
971
971
|
const value = useMemo(() => ({
|
|
972
972
|
boundKeyboard,
|
|
973
|
-
|
|
973
|
+
penetration,
|
|
974
974
|
stop,
|
|
975
975
|
globalKeys,
|
|
976
976
|
getGlobalKeys,
|
package/dist/keyboard/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { OverlayEntry } from "../screen/types.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* A single key rule with an optional when condition.
|
|
5
5
|
*
|
|
6
|
-
* Used internally for
|
|
6
|
+
* Used internally for penetrationKeys and stoppedKeys to support
|
|
7
7
|
* conditional transparency and conditional propagation barriers.
|
|
8
8
|
*/
|
|
9
9
|
export interface KeyRule {
|
|
@@ -132,7 +132,7 @@ export interface FocusTarget {
|
|
|
132
132
|
/** Registered key bindings (evaluation order). */
|
|
133
133
|
bindings: BoundKeyEntry[];
|
|
134
134
|
/** Key rules marked as transparent on this target (pass-through). */
|
|
135
|
-
|
|
135
|
+
penetrationKeys: KeyRule[];
|
|
136
136
|
/** Key rules stopped on this target (propagation barrier). */
|
|
137
137
|
stoppedKeys: KeyRule[];
|
|
138
138
|
/**
|
|
@@ -256,7 +256,7 @@ export interface ScreenKeyboardLayer {
|
|
|
256
256
|
/** Registered screen-level key bindings (evaluation order). */
|
|
257
257
|
bindings: BoundKeyEntry[];
|
|
258
258
|
/** Key rules marked as transparent at the screen level (pass-through). */
|
|
259
|
-
|
|
259
|
+
penetrationKeys: KeyRule[];
|
|
260
260
|
/** Key rules stopped at the screen level (propagation barrier). */
|
|
261
261
|
stoppedKeys: KeyRule[];
|
|
262
262
|
/**
|
|
@@ -303,7 +303,7 @@ export interface ScreenKeyboardLayer {
|
|
|
303
303
|
* Event object passed to the {@link ModalMissCallback}.
|
|
304
304
|
*
|
|
305
305
|
* When `miss` is `false`, the key was handled (by a binding, Tab
|
|
306
|
-
* navigation, sequence, or — depending on options — stop/
|
|
306
|
+
* navigation, sequence, or — depending on options — stop/penetration).
|
|
307
307
|
* When `miss` is `true`, the remaining fields describe the key that
|
|
308
308
|
* was not handled by any mechanism visible to the miss detector.
|
|
309
309
|
*/
|
|
@@ -365,16 +365,16 @@ export interface StopOptions {
|
|
|
365
365
|
when?: () => boolean;
|
|
366
366
|
}
|
|
367
367
|
/**
|
|
368
|
-
* Options for {@link KeyboardContextValue.
|
|
368
|
+
* Options for {@link KeyboardContextValue.penetration} when marking keys
|
|
369
369
|
* as transparent within a specific focus target.
|
|
370
370
|
*/
|
|
371
|
-
export interface
|
|
372
|
-
/** If provided,
|
|
371
|
+
export interface PenetrationOptions {
|
|
372
|
+
/** If provided, penetrates only within the named focus target. */
|
|
373
373
|
focusId?: string;
|
|
374
374
|
/**
|
|
375
375
|
* Optional condition callback. When provided, the key is only transparent
|
|
376
|
-
*
|
|
377
|
-
* is ignored and the key is not
|
|
376
|
+
* when this returns `true`. When `false`, the penetration rule
|
|
377
|
+
* is ignored and the key is not passed through.
|
|
378
378
|
*/
|
|
379
379
|
when?: () => boolean;
|
|
380
380
|
}
|
|
@@ -385,6 +385,8 @@ export interface BlockedKeyOptions {
|
|
|
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
|
@@ -251,9 +251,11 @@ function recalcActiveAfterNavigation(persistentOverlays, persistentModals, newTo
|
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
253
|
let activeModalId = null;
|
|
254
|
+
let maxZ = -1;
|
|
254
255
|
for (const m of persistentModals) {
|
|
255
|
-
if (m.originComponent === newTopScreen) {
|
|
256
|
+
if (m.originComponent === newTopScreen && m.zIndex > maxZ) {
|
|
256
257
|
activeModalId = m.id;
|
|
258
|
+
maxZ = m.zIndex;
|
|
257
259
|
}
|
|
258
260
|
}
|
|
259
261
|
return { activeOverlayIds, activeModalId };
|
|
@@ -481,7 +483,7 @@ function screenReducer(state, action) {
|
|
|
481
483
|
* Wraps the application and enables tree-based screen navigation, overlays,
|
|
482
484
|
* and module-level navigation functions.
|
|
483
485
|
*/
|
|
484
|
-
export function ScenarioManagementProvider({ children, defaultScreen, defaultParams, }) {
|
|
486
|
+
export function ScenarioManagementProvider({ children, defaultScreen, defaultParams, fullScreen }) {
|
|
485
487
|
if (!hasComponent(defaultScreen)) {
|
|
486
488
|
throw new Error(`[Ink-Cartridge] defaultScreen "${defaultScreen.displayName || defaultScreen.name || 'anonymous'}" is not registered. Please call registerComponent() first.`);
|
|
487
489
|
}
|
|
@@ -612,6 +614,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
|
|
|
612
614
|
openModal: openModalInContext,
|
|
613
615
|
closeModal: closeModalInContext,
|
|
614
616
|
closeAllModals: closeAllModalsInContext,
|
|
617
|
+
fullScreen
|
|
615
618
|
}), [
|
|
616
619
|
currentScreen,
|
|
617
620
|
currentOverlays,
|
|
@@ -634,6 +637,7 @@ export function ScenarioManagementProvider({ children, defaultScreen, defaultPar
|
|
|
634
637
|
closeModalInContext,
|
|
635
638
|
closeAllModalsInContext,
|
|
636
639
|
state.activeModalId,
|
|
640
|
+
fullScreen
|
|
637
641
|
]);
|
|
638
642
|
return (React.createElement(ScreenSystemContext.Provider, { value: value }, children));
|
|
639
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",
|