Skip to content

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

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