The Basic Building blocks of Ratatui - Part 1

Author

Dheepak Krishnamurthy

Published

May 15, 2024

Keywords

rust, ratatui

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.

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,

  • x,
  • y,
  • width and
  • height

In ratatui (and terminals in general), the origin is at the top left and increases horizontally left to right, and increases vertically top to bottom.

"(0,0)" -------------> x "(columns)"

   |
   |
   |
   |
   v

   y "(rows)"

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.

Reuse

Citation

BibTeX citation:
@online{krishnamurthy2024,
  author = {Krishnamurthy, Dheepak},
  title = {The {Basic} {Building} Blocks of {Ratatui} - {Part} 1},
  date = {2024-05-15},
  url = {https://kdheepak.com/blog/the-basic-building-blocks-of-ratatui-part-1},
  langid = {en}
}
For attribution, please cite this work as:
D. Krishnamurthy, “The Basic Building blocks of Ratatui - Part 1,” May 15, 2024. https://kdheepak.com/blog/the-basic-building-blocks-of-ratatui-part-1.