Skip to content

App.rs

Finally, putting all the pieces together, we are almost ready to get the Run struct. Before we do, we should discuss the process of a TUI.

Most TUIs are single process, single threaded applications.

Get Key Event Update State Draw

When an application is structured like this, the TUI is blocking at each step:

  1. Waiting for a Event.
    • If no key or mouse event in 250ms, send Tick.
  2. Update the state of the app based on event or action.
  3. draw the state of the app to the terminal using ratatui.

This works perfectly fine for small applications, and this is what I recommend starting out with. For most TUIs, you’ll never need to graduate from this process methodology.

Usually, draw and get_events are fast enough that it doesn’t matter. But if you do need to do a computationally demanding or I/O intensive task while updating state (e.g. reading a database, computing math or making a web request), your app may “hang” while it is doing so.

Let’s say a user presses j to scroll down a list. And every time the user presses j you want to check the web for additional items to add to the list.

What should happen when a user presses and holds j? It is up to you to decide how you would like your TUI application to behave in that instance.

You may decide that the desired behavior for your app is to hang while downloading new elements for the list, and all key presses while the app hangs are received and handled “instantly” after the download completes.

Or you may decide to flush all keyboard events so they are not buffered, and you may want to implement something like the following:

let mut app = App::new();
loop {
// ...
let before_draw = Instant::now();
t.terminal.draw(|f| self.render(f))?;
// If drawing to the terminal is slow, flush all keyboard events so they're not buffered.
if before_draw.elapsed() > Duration::from_millis(20) {
while let Ok(_) = events.try_next() {}
}
// ...
}

Alternatively, you may decide you want the app to update in the background, and a user should be able to scroll through the existing list while the app is downloading new elements.

In my experience, the trade-off is here is usually complexity for the developer versus ergonomics for the user.

Let’s say we weren’t worried about complexity, and were interested in performing a computationally demanding or I/O intensive task in the background. For our example, let’s say that we wanted to trigger a increment to the counter after sleeping for 5 seconds.

This means that we’ll have to start a “task” that sleeps for 5 seconds, and then sends another Action to be dispatched on.

Now, our update() method takes the following shape:

fn update(&mut self, action: Action) -> Option<Action> {
match action {
Action::Tick => self.tick(),
Action::ScheduleIncrement => self.schedule_increment(1),
Action::ScheduleDecrement => self.schedule_decrement(1),
Action::Increment(i) => self.increment(i),
Action::Decrement(i) => self.decrement(i),
_ => (),
}
None
}

And schedule_increment() and schedule_decrement() both spawn short lived tokio tasks:

pub fn schedule_increment(&mut self, i: i64) {
let tx = self.action_tx.clone().unwrap();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
tx.send(Action::Increment(i)).unwrap();
});
}
pub fn schedule_decrement(&mut self, i: i64) {
let tx = self.action_tx.clone().unwrap();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
tx.send(Action::Decrement(i)).unwrap();
});
}
pub fn increment(&mut self, i: i64) {
self.counter += i;
}
pub fn decrement(&mut self, i: i64) {
self.counter -= i;
}

In order to do this, we want to set up a action_tx on the App struct:

#[derive(Default)]
struct App {
counter: i64,
should_quit: bool,
action_tx: Option<UnboundedSender<Action>>
}

This is what we want to do:

pub async fn run(&mut self) -> Result<()> {
let (action_tx, mut action_rx) = mpsc::unbounded_channel();
let t = Tui::new();
t.enter();
tokio::spawn(async move {
let mut event = EventHandler::new(250);
loop {
let event = event.next().await;
let action = self.handle_events(event); // ERROR: self is moved to this tokio task
action_tx.send(action);
}
})
loop {
if let Some(action) = action_rx.recv().await {
self.update(action);
}
t.terminal.draw(|f| self.render(f))?;
if self.should_quit {
break
}
}
t.exit();
Ok(())
}

However, this doesn’t quite work because we can’t move self, i.e. the App to the event -> action mapping, i.e. self.handle_events(), and still use it later for self.update().

One way to solve this is to pass a Arc<Mutex<App> instance to the event -> action mapping loop, where it uses a lock() to get a reference to the object to call obj.handle_events(). We’ll have to use the same lock() functionality in the main loop as well to call obj.update().

