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 withHome
andFps
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.
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):
- https://github.com/ratatui-org/ratatui/tree/main/examples: Simple one-off examples to illustrate
various widgets and features in
ratatui
. - https://github.com/ratatui-org/rust-tui-template: Starter kit for using
ratatui
- https://github.com/ratatui-org/ratatui-book/tree/main/ratatui-book-tutorial-project: Tutorial project that the user a simple interface to enter key-value pairs, which will printed in json.
- https://github.com/ratatui-org/async-template: Async tokio crossterm based opinionated starter
kit for using
ratatui
. - https://github.com/veeso/tui-realm/: A framework for
tui.rs
to simplify the implementation of terminal user interfaces adding the possibility to work with re-usable components with properties and states.
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.
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(())
}
}
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(())
}
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.)
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()?)
}
}
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(())
}
}
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.
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 Action
s or Message
s.
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,
}
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 Action
s 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)
}
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,
}
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.
When an application is structured like this, the TUI is blocking at each step:
- Waiting for a Event.
- If no key or mouse event in 250ms, send
Tick
.
- If no key or mouse event in 250ms, send
- Update the state of the app based on
event
oraction
. draw
the state of the app to the terminal usingratatui
.
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>>
}
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:
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 Event
s.
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 Component
s 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 Component
s) 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:
show_help
is abool
that tracks whether or not help should be rendered or notticker
is a counter that increments everyAppTick
.
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 Action
s 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 KeyEvent
s 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 })
}
}
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>
toAction::GotoBottom
<g><k>
toAction::GotoTop
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
.
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:
- provide a useful stacktrace so that they can report errors back to you.
- 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.