Skip to content

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:

src/app.rs
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Quit,
SwitchMode(Mode),
ScrollDown,
ScrollUp,
SubmitSearchQuery,
UpdateSearchResults,
}

Channels

Define the following fields in the App struct:

src/app.rs
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: Transmitter
  • rx: 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 Actions 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 Actions 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 Actions 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:

src/app.rs
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:

src/app.rs
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.

src/app.rs
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:

src/app.rs
#[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:

src/app.rs
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 Actions 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 Actions in between Events and the app methods may seem like a lot more boilerplate at first, using an Action enum this way has a few advantages.

Firstly, Actions 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 Actions 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):

src/app.rs
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(&params).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(&params).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 StatefulWidgets 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