@cel-tui/core 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/cel.ts +67 -37
- package/src/keys.ts +141 -64
- package/src/primitives/text-input.ts +7 -5
- package/src/terminal.ts +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cel-tui/core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Core framework engine for cel-tui — primitives, layout, rendering, input",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"layout"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@cel-tui/types": "0.4.
|
|
37
|
+
"@cel-tui/types": "0.4.1",
|
|
38
38
|
"get-east-asian-width": "^1.5.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
package/src/cel.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Node,
|
|
3
|
+
TextInputNode,
|
|
4
|
+
TextInputProps,
|
|
5
|
+
Theme,
|
|
6
|
+
} from "@cel-tui/types";
|
|
2
7
|
import { CellBuffer } from "./cell-buffer.js";
|
|
3
8
|
import { emitBuffer, emitDiff, defaultTheme } from "./emitter.js";
|
|
4
9
|
import {
|
|
@@ -8,7 +13,7 @@ import {
|
|
|
8
13
|
collectKeyPressHandlers,
|
|
9
14
|
collectFocusable,
|
|
10
15
|
} from "./hit-test.js";
|
|
11
|
-
import {
|
|
16
|
+
import { decodeKeyEvents, isEditingKey, type KeyInput } from "./keys.js";
|
|
12
17
|
import { layout, type LayoutNode } from "./layout.js";
|
|
13
18
|
import {
|
|
14
19
|
paint,
|
|
@@ -195,25 +200,49 @@ const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
|
195
200
|
// need to remember the offset we already dispatched via onScroll.
|
|
196
201
|
let batchScrollOffsets: Map<object, number> | null = null;
|
|
197
202
|
|
|
203
|
+
// Tracks the focused TextInput's latest edit state during a batched keyboard
|
|
204
|
+
// chunk. The layout tree does not re-render until the next tick, so subsequent
|
|
205
|
+
// keys in the same chunk must see the updated value/cursor immediately.
|
|
206
|
+
let batchTextInputEdits: Map<TextInputProps, EditState> | null = null;
|
|
207
|
+
|
|
198
208
|
function handleInput(data: string): void {
|
|
199
|
-
// Terminals may batch multiple mouse events into one data chunk.
|
|
200
|
-
// Scan for all SGR mouse sequences and handle each one.
|
|
201
209
|
SGR_MOUSE_RE.lastIndex = 0;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
batchTextInputEdits = new Map();
|
|
211
|
+
|
|
212
|
+
let lastIndex = 0;
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
let match = SGR_MOUSE_RE.exec(data);
|
|
205
216
|
while (match) {
|
|
217
|
+
if (match.index > lastIndex) {
|
|
218
|
+
handleKeyChunk(data.slice(lastIndex, match.index));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (batchScrollOffsets === null) {
|
|
222
|
+
batchScrollOffsets = new Map();
|
|
223
|
+
}
|
|
224
|
+
|
|
206
225
|
const mouse = parseSgrMatch(match);
|
|
207
226
|
if (mouse) handleMouseEvent(mouse);
|
|
227
|
+
|
|
228
|
+
lastIndex = match.index + match[0].length;
|
|
208
229
|
match = SGR_MOUSE_RE.exec(data);
|
|
209
230
|
}
|
|
231
|
+
|
|
232
|
+
if (lastIndex < data.length) {
|
|
233
|
+
handleKeyChunk(data.slice(lastIndex));
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
210
236
|
batchScrollOffsets = null;
|
|
211
|
-
|
|
237
|
+
batchTextInputEdits = null;
|
|
212
238
|
}
|
|
239
|
+
}
|
|
213
240
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
241
|
+
function handleKeyChunk(data: string): void {
|
|
242
|
+
if (data.length === 0) return;
|
|
243
|
+
for (const key of decodeKeyEvents(data)) {
|
|
244
|
+
handleKeyEvent(key);
|
|
245
|
+
}
|
|
217
246
|
}
|
|
218
247
|
|
|
219
248
|
interface MouseEvent {
|
|
@@ -572,7 +601,18 @@ function findClickFocusTarget(path: LayoutNode[]): LayoutNode | null {
|
|
|
572
601
|
return null;
|
|
573
602
|
}
|
|
574
603
|
|
|
575
|
-
function
|
|
604
|
+
function getTextInputEditState(props: TextInputProps): EditState {
|
|
605
|
+
const batched = batchTextInputEdits?.get(props);
|
|
606
|
+
if (batched) return batched;
|
|
607
|
+
return {
|
|
608
|
+
value: props.value,
|
|
609
|
+
cursor: getTextInputCursor(props),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function handleKeyEvent(event: KeyInput): void {
|
|
614
|
+
const { key, text } = event;
|
|
615
|
+
|
|
576
616
|
// --- Focus traversal keys ---
|
|
577
617
|
|
|
578
618
|
// Tab / Shift+Tab: cycle through focusable elements
|
|
@@ -646,12 +686,10 @@ function handleKeyEvent(key: string, rawData?: string): void {
|
|
|
646
686
|
}
|
|
647
687
|
|
|
648
688
|
// --- TextInput key routing ---
|
|
649
|
-
// Find the focused TextInput (if any) to route editing keys
|
|
650
689
|
const focusedInput = findFocusedTextInput();
|
|
651
690
|
|
|
652
691
|
if (focusedInput) {
|
|
653
|
-
const props = focusedInput.node
|
|
654
|
-
.props as import("@cel-tui/types").TextInputProps;
|
|
692
|
+
const props = focusedInput.node.props as TextInputProps;
|
|
655
693
|
|
|
656
694
|
// onKeyPress fires before editing — return false prevents the default action
|
|
657
695
|
if (props.onKeyPress) {
|
|
@@ -662,12 +700,12 @@ function handleKeyEvent(key: string, rawData?: string): void {
|
|
|
662
700
|
}
|
|
663
701
|
}
|
|
664
702
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const cursor = getTextInputCursor(props);
|
|
668
|
-
const editState: EditState = { value: props.value, cursor };
|
|
669
|
-
let newState: EditState | null = null;
|
|
703
|
+
let newState: EditState | null = null;
|
|
704
|
+
const editState = getTextInputEditState(props);
|
|
670
705
|
|
|
706
|
+
if (text !== undefined) {
|
|
707
|
+
newState = insertChar(editState, text);
|
|
708
|
+
} else if (isEditingKey(key)) {
|
|
671
709
|
switch (key) {
|
|
672
710
|
case "backspace":
|
|
673
711
|
newState = deleteBackward(editState);
|
|
@@ -683,8 +721,7 @@ function handleKeyEvent(key: string, rawData?: string): void {
|
|
|
683
721
|
case "end":
|
|
684
722
|
{
|
|
685
723
|
const tiPadX =
|
|
686
|
-
(focusedInput.node as
|
|
687
|
-
.props.padding?.x ?? 0;
|
|
724
|
+
(focusedInput.node as TextInputNode).props.padding?.x ?? 0;
|
|
688
725
|
const contentWidth = Math.max(
|
|
689
726
|
0,
|
|
690
727
|
focusedInput.rect.width - tiPadX * 2,
|
|
@@ -709,24 +746,17 @@ function handleKeyEvent(key: string, rawData?: string): void {
|
|
|
709
746
|
case "plus":
|
|
710
747
|
newState = insertChar(editState, "+");
|
|
711
748
|
break;
|
|
712
|
-
default:
|
|
713
|
-
// Single printable character — use raw data to preserve case
|
|
714
|
-
if (key.length === 1 && rawData && rawData.length === 1) {
|
|
715
|
-
newState = insertChar(editState, rawData);
|
|
716
|
-
} else if (key.length === 1) {
|
|
717
|
-
newState = insertChar(editState, key);
|
|
718
|
-
}
|
|
719
|
-
break;
|
|
720
749
|
}
|
|
750
|
+
}
|
|
721
751
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
cel.render();
|
|
728
|
-
return;
|
|
752
|
+
if (newState && newState !== editState) {
|
|
753
|
+
batchTextInputEdits?.set(props, newState);
|
|
754
|
+
setTextInputCursor(props, newState.cursor);
|
|
755
|
+
if (newState.value !== editState.value) {
|
|
756
|
+
props.onChange(newState.value);
|
|
729
757
|
}
|
|
758
|
+
cel.render();
|
|
759
|
+
return;
|
|
730
760
|
}
|
|
731
761
|
}
|
|
732
762
|
|
package/src/keys.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Key parsing for
|
|
2
|
+
* Key parsing for Kitty-first terminal input with legacy compatibility.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* The decoder accepts a mixed keyboard stream containing:
|
|
5
5
|
* - **CSI u** (`ESC [ codepoint ; modifiers u`) for modified special keys
|
|
6
6
|
* and modifier combos (Ctrl+letter, Alt+letter, Shift+Tab, Ctrl+Enter, etc.)
|
|
7
7
|
* - **CSI letter** (`ESC [ A/B/C/D/H/F` or `ESC [ 1 ; modifiers letter`) for
|
|
@@ -9,16 +9,26 @@
|
|
|
9
9
|
* - **CSI tilde** (`ESC [ number ~` or `ESC [ number ; modifiers ~`) for
|
|
10
10
|
* Delete, PageUp, PageDown, and function keys
|
|
11
11
|
* - **Legacy bytes** for unmodified special keys (Tab=0x09, Enter=0x0D,
|
|
12
|
-
* Escape=0x1B, Backspace=0x7F)
|
|
13
|
-
* - **
|
|
12
|
+
* Escape=0x1B, Backspace=0x7F)
|
|
13
|
+
* - **Recoverable control bytes** for legacy `ctrl+letter` shortcuts
|
|
14
|
+
* - **ESC-prefixed Alt combinations** such as `ESC x`
|
|
15
|
+
* - **Raw printable text**
|
|
14
16
|
*
|
|
15
17
|
* Modifier bitmask (wire value = bitmask + 1):
|
|
16
|
-
* shift=1, alt=2, ctrl=4
|
|
18
|
+
* shift=1, alt=2, ctrl=4 -> e.g., ctrl = bitmask 4, wire param = 5
|
|
17
19
|
*
|
|
18
20
|
* @module
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
/** A decoded keyboard event from the terminal input stream. */
|
|
24
|
+
export interface KeyInput {
|
|
25
|
+
/** Normalized semantic key string (e.g. `"ctrl+s"`, `"enter"`, `"a"`). */
|
|
26
|
+
key: string;
|
|
27
|
+
/** Original insertable text, when this key should insert text. */
|
|
28
|
+
text?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Codepoint -> named key mappings ---
|
|
22
32
|
|
|
23
33
|
/** Named key lookup from Unicode codepoint (for CSI u sequences). */
|
|
24
34
|
const CODEPOINT_NAMES: Record<number, string> = {
|
|
@@ -60,7 +70,7 @@ const TILDE_NAMES: Record<number, string> = {
|
|
|
60
70
|
};
|
|
61
71
|
|
|
62
72
|
/**
|
|
63
|
-
* Legacy byte
|
|
73
|
+
* Legacy byte -> named key mapping for unmodified special keys.
|
|
64
74
|
*
|
|
65
75
|
* At Kitty level 1, unmodified special keys that have well-known legacy
|
|
66
76
|
* encodings retain those encodings. Only modified variants (e.g., Shift+Tab,
|
|
@@ -68,8 +78,8 @@ const TILDE_NAMES: Record<number, string> = {
|
|
|
68
78
|
* bytes that arrive for the unmodified case.
|
|
69
79
|
*/
|
|
70
80
|
const LEGACY_BYTE_NAMES: Record<number, string> = {
|
|
71
|
-
9: "tab", // \t — also Ctrl+I in legacy, but
|
|
72
|
-
13: "enter", // \r — also Ctrl+M in legacy, but
|
|
81
|
+
9: "tab", // \t — also Ctrl+I in legacy, but collapsed to tab here
|
|
82
|
+
13: "enter", // \r — also Ctrl+M in legacy, but collapsed to enter here
|
|
73
83
|
27: "escape", // \x1b — bare ESC byte
|
|
74
84
|
127: "backspace", // \x7f — DEL byte
|
|
75
85
|
};
|
|
@@ -99,9 +109,7 @@ function decodeModifiers(param: number): string {
|
|
|
99
109
|
return parts.length > 0 ? parts.join("+") + "+" : "";
|
|
100
110
|
}
|
|
101
111
|
|
|
102
|
-
/**
|
|
103
|
-
* Build a key string from a modifier prefix and base key name.
|
|
104
|
-
*/
|
|
112
|
+
/** Build a key string from a modifier prefix and base key name. */
|
|
105
113
|
function withModifiers(modParam: number, base: string): string {
|
|
106
114
|
return decodeModifiers(modParam) + base;
|
|
107
115
|
}
|
|
@@ -117,88 +125,157 @@ const CSI_LETTER_RE = /^(?:1;(\d+))?([A-H])$/;
|
|
|
117
125
|
/** Match CSI tilde format: ESC [ <number> ; <modifiers> ~ */
|
|
118
126
|
const CSI_TILDE_RE = /^(\d+)(?:;(\d+))?~$/;
|
|
119
127
|
|
|
120
|
-
function parseCsiSequence(seq: string):
|
|
128
|
+
function parseCsiSequence(seq: string): KeyInput {
|
|
121
129
|
// Legacy Shift+Tab (CSI Z) — sent by tmux and some terminals
|
|
122
|
-
if (seq === "Z") return "shift+tab";
|
|
130
|
+
if (seq === "Z") return { key: "shift+tab" };
|
|
123
131
|
|
|
124
132
|
// CSI u format: codepoint [; modifiers] u
|
|
125
133
|
let match = CSI_U_RE.exec(seq);
|
|
126
134
|
if (match) {
|
|
127
135
|
const codepoint = parseInt(match[1]!, 10);
|
|
128
|
-
const modParam = match[2] ? parseInt(match[2]
|
|
136
|
+
const modParam = match[2] ? parseInt(match[2]!, 10) : 0;
|
|
129
137
|
|
|
130
|
-
// Check named keys first
|
|
131
138
|
const name = CODEPOINT_NAMES[codepoint];
|
|
132
|
-
if (name)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
if (name) {
|
|
140
|
+
return { key: withModifiers(modParam, name) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const char = String.fromCodePoint(codepoint);
|
|
144
|
+
const key = withModifiers(modParam, char.toLowerCase());
|
|
145
|
+
if (modParam <= 1) {
|
|
146
|
+
return { key, text: char };
|
|
147
|
+
}
|
|
148
|
+
return { key };
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
// CSI letter format: [1 ; modifiers] <letter>
|
|
140
152
|
match = CSI_LETTER_RE.exec(seq);
|
|
141
153
|
if (match) {
|
|
142
|
-
const modParam = match[1] ? parseInt(match[1]
|
|
154
|
+
const modParam = match[1] ? parseInt(match[1]!, 10) : 0;
|
|
143
155
|
const letter = match[2]!;
|
|
144
156
|
const name = LETTER_NAMES[letter];
|
|
145
|
-
if (name) return withModifiers(modParam, name);
|
|
157
|
+
if (name) return { key: withModifiers(modParam, name) };
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
// CSI tilde format: number [; modifiers] ~
|
|
149
161
|
match = CSI_TILDE_RE.exec(seq);
|
|
150
162
|
if (match) {
|
|
151
163
|
const num = parseInt(match[1]!, 10);
|
|
152
|
-
const modParam = match[2] ? parseInt(match[2]
|
|
164
|
+
const modParam = match[2] ? parseInt(match[2]!, 10) : 0;
|
|
153
165
|
const name = TILDE_NAMES[num];
|
|
154
|
-
if (name) return withModifiers(modParam, name);
|
|
166
|
+
if (name) return { key: withModifiers(modParam, name) };
|
|
155
167
|
}
|
|
156
168
|
|
|
157
|
-
return `unknown:${seq}
|
|
169
|
+
return { key: `unknown:${seq}` };
|
|
158
170
|
}
|
|
159
171
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
* Parse raw terminal input data into a normalized key string.
|
|
164
|
-
*
|
|
165
|
-
* Handles the Kitty keyboard protocol level 1 (disambiguate) encoding:
|
|
166
|
-
* - CSI u sequences for special keys and modifier combos
|
|
167
|
-
* - CSI letter sequences for arrow keys and Home/End (with optional modifiers)
|
|
168
|
-
* - CSI tilde sequences for Delete, PageUp/Down, and function keys
|
|
169
|
-
* - Raw printable characters (unmodified, arrive as raw bytes at level 1)
|
|
170
|
-
*
|
|
171
|
-
* @param data - Raw terminal input string.
|
|
172
|
-
* @returns Normalized key string (e.g., `"ctrl+s"`, `"escape"`, `"alt+up"`).
|
|
173
|
-
*/
|
|
174
|
-
export function parseKey(data: string): string {
|
|
175
|
-
// CSI sequences: ESC [ ...
|
|
176
|
-
if (data.startsWith("\x1b[")) {
|
|
177
|
-
return parseCsiSequence(data.slice(2));
|
|
172
|
+
function decodeLegacyControlByte(code: number): string | null {
|
|
173
|
+
if (code >= 1 && code <= 26) {
|
|
174
|
+
return `ctrl+${String.fromCharCode(code + 96)}`;
|
|
178
175
|
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
179
178
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const code = data.charCodeAt(0);
|
|
179
|
+
function parseRawKeyInput(data: string): KeyInput {
|
|
180
|
+
const code = data.charCodeAt(0);
|
|
183
181
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (legacy) return legacy;
|
|
182
|
+
const legacy = LEGACY_BYTE_NAMES[code];
|
|
183
|
+
if (legacy) return { key: legacy };
|
|
187
184
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
185
|
+
const ctrl = decodeLegacyControlByte(code);
|
|
186
|
+
if (ctrl) return { key: ctrl };
|
|
187
|
+
|
|
188
|
+
const named = CHAR_NAMES[data];
|
|
189
|
+
if (named) return { key: named, text: data };
|
|
190
|
+
|
|
191
|
+
return { key: data.toLowerCase(), text: data };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseAltKeyInput(data: string): KeyInput {
|
|
195
|
+
const base = parseRawKeyInput(data);
|
|
196
|
+
return { key: normalizeKey(`alt+${base.key}`) };
|
|
197
|
+
}
|
|
191
198
|
|
|
192
|
-
|
|
193
|
-
|
|
199
|
+
function readCodePoint(
|
|
200
|
+
data: string,
|
|
201
|
+
index: number,
|
|
202
|
+
): { value: string; nextIndex: number } | null {
|
|
203
|
+
if (index >= data.length) return null;
|
|
204
|
+
const codepoint = data.codePointAt(index);
|
|
205
|
+
if (codepoint === undefined) return null;
|
|
206
|
+
const value = String.fromCodePoint(codepoint);
|
|
207
|
+
return { value, nextIndex: index + value.length };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function readCsiSequence(
|
|
211
|
+
data: string,
|
|
212
|
+
index: number,
|
|
213
|
+
): { value: string; nextIndex: number } | null {
|
|
214
|
+
if (!data.startsWith("\x1b[", index)) return null;
|
|
215
|
+
|
|
216
|
+
for (let i = index + 2; i < data.length; i++) {
|
|
217
|
+
const code = data.charCodeAt(i);
|
|
218
|
+
if (code >= 0x40 && code <= 0x7e) {
|
|
219
|
+
return { value: data.slice(index + 2, i + 1), nextIndex: i + 1 };
|
|
220
|
+
}
|
|
194
221
|
}
|
|
195
222
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Decode a raw keyboard data chunk into ordered key events.
|
|
228
|
+
*
|
|
229
|
+
* A single chunk may contain multiple key presses batched together.
|
|
230
|
+
*/
|
|
231
|
+
export function decodeKeyEvents(data: string): KeyInput[] {
|
|
232
|
+
const events: KeyInput[] = [];
|
|
233
|
+
let index = 0;
|
|
234
|
+
|
|
235
|
+
while (index < data.length) {
|
|
236
|
+
const csi = readCsiSequence(data, index);
|
|
237
|
+
if (csi) {
|
|
238
|
+
events.push(parseCsiSequence(csi.value));
|
|
239
|
+
index = csi.nextIndex;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const char = data[index]!;
|
|
244
|
+
if (char === "\x1b") {
|
|
245
|
+
const next = readCodePoint(data, index + 1);
|
|
246
|
+
if (next && next.value !== "[") {
|
|
247
|
+
events.push(parseAltKeyInput(next.value));
|
|
248
|
+
index = next.nextIndex;
|
|
249
|
+
} else {
|
|
250
|
+
events.push({ key: "escape" });
|
|
251
|
+
index += 1;
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const codePoint = readCodePoint(data, index);
|
|
257
|
+
if (!codePoint) break;
|
|
258
|
+
events.push(parseRawKeyInput(codePoint.value));
|
|
259
|
+
index = codePoint.nextIndex;
|
|
199
260
|
}
|
|
200
261
|
|
|
201
|
-
return
|
|
262
|
+
return events;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --- Public API ---
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Parse a single raw key sequence into a normalized key string.
|
|
269
|
+
*
|
|
270
|
+
* This is a convenience wrapper around {@link decodeKeyEvents} for callers that
|
|
271
|
+
* already know the input contains one logical key event.
|
|
272
|
+
*
|
|
273
|
+
* @param data - Raw terminal input string.
|
|
274
|
+
* @returns Normalized key string (e.g., `"ctrl+s"`, `"escape"`, `"alt+up"`).
|
|
275
|
+
*/
|
|
276
|
+
export function parseKey(data: string): string {
|
|
277
|
+
const events = decodeKeyEvents(data);
|
|
278
|
+
return events[0]?.key ?? `unknown:${data}`;
|
|
202
279
|
}
|
|
203
280
|
|
|
204
281
|
/**
|
|
@@ -217,7 +294,6 @@ export function normalizeKey(key: string): string {
|
|
|
217
294
|
const base = parts[parts.length - 1]!;
|
|
218
295
|
const mods = parts.slice(0, -1);
|
|
219
296
|
|
|
220
|
-
// Canonical order: ctrl, alt, shift
|
|
221
297
|
const ordered: string[] = [];
|
|
222
298
|
if (mods.includes("ctrl")) ordered.push("ctrl");
|
|
223
299
|
if (mods.includes("alt")) ordered.push("alt");
|
|
@@ -227,14 +303,15 @@ export function normalizeKey(key: string): string {
|
|
|
227
303
|
}
|
|
228
304
|
|
|
229
305
|
/**
|
|
230
|
-
* Check
|
|
231
|
-
*
|
|
306
|
+
* Check whether a semantic key represents TextInput editing/navigation.
|
|
307
|
+
*
|
|
308
|
+
* Single-character semantic keys represent insertable text, while named keys
|
|
309
|
+
* like `"enter"` and `"left"` represent editing/navigation actions. Modifier
|
|
310
|
+
* combos (`ctrl+s`, `alt+x`) are NOT editing keys and should bubble.
|
|
232
311
|
*/
|
|
233
312
|
export function isEditingKey(key: string): boolean {
|
|
234
|
-
// Single printable characters
|
|
235
313
|
if (key.length === 1) return true;
|
|
236
314
|
|
|
237
|
-
// Navigation and editing keys consumed by TextInput
|
|
238
315
|
const editingKeys = new Set([
|
|
239
316
|
"enter",
|
|
240
317
|
"backspace",
|
|
@@ -11,12 +11,14 @@ import type { TextInputNode, TextInputProps } from "@cel-tui/types";
|
|
|
11
11
|
* Scroll is always uncontrolled — the view follows the cursor and
|
|
12
12
|
* responds to mouse wheel automatically.
|
|
13
13
|
*
|
|
14
|
-
* TextInput is always focusable. When focused, text
|
|
15
|
-
*
|
|
16
|
-
* Modifier combos (e.g., `ctrl+s`)
|
|
14
|
+
* TextInput is always focusable. When focused, it consumes insertable text
|
|
15
|
+
* plus editing/navigation keys (arrows, backspace, delete, Enter, Tab).
|
|
16
|
+
* Modifier combos (e.g., `ctrl+s`) and non-insertable control keys bubble
|
|
17
|
+
* up to ancestor `onKeyPress` handlers.
|
|
17
18
|
*
|
|
18
|
-
* Use `onKeyPress` to intercept keys before editing.
|
|
19
|
-
*
|
|
19
|
+
* Use `onKeyPress` to intercept keys before editing. The handler receives a
|
|
20
|
+
* normalized semantic key string; inserted text preserves the original
|
|
21
|
+
* characters. Return `false` to prevent the default editing action.
|
|
20
22
|
*
|
|
21
23
|
* @param props - Value, callbacks, sizing, styling, and focus props.
|
|
22
24
|
* @returns A text input node for the UI tree.
|
package/src/terminal.ts
CHANGED
|
@@ -11,7 +11,13 @@ export interface Terminal {
|
|
|
11
11
|
get columns(): number;
|
|
12
12
|
/** Terminal height in rows. */
|
|
13
13
|
get rows(): number;
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Enter raw mode, enable Kitty level 1 keyboard reporting, enable mouse
|
|
16
|
+
* tracking, and hide the cursor.
|
|
17
|
+
*
|
|
18
|
+
* The framework prefers Kitty semantics but its parser also accepts mixed
|
|
19
|
+
* tmux/legacy keyboard encodings that may still arrive on stdin.
|
|
20
|
+
*/
|
|
15
21
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
16
22
|
/** Restore terminal state. */
|
|
17
23
|
stop(): void;
|
|
@@ -24,8 +30,10 @@ export interface Terminal {
|
|
|
24
30
|
/**
|
|
25
31
|
* Real terminal using process.stdin/stdout.
|
|
26
32
|
*
|
|
27
|
-
* Enables
|
|
28
|
-
*
|
|
33
|
+
* Enables Kitty keyboard protocol level 1, SGR mouse tracking, and raw mode.
|
|
34
|
+
* The runtime prefers Kitty semantics for full modifier fidelity, while the
|
|
35
|
+
* parser remains compatible with mixed tmux/legacy keyboard encodings that
|
|
36
|
+
* may still arrive on stdin. All modes are restored on stop/crash.
|
|
29
37
|
*/
|
|
30
38
|
export class ProcessTerminal implements Terminal {
|
|
31
39
|
private wasRaw = false;
|