Table of contents
Ratatui is a crate for building terminal user interfaces in Rust.
One of the unique features of Ratatui is that it is an immediate mode rendering library. In these series post, I'm going to describe some of the primitives of Ratatui. In every Ratatui application I build, I rely on theses concepts described in this post.
Immediate Mode Rendering
User interfaces can broadly be classified into two kinds:
- immediate mode GUIs,
- retained mode GUIs.
Casey Muratori has a great video on immediate mode rendering.
https://www.youtube.com/watch?v=Z1qyvQsjK5Y
At a very high level, in retained mode GUIs, you create UI elements and pass it to a framework and the framework is in charge of displaying them. For example, you can create a text field and input field, and then the browser will render them. The browser is in charge of handling events, and as a developer you have to define how these events interact with these widgets.
For example, in a simple counter example in a browser, we have to set up
an incrementCounter and decrementCounter callbacks that update the
relevant element's state. The browser is responsible for displaying
these elements, receiving user inputs, calling the appropriate onclick
callback, etc.
Code
:dep ratatui = "0.26.2":dep ratatui-macros = "0.4.0"
fn span_to_html(s: ratatui::text::Span) -> String{ let mut html = String::new(); html.push_str("<span style=\"");
// Set foreground color if let Some(color) = &s.style.fg { html.push_str(&format!("color: {};", color)); }
// Set background color if let Some(color) = &s.style.bg { html.push_str(&format!("background-color: {};", color)); }
// Add modifiers match s.style.add_modifier { ratatui::style::Modifier::BOLD => html.push_str("font-weight: bold;"), ratatui::style::Modifier::ITALIC => html.push_str("font-style: italic;"), ratatui::style::Modifier::UNDERLINED => html.push_str("text-decoration: underline;"), _ => {} } html.push_str("\">"); html.push_str(&s.content); html.push_str("</span>"); html}
fn buffer_to_html(buf: &ratatui::buffer::Buffer) -> String { fn escape_special_html_characters(text: &str) -> String { text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\"", """) .replace("'", "'") }
let mut html = String::from("<pre><code>");
let w = buf.area.width; let h = buf.area.height;
for y in 0..h { for x in 0..w { let s = buf.get(x, y).symbol();
let escaped = escape_special_html_characters(s);
let style = buf.get(x, y).style();
let span = ratatui::text::Span::styled(s, style);
html.push_str(&span_to_html(span)); } html.push('\n'); }
html.push_str("</code></pre>");
html}
fn show_html<D>(content: D) where D: std::fmt::Display { println!(r#"EVCXR_BEGIN_CONTENT text/html<div style="display: flex; justify-content:start; gap: 1em; margin: 1em">{}</div>EVCXR_END_CONTENT"#, content);}
show_html(r#"
<button onclick="decrementCounter()">Decrement</button>
<text> Counter: </text><text id="counter">0</text>
<button onclick="incrementCounter()">Increment</button>
<script> var counterElement = document.getElementById("counter");
var counterValue = 0; counterElement.textContent = counterValue;
function incrementCounter() { counterValue++; counterElement.textContent = counterValue; }
function decrementCounter() { counterValue--; counterElement.textContent = counterValue; }</script>"#)In immediate mode rendering, however, you are responsible for
rendering the UI every "frame". This is typically done in a for loop
or a while true loop in your application; and you use an immediate
mode rendering library (in our case ratatui) to render the elements.
This means you as the developer of the application using immediate mode
rendering are responsible for a lot more things but it also gives you
more control and freedom.
Rect Primitives
Code
:dep ratatui = "0.26.2":dep ratatui-macros = "0.4.0"One of Ratatui's core primitives is a Rect struct. Let's create one:
let (x, y, width, height) = (0, 0, 80, 5);let area = ratatui::layout::Rect::new(x, y, width, height);areaRect { x: 0, y: 0, width: 80, height: 5 }We can also create an inner Rect by using the inner() method and a
Margin struct:
let (horizontal, vertical) = (2, 1);area.inner(&ratatui::layout::Margin::new(horizontal, vertical))Rect { x: 2, y: 1, width: 76, height: 3 }Rect has 4 public fields,
x,y,widthandheight
If we want to loop though all elements in a Rect, we can use the
following pattern:
for x in area.left()..area.right() { for y in area.top()..area.bottom() { // ... }};Buffer Primitives
In Ratatui, every "widget" renders into a Buffer of a fixed size that
is equal to the terminal dimensions. Let create an empty buffer:
// create a `Rect`let (x, y, width, height) = (0, 0, 80, 5);let area = ratatui::layout::Rect::new(x, y, width, height);
// create a `Buffer` that is of size of `area`let mut buf = ratatui::buffer::Buffer::empty(area);We can print the buf here as HTML using this function:
Code
show_html(buffer_to_html(&buf))
Currently the buf is empty. Let's render into the buffer by using the
Block
widget with a border. We will discuss Block in more detail in a future
blog post.
The render method requires importing the Widget trait:
use ratatui::widgets::Widget; // required trait for `.render()` methodNow we can render a Block with borders widget into a Buffer using
the render method from the Widget trait:
let block = ratatui::widgets::Block::bordered();block.render(area, &mut buf);This is what it looks like when displayed in the browser.
Code
show_html(buffer_to_html(&buf))┌──────────────────────────────────────────────────────────────────────────────┐
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
Let's also add a title.
let block = ratatui::widgets::Block::bordered().title("Counter Example");block.render(area, &mut buf);Code
show_html(buffer_to_html(&buf))┌Counter Example───────────────────────────────────────────────────────────────┐
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
Now, let's put some text into the center of the buffer.
Let's say we have the following App:
#[derive(Debug, Default)]pub struct App { counter: u8,}let mut app = App::default();appApp { counter: 0 }And we want to render the App's counter in the center of the buffer.
use ratatui::widgets::Paragraph;
let inner_area = area.inner(&ratatui::layout::Margin { horizontal: 0, vertical: 2 });
let paragraph = ratatui::widgets::Paragraph::new(format!("Counter: {}", app.counter)).centered();paragraph.render(inner_area, &mut buf);Code
show_html(buffer_to_html(&buf))┌Counter Example───────────────────────────────────────────────────────────────┐
│ │
│ Counter: 0 │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
In immediate mode rendering, this is one frame of our UI!
Let's put our UI code into a function.
fn draw_ui(app: &App, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) { let block = ratatui::widgets::Block::bordered().title("Counter Example"); block.render(area, buf);
let inner_area = area.inner(&ratatui::layout::Margin { horizontal: 0, vertical: 2 }); let paragraph = ratatui::widgets::Paragraph::new(format!("Counter: {}", app.counter)).centered(); paragraph.render(inner_area, buf);}For the next frame, we can increment the counter and render into the buffer again.
app.counter += 1;
draw_ui(&app, area, &mut buf);Code
show_html(buffer_to_html(&buf))┌Counter Example───────────────────────────────────────────────────────────────┐
│ │
│ Counter: 1 │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
app.counter += 1;
draw_ui(&app, area, &mut buf);Code
show_html(buffer_to_html(&buf))┌Counter Example───────────────────────────────────────────────────────────────┐
│ │
│ Counter: 2 │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
app.counter += 1;
draw_ui(&app, area, &mut buf);Code
show_html(buffer_to_html(&buf))┌Counter Example───────────────────────────────────────────────────────────────┐
│ │
│ Counter: 3 │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
A Buffer contains a ratatui::layout::Rect indicating its size and
Vec<ratatui::buffer::Cell> storing its content.
A ratatui::buffer::Cell contains the symbol that represents the
content at a specific (x,y) location on the terminal as well as the
style of the content.
let cell = buf.get(0, 0).clone();cellCell { symbol: "┌", fg: Reset, bg: Reset, underline_color: Reset, modifier: NONE, skip: false }buf.content.len()400buf.area.width * buf.area.height400We will learn more about ratatui::style::Styles and how a widget draws
into a ratatui::buffer::Buffer in a future post.
Frame Primitive
ratatui exposes just one function as the API for drawing to the
terminal, the
Terminal::draw
method.
Let's create a TestBackend based Terminal to illustrate this.
let backend = ratatui::backend::TestBackend::new(80, 5);let mut terminal = ratatui::terminal::Terminal::new(backend).unwrap();terminal.draw(|frame| { dbg!(frame);});[src/lib.rs:202:5] frame = Frame { cursor_position: None, viewport_area: Rect { x: 0, y: 0, width: 80, height: 5, }, buffer: Buffer { area: Rect { x: 0, y: 0, width: 80, height: 5 }, content: [ " ", " ", " ", " ", " ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] }, count: 0,}We can see that when called terminal.draw(|f| ...), the callback
passed into the draw method is called immediately. The callback
receives an argument that is a
Frame.
Calling terminal.draw again increases the f.count value
terminal.draw(|frame| { dbg!(frame.count());});[src/lib.rs:202:5] frame.count() = 1terminal.draw(|frame| { dbg!(frame.count());});[src/lib.rs:202:5] frame.count() = 2terminal.draw(|frame| { dbg!(frame.count());});[src/lib.rs:202:5] frame.count() = 3Frame also has access to the current Buffer.
terminal.draw(|frame| { dbg!(frame.buffer_mut());});[src/lib.rs:202:5] frame.buffer_mut() = Buffer { area: Rect { x: 0, y: 0, width: 80, height: 5 }, content: [ " ", " ", " ", " ", " ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ]}All of ratatui functionality is to be used to draw into this Buffer
of the Frame passed into the callback. ratatui then figures out how
to print the Buffer to a terminal to display a UI.
Let's draw our app from earlier into the Buffer of the frame.
terminal.backend().buffer()Buffer { area: Rect { x: 0, y: 0, width: 80, height: 5 }, content: [ " ", " ", " ", " ", " ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ]}terminal.draw(|frame| { let mut buf = frame.buffer_mut(); draw_ui(&app, area, &mut buf)});terminal.backend().buffer()Buffer { area: Rect { x: 0, y: 0, width: 80, height: 5 }, content: [ "┌Counter Example───────────────────────────────────────────────────────────────┐", "│ │", "│ Counter: 3 │", "│ │", "└──────────────────────────────────────────────────────────────────────────────┘", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ]}If we repeat this process of "updating state" and "drawing UI" in a loop, we get an immediate mode rendered UI.
while true { // update app state app.counter += 1;
// draw app state terminal.draw(|frame| { let mut buf = frame.buffer_mut(); draw_ui(&app, area, &mut buf) });}Here's what a more complete counter application might look like with keyboard events.

If you are interested in seeing the full code regarding this, you can
check out the
basic-app
tutorial on the Ratatui website.
Ratatui uses a double buffer rendering technique that you can read about here.
Conclusion
We will discuss more about how this works under the hood in a future blog post.
In the next post, we'll discuss ratatui's layout primitives.