Home

async-template

DEPRECATED: Use https://github.com/ratatui-org/templates instead

cargo generate ratatui-org/templates async

Features

  • Uses tokio for async events
    • Start and stop key events to shell out to another TUI like vim
    • Supports suspend signal hooks
  • Logs using tracing
  • better-panic
  • color-eyre
  • human-panic
  • Clap for command line argument parsing
  • Component trait with Home and Fps components as examples

Usage

You can start by using cargo-generate:

cargo install cargo-generate
cargo generate --git https://github.com/ratatui-org/async-template --name ratatui-hello-world
cd ratatui-hello-world

You can also use a template.toml file to skip the prompts:

$ cargo generate --git https://github.com/ratatui-org/async-template --template-values-file .github/workflows/template.toml --name ratatui-hello-world
# OR generate from local clone
$ cargo generate --path . --template-values-file .github/workflows/template.toml --name ratatui-hello-world

Run

cargo run # Press `q` to exit

Show help

$ cargo run -- --help
Hello World project using ratatui-template

Usage: ratatui-hello-world [OPTIONS]

Options:
  -t, --tick-rate <FLOAT>   Tick rate, i.e. number of ticks per second [default: 1]
  -f, --frame-rate <FLOAT>  Frame rate, i.e. number of frames per second [default: 60]
  -h, --help                Print help
  -V, --version             Print version

Show version

Without direnv variables:

$ cargo run -- --version
    Finished dev [unoptimized + debuginfo] target(s) in 0.07s
     Running `target/debug/ratatui-hello-world --version`
ratatui-hello-world v0.1.0-47-eb0a31a

Authors: Dheepak Krishnamurthy

Config directory: /Users/kd/Library/Application Support/com.kdheepak.ratatui-hello-world
Data directory: /Users/kd/Library/Application Support/com.kdheepak.ratatui-hello-world

With direnv variables:

$ direnv allow
direnv: loading ~/gitrepos/async-template/ratatui-hello-world/.envrc
direnv: export +RATATUI_HELLO_WORLD_CONFIG +RATATUI_HELLO_WORLD_DATA +RATATUI_HELLO_WORLD_LOG_LEVEL

$ # OR

$ export RATATUI_HELLO_WORLD_CONFIG=`pwd`/.config
$ export RATATUI_HELLO_WORLD_DATA=`pwd`/.data
$ export RATATUI_HELLO_WORLD_LOG_LEVEL=debug

$ cargo run -- --version
    Finished dev [unoptimized + debuginfo] target(s) in 0.07s
     Running `target/debug/ratatui-hello-world --version`
ratatui-hello-world v0.1.0-47-eb0a31a

Authors: Dheepak Krishnamurthy

Config directory: /Users/kd/gitrepos/async-template/ratatui-hello-world/.config
Data directory: /Users/kd/gitrepos/async-template/ratatui-hello-world/.data

Documentation

Read documentation on design decisions in the template here: https://ratatui-org.github.io/async-template/

Counter + Text Input Demo

This repo contains a ratatui-counter folder that is a working demo as an example. If you wish to run a demo without using cargo generate, you can run the counter + text input demo by following the instructions below:

git clone https://github.com/ratatui-org/async-template
cd async-template
cd ratatui-counter # counter + text input demo

export RATATUI_COUNTER_CONFIG=`pwd`/.config
export RATATUI_COUNTER_DATA=`pwd`/.data
export RATATUI_COUNTER_LOG_LEVEL=debug
# OR
direnv allow

cargo run

You should see a demo like this:

Background

ratatui is a Rust library to build rich terminal user interfaces (TUIs) and dashboards. It is a community fork of the original tui-rs created to maintain and improve the project.

The source code of this project is an opinionated template for getting up and running with ratatui. You can pick and choose the pieces of this async-template to suit your needs and sensibilities. This rest of this documentation is a walk-through of why the code is structured the way it is, so that you are aided in modifying it as you require.

ratatui is based on the principle of immediate rendering with intermediate buffers. This means that at each new frame you have to build all widgets that are supposed to be part of the UI. In short, the ratatui library is largely handles just drawing to the terminal.

Additionally, the library does not provide any input handling nor any event system. The responsibility of getting keyboard input events, modifying the state of your application based on those events and figuring out which widgets best reflect the view of the state of your application is on you.

The ratatui-org project has added a template that covers the basics, and you find that here: https://github.com/ratatui-org/rust-tui-template.

I wanted to take another stab at a template, one that uses tokio and organizes the code a little differently. This is an opinionated view on how to organize a ratatui project.

Info

Since ratatui is a immediate mode rendering based library, there are multiple ways to organize your code, and there’s no real “right” answer. Choose whatever works best for you!

This project also adds commonly used dependencies like logging, command line arguments, configuration options, etc.

As part of this documentation, we’ll walk through some of the different ways you may choose to organize your code and project in order to build a functioning terminal user interface. You can pick and choose the parts you like.

You may also want to check out the following links (roughly in order of increasing complexity):

Structure of files

The rust files in the async-template project are organized as follows:

$ tree
.
├── build.rs
└── src
   ├── action.rs
   ├── components
   │  ├── app.rs
   │  └── mod.rs
   ├── config.rs
   ├── main.rs
   ├── runner.rs
   ├── tui.rs
   └── utils.rs

Once you have setup the project, you shouldn’t need to change the contents of anything outside of the components folder.

Let’s discuss the contents of the files in the src folder first, how these contents of these files interact with each other and why they do what they are doing.

main.rs

In this section, let’s just cover the contents of main.rs, build.rs and utils.rs.

The main.rs file is the entry point of the application. Here’s the complete main.rs file:

pub mod action;
pub mod app;
pub mod cli;
pub mod components;
pub mod config;
pub mod tui;
pub mod utils;

use clap::Parser;
use cli::Cli;
use color_eyre::eyre::Result;

use crate::{
  app::App,
  utils::{initialize_logging, initialize_panic_handler, version},
};

