@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.
Files changed (38) hide show
  1. package/README.md +126 -13
  2. package/index.ts +11 -5
  3. package/package.json +2 -2
  4. package/src/api/mount.ts +42 -27
  5. package/src/engine/arrays/core.ts +13 -21
  6. package/src/engine/arrays/dimensions.ts +22 -32
  7. package/src/engine/arrays/index.ts +88 -86
  8. package/src/engine/arrays/interaction.ts +34 -48
  9. package/src/engine/arrays/layout.ts +67 -92
  10. package/src/engine/arrays/spacing.ts +37 -52
  11. package/src/engine/arrays/text.ts +23 -31
  12. package/src/engine/arrays/visual.ts +56 -75
  13. package/src/engine/inheritance.ts +18 -18
  14. package/src/engine/registry.ts +15 -0
  15. package/src/pipeline/frameBuffer.ts +26 -26
  16. package/src/pipeline/layout/index.ts +2 -2
  17. package/src/pipeline/layout/titan-engine.ts +112 -84
  18. package/src/primitives/animation.ts +194 -0
  19. package/src/primitives/box.ts +74 -86
  20. package/src/primitives/each.ts +87 -0
  21. package/src/primitives/index.ts +7 -0
  22. package/src/primitives/scope.ts +215 -0
  23. package/src/primitives/show.ts +77 -0
  24. package/src/primitives/text.ts +63 -59
  25. package/src/primitives/types.ts +1 -1
  26. package/src/primitives/when.ts +102 -0
  27. package/src/renderer/append-region.ts +303 -0
  28. package/src/renderer/index.ts +4 -2
  29. package/src/renderer/output.ts +11 -34
  30. package/src/state/focus.ts +16 -5
  31. package/src/state/global-keys.ts +184 -0
  32. package/src/state/index.ts +44 -8
  33. package/src/state/input.ts +534 -0
  34. package/src/state/keyboard.ts +98 -674
  35. package/src/state/mouse.ts +163 -340
  36. package/src/state/scroll.ts +7 -9
  37. package/src/types/index.ts +6 -0
  38. package/src/renderer/input.ts +0 -518
@@ -1,24 +1,19 @@
1
1
  /**
2
- * TUI Framework - Keyboard State Module
2
+ * TUI Framework - Keyboard Module
3
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
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
- * 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
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
- 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)
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(() => _lastEvent.value?.key ?? '')
47
+ /** Last key pressed (reactive derived) */
48
+ export const lastKey = derived(() => lastEvent.value?.key ?? '')
566
49
 
567
50
  // =============================================================================
568
- // CONFIGURATION
51
+ // HANDLER REGISTRY
569
52
  // =============================================================================
570
53
 
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
- }
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
- // INPUT HANDLING
59
+ // EVENT DISPATCH (called by global-keys.ts)
581
60
  // =============================================================================
582
61
 
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
- }
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 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
- }
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 listeners) {
633
- if (handler(event) === true) return // Consumed
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
- process.stdin.setRawMode(true)
651
- process.stdin.resume()
652
- process.stdin.on('data', handleInput)
84
+ return false
653
85
  }
654
86
 
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
- }
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
- function handleInput(data: Buffer): void {
672
- // Unified parser handles both keyboard and mouse - no routing needed!
673
- inputBuffer?.parse(data)
674
- }
94
+ const handlers = focusedHandlers.get(focusedIndex)
95
+ if (!handlers) return false
675
96
 
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()
97
+ for (const handler of handlers) {
98
+ if (handler(event) === true) return true
688
99
  }
689
100
 
690
- process.stdout.write('\x1b[?25h') // Show cursor
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
- if (!initialized) initialize()
704
- listeners.add(handler)
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
- return on((event) => {
711
- if (keys.includes(event.key)) {
712
- return handler()
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 (!initialized) initialize()
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
- focusedListeners.get(index)!.add(handler)
151
+ focusedHandlers.get(index)!.add(handler)
724
152
 
725
153
  return () => {
726
- const handlers = focusedListeners.get(index)
154
+ const handlers = focusedHandlers.get(index)
727
155
  if (handlers) {
728
156
  handlers.delete(handler)
729
157
  if (handlers.size === 0) {
730
- focusedListeners.delete(index)
158
+ focusedHandlers.delete(index)
731
159
  }
732
160
  }
733
161
  }
734
162
  }
735
163
 
736
164
  /**
737
- * Clean up all listeners for a component index.
738
- * Called when a component is released to prevent memory leaks.
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
- focusedListeners.delete(index)
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
- // 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,
192
+ // Cleanup
193
+ cleanupIndex,
770
194
  cleanup,
771
195
  }