diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc1a275..8d9754d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] - ReleaseDate +### Added + +- Add search box to the recipe list + ### Changes - Wrap long error messages in response pane diff --git a/crates/core/src/collection/recipe_tree.rs b/crates/core/src/collection/recipe_tree.rs index b5b6cf87..a0895450 100644 --- a/crates/core/src/collection/recipe_tree.rs +++ b/crates/core/src/collection/recipe_tree.rs @@ -37,6 +37,7 @@ pub struct RecipeLookupKey(Vec); /// A node in the recipe tree, either a folder or recipe #[derive(Debug, From, Serialize, Deserialize, EnumDiscriminants)] +#[strum_discriminants(name(RecipeNodeType))] #[cfg_attr(any(test, feature = "test"), derive(PartialEq))] #[serde(rename_all = "snake_case", deny_unknown_fields)] #[allow(clippy::large_enum_variant)] diff --git a/crates/tui/src/view/component/exchange_pane.rs b/crates/tui/src/view/component/exchange_pane.rs index 6e94b98c..4efe2632 100644 --- a/crates/tui/src/view/component/exchange_pane.rs +++ b/crates/tui/src/view/component/exchange_pane.rs @@ -29,8 +29,7 @@ use ratatui::{ use serde::{Deserialize, Serialize}; use slumber_config::Action; use slumber_core::{ - collection::RecipeNodeDiscriminants, http::RequestRecord, - util::format_byte_size, + collection::RecipeNodeType, http::RequestRecord, util::format_byte_size, }; use std::sync::Arc; use strum::{EnumCount, EnumIter}; @@ -51,7 +50,7 @@ pub struct ExchangePane { pub struct ExchangePaneProps<'a> { /// Do we have a recipe, folder, or neither selected? Used to determine /// placeholder - pub selected_recipe_kind: Option, + pub selected_recipe_kind: Option, pub request_state: Option<&'a RequestState>, } @@ -112,10 +111,7 @@ impl<'a> Draw> for ExchangePane { } .generate(); // If a recipe is selected, history is available so show the hint - if matches!( - props.selected_recipe_kind, - Some(RecipeNodeDiscriminants::Recipe) - ) { + if matches!(props.selected_recipe_kind, Some(RecipeNodeType::Recipe)) { let text = input_engine.add_hint("History", Action::History); block = block.title(Title::from(text).alignment(Alignment::Right)); } @@ -127,14 +123,14 @@ impl<'a> Draw> for ExchangePane { None => { return; } - Some(RecipeNodeDiscriminants::Folder) => { + Some(RecipeNodeType::Folder) => { frame.render_widget( "Select a recipe to see its request history", area, ); return; } - Some(RecipeNodeDiscriminants::Recipe) => {} + Some(RecipeNodeType::Recipe) => {} } // Split out the areas we *may* need diff --git a/crates/tui/src/view/component/primary.rs b/crates/tui/src/view/component/primary.rs index 2a695dab..d7c493ac 100644 --- a/crates/tui/src/view/component/primary.rs +++ b/crates/tui/src/view/component/primary.rs @@ -41,7 +41,7 @@ use ratatui::{ use serde::{Deserialize, Serialize}; use slumber_config::Action; use slumber_core::collection::{ - Collection, ProfileId, RecipeId, RecipeNodeDiscriminants, + Collection, ProfileId, RecipeId, RecipeNodeType, }; use strum::{EnumCount, EnumIter}; @@ -142,7 +142,7 @@ impl PrimaryView { .data() .selected_node() .and_then(|(id, kind)| { - if matches!(kind, RecipeNodeDiscriminants::Recipe) { + if matches!(kind, RecipeNodeType::Recipe) { Some(id) } else { None diff --git a/crates/tui/src/view/component/recipe_list.rs b/crates/tui/src/view/component/recipe_list.rs index 6707ff54..6e211dd3 100644 --- a/crates/tui/src/view/component/recipe_list.rs +++ b/crates/tui/src/view/component/recipe_list.rs @@ -1,7 +1,13 @@ use crate::{ context::TuiContext, view::{ - common::{actions::ActionsModal, list::List, modal::ModalHandle, Pane}, + common::{ + actions::ActionsModal, + list::List, + modal::ModalHandle, + text_box::{TextBox, TextBoxEvent}, + Pane, + }, component::recipe_pane::RecipeMenuAction, context::UpdateContext, draw::{Draw, DrawMetadata, Generate}, @@ -13,11 +19,15 @@ use crate::{ }; use derive_more::{Deref, DerefMut}; use persisted::{PersistedKey, SingletonKey}; -use ratatui::{text::Text, Frame}; +use ratatui::{ + layout::{Constraint, Layout}, + text::Text, + Frame, +}; use serde::{Deserialize, Serialize}; use slumber_config::Action; use slumber_core::collection::{ - HasId, RecipeId, RecipeLookupKey, RecipeNodeDiscriminants, RecipeTree, + HasId, RecipeId, RecipeLookupKey, RecipeNodeType, RecipeTree, }; use std::collections::HashSet; @@ -48,6 +58,10 @@ pub struct RecipeListPane { /// issue though, it just means it'll be pre-collapsed if the user ever /// adds the folder back. Not worth working around. collapsed: Persisted>, + + search: Component, + search_focused: bool, + actions_handle: ModalHandle>, } @@ -58,27 +72,32 @@ struct SelectedRecipeKey; impl RecipeListPane { pub fn new(recipes: &RecipeTree) -> Self { + let input_engine = &TuiContext::get().input_engine; + let binding = input_engine.binding_display(Action::Search); + // This clone is unfortunate, but we can't hold onto a reference to the // recipes let collapsed: Persisted> = Persisted::default(); - let persistent = PersistedLazy::new( + let select = PersistedLazy::new( SelectedRecipeKey, collapsed.build_select_state(recipes), ); + let search = + TextBox::default().placeholder(format!("{binding} search")); Self { emitter_id: EmitterId::new(), - select: persistent.into(), + select: select.into(), collapsed, + search: search.into(), + search_focused: false, actions_handle: ModalHandle::default(), } } /// ID and kind of whatever recipe/folder in the list is selected. `None` /// iff the list is empty - pub fn selected_node( - &self, - ) -> Option<(&RecipeId, RecipeNodeDiscriminants)> { + pub fn selected_node(&self) -> Option<(&RecipeId, RecipeNodeType)> { self.select .data() .selected() @@ -126,6 +145,18 @@ impl RecipeListPane { changed } + + /// Apply the search query, selecting the first recipe/folder in the list + /// that contains the query in its name + fn search(&mut self) { + let query = self.search.data().text().trim().to_lowercase(); + if !query.is_empty() { + self.select + .data_mut() + .get_mut() + .find(|item| item.name.to_lowercase().contains(&query)); + } + } } impl EventHandler for RecipeListPane { @@ -139,6 +170,9 @@ impl EventHandler for RecipeListPane { Action::Right => { self.set_selected_collapsed(CollapseState::Expand); } + Action::Search => { + self.search_focused = true; + } Action::OpenActions => { let recipe = self .select @@ -176,6 +210,14 @@ impl EventHandler for RecipeListPane { self.set_selected_collapsed(CollapseState::Toggle); } } + } else if let Some(event) = self.search.emitted(&event) { + match event { + TextBoxEvent::Focus => self.search_focused = true, + TextBoxEvent::Change => self.search(), + TextBoxEvent::Cancel | TextBoxEvent::Submit => { + self.search_focused = false + } + } } else if let Some(menu_action) = self.actions_handle.emitted(&event) { // Menu actions are handled by the parent, so forward them self.emit(RecipeListPaneEvent::MenuAction(*menu_action)); @@ -187,7 +229,7 @@ impl EventHandler for RecipeListPane { } fn children(&mut self) -> Vec>> { - vec![self.select.to_child_mut()] + vec![self.select.to_child_mut(), self.search.to_child_mut()] } } @@ -206,8 +248,19 @@ impl Draw for RecipeListPane { let area = block.inner(metadata.area()); frame.render_widget(block, metadata.area()); - self.select - .draw(frame, List::from(&**self.select.data()), area, true); + let [select_area, search_area] = + Layout::vertical([Constraint::Min(0), Constraint::Length(1)]) + .areas(area); + + self.select.draw( + frame, + List::from(&**self.select.data()), + select_area, + !self.search_focused, + ); + + self.search + .draw(frame, (), search_area, self.search_focused); } } @@ -233,18 +286,18 @@ pub enum RecipeListPaneEvent { struct RecipeListItem { id: RecipeId, name: String, - kind: RecipeNodeDiscriminants, + kind: RecipeNodeType, depth: usize, collapsed: bool, } impl RecipeListItem { fn is_folder(&self) -> bool { - matches!(self.kind, RecipeNodeDiscriminants::Folder) + matches!(self.kind, RecipeNodeType::Folder) } fn is_recipe(&self) -> bool { - matches!(self.kind, RecipeNodeDiscriminants::Recipe) + matches!(self.kind, RecipeNodeType::Recipe) } } @@ -276,9 +329,9 @@ impl<'a> Generate for &'a RecipeListItem { Self: 'this, { let icon = match self.kind { - RecipeNodeDiscriminants::Folder if self.collapsed => "▶", - RecipeNodeDiscriminants::Folder => "▼", - RecipeNodeDiscriminants::Recipe => "", + RecipeNodeType::Folder if self.collapsed => "▶", + RecipeNodeType::Folder => "▼", + RecipeNodeType::Recipe => "", }; // Apply indentation @@ -347,3 +400,90 @@ impl Collapsed { .build() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + test_util::{harness, terminal, TestHarness, TestTerminal}, + view::test_util::TestComponent, + }; + use crossterm::event::KeyCode; + use rstest::{fixture, rstest}; + use slumber_core::{ + assert_matches, + collection::Recipe, + test_util::{by_id, Factory}, + }; + + /// Test the search box + #[rstest] + fn test_search( + harness: TestHarness, + terminal: TestTerminal, + recipes: RecipeTree, + ) { + let mut component = TestComponent::new( + &harness, + &terminal, + RecipeListPane::new(&recipes), + (), + ); + // Clear initial events + assert_matches!( + component.drain_draw().events(), + &[Event::HttpSelectRequest(None)], + ); + + // Enter search + component.send_key(KeyCode::Char('/')).assert_empty(); + assert!(component.data().search_focused); + + // Find something. Match should be caseless. The recipe selection + // triggers an event to load the latest request + assert_matches!( + component.send_text("recipe 2").events(), + &[Event::HttpSelectRequest(None)] + ); + assert_eq!( + component + .data() + .select + .data() + .selected() + .map(|item| &item.id), + Some(&RecipeId::from("recipe2")) + ); + + // Exit search + component.send_key(KeyCode::Esc).assert_empty(); + assert!(!component.data().search_focused); + } + + #[fixture] + fn recipes() -> RecipeTree { + by_id([ + Recipe { + id: "recipe1".into(), + name: Some("Recipe 1".into()), + ..Recipe::factory(()) + }, + Recipe { + id: "recipe2".into(), + name: Some("Recipe 2".into()), + ..Recipe::factory(()) + }, + Recipe { + id: "recipe3".into(), + name: Some("Recipe 3".into()), + ..Recipe::factory(()) + }, + Recipe { + id: "recipe22".into(), + name: Some("Recipe 22".into()), + ..Recipe::factory(()) + }, + ]) + .into() + } +} diff --git a/crates/tui/src/view/state/select.rs b/crates/tui/src/view/state/select.rs index 49395284..38937592 100644 --- a/crates/tui/src/view/state/select.rs +++ b/crates/tui/src/view/state/select.rs @@ -221,6 +221,15 @@ impl SelectState { self.select_delta(1); } + /// Select the first item in the list that matches the given predicate. If + /// no items match, the selection remains unchanged + pub fn find(&mut self, predicate: impl Fn(&Item) -> bool) { + let match_index = self.items().position(predicate); + if let Some(index) = match_index { + self.select_index(index); + } + } + /// Select an item by index fn select_index(&mut self, index: usize) { let state = self.state.get_mut(); @@ -630,6 +639,17 @@ mod tests { ]) } + /// Test the `find` method + #[rstest] + fn test_find() { + let mut select = SelectState::<_, ListState>::builder(vec![ + "alpha", "bravo", "charlie", + ]) + .build(); + select.find(|item| item.contains("avo")); + assert_eq!(select.selected(), Some(&"bravo")); + } + #[fixture] fn items() -> (Vec<&'static str>, List<'static>) { let items = vec!["a", "b", "c"];