async fn tokio_main() -> Result<()> {
  initialize_logging()?;

  initialize_panic_handler()?;

  let args = Cli::parse();
  let mut app = App::new(args.tick_rate, args.frame_rate)?;
  app.run().await?;

  Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
  if let Err(e) = tokio_main().await {
    eprintln!("{} error: Something went wrong", env!("CARGO_PKG_NAME"));
    Err(e)
  } else {
    Ok(())
  }
}

In essence, the main function creates an instance of App and calls App.run(), which runs the “handle event -> update state -> draw” loop. We will talk more about this in a later section.

This main.rs file incorporates some key features that are not necessarily related to ratatui, but in my opinion, essential for any Terminal User Interface (TUI) program:

  • Command Line Argument Parsing (clap)
  • XDG Base Directory Specification
  • Logging
  • Panic Handler

These are described in more detail in the utils.rs section.

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.

Note

Terminals have two screen buffers for each window. The default screen buffer is what you are dropped into when you start up a terminal. The second screen buffer, called the alternate screen, is used for running interactive apps such as the vim, less etc.

Here’s a 8 minute talk on Terminal User Interfaces I gave at JuliaCon2020: https://www.youtube.com/watch?v=-TASx67pphw that might be worth watching for more information about how terminal user interfaces work.

We can reorganize the setup and teardown functions into an enter() and exit() methods on a Tui struct.

use color_eyre::eyre::{anyhow, Context, Result};
use 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(())
  }
}

Note

This is the same Tui struct we used in initialize_panic_handler(). We call Tui::exit() before printing the stacktrace.

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

Note

The terminal.draw(|f| { ui(app, f); }) call is the only line in the code above that uses ratatui functionality. You can learn more about draw from the official documentation. Essentially, terminal.draw() takes a callback that takes a Frame and expects the callback to render widgets to that frame, which is then drawn to the terminal using a double buffer technique.

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

Warning

A lot of examples out there in the wild might use the following code for sending key presses:

  CrosstermEvent::Key(e) => tx.send(Event::Key(e)),

However, on Windows, when using Crossterm, this will send the same Event::Key(e) twice; one for when you press the key, i.e. KeyEventKind::Press and one for when you release the key, i.e. KeyEventKind::Release. On MacOS and Linux only KeyEventKind::Press kinds of key event is generated.

To make the code work as expected across all platforms, you can do this instead:

  CrosstermEvent::Key(key) => {
    if key.kind == KeyEventKind::Press {
      event_tx.send(Event::Key(key)).unwrap();
    }
  },

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

Note

Using crossterm::event::EventStream::new() requires the event-stream feature to be enabled.

crossterm = { version = "0.26.1", default-features = false, features = ["event-stream"] }

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::{
  ops::{Deref, DerefMut},
  time::Duration,
};

use color_eyre::eyre::Result;
use crossterm::{
  cursor,
  event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
  terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::{FutureExt, StreamExt};
use ratatui::backend::CrosstermBackend as Backend;
use serde::{Deserialize, Serialize};
use tokio::{
  sync::mpsc::{self, UnboundedReceiver, UnboundedSender},
  task::JoinHandle,
};
use tokio_util::sync::CancellationToken;

pub type Frame<'a> = ratatui::Frame<'a, Backend<std::io::Stderr>>;

#[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<Backend<std::io::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(Backend::new(std::io::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<Backend<std::io::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.

action.rs

Now that we have created a Tui and EventHandler, we are also going to introduce the Command pattern.

Tip

The Command pattern is the concept of “reified method calls”. You can learn a lot more about this pattern from the excellent http://gameprogrammingpatterns.com.

These are also typically called Actions or Messages.

Note

It should come as no surprise that building a terminal user interface using ratatui (i.e. an immediate mode rendering library) has a lot of similarities with game development or user interface libraries. For example, you’ll find these domains all have their own version of “input handling”, “event loop” and “draw” step.

If you are coming to ratatui with a background in Elm or React, or if you are looking for a framework that extends the ratatui library to provide a more standard UI design paradigm, you can check out tui-realm for a more featureful out of the box experience.

pub enum Action {
  Quit,
  Tick,
  Increment,
  Decrement,
  Noop,
}

Tip

You can attach payloads to enums in rust. For example, in the following Action enum, Increment(usize) and Decrement(usize) have a usize payload which can be used to represent the value to add to or subtract from the counter as a payload.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
  Quit,
  Tick,
  Increment(usize),
  Decrement(usize),
  Noop,
}

Also, note that we are using Noop here as a variant that means “no operation”. You can remove Noop from Action and return Option<Action> instead of Action, using Rust’s built in None type to represent “no operation”.

Let’s define a simple impl App such that every Event from the EventHandler is mapped to an Action from the enum.

#[derive(Default)]
struct App {
  counter: i64,
  should_quit: bool,
}

impl App {
  pub fn new() -> Self {
    Self::default()
  }

  pub async fn run(&mut self) -> Result<()> {
    let t = Tui::new();
    t.enter();
    let mut events = EventHandler::new(tick_rate);
    loop {
      let event = events.next().await;
      let action = self.handle_events(event);
      self.update(action);
      t.terminal.draw(|f| self.draw(f))?;
      if self.should_quit {
        break
      }
    };
    t.exit();
    Ok(())
  }

  fn handle_events(&mut self, event: Option<Event>) -> Action {
    match event {
      Some(Event::Quit) => Action::Quit,
      Some(Event::AppTick) => Action::Tick,
      Some(Event::Key(key_event)) => {
        if let Some(key) = event {
            match key.code {
              KeyCode::Char('q') => Action::Quit,
              KeyCode::Char('j') => Action::Increment,
              KeyCode::Char('k') => Action::Decrement
              _ => {}
          }
        }
      },
      Some(_) => Action::Noop,
      None => Action::Noop,
    }
  }

