Idea: FunctionWidget (was Thoughts on tui-react?)

I was looking at awesome-ratatui and found tui-react. Any opinions on this crate?

I’m not exactly clear about what problem it is solving, but it is awesome, so it must be solving something! :slight_smile: In particular, it seems to say it is about enabling “stateful widgets”, but was written after tui-rs (pre-ratatui) added StatefulWidget. I’m confused.

Looking at usage it is used by two projects:

  • dua, which is by the tui-react author.
  • prodash, which is a library that seems quite popular.

(I have posted on the tui-react discussion forum on github to perhaps get an answer from the author)

I have had a short discussion with the author of the tui-react create and it seems like it was born out of frustration with the tui-rs create’s apparent enforced immutability during the rendering path. Since StatefulWidget existed back then, I immediately wondered why StatefulWidget wasn’t enough.

Turns out that tui-react exists in large part to support a pretty cool disk utilization visualization program: GitHub - Byron/dua-cli: View disk space usage and delete unwanted data, fast.

And so I took a whack at porting dua off of tui-react’s custom Terminal API (that essentially does away with the Widget based rendering calls in favor of passing a Backend around directly).

To my surprise I found that it was pretty easy to pass mutable data to application level rendering code even using Widget as opposed to StatefulWidget. The answer, as almost always, is to introduce another level of indirection. The Widget passed to ratatui can be any struct, and those structs may hold mutable references to application data.

The result is what I called a FunctionWidget, reproduced here without code comments:

struct FunctionWidget<F>
where
    F: FnOnce(Rect, &mut Buffer),
{
    render: F,
}

impl<F> FunctionWidget<F>
where
    F: FnOnce(Rect, &mut Buffer),
{
    fn new(function: F) -> FunctionWidget<F>
    where
        F: FnOnce(Rect, &mut Buffer),
    {
        FunctionWidget { render: function }
    }
}

impl<F> Widget for FunctionWidget<F>
where
    F: FnOnce(Rect, &mut Buffer),
{
    fn render(self, area: Rect, buf: &mut Buffer) {
        (self.render)(area, buf);
    }
}

And here is how you might use it:

    terminal.draw(|frame| {
        frame.render_widget(
            FunctionWidget::new(|area, buf| {
                window.render(props, area, buf, cursor);
            }),
            frame.size(),
        );
    })?;

Above, props is moved into and owned by the closure, window is a mutable reference to application level object that doesn’t implement any ratatui trait, area and buf are the usual values passed from the Frame to the render call, and cursor is a mutable reference expected by the rest of the custom rendering code used by the dua program.

Does this pattern seem generally useful?

The tui-react create is essentially a soft fork of the Terminal API, born out of a perceived inflexibility in the API. I wonder if dua’s author would have ever bothered to create tui-react had FunctionWidget been part of tui-rs.

This is something I’ve thought of recently too (inspired a little by the “systems” concept from Bevy). I’m considering adding this to Ratatui. It can be implemented directly on any method that takes the right params without the need for the FunctionWidget struct btw:

impl<F> WidgetRef for F
where
    F: Fn(Rect, &mut Buffer),
{
    fn render_ref(&self, area: Rect, buf: &mut Buffer) {
        self(area, buf);
    }
}

fn draw(frame: &mut Frame) {
    frame.render_widget_ref(widget_function, frame.size());
    frame.render_widget_ref(
        |area: Rect, buf: &mut Buffer| {
            buf.
        },
        area,
    )
}

fn widget_function(area: Rect, buf: &mut Buffer) {
    // do something
}

You can’t implement Widget on FnOnce with this approach though, as it conflicts with the blanket implementation of Widget for &W where W: WidgetRef. (WidgetRef is however unstable, so I’m not 100% sure that this blanket implementation is the right approach yet).

After writing the above it looks like Byron commented much the same about the function on the linked commit.

There’s some related discussions to this at:

Just to state up front, I’m not actually proposing something here. This was primarily me trying to figure out why Ratatui is the way it is, and what I like and dislike about Ratatui’s design.

Continuing from my thoughts in feat: impl WidgetRef for Fn(Rect, &mut Buffer) by joshka · Pull Request #1154 · ratatui-org/ratatui · GitHub I ran an experiment to see how hard it would be to remove the Widget traits from Ratatui. It was surprisingly easy, in the sense that I found no place where I, personally, thought the use of a trait brought notable benefits to Ratatui code or examples. For the most part, I thought removing use of the traits made code simpler, easier to undersand, more straightforward, etc. I like it that way. :wink:

I think the benefit of the traits boil down to this:

  1. They name a thing called a “Widget” that can be talked about in documentation. It is nice to have a name for “a thing that renders”.
  2. They enforce a consistency in calling convention. Every “render” has the same signature (well, Ratatui has two and a lot of magic going on, but…). A big bonus of implementing a trait like Widget is not having to write a doc comment on your type’s new “pub” API.
  3. They make it possible to say frame.render(thing, area) rather than thing.render(area, frame.buffer_mut()), which is slightly shorter (though I argue it also obscures what is actually going on).
  4. They make it possible to return impl Widget + '_ from functions, and possibly in the future support boxed dyn Widget. This is less flexible than returning or boxing Fn traits, but is less wordy and cumbersome.

I think the way Widget has evolved in tui->Ratatui has some unfortunate drawbacks:

  1. The self vs. &self vs. &mut self quagmire is confusing. Some “widgets” benefit from one or the other calling convention. Trait based meta-programming tricks can paper over some of these cuts, but this also makes Ratatui code more confusing.
  2. The existence of StatefulWidget suggests that there are at least two useful ways to invoke a “renderable” operation. Have they all been discovered? Why force everything through a restricted calling convention? What if a widget function could most naturally be expressed by taking two different mutable state references? Etc…
  3. Widgets like Clear are, I think, more clearly expressed as functions, or methods on Buffer, or some such.

Ratatui could deprecate Widget today and move toward “render methods by convention” on renderable objects. The Widget trait could live on forever for legacy applications, but be entirely unused by Ratatui itself. Based on my experiments, moving away from Widget is pretty trivial for application code that wishes to do so. It is entirely possible to avoid Widget in your own code, and use it only if re-using code from another crate.

Moving Ratatui off Widget is in GitHub - matta/ratatui at no-widget-trait-squashed, where I do it in one commit. I do it in ~40 smaller commits here: GitHub - matta/ratatui at no-widget-trait.

Again, this isn’t a call to action or anything like that. If Ratatui 2.0 ever happens, maybe revisiting these ideas would be worth it.

1 Like