Skip to content
Select theme

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:

Cargo.toml
[package]
name = "rlog"
version = "0.1.0"
edition = "2024"
[dependencies]
crossterm = "0.29"
ratatui = "0.30"

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') -> j
Char('G') -> G
Char('c') -> ctrl-c
Up -> up
PageUp -> pgup
Enter -> alt-enter
F(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.

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) then Up(Left).
  • The wheel is ScrollUp and ScrollDown, with no button attached. Steps 4 and 5 use them to scroll the log pane.
  • Motion arrives as Moved with no button, or Drag(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,4
Up(Right) -> release right at 0,23
ScrollUp -> scroll up at 11,4
Drag(Left) -> drag left at 12,5
Moved -> move at 40,12
nothing 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.

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.

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 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:

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.

Terminal window
cargo run

The 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.