Introduction to Ratatui

Demo

Ratatui is a Rust library for cooking up delicious text user interfaces (TUIs). It is a lightweight library that provides a set of widgets and utilities to build simple or complex rust TUIs.

Ratatui is an immediate mode graphics library. Applications imperatively declare how to render each frame in full by combining widgets and layout. Ratatui then draws the described UI widgets efficiently to the terminal.

Applications built with Ratatui use the features of their chosen backend (Crossterm, Termion, or Termwiz to handle:

  • keyboard input events
  • mouse events
  • switching to raw mode and the alternate screen

Ratatui is very flexible and customizable. It does not dictate how you need to structure your application, as it is a library not a framework. This book covers some different options covering the range from simple single file applications through more complex applications using approaches based on components, Flux and The Elm Architecture.

Who is ratatui for?

Ratatui is designed for developers and enthusiasts who:

  • want a lightweight alternative to graphical user interfaces (GUIs),
  • need applications that are to be deployed in constrained environments, like on servers with limited resources, and
  • prefer to have full control over input and events, allowing for a more customized and tailored user experience.
  • appreciate the retro aesthetic of the terminal,

Who is this book for?

In this book, we will cover beginner guides to advanced patterns for developing terminal user interfaces.

Those new to the world of TUIs will find this book a comprehensive guide, introducing the foundational concepts and walking through common patterns of using Ratatui. Additionally, developers who have worked with TUIs will understand the nuances and benefits of using Ratatui.

We hope that this book can be a journey into creating beautiful and functional terminal-based applications.

Note

Help us improve!

We’ve designed this user guide to aid you throughout your journey with our open-source project. However, the beauty of open source is that it’s not just about receiving, but also contributing. We highly encourage you to contribute to our project and help improve it even further. If you have innovative ideas, helpful feedback, or useful suggestions, please don’t hesitate to share them with us.

If you see something that could be better written, feel free to create an issue, a discussion thread or even contribute a Pull Request. We’re also often active in the #doc-discussion channel on Discord and Matrix

Installation

ratatui is a standard rust crate and can be installed into your app using the following command:

cargo add ratatui crossterm

or by adding the following to your Cargo.toml file:

[dependencies]
crossterm = "0.27.0"
ratatui = "0.24.0"

Tip

Additionally, you can use the all-widgets feature, which enables additional widgets:

cargo add ratatui --features all-widgets
cargo add crossterm

or by adding the following to your Cargo.toml file:

[dependencies]
crossterm = "0.27.0"
ratatui = { version = "0.24.0", features = ["all-widgets"]}

You can learn more about available widgets from the docs.rs page on widgets.

By default, ratatui enables the crossterm, but it’s possible to alternatively use termion, or termwiz instead by enabling the appropriate feature and disabling the default features. See Backend for more information.

For Termion:

cargo add ratatui --no-default-features --features termion
cargo add termion

or in your Cargo.toml:

[dependencies]
ratatui = { version = "0.23", default-features = false, features = ["termion"] }
termion = "2.0.1"

For Termwiz:

cargo add ratatui --no-default-features --features termwiz
cargo add termwiz

or in your Cargo.toml:

[dependencies]
ratatui = { version = "0.23", default-features = false, features = ["termwiz"] }
termwiz = "0.20.0"

Tutorial

  • Hello World: This tutorial takes you through the basics of creating a simple Ratatui application that displays “Hello World”.
  • Counter App: This tutorial will set up the basics of a ratatui project by building a app that displays a counter.
  • JSON Editor: This tutorial will guide you through setting up a Rust project and organizing its structure for a ratatui-based application to edit json key value pairs. JSON Editor TUI will provide an interface for users to input key-value pairs, which are then converted into correct JSON format and printed to stdout.
  • Async Counter App: This tutorial, expands on the Counter app to build a an async TUI using tokio.
  • Stopwatch App: This tutorial will build a working stopwatch application that uses an external big-text widget library, runs asynchronously using tokio.

Hello World

This tutorial will lead you through creating a simple “Hello World” TUI app that displays some text in the middle of the screen and waits for the user to press q to exit. It demonstrates the necessary tasks that any application developed with Ratatui needs to undertake. We assume that you have a basic understanding of the terminal a text editor or Rust IDE (if you don’t have a preference, VSCode makes a good default choice).

We’re going to build the following:

hello

The full code for this tutorial is available to view at https://github.com/ratatui-org/ratatui-book/tree/main/code/hello-world-tutorial

Install Rust

The first step is to install Rust. See the Installation section of the official Rust Book for more information. Most people tend to use rustup, a command line tool for managing Rust versions and associated tools.

Once you’ve installed Rust, verify that it’s installed by running:

rustc --version

You should see output similar to the following (the exact version, date and commit hash will vary):

rustc 1.72.1 (d5c2e9c34 2023-09-13)

Create a new project

Let’s create a new Rust project. In the terminal, navigate to a folder where you will store your projects and run:

cargo new hello-ratatui
cd hello-ratatui

The cargo new command creates a new folder called hello-ratatui with a basic binary application in it. You should see:

     Created binary (application) `hello-ratatui` package

If you examine the folders and files created this will look like:

hello-ratatui/
├── src/
│  └── main.rs
└── Cargo.toml

cargo new created a default main.rs with a small console program that prints “Hello, world!”.

fn main() {
    println!("Hello, world!");
}

Let’s build and execute the project. Run:

cargo run

You should see:

   Compiling hello-ratatui v0.1.0 (/Users/joshka/local/hello-ratatui)
    Finished dev [unoptimized + debuginfo] target(s) in 0.18s
     Running `target/debug/hello-ratatui`
Hello, world!

The default main.rs program is responsible for printing the last line. We’re going to replace it with something a little bit more exciting.

Install Ratatui

First up, we need to install the Ratatui crate into our project. We also need to install a backend. We will use Crossterm here as the backend as it’s compatible with most operating systems. To install the latest version of the ratatui and crossterm crates into the project run:

cargo add ratatui crossterm

Cargo will output the following (note that the exact versions may be later than the ones in this tutorial).

    Updating crates.io index
      Adding ratatui v0.24.0 to dependencies.
             Features:
             + crossterm
             - all-widgets
             - document-features
             - macros
             - serde
             - termion
             - termwiz
             - widget-calendar
      Adding crossterm v0.27.0 to dependencies.
             Features:
             + bracketed-paste
             + events
             + windows
             - event-stream
             - filedescriptor
             - serde
             - use-dev-tty
    Updating crates.io index

If you examine the Cargo.toml file, you should see that the new crates have been added to the dependencies section:

[dependencies]
crossterm = "0.27.0"
ratatui = "0.24.0"

Create a TUI application

Let’s replace the default console application code that cargo new created with a Ratatui application that displays a colored message the middle of the screen and waits for the user to press a key to exit.

Note: a full copy of the code is available below in the Running the application section.

Imports

First let’s add the module imports necessary to run our application.

Info

Ratatui has a prelude module that re-exports the most used types and traits. Importing this module with a wildcard import can simplify your application’s imports.

In your editor, open src/main.rs and add the following at the top of the file.

use crossterm::{
    event::{self, KeyCode, KeyEventKind},
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
    ExecutableCommand,
};
use ratatui::{
    prelude::{CrosstermBackend, Stylize, Terminal},
    widgets::Paragraph,
};
use std::io::{stdout, Result};

Setting up and restoring the terminal

Next, we need to add code to the main function to setup and restore the terminal state.

Our application needs to do a few things in order to setup the terminal for use:

  • First, the application enters the alternate screen, which is a secondary screen that allows your application to render whatever it needs to, without disturbing the normal output of terminal apps in your shell.
  • Next, the application enables raw mode, which turns off input and output processing by the terminal. This allows our application control when characters are printed to the screen.
  • The app then creates a backend and Terminal and then clears the screen.

When the application is finished it needs to restore the terminal state by leaving the alternate screen and disabling raw mode.

Replace the existing main function with the following:

fn main() -> Result<()> {
    stdout().execute(EnterAlternateScreen)?;
    enable_raw_mode()?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
    terminal.clear()?;

    // TODO main loop

    stdout().execute(LeaveAlternateScreen)?;
    disable_raw_mode()?;
    Ok(())
}

Warning

If we don’t disable raw mode before exit, terminals can act weirdly when the mouse or navigation keys are pressed. To fix this, on a Linux / macOS terminal type reset. On Windows, you’ll have to close the tab and open a new terminal.

Add a main loop

The main part of our application is the main loop. Our application repeatedly draws the ui and then handles any events that have occurred.

Replace // TODO main loop with a loop:

    loop {
        // TODO draw
        // TODO handle events
    }

Draw to the terminal

The draw method on terminal is the main interaction point that an app has with Ratatui. The draw method accepts a closure that has a single Frame parameter, and renders the entire screen. Our application creates an area that is the full size of the terminal window and renders a new Paragraph with white foreground text and a blue background. The white() and on_blue() methods are defined in the Stylize extension trait as style shorthands, rather than on the Paragraph widget.

Replace // TODO draw with the following

        terminal.draw(|frame| {
            let area = frame.size();
            frame.render_widget(
                Paragraph::new("Hello Ratatui! (press 'q' to quit)")
                    .white()
                    .on_blue(),
                area,
            );
        })?;

Handle events

After Ratatui has drawn a frame, our application needs to check to see if any events have occurred. These are things like keyboard presses, mouse events, resizes, etc. If the user has pressed the q key, we break out of the loop. We add a small timeout to the event polling to ensure that our UI remains responsive regardless of whether there are events pending (16ms is ~60fps). Note that it’s important to check that the event kind is Press otherwise Windows terminals will see each key twice.

Replace // TODO handle events with:

        if event::poll(std::time::Duration::from_millis(16))? {
            if let event::Event::Key(key) = event::read()? {
                if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
                    break;
                }
            }
        }

Running the Application

Your application should look like:

main.rs
use crossterm::{
    event::{self, KeyCode, KeyEventKind},
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
    ExecutableCommand,
};
use ratatui::{
    prelude::{CrosstermBackend, Stylize, Terminal},
    widgets::Paragraph,
};
use std::io::{stdout, Result};

fn main() -> Result<()> {
    stdout().execute(EnterAlternateScreen)?;
    enable_raw_mode()?;
    let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
    terminal.clear()?;

    loop {
        terminal.draw(|frame| {
            let area = frame.size();
            frame.render_widget(
                Paragraph::new("Hello Ratatui! (press 'q' to quit)")
                    .white()
                    .on_blue(),
                area,
            );
        })?;

        if event::poll(std::time::Duration::from_millis(16))? {
            if let event::Event::Key(key) = event::read()? {
                if key.kind == KeyEventKind::Press && key.code == KeyCode::Char('q') {
                    break;
                }
            }
        }
    }

    stdout().execute(LeaveAlternateScreen)?;
    disable_raw_mode()?;
    Ok(())
}

Make sure you save the file! Let’s run the app:

cargo run

You should see a TUI app with Hello Ratatui! (press 'q' to quit) show up in your terminal as a TUI app.

hello

You can press q to exit and go back to your terminal as it was before.

Congratulations! 🎉

You have written a “hello world” terminal user interface with ratatui. We will learn more about how ratatui works in the next sections.

Question

Can you modify the example above to exit when pressing q or when pressing Q?

Counter App

In the previous section, we built a “hello world” TUI. In this tutorial, we’ll develop a simple counter application.

For the app, we’ll need a Paragraph to display the counter. We’ll also want to increment or decrement the counter when a key is pressed. Let’s increment and decrement the counter with j and k.

Initialization

Go ahead and set up a new rust project with

cargo new ratatui-counter-app
cd ratatui-counter-app

We are only going to use 3 dependencies in this tutorial:

cargo add ratatui crossterm anyhow

Tip

We opt to use the anyhow crate for easier error handling; it is not necessary to build apps with ratatui.

Filestructure

We are going to start off like in the previous “hello world” tutorial with one file like so:

tree .
├── Cargo.toml
├── LICENSE
└── src
   └── main.rs

but this time for the counter example, we will expand it out to multiple files like so:

tree .
├── Cargo.toml
├── LICENSE
└── src
   ├── app.rs
   ├── event.rs
   ├── lib.rs
   ├── main.rs
   ├── tui.rs
   ├── ui.rs
   └── update.rs

Single Function

In this section, we’ll walk through building a simple counter application, allowing users to increase or decrease a displayed number using keyboard input.

Here’s a first pass at a counter application in Rust using ratatui where all the code is in one main function:

use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
  // startup: Enable raw mode for the terminal, giving us fine control over user input
  crossterm::terminal::enable_raw_mode()?;
  crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;

  // Initialize the terminal backend using crossterm
  let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

  // Define our counter variable
  // This is the state of our application
  let mut counter = 0;

  // Main application loop
  loop {
    // Render the UI
    terminal.draw(|f| {
      f.render_widget(Paragraph::new(format!("Counter: {counter}")), f.size());
    })?;

    // Check for user input every 250 milliseconds
    if crossterm::event::poll(std::time::Duration::from_millis(250))? {
      // If a key event occurs, handle it
      if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
        if key.kind == crossterm::event::KeyEventKind::Press {
          match key.code {
            crossterm::event::KeyCode::Char('j') => counter += 1,
            crossterm::event::KeyCode::Char('k') => counter -= 1,
            crossterm::event::KeyCode::Char('q') => break,
            _ => {},
          }
        }
      }
    }
  }

  // shutdown down: reset terminal back to original state
  crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?;
  crossterm::terminal::disable_raw_mode()?;

  Ok(())
}

In the code above, it is useful to think about various parts of the code as separate pieces of the puzzle. This is useful to help refactor and reorganize your code for larger applications.

Imports

We start by importing necessary components from the ratatui library, which provides a number of different widgets and utilities.

use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};

Start up

Using crossterm, we can set the terminal to raw mode and enter an alternate screen.

crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;

Initialize

Again using crossterm, we can create an instance of terminal backend

let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

Shut down

Terminal disables raw mode and exits the alternate screen for a clean exit, ensuring the terminal returns to its original state

crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?;
crossterm::terminal::disable_raw_mode()?;

App state

Our application has just one variable that tracks the “state”, i.e. the counter value.

let mut counter = 0;

Run loop

Our application runs in a continuous loop, constantly checking for user input and updating the state, which in turn updates the display on the next loop.

  // Main application loop
  loop {
    // draw UI based on state
    // ...
    // Update state based on user input
    // ...
    // Break from loop based on user input and/or state
  }

Every TUI with ratatui is bound to have (at least) one main application run loop like this.

User interface

The UI part of our code takes the state of the application, i.e. the value of counter and uses it to render a widget, i.e. a Paragraph widget.

    terminal.draw(|f| {
      f.render_widget(Paragraph::new(format!("Counter: {counter}")), f.size());
    })?;

Note

The closure passed to the Terminal::draw() method must render the entire UI. Call the draw method at most once for each pass through your application’s main loop. See the FAQ for more information.

User input

Every 250 milliseconds, the application checks if the user has pressed a key:

  • j increases the counter
  • k decreases the counter
  • q exits the application

For Linux and MacOS, you’ll be able to write code like the following:

    if crossterm::event::poll(std::time::Duration::from_millis(250))? {
      if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
        match key.code {
          crossterm::event::KeyCode::Char('j') => counter += 1,
          crossterm::event::KeyCode::Char('k') => counter -= 1,
          crossterm::event::KeyCode::Char('q') => break,
          _ => {},
        }
      }
    }

On MacOS and Linux only KeyEventKind::Press kinds of key event is generated. However, on Windows when using Crossterm, the above code 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.

To make the code work in a cross platform manner, you’ll want to check that key.kind is KeyEventKind::Press, like so:

    if crossterm::event::poll(std::time::Duration::from_millis(250))? {
      if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
        // check if key.kind is a `KeyEventKind::Press`
        if key.kind == crossterm::event::KeyEventKind::Press {
          match key.code {
            crossterm::event::KeyCode::Char('j') => counter += 1,
            crossterm::event::KeyCode::Char('k') => counter -= 1,
            crossterm::event::KeyCode::Char('q') => break,
            _ => {},
          }
        }
      }
    }

Conclusion

By understanding the structure and components used in this simple counter application, you are set up to explore crafting more intricate terminal-based interfaces using ratatui.

In the next section, we will explore a refactor of the above code to separate the various parts into individual functions.

Multiple Functions

In this section, we will walk through the process of refactoring the application to set ourselves up better for bigger projects. Not all of these changes are ratatui specific, and are generally good coding practices to follow.

We are still going to keep everything in one file for this section, but we are going to split the previous functionality into separate functions.

Organizing imports

The first thing you might consider doing is reorganizing imports with qualified names.

use crossterm::{
  event::{self, Event::Key, KeyCode::Char},
  execute,
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
  prelude::{CrosstermBackend, Terminal, Frame},
  widgets::Paragraph,
};

Typedefs and Type Aliases

By defining custom types and aliases, we can simplify our code and make it more expressive.

type Err = Box<dyn std::error::Error>;
type Result<T> = std::result::Result<T, Err>;

Tip

If you use the popular anyhow then instead of these two lines:

type Err = Box<dyn std::error::Error>;
type Result<T> = std::result::Result<T, Err>;

you can simply import Result from anyhow:

use anyhow::Result;

You will need to run cargo add anyhow for this to work.

App struct

By defining an App struct, we can encapsulate our application state and make it more structured.

struct App {
  counter: i64,
  should_quit: bool,
}
  • counter holds the current value of our counter.
  • should_quit is a flag that indicates whether the application should exit its main loop.

Breaking up main()

We can extract significant parts of the main() function into separate smaller functions, e.g. startup(), shutdown(), ui(), update(), run().

startup() is responsible for initializing the terminal.

fn startup() -> Result<()> {
  enable_raw_mode()?;
  execute!(std::io::stderr(), EnterAlternateScreen)?;
  Ok(())
}

shutdown() cleans up the terminal.

fn shutdown() -> Result<()> {
  execute!(std::io::stderr(), LeaveAlternateScreen)?;
  disable_raw_mode()?;
  Ok(())
}

ui() handles rendering of our application state.

fn ui(app: &App, f: &mut Frame<'_>) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

update() processes user input and updates our application state.

fn update(app: &mut App) -> Result<()> {
  if event::poll(std::time::Duration::from_millis(250))? {
    if let Key(key) = event::read()? {
      if key.kind == event::KeyEventKind::Press {
        match key.code {
          Char('j') => app.counter += 1,
          Char('k') => app.counter -= 1,
          Char('q') => app.should_quit = true,
          _ => {},
        }
      }
    }
  }
  Ok(())
}

Tip

You’ll notice that in the update() function we make use of pattern matching for handling user input. This is a powerful feature in rust; and enhances readability and provides a clear pattern for how each input is processed.

You can learn more about pattern matching in the official rust book.

run() contains our main application loop.

fn run() -> Result<()> {
  // ratatui terminal
  let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    // application render
    t.draw(|f| {
      ui(&app, f);
    })?;

    // application update
    update(&mut app)?;

    // application exit
    if app.should_quit {
      break;
    }
  }

  Ok(())
}

Each function now has a specific task, making our main application logic more organized and easier to follow.

fn main() -> Result<()> {
  startup()?;
  let status = run();
  shutdown()?;
  status?;
  Ok(())
}

Note

You may be wondering if we could have written the main function like so:

fn main() -> Result<()> {
  startup()?;
  run()?;
  shutdown()?;
  Ok(())
}

This works fine during the happy path of a program.

However, if your run() function returns an error, the program will not call shutdown(). And this can leave your terminal in a messed up state for your users.

Instead, we should ensure that shutdown() is always called before the program exits.

fn main() -> Result<()> {
  startup()?;
  let result = run();
  shutdown()?;
  result?;
  Ok(())
}

Here, we can get the result of run(), and call shutdown() first and then unwrap() on the result. This will be a much better experience for users.

We will discuss in future sections how to handle the situation when your code unexpectedly panics.

Conclusion

By making our code more organized, modular, and readable, we not only make it easier for others to understand and work with but also set the stage for future enhancements and extensions.

Here’s the full code for reference:

use anyhow::Result;
use crossterm::{
  event::{self, Event::Key, KeyCode::Char},
  execute,
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};


fn startup() -> Result<()> {
  enable_raw_mode()?;
  execute!(std::io::stderr(), EnterAlternateScreen)?;
  Ok(())
}

fn shutdown() -> Result<()> {
  execute!(std::io::stderr(), LeaveAlternateScreen)?;
  disable_raw_mode()?;
  Ok(())
}

// App state
struct App {
  counter: i64,
  should_quit: bool,
}

// App ui render function
fn ui(app: &App, f: &mut Frame<'_>) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

// App update function
fn update(app: &mut App) -> Result<()> {
  if event::poll(std::time::Duration::from_millis(250))? {
    if let Key(key) = event::read()? {
      if key.kind == event::KeyEventKind::Press {
        match key.code {
          Char('j') => app.counter += 1,
          Char('k') => app.counter -= 1,
          Char('q') => app.should_quit = true,
          _ => {},
        }
      }
    }
  }
  Ok(())
}

fn run() -> Result<()> {
  // ratatui terminal
  let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    // application update
    update(&mut app)?;

    // application render
    t.draw(|f| {
      ui(&app, f);
    })?;

    // application exit
    if app.should_quit {
      break;
    }
  }

  Ok(())
}

fn main() -> Result<()> {
  // setup terminal
  startup()?;

  let result = run();

  // teardown terminal before unwrapping Result of app run
  shutdown()?;

  result?;

  Ok(())
}

Here’s a flow chart representation of the various steps in the program:

graph TD
    MainRun[Main: Run];
    CheckEvent[Main: Poll KeyPress];
    UpdateApp[Main: Update App];
    ShouldQuit[Main: Check should_quit?];
    BreakLoop[Main: Break Loop];
    MainStart[Main: Start];
    MainEnd[Main: End];
    MainStart --> MainRun;
    MainRun --> CheckEvent;
    CheckEvent -->|No KeyPress| Draw;
    CheckEvent --> |KeyPress Received| UpdateApp;
    Draw --> ShouldQuit;
    UpdateApp --> Draw;
    ShouldQuit -->|Yes| BreakLoop;
    BreakLoop --> MainEnd;
    ShouldQuit -->|No| CheckEvent;

Question

What do you think happens if you modify the example above to change the polling to 0 milliseconds?

What would happen if you change the example to poll every 10 seconds?

Experiment with different “tick rates” and see how that affects the user experience. Monitor your CPU usage when you do this experiment. What happens to your CPU usage as you change the poll frequency?

Multiple Files

At the moment, we have everything in just one file. However, this can be impractical if we want to expand our app further.

Let’s start by creating a number of different files to represent the various concepts we covered in the previous section:

$ tree .
├── Cargo.toml
├── LICENSE
└── src
   ├── app.rs
   ├── event.rs
   ├── main.rs
   ├── tui.rs
   ├── ui.rs
   └── update.rs

If you want to explore the code on your own, you can check out the completed source code here: https://github.com/ratatui-org/ratatui-book/tree/main/code/ratatui-counter-app

Let’s go ahead and declare these files as modules in src/main.rs

/// Application.
pub mod app;

/// Terminal events handler.
pub mod event;

/// Widget renderer.
pub mod ui;

/// Terminal user interface.
pub mod tui;

/// Application updater.
pub mod update;

We are going to use anyhow in this section of the tutorial.

cargo add anyhow

Tip

Instead of anyhow you can also use eyre or color-eyre.

- use anyhow::Result;
+ use color_eyre::eyre::Result;

You’ll need to add color-eyre and remove anyhow:

cargo remove anyhow
cargo add color-eyre

If you are using color_eyre, you’ll also want to add color_eyre::install()? to the beginning of your main() function:

use color_eyre::eyre::Result;

fn main() -> Result<()> {
    color_eyre::install()?;
    // ...
    Ok(())
}

color_eyre is an error report handler for colorful, consistent, and well formatted error reports for all kinds of errors. Check out the section for setting up panic hooks with color-eyre.

Now we are ready to start refactoring our app.

app.rs

Let’s start with the same struct as we had before:

/// Application.
#[derive(Debug, Default)]
pub struct App {
  /// should the application exit?
  pub should_quit: bool,
  /// counter
  pub counter: u8,
}

We can add additional methods to this Application struct:

impl App {
  /// Constructs a new instance of [`App`].
  pub fn new() -> Self {
    Self::default()
  }

  /// Handles the tick event of the terminal.
  pub fn tick(&self) {}

  /// Set running to false to quit the application.
  pub fn quit(&mut self) {
    self.should_quit = true;
  }

  pub fn increment_counter(&mut self) {
    if let Some(res) = self.counter.checked_add(1) {
      self.counter = res;
    }
  }

  pub fn decrement_counter(&mut self) {
    if let Some(res) = self.counter.checked_sub(1) {
      self.counter = res;
    }
  }
}

We use the principle of encapsulation to expose an interface to modify the state. In this particular instance, it may seem like overkill but it is good practice nonetheless.

The practical advantage of this is that it makes the state changes easy to test.

#[cfg(test)]
mod tests {
  use super::*;
  #[test]
  fn test_app_increment_counter() {
    let mut app = App::default();
    app.increment_counter();
    assert_eq!(app.counter, 1);
  }

  #[test]
  fn test_app_decrement_counter() {
    let mut app = App::default();
    app.decrement_counter();
    assert_eq!(app.counter, 0);
  }
}

Tip

You can test a single function by writing out fully qualified module path to the test function, like so:

cargo test -- app::tests::test_app_increment_counter --nocapture

Or even test all functions that start with test_app_ by doing this:

cargo test -- app::tests::test_app_ --nocapture

The --nocapture flag prints stdout and stderr to the console, which can help debugging tests.

ui.rs

Previously we were rendering a Paragraph with no styling.

Let’s make some improvements:

  1. Add a Block with a rounded border and the title "Counter App".
  2. Make everything in the Paragraph have a foreground color of Color::Yellow

This is what our code will now look like:

use ratatui::{
  prelude::{Alignment, Frame},
  style::{Color, Style},
  widgets::{Block, BorderType, Borders, Paragraph},
};

use crate::app::App;

pub fn render(app: &mut App, f: &mut Frame) {
  f.render_widget(
    Paragraph::new(format!(
      "
        Press `Esc`, `Ctrl-C` or `q` to stop running.\n\
        Press `j` and `k` to increment and decrement the counter respectively.\n\
        Counter: {}
      ",
      app.counter
    ))
    .block(
      Block::default()
        .title("Counter App")
        .title_alignment(Alignment::Center)
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded),
    )
    .style(Style::default().fg(Color::Yellow))
    .alignment(Alignment::Center),
    f.size(),
  )
}

Keep in mind it won’t render until we have written the code for tui::Frame

When rendered, this is what the UI will look like:

Counter app demo

event.rs

Most applications will have a main run loop like this:

fn main() -> Result<()> {
  crossterm::terminal::enable_raw_mode()?; // enter raw mode
  crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;
  let mut app = App::new();
  let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;
  // --snip--
  loop {
    // --snip--
    terminal.draw(|f| { // <- `terminal.draw` is the only ratatui function here
      ui(app, f) // render state to terminal
    })?;
  }
  crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?;
  crossterm::terminal::disable_raw_mode()?; // exit raw mode
  Ok(())
}

While the application is in the “raw mode”, any key presses in that terminal window are sent to stdin. We have to make sure that the application reads these key presses from stdin if we want to act on them.

In the tutorials up until now, we have been using crossterm::event::poll() and crossterm::event::read(), like so:

fn main() -> Result {
  let mut app = App::new();

  let mut t = Tui::new()?;

  t.enter()?;

  loop {
    // crossterm::event::poll() here will block for a maximum 250ms
    // will return true as soon as key is available to read
    if crossterm::event::poll(Duration::from_millis(250))? {

      // crossterm::event::read() blocks till it can read single key
      // when used with poll, key is always available
      if let Event::Key(key) = crossterm::event::read()? {

        if key.kind == event::KeyEventKind::Press {
          match key.code {
            KeyCode::Char('j') => app.increment(),
            KeyCode::Char('k') => app.decrement(),
            KeyCode::Char('q') => break,
            _ => {},
          }
        }

      }

    };
    t.terminal.draw(|f| {
      ui(app, f)
    })?;
  }

  t.exit()?;

  Ok(())
}

crossterm::event::poll() blocks till a key is received on stdin, at which point it returns true and crossterm::event::read() reads the single key event.

This works perfectly fine, and a lot of small to medium size programs can get away with doing just that.

However, this approach conflates the key input handling with app state updates, and does so in the “draw” loop. The practical issue with this approach is we block the draw loop for 250 ms waiting for a key press. This can have odd side effects, for example pressing and holding a key will result in faster draws to the terminal. You can try this out by pressing and holding any key and watching your CPU usage using top or htop.

In terms of architecture, the code could get complicated to reason about. For example, we may even want key presses to mean different things depending on the state of the app (when you are focused on an input field, you may want to enter the letter "j" into the text input field, but when focused on a list of items, you may want to scroll down the list.)

Pressing j 3 times to increment counter and 3 times in the text field

We have to do a few different things set ourselves up, so let’s take things one step at a time.

First, instead of polling, we are going to introduce channels to get the key presses “in the background” and send them over a channel. We will then receive these events on the channel in the main loop.

Let’s create an Event enum to handle the different kinds of events that can occur:

use crossterm::event::{KeyEvent, MouseEvent};

/// Terminal events.
#[derive(Clone, Copy, Debug)]
pub enum Event {
  /// Terminal tick.
  Tick,
  /// Key press.
  Key(KeyEvent),
  /// Mouse click/scroll.
  Mouse(MouseEvent),
  /// Terminal resize.
  Resize(u16, u16),
}

Next, let’s create an EventHandler struct:

use std::{sync::mpsc, thread};

/// Terminal event handler.
#[derive(Debug)]
pub struct EventHandler {
  /// Event sender channel.
  #[allow(dead_code)]
  sender: mpsc::Sender<Event>,
  /// Event receiver channel.
  receiver: mpsc::Receiver<Event>,
  /// Event handler thread.
  #[allow(dead_code)]
  handler: thread::JoinHandle<()>,
}

We are using std::sync::mpsc which is a “Multiple Producer Single Consumer” channel.

Tip

A channel is a thread-safe communication mechanism that allows data to be transmitted between threads. Essentially, it’s a conduit where one or more threads (the producers) can send data, and another thread (the consumer) can receive this data.

In Rust, channels are particularly useful for sending data between threads without the need for locks or other synchronization mechanisms. The “Multiple Producer, Single Consumer” aspect of std::sync::mpsc means that while multiple threads can send data into the channel, only a single thread can retrieve and process this data, ensuring a clear and orderly flow of information.

Note

In the code in this section, we only need a “Single Producer, Single Consumer” but we are going to use mpsc to set us up for the future.

Finally, here’s the code that starts a thread that polls for events from crossterm and maps it to our Event enum.

use std::{
  sync::mpsc,
  thread,
  time::{Duration, Instant},
};

use anyhow::Result;
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};

// -- snip --

