diff --git a/crates/story/src/dropdown_story.rs b/crates/story/src/dropdown_story.rs index 781a0d2b..bf29a85d 100644 --- a/crates/story/src/dropdown_story.rs +++ b/crates/story/src/dropdown_story.rs @@ -4,10 +4,10 @@ use gpui::{ }; use ui::{ - dropdown::{Dropdown, DropdownEvent, DropdownItem}, + dropdown::{Dropdown, DropdownEvent, DropdownItem, SearchableVec}, h_flex, theme::ActiveTheme, - v_flex, Selection, + v_flex, IconName, Selection, }; struct Country { @@ -38,7 +38,7 @@ impl DropdownItem for Country { pub struct DropdownStory { country_dropdown: View>>, - fruit_dropdown: View>>, + fruit_dropdown: View>>, simple_dropdown1: View>>, simple_dropdown2: View>>, simple_dropdown3: View>>, @@ -64,17 +64,21 @@ impl DropdownStory { Dropdown::new("dropdown-country", countries, Some(6), cx).cleanable(true) }); - let fruits = vec![ - "Apple", - "Orange", - "Banana", - "Grape", - "Pineapple", - "Watermelon & This is a longlonglonglonglonglonglonglonglong title", - "Avocado", - ]; - let fruit_dropdown = - cx.new_view(|cx| Dropdown::string_list("dropdown-fruits", fruits, None, cx)); + let fruits = SearchableVec::new(vec![ + "Apple".into(), + "Orange".into(), + "Banana".into(), + "Grape".into(), + "Pineapple".into(), + "Watermelon & This is a longlonglonglonglonglonglonglonglong title".into(), + "Avocado".into(), + ]); + let fruit_dropdown = cx.new_view(|cx| { + Dropdown::new("dropdown-fruits", fruits, None, cx) + .icon(IconName::Search) + .width(px(200.)) + .menu_width(px(320.)) + }); cx.new_view(|cx| { cx.subscribe(&country_dropdown, Self::on_dropdown_event) @@ -84,9 +88,9 @@ impl DropdownStory { country_dropdown, fruit_dropdown, simple_dropdown1: cx.new_view(|cx| { - Dropdown::string_list( + Dropdown::new( "string-list1", - vec!["QPUI", "Iced", "QT", "Cocoa"], + vec!["QPUI".into(), "Iced".into(), "QT".into(), "Cocoa".into()], Some(0), cx, ) @@ -95,9 +99,14 @@ impl DropdownStory { .title_prefix("UI: ") }), simple_dropdown2: cx.new_view(|cx| { - Dropdown::string_list( + Dropdown::new( "string-list2", - vec!["Rust", "Go", "C++", "JavaScript"], + vec![ + "Rust".into(), + "Go".into(), + "C++".into(), + "JavaScript".into(), + ], None, cx, ) @@ -106,7 +115,7 @@ impl DropdownStory { .title_prefix("Language: ") }), simple_dropdown3: cx.new_view(|cx| { - Dropdown::string_list("string-list3", Vec::::new(), None, cx) + Dropdown::new("string-list3", Vec::::new(), None, cx) .size(ui::Size::Small) .empty(|cx| { h_flex() diff --git a/crates/ui/src/dropdown.rs b/crates/ui/src/dropdown.rs index 8d815dc7..87fec5ff 100644 --- a/crates/ui/src/dropdown.rs +++ b/crates/ui/src/dropdown.rs @@ -1,8 +1,9 @@ use gpui::{ actions, deferred, div, prelude::FluentBuilder, px, rems, AnyElement, AppContext, ClickEvent, DismissEvent, Div, Element, ElementId, EventEmitter, FocusHandle, Focusable, FocusableView, - InteractiveElement, IntoElement, KeyBinding, LayoutId, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, View, ViewContext, VisualContext, WeakView, WindowContext, + InteractiveElement, IntoElement, KeyBinding, LayoutId, Length, ParentElement, Render, + SharedString, StatefulInteractiveElement, Styled, Task, View, ViewContext, VisualContext, + WeakView, WindowContext, }; use crate::{ @@ -57,7 +58,7 @@ impl DropdownItem for SharedString { } } -pub trait DropdownDelegate { +pub trait DropdownDelegate: Sized { type Item: DropdownItem; fn len(&self) -> usize; @@ -75,6 +76,14 @@ pub trait DropdownDelegate { { (0..self.len()).find(|&i| self.get(i).map_or(false, |item| item.value() == value)) } + + fn can_search(&self) -> bool { + false + } + + fn perform_search(&mut self, _query: &str, _cx: &mut ViewContext>) -> Task<()> { + Task::Ready(Some(())) + } } impl DropdownDelegate for Vec { @@ -129,15 +138,11 @@ where if let Some(item) = self.delegate.get(ix) { let list_item = ListItem::new(("list-item", ix)) .check_icon(IconName::Check) + .cursor_pointer() .selected(selected) .input_text_size(size) .list_size(size) - .child( - div() - .whitespace_nowrap() - .overflow_hidden() - .child(item.title().to_string()), - ); + .child(div().whitespace_nowrap().child(item.title().to_string())); Some(list_item) } else { None @@ -170,9 +175,34 @@ where } } + fn perform_search(&mut self, query: &str, cx: &mut ViewContext>) -> Task<()> { + self.dropdown + .upgrade() + .map_or(Task::Ready(None), |dropdown| { + dropdown.update(cx, |_, cx| self.delegate.perform_search(query, cx)) + }) + } + fn set_selected_index(&mut self, ix: Option, _: &mut ViewContext>) { self.selected_index = ix; } + + fn render_empty(&self, cx: &mut ViewContext>) -> impl IntoElement { + if let Some(empty) = self + .dropdown + .upgrade() + .and_then(|dropdown| dropdown.read(cx).empty.as_ref()) + { + empty(cx).into_any_element() + } else { + h_flex() + .justify_center() + .py_6() + .text_color(cx.theme().muted) + .child(Icon::new(IconName::Inbox).size(px(28.))) + .into_any_element() + } + } } pub enum DropdownEvent { @@ -184,12 +214,80 @@ pub struct Dropdown { focus_handle: FocusHandle, list: View>>, size: Size, + icon: Option, open: bool, cleanable: bool, placeholder: SharedString, title_prefix: Option, selected_value: Option<::Value>, empty: Option AnyElement + 'static>>, + width: Length, + menu_width: Length, +} + +pub struct SearchableVec { + items: Vec, + matched_items: Vec, +} + +impl SearchableVec { + pub fn new(items: impl Into>) -> Self { + let items = items.into(); + Self { + items: items.clone(), + matched_items: items, + } + } +} + +impl DropdownDelegate for SearchableVec { + type Item = T; + + fn len(&self) -> usize { + self.matched_items.len() + } + + fn get(&self, ix: usize) -> Option<&Self::Item> { + self.matched_items.get(ix) + } + + fn position(&self, value: &V) -> Option + where + Self::Item: DropdownItem, + V: PartialEq, + { + for (ix, item) in self.matched_items.iter().enumerate() { + if item.value() == value { + return Some(ix); + } + } + + None + } + + fn can_search(&self) -> bool { + true + } + + fn perform_search(&mut self, query: &str, _cx: &mut ViewContext>) -> Task<()> { + self.matched_items = self + .items + .iter() + .filter(|item| item.title().to_lowercase().contains(&query.to_lowercase())) + .cloned() + .collect(); + + Task::Ready(Some(())) + } +} + +impl From> for SearchableVec { + fn from(items: Vec) -> Self { + Self { + items: items.clone(), + matched_items: items, + } + } } impl Dropdown @@ -208,18 +306,29 @@ where selected_index, }; - let list = cx.new_view(|cx| List::new(delegate, cx).no_query().max_h(rems(20.))); + let searchable = delegate.delegate.can_search(); + + let list = cx.new_view(|cx| { + let mut list = List::new(delegate, cx).max_h(rems(20.)); + if !searchable { + list = list.no_query(); + } + list + }); let mut this = Self { id: id.into(), focus_handle: cx.focus_handle(), placeholder: "Select...".into(), list, size: Size::Medium, + icon: None, selected_value: None, open: false, cleanable: false, title_prefix: None, empty: None, + width: Length::Auto, + menu_width: Length::Auto, }; this.set_selected_index(selected_index, cx); this @@ -230,12 +339,30 @@ where self } + /// Set the width of the dropdown input, default: Length::Auto + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + /// Set the width of the dropdown menu, default: Length::Auto + pub fn menu_width(mut self, width: impl Into) -> Self { + self.menu_width = width.into(); + self + } + /// Set the placeholder for display when dropdown value is empty. pub fn placeholder(mut self, placeholder: impl Into) -> Self { self.placeholder = placeholder.into(); self } + /// Set the right icon for the dropdown input, instead of the default arrow icon. + pub fn icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } + /// Set title prefix for the dropdown. /// /// e.g.: Country: United States @@ -332,6 +459,9 @@ where fn toggle_menu(&mut self, _: &ClickEvent, cx: &mut ViewContext) { self.open = !self.open; + if self.open { + self.list.focus_handle(cx).focus(cx); + } cx.notify(); } @@ -345,24 +475,12 @@ where } fn render_menu_content(&self, cx: &WindowContext) -> impl IntoElement { - let is_empty = self.list.read(cx).delegate().delegate.is_empty(); - div() .track_focus(&self.list.focus_handle(cx)) .on_mouse_down_out(|_, cx| { cx.dispatch_action(Box::new(Escape)); }) - .map(|this| { - if is_empty { - if let Some(render_empty) = &self.empty { - with_style(this, cx).child(render_empty(cx)) - } else { - this - } - } else { - with_style(this, cx).child(self.list.clone()) - } - }) + .map(|this| with_style(this, cx).child(self.list.clone())) } fn display_title(&self, cx: &WindowContext) -> impl IntoElement { @@ -401,18 +519,6 @@ fn with_style(d: Focusable
, cx: &WindowContext) -> Focusable
{ .shadow_md() } -impl Dropdown> { - pub fn string_list( - id: impl Into, - items: Vec>, - selected_index: Option, - cx: &mut ViewContext, - ) -> Self { - let items = items.into_iter().map(Into::into).collect(); - Self::new(id, items, selected_index, cx) - } -} - impl EventEmitter> for Dropdown where D: DropdownDelegate + 'static {} impl EventEmitter for Dropdown where D: DropdownDelegate + 'static {} impl FocusableView for Dropdown @@ -448,7 +554,6 @@ where .id("dropdown-input") .relative() .flex() - .w_full() .items_center() .justify_between() .bg(cx.theme().background) @@ -456,6 +561,13 @@ where .border_color(cx.theme().input) .rounded(px(cx.theme().radius)) .shadow_sm() + .cursor_pointer() + .overflow_hidden() + .input_text_size(self.size) + .map(|this| match self.width { + Length::Definite(l) => this.flex_none().w(l), + Length::Auto => this.w_full(), + }) .when(is_focused, |this| this.outline(cx)) .input_size(self.size) .when(!self.open, |this| { @@ -466,11 +578,17 @@ where .w_full() .items_center() .justify_between() - .child(div().flex_1().child(self.display_title(cx))) + .gap_1() + .child( + div() + .w_full() + .overflow_hidden() + .child(self.display_title(cx)), + ) .when(show_clean, |this| { this.child( Button::new("clean", cx) - .icon(IconName::Close) + .icon(IconName::CircleX) .style(ButtonStyle::Ghost) .size(px(14.)) .cursor_pointer() @@ -478,17 +596,34 @@ where ) }) .when(!show_clean, |this| { - this.child( - Icon::new(IconName::ChevronDown) - .text_color(cx.theme().muted_foreground), - ) + let icon = match self.icon.clone() { + Some(icon) => icon, + None => { + if self.open { + IconName::ChevronUp + } else { + IconName::ChevronDown + } + } + }; + + this.child(Icon::new(icon).text_color(cx.theme().muted_foreground)) }), ), ) - .child(DropdownMenuElement { - id: "dropdown-menu".into(), - dropdown: cx.view().clone(), - }) + .child( + div() + .absolute() + .overflow_hidden() + .map(|this| match self.menu_width { + Length::Auto => this, + Length::Definite(l) => this.w(l), + }) + .child(DropdownMenuElement { + id: "dropdown-menu".into(), + dropdown: cx.view().clone(), + }), + ) } } diff --git a/crates/ui/src/list/list_item.rs b/crates/ui/src/list/list_item.rs index 3b7dc0bb..f10955df 100644 --- a/crates/ui/src/list/list_item.rs +++ b/crates/ui/src/list/list_item.rs @@ -5,7 +5,7 @@ use gpui::{ }; use smallvec::SmallVec; -use crate::{h_flex, theme::ActiveTheme, Disableable, Icon, IconName, Selectable}; +use crate::{h_flex, theme::ActiveTheme, Disableable, Icon, IconName, Selectable, Size}; #[derive(IntoElement)] pub struct ListItem { @@ -130,17 +130,17 @@ impl RenderOnce for ListItem { .items_center() .justify_between() .gap_x_1() - .child(div().w_full().children(self.children)) + .child(div().w_full().overflow_hidden().children(self.children)) .when_some(self.check_icon, |this, icon| { - this.child( - div() - .w_5() - .items_center() - .justify_center() - .when(self.selected, |this| { - this.child(icon.text_color(cx.theme().muted_foreground)) - }), - ) + this.child(div().w_5().items_center().justify_center().when( + self.selected, + |this| { + this.child( + icon.size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) + }, + )) }), ) .when_some(self.suffix, |this, suffix| this.child(suffix(cx)))