  fn update(&mut self, action: Action) {
    match action {
      Action::Quit => self.should_quit = true,
      Action::Tick => self.tick(),
      Action::Increment => self.increment(),
      Action::Decrement => self.decrement(),
  }

  fn increment(&mut self) {
    self.counter += 1;
  }

  fn decrement(&mut self) {
    self.counter -= 1;
  }

  fn draw(&mut self, f: &mut Frame<'_>) {
    f.render_widget(
      Paragraph::new(format!(
        "Press j or k to increment or decrement.\n\nCounter: {}",
        self.counter
      ))
    )
  }
}

We use handle_events(event) -> Action to take a Event and map it to a Action. We use update(action) to take an Action and modify the state of the app.

One advantage of this approach is that we can modify handle_key_events() to use a key configuration if we’d like, so that users can define their own map from key to action.

Another advantage of this is that the business logic of the App struct can be tested without having to create an instance of a Tui or EventHandler, e.g.:

mod tests {
  #[test]
  fn test_app() {
    let mut app = App::new();
    let old_counter = app.counter;
    app.update(Action::Increment);
    assert!(app.counter == old_counter + 1);
  }
}

In the test above, we did not create an instance of the Tui or the EventHandler, and did not call the run function, but we are still able to test the business logic of our application. Updating the app state on Actions gets us one step closer to making our application a “state machine”, which improves understanding and testability.

If we wanted to be purist about it, we would make our AppState immutable, and we would have an update function like so:

fn update(app_state::AppState, action::Action) -> new_app_state::State {
  let mut state = app_state.clone();
  state.counter += 1;
  // ...
  state
}

In rare occasions, we may also want to choose a future action during update.

fn update(app_state::AppState, action::Action) -> (new_app_state::State, Option<action::Action>) {
  let mut state = app_state.clone();
  state.counter += 1;
  // ...
  (state, Action::Tick)
}

Note

In Charm’s bubbletea, this function is called an Update. Here’s an example of what that might look like:

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {

    // Is it a key press?
    case tea.KeyMsg:
        // These keys should exit the program.
        case "q":
            return m, tea.Quit

        case "k":
            m.counter--

        case "j":
            m.counter++
    }

    // Note that we're not returning a command.
    return m, nil
}

Writing code to follow this architecture in rust (in my opinion) requires more upfront design, mostly because you have to make your AppState struct Clone-friendly. If I were in an exploratory or prototype stage of a TUI, I wouldn’t want to do that and would only be interested in refactoring it this way once I got a handle on the design.

My workaround for this (as you saw earlier) is to make update a method that takes a &mut self:

impl App {
  fn update(&mut self, action: Action) -> Option<Action> {
    self.counter += 1
    None
  }
}

You are free to reorganize the code as you see fit!

You can also add more actions as required. For example, here’s all the actions in the template:

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Display, Deserialize)]
pub enum Action {
  Tick,
  Render,
  Resize(u16, u16),
  Suspend,
  Resume,
  Quit,
  Refresh,
  Error(String),
  Help,
  ToggleShowHelp,
  ScheduleIncrement,
  ScheduleDecrement,
  Increment(usize),
  Decrement(usize),
  CompleteInput(String),
  EnterNormal,
  EnterInsert,
  EnterProcessing,
  ExitProcessing,
  Update,
}

Note

We are choosing to use serde for Action so that we can allow users to decide which key event maps to which Action using a file for configuration. This is discussed in more detail in the config section.

app.rs

Finally, putting all the pieces together, we are almost ready to get the Run struct. Before we do, we should discuss the process of a TUI.

Most TUIs are single process, single threaded applications.


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  Get
  Key
  Event
  Update
  State
  Draw
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
  

When an application is structured like this, the TUI is blocking at each step:

  1. Waiting for a Event.
    • If no key or mouse event in 250ms, send Tick.
  2. Update the state of the app based on event or action.
  3. draw the state of the app to the terminal using ratatui.

This works perfectly fine for small applications, and this is what I recommend starting out with. For most TUIs, you’ll never need to graduate from this process methodology.

Usually, draw and get_events are fast enough that it doesn’t matter. But if you do need to do a computationally demanding or I/O intensive task while updating state (e.g. reading a database, computing math or making a web request), your app may “hang” while it is doing so.

Let’s say a user presses j to scroll down a list. And every time the user presses j you want to check the web for additional items to add to the list.

What should happen when a user presses and holds j? It is up to you to decide how you would like your TUI application to behave in that instance.

You may decide that the desired behavior for your app is to hang while downloading new elements for the list, and all key presses while the app hangs are received and handled “instantly” after the download completes.

Or you may decide to flush all keyboard events so they are not buffered, and you may want to implement something like the following:

let mut app = App::new();
loop {
  // ...
  let before_draw = Instant::now();
  t.terminal.draw(|f| self.render(f))?;
  // If drawing to the terminal is slow, flush all keyboard events so they're not buffered.
  if before_draw.elapsed() > Duration::from_millis(20) {
      while let Ok(_) = events.try_next() {}
  }
  // ...
}

Alternatively, you may decide you want the app to update in the background, and a user should be able to scroll through the existing list while the app is downloading new elements.

In my experience, the trade-off is here is usually complexity for the developer versus ergonomics for the user.

Let’s say we weren’t worried about complexity, and were interested in performing a computationally demanding or I/O intensive task in the background. For our example, let’s say that we wanted to trigger a increment to the counter after sleeping for 5 seconds.

This means that we’ll have to start a “task” that sleeps for 5 seconds, and then sends another Action to be dispatched on.

Now, our update() method takes the following shape:

  fn update(&mut self, action: Action) -> Option<Action> {
    match action {
      Action::Tick => self.tick(),
      Action::ScheduleIncrement => self.schedule_increment(1),
      Action::ScheduleDecrement => self.schedule_decrement(1),
      Action::Increment(i) => self.increment(i),
      Action::Decrement(i) => self.decrement(i),
      _ => (),
    }
    None
  }

And schedule_increment() and schedule_decrement() both spawn short lived tokio tasks:

  pub fn schedule_increment(&mut self, i: i64) {
    let tx = self.action_tx.clone().unwrap();
    tokio::spawn(async move {
      tokio::time::sleep(Duration::from_secs(5)).await;
      tx.send(Action::Increment(i)).unwrap();
    });
  }

  pub fn schedule_decrement(&mut self, i: i64) {
    let tx = self.action_tx.clone().unwrap();
    tokio::spawn(async move {
      tokio::time::sleep(Duration::from_secs(5)).await;
      tx.send(Action::Decrement(i)).unwrap();
    });
  }

  pub fn increment(&mut self, i: i64) {
    self.counter += i;
  }

  pub fn decrement(&mut self, i: i64) {
    self.counter -= i;
  }

