Skip to content

event.rs

Most applications will have a main run loop like this:

fn main() -> Result<()> {
crossterm::terminal::enable_raw_mode()?; // enter raw mode
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;
let mut app = App::new(); // hide_line
let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?; // hide_line
// --snip--
loop {
// --snip--
terminal.draw(|f| { // <- `terminal.draw` is the only ratatui function here // hide_line
ui(app, f) // render state to terminal // hide_line
})?;
}
crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?;
crossterm::terminal::disable_raw_mode()?; // exit raw mode
Ok(())
}

While the application is in the “raw mode”, any key presses in that terminal window are sent to stdin. We have to make sure that the application reads these key presses from stdin if we want to act on them.

In the tutorials up until now, we have been using crossterm::event::poll() and crossterm::event::read(), like so:

fn main() -> Result { // hide_line
let mut app = App::new(); // hide_line
// hide_line
let mut t = Tui::new()?; // hide_line
// hide_line
t.enter()?; // hide_line
// hide_line
loop {
// crossterm::event::poll() here will block for a maximum 250ms
// will return true as soon as key is available to read
if crossterm::event::poll(Duration::from_millis(250))? {
// crossterm::event::read() blocks till it can read single key
// when used with poll, key is always available
if let Event::Key(key) = crossterm::event::read()? {
if key.kind == event::KeyEventKind::Press {
match key.code {
KeyCode::Char('j') => app.increment(),
KeyCode::Char('k') => app.decrement(),
KeyCode::Char('q') => break,
_ => {},
}
}
}
};
t.terminal.draw(|f| {
ui(app, f)
})?;
}
// hide_line
t.exit()?; // hide_line
// hide_line
Ok(()) // hide_line
} // hide_line

crossterm::event::poll() blocks till a key is received on stdin, at which point it returns true and crossterm::event::read() reads the single key event.

This works perfectly fine, and a lot of small to medium size programs can get away with doing just that.

However, this approach conflates the key input handling with app state updates, and does so in the “draw” loop. The practical issue with this approach is we block the draw loop for 250 ms waiting for a key press. This can have odd side effects, for example pressing and holding a key will result in faster draws to the terminal. You can try this out by pressing and holding any key and watching your CPU usage using top or htop.

In terms of architecture, the code could get complicated to reason about. For example, we may even want key presses to mean different things depending on the state of the app (when you are focused on an input field, you may want to enter the letter "j" into the text input field, but when focused on a list of items, you may want to scroll down the list.)

Pressing j 3 times to increment counter and 3 times in the text field

We have to do a few different things set ourselves up, so let’s take things one step at a time.

First, instead of polling, we are going to introduce channels to get the key presses “in the background” and send them over a channel. We will then receive these events on the channel in the main loop.

Let’s create an Event enum to handle the different kinds of events that can occur:

use crossterm::event::{KeyEvent, MouseEvent};
/// Terminal events.
#[derive(Clone, Copy, Debug)]
pub enum Event {
/// Terminal tick.
Tick,
/// Key press.
Key(KeyEvent),
/// Mouse click/scroll.
Mouse(MouseEvent),
/// Terminal resize.
Resize(u16, u16),
}

Next, let’s create an EventHandler struct:

use std::{sync::mpsc, thread};
/// Terminal event handler.
#[derive(Debug)]
pub struct EventHandler {
/// Event sender channel.
#[allow(dead_code)]
sender: mpsc::Sender<Event>,
/// Event receiver channel.
receiver: mpsc::Receiver<Event>,
/// Event handler thread.
#[allow(dead_code)]
handler: thread::JoinHandle<()>,
}

We are using std::sync::mpsc which is a “Multiple Producer Single Consumer” channel.

In Rust, channels are particularly useful for sending data between threads without the need for locks or other synchronization mechanisms. The “Multiple Producer, Single Consumer” aspect of std::sync::mpsc means that while multiple threads can send data into the channel, only a single thread can retrieve and process this data, ensuring a clear and orderly flow of information.

Finally, here’s the code that starts a thread that polls for events from crossterm and maps it to our Event enum.

