Trying to wrap my head around TUI design

So I'm using Rust for a few months now and I still feel I think too much in an object oriented way when designing code.

One thing that I'm trying to do is write a small TUI app with ratatui, where I use an App "object" which holds all kind of managers (tab manager, status bar manager, etc) within rc/refcell. I use the "app", which is an rc/refcell as well, and pass that along rendering and event-key processing functions, so those functions can handle things like updating the status bar, showing or hiding popup screens and such. But very soon the borrow checker knocks on my door and demanding me to explain why I want to borrow() stuff when 3 layers above you already borrow_mut()ted it..

So I think my way of thinking is wrong and looking at some example/showcase ratatui code, I see there is a lot of flat architecture where there is a single event loop, a single renderer that deals with ALL the cases. For instance, when pressing F1 when in certain mode, it does A, otherwise it does B.. I personally find this very confusing as you throw everything into a single pile that gets harder to grasp the more things you want your app to do. But, it keeps the borrow checker happy.

I'm sure there is some kind of middle ground somewhere to be found where i can separate logic for parts of the app (the help screen which I can access with F1, has its own event handler that deals with scrolling, searching and closing the help screen etc), while still keeping the borrow checker happy.. But i'm not sure how exactly.

1 Like

Welp, that right there's your first problem. You are using shared ownership (Rc) and interior mutability RefCell) when neither are required for a user interface. Probably because your object hierarchy or abstract interface has some kind of circular dependency or non-DAG structure [1].

Object oriented design is perfectly fine in Rust, but that has to be contrasted with "bad OOP" that doesn't really work in any language; You can almost make a messy object hierarchy work in Java or C++, but Rust won't stand for it. And you shouldn't be programming like that in Java or C++ anyway.

Too predictable.

Yeah, that's the way things should be. KISS. You want one piece of code to be responsible for handling F1, not N pieces of code for N different modes.

This is of course an over generalization, since you probably want to break up the "single event handler" into several subroutines for sanity and maintainability, but the point is that you really don't want to spread everything out by adding false abstractions that provide no value and make interfacing worse.

I am honestly not sure what you are getting at here. Separate the logic between ... what and what? You mention F1 again, but surely you don't want to have a "help screen handler" that checks for F1 and then switches into some kind of inner modal loop? What you want is the key handler to open a modal dialog. And modal dialogs just do the right thing because the key handler knows how to deal with modal interactions. Because that's how you need to design it.

ratatui is a basic no-frills immediate mode interface. It doesn't even provide an event loop! It's up to you to write one. And it's up to you to poll events in your event loop, and to route them correctly. That might just mean the event handler needs to be aware of things like "modal dialogs" for a help screen. But that's ok, because your event loop can be aware of the entire state of your application. And modal dialogs can be abstracted behind a method so that you don't end up with a bunch of inline conditions and so on.

If you have borrow checker problems, it's almost always going to be caused by improperly handling data flow in the application. Why are you taking an exclusive borrow at some point and then trying to gain access to it again further in the call stack? That says to me that your abstractions are leaking implementation details. You really don't need multiple functions trying to touch the same data through the wrong interface [2].


  1. Unfortunately, DAGs are not always the easiest thing to work with, either. Pure trees, opposed to graph structures, require much less ceremony to use. ↩︎

  2. But you absolutely can and should be sharing data with borrows. Passing loans to subroutines is effortless and doesn't cause errors in the borrow checker unless you are violating the shared XOR mutable principle. Which can be avoided in extreme cases by, for instance, looping twice (shared then mutable, or vice versa) instead of once with both shared and mutable access. ↩︎

1 Like

Thanks for your reply. I'm new to rust and i'm still trying to grasp the language.

Welp, that right there's your first problem. You are using shared ownership (Rc) and interior mutability RefCell) when neither are required for a user interface.

This is what I indeed suspect. rc/refcell doesn't sound quite right as a solution to begin with, because as far as I can tell it sidesteps the the whole borrow checker process and place it runtime. And now, instead of the compiler telling me things are not correct, I get panics that tell me i'm not doing things correctly.

Yeah, that's the way things should be. KISS. You want one piece of code to be responsible for handling F1, not N pieces of code for N different modes.

I think this depends on what you consider what is responsible for what. You can argue that an event handling system should handle all events for all scenario's. But imho you can also argue that you can have a single system (a help interface that allows you to search in the help, scroll, and even render the help screen itself) is responsible for all things help, including rendering.