In order to do this, we want to set up a action_tx on the App struct:

#[derive(Default)]
struct App {
  counter: i64,
  should_quit: bool,
  action_tx: Option<UnboundedSender<Action>>
}

Note

The only reason we are using an Option<T> here for action_tx is that we are not initializing the action channel when creating the instance of the App.

This is what we want to do:

  pub async fn run(&mut self) -> Result<()> {
    let (action_tx, mut action_rx) = mpsc::unbounded_channel();
    let t = Tui::new();
    t.enter();

    tokio::spawn(async move {
      let mut event = EventHandler::new(250);
      loop {
        let event = event.next().await;
        let action = self.handle_events(event); // ERROR: self is moved to this tokio task
        action_tx.send(action);
      }
    })

    loop {
      if let Some(action) = action_rx.recv().await {
        self.update(action);
      }
      t.terminal.draw(|f| self.render(f))?;
      if self.should_quit {
        break
      }
    }
    t.exit();
    Ok(())
  }

However, this doesn’t quite work because we can’t move self, i.e. the App to the event -> action mapping, i.e. self.handle_events(), and still use it later for self.update().

One way to solve this is to pass a Arc<Mutex<App> instance to the event -> action mapping loop, where it uses a lock() to get a reference to the object to call obj.handle_events(). We’ll have to use the same lock() functionality in the main loop as well to call obj.update().

pub struct App {
  pub component: Arc<Mutex<App>>,
  pub should_quit: bool,
}

impl App {
  pub async fn run(&mut self) -> Result<()> {
    let (action_tx, mut action_rx) = mpsc::unbounded_channel();

    let tui = Tui::new();
    tui.enter();

    tokio::spawn(async move {
      let component = self.component.clone();
      let mut event = EventHandler::new(250);
      loop {
        let event = event.next().await;
        let action = component.lock().await.handle_events(event);
        action_tx.send(action);
      }
    })

    loop {
      if let Some(action) = action_rx.recv().await {
        match action {
          Action::Render => {
            let c = self.component.lock().await;
            t.terminal.draw(|f| c.render(f))?;
          };
          Action::Quit => self.should_quit = true,
          _ => self.component.lock().await.update(action),
        }
      }
      self.should_quit {
        break;
      }
    }

    tui.exit();
    Ok(())
  }
}

Now our App is generic boilerplate that doesn’t depend on any business logic. It is responsible just to drive the application forward, i.e. call appropriate functions.

We can go one step further and make the render loop its own tokio task:

pub struct App {
  pub component: Arc<Mutex<Home>>,
  pub should_quit: bool,
}

impl App {
  pub async fn run(&mut self) -> Result<()> {
    let (render_tx, mut render_rx) = mpsc::unbounded_channel();

    tokio::spawn(async move {
      let component = self.component.clone();
      let tui = Tui::new();
      tui.enter();
      loop {
        if let Some(_) = render_rx.recv() {
          let c = self.component.lock().await;
          tui.terminal.draw(|f| c.render(f))?;
        }
      }
      tui.exit()
    })

    let (action_tx, mut action_rx) = mpsc::unbounded_channel();

    tokio::spawn(async move {
      let component = self.component.clone();
      let mut event = EventHandler::new(250);
      loop {
        let event = event.next().await;
        let action = component.lock().await.handle_events(event);
        action_tx.send(action);
      }
    })

    loop {
      if let Some(action) = action_rx.recv().await {
        match action {
          Action::Render => {
            render_tx.send(());
          };
          Action::Quit => self.should_quit = true,
          _ => self.component.lock().await.update(action),
        }
      }
      self.should_quit {
        break;
      }
    }

    Ok(())
  }
}

Now our final architecture would look like this:


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  Render
  Thread
  Event
  Thread
  Main
  Thread
  Get
  Key
  Event
  Map
  Event
  to
  Action
  Send
  Action
  on
  action
  
  tx
  Recv
  Action
  Recv
  on
  render
  
  rx
  Dispatch
  Action
  Render
  Component
  Update
  Component
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
  

You can change around when “thread” or “task” does what in your application if you’d like.

It is up to you to decide is this pattern is worth it. In this template, we are going to keep things a little simpler. We are going to use just one thread or task to handle all the Events.


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  Event
  Thread
  Main
  Thread
  Get
  Event
  Send
  Event
  on
  event
  
  tx
  Recv
  Event
  and
  Map
  to
  Action
  Update
  Component
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
  

All business logic will be located in a App struct.

#[derive(Default)]
struct App {
  counter: i64,
}

impl App {
  fn handle_events(&mut self, event: Option<Event>) -> Action {
    match event {
      Some(Event::Quit) => Action::Quit,
      Some(Event::AppTick) => Action::Tick,
      Some(Event::Render) => Action::Render,
      Some(Event::Key(key_event)) => {
        if let Some(key) = event {
            match key.code {
              KeyCode::Char('j') => Action::Increment,
              KeyCode::Char('k') => Action::Decrement
              _ => {}
          }
        }
      },
      Some(_) => Action::Noop,
      None => Action::Noop,
    }
  }

  fn update(&mut self, action: Action) {
    match action {
      Action::Tick => self.tick(),
      Action::Increment => self.increment(),
      Action::Decrement => self.decrement(),
  }

  fn increment(&mut self) {
    self.counter += 1;
  }

  fn decrement(&mut self) {
    self.counter -= 1;
  }

  fn render(&mut self, f: &mut Frame<'_>) {
    f.render_widget(
      Paragraph::new(format!(
        "Press j or k to increment or decrement.\n\nCounter: {}",
        self.counter
      ))
    )
  }
}

With that, our App becomes a little more simpler:

pub struct App {
  pub tick_rate: (u64, u64),
  pub component: Home,
  pub should_quit: bool,
}

impl Component {
  pub fn new(tick_rate: (u64, u64)) -> Result<Self> {
    let component = Home::new();
    Ok(Self { tick_rate, component, should_quit: false, should_suspend: false })
  }

