App Prototype
In this section, we are going to expand on the App
struct to add channels and actions.
Actions
One of the first steps to building truly async
TUI applications is to use the Command
, Action
,
or Message
pattern.
The key idea here is that Action
enum variants maps exactly to different methods on the App
struct, and the variants of Action
represent all the actions that can be carried out by an app
instance to update its state.
The variants of the Action
enum you will be using for this tutorial are:
#[derive(Debug, Clone, PartialEq, Eq)]pub enum Action { Quit, SwitchMode(Mode), ScrollDown, ScrollUp, SubmitSearchQuery, UpdateSearchResults,}
Channels
Define the following fields in the App
struct:
pub struct App { quit: bool, last_key_event: Option<crossterm::event::KeyEvent>, mode: Mode, crates: Arc<Mutex<Vec<crates_io_api::Crate>>>, table_state: TableState, prompt: tui_input::Input, cursor_position: Option<Position>,
tx: tokio::sync::mpsc::UnboundedSender<Action>, // new rx: tokio::sync::mpsc::UnboundedReceiver<Action>, // new}
where tx
and rx
are two parts of the pair of the Action
channel from tokio::mpsc
, i.e.
tx
: Transmitterrx
: Receiver
These pairs are created using the tokio::mpsc
channel, which stands for multiple producer single
consumer channels. These pairs from the channel can be used sending and receiving Action
s across
thread and task boundaries.
Practically, what this means for your application is that you can pass around clones of the
transmitter to any children of the App
struct and children can send Action
s at any point in the
operation of the app to trigger a state change in App
. This works because you have a single rx
here in the root App
struct that receives those Action
s and acts on them.
This allows you as a Ratatui app developer to organize your application in any way you please, and
still propagate information up from child to parent structs using the tx
transmitter.
Setup a App::new()
function to construct an App
instance like so:
impl App { pub fn new() -> Self { let quit = false; let mode = Mode::default(); let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let crates = Default::default(); let table_state = TableState::default(); let prompt = Default::default(); let cursor_position = None; let last_key_event = None; Self { quit, mode, last_key_event, tx, rx, crates, table_state, prompt, cursor_position, } }}
Let’s also update the async run
method now:
pub async fn run( &mut self, mut tui: Tui, mut events: Events, ) -> Result<()> { loop { if let Some(e) = events.next().await { if matches!(e, Event::Render) { self.draw(&mut tui)? } else { self.handle_event(e)? } } while let Ok(action) = self.rx.try_recv() { self.handle_action(action)?; } if self.should_quit() { break; } } Ok(()) }
handle_event
Update handle_event
to delegate to Mode
to figure out which Action
should be generated based
on the key event and the Mode
.
impl App { fn handle_event(&mut self, e: Event) -> Result<()> { use crossterm::event::Event as CrosstermEvent; if let Event::Crossterm(CrosstermEvent::Key(key)) = e { self.last_key_event = Some(key); self.handle_key(key) }; Ok(()) }}
Most of the work in deciding which Action
should be taken is done in Mode::handle_key
. Since
this is oriented around Mode
, implement the handle_key
method on Mode
in the following manner:
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]pub enum Mode { #[default] Prompt, Results,}
impl Mode { fn handle_key(&self, key: crossterm::event::KeyEvent) -> Option<Action> { use crossterm::event::KeyCode::*; let action = match self { Mode::Prompt => match key.code { Enter => Action::SubmitSearchQuery, Esc => Action::SwitchMode(Mode::Results), _ => return None, }, Mode::Results => match key.code { Up => Action::ScrollUp, Down => Action::ScrollDown, Char('/') => Action::SwitchMode(Mode::Prompt), Esc => Action::Quit, _ => return None, }, }; Some(action) }}
handle_action
Now implement the handle_action
method like so:
impl App { fn handle_action(&mut self, action: Action) -> Result<()> { match action { Action::Quit => self.quit(), Action::SwitchMode(mode) => self.switch_mode(mode), Action::ScrollUp => self.scroll_up(), Action::ScrollDown => self.scroll_down(), Action::SubmitSearchQuery => self.submit_search_query(), Action::UpdateSearchResults => self.update_search_results(), } Ok(()) }}
Because the run
method has the following block of code, any Action
received on rx
will trigger
an call to the handle_action
method.
while let Ok(action) = self.rx.try_recv() { self.handle_action(action.clone(), &mut tui)?;}
Since this is a while let
loop, multiple Action
s can be queued in your application and the
while let
will only return control back to the run
method when all the actions have been
processed.
Any time the rx
receiver receives an Action
from any tx
transmitter, the application will
“handle the action” and the state of the application will update. This means you can, for example,
send a new variant Action::Error(String)
from deep down in a nested child instance, which can
force the app to show an error message as a popup. You can also pass a clone of the tx
into a
tokio task, and have the tokio task propagate information back to the App
asynchronously. This is
particularly useful for error messages when a .unwrap()
would normally fail in a tokio task.
While introducing Action
s in between Event
s and the app methods may seem like a lot more
boilerplate at first, using an Action
enum this way has a few advantages.
Firstly, Action
s can be mapped from keypresses in a declarative manner. For example, you can
define a configuration file that reads which keys are mapped to which Action
like so:
[keyconfig]"q" = "Quit""j" = "ScrollDown""k" = "ScrollUp"
Then you can add a new keyconfig
in the App
like so:
struct App { ... // 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 directly from the keyconfig struct:
fn handle_event(&mut self, event: Event) -> Option<Action> { if let Event::Key(key) = event { return self.keyconfig.get(key.code) }; None}
A second reason you may want to use Action
s is that it allows us to send a tx
into a long
running task and retrieve information back from the task during its execution. For example, if a
task errors, you can send an Action::Error(String)
back to the app which can then be displayed as
a popup.
For example, you can send an Action::UpdateSearchResults
from inside the task once the query is
complete, when can make sure that the first time is selected after the results are loaded (by
scrolling down):
impl App { fn submit_search_query(&mut self) { // prepare request self.table_state.select(None); let params = SearchParameters::new( self.prompt.value().into(), self.crates.clone(), ); let tx = self.tx.clone(); tokio::spawn(async move { let _ = request_search_results(¶ms).await; tx.send(Action::UpdateSearchResults) // new }); self.switch_mode(Mode::Results); }
fn update_search_results(&mut self) { self.scroll_down(); // select first item in the results if they exist }}
Finally, using an Action
even allows us as app developers to trigger an action from anywhere in
any child struct by sending an Action
over tx
.
Here’s the full ./src/app.rs
file for your reference:
src/app.rs (click to expand)
use color_eyre::Result;use itertools::Itertools;use ratatui::layout::Position;use ratatui::prelude::*;use ratatui::widgets::*;
use crate::{ events::{Event, Events}, tui::Tui};
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]pub enum Mode { #[default] Prompt, Results,}
impl Mode { fn handle_key(&self, key: crossterm::event::KeyEvent) -> Option<Action> { use crossterm::event::KeyCode::*; let action = match self { Mode::Prompt => match key.code { Enter => Action::SubmitSearchQuery, Esc => Action::SwitchMode(Mode::Results), _ => return None, }, Mode::Results => match key.code { Up => Action::ScrollUp, Down => Action::ScrollDown, Char('/') => Action::SwitchMode(Mode::Prompt), Esc => Action::Quit, _ => return None, }, }; Some(action) }}
#[derive(Debug, Clone, PartialEq, Eq)]pub enum Action { Quit, SwitchMode(Mode), ScrollDown, ScrollUp, SubmitSearchQuery, UpdateSearchResults,}
pub struct App { quit: bool, last_key_event: Option<crossterm::event::KeyEvent>, mode: Mode, crates: Arc<Mutex<Vec<crates_io_api::Crate>>>, table_state: TableState, prompt: tui_input::Input, cursor_position: Option<Position>,
tx: tokio::sync::mpsc::UnboundedSender<Action>, // new rx: tokio::sync::mpsc::UnboundedReceiver<Action>, // new}
impl App { pub fn new() -> Self { let quit = false; let mode = Mode::default(); let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let crates = Default::default(); let table_state = TableState::default(); let prompt = Default::default(); let cursor_position = None; let last_key_event = None; Self { quit, mode, last_key_event, tx, rx, crates, table_state, prompt, cursor_position, } }
pub async fn run( &mut self, mut tui: Tui, mut events: Events, ) -> Result<()> { loop { if let Some(e) = events.next().await { if matches!(e, Event::Render) { self.draw(&mut tui)? } else { self.handle_event(e)? } } while let Ok(action) = self.rx.try_recv() { self.handle_action(action)?; } if self.should_quit() { break; } } Ok(()) }
fn handle_event(&mut self, e: Event) -> Result<()> { use crossterm::event::Event as CrosstermEvent; if let Event::Crossterm(CrosstermEvent::Key(key)) = e { self.last_key_event = Some(key); self.handle_key(key) }; Ok(()) }
fn handle_key(&mut self, key: crossterm::event::KeyEvent) { use crossterm::event::Event as CrosstermEvent; let maybe_action = self.mode.handle_key(key); if maybe_action.is_none() && matches!(self.mode, Mode::Prompt) { self.prompt.handle_event(&CrosstermEvent::Key(key)); } maybe_action.map(|action| self.tx.send(action)); }
fn handle_action(&mut self, action: Action) -> Result<()> { match action { Action::Quit => self.quit(), Action::SwitchMode(mode) => self.switch_mode(mode), Action::ScrollUp => self.scroll_up(), Action::ScrollDown => self.scroll_down(), Action::SubmitSearchQuery => self.submit_search_query(), Action::UpdateSearchResults => self.update_search_results(), } Ok(()) }
fn draw(&mut self, tui: &mut Tui) -> Result<()> { tui.draw(|frame| { frame.render_stateful_widget(AppWidget, frame.size(), self); self.set_cursor(frame); })?; Ok(()) }
fn switch_mode(&mut self, mode: Mode) { self.mode = mode; }
fn should_quit(&self) -> bool { self.quit }
fn quit(&mut self) { self.quit = true }
fn scroll_up(&mut self) { let last = self.crates.lock().unwrap().len().saturating_sub(1); let wrap_index = self.crates.lock().unwrap().len().max(1); let previous = self .table_state .selected() .map_or(last, |i| (i + last) % wrap_index); self.scroll_to(previous); }
fn scroll_down(&mut self) { let wrap_index = self.crates.lock().unwrap().len().max(1); let next = self .table_state .selected() .map_or(0, |i| (i + 1) % wrap_index); self.scroll_to(next); }
fn scroll_to(&mut self, index: usize) { if self.crates.lock().unwrap().is_empty() { self.table_state.select(None) } else { self.table_state.select(Some(index)); } }
fn submit_search_query(&mut self) { // prepare request self.table_state.select(None); let params = SearchParameters::new( self.prompt.value().into(), self.crates.clone(), ); let tx = self.tx.clone(); tokio::spawn(async move { let _ = request_search_results(¶ms).await; tx.send(Action::UpdateSearchResults) // new }); self.switch_mode(Mode::Results); }
fn update_search_results(&mut self) { self.scroll_down(); // select first item in the results if they exist }
fn set_cursor(&mut self, frame: &mut Frame<'_>) { if matches!(self.mode, Mode::Prompt) { if let Some(cursor_position) = self.cursor_position { frame.set_cursor(cursor_position.x, cursor_position.y) } } }}
impl Default for App { fn default() -> Self { Self::new() }}
struct AppWidget;
impl StatefulWidget for AppWidget { type State = App;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { let [last_key_event, results, prompt] = Layout::vertical([ Constraint::Length(1), Constraint::Fill(0), Constraint::Length(5), ]) .areas(area);
let table = state.results(); StatefulWidget::render(table, results, buf, &mut state.table_state);
let (block, paragraph) = state.prompt(); block.render(prompt, buf); paragraph.render( prompt.inner(&Margin { horizontal: 2, vertical: 2, }), buf, ); state.calculate_cursor_position(prompt);
if let Some(key) = state.last_key_event { Paragraph::new(format!("last key event: {:?}", key.code)) .right_aligned() .render(last_key_event, buf); } }}
impl App { fn results(&self) -> Table<'static> { let widths = [ Constraint::Length(15), Constraint::Min(0), Constraint::Length(20), ];
let crates = self.crates.lock().unwrap();
let rows = crates .iter() .map(|krate| { vec![ krate.name.clone(), krate.description.clone().unwrap_or_default(), krate.downloads.to_string(), ] }) .map(|row| Row::new(row.iter().map(String::from).collect_vec())) .collect_vec();
Table::new(rows, widths) .header(Row::new(vec!["Name", "Description", "Downloads"])) .highlight_symbol(" █ ") .highlight_spacing(HighlightSpacing::Always) }
fn prompt(&self) -> (Block, Paragraph) { let color = if matches!(self.mode, Mode::Prompt) { Color::Yellow } else { Color::Blue }; let block = Block::default().borders(Borders::ALL).border_style(color);
let paragraph = Paragraph::new(self.prompt.value());
(block, paragraph) }
fn calculate_cursor_position(&mut self, area: Rect) { if matches!(self.mode, Mode::Prompt) { let margin = (2, 2); let width = (area.width as f64 as u16).saturating_sub(margin.0); self.cursor_position = Some(Position::new( (area.x + margin.0 + self.prompt.cursor() as u16).min(width), area.y + margin.1, )); } else { self.cursor_position = None } }}
Conclusion
This is what our app currently looks like:
However, currently everything is in a single file, and the App
struct is starting to get a little
unwieldy. If we want to add more features or more widgets, this approach isn’t going to scale very
well.
In the rest of the tutorial, we are going to refactor the app into StatefulWidget
s and add more
polish.
Your folder structure should currently look like this:
.├── Cargo.lock├── Cargo.toml└── src ├── app.rs ├── crates_io_api_helper.rs ├── errors.rs ├── events.rs ├── main.rs └── tui.rs