impl EventHandler {
  /// Constructs a new instance of [`EventHandler`].
  pub fn new(tick_rate: u64) -> Self {
    let tick_rate = Duration::from_millis(tick_rate);
    let (sender, receiver) = mpsc::channel();
    let handler = {
      let sender = sender.clone();
      thread::spawn(move || {
        let mut last_tick = Instant::now();
        loop {
          let timeout = tick_rate.checked_sub(last_tick.elapsed()).unwrap_or(tick_rate);

          if event::poll(timeout).expect("unable to poll for event") {
            match event::read().expect("unable to read event") {
              CrosstermEvent::Key(e) => {
                if e.kind == event::KeyEventKind::Press {
                  sender.send(Event::Key(e))
                } else {
                  Ok(()) // ignore KeyEventKind::Release on windows
                }
              },
              CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)),
              CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)),
              _ => unimplemented!(),
            }
            .expect("failed to send terminal event")
          }

          if last_tick.elapsed() >= tick_rate {
            sender.send(Event::Tick).expect("failed to send tick event");
            last_tick = Instant::now();
          }
        }
      })
    };
    Self { sender, receiver, handler }
  }

  /// Receive the next event from the handler thread.
  ///
  /// This function will always block the current thread if
  /// there is no data available and it's possible for more data to be sent.
  pub fn next(&self) -> Result<Event> {
    Ok(self.receiver.recv()?)
  }
}

At the beginning of our EventHandler::new method, we create a channel using mpsc::channel().

let (sender, receiver) = mpsc::channel();

This gives us a sender and receiver pair. The sender can be used to send events, while the receiver can be used to receive them.

Notice that we are using std::thread::spawn in this EventHandler. This thread is spawned to handle events and runs in the background and is responsible for polling and sending events to our main application through the channel. In the async counter tutorial we will use tokio::task::spawn instead.

In this background thread, we continuously poll for events with event::poll(timeout). If an event is available, it’s read and sent through the sender channel. The types of events we handle include keypresses, mouse movements, screen resizing, and regular time ticks.

if event::poll(timeout)? {
  match event::read()? {
    CrosstermEvent::Key(e) => {
        if e.kind == event::KeyEventKind::Press {
            sender.send(Event::Key(e))
        } else {
            Ok(()) // ignore KeyEventKind::Release on windows
        }
    },
    CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e))?,
    CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h))?,
    _ => unimplemented!(),
  }
}

We expose the receiver channel as part of a next() method.

  pub fn next(&self) -> Result<Event> {
    Ok(self.receiver.recv()?)
  }

Calling event_handler.next() method will call receiver.recv() which will cause the thread to block until the receiver gets a new event.

Finally, we update the last_tick value based on the time elapsed since the previous Tick. We also send a Event::Tick on the channel during this.

if last_tick.elapsed() >= tick_rate {
    sender.send(Event::Tick).expect("failed to send tick event");
    last_tick = Instant::now();
}

In summary, our EventHandler abstracts away the complexity of event polling and handling into a dedicated background thread.

Here’s the full code for your reference:

use std::{
  sync::mpsc,
  thread,
  time::{Duration, Instant},
};

use anyhow::Result;
use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent};


/// Terminal events.
#[derive(Clone, Copy, Debug)]
pub enum Event {
  /// Terminal tick.
  Tick,
  /// Key press.
  Key(KeyEvent),
  /// Mouse click/scroll.
  Mouse(MouseEvent),
  /// Terminal resize.
  Resize(u16, u16),
}

/// Terminal event handler.
#[derive(Debug)]
pub struct EventHandler {
  /// Event sender channel.
  #[allow(dead_code)]
  sender: mpsc::Sender<Event>,
  /// Event receiver channel.
  receiver: mpsc::Receiver<Event>,
  /// Event handler thread.
  #[allow(dead_code)]
  handler: thread::JoinHandle<()>,
}

impl EventHandler {
  /// Constructs a new instance of [`EventHandler`].
  pub fn new(tick_rate: u64) -> Self {
    let tick_rate = Duration::from_millis(tick_rate);
    let (sender, receiver) = mpsc::channel();
    let handler = {
      let sender = sender.clone();
      thread::spawn(move || {
        let mut last_tick = Instant::now();
        loop {
          let timeout = tick_rate.checked_sub(last_tick.elapsed()).unwrap_or(tick_rate);

          if event::poll(timeout).expect("unable to poll for event") {
            match event::read().expect("unable to read event") {
              CrosstermEvent::Key(e) => {
                if e.kind == event::KeyEventKind::Press {
                  sender.send(Event::Key(e))
                } else {
                  Ok(()) // ignore KeyEventKind::Release on windows
                }
              },
              CrosstermEvent::Mouse(e) => sender.send(Event::Mouse(e)),
              CrosstermEvent::Resize(w, h) => sender.send(Event::Resize(w, h)),
              _ => unimplemented!(),
            }
            .expect("failed to send terminal event")
          }

          if last_tick.elapsed() >= tick_rate {
            sender.send(Event::Tick).expect("failed to send tick event");
            last_tick = Instant::now();
          }
        }
      })
    };
    Self { sender, receiver, handler }
  }

  /// Receive the next event from the handler thread.
  ///
  /// This function will always block the current thread if
  /// there is no data available and it's possible for more data to be sent.
  pub fn next(&self) -> Result<Event> {
    Ok(self.receiver.recv()?)
  }
}

tui.rs

Next, we can further abstract the terminal functionality from earlier into a Tui struct.

It provides a concise and efficient way to manage the terminal, handle events, and render content. Let’s dive into its composition and functionality.

This introductory section includes the same imports and type definitions as before. We add an additional type alias for CrosstermTerminal.

use std::{io, panic};

use anyhow::Result;
use crossterm::{
  event::{DisableMouseCapture, EnableMouseCapture},
  terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};

pub type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>;

use crate::{app::App, event::EventHandler, ui};

The Tui struct can be defined with two primary fields:

  • terminal: This provides a direct interface to the terminal, allowing operations like drawing, clearing the screen, and more.
  • events: An event handler that we defined in the previous section, which would help in managing terminal events like keystrokes, mouse movements, and other input events.
/// Representation of a terminal user interface.
///
/// It is responsible for setting up the terminal,
/// initializing the interface and handling the draw events.
pub struct Tui {
  /// Interface to the Terminal.
  terminal: CrosstermTerminal,
  /// Terminal event handler.
  pub events: EventHandler,
}

With this Tui struct, we can add helper methods to handle modifying the terminal state. For example, here’s the init method:

impl Tui {
  /// Constructs a new instance of [`Tui`].
  pub fn new(terminal: CrosstermTerminal, events: EventHandler) -> Self {
    Self { terminal, events }
  }

  /// Initializes the terminal interface.
  ///
  /// It enables the raw mode and sets terminal properties.
  pub fn enter(&mut self) -> Result<()> {
    terminal::enable_raw_mode()?;
    crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;

    // Define a custom panic hook to reset the terminal properties.
    // This way, you won't have your terminal messed up if an unexpected error happens.
    let panic_hook = panic::take_hook();
    panic::set_hook(Box::new(move |panic| {
      Self::reset().expect("failed to reset the terminal");
      panic_hook(panic);
    }));

    self.terminal.hide_cursor()?;
    self.terminal.clear()?;
    Ok(())
  }

}

This is essentially the same as the startup function from before. One important thing to note that this function can be used to set a panic hook that calls the reset() method.

impl Tui {
  // --snip--

  /// Resets the terminal interface.
  ///
  /// This function is also used for the panic hook to revert
  /// the terminal properties if unexpected errors occur.
  fn reset() -> Result<()> {
    terminal::disable_raw_mode()?;
    crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?;
    Ok(())
  }

  /// Exits the terminal interface.
  ///
  /// It disables the raw mode and reverts back the terminal properties.
  pub fn exit(&mut self) -> Result<()> {
    Self::reset()?;
    self.terminal.show_cursor()?;
    Ok(())
  }

  // --snip--
}

With this panic hook, in the event of an unexpected error or panic, the terminal properties will be reset, ensuring that the terminal doesn’t remain in a disrupted state.

Finally, we can set up the draw method:

impl Tui {
    // --snip--

  /// [`Draw`] the terminal interface by [`rendering`] the widgets.
  ///
  /// [`Draw`]: tui::Terminal::draw
  /// [`rendering`]: crate::ui:render
  pub fn draw(&mut self, app: &mut App) -> Result<()> {
    self.terminal.draw(|frame| ui::render(app, frame))?;
    Ok(())
  }

}

This draw method leverages the ui::render function from earlier in this section to transform the state of our application into widgets that are then displayed on the terminal.

Here’s the full tui.rs file for your reference:


use std::{io, panic};

use anyhow::Result;
use crossterm::{
  event::{DisableMouseCapture, EnableMouseCapture},
  terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};

pub type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stderr>>;

use crate::{app::App, event::EventHandler, ui};

/// Representation of a terminal user interface.
///
/// It is responsible for setting up the terminal,
/// initializing the interface and handling the draw events.
pub struct Tui {
  /// Interface to the Terminal.
  terminal: CrosstermTerminal,
  /// Terminal event handler.
  pub events: EventHandler,
}

impl Tui {
  /// Constructs a new instance of [`Tui`].
  pub fn new(terminal: CrosstermTerminal, events: EventHandler) -> Self {
    Self { terminal, events }
  }

  /// Initializes the terminal interface.
  ///
  /// It enables the raw mode and sets terminal properties.
  pub fn enter(&mut self) -> Result<()> {
    terminal::enable_raw_mode()?;
    crossterm::execute!(io::stderr(), EnterAlternateScreen, EnableMouseCapture)?;

    // Define a custom panic hook to reset the terminal properties.
    // This way, you won't have your terminal messed up if an unexpected error happens.
    let panic_hook = panic::take_hook();
    panic::set_hook(Box::new(move |panic| {
      Self::reset().expect("failed to reset the terminal");
      panic_hook(panic);
    }));

    self.terminal.hide_cursor()?;
    self.terminal.clear()?;
    Ok(())
  }


  /// [`Draw`] the terminal interface by [`rendering`] the widgets.
  ///
  /// [`Draw`]: tui::Terminal::draw
  /// [`rendering`]: crate::ui:render
  pub fn draw(&mut self, app: &mut App) -> Result<()> {
    self.terminal.draw(|frame| ui::render(app, frame))?;
    Ok(())
  }


  /// Resets the terminal interface.
  ///
  /// This function is also used for the panic hook to revert
  /// the terminal properties if unexpected errors occur.
  fn reset() -> Result<()> {
    terminal::disable_raw_mode()?;
    crossterm::execute!(io::stderr(), LeaveAlternateScreen, DisableMouseCapture)?;
    Ok(())
  }

  /// Exits the terminal interface.
  ///
  /// It disables the raw mode and reverts back the terminal properties.
  pub fn exit(&mut self) -> Result<()> {
    Self::reset()?;
    self.terminal.show_cursor()?;
    Ok(())
  }
}

update.rs

Finally we have the update.rs file. Here, the update() function takes in two arguments:

  • key_event: This is an event provided by the crossterm crate, representing a key press from the user.
  • app: A mutable reference to our application’s state, represented by the App struct.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use crate::app::App;

pub fn update(app: &mut App, key_event: KeyEvent) {
  match key_event.code {
    KeyCode::Esc | KeyCode::Char('q') => app.quit(),
    KeyCode::Char('c') | KeyCode::Char('C') => {
      if key_event.modifiers == KeyModifiers::CONTROL {
        app.quit()
      }
    },
    KeyCode::Right | KeyCode::Char('j') => app.increment_counter(),
    KeyCode::Left | KeyCode::Char('k') => app.decrement_counter(),
    _ => {},
  };
}

Note that here we don’t have to check that key_event.kind is KeyEventKind::Press because we already do that check in event.rs and only send KeyEventKind::Press events on the channel.

Question

As an exercise, can you refactor this app to use “The Elm Architecture” principles?

Check out the concepts page on The Elm Architecture for reference.

main.rs

Putting it all together, we have the main.rs function:

/// Application.
pub mod app;

/// Terminal events handler.
pub mod event;

/// Widget renderer.
pub mod ui;

/// Terminal user interface.
pub mod tui;

/// Application updater.
pub mod update;

use anyhow::Result;
use app::App;
use event::{Event, EventHandler};
use ratatui::{backend::CrosstermBackend, Terminal};
use tui::Tui;
use update::update;

fn main() -> Result<()> {
  // Create an application.
  let mut app = App::new();

  // Initialize the terminal user interface.
  let backend = CrosstermBackend::new(std::io::stderr());
  let terminal = Terminal::new(backend)?;
  let events = EventHandler::new(250);
  let mut tui = Tui::new(terminal, events);
  tui.enter()?;

  // Start the main loop.
  while !app.should_quit {
    // Render the user interface.
    tui.draw(&mut app)?;
    // Handle events.
    match tui.events.next()? {
      Event::Tick => {},
      Event::Key(key_event) => update(&mut app, key_event),
      Event::Mouse(_) => {},
      Event::Resize(_, _) => {},
    };
  }

  // Exit the user interface.
  tui.exit()?;
  Ok(())
}

Because we call tui.events.next() in a loop, it blocks until there’s an event generated. If there’s a key press, the state updates and the UI is refreshed. If there’s no key press, a Tick event is generated every 250 milliseconds, which causes the UI to be refreshed.

This is what it looks like in practice to:

  • Run the TUI
  • Wait 2.5 seconds
  • Press j 5 times
  • Wait 2.5 seconds
  • Press k 5 times
  • Wait 2.5 seconds
  • Press q

Counter app demo

You can find the full source code for this multiple files tutorial here: https://github.com/ratatui-org/ratatui-book/tree/main/src/tutorial/counter-app/ratatui-counter-app.

Question

Right now, this TUI application will render every time a key is pressed. As an exercise, can you make this app render only an a predefined tick rate?

JSON Editor

Now that we have covered some of the basics of a “hello world” and “counter” app, we are ready to build and manage something more involved.

In this tutorial, we will be creating an application that gives the user a simple interface to enter key-value pairs, which will be converted and printed to stdout in json. The purpose of this application will be to give the user an interface to create correct json, instead of having to worry about commas and brackets themselves.

Here’s a gif of what it will look like if you run this:

Demo

Initialization

Go ahead and set up a new rust project with

cargo new ratatui-json-editor

and put the following in the Cargo.toml:


# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
crossterm = "0.26.1"
ratatui = "0.22.0"
serde = { version = "1.0.181", features = ["derive"] }
serde_json = "1.0.104"

or the latest version of these libraries.

Filestructure

Now create two files inside of src/ so it looks like this:

src
├── main.rs
├── ui.rs
└── app.rs

This follows a common approach to small applications in ratatui, where we have a state file, a UI file, and the main file to tie it all together.

App.rs

As we saw in the previous section, a common model for smaller ratatui applications is to have one application state struct called App or some variant of that name. We will be using this paradigm in this application as well.

This struct will contain all of our “persistent” data and will be passed to any function that needs to know the current state of the application.

Application modes

It is useful to think about the several “modes” that your application can be in. Thinking in “modes” will make it easier to segregate everything from what window is getting drawn, to what keybinds to listen for.

We will be using the application’s state to track two things:

  1. what screen the user is seeing,
  2. which box should be highlighted, the “key” or “value” (this only applies when the user is editing a key-value pair).

Current Screen Enum

In this tutorial application, we will have three “screens”:

  • Main: the main summary screen showing all past key-value pairs entered
  • Editing: the screen shown when the user wishes to create a new key-value pair
  • Exiting: displays a prompt asking if the user wants to output the key-value pairs they have entered.

We represent these possible modes with a simple enum:

pub enum CurrentScreen {
    Main,
    Editing,
    Exiting,
}

Currently Editing Enum

As you may already know, ratatui does not automatically redraw the screen1. ratatui also does not remember anything about what it drew last frame.

This means that the programmer is responsible for handling all state and updating widgets to reflect changes. In this case, we will allow the user to input two strings in the Editing mode - a key and a value. The programmer is responsible for knowing which the user is trying to edit.

For this purpose, we will create another enum for our application state called CurrentlyEditing to keep track of which field the user is currently entering:

pub enum CurrentlyEditing {
    Key,
    Value,
}

The full application state

Now that we have enums to help us track where the user is, we will create the struct that actually stores this data which can be passed around where it is needed.

pub struct App {
    pub key_input: String,              // the currently being edited json key.
    pub value_input: String,            // the currently being edited json value.
    pub pairs: HashMap<String, String>, // The representation of our key and value pairs with serde Serialize support
    pub current_screen: CurrentScreen, // the current screen the user is looking at, and will later determine what is rendered.
    pub currently_editing: Option<CurrentlyEditing>, // the optional state containing which of the key or value pair the user is editing. It is an option, because when the user is not directly editing a key-value pair, this will be set to `None`.
}

Helper functions

While we could simply keep our application state as simply a holder of values, we can also create a few helper functions which will make our life easier elsewhere. Of course, these functions should only affect the application state itself, and nothing outside of it.

new()

We will be adding this function simply to make creating the state easier. While this could be avoided by specifying it all in the instantiation of the variable, doing it here allows for easy to change universal defaults for the state.

impl App {
    pub fn new() -> App {
        App {
            key_input: String::new(),
            value_input: String::new(),
            pairs: HashMap::new(),
            current_screen: CurrentScreen::Main,
            currently_editing: None,
        }
    }
    // --snip--

save_key_value()

This function will be called when the user saves a key-value pair in the editor. It adds the two stored variables to the key-value pairs HashMap, and resets the status of all of the editing variables.

    // --snip--
    pub fn save_key_value(&mut self) {
        self.pairs
            .insert(self.key_input.clone(), self.value_input.clone());

        self.key_input = String::new();
        self.value_input = String::new();
        self.currently_editing = None;
    }
    // --snip--

toggle_editing()

Sometimes it is easier to put simple logic into a convenience function so we don’t have to worry about it in the main code block. toggle_editing is one of those cases. All we are doing, is checking if something is currently being edited, and if it is, swapping between editing the Key and Value fields.

    // --snip--
    pub fn toggle_editing(&mut self) {
        if let Some(edit_mode) = &self.currently_editing {
            match edit_mode {
                CurrentlyEditing::Key => self.currently_editing = Some(CurrentlyEditing::Value),
                CurrentlyEditing::Value => self.currently_editing = Some(CurrentlyEditing::Key),
            };
        } else {
            self.currently_editing = Some(CurrentlyEditing::Key);
        }
    }
    // --snip--

Finally, is another convenience function to print out the serialized json from all of our key-value pairs.

    // --snip--
    pub fn print_json(&self) -> Result<()> {
        let output = serde_json::to_string(&self.pairs)?;
        println!("{}", output);
        Ok(())
    }
    // --snip--
1

In ratatui, every frame draws the UI anew. See the Rendering section for more information.

Main.rs

The main file in many ratatui applications is simply a place to store the startup loop, and occasionally event handling. (See more ways to handle events in Event Handling))

In this application, we will be using our main function to run the startup steps, and start the main loop. We will also put our main loop logic and event handling in this file.

Main

In our main function, we will set up the terminal, create an application state and run our application, and finally reset the terminal to the state we found it in.

Application pre-run steps

Because a ratatui application takes the whole screen, and captures all of the keyboard input, we need some boilerplate at the beginning of our main function.

use crossterm::event::EnableMouseCapture;
use crossterm::execute;
use crossterm::terminal::{enable_raw_mode, EnterAlternateScreen};
use std::io;
fn main() -> Result<(), Box<dyn Error>> {
    // setup terminal
    enable_raw_mode()?;
    let mut stderr = io::stderr(); // This is a special case. Normally using stdout is fine
    execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
    // --snip--

You might notice that we are using stderr for our output. This is because we want to allow the user to pipe their completed json to other programs like ratatui-tutorial > output.json. To do this, we are utilizing the fact that stderr is piped differently than stdout, and rendering out project in stderr, and printout our completed json in stdout.

For more information, please read the crossterm documentation

State creation, and loop starting

Now that we have prepared the terminal for our application to run, it is time to actually run it.

First, we need to create an instance of our ApplicationState or app, to hold all of the program’s state, and then we will call our function which handles the event and draw loop.

    // --snip--
    let backend = CrosstermBackend::new(stderr);
    let mut terminal = Terminal::new(backend)?;

    // create app and run it
    let mut app = App::new();
    let res = run_app(&mut terminal, &mut app);

    // --snip--

Application post-run steps

Since our ratatui application has changed the state of the user’s terminal with our pre-run boilerplate, we need to undo what we have done, and put the terminal back to the way we found it.

Most of these functions will simply be the inverse of what we have done above.

use crossterm::event::DisableMouseCapture;
use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen};
    // --snip--
    // restore terminal
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;
    // --snip--

When an application exits without running this closing boilerplate, the terminal will act very strange, and the user will usually have to end the terminal session and start a new one. Thus it is important that we handle our error in such a way that we can call this last piece.

    // --snip--
    if let Ok(do_print) = res {
        if do_print {
            app.print_json()?;
        }
    } else if let Err(err) = res {
        println!("{err:?}");
    }

    Ok(())
}

The if statement at the end of boilerplate checks if the run_app function errored, or if it returned an Ok state. If it returned an Ok state, we need to check if we should print the json.

If we don’t call our print function before we call execute!(LeaveAlternateScreen), our prints will be rendered on an old screen and lost when we leave the alternate screen. (For more information on how this works, read the Crossterm documentation)

So, altogether, our finished function should looks like this:

fn main() -> Result<(), Box<dyn Error>> {
    // setup terminal
    enable_raw_mode()?;
    let mut stderr = io::stderr(); // This is a special case. Normally using stdout is fine
    execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stderr);
    let mut terminal = Terminal::new(backend)?;

    // create app and run it
    let mut app = App::new();
    let res = run_app(&mut terminal, &mut app);


    // restore terminal
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    if let Ok(do_print) = res {
        if do_print {
            app.print_json()?;
        }
    } else if let Err(err) = res {
        println!("{err:?}");
    }

    Ok(())
}

run_app

In this function, we will start to do the actual logic.

Method signature

Let’s start with the method signature:

fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<bool> {
    // --snip--

You’ll notice that we make this function generic across the ratatui::backend::Backend. In previous sections we hardcoded the CrosstermBackend. This trait approach allows us to make our code backend agnostic.

This method accepts an object of type Terminal which implements the ratatui::backend::Backend trait. This trait includes the three (four counting the TestBackend) officially supported backends included in ratatui. It allows for 3rd party backends to be implemented.

run_app also requires a mutable borrow to an application state object, as defined in this project.

Finally, the run_app returns an io::Result<bool> that indicates if there was an io error with the Err state, and an Ok(true) or Ok(false) that indicates if the program should print out the finished json.

UI Loop

Because ratatui requires us to implement our own event/ui loop, we will simply use the following code to update our main loop.

    // --snip--
    loop {
        terminal.draw(|f| ui(f, app))?;
        // --snip--

Let’s unpack that draw call really quick.

  • terminal is the Terminal<Backend> that we take as an argument,
  • draw is the ratatui command to draw a Frame to the terminal1.
  • |f| ui(f, &app) tells draw that we want to take f: <Frame> and pass it to our function ui, and ui will draw to that Frame.
1

Technically this is the command to the Terminal<Backend>, but that only matters on the TestBackend.

Notice that we also pass an immutable borrow of our application state to the ui function. This will be important later.

Event handling

Now that we have started our app , and have set up the UI rendering, we will implement the event handling.

Polling

Because we are using crossterm, we can simply poll for keyboard events with

if let Event::Key(key) = event::read()? {
    dbg!(key.code)
}

and then match the results.

Alternatively, we can set up a thread to run in the background to poll and send Events (as we did in the “counter” tutorial). Let’s keep things simple here for the sake of illustration.

Note that the process for polling events will vary on the backend you are utilizing, and you will need to refer to the documentation of that backend for more information.

Main Screen

We will start with the keybinds and event handling for the CurrentScreen::Main.

        // --snip--
        if let Event::Key(key) = event::read()? {
            if key.kind == event::KeyEventKind::Release {
                // Skip events that are not KeyEventKind::Press
                continue;
            }
            match app.current_screen {
                CurrentScreen::Main => match key.code {
                    KeyCode::Char('e') => {
                        app.current_screen = CurrentScreen::Editing;
                        app.currently_editing = Some(CurrentlyEditing::Key);
                    }
                    KeyCode::Char('q') => {
                        app.current_screen = CurrentScreen::Exiting;
                    }
                    _ => {}
                },
                // --snip--

After matching to the Main enum variant, we match the event. When the user is in the main screen, there are only two keybinds, and the rest are ignored.

In this case, KeyCode::Char('e') changes the current screen to CurrentScreen::Editing and sets the CurrentlyEditing to a Some and notes that the user should be editing the Key value field, as opposed to the Value field.

KeyCode::Char('q') is straightforward, as it simply switches the application to the Exiting screen, and allows the ui and future event handling runs to do the rest.

Exiting

The next handler we will prepare, will handle events while the application is on the CurrentScreen::Exiting. The job of this screen is to ask if the user wants to exit without outputting the json. It is simply a y/n question, so that is all we listen for. We also add an alternate exit key with q. If the user chooses to output the json, we return an Ok(true) that indicates that our main function should call app.print_json() to perform the serialization and printing for us after resetting the terminal to normal

                // --snip--
                CurrentScreen::Exiting => match key.code {
                    KeyCode::Char('y') => {
                        return Ok(true);
                    }
                    KeyCode::Char('n') | KeyCode::Char('q') => {
                        return Ok(false);
                    }
                    _ => {}
                },
                // --snip--

Editing

Our final handler will be a bit more involved, as we will be changing the state of internal variables.

We would like the Enter key to serve two purposes. When the user is editing the Key, we want the enter key to switch the focus to editing the Value. However, if the Value is what is being currently edited, Enter will save the key-value pair, and return to the Main screen.

                // --snip--
                CurrentScreen::Editing if key.kind == KeyEventKind::Press => {
                    match key.code {
                        KeyCode::Enter => {
                            if let Some(editing) = &app.currently_editing {
                                match editing {
                                    CurrentlyEditing::Key => {
                                        app.currently_editing = Some(CurrentlyEditing::Value);
                                    }
                                    CurrentlyEditing::Value => {
                                        app.save_key_value();
                                        app.current_screen = CurrentScreen::Main;
                                    }
                                }
                            }
                        }
                        // --snip--

When Backspace is pressed, we need to first determine if the user is editing a Key or a Value, then pop() the endings of those strings accordingly.

                        // --snip--
                        KeyCode::Backspace => {
                            if let Some(editing) = &app.currently_editing {
                                match editing {
                                    CurrentlyEditing::Key => {
                                        app.key_input.pop();
                                    }
                                    CurrentlyEditing::Value => {
                                        app.value_input.pop();
                                    }
                                }
                            }
                        }
                        // --snip--

When Escape is pressed, we want to quit editing.

                        // --snip--
                        KeyCode::Esc => {
                            app.current_screen = CurrentScreen::Main;
                            app.currently_editing = None;
                        }
                        // --snip--

When Tab is pressed, we want the currently editing selection to switch.

                        // --snip--
                        KeyCode::Tab => {
                            app.toggle_editing();
                        }
                        // --snip--

And finally, if the user types a valid character, we want to capture that, and add it to the string that is the final key or value.

                        // --snip--
                        KeyCode::Char(value) => {
                            if let Some(editing) = &app.currently_editing {
                                match editing {
                                    CurrentlyEditing::Key => {
                                        app.key_input.push(value);
                                    }
                                    CurrentlyEditing::Value => {
                                        app.value_input.push(value);
                                    }
                                }
                            }
                        }
                        // --snip--

Altogether, the event loop should look like this:

        // --snip--
        if let Event::Key(key) = event::read()? {
            if key.kind == event::KeyEventKind::Release {
                // Skip events that are not KeyEventKind::Press
                continue;
            }
            match app.current_screen {
                CurrentScreen::Main => match key.code {
                    KeyCode::Char('e') => {
                        app.current_screen = CurrentScreen::Editing;
                        app.currently_editing = Some(CurrentlyEditing::Key);
                    }
                    KeyCode::Char('q') => {
                        app.current_screen = CurrentScreen::Exiting;
                    }
                    _ => {}
                },
                CurrentScreen::Exiting => match key.code {
                    KeyCode::Char('y') => {
                        return Ok(true);
                    }
                    KeyCode::Char('n') | KeyCode::Char('q') => {
                        return Ok(false);
                    }
                    _ => {}
                },
                CurrentScreen::Editing if key.kind == KeyEventKind::Press => {
                    match key.code {
                        KeyCode::Enter => {
                            if let Some(editing) = &app.currently_editing {
                                match editing {
                                    CurrentlyEditing::Key => {
                                        app.currently_editing = Some(CurrentlyEditing::Value);
                                    }
                                    CurrentlyEditing::Value => {
                                        app.save_key_value();
                                        app.current_screen = CurrentScreen::Main;
                                    }
                                }
                            }
                        }
                        KeyCode::Backspace => {
                            if let Some(editing) = &app.currently_editing {
                                match editing {
                                    CurrentlyEditing::Key => {
                                        app.key_input.pop();
                                    }
                                    CurrentlyEditing::Value => {
                                        app.value_input.pop();
                                    }
                                }
                            }
                        }
                        KeyCode::Esc => {
                            app.current_screen = CurrentScreen::Main;
                            app.currently_editing = None;
                        }
                        KeyCode::Tab => {
                            app.toggle_editing();
                        }
                        KeyCode::Char(value) => {
                            if let Some(editing) = &app.currently_editing {
                                match editing {
                                    CurrentlyEditing::Key => {
                                        app.key_input.push(value);
                                    }
                                    CurrentlyEditing::Value => {
                                        app.value_input.push(value);
                                    }
                                }
                            }
                        }
                        _ => {}
                    }
                }
                _ => {}
            }
        }
        // --snip--

UI.rs

Finally we come to the last piece of the puzzle, and also the hardest part when you are just starting out creating ratatui TUIs — the UI. We created a very simple UI with just one widget in the previous tutorial, but here we’ll explore some more sophisticated layouts.

Attention

If you have created a UI before, you should know that the UI code can take up much more space than you think it should, and this is not exception. We will only briefly cover all the functionality available in ratatui and how the core of ratatui design works.

There will be links to more resources where they are covered in depth in the following sections.

Layout basics

Our first step is to grasp how we render widgets onto the terminal.

In essence: Widgets are constructed and then drawn onto the screen using a Frame, which is placed within a specified Rect.

Now, envision a scenario where we wish to divide our renderable Rect area into three distinct areas. For this, we can use the Layout functionality in ratatui.

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(1),
            Constraint::Length(3),
        ])
        .split(f.size());

This can be likened to partitioning a large rectangle into smaller sections.

Tip

For a better understanding of layouts and constraints, refer to the concepts page on Layout.

In the example above, you can read the instructions aloud like this:

  1. Take the area f.size() (which is a rectangle), and cut it into three vertical pieces (making horizontal cuts).
  2. The first section will be 3 lines tall
  3. The second section should never be smaller than one line tall, but can expand if needed.
  4. The final section should also be 3 lines tall

For those visual learners, I have the following graphic:


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  Top
  segment
  always
  remains
  3
  lines
  Bottom
  segment
  is
  consistently
  3
  lines
  Constraint::Length
  
  
  3
  Middle
  segment
  maintains
  a
  minimum
  height
  of
  1
  line,
  but
  can
  expand
  if
  additional
  space
  is
  present.
  Constraint::Length
  >
  
  
  1
  Constraint::Length
  
  
  3
  
    
    
    
    
    
    
  

Now that we have that out of the way, let us create the TUI for our application.

The function signature

Our UI function needs two things to successfully create our UI elements. The Frame which contains the size of the terminal at render time (this is important, because it allows us to take resizable terminals into account), and the application state.

pub fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {

Before we proceed, let’s implement a centered_rect helper function. This code is adapted from the popup example found in the official repo.

/// helper function to create a centered rect using up certain percentage of the available rect `r`
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    // Cut the given rectangle into three vertical pieces
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);

    // Then cut the middle vertical piece into three width-wise pieces
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1] // Return the middle chunk
}

This will be useful for the later subsections.

The Main screen

Because we want the Main screen to be rendered behind the editing popup, we will draw it first, and then have additional logic about our popups

Our layout

Now that we have our Frame, we can actually begin drawing widgets onto it. We will begin by creating out layout.

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(1),
            Constraint::Length(3),
        ])
        .split(f.size());

