DEV Community

PraxTube
PraxTube

Posted on

Creating Terminal UI in Rust

Creating CLI tools with good looking UI can be a tricky undertaking. This is especially true for big projects, where you can quickly run into the issue of having to handle many different edge cases. Rust is a great language to create CLI tools in, given that it's both safe and performant and simply a joy to write code in.

One popular crate for creating TUI is the ratatui crate.
It's quite powerful and you can basically create any TUI you would want to. However I found that there is a lack of articles for newcomers to get a foot in the door.
So that's why I am writing this article!

In the following sections I will walk you through some of the main features to know about the ratatui crate.
Note that I won't be using any explicit examples, instead I will try my best to explain what kind of architecture you could create. I advice you to take a look at the ratatui examples which are very useful to tip your toes into the world of TUI. With that out of the way, let's take a look at the core of ratatui.

The Main Loop

Every TUI system has three important features:

- struct App
- fn run_app<B: Backend>(terminal: &mut Terminal<B>, mut app: App) -> io::Result<()>
- fn ui<B: Backend>(f: &mut Frame<B>, app: &App)
Enter fullscreen mode Exit fullscreen mode

The App contains all the relevant information about our current app. We use it to store data between frame updates. The run_app contains the main loop which we use to update our terminal. It handles user input and calling the UI update through the ui function. The ui handles everything related to drawing our UI on our terminal. Of course the names can be arbitrary, but this is what all examples in the ratatui crate use and there is no good reason to not use them here as well.

How to draw UI

As stated above, we draw our UI in the ui function.
To draw UI components we must define a widget,
For instance to define a simple block we can write

let upper_block = Block::default()
    .borders(Borders::NONE)
    .title(Title::from("My Title"));
let lower_block = Block::default()
    .borders(Borders::ALL)
    .title(Title::from("Second Title"));
Enter fullscreen mode Exit fullscreen mode

This defines what we render. Now we also need to define where to render it. We do that by creating a Rect

let size = f.size();
let chunks = Layout::default()
    .direction(Direction::Vertical)
    .constraints([Constraint::Length(3), Constraint::Min(0)])
    .split(size);
Enter fullscreen mode Exit fullscreen mode

In this case chunks is of type Rc<[Rect]> with a length of 2. The f we use here is the f we pass into the function as a parameter. We tell the code to use the whole terminal screen and split it into two sections along the vertical axis. We get one area which is 3 lines in height and another that will take up the remaining space available. Because we didn't specify anything for the horizontal axis yet it will take up the entire length of the terminal screen (because f.size() is the entire length). To now finally render this, we call

f.render_widget(upper_block, chunks[0]);
f.render_widget(lower_block, chunks[1]);
Enter fullscreen mode Exit fullscreen mode

which renders as

First Example
Basic UI components in a simple arrangement. source

It's helpful to really think about these two fields as the what and the where in terms of our UI component. Note that they are independent of each other, you can render any kind of widget with any kind of area. Let's take a deeper look at how you can fine-tune these.

Widgets (The What)

There are quite a lot of built in widgets like the List, Table, Chart, Gauge just to name of few. You can pretty much create anything you would need in a UI with the built-ins, so if you ever find yourself thinking that you must create your own widget really take a step back and consider what you need. In order to understand the widgets deeper you can take a look at the previously mentioned examples in the tui repository. This should cover pretty much most use cases you would encounter in the early to mid stages of a project.

Area (The Where)

There are multiples way you could define areas,
but the most common one is to split the main frame into the desired areas. You do this by setting the direction, which states along which axis to create the chunks, and the constraints, which lets you define how many chunks to create and in what ratio to create them. Lets consider the following

let chunks = Layout::default()
    .direction(Direction::Horizontal)
    .constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
    .split(size);
Enter fullscreen mode Exit fullscreen mode

Here we create two areas with the left one taking up 30% of the area size and the right one 70%. The Orientation is horizontal (left to right). In order to further cut these we could write

let sub_chunks_left = Layout::default()
    .direction(Direction::Vertical)
    .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
    .split(chunks[0]);

let sub_chunks_right = Layout::default()
    .direction(Direction::Vertical)
    .constraints([Constraint::Percentage(80), Constraint::Percentage(20)])
    .split(chunks[1]);
