@rlabs-inc/tui 0.1.0 → 0.2.0
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 +126 -13
- package/index.ts +11 -5
- package/package.json +2 -2
- package/src/api/mount.ts +42 -27
- package/src/engine/arrays/core.ts +13 -21
- package/src/engine/arrays/dimensions.ts +22 -32
- package/src/engine/arrays/index.ts +88 -86
- package/src/engine/arrays/interaction.ts +34 -48
- package/src/engine/arrays/layout.ts +67 -92
- package/src/engine/arrays/spacing.ts +37 -52
- package/src/engine/arrays/text.ts +23 -31
- package/src/engine/arrays/visual.ts +56 -75
- package/src/engine/inheritance.ts +18 -18
- package/src/engine/registry.ts +15 -0
- package/src/pipeline/frameBuffer.ts +26 -26
- package/src/pipeline/layout/index.ts +2 -2
- package/src/pipeline/layout/titan-engine.ts +112 -84
- package/src/primitives/animation.ts +194 -0
- package/src/primitives/box.ts +74 -86
- package/src/primitives/each.ts +87 -0
- package/src/primitives/index.ts +7 -0
- package/src/primitives/scope.ts +215 -0
- package/src/primitives/show.ts +77 -0
- package/src/primitives/text.ts +63 -59
- package/src/primitives/types.ts +1 -1
- package/src/primitives/when.ts +102 -0
- package/src/renderer/append-region.ts +303 -0
- package/src/renderer/index.ts +4 -2
- package/src/renderer/output.ts +11 -34
- package/src/state/focus.ts +16 -5
- package/src/state/global-keys.ts +184 -0
- package/src/state/index.ts +44 -8
- package/src/state/input.ts +534 -0
- package/src/state/keyboard.ts +98 -674
- package/src/state/mouse.ts +163 -340
- package/src/state/scroll.ts +7 -9
- package/src/types/index.ts +6 -0
- package/src/renderer/input.ts +0 -518
package/src/state/keyboard.ts
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TUI Framework - Keyboard
|
|
2
|
+
* TUI Framework - Keyboard Module
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - CSI, SS3, Kitty protocol support
|
|
8
|
-
* - Alt+key detection
|
|
4
|
+
* State and handler registry for keyboard events.
|
|
5
|
+
* Does NOT own stdin (that's input.ts).
|
|
6
|
+
* Does NOT handle global shortcuts (that's global-keys.ts).
|
|
9
7
|
*
|
|
10
8
|
* API:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* lastEvent - Reactive signal of last keyboard event
|
|
10
|
+
* lastKey - Reactive signal of last key pressed
|
|
11
|
+
* on(handler) - Subscribe to all keyboard events
|
|
12
|
+
* onKey(key, fn) - Subscribe to specific key(s)
|
|
13
|
+
* onFocused(i,fn)- Subscribe when component i has focus
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
import { signal, derived } from '@rlabs-inc/signals'
|
|
18
|
-
import { focusedIndex } from '../engine/arrays/interaction'
|
|
19
|
-
import { processMouseEvent, type MouseEvent as TUIMouseEvent } from './mouse'
|
|
20
|
-
import { focusNext, focusPrevious, focus, blur, focusManager } from './focus'
|
|
21
|
-
import { handleArrowScroll, handlePageScroll, handleHomeEnd, handleWheelScroll } from './scroll'
|
|
22
17
|
|
|
23
18
|
// =============================================================================
|
|
24
19
|
// TYPES
|
|
@@ -34,738 +29,167 @@ export interface Modifiers {
|
|
|
34
29
|
export type KeyState = 'press' | 'repeat' | 'release'
|
|
35
30
|
|
|
36
31
|
export interface KeyboardEvent {
|
|
37
|
-
/** Normalized key name (e.g., 'Enter', 'ArrowUp', 'a') */
|
|
38
32
|
key: string
|
|
39
|
-
/** Modifier state */
|
|
40
33
|
modifiers: Modifiers
|
|
41
|
-
/** Key state (press/repeat/release) */
|
|
42
34
|
state: KeyState
|
|
43
|
-
/** Raw input bytes (for debugging) */
|
|
44
35
|
raw?: string
|
|
45
36
|
}
|
|
46
37
|
|
|
47
38
|
export type KeyHandler = (event: KeyboardEvent) => void | boolean
|
|
48
39
|
|
|
49
|
-
// =============================================================================
|
|
50
|
-
// INPUT BUFFER (from terminalKit)
|
|
51
|
-
// =============================================================================
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Buffer for accumulating partial escape sequences.
|
|
55
|
-
* Escape sequences can arrive split across multiple stdin reads.
|
|
56
|
-
* Handles BOTH keyboard AND mouse events since stdin can deliver
|
|
57
|
-
* interleaved data in a single chunk.
|
|
58
|
-
*/
|
|
59
|
-
class InputBuffer {
|
|
60
|
-
private buffer: string = ''
|
|
61
|
-
private timeout: Timer | null = null
|
|
62
|
-
private readonly TIMEOUT_MS = 10 // Max wait for complete sequence (fast! we do 62K updates/sec)
|
|
63
|
-
private onKeyboard: (event: KeyboardEvent) => void
|
|
64
|
-
private onMouse: (event: TUIMouseEvent) => void
|
|
65
|
-
|
|
66
|
-
constructor(
|
|
67
|
-
onKeyboard: (event: KeyboardEvent) => void,
|
|
68
|
-
onMouse: (event: TUIMouseEvent) => void
|
|
69
|
-
) {
|
|
70
|
-
this.onKeyboard = onKeyboard
|
|
71
|
-
this.onMouse = onMouse
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Add data to buffer and extract complete events
|
|
76
|
-
*/
|
|
77
|
-
parse(data: Buffer): void {
|
|
78
|
-
const str = data.toString()
|
|
79
|
-
this.buffer += str
|
|
80
|
-
|
|
81
|
-
// Clear any pending timeout
|
|
82
|
-
if (this.timeout) {
|
|
83
|
-
clearTimeout(this.timeout)
|
|
84
|
-
this.timeout = null
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
let consumed = 0
|
|
88
|
-
|
|
89
|
-
while (consumed < this.buffer.length) {
|
|
90
|
-
const remaining = this.buffer.slice(consumed)
|
|
91
|
-
const result = this.parseOne(remaining)
|
|
92
|
-
|
|
93
|
-
if (result.keyboard) {
|
|
94
|
-
this.onKeyboard(result.keyboard)
|
|
95
|
-
consumed += result.consumed
|
|
96
|
-
} else if (result.mouse) {
|
|
97
|
-
this.onMouse(result.mouse)
|
|
98
|
-
consumed += result.consumed
|
|
99
|
-
} else if (result.incomplete) {
|
|
100
|
-
// Partial sequence - wait for more data
|
|
101
|
-
break
|
|
102
|
-
} else {
|
|
103
|
-
// Unknown/invalid - skip one byte
|
|
104
|
-
consumed++
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Remove consumed data from buffer
|
|
109
|
-
this.buffer = this.buffer.slice(consumed)
|
|
110
|
-
|
|
111
|
-
// If buffer still has data, set timeout to flush as raw input
|
|
112
|
-
// This handles genuine ESC key presses (vs ESC as start of sequence)
|
|
113
|
-
if (this.buffer.length > 0) {
|
|
114
|
-
this.timeout = setTimeout(() => {
|
|
115
|
-
for (const char of this.buffer) {
|
|
116
|
-
const code = char.charCodeAt(0)
|
|
117
|
-
// Use controlKey for control characters, simpleKey for printable
|
|
118
|
-
if (code < 32 || code === 127) {
|
|
119
|
-
this.onKeyboard(this.controlKey(code))
|
|
120
|
-
} else {
|
|
121
|
-
this.onKeyboard(this.simpleKey(char))
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
this.buffer = ''
|
|
125
|
-
}, this.TIMEOUT_MS)
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Result from parsing - either a keyboard event, mouse event, or incomplete/nothing */
|
|
130
|
-
private parseOne(data: string): { keyboard?: KeyboardEvent; mouse?: TUIMouseEvent; consumed: number; incomplete?: boolean } {
|
|
131
|
-
if (data.length === 0) {
|
|
132
|
-
return { consumed: 0 }
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Check for escape sequence
|
|
136
|
-
if (data[0] === '\x1b') {
|
|
137
|
-
return this.parseEscape(data)
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Regular character
|
|
141
|
-
const char = data[0]!
|
|
142
|
-
const codepoint = char.codePointAt(0) ?? 0
|
|
143
|
-
|
|
144
|
-
// Control characters (0-31)
|
|
145
|
-
if (codepoint < 32) {
|
|
146
|
-
return {
|
|
147
|
-
keyboard: this.controlKey(codepoint),
|
|
148
|
-
consumed: 1,
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// DEL character (127)
|
|
153
|
-
if (codepoint === 127) {
|
|
154
|
-
return {
|
|
155
|
-
keyboard: { key: 'Backspace', modifiers: this.defaultModifiers(), state: 'press' },
|
|
156
|
-
consumed: 1,
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Normal character (handle shift for uppercase letters)
|
|
161
|
-
const isUpper = char >= 'A' && char <= 'Z'
|
|
162
|
-
return {
|
|
163
|
-
keyboard: {
|
|
164
|
-
key: char,
|
|
165
|
-
modifiers: { ...this.defaultModifiers(), shift: isUpper },
|
|
166
|
-
state: 'press',
|
|
167
|
-
},
|
|
168
|
-
consumed: char.length,
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private parseEscape(data: string): { keyboard?: KeyboardEvent; mouse?: TUIMouseEvent; consumed: number; incomplete?: boolean } {
|
|
173
|
-
if (data.length === 1) {
|
|
174
|
-
// Just ESC, might be start of sequence
|
|
175
|
-
return { consumed: 0, incomplete: true }
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const second = data[1]!
|
|
179
|
-
|
|
180
|
-
// CSI sequence: ESC [
|
|
181
|
-
if (second === '[') {
|
|
182
|
-
return this.parseCSI(data)
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// SS3 sequence: ESC O
|
|
186
|
-
if (second === 'O') {
|
|
187
|
-
return this.parseSS3(data)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Alt + key: ESC + char
|
|
191
|
-
if (second.codePointAt(0)! >= 32) {
|
|
192
|
-
return {
|
|
193
|
-
keyboard: {
|
|
194
|
-
key: second,
|
|
195
|
-
modifiers: { ctrl: false, alt: true, shift: false, meta: false },
|
|
196
|
-
state: 'press',
|
|
197
|
-
},
|
|
198
|
-
consumed: 2,
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Just ESC key
|
|
203
|
-
return {
|
|
204
|
-
keyboard: { key: 'Escape', modifiers: this.defaultModifiers(), state: 'press' },
|
|
205
|
-
consumed: 1,
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
private parseCSI(data: string): { keyboard?: KeyboardEvent; mouse?: TUIMouseEvent; consumed: number; incomplete?: boolean } {
|
|
210
|
-
// Check for SGR mouse: ESC [ < ... M/m
|
|
211
|
-
if (data.length >= 3 && data[2] === '<') {
|
|
212
|
-
return this.parseMouseSGR(data)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Check for X10 mouse: ESC [ M followed by 3 bytes
|
|
216
|
-
if (data.length >= 3 && data[2] === 'M') {
|
|
217
|
-
return this.parseMouseX10(data)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// Find the terminator for keyboard CSI (letter or ~)
|
|
221
|
-
let i = 2
|
|
222
|
-
while (i < data.length) {
|
|
223
|
-
const c = data.charCodeAt(i)
|
|
224
|
-
// Terminator is A-Z, a-z, or ~
|
|
225
|
-
if ((c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 126) {
|
|
226
|
-
break
|
|
227
|
-
}
|
|
228
|
-
i++
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
if (i >= data.length) {
|
|
232
|
-
return { consumed: 0, incomplete: true }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const sequence = data.slice(2, i)
|
|
236
|
-
const terminator = data[i]!
|
|
237
|
-
const consumed = i + 1
|
|
238
|
-
|
|
239
|
-
// Keyboard sequences
|
|
240
|
-
return this.parseCSIKey(sequence, terminator, consumed)
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Parse SGR mouse: ESC [ < button ; x ; y M/m */
|
|
244
|
-
private parseMouseSGR(data: string): { mouse?: TUIMouseEvent; consumed: number; incomplete?: boolean } {
|
|
245
|
-
// Match the full SGR mouse sequence
|
|
246
|
-
const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/)
|
|
247
|
-
if (!match) {
|
|
248
|
-
// Check if it might be incomplete (still receiving digits/semicolons)
|
|
249
|
-
if (data.match(/^\x1b\[<[\d;]*$/)) {
|
|
250
|
-
return { consumed: 0, incomplete: true }
|
|
251
|
-
}
|
|
252
|
-
return { consumed: 0 }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const [full, buttonStr, xStr, yStr, terminator] = match
|
|
256
|
-
const buttonCode = parseInt(buttonStr!, 10)
|
|
257
|
-
const x = parseInt(xStr!, 10) - 1 // Convert to 0-based
|
|
258
|
-
const y = parseInt(yStr!, 10) - 1
|
|
259
|
-
|
|
260
|
-
const baseButton = buttonCode & 3
|
|
261
|
-
const isScroll = (buttonCode & 64) !== 0
|
|
262
|
-
const isMotion = (buttonCode & 32) !== 0
|
|
263
|
-
|
|
264
|
-
const shiftKey = (buttonCode & 4) !== 0
|
|
265
|
-
const altKey = (buttonCode & 8) !== 0
|
|
266
|
-
const ctrlKey = (buttonCode & 16) !== 0
|
|
267
|
-
|
|
268
|
-
type MouseAction = 'down' | 'up' | 'move' | 'drag' | 'scroll'
|
|
269
|
-
let action: MouseAction
|
|
270
|
-
let scrollDirection: 'up' | 'down' | undefined
|
|
271
|
-
let button = baseButton
|
|
272
|
-
|
|
273
|
-
if (isScroll) {
|
|
274
|
-
action = 'scroll'
|
|
275
|
-
scrollDirection = baseButton === 0 ? 'up' : 'down'
|
|
276
|
-
button = 3 // NONE
|
|
277
|
-
} else if (isMotion) {
|
|
278
|
-
action = baseButton === 3 ? 'move' : 'drag'
|
|
279
|
-
} else {
|
|
280
|
-
action = terminator === 'M' ? 'down' : 'up'
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return {
|
|
284
|
-
mouse: {
|
|
285
|
-
action,
|
|
286
|
-
button,
|
|
287
|
-
x,
|
|
288
|
-
y,
|
|
289
|
-
shiftKey,
|
|
290
|
-
altKey,
|
|
291
|
-
ctrlKey,
|
|
292
|
-
scroll: scrollDirection ? { direction: scrollDirection, delta: 1 } : undefined,
|
|
293
|
-
componentIndex: -1, // Will be filled by mouse module
|
|
294
|
-
},
|
|
295
|
-
consumed: full!.length,
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** Parse X10 mouse: ESC [ M followed by 3 bytes */
|
|
300
|
-
private parseMouseX10(data: string): { mouse?: TUIMouseEvent; consumed: number; incomplete?: boolean } {
|
|
301
|
-
if (data.length < 6) {
|
|
302
|
-
return { consumed: 0, incomplete: true }
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
const buttonByte = data.charCodeAt(3) - 32
|
|
306
|
-
const x = data.charCodeAt(4) - 33 // Convert to 0-based
|
|
307
|
-
const y = data.charCodeAt(5) - 33
|
|
308
|
-
|
|
309
|
-
const baseButton = buttonByte & 3
|
|
310
|
-
const isScroll = (buttonByte & 64) !== 0
|
|
311
|
-
|
|
312
|
-
const shiftKey = (buttonByte & 4) !== 0
|
|
313
|
-
const altKey = (buttonByte & 8) !== 0
|
|
314
|
-
const ctrlKey = (buttonByte & 16) !== 0
|
|
315
|
-
|
|
316
|
-
type MouseAction = 'down' | 'up' | 'move' | 'drag' | 'scroll'
|
|
317
|
-
let action: MouseAction
|
|
318
|
-
let scrollDirection: 'up' | 'down' | undefined
|
|
319
|
-
let button = baseButton
|
|
320
|
-
|
|
321
|
-
if (isScroll) {
|
|
322
|
-
action = 'scroll'
|
|
323
|
-
scrollDirection = baseButton === 0 ? 'up' : 'down'
|
|
324
|
-
button = 3 // NONE
|
|
325
|
-
} else {
|
|
326
|
-
action = baseButton === 3 ? 'up' : 'down'
|
|
327
|
-
if (baseButton === 3) button = 0 // Normalize to LEFT for up events
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return {
|
|
331
|
-
mouse: {
|
|
332
|
-
action,
|
|
333
|
-
button,
|
|
334
|
-
x,
|
|
335
|
-
y,
|
|
336
|
-
shiftKey,
|
|
337
|
-
altKey,
|
|
338
|
-
ctrlKey,
|
|
339
|
-
scroll: scrollDirection ? { direction: scrollDirection, delta: 1 } : undefined,
|
|
340
|
-
componentIndex: -1, // Will be filled by mouse module
|
|
341
|
-
},
|
|
342
|
-
consumed: 6,
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
private parseCSIKey(
|
|
347
|
-
sequence: string,
|
|
348
|
-
terminator: string,
|
|
349
|
-
consumed: number
|
|
350
|
-
): { keyboard?: KeyboardEvent; consumed: number } {
|
|
351
|
-
const modifiers = this.defaultModifiers()
|
|
352
|
-
|
|
353
|
-
// Parse modifiers from sequence (e.g., "1;5" means Ctrl)
|
|
354
|
-
const parts = sequence.split(';')
|
|
355
|
-
if (parts.length >= 2) {
|
|
356
|
-
const mod = parseInt(parts[1]!, 10) - 1
|
|
357
|
-
modifiers.shift = (mod & 1) !== 0
|
|
358
|
-
modifiers.alt = (mod & 2) !== 0
|
|
359
|
-
modifiers.ctrl = (mod & 4) !== 0
|
|
360
|
-
modifiers.meta = (mod & 8) !== 0
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
let key: string
|
|
364
|
-
|
|
365
|
-
// Arrow keys
|
|
366
|
-
if (terminator === 'A') key = 'ArrowUp'
|
|
367
|
-
else if (terminator === 'B') key = 'ArrowDown'
|
|
368
|
-
else if (terminator === 'C') key = 'ArrowRight'
|
|
369
|
-
else if (terminator === 'D') key = 'ArrowLeft'
|
|
370
|
-
else if (terminator === 'H') key = 'Home'
|
|
371
|
-
else if (terminator === 'F') key = 'End'
|
|
372
|
-
else if (terminator === 'Z') {
|
|
373
|
-
// Shift+Tab
|
|
374
|
-
key = 'Tab'
|
|
375
|
-
modifiers.shift = true
|
|
376
|
-
}
|
|
377
|
-
// Function keys (~ terminator)
|
|
378
|
-
else if (terminator === '~') {
|
|
379
|
-
const code = parseInt(parts[0]!, 10)
|
|
380
|
-
switch (code) {
|
|
381
|
-
case 1: key = 'Home'; break
|
|
382
|
-
case 2: key = 'Insert'; break
|
|
383
|
-
case 3: key = 'Delete'; break
|
|
384
|
-
case 4: key = 'End'; break
|
|
385
|
-
case 5: key = 'PageUp'; break
|
|
386
|
-
case 6: key = 'PageDown'; break
|
|
387
|
-
case 7: key = 'Home'; break
|
|
388
|
-
case 8: key = 'End'; break
|
|
389
|
-
case 11: key = 'F1'; break
|
|
390
|
-
case 12: key = 'F2'; break
|
|
391
|
-
case 13: key = 'F3'; break
|
|
392
|
-
case 14: key = 'F4'; break
|
|
393
|
-
case 15: key = 'F5'; break
|
|
394
|
-
case 17: key = 'F6'; break
|
|
395
|
-
case 18: key = 'F7'; break
|
|
396
|
-
case 19: key = 'F8'; break
|
|
397
|
-
case 20: key = 'F9'; break
|
|
398
|
-
case 21: key = 'F10'; break
|
|
399
|
-
case 23: key = 'F11'; break
|
|
400
|
-
case 24: key = 'F12'; break
|
|
401
|
-
default: key = `F${code}`; break
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
// Kitty keyboard protocol
|
|
405
|
-
else if (terminator === 'u') {
|
|
406
|
-
return this.parseKittyKey(sequence, consumed)
|
|
407
|
-
}
|
|
408
|
-
else {
|
|
409
|
-
key = `CSI${sequence}${terminator}`
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
return {
|
|
413
|
-
keyboard: { key, modifiers, state: 'press' },
|
|
414
|
-
consumed,
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
private parseKittyKey(sequence: string, consumed: number): { keyboard?: KeyboardEvent; consumed: number } {
|
|
419
|
-
const parts = sequence.split(';')
|
|
420
|
-
const codepoint = parseInt(parts[0]!, 10)
|
|
421
|
-
const modifiers = this.defaultModifiers()
|
|
422
|
-
let state: KeyState = 'press'
|
|
423
|
-
|
|
424
|
-
if (parts.length >= 2) {
|
|
425
|
-
const modBits = parseInt(parts[1]!, 10) - 1
|
|
426
|
-
modifiers.shift = (modBits & 1) !== 0
|
|
427
|
-
modifiers.alt = (modBits & 2) !== 0
|
|
428
|
-
modifiers.ctrl = (modBits & 4) !== 0
|
|
429
|
-
modifiers.meta = (modBits & 8) !== 0
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (parts.length >= 3) {
|
|
433
|
-
const eventType = parseInt(parts[2]!, 10)
|
|
434
|
-
if (eventType === 1) state = 'press'
|
|
435
|
-
else if (eventType === 2) state = 'repeat'
|
|
436
|
-
else if (eventType === 3) state = 'release'
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
let key: string
|
|
440
|
-
if (codepoint === 13) key = 'Enter'
|
|
441
|
-
else if (codepoint === 9) key = 'Tab'
|
|
442
|
-
else if (codepoint === 127) key = 'Backspace'
|
|
443
|
-
else if (codepoint === 27) key = 'Escape'
|
|
444
|
-
else key = String.fromCodePoint(codepoint)
|
|
445
|
-
|
|
446
|
-
return {
|
|
447
|
-
keyboard: { key, modifiers, state },
|
|
448
|
-
consumed,
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
private parseSS3(data: string): { keyboard?: KeyboardEvent; consumed: number; incomplete?: boolean } {
|
|
453
|
-
if (data.length < 3) {
|
|
454
|
-
return { consumed: 0, incomplete: true }
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const terminator = data[2]!
|
|
458
|
-
let key: string
|
|
459
|
-
|
|
460
|
-
switch (terminator) {
|
|
461
|
-
case 'P': key = 'F1'; break
|
|
462
|
-
case 'Q': key = 'F2'; break
|
|
463
|
-
case 'R': key = 'F3'; break
|
|
464
|
-
case 'S': key = 'F4'; break
|
|
465
|
-
case 'H': key = 'Home'; break
|
|
466
|
-
case 'F': key = 'End'; break
|
|
467
|
-
case 'A': key = 'ArrowUp'; break
|
|
468
|
-
case 'B': key = 'ArrowDown'; break
|
|
469
|
-
case 'C': key = 'ArrowRight'; break
|
|
470
|
-
case 'D': key = 'ArrowLeft'; break
|
|
471
|
-
default: key = `SS3${terminator}`; break
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
return {
|
|
475
|
-
keyboard: { key, modifiers: this.defaultModifiers(), state: 'press' },
|
|
476
|
-
consumed: 3,
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* Map control character to key event
|
|
482
|
-
* This is the KEY fix - proper Ctrl+letter vs special key mapping
|
|
483
|
-
*/
|
|
484
|
-
private controlKey(code: number): KeyboardEvent {
|
|
485
|
-
let key: string
|
|
486
|
-
const modifiers = this.defaultModifiers()
|
|
487
|
-
|
|
488
|
-
switch (code) {
|
|
489
|
-
case 0: key = '@'; modifiers.ctrl = true; break
|
|
490
|
-
case 1: key = 'a'; modifiers.ctrl = true; break
|
|
491
|
-
case 2: key = 'b'; modifiers.ctrl = true; break
|
|
492
|
-
case 3: key = 'c'; modifiers.ctrl = true; break
|
|
493
|
-
case 4: key = 'd'; modifiers.ctrl = true; break
|
|
494
|
-
case 5: key = 'e'; modifiers.ctrl = true; break
|
|
495
|
-
case 6: key = 'f'; modifiers.ctrl = true; break
|
|
496
|
-
case 7: key = 'g'; modifiers.ctrl = true; break
|
|
497
|
-
// 8 = Backspace (NOT Ctrl+H!)
|
|
498
|
-
case 8: key = 'Backspace'; break
|
|
499
|
-
// 9 = Tab (NOT Ctrl+I!)
|
|
500
|
-
case 9: key = 'Tab'; break
|
|
501
|
-
// 10 = Enter/LF (NOT Ctrl+J!)
|
|
502
|
-
case 10: key = 'Enter'; break
|
|
503
|
-
case 11: key = 'k'; modifiers.ctrl = true; break
|
|
504
|
-
case 12: key = 'l'; modifiers.ctrl = true; break
|
|
505
|
-
// 13 = Enter/CR (NOT Ctrl+M!)
|
|
506
|
-
case 13: key = 'Enter'; break
|
|
507
|
-
case 14: key = 'n'; modifiers.ctrl = true; break
|
|
508
|
-
case 15: key = 'o'; modifiers.ctrl = true; break
|
|
509
|
-
case 16: key = 'p'; modifiers.ctrl = true; break
|
|
510
|
-
case 17: key = 'q'; modifiers.ctrl = true; break
|
|
511
|
-
case 18: key = 'r'; modifiers.ctrl = true; break
|
|
512
|
-
case 19: key = 's'; modifiers.ctrl = true; break
|
|
513
|
-
case 20: key = 't'; modifiers.ctrl = true; break
|
|
514
|
-
case 21: key = 'u'; modifiers.ctrl = true; break
|
|
515
|
-
case 22: key = 'v'; modifiers.ctrl = true; break
|
|
516
|
-
case 23: key = 'w'; modifiers.ctrl = true; break
|
|
517
|
-
case 24: key = 'x'; modifiers.ctrl = true; break
|
|
518
|
-
case 25: key = 'y'; modifiers.ctrl = true; break
|
|
519
|
-
case 26: key = 'z'; modifiers.ctrl = true; break
|
|
520
|
-
// 27 = Escape
|
|
521
|
-
case 27: key = 'Escape'; break
|
|
522
|
-
case 28: key = '\\'; modifiers.ctrl = true; break
|
|
523
|
-
case 29: key = ']'; modifiers.ctrl = true; break
|
|
524
|
-
case 30: key = '^'; modifiers.ctrl = true; break
|
|
525
|
-
case 31: key = '_'; modifiers.ctrl = true; break
|
|
526
|
-
default: key = `Ctrl+${code}`; break
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
return { key, modifiers, state: 'press' }
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
private simpleKey(key: string): KeyboardEvent {
|
|
533
|
-
return { key, modifiers: this.defaultModifiers(), state: 'press' }
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
private defaultModifiers(): Modifiers {
|
|
537
|
-
return { ctrl: false, alt: false, shift: false, meta: false }
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
clear(): void {
|
|
541
|
-
this.buffer = ''
|
|
542
|
-
if (this.timeout) {
|
|
543
|
-
clearTimeout(this.timeout)
|
|
544
|
-
this.timeout = null
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
40
|
// =============================================================================
|
|
550
41
|
// STATE
|
|
551
42
|
// =============================================================================
|
|
552
43
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const listeners = new Set<KeyHandler>()
|
|
556
|
-
const focusedListeners = new Map<number, Set<KeyHandler>>()
|
|
557
|
-
|
|
558
|
-
// Reactive state
|
|
559
|
-
const _lastEvent = signal<KeyboardEvent | null>(null)
|
|
560
|
-
|
|
561
|
-
/** Last keyboard event (reactive) */
|
|
562
|
-
export const lastEvent = derived(() => _lastEvent.value)
|
|
44
|
+
/** Last keyboard event (reactive signal) */
|
|
45
|
+
export const lastEvent = signal<KeyboardEvent | null>(null)
|
|
563
46
|
|
|
564
|
-
/** Last key pressed (reactive) */
|
|
565
|
-
export const lastKey = derived(() =>
|
|
47
|
+
/** Last key pressed (reactive derived) */
|
|
48
|
+
export const lastKey = derived(() => lastEvent.value?.key ?? '')
|
|
566
49
|
|
|
567
50
|
// =============================================================================
|
|
568
|
-
//
|
|
51
|
+
// HANDLER REGISTRY
|
|
569
52
|
// =============================================================================
|
|
570
53
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
/** Set whether Ctrl+C exits the app */
|
|
575
|
-
export function setExitOnCtrlC(enabled: boolean): void {
|
|
576
|
-
exitOnCtrlC = enabled
|
|
577
|
-
}
|
|
54
|
+
const globalHandlers = new Set<KeyHandler>()
|
|
55
|
+
const keyHandlers = new Map<string, Set<() => void | boolean>>()
|
|
56
|
+
const focusedHandlers = new Map<number, Set<KeyHandler>>()
|
|
578
57
|
|
|
579
58
|
// =============================================================================
|
|
580
|
-
//
|
|
59
|
+
// EVENT DISPATCH (called by global-keys.ts)
|
|
581
60
|
// =============================================================================
|
|
582
61
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
cleanup()
|
|
592
|
-
process.exit(0)
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// Tab navigation (uses focus.ts with history support)
|
|
596
|
-
if (event.key === 'Tab' && !event.modifiers.ctrl && !event.modifiers.alt) {
|
|
597
|
-
if (event.modifiers.shift) {
|
|
598
|
-
focusPrevious()
|
|
599
|
-
} else {
|
|
600
|
-
focusNext()
|
|
601
|
-
}
|
|
602
|
-
return // Tab is always consumed
|
|
603
|
-
}
|
|
62
|
+
/**
|
|
63
|
+
* Dispatch a keyboard event to all registered handlers.
|
|
64
|
+
* Called by global-keys.ts after it processes global shortcuts.
|
|
65
|
+
*
|
|
66
|
+
* Returns true if any handler consumed the event.
|
|
67
|
+
*/
|
|
68
|
+
export function dispatch(event: KeyboardEvent): boolean {
|
|
69
|
+
lastEvent.value = event
|
|
604
70
|
|
|
605
|
-
// Dispatch to
|
|
606
|
-
const
|
|
607
|
-
if (
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
for (const handler of handlers) {
|
|
611
|
-
if (handler(event) === true) return // Consumed
|
|
612
|
-
}
|
|
71
|
+
// Dispatch to key-specific handlers
|
|
72
|
+
const handlers = keyHandlers.get(event.key)
|
|
73
|
+
if (handlers) {
|
|
74
|
+
for (const handler of handlers) {
|
|
75
|
+
if (handler() === true) return true
|
|
613
76
|
}
|
|
614
77
|
}
|
|
615
78
|
|
|
616
|
-
// Built-in scroll handling (after focused handlers, so they can override)
|
|
617
|
-
// Arrow keys scroll focused scrollable
|
|
618
|
-
if (event.key === 'ArrowUp' && handleArrowScroll('up')) return
|
|
619
|
-
if (event.key === 'ArrowDown' && handleArrowScroll('down')) return
|
|
620
|
-
if (event.key === 'ArrowLeft' && handleArrowScroll('left')) return
|
|
621
|
-
if (event.key === 'ArrowRight' && handleArrowScroll('right')) return
|
|
622
|
-
|
|
623
|
-
// Page Up/Down for larger scroll
|
|
624
|
-
if (event.key === 'PageUp' && handlePageScroll('up')) return
|
|
625
|
-
if (event.key === 'PageDown' && handlePageScroll('down')) return
|
|
626
|
-
|
|
627
|
-
// Home/End for scroll to start/end
|
|
628
|
-
if (event.key === 'Home' && !event.modifiers.ctrl && handleHomeEnd('home')) return
|
|
629
|
-
if (event.key === 'End' && !event.modifiers.ctrl && handleHomeEnd('end')) return
|
|
630
|
-
|
|
631
79
|
// Dispatch to global handlers
|
|
632
|
-
for (const handler of
|
|
633
|
-
if (handler(event) === true) return
|
|
80
|
+
for (const handler of globalHandlers) {
|
|
81
|
+
if (handler(event) === true) return true
|
|
634
82
|
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// =============================================================================
|
|
638
|
-
// INITIALIZATION
|
|
639
|
-
// =============================================================================
|
|
640
|
-
|
|
641
|
-
export function initialize(onCleanup?: () => void): void {
|
|
642
|
-
if (initialized || !process.stdin.isTTY) return
|
|
643
|
-
initialized = true
|
|
644
|
-
cleanupCallback = onCleanup ?? null
|
|
645
|
-
|
|
646
|
-
// Unified input buffer handles BOTH keyboard and mouse events
|
|
647
|
-
// This correctly handles interleaved data in a single stdin chunk
|
|
648
|
-
inputBuffer = new InputBuffer(handleKeyboardEvent, handleMouseEvent)
|
|
649
83
|
|
|
650
|
-
|
|
651
|
-
process.stdin.resume()
|
|
652
|
-
process.stdin.on('data', handleInput)
|
|
84
|
+
return false
|
|
653
85
|
}
|
|
654
86
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
function
|
|
660
|
-
|
|
661
|
-
if (event.action === 'scroll' && event.scroll) {
|
|
662
|
-
const direction = event.scroll.direction
|
|
663
|
-
// Try hovered element first, fallback to focused
|
|
664
|
-
handleWheelScroll(event.x, event.y, direction)
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// Route to mouse module for click/hover/drag processing
|
|
668
|
-
processMouseEvent(event)
|
|
669
|
-
}
|
|
87
|
+
/**
|
|
88
|
+
* Dispatch to focused component handlers.
|
|
89
|
+
* Returns true if consumed.
|
|
90
|
+
*/
|
|
91
|
+
export function dispatchFocused(focusedIndex: number, event: KeyboardEvent): boolean {
|
|
92
|
+
if (focusedIndex < 0) return false
|
|
670
93
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
inputBuffer?.parse(data)
|
|
674
|
-
}
|
|
94
|
+
const handlers = focusedHandlers.get(focusedIndex)
|
|
95
|
+
if (!handlers) return false
|
|
675
96
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
listeners.clear()
|
|
679
|
-
focusedListeners.clear()
|
|
680
|
-
_lastEvent.value = null
|
|
681
|
-
inputBuffer?.clear()
|
|
682
|
-
inputBuffer = null
|
|
683
|
-
|
|
684
|
-
if (process.stdin.isTTY) {
|
|
685
|
-
process.stdin.removeListener('data', handleInput)
|
|
686
|
-
process.stdin.setRawMode(false)
|
|
687
|
-
process.stdin.pause()
|
|
97
|
+
for (const handler of handlers) {
|
|
98
|
+
if (handler(event) === true) return true
|
|
688
99
|
}
|
|
689
100
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
if (cleanupCallback) {
|
|
693
|
-
cleanupCallback()
|
|
694
|
-
cleanupCallback = null
|
|
695
|
-
}
|
|
101
|
+
return false
|
|
696
102
|
}
|
|
697
103
|
|
|
698
104
|
// =============================================================================
|
|
699
105
|
// PUBLIC API
|
|
700
106
|
// =============================================================================
|
|
701
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Subscribe to all keyboard events.
|
|
110
|
+
* Return true from handler to consume the event.
|
|
111
|
+
*/
|
|
702
112
|
export function on(handler: KeyHandler): () => void {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
return () => listeners.delete(handler)
|
|
113
|
+
globalHandlers.add(handler)
|
|
114
|
+
return () => globalHandlers.delete(handler)
|
|
706
115
|
}
|
|
707
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Subscribe to specific key(s).
|
|
119
|
+
* Handler receives no arguments - check lastEvent if needed.
|
|
120
|
+
* Return true to consume the event.
|
|
121
|
+
*/
|
|
708
122
|
export function onKey(key: string | string[], handler: () => void | boolean): () => void {
|
|
709
123
|
const keys = Array.isArray(key) ? key : [key]
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
124
|
+
const unsubscribers: (() => void)[] = []
|
|
125
|
+
|
|
126
|
+
for (const k of keys) {
|
|
127
|
+
if (!keyHandlers.has(k)) {
|
|
128
|
+
keyHandlers.set(k, new Set())
|
|
129
|
+
}
|
|
130
|
+
keyHandlers.get(k)!.add(handler)
|
|
131
|
+
unsubscribers.push(() => {
|
|
132
|
+
const set = keyHandlers.get(k)
|
|
133
|
+
if (set) {
|
|
134
|
+
set.delete(handler)
|
|
135
|
+
if (set.size === 0) keyHandlers.delete(k)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return () => unsubscribers.forEach(fn => fn())
|
|
715
141
|
}
|
|
716
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Subscribe to events when a specific component has focus.
|
|
145
|
+
* Return true from handler to consume the event.
|
|
146
|
+
*/
|
|
717
147
|
export function onFocused(index: number, handler: KeyHandler): () => void {
|
|
718
|
-
if (!
|
|
719
|
-
|
|
720
|
-
if (!focusedListeners.has(index)) {
|
|
721
|
-
focusedListeners.set(index, new Set())
|
|
148
|
+
if (!focusedHandlers.has(index)) {
|
|
149
|
+
focusedHandlers.set(index, new Set())
|
|
722
150
|
}
|
|
723
|
-
|
|
151
|
+
focusedHandlers.get(index)!.add(handler)
|
|
724
152
|
|
|
725
153
|
return () => {
|
|
726
|
-
const handlers =
|
|
154
|
+
const handlers = focusedHandlers.get(index)
|
|
727
155
|
if (handlers) {
|
|
728
156
|
handlers.delete(handler)
|
|
729
157
|
if (handlers.size === 0) {
|
|
730
|
-
|
|
158
|
+
focusedHandlers.delete(index)
|
|
731
159
|
}
|
|
732
160
|
}
|
|
733
161
|
}
|
|
734
162
|
}
|
|
735
163
|
|
|
736
164
|
/**
|
|
737
|
-
* Clean up all
|
|
738
|
-
* Called when
|
|
165
|
+
* Clean up all handlers for a component index.
|
|
166
|
+
* Called when component is released to prevent memory leaks.
|
|
739
167
|
*/
|
|
740
168
|
export function cleanupIndex(index: number): void {
|
|
741
|
-
|
|
169
|
+
focusedHandlers.delete(index)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Clear all state and handlers.
|
|
174
|
+
*/
|
|
175
|
+
export function cleanup(): void {
|
|
176
|
+
globalHandlers.clear()
|
|
177
|
+
keyHandlers.clear()
|
|
178
|
+
focusedHandlers.clear()
|
|
179
|
+
lastEvent.value = null
|
|
742
180
|
}
|
|
743
181
|
|
|
744
182
|
// =============================================================================
|
|
745
|
-
// KEYBOARD OBJECT
|
|
183
|
+
// KEYBOARD OBJECT - Functions only, no state getters
|
|
746
184
|
// =============================================================================
|
|
747
185
|
|
|
748
186
|
export const keyboard = {
|
|
749
|
-
// Reactive state
|
|
750
|
-
get lastEvent() { return lastEvent.value },
|
|
751
|
-
get lastKey() { return lastKey.value },
|
|
752
|
-
|
|
753
187
|
// Handler registration
|
|
754
188
|
on,
|
|
755
189
|
onKey,
|
|
756
190
|
onFocused,
|
|
757
191
|
|
|
758
|
-
//
|
|
759
|
-
|
|
760
|
-
focusPrevious,
|
|
761
|
-
focus,
|
|
762
|
-
blur,
|
|
763
|
-
get focusedIndex() { return focusedIndex.value },
|
|
764
|
-
|
|
765
|
-
// Configuration
|
|
766
|
-
setExitOnCtrlC,
|
|
767
|
-
|
|
768
|
-
// Lifecycle
|
|
769
|
-
initialize,
|
|
192
|
+
// Cleanup
|
|
193
|
+
cleanupIndex,
|
|
770
194
|
cleanup,
|
|
771
195
|
}
|