@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
|
@@ -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
|
+
}
|