  pub async fn run(&mut self) -> Result<()> {
    let (action_tx, mut action_rx) = mpsc::unbounded_channel();

    let mut tui = Tui::new();
    tui.enter()

    loop {
      if let Some(e) = tui.next().await {
        if let Some(action) = self.component.handle_events(Some(e.clone())) {
          action_tx.send(action)?;
        }
      }

      while let Ok(action) = action_rx.try_recv().await {
        match action {
          Action::Render => tui.draw(|f| self.component.render(f, f.size()))?,
          Action::Quit => self.should_quit = true,
          _ => self.component.update(action),
        }
      }
      if self.should_quit {
        tui.stop()?;
        break;
      }
    }
    tui.exit()
    Ok(())
  }
}

Our Component currently does one thing and just one thing (increment and decrement a counter). But we may want to do more complex things and combine Components in interesting ways. For example, we may want to add a text input field as well as show logs conditionally from our TUI application.

In the next sections, we will talk about breaking out our app into various components, with the one root component called Home. And we’ll introduce a Component trait so it is easier to understand where the TUI specific code ends and where our app’s business logic begins.

components/mod.rs

In components/mod.rs, we implement a trait called Component:

pub trait Component {
  #[allow(unused_variables)]
  fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
    Ok(())
  }
  #[allow(unused_variables)]
  fn register_config_handler(&mut self, config: Config) -> Result<()> {
    Ok(())
  }
  fn init(&mut self) -> Result<()> {
    Ok(())
  }
  fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Action>> {
    let r = match event {
      Some(Event::Key(key_event)) => self.handle_key_events(key_event)?,
      Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event)?,
      _ => None,
    };
    Ok(r)
  }
  #[allow(unused_variables)]
  fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> {
    Ok(None)
  }
  #[allow(unused_variables)]
  fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result<Option<Action>> {
    Ok(None)
  }
  #[allow(unused_variables)]
  fn update(&mut self, action: Action) -> Result<Option<Action>> {
    Ok(None)
  }
  fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()>;
}

I personally like keeping the functions for handle_events (i.e. event -> action mapping), dispatch (i.e. action -> state update mapping) and render (i.e. state -> drawing mapping) all in one file for each component of my application.

There’s also an init function that can be used to setup the Component when it is loaded.

The Home struct (i.e. the root struct that may hold other Components) will implement the Component trait. We’ll have a look at Home next.

components/home.rs

Here’s an example of the Home component with additional state:

  1. show_help is a bool that tracks whether or not help should be rendered or not
  2. ticker is a counter that increments every AppTick.

This Home component also adds fields for input: Input, and stores a reference to action_tx: mpsc::UnboundedSender<Action>

use std::{collections::HashMap, time::Duration};

use color_eyre::eyre::Result;
use crossterm::event::{KeyCode, KeyEvent};
use log::error;
use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::UnboundedSender;
use tracing::trace;
use tui_input::{backend::crossterm::EventHandler, Input};

use super::{Component, Frame};
use crate::{action::Action, config::key_event_to_string};

#[derive(Default, Copy, Clone, PartialEq, Eq)]
pub enum Mode {
  #[default]
  Normal,
  Insert,
  Processing,
}

#[derive(Default)]
pub struct Home {
  pub show_help: bool,
  pub counter: usize,
  pub app_ticker: usize,
  pub render_ticker: usize,
  pub mode: Mode,
  pub input: Input,
  pub action_tx: Option<UnboundedSender<Action>>,
  pub keymap: HashMap<KeyEvent, Action>,
  pub text: Vec<String>,
  pub last_events: Vec<KeyEvent>,
}

impl Home {
  pub fn new() -> Self {
    Self::default()
  }

  pub fn keymap(mut self, keymap: HashMap<KeyEvent, Action>) -> Self {
    self.keymap = keymap;
    self
  }

  pub fn tick(&mut self) {
    log::info!("Tick");
    self.app_ticker = self.app_ticker.saturating_add(1);
    self.last_events.drain(..);
  }

  pub fn render_tick(&mut self) {
    log::debug!("Render Tick");
    self.render_ticker = self.render_ticker.saturating_add(1);
  }

  pub fn add(&mut self, s: String) {
    self.text.push(s)
  }

  pub fn schedule_increment(&mut self, i: usize) {
    let tx = self.action_tx.clone().unwrap();
    tokio::spawn(async move {
      tx.send(Action::EnterProcessing).unwrap();
      tokio::time::sleep(Duration::from_secs(1)).await;
      tx.send(Action::Increment(i)).unwrap();
      tx.send(Action::ExitProcessing).unwrap();
    });
  }

  pub fn schedule_decrement(&mut self, i: usize) {
    let tx = self.action_tx.clone().unwrap();
    tokio::spawn(async move {
      tx.send(Action::EnterProcessing).unwrap();
      tokio::time::sleep(Duration::from_secs(1)).await;
      tx.send(Action::Decrement(i)).unwrap();
      tx.send(Action::ExitProcessing).unwrap();
    });
  }

  pub fn increment(&mut self, i: usize) {
    self.counter = self.counter.saturating_add(i);
  }

  pub fn decrement(&mut self, i: usize) {
    self.counter = self.counter.saturating_sub(i);
  }
}

