From 8489a3f7f70a7c037a8546ca98e0d4021c6e29d9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 27 Jun 2024 18:07:08 +0800 Subject: [PATCH] Fix Picker to search filter. --- crates/ui-story/src/dropdown_story.rs | 152 ++++++++++++++++++++++++++ crates/ui-story/src/lib.rs | 5 +- crates/ui-story/src/picker_story.rs | 33 +++--- crates/ui/src/dropdown.rs | 124 ++++++++++++++++++++- crates/ui/src/input.rs | 16 ++- crates/ui/src/list/list_item.rs | 14 ++- crates/ui/src/picker.rs | 44 ++++++-- 7 files changed, 356 insertions(+), 32 deletions(-) create mode 100644 crates/ui-story/src/dropdown_story.rs diff --git a/crates/ui-story/src/dropdown_story.rs b/crates/ui-story/src/dropdown_story.rs new file mode 100644 index 00000000..49eb08e4 --- /dev/null +++ b/crates/ui-story/src/dropdown_story.rs @@ -0,0 +1,152 @@ +use std::{rc::Rc, vec}; + +use gpui::{ + div, ClickEvent, IntoElement, ParentElement, Render, RenderOnce, SharedString, Styled, + ViewContext, WindowContext, +}; + +use ui::{ + checkbox::Checkbox, + dropdown::{Dropdown, DropdownItem}, + h_flex, v_flex, Disableable as _, Selection, +}; + +use super::story_case; + +struct Country { + name: &'static str, + code: &'static str, +} + +impl Country { + pub fn new(name: &'static str, code: &'static str) -> Self { + Self { name, code } + } +} + +impl DropdownItem for Country { + fn title(&self) -> SharedString { + self.name.into() + } + + fn value(&self) -> SharedString { + self.code.into() + } +} + +#[derive(IntoElement)] +pub struct DropdownStory { + dropdown1: Dropdown, + dropdown2: Dropdown, + countries: Vec, +} + +impl DropdownStory { + pub(crate) fn new(cx: &mut WindowContext) -> Self { + let countries = vec![ + Country::new("United States", "US"), + Country::new("Canada", "CA"), + Country::new("Mexico", "MX"), + Country::new("Brazil", "BR"), + Country::new("Argentina", "AR"), + Country::new("Chile", "CL"), + Country::new("China", "CN"), + Country::new("Peru", "PE"), + Country::new("Colombia", "CO"), + Country::new("Venezuela", "VE"), + Country::new("Ecuador", "EC"), + ]; + + let items2 = vec![ + "Apple", + "Orange", + "Banana", + "Grape", + "Pineapple", + "Watermelon", + "Avocado", + ]; + + Self { + countries, + dropdown1: Dropdown::new("dropdown-country", Rc::new(countries), cx), + dropdown2: Dropdown::new("dropdown-fruit", Rc::new(items2), cx), + } + } + + #[allow(unused)] + fn on_click(sel: &Selection, cx: &mut WindowContext) { + println!("Check value changed: {}", sel); + } +} + +impl RenderOnce for CheckboxStory { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + story_case( + "Checkbox", + "A control that allows the user to toggle between checked and not checked.", + ) + .child( + v_flex().items_start().justify_start().gap_4().child( + h_flex() + .items_center() + .gap_4() + .child(self.check1) + .child(self.check1_1) + .child( + Checkbox::new("check1_2", cx) + .checked(Selection::Selected) + .on_click(Self::on_click), + ), + ), + ) + .child( + h_flex() + .items_center() + .gap_4() + .child( + Checkbox::new("check2", cx) + .checked(Selection::Unselected) + .label("With label (Unchecked)") + .on_click(Self::on_click), + ) + .child( + Checkbox::new("check2_1", cx) + .label("With Label (Indeterminate)") + .checked(Selection::Indeterminate) + .on_click(Self::on_click), + ) + .child( + Checkbox::new("check2_2", cx) + .label("With Label (Checked)") + .checked(Selection::Selected) + .on_click(Self::on_click), + ), + ) + .child( + h_flex().items_center().gap_4().child( + h_flex() + .items_center() + .gap_4() + .child( + Checkbox::new("check3", cx) + .label("Disabled Checked") + .checked(Selection::Selected) + .disabled(true), + ) + .child( + Checkbox::new("check3_1", cx) + .label("Disabled Unchecked") + .checked(Selection::Unselected) + .disabled(true), + ) + .child( + Checkbox::new("check3_2", cx) + .label("Disabled Indeterminate") + .checked(Selection::Indeterminate) + .disabled(true), + ), + ), + ) + } +} diff --git a/crates/ui-story/src/lib.rs b/crates/ui-story/src/lib.rs index bf496d65..55568aab 100644 --- a/crates/ui-story/src/lib.rs +++ b/crates/ui-story/src/lib.rs @@ -6,11 +6,10 @@ use gpui::{ RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _, View, ViewContext, VisualContext, WindowContext, }; -use picker_story::PickerStory; -use switch_story::SwitchStory; mod button_story; mod checkbox_story; +// mod dropdown_story; mod input_story; mod picker_story; mod switch_story; @@ -23,6 +22,8 @@ use ui::{ use button_story::ButtonStory; use input_story::InputStory; +use picker_story::PickerStory; +use switch_story::SwitchStory; pub fn story_case(name: &'static str, description: &'static str) -> StoryContainer { StoryContainer::new(name, description) diff --git a/crates/ui-story/src/picker_story.rs b/crates/ui-story/src/picker_story.rs index 468f4ddb..6a4c8348 100644 --- a/crates/ui-story/src/picker_story.rs +++ b/crates/ui-story/src/picker_story.rs @@ -22,7 +22,6 @@ actions!(picker_story, [DismissPicker]); pub struct ListItemDeletegate { story: WeakView, selected_index: usize, - items: Vec, matches: Vec, } @@ -48,7 +47,7 @@ impl PickerDelegate for ListItemDeletegate { _cx: &mut ViewContext>, ) -> Option { if let Some(item) = self.matches.get(ix) { - let list_item = ListItem::new(format!("item-{}", ix)) + let list_item = ListItem::new(("item", ix)) .check_icon(ui::IconName::Check) .selected(selected) .py_1() @@ -65,14 +64,18 @@ impl PickerDelegate for ListItemDeletegate { query: &str, cx: &mut ViewContext>, ) -> gpui::Task<()> { - let matched_items = self - .items - .iter() - .filter(|item| item.contains(query)) - .cloned() - .collect(); + if let Some(story) = self.story.upgrade() { + let matched_items = story + .read(cx) + .items + .iter() + .filter(|item| item.contains(query)) + .cloned() + .collect(); - self.matches = matched_items; + self.matches = matched_items; + cx.notify(); + } Task::ready(()) } @@ -102,12 +105,13 @@ impl PickerDelegate for ListItemDeletegate { pub struct PickerStory { picker: View>, open: bool, + items: Vec, selected_value: Option, } impl PickerStory { pub(crate) fn new(cx: &mut ViewContext) -> Self { - let items = [ + let items: Vec = [ "Baguette (France)", "Baklava (Turkey)", "Beef Wellington (UK)", @@ -157,18 +161,18 @@ impl PickerStory { "Tortilla (Spain)", "Tzatziki (Greece)", "Wiener Schnitzel (Austria)", - ]; + ] + .iter() + .map(|s| s.to_string()) + .collect(); let story = cx.view().downgrade(); let picker = cx.new_view(|cx| { - let items: Vec = items.iter().map(|s| s.to_string()).collect(); - let mut picker = Picker::uniform_list( ListItemDeletegate { story, selected_index: 0, matches: items.clone(), - items, }, cx, ) @@ -180,6 +184,7 @@ impl PickerStory { }); Self { + items, picker, open: false, selected_value: None, diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index 42469743..6a6c7381 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -1,9 +1,123 @@ +use std::rc::Rc; +use gpui::{ + div, prelude::FluentBuilder as _, Div, ElementId, InteractiveElement, IntoElement, + ParentElement as _, Render, RenderOnce, SharedString, Stateful, Styled as _, View, ViewContext, + VisualContext as _, WeakView, +}; -pub struct DropdownItemDelegate { - items: Vec, +use crate::{ + list::ListItem, + picker::{Picker, PickerDelegate}, + IconName, +}; + +/// A trait for items that can be displayed in a dropdown. +pub trait DropdownItem { + fn title(&self) -> &str; + fn value(&self) -> &str; } -pub struct Dropdown { - picker: -} \ No newline at end of file +pub trait DropdownDelegate { + type Item: DropdownItem; + + fn len(&self) -> usize; + fn get(&self, index: usize) -> Option<&Self::Item>; +} + +struct DropdownPickerDelegate { + dropdown: WeakView>, + selected_index: usize, +} + +impl PickerDelegate for DropdownPickerDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.delegate.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, index: usize, _cx: &mut gpui::ViewContext>) { + self.selected_index = index; + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut gpui::ViewContext>, + ) -> Option { + if let Some(item) = self.delegate.get(ix) { + let list_item = ListItem::new(("list-item", ix)) + .check_icon(IconName::Check) + .selected(selected) + .py_1() + .px_3() + .child(item.title()); + Some(list_item) + } else { + None + } + } + + fn dismissed(&mut self, cx: &mut ViewContext>) { + if let Some(view) = self.dropdown.upgrade() { + cx.update_view(&view, |view, cx| { + view.open = false; + cx.notify(); + }); + } + } + + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + if let Some(view) = self.dropdown.upgrade() { + cx.update_view(&view, |view, cx| { + if let Some(item) = self.delegate.get(self.selected_index) { + view.value = Some(item.value()); + } + view.open = false; + cx.notify(); + }); + } + } +} + +pub struct Dropdown { + base: Stateful
, + delegate: D, + picker: View>, + open: bool, + /// The value of the selected item. + value: Option, +} + +impl Dropdown { + pub fn new(id: impl Into, delegate: D, cx: &mut ViewContext) -> Self { + let delegate = DropdownPickerDelegate { + // delegate, + dropdown: cx.view().downgrade(), + selected_index: 0, + }; + + let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx)); + Self { + delegate, + base: div().id(id.into()), + picker, + open: false, + value: None, + } + } +} + +impl RenderOnce for Dropdown { + fn render(self, cx: &mut gpui::WindowContext) -> impl IntoElement { + self.base + .child(self.value.unwrap_or_else(|| "Select...".into())) + .when(self.open, |this| this.child(self.picker)) + } +} diff --git a/crates/ui/src/input.rs b/crates/ui/src/input.rs index eb1a441e..ce2f8e4b 100644 --- a/crates/ui/src/input.rs +++ b/crates/ui/src/input.rs @@ -7,7 +7,7 @@ use prelude::FluentBuilder as _; use unicode_segmentation::*; actions!( - text_input, + input, [ Backspace, Delete, @@ -24,9 +24,14 @@ actions!( Paste, MoveToStartOfLine, MoveToEndOfLine, + TextChanged, ] ); +pub enum TextEvent { + Input { text: SharedString }, +} + pub fn init(cx: &mut AppContext) { cx.bind_keys([ KeyBinding::new("backspace", Backspace, None), @@ -60,6 +65,8 @@ pub struct TextInput { appearance: bool, } +impl EventEmitter for TextInput {} + impl TextInput { pub fn new(cx: &mut ViewContext) -> Self { Self { @@ -316,6 +323,9 @@ impl ViewInputHandler for TextInput { (self.text[0..range.start].to_owned() + new_text + &self.text[range.end..]).into(); self.selected_range = range.start + new_text.len()..range.start + new_text.len(); self.marked_range.take(); + cx.emit(TextEvent::Input { + text: self.text.clone(), + }); cx.notify(); } @@ -340,7 +350,9 @@ impl ViewInputHandler for TextInput { .map(|range_utf16| self.range_from_utf16(range_utf16)) .map(|new_range| new_range.start + range.start..new_range.end + range.end) .unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len()); - + cx.emit(TextEvent::Input { + text: self.text.clone(), + }); cx.notify(); } diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index c3d3d82d..06728e54 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -1,5 +1,5 @@ use gpui::{ - div, prelude::FluentBuilder as _, ClickEvent, Div, InteractiveElement, IntoElement, + div, prelude::FluentBuilder as _, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _, Style, Styled, WindowContext, }; @@ -17,7 +17,7 @@ pub struct ListItem { } impl ListItem { - pub fn new(id: impl Into) -> Self { + pub fn new(id: impl Into) -> Self { Self { base: h_flex().id(id.into()), disabled: false, @@ -33,6 +33,16 @@ impl ListItem { self } + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + pub fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self { self.on_click = Some(Box::new(handler)); self diff --git a/crates/ui/src/picker.rs b/crates/ui/src/picker.rs index df92a2a4..3b2245eb 100644 --- a/crates/ui/src/picker.rs +++ b/crates/ui/src/picker.rs @@ -24,7 +24,12 @@ actions!( ); use crate::{ - divider::Divider, empty::Empty, input::TextInput, label::Label, stock::*, theme::ActiveTheme, + divider::Divider, + empty::Empty, + input::{TextEvent, TextInput}, + label::Label, + stock::*, + theme::ActiveTheme, StyledExt as _, }; @@ -80,7 +85,9 @@ pub trait PickerDelegate: Sized + 'static { fn separators_after_indices(&self) -> Vec { Vec::new() } - fn update_matches(&mut self, query: &str, cx: &mut ViewContext>) -> Task<()>; + fn update_matches(&mut self, query: &str, cx: &mut ViewContext>) -> Task<()> { + Task::ready(()) + } fn confirm_update_query(&mut self, _cx: &mut ViewContext>) -> Option { None } @@ -173,11 +180,13 @@ impl Picker { placehoder: impl Into, cx: &mut ViewContext, ) -> View { - cx.new_view(|cx| { + let input = cx.new_view(|cx| { let mut input = TextInput::new(cx).appearance(false); input.set_placeholder(placehoder, cx); input - }) + }); + cx.subscribe(&input, Self::on_query_input_event).detach(); + input } pub fn list(delegate: D, cx: &mut ViewContext) -> Self { @@ -444,6 +453,22 @@ impl Picker { // } cx.notify(); } + + fn on_query_input_event( + &mut self, + _: View, + event: &TextEvent, + cx: &mut ViewContext, + ) { + #[allow(clippy::single_match)] + match event { + TextEvent::Input { text } => { + self.set_query(text, cx); + self.refresh(cx); + } + _ => {} + } + } } impl Render for Picker { @@ -481,9 +506,14 @@ impl Render for Picker { .when(self.delegate.match_count() == 0, |el| { el.child( v_flex() - .flex_grow() - .py_2() - .child(div().child(Label::new("No matched.").text_color(cx.theme().muted))), + .h_full() + .size_full() + .h_16() + .items_center() + .content_center() + .justify_center() + .text_color(cx.theme().muted_foreground) + .child("No matched."), ) }) .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, cx| {