Multiple screens in ratatui

This is a repost from https://www.reddit.com/r/rust/comments/1ecjcxp/multiple_screens_in_ratatui/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

I recently started creating TUIs in Rust and now i am looking for a way to have many “screens” inside my ratatui app.

Context: I am creating a rather simple TUI app where users can log in to their account (a simple reddit-like backend made by me) and then browse through the feeds.

Now, i want to isolate the login code and the feeds code into two separate screens (so that i can test them as well
Currently, what i am doing is when the user clicks the enter key, i am creating a new tokio thread which does the authentication.

The question is, what is the best way to manage such screens in ratatui app and also with which i can move to.

And while we are at it, how can i listen if the authentication is successful and then move to the feeds screen if it is successful?

Or if there is some Rust TUI project that does the similar stuff, i would appreciate if you dropped the name :slight_smile:

I am cool with sharing more details of archiecture if that will help to solve my issue

1 Like

Yes! This is a fairly standard thing to do with ratatui.

Generally, you have a global state that stores the state of you application, and in that, you might have a boolean that stores of the user is logged in or not. When the boolean is false, you would render your login page, and while it is true, you would render the live feed page instead. You can easily control what screen renders with standard control flow.

I will note that it is not recommended to spread rendering across tokio tasks or threads - the stdio is a global lock, so it’s recommended to handle your rendering on a single thread.

1 Like

No no i didnt mean that i was rendering inside of the tokio thread, i meant that the authentication process (http request, parsing response etc) will be handled in the thread and the response will be sent back to the event loop (with mpsc or oneshot channels?)

Ah i see… so what i should is create a global state, and then when the user enters their creds, i will run the authenticator thread, mutate the global state (?), and then on next rerender, the mutated state would be picked up and then the feeds screen will be rendered?

1 Like

Thanks for asking the question, and welcome to the forums!

This is kinda a funny thing related to why I’m a Ratatui maintainer. Back in April last year I was learning rust by making a rust based Mastodon tui called tooters. One of the big things that I struggled with working out was exactly this problem - showing an auth screen, dealing with things being async, and then getting into the application proper once the auth process was completed. Mostly my self-induced complications were due to bringing an OOP mindset to rust (i.e. screens are all the same, why can’t I just inherit implementation details …)

I ended up making a few (unrelated) PRs to Ratatui and then doing a whole bunch more.

In tooters I solved the screen problem using a State enum and a a variable per state on my root widget. This is just one way, I think I’d probably solve it a bit differently if redoing it today (it’s on my someday list), as I was just getting to understand ownership and borrowing while trying to wrangle tokio and various async ideas and learning rust.

An updated version would move the auth state data out of the screen specific struct and into another struct that can be removed from the screen when the auth screen is no longer visible. I’d still implement this as an enum with all the screens included and have logic to choose which screen is rendered and when to change between them. I’d mostly avoid looking for ways to do virtual dispatch to solve this problem, and be fine with this being a match statement that chooses which screen to render.

The easiest way to do this is really something like:

enum Screen {
    ScreenA(ScreenA),
    ScreenB(ScreenB),
}

struct ScreenA {
    data_for_screen_a: ...
}

struct ScreenB {
    data_for_screen_b: ...
}

And your rendering looks a bit like:

impl Widget for &Screen {
    fn render(self, area: Rect, buf &mut Buffer) {

        // render anything common to all screens

        match self {
            Self::ScreenA(screen_a) => screen_a.render(area, buf);
            Self::ScreenB(screen_b) => screen_b.render(area, buf);
        }
    }
}

impl Widget for &ScreenA {
    ...
}

impl Widget for &ScreenB {
    ...
}

You can also avoid widgets altogether and just pass the Frame that you get from Terminal.draw() around the methods. Some people take a more OOP approach and some are more imperative. YMMV.

And then your app looks something like:

struct App {
    screen: Screen,
}

impl App {
    fn run(&self) {
        // main loop etc.
        // contains logic to choose which screen is visible,
        // move data in / out of the screen as necessary, etc.
        ...
        terminal.draw(|frame| frame.render_widget(self.screen))?;
    }
}

Sometimes you might find that it makes sense to store some common screen data on Screen. Don’t be afraid to refactor to a struct with ScreenScreenKind or something similar.

You can see some examples of this idea in demo2 in the Ratatui repo (which uses tabs not screens, but the idea is similar), and the crates-tui app, which calls this idea a Mode.

I’d also encourage you to look at a few different Ratatui apps and find patterns you like and steal the ideas. Each is a little different as Ratatui being a library rather than a framework (you call us, we don’t call you). I know that my approach in tooters led to some ideas in Yazi, and some of those ideas in Yazi have filtered back into the way I think about things in Ratatui apps.

1 Like

I think the Component architecture might be useful in this case Component Architecture | Ratatui for your application each screen could be a Component

2 Likes

One approach to handling the async part of this is to have your async flow encapsulate in the screen itself. It spawns a background task to handle the processing when needed (i.e. your auth requests), which updates fields that are wrapped with Arc<Mutex> or Arc<RwLock>, which are then drawn by other calls to the app from the main thread. I recently wrote a PR with an example that does a simple version of this at docs(examples): add async example by joshka · Pull Request #1248 · ratatui-org/ratatui · GitHub. There the task is a one shot call to a GitHub API to get a list of PRs. There’s some comments about how to extend this into solving this problem as well.

There’s a few different ways to do this part, which rephrased is “how do you communicate state back from a sub component in an app?”

  1. Store the auth state in async safe fields (Arc<Mutex> etc.) which you explicitly query each time through the app’s loop.
  2. Have the app call some sort of update method that does some processing and returns a result for the app to use. This could be an either an auth state enum or a command / action that indicates what to do next (which aligns with the ideas of Component Architecture | Ratatui mentioned above).
  3. Share a Sender/Receiver pair between the app and auth screen, and communicate state changes / commands using that as part of your main loop

These options are also covered in the tokio tutorials: Shared state | Tokio - An asynchronous Rust runtime.

1 Like

Oh i see… thats interesting!

That sounds good! I would go ahead with this method, thanks a bunch for the example as well!

I digged around how yazi does the stuff - they kind of have all the data at a single place inside of the context (correct me if i am wrong). I am not really a fan of this honestly, i want to have the screens / components be aware of their own data (so that i can keep all the stuff related to a component in its impl rather than having it in a single place where everything is handled and dispatched)

And that’s how I approached my application design, but I quickly realized that “component A needs to know if component B has a ‘show_icons’ state, and component C will render a short list that A owns” and that to untangle it would take restructuring my app state because I didn’t build it right in the first place.

That’s not to say don’t try, please do! But that was my experience, and I hope it helps you avoid the pain I had.

1 Like

It really comes down to the factors that Nickiel mentions. Is the (development) cost of sharing more difficult than thinking in terms of a single big bag of state. You might find some things are worth doing one way and others other ways. There is just one terminal and one app, so oneness is a reasonable concept to keep in mind for creating certain types of apps. I personally would break it up more too. Also bear in mind if your doing async then you should be mindful of not blocking the UI thread from reading the state it needs to render. And also avoiding deadlocks between multiple items that need to be read.

1 Like

@Nickel @joshka Your points do make sense, thanks i will be going ahead with that.

And also i wanted to ask a stupid question but how to manage multiple messages? I currently have around 20+ messages in the entire app. Should i just keep them inside of the Enum or do some other stuff to make it better? Like create a trait that implements std::any::Any and Send and then implement he trait for all the message struct? and then in the update function (like the elm arch), downcast to concrete type and then handle message.

If you are talking about asynchronous design, what I do is whenever two portions of the application need to communicate over a channel, is I make an Enum with all the different types of messages they need to exchange. You can make an Enum variant that contains a type, so ApplicationEvent::Socket message(tungstenite::Message) can be sent through that channel. (Every MPSC channel I’ve used supports this, including Tokio OneShots)

+1 to what nick is saying. Do the easy / simple thing until that gets painful (your call on what your pain tolerance in development is). 20 is probably a number that’s in the realm of splitting things up, but it could be easier not to split it if it’s working for you.


Something interesting that I read the other day, which is a little related to this is about how Iced commands work. Iced v0.12 Tutorial - Asynchronous actions with Commands

There, it uses messages to indicate something happened, and commands to indicate do something. The messages are defined as an enum, but the commands are a struct that carries some behavior, and are defined in a way that components can expose their supported commands to the calling code

iced/runtime/src/command.rs at a05b8044a9a82c1802d4d97f1723e24b9d9dad9c · n1ght-hunter/iced · GitHub has a pretty good list of commands (e.g. look at the way the text box commands are implemented)

That might be more interesting from a library maintainer perspective than for an app creator, but there’s some crossover here in the sense that screens are just a large complex collection of widgets with a layout and behavior. If you’re new to rust, I’d recommend steering clear of these ideas until you have a bit of experience. Trying to learn too many concepts at once with rust is something that many people find turns them off the language, and this is an area with a huge complexity / concept explosion.