use std::{
sync::mpsc,
thread,
time::{Duration, Instant},
};
use color_eyre::Result;
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};
// -- snip --
impl EventHandler {
/// Constructs a new instance of [`EventHandler`].
pub fn new(tick_rate: u64) -> Self {
let tick_rate = Duration::from_millis(tick_rate);
let (sender, receiver) = mpsc::channel();
let handler = {
let sender = sender.clone();
thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(tick_rate);
if event::poll(timeout).expect("unable to poll for event") {
match event::read().expect("unable to read event") {
CrosstermEvent::Key(e) => {
if e.kind == event::KeyEventKind::Press {
sender.send(Event::Key(e))
} else {
Ok(()) // ignore KeyEventKind::Release on windows
}
}
CrosstermEvent::Mouse(e) => {
sender.send(Event::Mouse(e))
}
CrosstermEvent::Resize(w, h) => {
sender.send(Event::Resize(w, h))
}
_ => unimplemented!(),
}
.expect("failed to send terminal event")
}
if last_tick.elapsed() >= tick_rate {
sender
.send(Event::Tick)
.expect("failed to send tick event");
last_tick = Instant::now();
}
}
})
};
Self {
sender,
receiver,
handler,
}
}
/// Receive the next event from the handler thread.
///
/// This function will always block the current thread if
/// there is no data available and it's possible for more data to be sent.
pub fn next(&self) -> Result<Event> {
Ok(self.receiver.recv()?)
}
}

At the beginning of our EventHandler::new method, we create a channel using mpsc::channel().

let (sender, receiver) = mpsc::channel();

This gives us a sender and receiver pair. The sender can be used to send events, while the receiver can be used to receive them.

Notice that we are using std::thread::spawn in this EventHandler. This thread is spawned to handle events and runs in the background and is responsible for polling and sending events to our main application through the channel.

In this background thread, we continuously poll for events with event::poll(timeout). If an event is available, it’s read and sent through the sender channel. The types of events we handle include, keypresses, mouse movements, screen resizing, and regular time ticks.

if event::poll(timeout)? {
match event::read()? {
CrosstermEvent::Key(e) => {
if e.kind == event::KeyEventKind::Press {
sender.send(Event::Key(e))
} else {
Ok(()) // ignore KeyEventKind::Release on windows
}
},
CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e))?,
CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h))?,
_ => unimplemented!(),
}
}

We expose the receiver channel as part of a next() method.

pub fn next(&self) -> Result<Event> {
Ok(self.receiver.recv()?)
}

Calling event_handler.next() method will call receiver.recv() which will cause the thread to block until the receiver gets a new event.

Finally, we update the last_tick value based on the time elapsed since the previous Tick. We also send a Event::Tick on the channel during this.

if last_tick.elapsed() >= tick_rate {
sender.send(Event::Tick).expect("failed to send tick event");
last_tick = Instant::now();
}

In summary, our EventHandler abstracts away the complexity of event polling and handling into a dedicated background thread.

Here’s the full code for your reference:

use std::{
sync::mpsc,
thread,
time::{Duration, Instant},
};
use color_eyre::Result;
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};
/// Terminal events.
#[derive(Clone, Copy, Debug)]
pub enum Event {
/// Terminal tick.
Tick,
/// Key press.
Key(KeyEvent),
/// Mouse click/scroll.
Mouse(MouseEvent),
/// Terminal resize.
Resize(u16, u16),
}
/// Terminal event handler.
#[derive(Debug)]
pub struct EventHandler {
/// Event sender channel.
#[allow(dead_code)]
sender: mpsc::Sender<Event>,
/// Event receiver channel.
receiver: mpsc::Receiver<Event>,
/// Event handler thread.
#[allow(dead_code)]
handler: thread::JoinHandle<()>,
}
impl EventHandler {
/// Constructs a new instance of [`EventHandler`].
pub fn new(tick_rate: u64) -> Self {
let tick_rate = Duration::from_millis(tick_rate);
let (sender, receiver) = mpsc::channel();
let handler = {
let sender = sender.clone();
thread::spawn(move || {
let mut last_tick = Instant::now();
loop {
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(tick_rate);
if event::poll(timeout).expect("unable to poll for event") {
match event::read().expect("unable to read event") {
CrosstermEvent::Key(e) => {
if e.kind == event::KeyEventKind::Press {
sender.send(Event::Key(e))
} else {
Ok(()) // ignore KeyEventKind::Release on windows
}
}
CrosstermEvent::Mouse(e) => {
sender.send(Event::Mouse(e))
}
CrosstermEvent::Resize(w, h) => {
sender.send(Event::Resize(w, h))
}
_ => unimplemented!(),
}
.expect("failed to send terminal event")
}
if last_tick.elapsed() >= tick_rate {
sender
.send(Event::Tick)
.expect("failed to send tick event");
last_tick = Instant::now();
}
}
})
};
Self {
sender,
receiver,
handler,
}
}
/// Receive the next event from the handler thread.
///
/// This function will always block the current thread if
/// there is no data available and it's possible for more data to be sent.
pub fn next(&self) -> Result<Event> {
Ok(self.receiver.recv()?)
}
}