@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cel-tui/core",
3
- "version": "0.4.0",
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.0",
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 { Node, Theme } from "@cel-tui/types";
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 { parseKey, isEditingKey } from "./keys.js";
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
- let match = SGR_MOUSE_RE.exec(data);
203
- if (match) {
204
- batchScrollOffsets = new Map();
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
- return;
237
+ batchTextInputEdits = null;
212
238
  }
239
+ }
213
240
 
214
- // Keyboard input
215
- const key = parseKey(data);
216
- handleKeyEvent(key, data);
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 handleKeyEvent(key: string, rawData?: string): void {
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
- // Editing keys are consumed by TextInput
666
- if (isEditingKey(key)) {
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 import("@cel-tui/types").TextInputNode)
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
- if (newState && newState !== editState) {
723
- setTextInputCursor(props, newState.cursor);
724
- if (newState.value !== editState.value) {
725
- props.onChange(newState.value);
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 the Kitty keyboard protocol (level 1 — disambiguate).
2
+ * Key parsing for Kitty-first terminal input with legacy compatibility.
3
3
  *
4
- * At level 1, the terminal sends:
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) — these retain their traditional encoding
13
- * - **Raw bytes** for unmodified printable characters
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 e.g., ctrl = bitmask 4, wire param = 5
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
- // --- Codepoint named key mappings ---
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 named key mapping for unmodified special keys.
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 at level 1 Ctrl+I → CSI u
72
- 13: "enter", // \r — also Ctrl+M in legacy, but at level 1 Ctrl+M → CSI u
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): 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], 10) : 0;
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) return withModifiers(modParam, name);
133
-
134
- // Printable character — convert codepoint to char, lowercase
135
- const char = String.fromCodePoint(codepoint).toLowerCase();
136
- return withModifiers(modParam, char);
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], 10) : 0;
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], 10) : 0;
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
- // --- Public API ---
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
- // Single-character input
181
- if (data.length === 1) {
182
- const code = data.charCodeAt(0);
179
+ function parseRawKeyInput(data: string): KeyInput {
180
+ const code = data.charCodeAt(0);
183
181
 
184
- // Legacy bytes for unmodified special keys (Kitty level 1 retains these)
185
- const legacy = LEGACY_BYTE_NAMES[code];
186
- if (legacy) return legacy;
182
+ const legacy = LEGACY_BYTE_NAMES[code];
183
+ if (legacy) return { key: legacy };
187
184
 
188
- // Named printable characters
189
- const named = CHAR_NAMES[data];
190
- if (named) return named;
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
- // Printable characters — return lowercase
193
- return data.toLowerCase();
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
- // Multi-byte UTF-8 (e.g., emoji, CJK) — return as-is lowercase
197
- if (data.length > 1) {
198
- return data.toLowerCase();
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 `unknown:${data}`;
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 if a parsed key is a text-editing key that TextInput should consume.
231
- * Modifier combos (ctrl+s, alt+x) are NOT editing keys and should bubble.
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-editing keys
15
- * (printable characters, arrows, backspace, Enter, Tab) are consumed.
16
- * Modifier combos (e.g., `ctrl+s`) bubble up to ancestor `onKeyPress` handlers.
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. Return `false` to
19
- * prevent the default editing action for that key.
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
- /** Enter raw mode, enable Kitty keyboard protocol, enable mouse tracking, hide cursor. */
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 the Kitty keyboard protocol (level 1) for unambiguous key input,
28
- * SGR mouse tracking, and raw mode. All modes are restored on stop/crash.
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;