@uniflowed/tui 0.0.0-alpha.18

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/keys.js ADDED
@@ -0,0 +1,585 @@
1
+ // @flow
2
+ //
3
+ // Terminal bytes in, key events out.
4
+ //
5
+ // A terminal does not deliver keys. It delivers a byte stream in which most
6
+ // keys are one byte, some are two, and the interesting ones are escape
7
+ // sequences of four to nine bytes whose grammar predates every convention a
8
+ // reader would guess from. `Escape` and `Alt+A` begin with the same byte.
9
+ // `Enter` is `\r` under raw mode and `\n` when it is not. The up arrow is
10
+ // `ESC [ A` on one terminal and `ESC O A` on another, depending on a mode the
11
+ // application itself may have set.
12
+ //
13
+ // This module is the whole of that grammar, and it is a pure function from a
14
+ // string to events so that a test can press a key without a terminal. Every
15
+ // input test in `tui.test.js` goes through here, which is what makes them
16
+ // tests of the real path rather than tests of a fake one.
17
+ //
18
+ // # One stream, two kinds of event
19
+ //
20
+ // A terminal with mouse reporting on writes its reports into the same stream,
21
+ // as escape sequences that are not keys. So the decoder's output is a union:
22
+ // {@link InputEvent} is a key or a mouse report, told apart by `kind`, and
23
+ // `mouse.js` holds the half this module does not. Splitting them at the byte
24
+ // level rather than after the fact is not a preference — a mouse report and
25
+ // `Alt+[` begin with the same two bytes, and a decoder that guessed later
26
+ // would have had to un-decode a key it had already emitted.
27
+ //
28
+ // # The names are OpenTUI's
29
+ //
30
+ // `"return"`, not `"enter"`. `"escape"`, not `"esc"`. Those are the canonical
31
+ // names OpenTUI's `KeyEvent.name` uses, and a component written against its
32
+ // documentation compares against them — so a handler that reads
33
+ // `key.name === "return"` behaves identically under either library. The
34
+ // aliases people actually type (`"enter"`, `"esc"`) are accepted where uf
35
+ // takes a key *binding* from a caller, and normalised there rather than here,
36
+ // so there is exactly one spelling in an event.
37
+ //
38
+ // # Propagation is OpenTUI's too, including the part that looks wrong
39
+ //
40
+ // `stopPropagation()` stops later global listeners *and* prevents the focused
41
+ // node from seeing the event. `preventDefault()` does the opposite half: later
42
+ // global listeners still run, but the focused node is skipped. They are not a
43
+ // pair of ordered severities, they are two independent answers to two
44
+ // questions — "does anyone else get to see this" and "does the thing that has
45
+ // focus act on it" — and conflating them is what makes a global `Ctrl+C`
46
+ // handler either unreachable or unable to stop a text input from inserting a
47
+ // character.
48
+
49
+ import type { MouseEvent } from "./mouse.js";
50
+ import { LEGACY_REPORT_LENGTH, decodeMouse, legacyReportLength } from "./mouse.js";
51
+
52
+ /** Which of the two parsers produced an event. */
53
+ export type KeySource = "raw" | "escape";
54
+
55
+ /**
56
+ * One key press.
57
+ *
58
+ * `sequence` is the text the key stands for and `raw` is the bytes it arrived
59
+ * as; they differ for every key that is not a printable character, and a
60
+ * handler that inserts `sequence` into a buffer rather than `raw` is the
61
+ * difference between typing `a` and typing `^[[A`.
62
+ */
63
+ export type KeyEvent = {
64
+ /**
65
+ * Which of the two things a terminal's byte stream carries.
66
+ *
67
+ * A stream holds keys and, when mouse reporting is on, mouse reports. This
68
+ * is what tells them apart, and it is on the event rather than inferred from
69
+ * the presence of a field so that a `switch` over it is exhaustive.
70
+ */
71
+ readonly kind: "key",
72
+ /**
73
+ * The canonical name: `"a"`, `"space"`, `"return"`, `"escape"`, `"up"`.
74
+ *
75
+ * `"paste"` is the one name that is not a key. A terminal in bracketed
76
+ * paste mode wraps pasted text in `ESC[200~` and `ESC[201~` so that an
77
+ * application can tell it from typing, and the whole point of knowing is to
78
+ * treat it as *text* — so it arrives as one event carrying all of it rather
79
+ * than as the burst of key presses it would otherwise look like.
80
+ */
81
+ readonly name: string,
82
+ /** The text this key stands for, empty for keys that stand for none. */
83
+ readonly sequence: string,
84
+ /** The bytes as they arrived. */
85
+ readonly raw: string,
86
+ /** Which parser produced it. */
87
+ readonly source: KeySource,
88
+ readonly ctrl: boolean,
89
+ readonly shift: boolean,
90
+ readonly meta: boolean,
91
+ /** Always `"press"`. Release reporting needs the Kitty protocol; see #314. */
92
+ readonly eventType: "press",
93
+ /** Skip the focused node's handler, without silencing later global ones. */
94
+ preventDefault(): void,
95
+ /** Silence later global handlers, and the focused node's. */
96
+ stopPropagation(): void,
97
+ /** Whether `preventDefault()` was called. */
98
+ defaultPrevented: boolean,
99
+ /** Whether `stopPropagation()` was called. */
100
+ propagationStopped: boolean,
101
+ };
102
+
103
+ /**
104
+ * One thing that arrived from a terminal.
105
+ *
106
+ * Everything a driver reads is one of these two, and `kind` is how a caller
107
+ * tells them apart without a type test on a field that might one day exist on
108
+ * both.
109
+ */
110
+ export type InputEvent = KeyEvent | MouseEvent;
111
+
112
+ const ESC = "\u001b";
113
+
114
+ /** What a terminal in bracketed paste mode puts around pasted text. */
115
+ const PASTE_START = "\u001b[200~";
116
+ const PASTE_END = "\u001b[201~";
117
+
118
+ /** What an SGR-1006 mouse report begins with; `mouse.js` has the rest. */
119
+ const MOUSE_SGR = "\u001b[<";
120
+
121
+ /** What a terminal that ignored `?1006h` begins one with instead. */
122
+ const MOUSE_LEGACY = "\u001b[M";
123
+
124
+ /** The `CSI …` final bytes that name a key on their own. */
125
+ const CSI_FINAL: { [string]: string } = {
126
+ A: "up",
127
+ B: "down",
128
+ C: "right",
129
+ D: "left",
130
+ H: "home",
131
+ F: "end",
132
+ E: "clear",
133
+ P: "f1",
134
+ Q: "f2",
135
+ R: "f3",
136
+ S: "f4",
137
+ Z: "tab",
138
+ };
139
+
140
+ /** The `CSI n ~` numbers, which is the other half of the same vocabulary. */
141
+ const CSI_TILDE: { [string]: string } = {
142
+ "1": "home",
143
+ "2": "insert",
144
+ "3": "delete",
145
+ "4": "end",
146
+ "5": "pageup",
147
+ "6": "pagedown",
148
+ "11": "f1",
149
+ "12": "f2",
150
+ "13": "f3",
151
+ "14": "f4",
152
+ "15": "f5",
153
+ "17": "f6",
154
+ "18": "f7",
155
+ "19": "f8",
156
+ "20": "f9",
157
+ "21": "f10",
158
+ "23": "f11",
159
+ "24": "f12",
160
+ };
161
+
162
+ /** Build an event with its two propagation flags wired up. */
163
+ function event(fields: {
164
+ name: string,
165
+ sequence: string,
166
+ raw: string,
167
+ source: KeySource,
168
+ ctrl?: boolean,
169
+ shift?: boolean,
170
+ meta?: boolean,
171
+ }): KeyEvent {
172
+ const key: KeyEvent = {
173
+ kind: "key",
174
+ name: fields.name,
175
+ sequence: fields.sequence,
176
+ raw: fields.raw,
177
+ source: fields.source,
178
+ ctrl: fields.ctrl === true,
179
+ shift: fields.shift === true,
180
+ meta: fields.meta === true,
181
+ eventType: "press",
182
+ defaultPrevented: false,
183
+ propagationStopped: false,
184
+ preventDefault() {
185
+ key.defaultPrevented = true;
186
+ },
187
+ stopPropagation() {
188
+ key.propagationStopped = true;
189
+ },
190
+ };
191
+ return key;
192
+ }
193
+
194
+ /**
195
+ * Decode the `1 + shift + 2·alt + 4·ctrl` parameter terminals encode
196
+ * modifiers in.
197
+ *
198
+ * The offset of one is not decoration: a parameter of zero means "absent" in
199
+ * the CSI grammar, so the unmodified case has to be one and every modifier
200
+ * combination is that plus a bit mask.
201
+ */
202
+ function modifiers(parameter: string | void): { ctrl: boolean, shift: boolean, meta: boolean } {
203
+ const value = Number.parseInt(parameter ?? "1", 10);
204
+ const bits = Number.isFinite(value) && value > 0 ? value - 1 : 0;
205
+ return { shift: (bits & 1) !== 0, meta: (bits & 2) !== 0, ctrl: (bits & 4) !== 0 };
206
+ }
207
+
208
+ /**
209
+ * One pasted block, as the event a handler sees.
210
+ *
211
+ * The payload is whatever was on the clipboard, given back verbatim and
212
+ * therefore **untrusted**: it can hold newlines, control bytes and escape
213
+ * sequences of its own. Handing it over as text rather than as keys is the
214
+ * entire purpose of bracketed paste — a terminal without it delivers a pasted
215
+ * `\r` as Enter, which is how pasting a two-line command into a prompt runs
216
+ * the first line. What a component does with the text is its own decision;
217
+ * `Input` takes the first line and drops the control characters, because it is
218
+ * one line of text and cannot hold either.
219
+ */
220
+ function pasteEvent(text: string, raw: string): KeyEvent {
221
+ return event({ name: "paste", sequence: text, raw, source: "escape" });
222
+ }
223
+
224
+ /**
225
+ * A decoder that survives a sequence arriving in pieces.
226
+ *
227
+ * {@link decodeInput} is a pure function of one chunk, which is right for
228
+ * every key: a terminal delivers a key's escape sequence in a single read, and
229
+ * `ESC` at the end of a chunk is the Escape key. Two things a terminal sends
230
+ * are not keys and do not keep that promise — a paste, which is as long as the
231
+ * clipboard, and a mouse report, which a terminal in any-motion mode sends one
232
+ * of per cell the pointer crosses. The operating system splits either wherever
233
+ * it likes. So a driver reading a real stream holds one of these across
234
+ * chunks.
235
+ *
236
+ * # The one rule, and what bounds it
237
+ *
238
+ * `push` holds back a trailing run of bytes that **cannot be anything but the
239
+ * beginning of a sequence this decoder must see whole** — see `incomplete`,
240
+ * which is the whole of that judgement and the only place it is made. Nothing
241
+ * else is buffered: a chunk that ends anywhere else is decoded completely,
242
+ * because every other sequence a terminal sends either fits in a read or is
243
+ * ambiguous with a key that must fire now.
244
+ *
245
+ * A held run is released by exactly two things. The next chunk completes it,
246
+ * or {@link InputDecoder.flush} says no next chunk is coming and the bytes are
247
+ * decoded as they stand. And a run that grows past {@link HOLD_LIMIT} without
248
+ * completing is not one of these sequences however it began, so it is decoded
249
+ * rather than held — which is what keeps a terminal emitting nonsense from
250
+ * wedging the decoder even where nobody calls `flush`.
251
+ */
252
+ export type InputDecoder = {
253
+ /** Decode one chunk, holding back a sequence that has not ended yet. */
254
+ push(chunk: string): Array<InputEvent>,
255
+ /** Give up on an unfinished sequence and emit what arrived. */
256
+ flush(): Array<InputEvent>,
257
+ };
258
+
259
+ /**
260
+ * The longest run of bytes this decoder will hold waiting for the rest of it.
261
+ *
262
+ * Every sequence `incomplete` waits for is shorter: the paste introducer is
263
+ * six bytes, an old-style mouse report is six, and an SGR report is
264
+ * `ESC [ <` plus three decimal parameters, two semicolons and a final byte —
265
+ * nineteen bytes for coordinates larger than any terminal has. Past this, the
266
+ * bytes are not the sequence they looked like and are decoded as what they
267
+ * are.
268
+ *
269
+ * The bound is what makes the hold safe without a timer. `flush` is the
270
+ * ordinary way out and a driver calls it when its stream ends; this is for the
271
+ * case where nothing ever calls it and the terminal has sent `ESC [ <` and
272
+ * then a thousand digits.
273
+ */
274
+ const HOLD_LIMIT = 32;
275
+
276
+ /**
277
+ * Whether the bytes from `start` begin a sequence whose rest has not arrived.
278
+ *
279
+ * The one buffering rule. It answers for the three sequences a read boundary
280
+ * can fall inside and this decoder would otherwise mis-decode:
281
+ *
282
+ * * `ESC [ 2 0 0 ~`, the paste introducer, where a split turns the marker
283
+ * into Alt-and-a-bracket followed by three digits and the paste's first
284
+ * line then runs as typing;
285
+ * * `ESC [ <` …, an SGR mouse report, where a split turns a click into the
286
+ * characters of its coordinates — ubugeeei-prod/uf#612, and the traffic
287
+ * `?1003h` produces is a report per cell crossed, which is the traffic
288
+ * most likely to be split;
289
+ * * `ESC [ M` …, the old-style report, whose three payload bytes are
290
+ * arbitrary and become arbitrary keys.
291
+ *
292
+ * Two bytes at least, so a lone `ESC` is still the Escape key: holding that
293
+ * back would mean Escape never fires until the next keystroke, which is worse
294
+ * than the ambiguity it would solve. From `ESC[` on there is nothing else the
295
+ * bytes could be that this decoder would get right anyway — an unfinished CSI
296
+ * at the end of a chunk decodes as Alt and a bracket.
297
+ *
298
+ * It is deliberately *not* asked about a complete sequence with more input
299
+ * after it. A run is held only when it reaches the end of the chunk, which is
300
+ * what "the rest has not arrived" means; a report followed by a keystroke has
301
+ * a final byte and is decoded where it stands.
302
+ */
303
+ function incomplete(input: string, start: number): boolean {
304
+ const rest = input.slice(start);
305
+ if (rest.length < 2 || rest.length > HOLD_LIMIT || !rest.startsWith(ESC)) {
306
+ return false;
307
+ }
308
+ if (rest.length < PASTE_START.length && PASTE_START.startsWith(rest)) {
309
+ return true;
310
+ }
311
+ if (rest.startsWith(MOUSE_SGR)) {
312
+ // Complete when the final byte has arrived, and no longer a report at all
313
+ // once something that is not a parameter byte has: `decodeMouse` answers
314
+ // `null` for that and the bytes fall through to the key grammar, which is
315
+ // where they should fall through rather than being waited on for ever.
316
+ const parameters = rest.slice(MOUSE_SGR.length);
317
+ return /^[0-9;]*$/.test(parameters);
318
+ }
319
+ if (rest.startsWith(MOUSE_LEGACY)) {
320
+ return rest.length < LEGACY_REPORT_LENGTH;
321
+ }
322
+ return false;
323
+ }
324
+
325
+ /** A decoder with somewhere to keep a half-arrived sequence. */
326
+ export function createInputDecoder(): InputDecoder {
327
+ let pending: string | null = null;
328
+ /** A chunk that ended part-way through a sequence; see `incomplete`. */
329
+ let waiting = "";
330
+
331
+ const decoder: InputDecoder = {
332
+ push(chunk: string): Array<InputEvent> {
333
+ const events: Array<InputEvent> = [];
334
+ let input = waiting + chunk;
335
+ waiting = "";
336
+ if (pending != null) {
337
+ const end = input.indexOf(PASTE_END);
338
+ if (end < 0) {
339
+ pending += input;
340
+ return events;
341
+ }
342
+ const text = pending + input.slice(0, end);
343
+ pending = null;
344
+ events.push(pasteEvent(text, PASTE_START + text + PASTE_END));
345
+ input = input.slice(end + PASTE_END.length);
346
+ }
347
+
348
+ let index = 0;
349
+ while (index < input.length) {
350
+ if (incomplete(input, index)) {
351
+ waiting = input.slice(index);
352
+ return events;
353
+ }
354
+ if (input.startsWith(PASTE_START, index)) {
355
+ const from = index + PASTE_START.length;
356
+ const end = input.indexOf(PASTE_END, from);
357
+ if (end < 0) {
358
+ pending = input.slice(from);
359
+ return events;
360
+ }
361
+ const text = input.slice(from, end);
362
+ events.push(pasteEvent(text, PASTE_START + text + PASTE_END));
363
+ index = end + PASTE_END.length;
364
+ continue;
365
+ }
366
+ index += decodeOne(input, index, events);
367
+ }
368
+ return events;
369
+ },
370
+ flush(): Array<InputEvent> {
371
+ if (waiting !== "") {
372
+ // Not the sequence it looked like after all: no more input is coming,
373
+ // so the bytes are whatever they decode to on their own.
374
+ const held = waiting;
375
+ waiting = "";
376
+ const events: Array<InputEvent> = [];
377
+ let index = 0;
378
+ while (index < held.length) {
379
+ index += decodeOne(held, index, events);
380
+ }
381
+ return events;
382
+ }
383
+ if (pending == null) {
384
+ return [];
385
+ }
386
+ const text = pending;
387
+ pending = null;
388
+ return [pasteEvent(text, PASTE_START + text)];
389
+ },
390
+ };
391
+ return decoder;
392
+ }
393
+
394
+ /**
395
+ * Everything in a chunk of terminal input: keys, and mouse reports.
396
+ *
397
+ * A chunk is not a key. Holding a key down, pasting, or simply typing fast
398
+ * delivers several at once, and a decoder that returns the first and drops the
399
+ * rest loses characters under exactly the conditions — fast typing — where
400
+ * losing them is most obvious.
401
+ *
402
+ * One chunk, decoded completely. A sequence this chunk begins and does not end
403
+ * is not held, because there is no later chunk for a pure function to wait for:
404
+ * an unfinished paste is emitted as the paste it was becoming, and an
405
+ * unfinished mouse report is decoded as the bytes it is. A driver reading a
406
+ * stream wants {@link createInputDecoder} instead, which holds them.
407
+ */
408
+ export function decodeInput(input: string): Array<InputEvent> {
409
+ const decoder = createInputDecoder();
410
+ return [...decoder.push(input), ...decoder.flush()];
411
+ }
412
+
413
+ /**
414
+ * The key events in a chunk, with any mouse reports left out.
415
+ *
416
+ * The narrow view, for a caller that has not turned mouse reporting on and
417
+ * therefore cannot receive one — which is every caller of this function until
418
+ * an application asks `render` for the mouse. A caller that has wants
419
+ * {@link decodeInput}, because dropping half of what a terminal said is a
420
+ * poor way to find out it was said.
421
+ */
422
+ export function decodeKeys(input: string): Array<KeyEvent> {
423
+ const keys: Array<KeyEvent> = [];
424
+ for (const event of decodeInput(input)) {
425
+ if (event.kind === "key") {
426
+ keys.push(event);
427
+ }
428
+ }
429
+ return keys;
430
+ }
431
+
432
+ function decodeOne(input: string, start: number, events: Array<InputEvent>): number {
433
+ const character = input[start];
434
+
435
+ if (character !== ESC) {
436
+ events.push(decodePlain(character));
437
+ return 1;
438
+ }
439
+
440
+ // `ESC` with nothing after it is the Escape key. This is the ambiguity at
441
+ // the centre of terminal input: the same byte begins `Alt+A` and every
442
+ // arrow key, and the only thing that distinguishes them is what follows in
443
+ // the *same read*. A terminal delivers a real escape sequence as one chunk,
444
+ // so "nothing follows in this chunk" is the signal — imperfect, and the
445
+ // reason `Escape` is felt as slightly laggy in every terminal program ever
446
+ // written.
447
+ const next = input[start + 1];
448
+ if (next === undefined) {
449
+ events.push(event({ name: "escape", sequence: "", raw: ESC, source: "raw" }));
450
+ return 1;
451
+ }
452
+
453
+ // A mouse report, before the key grammar gets a look at it. `ESC[<` is not
454
+ // reachable as a key — the CSI parameter bytes are digits and semicolons —
455
+ // so this branch takes nothing away from the one below it.
456
+ if (next === "[" && input[start + 2] === "<") {
457
+ const report = decodeMouse(input, start);
458
+ if (report != null) {
459
+ events.push(report.event);
460
+ return report.length;
461
+ }
462
+ }
463
+
464
+ // The report a terminal sends when it did not understand `?1006h`. Consumed
465
+ // and dropped: `mouse.js` says why decoding it would be worse, and why
466
+ // letting its three payload bytes through as keys would be worse still.
467
+ if (next === "[") {
468
+ const legacy = legacyReportLength(input, start);
469
+ if (legacy > 0) {
470
+ return legacy;
471
+ }
472
+ }
473
+
474
+ if (next === "[" || next === "O") {
475
+ const parsed = decodeSequence(input, start);
476
+ if (parsed != null) {
477
+ events.push(parsed.key);
478
+ return parsed.length;
479
+ }
480
+ }
481
+
482
+ // `ESC` followed by anything else is that key with Alt held.
483
+ const inner = decodePlain(next);
484
+ events.push(
485
+ event({
486
+ name: inner.name,
487
+ sequence: inner.sequence,
488
+ raw: ESC + next,
489
+ source: "escape",
490
+ ctrl: inner.ctrl,
491
+ shift: inner.shift,
492
+ meta: true,
493
+ }),
494
+ );
495
+ return 2;
496
+ }
497
+
498
+ function decodeSequence(input: string, start: number): { key: KeyEvent, length: number } | null {
499
+ const introducer = input[start + 1];
500
+ // `ESC O x` — the "application cursor keys" form. Same keys, different
501
+ // spelling, chosen by a mode the terminal may be in for reasons that have
502
+ // nothing to do with this program.
503
+ if (introducer === "O") {
504
+ const final = input[start + 2];
505
+ const name = final != null ? CSI_FINAL[final] : undefined;
506
+ if (name == null) {
507
+ return null;
508
+ }
509
+ const raw = input.slice(start, start + 3);
510
+ return { key: event({ name, sequence: "", raw, source: "escape" }), length: 3 };
511
+ }
512
+
513
+ let cursor = start + 2;
514
+ let parameters = "";
515
+ while (cursor < input.length && /[0-9;]/.test(input[cursor])) {
516
+ parameters += input[cursor];
517
+ cursor += 1;
518
+ }
519
+ const final = input[cursor];
520
+ if (final === undefined) {
521
+ return null;
522
+ }
523
+ const raw = input.slice(start, cursor + 1);
524
+ const [first, second] = parameters.split(";");
525
+
526
+ if (final === "~") {
527
+ const name = CSI_TILDE[first];
528
+ if (name == null) {
529
+ return null;
530
+ }
531
+ const mods = modifiers(second);
532
+ return {
533
+ key: event({ name, sequence: "", raw, source: "escape", ...mods }),
534
+ length: raw.length,
535
+ };
536
+ }
537
+
538
+ const name = CSI_FINAL[final];
539
+ if (name == null) {
540
+ return null;
541
+ }
542
+ // `CSI Z` is Shift+Tab, and it carries no modifier parameter to say so.
543
+ const mods = final === "Z" ? { ctrl: false, shift: true, meta: false } : modifiers(second);
544
+ return { key: event({ name, sequence: "", raw, source: "escape", ...mods }), length: raw.length };
545
+ }
546
+
547
+ /** One byte that is not part of an escape sequence. */
548
+ function decodePlain(character: string): KeyEvent {
549
+ const code = character.charCodeAt(0);
550
+
551
+ if (character === "\r" || character === "\n") {
552
+ return event({ name: "return", sequence: "\r", raw: character, source: "raw" });
553
+ }
554
+ if (character === "\t") {
555
+ return event({ name: "tab", sequence: "\t", raw: character, source: "raw" });
556
+ }
557
+ if (character === " ") {
558
+ return event({ name: "space", sequence: " ", raw: character, source: "raw" });
559
+ }
560
+ // Both spellings of Backspace. Which one arrives depends on the terminal's
561
+ // `erase` setting, and a program that handles only `\x7f` is a program whose
562
+ // backspace key does nothing on somebody else's machine.
563
+ if (code === 0x7f || code === 0x08) {
564
+ return event({ name: "backspace", sequence: "", raw: character, source: "raw" });
565
+ }
566
+ if (code === 0) {
567
+ return event({ name: "space", sequence: "", raw: character, source: "raw", ctrl: true });
568
+ }
569
+ if (code < 0x20) {
570
+ // A C0 control is Ctrl plus the letter at that position in the alphabet.
571
+ const letter = String.fromCharCode(code + 0x60);
572
+ return event({ name: letter, sequence: "", raw: character, source: "raw", ctrl: true });
573
+ }
574
+ // A printable character. `shift` is reported for an uppercase letter because
575
+ // that is the only evidence a terminal gives: there is no separate shift
576
+ // report outside the Kitty protocol, and `A` is what Shift+A means.
577
+ const lower = character.toLowerCase();
578
+ return event({
579
+ name: lower,
580
+ sequence: character,
581
+ raw: character,
582
+ source: "raw",
583
+ shift: character !== lower,
584
+ });
585
+ }