Keyboard & Mouse Input¶
TL;DR
- A terminal hands your program bytes, not key events. There is no
getKeyPress(). - It runs in one of two modes: cooked (the kernel buffers a whole line, echoes it, and turns Ctrl+C into a signal) or raw (every byte arrives instantly, nothing is echoed, nothing is interpreted). Every full-screen TUI runs in raw mode.
- Entering raw mode is a contract: if you don't restore the terminal on every exit path — normal return, exception, Ctrl+C, even a segfault — you leave the user's shell broken.
stty saneorresetis the rescue. - Printable keys are their UTF-8 bytes. Control keys are
0x00–0x1F. Special keys (arrows, F-keys, Home/End) are escape sequences starting with Esc (0x1B). - A lone Esc is ambiguous: it might be the Escape key, or the first byte of an arrow key. Sequences can split across
read()calls. So a real input layer is a stateful parser, not a lookup table. - Mouse input, bracketed paste, and the modern Kitty/CSI-u protocol are all more escape sequences you opt into.
- maya turns this entire mess into clean, typed
KeyEvent/MouseEvent/PasteEventobjects and restores the terminal for you no matter how you exit.
You press a key. A letter appears. Surely there's a function somewhere called
getKeyPress() and that's the end of it?
Not even close. Reading input in a terminal is one of the most surprisingly deep corners of TUI programming. The terminal was designed in the 1970s to talk to physical teletype machines over serial lines, and almost every quirk you'll fight today is an echo of that history. By the end of this page you'll know exactly what happens between a keypress and your program — and why a real framework spends a lot of code hiding it from you.
This is the fourth page in the Foundations series. We've covered what a terminal is and how text gets out of your program. Now we go the other direction: how bytes get in.
Here's the whole journey on one diagram. Don't worry if it's opaque now — every box is explained below.
flowchart TD
K([User presses a key]) --> TTY[Terminal driver / tty]
TTY --> MODE{Cooked or raw?}
MODE -->|cooked| COOK[Kernel echoes, buffers the line,<br/>handles backspace, turns Ctrl-C into SIGINT]
COOK --> ENTER[Waits for Enter, then hands<br/>your program the whole line]
MODE -->|raw| RAW[Every byte delivered immediately,<br/>no echo, no editing, no signals]
RAW --> READ["read(stdin, buf, n)"]
READ --> PARSE[Stateful parser]
PARSE --> EV[KeyEvent / MouseEvent / PasteEvent]
EV --> APP([Your app reacts])
The two modes of a terminal¶
Every terminal — more precisely, the terminal driver (the tty, "teletype") inside your operating system — operates in one of two fundamental modes. Understanding the difference is the single most important idea on this page.
stateDiagram-v2
[*] --> Cooked: shell starts here
Cooked --> Raw: TUI calls tcsetattr / SetConsoleMode
Raw --> Cooked: TUI restores on exit (you MUST do this)
Cooked: Cooked / canonical mode
Cooked: kernel line-buffers + echoes + handles signals
Raw: Raw mode
Raw: program gets every byte, owns everything
Raw --> [*]: crash without restoring = broken shell
Cooked mode (canonical mode)¶
This is the default. It's what you get when you write a normal command-line
program that calls read() or scanf or std::getline. It's called "cooked"
because the kernel does a lot of helpful preparation before your program ever
sees the data.
In cooked mode, the kernel line-buffers input. When the user types, the following all happen inside the operating system, before your program is involved at all:
- Characters are echoed to the screen automatically (you see what you type).
- Backspace erases the previous character from the buffer. Your program never learns the character was typed and then deleted.
- Ctrl+C raises
SIGINTand (by default) kills your program. - Ctrl+Z suspends it. Ctrl+D signals end-of-input.
- Ctrl+U / Ctrl+W do line-kill and word-erase.
- Nothing is handed to your program until the user presses Enter. At that
point the entire finished line arrives in one
read().
This is wonderful for a shell command. You ask a question, the user types an answer, fixes their typos, and presses Enter; you get one clean string. The kernel did the editing for you.
It's a disaster for a TUI.
A TUI needs to react the instant a key is pressed — arrow keys should move a cursor immediately, J / K should scroll without an Enter, and you certainly don't want the kernel echoing keystrokes wherever the text cursor happens to be, scribbling over your carefully drawn interface. You also want Ctrl+C to be a key event you can choose to handle, not an instant death sentence.
Raw mode¶
Raw mode turns off all that helpfulness. In raw mode:
- No echo. Keystrokes are not auto-printed. You decide what appears on screen.
- No line buffering. Every byte is delivered to your program the moment it arrives — no waiting for Enter.
- No line editing. Backspace is just a byte (
0x7F); it's your job to decide what it means. - No signal generation (optional). Ctrl+C arrives as the byte
0x03instead of killing you — unless you choose to leave signals on.
This is the mode every full-screen TUI runs in. The terminal becomes a dumb pipe that hands you raw bytes, and your program takes full responsibility for interpreting them.
Cooked vs raw, side by side¶
| Behavior | Cooked (canonical) | Raw |
|---|---|---|
| When you get data | after Enter, a whole line | every byte, immediately |
| Echo to screen | automatic, by the kernel | none — you draw it yourself |
| Backspace / line editing | kernel edits the buffer | raw byte, you handle it |
| Ctrl+C | SIGINT (kills you) |
byte 0x03 (a key event) |
| Ctrl+Z | suspends (SIGTSTP) |
byte 0x1A |
| Ctrl+S / Ctrl+Q | flow-control freeze/resume | bytes 0x13 / 0x11 |
| Ctrl+D | end-of-file | byte 0x04 |
| CR ↔ LF translation | yes (Enter → \n) |
none — you see 0x0D |
| Good for | shell commands, prompts | full-screen TUIs |
Cooked vs raw, in one sentence each
Cooked mode = "the kernel helps you read a line."
Raw mode = "give me every byte, right now, and get out of my way."
The termios flags, one by one¶
On POSIX systems (Linux, macOS, BSD), terminal behavior is controlled through a
struct called termios, manipulated with tcgetattr() (read current settings)
and tcsetattr() (write them back). Each behavior above is one bit in one of
four flag fields. Here is exactly what turning each one off does — which is
what "going raw" means:
| Flag | Field | Normally on, does… | Turn it OFF and… |
|---|---|---|---|
ICANON |
c_lflag (local) |
canonical/line buffering | bytes arrive immediately, no Enter wait, no line editing |
ECHO |
c_lflag |
echoes typed chars | nothing is auto-printed; you control the screen |
ISIG |
c_lflag |
Ctrl+C/Ctrl+Z → signals | those become ordinary bytes 0x03 / 0x1A |
IEXTEN |
c_lflag |
extended input (e.g. Ctrl+V literal-next) | Ctrl+V, Ctrl+O stop being special |
IXON |
c_iflag (input) |
Ctrl+S/Ctrl+Q flow control | Ctrl+S no longer freezes your output |
ICRNL |
c_iflag |
translates input CR → LF | Enter arrives as raw 0x0D (CR), not \n |
OPOST |
c_oflag (output) |
post-processes output (LF → CRLF) | you must write \r\n yourself; output is verbatim |
Two more fields control how read() blocks once ICANON is off — these
replace line-buffering with byte-level timing:
| Field | Meaning |
|---|---|
VMIN |
minimum bytes read() must collect before it returns |
VTIME |
timeout in tenths of a second |
The classic combinations:
VMIN=1, VTIME=0— block until at least one byte arrives. The common choice.VMIN=0, VTIME=0— return immediately, even with nothing (non-blocking poll).VMIN=0, VTIME=1— wait up to 100 ms for a byte, then return. Handy for the Escape-timeout trick you'll meet later.
The "enter raw mode" recipe¶
Conceptually, going raw is always the same four steps: save, copy, flip bits, apply — and crucially, save the originals so you can restore them.
#include <termios.h>
#include <unistd.h>
struct termios original; // save the cooked settings!
void enable_raw_mode(void) {
tcgetattr(STDIN_FILENO, &original); // 1. read current state
struct termios raw = original; // 2. copy it
raw.c_lflag &= ~(ICANON | ECHO | ISIG | IEXTEN); // 3. flip local bits off
raw.c_iflag &= ~(IXON | ICRNL); // flip input bits off
raw.c_oflag &= ~(OPOST); // raw output too
raw.c_cc[VMIN] = 1; // block for >= 1 byte
raw.c_cc[VTIME] = 0;
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); // 4. apply
}
void disable_raw_mode(void) {
tcsetattr(STDIN_FILENO, TCSAFLUSH, &original); // restore!
}
TCSAFLUSH means "apply after draining pending output and discarding
pending input," which avoids stray buffered bytes leaking between modes.
#include <windows.h>
HANDLE hIn = GetStdHandle(STD_INPUT_HANDLE);
DWORD original; // save the cooked settings!
void enable_raw_mode(void) {
GetConsoleMode(hIn, &original); // 1. read current state
DWORD raw = original; // 2. copy it
raw &= ~(ENABLE_LINE_INPUT // 3. clear flags: like ICANON
| ENABLE_ECHO_INPUT // like ECHO
| ENABLE_PROCESSED_INPUT); // like ISIG (Ctrl-C handling)
raw |= ENABLE_VIRTUAL_TERMINAL_INPUT; // get xterm-style escape sequences
SetConsoleMode(hIn, raw); // 4. apply
}
void disable_raw_mode(void) {
SetConsoleMode(hIn, original); // restore!
}
The flags map almost one-to-one onto termios: ENABLE_LINE_INPUT ≈
ICANON, ENABLE_ECHO_INPUT ≈ ECHO, ENABLE_PROCESSED_INPUT ≈ ISIG.
ENABLE_VIRTUAL_TERMINAL_INPUT asks the modern Windows console to deliver
the same VT/ANSI escape sequences that POSIX terminals send, so one parser
can serve both worlds. The idea is identical; only the API differs. A
cross-platform framework wraps both.
The single most important rule: restore the terminal¶
Notice the original variable in both recipes above. That's not optional
bookkeeping — it's the whole ballgame.
If you enable raw mode, you MUST restore cooked mode on exit
The terminal's mode is global state owned by the terminal, not by your process. When your program exits, the OS does not automatically reset it. If you crash, get killed, or simply forget to restore — the user's shell is left in raw mode.
They'll type and see nothing (echo is off). Ctrl+C won't work (signals are off). Enter won't start a new line (CR translation is off). The prompt looks frozen and broken. It is one of the worst first impressions a TUI can make, and the user almost never blames the right thing — they blame their machine.
"My shell is broken after the app crashed"¶
This is the symptom, and you will cause it at least once while learning. The session looks dead: you type, nothing echoes; you hit Enter, the cursor doesn't move to a new line; Ctrl+C does nothing. The terminal isn't broken — it's still in raw mode because the crashed app never restored it.
The rescue, typed blind (you won't see the characters as you type them):
| Command | What it does |
|---|---|
stty sane |
resets the tty's line discipline to sane defaults |
reset |
fuller reset — also clears the screen and re-inits the terminal |
tput reset |
similar; uses the terminfo entry |
After Ctrl+C doesn't work, try pressing Ctrl+J (a literal line feed)
before and after the command, since Enter's CR may not be translated:
Ctrl-J s t t y space s a n e Ctrl-J. Worst case, close the
window and open a fresh one.
Restore on every exit path¶
A naive program restores the terminal at the end of main(). But what if you
never reach the end of main()? A serious TUI library installs restoration on
every plausible exit path:
flowchart LR
RAW[Terminal in raw mode] --> N[Normal return]
RAW --> E[C++ exception thrown]
RAW --> S[Signal: SIGINT / SIGTERM / SIGQUIT]
RAW --> C[Crash: SIGSEGV / SIGABRT]
RAW --> Z[Suspend: SIGTSTP]
N --> R[restore cooked mode]
E --> R
S --> R
C --> R
Z --> R
R --> OK([Shell handed back exactly as found])
- Normal exit — via
atexit()and/or RAII destructors. RAII is the idiomatic C++ answer: a guard object enters raw mode in its constructor and restores in its destructor, so any return path — including stack unwinding from a thrown exception — restores the terminal automatically. - Signals — a handler for
SIGINT,SIGTERM,SIGQUITthat restores the terminal before re-raising the signal to die properly. - Crashes — even a handler for
SIGSEGV/SIGABRTthat restores the tty as its dying act, so a segfault in your app doesn't also wreck the user's shell. - Suspension — restore on
SIGTSTP(Ctrl+Z) and re-enter raw mode onSIGCONTwhen the job is resumed withfg.
Why a framework earns its keep here
Getting all of this right is fiddly and easy to forget. This is one of the
first things maya handles for you: enter run() and the terminal is put into
raw mode; leave it — however you leave it, including via a crash — and the
terminal is handed back exactly as it was found.
How keys actually arrive: it's just bytes¶
Once you're in raw mode, read(STDIN_FILENO, buf, n) hands you bytes. That's the
entire interface. There is no rich KeyEvent waiting for you; there is a stream
of uint8_t. Your job is to decode meaning from it. Keys fall into three
categories.
1. Printable keys → their bytes¶
The easy case. Press A, you get one byte: 0x61. Press A (with
Shift held), you get 0x41. The shift key itself is invisible — the terminal
already folded it into the resulting character.
Non-ASCII printable characters arrive as their UTF-8 encoding, which means a single keypress can be multiple bytes:
| Key | Bytes (hex) | Notes |
|---|---|---|
a |
61 |
one byte |
A |
41 |
shift already applied |
é |
C3 A9 |
2-byte UTF-8 |
€ |
E2 82 AC |
3-byte UTF-8 |
😀 |
F0 9F 98 80 |
4-byte UTF-8 |
So even "just a printable character" requires UTF-8 decoding to reassemble
multi-byte runes from the byte stream — and those bytes can be split across
separate read() calls.
2. Control keys → control bytes¶
The bottom 32 byte values (0x00–0x1F) are control codes, the C0 range.
Historically, holding Ctrl and pressing a letter clears the top bits of its
ASCII code, which is why the mapping is so regular.
The ++ctrl+key++ math
Ctrl-<letter> = (letter's ASCII code) AND 0x1F = letter's position in the
alphabet. Ctrl+A → 0x01, Ctrl+B → 0x02, … Ctrl+Z → 0x1A.
Lowercase a is 0x61; 0x61 & 0x1F = 0x01. That's the whole trick.
| Key | Byte | Name | Notes |
|---|---|---|---|
| Ctrl+A | 0x01 |
SOH | start of heading |
| Ctrl+C | 0x03 |
ETX | would be SIGINT in cooked mode |
| Ctrl+D | 0x04 |
EOT | would be EOF in cooked mode |
| Ctrl+H | 0x08 |
BS | backspace (the original one) |
| Tab | 0x09 |
HT | identical to Ctrl+I |
| Ctrl+J | 0x0A |
LF | line feed, \n |
| Ctrl+M | 0x0D |
CR | carriage return, \r — identical to Enter |
| Ctrl+Q | 0x11 |
DC1 | XON (flow control resume) |
| Ctrl+S | 0x13 |
DC3 | XOFF (flow control freeze) |
| Ctrl+Z | 0x1A |
SUB | would be SIGTSTP in cooked mode |
| Esc | 0x1B |
ESC | remember this one! |
| Backspace | 0x7F |
DEL | what most keyboards actually send |
Enter, Tab, Backspace, Delete: the subtleties¶
These four trip up everyone, because the byte rarely matches the key's name.
- Enter sends CR (
0x0D), not LF. Pressing Enter produces carriage return0x0D, the same byte as Ctrl+M. In cooked mode the kernel translates it to\n(0x0A) for you viaICRNL; in raw mode you see the raw0x0Dand must decide what it means. LF (0x0A,\n) is what Ctrl+J sends. CR is "go to column 0," LF is "move down a line" — a teletype needed both, which is why files on some systems still end lines with\r\n. - Tab is
0x09, the same byte as Ctrl+I. In a legacy terminal you literally cannot tell Tab from Ctrl+I apart. - Backspace is usually
0x7F(DEL), not0x08(BS). Most modern keyboards' Backspace key sends DEL0x7F. The byte0x08(BS) is what Ctrl+H sends, and what the forward-delete / very old terminals sometimes use. If your backspace handling does nothing, you're probably matching the wrong byte. - The Del (forward-delete) key is not a control byte at all — it's
an escape sequence (
ESC [ 3 ~, see below). So "Delete" the editing key and "DEL" the byte0x7Fare unrelated despite sharing a name. Welcome to terminals.
Two collisions to burn into memory
Tab ≡ Ctrl+I (0x09), and Enter ≡ Ctrl+M (0x0D). At the
byte level they are indistinguishable in the legacy world. The Kitty
protocol (below) is what finally lets you tell them apart.
3. Special keys → escape sequences¶
Here's the twist that makes terminal input genuinely tricky. Keys that aren't
characters — arrows, function keys, Home, End, Page Up — don't have a
single byte. They send a multi-byte escape sequence, almost always beginning
with the Esc byte (0x1B), followed by an introducer:
- CSI — Control Sequence Introducer —
ESC [(0x1B 0x5B). Arrows, Home/End, navigation keys, function keys F5+. - SS3 — Single Shift Three —
ESC O(0x1B 0x4F). Function keys F1–F4 in the classic xterm "application" mode.
| Key | Bytes (hex) | As text | Introducer |
|---|---|---|---|
| Up | 1B 5B 41 |
ESC [ A |
CSI |
| Down | 1B 5B 42 |
ESC [ B |
CSI |
| Right | 1B 5B 43 |
ESC [ C |
CSI |
| Left | 1B 5B 44 |
ESC [ D |
CSI |
| Home | 1B 5B 48 |
ESC [ H |
CSI |
| End | 1B 5B 46 |
ESC [ F |
CSI |
| Page Up | 1B 5B 35 7E |
ESC [ 5 ~ |
CSI |
| Page Down | 1B 5B 36 7E |
ESC [ 6 ~ |
CSI |
| Ins | 1B 5B 32 7E |
ESC [ 2 ~ |
CSI |
| Del | 1B 5B 33 7E |
ESC [ 3 ~ |
CSI |
| F1 | 1B 4F 50 |
ESC O P |
SS3 |
| F2 | 1B 4F 51 |
ESC O Q |
SS3 |
| F3 | 1B 4F 52 |
ESC O R |
SS3 |
| F4 | 1B 4F 53 |
ESC O S |
SS3 |
| F5 | 1B 5B 31 35 7E |
ESC [ 1 5 ~ |
CSI |
| F6 | 1B 5B 31 37 7E |
ESC [ 1 7 ~ |
CSI |
| F7 | 1B 5B 31 38 7E |
ESC [ 1 8 ~ |
CSI |
| F8 | 1B 5B 31 39 7E |
ESC [ 1 9 ~ |
CSI |
| F9 | 1B 5B 32 30 7E |
ESC [ 2 0 ~ |
CSI |
| F10 | 1B 5B 32 31 7E |
ESC [ 2 1 ~ |
CSI |
| F11 | 1B 5B 32 33 7E |
ESC [ 2 3 ~ |
CSI |
| F12 | 1B 5B 32 34 7E |
ESC [ 2 4 ~ |
CSI |
Notice the patternlessness: F5 jumps from 15~ (there is no 14~), and
F1–F4 use a completely different introducer from F5+. And modifiers complicate
it further — holding Shift with Up typically yields ESC [ 1 ; 2 A,
where the ;2 is a modifier parameter (2 = shift, 3 = alt, 5 = ctrl, and sums
for combinations).
The Esc byte does double duty
This is the deep symmetry that confuses everyone. In the previous page we saw
that output styling uses escape sequences: ESC [ 31 m to turn text red.
Now we see that input special keys also arrive as escape sequences
starting with ESC [. The very same byte (0x1B) you write out to color
your UI is the byte that comes in to signal an arrow key.
The terminal is a half-duplex stream of bytes where Esc is the universal "something structured follows" marker — in both directions.
There is no single tidy rule; the sequences are a historical patchwork, and they
vary between terminal types (xterm, rxvt, the linux console, etc.). This is
exactly the kind of mess a framework normalizes away.
The Escape ambiguity problem¶
Now we hit the single nastiest gotcha in terminal input. Look again at the table
above: every special key starts with the Esc byte (0x1B). But Esc is
also a key the user can press all by itself — to cancel a dialog, leave a mode,
close a menu.
So when your read() returns a lone 0x1B, you face an impossible question:
Did the user press the Esc key, or is this the first byte of an arrow key whose remaining bytes (
[ A) haven't arrived yet?
You cannot tell from the byte alone. The information simply isn't there yet.
The ++alt+key++ wrinkle¶
It gets worse. By convention, holding Alt (the "Meta" key) and pressing a key
sends Esc followed by that key's normal byte. So Alt+A arrives as
ESC a = 0x1B 0x61. That means a lone ESC could be:
- the Esc key by itself, or
- the start of a CSI/SS3 special-key sequence (
ESC [ …), or - the Alt modifier on whatever byte comes next (
ESCa).
| Bytes received | Most likely meaning |
|---|---|
1B then nothing |
Esc key alone |
1B 5B 41 |
Up arrow (CSI) |
1B 4F 50 |
F1 (SS3) |
1B 61 |
Alt+A |
1B 1B |
Esc then Esc (or Alt-Esc, ambiguous) |
The timeout heuristic¶
The classic disambiguation strategy: after seeing ESC, wait a short interval
(commonly ~25–50 ms, the VTIME trick from earlier). A human cannot type ESC
then [ then A within single-digit milliseconds, but a terminal emits the
bytes of an escape sequence back-to-back. So:
- More bytes arrive almost instantly → it's a sequence; keep reading and match.
- Nothing arrives within the window → it was a standalone Esc press.
This is why some TUIs feel like Esc has a tiny lag, and why pressing Esc quickly twice can behave oddly.
Split reads, and why a parser must buffer¶
Layered on top is a second headache: sequences can be split across read()
calls. The kernel might hand you ESC [ in one read and A in the next,
simply because of how the bytes were buffered or how the terminal flushed them.
So your parser cannot be a switch over a fixed-size buffer. It must be a
stateful machine that remembers "I'm currently in the middle of a CSI
sequence" across reads, accumulating bytes until it has enough to decide.
Here is the heart of such a parser as a state diagram:
stateDiagram-v2
[*] --> Ground
Ground --> Ground: printable byte → emit KeyEvent(char)
Ground --> Ground: C0 control byte → emit KeyEvent(ctrl)
Ground --> SawEsc: byte == 0x1B (ESC)
SawEsc --> Ground: timeout, no more bytes → emit KeyEvent(Esc)
SawEsc --> CSI: next byte == '[' (0x5B)
SawEsc --> SS3: next byte == 'O' (0x4F)
SawEsc --> Ground: next byte is printable → emit KeyEvent(Alt+char)
CSI --> CSI: collect params (digits, ';')
CSI --> Ground: final byte (A-Z, ~) → emit Key/MouseEvent
SS3 --> Ground: final byte (P-S, ...) → emit KeyEvent(F1-F4)
note right of SawEsc
The ambiguity lives here:
wait briefly (VTIME) to decide
Esc-alone vs sequence vs Alt-key
end note
This is the heart of why a parser exists
A lone byte is ambiguous. A sequence can arrive in pieces. Esc might be a key, a sequence prefix, or an Alt modifier. Any honest input layer is a small state machine, not a lookup table — and that state machine is precisely the kind of tedious, error-prone code maya writes once so you never have to.
Mouse input¶
Yes, the terminal can report the mouse — and it does it, of course, through more escape sequences. But mouse reporting is off by default. You opt in by writing an escape sequence to the terminal, which flips a private mode on.
Enabling the right mode¶
| Sequence | Mode | Reports |
|---|---|---|
ESC [ ? 1000 h |
click | button press and release only |
ESC [ ? 1002 h |
button-drag | press/release plus motion while a button is held |
ESC [ ? 1003 h |
any-motion | all motion, even with no button down |
ESC [ ? 1015 h |
urxvt | older extended encoding (rxvt-unicode) |
ESC [ ? 1006 h |
SGR | use the modern SGR coordinate encoding — do this |
h = "high"/enable; the matching … l = "low"/disable. You typically combine a
tracking mode with an encoding, e.g. enable ?1002h and ?1006h. You must
turn mouse reporting back off on exit, just like raw mode, or the user's
shell will spew gibberish whenever they move the mouse.
The SGR encoding (?1006)¶
The old default encoding packed the button and coordinates into raw bytes and
broke past column 223 (it could only encode coordinates up to 255 - 32). That's
why you almost always request ?1006h, the SGR extended format, which is
human-readable and unbounded:
ESC [ < Cb ; Cx ; Cy M button event (press / scroll / motion) — trailing capital M
ESC [ < Cb ; Cx ; Cy m button release — trailing lowercase m
Cb= button + modifier + motion code (decoded below).Cx,Cy= column and row, 1-based.- The trailing letter is the only thing distinguishing press (
M) from release (m).
Decoding Cb¶
Cb is a small integer with bit fields packed in:
| Bits | Meaning |
|---|---|
| low 2 bits (values 0–2) | button: 0 left, 1 middle, 2 right |
+ 4 (bit 2) |
Shift held |
+ 8 (bit 3) |
Alt / Meta held |
+ 16 (bit 4) |
Ctrl held |
+ 32 (bit 5) |
this is a motion event (drag/hover), not a fresh click |
+ 64 (bit 6) |
this is a scroll wheel event; then low bits: 0 = up, 1 = down |
So a plain left-click at column 10, row 5 is ESC [ < 0 ; 10 ; 5 M, releasing is
ESC [ < 0 ; 10 ; 5 m. Scroll-up is Cb = 64, scroll-down is Cb = 65. A
Ctrl-left-click is Cb = 0 + 16 = 16. A left-button drag emits motion events
with Cb = 0 + 32 = 32.
Reading a real SGR mouse report
ESC [ < 35 ; 80 ; 24 M → Cb = 35 = 32 + 3. Bit 5 (+32) means motion;
the low two bits 3 are the "no button held" code, so this is a plain
pointer move (a hover) across column 80, row 24 — exactly the kind of event
?1003h floods you with. By contrast ESC [ < 16 ; 80 ; 24 M is 16 = 0 + 16:
a Ctrl-left-click. The point: decoding is bit math, and edge cases abound.
This is why you want it done for you.
Any-motion mode (?1003h) is a firehose
With ?1003h the terminal emits a fresh escape sequence for every cell the
pointer moves over, button or not. Drag the mouse across the screen and you
can get hundreds of sequences in a heartbeat. If your input loop and parser
aren't ready for that volume — and you don't coalesce redundant move
events — your app will stutter or fall behind. Most apps prefer ?1002h
(drag only) and reach for ?1003h only when they genuinely need hover
tracking.
Bracketed paste¶
Here's a subtle one. When a user pastes a block of text into the terminal, each character arrives on stdin exactly as if it had been typed. That's a problem: if your app treats a newline in pasted text as "the user pressed Enter, run the command," pasting a multi-line snippet can trigger a cascade of unintended actions. This has even been a real security issue — pasting text that secretly contains a newline followed by a dangerous command.
Bracketed paste mode solves it. Enable it by writing:
Now, when text is pasted, the terminal wraps it in framing markers:
Everything between ESC [ 200 ~ and ESC [ 201 ~ was pasted, not typed.
Your app can treat it as literal text — insert it as-is, treat embedded newlines
as line breaks rather than command submissions, and refuse to act on control
characters hidden inside it. This is how editors and shells tell "the human is
typing" apart from "the human dumped in a blob," and it's what makes multiline
paste into a text field safe.
sequenceDiagram
participant U as User
participant T as Terminal
participant A as Your app
U->>T: Ctrl-V (paste 3 lines)
T->>A: ESC [ 200 ~
T->>A: line1\nline2\nline3 (verbatim bytes)
T->>A: ESC [ 201 ~
Note over A: treat the middle as one PasteEvent,<br/>NOT three Enter presses
Modern protocols: Kitty / CSI-u¶
Everything above is the legacy world, and it has real, unfixable holes:
- Tab and Ctrl+I are the same byte; so are Enter and Ctrl+M.
- You can't reliably see modifier keys (Ctrl / Alt / Shift) combined with most keys.
- Key release events don't exist; you only ever see presses.
- The Esc ambiguity forces you to guess with a timeout.
Newer terminals (kitty, foot, WezTerm, recent xterm, and others) support better schemes. The Kitty keyboard protocol and the related CSI-u encoding report keys in a single, unambiguous, structured form:
- The trailing
u(instead of~,A, etc.) marks it as a CSI-u key report. <unicode-codepoint>is the base key as a codepoint — so Tab and Ctrl+I now differ, and Enter and Ctrl+M now differ.<modifiers>is an explicit bitmask (shift / alt / ctrl / super / …), so Ctrl+Enter and Shift+Enter become representable.- Optional event types distinguish press / repeat / release.
- Because every key is fully self-describing and terminated by
u, there's no timeout guessing for Esc anymore.
An app opts in by writing an enable sequence and querying support; if the terminal doesn't respond, it falls back to legacy parsing. CSI-u is the future, but it isn't universal yet, so a framework must still speak the legacy dialect fluently and upgrade automatically when the terminal allows.
Why you want a parser (and why maya is it)¶
Step back and look at what "read the keyboard" actually entails:
- Put the terminal into raw mode — and restore it on every exit path, including crashes and signals, or you brick the user's shell.
- Read a stream of bytes that mixes printable UTF-8 runes, C0 control codes, and escape sequences, with no framing.
- Reassemble multi-byte UTF-8 characters that may split across reads.
- Run a stateful machine to recognize escape sequences that also may split across reads.
- Disambiguate a lone Esc from the start of a sequence — and from ++alt+key++ — using timing.
- Optionally enable, decode, and coalesce mouse events (and survive the
?1003hfirehose). - Handle bracketed paste so pastes aren't mistaken for typing.
- Detect and prefer modern protocols (Kitty/CSI-u) where available, while still supporting the legacy mess everywhere else.
- Normalize all of it across
xterm,rxvt, the Linux console, macOS Terminal, Windows console, tmux, and friends.
The output of all that machinery should be clean, typed events your app can
switch on without thinking about any of the above:
// You write this — clear intent, no byte-wrangling.
on(ev, Key::Up, [&] { cursor.move_up(); });
on(ev, 'q', [&] { quit(); });
on(ev, Key::Enter, [&] { submit(); });
That transformation — from a messy, stateful, terminal-specific byte stream into
tidy KeyEvent / MouseEvent / PasteEvent objects — is exactly what maya's
input layer does for you. You get to think about what the user meant, not which
byte the End key happens to send on the third terminal emulator down.
Try it yourself¶
The best way to believe all this is to watch the bytes with your own eyes. None of these need any code — just a terminal.
Read the recovery steps first
Some commands below put your live shell into raw mode. If you can't type
or Enter stops working, type stty sane blind (you won't see it) and
press Enter — or just close the window. Now you know the stakes.
Watch raw key bytes with cat -v¶
Now press keys. Each control/special key prints its bytes. Press the Up arrow
and you'll see ^[[A — that's ESC [ A, exactly as promised (^[ is how
cat -v draws the Esc byte). Try F1 (^[OP), Page Up (^[[5~),
Tab (^I), Enter (^M), and Backspace (^? = DEL). Press
Ctrl+C to leave — it still works here because cat runs in cooked mode.
Even better: showkey -a¶
On Linux, showkey -a shows each key's decimal, octal, and hex value as you
press it — a perfect decoder ring for the tables above:
It exits on its own after a few seconds of no input.
Feel raw mode (and how to escape it)¶
You just disabled canonical mode and echo in your live shell. Type something — you'll see nothing, because echo is off, and the shell won't react to Enter the way you expect. This is precisely the broken state a careless TUI leaves behind.
To recover, type the following blind and press Enter (if Enter seems dead, try Ctrl+J):
More ways to rescue a broken terminal
stty sane, reset, and tput reset all rescue a terminal left in raw
mode. reset is the most thorough — it also clears the screen and re-inits
the terminal. Worst case, just close the window and open a fresh one. Now you
know why a framework's "restore on exit, no matter what" guarantee matters.
See mouse bytes¶
Turn on mouse reporting, then read raw bytes:
printf '\e[?1000;1006h' # enable basic mouse + SGR coordinates in one go
cat -v # now click around in the window
Click somewhere and you'll see something like ^[[<0;12;5M — button 0 (left) at
column 12, row 5. Release and you'll get ^[[<0;12;5m (lowercase m). Scroll the
wheel and watch the button code jump to 64/65. When you're done, exit cat
(Ctrl+C) and turn reporting back off:
If your mouse cursor still acts strangely afterward, a quick reset clears it.
Watch bracketed paste¶
printf '\e[?2004h' # enable bracketed paste
cat -v # now PASTE some multiline text (Ctrl-Shift-V / Cmd-V)
You'll see the pasted text wrapped in ^[[200~ … ^[[201~. Type the same text
by hand instead and the markers are absent — that's exactly how an app tells
pasting from typing. Disable and exit when done:
What's next¶
You now know how a TUI gets input and why even "read a keypress" is a small engineering project: raw mode and its restore contract, bytes versus control codes versus escape sequences, the Esc ambiguity, a stateful parser, mouse reporting, bracketed paste, and the modern CSI-u upgrade path.
But there's a mirror-image problem on the output side. Once you know what to draw in response to a key, how do you actually paint it to the screen — fast, flicker-free, and without redrawing the whole world every frame?
That's the subject of the next Foundations page: The Rendering Problem.