pub struct App {
pub component: Arc<Mutex<App>>,
pub should_quit: bool,
}
impl App {
pub async fn run(&mut self) -> Result<()> {
let (action_tx, mut action_rx) = mpsc::unbounded_channel();
let tui = Tui::new();
tui.enter();
tokio::spawn(async move {
let component = self.component.clone();
let mut event = EventHandler::new(250);
loop {
let event = event.next().await;
let action = component.lock().await.handle_events(event);
action_tx.send(action);
}
})
loop {
if let Some(action) = action_rx.recv().await {
match action {
Action::Render => {
let c = self.component.lock().await;
t.terminal.draw(|f| c.render(f))?;
};
Action::Quit => self.should_quit = true,
_ => self.component.lock().await.update(action),
}
}
self.should_quit {
break;
}
}
tui.exit();
Ok(())
}
}

Now our App is generic boilerplate that doesn’t depend on any business logic. It is responsible just to drive the application forward, i.e. call appropriate functions.

We can go one step further and make the render loop its own tokio task:

pub struct App {
pub component: Arc<Mutex<Home>>,
pub should_quit: bool,
}
impl App {
pub async fn run(&mut self) -> Result<()> {
let (render_tx, mut render_rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
let component = self.component.clone();
let tui = Tui::new();
tui.enter();
loop {
if let Some(_) = render_rx.recv() {
let c = self.component.lock().await;
tui.terminal.draw(|f| c.render(f))?;
}
}
tui.exit()
})
let (action_tx, mut action_rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
let component = self.component.clone();
let mut event = EventHandler::new(250);
loop {
let event = event.next().await;
let action = component.lock().await.handle_events(event);
action_tx.send(action);
}
})
loop {
if let Some(action) = action_rx.recv().await {
match action {
Action::Render => {
render_tx.send(());
};
Action::Quit => self.should_quit = true,
_ => self.component.lock().await.update(action),
}
}
self.should_quit {
break;
}
}
Ok(())
}
}

Now our final architecture would look like this:

Render Thread Event Thread Main Thread Get Key Event Map Event to Action Send Action on action tx Recv Action Recv on render rx Dispatch Action Render Component Update Component

You can change around when “thread” or “task” does what in your application if you’d like.

It is up to you to decide is this pattern is worth it. In this template, we are going to keep things a little simpler. We are going to use just one thread or task to handle all the Events.

Event Thread Main Thread Get Event Send Event on event tx Recv Event and Map to Action Update Component

All business logic will be located in a App struct.

#[derive(Default)]
struct App {
counter: i64,
}
impl App {
fn handle_events(&mut self, event: Option<Event>) -> Action {
match event {
Some(Event::Quit) => Action::Quit,
Some(Event::AppTick) => Action::Tick,
Some(Event::Render) => Action::Render,
Some(Event::Key(key_event)) => {
if let Some(key) = event {
match key.code {
KeyCode::Char('j') => Action::Increment,
KeyCode::Char('k') => Action::Decrement
_ => {}
}
}
},
Some(_) => Action::Noop,
None => Action::Noop,
}
}
fn update(&mut self, action: Action) {
match action {
Action::Tick => self.tick(),
Action::Increment => self.increment(),
Action::Decrement => self.decrement(),
}
fn increment(&mut self) {
self.counter += 1;
}
fn decrement(&mut self) {
self.counter -= 1;
}
fn render(&mut self, f: &mut Frame<'_>) {
f.render_widget(
Paragraph::new(format!(
"Press j or k to increment or decrement.\n\nCounter: {}",
self.counter
))
)
}
}

With that, our App becomes a little more simpler:

pub struct App {
pub tick_rate: (u64, u64),
pub component: Home,
pub should_quit: bool,
}
impl Component {
pub fn new(tick_rate: (u64, u64)) -> Result<Self> {
let component = Home::new();
Ok(Self { tick_rate, component, should_quit: false, should_suspend: false })
}
pub async fn run(&mut self) -> Result<()> {
let (action_tx, mut action_rx) = mpsc::unbounded_channel();
let mut tui = Tui::new();
tui.enter()
loop {
if let Some(e) = tui.next().await {
if let Some(action) = self.component.handle_events(Some(e.clone())) {
action_tx.send(action)?;
}
}
while let Ok(action) = action_rx.try_recv().await {
match action {
Action::Render => tui.draw(|f| self.component.render(f, f.size()))?,
Action::Quit => self.should_quit = true,
_ => self.component.update(action),
}
}
if self.should_quit {
tui.stop()?;
break;
}
}
tui.exit()
Ok(())
}
}

Our Component currently does one thing and just one thing (increment and decrement a counter). But we may want to do more complex things and combine Components in interesting ways. For example, we may want to add a text input field as well as show logs conditionally from our TUI application.

In the next sections, we will talk about breaking out our app into various components, with the one root component called Home. And we’ll introduce a Component trait so it is easier to understand where the TUI specific code ends and where our app’s business logic begins.