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{letmut html =String::new(); html.push_str("<span style=\"");// Set foreground colorifletSome(color) =&s.style.fg { html.push_str(&format!("color: {};", color));}// Set background colorifletSome(color) =&s.style.bg { html.push_str(&format!("background-color: {};", color));}// Add modifiersmatch 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("'","'")}letmut html =String::from("<pre><code>");let w = buf.area.width;let h = buf.area.height;for y in0..h {for x in0..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.
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`letmut 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:
useratatui::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.
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.
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);letmut terminal =ratatui::terminal::Terminal::new(backend).unwrap();terminal.draw(|frame|{dbg!(frame);});
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
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.