Counter App with Actions

One of the first steps to building truly async TUI applications is to use the Command, Action, or Message pattern.

Tip

The Command pattern is the concept of “reified method calls”. You can learn a lot more about this pattern from the excellent http://gameprogrammingpatterns.com.

You can learn more about this concept in The Elm Architecture section of the documentation.

We have learnt about enums in JSON-editor tutorial. We are going to extend the counter application to include Actions using Rust’s enum features. The key idea is that we have an Action enum that tracks all the actions that can be carried out by the App. Here’s the variants of the Action enum we will be using:

pub enum Action {
  Tick,
  Increment,
  Decrement,
  Quit,
  None,
}

Now we add a new get_action function to map a Event to an Action.

fn get_action(_app: &App, event: Event) -> Action {
  if let Key(key) = event {
    return match key.code {
      Char('j') => Action::Increment,
      Char('k') => Action::Decrement,
      Char('q') => Action::Quit,
      _ => Action::None,
    };
  };
  Action::None
}

Tip

Instead of using a None variant in Action, you can drop the None from Action and use Rust’s built-in Option types instead. This is what your code might actually look like:

fn get_action(_app: &App, event: Event) -> Result<Option<Action>> {
  if let Key(key) = event {
    let action = match key.code {
      Char('j') => Action::Increment,
      Char('k') => Action::Decrement,
      Char('q') => Action::Quit,
      _ => return Ok(None),
    };
    return Ok(Some(action))
  };
  Ok(None)
}

But, for illustration purposes, in this tutorial we will stick to using Action::None for now.

And the update function takes an Action instead:

fn update(app: &mut App, action: Action) {
  match action {
    Action::Quit => app.should_quit = true,
    Action::Increment => app.counter += 1,
    Action::Decrement => app.counter -= 1,
    Action::Tick => {},
    _ => {},
  };
}

Here’s the full single file version of the counter app using the Action enum for your reference:

mod tui;

use color_eyre::eyre::Result;
use crossterm::{
  event::{self, Event::Key, KeyCode::Char},
  execute,
  terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
  prelude::{CrosstermBackend, Terminal},
  widgets::Paragraph,
};

pub type Frame<'a> = ratatui::Frame<'a, CrosstermBackend<std::io::Stderr>>;

// App state
struct App {
  counter: i64,
  should_quit: bool,
}

// App actions
pub enum Action {
  Tick,
  Increment,
  Decrement,
  Quit,
  None,
}

// App ui render function
fn ui(app: &App, f: &mut Frame<'_>) {
  f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.size());
}

fn get_action(_app: &App, event: Event) -> Action {
  if let Key(key) = event {
    return match key.code {
      Char('j') => Action::Increment,
      Char('k') => Action::Decrement,
      Char('q') => Action::Quit,
      _ => Action::None,
    };
  };
  Action::None
}

fn update(app: &mut App, action: Action) {
  match action {
    Action::Quit => app.should_quit = true,
    Action::Increment => app.counter += 1,
    Action::Decrement => app.counter -= 1,
    Action::Tick => {},
    _ => {},
  };
}

fn run() -> Result<()> {
  // ratatui terminal
  let mut tui = tui::Tui::new()?.tick_rate(1.0).frame_rate(30.0);
  tui.enter()?;

  // application state
  let mut app = App { counter: 0, should_quit: false };

  loop {
    let event = tui.next().await?; // blocks until next event

    if let Event::Render = event.clone() {
      // application render
      tui.draw(|f| {
        ui(f, &app);
      })?;
    }
    let action = get_action(&mut app, event); // new

    // application update
    update(&mut app, action); // new

    // application exit
    if app.should_quit {
      break;
    }
  }

  Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
  let result = run().await;

  result?;

  Ok(())
}

While this may seem like a lot more boilerplate to achieve the same thing, Action enums have a few advantages.

Firstly, they can be mapped from keypresses programmatically. For example, you can define a configuration file that reads which keys are mapped to which Action like so:

[keymap]
"q" = "Quit"
"j" = "Increment"
"k" = "Decrement"

Then you can add a new key configuration like so:

struct App {
  counter: i64,
  should_quit: bool,
  // new field
  keyconfig: HashMap<KeyCode, Action>
}

If you populate keyconfig with the contents of a user provided toml file, then you can figure out which action to take by updating the get_action() function:

fn get_action(app: &App, event: Event) -> Action {
  if let Event::Key(key) = event {
    return app.keyconfig.get(key.code).unwrap_or(Action::None)
  };
  Action::None
}

Another advantage of this is that the business logic of the App struct can be tested without having to create an instance of a Tui or EventHandler, e.g.:

mod tests {
  #[test]
  fn test_app() {
    let mut app = App::new();
    let old_counter = app.counter;
    update(&mut app, Action::Increment);
    assert!(app.counter == old_counter + 1);
  }
}

In the test above, we did not create an instance of the Terminal or the EventHandler, and did not call the run function, but we are still able to test the business logic of our application. Updating the app state on Actions gets us one step closer to making our application a “state machine”, which improves understanding and testability.

If we wanted to be purist about it, we would make a struct called AppState which would be immutable, and we would have an update function return a new instance of the AppState:

fn update(app_state: AppState, action: Action) -> AppState {
  let mut state = app_state.clone();
  state.counter += 1;
  state
}

Note

Charm’s bubbletea also follows the TEA paradigm. Here’s an example of what the Update function for a counter example might look like in Go:

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {

    // Is it a key press?
    case tea.KeyMsg:
        // These keys should exit the program.
        case "q":
            return m, tea.Quit

        case "k":
            m.counter--

        case "j":
            m.counter++
    }

    // Note that we're not returning a command.
    return m, nil
}

Like in Charm, we may also want to choose a action to follow up after an update by returning another Action:

fn update(app_state: AppState, action: Action) -> (AppState, Action) {
  let mut state = app_state.clone();
  state.counter += 1;
  (state, Action::None) // no follow up action
  // OR
  (state, Action::Tick) // force app to tick
}

We would have to modify our run function to handle the above paradigm though. Also, writing code to follow this architecture in Rust requires more upfront design, mostly because you have to make your AppState struct Clone-friendly.

For this tutorial, we will stick to having a mutable App:

fn update(app: &mut App, action: Action) {
  match action {
    Action::Quit => app.should_quit = true,
    Action::Increment => app.counter += 1,
    Action::Decrement => app.counter -= 1,
    Action::Tick => {},
    _ => {},
  };
}

The other advantage of using an Action enum is that you can tell your application what it should do next by sending a message over a channel. We will discuss this approach in the next section.