impl Component for Home {
  fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
    self.action_tx = Some(tx);
    Ok(())
  }

  fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Action>> {
    self.last_events.push(key.clone());
    let action = match self.mode {
      Mode::Normal | Mode::Processing => return Ok(None),
      Mode::Insert => {
        match key.code {
          KeyCode::Esc => Action::EnterNormal,
          KeyCode::Enter => {
            if let Some(sender) = &self.action_tx {
              if let Err(e) = sender.send(Action::CompleteInput(self.input.value().to_string())) {
                error!("Failed to send action: {:?}", e);
              }
            }
            Action::EnterNormal
          },
          _ => {
            self.input.handle_event(&crossterm::event::Event::Key(key));
            Action::Update
          },
        }
      },
    };
    Ok(Some(action))
  }

  fn update(&mut self, action: Action) -> Result<Option<Action>> {
    match action {
      Action::Tick => self.tick(),
      Action::Render => self.render_tick(),
      Action::ToggleShowHelp => self.show_help = !self.show_help,
      Action::ScheduleIncrement => self.schedule_increment(1),
      Action::ScheduleDecrement => self.schedule_decrement(1),
      Action::Increment(i) => self.increment(i),
      Action::Decrement(i) => self.decrement(i),
      Action::CompleteInput(s) => self.add(s),
      Action::EnterNormal => {
        self.mode = Mode::Normal;
      },
      Action::EnterInsert => {
        self.mode = Mode::Insert;
      },
      Action::EnterProcessing => {
        self.mode = Mode::Processing;
      },
      Action::ExitProcessing => {
        // TODO: Make this go to previous mode instead
        self.mode = Mode::Normal;
      },
      _ => (),
    }
    Ok(None)
  }

  fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
    let rects = Layout::default().constraints([Constraint::Percentage(100), Constraint::Min(3)].as_ref()).split(rect);

    let mut text: Vec<Line> = self.text.clone().iter().map(|l| Line::from(l.clone())).collect();
    text.insert(0, "".into());
    text.insert(0, "Type into input and hit enter to display here".dim().into());
    text.insert(0, "".into());
    text.insert(0, format!("Render Ticker: {}", self.render_ticker).into());
    text.insert(0, format!("App Ticker: {}", self.app_ticker).into());
    text.insert(0, format!("Counter: {}", self.counter).into());
    text.insert(0, "".into());
    text.insert(
      0,
      Line::from(vec![
        "Press ".into(),
        Span::styled("j", Style::default().fg(Color::Red)),
        " or ".into(),
        Span::styled("k", Style::default().fg(Color::Red)),
        " to ".into(),
        Span::styled("increment", Style::default().fg(Color::Yellow)),
        " or ".into(),
        Span::styled("decrement", Style::default().fg(Color::Yellow)),
        ".".into(),
      ]),
    );
    text.insert(0, "".into());

    f.render_widget(
      Paragraph::new(text)
        .block(
          Block::default()
            .title("ratatui async template")
            .title_alignment(Alignment::Center)
            .borders(Borders::ALL)
            .border_style(match self.mode {
              Mode::Processing => Style::default().fg(Color::Yellow),
              _ => Style::default(),
            })
            .border_type(BorderType::Rounded),
        )
        .style(Style::default().fg(Color::Cyan))
        .alignment(Alignment::Center),
      rects[0],
    );
    let width = rects[1].width.max(3) - 3; // keep 2 for borders and 1 for cursor
    let scroll = self.input.visual_scroll(width as usize);
    let input = Paragraph::new(self.input.value())
      .style(match self.mode {
        Mode::Insert => Style::default().fg(Color::Yellow),
        _ => Style::default(),
      })
      .scroll((0, scroll as u16))
      .block(Block::default().borders(Borders::ALL).title(Line::from(vec![
        Span::raw("Enter Input Mode "),
        Span::styled("(Press ", Style::default().fg(Color::DarkGray)),
        Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
        Span::styled(" to start, ", Style::default().fg(Color::DarkGray)),
        Span::styled("ESC", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
        Span::styled(" to finish)", Style::default().fg(Color::DarkGray)),
      ])));
    f.render_widget(input, rects[1]);
    if self.mode == Mode::Insert {
      f.set_cursor((rects[1].x + 1 + self.input.cursor() as u16).min(rects[1].x + rects[1].width - 2), rects[1].y + 1)
    }

    if self.show_help {
      let rect = rect.inner(&Margin { horizontal: 4, vertical: 2 });
      f.render_widget(Clear, rect);
      let block = Block::default()
        .title(Line::from(vec![Span::styled("Key Bindings", Style::default().add_modifier(Modifier::BOLD))]))
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Yellow));
      f.render_widget(block, rect);
      let rows = vec![
        Row::new(vec!["j", "Increment"]),
        Row::new(vec!["k", "Decrement"]),
        Row::new(vec!["/", "Enter Input"]),
        Row::new(vec!["ESC", "Exit Input"]),
        Row::new(vec!["Enter", "Submit Input"]),
        Row::new(vec!["q", "Quit"]),
        Row::new(vec!["?", "Open Help"]),
      ];
      let table = Table::new(rows)
        .header(Row::new(vec!["Key", "Action"]).bottom_margin(1).style(Style::default().add_modifier(Modifier::BOLD)))
        .widths(&[Constraint::Percentage(10), Constraint::Percentage(90)])
        .column_spacing(1);
      f.render_widget(table, rect.inner(&Margin { vertical: 4, horizontal: 2 }));
    };

    f.render_widget(
      Block::default()
        .title(
          ratatui::widgets::block::Title::from(format!(
            "{:?}",
            &self.last_events.iter().map(|k| key_event_to_string(k)).collect::<Vec<_>>()
          ))
          .alignment(Alignment::Right),
        )
        .title_style(Style::default().add_modifier(Modifier::BOLD)),
      Rect { x: rect.x + 1, y: rect.height.saturating_sub(1), width: rect.width.saturating_sub(2), height: 1 },
    );

    Ok(())
  }
}

The render function takes a Frame and draws a paragraph to display a counter as well as a text box input:

The Home component has a couple of methods increment and decrement that we saw earlier, but this time additional Actions are sent on the action_tx channel to track the start and end of the increment.

  pub fn schedule_increment(&mut self, i: usize) {
    let tx = self.action_tx.clone().unwrap();
    tokio::task::spawn(async move {
      tx.send(Action::EnterProcessing).unwrap();
      tokio::time::sleep(Duration::from_secs(5)).await;
      tx.send(Action::Increment(i)).unwrap();
      tx.send(Action::ExitProcessing).unwrap();
    });
  }

  pub fn schedule_decrement(&mut self, i: usize) {
    let tx = self.action_tx.clone().unwrap();
    tokio::task::spawn(async move {
      tx.send(Action::EnterProcessing).unwrap();
      tokio::time::sleep(Duration::from_secs(5)).await;
      tx.send(Action::Decrement(i)).unwrap();
      tx.send(Action::ExitProcessing).unwrap();
    });
  }

