How do I represent application state ergonomically?

I have a nacent todo list app that is using Ratatui, but have run in to more of a Rust design question than a Ratatui one. I’m asking here because it has to do with how to store application state in an ergonomic way, which is a problem Ratatui apps need to solve in general.

My application state is stored in a struct:

pub(crate) struct State {
    pub list: TodoList,
    pub current_screen: Screen,
}

With the Screen enum containing per-screen state:

pub(crate) enum Screen {
    Main,
    Edit(EditState),
}

And in this case, EditState holds the stuff needed only when editing a todo item. Right now the only editable thing is the todo’s description, so this is straightforward goop that Ratatui wants to track across renderings:

pub(crate) struct EditState {
    pub index: usize,
    pub text_state: TextState<'static>,
}

Now for code that handles key presses:

pub(crate) fn update(
    state: &mut ui_state::State,
    key_event: crossterm::event::KeyEvent,
) -> Disposition {
    match &mut state.current_screen {
        ui_state::Screen::Main => {
            let key_combination: crokey::KeyCombination = key_event.into();
            update_main_screen(key_combination, state)
        }
        ui_state::Screen::Edit(edit_state) => {
            let text_state = &mut edit_state.text_state;
            assert!(text_state.is_focused());
            text_state.handle_key_event(key_event);
            match text_state.status() {
                tui_prompts::Status::Pending => Disposition::Continue,
                tui_prompts::Status::Aborted => {
                    // TODO: When aborting a new item, delete it.
                    state.current_screen = ui_state::Screen::Main;
                    Disposition::Continue
                }
                tui_prompts::Status::Done => {
                    let task = &mut state.list.tasks.tasks[edit_state.index];
                    task.title = text_state.value().into();
                    state.current_screen = ui_state::Screen::Main;
                    Disposition::Continue
                }
            }
        }
    }
}

This works, but I’d like to break this function up and handle the Edit screen in its own function. Using Rust Analyzer’s function extraction feature I get:

pub(crate) fn update(
    state: &mut ui_state::State,
    key_event: crossterm::event::KeyEvent,
) -> Disposition {
    match &mut state.current_screen {
        ui_state::Screen::Main => {
            let key_combination: crokey::KeyCombination = key_event.into();
            update_main_screen(key_combination, state)
        }
        ui_state::Screen::Edit(edit_state) => update_edit_screen(edit_state, key_event, state),
    }
}