I'm suggesting that when you have N different modes all handling keys differently, storing this in a single event handler gets messy really quick. When you can capture the event handling in N functions that all does 1 thing, you can scale in complexity.

But as you said, there can be other solutions, like having a "application mode" and call the event handler function for each of these application modes, which would simply things and keep all the different handlers separate, but this seems to me still having a single event handler to rule them all.

Because I'm seeing many tui applications using a single handler (albeit very complex), I assume i'm wrong in my thinking.

I am honestly not sure what you are getting at here. Separate the logic between ... what and what?

To give an example on what i'm trying to achieve:

i have a single tui app that has all kind of functionality in screens, tabs, input boxes, etc. I have an initial setup where I use a single event handler for all functionality (see this commit).

What i'm trying to do is have a more generic object manager that will render all visible widgets and calls the correct event handler (so, when the help widget is active, it will render and handle its keys, when a bookmark widget is active, it will render and handle its keys etc).

And it's up to you to poll events in your event loop, and to route them correctly. That might just mean the event handler needs to be aware of things like "modal dialogs" for a help screen. But that's ok, because your event loop can be aware of the entire state of your application.

I will try to see if I can make that work. I'm not entirely convinced this would be the correct way to deal with things, because to me it means i will be leaking all kind of information to the main application which I don't want. For instance, now the main loop would need to know about a state where I want to rename a label in my bookmark menu. I would be more comfortable to know that this kind of functionality and handling would be completely isolated in the bookmark handler.

Why are you taking an exclusive borrow at some point and then trying to gain access to it again further in the call stack? That says to me that your abstractions are leaking implementation details.

I agree that the current design is wrong. And even though it has proven itself in other projects for me that are non-rust, I assume this design for rust is not the correct one. For me, this all is also a process in trying to understand rust and trying to get along with the safety mechanics of it.

You can't sidestep the borrow checker. The fact that borrow-checking occurs at runtime in RefCell is proof. There are rare cases where RefCell can get you out of a hard position when you are unable to prove at compile time that there are no shared XOR mutable violations, but it's not like switching to RefCell is supposed to somehow allow you to have both shared and mutable access at the same time.

This seems like the "abstraction/encapsulation" instinct overpowering the need for simple, single-responsibility interfaces. "A single system responsible for all things help" is clearly not following the single-responsibility principle. It's a multi-responsibility encapsulation.

And I am not suggesting one mega function to handle all input events. I explicitly called that out by commenting that you probably want subroutines. "Single event handler" just means there is one top-level place where all events are processed. Which isn't anything special, it's just the normal thing that you do when you have to consume some events and route them to some widget. Don't make every widget poll events and filter the ones they care about.

If it helps, think in terms of pushing, not pulling. The top-level event handler routes by pushing the events to widgets. If the application data is structured similarly to the UI, then you'll have a pretty clear routing strategy: "iterate" through the top-level widgets ... maybe they are tabs in a tabbed interface. And each tab has its own children widgets that recursively passes the event if it doesn't want to handle it, etc. Until one handles it and the recursion stops/the event response bubbles back up.

Rendering works the same way. The same data model and the same kind of recursion.

You're right that these are the same. Just in one case you are breaking up a mega function into subroutines. And that's precisely what you need to do to avoid the "one X to rule them all" syndrome.

Why does it need to be generic? If I had to make an assumption (which, I guess I do, since you haven't offered any reasoning) it's because you want the flexibility to change your mind without changing a lot of code. And from my perspective, YAGNI. Rust is very well suited to large-scale refactoring. Don't be afraid to change things when change is needed. You are not going to get your interfaces right the first time. They will have to evolve with your understanding of the application you are building and the problems you are solving.

Certainly not! If you are leaking implementation details, you are designing your interfaces poorly.

The demo example from ratatui provides a good blueprint for how to do this: ratatui/examples/demo/crossterm.rs at main · ratatui-org/ratatui (github.com) This is its "one event handler to rule them all". It's 25 lines! 25! Twenty-five! It accomplishes this because it routes everything through a simple interface. App has methods on_left, on_right, on_up, and on_down for handling arrow keys, and on_key for handling character keys. Similarly, there's just one function that renders, and it's just called at the top of the loop. Feel free to check the implementation of App for those event handlers, but it's what I describe above. Recursive routing.

App isn't overly generic because it knows exactly what kind of app it is. It knows it has tabs. It knows that it has charts and progress bars. It doesn't need to use dynamic dispatch or encapsulate everything or add a bunch of false abstractions. Hardcoding is exactly good enough for the app. KISS. I can't really describe this any other way than doing exactly what is needed.

Why? Isn't that the responsibility of the label widget? Who's to say you need to inline anything about your bookmark menu into the main loop? This confusion is a pretty common theme in this thread.

You probably have the right idea but don't know how to express it. (And I am probably bad at communication, so that doesn't help.) Bookmarks can (and should) encapsulate functionality that is specific to bookmarks. But the main loop has to be aware that there is a bookmark thing available somewhere. This is why the demo example has state for a progress bar and a chart and stuff. Even if these aren't always displayed all the time, you do know that you need bookmarks. So, make that explicit in the application's data model.

