Skip to content
kd
14 May 2024
Back to blog

The Basic Building blocks of Ratatui - Part 1

6 min read (1,191 words)

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:

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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
}
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>
"#)

Counter: 0

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);
area
Rect { 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,

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()` method

Now 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();
app
App { 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();
cell
Cell { symbol: "┌", fg: Reset, bg: Reset, underline_color: Reset, modifier: NONE, skip: false }
buf.content.len()
400
buf.area.width * buf.area.height
400

We 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() = 1
terminal.draw(|frame| {
dbg!(frame.count());
});
[src/lib.rs:202:5] frame.count() = 2
terminal.draw(|frame| {
dbg!(frame.count());
});
[src/lib.rs:202:5] frame.count() = 3

Frame 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.


Citation

@online{krishnamurthy2024thebasicbuildingblocksofratatuipart1,
  author = {Dheepak Krishnamurthy},
  title = {The Basic Building blocks of Ratatui - Part 1},
  year = {2024},
  date = {2024-05-14},
  url = {https://kdheepak.com/blog/the-basic-building-blocks-of-ratatui-part-1/},
  langid = {en},
}

For attribution, please cite this work as:

Dheepak Krishnamurthy, "The Basic Building blocks of Ratatui - Part 1", May 14, 2024 https://kdheepak.com/blog/the-basic-building-blocks-of-ratatui-part-1/


The Basic Building blocks of Ratatui - Part 2
Effect of type inference on performance in Julia