App Async
We are finally ready to incorporate the helper module into the App
struct.
Define the 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>>>, // 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:
- Type any character into the search prompt
- Hit Enter to submit a search query
- Hit Esc to return focus to the results view
In results mode:
- Use arrow keys to scroll
- Use
/
to enter search mode - Use Esc to quit the application
Expand the handle_events
to the match on mode and change the app state accordingly:
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:
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:
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:
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
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:
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.
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 } }}