From 06960df287853505b84018eb3218525f4803aca3 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 7 Nov 2023 17:24:04 -0800 Subject: [PATCH] Implement basic fuzzy filtering in picker --- Cargo.lock | 1 + crates/picker2/src/picker2.rs | 41 +++++- crates/storybook2/Cargo.toml | 1 + crates/storybook2/src/stories/picker.rs | 176 +++++++++++++++--------- 4 files changed, 153 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e52a8444e4..580de35aa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8558,6 +8558,7 @@ dependencies = [ "chrono", "clap 4.4.4", "editor2", + "fuzzy2", "gpui2", "itertools 0.11.0", "language2", diff --git a/crates/picker2/src/picker2.rs b/crates/picker2/src/picker2.rs index e80801229d..075cf10ff6 100644 --- a/crates/picker2/src/picker2.rs +++ b/crates/picker2/src/picker2.rs @@ -1,7 +1,7 @@ use editor::Editor; use gpui::{ div, uniform_list, Component, Div, FocusEnabled, ParentElement, Render, StatefulInteractivity, - StatelessInteractive, Styled, UniformListScrollHandle, View, ViewContext, VisualContext, + StatelessInteractive, Styled, Task, UniformListScrollHandle, View, ViewContext, VisualContext, WindowContext, }; use std::cmp; @@ -10,6 +10,7 @@ pub struct Picker { pub delegate: D, scroll_handle: UniformListScrollHandle, editor: View, + pending_update_matches: Option>>, } pub trait PickerDelegate: Sized + 'static { @@ -20,7 +21,7 @@ pub trait PickerDelegate: Sized + 'static { fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>); // fn placeholder_text(&self) -> Arc; - // fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()>; + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()>; fn confirm(&mut self, secondary: bool, cx: &mut ViewContext>); fn dismissed(&mut self, cx: &mut ViewContext>); @@ -35,10 +36,13 @@ pub trait PickerDelegate: Sized + 'static { impl Picker { pub fn new(delegate: D, cx: &mut ViewContext) -> Self { + let editor = cx.build_view(|cx| Editor::single_line(cx)); + cx.subscribe(&editor, Self::on_input_editor_event).detach(); Self { delegate, scroll_handle: UniformListScrollHandle::new(), - editor: cx.build_view(|cx| Editor::single_line(cx)), + pending_update_matches: None, + editor, } } @@ -93,6 +97,37 @@ impl Picker { fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext) { self.delegate.confirm(true, cx); } + + fn on_input_editor_event( + &mut self, + _: View, + event: &editor::Event, + cx: &mut ViewContext, + ) { + if let editor::Event::BufferEdited = event { + let query = self.editor.read(cx).text(cx); + self.update_matches(query, cx); + } + } + + pub fn update_matches(&mut self, query: String, cx: &mut ViewContext) { + let update = self.delegate.update_matches(query, cx); + self.matches_updated(cx); + self.pending_update_matches = Some(cx.spawn(|this, mut cx| async move { + update.await; + this.update(&mut cx, |this, cx| { + this.matches_updated(cx); + }) + .ok() + })); + } + + fn matches_updated(&mut self, cx: &mut ViewContext) { + let index = self.delegate.selected_index(); + self.scroll_handle.scroll_to_item(index); + self.pending_update_matches = None; + cx.notify(); + } } impl Render for Picker { diff --git a/crates/storybook2/Cargo.toml b/crates/storybook2/Cargo.toml index 3bf06a9778..7c6776c930 100644 --- a/crates/storybook2/Cargo.toml +++ b/crates/storybook2/Cargo.toml @@ -15,6 +15,7 @@ backtrace-on-stack-overflow = "0.3.0" clap = { version = "4.4", features = ["derive", "string"] } editor = { package = "editor2", path = "../editor2" } chrono = "0.4" +fuzzy = { package = "fuzzy2", path = "../fuzzy2" } gpui = { package = "gpui2", path = "../gpui2" } itertools = "0.11.0" language = { package = "language2", path = "../language2" } diff --git a/crates/storybook2/src/stories/picker.rs b/crates/storybook2/src/stories/picker.rs index 0a0ee0c1d4..82a010e6b3 100644 --- a/crates/storybook2/src/stories/picker.rs +++ b/crates/storybook2/src/stories/picker.rs @@ -1,6 +1,9 @@ +use std::sync::Arc; + +use fuzzy::StringMatchCandidate; use gpui::{ - div, Component, Div, KeyBinding, ParentElement, Render, SharedString, StatelessInteractive, - Styled, View, VisualContext, WindowContext, + div, Component, Div, KeyBinding, ParentElement, Render, StatelessInteractive, Styled, Task, + View, VisualContext, WindowContext, }; use picker::{Picker, PickerDelegate}; use theme2::ActiveTheme; @@ -10,10 +13,30 @@ pub struct PickerStory { } struct Delegate { - candidates: Vec, + candidates: Arc<[StringMatchCandidate]>, + matches: Vec, selected_ix: usize, } +impl Delegate { + fn new(strings: &[&str]) -> Self { + Self { + candidates: strings + .iter() + .copied() + .enumerate() + .map(|(id, string)| StringMatchCandidate { + id, + char_bag: string.into(), + string: string.into(), + }) + .collect(), + matches: vec![], + selected_ix: 0, + } + } +} + impl PickerDelegate for Delegate { type ListItem = Div>; @@ -28,6 +51,10 @@ impl PickerDelegate for Delegate { cx: &mut gpui::ViewContext>, ) -> Self::ListItem { let colors = cx.theme().colors(); + let Some(candidate_ix) = self.matches.get(ix) else { + return div(); + }; + let candidate = self.candidates[*candidate_ix].string.clone(); div() .text_color(colors.text) @@ -39,7 +66,7 @@ impl PickerDelegate for Delegate { .bg(colors.element_active) .text_color(colors.text_accent) }) - .child(self.candidates[ix].clone()) + .child(candidate) } fn selected_index(&self) -> usize { @@ -52,16 +79,42 @@ impl PickerDelegate for Delegate { } fn confirm(&mut self, secondary: bool, cx: &mut gpui::ViewContext>) { + let candidate_ix = self.matches[self.selected_ix]; + let candidate = self.candidates[candidate_ix].string.clone(); + if secondary { - eprintln!("Secondary confirmed {}", self.candidates[self.selected_ix]) + eprintln!("Secondary confirmed {}", candidate) } else { - eprintln!("Confirmed {}", self.candidates[self.selected_ix]) + eprintln!("Confirmed {}", candidate) } } fn dismissed(&mut self, cx: &mut gpui::ViewContext>) { cx.quit(); } + + fn update_matches( + &mut self, + query: String, + cx: &mut gpui::ViewContext>, + ) -> Task<()> { + let candidates = self.candidates.clone(); + self.matches = cx + .background_executor() + .block(fuzzy::match_strings( + &candidates, + &query, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + )) + .into_iter() + .map(|r| r.candidate_id) + .collect(); + self.selected_ix = 0; + Task::ready(()) + } } impl PickerStory { @@ -87,63 +140,60 @@ impl PickerStory { PickerStory { picker: cx.build_view(|cx| { - let picker = Picker::new( - Delegate { - candidates: vec![ - "Baguette (France)".into(), - "Baklava (Turkey)".into(), - "Beef Wellington (UK)".into(), - "Biryani (India)".into(), - "Borscht (Ukraine)".into(), - "Bratwurst (Germany)".into(), - "Bulgogi (Korea)".into(), - "Burrito (USA)".into(), - "Ceviche (Peru)".into(), - "Chicken Tikka Masala (India)".into(), - "Churrasco (Brazil)".into(), - "Couscous (North Africa)".into(), - "Croissant (France)".into(), - "Dim Sum (China)".into(), - "Empanada (Argentina)".into(), - "Fajitas (Mexico)".into(), - "Falafel (Middle East)".into(), - "Feijoada (Brazil)".into(), - "Fish and Chips (UK)".into(), - "Fondue (Switzerland)".into(), - "Goulash (Hungary)".into(), - "Haggis (Scotland)".into(), - "Kebab (Middle East)".into(), - "Kimchi (Korea)".into(), - "Lasagna (Italy)".into(), - "Maple Syrup Pancakes (Canada)".into(), - "Moussaka (Greece)".into(), - "Pad Thai (Thailand)".into(), - "Paella (Spain)".into(), - "Pancakes (USA)".into(), - "Pasta Carbonara (Italy)".into(), - "Pavlova (Australia)".into(), - "Peking Duck (China)".into(), - "Pho (Vietnam)".into(), - "Pierogi (Poland)".into(), - "Pizza (Italy)".into(), - "Poutine (Canada)".into(), - "Pretzel (Germany)".into(), - "Ramen (Japan)".into(), - "Rendang (Indonesia)".into(), - "Sashimi (Japan)".into(), - "Satay (Indonesia)".into(), - "Shepherd's Pie (Ireland)".into(), - "Sushi (Japan)".into(), - "Tacos (Mexico)".into(), - "Tandoori Chicken (India)".into(), - "Tortilla (Spain)".into(), - "Tzatziki (Greece)".into(), - "Wiener Schnitzel (Austria)".into(), - ], - selected_ix: 0, - }, - cx, - ); + let mut delegate = Delegate::new(&[ + "Baguette (France)", + "Baklava (Turkey)", + "Beef Wellington (UK)", + "Biryani (India)", + "Borscht (Ukraine)", + "Bratwurst (Germany)", + "Bulgogi (Korea)", + "Burrito (USA)", + "Ceviche (Peru)", + "Chicken Tikka Masala (India)", + "Churrasco (Brazil)", + "Couscous (North Africa)", + "Croissant (France)", + "Dim Sum (China)", + "Empanada (Argentina)", + "Fajitas (Mexico)", + "Falafel (Middle East)", + "Feijoada (Brazil)", + "Fish and Chips (UK)", + "Fondue (Switzerland)", + "Goulash (Hungary)", + "Haggis (Scotland)", + "Kebab (Middle East)", + "Kimchi (Korea)", + "Lasagna (Italy)", + "Maple Syrup Pancakes (Canada)", + "Moussaka (Greece)", + "Pad Thai (Thailand)", + "Paella (Spain)", + "Pancakes (USA)", + "Pasta Carbonara (Italy)", + "Pavlova (Australia)", + "Peking Duck (China)", + "Pho (Vietnam)", + "Pierogi (Poland)", + "Pizza (Italy)", + "Poutine (Canada)", + "Pretzel (Germany)", + "Ramen (Japan)", + "Rendang (Indonesia)", + "Sashimi (Japan)", + "Satay (Indonesia)", + "Shepherd's Pie (Ireland)", + "Sushi (Japan)", + "Tacos (Mexico)", + "Tandoori Chicken (India)", + "Tortilla (Spain)", + "Tzatziki (Greece)", + "Wiener Schnitzel (Austria)", + ]); + delegate.update_matches("".into(), cx).detach(); + + let picker = Picker::new(delegate, cx); picker.focus(cx); picker }),