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();
let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;
// --snip--
loop {
// --snip--
terminal.draw(|f| { // <- `terminal.draw` is the only ratatui function here
ui(app, f) // render state to terminal
})?;
}
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 {
let mut app = App::new();
let mut t = Tui::new()?;
t.enter()?;
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)
})?;
}
t.exit()?;
Ok(())
}
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.)
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.
A channel is a thread-safe communication mechanism that allows data to be transmitted between threads. Essentially, it’s a conduit where one or more threads (the producers) can send data, and another thread (the consumer) can receive this data.
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.
In the code in this section, we only need a “Single Producer, Single Consumer” but we are going to
use mpsc
to set us up for the future.
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 anyhow::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 the
async counter tutorial we will use
tokio::task::spawn
instead.
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 anyhow::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()?)
}
}