2 Likes

Thanks for you replies. I find it frustrating not to be able to explain my issues correctly, but this might also be the issue itself: I know things I do are wrong and if I was able to explain what's wrong, I would have an easier time to figure out how to solve it myself.

You can't sidestep the borrow checker. The fact that borrow-checking occurs at runtime in RefCell is proof. There are rare cases where RefCell can get you out of a hard position when you are unable to prove at compile time that there are no shared XOR mutable violations, but it's not like switching to RefCell is supposed to somehow allow you to have both shared and mutable access at the same time.

Agreed. I think this makes sense.

If it helps, think in terms of pushing, not pulling. The top-level event handler routes by pushing the events to widgets. If the application data is structured similarly to the UI, then you'll have a pretty clear routing strategy: "iterate" through the top-level widgets ... maybe they are tabs in a tabbed interface. And each tab has its own children widgets that recursively passes the event if it doesn't want to handle it, etc. Until one handles it and the recursion stops/the event response bubbles back up.

This is what I want to do: i know which widget is currently active so I could pass handling to it. That part works fine. The main problem is that we are (deep) inside a widget, and there is no way to communicate with other widgets (for instance, setting a status line in the statusbar widget). This is by design flaw I'm trying to get right.

You're right that these are the same. Just in one case you are breaking up a mega function into subroutines. And that's precisely what you need to do to avoid the "one X to rule them all" syndrome.

