Day 29: Border crossing

A shorter update today, as I am still in the early stages of my tree TUI element. Most of my progress has been in familiarizing myself better with Ratatui’s API, on which I have started to from opinions.

How immediate are we talking?

Ratatui describes itself as an immediate mode–style interface. This terminology descends from the world of graphical UIs, and was “immediate” in relation to retained mode, where you modify an intermediate data structure that is then rendered for you at regular intervals.

I don’t think “immediate mode” is an accurate description of how Ratatui works. Typically immediate-mode interfaces are based on a series of time-ordered drawing commands. This is the pattern I previously called “church encoding”. A popular example is Dear Imgui. Such an approach is possible with Ratatui, but isn’t ergonomic.

Ratatui’s core abstraction is Widget, which contains a single method, render(), which mutably modifies a shared pseudo-framebuffer of text. So far so good. However, this requires that you first create a widget before it can be rendered, containing all its necessary data internally. This nudges you towards a design where you create a full UI hierarchy in a single widget, call render() down it once, and then throw it away on every frame. Indeed, Ratatui’s built-in widgets work like this, being based on Vecs internally. This is the worst of both worlds: you pay the cost of creating the structure but don’t benefit from reusing it on subsequent frames. The widget documentation acknowledges that they were intended originally to be thought of as drawing commands, but later versions allowed you to store them between frames (which just seems like retained mode).

As an example, in a library like Dear Imgui, you would represent a dynamic list of items as a sequence of calls, like this:

// Note: Made up API because Dear Imgui's documentation
// is a bit of a mess.

imgui::begin_list(items.len());
for item in items {
    imgui::list_item(item.title)
}
imgui::end_list();

Whereas in Ratatui, you would do it like this:

ratatui::Text::from(
    items.map(|item| Line::raw(item.title))
        .collect::<Vec<_>>()
]);

Note that in Ratatui’s version we have to create a new Vec each time. There are benefits to this: it allows the library to do more advanced layout, since it knows how much space it needs to allocate. You often need to figure out sizing yourself with an immediate mode UI. But it also means you have to allocate a new Vec on each frame, or else lean into retained mode and store it somewhere yourself.

I would guess that the API is like this because it was more directly inspired by virtual DOM libraries like React than Dear Imgui, but I don’t have any direct evidence for that.

Colouring outside the lines

Though the widget API does push you towards creating a lot of single-frame temporary allocations, you don’t need to do this. Under the hood, widgets just have write access to a mutable buffer of text. I take advantage of this in the TreeWidget trait like this:

pub(crate) trait TreeWidget {
    type Child: TreeWidget;

    fn render_node(&self, area: Rect, buf: &mut Buffer);

    fn children(&self) -> impl Iterator<Item=Self::Child>;
}

Note that children returns an iterator rather than a Vec. This allows for implementations like this:

fn children(&self) -> impl Iterator<Item=Self::Child> {
    self.items.map(|i| ItemTree(t))
}

No allocations required.

However, this is where my second small annoyance with the widget API comes into play: you get both a Buffer containing the output of the entire app and a Rect which describes where in that buffer you are supposed to draw. This means all your code needs to calculate ahead of time if they have space to draw the next bit of UI, and if not how much they have to truncate, etc. This is flexible, but also a huge pain for dynamic-sized items.

It would be nice if Ratatui exposed a utility that wrapped both inputs and discarded any writes you attempted outside the allowed area. This would at least let me focus on scaling the bits of the UI where possible. I may explore building such a tool myself.