@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
@@ -0,0 +1,534 @@
1
+ /**
2
+ * TUI Framework - Input Module
3
+ *
4
+ * Owns stdin. Parses all terminal input (keyboard + mouse).
5
+ * Routes typed events to keyboard and mouse modules.
6
+ *
7
+ * This is the ONLY module that touches process.stdin.
8
+ */
9
+
10
+ import type { KeyboardEvent } from './keyboard'
11
+ import type { MouseEvent } from './mouse'
12
+
13
+ // =============================================================================
14
+ // TYPES
15
+ // =============================================================================
16
+
17
+ type KeyboardHandler = (event: KeyboardEvent) => void
18
+ type MouseHandler = (event: MouseEvent) => void
19
+
20
+ interface Modifiers {
21
+ ctrl: boolean
22
+ alt: boolean
23
+ shift: boolean
24
+ meta: boolean
25
+ }
26
+
27
+ type KeyState = 'press' | 'repeat' | 'release'
28
+
29
+ // =============================================================================
30
+ // INPUT BUFFER - Unified Parser
31
+ // =============================================================================
32
+
33
+ /**
34
+ * Buffer for accumulating partial escape sequences.
35
+ * Escape sequences can arrive split across multiple stdin reads.
36
+ * Handles BOTH keyboard AND mouse since stdin delivers interleaved data.
37
+ */
38
+ class InputBuffer {
39
+ private buffer = ''
40
+ private timeout: Timer | null = null
41
+ private readonly TIMEOUT_MS = 10
42
+
43
+ constructor(
44
+ private onKeyboard: KeyboardHandler,
45
+ private onMouse: MouseHandler
46
+ ) {}
47
+
48
+ parse(data: Buffer): void {
49
+ this.buffer += data.toString()
50
+
51
+ if (this.timeout) {
52
+ clearTimeout(this.timeout)
53
+ this.timeout = null
54
+ }
55
+
56
+ let consumed = 0
57
+
58
+ while (consumed < this.buffer.length) {
59
+ const remaining = this.buffer.slice(consumed)
60
+ const result = this.parseOne(remaining)
61
+
62
+ if (result.keyboard) {
63
+ this.onKeyboard(result.keyboard)
64
+ consumed += result.consumed
65
+ } else if (result.mouse) {
66
+ this.onMouse(result.mouse)
67
+ consumed += result.consumed
68
+ } else if (result.incomplete) {
69
+ break
70
+ } else {
71
+ consumed++
72
+ }
73
+ }
74
+
75
+ this.buffer = this.buffer.slice(consumed)
76
+
77
+ // Timeout flushes incomplete sequences as raw input (handles genuine ESC key)
78
+ if (this.buffer.length > 0) {
79
+ this.timeout = setTimeout(() => {
80
+ for (const char of this.buffer) {
81
+ const code = char.charCodeAt(0)
82
+ if (code < 32 || code === 127) {
83
+ this.onKeyboard(this.controlKey(code))
84
+ } else {
85
+ this.onKeyboard(this.simpleKey(char))
86
+ }
87
+ }
88
+ this.buffer = ''
89
+ }, this.TIMEOUT_MS)
90
+ }
91
+ }
92
+
93
+ private parseOne(data: string): ParseResult {
94
+ if (data.length === 0) return { consumed: 0 }
95
+
96
+ if (data[0] === '\x1b') {
97
+ return this.parseEscape(data)
98
+ }
99
+
100
+ const char = data[0]!
101
+ const code = char.codePointAt(0) ?? 0
102
+
103
+ if (code < 32) {
104
+ return { keyboard: this.controlKey(code), consumed: 1 }
105
+ }
106
+
107
+ if (code === 127) {
108
+ return { keyboard: this.key('Backspace'), consumed: 1 }
109
+ }
110
+
111
+ // Normalize space to 'Space' for consistency
112
+ if (code === 32) {
113
+ return { keyboard: this.key('Space'), consumed: 1 }
114
+ }
115
+
116
+ const isUpper = char >= 'A' && char <= 'Z'
117
+ return {
118
+ keyboard: { key: char, modifiers: this.mods({ shift: isUpper }), state: 'press' },
119
+ consumed: char.length,
120
+ }
121
+ }
122
+
123
+ // ===========================================================================
124
+ // ESCAPE SEQUENCES
125
+ // ===========================================================================
126
+
127
+ private parseEscape(data: string): ParseResult {
128
+ if (data.length === 1) {
129
+ return { consumed: 0, incomplete: true }
130
+ }
131
+
132
+ const second = data[1]!
133
+
134
+ if (second === '[') return this.parseCSI(data)
135
+ if (second === 'O') return this.parseSS3(data)
136
+
137
+ // Alt + key
138
+ if (second.codePointAt(0)! >= 32) {
139
+ return {
140
+ keyboard: { key: second, modifiers: this.mods({ alt: true }), state: 'press' },
141
+ consumed: 2,
142
+ }
143
+ }
144
+
145
+ return { keyboard: this.key('Escape'), consumed: 1 }
146
+ }
147
+
148
+ private parseCSI(data: string): ParseResult {
149
+ // SGR mouse: ESC [ < ...
150
+ if (data.length >= 3 && data[2] === '<') {
151
+ return this.parseMouseSGR(data)
152
+ }
153
+
154
+ // X10 mouse: ESC [ M ...
155
+ if (data.length >= 3 && data[2] === 'M') {
156
+ return this.parseMouseX10(data)
157
+ }
158
+
159
+ // Find terminator (A-Z, a-z, or ~)
160
+ let i = 2
161
+ while (i < data.length) {
162
+ const c = data.charCodeAt(i)
163
+ if ((c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 126) break
164
+ i++
165
+ }
166
+
167
+ if (i >= data.length) {
168
+ return { consumed: 0, incomplete: true }
169
+ }
170
+
171
+ const sequence = data.slice(2, i)
172
+ const terminator = data[i]!
173
+
174
+ return this.parseCSIKey(sequence, terminator, i + 1)
175
+ }
176
+
177
+ private parseCSIKey(sequence: string, terminator: string, consumed: number): ParseResult {
178
+ const parts = sequence.split(';')
179
+ const modifiers = this.mods()
180
+
181
+ if (parts.length >= 2) {
182
+ const mod = parseInt(parts[1]!, 10) - 1
183
+ modifiers.shift = (mod & 1) !== 0
184
+ modifiers.alt = (mod & 2) !== 0
185
+ modifiers.ctrl = (mod & 4) !== 0
186
+ modifiers.meta = (mod & 8) !== 0
187
+ }
188
+
189
+ let key: string
190
+
191
+ switch (terminator) {
192
+ case 'A': key = 'ArrowUp'; break
193
+ case 'B': key = 'ArrowDown'; break
194
+ case 'C': key = 'ArrowRight'; break
195
+ case 'D': key = 'ArrowLeft'; break
196
+ case 'H': key = 'Home'; break
197
+ case 'F': key = 'End'; break
198
+ case 'Z': key = 'Tab'; modifiers.shift = true; break
199
+ case '~': key = this.parseTildeKey(parts[0]!); break
200
+ case 'u': return this.parseKittyKey(sequence, consumed)
201
+ default: key = `CSI${sequence}${terminator}`
202
+ }
203
+
204
+ return { keyboard: { key, modifiers, state: 'press' }, consumed }
205
+ }
206
+
207
+ private parseTildeKey(code: string): string {
208
+ switch (parseInt(code, 10)) {
209
+ case 1: case 7: return 'Home'
210
+ case 2: return 'Insert'
211
+ case 3: return 'Delete'
212
+ case 4: case 8: return 'End'
213
+ case 5: return 'PageUp'
214
+ case 6: return 'PageDown'
215
+ case 11: return 'F1'
216
+ case 12: return 'F2'
217
+ case 13: return 'F3'
218
+ case 14: return 'F4'
219
+ case 15: return 'F5'
220
+ case 17: return 'F6'
221
+ case 18: return 'F7'
222
+ case 19: return 'F8'
223
+ case 20: return 'F9'
224
+ case 21: return 'F10'
225
+ case 23: return 'F11'
226
+ case 24: return 'F12'
227
+ default: return `F${code}`
228
+ }
229
+ }
230
+
231
+ private parseKittyKey(sequence: string, consumed: number): ParseResult {
232
+ const parts = sequence.split(';')
233
+ const codepoint = parseInt(parts[0]!, 10)
234
+ const modifiers = this.mods()
235
+ let state: KeyState = 'press'
236
+
237
+ if (parts.length >= 2) {
238
+ const modBits = parseInt(parts[1]!, 10) - 1
239
+ modifiers.shift = (modBits & 1) !== 0
240
+ modifiers.alt = (modBits & 2) !== 0
241
+ modifiers.ctrl = (modBits & 4) !== 0
242
+ modifiers.meta = (modBits & 8) !== 0
243
+ }
244
+
245
+ if (parts.length >= 3) {
246
+ const eventType = parseInt(parts[2]!, 10)
247
+ if (eventType === 2) state = 'repeat'
248
+ else if (eventType === 3) state = 'release'
249
+ }
250
+
251
+ let key: string
252
+ switch (codepoint) {
253
+ case 13: key = 'Enter'; break
254
+ case 9: key = 'Tab'; break
255
+ case 127: key = 'Backspace'; break
256
+ case 27: key = 'Escape'; break
257
+ default: key = String.fromCodePoint(codepoint)
258
+ }
259
+
260
+ return { keyboard: { key, modifiers, state }, consumed }
261
+ }
262
+
263
+ private parseSS3(data: string): ParseResult {
264
+ if (data.length < 3) {
265
+ return { consumed: 0, incomplete: true }
266
+ }
267
+
268
+ const terminator = data[2]!
269
+ let key: string
270
+
271
+ switch (terminator) {
272
+ case 'P': key = 'F1'; break
273
+ case 'Q': key = 'F2'; break
274
+ case 'R': key = 'F3'; break
275
+ case 'S': key = 'F4'; break
276
+ case 'H': key = 'Home'; break
277
+ case 'F': key = 'End'; break
278
+ case 'A': key = 'ArrowUp'; break
279
+ case 'B': key = 'ArrowDown'; break
280
+ case 'C': key = 'ArrowRight'; break
281
+ case 'D': key = 'ArrowLeft'; break
282
+ default: key = `SS3${terminator}`
283
+ }
284
+
285
+ return { keyboard: this.key(key), consumed: 3 }
286
+ }
287
+
288
+ // ===========================================================================
289
+ // MOUSE PARSING
290
+ // ===========================================================================
291
+
292
+ private parseMouseSGR(data: string): ParseResult {
293
+ const match = data.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/)
294
+ if (!match) {
295
+ if (data.match(/^\x1b\[<[\d;]*$/)) {
296
+ return { consumed: 0, incomplete: true }
297
+ }
298
+ return { consumed: 0 }
299
+ }
300
+
301
+ const [full, buttonStr, xStr, yStr, terminator] = match
302
+ const buttonCode = parseInt(buttonStr!, 10)
303
+ const x = parseInt(xStr!, 10) - 1
304
+ const y = parseInt(yStr!, 10) - 1
305
+
306
+ const baseButton = buttonCode & 3
307
+ const isScroll = (buttonCode & 64) !== 0
308
+ const isMotion = (buttonCode & 32) !== 0
309
+
310
+ type Action = 'down' | 'up' | 'move' | 'drag' | 'scroll'
311
+ let action: Action
312
+ let button = baseButton
313
+ let scrollDirection: 'up' | 'down' | undefined
314
+
315
+ if (isScroll) {
316
+ action = 'scroll'
317
+ scrollDirection = baseButton === 0 ? 'up' : 'down'
318
+ button = 3
319
+ } else if (isMotion) {
320
+ action = baseButton === 3 ? 'move' : 'drag'
321
+ } else {
322
+ action = terminator === 'M' ? 'down' : 'up'
323
+ }
324
+
325
+ return {
326
+ mouse: {
327
+ action,
328
+ button,
329
+ x,
330
+ y,
331
+ shiftKey: (buttonCode & 4) !== 0,
332
+ altKey: (buttonCode & 8) !== 0,
333
+ ctrlKey: (buttonCode & 16) !== 0,
334
+ scroll: scrollDirection ? { direction: scrollDirection, delta: 1 } : undefined,
335
+ componentIndex: -1,
336
+ },
337
+ consumed: full!.length,
338
+ }
339
+ }
340
+
341
+ private parseMouseX10(data: string): ParseResult {
342
+ if (data.length < 6) {
343
+ return { consumed: 0, incomplete: true }
344
+ }
345
+
346
+ const buttonByte = data.charCodeAt(3) - 32
347
+ const x = data.charCodeAt(4) - 33
348
+ const y = data.charCodeAt(5) - 33
349
+
350
+ const baseButton = buttonByte & 3
351
+ const isScroll = (buttonByte & 64) !== 0
352
+
353
+ type Action = 'down' | 'up' | 'move' | 'drag' | 'scroll'
354
+ let action: Action
355
+ let button = baseButton
356
+ let scrollDirection: 'up' | 'down' | undefined
357
+
358
+ if (isScroll) {
359
+ action = 'scroll'
360
+ scrollDirection = baseButton === 0 ? 'up' : 'down'
361
+ button = 3
362
+ } else {
363
+ action = baseButton === 3 ? 'up' : 'down'
364
+ if (baseButton === 3) button = 0
365
+ }
366
+
367
+ return {
368
+ mouse: {
369
+ action,
370
+ button,
371
+ x,
372
+ y,
373
+ shiftKey: (buttonByte & 4) !== 0,
374
+ altKey: (buttonByte & 8) !== 0,
375
+ ctrlKey: (buttonByte & 16) !== 0,
376
+ scroll: scrollDirection ? { direction: scrollDirection, delta: 1 } : undefined,
377
+ componentIndex: -1,
378
+ },
379
+ consumed: 6,
380
+ }
381
+ }
382
+
383
+ // ===========================================================================
384
+ // CONTROL KEYS
385
+ // ===========================================================================
386
+
387
+ private controlKey(code: number): KeyboardEvent {
388
+ let key: string
389
+ const modifiers = this.mods()
390
+
391
+ switch (code) {
392
+ case 0: key = '@'; modifiers.ctrl = true; break
393
+ case 1: key = 'a'; modifiers.ctrl = true; break
394
+ case 2: key = 'b'; modifiers.ctrl = true; break
395
+ case 3: key = 'c'; modifiers.ctrl = true; break
396
+ case 4: key = 'd'; modifiers.ctrl = true; break
397
+ case 5: key = 'e'; modifiers.ctrl = true; break
398
+ case 6: key = 'f'; modifiers.ctrl = true; break
399
+ case 7: key = 'g'; modifiers.ctrl = true; break
400
+ case 8: key = 'Backspace'; break
401
+ case 9: key = 'Tab'; break
402
+ case 10: case 13: key = 'Enter'; break
403
+ case 11: key = 'k'; modifiers.ctrl = true; break
404
+ case 12: key = 'l'; modifiers.ctrl = true; break
405
+ case 14: key = 'n'; modifiers.ctrl = true; break
406
+ case 15: key = 'o'; modifiers.ctrl = true; break
407
+ case 16: key = 'p'; modifiers.ctrl = true; break
408
+ case 17: key = 'q'; modifiers.ctrl = true; break
409
+ case 18: key = 'r'; modifiers.ctrl = true; break
410
+ case 19: key = 's'; modifiers.ctrl = true; break
411
+ case 20: key = 't'; modifiers.ctrl = true; break
412
+ case 21: key = 'u'; modifiers.ctrl = true; break
413
+ case 22: key = 'v'; modifiers.ctrl = true; break
414
+ case 23: key = 'w'; modifiers.ctrl = true; break
415
+ case 24: key = 'x'; modifiers.ctrl = true; break
416
+ case 25: key = 'y'; modifiers.ctrl = true; break
417
+ case 26: key = 'z'; modifiers.ctrl = true; break
418
+ case 27: key = 'Escape'; break
419
+ case 28: key = '\\'; modifiers.ctrl = true; break
420
+ case 29: key = ']'; modifiers.ctrl = true; break
421
+ case 30: key = '^'; modifiers.ctrl = true; break
422
+ case 31: key = '_'; modifiers.ctrl = true; break
423
+ default: key = `Ctrl+${code}`
424
+ }
425
+
426
+ return { key, modifiers, state: 'press' }
427
+ }
428
+
429
+ // ===========================================================================
430
+ // HELPERS
431
+ // ===========================================================================
432
+
433
+ private key(key: string): KeyboardEvent {
434
+ return { key, modifiers: this.mods(), state: 'press' }
435
+ }
436
+
437
+ private simpleKey(key: string): KeyboardEvent {
438
+ return { key, modifiers: this.mods(), state: 'press' }
439
+ }
440
+
441
+ private mods(overrides: Partial<Modifiers> = {}): Modifiers {
442
+ return { ctrl: false, alt: false, shift: false, meta: false, ...overrides }
443
+ }
444
+
445
+ clear(): void {
446
+ this.buffer = ''
447
+ if (this.timeout) {
448
+ clearTimeout(this.timeout)
449
+ this.timeout = null
450
+ }
451
+ }
452
+ }
453
+
454
+ interface ParseResult {
455
+ keyboard?: KeyboardEvent
456
+ mouse?: MouseEvent
457
+ consumed: number
458
+ incomplete?: boolean
459
+ }
460
+
461
+ // =============================================================================
462
+ // STATE
463
+ // =============================================================================
464
+
465
+ let initialized = false
466
+ let inputBuffer: InputBuffer | null = null
467
+ let keyboardHandler: KeyboardHandler | null = null
468
+ let mouseHandler: MouseHandler | null = null
469
+
470
+ // =============================================================================
471
+ // STDIN MANAGEMENT
472
+ // =============================================================================
473
+
474
+ function handleData(data: Buffer): void {
475
+ inputBuffer?.parse(data)
476
+ }
477
+
478
+ // =============================================================================
479
+ // PUBLIC API
480
+ // =============================================================================
481
+
482
+ /**
483
+ * Initialize stdin input handling.
484
+ * Call once at app start. Subsequent calls are no-ops.
485
+ */
486
+ export function initialize(
487
+ onKeyboard: KeyboardHandler,
488
+ onMouse: MouseHandler
489
+ ): void {
490
+ if (initialized) return
491
+ if (!process.stdin.isTTY) return
492
+
493
+ initialized = true
494
+ keyboardHandler = onKeyboard
495
+ mouseHandler = onMouse
496
+
497
+ inputBuffer = new InputBuffer(onKeyboard, onMouse)
498
+
499
+ process.stdin.setRawMode(true)
500
+ process.stdin.resume()
501
+ process.stdin.on('data', handleData)
502
+ }
503
+
504
+ /**
505
+ * Clean up stdin handling.
506
+ */
507
+ export function cleanup(): void {
508
+ if (!initialized) return
509
+
510
+ initialized = false
511
+ inputBuffer?.clear()
512
+ inputBuffer = null
513
+ keyboardHandler = null
514
+ mouseHandler = null
515
+
516
+ if (process.stdin.isTTY) {
517
+ process.stdin.removeListener('data', handleData)
518
+ process.stdin.setRawMode(false)
519
+ process.stdin.pause()
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Check if input system is initialized.
525
+ */
526
+ export function isInitialized(): boolean {
527
+ return initialized
528
+ }
529
+
530
+ export const input = {
531
+ initialize,
532
+ cleanup,
533
+ isInitialized,
534
+ }