The variable chunks now contains a length 3 array of Rect objects that contain the top left corner of their space, and their size. We will use these later, after we prepare our widgets.

The title

The title is an important piece for any application. It helps the user understand what they can do and where they are. To create our title, we are going to use a Paragraph widget (which is used to display only text), and we are going to tell that Paragraph we want a border all around it by giving it a Block with borders enabled. (See How-To: Block and How-To: Paragraph for more information about Block and Paragraph).

    let title_block = Block::default()
        .borders(Borders::ALL)
        .style(Style::default());

    let title = Paragraph::new(Text::styled(
        "Create New Json",
        Style::default().fg(Color::Green),
    ))
    .block(title_block);

    f.render_widget(title, chunks[0]);

In this code, the first thing we do, is create a Block with all borders enabled, and the default style. Next, we created a paragraph widget with the text “Create New Json” styled green. (See How-To: Paragraphs for more information about creating paragraphs and How-To: Styling-Text for styling text) Finally, we call render_widget on our Frame, and give it the widget we want to render it, and the Rect representing where it needs to go and what size it should be. (this is the way all widgets are drawn)

The list of existing pairs

We would also like the user to be able to see any key-value pairs that they have already entered. For this, we will be using another widget, the List. The list is what it sounds like - it creates a new line of text for each ListItem, and it supports passing in a state so you can implement selecting items on the list with little extra work. We will not be implementing selection, as we simply want the user to be able to see what they have already entered.

    let mut list_items = Vec::<ListItem>::new();

    for key in app.pairs.keys() {
        list_items.push(ListItem::new(Line::from(Span::styled(
            format!("{: <25} : {}", key, app.pairs.get(key).unwrap()),
            Style::default().fg(Color::Yellow),
        ))));
    }

    let list = List::new(list_items);

    f.render_widget(list, chunks[1]);

For more information on Line, Span, and Style see How-To: Displaying Text

In this piece of the function, we create a vector of ListItems, and populate it with styled and formatted key-value pairs. Finally, we create the List widget, and render it.

The bottom navigational bar

It can help new users of your application, to see hints about what keys they can press. For this, we are going to implement two bars, and another layout. These two bars will contain information on 1) The current screen (Main, Editing, and Exiting), and 2) what keybinds are available.

Here, we will create a Vec of Span which will be converted later into a single line by the Paragraph. (A Span is different from a Line, because a Span indicates a section of Text with a style applied, and doesn’t end with a newline)

    let current_navigation_text = vec![
        // The first half of the text
        match app.current_screen {
            CurrentScreen::Main => Span::styled("Normal Mode", Style::default().fg(Color::Green)),
            CurrentScreen::Editing => {
                Span::styled("Editing Mode", Style::default().fg(Color::Yellow))
            }
            CurrentScreen::Exiting => Span::styled("Exiting", Style::default().fg(Color::LightRed)),
        }
        .to_owned(),
        // A white divider bar to separate the two sections
        Span::styled(" | ", Style::default().fg(Color::White)),
        // The final section of the text, with hints on what the user is editing
        {
            if let Some(editing) = &app.currently_editing {
                match editing {
                    CurrentlyEditing::Key => {
                        Span::styled("Editing Json Key", Style::default().fg(Color::Green))
                    }
                    CurrentlyEditing::Value => {
                        Span::styled("Editing Json Value", Style::default().fg(Color::LightGreen))
                    }
                }
            } else {
                Span::styled("Not Editing Anything", Style::default().fg(Color::DarkGray))
            }
        },
    ];

    let mode_footer = Paragraph::new(Line::from(current_navigation_text))
        .block(Block::default().borders(Borders::ALL));

Next, we are also going to make a hint in the navigation bar with available keys. This one does not have several sections of text with different styles, and is thus less code.

    let current_keys_hint = {
        match app.current_screen {
            CurrentScreen::Main => Span::styled(
                "(q) to quit / (e) to make new pair",
                Style::default().fg(Color::Red),
            ),
            CurrentScreen::Editing => Span::styled(
                "(ESC) to cancel/(Tab) to switch boxes/enter to complete",
                Style::default().fg(Color::Red),
            ),
            CurrentScreen::Exiting => Span::styled(
                "(q) to quit / (e) to make new pair",
                Style::default().fg(Color::Red),
            ),
        }
    };

    let key_notes_footer =
        Paragraph::new(Line::from(current_keys_hint)).block(Block::default().borders(Borders::ALL));

Finally, we are going to create our first nested layout. Because the Layout.split function requires a Rect, and not a Frame, we can pass one of our chunks from the previous layout as the space for the new layout. If you remember the bottom most section from the above graphic:


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  
  This
  section
  should
  always
  be
  3
  lines
  tall
  Constraint::Length
  
  
  3

We will create a new layout in this space by passing it (chunks[2]) as the parameter for split.

    let footer_chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
        .split(chunks[2]);

This code is the visual equivalent of this:


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  
  
  Length
  
  
  50%
  Length
  
  
  50%
  Constraint::Length
  
  
  3

And now we can render our footer paragraphs in the appropriate spaces.

    f.render_widget(mode_footer, footer_chunks[0]);
    f.render_widget(key_notes_footer, footer_chunks[1]);

The Editing Popup

Now that the Main screen is rendered, we now need to check if the Editing popup needs to be rendered. Since the ratatui renderer simply writes over the cells within a Rect on a render_widget, we simply need to give render_widget an area on top of our Main screen to create the appearance of a popup.

The first thing we will do, is draw the Block that will contain the popup. We will give this Block a title to display as well to explain to the user what it is. (We will cover centered_rect below)

    if let Some(editing) = &app.currently_editing {
        let popup_block = Block::default()
            .title("Enter a new key-value pair")
            .borders(Borders::NONE)
            .style(Style::default().bg(Color::DarkGray));

        let area = centered_rect(60, 25, f.size());
        f.render_widget(popup_block, area);

Now that we have where our popup is going to go, we can create the layout for the popup, and create and draw the widgets inside of it.

First, we will create split the Rect given to us by centered_rect, and create a layout from it. Note the use of margin(1), which gives a 1 space margin around any layout block, meaning our new blocks and widgets don’t overwrite anything from the first popup block.

        let popup_chunks = Layout::default()
            .direction(Direction::Horizontal)
            .margin(1)
            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
            .split(area);

Now that we have the layout for where we want to display the keys and values, we will actually create the blocks and paragraphs to show what the user has already entered.

        let mut key_block = Block::default().title("Key").borders(Borders::ALL);
        let mut value_block = Block::default().title("Value").borders(Borders::ALL);

        let active_style = Style::default().bg(Color::LightYellow).fg(Color::Black);

        match editing {
            CurrentlyEditing::Key => key_block = key_block.style(active_style),
            CurrentlyEditing::Value => value_block = value_block.style(active_style),
        };

        let key_text = Paragraph::new(app.key_input.clone()).block(key_block);
        f.render_widget(key_text, popup_chunks[0]);

        let value_text = Paragraph::new(app.value_input.clone()).block(value_block);
        f.render_widget(value_text, popup_chunks[1]);
    }

Note that we are declaring the blocks as variables, and then adding extra styling to the block the user is currently editing. Then we create the Paragraph widgets, and assign the blocks with those variables. Also note how we used the popup_chunks layout instead of the popup_block layout to render these widgets into.

The Exit Popup

We have a way for the user to view their already entered key-value pairs, and we have a way for the user to enter new ones. The last screen we need to create, is the exit/confirmation screen.

In this screen, we are asking the user if they want to output the key-value pairs they have entered in the stdout pipe, or close without outputting anything.

    if let CurrentScreen::Exiting = app.current_screen {
        f.render_widget(Clear, f.size()); //this clears the entire screen and anything already drawn
        let popup_block = Block::default()
            .title("Y/N")
            .borders(Borders::NONE)
            .style(Style::default().bg(Color::DarkGray));

        let exit_text = Text::styled(
            "Would you like to output the buffer as json? (y/n)",
            Style::default().fg(Color::Red),
        );
        // the `trim: false` will stop the text from being cut off when over the edge of the block
        let exit_paragraph = Paragraph::new(exit_text)
            .block(popup_block)
            .wrap(Wrap { trim: false });

        let area = centered_rect(60, 25, f.size());
        f.render_widget(exit_paragraph, area);
    }

The only thing in this part that we haven’t done before, is use the Clear widget. This is a special widget that does what the name suggests — it clears everything in the space it is rendered.

Closing Thoughts

This tutorial should get you started with a basic understanding of the flow of a ratatui program. However, this is only one way to create a ratatui application. Because ratatui is relatively low level compared to other UI frameworks, almost any application model can be implemented. You can explore more of these in Concepts: Application Patterns and get some inspiration for what model will work best for your application.

Finished Files

You can find the finished project used for the tutorial on GitHub. The code is also shown at the bottom of this page.

You can test this application by yourself by running:

cargo run > test.json

and double checking the output.

Main.rs

use std::{error::Error, io};

use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::{Backend, CrosstermBackend},
    Terminal,
};

mod app;
mod ui;
use crate::{
    app::{App, CurrentScreen, CurrentlyEditing},
    ui::ui,
};

fn main() -> Result<(), Box<dyn Error>> {
    // setup terminal
    enable_raw_mode()?;
    let mut stderr = io::stderr(); // This is a special case. Normally using stdout is fine
    execute!(stderr, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stderr);
    let mut terminal = Terminal::new(backend)?;

    // create app and run it
    let mut app = App::new();
    let res = run_app(&mut terminal, &mut app);


    // restore terminal
    disable_raw_mode()?;
    execute!(
        terminal.backend_mut(),
        LeaveAlternateScreen,
        DisableMouseCapture
    )?;
    terminal.show_cursor()?;

    if let Ok(do_print) = res {
        if do_print {
            app.print_json()?;
        }
    } else if let Err(err) = res {
        println!("{err:?}");
    }

    Ok(())
}

fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> io::Result<bool> {
    loop {
        terminal.draw(|f| ui(f, app))?;

        if let Event::Key(key) = event::read()? {
            if key.kind == event::KeyEventKind::Release {
                // Skip events that are not KeyEventKind::Press
                continue;
            }
            match app.current_screen {
                CurrentScreen::Main => match key.code {
                    KeyCode::Char('e') => {
                        app.current_screen = CurrentScreen::Editing;
                        app.currently_editing = Some(CurrentlyEditing::Key);
                    }
                    KeyCode::Char('q') => {
                        app.current_screen = CurrentScreen::Exiting;
                    }
                    _ => {}
                },
                CurrentScreen::Exiting => match key.code {
                    KeyCode::Char('y') => {
                        return Ok(true);
                    }
                    KeyCode::Char('n') | KeyCode::Char('q') => {
                        return Ok(false);
                    }
                    _ => {}
                },
                CurrentScreen::Editing if key.kind == KeyEventKind::Press => {
                    match key.code {
                        KeyCode::Enter => {
                            if let Some(editing) = &app.currently_editing {
                                match editing {
                                    CurrentlyEditing::Key => {
                                        app.currently_editing = Some(CurrentlyEditing::Value);
                                    }
                                    CurrentlyEditing::Value => {
                                        app.save_key_value();
                                        app.current_screen = CurrentScreen::Main;
                                    }
                                }
                            }
                        }
                        KeyCode::Backspace => {
                            if let Some(editing) = &app.currently_editing {
                                match editing {
                                    CurrentlyEditing::Key => {
                                        app.key_input.pop();
                                    }
                                    CurrentlyEditing::Value => {
                                        app.value_input.pop();
                                    }
                                }
                            }
                        }
                        KeyCode::Esc => {
                            app.current_screen = CurrentScreen::Main;
                            app.currently_editing = None;
                        }
                        KeyCode::Tab => {
                            app.toggle_editing();
                        }
                        KeyCode::Char(value) => {
                            if let Some(editing) = &app.currently_editing {
                                match editing {
                                    CurrentlyEditing::Key => {
                                        app.key_input.push(value);
                                    }
                                    CurrentlyEditing::Value => {
                                        app.value_input.push(value);
                                    }
                                }
                            }
                        }
                        _ => {}
                    }
                }
                _ => {}
            }
        }
    }
}

App.rs

use serde_json::Result;

pub enum CurrentScreen {
    Main,
    Editing,
    Exiting,
}

pub enum CurrentlyEditing {
    Key,
    Value,
}

pub struct App {
    pub key_input: String,              // the currently being edited json key.
    pub value_input: String,            // the currently being edited json value.
    pub pairs: HashMap<String, String>, // The representation of our key and value pairs with serde Serialize support
    pub current_screen: CurrentScreen, // the current screen the user is looking at, and will later determine what is rendered.
    pub currently_editing: Option<CurrentlyEditing>, // the optional state containing which of the key or value pair the user is editing. It is an option, because when the user is not directly editing a key-value pair, this will be set to `None`.
}

impl App {
    pub fn new() -> App {
        App {
            key_input: String::new(),
            value_input: String::new(),
            pairs: HashMap::new(),
            current_screen: CurrentScreen::Main,
            currently_editing: None,
        }
    }

    pub fn save_key_value(&mut self) {
        self.pairs
            .insert(self.key_input.clone(), self.value_input.clone());

        self.key_input = String::new();
        self.value_input = String::new();
        self.currently_editing = None;
    }

    pub fn toggle_editing(&mut self) {
        if let Some(edit_mode) = &self.currently_editing {
            match edit_mode {
                CurrentlyEditing::Key => self.currently_editing = Some(CurrentlyEditing::Value),
                CurrentlyEditing::Value => self.currently_editing = Some(CurrentlyEditing::Key),
            };
        } else {
            self.currently_editing = Some(CurrentlyEditing::Key);
        }
    }

    pub fn print_json(&self) -> Result<()> {
        let output = serde_json::to_string(&self.pairs)?;
        println!("{}", output);
        Ok(())
    }
}

UI.rs

use ratatui::{
    backend::Backend,
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Style},
    text::{Line, Span, Text},
    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
    Frame,
};

use crate::app::{App, CurrentScreen, CurrentlyEditing};

pub fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
    // Create the layout sections.
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(1),
            Constraint::Length(3),
        ])
        .split(f.size());

    let title_block = Block::default()
        .borders(Borders::ALL)
        .style(Style::default());

    let title = Paragraph::new(Text::styled(
        "Create New Json",
        Style::default().fg(Color::Green),
    ))
    .block(title_block);

    f.render_widget(title, chunks[0]);
    let mut list_items = Vec::<ListItem>::new();

    for key in app.pairs.keys() {
        list_items.push(ListItem::new(Line::from(Span::styled(
            format!("{: <25} : {}", key, app.pairs.get(key).unwrap()),
            Style::default().fg(Color::Yellow),
        ))));
    }

    let list = List::new(list_items);

    f.render_widget(list, chunks[1]);
    let current_navigation_text = vec![
        // The first half of the text
        match app.current_screen {
            CurrentScreen::Main => Span::styled("Normal Mode", Style::default().fg(Color::Green)),
            CurrentScreen::Editing => {
                Span::styled("Editing Mode", Style::default().fg(Color::Yellow))
            }
            CurrentScreen::Exiting => Span::styled("Exiting", Style::default().fg(Color::LightRed)),
        }
        .to_owned(),
        // A white divider bar to separate the two sections
        Span::styled(" | ", Style::default().fg(Color::White)),
        // The final section of the text, with hints on what the user is editing
        {
            if let Some(editing) = &app.currently_editing {
                match editing {
                    CurrentlyEditing::Key => {
                        Span::styled("Editing Json Key", Style::default().fg(Color::Green))
                    }
                    CurrentlyEditing::Value => {
                        Span::styled("Editing Json Value", Style::default().fg(Color::LightGreen))
                    }
                }
            } else {
                Span::styled("Not Editing Anything", Style::default().fg(Color::DarkGray))
            }
        },
    ];

    let mode_footer = Paragraph::new(Line::from(current_navigation_text))
        .block(Block::default().borders(Borders::ALL));

    let current_keys_hint = {
        match app.current_screen {
            CurrentScreen::Main => Span::styled(
                "(q) to quit / (e) to make new pair",
                Style::default().fg(Color::Red),
            ),
            CurrentScreen::Editing => Span::styled(
                "(ESC) to cancel/(Tab) to switch boxes/enter to complete",
                Style::default().fg(Color::Red),
            ),
            CurrentScreen::Exiting => Span::styled(
                "(q) to quit / (e) to make new pair",
                Style::default().fg(Color::Red),
            ),
        }
    };

    let key_notes_footer =
        Paragraph::new(Line::from(current_keys_hint)).block(Block::default().borders(Borders::ALL));

    let footer_chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
        .split(chunks[2]);

    f.render_widget(mode_footer, footer_chunks[0]);
    f.render_widget(key_notes_footer, footer_chunks[1]);

    if let Some(editing) = &app.currently_editing {
        let popup_block = Block::default()
            .title("Enter a new key-value pair")
            .borders(Borders::NONE)
            .style(Style::default().bg(Color::DarkGray));

        let area = centered_rect(60, 25, f.size());
        f.render_widget(popup_block, area);

        let popup_chunks = Layout::default()
            .direction(Direction::Horizontal)
            .margin(1)
            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
            .split(area);

        let mut key_block = Block::default().title("Key").borders(Borders::ALL);
        let mut value_block = Block::default().title("Value").borders(Borders::ALL);

        let active_style = Style::default().bg(Color::LightYellow).fg(Color::Black);

        match editing {
            CurrentlyEditing::Key => key_block = key_block.style(active_style),
            CurrentlyEditing::Value => value_block = value_block.style(active_style),
        };

        let key_text = Paragraph::new(app.key_input.clone()).block(key_block);
        f.render_widget(key_text, popup_chunks[0]);

        let value_text = Paragraph::new(app.value_input.clone()).block(value_block);
        f.render_widget(value_text, popup_chunks[1]);
    }

    if let CurrentScreen::Exiting = app.current_screen {
        f.render_widget(Clear, f.size()); //this clears the entire screen and anything already drawn
        let popup_block = Block::default()
            .title("Y/N")
            .borders(Borders::NONE)
            .style(Style::default().bg(Color::DarkGray));

        let exit_text = Text::styled(
            "Would you like to output the buffer as json? (y/n)",
            Style::default().fg(Color::Red),
        );
        // the `trim: false` will stop the text from being cut off when over the edge of the block
        let exit_paragraph = Paragraph::new(exit_text)
            .block(popup_block)
            .wrap(Wrap { trim: false });

        let area = centered_rect(60, 25, f.size());
        f.render_widget(exit_paragraph, area);
    }
}

/// helper function to create a centered rect using up certain percentage of the available rect `r`
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    // Cut the given rectangle into three vertical pieces
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);

    // Then cut the middle vertical piece into three width-wise pieces
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1] // Return the middle chunk
}

Async Counter App

In the previous counter app, we had a purely sequential blocking application. There are times when you may be interested in running IO operations or compute asynchronously.

For this tutorial, we will build a single file version of an async TUI using tokio. This tutorial section is a simplified version of the ratatui-async-template project.

Installation

Here’s an example of the Cargo.toml file required for this tutorial:

[package]
name = "ratatui-counter-async-app"
version = "0.1.0"
edition = "2021"

[dependencies]
color-eyre = "0.6.2"
crossterm = { version = "0.27.0", features = ["event-stream"] }
ratatui = "0.24.0"
tokio = { version = "1.32.0", features = ["full"] }
tokio-util = "0.7.9"
futures = "0.3.28"

Note

If you were already using crossterm before, note that now you’ll need to add features = ["event-stream"] to use crossterm’s async features.

You can use cargo add from the command line to add the above dependencies in one go:

cargo add ratatui crossterm color-eyre tokio tokio-util futures --features tokio/full,crossterm/event-stream

Setup

Let’s take the single file multiple function example from the counter app from earlier:

// Hover on this codeblock and click "Show hidden lines" in the top right to see the full code
use color_eyre::eyre::Result;
use crossterm::{
  event::{self, Event::Key, KeyCode::Char},
  execute,
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};

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

fn startup() -> Result<()> {
  enable_raw_mode()?;
  execute!(std::io::stderr(), EnterAlternateScreen)?;
  Ok(())
}

fn shutdown() -> Result<()> {
  execute!(std::io::stderr(), LeaveAlternateScreen)?;
  disable_raw_mode()?;
  Ok(())
}

// App state
struct App {
  counter: i64,
  should_quit: bool,
}

// App ui render function
fn ui(app: &App, f: &mut Frame<'_>) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

// App update function
fn update(app: &mut App) -> Result<()> {
  if event::poll(std::time::Duration::from_millis(250))? {
    if let Key(key) = event::read()? {
      if key.kind == event::KeyEventKind::Press {
        match key.code {
          Char('j') => app.counter += 1,
          Char('k') => app.counter -= 1,
          Char('q') => app.should_quit = true,
          _ => {},
        }
      }
    }
  }
  Ok(())
}

fn run() -> Result<()> {
  // ratatui terminal
  let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    // application update
    update(&mut app)?;

    // application render
    t.draw(|f| {
      ui(&app, f);
    })?;

    // application exit
    if app.should_quit {
      break;
    }
  }

  Ok(())
}

fn main() -> Result<()> {
  // setup terminal
  startup()?;

  let result = run();

  // teardown terminal before unwrapping Result of app run
  shutdown()?;

  result?;

  Ok(())
}

Tokio is an asynchronous runtime for the Rust programming language. It provides the building blocks needed for writing network applications. We recommend you read the Tokio documentation to learn more.

For the setup for this section of the tutorial, we are going to make just one change. We are going to make our main function a tokio entry point.

// Hover on this codeblock and click "Show hidden lines" in the top right to see the full code
use color_eyre::eyre::Result;
use crossterm::{
  event::{self, Event::Key, KeyCode::Char},
  execute,
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};

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

fn startup() -> Result<()> {
  enable_raw_mode()?;
  execute!(std::io::stderr(), EnterAlternateScreen)?;
  Ok(())
}

fn shutdown() -> Result<()> {
  execute!(std::io::stderr(), LeaveAlternateScreen)?;
  disable_raw_mode()?;
  Ok(())
}

// App state
struct App {
  counter: i64,
  should_quit: bool,
}

// App ui render function
fn ui(app: &App, f: &mut Frame<'_>) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

// App update function
fn update(app: &mut App) -> Result<()> {
  if event::poll(std::time::Duration::from_millis(250))? {
    if let Key(key) = event::read()? {
      if key.kind == event::KeyEventKind::Press {
        match key.code {
          Char('j') => app.counter += 1,
          Char('k') => app.counter -= 1,
          Char('q') => app.should_quit = true,
          _ => {},
        }
      }
    }
  }
  Ok(())
}

fn run() -> Result<()> {
  // ratatui terminal
  let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    // application update
    update(&mut app)?;

    // application render
    t.draw(|f| {
      ui(&app, f);
    })?;

    // application exit
    if app.should_quit {
      break;
    }
  }

  Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
  // setup terminal
  startup()?;

  let result = run();

  // teardown terminal before unwrapping Result of app run
  shutdown()?;

  result?;

  Ok(())
}

Adding this #[tokio::main] macro allows us to spawn tokio tasks within main. At the moment, there are no async functions other than main and we are not using .await anywhere yet. We will change that in the following sections. But first, we let us introduce the Action enum.

Async Event Stream

Previously, in the multiple file version of the counter app, in event.rs we created an EventHandler using std::thread::spawn, i.e. OS threads.

In this section, we are going to do the same thing with “green” threads or tasks, i.e. rust’s async-await features + a future executor. We will be using tokio for this.

Here’s example code of reading key presses asynchronously comparing std::thread and tokio::task. Notably, we are using tokio::sync::mpsc channels instead of std::sync::mpsc channels. And because of this, receiving on a channel needs to be .await’d and hence needs to be in a async fn method.

  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).unwrap() {
            match crossterm::event::read().unwrap() {
              CrosstermEvent::Key(e) => {
                if key.kind == event::KeyEventKind::Press {
                  tx.send(Event::Key(e)).unwrap()
                }
              },
              _ => unimplemented!(),
            }
          }
        }
      })

      EventHandler { rx }
    }

-   fn next(&self) -> Result<Event> {
+   async fn next(&mut self) -> Result<Event> {
-     Ok(self.rx.recv()?)
+     self.rx.recv().await.ok_or(color_eyre::eyre::eyre!("Unable to get event"))
    }
  }

Even with this change, our EventHandler behaves the same way as before. In order to take advantage of using tokio we have to use tokio::select!.

We can use tokio’s select! macro to wait on multiple async computations and return when a any single computation completes.

Note

Using crossterm::event::EventStream::new() requires the event-stream feature to be enabled. This also requires the futures crate. Naturally you’ll also need tokio.

If you haven’t already, add the following to your Cargo.toml:

crossterm = { version = "0.27.0", features = ["event-stream"] }
futures = "0.3.28"
tokio = { version = "1.32.0", features = ["full"] }
tokio-util = "0.7.9" # required for `CancellationToken` introduced in the next section

Here’s what the EventHandler looks like with the select! macro:

use color_eyre::eyre::Result;
use crossterm::event::KeyEvent;
use futures::{FutureExt, StreamExt};
use tokio::{sync::mpsc, task::JoinHandle};

#[derive(Clone, Copy, Debug)]
pub enum Event {
  Error,
  Tick,
  Key(KeyEvent),
}

#[derive(Debug)]
pub struct EventHandler {
  _tx: mpsc::UnboundedSender<Event>,
  rx: mpsc::UnboundedReceiver<Event>,
  task: Option<JoinHandle<()>>,
}

impl EventHandler {
  pub fn new() -> Self {
    let tick_rate = std::time::Duration::from_millis(250);

    let (tx, rx) = mpsc::unbounded_channel();
    let _tx = tx.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! {
          maybe_event = crossterm_event => {
            match maybe_event {
              Some(Ok(evt)) => {
                match evt {
                  crossterm::event::Event::Key(key) => {
                    if key.kind == crossterm::event::KeyEventKind::Press {
                      tx.send(Event::Key(key)).unwrap();
                    }
                  },
                  _ => {},
                }
              }
              Some(Err(_)) => {
                tx.send(Event::Error).unwrap();
              }
              None => {},
            }
          },
          _ = delay => {
              tx.send(Event::Tick).unwrap();
          },
        }
      }
    });

    Self { _tx, rx, task: Some(task) }
  }

  pub async fn next(&mut self) -> Result<Event> {
    self.rx.recv().await.ok_or(color_eyre::eyre::eyre!("Unable to get event"))
  }
}

As mentioned before, since EventHandler::next() is a async function, when we use it we have to call .await on it. And the function that is the call site of event_handler.next().await also needs to be an async function. In our tutorial, we are going to use the event handler in the run() function which will now be async.

Also, now that we are getting events asynchronously, we don’t need to call crossterm::event::poll() in the update function. Let’s make the update function take an Event instead.

If you place the above EventHandler in a src/tui.rs file, then here’s what our application now looks like:

mod tui;

use color_eyre::eyre::Result;
use crossterm::{
  event::{self, Event::Key, KeyCode::Char},
  execute,
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};
use crossterm::{
  cursor,
  event::{Event as CrosstermEvent, KeyEvent, KeyEventKind, MouseEvent},
};

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

fn startup() -> Result<()> {
  enable_raw_mode()?;
  execute!(std::io::stderr(), EnterAlternateScreen)?;
  Ok(())
}

fn shutdown() -> Result<()> {
  execute!(std::io::stderr(), LeaveAlternateScreen)?;
  disable_raw_mode()?;
  Ok(())
}

// App state
struct App {
  counter: i64,
  should_quit: bool,
}

// App actions
pub enum Action {
  Tick,
  Increment,
  Decrement,
  Quit,
  None,
}

// App ui render function
fn ui(f: &mut Frame<'_>, app: &App) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

fn update(app: &mut App, event: Event) -> Result<()> {
  if let Event::Key(key) = event {
    match key.code {
      Char('j') => app.counter += 1,
      Char('k') => app.counter -= 1,
      Char('q') => app.should_quit = true,
      _ => {},
    }
  }
  Ok(())
}

async fn run() -> Result<()> {

  let mut events = tui::EventHandler::new(); // new

  // ratatui terminal
  let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    let event = events.next().await?; // new

    // application update
    update(&mut app, event)?;

    // application render
    t.draw(|f| {
      ui(f, &app);
    })?;

    // application exit
    if app.should_quit {
      break;
    }
  }

  Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
  // setup terminal
  startup()?;

  let result = run().await;

  // teardown terminal before unwrapping Result of app run
  shutdown()?;

  result?;

  Ok(())
}

Using tokio in this manner however only makes the key events asynchronous but doesn’t make the rest of our application asynchronous yet. We will discuss that in the next section.

Full Async - Events

There are a number of ways to make our application work more in an async manner. The easiest way to do this is to add more Event variants to our existing EventHandler. Specifically, we would like to only render in the main run loop when we receive a Event::Render variant:

#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {
  Quit,
  Error,
  Tick,
  Render, // new
  Key(KeyEvent),
}

Another thing I personally like to do is combine the EventHandler struct and the Terminal functionality. To do this, we are going to rename our EventHandler struct to a Tui struct. We are also going to include a few more Event variants for making our application more capable.

Below is the relevant snippet of an updated Tui struct. You can click on the “Show hidden lines” button at the top right of the code block or check out this section of the book for the full version this struct.

The key things to note are that we create a tick_interval, render_interval and reader stream that can be polled using tokio::select!. This means that even while waiting for a key press, we will still send a Event::Tick and Event::Render at regular intervals.

use std::{
  ops::{Deref, DerefMut},
  time::Duration,
};

use color_eyre::eyre::Result;
use crossterm::{
  cursor,
  event::{
    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, Event as CrosstermEvent,
    KeyEvent, KeyEventKind, MouseEvent,
  },
  terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::{FutureExt, StreamExt};
use ratatui::backend::CrosstermBackend as Backend;
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)]
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,
  pub mouse: bool,
  pub paste: bool,
}

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 {});
    let mouse = false;
    let paste = false;
    Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate, mouse, paste })
  }

  pub fn tick_rate(mut self, tick_rate: f64) -> Self {
    self.tick_rate = tick_rate;
    self
  }

  pub fn frame_rate(mut self, frame_rate: f64) -> Self {
    self.frame_rate = frame_rate;
    self
  }

  pub fn mouse(mut self, mouse: bool) -> Self {
    self.mouse = mouse;
    self
  }

  pub fn paste(mut self, paste: bool) -> Self {
    self.paste = paste;
    self
  }

  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 enter(&mut self) -> Result<()> {
    crossterm::terminal::enable_raw_mode()?;
    crossterm::execute!(std::io::stderr(), EnterAlternateScreen, cursor::Hide)?;
    if self.mouse {
      crossterm::execute!(std::io::stderr(), EnableMouseCapture)?;
    }
    if self.paste {
      crossterm::execute!(std::io::stderr(), EnableBracketedPaste)?;
    }
    self.start();
    Ok(())
  }

  pub fn exit(&mut self) -> Result<()> {
    self.stop()?;
    if crossterm::terminal::is_raw_mode_enabled()? {
      self.flush()?;
      if self.paste {
        crossterm::execute!(std::io::stderr(), DisableBracketedPaste)?;
      }
      if self.mouse {
        crossterm::execute!(std::io::stderr(), DisableMouseCapture)?;
      }
      crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?;
      crossterm::terminal::disable_raw_mode()?;
    }
    Ok(())
  }

  pub fn cancel(&self) {
    self.cancellation_token.cancel();
  }

  pub fn resume(&mut self) -> Result<()> {
    self.enter()?;
    Ok(())
  }

  pub async fn next(&mut self) -> Result<Event> {
    self.event_rx.recv().await.ok_or(color_eyre::eyre::eyre!("Unable to get event"))
  }
}

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

