Reading terminal events
Step 3 of 8. Step 2
left a loop that reads one kind of event and throws away everything except q. This step makes it
read all of them — keys, the mouse, and a resize — and the frame reports the last of each back.
That is the whole event surface the later steps select from: step 4 scrolls the log pane with the
wheel, step 6 moves the selection with the arrow keys. Start from tag tui-ch02; the end state is
tagged tui-ch03.
The project and its dependencies are unchanged from step 2. Ratatui draws; crossterm reads the terminal, and every event in this step comes from its reader:
[package]name = "rlog"version = "0.1.0"edition = "2024"
[dependencies]crossterm = "0.29"ratatui = "0.30"What the terminal reports
Section titled “What the terminal reports”event::read() hands back one Event, and there are six of them: Key(KeyEvent),
Mouse(MouseEvent), Resize(u16, u16), FocusGained, FocusLost and Paste(String). The last
three only arrive if the program asks for them — focus reporting and bracketed paste each have
their own enable command, and rlog sends neither — so the catch-all arm in the loop is honest:
nothing else can arrive.
One event per read. poll says whether one is waiting within the tick; read takes it. That
pairing is what gives the loop its clock: the wait is bounded, and a burst of input drains one
event per pass rather than all at once.
A KeyEvent carries four fields: code for which key, modifiers for shift, control, alt and
friends, kind for press, repeat or release, and state for the keypad and lock keys. rlog reads
the first two and the kind.
The kind guard from step 2 earns a word here. Crossterm only reports repeats and releases when the
terminal speaks the kitty keyboard protocol; a plain xterm sends one escape sequence per keypress
and every event arrives as Press. The guard costs one comparison and keeps the loop correct on
terminals that do report them — without it, holding a key down would count it three times.
The status line names keys instead of printing them with Debug, because Debug on a KeyEvent
prints the whole struct. Naming them is a match over the KeyCode variants this track uses, with
a fallback for the rest:
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// Name a key the way the status line shows it. Modifiers come first, the key/// itself second; `None` before the first press.fn describe_key(key: Option<&KeyEvent>) -> String { let Some(key) = key else { return "(none yet)".to_string(); }; let mut name = match key.code { KeyCode::Char(c) => c.to_string(), KeyCode::Enter => "enter".to_string(), KeyCode::Esc => "esc".to_string(), KeyCode::Backspace => "backspace".to_string(), KeyCode::Tab => "tab".to_string(), KeyCode::Up => "up".to_string(), KeyCode::Down => "down".to_string(), KeyCode::Left => "left".to_string(), KeyCode::Right => "right".to_string(), KeyCode::PageUp => "pgup".to_string(), KeyCode::PageDown => "pgdn".to_string(), KeyCode::Home => "home".to_string(), KeyCode::End => "end".to_string(), other => format!("{other:?}").to_lowercase(), }; if key.modifiers.contains(KeyModifiers::CONTROL) { name = format!("ctrl-{name}"); } if key.modifiers.contains(KeyModifiers::ALT) { name = format!("alt-{name}"); } name}
fn main() { let keys = [ KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE), KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT), KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE), ]; for key in keys { println!("{:?} -> {}", key.code, describe_key(Some(&key))); } println!("nothing yet -> {}", describe_key(None));}Char('j') -> jChar('G') -> GChar('c') -> ctrl-cUp -> upPageUp -> pgupEnter -> alt-enterF(5) -> f(5)nothing yet -> (none yet)KeyEvent::new takes a code and modifiers and defaults the kind to Press, which is why the
demo can build events without a terminal. Char('G') keeps its capital: the case normalisation
crossterm applies to real input happens in its parser, not in the constructor, and this demo
constructs events by hand.
The mouse
Section titled “The mouse”Mouse reporting is a terminal mode, like raw mode and the alternate screen: the program asks for it
on the way in and gives it back on the way out. EnableMouseCapture does the asking — one
execute! call beside EnterAlternateScreen — and it turns on five modes at once. Modes 1000,
1002 and 1003 decide which motions are reported: presses and releases, drags, and then every
movement of the pointer. Modes 1015 and 1006 are two coordinate encodings, the RXVT form and the
SGR form; SGR is the newer one, it carries coordinates past column 223 and puts
press-versus-release in the final byte.
What arrives is a MouseEvent: kind, column, row and modifiers. The coordinates are
zero-based, counted from the top-left cell — crossterm subtracts one from the terminal’s one-based
numbering on the way in.
Three of the kinds matter to this track:
- A click is two events:
Down(Left)thenUp(Left). - The wheel is
ScrollUpandScrollDown, with no button attached. Steps 4 and 5 use them to scroll the log pane. - Motion arrives as
Movedwith no button, orDrag(button)with one held. Because mode 1003 is on, a mouse crossing the screen produces a stream of these. The loop keeps the last one and redraws; step 5’s dirty flag is what stops that.
Naming a mouse event is the same shape as naming a key:
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
fn button_name(button: MouseButton) -> &'static str { match button { MouseButton::Left => "left", MouseButton::Right => "right", MouseButton::Middle => "middle", }}
/// Name a mouse event for the status line: what happened, then where. Both/// coordinates are zero-based, counted from the top-left cell.fn describe_mouse(mouse: Option<&MouseEvent>) -> String { let Some(mouse) = mouse else { return "(none yet)".to_string(); }; let kind = match mouse.kind { MouseEventKind::Down(button) => format!("press {}", button_name(button)), MouseEventKind::Up(button) => format!("release {}", button_name(button)), MouseEventKind::Drag(button) => format!("drag {}", button_name(button)), MouseEventKind::Moved => "move".to_string(), MouseEventKind::ScrollUp => "scroll up".to_string(), MouseEventKind::ScrollDown => "scroll down".to_string(), MouseEventKind::ScrollLeft => "scroll left".to_string(), MouseEventKind::ScrollRight => "scroll right".to_string(), }; format!("{} at {},{}", kind, mouse.column, mouse.row)}
fn main() { let mice = [ MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 11, row: 4, modifiers: KeyModifiers::NONE, }, MouseEvent { kind: MouseEventKind::Up(MouseButton::Right), column: 0, row: 23, modifiers: KeyModifiers::NONE, }, MouseEvent { kind: MouseEventKind::ScrollUp, column: 11, row: 4, modifiers: KeyModifiers::NONE, }, MouseEvent { kind: MouseEventKind::Drag(MouseButton::Left), column: 12, row: 5, modifiers: KeyModifiers::NONE, }, MouseEvent { kind: MouseEventKind::Moved, column: 40, row: 12, modifiers: KeyModifiers::NONE, }, ]; for mouse in mice { println!("{:?} -> {}", mouse.kind, describe_mouse(Some(&mouse))); } println!("nothing yet -> {}", describe_mouse(None));}Down(Left) -> press left at 11,4Up(Right) -> release right at 0,23ScrollUp -> scroll up at 11,4Drag(Left) -> drag left at 12,5Moved -> move at 40,12nothing yet -> (none yet)Every field of MouseEvent is public, which is what makes a demo like this possible without a
terminal: build the struct, call the formatter, print the line.
A resize
Section titled “A resize”Event::Resize(w, h) reports the new size in columns and rows, and it arrives in batches while a
window is dragged — one event per intermediate size, not one per final size.
Ratatui doesn’t wait for the event. Terminal::draw asks the backend for its size on every pass
and resizes its buffers when it has changed, so the reflow is ratatui’s job whether or not the app
has seen the Resize yet. So what is the event for? The app’s own copy of the size: the status
line reports it, and step 4’s layout reads it. Two mechanisms, and only one of them is the
program’s to maintain.
The frame reports what arrived
Section titled “The frame reports what arrived”App grows two fields — the last key and the last mouse event — and the box gains two lines. Both
are Option, because before anything happens there is nothing to report; draw prints
(none yet) until the first event lands. The size starts from the terminal itself and the resize
event keeps it current.
The same draw the program runs, rendered into ratatui’s in-memory terminal:
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};use ratatui::Terminal;use ratatui::backend::TestBackend;use ratatui::prelude::*;use ratatui::widgets::{Block, Paragraph};
struct App { frames: u64, last_key: Option<KeyEvent>, last_mouse: Option<MouseEvent>, size: (u16, u16),}
/// Name a key the way the status line shows it. Modifiers come first, the key/// itself second; `None` before the first press.fn describe_key(key: Option<&KeyEvent>) -> String { let Some(key) = key else { return "(none yet)".to_string(); }; let mut name = match key.code { KeyCode::Char(c) => c.to_string(), KeyCode::Enter => "enter".to_string(), KeyCode::Esc => "esc".to_string(), KeyCode::Backspace => "backspace".to_string(), KeyCode::Tab => "tab".to_string(), KeyCode::Up => "up".to_string(), KeyCode::Down => "down".to_string(), KeyCode::Left => "left".to_string(), KeyCode::Right => "right".to_string(), KeyCode::PageUp => "pgup".to_string(), KeyCode::PageDown => "pgdn".to_string(), KeyCode::Home => "home".to_string(), KeyCode::End => "end".to_string(), other => format!("{other:?}").to_lowercase(), }; if key.modifiers.contains(KeyModifiers::CONTROL) { name = format!("ctrl-{name}"); } if key.modifiers.contains(KeyModifiers::ALT) { name = format!("alt-{name}"); } name}
/// Name a mouse event for the status line: what happened, then where. Both/// coordinates are zero-based, counted from the top-left cell.fn describe_mouse(mouse: Option<&MouseEvent>) -> String { let Some(mouse) = mouse else { return "(none yet)".to_string(); }; let kind = match mouse.kind { MouseEventKind::Down(button) => format!("press {}", button_name(button)), MouseEventKind::Up(button) => format!("release {}", button_name(button)), MouseEventKind::Drag(button) => format!("drag {}", button_name(button)), MouseEventKind::Moved => "move".to_string(), MouseEventKind::ScrollUp => "scroll up".to_string(), MouseEventKind::ScrollDown => "scroll down".to_string(), MouseEventKind::ScrollLeft => "scroll left".to_string(), MouseEventKind::ScrollRight => "scroll right".to_string(), }; format!("{} at {},{}", kind, mouse.column, mouse.row)}
fn button_name(button: MouseButton) -> &'static str { match button { MouseButton::Left => "left", MouseButton::Right => "right", MouseButton::Middle => "middle", }}
fn draw(frame: &mut ratatui::Frame, app: &App) { let block = Block::bordered().title(" rlog "); frame.render_widget( Paragraph::new(vec![ Line::from(format!("frame {} — press q to quit", app.frames)), Line::from(format!("last key: {}", describe_key(app.last_key.as_ref()))), Line::from(format!( "last mouse: {}", describe_mouse(app.last_mouse.as_ref()) )), Line::from(format!("terminal: {}x{}", app.size.0, app.size.1)), ]), block.inner(frame.area()), ); frame.render_widget(block, frame.area());}
fn main() { let app = App { frames: 1, last_key: Some(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)), last_mouse: Some(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 11, row: 4, modifiers: KeyModifiers::NONE, }), size: (46, 7), }; let mut terminal = Terminal::new(TestBackend::new(46, 7)).unwrap(); terminal.draw(|frame| draw(frame, &app)).unwrap();
// Print the cells the backend was given, the way a terminal would show them. let buffer = terminal.backend().buffer(); for y in 0..buffer.area.height { let line: String = (0..buffer.area.width) .map(|x| buffer[(x, y)].symbol()) .collect(); println!("{}", line.trim_end()); }}┌ rlog ──────────────────────────────────────┐│frame 1 — press q to quit ││last key: j ││last mouse: press left at 11,4 ││terminal: 46x7 ││ │└────────────────────────────────────────────┘The state here is one a reader can build by hand, so the block tests the frame rather than the terminal. The two naming functions are the ones from the sections above, copied in so the block stands alone.
The loop
Section titled “The loop”The loop keeps step 2’s shape — draw, then wait one tick — and the wait now reads every kind of event. The whole file at this step:
//! rlog — a terminal UI that follows a log file and watches the process table.//!//! Step 3 of the track: the event loop reads every kind of event the terminal//! produces — keys, the mouse, and a resize — and the frame reports the last of//! each. Step 4 turns the box into panes; the events read here are what those//! panes answer.
use std::io;use std::time::Duration;
use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,};use crossterm::execute;use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,};use ratatui::prelude::*;use ratatui::widgets::{Block, Paragraph};
/// How long the loop waits for an event before redrawing anyway. One tick of/// the program's clock; the refresh schedules of later steps are measured/// against it.const TICK: Duration = Duration::from_millis(250);
/// The whole state of the program at this step. Step 2 carried only the frame/// counter; the event step adds the last event of each kind and the terminal/// size as of the last resize. Every later step grows this struct — the log/// tail, the process table, the selection — and `draw` stays a pure function/// of it.struct App { frames: u64, /// The last key press, `None` until the first one arrives. last_key: Option<KeyEvent>, /// The last mouse event, `None` until the mouse does something. last_mouse: Option<MouseEvent>, /// Columns and rows, as of the last resize. size: (u16, u16),}
fn main() -> io::Result<()> { // A panic while the terminal is in raw mode leaves it unusable: every line // the shell prints afterwards lands wherever the cursor happens to sit. // Restore the terminal first, then let the default hook print the panic. let default_panic_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { let _ = leave_full_screen(); default_panic_hook(info); }));
enable_raw_mode()?; // Mouse tracking is a terminal mode like the alternate screen: the program // asks for it, and it has to give it back on the way out. execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
// The size starts from the terminal itself; `Event::Resize` keeps it // current after that. let mut app = App { frames: 0, last_key: None, last_mouse: None, size: crossterm::terminal::size()?, }; let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; let result = run(&mut terminal, &mut app);
leave_full_screen()?; result}
/// Undo what `main` set up: mouse tracking off, the alternate screen away, then/// raw mode. The order matters — raw mode is what makes the escape sequences/// arrive intact, so it goes last.fn leave_full_screen() -> io::Result<()> { execute!( io::stdout(), DisableMouseCapture, LeaveAlternateScreen )?; disable_raw_mode()}
/// The render loop. Every pass draws a frame, then waits one tick for an event./// `q`, `Esc` and `Ctrl-C` end the loop and give the terminal back; every other/// event lands in the state, where the next frame reports it.fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, app: &mut App) -> io::Result<()> { loop { app.frames += 1; terminal.draw(|frame| draw(frame, app))?;
if event::poll(TICK)? { match event::read()? { Event::Key(key) => { if key.kind == KeyEventKind::Press { if is_quit(key) { break; } app.last_key = Some(key); } } Event::Mouse(mouse) => app.last_mouse = Some(mouse), Event::Resize(w, h) => app.size = (w, h), // Focus and paste events only arrive if the program asks for // them with their own commands; rlog asks for neither, so this // arm is unreachable in practice. _ => {} } } } Ok(())}
/// The whole frame for this step: one bordered box filling the screen, with/// the frame count, the last key, the last mouse event and the terminal size/// inside it. Each line is a claim this step can check by hand.fn draw(frame: &mut Frame, app: &App) { let block = Block::bordered().title(" rlog "); frame.render_widget( Paragraph::new(vec![ Line::from(format!("frame {} — press q to quit", app.frames)), Line::from(format!("last key: {}", describe_key(app.last_key.as_ref()))), Line::from(format!( "last mouse: {}", describe_mouse(app.last_mouse.as_ref()) )), Line::from(format!("terminal: {}x{}", app.size.0, app.size.1)), ]), block.inner(frame.area()), ); frame.render_widget(block, frame.area());}
/// The keys that end the program. `Ctrl-C` arrives as a key event in raw mode,/// not as a signal, so it has to be handled here or not at all.fn is_quit(key: KeyEvent) -> bool { matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))}
/// Name a key the way the status line shows it. Modifiers come first, the key/// itself second; `None` before the first press.fn describe_key(key: Option<&KeyEvent>) -> String { let Some(key) = key else { return "(none yet)".to_string(); }; let mut name = match key.code { KeyCode::Char(c) => c.to_string(), KeyCode::Enter => "enter".to_string(), KeyCode::Esc => "esc".to_string(), KeyCode::Backspace => "backspace".to_string(), KeyCode::Tab => "tab".to_string(), KeyCode::Up => "up".to_string(), KeyCode::Down => "down".to_string(), KeyCode::Left => "left".to_string(), KeyCode::Right => "right".to_string(), KeyCode::PageUp => "pgup".to_string(), KeyCode::PageDown => "pgdn".to_string(), KeyCode::Home => "home".to_string(), KeyCode::End => "end".to_string(), other => format!("{other:?}").to_lowercase(), }; if key.modifiers.contains(KeyModifiers::CONTROL) { name = format!("ctrl-{name}"); } if key.modifiers.contains(KeyModifiers::ALT) { name = format!("alt-{name}"); } name}
/// Name a mouse event for the status line: what happened, then where. Both/// coordinates are zero-based, counted from the top-left cell.fn describe_mouse(mouse: Option<&MouseEvent>) -> String { let Some(mouse) = mouse else { return "(none yet)".to_string(); }; let kind = match mouse.kind { MouseEventKind::Down(button) => format!("press {}", button_name(button)), MouseEventKind::Up(button) => format!("release {}", button_name(button)), MouseEventKind::Drag(button) => format!("drag {}", button_name(button)), MouseEventKind::Moved => "move".to_string(), MouseEventKind::ScrollUp => "scroll up".to_string(), MouseEventKind::ScrollDown => "scroll down".to_string(), MouseEventKind::ScrollLeft => "scroll left".to_string(), MouseEventKind::ScrollRight => "scroll right".to_string(), }; format!("{} at {},{}", kind, mouse.column, mouse.row)}
fn button_name(button: MouseButton) -> &'static str { match button { MouseButton::Left => "left", MouseButton::Right => "right", MouseButton::Middle => "middle", }}This block is marked compile-only for the same reason as step 2’s: a program that enters raw mode needs a terminal to enter it, and the sandbox that checks the article’s blocks has none. The run below is the real one, the same binary the tag builds.
Running it
Section titled “Running it”cargo runThe capture is a real run on a pseudo-terminal, 80 columns by 24 rows to start, with the events a
terminal would send written into its input: the key j, a wheel-up, a click, a resize to 100×30,
and finally q. The frame count depends on how long the program ran before the key landed, so a
reader’s own number will differ. The empty middle rows are trimmed here, as in step 2’s capture.
┌ rlog ────────────────────────────────────────────────────────────────────────────────────────────┐│frame 17 — press q to quit ││last key: j ││last mouse: release left at 11,4 ││terminal: 100x30 │└──────────────────────────────────────────────────────────────────────────────────────────────────┘Three things to check in it. The key arrived and was named. The click arrived as two events and the
line kept the release. And the resize reflowed the box to 100 columns while the size line followed
it — the wheel-up from a moment earlier is gone from the mouse line, because only the last event of
each kind is kept. The click’s coordinates read 11,4 because they are zero-based: column 12, row
5 is where the terminal’s own numbering put it.
Step 4 turns the box into panes: layout constraints, the Table and List widgets, and scrollback
in the log pane — the first step that uses the wheel events this one reads. The
track overview keeps the full list current as articles land.