Skip to Content
ftui-coreEvents & input parser

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.

crates/ftui-core/src/event.rs
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

BoundValueSource
MAX_CSI_LEN256 bytesinput_parser.rs:L38
MAX_OSC_LEN102 400 bytes (100 KB)input_parser.rs:L41
MAX_PASTE_LEN1 048 576 bytes (1 MB)input_parser.rs:L44
MAX_EVENT_RESERVE_HINT8 193input_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 (0xC00xDF → 2 bytes, 0xE00xEF → 3, 0xF00xF7 → 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

examples/parse.rs
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 — allow CSI M cb cx cy (3 raw bytes after M). Off by default; the runtime flips it on when mouse capture is active.
  • allow_legacy_mouse — allow CSI Cb;Cx;Cy M numeric-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::Mouse is 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.

Where next