// lesson: parse-input
Decoding the Keyboard
The output direction (previous lesson) had a tidy standard. The input direction is messier, because what your program reads in raw mode is whatever bytes the terminal emulator decided to send for each key โ a dialect frozen by history, with real variation between terminals. Time to learn it cold, because your editor's first job every loop iteration is turning these bytes back into intent.
One byte: printable keys and the Ctrl story
Letters, digits, punctuation, space: one byte each, exactly the ASCII you expect (multi-byte UTF-8 for non-ASCII โ next lesson).
Ctrl+letter is where the teletype heritage shows. Terminals send
Ctrl+A through Ctrl+Z as bytes 0x01โ0x1A: the letter's ASCII value
with bits 6 and 5 stripped โ 'A' & 0x1F == 0x01. The Ctrl key
was literally a "zero the high bits" key on teletypes. This mapping
has consequences you must design around:
- Ctrl+M is 0x0D โ the same byte as Enter. You cannot tell them
apart. (
'M' & 0x1F == 13 == '\r'.) - Ctrl+I is 0x09 โ Tab. Same collision.
- Ctrl+[ is 0x1B โ Escape itself. That's why vim users remap Caps Lock: Ctrl+[ is ESC at the byte level.
- In raw mode (ICRNL off), Enter arrives as
\r(0x0D), not\n. Programs that forget this wait forever for a newline that never comes.
And the strangest resident of the one-byte world: Backspace sends 0x7F (DEL), not 0x08 (BS), on essentially every modern terminal. Byte 0x08 is what Ctrl+H sends. The reasons are pure archaeology (DEL was the "rub out a punch-tape mistake" character; the VT100 shipped its Backspace key sending DEL), and the upshot is a rule: treat both 0x7F and 0x08 as Backspace and nobody gets hurt.
Many bytes: the escape sequences
Keys that had no ASCII seat at the table send short escape sequences โ the same CSI grammar you've been writing, now arriving as input:
Up ESC [ A Home ESC [ H or ESC [ 1 ~ or ESC O H
Down ESC [ B End ESC [ F or ESC [ 4 ~ or ESC O F
Right ESC [ C Delete ESC [ 3 ~
Left ESC [ D PgUp ESC [ 5 ~
PgDn ESC [ 6 ~
Notice Home and End each have three spellings โ CSI-letter,
CSI-number-tilde, and ESC O letter (the VT100's "application mode",
called SS3). Which one you receive depends on the terminal and its
mode. A robust decoder simply accepts all of them; that's not
sloppiness, that's the actual job.
Modifier keys extend the grammar with a parameter: the encoding is
1 + bitmask where Shift=1, Alt=2, Ctrl=4. So Ctrl+Right arrives as
ESC [ 1 ; 5 C (5 = 1 + Ctrl's 4) and Shift+Alt+Up as
ESC [ 1 ; 4 A (4 = 1 + 1 + 2). Same shape for tilde keys:
Ctrl+Delete is ESC [ 3 ; 5 ~.
The ESC ambiguity โ the one genuinely hard part
The user presses the Escape key: you read byte 0x1B, alone.
The user presses Up: you read 0x1B, then [, then A โ but
possibly split across read() calls, with 0x1B arriving alone in
the first one!
At the moment 0x1B lands in your buffer, "Escape was pressed" and
"an arrow key's first byte arrived" are indistinguishable. No
amount of cleverness fixes this; the information simply isn't there
yet. Every terminal program resolves it the same way: wait a few
milliseconds. If more bytes follow immediately, it was a sequence
(machines are fast); if silence follows, it was the Escape key
(humans are slow). That's what vim's ttimeoutlen option tunes, and
it's why Escape feels ever-so-slightly laggy in some tools.
Related: terminals encode Alt+x by prefixing the key with ESC โ
Alt+f sends ESC f. Your decoder gets that for free once it treats
"ESC followed by a non-sequence byte" as Alt+byte.
This shapes the decoder's interface. Rather than reading the fd itself (unmockable, untestable), the decoder is a pure function over a byte buffer:
size_t key_decode(const unsigned char *buf, size_t len,
struct key_event *out);
Return how many bytes you consumed; return 0 to mean "I can't
decide yet โ bring more bytes (or a timeout)". The event loop owns
the fd, the timing, and the buffer; the decoder owns the grammar.
The tests can then feed any byte pattern, including pathological
splits, without a terminal in sight. (Two modern extensions worth
knowing exist โ the kitty keyboard protocol, which fixes the
ambiguity properly, and bracketed paste, which brackets pasted text
in ESC[200~/ESC[201~ so a pasted :wq! can't execute โ both are
opt-in CSI modes and out of our scope.)
โบ Decode Key Events
25 ptsImplement key_decode with this contract:
- Returns the number of bytes consumed for one complete key event
stored in
*out, or 0 ifbufholds an incomplete sequence (starts with ESC but needs more bytes to classify). len == 0โ return 0.- One-byte keys: printable bytes (0x20โ0x7E) and bytes โฅ 0x80 โ
KEY_CHARwithvalue= the byte.\rโKEY_ENTER.\tโKEY_CHARvalue'\t'. 0x7F and 0x08 โKEY_BACKSPACE. Remaining bytes 0x01โ0x1A โKEY_CTRLwithvalue= the letter ('A'for 0x01 โฆ'Z'for 0x1A; so 0x03 โ'C'). Byte 0x00 โKEY_UNKNOWN, consume 1. - ESC sequences (
buf[0] == 0x1B):len == 1โ return 0 (can't decide โ the caller's timeout will decide it was the Escape key and synthesizeKEY_ESCAPE).ESC [ A/B/C/Dโ arrow keys.ESC [ H/ESC [ Fโ Home/End.ESC [ <digits> ~โ 1/7=Home, 4/8=End, 3=Delete, 5=PageUp, 6=PageDown; other numbers โKEY_UNKNOWN(consume the whole sequence!).- Modifier form
ESC [ 1 ; <m> <letter>andESC [ <digits> ; <m> ~: decode the key as above and setmods=m - 1(bit 0 Shift, bit 1 Alt, bit 2 Ctrl). - Incomplete CSI (e.g.
ESC [alone,ESC [ 5with no final byte yet) โ return 0. ESC O H/F/P/Q/R/Sโ Home, End, F1โF4 (map F1โF4 toKEY_UNKNOWNโ we don't use them โ but consume 3 bytes).ESC <other byte>โKEY_ALTwithvalue= that byte, consume 2.
- Unrecognized-but-complete CSI sequences must be consumed in full
and reported as
KEY_UNKNOWNโ a decoder that consumes the wrong number of bytes poisons every key after it.
Log in to submit a solution and earn points.