Nice. But it doesn’t compile:

 1  error[E0499]: cannot borrow `*state` as mutable more than once at a time
   --> src/update.rs:29:89
    |
 24 |     match &mut state.current_screen {
    |           ------------------------- first mutable borrow occurs here
 ...
 29 |         ui_state::Screen::Edit(edit_state) => update_edit_screen(edit_state, key_event, state),
    |                                               ------------------                        ^^^^^ second mutable borrow occurs here
    |                                               |
    |                                               first borrow later used by call

 For more information about this error, try `rustc --explain E0499`.
 error: could not compile `sift` (bin "sift") due to 1 previous error

I understand the error. I’ve matched on &mut state.current_screen, kept that borrow active (for lack of a better word) with the edit_state: &mut EditState reference, and then I pass both edit_state and state to a function. This makes sense even as a code smell: I’ve extracted a reference to something within state and I am passing both that and state to a function.

Before I extracted this new, broken, function, the compiler could see more of the code and, I assume, figure out that the lifetimes worked out. So, I’m left wondering: if my design works out okay when events are handled within one function, but breaks down when I refactor into smaller, “simpler”, function calls, am I missing a way to design this that works well regardless?

The Question

I’m interested in general advice for structuring my application state to avoid this kind of thing in most cases. Are there well known patterns for structuring state in Ratatui apps that allow the event handling “mutation logic” to be easily refactored across functions?

1 Like

I’m not 100% sure this can hold up long-term, but you might consider decoupling the enum from the EditState struct, by having sth like:

enum Screen {
    Main,
    Edit,
}

struct State {
    current_screen: Screen,
    edit_state: Option<EditState>
}

As much as I like the borrow checker, sometimes it’s not worth fighting it teeth and nails just to have your logic perfectly represented by types.

1 Like

Have you ever used ref mut inside the enum unpacking? The following compiles for me:

#[derive(Debug, Default)]
pub(crate) struct State {
    pub current_screen: Screen,
}

#[derive(Debug, Default)]
pub struct EditState {
    pub index: usize,
}

#[derive(Debug, Default)]
pub enum Screen {
    #[default]
    Main,
    Edit(EditState),
}

fn update_edit_screen(edit_state: &mut EditState) {
    edit_state.index += 1;
}

fn update(state: &mut State) {
    match state.current_screen {
        Screen::Main => todo!("Not implemented"),
        Screen::Edit(ref mut edit_state) => update_edit_screen(edit_state),
    }
}

fn main() {
    let mut state = State::default();
    state.current_screen = Screen::Edit(EditState::default());
    
    update(&mut state);
    
    dbg!(state);
}
1 Like

I’d generally write functions with a clear receiver as a method instead of a function, and I’d name State App, leading to something like the following:

#[derive(Debug, Default)]
pub struct App {
    pub current_screen: Screen,
}

#[derive(Debug, Default)]
pub enum Screen {
    #[default]
    Main,
    Edit(EditScreen),
}

#[derive(Debug, Default)]
pub struct EditScreen {
    pub index: usize,
}

// some standins for the actual event logic
enum Event {
    KeepEditing,
    CancelEditing,
}

impl App {
    fn update(&mut self, event: Event) {
        self.current_screen.update(event);
    }
}

impl Screen {
    fn update(&mut self, event: Event) {
        match self {
            Screen::Main => todo!("Not implemented"),
            Screen::Edit(ref mut edit_state) => {
                edit_state.update(event, self)
            }
        }
    }
}

impl EditScreen {
    fn update(&mut self, event: Event, screen: &mut Screen) {
        self.index += 1;
        match event {
            Event::KeepEditing => {},
            Event::CancelEditing => {
                *screen = Screen::Main;
            }
        }
    }
}


fn main() {
    let mut app = App::default();
    app.current_screen = Screen::Edit(EditScreen::default());
    app.update(Event::KeepEditing);
    dbg!(&app);
    app.update(Event::CancelEditing);
    dbg!(&app);
}

But this runs into the same issue:

   Compiling playground v0.0.1 (/playground)
error[E0499]: cannot borrow `*self` as mutable more than once at a time
  --> src/main.rs:35:42
   |
34 |             Screen::Edit(ref mut edit_state) => {
   |                          ------------------ first mutable borrow occurs here
35 |                 edit_state.update(event, self)
   |                            ------        ^^^^ second mutable borrow occurs here
   |                            |
   |                            first borrow later used by call

For more information about this error, try `rustc --explain E0499`.

The issue simplified is that you’re trying to borrow the screen / state mutably twice in the child function (EditScreen::update) because you want to change something on the parent (Screen / App). A question that clarifies why this is a problem is to ask what should the following code do in EditScreen::update:

*screen = Screen::Main;
self.index = 1;

There are several approaches to fixing this (here are a few - there might be more):

  1. just pass the main object to the child function (this is annoying)
  2. return results from the child that indicate what the parent should do (e.g. Action::ChangeScreenTo(…)
  3. decouple by sending messages over a channel (pass the sender to the child via a method parameter or a constructor parameter, and call sender.send(Action::ChangeScreenTo(...)) (or Event::EditingFinished if you prefer this to be raised as an event that the app / screen can handle.

I like the last of the three:

impl Screen {
    fn update(&mut self, event: Event) {
        let (tx, rx) = mpsc::channel();
        match self {
            Screen::Main => todo!("Not implemented"),
            Screen::Edit(ref mut edit_state) => {
                edit_state.update(event, tx)
            }
        }
        if let Ok(Action::GoMain) = rx.try_recv() {
            *self = Screen::Main;
        }
    }
}

enum Action {
    GoMain,
}

impl EditScreen {
    fn update(&mut self, event: Event, tx: mpsc::Sender<Action>) {
        self.index += 1;
        match event {
            Event::KeepEditing => {},
            Event::CancelEditing => {
                let _ = tx.send(Action::GoMain);
            }
        }
    }
}

Incidentally, ControlFlow seems simlar to your Disposition type.

One of the best things I like about rust is the idea of making invalid states non-representable. Splitting the enum and the data about the enum often breaks this idea a bit. That said, there are some reasons you might need to do this. One of those is maintaining state between screens instead of losing it when the enum changes to a different value. You can still have both, but you often need to have an enum without state that you use for selection and not state storage.

You can work around that to some extent, by doing something like keeping an array of screen values, and just having an index into the current screen. You can also have a second enum parallel to the first that is just the names of the screens (use strum::EnumDiscriminants to make this easy. The Demo2 example has a similar approach (it stores a value for each tabs directly in the app, and then uses a Tab enum to choose which to render.

P.S. Thanks for kicking off the help section with a great question!

1 Like

Yes, and I suppose it is a meta issue that occurs over and over in UI programming. How do you encapsulate local state (in a “component” or “window” or similar) and deal with things like “text areas” and “check boxes” but glue it all together into a coherent whole, where clicking on one thing can cause radical changes in almost anything else?

Personally, I’m trying to get the simplest possible design in place, but not too simple. For the most part I’m trying to introduce abstractions, deferred execution, etc., only when really useful. I.e. my starting point isn’t to make the app use Redux or Elm patterns from the get-go, because I sort of wonder if some of those patterns are the way they are for Javascript and/or Browser reasons, and wonder where thing’d end up with a rust-first design exploration.

You state a preference for the channel. Have you tried the return value option and found it lacking? Both options “color” the code in an intrusive way. You’ve either got to return a value that captures “do the thing”, or take and store a channel in some way and send “do the thing” over it. One issue I have with message passing is the whole question of figuring out who sends and handles the messages and when, what to do when a handler sends multiple messages, or forgets to send a message, etc. With a return value that is generally a bit more clear, even if you don’t happen to be familiar with the architecture of the program, so long as you can get by with generating one and only one higher level “do the thing” per keyboard event (which I admit is not very flexible).

(I’ll also point out that it need not be a channel, it could be a Vec that code pushes stuff onto, or even an Option, if the entire UI can get away without being async at all)

1 Like

Yeah, I’ve tried a bunch of approaches in various forms (you can see how my thinking has evolved over time if you look at the history of the Ratatui examples and tutorials, and follow a bunch of the discord threads around async stuff). Using a channel for this is about decoupling the need to defer some action from the need to return something to the caller (and the caller’s caller, etc.). It helps to flatten a tree, and It allows you to defer multiple things if needed, or none if you don’t care to handle an event. In the return value approach, you have to create a composite return value for multiple things, and use Option / ControlFlow / your custom disposition enum to indicate what happens next, and you need to pass that back up. You also have to think in every level about things that happen before and after, where-as the the channel approach allows you to more easily just think about things happening in the order they are added to the channel. It’s easy to do things like handle all the resize /other relevant events that happened in the current 16.7ms frame, and then handle the necessary things that need to be updated as a result rather than interleaving them. It’s also easy to add in threading / async because less components are directly coupled by a parent child (caller / callee) relationship.

std::sync::mpsc isn’t async in the async / await sense. It’s async in the sense that generally sends/receives can be written such that they don’t block the current thread. It’s inherently just a threadsafe wrapper around an array (for bounded synchronous channels that do block) or a linked list (for unbounded asynchronous channels that don’t block), using either as a queue of messages.

Another related thing I’ve been experimenting quite a bit with is extracting out the event loop part of an app, and making that abstract enough that the actual app part is fairly simple and can be fully state tested without having to read/write from stdio.

Perhaps the flexibility of a channel approach could be achieved by instead returning a Vec<MyMessageType>?

I guess I don’t see a huge gap between the two approaches, but returning values is just so bog-standard that it remains pretty appealing to me. Channels resemble side effects, and in my experience side effects are where the complexity and bugs live (in a very broad, general sense).

Can you say more about this one? I don’t understand how returning values, rather than using a queue, would require interleaving with respect to things like window resize events.

Yep. I guess I’m interested in whether baking that in from the start is worth it for simpler TUI programs that don’t need it.

  • What is returned - sure
  • How the returned value is handled / processed / bubbled etc. probably not (or yes it can, but it’s much more code sprinkled throughout instead of a single easy to use channel)

Ever heard of the command query separation principle? It states that every method should either be a command that performs an action, or a query that returns data to the caller, but not both. The methods that handle the events perform actions on the app. They’re expected to have effects. Returning a value would make this both a command and a query (do this thing but also what should I do next). The gap in returning any value vs sending it to some place is there. Channels aren’t a side effect, they’re just an effect. This method does this to the app, and does this as well to the other things in the app. Side-effects refer to things that happen when you’re not expecting them (analogy: querying the balance of a bank account, uh oh, we just charged you $5 for that, and an overdraft fee of $50).

Yeah. When you return a value from an event handler function (fn handle_key_event(event: KeyEvent) -> MyMessage), you have to immediately do something with the return value (either do whatever it says needing doing, or store it somewhere to process when you’re ready to handle it, or bubble it all the way up/ down to where it needs to be handled in the tree). I.e. let’s say your switch to main screen logic happens several layers deep in your widget tree. That message is for the top layer that handles screen, so if there are 2 layers of the widget event handling tree, then you’re passing that value up those two levels. If the message has to effect something up two levels and then down, then you have to write your logic so the order that the events get handled is coupled to the way that the events effect each part of the tree.

Consider an example: a menu bar at the top of a screen and a status bar at the bottom that shows some help text about the selected menu item. Your tree might look like App → Screen → { MenuBar, StatusBar } and the menu bar might want to have a DisplayHelpText(...) action / message that affects the StatusBar (and maybe also affects some state elsewhere that would allow you to hit F1 to get help on the menu item (or something like that). Realistically, you don’t need to worry about displaying the help text for a menu item if the user has pressed the down key twice in succession since the last frame. You only need to display the end result of the two presses. By handling all the external input stuff first, and only when there’s no pending input (or a timeout has expired) handling the effects of the external things, you end up with a system that’s more responsive and doesn’t ignore keystrokes / feel “sticky” when input is coming in faster than the fps of the app.

Your loop can look conceptually something like:

  • handle all the external events until there are none
  • handle all the messages from the channel
  • draw

Having a channel makes that approach pretty easy. You could implement this with a Vec or whatever, but you’re basically just reproducing the queue without the thread safety.

Threads / async are a necessary evil. As soon as you have any sort of processing that you want to take more time than the latency that you want your keystrokes or rendering to see you need to do things with one or the other. Picking an approach that makes that transition easy is where I’d choose to be in general.

The approaches recommended above really come down to finding the right mix concepts found in GUI programming, with the constraints of the rust borrow checker, and the baked in design decisions of Ratatui and working out which of these things work well for TUIs. Viewed a certain way, a performant, non “sticky” TUI has much in common with a GUI.

All that said, there’s more than one way to do things. It’s worth reading a few different projects to understand the approaches they choose. I’d suggest taking a look at a few from the app showcase part of the website or the Awesome Ratatui and see what patterns you can see and what resonate with your views on the sorts of TUIs you write.

No, I hadn’t heard of it. Great food for thought. I’m curious how Bertrand Meyer would have designed a GUI (or TUI).

I’d flip it the other way: all effects are side effects. Even mutation is a side effect. I mean this in the FP sense Side effect (computer science) - Wikipedia. A channel is an example of a non-local side effect. You post something and then something happens somewhere else. The poster of the message doesn’t know or when or where it will be handled. Certainly nothing in the code at the point it is posted makes it clear. But, as soon as this pattern enters the code the burden is now on the programmer to care about the what or where. The programmer must now also understand a larger design pattern or context. That’s what I best like to avoid (at least for as long as I can).

A potential problem with handling input like your outline above is that step 2 (above) may change program state in a way that modifies which keys do what. The program may then behave differently depending on how fast the user types or how slow their computer is, which I think undesirable in general and is arguably a bug.

For user input I think it is almost always better to process of one user event to completion, then look at the next user input event. I’d suggest a modification of your loop above:

wait for key press {
  while user input is pending {
    1. handle one user input event
    2. handle all deferred events/messages generated by step 1
  }
  draw()
}

Or equivalently:

  loop {
    1. wait for key press
    2. handle key press
    3. handle all deferred events/messages generated by step 2
    4. if user input is not pending then draw()
  }

Either way, I think this is orthogonal to whether the UI uses a queue or a return value to get the events/messages back to the main event loop. I still see them as equivalently powerful mechanisms, with different tradeoffs, with return values being conceptually simpler but perhaps causing more boilerplate in the event handling code.

Absolutely agree. I suppose I think it is ideal for even a multi-threaded program to have single-threaded UI. Especially so in a TUI, where even the most complex “rendering” and event processing is likely to be fast.

At the moment, I see this lack of thread safety as a feature, in the sense of Interface segregation principle - Wikipedia, assuming the aim is to keep the UI single threaded and non-async (in the rust sense). I.e. using a thread safe abstraction when it isn’t necessary is unecessarily confusing to the future reader who might assume it was used for the thread safety guarantees it provides. The good thing about the mpsc queues are the separated tx and rx sides, which does adhere nicely to the interface segregation principle.

Yes, great advice. I have found all of your comments very helpful, and appreciate your time. I have a lot of old-school GUI experience (Windows 3.0, early Mac, and even developing GUI frameworks for early embedded “touch screen” devices – pre Palm Pilot, etc.) and have deep respect for the “user interface problem” as a hard problem. Much like build systems and programming languages, I don’t ever think the world will converge on one paradigm.

As a side note, in one recent case I forked GitHub - Yengas/rust-chat-server: Room based Chat Server and TUI implementation in Rust and removed mention of tokio APIs in the UI, for what I thought was a net improvement. E.g. Components are now synchronous. · matta/my-rust-chat-server@08baa92 · GitHub. In that commit, all the channel stuff happens in a central place, where the concurrency is actually necessary, rather than occurring in every component in the TUI.

Event Programming with Agents
Agents (injected callbacks basically)

As with everything - this depends. Choose the approach that minimizes doing unnecessary work, but doesn’t prevent doing necessary work. YMMV on what that means for you.

Time is an interesting thing. There are a lot of abstractions that ignore it, and do so quite fine for 99% of the cases where the abstraction holds. But when things break you get beach balls / non-responsive software because you didn’t take account of a thing that is mostly instant has a failure mode that is > than the latency requirement (sometimes by large amounts - e.g. reading a small file is “instant”, but if that file is on a network drive and I’m tunneling through a 2G mobile connection in the middle of nowhere, then instant becomes infinite)

Neat. I definitely think that async should be handled with care in UIs, and they mostly should be single threaded (if you’re using tokio, use the blocking threadpool). But it’s the non UI work (and any work that takes a while) that should be in the background.

One thing that is a possible interesting thing that would involve multi-threading in Ratatui is to split the part of the rendering chain that draws the buffer and the part that sends the commands to the screen to be drawn over two threads. This would allow the fresh buffer for the next frame to be composed while potentially another CPU core is handling splatting the buffer to the screen. For most apps this is probably unnecessary. But take a look at the performance you get running the colors_rgb example in Ratatui (which changes the colors on every cell of the screen). It maxes out on my laptop to around 20-40 fps depending on which terminal emulator I use. It would be nice to be able to guarantee 60fps in Ratatui regardless of just how much change in between frames. Threads are one possible way of getting there (alongside better choices for some of the algorithms perhaps).

There’s a Chris Biscardi talk I’m watching right now about Bevy which has a great quote that sums up some of the Event vs Return value arguments really well:

Events are what let you have systems that don’t grow into huge unmaintainable monoliths
– Ida Iyes via https://youtu.be/CnoDOc6ML0Y?si=-TD-4MhhcJd9cGTf&t=1994

Using Bevy’s approach to systems could be a good way to handle some of the ideas of app state interacting with rendering pretty nicely. I’ve seen a couple of devs do ratatui rendering to wasm / egui, but not one where they’re using Bevy to run the app loop and handle interaction between parts. This seems like it would a good idea to play with.

Bevy seems really cool, and I especially like the focus Bevy seems to have on programmer ergonomics. I don’t know much about it.

It is interesting to me that Ratatui essentially provides only conveniences for text rendering part of the problem, but leaves the rest to the app.

Maybe many TUI programs are fine without the complexity of a larger framework like Bevy. They get away with hard coded focus management, keyboard dispatch, simplistic state approaches, etc.

I spent a bunch of time this evening looking over the bevy docs. There’s some parts that seem like they don’t easily lend themselves to ratatui and some parts that do:

  • Each event is a Struct (or enum) that carries data for the event
  • apps are structured in systems - methods that have params that pull in their necessary parts - e.g. a system that draws to the terminal might look like:
    fn draw(terminal: ResMut<Terminal>, other_params) {
      terminal.draw(...);
    }
    
    To handle an event, you just define a method that accepts that event and whatever other components / resources it needs and register it as a system.
  • there’s a neat approach to schedules (steps that repeat in order (e.g. {pre,post,}startup, first, {pre,post,}update etc. systems can be attached to run in various steps
  • SubApp for rendering that can copy data out of app and render it while the app is still continuing to work on the next frame in another thread.
  • neat approach to writing the startup logic as plugins that add systems / resources etc. to the app when it’s being configured. A ratatui bevy app might look something like
      App::new()
          .add_plugins((
              MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(frame_rate)),
              RatatuiPlugin,
          ))
          .add_systems(/* your app logic */)
          .run();
    
  • There’s a good state approach (e.g. MainScreen, SomeOtherScreen) and it includes OnEnter/OnExit events.
  • The docs have some stuff which explains things in ways obviously targeted at rust newbies in ways that I think would work well in ratatui too (like highlighting pain points, things about implementation of traits, etc.)

Downsides:

  • There seems to be a lot of stuff we’d throw away to do terminal stuff (most of the layout, style, rendering, 3d, camera, … stuff)
  • adding complexity in order to simplify apps
  • it could be worth rewriting the terminal struct and crossterm backend entirely to fit the bevy rendering ideas better (i.e. basically keep widgets and layout from Ratatui)
  • I think it might be difficult to match the Widget concepts with the systems concept (but if this works, it might be easy to make most widgets work with mouse events etc.)

I think this is something that will someday change (more likely provided by crates on top of Ratatui) I know a few people have tried various versions of things and the ideas bounce around and go into apps and then come out of apps into libs etc. E.g. crates.io: Rust Package Registry is kinda interesting as is GitHub - fiadliel/fiadtui: Simple TUI wrapper for ratatui with tokio and crossterm

An interesting demo: GitHub - joshka/bevy_ratatui

This was somewhat easier to build than other techniques I’ve tried in the past. It’s kinda neat IMO.

1 Like