@textui/terminal 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/src/input.ts ADDED
@@ -0,0 +1,394 @@
1
+ import type { InputEvent, KeyEvent, MouseEvent } from '@textui/core';
2
+
3
+ /**
4
+ * Input decoding.
5
+ *
6
+ * A terminal delivers keys as bytes that may arrive split across reads, so the
7
+ * decoder is a small state machine over a carry buffer rather than a parser
8
+ * over whole strings. When a sequence is incomplete it stays in the buffer and
9
+ * waits, which is what stops a fast paste from being read as a burst of keys.
10
+ */
11
+
12
+ const ESC = '\x1b';
13
+
14
+ function key(name: string, partial: Partial<KeyEvent> = {}): KeyEvent {
15
+ return {
16
+ type: 'key',
17
+ name,
18
+ ctrl: false,
19
+ alt: false,
20
+ shift: false,
21
+ meta: false,
22
+ raw: partial.raw ?? name,
23
+ handled: false,
24
+ ...partial,
25
+ };
26
+ }
27
+
28
+ /** CSI final byte to key name, for the `ESC [ A` family. */
29
+ const CSI_LETTERS: Record<string, string> = {
30
+ A: 'up', B: 'down', C: 'right', D: 'left',
31
+ E: 'center', F: 'end', H: 'home',
32
+ P: 'f1', Q: 'f2', R: 'f3', S: 'f4',
33
+ Z: 'tab', // shift+tab arrives as CSI Z
34
+ };
35
+
36
+ /** `ESC [ <n> ~` numbers. */
37
+ const CSI_NUMBERS: Record<number, string> = {
38
+ 1: 'home', 2: 'insert', 3: 'delete', 4: 'end', 5: 'pageup', 6: 'pagedown',
39
+ 7: 'home', 8: 'end',
40
+ 11: 'f1', 12: 'f2', 13: 'f3', 14: 'f4', 15: 'f5',
41
+ 17: 'f6', 18: 'f7', 19: 'f8', 20: 'f9', 21: 'f10',
42
+ 23: 'f11', 24: 'f12',
43
+ };
44
+
45
+ /** xterm modifier parameter: 1 + bitmask. */
46
+ function modifiers(param: number | undefined): Pick<KeyEvent, 'shift' | 'alt' | 'ctrl' | 'meta'> {
47
+ const bits = (param ?? 1) - 1;
48
+ return {
49
+ shift: (bits & 1) !== 0,
50
+ alt: (bits & 2) !== 0,
51
+ ctrl: (bits & 4) !== 0,
52
+ meta: (bits & 8) !== 0,
53
+ };
54
+ }
55
+
56
+ const CTRL_NAMES: Record<number, string> = {
57
+ 0x00: 'space', // ctrl+space
58
+ 0x08: 'backspace',
59
+ 0x09: 'tab',
60
+ 0x0a: 'enter',
61
+ 0x0d: 'enter',
62
+ 0x1b: 'escape',
63
+ 0x7f: 'backspace',
64
+ };
65
+
66
+ export interface DecoderOptions {
67
+ /** How long to wait before a lone ESC is reported as the escape key. */
68
+ escapeTimeoutMs?: number;
69
+ }
70
+
71
+ export class InputDecoder {
72
+ private carry = '';
73
+ private pasting = false;
74
+ private pasteBuffer = '';
75
+ private escapeTimer: ReturnType<typeof setTimeout> | null = null;
76
+
77
+ constructor(
78
+ private emit: (event: InputEvent) => void,
79
+ private options: DecoderOptions = {},
80
+ ) {}
81
+
82
+ /** Feed raw input. Emits zero or more events. */
83
+ feed(chunk: string): void {
84
+ this.clearEscapeTimer();
85
+ this.carry += chunk;
86
+
87
+ for (;;) {
88
+ const consumed = this.step();
89
+ if (consumed === 0) break;
90
+ }
91
+
92
+ // A lone ESC is ambiguous until either more bytes arrive or time passes.
93
+ if (this.carry === ESC) {
94
+ this.escapeTimer = setTimeout(() => {
95
+ if (this.carry === ESC) {
96
+ this.carry = '';
97
+ this.emit(key('escape', { raw: ESC }));
98
+ }
99
+ }, this.options.escapeTimeoutMs ?? 30);
100
+ this.escapeTimer.unref?.();
101
+ }
102
+ }
103
+
104
+ private clearEscapeTimer(): void {
105
+ if (this.escapeTimer) {
106
+ clearTimeout(this.escapeTimer);
107
+ this.escapeTimer = null;
108
+ }
109
+ }
110
+
111
+ /** Consume one event's worth of bytes. Returns how many were used. */
112
+ private step(): number {
113
+ if (this.carry === '') return 0;
114
+
115
+ if (this.pasting) return this.stepPaste();
116
+
117
+ const first = this.carry[0] as string;
118
+
119
+ if (first !== ESC) return this.stepPlain();
120
+ if (this.carry.length === 1) return 0; // wait for more
121
+
122
+ const second = this.carry[1] as string;
123
+
124
+ if (second === '[') return this.stepCsi();
125
+ if (second === 'O') return this.stepSs3();
126
+
127
+ // ESC followed by a printable character is alt+that.
128
+ if (second !== ESC) {
129
+ const cp = this.carry.codePointAt(1) as number;
130
+ const char = String.fromCodePoint(cp);
131
+ const raw = ESC + char;
132
+ this.carry = this.carry.slice(raw.length);
133
+ /*
134
+ * A control byte after ESC is alt+that key - and only *also* ctrl when
135
+ * the byte has no name of its own.
136
+ *
137
+ * `alt+enter` arrives as ESC then 0x0d, and 0x0d is the enter key, not
138
+ * ctrl+m: reporting `ctrl` beside it filed the stroke as
139
+ * `ctrl+alt+enter`, which is not what anybody binds and not what the
140
+ * same key produces without the ESC. `stepPlain` has always had this
141
+ * rule - `ctrl: cp === 0x00` - and this path did not, so `alt+tab`,
142
+ * `alt+backspace` and `alt+enter` were all unreachable.
143
+ *
144
+ * 0x00 keeps it, because that one really is ctrl+space.
145
+ */
146
+ // 0x7f as well as 0x20 and below: that is the backspace most terminals
147
+ // send, and `stepPlain` has always counted it as a control. Leaving it
148
+ // out here made `alt+backspace` a key named "\x7f".
149
+ const control = cp < 0x20 || cp === 0x7f;
150
+ const named = control ? CTRL_NAMES[cp] : undefined;
151
+ this.emit(
152
+ control
153
+ ? key(named ?? String.fromCharCode(cp + 96), {
154
+ alt: true,
155
+ ...(named === undefined || cp === 0x00 ? { ctrl: true } : {}),
156
+ raw,
157
+ })
158
+ : key(char, { char, alt: true, raw, shift: char !== char.toLowerCase() }),
159
+ );
160
+ return raw.length;
161
+ }
162
+
163
+ // Two escapes: report the first as the escape key.
164
+ this.carry = this.carry.slice(1);
165
+ this.emit(key('escape', { raw: ESC }));
166
+ return 1;
167
+ }
168
+
169
+ private stepPlain(): number {
170
+ const cp = this.carry.codePointAt(0) as number;
171
+ const char = String.fromCodePoint(cp);
172
+
173
+ if (cp < 0x20 || cp === 0x7f) {
174
+ this.carry = this.carry.slice(char.length);
175
+ const named = CTRL_NAMES[cp];
176
+ if (named) {
177
+ /*
178
+ * 0x0a is `ctrl+enter`; 0x0d is enter.
179
+ *
180
+ * Both were named `enter` with no modifier, which made them the same
181
+ * key - and that is what a composer sees when it offers "enter sends,
182
+ * ctrl+enter is a newline" and the newline never comes. In raw mode
183
+ * the Return key sends CR: the kernel's CR-to-NL translation is off,
184
+ * so a bare LF is not Return, it is ctrl+Return (or ctrl+j, which is
185
+ * the same byte and therefore the same key - there is no encoding in
186
+ * which those two differ).
187
+ *
188
+ * Pasted newlines do not come through here: a bracketed paste is
189
+ * buffered whole by `stepPaste` and emitted as a paste event.
190
+ *
191
+ * 0x00 keeps its ctrl for the same reason it always had it: that byte
192
+ * really is ctrl+space.
193
+ */
194
+ this.emit(key(named, { raw: char, ctrl: cp === 0x00 || cp === 0x0a }));
195
+ } else {
196
+ // 0x01..0x1a are ctrl+a .. ctrl+z
197
+ const letter = String.fromCharCode(cp + 96);
198
+ this.emit(key(letter, { ctrl: true, raw: char }));
199
+ }
200
+ return char.length;
201
+ }
202
+
203
+ this.carry = this.carry.slice(char.length);
204
+ // Space is the one printable with a name, because it is the one printable
205
+ // people bind to. `KeyName` has always listed it and `Button`, `Checkbox`,
206
+ // `Switch` and `Select` have always tested for it - and none of them ever
207
+ // saw it, because a terminal sends 0x20 and this named it `' '`. The
208
+ // harness synthesised `'space'`, so every one of those tests passed while
209
+ // nothing worked. `char` stays `' '`, so typing one still types one.
210
+ const name = cp === 0x20 ? 'space' : char;
211
+ this.emit(key(name, { char, raw: char, shift: char !== char.toLowerCase() && char.toLowerCase() !== char.toUpperCase() }));
212
+ return char.length;
213
+ }
214
+
215
+ private stepPaste(): number {
216
+ const end = this.carry.indexOf(`${ESC}[201~`);
217
+ if (end === -1) {
218
+ this.pasteBuffer += this.carry;
219
+ const used = this.carry.length;
220
+ this.carry = '';
221
+ return used;
222
+ }
223
+ this.pasteBuffer += this.carry.slice(0, end);
224
+ const used = end + 6;
225
+ this.carry = this.carry.slice(used);
226
+ this.pasting = false;
227
+ const text = this.pasteBuffer;
228
+ this.pasteBuffer = '';
229
+ this.emit({ type: 'paste', text, handled: false });
230
+ return used;
231
+ }
232
+
233
+ /** `ESC O P` - the application-cursor form of F1..F4 and the arrows. */
234
+ private stepSs3(): number {
235
+ if (this.carry.length < 3) return 0;
236
+ const final = this.carry[2] as string;
237
+ const raw = this.carry.slice(0, 3);
238
+ this.carry = this.carry.slice(3);
239
+ const name = CSI_LETTERS[final];
240
+ if (name) this.emit(key(name, { raw }));
241
+ return 3;
242
+ }
243
+
244
+ private stepCsi(): number {
245
+ // Find the final byte: the first in the range @ to ~ after the parameters.
246
+ let i = 2;
247
+ while (i < this.carry.length) {
248
+ const code = this.carry.charCodeAt(i);
249
+ if (code >= 0x40 && code <= 0x7e) break;
250
+ i++;
251
+ }
252
+ if (i >= this.carry.length) return 0; // incomplete
253
+
254
+ const final = this.carry[i] as string;
255
+ const body = this.carry.slice(2, i);
256
+ const raw = this.carry.slice(0, i + 1);
257
+
258
+ // Bracketed paste start.
259
+ if (body === '200' && final === '~') {
260
+ this.carry = this.carry.slice(i + 1);
261
+ this.pasting = true;
262
+ this.pasteBuffer = '';
263
+ return raw.length;
264
+ }
265
+
266
+ // SGR mouse: CSI < b ; x ; y M|m
267
+ if (body.startsWith('<') && (final === 'M' || final === 'm')) {
268
+ this.carry = this.carry.slice(i + 1);
269
+ const event = decodeSgrMouse(body.slice(1), final);
270
+ if (event) this.emit(event);
271
+ return raw.length;
272
+ }
273
+
274
+ // Terminal focus in/out.
275
+ if (final === 'I' || final === 'O') {
276
+ this.carry = this.carry.slice(i + 1);
277
+ this.emit({ type: 'terminal-focus', focused: final === 'I' });
278
+ return raw.length;
279
+ }
280
+
281
+ this.carry = this.carry.slice(i + 1);
282
+ const params = body.split(';').map((p) => (p === '' ? undefined : Number.parseInt(p, 10)));
283
+
284
+ // Kitty keyboard: CSI codepoint ; modifiers u
285
+ if (final === 'u') {
286
+ const cp = params[0];
287
+ if (cp !== undefined) {
288
+ const mods = modifiers(params[1]);
289
+ const char = cp >= 0x20 ? String.fromCodePoint(cp) : undefined;
290
+ const named = CTRL_NAMES[cp];
291
+ this.emit(key(named ?? char ?? String(cp), { ...mods, char, raw }));
292
+ }
293
+ return raw.length;
294
+ }
295
+
296
+ if (final === '~') {
297
+ /*
298
+ * xterm's `modifyOtherKeys`: CSI 27 ; modifiers ; codepoint ~
299
+ *
300
+ * The *other* way a terminal can say `ctrl+enter`, and the one that was
301
+ * going straight in the bin: 27 is not in `CSI_NUMBERS`, so
302
+ * `CSI 27;5;13~` matched nothing, fell through every branch and the key
303
+ * did nothing at all - not "arrived as plain enter", nothing.
304
+ *
305
+ * It carries the same information as the kitty form with the parameters
306
+ * the other way round, so it decodes through the same rules. Terminals
307
+ * that will not do the kitty protocol often do this one, which makes it
308
+ * the difference between `ctrl+enter` existing and not.
309
+ */
310
+ if (params[0] === 27 && params[2] !== undefined) {
311
+ const cp = params[2];
312
+ const char = cp >= 0x20 ? String.fromCodePoint(cp) : undefined;
313
+ this.emit(key(CTRL_NAMES[cp] ?? char ?? String(cp), {
314
+ ...modifiers(params[1]),
315
+ char,
316
+ raw,
317
+ }));
318
+ return raw.length;
319
+ }
320
+
321
+ const name = CSI_NUMBERS[params[0] ?? 0];
322
+ if (name) this.emit(key(name, { ...modifiers(params[1]), raw }));
323
+ return raw.length;
324
+ }
325
+
326
+ const name = CSI_LETTERS[final];
327
+ if (name) {
328
+ if (final === 'Z') {
329
+ this.emit(key('tab', { shift: true, raw }));
330
+ } else {
331
+ // CSI 1 ; mod A
332
+ this.emit(key(name, { ...modifiers(params[1]), raw }));
333
+ }
334
+ }
335
+ return raw.length;
336
+ }
337
+
338
+ /** Drop any half-read sequence. After releasing the terminal. */
339
+ reset(): void {
340
+ this.clearEscapeTimer();
341
+ this.carry = '';
342
+ this.pasting = false;
343
+ this.pasteBuffer = '';
344
+ }
345
+ }
346
+
347
+ function decodeSgrMouse(body: string, final: string): MouseEvent | null {
348
+ const parts = body.split(';').map((p) => Number.parseInt(p, 10));
349
+ if (parts.length < 3) return null;
350
+ const [rawButton, col, row] = parts as [number, number, number];
351
+ if (!Number.isFinite(rawButton) || !Number.isFinite(col) || !Number.isFinite(row)) return null;
352
+
353
+ const shift = (rawButton & 4) !== 0;
354
+ const alt = (rawButton & 8) !== 0;
355
+ const ctrl = (rawButton & 16) !== 0;
356
+ const motion = (rawButton & 32) !== 0;
357
+ const wheel = (rawButton & 64) !== 0;
358
+ const code = rawButton & 3;
359
+
360
+ const base = {
361
+ type: 'mouse' as const,
362
+ x: col - 1,
363
+ y: row - 1,
364
+ ctrl, alt, shift,
365
+ handled: false,
366
+ };
367
+
368
+ if (wheel) {
369
+ return { ...base, action: 'wheel', button: 'none', wheel: code === 0 ? -1 : 1 };
370
+ }
371
+
372
+ const button = code === 0 ? 'left' : code === 1 ? 'middle' : code === 2 ? 'right' : 'none';
373
+
374
+ if (motion) {
375
+ return {
376
+ ...base,
377
+ action: button === 'none' ? 'move' : 'drag',
378
+ button: button as MouseEvent['button'],
379
+ };
380
+ }
381
+
382
+ return {
383
+ ...base,
384
+ action: final === 'M' ? 'down' : 'up',
385
+ button: button as MouseEvent['button'],
386
+ };
387
+ }
388
+
389
+ export function createDecoder(
390
+ emit: (event: InputEvent) => void,
391
+ options?: DecoderOptions,
392
+ ): InputDecoder {
393
+ return new InputDecoder(emit, options);
394
+ }