Introduction to Ratatui
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.
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"
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:
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.
- From
crossterm
import modules, types, methods and traits to handle events, raw mode, and the alternate screen. See the Crossterm docs for more information on these types. - From
ratatui
import:CrosstermBackend
, a backend implementation for CrosstermTerminal
which provides the means to output to the terminalStylize
, an extension trait that adds style shorthands to other typesParagraph
widget, which is used to display text
- From
std
we import theio::Result
which most of the backend methods return, and thestdout()
method.
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(())
}
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.
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.
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
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());
})?;
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 counterk
decreases the counterq
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>;
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(())
}
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(())
}
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;
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
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);
}
}
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:
- Add a
Block
with a rounded border and the title"Counter App"
. - 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:
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.)
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.
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.
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 thecrossterm
crate, representing a key press from the user.app
: A mutable reference to our application’s state, represented by theApp
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.
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
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.
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:
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:
- what screen the user is seeing,
- 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 enteredEditing
: the screen shown when the user wishes to create a new key-value pairExiting
: 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--
print_json()
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--
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 theTerminal<Backend>
that we take as an argument,draw
is theratatui
command to draw aFrame
to the terminal1.|f| ui(f, &app)
tellsdraw
that we want to takef: <Frame>
and pass it to our functionui
, andui
will draw to thatFrame
.
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 Event
s (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.
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.
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:
- Take the area
f.size()
(which is a rectangle), and cut it into three vertical pieces (making horizontal cuts). - The first section will be 3 lines tall
- The second section should never be smaller than one line tall, but can expand if needed.
- The final section should also be 3 lines tall
For those visual learners, I have the following graphic:
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 ListItem
s, 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:
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:
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.
Popup area and title
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);
Popup contents
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"
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.
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 - Event
s
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.
- We added a
Deref
andDerefMut
so we can calltui.draw(|f| ...)
to have it calltui.terminal.draw(|f| ...)
. - We moved the
startup()
andshutdown()
functionality into theTui
struct. - We also added a
CancellationToken
so that we can start and stop the tokio task more easily. - We added
Event
variants forResize
,Focus
, andPaste
. - We added methods to set the
tick_rate
,frame_rate
, and whether we want to enablemouse
orpaste
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.
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 Action
s 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
}
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 Action
s gets us one step closer to making our application a “state
machine”, which improves understanding and testability.
If we wanted to be purist about it, we would make 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
}
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 - Action
s
Now that we have introduced Event
s and Action
s, we are going introduce a new mpsc::channel
for
Action
s. The advantage of this is that we can programmatically trigger updates to the state of the
app by sending Action
s 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 Action
s 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:
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. Theratatui
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 aasync
event stream, if you are interested in usingtokio
. -
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
Cell
s to represent the rows and columns of the terminal’s display area. As we saw in the Clear
example above, widgets interact with these Cell
s using Buffer
methods.
Here’s a visual representation of a Buffer
that is 12 Cell
s wide and 4 Cell
s tall.
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). Cell
s 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 aCell
with the symbol and style information for row = 0 and col = 0.buf.set_string(0, 0, "Hello World!", Style::default())
will renderhello world
into theBuffer
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.
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.
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
Constraint
s 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 aMin
constraint is ensured with aPercentage
orRatio
, the component will never shrink below the specified minimum size. -
Constraint::Max(u16)
: Limits the maximum size of a component. Similar toMin
, if mixed withPercentage
orRatio
, the component will never exceed the specified maximum size.
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).
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:
- Define Your Model
- Handling Updates
- 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.
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
}
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.
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
StatefulWidget
s 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.
-
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. -
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. -
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. -
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:
- Crossterm via
CrosstermBackend
and thecrossterm
(enabled by default). - Termion via
TermionBackend
and thetermion
feature. - Termwiz via
TermwizBackend
and thetermion
feature. - A
TestBackend
which can be useful to unit test your application’s UI
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
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:
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.:
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]);
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.
-
using
Span::raw
:fn ui(_app: &App, f: &mut Frame<'_>) { let span = Span::raw("This is text that is not styled"); // --snip-- }
-
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-- }
-
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
Line
s.
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:
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
andbg
) - Modifiers (like
bold
,italic
, andunderline
)
-
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) );
-
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) );
-
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 !
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
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);
}
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:
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:
- CLI arguments
- Configuration Directories
- Tracing
- Arrange your App Code
- Setup Panic Hooks
- Better Panic Hooks
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.
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.
-
Add
directories-rs
to yourCargo.toml
cargo add directories
-
Use the
ProjectDirs
struct to retrieve paths based on your project’s domain and project name and create helper functions for getting thedata_dir
andconfig_dir
. -
Allow users to specify custom locations using environment variables. This flexibility can be crucial for users with unique directory structures or for testing.
-
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.
Check out tui-logger
for setting up a
tui logger widget with tracing.
Async Tui
with Terminal
and EventHandler
using tokio
and crossterm
If you want a Tui
struct:
- with
Deref
andDerefMut
- with
Terminal
enter raw mode, exit raw mode etc - with signal handling support
- with key event
EventHandler
withcrossterm
’sEventStream
support - and with
tokio
’sselect!
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:
- provide a useful stacktrace so that they can report errors back to you.
- not leave the users terminal state in a botched condition, resetting it back to the way it was.
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.
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
withratatui
(preferred approach) - Use
ratatui
as a drop in replacement aliased astui
- Support both
tui
andratatui
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
- BREAKING-CHANGES.md
- PRs tagged with the breaking changes label
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.
Library | Framework | |
---|---|---|
Usage | A 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 Flow | In 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). |
Nature | Libraries 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. |
Example | Imagine 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
},
_ => {}
}
}
}
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
:
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?
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:
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:
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!
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.
cargo run --example=custom_widget --features=crossterm
The code is available here.
Lastly, we added an example to demonstrate RGB color options:
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 toto_border_set
BorderType::line_symbols
is renamed toborder_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 toFrame
- Integrated
Dependabot
for dependency updates and bump dependencies - Refactored examples
- Fixed arithmetic overflow edge cases
v0.23.0
reposted from https://blog.orhun.dev/ratatui-0-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):
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” sequenceESC+\
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
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:
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
Rect
s 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
reposted from https://blog.orhun.dev/ratatui-0-22-0/
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
(includingAxis
andDataset
)Gauge
andLineGauge
List
andListItem
Sparkline
Table
,Row
, andCell
Tabs
Style
Constant styles
Style
s 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 calledwhite
- this is not supported as we usewhite
for bright whitegray
is sometimes calledsilver
- this is supporteddarkgray
is sometimes calledlight black
orbright black
(both are supported)white
is sometimes calledlight white
orbright white
(both are supported)- we support
bright
andlight
prefixes for all colors - we support
"-"
,"_"
, and" "
as separators for all colors - we support both
gray
andgrey
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:
cargo-husky
: git pre-push hooksbacon
: background code checks / coveragecommitizen
: conventional commitscargo-deny
: linting dependenciestypos
: spell checker
Other
- Benchmarks added for the
Paragraph
widget - Added underline colors support for
crossterm
backend - Mark some of the low-level functions of
Block
,Layout
andRect
asconst
- 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 alen()
method for returning the number of itemsSparkline
now has adirection()
method for specifying the render direction (left to right / right to left)Table
andList
states now haveoffset()
andoffset_mut()
methods- Expose the test buffer (
TestBackend
) withDisplay
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
.
- 💻 Development Tools
- 🕹️ Games and Entertainment
- 🚀 Productivity and Utilities
- 🎼 Music and Media
- 🌐 Networking and Internet
- 👨💻 System Administration
- 🌌 Other
Aside from those listed here, many other apps and libraries can be easily be found via the reverse dependencies on crates.io and GitHub:
- https://crates.io/crates/ratatui/reverse_dependencies
- https://crates.io/crates/tui/reverse_dependencies
- https://github.com/ratatui-org/ratatui/network/dependents
- https://github.com/fdehau/tui-rs/network/dependents?package_id=UGFja2FnZS0zMjE3MzkzMDMx
💻 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
- glicol-cli: Cross-platform music live coding in terminal
- spotify-tui: Spotify client for the terminal
- twitch-tui: Twitch chat in the terminal
- ytui-music: Listen to music from YouTube in the terminal
🌐 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
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.:
- https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters
- https://github.com/alepez/vim-llvmcov
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:
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
See the contributors graph on GitHub for more up to date information.