diff --git a/Cargo.lock b/Cargo.lock index 2e9579d..e2158cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,9 +52,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.7" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" @@ -202,9 +202,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.9" +version = "4.5.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64acc1846d54c1fe936a78dc189c34e28d3f5afc348403f28ecf53660b9b8462" +checksum = "769b0145982b4b48713e01ec42d61614425f27b7058bda7180a3a41f30104796" dependencies = [ "clap_builder", "clap_derive", @@ -212,9 +212,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.9" +version = "4.5.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb8393d67ba2e7bfaf28a23458e4e2b543cc73a99595511eb207fdb8aede942" +checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" dependencies = [ "anstream", "anstyle", @@ -224,9 +224,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.8" +version = "4.5.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" +checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" dependencies = [ "heck", "proc-macro2", @@ -236,9 +236,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.1" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "clipboard-win" @@ -491,6 +491,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "gethostname" version = "0.4.3" @@ -531,6 +540,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + [[package]] name = "heck" version = "0.5.0" @@ -561,12 +576,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.2.6" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -670,7 +685,7 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -1412,7 +1427,7 @@ dependencies = [ [[package]] name = "thoth-cli" -version = "0.1.72" +version = "0.1.73" dependencies = [ "anyhow", "arboard", @@ -1420,6 +1435,7 @@ dependencies = [ "clap", "crossterm", "dirs", + "fuzzy-matcher", "mockall", "once_cell", "pulldown-cmark", @@ -1434,6 +1450,16 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "tiff" version = "0.9.1" diff --git a/Cargo.toml b/Cargo.toml index e204b05..51aa4a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ unicode-width = "0.1.10" rand = "0.8.5" once_cell = "1.19.0" arboard = "3.4.1" +fuzzy-matcher = "0.3.7" [[bin]] name = "thoth" diff --git a/src/title_select_popup.rs b/src/title_select_popup.rs index da3a9f0..f00aa88 100644 --- a/src/title_select_popup.rs +++ b/src/title_select_popup.rs @@ -1,52 +1,100 @@ +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; + pub struct TitleSelectPopup { pub titles: Vec, + pub filtered_titles: Vec, pub selected_index: usize, pub visible: bool, pub scroll_offset: usize, + pub search_query: String, +} + +pub struct TitleMatch { + pub title: String, + pub index: usize, + pub score: i64, +} + +impl TitleMatch { + pub fn new(title: String, index: usize, score: i64) -> Self { + Self { + title, + index, + score, + } + } +} + +impl Default for TitleSelectPopup { + fn default() -> Self { + Self::new() + } } impl TitleSelectPopup { pub fn new() -> Self { TitleSelectPopup { titles: Vec::new(), + filtered_titles: Vec::new(), selected_index: 0, visible: false, scroll_offset: 0, + search_query: String::new(), } } + pub fn set_titles(&mut self, titles: Vec) { + self.titles = titles; + self.filtered_titles = self + .titles + .iter() + .enumerate() + .map(|(idx, title)| TitleMatch::new(title.clone(), idx, 0)) + .collect(); + } + + pub fn reset_filtered_titles(&mut self) { + self.filtered_titles = self + .titles + .iter() + .enumerate() + .map(|(idx, title)| TitleMatch::new(title.clone(), idx, 0)) + .collect(); + } + pub fn move_selection_up(&mut self, visible_items: usize) { - if self.titles.is_empty() { + if self.filtered_titles.is_empty() { return; } if self.selected_index > 0 { self.selected_index -= 1; } else { - self.selected_index = self.titles.len() - 1; + self.selected_index = self.filtered_titles.len() - 1; } if self.selected_index < self.scroll_offset { self.scroll_offset = self.selected_index; } - if self.selected_index == self.titles.len() - 1 { - self.scroll_offset = self.titles.len().saturating_sub(visible_items); + if self.selected_index == self.filtered_titles.len() - 1 { + self.scroll_offset = self.filtered_titles.len().saturating_sub(visible_items); } } pub fn move_selection_down(&mut self, visible_items: usize) { - if self.titles.is_empty() { + if self.filtered_titles.is_empty() { return; } - if self.selected_index < self.titles.len() - 1 { + if self.selected_index < self.filtered_titles.len() - 1 { self.selected_index += 1; } else { self.selected_index = 0; self.scroll_offset = 0; } - let max_scroll = self.titles.len().saturating_sub(visible_items); + let max_scroll = self.filtered_titles.len().saturating_sub(visible_items); if self.selected_index >= self.scroll_offset + visible_items { self.scroll_offset = (self.selected_index + 1).saturating_sub(visible_items); if self.scroll_offset > max_scroll { @@ -54,11 +102,29 @@ impl TitleSelectPopup { } } } -} -impl Default for TitleSelectPopup { - fn default() -> Self { - Self::new() + pub fn update_search(&mut self) { + let matcher = SkimMatcherV2::default(); + + let mut matched_titles: Vec = self + .titles + .iter() + .enumerate() + .filter_map(|(idx, title)| { + matcher + .fuzzy_match(title, &self.search_query) + .map(|score| TitleMatch::new(title.clone(), idx, score)) + }) + .collect(); + + matched_titles.sort_by(|a, b| b.score.cmp(&a.score)); + + self.filtered_titles = matched_titles; + + if !self.filtered_titles.is_empty() { + self.selected_index = 0; + self.scroll_offset = 0; + } } } @@ -77,16 +143,18 @@ mod tests { #[test] fn test_title_select_popup_add_titles() { let mut popup = TitleSelectPopup::new(); - popup.titles = vec!["Title1".to_string(), "Title2".to_string()]; + let titles = vec!["Title1".to_string(), "Title2".to_string()]; + popup.set_titles(titles); assert_eq!(popup.titles.len(), 2); assert_eq!(popup.titles[0], "Title1"); assert_eq!(popup.titles[1], "Title2"); + assert_eq!(popup.filtered_titles.len(), 2); } #[test] fn test_wrap_around_selection() { let mut popup = TitleSelectPopup::new(); - popup.titles = vec!["1".to_string(), "2".to_string(), "3".to_string()]; + popup.set_titles(vec!["1".to_string(), "2".to_string(), "3".to_string()]); popup.selected_index = 0; popup.move_selection_up(2); @@ -98,4 +166,22 @@ mod tests { assert_eq!(popup.selected_index, 0); assert_eq!(popup.scroll_offset, 0); } + + #[test] + fn test_search_filtering() { + let mut popup = TitleSelectPopup::new(); + popup.set_titles(vec![ + "Apple".to_string(), + "Banana".to_string(), + "Apricot".to_string(), + ]); + + popup.search_query = "ap".to_string(); + popup.update_search(); + assert_eq!(popup.filtered_titles.len(), 2); + assert!(popup.filtered_titles.iter().any(|tm| tm.title == "Apple")); + assert!(popup.filtered_titles.iter().any(|tm| tm.title == "Apricot")); + + assert!(popup.filtered_titles[0].score >= popup.filtered_titles[1].score); + } } diff --git a/src/ui.rs b/src/ui.rs index 0f29064..471d26d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -200,24 +200,34 @@ pub fn render_title_select_popup(f: &mut Frame, popup: &TitleSelectPopup) { let area = centered_rect(80, 80, f.size()); f.render_widget(ratatui::widgets::Clear, area); - let visible_height = area.height.saturating_sub(BORDER_PADDING_SIZE as u16) as usize; + let constraints = vec![Constraint::Min(1), Constraint::Length(3)]; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(area); + + let main_area = chunks[0]; + let search_box = chunks[1]; + + let visible_height = main_area.height.saturating_sub(BORDER_PADDING_SIZE as u16) as usize; let start_idx = popup.scroll_offset; - let end_idx = (popup.scroll_offset + visible_height).min(popup.titles.len()); - let visible_titles = &popup.titles[start_idx..end_idx]; + let end_idx = (popup.scroll_offset + visible_height).min(popup.filtered_titles.len()); + let visible_titles = &popup.filtered_titles[start_idx..end_idx]; let items: Vec = visible_titles .iter() .enumerate() - .map(|(i, title)| { + .map(|(i, title_match)| { let absolute_idx = i + popup.scroll_offset; if absolute_idx == popup.selected_index { Line::from(vec![Span::styled( - format!("> {}", title), + format!("> {}", title_match.title), Style::default().fg(Color::Yellow), )]) } else { - Line::from(vec![Span::raw(format!(" {}", title))]) + Line::from(vec![Span::raw(format!(" {}", title_match.title))]) } }) .collect(); @@ -231,7 +241,16 @@ pub fn render_title_select_popup(f: &mut Frame, popup: &TitleSelectPopup) { .block(block) .wrap(ratatui::widgets::Wrap { trim: true }); - f.render_widget(paragraph, area); + f.render_widget(paragraph, main_area); + + let search_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(ORANGE)) + .title("Search"); + + let search_text = Paragraph::new(popup.search_query.as_str()).block(search_block); + + f.render_widget(search_text, search_box); } pub fn render_ui_popup(f: &mut Frame, popup: &UiPopup) { @@ -376,13 +395,17 @@ mod tests { fn test_render_title_select_popup() { let backend = TestBackend::new(100, 30); let mut terminal = Terminal::new(backend).unwrap(); - let popup = TitleSelectPopup { - titles: vec!["Title1".to_string(), "Title2".to_string()], + let mut popup = TitleSelectPopup { + titles: Vec::new(), selected_index: 0, visible: true, scroll_offset: 0, + search_query: "".to_string(), + filtered_titles: Vec::new(), }; + popup.set_titles(vec!["Title1".to_string(), "Title2".to_string()]); + terminal .draw(|f| { render_title_select_popup(f, &popup); diff --git a/src/ui_handler.rs b/src/ui_handler.rs index 90cc7b9..eb44049 100644 --- a/src/ui_handler.rs +++ b/src/ui_handler.rs @@ -243,20 +243,31 @@ fn handle_title_select_popup_input(state: &mut UIState, key: event::KeyEvent) -> // The borders are rendered using unicode box-drawing characters: // top border : ┌───┐ // bottom border : └───┘ - // let visible_items = state.scrollable_textarea.viewport_height.saturating_sub(2) as usize; let visible_items = (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize - BORDER_PADDING_SIZE; match key.code { KeyCode::Enter => { - state - .scrollable_textarea - .jump_to_textarea(state.title_select_popup.selected_index); - state.title_select_popup.visible = false; + if !state.title_select_popup.filtered_titles.is_empty() { + let selected_title_match = &state.title_select_popup.filtered_titles + [state.title_select_popup.selected_index]; + state + .scrollable_textarea + .jump_to_textarea(selected_title_match.index); + state.title_select_popup.visible = false; + if !state.title_select_popup.search_query.is_empty() { + state.title_select_popup.search_query.clear(); + state.title_select_popup.reset_filtered_titles(); + } + } } KeyCode::Esc => { state.title_select_popup.visible = false; state.edit_commands_popup.visible = false; + if !state.title_select_popup.search_query.is_empty() { + state.title_select_popup.search_query.clear(); + state.title_select_popup.reset_filtered_titles(); + } } KeyCode::Up => { state.title_select_popup.move_selection_up(visible_items); @@ -264,6 +275,15 @@ fn handle_title_select_popup_input(state: &mut UIState, key: event::KeyEvent) -> KeyCode::Down => { state.title_select_popup.move_selection_down(visible_items); } + KeyCode::Char(c) => { + state.title_select_popup.search_query.push(c); + state.title_select_popup.update_search(); + } + KeyCode::Backspace => { + state.title_select_popup.search_query.pop(); + state.title_select_popup.update_search(); + } + _ => {} } Ok(false) @@ -356,7 +376,10 @@ fn handle_normal_input( if key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::SHIFT) => { - state.title_select_popup.titles = state.scrollable_textarea.titles.clone(); + // populate title_select_popup with the current titles from the textareas + state + .title_select_popup + .set_titles(state.scrollable_textarea.titles.clone()); state.title_select_popup.selected_index = 0; state.title_select_popup.visible = true; }