Enter fullscreen mode Exit fullscreen mode

this will give us

Second Example
4 blocks rendered to different areas on the screen. source

Which would create 4 areas in total. This alone is already enough to create most UI arrangements, however there are two more things you should be aware of: Margins and Constraints

It can be useful to set Margins for certain areas, for example when you have one widget inside another one, e.g. when you want to have text surrounded by a block.

let size = f.size();
let block = Block::default().title("Border").borders(Borders::ALL);
f.render_widget(block, size);

let chunks = Layout::default()
    .direction(Direction::Vertical)
    .margin(1)
    .constraints([Constraint::Min(1), Constraint::Length(2)])
    .split(size);

let message_top = Paragraph::new("Hello World\nSome text\nEven more text")
    .block(Block::default().borders(Borders::NONE));
f.render_widget(message_top, chunks[0]);
Enter fullscreen mode Exit fullscreen mode

which will result in

Third Example
Block with text inside using margin. source

Here block is a simple border and the two text components will be inside of this border. Also take note of the Constraint here. We already saw a few different ones like Percentage, Min, Length. The Constraint is really powerful, as it allows you to create pretty much any Ratios you would need. This is also why creating the areas relative to the terminal size is the recommended way. It's pretty easy and most flexible, the size of the user's terminal is of no concern to you the developer.

Okay so now that we have seen how we can
define our what and where, it's time to look at how to store our data using the App.

How to store Data

Managing information in programming is generally a tricky part. This is no exception. The easiest way for us to store information throughout frame updates is to do so directly in the App. This however quickly becomes unfeasible as we need to store more and more data. One possible way to solve this is to store structs in the App, which then store the data relevant to them. So for instance

struct App {
    confirmation_data: confirmation::Data,
    text_prompt_data: text_prompt::Data,
    song_data: song::Data,
}
Enter fullscreen mode Exit fullscreen mode

This might seem a little overkill for very small
projects, but I assure that you will quickly need
to split up the logic as your projects grows.

Managing multiple Controllers

You will eventually want to have multiple controllers for different UI states. For instance you might have a UI component that asks the user for a string input and another one that is a simple confirmation pop up. You can't have the same controller for both of these, instead you have two separate ones, and you call whichever is currently in use. Here we could write

pub enum Controller {
    Main,
    FuzzyFinder,
    Confirmation,
}
Enter fullscreen mode Exit fullscreen mode

and then we check which controller to call with

fn run_app<B: Backend>(
    terminal: &mut Terminal<B>,
    mut app: App,
    tick_rate: Duration,
) -> io::Result<()> {
    let mut last_tick = Instant::now();
    loop {
        terminal.draw(|f| ui(f, &mut app))?;
        match app.controller {
            Controller::Main => main_controller::<B>(&mut app, tick_rate, &mut last_tick),
            Controller::FuzzyFinder => fuzzy_finder::controller::<B>(&mut app, tick_rate, &mut last_tick),
            Controller::Confirmation => confirmation::controller::<B>(&mut app, tick_rate, &mut last_tick),
        }?;
    }
}
Enter fullscreen mode Exit fullscreen mode

In each controller we can now define custom key bindings and have completely separate logic in them. Of course this is only a suggestion, there are absolutely other ways to structure your code. This is simply the way I have been writing it in my project and I wanted to give you a point to start. This goes for everything I have been showing you here.

Conclusion

In this article I went over the bigger picture of how to
structure a TUI rust project using the ratatui crate. We saw how to create widgets and areas and how to store data throughout frame updates in an App. I also showed you how to create multiple controllers to handle different logic for your UI components. All in all this was merely an overview of what you can do and how you might want to structure your project. It's still highly advisable to look through the ratatui examples to get a basic understanding of how to create UI components.
Remember that nothing I displayed here is a must, you are free to structure your code in any way you want. It's simply a guideline which you can follow if you want to, in case you are unsure how to structure your TUI project.

Top comments (2)

Collapse
 
nazaroni profile image
Nazar

thank for sharing, it's a good starting point to build something cool in terminal! 🥰

Collapse
 
praxtube profile image
PraxTube

I am glad to hear that, I hope it helps :)