@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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/ansi.d.ts +84 -0
- package/dist/ansi.d.ts.map +1 -0
- package/dist/ansi.js +102 -0
- package/dist/capabilities.d.ts +22 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +130 -0
- package/dist/capture.d.ts +24 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +72 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/input.d.ts +28 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +357 -0
- package/dist/node.d.ts +99 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +233 -0
- package/dist/svg.d.ts +74 -0
- package/dist/svg.d.ts.map +1 -0
- package/dist/svg.js +235 -0
- package/dist/virtual.d.ts +65 -0
- package/dist/virtual.d.ts.map +1 -0
- package/dist/virtual.js +137 -0
- package/dist/writer.d.ts +19 -0
- package/dist/writer.d.ts.map +1 -0
- package/dist/writer.js +173 -0
- package/package.json +62 -0
- package/src/ansi.ts +120 -0
- package/src/capabilities.ts +140 -0
- package/src/capture.ts +101 -0
- package/src/index.ts +17 -0
- package/src/input.ts +394 -0
- package/src/node.ts +329 -0
- package/src/svg.ts +352 -0
- package/src/virtual.ts +185 -0
- package/src/writer.ts +202 -0
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
|
+
}
|