Skip to content

App Async

We are finally ready to incorporate the helper module into the App struct.

Define the 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>>>, // new
prompt: tui_input::Input, // new
cursor_position: Option<Position>, // new
table_state: TableState, // new
}

We already saw that we needed a Arc<Mutex<Vec<crates_io_api::Crate>>> for getting results. Let’s use tui-input for handling the search prompt and a Option<Position> to handle displaying the cursor in the prompt.

Let’s also add a TableState for allowing scrolling in the results.

For the application, we want to be able to:

In prompt mode:

  1. Type any character into the search prompt
  2. Hit Enter to submit a search query
  3. Hit Esc to return focus to the results view

In results mode:

  1. Use arrow keys to scroll
  2. Use / to enter search mode
  3. Use Esc to quit the application

Expand the handle_events to the match on mode and change the app state accordingly:

src/app.rs
impl App {
fn handle_event(&mut self, e: Event, tui: &mut Tui) -> Result<()> {
use crossterm::event::Event as CrosstermEvent;
use crossterm::event::KeyCode::*;
match e {
Event::Render => self.draw(tui)?,
Event::Crossterm(CrosstermEvent::Key(key)) => {
self.last_key_event = Some(key);
match self.mode {
Mode::Prompt => match key.code {
Enter => self.submit_search_query(), // new
Esc => self.switch_mode(Mode::Results),
_ => {
// new
self.prompt.handle_event(&CrosstermEvent::Key(key));
}
},
Mode::Results => match key.code {
Up => self.scroll_up(), // new
Down => self.scroll_down(), // new
Char('/') => self.switch_mode(Mode::Prompt), // new
Esc => self.quit(),
_ => (),
},
};
}
_ => (),
};
Ok(())
}
}

tui-input handles events for moving the cursor in the prompt.

Submit search query

tui-input has a Input::value method that you can use to get a reference to the current search query that the user has typed in, i.e. self.prompt.value() -> &str.

Implement the following method:

src/app.rs
impl App {
fn submit_search_query(&mut self) {
self.table_state.select(None);
let search_params = SearchParameters::new(
self.prompt.value().into(),
self.crates.clone(),
);
tokio::spawn(async move {
let _ = request_search_results(&search_params).await;
});
self.switch_mode(Mode::Results);
}
}

Scroll up and Scroll down

When the scroll_up or scroll_down methods are called, you have to update the TableState of the results to select the new index.

Implement the following for wrapped scrolling:

src/app.rs
impl App {
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));
}
}
}

Cursor state

Ratatui hides the cursor by default every frame. To show it, we have to call set_cursor explicitly. We only want to show the cursor when the prompt is in focus.

Implement the following to show the cursor conditionally:

src/app.rs
impl App {
fn draw(&mut self, tui: &mut Tui) -> Result<()> {
tui.draw(|frame| {
frame.render_stateful_widget(AppWidget, frame.size(), self);
self.set_cursor(frame); // new
})?;
Ok(())
}
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)
}
}
}
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
}
}
}

Draw

Finally, you can update the render using the new information to replace placeholder data with the data from the results or the prompt value.

Results

src/app.rs
impl App {
fn results(&self) -> Table<'static> {
let widths = [
Constraint::Length(15),
Constraint::Min(0),
Constraint::Length(20),
];
let crates = self.crates.lock().unwrap(); // new
// new
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("") // new
.highlight_spacing(HighlightSpacing::Always) // new
}
}

Note the use highlight_symbol here to show the cursor when scrolling.

Prompt

Update the prompt widget to show the text from tui-input::Input in a Paragraph widget:

src/app.rs
impl App {
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()); // new
(block, paragraph)
}
}

Render

And in the render function for the StatefulWidget, make sure you create a stateful widget for the table results instead. You have to also call the function that updates the cursor position based on the prompt Rect, which is only known during render.

src/app.rs
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); // new
let (block, paragraph) = state.prompt();
block.render(prompt, buf);
paragraph.render(
prompt.inner(&Margin {
horizontal: 2,
vertical: 2,
}),
buf,
);
state.calculate_cursor_position(prompt); // new
if let Some(key) = state.last_key_event {
Paragraph::new(format!("last key event: {:?}", key.code))
.right_aligned()
.render(last_key_event, buf);
}
}
}

Conclusion

Here’s the full app 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,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Render,
Quit,
SwitchMode(Mode),
ScrollDown,
ScrollUp,
SubmitSearchQuery,
}
pub struct App {
quit: bool,
last_key_event: Option<crossterm::event::KeyEvent>,
mode: Mode,
crates: Arc<Mutex<Vec<crates_io_api::Crate>>>, // new
prompt: tui_input::Input, // new
cursor_position: Option<Position>, // new
table_state: TableState, // new
}
impl App {
pub fn new() -> Self {
let quit = false;
let mode = Mode::default();
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,
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 {
self.handle_event(e, &mut tui)?
}
if self.should_quit() {
break;
}
}
Ok(())
}
fn handle_event(&mut self, e: Event, tui: &mut Tui) -> Result<()> {
use crossterm::event::Event as CrosstermEvent;
use crossterm::event::KeyCode::*;
match e {
Event::Render => self.draw(tui)?,
Event::Crossterm(CrosstermEvent::Key(key)) => {
self.last_key_event = Some(key);
match self.mode {
Mode::Prompt => match key.code {
Enter => self.submit_search_query(), // new
Esc => self.switch_mode(Mode::Results),
_ => {
// new
self.prompt.handle_event(&CrosstermEvent::Key(key));
}
},
Mode::Results => match key.code {
Up => self.scroll_up(), // new
Down => self.scroll_down(), // new
Char('/') => self.switch_mode(Mode::Prompt), // new
Esc => self.quit(),
_ => (),
},
};
}
_ => (),
};
Ok(())
}
fn draw(&mut self, tui: &mut Tui) -> Result<()> {
tui.draw(|frame| {
frame.render_stateful_widget(AppWidget, frame.size(), self);
self.set_cursor(frame); // new
})?;
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) {
self.table_state.select(None);
let search_params = SearchParameters::new(
self.prompt.value().into(),
self.crates.clone(),
);
tokio::spawn(async move {
let _ = request_search_results(&search_params).await;
});
self.switch_mode(Mode::Results);
}
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); // new
let (block, paragraph) = state.prompt();
block.render(prompt, buf);
paragraph.render(
prompt.inner(&Margin {
horizontal: 2,
vertical: 2,
}),
buf,
);
state.calculate_cursor_position(prompt); // new
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(); // new
// new
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("") // new
.highlight_spacing(HighlightSpacing::Always) // new
}
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()); // new
(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
}
}
}