When a Action is sent on the action channel, it is received in the main thread in the app.run() loop which then calls the dispatch method with the appropriate action:

  fn dispatch(&mut self, action: Action) -> Option<Action> {
    match action {
      Action::Tick => self.tick(),
      Action::ToggleShowHelp => self.show_help = !self.show_help,
      Action::ScheduleIncrement=> self.schedule_increment(1),
      Action::ScheduleDecrement=> self.schedule_decrement(1),
      Action::Increment(i) => self.increment(i),
      Action::Decrement(i) => self.decrement(i),
      Action::EnterNormal => {
        self.mode = Mode::Normal;
      },
      Action::EnterInsert => {
        self.mode = Mode::Insert;
      },
      Action::EnterProcessing => {
        self.mode = Mode::Processing;
      },
      Action::ExitProcessing => {
        // TODO: Make this go to previous mode instead
        self.mode = Mode::Normal;
      },
      _ => (),
    }
    None
  }

This way, you can have Action affect multiple components by propagating the actions down all of them.

When the Mode is switched to Insert, all events are handled off the Input widget from the excellent tui-input crate.

config.rs

At the moment, our keys are hard coded into the app.

impl Component for Home {

  fn handle_key_events(&mut self, key: KeyEvent) -> Action {
    match self.mode {
      Mode::Normal | Mode::Processing => {
        match key.code {
          KeyCode::Char('q') => Action::Quit,
          KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Quit,
          KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Quit,
          KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::CONTROL) => Action::Suspend,
          KeyCode::Char('?') => Action::ToggleShowHelp,
          KeyCode::Char('j') => Action::ScheduleIncrement,
          KeyCode::Char('k') => Action::ScheduleDecrement,
          KeyCode::Char('/') => Action::EnterInsert,
          _ => Action::Tick,
        }
      },
      Mode::Insert => {
        match key.code {
          KeyCode::Esc => Action::EnterNormal,
          KeyCode::Enter => Action::EnterNormal,
          _ => {
            self.input.handle_event(&crossterm::event::Event::Key(key));
            Action::Update
          },
        }
      },
    }
  }

If a user wants to press Up and Down arrow key to ScheduleIncrement and ScheduleDecrement, the only way for them to do it is having to make changes to the source code and recompile the app. It would be better to provide a way for users to set up a configuration file that maps key presses to actions.

For example, assume we want a user to be able to set up a keyevents-to-actions mapping in a config.toml file like below:

[keymap]
"q" = "Quit"
"j" = "ScheduleIncrement"
"k" = "ScheduleDecrement"
"l" = "ToggleShowHelp"
"/" = "EnterInsert"
"ESC" = "EnterNormal"
"Enter" = "EnterNormal"
"Ctrl-d" = "Quit"
"Ctrl-c" = "Quit"
"Ctrl-z" = "Suspend"

We can set up a Config struct using the excellent config crate:

use std::collections::HashMap;

use color_eyre::eyre::Result;
use crossterm::event::KeyEvent;
use serde_derive::Deserialize;

use crate::action::Action;

#[derive(Clone, Debug, Default, Deserialize)]
pub struct Config {
  #[serde(default)]
  pub keymap: KeyMap,
}

#[derive(Clone, Debug, Default, Deserialize)]
pub struct KeyMap(pub HashMap<KeyEvent, Action>);

impl Config {
  pub fn new() -> Result<Self, config::ConfigError> {
    let mut builder = config::Config::builder();
    builder = builder
      .add_source(config::File::from(config_dir.join("config.toml")).format(config::FileFormat::Toml).required(false));
    builder.build()?.try_deserialize()
  }
}

We are using serde to deserialize from a TOML file.

Now the default KeyEvent serialized format is not very user friendly, so let’s implement our own version:

#[derive(Clone, Debug, Default)]
pub struct KeyMap(pub HashMap<KeyEvent, Action>);

impl<'de> Deserialize<'de> for KeyMap {
  fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>,
  {
    struct KeyMapVisitor;
    impl<'de> Visitor<'de> for KeyMapVisitor {
      type Value = KeyMap;
      fn visit_map<M>(self, mut access: M) -> Result<KeyMap, M::Error>
      where
        M: MapAccess<'de>,
      {
        let mut keymap = HashMap::new();
        while let Some((key_str, action)) = access.next_entry::<String, Action>()? {
          let key_event = parse_key_event(&key_str).map_err(de::Error::custom)?;
          keymap.insert(key_event, action);
        }
        Ok(KeyMap(keymap))
      }
    }
    deserializer.deserialize_map(KeyMapVisitor)
  }
}

Now all we need to do is implement a parse_key_event function. You can check the source code for an example of this implementation.

With that implementation complete, we can add a HashMap to store a map of KeyEvents and Action in the Home component:

#[derive(Default)]
pub struct Home {
  ...
  pub keymap: HashMap<KeyEvent, Action>,
}

Now we have to create an instance of Config and pass the keymap to Home:

impl App {
  pub fn new(tick_rate: (u64, u64)) -> Result<Self> {
    let h = Home::new();
    let config = Config::new()?;
    let h = h.keymap(config.keymap.0.clone());
    let home = Arc::new(Mutex::new(h));
    Ok(Self { tick_rate, home, should_quit: false, should_suspend: false, config })
  }
}

Tip

You can create different keyevent presses to map to different actions based on the mode of the app by adding more sections into the toml configuration file.

And in the handle_key_events we get the Action that should to be performed from the HashMap directly.

impl Component for Home {
  fn handle_key_events(&mut self, key: KeyEvent) -> Action {
    match self.mode {
      Mode::Normal | Mode::Processing => {
        if let Some(action) = self.keymap.get(&key) {
          *action
        } else {
          Action::Tick
        }
      },
      Mode::Insert => {
        match key.code {
          KeyCode::Esc => Action::EnterNormal,
          KeyCode::Enter => Action::EnterNormal,
          _ => {
            self.input.handle_event(&crossterm::event::Event::Key(key));
            Action::Update
          },
        }
      },
    }
  }
}