We made a number of changes to the Tui struct.

  1. We added a Deref and DerefMut so we can call tui.draw(|f| ...) to have it call tui.terminal.draw(|f| ...).
  2. We moved the startup() and shutdown() functionality into the Tui struct.
  3. We also added a CancellationToken so that we can start and stop the tokio task more easily.
  4. We added Event variants for Resize, Focus, and Paste.
  5. We added methods to set the tick_rate, frame_rate, and whether we want to enable mouse or paste events.

Here’s the code for the fully async application:

mod tui;

use color_eyre::eyre::Result;
use crossterm::event::KeyCode::Char;
use ratatui::{prelude::CrosstermBackend, widgets::Paragraph};
use tui::Event;

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

// App state
struct App {
  counter: i64,
  should_quit: bool,
}

// App ui render function
fn ui(f: &mut Frame<'_>, app: &App) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

fn update(app: &mut App, event: Event) {
  match event {
    Event::Key(key) => {
      match key.code {
        Char('j') => app.counter += 1,
        Char('k') => app.counter -= 1,
        Char('q') => app.should_quit = true,
        _ => Action::None,
      }
    },
    _ => {},
  };
}

async fn run() -> Result<()> {
  // ratatui terminal
  let mut tui = tui::Tui::new()?.tick_rate(1.0).frame_rate(30.0);
  tui.enter()?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    let event = tui.next().await?; // blocks until next event

    if let Event::Render = event.clone() {
      // application render
      tui.draw(|f| {
        ui(f, &app);
      })?;
    }

    // application update
    update(&mut app, event);

    // application exit
    if app.should_quit {
      break;
    }
  }
  tui.exit()?;

  Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
  let result = run().await;

  result?;

  Ok(())
}

The above code ensures that we render at a consistent frame rate. As an exercise, play around with this frame rate and tick rate to see how the CPU utilization changes as you change those numbers.

Even though our application renders in an “async” manner, we also want to perform “actions” in an asynchronous manner. We will improve this in the next section to make our application truly async capable.

Counter App with Actions

One of the first steps to building truly async TUI applications is to use the Command, Action, or Message 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.

You can learn more about this concept in The Elm Architecture section of the documentation.

We have learnt about enums in JSON-editor tutorial. We are going to extend the counter application to include Actions using Rust’s enum features. The key idea is that we have an Action enum that tracks all the actions that can be carried out by the App. Here’s the variants of the Action enum we will be using:

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

Now we add a new get_action function to map a Event to an Action.

fn get_action(_app: &App, event: Event) -> Action {
  if let Key(key) = event {
    return match key.code {
      Char('j') => Action::Increment,
      Char('k') => Action::Decrement,
      Char('q') => Action::Quit,
      _ => Action::None,
    };
  };
  Action::None
}

Tip

Instead of using a None variant in Action, you can drop the None from Action and use Rust’s built-in Option types instead. This is what your code might actually look like:

fn get_action(_app: &App, event: Event) -> Result<Option<Action>> {
  if let Key(key) = event {
    let action = match key.code {
      Char('j') => Action::Increment,
      Char('k') => Action::Decrement,
      Char('q') => Action::Quit,
      _ => return Ok(None),
    };
    return Ok(Some(action))
  };
  Ok(None)
}

But, for illustration purposes, in this tutorial we will stick to using Action::None for now.

And the update function takes an Action instead:

fn update(app: &mut App, action: Action) {
  match action {
    Action::Quit => app.should_quit = true,
    Action::Increment => app.counter += 1,
    Action::Decrement => app.counter -= 1,
    Action::Tick => {},
    _ => {},
  };
}

Here’s the full single file version of the counter app using the Action enum for your reference:

mod tui;

use color_eyre::eyre::Result;
use crossterm::{
  event::{self, Event::Key, KeyCode::Char},
  execute,
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};

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

// App state
struct App {
  counter: i64,
  should_quit: bool,
}

// App actions
pub enum Action {
  Tick,
  Increment,
  Decrement,
  Quit,
  None,
}

// App ui render function
fn ui(app: &App, f: &mut Frame<'_>) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

fn get_action(_app: &App, event: Event) -> Action {
  if let Key(key) = event {
    return match key.code {
      Char('j') => Action::Increment,
      Char('k') => Action::Decrement,
      Char('q') => Action::Quit,
      _ => Action::None,
    };
  };
  Action::None
}

fn update(app: &mut App, action: Action) {
  match action {
    Action::Quit => app.should_quit = true,
    Action::Increment => app.counter += 1,
    Action::Decrement => app.counter -= 1,
    Action::Tick => {},
    _ => {},
  };
}

fn run() -> Result<()> {
  // ratatui terminal
  let mut tui = tui::Tui::new()?.tick_rate(1.0).frame_rate(30.0);
  tui.enter()?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    let event = tui.next().await?; // blocks until next event

    if let Event::Render = event.clone() {
      // application render
      tui.draw(|f| {
        ui(f, &app);
      })?;
    }
    let action = get_action(&mut app, event); // new

    // application update
    update(&mut app, action); // new

    // application exit
    if app.should_quit {
      break;
    }
  }

  Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
  let result = run().await;

  result?;

  Ok(())
}

While this may seem like a lot more boilerplate to achieve the same thing, Action enums have a few advantages.

Firstly, they can be mapped from keypresses programmatically. For example, you can define a configuration file that reads which keys are mapped to which Action like so:

[keymap]
"q" = "Quit"
"j" = "Increment"
"k" = "Decrement"

Then you can add a new key configuration like so:

struct App {
  counter: i64,
  should_quit: bool,
  // new field
  keyconfig: HashMap<KeyCode, Action>
}

If you populate keyconfig with the contents of a user provided toml file, then you can figure out which action to take by updating the get_action() function:

fn get_action(app: &App, event: Event) -> Action {
  if let Event::Key(key) = event {
    return app.keyconfig.get(key.code).unwrap_or(Action::None)
  };
  Action::None
}

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;
    update(&mut app, Action::Increment);
    assert!(app.counter == old_counter + 1);
  }
}

In the test above, we did not create an instance of the Terminal 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 a struct called AppState which would be immutable, and we would have an update function return a new instance of the AppState:

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

Note

Charm’s bubbletea also follows the TEA paradigm. Here’s an example of what the Update function for a counter example might look like in Go:

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
}

Like in Charm, we may also want to choose a action to follow up after an update by returning another Action:

fn update(app_state: AppState, action: Action) -> (AppState, Action) {
  let mut state = app_state.clone();
  state.counter += 1;
  (state, Action::None) // no follow up action
  // OR
  (state, Action::Tick) // force app to tick
}

We would have to modify our run function to handle the above paradigm though. Also, writing code to follow this architecture in Rust requires more upfront design, mostly because you have to make your AppState struct Clone-friendly.

For this tutorial, we will stick to having a mutable App:

fn update(app: &mut App, action: Action) {
  match action {
    Action::Quit => app.should_quit = true,
    Action::Increment => app.counter += 1,
    Action::Decrement => app.counter -= 1,
    Action::Tick => {},
    _ => {},
  };
}

The other advantage of using an Action enum is that you can tell your application what it should do next by sending a message over a channel. We will discuss this approach in the next section.

Full Async - Actions

Now that we have introduced Events and Actions, we are going introduce a new mpsc::channel for Actions. The advantage of this is that we can programmatically trigger updates to the state of the app by sending Actions on the channel.

Here’s the run function refactored from before to introduce an Action channel. In addition to refactoring, we store the action_tx half of the channel in the App.

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

  // ratatui terminal
  let mut tui = tui::Tui::new()?.tick_rate(1.0).frame_rate(30.0);
  tui.enter()?;

  // application state
  let mut app = App { counter: 0, should_quit: false, action_tx: action_tx.clone() };

  loop {
    let e = tui.next().await?;
    match e {
      tui::Event::Quit => action_tx.send(Action::Quit)?,
      tui::Event::Tick => action_tx.send(Action::Tick)?,
      tui::Event::Render => action_tx.send(Action::Render)?,
      tui::Event::Key(_) => {
        let action = get_action(&app, e);
        action_tx.send(action.clone())?;
      },
      _ => {},
    };

    while let Ok(action) = action_rx.try_recv() {
      // application update
      update(&mut app, action.clone());
      // render only when we receive Action::Render
      if let Action::Render = action {
        tui.draw(|f| {
          ui(f, &mut app);
        })?;
      }
    }

    // application exit
    if app.should_quit {
      break;
    }
  }
  tui.exit()?;

  Ok(())
}

Running the code with this change should give the exact same behavior as before.

Now that we have stored the action_tx half of the channel in the App, we can use this to schedule tasks. For example, let’s say we wanted to press J and K to perform some network request and then increment the counter.

First, we have to update my Action enum:

#[derive(Clone)]
pub enum Action {
  Tick,
  Increment,
  Decrement,
  NetworkRequestAndThenIncrement, // new
  NetworkRequestAndThenDecrement, // new
  Quit,
  Render,
  None,
}

Next, we can update my event handler:

fn get_action(_app: &App, event: Event) -> Action {
  match event {
    Event::Error => Action::None,
    Event::Tick => Action::Tick,
    Event::Render => Action::Render,
    Event::Key(key) => {
      match key.code {
        Char('j') => Action::Increment,
        Char('k') => Action::Decrement,
        Char('J') => Action::NetworkRequestAndThenIncrement, // new
        Char('K') => Action::NetworkRequestAndThenDecrement, // new
        Char('q') => Action::Quit,
        _ => Action::None,
      }
    },
    _ => Action::None,
  }
}

Finally, we can handle the action in my update function by spawning a tokio task:

fn update(app: &mut App, action: Action) {
  match action {
    Action::Increment => {
      app.counter += 1;
    },
    Action::Decrement => {
      app.counter -= 1;
    },
    Action::NetworkRequestAndThenIncrement => {
      let tx = app.action_tx.clone();
      tokio::spawn(async move {
        tokio::time::sleep(Duration::from_secs(5)).await; // simulate network request
        tx.send(Action::Increment).unwrap();
      });
    },
    Action::NetworkRequestAndThenDecrement => {
      let tx = app.action_tx.clone();
      tokio::spawn(async move {
        tokio::time::sleep(Duration::from_secs(5)).await; // simulate network request
        tx.send(Action::Decrement).unwrap();
      });
    },
    Action::Quit => app.should_quit = true,
    _ => {},
  };
}

Here is the full code for reference:

mod tui;

use std::time::Duration;

use color_eyre::eyre::Result;
use crossterm::event::KeyCode::Char;
use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::{self, UnboundedSender};
use tui::Event;

// App state
struct App {
  counter: i64,
  should_quit: bool,
  action_tx: UnboundedSender<Action>,
}

// App actions
#[derive(Clone)]
pub enum Action {
  Tick,
  Increment,
  Decrement,
  NetworkRequestAndThenIncrement, // new
  NetworkRequestAndThenDecrement, // new
  Quit,
  Render,
  None,
}

// App ui render function
fn ui(f: &mut Frame<'_>, app: &mut App) {
  let area = f.size();
  f.render_widget(
    Paragraph::new(format!("Press j or k to increment or decrement.\n\nCounter: {}", app.counter,))
      .block(
        Block::default()
          .title("ratatui async counter app")
          .title_alignment(Alignment::Center)
          .borders(Borders::ALL)
          .border_type(BorderType::Rounded),
      )
      .style(Style::default().fg(Color::Cyan))
      .alignment(Alignment::Center),
    area,
  );
}

fn get_action(_app: &App, event: Event) -> Action {
  match event {
    Event::Error => Action::None,
    Event::Tick => Action::Tick,
    Event::Render => Action::Render,
    Event::Key(key) => {
      match key.code {
        Char('j') => Action::Increment,
        Char('k') => Action::Decrement,
        Char('J') => Action::NetworkRequestAndThenIncrement, // new
        Char('K') => Action::NetworkRequestAndThenDecrement, // new
        Char('q') => Action::Quit,
        _ => Action::None,
      }
    },
    _ => Action::None,
  }
}

fn update(app: &mut App, action: Action) {
  match action {
    Action::Increment => {
      app.counter += 1;
    },
    Action::Decrement => {
      app.counter -= 1;
    },
    Action::NetworkRequestAndThenIncrement => {
      let tx = app.action_tx.clone();
      tokio::spawn(async move {
        tokio::time::sleep(Duration::from_secs(5)).await; // simulate network request
        tx.send(Action::Increment).unwrap();
      });
    },
    Action::NetworkRequestAndThenDecrement => {
      let tx = app.action_tx.clone();
      tokio::spawn(async move {
        tokio::time::sleep(Duration::from_secs(5)).await; // simulate network request
        tx.send(Action::Decrement).unwrap();
      });
    },
    Action::Quit => app.should_quit = true,
    _ => {},
  };
}

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

  // ratatui terminal
  let mut tui = tui::Tui::new()?.tick_rate(1.0).frame_rate(30.0);
  tui.enter()?;

  // application state
  let mut app = App { counter: 0, should_quit: false, action_tx: action_tx.clone() };

  loop {
    let e = tui.next().await?;
    match e {
      tui::Event::Quit => action_tx.send(Action::Quit)?,
      tui::Event::Tick => action_tx.send(Action::Tick)?,
      tui::Event::Render => action_tx.send(Action::Render)?,
      tui::Event::Key(_) => {
        let action = get_action(&app, e);
        action_tx.send(action.clone())?;
      },
      _ => {},
    };

    while let Ok(action) = action_rx.try_recv() {
      // application update
      update(&mut app, action.clone());
      // render only when we receive Action::Render
      if let Action::Render = action {
        tui.draw(|f| {
          ui(f, &mut app);
        })?;
      }
    }

    // application exit
    if app.should_quit {
      break;
    }
  }
  tui.exit()?;

  Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
  let result = run().await;

  result?;

  Ok(())
}

With that, we have a fully async application that is tokio ready to spawn tasks to do work concurrently.

Conclusion

We touched on the basic framework for building an async application with Ratatui, namely using tokio and crossterm’s async features to create an Event and Action enum that contain Render variants. We also saw how we could use tokio channels to send Actions to run domain specific async operations concurrently.

There’s more information in ratatui-async-template about structuring an async application. The template also covers setting up a Component based architecture.

For more information, refer to the documentation for the template: https://ratatui-org.github.io/ratatui-async-template/

Stopwatch App

In this section, we are going to combine what we learnt in the previous tutorials and build a stopwatch application. We are also going to take advantage of a widget from an external dependency.

Here’s the dependencies you’ll need in your Cargo.toml:

[package]
name = "ratatui-stopwatch-app"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
publish.workspace = true

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
color-eyre = "0.6.2"
crossterm = { version = "0.27.0", features = ["event-stream"] }
directories = "5.0.1"
futures = "0.3.28"
human-panic = "1.2.0"
itertools = "0.11.0"
libc = "0.2.147"
log = "0.4.20"
ratatui = "0.24.0"
strip-ansi-escapes = "0.2.0"
strum = "0.25.0"
tokio = { version = "1.32.0", features = ["full"] }
tokio-util = "0.7.8"
tui-big-text = "0.2.1"

Here’s a gif of what it will look like if you run this:

Stopwatch

This application uses an external dependency called tui-big-text.

This application also combines the AppState (or Mode) pattern from the JSON Editor with the Message (or Command or Action) pattern from the Async Counter App. This Message pattern is common in The Elm Architecture pattern.

This application uses a Tui struct that combines the Terminal and Event Handler that we discussed in the previous section.

The full code is available on GitHub.

Here’s the relevant application part of the code:

use std::time::{Duration, Instant};

use color_eyre::eyre::{eyre, Result};
use futures::{FutureExt, StreamExt};
use itertools::Itertools;
use ratatui::{backend::CrosstermBackend as Backend, prelude::*, widgets::*};
use strum::EnumIs;
use tui_big_text::BigText;

#[tokio::main]
async fn main() -> Result<()> {
  let mut app = StopwatchApp::default();
  app.run().await
}

#[derive(Clone, Debug)]
pub enum Event {
  Error,
  Tick,
  Key(crossterm::event::KeyEvent),
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, EnumIs)]
enum AppState {
  #[default]
  Stopped,
  Running,
  Quitting,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Message {
  StartOrSplit,
  Stop,
  Tick,
  Quit,
}

#[derive(Debug, Clone, PartialEq)]
struct StopwatchApp {
  state: AppState,
  splits: Vec<Instant>,
  start_time: Instant,
  frames: u32,
  fps: f64,
}

impl Default for StopwatchApp {
  fn default() -> Self {
    Self::new()
  }
}

impl StopwatchApp {
  fn new() -> Self {
    Self {
      start_time: Instant::now(),
      frames: Default::default(),
      fps: Default::default(),
      splits: Default::default(),
      state: Default::default(),
    }
  }

  async fn run(&mut self) -> Result<()> {
    let mut tui = Tui::new()?;
    tui.enter()?;
    while !self.state.is_quitting() {
      tui.draw(|f| self.ui(f).expect("Unexpected error during drawing"))?;
      let event = tui.next().await.ok_or(eyre!("Unable to get event"))?; // blocks until next event
      let message = self.handle_event(event)?;
      self.update(message)?;
    }
    tui.exit()?;
    Ok(())
  }

  fn handle_event(&self, event: Event) -> Result<Message> {
    let msg = match event {
      Event::Key(key) => {
        match key.code {
          crossterm::event::KeyCode::Char('q') => Message::Quit,
          crossterm::event::KeyCode::Char(' ') => Message::StartOrSplit,
          crossterm::event::KeyCode::Char('s') | crossterm::event::KeyCode::Enter => Message::Stop,
          _ => Message::Tick,
        }
      },
      _ => Message::Tick,
    };
    Ok(msg)
  }

  fn update(&mut self, message: Message) -> Result<()> {
    match message {
      Message::StartOrSplit => self.start_or_split(),
      Message::Stop => self.stop(),
      Message::Tick => self.tick(),
      Message::Quit => self.quit(),
    }
    Ok(())
  }

  fn start_or_split(&mut self) {
    if self.state.is_stopped() {
      self.start();
    } else {
      self.record_split();
    }
  }

  fn stop(&mut self) {
    self.record_split();
    self.state = AppState::Stopped;
  }

  fn tick(&mut self) {
    self.frames += 1;
    let now = Instant::now();
    let elapsed = (now - self.start_time).as_secs_f64();
    if elapsed >= 1.0 {
      self.fps = self.frames as f64 / elapsed;
      self.start_time = now;
      self.frames = 0;
    }
  }

  fn quit(&mut self) {
    self.state = AppState::Quitting
  }

  fn start(&mut self) {
    self.splits.clear();
    self.state = AppState::Running;
    self.record_split();
  }

  fn record_split(&mut self) {
    if !self.state.is_running() {
      return;
    }
    self.splits.push(Instant::now());
  }

  fn elapsed(&mut self) -> Duration {
    if self.state.is_running() {
      self.splits.first().map_or(Duration::ZERO, Instant::elapsed)
    } else {
      // last - first or 0 if there are no splits
      let now = Instant::now();
      let first = *self.splits.first().unwrap_or(&now);
      let last = *self.splits.last().unwrap_or(&now);
      last - first
    }
  }

  fn ui(&mut self, f: &mut Frame) -> Result<()> {
    let layout = self.layout(f.size());
    f.render_widget(Paragraph::new("Stopwatch Example"), layout[0]);
    f.render_widget(self.fps_paragraph(), layout[1]);
    f.render_widget(self.timer_paragraph(), layout[2]);
    f.render_widget(Paragraph::new("Splits:"), layout[3]);
    f.render_widget(self.splits_paragraph(), layout[4]);
    f.render_widget(self.help_paragraph(), layout[5]);
    Ok(())
  }

  fn fps_paragraph(&mut self) -> Paragraph<'_> {
    let fps = format!("{:.2} fps", self.fps);
    Paragraph::new(fps).style(Style::new().dim()).alignment(Alignment::Right)
  }

  fn timer_paragraph(&mut self) -> BigText<'_> {
    let style = if self.state.is_running() { Style::new().green() } else { Style::new().red() };
    let elapsed = self.elapsed();
    let duration = self.format_duration(elapsed);
    let lines = vec![duration.into()];
    tui_big_text::BigTextBuilder::default().lines(lines).style(style).build().unwrap()
  }

  /// Renders the splits as a list of lines.
  ///
  /// ```text
  /// #01 -- 00:00.693 -- 00:00.693
  /// #02 -- 00:00.719 -- 00:01.413
  /// ```
  fn splits_paragraph(&mut self) -> Paragraph<'_> {
    let start = *self.splits.first().unwrap_or(&Instant::now());
    let mut splits = self
      .splits
      .iter()
      .copied()
      .tuple_windows()
      .enumerate()
      .map(|(index, (prev, current))| self.format_split(index, start, prev, current))
      .collect::<Vec<_>>();
    splits.reverse();
    Paragraph::new(splits)
  }

  fn help_paragraph(&mut self) -> Paragraph<'_> {
    let space_action = if self.state.is_stopped() { "start" } else { "split" };
    let help_text =
      Line::from(vec!["space ".into(), space_action.dim(), " enter ".into(), "stop".dim(), " q ".into(), "quit".dim()]);
    Paragraph::new(help_text).gray()
  }

  fn layout(&self, area: Rect) -> Vec<Rect> {
    let layout = Layout::default()
      .direction(Direction::Vertical)
      .constraints(vec![
        Constraint::Length(2), // top bar
        Constraint::Length(8), // timer
        Constraint::Length(1), // splits header
        Constraint::Min(0),    // splits
        Constraint::Length(1), // help
      ])
      .split(area);
    let top_layout = Layout::default()
      .direction(Direction::Horizontal)
      .constraints(vec![
        Constraint::Length(20), // title
        Constraint::Min(0),     // fps counter
      ])
      .split(layout[0]);

    // return a new vec with the top_layout rects and then rest of layout
    top_layout[..].iter().chain(layout[1..].iter()).copied().collect()
  }

  fn format_split<'a>(&self, index: usize, start: Instant, previous: Instant, current: Instant) -> Line<'a> {
    let split = self.format_duration(current - previous);
    let elapsed = self.format_duration(current - start);
    Line::from(vec![
      format!("#{:02} -- ", index + 1).into(),
      Span::styled(split, Style::new().yellow()),
      " -- ".into(),
      Span::styled(elapsed, Style::new()),
    ])
  }

  fn format_duration(&self, duration: Duration) -> String {
    format!("{:02}:{:02}.{:03}", duration.as_secs() / 60, duration.as_secs() % 60, duration.subsec_millis())
  }
}

It is worth thinking about what it takes to build your own custom widget by looking at the source for the BigText widget:

#[derive(Debug, Builder, Clone, PartialEq, Eq, Hash)]
pub struct BigText<'a> {
    #[builder(setter(into))]
    lines: Vec<Line<'a>>,

    #[builder(default)]
    style: Style,
}

impl Widget for BigText<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let layout = layout(area);
        for (line, line_layout) in self.lines.iter().zip(layout) {
            for (g, cell) in line.styled_graphemes(self.style).zip(line_layout) {
                render_symbol(g, cell, buf);
            }
        }
    }
}

To build a custom widget, you have to implement the Widget trait. We cover how to implement the Widget trait for your own structs in a separate section.

Concepts

In this section, we will cover various concepts associated with terminal user interfaces, such as:

  • Rendering
  • Layout
  • Application patterns
  • Backends
  • Event handling

Rendering

The world of UI development consists mainly of two dominant paradigms: retained mode and immediate mode. Most traditional GUI libraries operate under the retained mode paradigm. However, ratatui employs the immediate mode rendering approach. for TUI development.

This makes ratatui different from GUI frameworks you might use, because it only updates when you tell it to.

What is Immediate Mode Rendering?

Immediate mode rendering is a UI paradigm where the UI is recreated every frame. Instead of creating a fixed set of UI widgets and updating their state, you “draw” your UI from scratch in every frame based on the current application state.

In a nutshell:

  • Retained Mode: You set up your UI once, create widgets, and later modify their properties or handle their events.
  • Immediate Mode: You redraw your UI every frame based on your application state. There’s no permanent widget object in memory.

In ratatui, every frame draws the UI anew.

loop {
    terminal.draw(|f| {
        if state.condition {
            f.render_widget(SomeWidget::new(), layout);
        } else {
            f.render_widget(AnotherWidget::new(), layout);
        }
    })?;
}

This article and the accompanying YouTube video is worth your time if you are new to the immediate mode rendering paradigm.

This 4 minute talk about IMGUI is also tangentially relevant.

Advantages of Immediate Mode Rendering

  • Simplicity: Without a persistent widget state, your UI logic becomes a direct reflection of your application state. You don’t have to sync them or worry about past widget states.
  • Flexibility: You can change your UI layout or logic any time, as nothing is set in stone. Want to hide a widget conditionally? Just don’t draw it based on some condition.

Disadvantages of Immediate Mode Rendering

  • Render loop management: In Immediate mode rendering, the onus of triggering rendering lies on the programmer. Every visual update necessitates a call to Backend.draw(). Hence, if the rendering thread is inadvertently blocked, the UI will not update until the thread resumes. The ratatui library in particular only handles how widgets are rendered to a “Backend” (e.g. CrosstermBackend). The Backend would in turn use an external crate (e.g. crossterm) to actually draw to the terminal.

  • Event loop orchestration: Along with managing “the render loop”, developers are also responsible for handling “the event loop”. This involves deciding on a third-party library for the job. crossterm is a popular crate to handle key inputs and you’ll find plenty of examples in the repository and online for how to use it. crossterm also supports a async event stream, if you are interested in using tokio.

  • Architecture design considerations: With ratatui, out of the box, there’s little to no help in organizing large applications. Ultimately, the decision on structure and discipline rests with the developer to be principled.

How does Ratatui work under the hood?

You may have read in previous sections that Ratatui is a immediate mode rendering library. But what does that really mean? And how is it implemented? In this section, we will discuss how Ratatui renders a widget to the screen, starting with the Terminal’s draw method and ending with your chosen backend library.

Overview

To render an UI in Ratatui, your application calls the Terminal::draw() method. This method takes a closure which accepts an instance of Frame. Inside the draw method, applications can call Frame::render_widget() to render the state of a widget within the available renderable area. We only discuss the Frame::render_widget() on this page but this discussion about rendering applies equally to Frame::render_stateful_widget().

As an example, here is the terminal.draw() call for a simple “hello world” with Ratatui.

terminal.draw(|frame| {
    frame.render_widget(Paragraph::new("Hello World!"), frame.size());
});

The closure gets an argument frame of type &mut Frame.

frame.size() returns a Rect that represents the total renderable area. Frame also holds a reference to an intermediate buffer which it can render widgets to using the render_widget() method. At the end of the draw method (after the closure returns), Ratatui persists the content of the buffer to the terminal. Let’s walk through more specifics in the following sections.

Widget trait

In Ratatui, the frame.render_widget() method calls a Widget::render() method on the type-erased struct that implements the Widget trait.

pub trait Widget {
    /// Draws the current state of the widget in the given buffer.
    fn render(self, area: Rect, buf: &mut Buffer);
}

Any struct (inside Ratatui or third party crates) can implement the Widget trait, making an instance of that struct renderable to the terminal. The Widget::render() method is the only method required to make a struct a renderable widget.

In the Paragraph example above, frame.render_widget() calls the Widget::render() method implemented for Paragraph. You can take a look at other widgets’ render methods for examples of how to draw content.

As a simple example, let’s take a look at the builtin Clear widget. The Clear widget resets the style information of every cell in the buffer back to the defaults. Here is the full implementation for the Clear widget:

pub struct Clear;

impl Widget for Clear {
    fn render(self, area: Rect, buf: &mut Buffer) {
        for x in area.left()..area.right() {
            for y in area.top()..area.bottom() {
                buf.get_mut(x, y).reset();
            }
        }
    }
}

In the Clear widget example above, when the application calls the Frame::render_widget() method, it will call the Clear’s Widget::render() method passing it the area (a Rect value) and a mutable reference to the frame’s Buffer. You can see that the render loops through the entire area and calls buf.get_mut(x, y).reset(). Here we only use one of the many methods on Buffer, i.e. get_mut(x, y) which returns a Cell and reset() is a method on Cell.

Buffer

A Buffer represents a rectangular area that covers the Terminal’s Viewport which the application can draw into by manipulating its contents. A Buffer contains a collection of Cells to represent the rows and columns of the terminal’s display area. As we saw in the Clear example above, widgets interact with these Cells using Buffer methods.

Here’s a visual representation of a Buffer that is 12 Cells wide and 4 Cells tall.


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  0
  1
  2
  3
  4
  5
  6
  7
  8
  9
  10
  11
  H
  e
  l
  l
  o
  W
  o
  r
  l
  d
  !
  symbol
  style
  0
  1
  2
  3
  fg
  bg
  Reset
  Reset
  :
  :
  
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
  
  
    
    o”
  

In Ratatui, a Cell struct is the smallest renderable unit of code. Each Cell tracks symbol and style information (foreground color, background color, modifiers etc). Cells are similar to a “pixel” in a graphical UI. Terminals generally render text so that each individual cell takes up space approximately twice as high as it is wide. A Cell in Ratatui should usually contain 1 wide string content.

Buffer implements methods to write text, set styles on particular areas and manipulate individual cells. For example,

  • buf.get_mut(0, 0) will return a Cell with the symbol and style information for row = 0 and col = 0.
  • buf.set_string(0, 0, "Hello World!", Style::default()) will render hello world into the Buffer starting at row = 0 and col = 0 with the style set to default for all those cells.

These methods allow any implementation of the Widget trait to write into different parts of the Buffer.

Every time your application calls terminal.draw(|frame| ...), Ratatui passes into the closure a new instance of Frame which contains a mutable reference to an instance of Buffer. Ratatui widgets render to this intermediate buffer before any information is written to the terminal and any content rendered to a Buffer is only stored in Buffer that is attached to the frame during the draw call. This is in contrast to using a library like crossterm directly, where writing text to terminal can occur immediately.

Note

ANSI Escape sequences for color and style that are stored in the cell’s string content are not rendered as the style information is stored separately in the cell. If your text has ANSI styling info, consider using the ansi-to-tui crate to convert it to a Text value before rendering. You can learn more about the text related Ratatui features and displaying text here.

flush()

After the closure provided to the draw method finishes, the draw method calls Terminal::flush(). flush() writes the content of the buffer to the terminal. Ratatui uses a double buffer approach. It calculates a diff between the current buffer and the previous buffer to figure out what content to write to the terminal screen efficiently. After flush(), Ratatui swaps the buffers and the next time it calls terminal.draw(|frame| ...) it constructs Frame with the other Buffer.

