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 Cells to represent the rows and columns of the terminal’s display area. As we saw in the Clear example above, widgets interact with these Cells using Buffer methods.

Here’s a visual representation of a Buffer that is 12 Cells wide and 4 Cells tall.


  
  
    
      
    
    
      
    
    
      
    
    
      
    
    
      
    
  
  
  0
  1
  2
  3
  4
  5
  6
  7
  8
  9
  10
  11
  H
  e
  l
  l
  o
  W
  o
  r
  l
  d
  !
  symbol
  style
  0
  1
  2
  3
  fg
  bg
  Reset
  Reset
  :
  :
  
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
    
    
    
    
    
    
    
    
  
  
    
    
    
    
    
  
  
    
    o”
  

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). Cells 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 a Cell with the symbol and style information for row = 0 and col = 0.
  • buf.set_string(0, 0, "Hello World!", Style::default()) will render hello world into the Buffer 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.

Note

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