Skip to content

App

SearchPage

Finally, let’s make a field in the app struct that uses the SearchPage widget:

src/app.rs
use crate::{
events::{Event, Events},
tui::Tui,
widgets::{
search_prompt::{SearchPrompt, SearchPromptWidget},
search_results::{SearchResults, SearchResultsWidget},
status_bar::{StatusBar, StatusBarWidget},
},
};
#[derive(Debug)]
pub struct App {
quit: bool,
mode: Mode,
rx: tokio::sync::mpsc::UnboundedReceiver<Action>,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
status_bar: StatusBar,
results: SearchResults,
prompt: SearchPrompt,
}

With this refactor, now ./src/app.rs becomes a lot simpler. For example, app now delegates to the search page widget for all core functionality.

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.results.scroll_previous(),
Action::ScrollDown => self.results.scroll_next(),
Action::SubmitSearchQuery => {
self.results.clear_selection();
self.prompt.submit_query();
}
Action::UpdateSearchResults => self.results.update_search_results(),
}
Ok(())
}
}

SearchPageWidget

And rendering delegates to SearchPageWidget:

src/app.rs
impl App {
impl StatefulWidget for AppWidget {
type State = App;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let [status_bar, main, prompt] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(PROMPT_HEIGHT),
])
.areas(area);
StatusBarWidget.render(status_bar, buf, &mut state.status_bar);
SearchResultsWidget::new(matches!(state.mode, Mode::Results)).render(
main,
buf,
&mut state.results,
);
SearchPromptWidget::new(matches!(state.mode, Mode::Prompt)).render(
prompt,
buf,
&mut state.prompt,
);
}
}
}

Conclusion

Here’s the full code for your reference:

src/app.rs (click to expand)
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use color_eyre::eyre::Result;
use crossterm::event::KeyEvent;
use ratatui::prelude::*;
use crate::{
events::{Event, Events},
tui::Tui,
widgets::{
search_prompt::{SearchPrompt, SearchPromptWidget},
search_results::{SearchResults, SearchResultsWidget},
status_bar::{StatusBar, StatusBarWidget},
},
};
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Mode {
#[default]
Prompt,
Results,
}
impl Mode {
fn handle_key(&self, key: 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,
}
#[derive(Debug)]
pub struct App {
quit: bool,
mode: Mode,
rx: tokio::sync::mpsc::UnboundedReceiver<Action>,
tx: tokio::sync::mpsc::UnboundedSender<Action>,
status_bar: StatusBar,
results: SearchResults,
prompt: SearchPrompt,
}
impl App {
pub fn new() -> Self {
let loading_status = Arc::new(AtomicBool::default());
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let crates: Arc<Mutex<Vec<crates_io_api::Crate>>> = Default::default();
let results = SearchResults::new(crates.clone());
let prompt = SearchPrompt::new(
tx.clone(),
loading_status.clone(),
crates.clone(),
);
let status_bar = StatusBar::new(loading_status);
let mode = Mode::default();
let quit = false;
Self {
quit,
mode,
rx,
tx,
status_bar,
results,
prompt,
}
}
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.status_bar.last_key_event = Some(key);
self.handle_key(key)
};
Ok(())
}
fn handle_key(&mut self, key: KeyEvent) {
let maybe_action = self.mode.handle_key(key);
if maybe_action.is_none() && matches!(self.mode, Mode::Prompt) {
self.prompt.handle_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.results.scroll_previous(),
Action::ScrollDown => self.results.scroll_next(),
Action::SubmitSearchQuery => {
self.results.clear_selection();
self.prompt.submit_query();
}
Action::UpdateSearchResults => self.results.update_search_results(),
}
Ok(())
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
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);
})?;
Ok(())
}
fn quit(&mut self) {
self.quit = true
}
fn switch_mode(&mut self, mode: Mode) {
self.mode = mode;
}
fn should_quit(&self) -> bool {
self.quit
}
fn set_cursor(&mut self, frame: &mut Frame<'_>) {
if matches!(self.mode, Mode::Prompt) {
if let Some(cursor_position) = self.prompt.cursor_position {
frame.set_cursor(cursor_position.x, cursor_position.y)
}
}
}
}
const PROMPT_HEIGHT: u16 = 5;
struct AppWidget;
impl StatefulWidget for AppWidget {
type State = App;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let [status_bar, main, prompt] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(PROMPT_HEIGHT),
])
.areas(area);
StatusBarWidget.render(status_bar, buf, &mut state.status_bar);
SearchResultsWidget::new(matches!(state.mode, Mode::Results)).render(
main,
buf,
&mut state.results,
);
SearchPromptWidget::new(matches!(state.mode, Mode::Prompt)).render(
prompt,
buf,
&mut state.prompt,
);
}
}

Your final folder structure will look like this:

.
├── Cargo.lock
├── Cargo.toml
└── src
├── app.rs
├── crates_io_api_helper.rs
├── errors.rs
├── events.rs
├── main.rs
├── tui.rs
├── widgets
│ ├── search_page.rs
│ ├── search_prompt.rs
│ └── search_results.rs
└── widgets.rs

If you put all of it together, you should be able run the TUI again:

Terminal window
cargo run