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 } }}