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

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.

Tip

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.

Note

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