@rlabs-inc/tui 0.1.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 +141 -0
- package/index.ts +45 -0
- package/package.json +59 -0
- package/src/api/index.ts +7 -0
- package/src/api/mount.ts +230 -0
- package/src/engine/arrays/core.ts +60 -0
- package/src/engine/arrays/dimensions.ts +68 -0
- package/src/engine/arrays/index.ts +166 -0
- package/src/engine/arrays/interaction.ts +112 -0
- package/src/engine/arrays/layout.ts +175 -0
- package/src/engine/arrays/spacing.ts +100 -0
- package/src/engine/arrays/text.ts +55 -0
- package/src/engine/arrays/visual.ts +140 -0
- package/src/engine/index.ts +25 -0
- package/src/engine/inheritance.ts +138 -0
- package/src/engine/registry.ts +180 -0
- package/src/pipeline/frameBuffer.ts +473 -0
- package/src/pipeline/layout/index.ts +105 -0
- package/src/pipeline/layout/titan-engine.ts +798 -0
- package/src/pipeline/layout/types.ts +194 -0
- package/src/pipeline/layout/utils/hierarchy.ts +202 -0
- package/src/pipeline/layout/utils/math.ts +134 -0
- package/src/pipeline/layout/utils/text-measure.ts +160 -0
- package/src/pipeline/layout.ts +30 -0
- package/src/primitives/box.ts +312 -0
- package/src/primitives/index.ts +12 -0
- package/src/primitives/text.ts +199 -0
- package/src/primitives/types.ts +222 -0
- package/src/primitives/utils.ts +37 -0
- package/src/renderer/ansi.ts +625 -0
- package/src/renderer/buffer.ts +667 -0
- package/src/renderer/index.ts +40 -0
- package/src/renderer/input.ts +518 -0
- package/src/renderer/output.ts +451 -0
- package/src/state/cursor.ts +176 -0
- package/src/state/focus.ts +241 -0
- package/src/state/index.ts +43 -0
- package/src/state/keyboard.ts +771 -0
- package/src/state/mouse.ts +524 -0
- package/src/state/scroll.ts +341 -0
- package/src/state/theme.ts +687 -0
- package/src/types/color.ts +401 -0
- package/src/types/index.ts +316 -0
- package/src/utils/text.ts +471 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI Framework - Keyboard State Module
|
|
3
|
+
*
|
|
4
|
+
* Robust keyboard handling based on terminalKit patterns:
|
|
5
|
+
* - InputBuffer for partial escape sequence handling
|
|
6
|
+
* - Proper control key mapping (Ctrl+H ≠ Backspace)
|
|
7
|
+
* - CSI, SS3, Kitty protocol support
|
|
8
|
+
* - Alt+key detection
|
|
9
|
+
*
|
|
10
|
+
* API:
|
|
11
|
+
* keyboard.lastEvent - Reactive last event
|
|
12
|
+
* keyboard.on(fn) - Subscribe to all keys
|
|
13
|
+
* keyboard.onKey(k,fn)- Subscribe to specific key
|
|
14
|
+
* keyboard.onFocused(i,fn) - Subscribe when focused
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
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
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// TYPES
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
export interface Modifiers {
|
|
28
|
+
ctrl: boolean
|
|
29
|
+
alt: boolean
|
|
30
|
+
shift: boolean
|
|
31
|
+
meta: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type KeyState = 'press' | 'repeat' | 'release'
|
|
35
|
+
|
|
36
|
+
export interface KeyboardEvent {
|
|
37
|
+
/** Normalized key name (e.g., 'Enter', 'ArrowUp', 'a') */
|
|
38
|
+
key: string
|
|
39
|
+
/** Modifier state */
|
|
40
|
+
modifiers: Modifiers
|
|
41
|
+
/** Key state (press/repeat/release) */
|
|
42
|
+
state: KeyState
|
|
43
|
+
/** Raw input bytes (for debugging) */
|
|
44
|
+
raw?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type KeyHandler = (event: KeyboardEvent) => void | boolean
|
|
48
|
+
|
|
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
|
+
// =============================================================================
|
|
550
|
+
// STATE
|
|
551
|
+
// =============================================================================
|
|
552
|
+
|
|
553
|
+
let initialized = false
|
|
554
|
+
let inputBuffer: InputBuffer | null = null
|
|
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)
|
|
563
|
+
|
|
564
|
+
/** Last key pressed (reactive) */
|
|
565
|
+
export const lastKey = derived(() => _lastEvent.value?.key ?? '')
|
|
566
|
+
|
|
567
|
+
// =============================================================================
|
|
568
|
+
// CONFIGURATION
|
|
569
|
+
// =============================================================================
|
|
570
|
+
|
|
571
|
+
/** Whether Ctrl+C should exit the app (can be overridden) */
|
|
572
|
+
let exitOnCtrlC = true
|
|
573
|
+
|
|
574
|
+
/** Set whether Ctrl+C exits the app */
|
|
575
|
+
export function setExitOnCtrlC(enabled: boolean): void {
|
|
576
|
+
exitOnCtrlC = enabled
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// =============================================================================
|
|
580
|
+
// INPUT HANDLING
|
|
581
|
+
// =============================================================================
|
|
582
|
+
|
|
583
|
+
let cleanupCallback: (() => void) | null = null
|
|
584
|
+
|
|
585
|
+
function handleEvent(event: KeyboardEvent): void {
|
|
586
|
+
// Update reactive state
|
|
587
|
+
_lastEvent.value = event
|
|
588
|
+
|
|
589
|
+
// System-level: Ctrl+C exits (configurable)
|
|
590
|
+
if (exitOnCtrlC && event.key === 'c' && event.modifiers.ctrl) {
|
|
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
|
+
}
|
|
604
|
+
|
|
605
|
+
// Dispatch to focused component handlers first
|
|
606
|
+
const currentFocus = focusedIndex.value
|
|
607
|
+
if (currentFocus >= 0) {
|
|
608
|
+
const handlers = focusedListeners.get(currentFocus)
|
|
609
|
+
if (handlers) {
|
|
610
|
+
for (const handler of handlers) {
|
|
611
|
+
if (handler(event) === true) return // Consumed
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
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
|
+
// Dispatch to global handlers
|
|
632
|
+
for (const handler of listeners) {
|
|
633
|
+
if (handler(event) === true) return // Consumed
|
|
634
|
+
}
|
|
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
|
+
|
|
650
|
+
process.stdin.setRawMode(true)
|
|
651
|
+
process.stdin.resume()
|
|
652
|
+
process.stdin.on('data', handleInput)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function handleKeyboardEvent(event: KeyboardEvent): void {
|
|
656
|
+
handleEvent(event)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function handleMouseEvent(event: TUIMouseEvent): void {
|
|
660
|
+
// Handle scroll wheel (before routing to mouse module)
|
|
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
|
+
}
|
|
670
|
+
|
|
671
|
+
function handleInput(data: Buffer): void {
|
|
672
|
+
// Unified parser handles both keyboard and mouse - no routing needed!
|
|
673
|
+
inputBuffer?.parse(data)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
export function cleanup(): void {
|
|
677
|
+
initialized = false
|
|
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()
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
process.stdout.write('\x1b[?25h') // Show cursor
|
|
691
|
+
|
|
692
|
+
if (cleanupCallback) {
|
|
693
|
+
cleanupCallback()
|
|
694
|
+
cleanupCallback = null
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// =============================================================================
|
|
699
|
+
// PUBLIC API
|
|
700
|
+
// =============================================================================
|
|
701
|
+
|
|
702
|
+
export function on(handler: KeyHandler): () => void {
|
|
703
|
+
if (!initialized) initialize()
|
|
704
|
+
listeners.add(handler)
|
|
705
|
+
return () => listeners.delete(handler)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function onKey(key: string | string[], handler: () => void | boolean): () => void {
|
|
709
|
+
const keys = Array.isArray(key) ? key : [key]
|
|
710
|
+
return on((event) => {
|
|
711
|
+
if (keys.includes(event.key)) {
|
|
712
|
+
return handler()
|
|
713
|
+
}
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function onFocused(index: number, handler: KeyHandler): () => void {
|
|
718
|
+
if (!initialized) initialize()
|
|
719
|
+
|
|
720
|
+
if (!focusedListeners.has(index)) {
|
|
721
|
+
focusedListeners.set(index, new Set())
|
|
722
|
+
}
|
|
723
|
+
focusedListeners.get(index)!.add(handler)
|
|
724
|
+
|
|
725
|
+
return () => {
|
|
726
|
+
const handlers = focusedListeners.get(index)
|
|
727
|
+
if (handlers) {
|
|
728
|
+
handlers.delete(handler)
|
|
729
|
+
if (handlers.size === 0) {
|
|
730
|
+
focusedListeners.delete(index)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Clean up all listeners for a component index.
|
|
738
|
+
* Called when a component is released to prevent memory leaks.
|
|
739
|
+
*/
|
|
740
|
+
export function cleanupIndex(index: number): void {
|
|
741
|
+
focusedListeners.delete(index)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// =============================================================================
|
|
745
|
+
// KEYBOARD OBJECT
|
|
746
|
+
// =============================================================================
|
|
747
|
+
|
|
748
|
+
export const keyboard = {
|
|
749
|
+
// Reactive state
|
|
750
|
+
get lastEvent() { return lastEvent.value },
|
|
751
|
+
get lastKey() { return lastKey.value },
|
|
752
|
+
|
|
753
|
+
// Handler registration
|
|
754
|
+
on,
|
|
755
|
+
onKey,
|
|
756
|
+
onFocused,
|
|
757
|
+
|
|
758
|
+
// Focus navigation (re-exported from focus.ts for convenience)
|
|
759
|
+
focusNext,
|
|
760
|
+
focusPrevious,
|
|
761
|
+
focus,
|
|
762
|
+
blur,
|
|
763
|
+
get focusedIndex() { return focusedIndex.value },
|
|
764
|
+
|
|
765
|
+
// Configuration
|
|
766
|
+
setExitOnCtrlC,
|
|
767
|
+
|
|
768
|
+
// Lifecycle
|
|
769
|
+
initialize,
|
|
770
|
+
cleanup,
|
|
771
|
+
}
|