Because all widgets render to the same Buffer within a single terminal.draw(|frame| ...) call, rendering of different widgets may overwrite the same Cell in the buffer. This means the order in which widgets are rendered will affect the final UI.

For example, in this draw example below, "content1" will be overwritten by "content2" which will be overwritten by "content3" in Buffer, and Ratatui will only ever write out "content3" to the terminal:

terminal.draw(|frame| {
    frame.render_widget(Paragraph::new("content1"), frame.size());
    frame.render_widget(Paragraph::new("content2"), frame.size());
    frame.render_widget(Paragraph::new("content3"), frame.size());
})

Before a new Frame is constructed, Ratatui wipes the current buffer clean. Because of this, when an application calls terminal.draw() it must draw all the widgets it expects to be rendered to the terminal, and not just a part of the frame. The diffing algorithm in Ratatui ensures efficient writing to the terminal screen.

Conclusion

In summary, the application calls terminal.draw(|frame| ...), and the terminal constructs a frame that is passed to the closure provided by the application. The closure draws each widget to the buffer by calling the Frame::render_widget, which in turn calls each widget’s render method. Finally, Ratatui writes the contents of the buffer to the terminal.

sequenceDiagram
    participant A as App
    participant C as Crossterm
    participant T as Terminal
    participant B as Buffer
    A ->>+ T: draw
    create participant F as frame
    T ->> F: new
    T ->>+ A: ui
    create participant W as widget
    A ->> W: new
    A ->>+ F: render_widget
    F ->>+ W: render
    opt
    W ->> B: get_cell
    W ->> B: set_string
    W ->> B: set_line
    W ->>- B: set_style
    end
    deactivate A
    T -->> C: flush()
    T -->> A: return

Layout

The coordinate system in Ratatui runs left to right, top to bottom, with the origin (0, 0) in the top left corner of the terminal. The x and y coordinates are represented by u16 values and are generally listed in that order in most places.


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  x
  y
  (0,0)
  (columns)
  (rows)
  
    
    
  
  
    
    
  

Layouts and widgets form the basis of the UI in Ratatui. Layouts dictate the structure of the interface, dividing the screen into various sections using constraints, while widgets fill these sections with content.

When rendering widgets to the screen, you first need to define the area where the widget will be displayed. This area is represented by a rectangle with a specific height and width in the buffer. You can specify this rectangle as an absolute position and size, or you can use the Layout struct to divide the terminal window dynamically based on constraints such as Length, Min, Max, Ratio, Percentage.

The following example renders “Hello world!” 10 times, by manually calculating the areas to render within.

for i in 0..10 {
    let area = Rect::new(0, i, frame.size().width, 1);
    frame.render_widget(Paragraph::new("Hello world!"), area);
}

The Layout struct

A simple example of using the layout struct might look like this:

use ratatui::prelude::*;

let layout = Layout::default()
    .direction(Direction::Vertical)
    .constraints(vec![
        Constraint::Percentage(50),
        Constraint::Percentage(50),
    ])
    .split(frame.size());

In this example, we have indicated that we want to split the available space vertically into two equal parts, allocating 50% of the screen height to each. The Layout::split function takes the total size of the terminal window as an argument, returned by the Frame::size() method, and then calculates the appropriate size and placement for each rectangle based on the specified constraints.

Once you have defined your layout (or a set of nested layouts), you can use one of the rectangle areas derived from such layout to render your widget. This can be achieved by calling either the Frame::render_widget or frame::render_stateful_widget methods:

frame.render_widget(
    Paragraph::new("Top")
        .block(Block::new().borders(Borders::ALL)),
    layout[0]);
frame.render_widget(
    Paragraph::new("Bottom")
        .block(Block::new().borders(Borders::ALL)),
    layout[1]);

This might look something like:

┌───────────────────────────────────┐
│Top                                │
│                                   │
│                                   │
└───────────────────────────────────┘
┌───────────────────────────────────┐
│Bottom                             │
│                                   │
│                                   │
└───────────────────────────────────┘

In this example, two Paragraph widgets are generated, named “Top” and “Bottom.” These widgets are then rendered in the first and second areas (layout[0] and layout[1]) of the split buffer, respectively. It’s important to note that layouts return an indexed list of rectangles, defined by their respective constraints. In this case, layout[0] refers to the top half of the screen, and layout[1] refers to the bottom half.

Nesting Layouts

One of the important concepts to understand is that layouts can be nested. This means you can define another Layout within a rectangle of an outer layout. This nested layouts allow complex and flexible UI designs to be built while still maintaining control over how your grid of widgets resize with the terminal window.

Here’s how you might use nested layouts:

let outer_layout = Layout::default()
    .direction(Direction::Vertical)
    .constraints(vec![
        Constraint::Percentage(50),
        Constraint::Percentage(50),
    ])
    .split(f.size());

let inner_layout = Layout::default()
    .direction(Direction::Horizontal)
    .constraints(vec![
        Constraint::Percentage(25),
        Constraint::Percentage(75),
    ])
    .split(outer_layout[1]);

In this situation, the terminal window is initially split vertically into two equal parts by outer_layout. Then, inner_layout splits the second rectangle of outer_layout horizontally, creating two areas that are 25% and 75% of the width of the original rectangle, respectively.

Rendering some Paragraphs of text into the above layouts produces the following:

frame.render_widget(
    Paragraph::new("outer 0")
        .block(Block::new().borders(Borders::ALL)),
    outer_layout[0]);
frame.render_widget(
    Paragraph::new("inner 0")
        .block(Block::new().borders(Borders::ALL)),
    inner_layout[0]);
frame.render_widget(
    Paragraph::new("inner 1")
        .block(Block::new().borders(Borders::ALL)),
    inner_layout[1]);
┌───────────────────────────────────┐
│outer 0                            │
│                                   │
│                                   │
└───────────────────────────────────┘
┌────────────────┐┌─────────────────┐
│inner 0         ││inner 1          │
│                ││                 │
│                ││                 │
└────────────────┘└─────────────────┘

This enables you to divide the terminal window into multiple sections of varying sizes, giving you the flexibility to create complex and adaptive graphical interfaces.

Constraints

Constraints dictate the size and arrangement of components within layouts. The Ratatui framework provides several constraint types for fine-tuning your user interface’s layout:

  • Constraint::Length(u16): This constraint specifies a specific number of rows or columns that a rectangle should take up. Note that this is determined by absolute size and is not responsive to the overall terminal window size.

  • Constraint::Percentage(u16): This constraint offers a size relative to the size of the parent layout or the terminal window itself. For instance, Constraint::Percentage(50) signifies that a rectangle should take up half of its parent’s size.

  • Constraint::Ratio(u16, u16): Utilizing ratios offers an even finer granularity for splitting your layout. For instance, Constraint::Ratio(1, 3) will allocate 1/3rd of the parent’s size to this constraint.

  • Constraint::Min(u16): Immerses a minimum limit to the size of a component. If a Min constraint is ensured with a Percentage or Ratio, the component will never shrink below the specified minimum size.

  • Constraint::Max(u16): Limits the maximum size of a component. Similar to Min, if mixed with Percentage or Ratio, the component will never exceed the specified maximum size.

Warning

The Ratio and Percentage constraints are defined in terms of the parent’s size.

This may have unexpected side effects in situations where you expect a fixed and flexible sized rects to be combined in the same layout. Consider using nested layouts or manually calculating the sizes if necessary to create complex layouts.

Constraints can be mixed and matched within a layout to create dynamic and adjustable interfaces. These constraints can be used when defining the layout for an application:

let layout = Layout::default()
    .direction(Direction::Horizontal)
    .constraints([
        Constraint::Length(10),
        Constraint::Percentage(70),
        Constraint::Min(5),
    ]
    .into_iter())
    .split(frame.size());

In this example, the initial Length constraint cause the first rectangle to have a width of 10 characters. The next rectangle will be 70% of the total width. The final rectangle will take up the remaining space, but will never be smaller than 5 characters.

Note that the order in which you specify your constraints is the order in which they will apply to the screen space.

By default, the split method allocates any remaining space in the area to the last area of the layout. To avoid this, add an unused Min(0) constraint as the last constraint.

Ratatui uses a constraint solver algorithm called Casssowary in order to determine the right size for the rects. In some cases, not every constraint will be possible to achieve, and the solver can return an arbitrary solution that is close to fulfilling the constraints. The specific result is non-deterministic when this occurs.

Other Layout approaches

There are a few PoCs of using Taffy for creating layouts that use flexbox / grid algorithms (similar to CSS) to layout rects. This can work nicely, but is not built in to Ratatui (yet). See taffy in ratatui for more details.

Application Patterns

This page covers several patterns one can use for their application and acts as a top-level page for the following articles where these patterns are gone into more in-depth.

Using The Elm Architecture (TEA) with ratatui

When building terminal user interfaces (TUI) with ratatui, it’s helpful to have a solid structure for organizing your application. One proven architecture comes from the Elm language, known simply as The Elm Architecture (TEA).

Attention

If you are interested in a framework that uses ratatui that is based on The Elm Architecture, you should check out https://github.com/veeso/tui-realm/. The documentation on this page is for theoretical understanding and pedagogical purposes only.

In this section, we’ll explore how to apply The Elm Architecture principles to ratatui TUI apps.

The Elm Architecture: A Quick Overview

At its core, TEA is split into three main components:

  • Model: This is your application’s state. It contains all the data your application works with.
  • Update: When there’s a change (like user input), the update function takes the current model and the input, and produces a new model.
  • View: This function is responsible for displaying your model to the user. In Elm, it produces HTML. In our case, it’ll produce terminal UI elements.
sequenceDiagram
participant User
participant TUI Application

User->>TUI Application: Input/Event/Message
TUI Application->>TUI Application: Update (based on Model and Message)
TUI Application->>TUI Application: Render View (from Model)
TUI Application-->>User: Display UI

Applying The Elm Architecture to ratatui

Following TEA principles typically involves ensuring that you do the following things:

  1. Define Your Model
  2. Handling Updates
  3. Rendering the View

1. Define Your Model

In ratatui, you’ll typically use a struct to represent your model:

struct Model {
    //... your application's data goes here
}

For a counter app, our model may look like this:

struct Model {
  counter: i32,
  should_quit: bool,
}

2. Handling Updates

Updates in TEA are actions triggered by events, such as user inputs. The core idea is to map each of these actions or events to a message. This can be achieved by creating an enum to keep track of messages. Based on the received message, the current state of the model is used to determine the next state.

Defining a Message enum

enum Message {
    //... various inputs or actions that your app cares about
    // e.g., ButtonPressed, TextEntered, etc.
}

For a counter app, our Message enum may look like this:

enum Message {
  Increment,
  Decrement,
  Reset,
  Quit,
}

update() function

The update function is at the heart of this process. It takes the current model and a message, and decides how the model should change in response to that message.

A key feature of TEA is immutability. Hence, the update function should avoid direct mutation of the model. Instead, it should produce a new instance of the model reflecting the desired changes.

fn update(model: &Model, msg: Message) -> Model {
    match msg {
        // Match each possible message and decide how the model should change
        // Return a new model reflecting those changes
    }
}

In TEA, it’s crucial to maintain a clear separation between the data (model) and the logic that alters it (update). This immutability principle ensures predictability and makes the application easier to reason about.

Note

Hence, while immutability is emphasized in TEA, Rust developers can choose the most suitable approach based on performance and their application’s needs.

For example, it would be perfectly valid to do the following:

fn update(model: &mut Model, msg: Message) {
    match msg {
        // Match each possible message and decide how the model should change
        // Modify existing mode reflecting those changes
    };
}

In TEA, the update() function can not only modify the model based on the Message, but it can also return another Message. This design can be particularly useful if you want to chain messages or have an update lead to another update.

For example, this is what the update() function may look like for a counter app:

fn update(model: &mut Model, msg: Message) -> Option<Message> {
  match msg {
    Message::Increment => {
      model.counter += 1;
      if model.counter > 50 {
        return Some(Message::Reset);
      }
    },
    Message::Decrement => {
      model.counter -= 1;
      if model.counter < -50 {
        return Some(Message::Reset);
      }
    },
    Message::Reset => {
      model.counter = 0;
    },
    Message::Quit => {
      model.should_quit = true;
    },
    _ => {},
  }
  None // Default return value if no specific message is to be returned
}

Attention

Remember that this design choice means that the main loop will need to handle the returned message, calling update() again based on that returned message.

Returning a Message from the update() function allows a developer to reason about their code as a “Finite State Machine”. Finite State Machines operate on defined states and transitions, where an initial state and an event (in our case, a Message) lead to a subsequent state. This cascading approach ensures that the system remains in a consistent and predictable state after handling a series of interconnected events.

Here’s a state transition diagram of the counter example from above:

stateDiagram-v2
    state Model {
        counter : counter = 0
        should_quit : should_quit = false
    }

    Model --> Increment
    Model --> Decrement
    Model --> Reset
    Model --> Quit

    Increment --> Model: counter += 1
    Increment --> Reset: if > 50

    Decrement --> Model: counter -= 1
    Decrement --> Reset: if < -50

    Reset --> Model: counter = 0

    Quit --> break: should_quit = true

While TEA doesn’t use the Finite State Machine terminology or strictly enforce that paradigm, thinking of your application’s state as a state machine can allow developers to break down intricate state transitions into smaller, more manageable steps. This can make designing the application’s logic clearer and improve code maintainability.

3. Rendering the View

The view function in the Elm Architecture is tasked with taking the current model and producing a visual representation for the user. In the case of ratatui, it translates the model into terminal UI elements. It’s essential that the view function remains a pure function: for a given state of the model, it should always produce the same UI representation.

fn view(model: &Model) {
    //... use `ratatui` functions to draw your UI based on the model's state
}

Every time the model is updated, the view function should be capable of reflecting those changes accurately in the terminal UI.

In TEA, you are expected to ensure that your view function is side-effect free. The view() function shouldn’t modify global state or perform any other actions. Its sole job is to map the model to a visual representation.

For a given state of the model, the view function should always produce the same visual output. This predictability makes your TUI application easier to reason about and debug.

Note

With immediate mode rendering you may run into an issue: the view function is only aware of the area available to draw in at render time.

This limitation is a recognized constraint of immediate mode GUIs. Overcoming it often involves trade-offs. One common solution is to store the drawable size and reference it in the subsequent frame, although this can introduce a frame delay in layout adjustments, leading to potential flickering during the initial rendering when changes in screen size occur.

An alternative would be using the Resize event from crossterm and to clear the UI and force redraw everything during that event.

In ratatui, there are StatefulWidgets which require a mutable reference to state during render.

For this reason, you may choose to forego the view immutability principle. For example, if you were interested in rendering a List, your view function may look like this:

fn view(model: &mut Model, f: &mut Frame) {
  let items = app.items.items.iter().map(|element| ListItem::new(element)).collect();
  f.render_stateful_widget(List::new(items), f.size(), &mut app.items.state);
}

fn main() {
  loop {
    ...
    terminal
      .draw(|f| {
        view(&mut model, f);
      })?;
    ...
  }
}

Another advantage of having access to the Frame in the view() function is that you have access to setting the cursor position, which is useful for displaying text fields. For example, if you wanted to draw an input field using tui-input, you might have a view that looks like this:

fn view(model: &mut Model, f: &mut Frame) {
  let area = f.size();
  let input = Paragraph::new(app.input.value());
  f.render_widget(input, area);
  if app.mode == Mode::Insert {
    f.set_cursor(
      (area.x + 1 + self.input.cursor() as u16).min(area.x + area.width - 2),
      area.y + 1
    )
  }
}

Putting it all together

When you put it all together, your main application loop might look something like:

  • Listen for user input.
  • Map input to a Message
  • Pass that message to the update function.
  • Draw the UI with the view function.

This cycle repeats, ensuring your TUI is always up-to-date with user interactions.

As an illustrative example, here’s the Counter App refactored using TEA.

The notable difference from before is that we have an Model struct that captures the app state, and a Message enum that captures the various actions your app can take.

// cargo add anyhow ratatui crossterm
use anyhow::Result;
use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};

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

// MODEL
struct Model {
  counter: i32,
  should_quit: bool,
}

// MESSAGES
#[derive(PartialEq)]
enum Message {
  Increment,
  Decrement,
  Reset,
  Quit,
}

// UPDATE
fn update(model: &mut Model, msg: Message) -> Option<Message> {
  match msg {
    Message::Increment => {
      model.counter += 1;
      if model.counter > 50 {
        return Some(Message::Reset);
      }
    },
    Message::Decrement => {
      model.counter -= 1;
      if model.counter < -50 {
        return Some(Message::Reset);
      }
    },
    Message::Reset => model.counter = 0,
    Message::Quit => model.should_quit = true, // You can handle cleanup and exit here
  };
  None
}

// VIEW
fn view(model: &mut Model, f: &mut Frame) {
  f.render_widget(Paragraph::new(format!("Counter: {}", model.counter)), f.size());
}

// Convert Event to Message
// We don't need to pass in a `model` to this function in this example
// but you might need it as your project evolves
fn handle_event(_: &Model) -> Result<Option<Message>> {
  let message = if crossterm::event::poll(std::time::Duration::from_millis(250))? {
    if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
      match key.code {
        crossterm::event::KeyCode::Char('j') => Message::Increment,
        crossterm::event::KeyCode::Char('k') => Message::Decrement,
        crossterm::event::KeyCode::Char('q') => Message::Quit,
        _ => return Ok(None),
      }
    } else {
      return Ok(None);
    }
  } else {
    return Ok(None);
  };
  Ok(Some(message))
}

pub fn initialize_panic_handler() {
  let original_hook = std::panic::take_hook();
  std::panic::set_hook(Box::new(move |panic_info| {
    crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen).unwrap();
    crossterm::terminal::disable_raw_mode().unwrap();
    original_hook(panic_info);
  }));
}

fn main() -> Result<()> {
  initialize_panic_handler();

  // Startup
  crossterm::terminal::enable_raw_mode()?;
  crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;

  let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;
  let mut model = Model { counter: 0, should_quit: false };

  loop {
    // Render the current view
    terminal.draw(|f| {
      view(&mut model, f);
    })?;

    // Handle events and map to a Message
    let mut current_msg = handle_event(&model)?;

    // Process updates as long as they return a non-None message
    while current_msg != None {
      current_msg = update(&mut model, current_msg.unwrap());
    }

    // Exit loop if quit flag is set
    if model.should_quit {
      break;
    }
  }

  // Shutdown
  crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?;
  crossterm::terminal::disable_raw_mode()?;
  Ok(())
}

Component Architecture

If you are interested in a more object oriented approach to organizing TUIs, you can use a Component based approach.

A couple of projects in the wild use this approach

We also have a ratatui-async-template that has an example of this Component based approach:

We already covered TEA in the previous section. The Component architecture takes a slightly more object oriented trait based approach.

Each component encapsulates its own state, event handlers, and rendering logic.

  1. Component Initialization (init) - This is where a component can set up any initial state or resources it needs. It’s a separate process from handling events or rendering.

  2. Event Handling (handle_events, handle_key_events, handle_mouse_events) - Each component has its own event handlers. This allows for a finer-grained approach to event handling, with each component only dealing with the events it’s interested in. This contrasts with Elm’s single update function that handles messages for the entire application.

  3. State Update (update) - Components can have their own local state and can update it in response to actions. This state is private to the component, which differs from Elm’s global model.

  4. Rendering (render) - Each component defines its own rendering logic. It knows how to draw itself, given a rendering context. This is similar to Elm’s view function but on a component-by-component basis.

Here’s an example of the Component trait implementation you might use:

use anyhow::Result;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::layout::Rect;

use crate::{action::Action, event::Event, terminal::Frame};

pub trait Component {
  fn init(&mut self) -> Result<()> {
    Ok(())
  }
  fn handle_events(&mut self, event: Option<Event>) -> Action {
    match event {
      Some(Event::Quit) => Action::Quit,
      Some(Event::Tick) => Action::Tick,
      Some(Event::Key(key_event)) => self.handle_key_events(key_event),
      Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event),
      Some(Event::Resize(x, y)) => Action::Resize(x, y),
      Some(_) => Action::Noop,
      None => Action::Noop,
    }
  }
  fn handle_key_events(&mut self, key: KeyEvent) -> Action {
    Action::Noop
  }
  fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Action {
    Action::Noop
  }
  fn update(&mut self, action: Action) -> Action {
    Action::Noop
  }
  fn render(&mut self, f: &mut Frame<'_>, rect: Rect);
}

One advantage of this approach is that it incentivizes co-locating the handle_events, update and render functions on a component level.

Flux Architecture

Flux is a design pattern introduced by Facebook to address the challenges of building large scale web applications. Though originally designed with web applications in mind, the Flux architecture can be applied to any client-side project, including terminal applications. Here’s real world example of using the Flux architecture with ratatui: https://github.com/Yengas/rust-chat-server/tree/main/tui.

Why Flux for ratatui?

Terminal applications often have to deal with complex user interactions, multiple views, and dynamic data sources. Keeping the application predictable and the logic decoupled is crucial. Flux, with its unidirectional data flow, allows ratatui developers to have a structured way to handle user input, process data, and update the views.

Flux ratatui Overview

Dispatcher

The dispatcher remains the central hub that manages all data flow in your application. Every action in the application, whether it’s a user input or a response from a server, will be channeled through the dispatcher. This ensures a unified way of handling data, and since the dispatcher has no logic of its own, it simply ensures that all registered callbacks receive the action data.

struct Dispatcher {
    store: Store,
}

impl Dispatcher {
    fn dispatch(&mut self, action: Action) {
        self.store.update(action);
    }
}

Stores

Stores in Ratatui hold the application’s state and its logic. They could represent things like:

  • A list of items in a menu.
  • The content of a text editor or viewer.
  • User configurations or preferences.

Stores listen for actions dispatched from the Dispatcher. When a relevant action is dispatched, the store updates its state and notifies any listening components (or views) that a change has occurred.

struct Store {
    counter: i32,
}

impl Store {
    fn new() -> Self {
        Self { counter: 0 }
    }

    fn update(&mut self, action: Action) {
        match action {
            Action::Increment => self.counter += 1,
            Action::Decrement => self.counter -= 1,
        }
    }

    fn get_state(&self) -> i32 {
        self.counter
    }
}

Actions

Actions represent any change or event in your application. For instance, when a user presses a key, selects a menu item, or inputs text, an action is created. This action is dispatched and processed by the relevant stores, leading to potential changes in application state.

enum Action {
    Increment,
    Decrement,
}

Views / Widgets

ratatui’s widgets display the application’s UI. They don’t hold or manage the application state, but they display it. When a user interacts with a widget, it can create an action that gets dispatched, which may lead to a change in a store, which in turn may lead to the widget being updated.

Backends

Ratatui interfaces with the terminal emulator through a backend. These libraries enable Ratatui via the Terminal type to draw styled text to the screen, manipulate the cursor, and interrogate properties of the terminal such as the console or window size. You application will generally also use the backend directly to capture keyboard, mouse and window events, and enable raw mode and the alternate screen.

Ratatui supports the following backends:

For information on how to choose a backend see: Comparison

Each backend supports Raw Mode (which changes how the terminal handles input and output processing), an Alternate Screen which allows it to render to a separate buffer than your shell commands use, and Mouse Capture, which allows your application to capture mouse events.

Comparison of Backends

Tldr

Choose Crossterm for most tasks.

Ratatui interfaces with the terminal emulator through its “backends”. These are powerful libraries that grant ratatui the ability to capture keypresses, maneuver the cursor, style the text with colors and other features. As of now, ratatui supports three backends:

Selecting a backend does influence your project’s structure, but the core functionalities remain consistent across all options. Here’s a flowchart that can help you make your decision.

graph TD;
    Q1[Is the TUI only for Wezterm users?]
    Q2[Is Windows compatibility important?]
    Q3[Are you familiar with Crossterm?]
    Q4[Are you familiar with Termion?]
    Crossterm
    Termwiz
    Termion

    Q1 -->|Yes| Termwiz
    Q1 -->|No| Q2
    Q2 -->|Yes| Crossterm
    Q2 -->|No| Q3
    Q3 -->|Yes| Crossterm
    Q3 -->|No| Q4
    Q4 -->|Yes| Termion
    Q4 -->|No| Crossterm

Though we try to make sure that all backends are fully-supported, the most commonly-used backend is Crossterm. If you have no particular reason to use Termion or Termwiz, you will find it easiest to learn Crossterm simply due to its popularity.

Raw Mode

Raw mode is a mode where the terminal does not perform any processing or handling of the input and output. This means that features such as echoing input characters, line buffering, and special character processing (e.g., CTRL-C or SIGINT) are disabled. This is useful for applications that want to have complete control over the terminal input and output, processing each keystroke themselves.

For example, in raw mode, the terminal will not perform line buffering on the input, so the application will receive each key press as it is typed, instead of waiting for the user to press enter. This makes it suitable for real-time applications like text editors, terminal-based games, and more.

Each backend handles raw mode differently, so the behavior may vary depending on the backend being used. Be sure to consult the backend’s specific documentation for exact details on how it implements raw mode.

Alternate Screen

The alternate screen is a separate buffer that some terminals provide, distinct from the main screen. When activated, the terminal will display the alternate screen, hiding the current content of the main screen. Applications can write to this screen as if it were the regular terminal display, but when the application exits, the terminal will switch back to the main screen, and the contents of the alternate screen will be cleared. This is useful for applications like text editors or terminal games that want to use the full terminal window without disrupting the command line or other terminal content.

This creates a seamless transition between the application and the regular terminal session, as the content displayed before launching the application will reappear after the application exits.

Take this “hello world” program below. If we run it with and without the std::io::stderr().execute(EnterAlternateScreen)? (and the corresponding LeaveAlternateScreen), you can see how the program behaves differently.

use std::{
  io::{stderr, Result},
  thread::sleep,
  time::Duration,
};

use crossterm::{
  terminal::{EnterAlternateScreen, LeaveAlternateScreen},
  ExecutableCommand,
};
use ratatui::{prelude::*, widgets::*};

fn main() -> Result<()> {
  let should_enter_alternate_screen = std::env::args().nth(1).unwrap().parse::<bool>().unwrap();
  if should_enter_alternate_screen {
  stderr().execute(EnterAlternateScreen)?; // remove this line
  }

  let mut terminal = Terminal::new(CrosstermBackend::new(stderr()))?;

  terminal.draw(|f| {
    f.render_widget(Paragraph::new("Hello World!"), Rect::new(10, 20, 20, 1));
  })?;
  sleep(Duration::from_secs(2));

  if should_enter_alternate_screen {
  stderr().execute(LeaveAlternateScreen)?; // remove this line
  }
  Ok(())
}

Try running this code on your own and experiment with EnterAlternateScreen and LeaveAlternateScreen.

Note that not all terminal emulators support the alternate screen, and even those that do may handle it differently. As a result, the behavior may vary depending on the backend being used. Always consult the specific backend’s documentation to understand how it implements the alternate screen.

Mouse Capture

Mouse capture is a mode where the terminal captures mouse events such as clicks, scrolls, and movement, and sends them to the application as special sequences or events. This enables the application to handle and respond to mouse actions, providing a more interactive and graphical user experience within the terminal. It’s particularly useful for applications like terminal-based games, text editors, or other programs that require more direct interaction from the user.

Each backend handles mouse capture differently, with variations in the types of events that can be captured and how they are represented. As such, the behavior may vary depending on the backend being used, and developers should consult the specific backend’s documentation to understand how it implements mouse capture.

Event Handling

There are many ways to handle events with the ratatui library. Mostly because ratatui does not directly expose any event catching; the programmer will depend on the chosen backend’s library.

However, there are a few ways to think about event handling that may help you. While this is not an exhaustive list, it covers a few of the more common implementations. But remember, the correct way, is the one that works for you and your current application.

Centralized event handling

This is the simplest way to handle events because it handles all of the events as they appear. It is often simply a match on the results of event::read()? (in crossterm) on the different supported keys. Pros: This has the advantage of requiring no message passing, and allows the programmer to edit all of the possible keyboard events in one place.

Cons: However, this particular way of handling events simply does not scale well. Because all events are handled in one place, you will be unable to split different groups of keybinds out into separate locations.

Centralized catching, message passing

This way of handling events involves polling for events in one place, and then sending messages/calling sub functions with the event that was caught. Pros: This has a similar appeal to the first method in its simplicity. With this paradigm, you can easily split extensive pattern matching into sub functions that can go in separate files. This way is also the idea often used in basic multi-threaded applications because message channels are used to pass multi-threaded safe messages.

Cons: This method requires a main loop to be running to consistently poll for events in a centralized place.

Distributed event loops/segmented applications

In this style, control of the Terminal and the main loop to a sub-module. In this case, the entire rendering and event handling responsibilities can be safely passed to the sub-module. In theory, an application built like this doesn’t need a centralized event listener. Pros: There is no centralized event loop that you need to update whenever a new sub-module is created.

Cons: However, if several sub-modules in your application have similar event handling loops, this way could lead to a lot of duplicated code.

How To

  • Layout UIs: Articles regarding how to layout your application’s User Interface including widgets and nesting blocks
  • Render Text: Articles related to actually rendering text and widgets to the screen including how to style and write to the buffer.
  • Use Widgets: Articles related to using individual widgets suchs as the paragraph, block, and creating your own custom widget.
  • Develop Applications: Articles related to developing applications. E.g. how to handle CLI arguments, tracing, configuration, panics, etc.

Layout

In this section we will cover layout basics and advanced techniques.

How to: Create Dynamic layouts

With real world applications, the content can often be dynamic. For example, a chat application may need to resize the chat input area based on the number of incoming messages. To achieve this, you can generate layouts dynamically:

fn get_layout_based_on_messages(msg_count: usize, f: &Frame) -> Rc<[Rect]> {
    let msg_percentage = if msg_count > 50 { 80 } else { 50 };

    Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage(msg_percentage),
            Constraint::Percentage(100 - msg_percentage),
        ])
        .split(f.size())
}

You can even update the layout based on some user input or command:

match action {
    Action::IncreaseSize => {
        current_percentage += 5;
        if current_percentage > 95 {
            current_percentage = 95;
        }
    },
    Action::DecreaseSize => {
        current_percentage -= 5;
        if current_percentage < 5 {
            current_percentage = 5;
        }
    },
    _ => {}
}

let chunks = Layout::default()
    .direction(Direction::Horizontal)
    .constraints([
        Constraint::Percentage(current_percentage),
        Constraint::Percentage(100 - current_percentage),
    ])
    .split(f.size());

How to: Center a Rect

You can use a Vertical layout followed by a Horizontal layout to get a centered Rect.

/// # Usage
///
/// ```rust
/// let rect = centered_rect(f.size(), 50, 50);
/// ```
fn centered_rect(r: Rect, percent_x: u16, percent_y: u16) -> Rect {
  let popup_layout = Layout::default()
    .direction(Direction::Vertical)
    .constraints([
      Constraint::Percentage((100 - percent_y) / 2),
      Constraint::Percentage(percent_y),
      Constraint::Percentage((100 - percent_y) / 2),
    ])
    .split(r);

  Layout::default()
    .direction(Direction::Horizontal)
    .constraints([
      Constraint::Percentage((100 - percent_x) / 2),
      Constraint::Percentage(percent_x),
      Constraint::Percentage((100 - percent_x) / 2),
    ])
    .split(popup_layout[1])[1]
}

