Input Encoding: Keys to Bytes
The input encoder is the mirror of the parser. The parser turns bytes into meaning; the encoder turns
meaning into bytes. It is the component that decides that the Up arrow is \x1b[A — except when the
program has set DECCKM, when it is \x1bOA.
It is also where the most compatibility bugs live, because there is no single standard: there is the
VT100 lineage, the VT220 lineage, xterm's extensions, modifyOtherKeys, the kitty keyboard protocol,
and forty years of programs written against whichever one they encountered.
Physical Keys vs. Logical Keys vs. Text
winit (and every modern windowing system) gives you three different things per key press, and
using the wrong one is the most common frontend bug.
Pressing the key labeled "A" on a US keyboard, with Shift:
PHYSICAL KEY KeyCode::KeyA
← the physical position. SAME on every layout.
← use for: Ctrl+key shortcuts, WASD-style bindings
LOGICAL KEY Key::Character("A")
← what the key means with the current layout+modifiers
← use for: deciding "this is the letter A"
TEXT "A"
← the committed text, after IME and dead keys
← use for: inserting characters
The rules for a terminal:
| Input | Use | Because |
|---|---|---|
| Ordinary characters | text | Handles layouts, dead keys (´ + e = é), and IME correctly |
| Ctrl+letter | logical key (fall back to physical) | Ctrl+C produces no text on most platforms; you must derive 0x03 yourself |
| Alt/Option+letter | logical key | On macOS, Option+B produces the text ∫; you usually want ESC b |
| Arrows, F-keys, Home/End | logical key (named keys) | They produce no text at all |
| Enter, Tab, Backspace, Escape | logical key | They may produce text (\r, \t) but you need the key, and the byte differs from the text |
Warning: A frontend that uses only
textcannot send Ctrl+C. A frontend that uses only physical keys is broken on Dvorak, AZERTY, and every non-US layout, and cannot do IME at all. You need both, and the dispatch order matters: check named keys first, then modifier combinations, then fall through to text.
#![allow(unused)] fn main() { fn on_key(&mut self, event: &KeyEvent) { if event.state != ElementState::Pressed { return; } // terminals send on press only // 1. Named keys (arrows, F-keys, Enter, Tab, Backspace, Escape, ...). if let Key::Named(named) = &event.logical_key { if let Some(bytes) = encode_named(*named, self.mods, self.term.modes()) { self.pty.write_all(&bytes); return; } } // 2. Ctrl / Alt combinations with a character. These produce no usable text // on most platforms, so we derive the bytes ourselves. if let Key::Character(s) = &event.logical_key { if self.mods.ctrl || self.mods.alt { if let Some(bytes) = encode_char_with_modifiers(s, self.mods, self.term.modes()) { self.pty.write_all(&bytes); return; } } } // 3. Plain text. This path handles layouts, dead keys, and IME output. if let Some(text) = &event.text { self.pty.write_all(text.as_bytes()); } } }
The Control-Key Rule
Ctrl + <char> = <uppercase char> & 0x1F
Ctrl+A → 0x01 Ctrl+I → 0x09 (= Tab!) Ctrl+[ → 0x1b (= Escape!)
Ctrl+B → 0x02 Ctrl+J → 0x0a (= LF) Ctrl+\ → 0x1c
Ctrl+C → 0x03 Ctrl+M → 0x0d (= Enter!) Ctrl+] → 0x1d
Ctrl+D → 0x04 Ctrl+Z → 0x1a Ctrl+^ → 0x1e
Ctrl+H → 0x08 (= Backspace) Ctrl+_ → 0x1f
Ctrl+Space / Ctrl+@ → 0x00 (NUL)
Ctrl+? → 0x7f (DEL)
These are not analogous, they are identical bytes. Ctrl+M and Enter are indistinguishable to
the program. That is why vim cannot bind Ctrl+M separately from Enter, and it is a fact worth
knowing before someone files it as a bug against your terminal.
#![allow(unused)] fn main() { fn ctrl_byte(c: char) -> Option<u8> { match c { ' ' | '@' => Some(0x00), 'a'..='z' => Some(c as u8 - b'a' + 1), 'A'..='Z' => Some(c as u8 - b'A' + 1), '[' => Some(0x1b), '\\' => Some(0x1c), ']' => Some(0x1d), '^' => Some(0x1e), '_' => Some(0x1f), '?' => Some(0x7f), _ => None, } } }
The Alt/Meta Rule
Two historical conventions:
ESC PREFIX (the modern default): Alt+B → 0x1b 0x62
8-BIT META (legacy): Alt+B → 0xe2 (0x62 | 0x80)
Use the ESC prefix. 8-bit meta is incompatible with UTF-8 — 0xe2 is a valid UTF-8 lead byte and
would be decoded as part of a character.
macOS complication: the Option key is a composing modifier by default (Option+B gives ∫).
Every macOS terminal has an "Option as Meta" setting for exactly this reason. Provide it, default it
to on for the left Option key, and document the choice.
Special Keys: The Two Lineages
| Key | VT100 / SS3 form | VT220 / CSI ~ form |
|---|---|---|
| F1 | ESC O P | ESC [ 1 1 ~ |
| F2 | ESC O Q | ESC [ 1 2 ~ |
| F3 | ESC O R | ESC [ 1 3 ~ |
| F4 | ESC O S | ESC [ 1 4 ~ |
| F5 | — | ESC [ 1 5 ~ |
| F6–F12 | — | ESC [ 17~ … ESC [ 24~ |
| Home | ESC O H | ESC [ 1 ~ or ESC [ H |
| End | ESC O F | ESC [ 4 ~ or ESC [ F |
| Insert | — | ESC [ 2 ~ |
| Delete | — | ESC [ 3 ~ |
| Page Up | — | ESC [ 5 ~ |
| Page Down | — | ESC [ 6 ~ |
Both are in the wild. Emit what xterm emits — F1–F4 as SS3, F5–F12 as CSI ~, Home/End as
CSI H/CSI F — because that is what TERM=xterm-256color promises and what programs' terminfo
lookups expect.
Arrow Keys and DECCKM
DECCKM (mode ?1) RESET — "normal cursor keys":
Up=CSI A Down=CSI B Right=CSI C Left=CSI D
DECCKM SET — "application cursor keys":
Up=SS3 A Down=SS3 B Right=SS3 C Left=SS3 D
The program decides, by setting the mode. The terminal obeys. This is why the same key produces
different bytes in bash (normal) and inside vim's insert mode (application) — and it is the
concrete reason terminal-input must read terminal-core's mode flags.
#![allow(unused)] fn main() { fn encode_arrow(dir: char, mods: Modifiers, modes: &TerminalModes) -> Vec<u8> { if mods.any() { // MODIFIED arrows always use the CSI form with a modifier parameter, // regardless of DECCKM. This asymmetry is xterm's behavior and programs // depend on it. format!("\x1b[1;{}{}", modifier_param(mods), dir).into_bytes() } else if modes.contains(Mode::APP_CURSOR_KEYS) { format!("\x1bO{dir}").into_bytes() } else { format!("\x1b[{dir}").into_bytes() } } }
Modifier Encoding
The modifier parameter is 1 + sum of:
1 = Shift 2 = Alt/Meta 4 = Ctrl 8 = Super/Meta(hyper)
Shift+Up → CSI 1;2 A
Alt+Up → CSI 1;3 A
Shift+Alt+Up → CSI 1;4 A
Ctrl+Up → CSI 1;5 A
Ctrl+Shift+Up → CSI 1;6 A
Ctrl+Alt+Up → CSI 1;7 A
Ctrl+Alt+Shift+Up → CSI 1;8 A
For CSI ~ keys the modifier goes after the number:
Ctrl+Delete → CSI 3;5 ~
Shift+F5 → CSI 15;2 ~
#![allow(unused)] fn main() { fn modifier_param(m: Modifiers) -> u8 { 1 + (m.shift as u8) + 2 * (m.alt as u8) + 4 * (m.ctrl as u8) + 8 * (m.superkey as u8) } }
Beyond the Legacy Protocol
The legacy encoding cannot represent Ctrl+1, Ctrl+Shift+C, or key release events. Two extensions exist.
modifyOtherKeys (xterm)
CSI > 4 ; 2 m enable
CSI > 4 ; 0 m disable
Then keys that have no legacy encoding are sent as:
CSI 27 ; <modifier> ; <codepoint> ~
e.g. Ctrl+Shift+A → CSI 27;6;65~
Simple, widely supported. Implement it.
The kitty keyboard protocol
CSI > <flags> u push a flag set
CSI < u pop
CSI ? u query current flags
flags: 1 = disambiguate escape codes
2 = report event types (press/repeat/release)
4 = report alternate keys
8 = report all keys as escape codes
16 = report associated text
Keys are then reported as:
CSI <unicode-key-code> [; <modifiers> [: <event-type>]] u
Richer: it can report key release, distinguish Escape from an escape sequence unambiguously, and carry arbitrary key combinations. Supported by kitty, foot, WezTerm, Ghostty, and increasingly by Neovim and other TUIs.
Recommended scope: implement the legacy encoding fully, add modifyOtherKeys, and implement kitty
flag 1 (disambiguation) if you have the appetite. Full kitty protocol support is a stretch goal.
The Escape-Key Ambiguity
The user presses Escape: 1b
The user presses the Up arrow: 1b 5b 41
↑
the same first byte
A program reading from the PTY cannot distinguish them without waiting. The universal solution is a
timeout: if nothing follows ESC within ~25–50 ms, it was the Escape key.
Your terminal does not have this problem — you know which key was pressed. But you inherit the
consequence: over a slow link, or when a program is busy, Escape feels laggy in vim. This is what
kitty flag 1 fixes (Escape becomes CSI 27 u, unambiguously), and it is a good argument for
implementing it.
Bracketed Paste
#![allow(unused)] fn main() { fn on_paste(&mut self, text: &str) { let bytes = terminal_input::encode_paste(text, self.term.modes()); self.pty.write_all(&bytes); } }
See Lab 11 for the envelope-escape protection, which is mandatory.
When the mode is off, consider warning on a multi-line paste — pasting \n into a shell executes
a command immediately, and a user pasting from a web page may not intend that. Several terminals now
prompt. It is a product decision, but it should be a considered one.
Focus Reporting (?1004)
Focus gained → CSI I
Focus lost → CSI O
Used by vim (to reload changed files) and tmux. Two lines to implement; do it.
The Complete Encoding Table
The full table lives in the appendix and is the specification for Lab 13. Summary:
| Key | Bytes | Notes |
|---|---|---|
| Printable | UTF-8 of the text | From the text event |
| Enter | 0x0d (CR) | Not 0x0a. ICRNL converts on the kernel side. |
| Tab | 0x09 | Shift+Tab = CSI Z |
| Backspace | 0x7f (DEL) | Ctrl+Backspace = 0x08. Make it configurable — some setups expect the reverse. |
| Escape | 0x1b | |
| Ctrl+letter | letter & 0x1f | |
| Alt+key | ESC + key bytes | |
| Arrows | CSI A/B/C/D or SS3 | Per DECCKM |
| Home/End | CSI H / CSI F | |
| Insert/Delete | CSI 2~ / CSI 3~ | |
| PgUp/PgDn | CSI 5~ / CSI 6~ | |
| F1–F4 | SS3 P/Q/R/S | |
| F5–F12 | CSI 15~…CSI 24~ | Note the gaps: there is no 16~ or 22~ |
| Modified keys | CSI 1;<mod><final> or CSI <n>;<mod>~ | |
| Paste | CSI 200~ … CSI 201~ | When ?2004 |
| Mouse | CSI <b;x;yM/m | When ?1006 |
| Focus | CSI I / CSI O | When ?1004 |
Experiment
CLAIM. The same key produces different bytes depending on terminal mode, and this is externally observable.
METHOD.
# Run your Lab 1 inspector, and in another window drive the modes.
# (Or add mode toggles to the inspector itself.)
cargo run -p raw-inspector
# press Up → 1b 5b 41
# In another shell, targeting the inspector's tty:
printf '\033[?1h' > /dev/pts/N # DECCKM on
# press Up → 1b 4f 41
printf '\033[?1l' > /dev/pts/N
# press Up → 1b 5b 41 again
# Now compare `bash` and `vim`:
pty-runner --record keys.cast
# inside: press Up at the bash prompt, then run vim, press Up, :q
grep -o 'u001b\[?1[hl]' keys.cast
PREDICTION. Does bash set DECCKM? Does vim? Does vim restore it on exit? What happens if it
crashes instead?
Test
#![allow(unused)] fn main() { #[test] fn enter_is_cr_not_lf() { assert_eq!(encode_named(Named::Enter, NO_MODS, &default_modes()).unwrap(), vec![0x0d]); } #[test] fn backspace_is_del_and_ctrl_backspace_is_bs() { assert_eq!(encode_named(Named::Backspace, NO_MODS, &default_modes()).unwrap(), vec![0x7f]); assert_eq!(encode_named(Named::Backspace, CTRL, &default_modes()).unwrap(), vec![0x08]); } #[test] fn ctrl_letters_map_to_control_bytes() { for (c, b) in [('a', 0x01u8), ('c', 0x03), ('z', 0x1a), ('[', 0x1b), (' ', 0x00)] { assert_eq!(encode_char_with_modifiers(&c.to_string(), CTRL, &default_modes()).unwrap(), vec![b], "Ctrl+{c}"); } } #[test] fn arrows_honor_decckm() { let mut modes = default_modes(); assert_eq!(encode_named(Named::ArrowUp, NO_MODS, &modes).unwrap(), b"\x1b[A".to_vec()); modes.insert(Mode::APP_CURSOR_KEYS); assert_eq!(encode_named(Named::ArrowUp, NO_MODS, &modes).unwrap(), b"\x1bOA".to_vec()); } #[test] fn modified_arrows_use_the_csi_form_even_with_decckm_set() { // The asymmetry is xterm's behavior; programs depend on it. let mut modes = default_modes(); modes.insert(Mode::APP_CURSOR_KEYS); assert_eq!(encode_named(Named::ArrowUp, CTRL, &modes).unwrap(), b"\x1b[1;5A".to_vec()); } #[test] fn modifier_parameter_arithmetic() { assert_eq!(modifier_param(SHIFT), 2); assert_eq!(modifier_param(ALT), 3); assert_eq!(modifier_param(CTRL), 5); assert_eq!(modifier_param(CTRL | SHIFT), 6); assert_eq!(modifier_param(CTRL | ALT | SHIFT), 8); } #[test] fn alt_uses_an_esc_prefix_not_the_high_bit() { // 8-bit meta is incompatible with UTF-8: 0xe2 is a valid lead byte. assert_eq!(encode_char_with_modifiers("b", ALT, &default_modes()).unwrap(), vec![0x1b, b'b']); } #[test] fn function_keys_follow_the_xterm_split() { assert_eq!(encode_named(Named::F1, NO_MODS, &default_modes()).unwrap(), b"\x1bOP".to_vec()); assert_eq!(encode_named(Named::F5, NO_MODS, &default_modes()).unwrap(), b"\x1b[15~".to_vec()); assert_eq!(encode_named(Named::F12, NO_MODS, &default_modes()).unwrap(), b"\x1b[24~".to_vec()); } #[test] fn terminal_input_does_not_depend_on_winit() { // A compile-time boundary check, enforced in CI: // cargo tree -p terminal-input | grep -E 'winit|wgpu|softbuffer' → empty } }
Validation / Self-check
- Name the three things a windowing system gives you per key press, and which to use for what.
- Why can a text-only frontend not send Ctrl+C? Why is a physical-key-only frontend broken?
- State the Ctrl-key rule. Which three common keys are identical to control bytes?
- Why use an
ESCprefix rather than 8-bit meta for Alt? - What are the two function-key lineages, and which does
xtermuse for which keys? - What is DECCKM, who sets it, and why does
terminal-inputneedterminal-core's modes? - Why do modified arrows use the CSI form even when DECCKM is set?
- Give the modifier parameter for Ctrl+Alt+Shift.
- What is the Escape ambiguity, how do programs cope, and how does the kitty protocol remove it?
- What must be stripped from a bracketed-paste payload, and why?
- Why must
terminal-inputnot depend onwinit, and how do you enforce it?
Next: Fonts and Rasterization.