Tui.rs
Terminal
In this section of the tutorial, we are going to discuss the basic components of the Tui
struct.
You’ll find most people setup and teardown of a terminal application using crossterm
like so:
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> { let mut stdout = io::stdout(); crossterm::terminal::enable_raw_mode()?; crossterm::execute!(stdout, EnterAlternateScreen, EnableMouseCapture, HideCursor)?; Terminal::new(CrosstermBackend::new(stdout))}
fn teardown_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> { let mut stdout = io::stdout(); crossterm::terminal::disable_raw_mode()?; crossterm::execute!(stdout, LeaveAlternateScreen, DisableMouseCapture, ShowCursor)?; Ok(())}
fn main() -> Result<()> { let mut terminal = setup_terminal()?; run_app(&mut terminal)?; teardown_terminal(&mut terminal)?; Ok(())}
You can use termion
or termwiz
instead here, and you’ll have to change the implementation of
setup_terminal
and teardown_terminal
.
I personally like to use crossterm
so that I can run the TUI on windows as well.
We can reorganize the setup and teardown functions into an enter()
and exit()
methods on a Tui
struct.
use color_eyre::eyre::Result;use ratatui::crossterm::{ cursor, event::{DisableMouseCapture, EnableMouseCapture}, terminal::{EnterAlternateScreen, LeaveAlternateScreen},};use ratatui::backend::CrosstermBackend as Backend;use tokio::{ sync::{mpsc, Mutex}, task::JoinHandle,};
pub type Frame<'a> = ratatui::Frame<'a, Backend<std::io::Stderr>>;
pub struct Tui { pub terminal: ratatui::Terminal<Backend<std::io::Stderr>>,}
impl Tui { pub fn new() -> Result<Self> { let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?; Ok(Self { terminal }) }
pub fn enter(&self) -> Result<()> { crossterm::terminal::enable_raw_mode()?; crossterm::execute!(std::io::stderr(), EnterAlternateScreen, EnableMouseCapture, cursor::Hide)?; Ok(()) }
pub fn exit(&self) -> Result<()> { crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, DisableMouseCapture, cursor::Show)?; crossterm::terminal::disable_raw_mode()?; Ok(()) }
pub fn suspend(&self) -> Result<()> { self.exit()?; #[cfg(not(windows))] signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; Ok(()) }
pub fn resume(&self) -> Result<()> { self.enter()?; Ok(()) }}
Feel free to modify this as you need for use with termion
or wezterm
.
The type alias to Frame
is only to make the components
folder easier to work with, and is not
strictly required.
Event
In it’s simplest form, most applications will have a main
loop like this:
fn main() -> Result<()> { let mut app = App::new();
let mut t = Tui::new()?;
t.enter()?; // raw mode enabled
loop {
// get key event and update state // ... Special handling to read key or mouse events required here
t.terminal.draw(|f| { // <- `terminal.draw` is the only ratatui function here ui(app, f) // render state to terminal })?;
}
t.exit()?; // raw mode disabled
Ok(())}
While we are in the “raw mode”, i.e. after we call t.enter()
, any key presses in that terminal
window are sent to stdin
. We have to read these key presses from stdin
if we want to act on
them.
There’s a number of different ways to do that. crossterm
has a event
module that implements
features to read these key presses for us.
Let’s assume we were building a simple “counter” application, that incremented a counter when we
pressed j
and decremented a counter when we pressed k
.
fn main() -> Result { let mut app = App::new();
let mut t = Tui::new()?;
t.enter()?;
loop { if crossterm::event::poll(Duration::from_millis(250))? { if let Event::Key(key) = crossterm::event::read()? { 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(())}
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 an holding a key will result in faster draws to the terminal.
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 asynchronously
and send them over a channel. We will then receive on the channel in the main
loop.
There are two ways to do this. We can either use OS threads or “green” threads, i.e. tasks, i.e.
rust’s async
-await
features + a future executor.
Here’s example code of reading key presses asynchronously using std::thread
and tokio::task
.
std::thread
enum Event { Key(crossterm::event::KeyEvent)}
struct EventHandler { rx: std::sync::mpsc::Receiver<Event>,}
impl EventHandler { fn new() -> Self { let tick_rate = std::time::Duration::from_millis(250); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { loop { if crossterm::event::poll(tick_rate)? { match crossterm::event::read()? { CrosstermEvent::Key(e) => tx.send(Event::Key(e)), _ => unimplemented!(), }? } } })
EventHandler { rx } }
fn next(&self) -> Result<Event> { Ok(self.rx.recv()?) }}
tokio::task
enum Event { Key(crossterm::event::KeyEvent)}
struct EventHandler { rx: tokio::sync::mpsc::UnboundedReceiver<Event>,}
impl EventHandler { fn new() -> Self { let tick_rate = std::time::Duration::from_millis(250); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); tokio::spawn(async move { loop { if crossterm::event::poll(tick_rate)? { match crossterm::event::read()? { CrosstermEvent::Key(e) => tx.send(Event::Key(e)), _ => unimplemented!(), }? } } })
EventHandler { rx } }
async fn next(&self) -> Result<Event> { Ok(self.rx.recv().await.ok()?) }}
diff
enum Event { Key(crossterm::event::KeyEvent)}
struct EventHandler { rx: std::sync::mpsc::Receiver<Event>, rx: tokio::sync::mpsc::UnboundedReceiver<Event>,}
impl EventHandler { fn new() -> Self { let tick_rate = std::time::Duration::from_millis(250); let (tx, rx) = std::sync::mpsc::channel(); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); std::thread::spawn(move || { tokio::spawn(async move { loop { if crossterm::event::poll(tick_rate)? { match crossterm::event::read()? { CrosstermEvent::Key(e) => tx.send(Event::Key(e)), _ => unimplemented!(), }? } } })
EventHandler { rx } }
fn next(&self) -> Result<Event> { async fn next(&self) -> Result<Event> { Ok(self.rx.recv()?) Ok(self.rx.recv().await.ok()?) }}
Tokio is an asynchronous runtime for the Rust programming language. It is one of the more popular
runtimes for asynchronous programming in rust. You can learn more about here
https://tokio.rs/tokio/tutorial. For the rest of the tutorial here, we are going to assume we want
to use tokio. I highly recommend you read the official tokio
documentation.
If we use tokio
, receiving a event requires .await
. So our main
loop now looks like this:
#[tokio::main]async fn main() -> { let mut app = App::new();
let events = EventHandler::new();
let mut t = Tui::new()?;
t.enter()?;
loop { if let Event::Key(key) = events.next().await? { 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(())}
Additional improvements
We are going to modify our EventHandler
to handle a AppTick
event. We want the Event::AppTick
to be sent at regular intervals. We are also going to want to use a CancellationToken
to stop the
tokio task on request.
tokio
’s select!
macro allows us to wait on multiple
async
computations and returns when a single computation completes.
Here’s what the completed EventHandler
code now looks like:
use color_eyre::eyre::Result;use ratatui::crossterm::{ cursor, event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},};use futures::{FutureExt, StreamExt};use tokio::{ sync::{mpsc, oneshot}, task::JoinHandle,};
#[derive(Clone, Copy, Debug)]pub enum Event { Error, AppTick, Key(KeyEvent),}
#[derive(Debug)]pub struct EventHandler { _tx: mpsc::UnboundedSender<Event>, rx: mpsc::UnboundedReceiver<Event>, task: Option<JoinHandle<()>>, stop_cancellation_token: CancellationToken,}
impl EventHandler { pub fn new(tick_rate: u64) -> Self { let tick_rate = std::time::Duration::from_millis(tick_rate);
let (tx, rx) = mpsc::unbounded_channel(); let _tx = tx.clone();
let stop_cancellation_token = CancellationToken::new(); let _stop_cancellation_token = stop_cancellation_token.clone();
let task = tokio::spawn(async move { let mut reader = crossterm::event::EventStream::new(); let mut interval = tokio::time::interval(tick_rate); loop { let delay = interval.tick(); let crossterm_event = reader.next().fuse(); tokio::select! { _ = _stop_cancellation_token.cancelled() => { break; } maybe_event = crossterm_event => { match maybe_event { Some(Ok(evt)) => { match evt { CrosstermEvent::Key(key) => { if key.kind == KeyEventKind::Press { tx.send(Event::Key(key)).unwrap(); } }, _ => {}, } } Some(Err(_)) => { tx.send(Event::Error).unwrap(); } None => {}, } }, _ = delay => { tx.send(Event::AppTick).unwrap(); }, } } });
Self { _tx, rx, task: Some(task), stop_cancellation_token } }
pub async fn next(&mut self) -> Option<Event> { self.rx.recv().await }
pub async fn stop(&mut self) -> Result<()> { self.stop_cancellation_token.cancel(); if let Some(handle) = self.task.take() { handle.await.unwrap(); } Ok(()) }}
With this EventHandler
implemented, we can use tokio
to create a separate “task” that handles
any key asynchronously in our main
loop.
I personally like to combine the EventHandler
and the Tui
struct into one struct. Here’s an
example of that Tui
struct for your reference.
use std::{ io::{stderr, Stderr}, ops::{Deref, DerefMut}, time::Duration,};
use color_eyre::eyre::Result;use futures::{FutureExt, StreamExt};use ratatui::{ backend::CrosstermBackend, crossterm::{ cursor, event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent}, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, },};use serde::{Deserialize, Serialize};use tokio::{ sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, task::JoinHandle,};use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug, Serialize, Deserialize)]pub enum Event { Init, Quit, Error, Closed, Tick, Render, FocusGained, FocusLost, Paste(String), Key(KeyEvent), Mouse(MouseEvent), Resize(u16, u16),}
pub struct Tui { pub terminal: ratatui::Terminal<CrosstermBackend<Stderr>>, pub task: JoinHandle<()>, pub cancellation_token: CancellationToken, pub event_rx: UnboundedReceiver<Event>, pub event_tx: UnboundedSender<Event>, pub frame_rate: f64, pub tick_rate: f64,}
impl Tui { pub fn new() -> Result<Self> { let tick_rate = 4.0; let frame_rate = 60.0; let terminal = ratatui::Terminal::new(CrosstermBackend::new(stderr()))?; let (event_tx, event_rx) = mpsc::unbounded_channel(); let cancellation_token = CancellationToken::new(); let task = tokio::spawn(async {}); Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate }) }
pub fn tick_rate(&mut self, tick_rate: f64) { self.tick_rate = tick_rate; }
pub fn frame_rate(&mut self, frame_rate: f64) { self.frame_rate = frame_rate; }
pub fn start(&mut self) { let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate); let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate); self.cancel(); self.cancellation_token = CancellationToken::new(); let _cancellation_token = self.cancellation_token.clone(); let _event_tx = self.event_tx.clone(); self.task = tokio::spawn(async move { let mut reader = crossterm::event::EventStream::new(); let mut tick_interval = tokio::time::interval(tick_delay); let mut render_interval = tokio::time::interval(render_delay); _event_tx.send(Event::Init).unwrap(); loop { let tick_delay = tick_interval.tick(); let render_delay = render_interval.tick(); let crossterm_event = reader.next().fuse(); tokio::select! { _ = _cancellation_token.cancelled() => { break; } maybe_event = crossterm_event => { match maybe_event { Some(Ok(evt)) => { match evt { CrosstermEvent::Key(key) => { if key.kind == KeyEventKind::Press { _event_tx.send(Event::Key(key)).unwrap(); } }, CrosstermEvent::Mouse(mouse) => { _event_tx.send(Event::Mouse(mouse)).unwrap(); }, CrosstermEvent::Resize(x, y) => { _event_tx.send(Event::Resize(x, y)).unwrap(); }, CrosstermEvent::FocusLost => { _event_tx.send(Event::FocusLost).unwrap(); }, CrosstermEvent::FocusGained => { _event_tx.send(Event::FocusGained).unwrap(); }, CrosstermEvent::Paste(s) => { _event_tx.send(Event::Paste(s)).unwrap(); }, } } Some(Err(_)) => { _event_tx.send(Event::Error).unwrap(); } None => {}, } }, _ = tick_delay => { _event_tx.send(Event::Tick).unwrap(); }, _ = render_delay => { _event_tx.send(Event::Render).unwrap(); }, } } }); }
pub fn stop(&self) -> Result<()> { self.cancel(); let mut counter = 0; while !self.task.is_finished() { std::thread::sleep(Duration::from_millis(1)); counter += 1; if counter > 50 { self.task.abort(); } if counter > 100 { log::error!("Failed to abort task in 100 milliseconds for unknown reason"); break; } } Ok(()) }
pub fn enter(&mut self) -> Result<()> { crossterm::terminal::enable_raw_mode()?; crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?; self.start(); Ok(()) }
pub fn exit(&mut self) -> Result<()> { self.stop()?; if crossterm::terminal::is_raw_mode_enabled()? { self.flush()?; crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?; crossterm::terminal::disable_raw_mode()?; } Ok(()) }
pub fn cancel(&self) { self.cancellation_token.cancel(); }
pub fn suspend(&mut self) -> Result<()> { self.exit()?; #[cfg(not(windows))] signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?; Ok(()) }
pub fn resume(&mut self) -> Result<()> { self.enter()?; Ok(()) }
pub async fn next(&mut self) -> Option<Event> { self.event_rx.recv().await }}
impl Deref for Tui { type Target = ratatui::Terminal<CrosstermBackend<Stderr>>;
fn deref(&self) -> &Self::Target { &self.terminal }}
impl DerefMut for Tui { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.terminal }}
impl Drop for Tui { fn drop(&mut self) { self.exit().unwrap(); }}
In the next section, we will introduce a Command
pattern to bridge handling the effect of an
event.