diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 797af910c4..3247066c93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,9 @@ jobs: run: script/bootstrap - name: Machete if: ${{ matrix.run_on == 'macos-latest' }} - uses: bnjbvr/cargo-machete@v0.9.1 + run: | + cargo install cargo-machete --quiet + cargo machete - name: Setup | Cache Cargo uses: actions/cache@v4 with: diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index dd9ba123a6..adad3e59e2 100644 --- a/crates/story/src/gallery.rs +++ b/crates/story/src/gallery.rs @@ -50,6 +50,7 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), diff --git a/crates/story/src/stories/combobox_story.rs b/crates/story/src/stories/combobox_story.rs new file mode 100644 index 0000000000..41be99d166 --- /dev/null +++ b/crates/story/src/stories/combobox_story.rs @@ -0,0 +1,888 @@ +use gpui::{prelude::FluentBuilder as _, *}; +use gpui_component::{ + ActiveTheme, Icon, IconName, IndexPath, Sizable as _, + button::Button, + button::ButtonVariants as _, + combobox::*, + h_flex, + v_flex, white, +}; + +use crate::section; + +pub fn init(_: &mut App) {} + +// MARK: Data + +#[derive(Clone)] +struct FoodItem { + label: SharedString, + is_disabled: bool, +} + +impl FoodItem { + fn new(label: impl Into) -> Self { + Self { + label: label.into(), + is_disabled: false, + } + } + + fn disabled(mut self) -> Self { + self.is_disabled = true; + self + } +} + +impl SearchableListItem for FoodItem { + type Value = SharedString; + + fn title(&self) -> SharedString { + self.label.clone() + } + + fn value(&self) -> &SharedString { + &self.label + } + + fn disabled(&self) -> bool { + self.is_disabled + } +} + +#[derive(Clone)] +struct Industry { + label: SharedString, + icon: IconName, +} + +impl Industry { + fn new(label: impl Into, icon: IconName) -> Self { + Self { + label: label.into(), + icon, + } + } +} + +impl SearchableListItem for Industry { + type Value = SharedString; + + fn title(&self) -> SharedString { + self.label.clone() + } + + fn value(&self) -> &SharedString { + &self.label + } + + fn render(&self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement { + use gpui_component::ActiveTheme as _; + + h_flex() + .w_full() + .gap_2() + .items_center() + .child( + Icon::new(self.icon.clone()) + .small() + .text_color(cx.theme().muted_foreground), + ) + .child(gpui::div().child(self.label.clone())) + } +} + +// MARK: Max2Delegate — allows at most 2 items to be selected simultaneously + +/// Shadows the current selection indices so `is_item_enabled` can disable unselected items +/// when the capacity is reached, providing visual feedback without `current_selection` access. +struct Max2Delegate { + items: SearchableVec<&'static str>, + selected_indices: Vec, +} + +impl Max2Delegate { + fn new(items: SearchableVec<&'static str>) -> Self { + Self { + items, + selected_indices: Vec::new(), + } + } +} + +impl SearchableListDelegate for Max2Delegate { + type Item = &'static str; + + fn items_count(&self, section: usize) -> usize { + self.items.items_count(section) + } + + fn item(&self, ix: IndexPath) -> Option<&&'static str> { + self.items.item(ix) + } + + fn position(&self, value: &V) -> Option + where + &'static str: SearchableListItem, + V: PartialEq, + { + self.items.position(value) + } + + fn is_item_enabled(&self, ix: IndexPath, _item: &&'static str, _cx: &App) -> bool { + let at_capacity = self.selected_indices.len() >= 2; + let is_selected = self.selected_indices.contains(&ix); + + !at_capacity || is_selected + } + + fn on_will_change( + &mut self, + selection: &mut Vec<(IndexPath, &'static str)>, + changes: &[SearchableListChange], + ) { + for change in changes { + match change { + SearchableListChange::Deselect { index } => { + selection.retain(|(ix, _)| ix != index); + } + SearchableListChange::Select { index } => { + if selection.len() < 2 { + if let Some(item) = self.item(*index) { + if !selection.iter().any(|(ix, _)| ix == index) { + selection.push((*index, *item)); + } + } + } + } + } + } + + // Keep the shadow in sync for is_item_enabled. + self.selected_indices = selection.iter().map(|(ix, _)| *ix).collect(); + } +} + +// MARK: PinnedDelegate — first two items always appear checked regardless of selection + +struct PinnedDelegate(SearchableVec<&'static str>); + +impl SearchableListDelegate for PinnedDelegate { + type Item = &'static str; + + fn items_count(&self, section: usize) -> usize { + self.0.items_count(section) + } + + fn item(&self, ix: IndexPath) -> Option<&&'static str> { + self.0.item(ix) + } + + fn position(&self, value: &V) -> Option + where + &'static str: SearchableListItem, + V: PartialEq, + { + self.0.position(value) + } + + fn is_item_enabled(&self, ix: IndexPath, _item: &&'static str, _cx: &App) -> bool { + // Pinned items are non-interactive — their checked state is fixed. + ix != IndexPath::new(0) && ix != IndexPath::new(1) + } + + fn is_item_checked( + &self, + ix: IndexPath, + _item: &&'static str, + current_selection: &[(IndexPath, &'static str)], + _cx: &App, + ) -> bool { + // First two items are always rendered checked (externally pinned), + // regardless of what is in the normal selection. + ix == IndexPath::new(0) + || ix == IndexPath::new(1) + || current_selection.iter().any(|(sel_ix, _)| sel_ix == &ix) + } +} + +// MARK: FeaturedDelegate — first item gets a "Featured" badge via render_item + +struct FeaturedDelegate(SearchableVec<&'static str>); + +impl SearchableListDelegate for FeaturedDelegate { + type Item = &'static str; + + fn items_count(&self, section: usize) -> usize { + self.0.items_count(section) + } + + fn item(&self, ix: IndexPath) -> Option<&&'static str> { + self.0.item(ix) + } + + fn position(&self, value: &V) -> Option + where + &'static str: SearchableListItem, + V: PartialEq, + { + self.0.position(value) + } + + fn render_item( + &self, + ix: IndexPath, + item: &&'static str, + checked: bool, + _window: &mut Window, + cx: &mut App, + ) -> Option { + if ix != IndexPath::new(0) { + return None; + } + + Some( + h_flex() + .w_full() + .justify_between() + .items_center() + .child(*item) + .when(checked, |this| { + this.child( + Icon::new(IconName::Check) + .xsmall() + .text_color(cx.theme().muted_foreground), + ) + }) + .child( + div() + .rounded_sm() + .bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + .px_1() + .text_xs() + .child("Featured"), + ) + .into_any_element(), + ) + } +} + +// MARK: Story + +pub struct ComboboxStory { + // 01 basic single-select + basic: Entity>>, + // 02 basic multi-select + basic_multi: Entity>>, + // 03 grouped single-select + grouped: Entity>>>, + // 03 disabled items (single) + disabled_items: Entity>>, + // 04 item icon (single) + with_icon: Entity>>, + // 05 custom check icon (single) + custom_check: Entity>>, + // 06 footer button (single) + with_footer: Entity>>, + // 07 custom trigger (single) + custom_trigger: Entity>>, + // 08 multi badges + multi_badges: Entity>>, + // 09 on_will_change — max 2 items + custom_max2: Entity>, + // 10 is_item_checked — externally pinned items + pinned: Entity>, + // 11 render_item delegate hook — custom row renderer + featured: Entity>, + // 12 multi expandable + multi_expand: Entity>>, + // 12 multi count-badge + multi_count: Entity>>, +} + +impl super::Story for ComboboxStory { + fn title() -> &'static str { + "Combobox" + } + + fn description() -> &'static str { + "An autocomplete input paired with a searchable dropdown list." + } + + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + Self::view(window, cx) + } +} + +impl Focusable for ComboboxStory { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.basic.focus_handle(cx) + } +} + +const FRAMEWORKS: &[&str] = &["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"]; + +const MULTI_FRAMEWORKS: &[&str] = &[ + "React", "Nextjs", "Angular", "VueJS", "Django", "Astro", "Remix", "Svelte", "SolidJS", "Qwik", +]; + +fn food_groups() -> SearchableVec> { + SearchableVec::new(vec![ + SearchableGroup::new("Fruits").items(vec![ + FoodItem::new("Apples"), + FoodItem::new("Bananas"), + FoodItem::new("Cherries"), + ]), + SearchableGroup::new("Vegetables").items(vec![ + FoodItem::new("Carrots"), + FoodItem::new("Broccoli").disabled(), + FoodItem::new("Spinach"), + ]), + SearchableGroup::new("Beverages").items(vec![ + FoodItem::new("Tea"), + FoodItem::new("Coffee").disabled(), + FoodItem::new("Juice"), + ]), + ]) +} + +fn industries() -> SearchableVec { + SearchableVec::new(vec![ + Industry::new("Information Technology", IconName::Cpu), + Industry::new("Healthcare", IconName::Heart), + Industry::new("Finance", IconName::Globe), + Industry::new("Education", IconName::BookOpen), + Industry::new("Entertainment", IconName::Star), + ]) +} + +impl ComboboxStory { + fn new(window: &mut Window, cx: &mut App) -> Entity { + let basic = cx.new(|cx| { + ComboboxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) + .searchable(true) + }); + + let basic_multi = cx.new(|cx| { + ComboboxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) + .multiple(true) + .searchable(true) + }); + + let grouped = cx.new(|cx| { + ComboboxState::new(food_groups(), vec![IndexPath::default()], window, cx) + .searchable(true) + }); + + let disabled_items = cx.new(|cx| { + let items = SearchableVec::new(vec![ + FoodItem::new("Apples"), + FoodItem::new("Bananas").disabled(), + FoodItem::new("Cherries"), + FoodItem::new("Carrots"), + FoodItem::new("Broccoli").disabled(), + ]); + ComboboxState::new(items, vec![], window, cx) + .searchable(true) + }); + + let with_icon = cx.new(|cx| { + ComboboxState::new(industries(), vec![], window, cx) + .searchable(true) + }); + + let custom_check = cx.new(|cx| { + ComboboxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) + .searchable(true) + }); + + let with_footer = cx.new(|cx| { + let items = + SearchableVec::new(vec!["Harvard University", "MIT", "Stanford", "Cambridge"]); + ComboboxState::new(items, vec![IndexPath::default()], window, cx) + .searchable(true) + }); + + let custom_trigger = cx.new(|cx| { + ComboboxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) + .searchable(true) + }); + + let multi_badges = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(MULTI_FRAMEWORKS.to_vec()), + vec![IndexPath::new(0), IndexPath::new(2)], + window, + cx, + ) + .multiple(true) + .searchable(true) + }); + + let custom_max2 = cx.new(|cx| { + ComboboxState::new( + Max2Delegate::new(SearchableVec::new(MULTI_FRAMEWORKS.to_vec())), + vec![], + window, + cx, + ) + .multiple(true) + .searchable(true) + }); + + let pinned = cx.new(|cx| { + ComboboxState::new( + PinnedDelegate(SearchableVec::new(FRAMEWORKS.to_vec())), + vec![], + window, + cx, + ) + .searchable(true) + }); + + let featured = cx.new(|cx| { + ComboboxState::new( + FeaturedDelegate(SearchableVec::new(FRAMEWORKS.to_vec())), + vec![], + window, + cx, + ) + .searchable(true) + }); + + let multi_expand = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(MULTI_FRAMEWORKS.to_vec()), + vec![ + IndexPath::new(0), + IndexPath::new(2), + IndexPath::new(5), + IndexPath::new(8), + IndexPath::new(9), + ], + window, + cx, + ) + .multiple(true) + .searchable(true) + }); + + let multi_count = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(MULTI_FRAMEWORKS.to_vec()), + vec![ + IndexPath::new(0), + IndexPath::new(1), + IndexPath::new(2), + IndexPath::new(3), + IndexPath::new(4), + IndexPath::new(5), + ], + window, + cx, + ) + .multiple(true) + .searchable(true) + }); + + cx.new(|_| Self { + basic, + basic_multi, + grouped, + disabled_items, + with_icon, + custom_check, + with_footer, + custom_trigger, + multi_badges, + custom_max2, + pinned, + featured, + multi_expand, + multi_count, + }) + } + + pub fn view(window: &mut Window, cx: &mut App) -> Entity { + Self::new(window, cx) + } +} + +impl Render for ComboboxStory { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let multi_badges_state = self.multi_badges.clone(); + + v_flex() + .size_full() + .gap_4() + .child( + section("Basic Single-Select").max_w_md().child( + Combobox::new(&self.basic) + .placeholder("Select framework...") + .search_placeholder("Search framework...") + .w_full(), + ), + ) + .child( + section("Basic Multi-Select").max_w_md().child( + Combobox::new(&self.basic_multi) + .placeholder("Select frameworks...") + .search_placeholder("Search framework...") + .w_full(), + ), + ) + .child( + section("Grouped Items").max_w_md().child( + Combobox::new(&self.grouped) + .placeholder("Select item...") + .search_placeholder("Search item...") + .w_full(), + ), + ) + .child( + section("Disabled Items").max_w_md().child( + Combobox::new(&self.disabled_items) + .placeholder("Select item...") + .search_placeholder("Search item...") + .w_full(), + ), + ) + .child( + section("Item with Icon").max_w_md().child( + Combobox::new(&self.with_icon) + .placeholder("Select industry category") + .search_placeholder("Search industries...") + .render_trigger(|ctx, _, cx| { + let (icon, title) = match &ctx.selection { + [] => (None, None), + [(_index, item)] => { + (Some(item.icon.clone()), Some(item.title().clone())) + } + items => ( + None, + Some(SharedString::new(format!("{} selected", items.len()))), + ), + }; + + h_flex() + .w_full() + .gap_2() + .items_center() + .when_some(icon, |this, icon| { + this.child( + Icon::new(icon) + .small() + .text_color(cx.theme().muted_foreground), + ) + }) + .child( + div() + .w_full() + .overflow_hidden() + .truncate() + .when_some(title, |this, title| this.child(title)) + .when(ctx.selection.is_empty(), |this| { + this.text_color(cx.theme().muted_foreground) + .child("Select industry category") + }), + ) + .child( + Icon::new(IconName::ChevronDown) + .xsmall() + .text_color(cx.theme().muted_foreground), + ) + .into_any_element() + }) + .w_full(), + ), + ) + .child( + section("Custom Check Icon").max_w_md().child( + Combobox::new(&self.custom_check) + .placeholder("Select framework...") + .search_placeholder("Search framework...") + .check_icon(Icon::new(IconName::CircleCheck)) + .w_full(), + ), + ) + .child( + section("Footer Action Button").max_w_md().child( + Combobox::new(&self.with_footer) + .placeholder("Select university") + .search_placeholder("Find university") + .footer(|_, cx| { + Button::new("add-new") + .ghost() + .label("New university") + .icon(Icon::new(IconName::Plus)) + .text_color(cx.theme().foreground) + .w_full() + .justify_start() + .into_any_element() + }) + .w_full(), + ), + ) + .child( + section("Custom Trigger").max_w_md().child( + Combobox::new(&self.custom_trigger) + .placeholder("Select framework") + .search_placeholder("Search framework...") + .render_trigger(|ctx, _, cx| { + let title = match &ctx.selection { + [] => None, + [(_index, item)] => Some(item.title().clone()), + items => { + Some(SharedString::new(format!("{} selected", items.len()))) + } + }; + + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_2() + .child( + h_flex() + .items_center() + .gap_2() + .child( + Icon::new(IconName::Palette) + .small() + .text_color(cx.theme().primary), + ) + .when_some(title, |this, title| { + this.child( + div() + .bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + .rounded_full() + .px_2() + .py_0p5() + .text_xs() + .child(title), + ) + }) + .when(ctx.selection.is_empty(), |this| { + this.text_color(cx.theme().muted_foreground).child( + ctx.placeholder + .cloned() + .unwrap_or_else(|| "Select...".into()), + ) + }), + ) + .child( + Icon::new(IconName::ChevronDown) + .xsmall() + .text_color(cx.theme().muted_foreground), + ) + .into_any_element() + }) + .w_full(), + ), + ) + .child( + section("Multi-Select with Badges").max_w_md().child( + Combobox::new(&self.multi_badges) + .placeholder("Select frameworks") + .search_placeholder("Search framework...") + .render_trigger(move |ctx, _, cx| { + let items = ctx.selection; + + if items.is_empty() { + return div() + .text_color(cx.theme().muted_foreground) + .child("Select frameworks") + .into_any_element(); + } + + h_flex() + .w_full() + .flex_wrap() + .gap_1() + .children(items.iter().cloned().map(|(index, item)| { + let state = multi_badges_state.clone(); + h_flex() + .gap_0p5() + .items_center() + .rounded_sm() + .border_1() + .border_color(cx.theme().border) + .px_1() + .text_xs() + .child(item) + .child( + Button::new(SharedString::from(format!( + "remove-{item}" + ))) + .ghost() + .xsmall() + .icon(Icon::new(IconName::Close).xsmall()) + .tab_stop(false) + .on_click(move |_ev, _window, cx| { + state.update(cx, |s, cx| { + s.remove_selected_index(index, cx); + }); + }), + ) + })) + .into_any_element() + }) + .w_full(), + ), + ) + .child( + section("Max 2 Selections").max_w_md().child( + Combobox::new(&self.custom_max2) + .placeholder("Select up to 2 frameworks") + .search_placeholder("Search framework...") + .w_full(), + ), + ) + .child( + section("Pinned Items").max_w_md().child( + Combobox::new(&self.pinned) + .placeholder("Select framework...") + .search_placeholder("Search framework...") + .w_full(), + ), + ) + .child( + section("Custom Row Renderer").max_w_md().child( + Combobox::new(&self.featured) + .placeholder("Select framework...") + .search_placeholder("Search framework...") + .w_full(), + ), + ) + .child( + section("Multi-Select Expandable").max_w_md().child( + Combobox::new(&self.multi_expand) + .placeholder("Select frameworks") + .search_placeholder("Search framework...") + .render_trigger(|ctx, _, cx| { + const MAX_SHOWN: usize = 2; + + if ctx.selection.is_empty() { + return div() + .text_color(cx.theme().muted_foreground) + .child("Select frameworks") + .into_any_element(); + } + + h_flex() + .w_full() + .flex_wrap() + .gap_1() + .children(ctx.selection.iter().take(MAX_SHOWN).map( + |(_index, item)| { + div() + .rounded_sm() + .border_1() + .border_color(cx.theme().border) + .px_1() + .text_xs() + .child(*item) + }, + )) + .when(ctx.selection.len() > MAX_SHOWN, |this| { + let hidden = ctx.selection.len() - MAX_SHOWN; + this.child( + div() + .rounded_sm() + .border_1() + .border_color(cx.theme().border) + .px_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("+{} more", hidden)), + ) + }) + .into_any_element() + }) + .w_full(), + ), + ) + .child( + section("Multi-Select with Count Badge").max_w_md().child( + Combobox::new(&self.multi_count) + .placeholder("Select frameworks") + .search_placeholder("Search framework...") + .render_trigger(|ctx, _, cx| { + let count = ctx.selection.len(); + + if count == 0 { + return div() + .text_color(cx.theme().muted_foreground) + .child("Select frameworks") + .into_any_element(); + } + + let display = if count > 99 { + "99+".to_string() + } else { + count.to_string() + }; + + h_flex() + .gap_1p5() + .items_center() + .child( + h_flex() + .justify_center() + .items_center() + .min_w(px(16.)) + .h(px(16.)) + .px_1() + .rounded_full() + .bg(cx.theme().red) + .text_color(white()) + .text_size(px(10.)) + .line_height(relative(1.)) + .child(display), + ) + .child( + div() + .text_color(cx.theme().foreground) + .child("frameworks selected"), + ) + .into_any_element() + }) + .w_full(), + ), + ) + .child( + section("Selected Values").max_w_lg().child( + v_flex() + .gap_2() + .child(format!( + "basic: {:?}", + self.basic.read(cx).selected_values() + )) + .child(format!( + "grouped: {:?}", + self.grouped.read(cx).selected_values() + )) + .child(format!( + "multi_badges: {:?}", + self.multi_badges + .read(cx) + .selected_values() + .iter() + .map(|v| v.to_string()) + .collect::>() + )) + .child(format!( + "multi_count: {:?}", + self.multi_count + .read(cx) + .selected_values() + .iter() + .map(|v| v.to_string()) + .collect::>() + )), + ), + ) + } +} diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index f9e0f5989a..7a93c73e39 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -14,6 +14,7 @@ mod checkbox_story; mod clipboard_story; mod collapsible_story; mod color_picker_story; +mod combobox_story; mod data_table_story; mod date_picker_story; mod description_list_story; @@ -74,6 +75,7 @@ pub use checkbox_story::CheckboxStory; pub use clipboard_story::ClipboardStory; pub use collapsible_story::CollapsibleStory; pub use color_picker_story::ColorPickerStory; +pub use combobox_story::ComboboxStory; pub use data_table_story::DataTableStory; pub use date_picker_story::DatePickerStory; pub use description_list_story::DescriptionListStory; @@ -124,6 +126,7 @@ pub use welcome_story::WelcomeStory; pub(crate) fn init(cx: &mut App) { input_story::init(cx); + combobox_story::init(cx); rating_story::init(cx); number_input_story::init(cx); textarea_story::init(cx); diff --git a/crates/story/src/stories/select_story.rs b/crates/story/src/stories/select_story.rs index 87255c2996..893f03fb16 100644 --- a/crates/story/src/stories/select_story.rs +++ b/crates/story/src/stories/select_story.rs @@ -257,13 +257,13 @@ impl Render for SelectStory { Select::new(&self.simple_select3) .disabled(self.disabled) .small() - .empty( + .empty(|_, cx| { h_flex() .h_24() .justify_center() .text_color(cx.theme().muted_foreground) - .child("No Data"), - ), + .child("No Data") + }), ), ) .child( diff --git a/crates/ui/locales/ui.yml b/crates/ui/locales/ui.yml index ce2a985881..89637cdd9b 100644 --- a/crates/ui/locales/ui.yml +++ b/crates/ui/locales/ui.yml @@ -107,6 +107,22 @@ Select: zh-CN: "请选择" zh-HK: "請選擇" it: Seleziona +ComboBox: + placeholder: + en: "Please select" + zh-CN: "请选择" + zh-HK: "請選擇" + it: Seleziona + search_placeholder: + en: "Search..." + zh-CN: "搜索..." + zh-HK: "搜索..." + it: "Cerca..." + empty: + en: "No results" + zh-CN: "暂无数据" + zh-HK: "暫無數據" + it: "Nessun risultato" Dock: Unnamed: en: Unnamed diff --git a/crates/ui/src/combobox.rs b/crates/ui/src/combobox.rs new file mode 100644 index 0000000000..4c576d5ec5 --- /dev/null +++ b/crates/ui/src/combobox.rs @@ -0,0 +1,1232 @@ +use gpui::{ + AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, Edges, ElementId, Entity, + EventEmitter, FocusHandle, Focusable, Hsla, InteractiveElement, IntoElement, KeyBinding, + Length, MouseDownEvent, ParentElement, Pixels, Render, RenderOnce, SharedString, + StatefulInteractiveElement, StyleRefinement, Styled, Window, anchored, deferred, div, + prelude::FluentBuilder, px, rems, +}; + +use rust_i18n::t; + +use crate::{ + ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Sizable, Size, + StyleSized, StyledExt, + actions::{Cancel, Confirm, SelectDown, SelectUp}, + global_state::GlobalState, + h_flex, + input::{clear_button, input_style}, + list::{List, ListState}, + searchable_list::{SearchableListAdapter, SearchableListState}, + v_flex, +}; + +// Re-export searchable_list types needed by users of this module. +pub use crate::searchable_list::{ + SearchableGroup, SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableVec, +}; + +const CONTEXT: &str = "Combobox"; + +pub(crate) fn init(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("up", SelectUp, Some(CONTEXT)), + KeyBinding::new("down", SelectDown, Some(CONTEXT)), + KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), + KeyBinding::new( + "secondary-enter", + Confirm { secondary: true }, + Some(CONTEXT), + ), + KeyBinding::new("escape", Cancel, Some(CONTEXT)), + ]) +} + +// MARK: ComboboxTriggerContext + +/// Context passed to the `render_trigger` closure on [`Combobox`]. +pub struct ComboboxTriggerContext<'a, D: SearchableListDelegate + 'static> { + pub selection: &'a [(IndexPath, D::Item)], + pub placeholder: Option<&'a SharedString>, + pub open: bool, + pub disabled: bool, + pub size: Size, +} + +// MARK: ComboboxChange + +/// Back-compat alias — new code should use [`SearchableListChange`] directly. +pub type ComboboxChange = SearchableListChange; + +// MARK: ComboboxOptions + +struct ComboboxOptions { + style: StyleRefinement, + size: Size, + cleanable: bool, + placeholder: Option, + search_placeholder: Option, + menu_width: Length, + menu_max_h: Length, + disabled: bool, + appearance: bool, + trigger_icon: Option, + check_icon: Option, +} + +impl Default for ComboboxOptions { + fn default() -> Self { + Self { + style: StyleRefinement::default(), + size: Size::default(), + cleanable: false, + placeholder: None, + search_placeholder: None, + menu_width: Length::Auto, + menu_max_h: rems(20.).into(), + disabled: false, + appearance: true, + trigger_icon: None, + check_icon: None, + } + } +} + +// MARK: ComboboxState + +/// State of the [`Combobox`] component. +pub struct ComboboxState +where + ::Value: PartialEq + Clone, +{ + pub(crate) state: SearchableListState, + + // Combobox-specific fields + multiple: bool, + searchable: bool, + trigger_icon: Option, + check_icon: Option, + render_trigger: + Option, &mut Window, &mut App) -> AnyElement + 'static>>, + footer: Option AnyElement + 'static>>, +} + +/// Events emitted by [`ComboboxState`]. +pub enum ComboboxEvent +where + ::Value: PartialEq + Clone, +{ + /// Emitted on every toggle (item added or removed). + Change(Vec<::Value>), + /// Emitted when the popover closes. + Confirm(Vec<::Value>), +} + +impl ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + /// Create a new `Combobox` state. + pub fn new( + delegate: D, + selected_indices: Vec, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let weak = cx.entity().downgrade(); + let weak_confirm = weak.clone(); + let weak_cancel = weak.clone(); + let weak_empty = weak; + + let state = SearchableListState::new( + delegate, + selected_indices, + move |selected_index, _secondary, window, cx| { + cx.defer_in(window, { + let weak_confirm = weak_confirm.clone(); + move |list_state, window, cx| { + let Some(index) = selected_index else { + return; + }; + + // Guard: verify the item exists before proceeding. + if list_state.delegate().delegate.item(index).is_none() { + return; + } + + let ix = index; + + let Some(weak) = weak_confirm.upgrade() else { + return; + }; + + let (multiple, mut selection) = { + let s = weak.read(cx); + (s.multiple, s.state.selection.clone()) + }; + + let is_selected = selection.iter().any(|(cur_ix, _)| cur_ix == &ix); + let changes: Vec = if multiple { + if is_selected { + vec![SearchableListChange::Deselect { index: ix }] + } else { + vec![SearchableListChange::Select { index: ix }] + } + } else { + let mut changes: Vec = selection + .iter() + .map(|(cur_ix, _)| SearchableListChange::Deselect { + index: *cur_ix, + }) + .collect(); + changes.push(SearchableListChange::Select { index: ix }); + changes + }; + + let before_indices: Vec = + selection.iter().map(|(ix, _)| *ix).collect(); + + // on_will_change is called directly — entity-handle access would + // re-enter the ListState lock that defer_in holds for this callback. + list_state + .delegate_mut() + .delegate + .on_will_change(&mut selection, &changes); + + let after_indices: Vec = + selection.iter().map(|(ix, _)| *ix).collect(); + let changed = before_indices != after_indices; + let should_close = changed && !multiple; + + let new_selection = weak_confirm.update(cx, |this, cx| { + this.state.selection = selection; + + if changed { + cx.emit(ComboboxEvent::Change(this.selected_values())); + cx.notify(); + } + + if should_close { + cx.emit(ComboboxEvent::Confirm(this.selected_values())); + this.set_open(false, cx); + this.focus(window, cx); + } + + this.state.selection.clone() + }); + + // Sync snapshot and fire on_confirm directly — same re-entrancy guard. + if let Ok(new_selection) = new_selection { + list_state + .delegate_mut() + .update_selection_snapshot(new_selection.clone()); + + if should_close { + list_state + .delegate_mut() + .delegate + .on_confirm(&new_selection); + } + } + } + }); + }, + // on_cancel — close and emit Confirm with current values + move |_final_selected_index, window, cx| { + cx.defer_in(window, { + let weak_cancel = weak_cancel.clone(); + move |_list_state, window, cx| { + _ = weak_cancel.update(cx, |this, cx| { + cx.emit(ComboboxEvent::Confirm(this.selected_values())); + this.set_open(false, cx); + this.focus(window, cx); + }); + } + }); + }, + // on_render_empty + move |window, cx| { + if let Some(empty) = weak_empty + .upgrade() + .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx))) + { + empty + } else { + h_flex() + .justify_center() + .py_6() + .text_color(cx.theme().muted_foreground.opacity(0.6)) + .child(Icon::new(IconName::Inbox).size(px(28.))) + .into_any_element() + } + }, + Self::on_blur, + window, + cx, + ); + + Self { + state, + multiple: false, + searchable: false, + trigger_icon: None, + check_icon: None, + render_trigger: None, + footer: None, + } + } + + /// Enable multi-select mode. + /// + /// When `true`, clicking an item toggles it in the selection and the popover stays open. + /// When `false` (default), clicking an item replaces the selection and closes the popover. + pub fn multiple(mut self, multiple: bool) -> Self { + self.multiple = multiple; + self + } + + /// Enable or disable the search input at the top of the dropdown. + pub fn searchable(mut self, searchable: bool) -> Self { + self.searchable = searchable; + self + } + + /// Return the currently selected values. + pub fn selected_values(&self) -> Vec<::Value> { + self.state.selected_values() + } + + /// Return the first selected value, or `None` when nothing is selected. + /// + /// Convenience for single-select mode (`.multiple(false)`). + pub fn selected_value(&self) -> Option<::Value> { + self.state.selected_values().into_iter().next() + } + + /// Return the currently selected `(IndexPath, Item)` pairs. + pub fn selection(&self) -> &[(IndexPath, D::Item)] { + self.state.selection() + } + + /// Replace the entire selection set. + pub fn set_selected_indices( + &mut self, + indices: impl IntoIterator, + _window: &mut Window, + cx: &mut Context, + ) { + self.state.set_selected_indices(indices, cx); + self.state.sync_snapshot(cx); + cx.notify(); + } + + /// Add a single index to the selection, if not already present, returning whether it was added. + pub fn add_selected_index(&mut self, index: IndexPath, cx: &mut Context) -> bool { + let added = self.state.add_selected_index(index, cx); + + if added { + self.state.sync_snapshot(cx); + cx.notify(); + } + + added + } + + /// Remove a single index from the selection, returning whether it was removed. + pub fn remove_selected_index(&mut self, index: IndexPath, cx: &mut Context) -> bool { + let removed = self.state.remove_selected_index(index); + + if removed { + self.state.sync_snapshot(cx); + } + + removed + } + + /// Clear all selected values. + pub fn clear_selection(&mut self, cx: &mut Context) { + self.state.selection.clear(); + self.state.sync_snapshot(cx); + cx.emit(ComboboxEvent::Change(self.selected_values())); + cx.notify(); + } + + /// Replace the underlying delegate (item data source). + pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context) { + self.state.list.update(cx, |list, _| { + list.delegate_mut().delegate = items; + }); + } + + /// Focus the trigger. + pub fn focus(&self, window: &mut Window, cx: &mut App) { + self.state.focus_handle.focus(window, cx); + } + + /// Process an item click: single-select replaces the selection and closes; multi-select toggles. + /// + /// Calls `delegate.on_will_change` before committing and `delegate.on_confirm` when closing. + #[allow(dead_code)] + pub(crate) fn handle_item_select( + &mut self, + ix: IndexPath, + window: &mut Window, + cx: &mut Context, + ) { + let is_selected = self.state.selection.iter().any(|(cur_ix, _)| cur_ix == &ix); + + let changes: Vec = if self.multiple { + if is_selected { + vec![SearchableListChange::Deselect { index: ix }] + } else { + vec![SearchableListChange::Select { index: ix }] + } + } else { + let mut changes: Vec = self + .state + .selection + .iter() + .map(|(cur_ix, _)| SearchableListChange::Deselect { index: *cur_ix }) + .collect(); + changes.push(SearchableListChange::Select { index: ix }); + changes + }; + + let mut selection = self.state.selection.clone(); + let before_indices: Vec = selection.iter().map(|(ix, _)| *ix).collect(); + + self.state.list.update(cx, |list, _cx| { + list.delegate_mut() + .delegate + .on_will_change(&mut selection, &changes); + }); + + let after_indices: Vec = selection.iter().map(|(ix, _)| *ix).collect(); + let changed = before_indices != after_indices; + let should_close = changed && !self.multiple; + + self.state.selection = selection; + self.state.sync_snapshot(cx); + + if changed { + cx.emit(ComboboxEvent::Change(self.selected_values())); + cx.notify(); + } + + if should_close { + let final_selection = self.state.selection.clone(); + self.state.list.update(cx, |list, _cx| { + list.delegate_mut().delegate.on_confirm(&final_selection); + }); + + cx.emit(ComboboxEvent::Confirm(self.selected_values())); + self.set_open(false, cx); + self.focus(window, cx); + } + } + + fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { + if self.state.list.read(cx).is_focused(window, cx) + || self.state.focus_handle.is_focused(window) + { + return; + } + + self.set_open(false, cx); + cx.notify(); + } + + fn up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { + if !self.state.open { + self.set_open(true, cx); + } + + self.state.list.focus_handle(cx).focus(window, cx); + cx.propagate(); + } + + fn down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { + if !self.state.open { + self.set_open(true, cx); + } + + self.state.list.focus_handle(cx).focus(window, cx); + cx.propagate(); + } + + fn enter(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + cx.propagate(); + + if !self.state.open { + self.set_open(true, cx); + cx.notify(); + } + + self.state.list.focus_handle(cx).focus(window, cx); + } + + fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + cx.stop_propagation(); + + self.set_open(!self.state.open, cx); + + if self.state.open { + self.state.list.focus_handle(cx).focus(window, cx); + } + + cx.notify(); + } + + fn escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { + if !self.state.open { + cx.propagate(); + return; + } + + cx.stop_propagation(); + cx.emit(ComboboxEvent::Confirm(self.selected_values())); + + self.set_open(false, cx); + self.focus(window, cx); + cx.notify(); + } + + fn set_open(&mut self, open: bool, cx: &mut Context) { + self.state.open = open; + + if self.state.open { + GlobalState::global_mut(cx).register_deferred_popover(&self.state.focus_handle) + } else { + GlobalState::global_mut(cx).unregister_deferred_popover(&self.state.focus_handle) + } + + cx.notify(); + } + + fn clean(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + cx.stop_propagation(); + self.clear_selection(cx); + } + + fn default_trigger_body(&self, _window: &mut Window, cx: &mut Context) -> AnyElement { + let placeholder_text = self + .state + .placeholder + .clone() + .unwrap_or_else(|| t!("Combobox.placeholder").into()); + + if self.state.selection.is_empty() { + return div() + .text_color(cx.theme().muted_foreground) + .child(placeholder_text) + .into_any_element(); + } + + if self.multiple { + let items: Vec = self + .state + .selection + .iter() + .map(|(_, i)| i.title()) + .collect(); + + div() + .w_full() + .overflow_hidden() + .whitespace_nowrap() + .truncate() + .child(items.join(", ")) + .into_any_element() + } else { + let title = self + .state + .selection + .first() + .map(|(_, i)| i.title()) + .unwrap_or_default(); + + div() + .w_full() + .overflow_hidden() + .whitespace_nowrap() + .truncate() + .child(title) + .into_any_element() + } + } +} + +impl Render for ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let searchable = self.searchable; + let is_focused = self.state.focus_handle.is_focused(window); + let show_clean = self.state.cleanable && !self.state.selection.is_empty(); + let bounds = self.state.bounds; + let allow_open = !(self.state.open || self.state.disabled); + let outline_visible = self.state.open || (is_focused && !self.state.disabled); + let disabled = self.state.disabled; + + let (bg, fg) = input_style(disabled, cx); + + self.state.list.update(cx, |list, cx| { + list.set_searchable(searchable, cx); + list.delegate_mut().size = self.state.size; + list.delegate_mut().check_icon = self.check_icon.clone(); + }); + + let selection = &self.state.selection; + let placeholder = self.state.placeholder.as_ref(); + let open = self.state.open; + let size = self.state.size; + let has_custom_trigger = self.render_trigger.is_some(); + + let trigger_icon = self + .trigger_icon + .clone() + .unwrap_or_else(|| Icon::new(IconName::ChevronDown)); + + let trigger_body = if let Some(render_trigger) = &self.render_trigger { + let ctx = ComboboxTriggerContext { + selection, + placeholder, + open, + disabled, + size, + }; + + render_trigger(&ctx, window, cx) + } else { + self.default_trigger_body(window, cx) + }; + + let trailing: AnyElement = if has_custom_trigger { + div().into_any_element() + } else if show_clean { + clear_button(cx) + .map(|this| { + if disabled { + this.disabled(true) + } else { + this.on_click(cx.listener(Self::clean)) + } + }) + .into_any_element() + } else { + trigger_icon + .xsmall() + .text_color(cx.theme().muted_foreground) + .into_any_element() + }; + + let toggle_handler: Option> = + if allow_open { + Some(Box::new(cx.listener(Self::toggle_menu))) + } else { + None + }; + + let prepaint_handler: Box, &mut Window, &mut App) + 'static> = { + let state = cx.entity(); + Box::new(move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)) + }; + + let footer_el = self.footer.as_ref().map(|f| f(window, cx)); + + let dismiss_handler: Box = + Box::new(cx.listener(|this, _, window, cx| this.escape(&Cancel, window, cx))); + + div() + .size_full() + .relative() + .child(render_trigger_container( + disabled, + self.state.appearance, + self.state.size, + &self.state.style, + bg, + fg, + outline_visible, + allow_open, + trigger_body, + trailing, + toggle_handler, + prepaint_handler, + cx, + )) + .when(self.state.open, |this| { + this.child( + deferred(render_popup_shell( + &self.state.list, + self.state.menu_width, + self.state.search_placeholder.clone(), + self.state.size, + self.state.menu_max_h, + bounds, + footer_el, + dismiss_handler, + cx, + )) + .with_priority(1), + ) + }) + } +} + +impl EventEmitter> for ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ +} +impl EventEmitter for ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ +} + +impl Focusable for ComboboxState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn focus_handle(&self, cx: &App) -> FocusHandle { + if self.state.open { + self.state.list.focus_handle(cx) + } else { + self.state.focus_handle.clone() + } + } +} + +// MARK: Combobox element + +/// A combo box with support for single and multi-select. +/// +/// Clicking an item toggles it in the selection; the dropdown stays open until the user +/// presses Escape or clicks outside. +#[derive(IntoElement)] +pub struct Combobox +where + ::Value: PartialEq + Clone, +{ + id: ElementId, + state: Entity>, + options: ComboboxOptions, + render_trigger: + Option, &mut Window, &mut App) -> AnyElement + 'static>>, + footer: Option AnyElement + 'static>>, + empty: Option AnyElement + 'static>>, +} + +impl Combobox +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + pub fn new(state: &Entity>) -> Self { + Self { + id: ("multi-combo-box", state.entity_id()).into(), + state: state.clone(), + options: ComboboxOptions::default(), + render_trigger: None, + footer: None, + empty: None, + } + } + + /// Set the width of the dropdown menu. + pub fn menu_width(mut self, width: impl Into) -> Self { + self.options.menu_width = width.into(); + self + } + + /// Set the maximum height of the dropdown menu. + pub fn menu_max_h(mut self, max_h: impl Into) -> Self { + self.options.menu_max_h = max_h.into(); + self + } + + /// Set the placeholder text shown when no items are selected. + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.options.placeholder = Some(placeholder.into()); + self + } + + /// Override the trigger chevron icon. + pub fn icon(mut self, icon: impl Into) -> Self { + self.options.trigger_icon = Some(icon.into()); + self + } + + /// Override the trailing check icon shown next to selected items. + pub fn check_icon(mut self, icon: impl Into) -> Self { + self.options.check_icon = Some(icon.into()); + self + } + + /// Set the placeholder text for the search input. + pub fn search_placeholder(mut self, placeholder: impl Into) -> Self { + self.options.search_placeholder = Some(placeholder.into()); + self + } + + /// Show a clear button when at least one item is selected. + pub fn cleanable(mut self, cleanable: bool) -> Self { + self.options.cleanable = cleanable; + self + } + + /// Set the disabled state. + pub fn disabled(mut self, disabled: bool) -> Self { + self.options.disabled = disabled; + self + } + + /// Set a custom closure that renders the empty-state element. + pub fn empty( + mut self, + builder: impl Fn(&mut Window, &App) -> E + 'static, + ) -> Self { + self.empty = Some(Box::new(move |window, cx| builder(window, cx).into_any_element())); + self + } + + /// Control whether the trigger shows a border and background. + pub fn appearance(mut self, appearance: bool) -> Self { + self.options.appearance = appearance; + self + } + + /// Override the entire trigger element. + pub fn render_trigger( + mut self, + f: impl Fn(&ComboboxTriggerContext, &mut Window, &mut App) -> E + 'static, + ) -> Self { + self.render_trigger = Some(Box::new(move |ctx, window, cx| { + f(ctx, window, cx).into_any_element() + })); + self + } + + /// Render an element below a separator at the bottom of the dropdown. + pub fn footer( + mut self, + f: impl Fn(&mut Window, &mut App) -> E + 'static, + ) -> Self { + self.footer = Some(Box::new(move |window, cx| f(window, cx).into_any_element())); + self + } +} + +impl Sizable for Combobox +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn with_size(mut self, size: impl Into) -> Self { + self.options.size = size.into(); + self + } +} + +impl Styled for Combobox +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn style(&mut self) -> &mut StyleRefinement { + &mut self.options.style + } +} + +impl RenderOnce for Combobox +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let disabled = self.options.disabled; + let focus_handle = self.state.focus_handle(cx); + let render_trigger = self.render_trigger; + let footer = self.footer; + let empty = self.empty; + let opts = self.options; + + self.state.update(cx, |this, _| { + this.state.style = opts.style; + this.state.size = opts.size; + this.state.cleanable = opts.cleanable; + this.state.placeholder = opts.placeholder; + this.state.search_placeholder = opts.search_placeholder; + this.state.menu_width = opts.menu_width; + this.state.menu_max_h = opts.menu_max_h; + this.state.disabled = opts.disabled; + this.state.appearance = opts.appearance; + this.trigger_icon = opts.trigger_icon; + this.check_icon = opts.check_icon; + this.render_trigger = render_trigger; + this.footer = footer; + + if let Some(empty) = empty { + this.state.empty = Some(empty); + } + }); + + div() + .id(self.id.clone()) + .key_context(CONTEXT) + .when(!disabled, |this| { + this.track_focus(&focus_handle.tab_stop(true)) + }) + .on_action(window.listener_for(&self.state, ComboboxState::up)) + .on_action(window.listener_for(&self.state, ComboboxState::down)) + .on_action(window.listener_for(&self.state, ComboboxState::enter)) + .on_action(window.listener_for(&self.state, ComboboxState::escape)) + .size_full() + .child(self.state) + } +} + +// MARK: Rendering helpers + +/// Renders the styled trigger container. +#[allow(clippy::too_many_arguments)] +fn render_trigger_container( + disabled: bool, + appearance: bool, + size: Size, + style: &StyleRefinement, + bg: Hsla, + fg: Hsla, + outline_visible: bool, + allow_open: bool, + trigger_body: AnyElement, + trailing: AnyElement, + toggle_handler: Option>, + prepaint_handler: Box, &mut Window, &mut App) + 'static>, + cx: &mut App, +) -> impl IntoElement { + div() + .id("input") + .relative() + .flex() + .items_center() + .justify_between() + .border_1() + .border_color(cx.theme().transparent) + .when(appearance, |this| { + this.bg(bg) + .text_color(fg) + .when(disabled, |this| this.opacity(0.5)) + .border_color(cx.theme().input) + .rounded(cx.theme().radius) + .when(cx.theme().shadow, |this| this.shadow_xs()) + }) + .map(|this| { + if disabled { + this.shadow_none() + } else { + this + } + }) + .overflow_hidden() + .input_size(size) + .input_text_size(size) + .refine_style(style) + .when(outline_visible, |this| this.focused_border(cx)) + .when(allow_open, |this| { + this.when_some(toggle_handler, |this, handler| this.on_click(handler)) + }) + .child( + h_flex() + .id("inner") + .w_full() + .items_center() + .justify_between() + .gap_1() + .child(trigger_body) + .child(trailing), + ) + .on_prepaint(prepaint_handler) +} + +/// Renders the deferred anchored popup shell containing the searchable list and optional footer. +#[allow(clippy::too_many_arguments)] +fn render_popup_shell( + list: &Entity>>, + menu_width: Length, + search_placeholder: Option, + size: Size, + menu_max_h: Length, + bounds: Bounds, + footer_el: Option, + dismiss_handler: Box, + cx: &mut App, +) -> AnyElement { + let has_footer = footer_el.is_some(); + let popup_radius = cx.theme().radius.min(px(8.)); + + anchored() + .snap_to_window_with_margin(px(8.)) + .child( + div() + .occlude() + .map(|this| match menu_width { + Length::Auto => this.w(bounds.size.width + px(2.)), + Length::Definite(w) => this.w(w), + }) + .child( + v_flex() + .occlude() + .mt_1p5() + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .rounded(popup_radius) + .shadow_md() + .child( + List::new(list) + .when_some(search_placeholder, |this, placeholder| { + this.search_placeholder(placeholder) + }) + .with_size(size) + .max_h(menu_max_h) + .paddings(Edges::all(px(4.))), + ) + .when(has_footer, |this| { + this.child( + div() + .border_t_1() + .border_color(cx.theme().border) + .p_1() + .when_some(footer_el, |this, el| this.child(el)), + ) + }), + ) + .on_mouse_down_out(dismiss_handler), + ) + .into_any_element() +} + +// MARK: Tests + +#[cfg(test)] +mod tests { + use gpui::{AppContext as _, TestAppContext}; + + use crate::{ + IndexPath, + combobox::{Combobox, ComboboxState}, + searchable_list::{ + SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState, + SearchableVec, + }, + }; + + #[gpui::test] + fn test_combo_box_builder(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["Rust", "Go", "C++"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true)); + + let _cb = Combobox::new(&state) + .placeholder("Select language") + .search_placeholder("Search...") + .menu_width(gpui::px(300.)) + .menu_max_h(gpui::rems(15.)) + .cleanable(true) + .disabled(false) + .appearance(true); + }); + } + + #[gpui::test] + fn test_combo_box_search_filters_items(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["Rust", "Go", "C++"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true)); + + let count_before = state + .read(cx) + .state + .list + .read(cx) + .delegate() + .delegate + .items_count(0); + assert_eq!(count_before, 3); + + state.update(cx, |s, cx| { + s.state.list.update(cx, |list, cx| { + let _ = list + .delegate_mut() + .delegate + .perform_search("Rust", window, cx); + }); + }); + + let count_after = state + .read(cx) + .state + .list + .read(cx) + .delegate() + .delegate + .items_count(0); + assert_eq!(count_after, 1); + }); + } + + #[gpui::test] + fn test_multi_combo_box_builder(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| { + ComboboxState::new(items, vec![IndexPath::new(0)], window, cx) + .multiple(true) + .searchable(true) + }); + + let _cb = Combobox::new(&state) + .placeholder("Select frameworks") + .search_placeholder("Search...") + .menu_width(gpui::px(300.)) + .cleanable(true) + .disabled(false); + + assert_eq!(state.read(cx).selected_values(), vec!["React"]); + }); + } + + #[gpui::test] + fn test_multi_combo_box_toggle(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx + .new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true)); + + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx)); + assert_eq!(state.read(cx).selected_values(), &["React"]); + + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx)); + assert_eq!(state.read(cx).selected_values(), &["React", "Vue"]); + + state.update(cx, |s, cx| s.remove_selected_index(IndexPath::new(0), cx)); + assert_eq!(state.read(cx).selected_values(), &["Vue"]); + }); + } + + #[gpui::test] + fn test_multi_combo_box_clear(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); + let state = cx.new(|cx| { + ComboboxState::new( + items, + vec![IndexPath::new(0), IndexPath::new(1)], + window, + cx, + ) + .multiple(true) + }); + + assert_eq!(state.read(cx).selected_values().len(), 2); + state.update(cx, |s, cx| s.clear_selection(cx)); + assert!(state.read(cx).selected_values().is_empty()); + }); + } + + #[gpui::test] + fn test_single_combo_box_mode(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let items = SearchableVec::new(vec!["Rust", "Go", "C++"]); + let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx)); + + // Default mode is Single. + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx)); + assert_eq!(state.read(cx).selected_values(), &["Rust"]); + + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx)); + assert_eq!(state.read(cx).selected_values(), &["Rust", "Go"]); + }); + } + + // Delegate that vetoes all selections via on_will_change by ignoring the changes. + struct VetoDelegate(SearchableVec<&'static str>); + + impl SearchableListDelegate for VetoDelegate { + type Item = &'static str; + + fn items_count(&self, section: usize) -> usize { + self.0.items_count(section) + } + + fn item(&self, ix: IndexPath) -> Option<&&'static str> { + self.0.item(ix) + } + + fn position(&self, value: &V) -> Option + where + &'static str: SearchableListItem, + V: PartialEq, + { + self.0.position(value) + } + + fn on_will_change( + &mut self, + _selection: &mut Vec<(IndexPath, &'static str)>, + _changes: &[SearchableListChange], + ) { + // Leave selection unchanged — acts as a veto. + } + } + + #[gpui::test] + fn test_on_will_change_veto(cx: &mut TestAppContext) { + cx.update(crate::init); + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + let delegate = VetoDelegate(SearchableVec::new(vec!["Rust", "Go", "C++"])); + let state = cx.new(|cx| ComboboxState::new(delegate, vec![], window, cx)); + + // Pre-select an item directly so we can verify veto prevents changes. + state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx)); + assert_eq!(state.read(cx).selected_values(), &["Rust"]); + + // Simulate a click on index 1 via handle_item_select; on_will_change vetoes it. + state.update(cx, |s, cx| { + s.handle_item_select(IndexPath::new(1), window, cx); + }); + + // Selection must remain unchanged because on_will_change left it unmodified. + assert_eq!(state.read(cx).selected_values(), &["Rust"]); + }); + } + + // Suppress unused import warning for SearchableListState in test module. + #[allow(unused)] + fn _uses_state( + _: &SearchableListState, + ) where + ::Value: PartialEq + Clone, + { + } +} diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 6f8566af6e..8688613c02 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -33,6 +33,7 @@ pub mod checkbox; pub mod clipboard; pub mod collapsible; pub mod color_picker; +pub mod combobox; pub mod description_list; pub mod dialog; pub mod dock; @@ -56,6 +57,7 @@ pub mod radio; pub mod rating; pub mod resizable; pub mod scroll; +pub(crate) mod searchable_list; pub mod select; pub mod separator; pub mod setting; @@ -111,6 +113,7 @@ pub fn init(cx: &mut App) { date_picker::init(cx); dock::init(cx); sheet::init(cx); + combobox::init(cx); select::init(cx); input::init(cx); list::init(cx); diff --git a/crates/ui/src/searchable_list.rs b/crates/ui/src/searchable_list.rs new file mode 100644 index 0000000000..aa29de47bf --- /dev/null +++ b/crates/ui/src/searchable_list.rs @@ -0,0 +1,13 @@ +pub(crate) mod adapter; +pub mod change; +mod delegate; +mod item; +pub mod state; +mod vec; + +pub(crate) use adapter::SearchableListAdapter; +pub use change::SearchableListChange; +pub use delegate::{SearchableListDelegate, SearchableListItem}; +pub use item::SearchableListItemElement; +pub use state::SearchableListState; +pub use vec::{SearchableGroup, SearchableVec}; diff --git a/crates/ui/src/searchable_list/adapter.rs b/crates/ui/src/searchable_list/adapter.rs new file mode 100644 index 0000000000..8c88dfe4e2 --- /dev/null +++ b/crates/ui/src/searchable_list/adapter.rs @@ -0,0 +1,178 @@ +use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Window, div}; + +use crate::{ + ActiveTheme, Disableable as _, Icon, IconName, IndexPath, Sizable as _, Size, StyleSized as _, + list::{ListDelegate, ListState}, +}; + +use super::{delegate::{SearchableListDelegate, SearchableListItem as _}, item::SearchableListItemElement}; + +/// Bridges a [`SearchableListDelegate`] into the [`ListDelegate`] protocol. +/// +/// Parent states (`SelectState`, `ComboBoxState`) create one of these, supplying closures that +/// encode parent-specific confirm / cancel / empty-render behaviour. All state mutation stays in +/// the parent; the adapter only handles item layout. +pub(crate) struct SearchableListAdapter { + pub(crate) delegate: D, + /// Keyboard cursor row — updated by `ListDelegate::set_selected_index`. + selected_index: Option, + /// Snapshot of the parent's committed selection, kept in sync by the parent state after every + /// selection change. `render_item` reads this directly so it never touches the parent entity + /// (which would panic — the `ListState` entity is already locked during render). + pub(crate) selection_snapshot: Vec<(IndexPath, D::Item)>, + /// Called when the user confirms an item (click or Enter). + on_confirm: + Box, bool, &mut Window, &mut Context>) + 'static>, + /// Called when the user cancels (Escape) or focus leaves the dropdown. + on_cancel: Box, &mut Window, &mut Context>) + 'static>, + /// Renders the empty-state placeholder. + on_render_empty: Box AnyElement + 'static>, + pub(crate) size: Size, + /// Override the trailing check icon; defaults to `IconName::Check`. + pub(crate) check_icon: Option, +} + +impl SearchableListAdapter { + pub(crate) fn new( + delegate: D, + selected_index: Option, + on_confirm: impl Fn(Option, bool, &mut Window, &mut Context>) + + 'static, + on_cancel: impl Fn(Option, &mut Window, &mut Context>) + 'static, + on_render_empty: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + Self { + delegate, + selected_index, + selection_snapshot: Vec::new(), + on_confirm: Box::new(on_confirm), + on_cancel: Box::new(on_cancel), + on_render_empty: Box::new(on_render_empty), + size: Size::default(), + check_icon: None, + } + } + + /// Replace the selection snapshot. Call this after every selection mutation so that + /// `render_item` sees up-to-date check state without touching any external entity. + pub(crate) fn update_selection_snapshot(&mut self, snapshot: Vec<(IndexPath, D::Item)>) { + self.selection_snapshot = snapshot; + } +} + +impl ListDelegate for SearchableListAdapter { + type Item = SearchableListItemElement; + + fn sections_count(&self, cx: &App) -> usize { + self.delegate.sections_count(cx) + } + + fn items_count(&self, section: usize, _: &App) -> usize { + self.delegate.items_count(section) + } + + fn render_section_header( + &mut self, + section: usize, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + if let Some(el) = self.delegate.render_section_header(section, window, cx) { + return Some(el.into_any_element()); + } + + #[allow(deprecated)] + let item = self.delegate.section(section)?; + + Some( + div() + .py_0p5() + .px_2() + .list_size(self.size) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(item) + .into_any_element(), + ) + } + + fn render_item( + &mut self, + ix: IndexPath, + window: &mut Window, + cx: &mut Context>, + ) -> Option { + use gpui::IntoElement as _; + + let item = self.delegate.item(ix)?; + // Read check state from the snapshot — never from an external entity, which would panic + // because the ListState entity is already locked for this render pass. + let is_checked = self + .delegate + .is_item_checked(ix, item, &self.selection_snapshot, cx); + let disabled = !self.delegate.is_item_enabled(ix, item, cx); + let size = self.size; + + if let Some(el) = self.delegate.render_item(ix, item, is_checked, window, cx) { + return Some( + SearchableListItemElement::new(ix.row) + .disabled(disabled) + .with_size(size) + .child(el), + ); + } + + let check_icon = self + .check_icon + .clone() + .unwrap_or_else(|| Icon::new(IconName::Check)); + + let content = div() + .whitespace_nowrap() + .child(item.render(window, cx).into_any_element()); + + Some( + SearchableListItemElement::new(ix.row) + .checked(is_checked) + .check_icon(check_icon) + .disabled(disabled) + .with_size(size) + .child(content.into_any_element()), + ) + } + + fn cancel(&mut self, window: &mut Window, cx: &mut Context>) { + let saved = self.selected_index; + (self.on_cancel)(saved, window, cx); + } + + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + (self.on_confirm)(self.selected_index, secondary, window, cx); + } + + fn perform_search( + &mut self, + query: &str, + window: &mut Window, + cx: &mut Context>, + ) -> gpui::Task<()> { + self.delegate.perform_search(query, window, cx) + } + + fn set_selected_index( + &mut self, + ix: Option, + _: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn render_empty( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) -> impl IntoElement { + (self.on_render_empty)(window, cx) + } +} diff --git a/crates/ui/src/searchable_list/change.rs b/crates/ui/src/searchable_list/change.rs new file mode 100644 index 0000000000..1ec6c5f226 --- /dev/null +++ b/crates/ui/src/searchable_list/change.rs @@ -0,0 +1,13 @@ +use crate::IndexPath; + +/// A single, atomic selection change proposed by the mode-strategy for a user interaction. +/// +/// Passed as a slice to [`SearchableListDelegate::on_will_change`], giving the delegate +/// a description of what the default strategy intends to do. The delegate may apply all, +/// some, or none of the changes by mutating the `selection` argument directly. +pub enum SearchableListChange { + /// Select the item at the given index path. + Select { index: IndexPath }, + /// Deselect the item at the given index path. + Deselect { index: IndexPath }, +} diff --git a/crates/ui/src/searchable_list/delegate.rs b/crates/ui/src/searchable_list/delegate.rs new file mode 100644 index 0000000000..6a8894b0fd --- /dev/null +++ b/crates/ui/src/searchable_list/delegate.rs @@ -0,0 +1,181 @@ +use gpui::{AnyElement, App, IntoElement, SharedString, Task, Window}; + +use crate::IndexPath; + +use super::change::SearchableListChange; + +/// An item that can appear in a searchable list (Select, ComboBox). +pub trait SearchableListItem: Clone { + type Value: Clone; + + /// Short display label shown in the dropdown row and in the trigger by default. + fn title(&self) -> SharedString; + + /// Override the trigger display element (e.g. "Country (US)" instead of just "United States"). + /// + /// Returns `None` to fall back to `title()`. + fn display_title(&self) -> Option { + None + } + + /// Render this item's row content inside the dropdown. + /// + /// Override to add icons, avatars, secondary text, etc. + /// The default renders `title()`. + fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.title() + } + + /// The value that identifies this item. + fn value(&self) -> &Self::Value; + + /// Whether this item matches the search query. + /// + /// Defaults to case-insensitive substring match on `title()`. + fn matches(&self, query: &str) -> bool { + self.title().to_lowercase().contains(&query.to_lowercase()) + } + + /// Whether this item should be shown as non-interactive (grayed-out, unclickable). + fn disabled(&self) -> bool { + false + } +} + +/// Provides data and search behaviour to a searchable list component. +pub trait SearchableListDelegate: Sized + 'static { + type Item: SearchableListItem; + + /// Number of sections (groups) in the list. Defaults to 1. + fn sections_count(&self, _: &App) -> usize { + 1 + } + + /// Optional header element for the given section index. + /// + /// Deprecated: override [`render_section_header`] instead (provides `Window` + `App` access). + #[deprecated] + fn section(&self, _section: usize) -> Option { + None + } + + /// Number of items in the given section. + fn items_count(&self, section: usize) -> usize; + + /// Return a reference to the item at the given index path. + fn item(&self, ix: IndexPath) -> Option<&Self::Item>; + + /// Find the index path of the item whose value equals `value`. + fn position(&self, _value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq; + + /// Called when the search query changes. + /// + /// Implementations should filter or fetch items and may return an async `Task`. + /// The `App` context allows spawning background work. + fn perform_search(&mut self, _query: &str, _window: &mut Window, _cx: &mut App) -> Task<()> { + Task::ready(()) + } + + // MARK: Rendering hooks + + /// Override the row content for the item at `ix`. + /// + /// When `Some(_)` is returned, the adapter suppresses its default `SearchableListItemElement` + /// layout (including the automatic trailing check icon) — the returned element is rendered + /// as-is. Return `None` to fall back to the standard rendering. + /// + /// `checked` is `true` when the item is in the current selection (as determined by + /// `is_item_checked`), letting custom renderers show their own selection indicator. + /// + /// Replaces the `item_renderer` closure that was previously set on `SearchableListAdapter`. + fn render_item( + &self, + _ix: IndexPath, + _item: &Self::Item, + _checked: bool, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + None + } + + /// Render the header element for the given section (full render access). + /// + /// When `Some(_)` is returned, it is rendered directly — the adapter's default div wrapper + /// (padding, muted colour) is bypassed. Return `None` to fall back to the deprecated + /// `section()` wrapped in the standard div (no visual change for existing delegates). + fn render_section_header( + &self, + _section: usize, + _window: &mut Window, + _cx: &mut App, + ) -> Option { + None + } + + // MARK: Item state hooks + + /// Whether the item at `ix` should be rendered as interactive. + /// + /// Default: `!item.disabled()`. + fn is_item_enabled(&self, _ix: IndexPath, item: &Self::Item, _cx: &App) -> bool { + !item.disabled() + } + + /// Whether the item at `ix` should show a checkmark. + /// + /// `current_selection` is the slice of currently selected `(IndexPath, Item)` pairs. + /// + /// Default: checks whether `ix` is present in `current_selection`. + fn is_item_checked( + &self, + ix: IndexPath, + _item: &Self::Item, + current_selection: &[(IndexPath, Self::Item)], + _cx: &App, + ) -> bool { + current_selection.iter().any(|(sel_ix, _)| sel_ix == &ix) + } + + // MARK: Lifecycle / selection hooks + + /// Called before a user-triggered selection change is committed. + /// + /// `selection` is the live selection vec — the delegate may freely mutate it: add items, + /// remove items, reorder, or leave it unchanged to effectively veto the operation. + /// + /// `changes` is the slice of atomic changes the mode-strategy computed (e.g. Single + /// replacement deselects all then selects one; Multi toggles the clicked item). The delegate + /// is not required to apply them — they are informational. The default implementation applies + /// every change in order. + /// + /// No `cx` is available: this hook runs synchronously during the item-click handler while + /// the list entity is mutably borrowed. Side effects that need cx belong in `on_confirm`. + fn on_will_change( + &mut self, + selection: &mut Vec<(IndexPath, Self::Item)>, + changes: &[SearchableListChange], + ) { + for change in changes { + match change { + SearchableListChange::Select { index } => { + if !selection.iter().any(|(ix, _)| ix == index) { + if let Some(item) = self.item(*index) { + selection.push((*index, item.clone())); + } + } + } + SearchableListChange::Deselect { index } => { + selection.retain(|(ix, _)| ix != index); + } + } + } + } + + /// Called when the dropdown/popover is committed (Escape, `close_on_select`, or explicit + /// confirm). `final_selection` is the selection after the last committed change. + fn on_confirm(&mut self, _final_selection: &[(IndexPath, Self::Item)]) {} +} diff --git a/crates/ui/src/searchable_list/item.rs b/crates/ui/src/searchable_list/item.rs new file mode 100644 index 0000000000..b2ba9655dc --- /dev/null +++ b/crates/ui/src/searchable_list/item.rs @@ -0,0 +1,138 @@ +use gpui::{ + AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce, + StyleRefinement, Styled, Window, prelude::FluentBuilder, +}; + +use crate::{ + ActiveTheme, Disableable, Icon, IconName, Selectable, Sizable, Size, StyleSized, StyledExt, + h_flex, +}; + +/// A single row element used inside searchable-list dropdowns (Select, ComboBox, MultiComboBox). +/// +/// - `selected` — controls the cursor-highlight background (the `List` overwrites this field via +/// `Selectable::selected` to match the keyboard cursor position). +/// - `checked` — controls the visibility of the trailing check icon; set by the adapter based on +/// the current selection state and NOT overwritten by the `List`. +#[derive(IntoElement)] +pub struct SearchableListItemElement { + id: ElementId, + size: Size, + style: StyleRefinement, + /// Cursor/highlight background (overridden by `List` to the keyboard cursor row). + selected: bool, + /// Whether the trailing check icon is shown. + checked: bool, + disabled: bool, + children: Vec, + /// The icon drawn at the trailing edge when `checked` is `true`. + check_icon: Option, +} + +impl SearchableListItemElement { + pub fn new(ix: usize) -> Self { + Self { + id: ("searchable-list-item", ix).into(), + size: Size::default(), + style: StyleRefinement::default(), + selected: false, + checked: false, + disabled: false, + children: Vec::new(), + check_icon: Some(Icon::new(IconName::Check)), + } + } + + /// Set whether the trailing check icon is visible. + pub fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } + + /// Override the default check icon. + pub fn check_icon(mut self, icon: impl Into) -> Self { + self.check_icon = Some(icon.into()); + self + } +} + +impl ParentElement for SearchableListItemElement { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Disableable for SearchableListItemElement { + fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl Selectable for SearchableListItemElement { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl Sizable for SearchableListItemElement { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl Styled for SearchableListItemElement { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for SearchableListItemElement { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .id(self.id) + .relative() + .gap_x_1() + .py_1() + .px_2() + .rounded(cx.theme().radius) + .text_base() + .text_color(cx.theme().foreground) + .items_center() + .justify_between() + .input_text_size(self.size) + .list_size(self.size) + .refine_style(&self.style) + .when(!self.disabled, |this| { + this.when(!self.selected, |this| { + this.hover(|this| this.bg(cx.theme().accent.alpha(0.7))) + }) + }) + .when(self.selected, |this| this.bg(cx.theme().accent)) + .when(self.disabled, |this| { + this.cursor_not_allowed() + .text_color(cx.theme().muted_foreground) + }) + .child( + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_x_1() + .child(h_flex().w_full().items_center().children(self.children)) + .when_some(self.check_icon, |this, icon| { + this.child( + icon.xsmall() + .text_color(cx.theme().foreground) + .when(!self.checked, |this| this.invisible()), + ) + }), + ) + } +} diff --git a/crates/ui/src/searchable_list/state.rs b/crates/ui/src/searchable_list/state.rs new file mode 100644 index 0000000000..7d2356d1cb --- /dev/null +++ b/crates/ui/src/searchable_list/state.rs @@ -0,0 +1,209 @@ +use gpui::{ + AnyElement, App, AppContext as _, Bounds, Context, Entity, FocusHandle, Focusable as _, Length, + Pixels, StyleRefinement, Subscription, Window, +}; + +use crate::{IndexPath, Size, list::ListState, searchable_list::adapter::SearchableListAdapter}; + +use super::delegate::{SearchableListDelegate, SearchableListItem}; + +/// Shared infrastructure for all searchable-list-based components (`SelectState`, `ComboBoxState`). +/// +/// This struct is a plain nested value inside a GPUI entity — it has no entity context of its +/// own and cannot call `cx.notify()` or `cx.emit()`. Callers are responsible for those after +/// calling mutable methods. +pub struct SearchableListState +where + ::Value: PartialEq + Clone, +{ + pub focus_handle: FocusHandle, + pub(crate) list: Entity>>, + pub(crate) selection: Vec<(IndexPath, D::Item)>, + pub(crate) open: bool, + pub(crate) bounds: Bounds, + + // Shared options + pub(crate) size: Size, + pub(crate) style: StyleRefinement, + pub(crate) cleanable: bool, + pub(crate) placeholder: Option, + pub(crate) search_placeholder: Option, + pub(crate) menu_width: Length, + pub(crate) menu_max_h: Length, + pub(crate) disabled: bool, + pub(crate) appearance: bool, + pub(crate) empty: Option AnyElement + 'static>>, + + pub(crate) _subscriptions: Vec, +} + +#[allow(private_bounds)] +impl SearchableListState +where + ::Value: PartialEq + Clone, +{ + /// Create a new `SearchableListState`, creating the list entity in the given parent context. + /// + /// `on_confirm`, `on_cancel`, and `on_render_empty` are forwarded to the underlying adapter. + /// `on_blur` is a function pointer invoked on the parent entity when focus leaves any of the + /// list's focus handles. + #[allow(clippy::too_many_arguments)] + pub fn new( + delegate: D, + selected_indices: Vec, + on_confirm: impl Fn( + Option, + bool, + &mut Window, + &mut Context>>, + ) + 'static, + on_cancel: impl Fn( + Option, + &mut Window, + &mut Context>>, + ) + 'static, + on_render_empty: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, + on_blur: fn(&mut P, &mut Window, &mut Context

), + window: &mut Window, + cx: &mut Context

, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let initial_cursor = selected_indices.first().copied(); + let adapter = SearchableListAdapter::new( + delegate, + initial_cursor, + on_confirm, + on_cancel, + on_render_empty, + ); + let list = cx.new(|cx| ListState::new(adapter, window, cx).reset_on_cancel(false)); + + let list_focus_handle = list.read(cx).focus_handle.clone(); + let list_search_focus_handle = list.read(cx).query_input.focus_handle(cx); + + let selection = { + let delegate = &list.read(cx).delegate().delegate; + + selected_indices + .iter() + .copied() + .filter_map(|ix| delegate.item(ix).map(|i| (ix, i.clone()))) + .collect::>() + }; + + // Prime the adapter's snapshot so the very first render pass sees correct check state. + let initial_snapshot = selection.clone(); + list.update(cx, |l, _| { + l.delegate_mut().update_selection_snapshot(initial_snapshot); + }); + + let _subscriptions = vec![ + cx.on_blur(&list_focus_handle, window, on_blur), + cx.on_blur(&list_search_focus_handle, window, on_blur), + cx.on_blur(&focus_handle, window, on_blur), + ]; + + Self { + focus_handle, + list, + selection, + open: false, + bounds: Bounds::default(), + size: Size::default(), + style: StyleRefinement::default(), + cleanable: false, + placeholder: None, + search_placeholder: None, + menu_width: Length::Auto, + menu_max_h: gpui::rems(20.).into(), + disabled: false, + appearance: true, + empty: None, + _subscriptions, + } + } + + // MARK: Read-only accessors + + pub fn selection(&self) -> &[(IndexPath, D::Item)] { + &self.selection + } + + pub fn selected_values(&self) -> Vec<::Value> { + self.selection + .iter() + .map(|(_ix, i)| i.value().clone()) + .collect() + } + + // MARK: Mutation (no cx — callers emit events and notify) + + /// Add an index+item pair to the selection; no-op if already present. + pub(crate) fn add_by_item(&mut self, index: IndexPath, item: D::Item) { + if self.selection.iter().any(|(ix, _)| ix == &index) { + return; + } + + self.selection.push((index, item)); + } + + /// Remove an index from the selection by index path. + pub(crate) fn remove_by_index(&mut self, index: &IndexPath) -> bool { + if let Some(pos) = self.selection.iter().position(|(ix, _)| ix == index) { + self.selection.remove(pos); + + return true; + } + + false + } + + /// Add a single index to the selection by looking up the item in the list. + /// + /// Requires `cx` only to read the list entity; does not notify. + pub fn add_selected_index(&mut self, index: IndexPath, cx: &App) -> bool { + if self.selection.iter().any(|(ix, _)| ix == &index) { + return false; + } + + let Some(item) = self.list.read(cx).delegate().delegate.item(index) else { + return false; + }; + + self.add_by_item(index, item.clone()); + + true + } + + /// Remove a single index from the selection. + pub fn remove_selected_index(&mut self, index: IndexPath) -> bool { + self.remove_by_index(&index) + } + + /// Replace the entire selection, looking up items from the list. + pub fn set_selected_indices(&mut self, indices: impl IntoIterator, cx: &App) { + let indices: Vec = indices.into_iter().collect(); + + self.selection = indices + .into_iter() + .filter_map(|ix| { + self.list + .read(cx) + .delegate() + .delegate + .item(ix) + .map(|i| (ix, i.clone())) + }) + .collect(); + } + + /// Push the current selection into the adapter's snapshot so the next render pass sees + /// up-to-date check state. Call after every mutation that changes `self.selection`. + pub(crate) fn sync_snapshot(&self, cx: &mut Context

) { + let snapshot = self.selection.clone(); + self.list.update(cx, |l, _| { + l.delegate_mut().update_selection_snapshot(snapshot); + }); + } +} diff --git a/crates/ui/src/searchable_list/vec.rs b/crates/ui/src/searchable_list/vec.rs new file mode 100644 index 0000000000..056e8f6aba --- /dev/null +++ b/crates/ui/src/searchable_list/vec.rs @@ -0,0 +1,239 @@ +use gpui::{App, SharedString, Task, Window}; + +use crate::IndexPath; + +use super::delegate::{SearchableListDelegate, SearchableListItem}; + +// MARK: Primitive impls + +impl SearchableListItem for String { + type Value = Self; + + fn title(&self) -> SharedString { + SharedString::from(self.clone()) + } + + fn value(&self) -> &Self::Value { + self + } +} + +impl SearchableListItem for SharedString { + type Value = Self; + + fn title(&self) -> SharedString { + self.clone() + } + + fn value(&self) -> &Self::Value { + self + } +} + +impl SearchableListItem for &'static str { + type Value = Self; + + fn title(&self) -> SharedString { + SharedString::from(*self) + } + + fn value(&self) -> &Self::Value { + self + } +} + +// MARK: Vec delegate + +impl SearchableListDelegate for Vec { + type Item = T; + + fn items_count(&self, _: usize) -> usize { + self.len() + } + + fn item(&self, ix: IndexPath) -> Option<&Self::Item> { + self.as_slice().get(ix.row) + } + + fn position(&self, value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq, + { + self.iter() + .position(|v| v.value() == value) + .map(|ix| IndexPath::default().row(ix)) + } +} + +// MARK: SearchableVec + +/// A vector of items that supports incremental filtering. +/// +/// On each `perform_search` call the `matched_items` view is rebuilt by filtering +/// the full `items` list. Use this as a delegate when all data is already in memory. +#[derive(Debug, Clone)] +pub struct SearchableVec { + items: Vec, + matched_items: Vec, +} + +impl SearchableVec { + /// Create a new `SearchableVec` from an initial list of items. + pub fn new(items: impl Into>) -> Self { + let items = items.into(); + + Self { + items: items.clone(), + matched_items: items, + } + } + + /// Append an item to both the master list and the current filtered view. + pub fn push(&mut self, item: T) { + self.items.push(item.clone()); + self.matched_items.push(item); + } +} + +impl From> for SearchableVec { + fn from(items: Vec) -> Self { + Self { + items: items.clone(), + matched_items: items, + } + } +} + +impl SearchableListDelegate for SearchableVec { + type Item = I; + + fn items_count(&self, _: usize) -> usize { + self.matched_items.len() + } + + fn item(&self, ix: IndexPath) -> Option<&Self::Item> { + self.matched_items.get(ix.row) + } + + fn position(&self, value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq, + { + self.matched_items + .iter() + .position(|v| v.value() == value) + .map(|ix| IndexPath::default().row(ix)) + } + + fn perform_search(&mut self, query: &str, _: &mut Window, _: &mut App) -> Task<()> { + self.matched_items = self + .items + .iter() + .filter(|item| item.matches(query)) + .cloned() + .collect(); + + Task::ready(()) + } +} + +// MARK: SearchableGroup + +/// A named group of items used for sectioned lists. +#[derive(Debug, Clone)] +pub struct SearchableGroup { + pub title: SharedString, + pub items: Vec, +} + +impl SearchableGroup { + /// Create an empty group with the given section title. + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + items: vec![], + } + } + + /// Append a single item to this group. + pub fn item(mut self, item: I) -> Self { + self.items.push(item); + self + } + + /// Append multiple items to this group. + pub fn items(mut self, items: impl IntoIterator) -> Self { + self.items.extend(items); + self + } + + pub(super) fn matches(&self, query: &str) -> bool { + self.title.to_lowercase().contains(&query.to_lowercase()) + || self.items.iter().any(|item| item.matches(query)) + } +} + +impl SearchableListDelegate for SearchableVec> { + type Item = I; + + fn sections_count(&self, _: &App) -> usize { + self.matched_items.len() + } + + fn items_count(&self, section: usize) -> usize { + self.matched_items + .get(section) + .map_or(0, |group| group.items.len()) + } + + fn section(&self, section: usize) -> Option { + use gpui::IntoElement as _; + + Some( + self.matched_items + .get(section)? + .title + .clone() + .into_any_element(), + ) + } + + fn item(&self, ix: IndexPath) -> Option<&Self::Item> { + let section = self.matched_items.get(ix.section)?; + + section.items.get(ix.row) + } + + fn position(&self, value: &V) -> Option + where + Self::Item: SearchableListItem, + V: PartialEq, + { + for (ix, group) in self.matched_items.iter().enumerate() { + for (row_ix, item) in group.items.iter().enumerate() { + if item.value() == value { + return Some(IndexPath::default().section(ix).row(row_ix)); + } + } + } + + None + } + + fn perform_search(&mut self, query: &str, _: &mut Window, _: &mut App) -> Task<()> { + self.matched_items = self + .items + .iter() + .filter(|item| item.matches(query)) + .cloned() + .map(|mut item| { + item.items.retain(|item| item.matches(query)); + item + }) + .collect(); + + Task::ready(()) + } +} diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index 296a6e5032..1ff78b37e4 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -1,24 +1,40 @@ use gpui::{ - AnyElement, App, AppContext, Bounds, ClickEvent, Context, DismissEvent, Edges, ElementId, - Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, - Length, ParentElement, Pixels, Render, RenderOnce, SharedString, StatefulInteractiveElement, - StyleRefinement, Styled, Subscription, Task, WeakEntity, Window, anchored, deferred, div, - prelude::FluentBuilder, px, rems, + AnyElement, App, ClickEvent, Context, DismissEvent, Edges, ElementId, Entity, EventEmitter, + FocusHandle, Focusable, InteractiveElement, IntoElement, KeyBinding, Length, ParentElement, + Render, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, + anchored, deferred, div, prelude::FluentBuilder, px, rems, }; use rust_i18n::t; use crate::{ - ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Selectable, Sizable, - Size, StyleSized, StyledExt, + ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Sizable, Size, + StyleSized, StyledExt, actions::{Cancel, Confirm, SelectDown, SelectUp}, global_state::GlobalState, h_flex, input::{clear_button, input_style}, - list::{List, ListDelegate, ListState}, + list::List, + searchable_list::{ + SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState, + }, v_flex, }; +// MARK: Public re-exports for back-compat + +/// Re-exported for backward compatibility. New code should prefer [`SearchableGroup`]. +pub use crate::searchable_list::SearchableGroup as SelectGroup; +/// Re-exported for backward compatibility. New code should prefer [`SearchableListDelegate`]. +pub use crate::searchable_list::SearchableListDelegate as SelectDelegate; +/// Re-exported for backward compatibility. New code should prefer [`SearchableListItem`]. +pub use crate::searchable_list::SearchableListItem as SelectItem; +/// Re-exported for backward compatibility. New code should prefer [`SearchableListItemElement`]. +pub use crate::searchable_list::SearchableListItemElement as SelectListItem; +/// Re-exported for backward compatibility. +pub use crate::searchable_list::SearchableVec; + const CONTEXT: &str = "Select"; + pub(crate) fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("up", SelectUp, Some(CONTEXT)), @@ -33,279 +49,15 @@ pub(crate) fn init(cx: &mut App) { ]) } -/// A trait for items that can be displayed in a select. -pub trait SelectItem: Clone { - type Value: Clone; - fn title(&self) -> SharedString; - /// Customize the display title used to selected item in Select Input. - /// - /// If return None, the title will be used. - fn display_title(&self) -> Option { - None - } - /// Render the item for the select dropdown menu, default is to render the title. - fn render(&self, _: &mut Window, _: &mut App) -> impl IntoElement { - self.title().into_element() - } - /// Get the value of the item. - fn value(&self) -> &Self::Value; - /// Check if the item matches the query for search, default is to match the title. - fn matches(&self, query: &str) -> bool { - self.title().to_lowercase().contains(&query.to_lowercase()) - } -} - -impl SelectItem for String { - type Value = Self; - - fn title(&self) -> SharedString { - SharedString::from(self.to_string()) - } - - fn value(&self) -> &Self::Value { - &self - } -} - -impl SelectItem for SharedString { - type Value = Self; - - fn title(&self) -> SharedString { - SharedString::from(self.to_string()) - } - - fn value(&self) -> &Self::Value { - &self - } -} - -impl SelectItem for &'static str { - type Value = Self; - - fn title(&self) -> SharedString { - SharedString::from(self.to_string()) - } - - fn value(&self) -> &Self::Value { - self - } -} - -pub trait SelectDelegate: Sized { - type Item: SelectItem; - - /// Returns the number of sections in the [`Select`]. - fn sections_count(&self, _: &App) -> usize { - 1 - } - - /// Returns the section header element for the given section index. - fn section(&self, _section: usize) -> Option { - return None; - } - - /// Returns the number of items in the given section. - fn items_count(&self, section: usize) -> usize; - - /// Returns the item at the given index path (Only section, row will be use). - fn item(&self, ix: IndexPath) -> Option<&Self::Item>; - - /// Returns the index of the item with the given value, or None if not found. - fn position(&self, _value: &V) -> Option - where - Self::Item: SelectItem, - V: PartialEq; - - fn perform_search( - &mut self, - _query: &str, - _window: &mut Window, - _: &mut Context>, - ) -> Task<()> { - Task::ready(()) - } -} - -impl SelectDelegate for Vec { - type Item = T; - - fn items_count(&self, _: usize) -> usize { - self.len() - } - - fn item(&self, ix: IndexPath) -> Option<&Self::Item> { - self.as_slice().get(ix.row) - } - - fn position(&self, value: &V) -> Option - where - Self::Item: SelectItem, - V: PartialEq, - { - self.iter() - .position(|v| v.value() == value) - .map(|ix| IndexPath::default().row(ix)) - } -} - -struct SelectListDelegate { - delegate: D, - state: WeakEntity>, - selected_index: Option, -} - -impl ListDelegate for SelectListDelegate +/// Events emitted by [`SelectState`]. +pub enum SelectEvent where - D: SelectDelegate + 'static, + ::Value: PartialEq + Clone, { - type Item = SelectListItem; - - fn sections_count(&self, cx: &App) -> usize { - self.delegate.sections_count(cx) - } - - fn items_count(&self, section: usize, _: &App) -> usize { - self.delegate.items_count(section) - } - - fn render_section_header( - &mut self, - section: usize, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - let state = self.state.upgrade()?.read(cx); - let Some(item) = self.delegate.section(section) else { - return None; - }; - - return Some( - div() - .py_0p5() - .px_2() - .list_size(state.options.size) - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(item), - ); - } - - fn render_item( - &mut self, - ix: IndexPath, - window: &mut Window, - cx: &mut Context>, - ) -> Option { - let selected = self - .selected_index - .map_or(false, |selected_index| selected_index == ix); - let size = self - .state - .upgrade() - .map_or(Size::Medium, |state| state.read(cx).options.size); - - if let Some(item) = self.delegate.item(ix) { - let list_item = SelectListItem::new(ix.row) - .selected(selected) - .with_size(size) - .child(div().whitespace_nowrap().child(item.render(window, cx))); - Some(list_item) - } else { - None - } - } - - fn cancel(&mut self, window: &mut Window, cx: &mut Context>) { - let state = self.state.clone(); - let final_selected_index = state - .read_with(cx, |this, _| this.final_selected_index) - .ok() - .flatten(); - - // If the selected index is not the final selected index, we need to restore it. - let need_restore = if final_selected_index != self.selected_index { - self.selected_index = final_selected_index; - true - } else { - false - }; - - cx.defer_in(window, move |this, window, cx| { - if need_restore { - this.set_selected_index(final_selected_index, window, cx); - } - - _ = state.update(cx, |this, cx| { - this.set_open(false, cx); - this.focus(window, cx); - }); - }); - } - - fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { - let selected_index = self.selected_index; - let selected_value = selected_index - .and_then(|ix| self.delegate.item(ix)) - .map(|item| item.value().clone()); - let state = self.state.clone(); - - cx.defer_in(window, move |_, window, cx| { - _ = state.update(cx, |this, cx| { - cx.emit(SelectEvent::Confirm(selected_value.clone())); - this.final_selected_index = selected_index; - this.selected_value = selected_value; - this.set_open(false, cx); - this.focus(window, cx); - }); - }); - } - - fn perform_search( - &mut self, - query: &str, - window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - self.state.upgrade().map_or(Task::ready(()), |state| { - state.update(cx, |_, cx| self.delegate.perform_search(query, window, cx)) - }) - } - - fn set_selected_index( - &mut self, - ix: Option, - _: &mut Window, - _: &mut Context>, - ) { - self.selected_index = ix; - } - - fn render_empty( - &mut self, - window: &mut Window, - cx: &mut Context>, - ) -> impl IntoElement { - if let Some(empty) = self - .state - .upgrade() - .and_then(|state| state.read(cx).empty.as_ref()) - { - empty(window, cx).into_any_element() - } else { - h_flex() - .justify_center() - .py_6() - .text_color(cx.theme().muted_foreground.opacity(0.6)) - .child(Icon::new(IconName::Inbox).size(px(28.))) - .into_any_element() - } - } + Confirm(Option<::Value>), } -/// Events emitted by the [`SelectState`]. -pub enum SelectEvent { - Confirm(Option<::Value>), -} +// MARK: SelectOptions (builder only — applied to SearchableListState during render) struct SelectOptions { style: StyleRefinement, @@ -315,7 +67,6 @@ struct SelectOptions { placeholder: Option, title_prefix: Option, search_placeholder: Option, - empty: Option, menu_width: Length, menu_max_h: Length, disabled: bool, @@ -331,7 +82,6 @@ impl Default for SelectOptions { cleanable: false, placeholder: None, title_prefix: None, - empty: None, menu_width: Length::Auto, menu_max_h: rems(20.).into(), disabled: false, @@ -341,210 +91,37 @@ impl Default for SelectOptions { } } -/// State of the [`Select`]. -pub struct SelectState { - focus_handle: FocusHandle, - options: SelectOptions, +// MARK: SelectState + +/// State of the [`Select`] component. +pub struct SelectState +where + ::Value: PartialEq + Clone, +{ + pub(crate) state: SearchableListState, + + // Select-specific fields searchable: bool, - list: Entity>>, - empty: Option AnyElement>>, - /// Store the bounds of the input - bounds: Bounds, - open: bool, - selected_value: Option<::Value>, - final_selected_index: Option, - _subscriptions: Vec, + icon: Option, + title_prefix: Option, } /// A Select element. #[derive(IntoElement)] -pub struct Select { +pub struct Select +where + ::Value: PartialEq + Clone, +{ id: ElementId, state: Entity>, options: SelectOptions, -} - -/// A built-in searchable vector for select items. -#[derive(Debug, Clone)] -pub struct SearchableVec { - items: Vec, - matched_items: Vec, -} - -impl SearchableVec { - pub fn push(&mut self, item: T) { - self.items.push(item.clone()); - self.matched_items.push(item); - } -} - -impl SearchableVec { - pub fn new(items: impl Into>) -> Self { - let items = items.into(); - Self { - items: items.clone(), - matched_items: items, - } - } -} - -impl From> for SearchableVec { - fn from(items: Vec) -> Self { - Self { - items: items.clone(), - matched_items: items, - } - } -} - -impl SelectDelegate for SearchableVec { - type Item = I; - - fn items_count(&self, _: usize) -> usize { - self.matched_items.len() - } - - fn item(&self, ix: IndexPath) -> Option<&Self::Item> { - self.matched_items.get(ix.row) - } - - fn position(&self, value: &V) -> Option - where - Self::Item: SelectItem, - V: PartialEq, - { - for (ix, item) in self.matched_items.iter().enumerate() { - if item.value() == value { - return Some(IndexPath::default().row(ix)); - } - } - - None - } - - fn perform_search( - &mut self, - query: &str, - _window: &mut Window, - _: &mut Context>, - ) -> Task<()> { - self.matched_items = self - .items - .iter() - .filter(|item| item.matches(query)) - .cloned() - .collect(); - - Task::ready(()) - } -} - -impl SelectDelegate for SearchableVec> { - type Item = I; - - fn sections_count(&self, _: &App) -> usize { - self.matched_items.len() - } - - fn items_count(&self, section: usize) -> usize { - self.matched_items - .get(section) - .map_or(0, |group| group.items.len()) - } - - fn section(&self, section: usize) -> Option { - Some( - self.matched_items - .get(section)? - .title - .clone() - .into_any_element(), - ) - } - - fn item(&self, ix: IndexPath) -> Option<&Self::Item> { - let section = self.matched_items.get(ix.section)?; - - section.items.get(ix.row) - } - - fn position(&self, value: &V) -> Option - where - Self::Item: SelectItem, - V: PartialEq, - { - for (ix, group) in self.matched_items.iter().enumerate() { - for (row_ix, item) in group.items.iter().enumerate() { - if item.value() == value { - return Some(IndexPath::default().section(ix).row(row_ix)); - } - } - } - - None - } - - fn perform_search( - &mut self, - query: &str, - _window: &mut Window, - _: &mut Context>, - ) -> Task<()> { - self.matched_items = self - .items - .iter() - .filter(|item| item.matches(&query)) - .cloned() - .map(|mut item| { - item.items.retain(|item| item.matches(&query)); - item - }) - .collect(); - - Task::ready(()) - } -} - -/// A group of select items with a title. -#[derive(Debug, Clone)] -pub struct SelectGroup { - pub title: SharedString, - pub items: Vec, -} - -impl SelectGroup -where - I: SelectItem, -{ - /// Create a new SelectGroup with the given title. - pub fn new(title: impl Into) -> Self { - Self { - title: title.into(), - items: vec![], - } - } - - /// Add an item to the group. - pub fn item(mut self, item: I) -> Self { - self.items.push(item); - self - } - - /// Add multiple items to the group. - pub fn items(mut self, items: impl IntoIterator) -> Self { - self.items.extend(items); - self - } - - fn matches(&self, query: &str) -> bool { - self.title.to_lowercase().contains(&query.to_lowercase()) - || self.items.iter().any(|item| item.matches(query)) - } + empty: Option AnyElement + 'static>>, } impl SelectState where - D: SelectDelegate + 'static, + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, { /// Create a new Select state. pub fn new( @@ -553,42 +130,128 @@ where window: &mut Window, cx: &mut Context, ) -> Self { - let focus_handle = cx.focus_handle(); - let delegate = SelectListDelegate { - delegate, - state: cx.entity().downgrade(), - selected_index, - }; + let weak = cx.entity().downgrade(); + let weak_confirm = weak.clone(); + let weak_cancel = weak.clone(); + let weak_empty = weak; - let list = cx.new(|cx| ListState::new(delegate, window, cx).reset_on_cancel(false)); - let list_focus_handle = list.read(cx).focus_handle.clone(); - let list_search_focus_handle = list.read(cx).query_input.focus_handle(cx); + let selected_indices = selected_index.into_iter().collect::>(); - let _subscriptions = vec![ - cx.on_blur(&list_focus_handle, window, Self::on_blur), - cx.on_blur(&list_search_focus_handle, window, Self::on_blur), - cx.on_blur(&focus_handle, window, Self::on_blur), - ]; + let state = SearchableListState::new( + delegate, + selected_indices, + // on_confirm — commit the selection + move |selected_index, _secondary, window, cx| { + cx.defer_in(window, { + let weak_confirm = weak_confirm.clone(); + move |list_state, window, cx| { + let mut selection = weak_confirm + .upgrade() + .map(|e| e.read(cx).state.selection.clone()) + .unwrap_or_default(); + + let changes = { + let mut changes: Vec = selection + .iter() + .map(|(ix, _)| SearchableListChange::Deselect { index: *ix }) + .collect(); + + if let Some(ix) = selected_index { + changes.push(SearchableListChange::Select { index: ix }); + } + + changes + }; + + // on_will_change is called directly — entity-handle access would + // re-enter the ListState lock that defer_in holds for this callback. + list_state + .delegate_mut() + .delegate + .on_will_change(&mut selection, &changes); + + let new_selection = weak_confirm.update(cx, |this, cx| { + this.state.selection = selection; + + let final_value = this + .state + .selection + .first() + .map(|(_, i)| i.value().clone()); + + cx.emit(SelectEvent::Confirm(final_value)); + cx.notify(); + this.set_open(false, cx); + this.focus(window, cx); + + this.state.selection.clone() + }); + + // Sync snapshot and fire on_confirm directly — same re-entrancy guard. + if let Ok(new_selection) = new_selection { + list_state + .delegate_mut() + .update_selection_snapshot(new_selection.clone()); + list_state + .delegate_mut() + .delegate + .on_confirm(&new_selection); + } + } + }); + }, + // on_cancel — restore cursor to committed index, close + move |_final_selected_index, window, cx| { + cx.defer_in(window, { + let weak_cancel = weak_cancel.clone(); + move |list_state, window, cx| { + let committed_ix = weak_cancel + .upgrade() + .and_then(|e| { + e.read(cx).state.selection.first().map(|(ix, _)| *ix) + }); + + list_state.set_selected_index(committed_ix, window, cx); + + _ = weak_cancel.update(cx, |this, cx| { + this.set_open(false, cx); + this.focus(window, cx); + }); + } + }); + }, + // on_render_empty + move |window, cx| { + if let Some(empty) = weak_empty + .upgrade() + .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx))) + { + empty + } else { + h_flex() + .justify_center() + .py_6() + .text_color(cx.theme().muted_foreground.opacity(0.6)) + .child(Icon::new(IconName::Inbox).size(px(28.))) + .into_any_element() + } + }, + Self::on_blur, + window, + cx, + ); - let mut this = Self { - focus_handle, - options: SelectOptions::default(), + Self { + state, searchable: false, - list, - selected_value: None, - open: false, - bounds: Bounds::default(), - empty: None, - final_selected_index: None, - _subscriptions, - }; - this.set_selected_index(selected_index, window, cx); - this + icon: None, + title_prefix: None, + } } /// Sets whether the dropdown menu is searchable, default is `false`. /// - /// When `true`, there will be a search input at the top of the dropdown menu. + /// When `true`, a search input appears at the top of the dropdown menu. pub fn searchable(mut self, searchable: bool) -> Self { self.searchable = searchable; self @@ -601,75 +264,78 @@ where window: &mut Window, cx: &mut Context, ) { - self.list.update(cx, |list, cx| { + self.state.list.update(cx, |list, cx| { list._set_selected_index(selected_index, window, cx); }); - self.final_selected_index = selected_index; - self.update_selected_value(window, cx); + + let item = selected_index + .and_then(|ix| self.state.list.read(cx).delegate().delegate.item(ix)) + .map(|i| i.clone()); + + self.state.selection = match (selected_index, item) { + (Some(ix), Some(item)) => vec![(ix, item)], + _ => vec![], + }; + self.state.sync_snapshot(cx); } /// Set selected value for the select. /// - /// This method will to get position from delegate and set selected index. - /// - /// If the value is not found, the None will be sets. + /// Looks up the position from the delegate and sets the selected index accordingly. + /// Passes `None` when the value is not found. pub fn set_selected_value( &mut self, - selected_value: &::Value, + selected_value: &::Value, window: &mut Window, cx: &mut Context, - ) where - <::Item as SelectItem>::Value: PartialEq, - { - let delegate = self.list.read(cx).delegate(); - let selected_index = delegate.delegate.position(selected_value); + ) { + let selected_index = self + .state + .list + .read(cx) + .delegate() + .delegate + .position(selected_value); + self.set_selected_index(selected_index, window, cx); } - /// Set the items for the select state. + /// Replace the delegate (item data) for the select state. pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context) where - D: SelectDelegate + 'static, + D: SearchableListDelegate + 'static, { - self.list.update(cx, |list, _| { + self.state.list.update(cx, |list, _| { list.delegate_mut().delegate = items; }); } - /// Get the selected index of the select. + /// Get the current selected index. pub fn selected_index(&self, cx: &App) -> Option { - self.list.read(cx).selected_index() + self.state.list.read(cx).selected_index() } - /// Get the selected value of the select. - pub fn selected_value(&self) -> Option<&::Value> { - self.selected_value.as_ref() + /// Get the current selected value. + pub fn selected_value(&self) -> Option<&::Value> { + self.state.selection.first().map(|(_, i)| i.value()) } - /// Focus the select input. + /// Focus the select trigger input. pub fn focus(&self, window: &mut Window, cx: &mut App) { - self.focus_handle.focus(window, cx); - } - - fn update_selected_value(&mut self, _: &Window, cx: &App) { - self.selected_value = self - .selected_index(cx) - .and_then(|ix| self.list.read(cx).delegate().delegate.item(ix)) - .map(|item| item.value().clone()); + self.state.focus_handle.focus(window, cx); } fn on_blur(&mut self, window: &mut Window, cx: &mut Context) { - // When the select and dropdown menu are both not focused, close the dropdown menu. - if self.list.read(cx).is_focused(window, cx) || self.focus_handle.is_focused(window) { + if self.state.list.read(cx).is_focused(window, cx) + || self.state.focus_handle.is_focused(window) + { return; } - // If the selected index is not the final selected index, we need to restore it. - let final_selected_index = self.final_selected_index; - let selected_index = self.selected_index(cx); - if final_selected_index != selected_index { - self.list.update(cx, |list, cx| { - list.set_selected_index(self.final_selected_index, window, cx); + let committed_ix = self.state.selection.first().map(|(ix, _)| *ix); + if self.selected_index(cx) != committed_ix { + self.state.list.update(cx, |list, cx| { + list.set_selected_index(committed_ix, window, cx); }); } @@ -678,47 +344,48 @@ where } fn up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context) { - if !self.open { + if !self.state.open { self.set_open(true, cx); } - self.list.focus_handle(cx).focus(window, cx); + self.state.list.focus_handle(cx).focus(window, cx); cx.propagate(); } fn down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context) { - if !self.open { + if !self.state.open { self.set_open(true, cx); } - self.list.focus_handle(cx).focus(window, cx); + self.state.list.focus_handle(cx).focus(window, cx); cx.propagate(); } fn enter(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - // Propagate the event to the parent view, for example to the Dialog to support ENTER to confirm. cx.propagate(); - if !self.open { + if !self.state.open { self.set_open(true, cx); cx.notify(); } - self.list.focus_handle(cx).focus(window, cx); + self.state.list.focus_handle(cx).focus(window, cx); } fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { cx.stop_propagation(); - self.set_open(!self.open, cx); - if self.open { - self.list.focus_handle(cx).focus(window, cx); + self.set_open(!self.state.open, cx); + + if self.state.open { + self.state.list.focus_handle(cx).focus(window, cx); } + cx.notify(); } fn escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - if !self.open { + if !self.state.open { cx.propagate(); return; } @@ -730,12 +397,14 @@ where } fn set_open(&mut self, open: bool, cx: &mut Context) { - self.open = open; - if self.open { - GlobalState::global_mut(cx).register_deferred_popover(&self.focus_handle) + self.state.open = open; + + if self.state.open { + GlobalState::global_mut(cx).register_deferred_popover(&self.state.focus_handle) } else { - GlobalState::global_mut(cx).unregister_deferred_popover(&self.focus_handle) + GlobalState::global_mut(cx).unregister_deferred_popover(&self.state.focus_handle) } + cx.notify(); } @@ -745,34 +414,32 @@ where cx.emit(SelectEvent::Confirm(None)); } - /// Returns the title element for the select input. fn display_title(&mut self, _: &Window, cx: &mut Context) -> impl IntoElement { let default_title = div().text_color(cx.theme().muted_foreground).child( - self.options + self.state .placeholder .clone() .unwrap_or_else(|| t!("Select.placeholder").into()), ); - let Some(selected_index) = &self.selected_index(cx) else { + let Some(selected_index) = self.selected_index(cx) else { return default_title; }; let Some(title) = self + .state .list .read(cx) .delegate() .delegate - .item(*selected_index) + .item(selected_index) .map(|item| { if let Some(el) = item.display_title() { el + } else if let Some(prefix) = self.title_prefix.as_ref() { + format!("{}{}", prefix, item.title()).into_any_element() } else { - if let Some(prefix) = self.options.title_prefix.as_ref() { - format!("{}{}", prefix, item.title()).into_any_element() - } else { - item.title().into_any_element() - } + item.title().into_any_element() } }) else { @@ -780,7 +447,7 @@ where }; div() - .when(self.options.disabled, |this| { + .when(self.state.disabled, |this| { this.text_color(cx.theme().muted_foreground) }) .child(title) @@ -789,21 +456,24 @@ where impl Render for SelectState where - D: SelectDelegate + 'static, + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let searchable = self.searchable; - let is_focused = self.focus_handle.is_focused(window); - let show_clean = self.options.cleanable && self.selected_index(cx).is_some(); - let bounds = self.bounds; - let allow_open = !(self.open || self.options.disabled); - let outline_visible = self.open || is_focused && !self.options.disabled; + let is_focused = self.state.focus_handle.is_focused(window); + let show_clean = self.state.cleanable && self.selected_index(cx).is_some(); + let bounds = self.state.bounds; + let allow_open = !(self.state.open || self.state.disabled); + let outline_visible = self.state.open || (is_focused && !self.state.disabled); let popup_radius = cx.theme().radius.min(px(8.)); - let (bg, fg) = input_style(self.options.disabled, cx); + let (bg, fg) = input_style(self.state.disabled, cx); - self.list - .update(cx, |list, cx| list.set_searchable(searchable, cx)); + self.state.list.update(cx, |list, cx| { + list.set_searchable(searchable, cx); + list.delegate_mut().size = self.state.size; + }); div() .size_full() @@ -817,25 +487,25 @@ where .justify_between() .border_1() .border_color(cx.theme().transparent) - .when(self.options.appearance, |this| { + .when(self.state.appearance, |this| { this.bg(bg) .text_color(fg) - .when(self.options.disabled, |this| this.opacity(0.5)) + .when(self.state.disabled, |this| this.opacity(0.5)) .border_color(cx.theme().input) .rounded(cx.theme().radius) .when(cx.theme().shadow, |this| this.shadow_xs()) }) .map(|this| { - if self.options.disabled { + if self.state.disabled { this.shadow_none() } else { this } }) .overflow_hidden() - .input_size(self.options.size) - .input_text_size(self.options.size) - .refine_style(&self.options.style) + .input_size(self.state.size) + .input_text_size(self.state.size) + .refine_style(&self.state.style) .when(outline_visible, |this| this.focused_border(cx)) .when(allow_open, |this| { this.on_click(cx.listener(Self::toggle_menu)) @@ -858,7 +528,7 @@ where ) .when(show_clean, |this| { this.child(clear_button(cx).map(|this| { - if self.options.disabled { + if self.state.disabled { this.disabled(true) } else { this.on_click(cx.listener(Self::clean)) @@ -866,7 +536,7 @@ where })) }) .when(!show_clean, |this| { - let icon = match self.options.icon.clone() { + let icon = match self.icon.clone() { Some(icon) => icon, None => Icon::new(IconName::ChevronDown), }; @@ -876,16 +546,16 @@ where ) .on_prepaint({ let state = cx.entity(); - move |bounds, _, cx| state.update(cx, |r, _| r.bounds = bounds) + move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds) }), ) - .when(self.open, |this| { + .when(self.state.open, |this| { this.child( deferred( anchored().snap_to_window_with_margin(px(8.)).child( div() .occlude() - .map(|this| match self.options.menu_width { + .map(|this| match self.state.menu_width { Length::Auto => this.w(bounds.size.width + px(2.)), Length::Definite(w) => this.w(w), }) @@ -899,15 +569,15 @@ where .rounded(popup_radius) .shadow_md() .child( - List::new(&self.list) + List::new(&self.state.list) .when_some( - self.options.search_placeholder.clone(), + self.state.search_placeholder.clone(), |this, placeholder| { this.search_placeholder(placeholder) }, ) - .with_size(self.options.size) - .max_h(self.options.menu_max_h) + .with_size(self.state.size) + .max_h(self.state.menu_max_h) .paddings(Edges::all(px(4.))), ), ) @@ -924,75 +594,78 @@ where impl Select where - D: SelectDelegate + 'static, + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, { pub fn new(state: &Entity>) -> Self { Self { id: ("select", state.entity_id()).into(), state: state.clone(), options: SelectOptions::default(), + empty: None, } } - /// Set the width of the dropdown menu, default: Length::Auto + /// Set the width of the dropdown menu, default: `Length::Auto`. pub fn menu_width(mut self, width: impl Into) -> Self { self.options.menu_width = width.into(); self } - /// Set the max height of the dropdown menu, default: 20rem + /// Set the max height of the dropdown menu, default: 20rem. pub fn menu_max_h(mut self, max_h: impl Into) -> Self { self.options.menu_max_h = max_h.into(); self } - /// Set the placeholder for display when select value is empty. + /// Set the placeholder shown when no value is selected. pub fn placeholder(mut self, placeholder: impl Into) -> Self { self.options.placeholder = Some(placeholder.into()); self } - /// Set the right icon for the select input, instead of the default arrow icon. + /// Override the trailing icon, replacing the default chevron. pub fn icon(mut self, icon: impl Into) -> Self { self.options.icon = Some(icon.into()); self } - /// Set title prefix for the select. + /// Set a label prefix shown before the selected title in the trigger. /// - /// e.g.: Country: United States - /// - /// You should set the label is `Country: ` + /// e.g. `title_prefix("Country: ")` → "Country: United States" pub fn title_prefix(mut self, prefix: impl Into) -> Self { self.options.title_prefix = Some(prefix.into()); self } - /// Set whether to show the clear button when the input field is not empty, default is false. + /// Show a clear button when a value is selected. pub fn cleanable(mut self, cleanable: bool) -> Self { self.options.cleanable = cleanable; self } - /// Sets the placeholder text for the search input. + /// Set the placeholder text for the search input. pub fn search_placeholder(mut self, placeholder: impl Into) -> Self { self.options.search_placeholder = Some(placeholder.into()); self } - /// Set the disable state for the select. + /// Set the disabled state. pub fn disabled(mut self, disabled: bool) -> Self { self.options.disabled = disabled; self } - /// Set the element to display when the select list is empty. - pub fn empty(mut self, el: impl IntoElement) -> Self { - self.options.empty = Some(el.into_any_element()); + /// Set a custom closure that renders the empty-state element. + pub fn empty( + mut self, + builder: impl Fn(&mut Window, &App) -> E + 'static, + ) -> Self { + self.empty = Some(Box::new(move |window, cx| builder(window, cx).into_any_element())); self } - /// Set the appearance of the select, if false the select input will no border, background. + /// Control whether the trigger shows a border and background (`true` by default). pub fn appearance(mut self, appearance: bool) -> Self { self.options.appearance = appearance; self @@ -1001,7 +674,8 @@ where impl Sizable for Select where - D: SelectDelegate + 'static, + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, { fn with_size(mut self, size: impl Into) -> Self { self.options.size = size.into(); @@ -1009,24 +683,38 @@ where } } -impl EventEmitter> for SelectState where D: SelectDelegate + 'static {} -impl EventEmitter for SelectState where D: SelectDelegate + 'static {} +impl EventEmitter> for SelectState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ +} + +impl EventEmitter for SelectState +where + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, +{ +} + impl Focusable for SelectState where - D: SelectDelegate, + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, { fn focus_handle(&self, cx: &App) -> FocusHandle { - if self.open { - self.list.focus_handle(cx) + if self.state.open { + self.state.list.focus_handle(cx) } else { - self.focus_handle.clone() + self.state.focus_handle.clone() } } } impl Styled for Select where - D: SelectDelegate, + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, { fn style(&mut self) -> &mut StyleRefinement { &mut self.options.style @@ -1035,14 +723,31 @@ where impl RenderOnce for Select where - D: SelectDelegate + 'static, + D: SearchableListDelegate + 'static, + ::Value: PartialEq + Clone, { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let disabled = self.options.disabled; let focus_handle = self.state.focus_handle(cx); - // If the size has change, set size to self.list, to change the QueryInput size. + let empty = self.empty; + let opts = self.options; + self.state.update(cx, |this, _| { - this.options = self.options; + this.state.style = opts.style; + this.state.size = opts.size; + this.state.cleanable = opts.cleanable; + this.state.placeholder = opts.placeholder; + this.state.search_placeholder = opts.search_placeholder; + this.state.menu_width = opts.menu_width; + this.state.menu_max_h = opts.menu_max_h; + this.state.disabled = opts.disabled; + this.state.appearance = opts.appearance; + this.icon = opts.icon; + this.title_prefix = opts.title_prefix; + + if let Some(empty) = empty { + this.state.empty = Some(empty); + } }); div() @@ -1059,100 +764,3 @@ where .child(self.state) } } - -#[derive(IntoElement)] -struct SelectListItem { - id: ElementId, - size: Size, - style: StyleRefinement, - selected: bool, - disabled: bool, - children: Vec, -} - -impl SelectListItem { - pub fn new(ix: usize) -> Self { - Self { - id: ("select-item", ix).into(), - size: Size::default(), - style: StyleRefinement::default(), - selected: false, - disabled: false, - children: Vec::new(), - } - } -} - -impl ParentElement for SelectListItem { - fn extend(&mut self, elements: impl IntoIterator) { - self.children.extend(elements); - } -} - -impl Disableable for SelectListItem { - fn disabled(mut self, disabled: bool) -> Self { - self.disabled = disabled; - self - } -} - -impl Selectable for SelectListItem { - fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - - fn is_selected(&self) -> bool { - self.selected - } -} - -impl Sizable for SelectListItem { - fn with_size(mut self, size: impl Into) -> Self { - self.size = size.into(); - self - } -} - -impl Styled for SelectListItem { - fn style(&mut self) -> &mut StyleRefinement { - &mut self.style - } -} - -impl RenderOnce for SelectListItem { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - h_flex() - .id(self.id) - .relative() - .gap_x_1() - .py_1() - .px_2() - .rounded(cx.theme().radius) - .text_base() - .text_color(cx.theme().foreground) - .relative() - .items_center() - .justify_between() - .input_text_size(self.size) - .list_size(self.size) - .refine_style(&self.style) - .when(!self.disabled, |this| { - this.when(!self.selected, |this| { - this.hover(|this| this.bg(cx.theme().accent.alpha(0.7))) - }) - }) - .when(self.selected, |this| this.bg(cx.theme().accent)) - .when(self.disabled, |this| { - this.text_color(cx.theme().muted_foreground) - }) - .child( - h_flex() - .w_full() - .items_center() - .justify_between() - .gap_x_1() - .child(div().w_full().children(self.children)), - ) - } -} diff --git a/docs/docs/components/combobox.md b/docs/docs/components/combobox.md new file mode 100644 index 0000000000..1c495735e7 --- /dev/null +++ b/docs/docs/components/combobox.md @@ -0,0 +1,277 @@ +--- +title: Combobox +description: An autocomplete input paired with a searchable dropdown list. +--- + +# Combobox + +A searchable dropdown for selecting one or multiple values from a list. + +## Select vs Combobox + +| Feature | Select | Combobox | +| --- | --- | --- | +| Searchable | ✓ (optional) | ✓ (optional) | +| Multi-select | — | ✓ (`.multiple(true)`) | +| Custom trigger rendering | — | ✓ | +| Custom item rendering | — | ✓ | +| Footer action slot | — | ✓ | + +Use `Select` for simple single-value picking. Use `Combobox` when you need multi-select, a fully custom trigger, or custom item rendering. + +## Import + +```rust +use gpui_component::combobox::{ + Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerContext, + SearchableListItem, SearchableVec, SearchableGroup, +}; +``` + +## Usage + +### Basic Single-Select + +```rust +let state = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]), + vec![], // no initial selection + window, + cx, + ) + .searchable(true) +}); + +Combobox::new(&state) + .placeholder("Select framework...") + .search_placeholder("Search...") + .w_full() +``` + +### Multi-Select + +Pass `.multiple(true)` to enable multi-select mode. Clicking an item toggles it; the dropdown stays open until the user presses Escape or clicks outside. + +```rust +let state = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(vec!["React", "Vue", "Angular"]), + vec![IndexPath::new(0)], // pre-selected + window, + cx, + ) + .multiple(true) + .searchable(true) +}); + +Combobox::new(&state).placeholder("Select frameworks") +``` + +### Pre-selected Item + +Pass index paths of items to pre-select: + +```rust +let state = cx.new(|cx| { + ComboboxState::new(items, vec![IndexPath::new(0)], window, cx) +}); +``` + +### Grouped Items + +Use `SearchableGroup` to group items under a heading: + +```rust +let grouped = SearchableVec::new(vec![ + SearchableGroup::new("Fruits").items(vec![ + FoodItem::new("Apples"), + FoodItem::new("Bananas"), + ]), + SearchableGroup::new("Vegetables").items(vec![ + FoodItem::new("Carrots"), + FoodItem::new("Spinach"), + ]), +]); + +let state = cx.new(|cx| { + ComboboxState::new(grouped, vec![], window, cx).searchable(true) +}); + +Combobox::new(&state) +``` + +### Implementing `SearchableListItem` + +Built-in implementations exist for `String`, `SharedString`, and `&'static str`. For custom types implement the trait: + +```rust +#[derive(Clone)] +struct Country { + name: SharedString, + code: SharedString, +} + +impl SearchableListItem for Country { + type Value = SharedString; + + fn title(&self) -> SharedString { + self.name.clone() + } + + fn value(&self) -> &SharedString { + &self.code + } + + fn matches(&self, query: &str) -> bool { + self.name.to_lowercase().contains(query) + || self.code.to_lowercase().contains(query) + } +} +``` + +### Disabled Items + +Return `true` from `disabled()` on items that should not be selectable: + +```rust +impl SearchableListItem for MyItem { + // ... + fn disabled(&self) -> bool { + self.is_unavailable + } +} +``` + +### Custom Check Icon + +```rust +Combobox::new(&state) + .check_icon(Icon::new(IconName::CircleCheck)) +``` + +### Footer Action + +Render a persistent action at the bottom of the dropdown (e.g. an "Add new" button): + +```rust +Combobox::new(&state) + .footer(|_, cx| { + Button::new("add-new") + .ghost() + .label("New item") + .icon(Icon::new(IconName::Plus)) + .w_full() + .justify_start() + .into_any_element() + }) +``` + +### Custom Trigger + +Override the entire trigger element. `ComboboxTriggerContext` exposes the current selection, open/disabled flags, and size: + +```rust +Combobox::new(&state) + .render_trigger(|ctx, _, cx| { + h_flex() + .w_full() + .items_center() + .gap_2() + .when(ctx.selection.is_empty(), |this| { + this.text_color(cx.theme().muted_foreground) + .child("Select...") + }) + .children(ctx.selection.iter().map(|(_, item)| { + div() + .bg(cx.theme().accent) + .rounded_sm() + .px_1p5() + .py_0p5() + .text_sm() + .child(item.title()) + })) + .into_any_element() + }) +``` + +### Sizes + +```rust +Combobox::new(&state).large() +Combobox::new(&state) // medium (default) +Combobox::new(&state).small() +``` + +### Cleanable + +```rust +Combobox::new(&state).cleanable(true) // show clear button when a value is selected +``` + +### Disabled + +```rust +Combobox::new(&state).disabled(true) +``` + +### Events + +Both `Change` (fired on every toggle) and `Confirm` (fired when the dropdown closes) carry the full selection as `Vec`. + +```rust +cx.subscribe_in(&state, window, |view, _, event, window, cx| { + match event { + ComboboxEvent::Change(values) => { + // fired on every toggle + } + ComboboxEvent::Confirm(values) => { + // fired when the dropdown closes + } + } +}); +``` + +### Mutating Programmatically + +```rust +// Replace the entire selection +state.update(cx, |s, cx| { + s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], window, cx); +}); + +// Add / remove individual items +state.update(cx, |s, cx| { + s.add_selected_index(IndexPath::new(1), cx); + s.remove_selected_index(IndexPath::new(0), cx); +}); + +// Clear all selections +state.update(cx, |s, cx| { + s.clear_selection(cx); +}); + +// Read all selected values (multi-select) +let values = state.read(cx).selected_values(); // Vec + +// Read the first selected value (single-select convenience) +let value = state.read(cx).selected_value(); // Option +``` + +## Keyboard Shortcuts + +| Key | Action | +| --------- | ---------------------------------------- | +| `Tab` | Focus trigger | +| `Enter` | Open menu or confirm highlighted item | +| `Up/Down` | Navigate options (opens menu if closed) | +| `Escape` | Close menu | + +## Theming + +- `background` — Dropdown input background +- `input` — Trigger border color +- `foreground` — Text color +- `muted_foreground` — Placeholder and disabled text +- `border` — Menu border +- `radius` — Border radius diff --git a/docs/docs/components/index.md b/docs/docs/components/index.md index 054ba83f17..c051003d06 100644 --- a/docs/docs/components/index.md +++ b/docs/docs/components/index.md @@ -37,6 +37,7 @@ collapsed: false - [Input](input) - An input field or a component that looks like an input field. - [Select](select) - A list of options for the user to pick. +- [Combobox](combobox) - Searchable single-select or multi-select dropdown. - [NumberInput](number-input) - Numeric input with increment/decrement - [DatePicker](date-picker) - Date selection with calendar - [OtpInput](otp-input) - One-time password input diff --git a/docs/docs/components/select.md b/docs/docs/components/select.md index f5a54637b5..3d8a12a53e 100644 --- a/docs/docs/components/select.md +++ b/docs/docs/components/select.md @@ -15,6 +15,10 @@ A select component that allows users to choose from a list of options. Supports search functionality, grouped items, custom rendering, and various states. Built with keyboard navigation and accessibility in mind. +:::tip +For richer selection UIs with custom trigger rendering or multi-select, see [Combobox](combobox). +::: + ## Import ```rust diff --git a/docs/zh-CN/docs/components/combobox.md b/docs/zh-CN/docs/components/combobox.md new file mode 100644 index 0000000000..8c60a3e80f --- /dev/null +++ b/docs/zh-CN/docs/components/combobox.md @@ -0,0 +1,277 @@ +--- +title: Combobox +description: 带有可搜索下拉列表的自动补全输入组件。 +--- + +# Combobox + +支持从可搜索列表中选择一个或多个值的下拉选择组件。 + +## Select 与 Combobox 的区别 + +| 功能 | Select | Combobox | +| --- | --- | --- | +| 可搜索 | ✓(可选) | ✓(可选) | +| 多选 | — | ✓(`.multiple(true)`) | +| 自定义触发器渲染 | — | ✓ | +| 自定义列表项渲染 | — | ✓ | +| 底部操作插槽 | — | ✓ | + +需要简单单选时用 `Select`;需要多选、完全自定义触发器或自定义列表项渲染时用 `Combobox`。 + +## 导入 + +```rust +use gpui_component::combobox::{ + Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerContext, + SearchableListItem, SearchableVec, SearchableGroup, +}; +``` + +## 用法 + +### 基础单选 + +```rust +let state = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]), + vec![], // 无初始选中 + window, + cx, + ) + .searchable(true) +}); + +Combobox::new(&state) + .placeholder("选择框架...") + .search_placeholder("搜索...") + .w_full() +``` + +### 多选 + +通过 `.multiple(true)` 开启多选模式。点击列表项会切换其选中状态,下拉菜单保持展开直到按下 Escape 或点击外部。 + +```rust +let state = cx.new(|cx| { + ComboboxState::new( + SearchableVec::new(vec!["React", "Vue", "Angular"]), + vec![IndexPath::new(0)], // 预选项 + window, + cx, + ) + .multiple(true) + .searchable(true) +}); + +Combobox::new(&state).placeholder("选择框架") +``` + +### 预选项 + +通过索引路径指定预选的列表项: + +```rust +let state = cx.new(|cx| { + ComboboxState::new(items, vec![IndexPath::new(0)], window, cx) +}); +``` + +### 分组列表项 + +使用 `SearchableGroup` 对列表项进行分组: + +```rust +let grouped = SearchableVec::new(vec![ + SearchableGroup::new("水果").items(vec![ + FoodItem::new("苹果"), + FoodItem::new("香蕉"), + ]), + SearchableGroup::new("蔬菜").items(vec![ + FoodItem::new("胡萝卜"), + FoodItem::new("菠菜"), + ]), +]); + +let state = cx.new(|cx| { + ComboboxState::new(grouped, vec![], window, cx).searchable(true) +}); + +Combobox::new(&state) +``` + +### 实现 `SearchableListItem` + +`String`、`SharedString` 和 `&'static str` 已内置实现了 `SearchableListItem`。自定义类型需手动实现该 trait: + +```rust +#[derive(Clone)] +struct Country { + name: SharedString, + code: SharedString, +} + +impl SearchableListItem for Country { + type Value = SharedString; + + fn title(&self) -> SharedString { + self.name.clone() + } + + fn value(&self) -> &SharedString { + &self.code + } + + fn matches(&self, query: &str) -> bool { + self.name.to_lowercase().contains(query) + || self.code.to_lowercase().contains(query) + } +} +``` + +### 禁用列表项 + +在列表项的 `disabled()` 方法中返回 `true` 即可将该项设为不可选: + +```rust +impl SearchableListItem for MyItem { + // ... + fn disabled(&self) -> bool { + self.is_unavailable + } +} +``` + +### 自定义勾选图标 + +```rust +Combobox::new(&state) + .check_icon(Icon::new(IconName::CircleCheck)) +``` + +### 底部操作按钮 + +在下拉菜单底部渲染一个固定操作项(如"新建"按钮): + +```rust +Combobox::new(&state) + .footer(|_, cx| { + Button::new("add-new") + .ghost() + .label("新建项目") + .icon(Icon::new(IconName::Plus)) + .w_full() + .justify_start() + .into_any_element() + }) +``` + +### 自定义触发器 + +完全覆盖触发器元素的渲染。`ComboboxTriggerContext` 包含当前选中状态、开关标志和尺寸信息: + +```rust +Combobox::new(&state) + .render_trigger(|ctx, _, cx| { + h_flex() + .w_full() + .items_center() + .gap_2() + .when(ctx.selection.is_empty(), |this| { + this.text_color(cx.theme().muted_foreground) + .child("请选择...") + }) + .children(ctx.selection.iter().map(|(_, item)| { + div() + .bg(cx.theme().accent) + .rounded_sm() + .px_1p5() + .py_0p5() + .text_sm() + .child(item.title()) + })) + .into_any_element() + }) +``` + +### 尺寸 + +```rust +Combobox::new(&state).large() +Combobox::new(&state) // 默认(medium) +Combobox::new(&state).small() +``` + +### 可清除 + +```rust +Combobox::new(&state).cleanable(true) // 有选中值时显示清除按钮 +``` + +### 禁用状态 + +```rust +Combobox::new(&state).disabled(true) +``` + +### 事件监听 + +`Change`(每次切换时触发)和 `Confirm`(下拉菜单关闭时触发)均携带完整的选中值列表 `Vec`。 + +```rust +cx.subscribe_in(&state, window, |view, _, event, window, cx| { + match event { + ComboboxEvent::Change(values) => { + // 每次切换时触发 + } + ComboboxEvent::Confirm(values) => { + // 下拉菜单关闭时触发 + } + } +}); +``` + +### 程序化操控 + +```rust +// 替换整个选中集合 +state.update(cx, |s, cx| { + s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], window, cx); +}); + +// 增加 / 移除单个选项 +state.update(cx, |s, cx| { + s.add_selected_index(IndexPath::new(1), cx); + s.remove_selected_index(IndexPath::new(0), cx); +}); + +// 清空选中 +state.update(cx, |s, cx| { + s.clear_selection(cx); +}); + +// 读取所有选中值(多选) +let values = state.read(cx).selected_values(); // Vec + +// 读取第一个选中值(单选便利方法) +let value = state.read(cx).selected_value(); // Option +``` + +## 键盘快捷键 + +| 按键 | 操作 | +| ---------- | -------------------------------- | +| `Tab` | 聚焦触发器 | +| `Enter` | 打开菜单或确认当前高亮项 | +| `↑ / ↓` | 在选项间导航(未打开时自动打开) | +| `Escape` | 关闭菜单 | + +## 主题样式 + +- `background` — 触发器背景 +- `input` — 触发器边框颜色 +- `foreground` — 文字颜色 +- `muted_foreground` — 占位符和禁用文字颜色 +- `border` — 菜单边框颜色 +- `radius` — 圆角 diff --git a/docs/zh-CN/docs/components/index.md b/docs/zh-CN/docs/components/index.md index ac892e662f..64776bbea9 100644 --- a/docs/zh-CN/docs/components/index.md +++ b/docs/zh-CN/docs/components/index.md @@ -22,6 +22,7 @@ collapsed: false - [Input](input) - 文本输入与类输入控件 - [Select](select) - 选项选择器 +- [Combobox](combobox) - 可搜索的单选或多选下拉组件 - [NumberInput](number-input) - 数字输入 - [DatePicker](date-picker) - 日期选择器 - [OtpInput](otp-input) - 一次性验证码输入 diff --git a/docs/zh-CN/docs/components/select.md b/docs/zh-CN/docs/components/select.md index 1ee56b0fe1..517e162627 100644 --- a/docs/zh-CN/docs/components/select.md +++ b/docs/zh-CN/docs/components/select.md @@ -15,6 +15,10 @@ Select 允许用户从一组选项中选择一个值。 它支持搜索、分组、自定义渲染和多种状态,并内建键盘导航和可访问性支持。 +:::tip +如需自定义触发器渲染或多选功能,请参阅 [Combobox](combobox)。 +::: + ## 导入 ```rust