Why does it need to be generic? If I had to make an assumption (which, I guess I do, since you haven't offered any reasoning) it's because you want the flexibility to change your mind without changing a lot of code.

App isn't overly generic because it knows exactly what kind of app it is. It knows it has tabs. It knows that it has charts and progress bars. It doesn't need to use dynamic dispatch or encapsulate everything or add a bunch of false abstractions. Hardcoding is exactly good enough for the app. KISS. I can't really describe this any other way than doing exactly what is needed.

I'm afraid this might be my way of thinking I build up over the years. It's what I call "trying not to paint yourself into a corner". For instance, when I talk about a bookmark screen, I don't see why it should stop there and why i cannot reuse this for a help-screen, history-screen other screens, so I end up quickly with some kind of screen handling system. I think i'm just used to trying to abstract things away so we can end up with basically everything you want to throw at it.

Given the project i'm doing (a textual browser), there is a limited number of widgets I have, and my application struct can hold these without problems: bookmarks, history, tabs with content, etc, so we don't need this system to be capable to drive a cappuccino machine. But as said, I think this is my way of thought.

The demo example from ratatui provides a good blueprint for how to do this: ratatui/examples/demo/crossterm.rs at main · ratatui-org/ratatui (github.com) This is its "one event handler to rule them all". It's 25 lines! 25! Twenty-five! It accomplishes this because it routes everything through a simple interface. App has methods on_left, on_right, on_up, and on_down for handling arrow keys, and on_key for handling character keys. Similarly, there's just one function that renders, and it's just called at the top of the loop. Feel free to check the implementation of App for those event handlers, but it's what I describe above. Recursive routing.

Looking at demo and demo2, I indeed see one event handler, but there isn't much to handle to begin with. For instance, in the demo2, what would happen when we get to the "email" tab, and we want to use up/down to scroll through emails? The main handler knows we are in the email tab, and can act accordingly:

pub fn handle_events(&self) {
  if self.state == EmailTabState {
     self.handle_email_tab_events();
  }
}

I think this is what you are are suggesting I think? (please correct me if i'm wrong)

To continue this: if I have a handle_email_tab_events() in my app struct, that handles up/down keys for selecting the current email, I can also do this quite easily:

fn handle_email_tab_events(&mut self) {
  key = read_event_key();
  if key == key.down {
     self.email_tab.selected_index += 1;
  }
  if key == key.up {
    self.email_tab.selected_index -= 1;
  }
 
  self.status_bar.set_status("Current selected email: {}", self.email_tab.selected_index);
}

which in a sense is ok: i can call other widgets and do things (or even i should relay that through a more simplistic self.set_status(), which in turn calls the status_bar. I think this might work and I assume this is your solution?

The problem i'm having with this is that we the widget functionality is now located outside the widget.

Why? Isn't that the responsibility of the label widget? Who's to say you need to inline anything about your bookmark menu into the main loop? This confusion is a pretty common theme in this thread.

My reasoning is that when you have a application state we have something like this:

enum AppState {
   Main,
   BookmarkList,
   BookmarkListAddBookmarkInputbox,
   BookmarkListRenameBookmarkInputbox,
   BookmarkListDeleteBookmarkConfirmationBox,
}

I'm sorry if I confuse you.. I'm trying to get my thoughts written down but it's not always easy since i'm having trouble to express the problem in the first place.

You probably have the right idea but don't know how to express it. (And I am probably bad at communication, so that doesn't help.) Bookmarks can (and should) encapsulate functionality that is specific to bookmarks.

Agreed

But the main loop has to be aware that there is a bookmark thing available somewhere. This is why the demo example has state for a progress bar and a chart and stuff. Even if these aren't always displayed all the time, you do know that you need bookmarks. So, make that explicit in the application's data model.

I agree with this too. I'm ok with the fact that my app knows about a bookmark list, a history list, or any kind of other thing. But my reasoning was to make this generic enough so I can keep the application clean. The app knows that pressing F8 means the bookmark widget becomes visible and handles events from that point on, and that ESC in that widget means the bookmark widget closes again. But the application should not know anything about what we can do in the bookmark widget. This is what I'm trying to separate (and failing)

Widgets shouldn't be communicating to other widgets. They should be writing to application data/state storage (the “model” in “model-view-controller” etc.) which the widgets read on the next redraw. (That storage might well need a RefCell in it, but it won't have conflicts as you mentioned at the beginning, since it's only ever used momentarily.)

2 Likes

Pass the data that you explicitly want the widget to have access to. If it needs to update the state for a progress bar and the only state needed is the percentage, pass progress: &mut f32 as a function argument. If you need more state than just the percentage, wrap it into a struct and pass it as &mut ProgressBarData. In any case, the data itself will live somewhere higher in the data model, and every piece that is needed will have to be passed down the stack in function arguments. This implies that a flat hierarchy can be beneficial when widgets need to update state for other widgets. It would be much better to avoid the coupling entirely. But sometimes you just have to put a communication channel between things.

I have seen this kind of thing often, and it has never sat right with me, personally. Abstractions need to serve a purpose. They need to hold up their own weight and not get in the way of getting things done. This is what I mean by "false abstractions". It's just a thing that exists to "feel good". It doesn't make the code easier to read or work with, in fact it makes it much worse. If you want to add an about/credits screen, you are stuck implementing it in terms of what the "screen" interface thinks it needs. And if it's the wrong interface, you're out of luck. Worse still, if you are a newcomer to the project, you first have to learn what the "screen" abstraction is, what it does, and why it exists in the first place. And that's completely distracting for someone who doesn't care about any of that and just wants to make a credits screen so they can credit contributors. The task has nothing to do with the abstraction.

How about a good example of an abstraction for completeness? The standard String is excellent. It provides exactly what is needed for manipulating and working with text. You don't have to extend String just because you want to write a paragraph in a different language. This might seem like a bad counterexample because it's so absurd. But I argue that an "abstract base class" for screens is just as absurd. That's "bad OOP".

I take this sample code to be the "top-level event handler". And if that's the case, then no, this is absolutely not what I am proposing. Handling arrow keys in the email tab should be done by the email tab when the recursive calls reach the email tab. Pseudo code below:

impl App {
    fn handle_events(&mut self) {
        loop {
            let event = poll_events();
            match event {
                Event::Arrow(key) => self.handle_arrow(key),
                _ => todo!(), 
           }
        }
    }

    fn handle_arrow(&mut self, key: ArrowKey) -> bool {
        let tab = &mut self.tabs[self.selected_tab];
        tab.handle_arrow(key)
    }
}

impl Tab for EmailTab {
    fn handle_arrow(&mut self, key: ArrowKey) -> bool {
        match key {
            ArrowKey::Up => self.arrow_up(),
            ArrowKey::Down => self.arrow_down(),
            _ => false,
        }
    }

    fn arrow_up(&mut self) -> bool {
        todo!()
    }

    fn arrow_down(&mut self) -> bool {
        todo!()
    }
}

... and so on

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.