Implement basic fuzzy filtering in picker

Max Brunsfeld created

Change summary

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(-)

Detailed changes

Cargo.lock 🔗

@@ -8558,6 +8558,7 @@ dependencies = [
  "chrono",
  "clap 4.4.4",
  "editor2",
+ "fuzzy2",
  "gpui2",
  "itertools 0.11.0",
  "language2",

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<D: PickerDelegate> {
     pub delegate: D,
     scroll_handle: UniformListScrollHandle,
     editor: View<Editor>,
+    pending_update_matches: Option<Task<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<Picker<Self>>);
 
     // fn placeholder_text(&self) -> Arc<str>;
-    // fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()>;
+    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()>;
 
     fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<Self>>);
     fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>);
@@ -35,10 +36,13 @@ pub trait PickerDelegate: Sized + 'static {
 
 impl<D: PickerDelegate> Picker<D> {
     pub fn new(delegate: D, cx: &mut ViewContext<Self>) -> 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<D: PickerDelegate> Picker<D> {
     fn secondary_confirm(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
         self.delegate.confirm(true, cx);
     }
+
+    fn on_input_editor_event(
+        &mut self,
+        _: View<Editor>,
+        event: &editor::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        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<Self>) {
+        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<Self>) {
+        let index = self.delegate.selected_index();
+        self.scroll_handle.scroll_to_item(index);
+        self.pending_update_matches = None;
+        cx.notify();
+    }
 }
 
 impl<D: PickerDelegate> Render for Picker<D> {

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

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<SharedString>,
+    candidates: Arc<[StringMatchCandidate]>,
+    matches: Vec<usize>,
     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<Picker<Self>>;
 
@@ -28,6 +51,10 @@ impl PickerDelegate for Delegate {
         cx: &mut gpui::ViewContext<Picker<Self>>,
     ) -> 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<Picker<Self>>) {
+        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<Picker<Self>>) {
         cx.quit();
     }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        cx: &mut gpui::ViewContext<Picker<Self>>,
+    ) -> 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
                 }),