Cargo.lock 🔗
@@ -8558,6 +8558,7 @@ dependencies = [
"chrono",
"clap 4.4.4",
"editor2",
+ "fuzzy2",
"gpui2",
"itertools 0.11.0",
"language2",
Max Brunsfeld created
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(-)
@@ -8558,6 +8558,7 @@ dependencies = [
"chrono",
"clap 4.4.4",
"editor2",
+ "fuzzy2",
"gpui2",
"itertools 0.11.0",
"language2",
@@ -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> {
@@ -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" }
@@ -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
}),