In the template, it is set up to handle Vec<KeyEvent> mapped to an Action. This allows you to map for example:

  • <g><j> to Action::GotoBottom
  • <g><k> to Action::GotoTop

Note

Remember, if you add a new Action variant you also have to update the deserialize method accordingly.

And because we are now using multiple keys as input, you have to update the app.rs main loop accordingly to handle that:

    // -- snip --
    loop {
      if let Some(e) = tui.next().await {
        match e {
          // -- snip --
          tui::Event::Key(key) => {
            if let Some(keymap) = self.config.keybindings.get(&self.mode) {
              // If the key is a single key action
              if let Some(action) = keymap.get(&vec![key.clone()]) {
                log::info!("Got action: {action:?}");
                action_tx.send(action.clone())?;
              } else {
                // If the key was not handled as a single key action,
                // then consider it for multi-key combinations.
                self.last_tick_key_events.push(key);

                // Check for multi-key combinations
                if let Some(action) = keymap.get(&self.last_tick_key_events) {
                  log::info!("Got action: {action:?}");
                  action_tx.send(action.clone())?;
                }
              }
            };
          },
          _ => {},
        }
        // -- snip --
      }
      while let Ok(action) = action_rx.try_recv() {
        // -- snip --
        for component in self.components.iter_mut() {
          if let Some(action) = component.update(action.clone())? {
            action_tx.send(action)?
          };
        }
      }
      // -- snip --
    }
    // -- snip --

Here’s the JSON configuration we use for the counter application:

{
  "keybindings": {
    "Home": {
      "<q>": "Quit", // Quit the application
      "<j>": "ScheduleIncrement",
      "<k>": "ScheduleDecrement",
      "<l>": "ToggleShowHelp",
      "</>": "EnterInsert",
      "<Ctrl-d>": "Quit", // Another way to quit
      "<Ctrl-c>": "Quit", // Yet another way to quit
      "<Ctrl-z>": "Suspend" // Suspend the application
    },
  }
}

utils.rs

Command Line Argument Parsing (clap)

In this file, we define a clap Args struct.

use std::path::PathBuf;

use clap::Parser;

use crate::utils::version;

#[derive(Parser, Debug)]
#[command(author, version = version(), about)]
pub struct Cli {
  #[arg(short, long, value_name = "FLOAT", help = "Tick rate, i.e. number of ticks per second", default_value_t = 1.0)]
  pub tick_rate: f64,

  #[arg(
    short,
    long,
    value_name = "FLOAT",
    help = "Frame rate, i.e. number of frames per second",
    default_value_t = 60.0
  )]
  pub frame_rate: f64,
}

This allows us to pass command line arguments to our terminal user interface if we need to.

In addtion to command line arguments, we typically want the version of the command line program to show up on request. In the clap command, we pass in an argument called version(). This version() function (defined in src/utils.rs) uses a environment variable called RATATUI_ASYNC_TEMPLATE_GIT_INFO to get the version number with the git commit hash. RATATUI_ASYNC_TEMPLATE_GIT_INFO is populated in ./build.rs when building with cargo, because of this line:

  println!("cargo:rustc-env=RATATUI_ASYNC_TEMPLATE_GIT_INFO={}", git_describe);

You can configure what the version string should look like by modifying the string template code in utils::version().

XDG Base Directory Specification

Most command line tools have configuration files or data files that they need to store somewhere. To be a good citizen, you might want to consider following the XDG Base Directory Specification.

This template uses directories-rs and ProjectDirs’s config and data local directories. You can find more information about the exact location for your operating system here: https://github.com/dirs-dev/directories-rs#projectdirs.

This template also prints out the location when you pass in the --version command line argument.

There are situations where you or your users may want to override where the configuration and data files should be located. This can be accomplished by using the environment variables RATATUI_ASYNC_TEMPLATE_CONFIG and RATATUI_ASYNC_TEMPLATE_DATA.

The functions that calculate the config and data directories are in src/utils.rs. Feel free to modify the utils::get_config_dir() and utils::get_data_dir() functions as you see fit.

Logging

The utils::initialize_logging() function is defined in src/utils.rs. The log level is decided by the RUST_LOG environment variable (default = log::LevelFilter::Info). In addition, the location of the log files are decided by the RATATUI_ASYNC_TEMPLATE_DATA environment variable (default = XDG_DATA_HOME (local)).

I tend to use .envrc and direnv for development purposes, and I have the following in my .envrc:

export RATATUI_COUNTER_CONFIG=`pwd`/.config
export RATATUI_COUNTER_DATA=`pwd`/.data
export RATATUI_COUNTER_LOG_LEVEL=debug

This puts the log files in the RATATUI_ASYNC_TEMPLATE_DATA folder, i.e. .data folder in the current directory, and sets the log level to RUST_LOG, i.e. debug when I am prototyping and developing using cargo run.

Top half is a Iterm2 terminal with the TUI showing a Vertical split with tui-logger widget. Bottom half is a ITerm2 terminal showing the output of running tail -f on the log file.

Using the RATATUI_ASYNC_TEMPLATE_CONFIG environment variable also allows me to have configuration data that I can use for testing when development that doesn’t affect my local user configuration for the same program.

Panic Handler

Finally, let’s discuss the initialize_panic_handler() function, which is also defined in src/utils.rs, and is used to define a callback when the application panics. Your application may panic for a number of reasons (e.g. when you call .unwrap() on a None). And when this happens, you want to be a good citizen and:

  1. provide a useful stacktrace so that they can report errors back to you.
  2. not leave the users terminal state in a botched condition, resetting it back to the way it was.

In the screenshot below, I added a None.unwrap() into a function that is called on a keypress, so that you can see what a prettier backtrace looks like:

utils::initialize_panic_handler() also calls Tui::new().exit() to reset the terminal state back to the way it was before the user started the TUI program. We’ll learn more about the Tui in the next section.