Improve Dropdown to Add search support and custom icon (#106)

- Add SearchableVec to let Dropdown support search filter.
- Fix dropdown title overflow.
- Add to support custom dropdown right icon.

https://github.com/user-attachments/assets/b9e28fce-3e0f-44f7-a0f4-3e01f6db31d1
This commit is contained in:
Jason Lee 2024-08-05 11:47:55 +08:00 committed by GitHub
parent 12b5d1b501
commit 553b6409c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 220 additions and 76 deletions

View file

@ -4,10 +4,10 @@ use gpui::{
}; };
use ui::{ use ui::{
dropdown::{Dropdown, DropdownEvent, DropdownItem}, dropdown::{Dropdown, DropdownEvent, DropdownItem, SearchableVec},
h_flex, h_flex,
theme::ActiveTheme, theme::ActiveTheme,
v_flex, Selection, v_flex, IconName, Selection,
}; };
struct Country { struct Country {
@ -38,7 +38,7 @@ impl DropdownItem for Country {
pub struct DropdownStory { pub struct DropdownStory {
country_dropdown: View<Dropdown<Vec<Country>>>, country_dropdown: View<Dropdown<Vec<Country>>>,
fruit_dropdown: View<Dropdown<Vec<SharedString>>>, fruit_dropdown: View<Dropdown<SearchableVec<SharedString>>>,
simple_dropdown1: View<Dropdown<Vec<SharedString>>>, simple_dropdown1: View<Dropdown<Vec<SharedString>>>,
simple_dropdown2: View<Dropdown<Vec<SharedString>>>, simple_dropdown2: View<Dropdown<Vec<SharedString>>>,
simple_dropdown3: View<Dropdown<Vec<SharedString>>>, simple_dropdown3: View<Dropdown<Vec<SharedString>>>,
@ -64,17 +64,21 @@ impl DropdownStory {
Dropdown::new("dropdown-country", countries, Some(6), cx).cleanable(true) Dropdown::new("dropdown-country", countries, Some(6), cx).cleanable(true)
}); });
let fruits = vec![ let fruits = SearchableVec::new(vec![
"Apple", "Apple".into(),
"Orange", "Orange".into(),
"Banana", "Banana".into(),
"Grape", "Grape".into(),
"Pineapple", "Pineapple".into(),
"Watermelon & This is a longlonglonglonglonglonglonglonglong title", "Watermelon & This is a longlonglonglonglonglonglonglonglong title".into(),
"Avocado", "Avocado".into(),
]; ]);
let fruit_dropdown = let fruit_dropdown = cx.new_view(|cx| {
cx.new_view(|cx| Dropdown::string_list("dropdown-fruits", fruits, None, cx)); Dropdown::new("dropdown-fruits", fruits, None, cx)
.icon(IconName::Search)
.width(px(200.))
.menu_width(px(320.))
});
cx.new_view(|cx| { cx.new_view(|cx| {
cx.subscribe(&country_dropdown, Self::on_dropdown_event) cx.subscribe(&country_dropdown, Self::on_dropdown_event)
@ -84,9 +88,9 @@ impl DropdownStory {
country_dropdown, country_dropdown,
fruit_dropdown, fruit_dropdown,
simple_dropdown1: cx.new_view(|cx| { simple_dropdown1: cx.new_view(|cx| {
Dropdown::string_list( Dropdown::new(
"string-list1", "string-list1",
vec!["QPUI", "Iced", "QT", "Cocoa"], vec!["QPUI".into(), "Iced".into(), "QT".into(), "Cocoa".into()],
Some(0), Some(0),
cx, cx,
) )
@ -95,9 +99,14 @@ impl DropdownStory {
.title_prefix("UI: ") .title_prefix("UI: ")
}), }),
simple_dropdown2: cx.new_view(|cx| { simple_dropdown2: cx.new_view(|cx| {
Dropdown::string_list( Dropdown::new(
"string-list2", "string-list2",
vec!["Rust", "Go", "C++", "JavaScript"], vec![
"Rust".into(),
"Go".into(),
"C++".into(),
"JavaScript".into(),
],
None, None,
cx, cx,
) )
@ -106,7 +115,7 @@ impl DropdownStory {
.title_prefix("Language: ") .title_prefix("Language: ")
}), }),
simple_dropdown3: cx.new_view(|cx| { simple_dropdown3: cx.new_view(|cx| {
Dropdown::string_list("string-list3", Vec::<SharedString>::new(), None, cx) Dropdown::new("string-list3", Vec::<SharedString>::new(), None, cx)
.size(ui::Size::Small) .size(ui::Size::Small)
.empty(|cx| { .empty(|cx| {
h_flex() h_flex()

View file

@ -1,8 +1,9 @@
use gpui::{ use gpui::{
actions, deferred, div, prelude::FluentBuilder, px, rems, AnyElement, AppContext, ClickEvent, actions, deferred, div, prelude::FluentBuilder, px, rems, AnyElement, AppContext, ClickEvent,
DismissEvent, Div, Element, ElementId, EventEmitter, FocusHandle, Focusable, FocusableView, DismissEvent, Div, Element, ElementId, EventEmitter, FocusHandle, Focusable, FocusableView,
InteractiveElement, IntoElement, KeyBinding, LayoutId, ParentElement, Render, SharedString, InteractiveElement, IntoElement, KeyBinding, LayoutId, Length, ParentElement, Render,
StatefulInteractiveElement, Styled, View, ViewContext, VisualContext, WeakView, WindowContext, SharedString, StatefulInteractiveElement, Styled, Task, View, ViewContext, VisualContext,
WeakView, WindowContext,
}; };
use crate::{ use crate::{
@ -57,7 +58,7 @@ impl DropdownItem for SharedString {
} }
} }
pub trait DropdownDelegate { pub trait DropdownDelegate: Sized {
type Item: DropdownItem; type Item: DropdownItem;
fn len(&self) -> usize; 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)) (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<Dropdown<Self>>) -> Task<()> {
Task::Ready(Some(()))
}
} }
impl<T: DropdownItem> DropdownDelegate for Vec<T> { impl<T: DropdownItem> DropdownDelegate for Vec<T> {
@ -129,15 +138,11 @@ where
if let Some(item) = self.delegate.get(ix) { if let Some(item) = self.delegate.get(ix) {
let list_item = ListItem::new(("list-item", ix)) let list_item = ListItem::new(("list-item", ix))
.check_icon(IconName::Check) .check_icon(IconName::Check)
.cursor_pointer()
.selected(selected) .selected(selected)
.input_text_size(size) .input_text_size(size)
.list_size(size) .list_size(size)
.child( .child(div().whitespace_nowrap().child(item.title().to_string()));
div()
.whitespace_nowrap()
.overflow_hidden()
.child(item.title().to_string()),
);
Some(list_item) Some(list_item)
} else { } else {
None None
@ -170,9 +175,34 @@ where
} }
} }
fn perform_search(&mut self, query: &str, cx: &mut ViewContext<List<Self>>) -> 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<usize>, _: &mut ViewContext<List<Self>>) { fn set_selected_index(&mut self, ix: Option<usize>, _: &mut ViewContext<List<Self>>) {
self.selected_index = ix; self.selected_index = ix;
} }
fn render_empty(&self, cx: &mut ViewContext<List<Self>>) -> 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<D: DropdownDelegate + 'static> { pub enum DropdownEvent<D: DropdownDelegate + 'static> {
@ -184,12 +214,80 @@ pub struct Dropdown<D: DropdownDelegate + 'static> {
focus_handle: FocusHandle, focus_handle: FocusHandle,
list: View<List<DropdownListDelegate<D>>>, list: View<List<DropdownListDelegate<D>>>,
size: Size, size: Size,
icon: Option<IconName>,
open: bool, open: bool,
cleanable: bool, cleanable: bool,
placeholder: SharedString, placeholder: SharedString,
title_prefix: Option<SharedString>, title_prefix: Option<SharedString>,
selected_value: Option<<D::Item as DropdownItem>::Value>, selected_value: Option<<D::Item as DropdownItem>::Value>,
empty: Option<Box<dyn Fn(&WindowContext) -> AnyElement + 'static>>, empty: Option<Box<dyn Fn(&WindowContext) -> AnyElement + 'static>>,
width: Length,
menu_width: Length,
}
pub struct SearchableVec<T> {
items: Vec<T>,
matched_items: Vec<T>,
}
impl<T: DropdownItem + Clone> SearchableVec<T> {
pub fn new(items: impl Into<Vec<T>>) -> Self {
let items = items.into();
Self {
items: items.clone(),
matched_items: items,
}
}
}
impl<T: DropdownItem + Clone> DropdownDelegate for SearchableVec<T> {
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<V>(&self, value: &V) -> Option<usize>
where
Self::Item: DropdownItem<Value = V>,
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<Dropdown<Self>>) -> Task<()> {
self.matched_items = self
.items
.iter()
.filter(|item| item.title().to_lowercase().contains(&query.to_lowercase()))
.cloned()
.collect();
Task::Ready(Some(()))
}
}
impl From<Vec<SharedString>> for SearchableVec<SharedString> {
fn from(items: Vec<SharedString>) -> Self {
Self {
items: items.clone(),
matched_items: items,
}
}
} }
impl<D> Dropdown<D> impl<D> Dropdown<D>
@ -208,18 +306,29 @@ where
selected_index, 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 { let mut this = Self {
id: id.into(), id: id.into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
placeholder: "Select...".into(), placeholder: "Select...".into(),
list, list,
size: Size::Medium, size: Size::Medium,
icon: None,
selected_value: None, selected_value: None,
open: false, open: false,
cleanable: false, cleanable: false,
title_prefix: None, title_prefix: None,
empty: None, empty: None,
width: Length::Auto,
menu_width: Length::Auto,
}; };
this.set_selected_index(selected_index, cx); this.set_selected_index(selected_index, cx);
this this
@ -230,12 +339,30 @@ where
self self
} }
/// Set the width of the dropdown input, default: Length::Auto
pub fn width(mut self, width: impl Into<Length>) -> 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<Length>) -> Self {
self.menu_width = width.into();
self
}
/// Set the placeholder for display when dropdown value is empty. /// Set the placeholder for display when dropdown value is empty.
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self { pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into(); self.placeholder = placeholder.into();
self self
} }
/// Set the right icon for the dropdown input, instead of the default arrow icon.
pub fn icon(mut self, icon: impl Into<IconName>) -> Self {
self.icon = Some(icon.into());
self
}
/// Set title prefix for the dropdown. /// Set title prefix for the dropdown.
/// ///
/// e.g.: Country: United States /// e.g.: Country: United States
@ -332,6 +459,9 @@ where
fn toggle_menu(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) { fn toggle_menu(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
self.open = !self.open; self.open = !self.open;
if self.open {
self.list.focus_handle(cx).focus(cx);
}
cx.notify(); cx.notify();
} }
@ -345,24 +475,12 @@ where
} }
fn render_menu_content(&self, cx: &WindowContext) -> impl IntoElement { fn render_menu_content(&self, cx: &WindowContext) -> impl IntoElement {
let is_empty = self.list.read(cx).delegate().delegate.is_empty();
div() div()
.track_focus(&self.list.focus_handle(cx)) .track_focus(&self.list.focus_handle(cx))
.on_mouse_down_out(|_, cx| { .on_mouse_down_out(|_, cx| {
cx.dispatch_action(Box::new(Escape)); cx.dispatch_action(Box::new(Escape));
}) })
.map(|this| { .map(|this| with_style(this, cx).child(self.list.clone()))
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())
}
})
} }
fn display_title(&self, cx: &WindowContext) -> impl IntoElement { fn display_title(&self, cx: &WindowContext) -> impl IntoElement {
@ -401,18 +519,6 @@ fn with_style(d: Focusable<Div>, cx: &WindowContext) -> Focusable<Div> {
.shadow_md() .shadow_md()
} }
impl Dropdown<Vec<SharedString>> {
pub fn string_list(
id: impl Into<ElementId>,
items: Vec<impl Into<SharedString>>,
selected_index: Option<usize>,
cx: &mut ViewContext<Self>,
) -> Self {
let items = items.into_iter().map(Into::into).collect();
Self::new(id, items, selected_index, cx)
}
}
impl<D> EventEmitter<DropdownEvent<D>> for Dropdown<D> where D: DropdownDelegate + 'static {} impl<D> EventEmitter<DropdownEvent<D>> for Dropdown<D> where D: DropdownDelegate + 'static {}
impl<D> EventEmitter<DismissEvent> for Dropdown<D> where D: DropdownDelegate + 'static {} impl<D> EventEmitter<DismissEvent> for Dropdown<D> where D: DropdownDelegate + 'static {}
impl<D> FocusableView for Dropdown<D> impl<D> FocusableView for Dropdown<D>
@ -448,7 +554,6 @@ where
.id("dropdown-input") .id("dropdown-input")
.relative() .relative()
.flex() .flex()
.w_full()
.items_center() .items_center()
.justify_between() .justify_between()
.bg(cx.theme().background) .bg(cx.theme().background)
@ -456,6 +561,13 @@ where
.border_color(cx.theme().input) .border_color(cx.theme().input)
.rounded(px(cx.theme().radius)) .rounded(px(cx.theme().radius))
.shadow_sm() .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)) .when(is_focused, |this| this.outline(cx))
.input_size(self.size) .input_size(self.size)
.when(!self.open, |this| { .when(!self.open, |this| {
@ -466,11 +578,17 @@ where
.w_full() .w_full()
.items_center() .items_center()
.justify_between() .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| { .when(show_clean, |this| {
this.child( this.child(
Button::new("clean", cx) Button::new("clean", cx)
.icon(IconName::Close) .icon(IconName::CircleX)
.style(ButtonStyle::Ghost) .style(ButtonStyle::Ghost)
.size(px(14.)) .size(px(14.))
.cursor_pointer() .cursor_pointer()
@ -478,17 +596,34 @@ where
) )
}) })
.when(!show_clean, |this| { .when(!show_clean, |this| {
this.child( let icon = match self.icon.clone() {
Icon::new(IconName::ChevronDown) Some(icon) => icon,
.text_color(cx.theme().muted_foreground), None => {
) if self.open {
IconName::ChevronUp
} else {
IconName::ChevronDown
}
}
};
this.child(Icon::new(icon).text_color(cx.theme().muted_foreground))
}), }),
), ),
) )
.child(DropdownMenuElement { .child(
id: "dropdown-menu".into(), div()
dropdown: cx.view().clone(), .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(),
}),
)
} }
} }

View file

@ -5,7 +5,7 @@ use gpui::{
}; };
use smallvec::SmallVec; 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)] #[derive(IntoElement)]
pub struct ListItem { pub struct ListItem {
@ -130,17 +130,17 @@ impl RenderOnce for ListItem {
.items_center() .items_center()
.justify_between() .justify_between()
.gap_x_1() .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| { .when_some(self.check_icon, |this, icon| {
this.child( this.child(div().w_5().items_center().justify_center().when(
div() self.selected,
.w_5() |this| {
.items_center() this.child(
.justify_center() icon.size(Size::Small)
.when(self.selected, |this| { .text_color(cx.theme().muted_foreground),
this.child(icon.text_color(cx.theme().muted_foreground)) )
}), },
) ))
}), }),
) )
.when_some(self.suffix, |this, suffix| this.child(suffix(cx))) .when_some(self.suffix, |this, suffix| this.child(suffix(cx)))