// 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 pts

Implement key_decode with this contract:

  • Returns the number of bytes consumed for one complete key event stored in *out, or 0 if buf holds 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_CHAR with value = the byte. \r โ†’ KEY_ENTER. \t โ†’ KEY_CHAR value '\t'. 0x7F and 0x08 โ†’ KEY_BACKSPACE. Remaining bytes 0x01โ€“0x1A โ†’ KEY_CTRL with value = 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 synthesize KEY_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> and ESC [ <digits> ; <m> ~: decode the key as above and set mods = m - 1 (bit 0 Shift, bit 1 Alt, bit 2 Ctrl).
    • Incomplete CSI (e.g. ESC [ alone, ESC [ 5 with no final byte yet) โ†’ return 0.
    • ESC O H/F/P/Q/R/S โ†’ Home, End, F1โ€“F4 (map F1โ€“F4 to KEY_UNKNOWN โ€” we don't use them โ€” but consume 3 bytes).
    • ESC <other byte> โ†’ KEY_ALT with value = 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.