Then you can use it to draw any widget like this:

terminal.draw(|f| {
    f.render_widget(Block::default().borders(Borders::all()).title("Main"), centered_rect(f.size(), 35, 35));
})?;






                    ┌Main────────────────────────────────┐
                    │                                    │
                    │                                    │
                    │                                    │
                    │                                    │
                    │                                    │
                    │                                    │
                    │                                    │
                    │                                    │
                    └────────────────────────────────────┘










A common use case for this feature is to create a popup style dialog block. For this, typically, you’ll want to Clear the popup area before rendering your content to it. The following is an example of how you might do that:

terminal.draw(|f| {
    let popup_area = centered_rect(f.size(), 35, 35);
    f.render_widget(Clear, popup_area);
    f.render_widget(Block::default().borders(Borders::all()).title("Main"), popup_area);
})?;

How to: Collapse borders in a layout

A common layout for applications is to split up the screen into panes, with borders around each pane. Often this leads to making UIs that look disconnected. E.g., the following layout:

problem

Created by the following code:

fn ui(frame: &mut Frame) {
    // create a layout that splits the screen into 2 equal columns and the right column
    // into 2 equal rows
    let layout = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
        .split(frame.size());
    let sub_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
        .split(layout[1]);

    frame.render_widget(
        Block::new().borders(Borders::ALL).title("Left Block"),
        layout[0],
    );

    frame.render_widget(
        Block::new().borders(Borders::ALL).title("Top Right Block"),
        sub_layout[0],
    );

    frame.render_widget(
        Block::new()
            .borders(Borders::ALL)
            .title("Bottom Right Block"),
        sub_layout[1],
    );
}

We can do better though, by collapsing borders. E.g.:

solution

The first thing we need to do is work out which borders to collapse. Because in the layout above we want to connect the bottom right block to the middle vertical border, we’re going to need this to be rendered by the top left and bottom left blocks rather than the right block.

We need to use the symbols module to achieve this so we add this to the imports:

use ratatui::{prelude::*, symbols, widgets::*};

Our first change is to the left block where we remove the right border:

    frame.render_widget(
        Block::new()
            // don't render the right border because it will be rendered by the right block
            .border_set(symbols::border::PLAIN)
            .borders(Borders::TOP | Borders::LEFT | Borders::BOTTOM)
            .title("Left Block"),
        layout[0],
    );

Next, we see that the top left corner of the top right block joins with the top right corner of the left block, so we need to replace that with a T shape. We also see omit the bottom border as that will be rendered by the bottom right block. We use a custom symbols::border::Set to achieve this.

    // top right block must render the top left border to join with the left block
    let top_right_border_set = symbols::border::Set {
        top_left: symbols::line::NORMAL.horizontal_down,
        ..symbols::border::PLAIN
    };
    frame.render_widget(
        Block::new()
            .border_set(top_right_border_set)
            // don't render the bottom border because it will be rendered by the bottom block
            .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
            .title("Top Right Block"),
        sub_layout[0],
    );

In the bottom right block, we see that the top right corner joins the left block’s right border and so we need to rend this with a horizontal T shape pointing to the right. We need to do the same for the top right corner and the bottom left corner.

    // bottom right block must render:
    // - top left border to join with the left block and top right block
    // - top right border to join with the top right block
    // - bottom left border to join with the left block
    let collapsed_top_and_left_border_set = symbols::border::Set {
        top_left: symbols::line::NORMAL.vertical_right,
        top_right: symbols::line::NORMAL.vertical_left,
        bottom_left: symbols::line::NORMAL.horizontal_up,
        ..symbols::border::PLAIN
    };
    frame.render_widget(
        Block::new()
            .border_set(collapsed_top_and_left_border_set)
            .borders(Borders::ALL)
            .title("Bottom Right Block"),
        sub_layout[1],
    );

If we left it here, then we’d be mostly fine, but in small areas we’d notice that the 50/50 split no longer looks right. This is due to the fact that by default we round up when splitting an odd number of rows or columns in 2 (e.g. 5 rows => 2.5/2.5 => 3/2). This is fine normally, but when we collapse borders between blocks, the first block has one extra row (or columns) already as it does not have the collapsed block. We can easily work around this issue by allocating a small amount of extra space to the last layout item (e.g. by using 49/51 or 33/33/34).

    let layout = Layout::default()
        .direction(Direction::Horizontal)
        // use a 49/51 split instead of 50/50 to ensure that any extra space is on the right
        // side of the screen. This is important because the right side of the screen is
        // where the borders are collapsed.
        .constraints([Constraint::Percentage(49), Constraint::Percentage(51)])
        .split(frame.size());
    let sub_layout = Layout::default()
        .direction(Direction::Vertical)
        // use a 49/51 split to ensure that any extra space is on the bottom
        .constraints([Constraint::Percentage(49), Constraint::Percentage(51)])
        .split(layout[1]);

Note

If this sounds too complex, we’re looking for some help to make this easier in https://github.com/ratatui-org/ratatui/issues/605.

The full code for this example is available at https://github.com/ratatui-org/ratatui-book/code/how-to-collapse-borders

Full ui() function
fn ui(frame: &mut Frame) {
    // create a layout that splits the screen into 2 equal columns and the right column
    // into 2 equal rows

    let layout = Layout::default()
        .direction(Direction::Horizontal)
        // use a 49/51 split instead of 50/50 to ensure that any extra space is on the right
        // side of the screen. This is important because the right side of the screen is
        // where the borders are collapsed.
        .constraints([Constraint::Percentage(49), Constraint::Percentage(51)])
        .split(frame.size());
    let sub_layout = Layout::default()
        .direction(Direction::Vertical)
        // use a 49/51 split to ensure that any extra space is on the bottom
        .constraints([Constraint::Percentage(49), Constraint::Percentage(51)])
        .split(layout[1]);

    frame.render_widget(
        Block::new()
            // don't render the right border because it will be rendered by the right block
            .border_set(symbols::border::PLAIN)
            .borders(Borders::TOP | Borders::LEFT | Borders::BOTTOM)
            .title("Left Block"),
        layout[0],
    );

    // top right block must render the top left border to join with the left block
    let top_right_border_set = symbols::border::Set {
        top_left: symbols::line::NORMAL.horizontal_down,
        ..symbols::border::PLAIN
    };
    frame.render_widget(
        Block::new()
            .border_set(top_right_border_set)
            // don't render the bottom border because it will be rendered by the bottom block
            .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
            .title("Top Right Block"),
        sub_layout[0],
    );

    // bottom right block must render:
    // - top left border to join with the left block and top right block
    // - top right border to join with the top right block
    // - bottom left border to join with the left block
    let collapsed_top_and_left_border_set = symbols::border::Set {
        top_left: symbols::line::NORMAL.vertical_right,
        top_right: symbols::line::NORMAL.vertical_left,
        bottom_left: symbols::line::NORMAL.horizontal_up,
        ..symbols::border::PLAIN
    };
    frame.render_widget(
        Block::new()
            .border_set(collapsed_top_and_left_border_set)
            .borders(Borders::ALL)
            .title("Bottom Right Block"),
        sub_layout[1],
    );
}

Render Text

How to: Display Text

This page covers how text displaying works. It will cover Span, Line, and Text, and how these can be created, styled, displayed, altered, and such.

Span

A Span is a styled segment of text. You can think of it as a substring with its own unique style. It is the most basic unit of displaying text in ratatui.

The examples below assume the following imports:

use ratatui::{prelude::*, widgets::*};
pub type Frame<'a> = ratatui::Frame<'a, CrosstermBackend<std::io::Stderr>>;

A Span consists of “content” and a “style” for the content. And a Span can be created in a few different ways.

  1. using Span::raw:

    fn ui(_app: &App, f: &mut Frame<'_>) {
        let span = Span::raw("This is text that is not styled");
        // --snip--
    }
  2. using Span::styled:

    fn ui(_app: &App, f: &mut Frame<'_>) {
        let span = Span::styled("This is text that will be yellow", Style::default().fg(Color::Yellow));
        // --snip--
    }
  3. using the Stylize trait:

    fn ui(_app: &App, f: &mut Frame<'_>) {
        let span = "This is text that will be yellow".yellow();
        // --snip--
    }

A Span is the basic building block for any styled text, and can be used anywhere text is displayed.

Line

The next building block that we are going to talk about is a Line. A Line represents a cluster of graphemes, where each unit in the cluster can have its own style. You can think of an instance of the Line struct as essentially a collection of Span objects, i.e. Vec<Span>.

Since each Line struct consists of multiple Span objects, this allows for varied styling in a row of words, phrases or sentences.

fn ui(_: &App, f: &mut Frame<'_>) {
    let line = Line::from(vec![
        "hello".red(),
        " ".into(),
        "world".red().bold()
    ]);
    // --snip--
}

A Line can be constructed directly from content, where the content is Into<Cow<'a, &str>>.

fn ui(_: &App, f: &mut Frame<'_>) {
    let line = Line::from("hello world");
    // --snip--
}

You can even style a full line directly:

fn ui(_: &App, f: &mut Frame<'_>) {
    let line = Line::styled("hello world", Style::default().fg(Color::Yellow));
    // --snip--
}

And you can use the Stylize trait on the line directly by using into():

fn ui(_: &App, f: &mut Frame<'_>) {
    let line: Line = "hello world".yellow().into();
    // --snip--
}

Text

Text is the final building block of outputting text. A Text object represents a collection of Lines.

Most widgets accept content that can be converted to Text.

fn ui(_: &App, f: &mut Frame<'_>) {
    let span1 = "hello".red();
    let span2 = "world".red().bold();
    let line = Line::from(vec![span1, " ".into(), span2]);
    let text = Text::from(line);
    f.render_widget(Paragraph::new(text).block(Block::default().borders(Borders::ALL)), f.size());
}

Here’s an HTML representation of what you’d get in the terminal:

hello world

Often code like the one above can be simplified:

fn ui(_: &App, f: &mut Frame<'_>) {
    let line: Line = vec![
        "hello".red(),
        " ".into(),
        "world".red().bold()
    ].into();
    f.render_widget(Paragraph::new(line).block(Block::default().borders(Borders::ALL)), f.size());
}

This is because in this case, Rust is able to infer the types and convert them into appropriately.

Text instances can be created using the raw or styled constructors too.

Something that you might find yourself doing pretty often for a Paragraph is wanting to have multiple lines styled differently. This is one way you might go about that:

fn ui(_: &App, f: &mut Frame<'_>) {
    let text = vec![
        "hello world 1".into(),
        "hello world 2".blue().into(),
        Line::from(vec!["hello".green(), " ".into(), "world".green().bold(), "3".into()]),
    ]
    .into();
    f.render_widget(Paragraph::new(text).block(Block::default().borders(Borders::ALL)), f.size());
}

hello world 1

hello world 2

hello world 3

We will talk more about styling in the next section.

How to: Style Text

Styling enhances user experience by adding colors, emphasis, and other visual aids. In ratatui, the primary tool for this is the ratatui::style::Style struct.

ratatui::style::Style provides a set of methods to apply styling attributes to your text. These styles can then be applied to various text structures like Text, Span, and Line (as well as other non text structures).

Common styling attributes include:

  • Foreground and Background Colors (fg and bg)
  • Modifiers (like bold, italic, and underline)
  1. Basic Color Styling

    Setting the foreground (text color) and background:

    let styled_text = Span::styled(
        "Hello, Ratatui!",
        Style::default().fg(Color::Red).bg(Color::Yellow)
    );
  2. Using Modifiers

    Making text bold or italic:

    let bold_text = Span::styled(
        "This is bold",
        Style::default().modifier(Modifier::BOLD)
    );
    
    let italic_text = Span::styled(
        "This is italic",
        Style::default().modifier(Modifier::ITALIC)
    );

    You can also combine multiple modifiers:

    let bold_italic_text = Span::styled(
        "This is bold and italic",
        Style::default().modifier(Modifier::BOLD | Modifier::ITALIC)
    );
  3. Styling within a Line

    You can mix and match different styled spans within a single line:

    let mixed_line = Line::from(vec![
        Span::styled("This is mixed", Style::default().fg(Color::Green)),
        Span::styled("styling", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
        Span::from("!"),
    ]);

This is what it would look like if you rendered a Paragraph with different styles for each line:

fn ui(_: &App, f: &mut Frame<'_>) {
  let styled_text = Span::styled("Hello, Ratatui!", Style::default().fg(Color::Red).bg(Color::Yellow));
  let bold_text = Span::styled("This is bold", Style::default().add_modifier(Modifier::BOLD));
  let italic_text = Span::styled("This is italic", Style::default().add_modifier(Modifier::ITALIC));
  let bold_italic_text =
    Span::styled("This is bold and italic", Style::default().add_modifier(Modifier::BOLD | Modifier::ITALIC));
  let mixed_line = vec![
    Span::styled("This is mixed", Style::default().fg(Color::Green)),
    Span::styled("styling", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
    Span::from("!"),
  ];
  let text: Vec<Line<'_>> =
    vec![styled_text.into(), bold_text.into(), italic_text.into(), bold_italic_text.into(), mixed_line.into()];
  f.render_widget(Paragraph::new(text).block(Block::default().borders(Borders::ALL)), f.size());
}

Here’s the HTML representation of the above styling:

Hello, Ratatui!

This is bold

This is italic

This is bold and italic

This is mixed styling !

Tip

You can also create instances of Color from a string:

use std::str::FromStr;

let color: Color = Color::from_str("blue").unwrap();
assert_eq!(color, Color::Blue);

let color: Color = Color::from_str("#FF0000").unwrap();
assert_eq!(color, Color::Rgb(255, 0, 0));

let color: Color = Color::from_str("10").unwrap();
assert_eq!(color, Color::Indexed(10));

You can read more about the Color enum and Modifier in the reference documentation online.

How to: Overwrite regions

Tldr

Use the Clear widget to clear areas of the screen to avoid style and symbols from leaking from previously rendered widgets.

Ratatui renders text in the order that the application writes to the buffer. This means that earlier instructions will be overwritten by later ones. However, it’s important to note that widgets do not always clear every cell in the area that they are rendering to. This may cause symbols and styles that were previously rendered to the buffer to “bleed” through into the cells that are rendered on top of those cells.

The following code exhibits this problem:

use lipsum::lipsum;
use ratatui::{prelude::*, widgets::*};

// -- snip --

fn ui(frame: &mut Frame) {
    let area = frame.size();
    let background_text = Paragraph::new(lipsum(1000))
        .wrap(Wrap { trim: true })
        .light_blue()
        .italic()
        .on_black();
    frame.render_widget(background_text, area);

    // take up a third of the screen vertically and half horizontally
    let popup_area = Rect {
        x: area.width / 4,
        y: area.height / 3,
        width: area.width / 2,
        height: area.height / 3,
    };
    let bad_popup = Paragraph::new("Hello world!")
        .wrap(Wrap { trim: true })
        .style(Style::new().yellow())
        .block(
            Block::new()
                .title("Without Clear")
                .title_style(Style::new().white().bold())
                .borders(Borders::ALL)
                .border_style(Style::new().red()),
        );
    frame.render_widget(bad_popup, popup_area);
}

problem

Notice that the background color (black in this case), the italics, and the lorem ipsum background text show through the popup.

This problem is easy to prevent by rendering a Clear widget prior to rendering the main popup. Here is an example of how to use this technique to create a Popup widget:

use derive_setters::Setters;
use lipsum::lipsum;
use ratatui::{prelude::*, widgets::*};

#[derive(Debug, Default, Setters)]
struct Popup<'a> {
    #[setters(into)]
    title: Line<'a>,
    #[setters(into)]
    content: Text<'a>,
    border_style: Style,
    title_style: Style,
    style: Style,
}

impl Widget for Popup<'_> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // ensure that all cells under the popup are cleared to avoid leaking content
        Clear.render(area, buf);
        let block = Block::new()
            .title(self.title)
            .title_style(self.title_style)
            .borders(Borders::ALL)
            .border_style(self.border_style);
        Paragraph::new(self.content)
            .wrap(Wrap { trim: true })
            .style(self.style)
            .block(block)
            .render(area, buf);
    }
}

We can use the new Popup widget with the following code:

    let popup = Popup::default()
        .content("Hello world!")
        .style(Style::new().yellow())
        .title("With Clear")
        .title_style(Style::new().white().bold())
        .border_style(Style::new().red());
    frame.render_widget(popup, popup_area);

Which results in the following:

demo

Notice that the background is set to the default background and there are no italics or symbols from the background text.

Full source for this article is available at https://github.com/ratatui-org/ratatui-book/tree/main/code/how-to-overwrite-regions

Use Widgets

Paragraph

The Paragraph widget provides a way to display text content in your terminal user interface. It allows not only plain text display but also handling text wrapping, alignment, and styling. This page will delve deeper into the functionality of the Paragraph widget.

Usage

let p = Paragraph::new("Hello, World!");
f.render_widget(p, chunks[0]);

Styling and Borders

You can also apply styles to your text and wrap it with a border:

let p = Paragraph::new("Hello, World!")
    .style(Style::default().fg(Color::Yellow))
    .block(
        Block::default()
            .borders(Borders::ALL)
            .title("Title")
            .border_type(BorderType::Rounded)
    );
f.render_widget(p, chunks[0]);

Wrapping

The Paragraph widget will wrap the content based on the available width in its containing block. You can also control the wrapping behavior using the wrap method:

let p = Paragraph::new("A very long text that might not fit the container...")
    .wrap(Wrap { trim: true });
f.render_widget(p, chunks[0]);

Setting trim to true will ensure that trailing whitespaces at the end of each line are removed.

Alignment

let p = Paragraph::new("Centered Text")
    .alignment(Alignment::Center);
f.render_widget(p, chunks[0]);

Styled Text

Paragraph supports rich text through Span, Line, and Text:

let lines = vec![];
lines.push(Line::from(vec![
    Span::styled("Hello ", Style::default().fg(Color::Yellow)),
    Span::styled("World", Style::default().fg(Color::Blue).bg(Color::White)),
]));
lines.push(Line::from(vec![
    Span::styled("Goodbye ", Style::default().fg(Color::Yellow)),
    Span::styled("World", Style::default().fg(Color::Blue).bg(Color::White)),
]));
let text = Text::from(lines);
let p = Paragraph::new(text);
f.render_widget(p, chunks[0]);

Scrolling

For long content, Paragraph supports scrolling:

let mut p = Paragraph::new("Lorem ipsum ...")
    .scroll((1, 0));  // Scroll down by one line
f.render_widget(p, chunks[0]);

Block

The Block widget serves as a foundational building block for structuring and framing other widgets. It’s essentially a container that can have borders, a title, and other styling elements to enhance the aesthetics and structure of your terminal interface. This page provides an in-depth exploration of the Block widget.

Basic Usage

The simplest use case for a Block is to create a container with borders:

let b = Block::default()
    .borders(Borders::ALL);
f.render_widget(b, chunks[0]);

Titles

A common use case for Block is to give a section of the UI a title or a label:

let b = Block::default()
    .title("Header")
    .borders(Borders::ALL);
f.render_widget(b, chunks[0]);

You can also use the Title struct for better positioning or multiple titles.

let b = Block::default()
    .title(block::Title::from("Left Title").alignment(Alignment::Left))
    .title(block::Title::from("Middle Title").alignment(Alignment::Center))
    .title(block::Title::from("Right Title").alignment(Alignment::Right))
    .borders(Borders::ALL);
f.render_widget(b, chunks[0]);

Border style

Block provides flexibility in both the borders style and type:

let b = Block::default()
    .title("Styled Header")
    .border_style(Style::default().fg(Color::Magenta))
    .border_type(BorderType::Rounded)
    .borders(Borders::ALL);
f.render_widget(b, chunks[0]);

Create a custom widget

While Ratatui offers a rich set of pre-built widgets, there may be scenarios where you require a unique component tailored to specific needs. In such cases, creating a custom widget becomes invaluable. This page will guide you through the process of designing and implementing custom widgets.

Widget trait

At the core of creating a custom widget is the Widget trait. Any struct that implements this trait can be rendered using the framework’s drawing capabilities.


pub struct MyWidget {
    // Custom widget properties
    content: String,
}

impl Widget for MyWidget {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // Rendering logic goes here
    }
}

The render method must draw into the current Buffer. There are a number of methods implemented on Buffer.

impl Widget for MyWidget {
    fn render(self, area: Rect, buf: &mut Buffer) {
        buf.set_string(area.left(), area.top(), &self.content, Style::default().fg(Color::Green));
    }
}

For a given state, the Widget trait implements how that struct should be rendered.

pub struct Button {
    label: String,
    is_pressed: bool,
    style: Style,
    pressed_style: Option<Style>,
}

impl Widget for Button {
    fn render(self, area: Rect, buf: &mut Buffer) {
        let style = if self.is_pressed {
            self.pressed_style.unwrap_or_else(|| Style::default().fg(Color::Blue))
        } else {
            self.style
        };
        buf.set_string(area.left(), area.top(), &self.label, style);
    }
}

Ratatui also has a StatefulWidget. This is essentially a widget that can “remember” information between two draw calls. This is essential when you have interactive UI components, like lists, where you might need to remember which item was selected or how much the user has scrolled.

Here’s a breakdown of the trait:

pub trait StatefulWidget {
    type State;
    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State);
}
  • type State: This represents the type of the state that this widget will use to remember details between draw calls.
  • fn render(...): This method is responsible for drawing the widget on the terminal. Notably, it also receives a mutable reference to the state, allowing you to read from and modify the state as needed.

Develop Applications

This section covers topics on how to develop applications:

Handle CLI arguments

Command Line Interface (CLI) tools often require input parameters to dictate their behavior. clap (Command Line Argument Parser) is a feature-rich Rust library that facilitates the parsing of these arguments in an intuitive manner.

Defining Command Line Arguments

In this snippet, we utilize the clap library to define an Args struct, which will be used to capture and structure the arguments passed to the application:

use clap::Parser;

#[derive(Parser, Debug)]
#[command(version = version(), about = "ratatui template with crossterm and tokio")]
struct Args {
  /// App tick rate
  #[arg(short, long, default_value_t = 1000)]
  app_tick_rate: u64,
}

Here, the Args struct defines one command-line arguments:

  • app_tick_rate: Dictates the application’s tick rate.

This is supplied with default values, ensuring that even if the user doesn’t provide this argument, the application can still proceed with its defaults.

Displaying Version Information

One common convention in CLIs is the ability to display version information. Here, the version information is presented as a combination of various parameters, including the Git commit hash.

The version() function, as seen in the snippet, fetches this information:

pub fn version() -> String {
  let author = clap::crate_authors!();

  let commit_hash = env!("RATATUI_TEMPLATE_GIT_INFO");

  // let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string();
  let config_dir_path = get_config_dir().unwrap().display().to_string();
  let data_dir_path = get_data_dir().unwrap().display().to_string();

  format!(
    "\
{commit_hash}

Authors: {author}

Config directory: {config_dir_path}
Data directory: {data_dir_path}"
  )
}

This function uses the get_data_dir() and get_config_dir() from the section on XDG directories.

This function also makes use of an environment variable RATATUI_TEMPLATE_GIT_INFO to derive the Git commit hash. The variable can be populated during the build process by build.rs:

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

By invoking the CLI tool with the --version flag, users will be presented with the version details, including the authors, commit hash, and the paths to the configuration and data directories.

–version output

The version() function’s output is just an example. You can easily adjust its content by amending the string template code above.

Here’s the full build.rs for your reference:

fn main() {
  let git_output = std::process::Command::new("git").args(["rev-parse", "--git-dir"]).output().ok();
  let git_dir = git_output.as_ref().and_then(|output| {
    std::str::from_utf8(&output.stdout).ok().and_then(|s| s.strip_suffix('\n').or_else(|| s.strip_suffix("\r\n")))
  });

  // Tell cargo to rebuild if the head or any relevant refs change.
  if let Some(git_dir) = git_dir {
    let git_path = std::path::Path::new(git_dir);
    let refs_path = git_path.join("refs");
    if git_path.join("HEAD").exists() {
      println!("cargo:rerun-if-changed={}/HEAD", git_dir);
    }
    if git_path.join("packed-refs").exists() {
      println!("cargo:rerun-if-changed={}/packed-refs", git_dir);
    }
    if refs_path.join("heads").exists() {
      println!("cargo:rerun-if-changed={}/refs/heads", git_dir);
    }
    if refs_path.join("tags").exists() {
      println!("cargo:rerun-if-changed={}/refs/tags", git_dir);
    }
  }

  let git_output =
    std::process::Command::new("git").args(["describe", "--always", "--tags", "--long", "--dirty"]).output().ok();
  let git_info = git_output.as_ref().and_then(|output| std::str::from_utf8(&output.stdout).ok().map(str::trim));
  let cargo_pkg_version = env!("CARGO_PKG_VERSION");

  // Default git_describe to cargo_pkg_version
  let mut git_describe = String::from(cargo_pkg_version);

  if let Some(git_info) = git_info {
    // If the `git_info` contains `CARGO_PKG_VERSION`, we simply use `git_info` as it is.
    // Otherwise, prepend `CARGO_PKG_VERSION` to `git_info`.
    if git_info.contains(cargo_pkg_version) {
      // Remove the 'g' before the commit sha
      let git_info = &git_info.replace('g', "");
      git_describe = git_info.to_string();
    } else {
      git_describe = format!("v{}-{}", cargo_pkg_version, git_info);
    }
  }

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

Handle XDG Directories

Handling files and directories correctly in a command-line or TUI application ensures that the application fits seamlessly into a user’s workflow and adheres to established conventions. One of the key conventions on Linux-based systems is the XDG Base Directory Specification.

Why the XDG Base Directory Specification?

The XDG Base Directory Specification is a set of standards that define where user files should reside, ensuring a cleaner home directory and a more organized storage convention. By adhering to this standard, your application will store files in the expected directories, making it more predictable and user-friendly.

Using directories-rs for Path Resolution

The directories-rs library offers a Rust-friendly interface to locate common directories (like config and data directories) based on established conventions, including the XDG Base Directory Specification.

  1. Add directories-rs to your Cargo.toml

    cargo add directories
    
  2. Use the ProjectDirs struct to retrieve paths based on your project’s domain and project name and create helper functions for getting the data_dir and config_dir.

  3. Allow users to specify custom locations using environment variables. This flexibility can be crucial for users with unique directory structures or for testing.

  4. A good practice is to notify the user about the location of the configuration and data directories. An example from the template is to print out these locations when the user invokes the --version command-line argument. See the section on Command line argument parsing

Here’s an example get_data_dir() and get_config_dir() functions for your reference:

use std::path::PathBuf;

use anyhow::{anyhow, Context, Result};
use directories::ProjectDirs;

pub fn get_data_dir() -> Result<PathBuf> {
  let directory = if let Ok(s) = std::env::var("RATATUI_TEMPLATE_DATA") {
    PathBuf::from(s)
  } else if let Some(proj_dirs) = ProjectDirs::from("com", "kdheepak", "ratatui-template") {
    proj_dirs.data_local_dir().to_path_buf()
  } else {
    return Err(anyhow!("Unable to find data directory for ratatui-template"));
  };
  Ok(directory)
}

pub fn get_config_dir() -> Result<PathBuf> {
  let directory = if let Ok(s) = std::env::var("RATATUI_TEMPLATE_CONFIG") {
    PathBuf::from(s)
  } else if let Some(proj_dirs) = ProjectDirs::from("com", "kdheepak", "ratatui-template") {
    proj_dirs.config_local_dir().to_path_buf()
  } else {
    return Err(anyhow!("Unable to find config directory for ratatui-template"));
  };
  Ok(directory)
}

You will want to replace kdheepak with your user name or company name (or any unique name for that matter); and ratatui-app with the name of your CLI.

I own https://kdheepak.com so I tend to use com.kdheepak.ratatui-app for my project directories. That way it is unlikely that any other program will mess with the configuration files for the app I plan on distributing.

Setup Logging with tracing

You’ll need to install tracing and a few related dependencies:

cargo add tracing-error tracing
cargo add tracing-subscriber --features env-filter
cargo add directories lazy_static color-eyre # (optional)

You can paste the following in any module in your project.

use std::path::PathBuf;

use color_eyre::eyre::{Context, Result};
use directories::ProjectDirs;
use lazy_static::lazy_static;
use tracing::error;
use tracing_error::ErrorLayer;
use tracing_subscriber::{self, layer::SubscriberExt, util::SubscriberInitExt, Layer};

lazy_static! {
  pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
  pub static ref DATA_FOLDER: Option<PathBuf> =
    std::env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from);
  pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", PROJECT_NAME.clone());
  pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME"));
}

fn project_directory() -> Option<ProjectDirs> {
  ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME"))
}

pub fn get_data_dir() -> PathBuf {
  let directory = if let Some(s) = DATA_FOLDER.clone() {
    s
  } else if let Some(proj_dirs) = project_directory() {
    proj_dirs.data_local_dir().to_path_buf()
  } else {
    PathBuf::from(".").join(".data")
  };
  directory
}

pub fn initialize_logging() -> Result<()> {
  let directory = get_data_dir();
  std::fs::create_dir_all(directory.clone())?;
  let log_path = directory.join(LOG_FILE.clone());
  let log_file = std::fs::File::create(log_path)?;
  std::env::set_var(
    "RUST_LOG",
    std::env::var("RUST_LOG")
      .or_else(|_| std::env::var(LOG_ENV.clone()))
      .unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))),
  );
  let file_subscriber = tracing_subscriber::fmt::layer()
    .with_file(true)
    .with_line_number(true)
    .with_writer(log_file)
    .with_target(false)
    .with_ansi(false)
    .with_filter(tracing_subscriber::filter::EnvFilter::from_default_env());
  tracing_subscriber::registry().with(file_subscriber).with(ErrorLayer::default()).init();
  Ok(())
}

/// Similar to the `std::dbg!` macro, but generates `tracing` events rather
/// than printing to stdout.
///
/// By default, the verbosity level for the generated events is `DEBUG`, but
/// this can be customized.
#[macro_export]
macro_rules! trace_dbg {
    (target: $target:expr, level: $level:expr, $ex:expr) => {{
        match $ex {
            value => {
                tracing::event!(target: $target, $level, ?value, stringify!($ex));
                value
            }
        }
    }};
    (level: $level:expr, $ex:expr) => {
        trace_dbg!(target: module_path!(), level: $level, $ex)
    };
    (target: $target:expr, $ex:expr) => {
        trace_dbg!(target: $target, level: tracing::Level::DEBUG, $ex)
    };
    ($ex:expr) => {
        trace_dbg!(level: tracing::Level::DEBUG, $ex)
    };
}

Call initialize_logging()? in your main() function.

The log level is decided by the ${YOUR_CRATE_NAME}_LOGLEVEL environment variable (default = log::LevelFilter::Info).

Additionally, the location of the log files would be decided by your environment variables. See the section on XDG directories for more information.

Tip

Check out tui-logger for setting up a tui logger widget with tracing.

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

Async Tui with Terminal and EventHandler using tokio and crossterm

If you want a Tui struct:

  • with Deref and DerefMut
  • with Terminal enter raw mode, exit raw mode etc
  • with signal handling support
  • with key event EventHandler with crossterm’s EventStream support
  • and with tokio’s select!

then you can copy-paste this Tui struct into your project.

Add the following dependencies:

cargo add ratatui crossterm tokio tokio_util futures # required
cargo add color_eyre serde serde_derive # optional

