Events & Input Parsing
Terminals talk to us in raw bytes: a printable ASCII character, a
partial UTF-8 sequence, a CSI escape, an OSC string, a bracketed paste
payload, an X10 mouse packet. InputParser is the state machine that
decodes this stream into a flat list of Event values the runtime can
reason about, and it does so under hard byte-count bounds designed
to survive hostile or misbehaving input sources.
Event is the canonical shape every other crate consumes:
ftui-runtime dispatches it to Model::update, ftui-widgets hit-tests
mouse events against the hit grid, and ftui-harness injects synthetic
events directly — never re-parsing. If it is not an Event, the
runtime does not see it.
This page documents the enum, the parser’s state machine, the three DoS bounds that keep it safe, and the specific corners (UTF-8 resume, ambiguous CSI prefixes, legacy vs. SGR mouse, bracketed paste framing) that trip people up.
Why a bounded state machine?
Naive ANSI parsers tend to accumulate “the sequence so far” into a
Vec<u8> that grows without limit until a terminator arrives. A
malicious or malfunctioning source can abuse that: ESC [ 9 9 9 9 ...
with no final byte; an OSC 52 ; ... clipboard dump with a gigabyte
of base64; a bracketed paste that never closes. The bound on each path
is the difference between a TUI that survives cat /dev/urandom and
one that OOM-kills itself.
Every growing buffer in InputParser has an explicit cap
(crates/ftui-core/src/input_parser.rs:L38-L44). Oversized sequences
transition into a dedicated *Ignore state that consumes the rest of
the sequence and discards it silently.
The Event enum
Event is closed (non-exhaustive matches must compile-error when new
variants arrive) and cheap to clone. Every input path on every backend
eventually normalizes to one of these.
pub enum Event {
Key(KeyEvent), // code + modifiers + Press/Repeat/Release
Mouse(MouseEvent), // position + button + kind + modifiers
Resize { width: u16, height: u16 },
Paste(PasteEvent), // bounded to 1 MB by the parser
Ime(ImeEvent), // preedit / commit / cancel
Focus(bool), // true = gained, false = lost
Clipboard(ClipboardEvent), // OSC 52 response
Tick, // runtime timer
}KeyEvent carries a KeyEventKind (Press, Repeat, Release);
Repeat and Release only fire when the kitty keyboard protocol is
active (see capabilities). MouseEvent is
normalized to SGR coordinates (origin (0, 0), no 223-column cap).
Parser state machine
┌───────────┐ 0x1B ┌─────────┐ [ ┌─────┐
│ Ground ├──────────▶│ Escape ├────────────▶│ CSI │
└─────┬─────┘ │ │ └──┬──┘
│ printable │ │ ] │ params
│ byte │ ├───────┐ ▼
│ │ │ │ ┌────────┐
│ │ O │ │ │CsiParam│
│ ├────┐ │ │ └────────┘
│ multi-byte │ ▼ │ ▼ │ >256 B
│ 0xC0..=0xF7 │ ┌────┐ │ ┌─────┐ ▼
▼ │ │SS3 │ │ │ Osc │ ┌──────────┐
┌──────┐ │ └────┘ │ └──┬──┘ │CsiIgnore │
│ Utf8 │ │ │ │ └──────────┘
└──────┘ │ CSI M │ │ content
|expect=2..4 │ (legacy│ ▼ >102 KB
▼ │ mouse)│ ┌────────┐ ┌──────────┐
emit Key(Char) │ ├──▶│OscEsc..│ │OscIgnore │
│ │ └────────┘ └──────────┘
└─────────┘States live in ParserState at
crates/ftui-core/src/input_parser.rs:L51. Each parse(&[u8]) -> Vec<Event> call drives the machine byte-by-byte; the accumulator is
drained on final-byte delivery and any oversized sequence transitions
into CsiIgnore or OscIgnore until it terminates.
Hard DoS bounds
| Bound | Value | Source |
|---|---|---|
MAX_CSI_LEN | 256 bytes | input_parser.rs:L38 |
MAX_OSC_LEN | 102 400 bytes (100 KB) | input_parser.rs:L41 |
MAX_PASTE_LEN | 1 048 576 bytes (1 MB) | input_parser.rs:L44 |
MAX_EVENT_RESERVE_HINT | 8 193 | input_parser.rs:L49 |
When a bound trips, the parser does not allocate more memory; it
walks into the ignore state, drains until the terminator, and proceeds.
Proptests in the same file (quickcheck_paste_bounded, csi_ignore,
osc_oversize_ignored) lock this behavior in as a correctness property,
not a best-effort.
Do not raise these bounds blindly. They bound worst-case memory
per connection under adversarial input. If your workload needs larger
paste payloads, chunk at the source or use a clipboard-specific
channel rather than lifting MAX_PASTE_LEN. The limit is there to
make cat /dev/urandom | my-tui a non-event.
UTF-8 streaming
Terminals deliver multi-byte UTF-8 one byte at a time; the parser must
hold partial continuations across parse() calls so a 4-byte codepoint
split across two reads still surfaces as a single Key(Char(..)).
The Utf8 { collected, expected } state tracks the continuation;
utf8_buffer: [u8; 4] holds the bytes. A leading byte is classified by
its top bits (0xC0–0xDF → 2 bytes, 0xE0–0xEF → 3, 0xF0–0xF7
→ 4). Invalid continuations reset to Ground and emit no event — the
parser is a strict decoder, not a “replacement character” producer.
Ambiguous CSI prefix handling
A lone ESC is indistinguishable from the start of an ESC [
sequence; the parser’s Escape state waits for the next byte.
InputParser::is_pending() returns true when the buffer holds an
unfinished sequence — the runtime uses this to decide whether to keep
polling or to deliver a standalone Escape key.
The runtime flushes a pending lone Escape after a short idle (the
default is ~25 ms, tunable at the ftui-runtime layer). This is the
classic “Esc vs Alt-…” disambiguation; see
model-trait for how the event loop integrates
the timeout.
Minimal parser session
use ftui_core::event::{Event, KeyCode};
use ftui_core::input_parser::InputParser;
fn main() {
let mut parser = InputParser::new();
// Arrow-up (CSI A) arrives in two reads; the parser carries state.
let first = parser.parse(b"\x1b"); // Escape state, pending
assert!(first.is_empty());
assert!(parser.is_pending());
let second = parser.parse(b"[A"); // completes the CSI
assert_eq!(second.len(), 1);
match &second[0] {
Event::Key(k) => assert_eq!(k.code, KeyCode::Up),
_ => unreachable!(),
}
// An oversized CSI is quietly discarded.
let garbage = {
let mut v = vec![0x1b, b'['];
v.extend(std::iter::repeat(b'0').take(512)); // > 256 B
v.push(b'm');
v
};
let events = parser.parse(&garbage);
assert!(events.is_empty());
}Mouse: SGR vs legacy
SGR mouse reporting (CSI < b ; x ; y M|m) has no coordinate cap and
is the default. InputParser exposes two flags for terminals that
ignore negotiation:
expect_x10_mouse— allowCSI M cb cx cy(3 raw bytes afterM). Off by default; the runtime flips it on when mouse capture is active.allow_legacy_mouse— allowCSI Cb;Cx;Cy Mnumeric-packet fallback for terminals that silently ignore SGR mode requests.
Both paths emit the same MouseEvent; only the wire format differs.
Bracketed paste framing
ESC [ 200 ~ opens a paste block, ESC [ 201 ~ closes it. Everything
in between is appended to paste_buffer up to MAX_PASTE_LEN; bytes
past the cap are dropped without disturbing the enclosing stream. The
closing marker always emits an Event::Paste — even when the content
was truncated — so the application can notice and respond (e.g. by
warning the user or falling back to streaming).
Cross-references
- Gestures — how
Event::Mouseis lifted into clicks, drags, and long-presses. - Terminal session — what flips the input modes the parser decodes.
- Capabilities — which mouse / keyboard protocols are actually enabled for a given terminal.
- Model trait — how events reach
update(). - Frame API — the other half of the runtime contract.