Counter App with Actions
One of the first steps to building truly async TUI applications is to use the Command, Action,
or Message pattern.
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}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 ratatui::crossterm::{ event::{self, Event::Key, KeyCode::Char}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},};use ratatui::{ prelude::{CrosstermBackend, Terminal}, widgets::Paragraph,};
// App statestruct App { counter: i64, should_quit: bool,}
// App actionspub enum Action { Tick, Increment, Decrement, Quit, None,}
// App ui render functionfn ui(app: &App, f: &mut Frame) { f.render_widget(Paragraph::new(format!("Counter: {}", app.counter)), f.area());}
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}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.