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_linecrossterm::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.)

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()?) }}