You’ll need to copy the code to a ./src/tui.rs:

use std::{
  ops::{Deref, DerefMut},
  time::Duration,
};

use color_eyre::eyre::Result;
use crossterm::{
  cursor,
  event::{
    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, 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,
  pub mouse: bool,
  pub paste: bool,
}

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 {});
    let mouse = false;
    let paste = false;
    Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate, mouse, paste })
  }

  pub fn tick_rate(mut self, tick_rate: f64) -> Self {
    self.tick_rate = tick_rate;
    self
  }

  pub fn frame_rate(mut self, frame_rate: f64) -> Self {
    self.frame_rate = frame_rate;
    self
  }

  pub fn mouse(mut self, mouse: bool) -> Self {
    self.mouse = mouse;
    self
  }

  pub fn paste(mut self, paste: bool) -> Self {
    self.paste = paste;
    self
  }

  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)?;
    if self.mouse {
      crossterm::execute!(std::io::stderr(), EnableMouseCapture)?;
    }
    if self.paste {
      crossterm::execute!(std::io::stderr(), EnableBracketedPaste)?;
    }
    self.start();
    Ok(())
  }

  pub fn exit(&mut self) -> Result<()> {
    self.stop()?;
    if crossterm::terminal::is_raw_mode_enabled()? {
      self.flush()?;
      if self.paste {
        crossterm::execute!(std::io::stderr(), DisableBracketedPaste)?;
      }
      if self.mouse {
        crossterm::execute!(std::io::stderr(), DisableMouseCapture)?;
      }
      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();
  }
}

Then you’ll be able write code like this:

mod tui;

impl App {
  async fn run(&mut self) -> Result<()> {

    let mut tui = tui::Tui::new()?
            .tick_rate(4.0) // 4 ticks per second
            .frame_rate(30.0); // 30 frames per second

    tui.enter()?; // Starts event handler, enters raw mode, enters alternate screen

    loop {

      tui.draw(|f| { // Deref allows calling `tui.terminal.draw`
        self.ui(f);
      })?;

      if let Some(evt) = tui.next().await { // `tui.next().await` blocks till next event
        let mut maybe_action = self.handle_event(evt);
        while let Some(action) = maybe_action {
          maybe_action = self.update(action);
        }
      };

      if self.should_quit {
        break;
      }
    }

    tui.exit()?; // stops event handler, exits raw mode, exits alternate screen

    Ok(())
  }
}

Setup Panic Hooks

When building TUIs with ratatui, it’s vital to ensure that if your application encounters a panic, it gracefully returns to the original terminal state. This prevents the terminal from getting stuck in a modified state, which can be quite disruptive for users.

Here’s an example initialize_panic_handler that works with crossterm and with the Rust standard library functionality and no external dependencies.

pub fn initialize_panic_handler() {
    let original_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |panic_info| {
        crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen).unwrap();
        crossterm::terminal::disable_raw_mode().unwrap();
        original_hook(panic_info);
    }));
}

With this function, all your need to do is call initialize_panic_handler() in main() before running any terminal initialization code:

fn main() -> Result<()> {
    initialize_panic_handler();

    // Startup
    crossterm::terminal::enable_raw_mode()?;
    crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;

    let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

    // ...

    // Shutdown
    crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?;
    crossterm::terminal::disable_raw_mode()?;
    Ok(())
}

We used crossterm for panic handling. If you are using termion you can do something like the following:

use std::panic;
use std::error::Error;

pub fn initialize_panic_handler() {
    let panic_hook = panic::take_hook();
    panic::set_hook(Box::new(move |panic| {
        let panic_cleanup = || -> Result<(), Box<dyn Error>> {
            let mut output = io::stderr();
            write!(
                output,
                "{}{}{}",
                termion::clear::All,
                termion::screen::ToMainScreen,
                termion::cursor::Show
            )?;
            output.into_raw_mode()?.suspend_raw_mode()?;
            io::stderr().flush()?;
            Ok(())
        };
        panic_cleanup().expect("failed to clean up for panic");
        panic_hook(panic);
    }));
}

As a general rule, you want to take the original panic hook and execute it after cleaning up the terminal. In the next sections we will discuss some third party packages that can help give better stacktraces.

Better Panic Hooks using better-panic, color-eyre and human-panic

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.

better-panic

better-panic gives you pretty backtraces for panics.

cargo add better-panic

Here’s an example of initialize_panic_handler() using better-panic to provide a prettier backtrace by default.

use better_panic::Settings;

pub fn initialize_panic_handler() {
  std::panic::set_hook(Box::new(|panic_info| {
    crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen).unwrap();
    crossterm::terminal::disable_raw_mode().unwrap();
    Settings::auto().most_recent_first(false).lineno_suffix(true).create_panic_handler()(panic_info);
  }));
}

I personally like to reuse the Tui struct in the panic handler. That way, if I ever decide to move from crossterm to termion in the future, there’s one less place in the project that I have to worry about refactoring.

Here’s an example of initialize_panic_handler() using better_panic and libc to provide a prettier backtrace by default.

use better_panic::Settings;

pub fn initialize_panic_handler() {
  std::panic::set_hook(Box::new(|panic_info| {
    match crate::tui::Tui::new() {
      Ok(t) => {
        if let Err(r) = t.exit() {
          error!("Unable to exit Terminal: {r:?}");
        }
      },
      Err(r) => error!("Unable to exit Terminal: {r:?}"),
    }
    better_panic::Settings::auto()
      .most_recent_first(false)
      .lineno_suffix(true)
      .verbosity(better_panic::Verbosity::Full)
      .create_panic_handler()(panic_info);
    std::process::exit(libc::EXIT_FAILURE);
  }));
}

Now, let’s say I added a panic! to an application as an example:

diff --git a/src/components/app.rs b/src/components/app.rs
index 289e40b..de48392 100644
--- a/src/components/app.rs
+++ b/src/components/app.rs
@@ -77,6 +77,7 @@ impl App {
   }

   pub fn increment(&mut self, i: usize) {
+    panic!("At the disco");
     self.counter = self.counter.saturating_add(i);
   }

This is what a prettier stacktrace would look like with better-panic:

Backtrace (most recent call last):
  File "/Users/kd/gitrepos/ratatui-async-template/src/main.rs:46", in ratatui_async_template::main
    Ok(())
  File "/Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/runtime.rs:304", in tokio::runtime::runtime::Runtime::block_on
    Scheduler::MultiThread(exec) => exec.block_on(&self.handle.inner, future),
  File "/Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/scheduler/multi_thread/mod.rs:66", in tokio::runtime::scheduler::multi_thread::MultiThread::block_on
    enter
  File "/Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/context.rs:315", in tokio::runtime::context::BlockingRegionGuard::block_on
    park.block_on(f)
  File "/Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/park.rs:283", in tokio::runtime::park::CachedParkThread::block_on
    if let Ready(v) = crate::runtime::coop::budget(|| f.as_mut().poll(&mut cx)) {
  File "/Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/coop.rs:73", in tokio::runtime::coop::budget
    with_budget(Budget::initial(), f)
  File "/Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/coop.rs:107", in tokio::runtime::coop::with_budget
    f()
  File "/Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/park.rs:283", in tokio::runtime::park::CachedParkThread::block_on::{{closure}}
    if let Ready(v) = crate::runtime::coop::budget(|| f.as_mut().poll(&mut cx)) {
  File "/Users/kd/gitrepos/ratatui-async-template/src/main.rs:44", in ratatui_async_template::main::{{closure}}
    runner.run().await?;
  File "/Users/kd/gitrepos/ratatui-async-template/src/runner.rs:80", in ratatui_async_template:🏃:Runner::run::{{closure}}
    if let Some(action) = component.update(action.clone())? {
  File "/Users/kd/gitrepos/ratatui-async-template/src/components/app.rs:132", in <ratatui_async_template::components::app::App as ratatui_async_template::components::Component>::update
    Action::Increment(i) => self.increment(i),
  File "/Users/kd/gitrepos/ratatui-async-template/src/components/app.rs:80", in ratatui_async_template::components::app::App::increment
    panic!("At the disco");

The application panicked (crashed).
  At the disco
in src/components/app.rs:80
thread: main

With .most_recent_first(false) the last line of the stacktrace is typically where the error has occurred. This makes it fast and easy to find the error without having to scroll up the terminal history, and iterate on your application rapidly during development.

This kind of detailed stacktrace is only available in debug builds. For release builds, you may get inlined or truncated stacktraces.

For example, here’s what I get when I compile with all optimizations on:

Backtrace (most recent call last):
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header
  File "<unknown>:0", in __mh_execute_header

The application panicked (crashed).
  At the disco
in src/components/app.rs:80
thread: main

This is not particularly useful to show to the average user. We’ll discuss better solutions for what to show the users of your application in the following subsections.

color-eyre panic hook

Another way to manage printing of stack-traces is by using color-eyre:

cargo add color-eyre

color-eyre has a panic hook that is better suited for users in my opinion.

Tip

You will also want to add a repository key to your Cargo.toml file:

repository = "https://github.com/ratatui-org/ratatui-async-template" # used by env!("CARGO_PKG_REPOSITORY")

When a panic! occurs, after the application cleanly restores the terminal, we can print out a nice error message created by color-eyre like so:

The application panicked (crashed).
Message:  At the disco
Location: src/components/app.rs:80

This is a bug. Consider reporting it at https://github.com/ratatui-org/ratatui-async-template

Backtrace omitted. Run with RUST_BACKTRACE=1 environment variable to display it.
Run with RUST_BACKTRACE=full to include source snippets.

This is short and clear, providing a link to the user to report the bug.

Users can also opt to give you a more detailed stacktrace if they can reproduce the error (with a debug build and with export RUST_BACKTRACE=1):

The application panicked (crashed).
Message:  At the disco
Location: src/components/app.rs:80

This is a bug. Consider reporting it at https://github.com/ratatui-org/ratatui-async-template

  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ BACKTRACE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
                                ⋮ 13 frames hidden ⋮
  14: ratatui_async_template::components::app::App::increment::h4e8b6e0d83d3d575
      at /Users/kd/gitrepos/ratatui-async-template/src/components/app.rs:80
  15: <ratatui_async_template::components::app::App as ratatui_async_template::components::Component>::update::hc78145b4a91e06b6
      at /Users/kd/gitrepos/ratatui-async-template/src/components/app.rs:132
  16: ratatui_async_template:🏃:Runner::run::{{closure}}::h802b0d3c3413762b
      at /Users/kd/gitrepos/ratatui-async-template/src/runner.rs:80
  17: ratatui_async_template::main::{{closure}}::hd78d335f19634c3f
      at /Users/kd/gitrepos/ratatui-async-template/src/main.rs:44
  18: tokio::runtime::park::CachedParkThread::block_on::{{closure}}::hd7949515524de9f8
      at /Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/park.rs:283
  19: tokio::runtime::coop::with_budget::h39648e20808374d3
      at /Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/coop.rs:107
  20: tokio::runtime::coop::budget::h653c1593abdd982d
      at /Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/coop.rs:73
  21: tokio::runtime::park::CachedParkThread::block_on::hb0a0dd4a7c3cf33b
      at /Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/park.rs:283
  22: tokio::runtime::context::BlockingRegionGuard::block_on::h4d02ab23bd93d0fd
      at /Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/context.rs:315
  23: tokio::runtime::scheduler::multi_thread::MultiThread::block_on::h8aaba9030519c80d
      at /Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/scheduler/multi_thread/mod.rs:66
  24: tokio::runtime::runtime::Runtime::block_on::h73a6fbfba201fac9
      at /Users/kd/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.28.2/src/runtime/runtime.rs:304
  25: ratatui_async_template::main::h6da543b193746523
      at /Users/kd/gitrepos/ratatui-async-template/src/main.rs:46
  26: core::ops::function::FnOnce::call_once::h6cac3edc975fcef2
      at /rustc/eb26296b556cef10fb713a38f3d16b9886080f26/library/core/src/ops/function.rs:250
                                ⋮ 13 frames hidden ⋮

human-panic

To use human-panic, you’ll have to install it as a dependency:

cargo add human-panic

Personally, I think human-panic provides the most user friendly panic handling functionality out of the box when users experience an unexpected panic:

Well, this is embarrassing.

ratatui-async-template had a problem and crashed. To help us diagnose the problem you can send us a crash report.

We have generated a report file at "/var/folders/l4/bnjjc6p15zd3jnty8c_qkrtr0000gn/T/report-ce1e29cb-c17c-4684-b9d4-92d9678242b7.toml". Submit an issue or email with the subject of "ratatui-async-template Crash Report" and include the report as an attachment.

- Authors: Dheepak Krishnamurthy

We take privacy seriously, and do not perform any automated error collection. In order to improve the software, we rely on people to submit reports.

Thank you kindly!

It generates a report where information relevant to the crash is logged. Here’s the content of the temporary report file that human-panic creates (with optimizations turned on):

name = "ratatui-async-template"
operating_system = "Mac OS 13.5.2 [64-bit]"
crate_version = "0.1.0"
explanation = """
Panic occurred in file 'src/components/app.rs' at line 80
"""
cause = "At the disco"
method = "Panic"
backtrace = """

   0: 0x10448f5f8 - __mh_execute_header
   1: 0x1044a43c8 - __mh_execute_header
   2: 0x1044a01ac - __mh_execute_header
   3: 0x10446f8c0 - __mh_execute_header
   4: 0x1044ac850 - __mh_execute_header"""

In debug mode, the stacktrace is as descriptive as earlier.

Configuration

You can mix and match these different panic handlers, using better-panic for debug builds and color-eyre and human-panic for release builds. The code below also prints the color-eyre stacktrace to log::error! for good measure (after striping ansi escape sequences).

cargo add color-eyre human-panic libc better-panic strip-ansi-escapes

Here’s code you can copy paste into your project (if you use the Tui struct to handle terminal exits):

pub fn initialize_panic_handler() -> Result<()> {
  let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default()
    .panic_section(format!("This is a bug. Consider reporting it at {}", env!("CARGO_PKG_REPOSITORY")))
    .display_location_section(true)
    .display_env_section(true)
    .into_hooks();
  eyre_hook.install()?;
  std::panic::set_hook(Box::new(move |panic_info| {
    if let Ok(t) = crate::tui::Tui::new() {
      if let Err(r) = t.exit() {
        error!("Unable to exit Terminal: {:?}", r);
      }
    }

    let msg = format!("{}", panic_hook.panic_report(panic_info));
    #[cfg(not(debug_assertions))]
    {
      eprintln!("{}", msg); // prints color-eyre stack trace to stderr
      use human_panic::{handle_dump, print_msg, Metadata};
      let meta = Metadata {
        version: env!("CARGO_PKG_VERSION").into(),
        name: env!("CARGO_PKG_NAME").into(),
        authors: env!("CARGO_PKG_AUTHORS").replace(':', ", ").into(),
        homepage: env!("CARGO_PKG_HOMEPAGE").into(),
      };

      let file_path = handle_dump(&meta, panic_info);
      // prints human-panic message
      print_msg(file_path, &meta).expect("human-panic: printing error message to console failed");
    }
    log::error!("Error: {}", strip_ansi_escapes::strip_str(msg));

    #[cfg(debug_assertions)]
    {
      // Better Panic stacktrace that is only enabled when debugging.
      better_panic::Settings::auto()
        .most_recent_first(false)
        .lineno_suffix(true)
        .verbosity(better_panic::Verbosity::Full)
        .create_panic_handler()(panic_info);
    }

    std::process::exit(libc::EXIT_FAILURE);
  }));
  Ok(())
}

Migrate from tui-rs

Ratatui is a fork of tui-rs, created to continue maintenance of the project.

Several options are available to migrate apps and libs:

  • Fully replace tui-rs with ratatui (preferred approach)
  • Use ratatui as a drop in replacement aliased as tui
  • Support both tui and ratatui

Fully replace Tui with Ratatui

Most new code should use the following. To take this approach to migration requires find and replace tui::->ratatui:: on the entire codebase.

ratatui = { version = "0.24.0" }
crossterm = { version = "0.27.0" }

Drop in replacement

The simplest approach to migrating to ratatui is to use it as drop in replacement for tui and update the terminal libraries used (crossterm / termion). E.g.:

tui = { package = "ratatui", version = "0.24.0", features = ["crossterm"] }
crossterm = { version = "0.27.0" }

Support both tui and ratatui

For more complex scenarios where a library (or in some cases an app) needs to support both ratatui and maintain existing support for tui, it may be feasible to use feature flags to select which library to use. See tui-logger for an example of this approach.

Backwards compatibility and breaking changes

FAQ

What is the difference between a library and a framework?

The terms library and framework are often used interchangeably in software development, but they serve different purposes and have distinct characteristics.

LibraryFramework
UsageA library is a collection of functions and procedures that a programmer can call in their application. The library provides specific functionality, but it’s the developer’s responsibility to explicitly call and use it.A framework is a pre-built structure or scaffold that developers build their application within. It provides a foundation, enforcing a particular way of creating an application.
Control FlowIn the case of a library, the control flow remains with the developer’s application. The developer chooses when and where to use the library.With a framework, the control flow is inverted. The framework decides the flow of control by providing places for the developer to plug in their own logic (often referred to as “Inversion of Control” or IoC).
NatureLibraries are passive in nature. They wait for the application’s code to invoke their methods.Frameworks are active and have a predefined flow of their own. The developer fills in specific pieces of the framework with their own code.
ExampleImagine you’re building a house. A library would be like a toolbox with tools (functions) that you can use at will. You decide when and where to use each tool.Using the house-building analogy, a framework would be like a prefabricated house where the main structure is already built. You’re tasked with filling in the interiors and decor, but you have to follow the design and architecture already provided by the prefabricated design.

What is the difference between a ratatui (a library) and a tui-realm (a framework)?

While ratatui provides tools (widgets) for building terminal UIs, it doesn’t dictate or enforce a specific way to structure your application. You need to decide how to best use the library in your particular context, giving you more flexibility.

In contrast, tui-realm might provide more guidelines and enforcements about how your application should be structured or how data flows through it. And, for the price of that freedom, you get more features out of the box with tui-realm and potentially lesser code in your application to do the same thing that you would with ratatui.

What is the difference between ratatui and cursive?

Cursive and Ratatui are both libraries that make TUIs easier to write. Both libraries are great! Both also work on linux, macOS and windows.

Cursive

Cursive uses a more declarative UI: the user defines the layout, then cursive handles the event loop. Cursive also handles most input (including mouse clicks), and forwards events to the currently focused view. User-code is more focused on “events” than on keyboard input. Cursive also supports different backends like ncurses, pancurses, termion, and crossterm.

One of cursive’s main features is its built-in event loop. You can easily attach callbacks to events like clicks or key presses, making it straightforward to handle user interactions.

use cursive::views::{Dialog, TextView};

fn main() {
    // Creates the cursive root - required for every application.
    let mut siv = cursive::default();

    // Creates a dialog with a single "Quit" button
    siv.add_layer(Dialog::around(TextView::new("Hello World!"))
                         .title("Cursive")
                         .button("Quit", |s| s.quit()));

    // Starts the event loop.
    siv.run();
}

Ratatui

In Ratatui, the user handles the event loop, the application state, and re-draws the entire UI on each iteration. It does not handle input and users have use another library (like crossterm). Ratatui supports Crossterm, termion, wezterm as backends.

use ratatui::{prelude::*, widgets::*};

fn init() -> Result<(), Box<dyn std::error::Error>> {
  crossterm::terminal::enable_raw_mode()?;
  crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?;
  Ok(())
}

fn exit() -> Result<(), Box<dyn std::error::Error>> {
  crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?;
  crossterm::terminal::disable_raw_mode()?;
  Ok(())
}

fn centered_rect(r: Rect, percent_x: u16, percent_y: u16) -> Rect {
  let popup_layout = Layout::default()
    .direction(Direction::Vertical)
    .constraints([
      Constraint::Percentage((100 - percent_y) / 2),
      Constraint::Percentage(percent_y),
      Constraint::Percentage((100 - percent_y) / 2),
    ])
    .split(r);

  Layout::default()
    .direction(Direction::Horizontal)
    .constraints([
      Constraint::Percentage((100 - percent_x) / 2),
      Constraint::Percentage(percent_x),
      Constraint::Percentage((100 - percent_x) / 2),
    ])
    .split(popup_layout[1])[1]
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
  init()?;

  let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

  loop {
    terminal.draw(|f| {
      let rect = centered_rect(f.size(), 35, 35);
      f.render_widget(
        Paragraph::new("Hello World!\n\n\n'q' to quit")
          .block(
            Block::default().title(block::Title::from("Ratatui").alignment(Alignment::Center)).borders(Borders::all()),
          )
          .alignment(Alignment::Center),
        rect,
      );
    })?;

    if crossterm::event::poll(std::time::Duration::from_millis(250))? {
      if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
        if key.code == crossterm::event::KeyCode::Char('q') {
          break;
        }
      }
    }
  }
  exit()?;

  Ok(())
}

You may have to write more code but you get precise control over exact UI you want to display with Ratatui.

Can you change font size in a terminal using ratatui?

ratatui itself doesn’t control the terminal’s font size. ratatui renders content based on the size and capabilities of the terminal it’s running in. If you want to change the font size, you’ll need to adjust the settings of your terminal emulator.

However, changing this setting in your terminal emulator will only change the font size for you while you are developing your ratatui based application.

When a user zooms in and out using terminal shortcuts, that will typically change the font size in their terminal. You typically will not know what the terminal font size is ahead of time.

However, you can know the current terminal size (i.e. columns and rows). Additionally, when zooming in and out ratatui applications will see a terminal resize event that will contain the new columns and rows. You should ensure your ratatui application can handle these changes gracefully.

You can detect changes in the terminal’s size by listening for terminal resize events from the backend of your choice and you can adjust your application layout as needed.

For example, here’s how you might do it in crossterm:

    match crossterm::terminal::read() {
        Ok(evt) => {
            match evt {
                crossterm::event::Event::Resize(x, y) => {
                    // handle resize event here
                },
                _ => {}
            }
        }
    }

Tip

Since this can happen on the user end without your control, this means that you’ll have to design your ratatui based terminal user interface application to display content well in a number of different terminal sizes.

ratatui does support various styles, including bold, italic, underline, and more, and while this doesn’t change the font size, it does provide you with the capability to emphasize or de-emphasize text content in your application.

Additionally you can use figlet or tui-big-text to display text content across multiple lines. Here’s an example using tui-big-text:

tui-big-text

Can you use multiple terminal.draw() calls consequently?

You cannot use terminal.draw() multiple times in the same main loop.

Because Ratatui uses a double buffer rendering technique, writing code like this will NOT render all three widgets:

  loop {
    terminal.draw(|f| {
      f.render_widget(widget1, f.size());
    })?;
    terminal.draw(|f| {
      f.render_widget(widget2, f.size());
    })?;
    terminal.draw(|f| {
      f.render_widget(widget3, f.size());
    })?;
    // handle events
    // manage state
  }

You want to write the code like this instead:

  loop {
    terminal.draw(|f| {
      f.render_widget(widget1, f.size());
      f.render_widget(widget2, f.size());
      f.render_widget(widget3, f.size());
    })?;
    // handle events
    // manage state
  }

Should I use stdout or stderr?

When using crossterm, application developers have the option of rendering to stdout or stderr.

let mut t = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
// OR
let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;

Both of these will work fine for normal purposes. The question you have to ask if how would you like your application to behave in non-TTY environments.

For example, if you run ratatui-application | grep foo with stdout, your application won’t render anything to the screen and there would be no indication of anything going wrong. With stderr the application will still render a TUI.

With stdout:

  • Every app needs to add code to check if the output is a TTY and do something different based on the result
  • App can’t write a result to the user that can be passed in a pipeline, e.g. my-select-some-value-app | grep foo
  • Tends to be what most command line applications do by default.

With stderr:

  • No special setup necessary in order to run in a pipe command
  • Unconventional and that might subvert users expectations

Why am I getting duplicate key events on Windows?

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

When should I use tokio and async/await?

ratatui isn’t a native async library. So is it beneficial to use tokio or async/await?

As a user of rataui, there really is only one point of interface with the ratatui library and that’s the terminal.draw(|f| ui(f)) functionality (the creation of widgets provided by ratatui typically happens in ui(f)). Everything else in your code is your own to do as you wish.

Should terminal.draw(|f| ui(f)) be async? Possibly. Rendering to the terminal buffer is relatively fast, especially using the double buffer technique that only renders diffs that ratatui uses. Creating of the widgets can also be done quite efficiently.

So one question you may ask is can we make terminal.draw(|f| ui(f)) async ourselves? Yes, we can. Check out https://github.com/ratatui-org/ratatui-async-template/tree/v0.1.0 for an example.

The only other part related to ratatui that is beneficial to being async is reading the key event inputs from stdin, and that can be made async with crossterm’s event-stream.

So the real question is what other parts of your app require async or benefit from being async? If the answer is not much, maybe it is simpler to not use async and avoiding tokio.

Another way to think about it is, do you think your app would work better with 1 thread like this?


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  Get
  Key
  Event
  Update
  State
  Render
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
  

Note

Even with the above architecture, you can use tokio to spawn tasks during Update State, and follow up on the work done by those tasks in the next iteration.

Or would it work with 3 threads / tokio tasks like this:


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

In your main thread or tokio task, do you expect to be spawning more tokio tasks? How many more tasks do you plan to be spawning?

The former can be done without any async code and the latter is the approach showcased in ratatui-async-template#v1.0 with tokio.

The most recent version of the ratatui-async-template uses this architecture instead with tokio:


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  Event
  Thread
  
  Get
  Key
  Event
  Send
  Event
  on
  event
  
  tx
  Recv
  Event
  Map
  Event
  to
  Action
  Tick
  Update
  State
  Render
  Render
  Component
  Main
  Thread
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
  
  
    
    
  
  
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
  
  
    
    
    
    
    
    
    
    
    
  
  
    
    
  
  
    
    
    
    
    
    
    
    
    
  

tui.rs history

This project was forked from tui-rs in February 2023, with the blessing of the original author, Florian Dehau (@fdehau).

The original repository contains all the issues, PRs and discussion that were raised originally, and it is useful to refer to when contributing code, documentation, or issues with Ratatui.

We imported all the PRs from the original repository and implemented many of the smaller ones and made notes on the leftovers. These are marked as draft PRs and labelled as imported from tui. We have documented the current state of those PRs, and anyone is welcome to pick them up and continue the work on them.

We have not imported all issues opened on the previous repository. For that reason, anyone wanting to work on or discuss an issue will have to follow the following workflow:

  • Recreate the issue
  • Start by referencing the original issue: Referencing issue #[<issue number>](<original issue link>)
  • Then, paste the original issues opening text

You can then resume the conversation by replying to the new issue you have created.

v0.24.0

⚠️ We created a breaking changes document for easily going through the breaking changes in each version.

Ratatui Website/Book 📚

The site you are browsing right now (ratatui.rs) is the new homepage of ratatui! For now, we host the book here which contains a bunch of useful tutorials, concepts and FAQ sections and there is a plan to create a landing page pretty soon!

All the code is available at https://github.com/ratatui-org/ratatui-book


Frame: no more generics 🚫

If you were using the Frame type with a Backend parameter before:

fn draw<B: Backend>(frame: &mut Frame<B>) {
    // ...
}

You no longer need to provide a generic over backend (B):

fn draw(frame: &mut Frame) {
    // ...
}

New Demo / Examples ✨

We have a new kick-ass demo!

demo2

To try it out:

cargo run --example=demo2 --features="crossterm widget-calendar"

The code is available here.

We also have a new example demonstrating how to create a custom widget.

custom widget

cargo run --example=custom_widget --features=crossterm

The code is available here.

Lastly, we added an example to demonstrate RGB color options:

RGB colors

cargo run --example=colors_rgb --features=crossterm

The code is available here.


Window Size API 🪟

A new method called window_size is added for retrieving the window size. It returns a struct called WindowSize that contains both pixels (width, height) and columns/rows information.

let stdout = std::io::stdout();
let mut backend = CrosstermBackend::new(stdout);
println!("{:#?}", backend.window_size()?;

Outputs:

WindowSize {
    columns_rows: Size {
        width: 240,
        height: 30,
    },
    pixels: Size {
        width: 0,
        height: 0,
    },
}

With this new API, the goal is to improve image handling in terminal emulators by sharing terminal size and layout information, enabling precise image placement and resizing within rectangles.

See the pull request for more information: https://github.com/ratatui-org/ratatui/pull/276


BarChart: Render smol charts 📊

We had a bug where the BarChart widget doesn’t render labels that are full width. Now this is fixed and we are able to render charts smaller than 3 lines!

For example, here is how BarChart is rendered and resized from a single line to 4 lines in order:

  ▁ ▂ ▃ ▄ ▅ ▆ ▇ 8


  ▁ ▂ ▃ ▄ ▅ ▆ ▇ 8
a b c d e f g h i

  ▁ ▂ ▃ ▄ ▅ ▆ ▇ 8
a b c d e f g h i
      Group

          ▂ ▄ ▆ █
  ▂ ▄ ▆ 4 5 6 7 8
a b c d e f g h i
      Group

If you set bar_width(2) for 3 lines, then it is rendered as:

          ▂ ▄ ▆ █
  ▂ ▄ ▆ 4 5 6 7 8
a b c d e f g h i
      Group

Block: custom symbols for borders 🛡️

The Block widget has a new method called border_set that can be used to specify the symbols that are going to be used for the borders.

Block::default()
    .borders(Borders::ALL)
    .border_set(border::Set {
        top_left: "1",
        top_right: "2",
        bottom_left: "3",
        bottom_right: "4",
        vertical_left: "L",
        vertical_right: "R",
        horizontal_top: "T",
        horizontal_bottom: "B",
    })

When rendered:

1TTTTTTTTTTTTT2
L             R
3BBBBBBBBBBBBB4

There are also 2 new border types added (QuadrantInside, QuadrantOutside).

See the available border types at https://docs.rs/ratatui/latest/ratatui/widgets/block/enum.BorderType.html

Also, there are breaking changes to note here:

  • BorderType::to_line_set is renamed to to_border_set
  • BorderType::line_symbols is renamed to border_symbols

Canvas: half block marker 🖼️

A new marker named HalfBlock is added to Canvas widget along with the associated HalfBlockGrid.

The idea is to use half blocks to draw a grid of “pixels” on the screen. Because we can set two colors per cell, and because terminal cells are about twice as tall as they are wide, we can draw a grid of half blocks that looks like a grid of square pixels.

Canvas::default()
    .marker(Marker::HalfBlock)
    .x_bounds([0.0, 10.0])
    .y_bounds([0.0, 10.0])
    .paint(|context| {
        context.draw(&Rectangle {
            x: 0.0,
            y: 0.0,
            width: 10.0,
            height: 10.0,
            color: Color::Red,
        });
    });

Rendered as:

█▀▀▀▀▀▀▀▀█
█        █
█        █
█        █
█        █
█        █
█        █
█        █
█        █
█▄▄▄▄▄▄▄▄█

Line: raw constructor 📝

You can simply construct Line widgets from strings using raw (similar to Span::raw and Text::raw):

let line = Line::raw("test content");

One thing to note here is that multi-line content is converted to multiple spans with the new lines removed.


Rect: is empty? 🛍️

With the newly added is_empty method, you can check if a Rect has any area or not:

assert!(!Rect::new(1, 2, 3, 4).is_empty());
assert!(Rect::new(1, 2, 0, 4).is_empty());
assert!(Rect::new(1, 2, 3, 0).is_empty());

Layout: LRU cache 📚

The layout cache now uses a LruCache with default size set to 16 entries. Previously the cache was backed by a HashMap, and was able to grow without bounds as a new entry was added for every new combination of layout parameters.

We also added a new method called init_cache for changing the cache size if necessary:

Layout::init_cache(10);

This will only have an effect if it is called prior to any calls to layout::split().


Backend: optional underline colors 🎨

Windows 7 doesn’t support the underline color attribute, so we need to make it optional. For that, we added a feature fla called underline-color and enabled it as default.

It can be disabled as follows for applications that supports Windows 7:

ratatui = { version = "0.24.0", default-features = false, features = ["crossterm"] }

Stylized strings ✨

Although the Stylize trait is already implemented for &str which extends to String, it is not implemented for String itself.

So we added an implementation of Stylize for String that returns Span<'static> which makes the following code compile just fine instead of failing with a temporary value error:

let s = format!("hello {name}!", "world").red();

This may break some code that expects to call Stylize methods on String values and then use the String value later. For example, following code will now fail to compile because the String is consumed by set_style instead of a slice being created and consumed.

let s = String::from("hello world");
let line = Line::from(vec![s.red(), s.green()]); // fails to compile

Simply clone the String to fix the compilation error:

let s = String::from("hello world");
let line = Line::from(vec![s.clone().red(), s.green()]);

Spans: RIP 💀

The Spans was deprecated and replaced with a more ergonomic Line type in 0.21.0 and now it is removed.

Long live Line!


Other 💼

  • Simplified the internal implementation of BarChart and add benchmarks
  • Add documentation to various places including Block, Gauge, Table, BarChart, etc.
  • Used modern modules syntax throughout the codebase (xxx/mod.rs -> xxx.rs)
  • Added buffer_mut method to Frame
  • Integrated Dependabot for dependency updates and bump dependencies
  • Refactored examples
  • Fixed arithmetic overflow edge cases

v0.23.0

Coolify everything 😎

We already had a cool name and a logo, and now we have a cool description as well:

- ratatui: A Rust library to build rich terminal user interfaces or dashboards.
+ ratatui: A Rust library that's all about cooking up terminal user interfaces.

We also renamed our organization from tui-rs-revival to ratatui-org:

Barchart: horizontal bars

You can now render the bars horizontally for the Barchart widget. This is especially useful in some cases to make more efficient use of the available space.

Simply use the Direction attribute for rendering horizontal bars:

let mut barchart = BarChart::default()
    .block(Block::default().title("Data1").borders(Borders::ALL))
    .bar_width(1)
    .group_gap(1)
    .bar_gap(0)
    .direction(Direction::Horizontal);

Here is an example of what you can do with the Barchart widget (see the bottom right for horizontal bars):

horizontal bars


Voluntary skipping capability for Sixel

Sixel is a bitmap graphics format supported by terminals. “Sixel mode” is entered by sending the sequence ESC+Pq. The “String Terminator” sequence ESC+\ exits the mode.

Cell widget now has a set_skip method that allows the cell to be skipped when copying (diffing) the buffer to the screen. This is helpful when it is necessary to prevent the buffer from overwriting a cell that is covered by an image from some terminal graphics protocol such as Sixel, iTerm, Kitty, etc.

See the pull request for more information: https://github.com/ratatui-org/ratatui/pull/215

In this context, there is also an experimental image rendering crate: ratatui-image

ratatui-image


Table/List: Highlight spacing

We added a new property called HighlightSpacing to the Table and List widgets and it can be optionally set via calling highlight_spacing function.

Before this option was available, selecting a row in the table when no row was selected previously made the tables layout change (the same applies to unselecting) by adding the width of the “highlight symbol” in the front of the first column. The idea is that we want this behaviour to be configurable with this newly added option.

let list = List::new(items)
    .highlight_symbol(">>")
    .highlight_spacing(HighlightSpacing::Always);

Right now, there are 3 variants:

  • Always: Always add spacing for the selection symbol column.
  • WhenSelected: Only add spacing for the selection symbol column if a row is selected.
  • Never: Never add spacing to the selection symbol column, regardless of whether something is selected or not.

Table: support line alignment

let table = Table::new(vec![
        Row::new(vec![Line::from("Left").alignment(Alignment::Left)]),
        Row::new(vec![Line::from("Center").alignment(Alignment::Center)]),
        Row::new(vec![Line::from("Right").alignment(Alignment::Right)]),
    ])
    .widths(&[Constraint::Percentage(100)]);

Now results in:

Left
       Center
               Right

Scrollbar: optional track symbol

The track symbol in the Scrollbar is now optional, simplifying composition with other widgets. It also makes it easier to use the Scrollbar in tandem with a block with special block characters.

One breaking change is that track_symbol needs to be set in the following way now:

-let scrollbar = Scrollbar::default().track_symbol("-");
+let scrollbar = Scrollbar::default().track_symbol(Some("-"));

It also makes it possible to render a custom track that is composed out of multiple differing track symbols.


symbols::scrollbar module

The symbols and sets are moved from widgets::scrollbar to symbols::scrollbar. This makes it consistent with the other symbol sets. We also made the scrollbar module private.

Since this is a breaking change, you need to update your code to add an import for ratatui::symbols::scrollbar::* (or the specific symbols you need).


Alpha releases

The alpha releases (i.e. pre-releases) are created *every Saturday* and they are automated with the help of this GitHub Actions workflow. This is especially useful if you want to test ratatui or use unstable/experimental features before we hit a stable release.

The versioning scheme is v<version>-alpha.<num>, for example: v0.22.1-alpha.2

Additionally, see the following issue for possible contributions in the context of alpha releases and documentation: https://github.com/ratatui-org/ratatui/issues/412


Example GIFs

We added GIFs for each example in the examples/ directory and added a README.md for preview. This should make it easier to see what each example does without having to run it.

See: https://github.com/ratatui-org/ratatui/blob/main/examples/README.md

One thing to note here is that we used vhs for generating GIFs from a set of instructions. For example:

# This is a vhs script. See https://github.com/charmbracelet/vhs for more info.
# To run this script, install vhs and run `vhs ./examples/demo.tape`
Output "target/demo.gif"
Set Theme "OceanicMaterial"
Set Width 1200
Set Height 1200
Set PlaybackSpeed 0.5
Hide
Type "cargo run --example demo"
Enter
Sleep 2s
Show
Sleep 1s
Down@1s 12
Right
Sleep 4s
Right
Sleep 4s

Results in:

ratatui demo

We also host these GIFs at https://vhs.charm.sh but there is an issue about moving everything to GitHub. If you are interested in contributing regarding this, see https://github.com/ratatui-org/ratatui/issues/401


Common traits

With the help of strum crate, we added Display and FromStr implementation to enum types.

Also, we implemented common traits such as Debug, Default, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash to the structs/enums where possible.


Test coverage 🧪

ratatui now has 90% test coverage!

Shoutout to everyone who added tests/benchmarks for various widgets made this possible.


No unsafe ⚠️

We now forbid unsafe code in ratatui. Also, see this discussion we had in the past about using unsafe code for optimization purposes.


The book 📕

We are working on a book for more in-depth ratatui documentation and usage examples, you can read it from here: https://ratatui-org.github.io/ratatui-book/

Repository: https://github.com/ratatui-org/ratatui-book


Other

  • Expand serde attributes for TestBuffer for de/serializing the whole test buffer.
  • Add weak constraints to make Rects closer to each other in size.
  • Simplify Layout::split function.
  • Various bug fixes and improvements in Barchart, Block, Layout and other widgets.
  • Add documentation to various widgets and improve existing documentation.
  • Add examples for colors and modifiers.
  • We created a Matrix bridge at #ratatui:matrix.org.

v0.22

Prelude

We now have a prelude module! This allows users of the library to easily use ratatui without a huge amount of imports.

use ratatui::prelude::*;

Aside from the main types that are used in the library, this prelude also re-exports several modules to make it easy to qualify types that would otherwise collide. For example:

use ratatui::{prelude::*, widgets::*};

#[derive(Debug, Default, PartialEq, Eq)]
struct Line;

assert_eq!(Line::default(), Line);
assert_eq!(text::Line::default(), ratatui::text::Line::from(vec![]));

New widget: Scrollbar

A scrollbar widget has been added which can be used with any Rect. It can also be customized with different styles and symbols.

Here are the components of a Scrollbar:

<--▮------->
^  ^   ^   ^
│  │   │   └ end
│  │   └──── track
│  └──────── thumb
└─────────── begin

To use it, render it as a stateful widget along with ScrollbarState:

frame.render_stateful_widget(
    Scrollbar::default()
        .orientation(ScrollbarOrientation::VerticalRight)
        .begin_symbol(Some("↑"))
        .end_symbol(Some("↓")),
    rect,
    &mut scrollbar_state,
);

Will result in:

┌scrollbar──────────────────↑
│This is a longer line      ║
│Veeeeeeeeeeeeeeeery    looo█
│This is a line             ║
└───────────────────────────↓

Block: support multiple titles

Block widget now supports having more than one title via Title widget.

Each title will be rendered with a single space separating titles that are in the same position or alignment. When both centered and non-centered titles are rendered, the centered space is calculated based on the full width of the block, rather than the leftover width.

You can provide various types as the title, including strings, string slices, borrowed strings (Cow<str>), spans, or vectors of spans (Vec<Span>).

It can be used as follows:

Block::default()
    .borders(Borders::ALL)
    .title("Title") // By default in the top right corner
    .title(Title::from("Left").alignment(Alignment::Left))
    .title(Title::from("Center").alignment(Alignment::Center))
    .title(Title::from("Bottom").position(Position::Bottom))
    .title(
        Title::from("Bottom center")
            .alignment(Alignment::Center)
            .position(Position::Bottom),
    );

Results in:

┌Title─Left──Center─────────────┐
│                               │
│                               │
│                               │
└Bottom───Bottom center─────────┘

Barchart: support groups

Barchart has been improved to support adding multiple bars from different data sets. This can be done by using the newly added Bar and BarGroup objects.

See the barchart example for more information and implementation details.


Stylization shorthands

It is possible to use style shorthands for str, Span, and Paragraph.

A crazy example would be:

"hello"
    .on_black()
    .black()
    .bold()
    .underline()
    .dimmed()
    .slow_blink()
    .crossed_out()
    .reversed()

This especially helps with concise styling:

assert_eq!(
  "hello".red().on_blue().bold(),
  Span::styled("hello", Style::default().fg(Color::Red).bg(Color::Blue).add_modifier(Modifier::BOLD))
)

Stylize everything

All widgets can be styled now (i.e. set_style)

Styled trait is implemented for all the remaining widgets, including:

  • Barchart
  • Chart (including Axis and Dataset)
  • Gauge and LineGauge
  • List and ListItem
  • Sparkline
  • Table, Row, and Cell
  • Tabs
  • Style

Constant styles

Styles can be constructed in a const context as follows:

const DEFAULT_MODIFIER: Modifier = Modifier::BOLD.union(Modifier::ITALIC);
const EMPTY: Modifier = Modifier::empty();

const DEFAULT_STYLE: Style = Style::with(DEFAULT_MODIFIER, EMPTY)
    .fg(Color::Red)
    .bg(Color::Black);

More colors formats

It is now possible to parse hyphenated color names like light-red via Color::from_str.

Additionally, all colors from the ANSI color table are supported (though some names are not exactly the same).

  • gray is sometimes called white - this is not supported as we use white for bright white
  • gray is sometimes called silver - this is supported
  • darkgray is sometimes called light black or bright black (both are supported)
  • white is sometimes called light white or bright white (both are supported)
  • we support bright and light prefixes for all colors
  • we support "-", "_", and " " as separators for all colors
  • we support both gray and grey spellings

For example:

use ratatui::style::Color;
use std::str::FromStr;

assert_eq!(Color::from_str("red"), Ok(Color::Red));
assert_eq!("red".parse(), Ok(Color::Red));
assert_eq!("lightred".parse(), Ok(Color::LightRed));
assert_eq!("light red".parse(), Ok(Color::LightRed));
assert_eq!("light-red".parse(), Ok(Color::LightRed));
assert_eq!("light_red".parse(), Ok(Color::LightRed));
assert_eq!("lightRed".parse(), Ok(Color::LightRed));
assert_eq!("bright red".parse(), Ok(Color::LightRed));
assert_eq!("bright-red".parse(), Ok(Color::LightRed));
assert_eq!("silver".parse(), Ok(Color::Gray));
assert_eq!("dark-grey".parse(), Ok(Color::DarkGray));
assert_eq!("dark gray".parse(), Ok(Color::DarkGray));
assert_eq!("light-black".parse(), Ok(Color::DarkGray));
assert_eq!("white".parse(), Ok(Color::White));
assert_eq!("bright white".parse(), Ok(Color::White));

Integrations

Following tools are now integrated into the repository:


Other

  • Benchmarks added for the Paragraph widget
  • Added underline colors support for crossterm backend
  • Mark some of the low-level functions of Block, Layout and Rect as const
  • The project license has been updated to acknowledge ratatui developers

v0.21

New backend: termwiz

ratatui supports a new backend called termwiz which is a “Terminal Wizardry” crate that powers wezterm.

To use it, enable the termwiz feature in Cargo.toml:

[dependencies.ratatui]
version = "0.21.0"
features = ["termwiz"]
default-features = false

Then you can utilize TermwizBackend object for creating a terminal. Here is a simple program that shows a text on the screen for 5 seconds using ratatui + termwiz:

use ratatui::{backend::TermwizBackend, widgets::Paragraph, Terminal};
use std::{
    error::Error,
    thread,
    time::{Duration, Instant},
};

fn main() -> Result<(), Box<dyn Error>> {
    let backend = TermwizBackend::new()?;
    let mut terminal = Terminal::new(backend)?;
    terminal.hide_cursor()?;

    let now = Instant::now();
    while now.elapsed() < Duration::from_secs(5) {
        terminal.draw(|f| f.render_widget(Paragraph::new("termwiz example"), f.size()))?;
        thread::sleep(Duration::from_millis(250));
    }

    terminal.show_cursor()?;
    terminal.flush()?;
    Ok(())
}

New widget: Calendar

A calendar widget has been added which was originally a part of the extra-widgets repository.

Since this new widget depends on time crate, we gated it behind widget-calendar feature to avoid an extra dependency:

[dependencies.ratatui]
version = "0.21.0"
features = ["widget-calendar"]

Here is the example usage:

Monthly::new(
    time::Date::from_calendar_date(2023, time::Month::January, 1).unwrap(),
    CalendarEventStore::default(),
)
.show_weekdays_header(Style::default())
.show_month_header(Style::default())
.show_surrounding(Style::default()),

Results in:

     January 2023
 Su Mo Tu We Th Fr Sa
  1  2  3  4  5  6  7
  8  9 10 11 12 13 14
 15 16 17 18 19 20 21
 22 23 24 25 26 27 28
 29 30 31  1  2  3  4

New widget: Circle

Circle widget has been added with the use-case of showing an accuracy radius on the world map.

Here is an example of how to use it with Canvas:

Canvas::default()
    .paint(|ctx| {
        ctx.draw(&Circle {
            x: 5.0,
            y: 2.0,
            radius: 5.0,
            color: Color::Reset,
        });
    })
    .marker(Marker::Braille)
    .x_bounds([-10.0, 10.0])
    .y_bounds([-10.0, 10.0]),

Results in:

 ⡠⠤⢤⡀
⢸⡁  ⡧
 ⠑⠒⠚⠁

Inline Viewport

This was a highly requested feature and the original implementation was done by @fdehau himself. Folks at Atuin completed the implementation and we are happy to finally have this incorporated in the new release!

An inline viewport refers to a rectangular section of the terminal window that is set aside for displaying content.

In the repository, there is an example that simulates downloading multiple files in parallel: https://github.com/ratatui-org/ratatui/blob/main/examples/inline.rs


Block: title on bottom

Before you could only put the title on the top row of a Block. Now you can put it on the bottom row! Revolutionary.

For example, place the title on the bottom and center:

Paragraph::new("ratatui")
    .alignment(Alignment::Center)
    .block(
        Block::default()
            .title(Span::styled("Title", Style::default()))
            .title_on_bottom()
            .title_alignment(Alignment::Center)
            .borders(Borders::ALL),
    )

Results in:

┌─────────────────────┐
│       ratatui       │
│                     │
└────────Title────────┘

Block: support adding padding

If we want to render a widget inside a Block with a certain distance from its borders, we need to create another Layout element based on the outer Block, add a margin and render the Widget into it. Adding a padding property on the block element skips the creation of this second Layout.

This property works especially when rendering texts, as we can just create a block with padding and use it as the text wrapper:

let block = Block::default()
    .borders(Borders::ALL)
    .padding(Padding::new(1, 1, 2, 2));
let paragraph = Paragraph::new("example paragraph").block(block);
f.render_widget(paragraph, area);

Rendering another widget should be easy too, using the .inner method:

let block = Block::default().borders(Borders::ALL).padding(Padding {
    left: todo!(),
    right: todo!(),
    top: todo!(),
    bottom: todo!(),
});
let inner_block = Block::default().borders(Borders::ALL);
let inner_area = block.inner(area);

f.render_widget(block, area);
f.render_widget(inner_block, inner_area);
f.render_widget(paragraph, area);

Text: display secure data

A new type called Masked is added for text-related types for masking data with a mask character. The example usage is as follows:

Line::from(vec![
    Span::raw("Masked text: "),
    Span::styled(
        Masked::new("password", '*'),
        Style::default().fg(Color::Red),
    ),
])

Results in:

Masked text: ********

border! macro

A border! macro has been added that takes TOP, BOTTOM, LEFT, RIGHT, and ALL and returns a Borders object.

An empty border!() call returns NONE.

For example:

border!(ALL)
border!(LEFT, RIGHT)
border!()

This is gated behind a macros feature flag to ensure short build times. To enable it, update Cargo.toml as follows:

[dependencies.ratatui]
version = "0.21.0"
features = ["macros"]

Going forward, we will most likely put the new macros behind macros feature as well.


Color: support conversion from String

Have you ever needed this conversion?

"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
// etc.

Don’t worry, we got you covered:

Color::from_str("lightblue") // Color::LightBlue
Color::from_str("10")        // Color::Indexed(10)
Color::from_str("#FF0000")   // Color::Rgb(255, 0, 0)

Spans -> Line

Line is a significantly better name over Spans as the plural causes confusion and the type really is a representation of a line of text made up of spans.

So, Spans is renamed as Line and a deprecation notice has been added.

See https://github.com/ratatui-org/ratatui/pull/178 for more discussion.


Other features

  • List now has a len() method for returning the number of items
  • Sparkline now has a direction() method for specifying the render direction (left to right / right to left)
  • Table and List states now have offset() and offset_mut() methods
  • Expose the test buffer (TestBackend) with Display implementation

New apps

Here is the list of applications that has been added:

  • oxycards: quiz card application built within the terminal.
  • twitch-tui: twitch chat in the terminal.
  • tenere: TUI interface for LLMs.

Also, we moved APPS.md file to the Wiki so check it out for more applications built with ratatui!


Migration from tui-rs

We put together a migration guide at the Wiki: Migrating from TUI

Also, the minimum supported Rust version is 1.65.0


Contributing

Any contribution is highly appreciated! There are contribution guidelines for getting started.

Feel free to submit issues and throw in ideas!

If you are having a problem with ratatui or want to contribute to the project or just want to chit-chat, feel free to join our Discord server!

References

Apps using ratatui

Here you will find a list of TUI applications that are made using ratatui and tui.

Aside from those listed here, many other apps and libraries can be easily be found via the reverse dependencies on crates.io and GitHub:

💻 Development Tools

  • desed: Debugging tool for sed scripts
  • gitui: Terminal UI for Git
  • gobang: Cross-platform TUI database management tool
  • joshuto: Ranger-like terminal file manager written in Rust
  • repgrep: An interactive replacer for ripgrep that makes it easy to find and replace across files on the command line
  • tenere: TUI interface for LLMs written in Rust
  • nomad: Customizable next-gen tree command with Git integration and TUI

🕹️ Games and Entertainment

  • Battleship.rs: Terminal-based Battleship game
  • game-of-life-rs: Conway’s Game of Life implemented in Rust and visualized with tui-rs
  • oxycards: Quiz card application built within the terminal
  • minesweep: Terminal-based Minesweeper game
  • rust-sadari-cli: rust sadari game based on terminal! (Ghost leg or Amidakuji in another words)
  • tic-tac-toe: Terminal-based tic tac toe game

🚀 Productivity and Utilities

  • diskonaut: Terminal-based disk space navigator
  • exhaust: Exhaust all your possibilities.. for the next coming exam
  • gpg-tui: Manage your GnuPG keys with ease!
  • lazy-etherscan: A Simple Terminal UI for the Ethereum Blockchain Explorer
  • meteo-tui: French weather app in the command line
  • rusty-krab-manager: time management tui in rust
  • taskwarrior-tui: TUI for the Taskwarrior command-line task manager
  • tickrs: Stock market ticker in the terminal
  • tts-tui: Text to speech app that reads from clipboard
  • Jirust: A Jira TUI
  • igrep: Interactive Grep
  • todolist-rust: A terminal-based simple to-do app

🎼 Music and Media

🌐 Networking and Internet

  • adsb_deku/radar: TUI for displaying ADS-B data from aircraft
  • AdGuardian-Term: Real-time traffic monitoring and statistics for AdGuard Home
  • bandwhich: Displays network utilization by process
  • conclusive: A command line client for Plausible Analytics
  • gping: Ping tool with a graph
  • mqttui: MQTT client for subscribing or publishing to topics
  • oha: Top-like monitoring tool for HTTP(S) traffic
  • rrtop: Rust Redis monitoring (top like) app.
  • termscp: A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/S3/SMB
  • trippy: Network diagnostic tool
  • tsuchita: client-server notification center for dbus desktop notifications
  • vector: A high-performance observability data pipeline
  • vincenzo: A bittorrent client for the terminal with vim-like keybindings

👨‍💻 System Administration

  • bottom: Cross-platform graphical process/system monitor
  • kdash: A simple and fast dashboard for Kubernetes
  • kmon: Linux Kernel Manager and Activity Monitor
  • kubectl-watch: A kubectl plugin to provide a pretty delta change view of being watched kubernetes resources
  • kubetui: TUI for real-time monitoring of Kubernetes resources
  • logss: A simple cli for logs splitting
  • oxker: Simple TUI to view & control docker containers
  • pumas: Power Usage Monitor for Apple Silicon
  • systeroid: A more powerful alternative to sysctl(8) with a terminal user interface
  • xplr: Hackable, minimal, and fast TUI file explorer
  • ytop: TUI system monitor for Linux
  • zenith: Cross-platform monitoring tool for system stats

🌌 Other

  • cotp: Command-line TOTP/HOTP authenticator app
  • cube timer: A tui for cube timing, written in Rust
  • hg-tui: TUI for viewing the hellogithub.com website
  • hwatch: Alternative watch command with command history and diffs
  • poketex: Simple Pokedex based on TUI
  • termchat: Terminal chat through the LAN with video streaming and file transfer

Third Party Crates

  • ansi-to-tui — Convert ansi colored text to ratatui::text::Text
  • color-to-tui — Parse hex colors to ratatui::style::Color
  • rust-tui-template — A template for bootstrapping a Rust TUI application with Tui-rs & crossterm
  • simple-tui-rs — A simple example tui-rs app
  • tui-builder — Batteries-included MVC framework for Tui-rs + Crossterm apps
  • tui-clap — Use clap-rs together with Tui-rs
  • tui-log — Example of how to use logging with Tui-rs
  • tui-logger — Logger and Widget for Tui-rs
  • tui-realm — Tui-rs framework to build stateful applications with a React/Elm inspired approach
  • tui-realm-treeview — Treeview component for Tui-realm
  • tui-rs-tree-widgets: Widget for tree data structures.
  • tui-windows — Tui-rs abstraction to handle multiple windows and their rendering
  • tui-textarea: Simple yet powerful multi-line text editor widget supporting several key shortcuts, undo/redo, text search, etc.
  • tui-input: TUI input library supporting multiple backends and tui-rs.
  • tui-term: A pseudoterminal widget library that enables the rendering of terminal applications as ratatui widgets.
  • tui-big-text: A Rust crate that renders large pixel text as a ratatui widget using the glyphs from the font8x8 crate.
  • crokey: Crokey helps incorporate configurable keybindings in crossterm based terminal applications by providing functions to handle key combinations.

Showcase

scope-tui

A simple oscilloscope/vectorscope/spectroscope for your terminal


gitui

TUI for git written in rust


bottom

A customizable cross-platform graphical process/system monitor for the terminal


xplr

A hackable, minimal, fast TUI file explorer


yazi

Blazing fast terminal file manager written in Rust, based on async I/O

Showcase


joshuto

ranger-like terminal file manager written in Rust


taskwarrior-tui

A terminal user interface for taskwarrior


bandwich

This is a CLI utility for displaying current network utilization by process, connection and remote IP/hostname


oha

oha is a tiny program that sends some load to a web application and show realtime tui


gpg-tui

gpg-tui is a Terminal User Interface for GnuPG.


atuin

Atuin replaces your existing shell history with a SQLite database, and records additional context for your commands.


minesweep-rs

A mine sweeping game written in Rust

Features

As ratatui grows and evolves, this list may change, so make sure to check the main repo if you are unsure.

Backend Selection

For most cases, the default crossterm backend is the correct choice. See Backends for more information. However, this can be changed to termion or termwiz

# Defaults to crossterm
cargo add ratatui

# For termion, unset the default crossterm feature and select the termion feature
cargo add ratatui --no-default-features --features=terminon
cargo add termion

# For termwiz, unset the default crossterm feature and select the termwiz feature
cargo add ratatui --no-default-features --features=termwiz
cargo add termwiz

All-Widgets

This feature enables some extra widgets that are not in default to save on compile time. As of v0.21, the only widget in this feature group is the calendar widget, which can be enabled with the widget-calendar feature.

cargo add ratatui --features all-widgets

Widget-Calendar

This feature enables the calendar widget, which requires the time crate.

cargo add ratatui --features widget-calendar

Serde

cargo add ratatui --features serde

Ratatui

Check out the CONTRIBUTING GUIDE for more information.

Keep PRs small, intentional and focused

Try to do one pull request per change. The time taken to review a PR grows exponential with the size of the change. Small focused PRs will generally be much more faster to review. PRs that include both refactoring (or reformatting) with actual changes are more difficult to review as every line of the change becomes a place where a bug may have been introduced. Consider splitting refactoring / reformatting changes into a separate PR from those that make a behavioral change, as the tests help guarantee that the behavior is unchanged.

Search tui-rs for similar work

The original fork of Ratatui, tui-rs, has a large amount of history of the project. Please search, read, link, and summarize any relevant issues, discussions and pull requests.

Use conventional commits

We use conventional commits and check for them as a lint build step. To help adhere to the format, we recommend to install Commitizen. By using this tool you automatically follow the configuration defined in .cz.toml. Your commit messages should have enough information to help someone reading the CHANGELOG understand what is new just from the title. The summary helps expand on that to provide information that helps provide more context, describes the nature of the problem that the commit is solving and any unintuitive effects of the change. It’s rare that code changes can easily communicate intent, so make sure this is clearly documented.

Clean up your commits

The final version of your PR that will be committed to the repository should be rebased and tested against main. Every commit will end up as a line in the changelog, so please squash commits that are only formatting or incremental fixes to things brought up as part of the PR review. Aim for a single commit (unless there is a strong reason to stack the commits). See Git Best Practices - On Sausage Making for more on this.

Run CI tests before pushing a PR

We’re using cargo-husky to automatically run git hooks, which will run cargo make ci before each push. To initialize the hook run cargo test. If cargo-make is not installed, it will provide instructions to install it for you. This will ensure that your code is formatted, compiles and passes all tests before you push. If you need to skip this check, you can use git push --no-verify.

Sign your commits

We use commit signature verification, which will block commits from being merged via the UI unless they are signed. To set up your machine to sign commits, see managing commit signature verification in GitHub docs.

Setup

Clone the repo and build it using cargo-make

Ratatui is an ordinary Rust project where common tasks are managed with cargo-make. It wraps common cargo commands with sane defaults depending on your platform of choice. Building the project should be as easy as running cargo make build.

git clone https://github.com/ratatui-org/ratatui.git
cd ratatui
cargo make build

Tests

The test coverage of the crate is reasonably good, but this can always be improved. Focus on keeping the tests simple and obvious and write unit tests for all new or modified code. Beside the usual doc and unit tests, one of the most valuable test you can write for Ratatui is a test against the TestBackend. It allows you to assert the content of the output buffer that would have been flushed to the terminal after a given draw call. See widgets_block_renders in tests/widgets_block.rs for an example.

When writing tests, generally prefer to write unit tests and doc tests directly in the code file being tested rather than integration tests in the tests/ folder.

If an area that you’re making a change in is not tested, write tests to characterize the existing behavior before changing it. This helps ensure that we don’t introduce bugs to existing software using Ratatui (and helps make it easy to migrate apps still using tui-rs).

For coverage, we have two bacon jobs (one for all tests, and one for unit tests, keyboard shortcuts v and u respectively) that run cargo-llvm-cov to report the coverage. Several plugins exist to show coverage directly in your editor. E.g.:

Use of unsafe for optimization purposes

We don’t currently use any unsafe code in Ratatui, and would like to keep it that way. However there may be specific cases that this becomes necessary in order to avoid slowness. Please see this discussion for more about the decision.

Ratatui Book

The ratatui-book is written in mdbook.

The book is built as HTML pages as part of a GitHub Action and is available to view at https://ratatui-org.github.io/ratatui-book/.

Feel free to make contributions if you’d like to improve the documentation.

If you want to set up your local environment, you can run the following:

cargo install mdbook --version 0.4.35
cargo install mdbook-admonish --version 1.13.0
cargo install mdbook-svgbob2 --version 0.3.0
cargo install mdbook-linkcheck --version 0.7.7
cargo install mdbook-mermaid --version 0.12.6
cargo install mdbook-emojicodes --version 0.2.2
cargo install mdbook-catppuccin --version 2.0.1

These plugins allow additional features.

mdbook-admonish

The following raw markdown:

```admonish note
This is a note
```

```admonish tip
This is a tip
```

```admonish warning
This is a warning
```

```admonish info
This is a info
```

will render as the following:

Note

This is a note

Tip

This is a tip

Warning

This is a warning

Info

This is a info

mdbook-mermaid

The following raw markdown:

```mermaid
graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;
```

will render as the following:

graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;

mdbook-svgbob2

The following raw markdown:

```svgbob
       .---.
      /-o-/--
   .-/ / /->
  ( *  \/
   '-.  \
      \ /
       '
```

will render as the following:


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  
  
  
  
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
  

mdbook-emojicodes

The following raw markdown:

I love cats 🐱 and dogs 🐶, I have two, one's gray, like a raccoon 🦝, and the other
one is black, like the night 🌃.

will render as the following:

I love cats 🐱 and dogs 🐶, I have two, one’s gray, like a raccoon 🦝, and the other one is black, like the night 🌃.

LICENSE

The MIT License

Copyright (c) 2023 Ratatui Developers

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Contributors

https://github.com/ratatui-org/ratatui/graphs/contributors

See the contributors graph on GitHub for more up to date information.