From ca7ea3bac198ab160e0ea92149c26043bf3cdc99 Mon Sep 17 00:00:00 2001 From: Vincent Esche Date: Tue, 28 Apr 2026 22:55:16 +0200 Subject: [PATCH 01/10] select: Extract `searchable_list` module shared by `Select`, `ComboBox`, and `MultiComboBox` --- crates/story/src/stories/select_story.rs | 7 +- crates/ui/src/lib.rs | 1 + crates/ui/src/searchable_list.rs | 13 + crates/ui/src/searchable_list/adapter.rs | 170 ++++ crates/ui/src/searchable_list/change.rs | 13 + crates/ui/src/searchable_list/delegate.rs | 181 ++++ crates/ui/src/searchable_list/item.rs | 138 +++ crates/ui/src/searchable_list/state.rs | 217 +++++ crates/ui/src/searchable_list/vec.rs | 239 +++++ crates/ui/src/select.rs | 1023 +++++++-------------- 10 files changed, 1290 insertions(+), 712 deletions(-) create mode 100644 crates/ui/src/searchable_list.rs create mode 100644 crates/ui/src/searchable_list/adapter.rs create mode 100644 crates/ui/src/searchable_list/change.rs create mode 100644 crates/ui/src/searchable_list/delegate.rs create mode 100644 crates/ui/src/searchable_list/item.rs create mode 100644 crates/ui/src/searchable_list/state.rs create mode 100644 crates/ui/src/searchable_list/vec.rs diff --git a/crates/story/src/stories/select_story.rs b/crates/story/src/stories/select_story.rs index 87255c2996..6331c25d54 100644 --- a/crates/story/src/stories/select_story.rs +++ b/crates/story/src/stories/select_story.rs @@ -257,13 +257,14 @@ 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") + .into_any_element() + }), ), ) .child( diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 6f8566af6e..816bdfe912 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -56,6 +56,7 @@ pub mod radio; pub mod rating; pub mod resizable; pub mod scroll; +pub mod searchable_list; pub mod select; pub mod separator; pub mod setting; diff --git a/crates/ui/src/searchable_list.rs b/crates/ui/src/searchable_list.rs new file mode 100644 index 0000000000..53a56ff752 --- /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::SearchableListItemEl; +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..77c8486577 --- /dev/null +++ b/crates/ui/src/searchable_list/adapter.rs @@ -0,0 +1,170 @@ +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::SearchableListItemEl}; + +/// 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 = SearchableListItemEl; + + 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; + 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( + SearchableListItemEl::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..ab0839674c --- /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 `SearchableListItemEl` + /// 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..93e1c8e9af --- /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 SearchableListItemEl { + 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 SearchableListItemEl { + 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 SearchableListItemEl { + fn extend(&mut self, elements: impl IntoIterator) { + self.children.extend(elements); + } +} + +impl Disableable for SearchableListItemEl { + fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl Selectable for SearchableListItemEl { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + +impl Sizable for SearchableListItemEl { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl Styled for SearchableListItemEl { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for SearchableListItemEl { + 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..85389de837 --- /dev/null +++ b/crates/ui/src/searchable_list/state.rs @@ -0,0 +1,217 @@ +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() + } + + pub fn is_open(&self) -> bool { + self.open + } + + pub fn focus_handle(&self) -> &FocusHandle { + &self.focus_handle + } + + // 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..86e184f628 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 [`SearchableListItemEl`]. +pub use crate::searchable_list::SearchableListItemEl 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,75 @@ 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. - /// - /// e.g.: Country: United States + /// Set a label prefix shown before the selected title in the trigger. /// - /// 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) -> AnyElement + 'static) -> Self { + self.empty = Some(Box::new(builder)); 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 +671,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 +680,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 +720,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 +761,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)), - ) - } -} From 1d2828b3bec601e4f6b01b0caf808570dc516b4b Mon Sep 17 00:00:00 2001 From: Vincent Esche Date: Tue, 28 Apr 2026 23:20:09 +0200 Subject: [PATCH 02/10] combo_box: Add `ComboBox` and `MultiComboBox` components with `SearchableListAdapter` `item_renderer` hook --- crates/ui/locales/ui.yml | 16 + crates/ui/src/combo_box.rs | 1255 ++++++++++++++++++++++ crates/ui/src/lib.rs | 2 + crates/ui/src/searchable_list/adapter.rs | 14 +- 4 files changed, 1284 insertions(+), 3 deletions(-) create mode 100644 crates/ui/src/combo_box.rs 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/combo_box.rs b/crates/ui/src/combo_box.rs new file mode 100644 index 0000000000..3769f1491f --- /dev/null +++ b/crates/ui/src/combo_box.rs @@ -0,0 +1,1255 @@ +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, SearchableListChange, SearchableListDelegate, SearchableListItem, + SearchableListState, + }, + v_flex, +}; + +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: ComboBoxTriggerCtx + +/// Context passed to the `render_trigger` closure on [`ComboBox`]. +pub struct ComboBoxTriggerCtx<'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: ComboBoxMode + +/// Selection semantics for a [`ComboBox`]. +#[derive(Default, Clone, Copy, PartialEq, Eq)] +pub enum ComboBoxMode { + /// Clicking an item replaces the entire selection and closes the popover. + #[default] + Single, + /// Clicking an item toggles it in the selection; the popover stays open. + Multi, +} + +// MARK: ComboBoxOptions + +struct ComboBoxOptions { + style: StyleRefinement, + size: Size, + close_on_select: bool, + 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(), + close_on_select: true, + 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 + mode: ComboBoxMode, + searchable: bool, + close_on_select: 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, + // on_confirm — delegate to handle_item_select for mode-specific semantics + 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 (mode, close_on_select, mut selection) = { + let s = weak.read(cx); + (s.mode, s.close_on_select, s.state.selection.clone()) + }; + + let is_selected = selection.iter().any(|(cur_ix, _)| cur_ix == &ix); + let changes: Vec = match mode { + ComboBoxMode::Single => { + let mut changes: Vec = selection + .iter() + .map(|(cur_ix, _)| SearchableListChange::Deselect { + index: *cur_ix, + }) + .collect(); + changes.push(SearchableListChange::Select { index: ix }); + changes + } + ComboBoxMode::Multi => { + if is_selected { + vec![SearchableListChange::Deselect { index: ix }] + } else { + vec![SearchableListChange::Select { index: ix }] + } + } + }; + + 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 && close_on_select; + + 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, + mode: ComboBoxMode::default(), + searchable: false, + close_on_select: true, + trigger_icon: None, + check_icon: None, + render_trigger: None, + footer: None, + } + } + + /// Set the selection mode. + /// + /// - [`ComboBoxMode::Single`] — clicking an item replaces the selection and closes the popover. + /// - [`ComboBoxMode::Multi`] — clicking an item toggles it; the popover stays open. + pub fn mode(mut self, mode: ComboBoxMode) -> Self { + self.mode = mode; + 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 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, + 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, applying mode-specific selection semantics. + /// + /// - `Single`: replaces the entire selection with the clicked item, then closes. + /// - `Multi`: toggles the clicked item; respects `close_on_select`. + /// + /// Calls `delegate.on_will_change` before committing and `delegate.on_confirm` when closing. + pub fn handle_item_select( + &mut self, + ix: IndexPath, + window: &mut Window, + cx: &mut Context, + ) { + let close_on_select = self.close_on_select; + let is_selected = self.state.selection.iter().any(|(cur_ix, _)| cur_ix == &ix); + + let changes: Vec = match self.mode { + ComboBoxMode::Single => { + let mut changes: Vec = self + .state + .selection + .iter() + .map(|(cur_ix, _)| SearchableListChange::Deselect { index: *cur_ix }) + .collect(); + changes.push(SearchableListChange::Select { index: ix }); + changes + } + ComboBoxMode::Multi => { + if is_selected { + vec![SearchableListChange::Deselect { index: ix }] + } else { + vec![SearchableListChange::Select { index: ix }] + } + } + }; + + 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 && close_on_select; + + 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(); + } + + match self.mode { + ComboBoxMode::Single => { + 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() + } + ComboBoxMode::Multi => { + 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() + } + } + } +} + +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 = ComboBoxTriggerCtx { + 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 trigger_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 + } + + /// Control whether the popover closes after an item is selected (default: `true`). + /// + /// For [`ComboBoxMode::Multi`] you typically want `false` to allow batch selection. + pub fn close_on_select(mut self, close: bool) -> Self { + self.options.close_on_select = close; + self + } + + /// Set a custom closure that renders the empty-state element. + pub fn empty(mut self, builder: impl Fn(&mut Window, &App) -> AnyElement + 'static) -> Self { + self.empty = Some(Box::new(builder)); + 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(&ComboBoxTriggerCtx, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + self.render_trigger = Some(Box::new(f)); + 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) -> AnyElement + 'static) -> Self { + self.footer = Some(Box::new(f)); + 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.close_on_select = opts.close_on_select; + 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, + combo_box::{ComboBox, ComboBoxMode, 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) + .mode(ComboBoxMode::Multi) + .searchable(true) + }); + + let _cb = ComboBox::new(&state) + .placeholder("Select frameworks") + .search_placeholder("Search...") + .menu_width(gpui::px(300.)) + .cleanable(true) + .close_on_select(false) + .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).mode(ComboBoxMode::Multi)); + + 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, + ) + .mode(ComboBoxMode::Multi) + }); + + 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"]); + + // Adding a second index manually (bypasses mode logic) is still possible via + // the public API; mode only governs clicks routed through handle_item_select. + 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 816bdfe912..a5c10ec834 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 combo_box; pub mod description_list; pub mod dialog; pub mod dock; @@ -112,6 +113,7 @@ pub fn init(cx: &mut App) { date_picker::init(cx); dock::init(cx); sheet::init(cx); + combo_box::init(cx); select::init(cx); input::init(cx); list::init(cx); diff --git a/crates/ui/src/searchable_list/adapter.rs b/crates/ui/src/searchable_list/adapter.rs index 77c8486577..d4077d8742 100644 --- a/crates/ui/src/searchable_list/adapter.rs +++ b/crates/ui/src/searchable_list/adapter.rs @@ -1,6 +1,4 @@ -use gpui::{ - AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, Window, div, -}; +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 _, @@ -114,6 +112,16 @@ impl ListDelegate for SearchableListAdapter .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( + SearchableListItemEl::new(ix.row) + .disabled(disabled) + .with_size(size) + .child(el), + ); + } + let check_icon = self .check_icon .clone() From aaa55b280c5ab9226a30ea0b9a39065493c13e9d Mon Sep 17 00:00:00 2001 From: Vincent Esche Date: Tue, 28 Apr 2026 23:33:14 +0200 Subject: [PATCH 03/10] combo_box: Add `ComboBoxStory` to gallery with demo sections --- crates/story/src/gallery.rs | 1 + crates/story/src/stories/combo_box_story.rs | 906 ++++++++++++++++++++ crates/story/src/stories/mod.rs | 3 + 3 files changed, 910 insertions(+) create mode 100644 crates/story/src/stories/combo_box_story.rs diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index dd9ba123a6..e464ab6d04 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/combo_box_story.rs b/crates/story/src/stories/combo_box_story.rs new file mode 100644 index 0000000000..3fd737c280 --- /dev/null +++ b/crates/story/src/stories/combo_box_story.rs @@ -0,0 +1,906 @@ +use gpui::{prelude::FluentBuilder as _, *}; +use gpui_component::{ + ActiveTheme, Icon, IconName, IndexPath, Sizable as _, + button::Button, + button::ButtonVariants as _, + combo_box::*, + h_flex, + searchable_list::{ + SearchableGroup, SearchableListChange, SearchableListDelegate, SearchableListItem, + SearchableVec, + }, + 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 { + "Autocomplete input with a list of suggestions." + } + + 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) + .mode(ComboBoxMode::Single) + .searchable(true) + }); + + let basic_multi = cx.new(|cx| { + ComboBoxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) + .mode(ComboBoxMode::Multi) + .searchable(true) + }); + + let grouped = cx.new(|cx| { + ComboBoxState::new(food_groups(), vec![IndexPath::default()], window, cx) + .mode(ComboBoxMode::Single) + .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) + .mode(ComboBoxMode::Single) + .searchable(true) + }); + + let with_icon = cx.new(|cx| { + ComboBoxState::new(industries(), vec![], window, cx) + .mode(ComboBoxMode::Single) + .searchable(true) + }); + + let custom_check = cx.new(|cx| { + ComboBoxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) + .mode(ComboBoxMode::Single) + .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) + .mode(ComboBoxMode::Single) + .searchable(true) + }); + + let custom_trigger = cx.new(|cx| { + ComboBoxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) + .mode(ComboBoxMode::Single) + .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, + ) + .mode(ComboBoxMode::Multi) + .searchable(true) + }); + + let custom_max2 = cx.new(|cx| { + ComboBoxState::new( + Max2Delegate::new(SearchableVec::new(MULTI_FRAMEWORKS.to_vec())), + vec![], + window, + cx, + ) + .mode(ComboBoxMode::Multi) + .searchable(true) + }); + + let pinned = cx.new(|cx| { + ComboBoxState::new( + PinnedDelegate(SearchableVec::new(FRAMEWORKS.to_vec())), + vec![], + window, + cx, + ) + .mode(ComboBoxMode::Single) + .searchable(true) + }); + + let featured = cx.new(|cx| { + ComboBoxState::new( + FeaturedDelegate(SearchableVec::new(FRAMEWORKS.to_vec())), + vec![], + window, + cx, + ) + .mode(ComboBoxMode::Single) + .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, + ) + .mode(ComboBoxMode::Multi) + .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, + ) + .mode(ComboBoxMode::Multi) + .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...") + .close_on_select(false) + .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...") + .close_on_select(false) + .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...") + .close_on_select(false) + .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...") + .close_on_select(false) + .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...") + .close_on_select(false) + .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..7a29cc9089 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 combo_box_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 combo_box_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); + combo_box_story::init(cx); rating_story::init(cx); number_input_story::init(cx); textarea_story::init(cx); From 80ba2f541185c09e311267298725a7ca60e1bc17 Mon Sep 17 00:00:00 2001 From: Vincent Esche Date: Tue, 28 Apr 2026 23:29:57 +0200 Subject: [PATCH 04/10] combo_box: Add "combo-box.md" docs (EN + zh-CN) and cross-link from "select.md" --- docs/docs/components/combo-box.md | 339 ++++++++++++++++++++++++ docs/docs/components/index.md | 1 + docs/docs/components/select.md | 4 + docs/zh-CN/docs/components/combo-box.md | 339 ++++++++++++++++++++++++ docs/zh-CN/docs/components/index.md | 1 + docs/zh-CN/docs/components/select.md | 4 + 6 files changed, 688 insertions(+) create mode 100644 docs/docs/components/combo-box.md create mode 100644 docs/zh-CN/docs/components/combo-box.md diff --git a/docs/docs/components/combo-box.md b/docs/docs/components/combo-box.md new file mode 100644 index 0000000000..eecbcde2e0 --- /dev/null +++ b/docs/docs/components/combo-box.md @@ -0,0 +1,339 @@ +--- +title: ComboBox +description: An autocomplete input paired with a searchable dropdown list. +--- + +# ComboBox + +A combobox component that allows users to select one (or many) values from a searchable list. + +Compared to [Select](select), `ComboBox` adds support for custom trigger rendering and custom item rendering, making it easy to build rich selection UIs without forking the underlying list behaviour. + +`MultiComboBox` is the multi-select variant — it toggles items in the selection and keeps the dropdown open until the user dismisses it. + +## Import + +```rust +use gpui_component::combo_box::{ + ComboBox, ComboBoxState, ComboBoxEvent, + MultiComboBox, MultiComboBoxState, MultiComboBoxEvent, + TriggerCtx, MultiTriggerCtx, +}; +use gpui_component::searchable_list::{ + SearchableListItem, SearchableVec, SearchableGroup, +}; +``` + +## Usage + +### Basic Single-Select + +```rust +let state = cx.new(|cx| { + ComboBoxState::new( + SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]), + None, // no initial selection + window, + cx, + ) + .searchable(true) +}); + +ComboBox::new(&state) + .placeholder("Select framework...") + .search_placeholder("Search...") + .w_full() +``` + +### Pre-selected Item + +Pass the index path of the item to pre-select: + +```rust +let state = cx.new(|cx| { + ComboBoxState::new(items, Some(IndexPath::default()), 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, None, window, cx).searchable(true) +}); + +ComboBox::new(&state) +``` + +### Implementing `SearchableListItem` + +Built-in implementations of `SearchableListItem` 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. You control the label, icons, and layout. `TriggerCtx` exposes selection state, open/disabled flags, and the current size: + +```rust +ComboBox::new(&state) + .render_trigger(|ctx, _, cx| { + h_flex() + .w_full() + .items_center() + .gap_2() + .when_some(ctx.selected_item, |this, item| { + this.child( + div() + .bg(cx.theme().accent) + .rounded_sm() + .px_1p5() + .py_0p5() + .text_sm() + .child(item.title()), + ) + }) + .when(ctx.selected_item.is_none(), |this| { + this.text_color(cx.theme().muted_foreground) + .child("Select...") + }) + .into_any_element() + }) +``` + +### Custom Item Renderer + +Override how each item row is drawn. When set, the automatic trailing check icon is suppressed — your closure controls the full row: + +```rust +ComboBox::new(&state) + .render_item(|item: &MyItem, is_selected, _, cx| { + h_flex() + .w_full() + .gap_2() + .items_center() + .child(Icon::new(item.icon.clone()).small()) + .child(div().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 value is selected +``` + +### Disabled + +```rust +ComboBox::new(&state).disabled(true) +``` + +### Events + +```rust +cx.subscribe_in(&state, window, |view, _, event, window, cx| { + match event { + ComboBoxEvent::Confirm(value) => { + // value is Option + } + } +}); +``` + +### Mutating + +```rust +// Set by index +state.update(cx, |s, cx| { + s.set_selected_index(Some(IndexPath::default()), window, cx); +}); + +// Set by value (requires Value: PartialEq) +state.update(cx, |s, cx| { + s.set_selected_value(&"my-value".into(), window, cx); +}); + +// Read current value +let value = state.read(cx).selected_value(); // Option<&Value> +``` + +## Multi-Select + +### Basic Multi-Select + +`MultiComboBoxState` holds a `Vec` selection. Selecting an item toggles it; the dropdown stays open until dismissed. + +```rust +let state = cx.new(|cx| { + MultiComboBoxState::new( + SearchableVec::new(vec!["React", "Vue", "Angular"]), + vec!["React"], // pre-selected + window, + cx, + ) + .searchable(true) +}); + +MultiComboBox::new(&state) + .placeholder("Select frameworks") +``` + +### Custom Multi-Select Trigger + +`MultiTriggerCtx` exposes `selected_values: &[Value]`: + +```rust +MultiComboBox::new(&state) + .render_trigger(|ctx, _, cx| { + if ctx.selected_values.is_empty() { + return div() + .text_color(cx.theme().muted_foreground) + .child("Select...") + .into_any_element(); + } + + h_flex() + .flex_wrap() + .gap_1() + .children(ctx.selected_values.iter().map(|val| { + div() + .rounded_sm() + .border_1() + .border_color(cx.theme().border) + .px_1p5() + .py_0p5() + .text_sm() + .child(*val) + })) + .into_any_element() + }) +``` + +### Multi-Select Events + +```rust +cx.subscribe_in(&state, window, |view, _, event, window, cx| { + match event { + MultiComboBoxEvent::Change(values) => { + // fired on every toggle + } + MultiComboBoxEvent::Confirm(values) => { + // fired when the dropdown closes + } + } +}); +``` + +### Mutating Multi-Select + +```rust +state.update(cx, |s, cx| { + s.add_value("Vue", cx); + s.remove_value(&"React", cx); + s.clear_selection(cx); + s.set_selected_values(vec!["Angular", "Svelte"], cx); +}); + +let values = state.read(cx).selected_values(); // &[Value] +``` + +## 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..96ecae1038 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](combo-box) - 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..52be2e0729 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](combo-box). +::: + ## Import ```rust diff --git a/docs/zh-CN/docs/components/combo-box.md b/docs/zh-CN/docs/components/combo-box.md new file mode 100644 index 0000000000..92c846da63 --- /dev/null +++ b/docs/zh-CN/docs/components/combo-box.md @@ -0,0 +1,339 @@ +--- +title: ComboBox +description: 带有可搜索下拉列表的自动补全输入组件。 +--- + +# ComboBox + +ComboBox 允许用户从可搜索的列表中选择一个(或多个)值。 + +与 [Select](select) 相比,`ComboBox` 额外支持自定义触发器渲染和自定义列表项渲染,便于构建富交互的选择 UI。 + +`MultiComboBox` 是多选变体——点击列表项会切换其选中状态,下拉菜单保持展开直到用户主动关闭。 + +## 导入 + +```rust +use gpui_component::combo_box::{ + ComboBox, ComboBoxState, ComboBoxEvent, + MultiComboBox, MultiComboBoxState, MultiComboBoxEvent, + TriggerCtx, MultiTriggerCtx, +}; +use gpui_component::searchable_list::{ + SearchableListItem, SearchableVec, SearchableGroup, +}; +``` + +## 用法 + +### 基础单选 + +```rust +let state = cx.new(|cx| { + ComboBoxState::new( + SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]), + None, // 无初始选中 + window, + cx, + ) + .searchable(true) +}); + +ComboBox::new(&state) + .placeholder("选择框架...") + .search_placeholder("搜索...") + .w_full() +``` + +### 预选项 + +通过索引路径指定预选的列表项: + +```rust +let state = cx.new(|cx| { + ComboBoxState::new(items, Some(IndexPath::default()), 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, None, 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() + }) +``` + +### 自定义触发器 + +完全覆盖触发器元素的渲染。`TriggerCtx` 包含当前选中状态、开关标志和尺寸信息: + +```rust +ComboBox::new(&state) + .render_trigger(|ctx, _, cx| { + h_flex() + .w_full() + .items_center() + .gap_2() + .when_some(ctx.selected_item, |this, item| { + this.child( + div() + .bg(cx.theme().accent) + .rounded_sm() + .px_1p5() + .py_0p5() + .text_sm() + .child(item.title()), + ) + }) + .when(ctx.selected_item.is_none(), |this| { + this.text_color(cx.theme().muted_foreground) + .child("请选择...") + }) + .into_any_element() + }) +``` + +### 自定义列表项渲染 + +覆盖每行列表项的渲染方式。设置后自动隐藏默认的尾部勾选图标,由闭包完全控制行内容: + +```rust +ComboBox::new(&state) + .render_item(|item: &MyItem, is_selected, _, cx| { + h_flex() + .w_full() + .gap_2() + .items_center() + .child(Icon::new(item.icon.clone()).small()) + .child(div().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) +``` + +### 事件监听 + +```rust +cx.subscribe_in(&state, window, |view, _, event, window, cx| { + match event { + ComboBoxEvent::Confirm(value) => { + // value 为 Option + } + } +}); +``` + +### 程序化操控 + +```rust +// 通过索引设置 +state.update(cx, |s, cx| { + s.set_selected_index(Some(IndexPath::default()), window, cx); +}); + +// 通过值设置(需要 Value: PartialEq) +state.update(cx, |s, cx| { + s.set_selected_value(&"my-value".into(), window, cx); +}); + +// 读取当前值 +let value = state.read(cx).selected_value(); // Option<&Value> +``` + +## 多选 + +### 基础多选 + +`MultiComboBoxState` 保存 `Vec` 的选中集合。点击列表项切换其选中状态,下拉菜单保持展开直到关闭。 + +```rust +let state = cx.new(|cx| { + MultiComboBoxState::new( + SearchableVec::new(vec!["React", "Vue", "Angular"]), + vec!["React"], // 预选项 + window, + cx, + ) + .searchable(true) +}); + +MultiComboBox::new(&state) + .placeholder("选择框架") +``` + +### 自定义多选触发器 + +`MultiTriggerCtx` 提供 `selected_values: &[Value]`: + +```rust +MultiComboBox::new(&state) + .render_trigger(|ctx, _, cx| { + if ctx.selected_values.is_empty() { + return div() + .text_color(cx.theme().muted_foreground) + .child("请选择...") + .into_any_element(); + } + + h_flex() + .flex_wrap() + .gap_1() + .children(ctx.selected_values.iter().map(|val| { + div() + .rounded_sm() + .border_1() + .border_color(cx.theme().border) + .px_1p5() + .py_0p5() + .text_sm() + .child(*val) + })) + .into_any_element() + }) +``` + +### 多选事件 + +```rust +cx.subscribe_in(&state, window, |view, _, event, window, cx| { + match event { + MultiComboBoxEvent::Change(values) => { + // 每次切换时触发 + } + MultiComboBoxEvent::Confirm(values) => { + // 下拉菜单关闭时触发 + } + } +}); +``` + +### 程序化操控多选 + +```rust +state.update(cx, |s, cx| { + s.add_value("Vue", cx); + s.remove_value(&"React", cx); + s.clear_selection(cx); + s.set_selected_values(vec!["Angular", "Svelte"], cx); +}); + +let values = state.read(cx).selected_values(); // &[Value] +``` + +## 键盘快捷键 + +| 按键 | 操作 | +| ---------- | -------------------------------- | +| `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..f4416cf980 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](combo-box) - 可搜索的单选或多选下拉组件 - [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..361e38de89 100644 --- a/docs/zh-CN/docs/components/select.md +++ b/docs/zh-CN/docs/components/select.md @@ -15,6 +15,10 @@ Select 允许用户从一组选项中选择一个值。 它支持搜索、分组、自定义渲染和多种状态,并内建键盘导航和可访问性支持。 +:::tip +如需自定义触发器渲染或多选功能,请参阅 [ComboBox](combo-box)。 +::: + ## 导入 ```rust From 1ac251d31f5983677ab2baf593e8ed58117de3d2 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 14 May 2026 14:08:24 +0800 Subject: [PATCH 05/10] =?UTF-8?q?combobox:=20Rename=20ComboBox=E2=86=92Com?= =?UTF-8?q?bobox,=20drop=20ComboBoxMode=20and=20close=5Fon=5Fselect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename module `combo_box` → `combobox` and all types (`ComboBox`→`Combobox`, `ComboBoxState`→`ComboboxState`, etc.) - Replace `ComboBoxMode::Single/Multi` enum with a `multiple(bool)` prop, matching Shadcn Combobox API (`multiple` attribute) - Remove `close_on_select`: behavior is now derived from `multiple` (single→close on select, multiple→stay open), matching Shadcn semantics Co-Authored-By: Claude Sonnet 4.6 --- crates/story/src/gallery.rs | 2 +- .../{combo_box_story.rs => combobox_story.rs} | 140 ++++----- crates/story/src/stories/mod.rs | 6 +- crates/ui/src/{combo_box.rs => combobox.rs} | 297 ++++++++---------- crates/ui/src/lib.rs | 4 +- 5 files changed, 198 insertions(+), 251 deletions(-) rename crates/story/src/stories/{combo_box_story.rs => combobox_story.rs} (89%) rename crates/ui/src/{combo_box.rs => combobox.rs} (82%) diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index e464ab6d04..adad3e59e2 100644 --- a/crates/story/src/gallery.rs +++ b/crates/story/src/gallery.rs @@ -50,7 +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), StoryContainer::panel::(window, cx), diff --git a/crates/story/src/stories/combo_box_story.rs b/crates/story/src/stories/combobox_story.rs similarity index 89% rename from crates/story/src/stories/combo_box_story.rs rename to crates/story/src/stories/combobox_story.rs index 3fd737c280..a43f6895a9 100644 --- a/crates/story/src/stories/combo_box_story.rs +++ b/crates/story/src/stories/combobox_story.rs @@ -3,7 +3,7 @@ use gpui_component::{ ActiveTheme, Icon, IconName, IndexPath, Sizable as _, button::Button, button::ButtonVariants as _, - combo_box::*, + combobox::*, h_flex, searchable_list::{ SearchableGroup, SearchableListChange, SearchableListDelegate, SearchableListItem, @@ -274,40 +274,40 @@ impl SearchableListDelegate for FeaturedDelegate { // MARK: Story -pub struct ComboBoxStory { +pub struct ComboboxStory { // 01 basic single-select - basic: Entity>>, + basic: Entity>>, // 02 basic multi-select - basic_multi: Entity>>, + basic_multi: Entity>>, // 03 grouped single-select - grouped: Entity>>>, + grouped: Entity>>>, // 03 disabled items (single) - disabled_items: Entity>>, + disabled_items: Entity>>, // 04 item icon (single) - with_icon: Entity>>, + with_icon: Entity>>, // 05 custom check icon (single) - custom_check: Entity>>, + custom_check: Entity>>, // 06 footer button (single) - with_footer: Entity>>, + with_footer: Entity>>, // 07 custom trigger (single) - custom_trigger: Entity>>, + custom_trigger: Entity>>, // 08 multi badges - multi_badges: Entity>>, + multi_badges: Entity>>, // 09 on_will_change — max 2 items - custom_max2: Entity>, + custom_max2: Entity>, // 10 is_item_checked — externally pinned items - pinned: Entity>, + pinned: Entity>, // 11 render_item delegate hook — custom row renderer - featured: Entity>, + featured: Entity>, // 12 multi expandable - multi_expand: Entity>>, + multi_expand: Entity>>, // 12 multi count-badge - multi_count: Entity>>, + multi_count: Entity>>, } -impl super::Story for ComboBoxStory { +impl super::Story for ComboboxStory { fn title() -> &'static str { - "ComboBox" + "Combobox" } fn description() -> &'static str { @@ -319,7 +319,7 @@ impl super::Story for ComboBoxStory { } } -impl Focusable for ComboBoxStory { +impl Focusable for ComboboxStory { fn focus_handle(&self, cx: &App) -> FocusHandle { self.basic.focus_handle(cx) } @@ -361,24 +361,22 @@ fn industries() -> SearchableVec { ]) } -impl ComboBoxStory { +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) - .mode(ComboBoxMode::Single) - .searchable(true) + 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) - .mode(ComboBoxMode::Multi) + 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) - .mode(ComboBoxMode::Single) - .searchable(true) + ComboboxState::new(food_groups(), vec![IndexPath::default()], window, cx) + .searchable(true) }); let disabled_items = cx.new(|cx| { @@ -389,83 +387,76 @@ impl ComboBoxStory { FoodItem::new("Carrots"), FoodItem::new("Broccoli").disabled(), ]); - ComboBoxState::new(items, vec![], window, cx) - .mode(ComboBoxMode::Single) - .searchable(true) + ComboboxState::new(items, vec![], window, cx) + .searchable(true) }); let with_icon = cx.new(|cx| { - ComboBoxState::new(industries(), vec![], window, cx) - .mode(ComboBoxMode::Single) - .searchable(true) + ComboboxState::new(industries(), vec![], window, cx) + .searchable(true) }); let custom_check = cx.new(|cx| { - ComboBoxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) - .mode(ComboBoxMode::Single) - .searchable(true) + 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) - .mode(ComboBoxMode::Single) - .searchable(true) + 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) - .mode(ComboBoxMode::Single) - .searchable(true) + ComboboxState::new(SearchableVec::new(FRAMEWORKS.to_vec()), vec![], window, cx) + .searchable(true) }); let multi_badges = cx.new(|cx| { - ComboBoxState::new( + ComboboxState::new( SearchableVec::new(MULTI_FRAMEWORKS.to_vec()), vec![IndexPath::new(0), IndexPath::new(2)], window, cx, ) - .mode(ComboBoxMode::Multi) + .multiple(true) .searchable(true) }); let custom_max2 = cx.new(|cx| { - ComboBoxState::new( + ComboboxState::new( Max2Delegate::new(SearchableVec::new(MULTI_FRAMEWORKS.to_vec())), vec![], window, cx, ) - .mode(ComboBoxMode::Multi) + .multiple(true) .searchable(true) }); let pinned = cx.new(|cx| { - ComboBoxState::new( + ComboboxState::new( PinnedDelegate(SearchableVec::new(FRAMEWORKS.to_vec())), vec![], window, cx, ) - .mode(ComboBoxMode::Single) - .searchable(true) + .searchable(true) }); let featured = cx.new(|cx| { - ComboBoxState::new( + ComboboxState::new( FeaturedDelegate(SearchableVec::new(FRAMEWORKS.to_vec())), vec![], window, cx, ) - .mode(ComboBoxMode::Single) - .searchable(true) + .searchable(true) }); let multi_expand = cx.new(|cx| { - ComboBoxState::new( + ComboboxState::new( SearchableVec::new(MULTI_FRAMEWORKS.to_vec()), vec![ IndexPath::new(0), @@ -477,12 +468,12 @@ impl ComboBoxStory { window, cx, ) - .mode(ComboBoxMode::Multi) + .multiple(true) .searchable(true) }); let multi_count = cx.new(|cx| { - ComboBoxState::new( + ComboboxState::new( SearchableVec::new(MULTI_FRAMEWORKS.to_vec()), vec![ IndexPath::new(0), @@ -495,7 +486,7 @@ impl ComboBoxStory { window, cx, ) - .mode(ComboBoxMode::Multi) + .multiple(true) .searchable(true) }); @@ -522,7 +513,7 @@ impl ComboBoxStory { } } -impl Render for ComboBoxStory { +impl Render for ComboboxStory { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let multi_badges_state = self.multi_badges.clone(); @@ -531,7 +522,7 @@ impl Render for ComboBoxStory { .gap_4() .child( section("Basic Single-Select").max_w_md().child( - ComboBox::new(&self.basic) + Combobox::new(&self.basic) .placeholder("Select framework...") .search_placeholder("Search framework...") .w_full(), @@ -539,16 +530,15 @@ impl Render for ComboBoxStory { ) .child( section("Basic Multi-Select").max_w_md().child( - ComboBox::new(&self.basic_multi) + Combobox::new(&self.basic_multi) .placeholder("Select frameworks...") .search_placeholder("Search framework...") - .close_on_select(false) .w_full(), ), ) .child( section("Grouped Items").max_w_md().child( - ComboBox::new(&self.grouped) + Combobox::new(&self.grouped) .placeholder("Select item...") .search_placeholder("Search item...") .w_full(), @@ -556,7 +546,7 @@ impl Render for ComboBoxStory { ) .child( section("Disabled Items").max_w_md().child( - ComboBox::new(&self.disabled_items) + Combobox::new(&self.disabled_items) .placeholder("Select item...") .search_placeholder("Search item...") .w_full(), @@ -564,7 +554,7 @@ impl Render for ComboBoxStory { ) .child( section("Item with Icon").max_w_md().child( - ComboBox::new(&self.with_icon) + Combobox::new(&self.with_icon) .placeholder("Select industry category") .search_placeholder("Search industries...") .render_trigger(|ctx, _, cx| { @@ -613,7 +603,7 @@ impl Render for ComboBoxStory { ) .child( section("Custom Check Icon").max_w_md().child( - ComboBox::new(&self.custom_check) + Combobox::new(&self.custom_check) .placeholder("Select framework...") .search_placeholder("Search framework...") .check_icon(Icon::new(IconName::CircleCheck)) @@ -622,7 +612,7 @@ impl Render for ComboBoxStory { ) .child( section("Footer Action Button").max_w_md().child( - ComboBox::new(&self.with_footer) + Combobox::new(&self.with_footer) .placeholder("Select university") .search_placeholder("Find university") .footer(|_, cx| { @@ -640,7 +630,7 @@ impl Render for ComboBoxStory { ) .child( section("Custom Trigger").max_w_md().child( - ComboBox::new(&self.custom_trigger) + Combobox::new(&self.custom_trigger) .placeholder("Select framework") .search_placeholder("Search framework...") .render_trigger(|ctx, _, cx| { @@ -698,10 +688,9 @@ impl Render for ComboBoxStory { ) .child( section("Multi-Select with Badges").max_w_md().child( - ComboBox::new(&self.multi_badges) + Combobox::new(&self.multi_badges) .placeholder("Select frameworks") .search_placeholder("Search framework...") - .close_on_select(false) .render_trigger(move |ctx, _, cx| { let items = ctx.selection; @@ -749,16 +738,15 @@ impl Render for ComboBoxStory { ) .child( section("Max 2 Selections").max_w_md().child( - ComboBox::new(&self.custom_max2) + Combobox::new(&self.custom_max2) .placeholder("Select up to 2 frameworks") .search_placeholder("Search framework...") - .close_on_select(false) .w_full(), ), ) .child( section("Pinned Items").max_w_md().child( - ComboBox::new(&self.pinned) + Combobox::new(&self.pinned) .placeholder("Select framework...") .search_placeholder("Search framework...") .w_full(), @@ -766,7 +754,7 @@ impl Render for ComboBoxStory { ) .child( section("Custom Row Renderer").max_w_md().child( - ComboBox::new(&self.featured) + Combobox::new(&self.featured) .placeholder("Select framework...") .search_placeholder("Search framework...") .w_full(), @@ -774,10 +762,9 @@ impl Render for ComboBoxStory { ) .child( section("Multi-Select Expandable").max_w_md().child( - ComboBox::new(&self.multi_expand) + Combobox::new(&self.multi_expand) .placeholder("Select frameworks") .search_placeholder("Search framework...") - .close_on_select(false) .render_trigger(|ctx, _, cx| { const MAX_SHOWN: usize = 2; @@ -823,10 +810,9 @@ impl Render for ComboBoxStory { ) .child( section("Multi-Select with Count Badge").max_w_md().child( - ComboBox::new(&self.multi_count) + Combobox::new(&self.multi_count) .placeholder("Select frameworks") .search_placeholder("Search framework...") - .close_on_select(false) .render_trigger(|ctx, _, cx| { let count = ctx.selection.len(); diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index 7a29cc9089..7a93c73e39 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -14,7 +14,7 @@ mod checkbox_story; mod clipboard_story; mod collapsible_story; mod color_picker_story; -mod combo_box_story; +mod combobox_story; mod data_table_story; mod date_picker_story; mod description_list_story; @@ -75,7 +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 combo_box_story::ComboBoxStory; +pub use combobox_story::ComboboxStory; pub use data_table_story::DataTableStory; pub use date_picker_story::DatePickerStory; pub use description_list_story::DescriptionListStory; @@ -126,7 +126,7 @@ pub use welcome_story::WelcomeStory; pub(crate) fn init(cx: &mut App) { input_story::init(cx); - combo_box_story::init(cx); + combobox_story::init(cx); rating_story::init(cx); number_input_story::init(cx); textarea_story::init(cx); diff --git a/crates/ui/src/combo_box.rs b/crates/ui/src/combobox.rs similarity index 82% rename from crates/ui/src/combo_box.rs rename to crates/ui/src/combobox.rs index 3769f1491f..c4451bbddf 100644 --- a/crates/ui/src/combo_box.rs +++ b/crates/ui/src/combobox.rs @@ -23,7 +23,7 @@ use crate::{ v_flex, }; -const CONTEXT: &str = "ComboBox"; +const CONTEXT: &str = "Combobox"; pub(crate) fn init(cx: &mut App) { cx.bind_keys([ @@ -39,10 +39,10 @@ pub(crate) fn init(cx: &mut App) { ]) } -// MARK: ComboBoxTriggerCtx +// MARK: ComboboxTriggerCtx -/// Context passed to the `render_trigger` closure on [`ComboBox`]. -pub struct ComboBoxTriggerCtx<'a, D: SearchableListDelegate + 'static> { +/// Context passed to the `render_trigger` closure on [`Combobox`]. +pub struct ComboboxTriggerCtx<'a, D: SearchableListDelegate + 'static> { pub selection: &'a [(IndexPath, D::Item)], pub placeholder: Option<&'a SharedString>, pub open: bool, @@ -50,29 +50,16 @@ pub struct ComboBoxTriggerCtx<'a, D: SearchableListDelegate + 'static> { pub size: Size, } -// MARK: ComboBoxChange +// MARK: ComboboxChange /// Back-compat alias — new code should use [`SearchableListChange`] directly. -pub type ComboBoxChange = SearchableListChange; - -// MARK: ComboBoxMode - -/// Selection semantics for a [`ComboBox`]. -#[derive(Default, Clone, Copy, PartialEq, Eq)] -pub enum ComboBoxMode { - /// Clicking an item replaces the entire selection and closes the popover. - #[default] - Single, - /// Clicking an item toggles it in the selection; the popover stays open. - Multi, -} +pub type ComboboxChange = SearchableListChange; -// MARK: ComboBoxOptions +// MARK: ComboboxOptions -struct ComboBoxOptions { +struct ComboboxOptions { style: StyleRefinement, size: Size, - close_on_select: bool, cleanable: bool, placeholder: Option, search_placeholder: Option, @@ -84,12 +71,11 @@ struct ComboBoxOptions { check_icon: Option, } -impl Default for ComboBoxOptions { +impl Default for ComboboxOptions { fn default() -> Self { Self { style: StyleRefinement::default(), size: Size::default(), - close_on_select: true, cleanable: false, placeholder: None, search_placeholder: None, @@ -103,28 +89,27 @@ impl Default for ComboBoxOptions { } } -// MARK: ComboBoxState +// MARK: ComboboxState -/// State of the [`ComboBox`] component. -pub struct ComboBoxState +/// State of the [`Combobox`] component. +pub struct ComboboxState where ::Value: PartialEq + Clone, { pub(crate) state: SearchableListState, - // ComboBox-specific fields - mode: ComboBoxMode, + // Combobox-specific fields + multiple: bool, searchable: bool, - close_on_select: bool, trigger_icon: Option, check_icon: Option, render_trigger: - Option, &mut Window, &mut App) -> AnyElement + 'static>>, + Option, &mut Window, &mut App) -> AnyElement + 'static>>, footer: Option AnyElement + 'static>>, } -/// Events emitted by [`ComboBoxState`]. -pub enum ComboBoxEvent +/// Events emitted by [`ComboboxState`]. +pub enum ComboboxEvent where ::Value: PartialEq + Clone, { @@ -134,12 +119,12 @@ where Confirm(Vec<::Value>), } -impl ComboBoxState +impl ComboboxState where D: SearchableListDelegate + 'static, ::Value: PartialEq + Clone, { - /// Create a new `ComboBox` state. + /// Create a new `Combobox` state. pub fn new( delegate: D, selected_indices: Vec, @@ -174,30 +159,27 @@ where return; }; - let (mode, close_on_select, mut selection) = { + let (multiple, mut selection) = { let s = weak.read(cx); - (s.mode, s.close_on_select, s.state.selection.clone()) + (s.multiple, s.state.selection.clone()) }; let is_selected = selection.iter().any(|(cur_ix, _)| cur_ix == &ix); - let changes: Vec = match mode { - ComboBoxMode::Single => { - let mut changes: Vec = selection - .iter() - .map(|(cur_ix, _)| SearchableListChange::Deselect { - index: *cur_ix, - }) - .collect(); - changes.push(SearchableListChange::Select { index: ix }); - changes - } - ComboBoxMode::Multi => { - if is_selected { - vec![SearchableListChange::Deselect { index: ix }] - } else { - vec![SearchableListChange::Select { index: 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 = @@ -213,18 +195,18 @@ where let after_indices: Vec = selection.iter().map(|(ix, _)| *ix).collect(); let changed = before_indices != after_indices; - let should_close = changed && close_on_select; + 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.emit(ComboboxEvent::Change(this.selected_values())); cx.notify(); } if should_close { - cx.emit(ComboBoxEvent::Confirm(this.selected_values())); + cx.emit(ComboboxEvent::Confirm(this.selected_values())); this.set_open(false, cx); this.focus(window, cx); } @@ -254,7 +236,7 @@ where let weak_cancel = weak_cancel.clone(); move |_list_state, window, cx| { _ = weak_cancel.update(cx, |this, cx| { - cx.emit(ComboBoxEvent::Confirm(this.selected_values())); + cx.emit(ComboboxEvent::Confirm(this.selected_values())); this.set_open(false, cx); this.focus(window, cx); }); @@ -284,9 +266,8 @@ where Self { state, - mode: ComboBoxMode::default(), + multiple: false, searchable: false, - close_on_select: true, trigger_icon: None, check_icon: None, render_trigger: None, @@ -294,12 +275,12 @@ where } } - /// Set the selection mode. + /// Enable multi-select mode. /// - /// - [`ComboBoxMode::Single`] — clicking an item replaces the selection and closes the popover. - /// - [`ComboBoxMode::Multi`] — clicking an item toggles it; the popover stays open. - pub fn mode(mut self, mode: ComboBoxMode) -> Self { - self.mode = 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 } @@ -357,7 +338,7 @@ where 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.emit(ComboboxEvent::Change(self.selected_values())); cx.notify(); } @@ -373,10 +354,7 @@ where self.state.focus_handle.focus(window, cx); } - /// Process an item click, applying mode-specific selection semantics. - /// - /// - `Single`: replaces the entire selection with the clicked item, then closes. - /// - `Multi`: toggles the clicked item; respects `close_on_select`. + /// 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. pub fn handle_item_select( @@ -385,27 +363,23 @@ where window: &mut Window, cx: &mut Context, ) { - let close_on_select = self.close_on_select; let is_selected = self.state.selection.iter().any(|(cur_ix, _)| cur_ix == &ix); - let changes: Vec = match self.mode { - ComboBoxMode::Single => { - let mut changes: Vec = self - .state - .selection - .iter() - .map(|(cur_ix, _)| SearchableListChange::Deselect { index: *cur_ix }) - .collect(); - changes.push(SearchableListChange::Select { index: ix }); - changes - } - ComboBoxMode::Multi => { - if is_selected { - vec![SearchableListChange::Deselect { index: ix }] - } else { - vec![SearchableListChange::Select { index: 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(); @@ -419,13 +393,13 @@ where let after_indices: Vec = selection.iter().map(|(ix, _)| *ix).collect(); let changed = before_indices != after_indices; - let should_close = changed && close_on_select; + 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.emit(ComboboxEvent::Change(self.selected_values())); cx.notify(); } @@ -435,7 +409,7 @@ where list.delegate_mut().delegate.on_confirm(&final_selection); }); - cx.emit(ComboBoxEvent::Confirm(self.selected_values())); + cx.emit(ComboboxEvent::Confirm(self.selected_values())); self.set_open(false, cx); self.focus(window, cx); } @@ -500,7 +474,7 @@ where } cx.stop_propagation(); - cx.emit(ComboBoxEvent::Confirm(self.selected_values())); + cx.emit(ComboboxEvent::Confirm(self.selected_values())); self.set_open(false, cx); self.focus(window, cx); @@ -529,7 +503,7 @@ where .state .placeholder .clone() - .unwrap_or_else(|| t!("ComboBox.placeholder").into()); + .unwrap_or_else(|| t!("Combobox.placeholder").into()); if self.state.selection.is_empty() { return div() @@ -538,44 +512,41 @@ where .into_any_element(); } - match self.mode { - ComboBoxMode::Single => { - 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() - } - ComboBoxMode::Multi => { - 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() - } + 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 +impl Render for ComboboxState where D: SearchableListDelegate + 'static, ::Value: PartialEq + Clone, @@ -609,7 +580,7 @@ where .unwrap_or_else(|| Icon::new(IconName::ChevronDown)); let trigger_body = if let Some(render_trigger) = &self.render_trigger { - let ctx = ComboBoxTriggerCtx { + let ctx = ComboboxTriggerCtx { selection, placeholder, open, @@ -695,20 +666,20 @@ where } } -impl EventEmitter> for ComboBoxState +impl EventEmitter> for ComboboxState where D: SearchableListDelegate + 'static, ::Value: PartialEq + Clone, { } -impl EventEmitter for ComboBoxState +impl EventEmitter for ComboboxState where D: SearchableListDelegate + 'static, ::Value: PartialEq + Clone, { } -impl Focusable for ComboBoxState +impl Focusable for ComboboxState where D: SearchableListDelegate + 'static, ::Value: PartialEq + Clone, @@ -722,36 +693,36 @@ where } } -// MARK: ComboBox element +// 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 +pub struct Combobox where ::Value: PartialEq + Clone, { id: ElementId, - state: Entity>, - options: ComboBoxOptions, + state: Entity>, + options: ComboboxOptions, render_trigger: - Option, &mut Window, &mut App) -> AnyElement + 'static>>, + Option, &mut Window, &mut App) -> AnyElement + 'static>>, footer: Option AnyElement + 'static>>, empty: Option AnyElement + 'static>>, } -impl ComboBox +impl Combobox where D: SearchableListDelegate + 'static, ::Value: PartialEq + Clone, { - pub fn new(state: &Entity>) -> Self { + pub fn new(state: &Entity>) -> Self { Self { id: ("multi-combo-box", state.entity_id()).into(), state: state.clone(), - options: ComboBoxOptions::default(), + options: ComboboxOptions::default(), render_trigger: None, footer: None, empty: None, @@ -806,14 +777,6 @@ where self } - /// Control whether the popover closes after an item is selected (default: `true`). - /// - /// For [`ComboBoxMode::Multi`] you typically want `false` to allow batch selection. - pub fn close_on_select(mut self, close: bool) -> Self { - self.options.close_on_select = close; - self - } - /// Set a custom closure that renders the empty-state element. pub fn empty(mut self, builder: impl Fn(&mut Window, &App) -> AnyElement + 'static) -> Self { self.empty = Some(Box::new(builder)); @@ -829,7 +792,7 @@ where /// Override the entire trigger element. pub fn render_trigger( mut self, - f: impl Fn(&ComboBoxTriggerCtx, &mut Window, &mut App) -> AnyElement + 'static, + f: impl Fn(&ComboboxTriggerCtx, &mut Window, &mut App) -> AnyElement + 'static, ) -> Self { self.render_trigger = Some(Box::new(f)); self @@ -842,7 +805,7 @@ where } } -impl Sizable for ComboBox +impl Sizable for Combobox where D: SearchableListDelegate + 'static, ::Value: PartialEq + Clone, @@ -853,7 +816,7 @@ where } } -impl Styled for ComboBox +impl Styled for Combobox where D: SearchableListDelegate + 'static, ::Value: PartialEq + Clone, @@ -863,7 +826,7 @@ where } } -impl RenderOnce for ComboBox +impl RenderOnce for Combobox where D: SearchableListDelegate + 'static, ::Value: PartialEq + Clone, @@ -886,7 +849,6 @@ where this.state.menu_max_h = opts.menu_max_h; this.state.disabled = opts.disabled; this.state.appearance = opts.appearance; - this.close_on_select = opts.close_on_select; this.trigger_icon = opts.trigger_icon; this.check_icon = opts.check_icon; this.render_trigger = render_trigger; @@ -903,10 +865,10 @@ where .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)) + .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) } @@ -1041,7 +1003,7 @@ mod tests { use crate::{ IndexPath, - combo_box::{ComboBox, ComboBoxMode, ComboBoxState}, + combobox::{Combobox, ComboboxState}, searchable_list::{ SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState, SearchableVec, @@ -1054,9 +1016,9 @@ mod tests { 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 state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true)); - let _cb = ComboBox::new(&state) + let _cb = Combobox::new(&state) .placeholder("Select language") .search_placeholder("Search...") .menu_width(gpui::px(300.)) @@ -1073,7 +1035,7 @@ mod tests { 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 state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true)); let count_before = state .read(cx) @@ -1113,17 +1075,16 @@ mod tests { 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) - .mode(ComboBoxMode::Multi) + ComboboxState::new(items, vec![IndexPath::new(0)], window, cx) + .multiple(true) .searchable(true) }); - let _cb = ComboBox::new(&state) + let _cb = Combobox::new(&state) .placeholder("Select frameworks") .search_placeholder("Search...") .menu_width(gpui::px(300.)) .cleanable(true) - .close_on_select(false) .disabled(false); assert_eq!(state.read(cx).selected_values(), vec!["React"]); @@ -1137,7 +1098,7 @@ mod tests { cx.update(|window, cx| { let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); let state = cx - .new(|cx| ComboBoxState::new(items, vec![], window, cx).mode(ComboBoxMode::Multi)); + .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"]); @@ -1157,13 +1118,13 @@ mod tests { cx.update(|window, cx| { let items = SearchableVec::new(vec!["React", "Vue", "Angular"]); let state = cx.new(|cx| { - ComboBoxState::new( + ComboboxState::new( items, vec![IndexPath::new(0), IndexPath::new(1)], window, cx, ) - .mode(ComboBoxMode::Multi) + .multiple(true) }); assert_eq!(state.read(cx).selected_values().len(), 2); @@ -1178,7 +1139,7 @@ mod tests { 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)); + 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)); @@ -1228,7 +1189,7 @@ mod tests { 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)); + 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)); diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index a5c10ec834..47d8cf74c7 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -33,7 +33,7 @@ pub mod checkbox; pub mod clipboard; pub mod collapsible; pub mod color_picker; -pub mod combo_box; +pub mod combobox; pub mod description_list; pub mod dialog; pub mod dock; @@ -113,7 +113,7 @@ pub fn init(cx: &mut App) { date_picker::init(cx); dock::init(cx); sheet::init(cx); - combo_box::init(cx); + combobox::init(cx); select::init(cx); input::init(cx); list::init(cx); From abd3448b546aedf6308a9390f7207adece378f09 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 14 May 2026 14:12:01 +0800 Subject: [PATCH 06/10] =?UTF-8?q?combobox:=20Rename=20doc=20files=20combo-?= =?UTF-8?q?box.md=20=E2=86=92=20combobox.md;=20update=20cross-links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- crates/story/src/stories/combobox_story.rs | 2 +- docs/docs/components/{combo-box.md => combobox.md} | 4 ++-- docs/docs/components/index.md | 2 +- docs/docs/components/select.md | 2 +- docs/zh-CN/docs/components/{combo-box.md => combobox.md} | 4 ++-- docs/zh-CN/docs/components/index.md | 2 +- docs/zh-CN/docs/components/select.md | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) rename docs/docs/components/{combo-box.md => combobox.md} (99%) rename docs/zh-CN/docs/components/{combo-box.md => combobox.md} (99%) diff --git a/crates/story/src/stories/combobox_story.rs b/crates/story/src/stories/combobox_story.rs index a43f6895a9..86e5eddd9a 100644 --- a/crates/story/src/stories/combobox_story.rs +++ b/crates/story/src/stories/combobox_story.rs @@ -311,7 +311,7 @@ impl super::Story for ComboboxStory { } fn description() -> &'static str { - "Autocomplete input with a list of suggestions." + "An autocomplete input paired with a searchable dropdown list." } fn new_view(window: &mut Window, cx: &mut App) -> Entity { diff --git a/docs/docs/components/combo-box.md b/docs/docs/components/combobox.md similarity index 99% rename from docs/docs/components/combo-box.md rename to docs/docs/components/combobox.md index eecbcde2e0..a92ea9a623 100644 --- a/docs/docs/components/combo-box.md +++ b/docs/docs/components/combobox.md @@ -1,9 +1,9 @@ --- -title: ComboBox +title: Combobox description: An autocomplete input paired with a searchable dropdown list. --- -# ComboBox +# Combobox A combobox component that allows users to select one (or many) values from a searchable list. diff --git a/docs/docs/components/index.md b/docs/docs/components/index.md index 96ecae1038..c051003d06 100644 --- a/docs/docs/components/index.md +++ b/docs/docs/components/index.md @@ -37,7 +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](combo-box) - Searchable single-select or multi-select dropdown. +- [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 52be2e0729..3d8a12a53e 100644 --- a/docs/docs/components/select.md +++ b/docs/docs/components/select.md @@ -16,7 +16,7 @@ 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](combo-box). +For richer selection UIs with custom trigger rendering or multi-select, see [Combobox](combobox). ::: ## Import diff --git a/docs/zh-CN/docs/components/combo-box.md b/docs/zh-CN/docs/components/combobox.md similarity index 99% rename from docs/zh-CN/docs/components/combo-box.md rename to docs/zh-CN/docs/components/combobox.md index 92c846da63..c71da8a4e0 100644 --- a/docs/zh-CN/docs/components/combo-box.md +++ b/docs/zh-CN/docs/components/combobox.md @@ -1,9 +1,9 @@ --- -title: ComboBox +title: Combobox description: 带有可搜索下拉列表的自动补全输入组件。 --- -# ComboBox +# Combobox ComboBox 允许用户从可搜索的列表中选择一个(或多个)值。 diff --git a/docs/zh-CN/docs/components/index.md b/docs/zh-CN/docs/components/index.md index f4416cf980..64776bbea9 100644 --- a/docs/zh-CN/docs/components/index.md +++ b/docs/zh-CN/docs/components/index.md @@ -22,7 +22,7 @@ collapsed: false - [Input](input) - 文本输入与类输入控件 - [Select](select) - 选项选择器 -- [ComboBox](combo-box) - 可搜索的单选或多选下拉组件 +- [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 361e38de89..517e162627 100644 --- a/docs/zh-CN/docs/components/select.md +++ b/docs/zh-CN/docs/components/select.md @@ -16,7 +16,7 @@ Select 允许用户从一组选项中选择一个值。 它支持搜索、分组、自定义渲染和多种状态,并内建键盘导航和可访问性支持。 :::tip -如需自定义触发器渲染或多选功能,请参阅 [ComboBox](combo-box)。 +如需自定义触发器渲染或多选功能,请参阅 [Combobox](combobox)。 ::: ## 导入 From 081c22a33b816d865f47fc7954cb65c766e778fc Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 14 May 2026 14:15:20 +0800 Subject: [PATCH 07/10] =?UTF-8?q?combobox:=20Rewrite=20docs=20=E2=80=94=20?= =?UTF-8?q?add=20Select=20vs=20Combobox=20comparison,=20fix=20API=20for=20?= =?UTF-8?q?unified=20multiple(bool)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Select vs Combobox feature comparison table - Highlight multi-select as the key differentiator from Select - Fix import section: remove deprecated MultiComboBox/MultiComboBoxState types - Fix all code examples to use current API (ComboboxState, multiple(true), vec![] indices) - Fix ComboboxTriggerCtx usage: ctx.selection instead of ctx.selected_item - Merge single/multi-select sections into unified usage examples Co-Authored-By: Claude Sonnet 4.6 --- docs/docs/components/combobox.md | 213 +++++++++---------------- docs/zh-CN/docs/components/combobox.md | 207 +++++++++--------------- 2 files changed, 147 insertions(+), 273 deletions(-) diff --git a/docs/docs/components/combobox.md b/docs/docs/components/combobox.md index a92ea9a623..4c9846d62c 100644 --- a/docs/docs/components/combobox.md +++ b/docs/docs/components/combobox.md @@ -5,19 +5,25 @@ description: An autocomplete input paired with a searchable dropdown list. # Combobox -A combobox component that allows users to select one (or many) values from a searchable list. +A searchable dropdown for selecting one or multiple values from a list. -Compared to [Select](select), `ComboBox` adds support for custom trigger rendering and custom item rendering, making it easy to build rich selection UIs without forking the underlying list behaviour. +## Select vs Combobox -`MultiComboBox` is the multi-select variant — it toggles items in the selection and keeps the dropdown open until the user dismisses it. +| 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::combo_box::{ - ComboBox, ComboBoxState, ComboBoxEvent, - MultiComboBox, MultiComboBoxState, MultiComboBoxEvent, - TriggerCtx, MultiTriggerCtx, +use gpui_component::combobox::{ + Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerCtx, }; use gpui_component::searchable_list::{ SearchableListItem, SearchableVec, SearchableGroup, @@ -30,28 +36,47 @@ use gpui_component::searchable_list::{ ```rust let state = cx.new(|cx| { - ComboBoxState::new( + ComboboxState::new( SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]), - None, // no initial selection + vec![], // no initial selection window, cx, ) .searchable(true) }); -ComboBox::new(&state) +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 the index path of the item to pre-select: +Pass index paths of items to pre-select: ```rust let state = cx.new(|cx| { - ComboBoxState::new(items, Some(IndexPath::default()), window, cx) + ComboboxState::new(items, vec![IndexPath::new(0)], window, cx) }); ``` @@ -72,15 +97,15 @@ let grouped = SearchableVec::new(vec![ ]); let state = cx.new(|cx| { - ComboBoxState::new(grouped, None, window, cx).searchable(true) + ComboboxState::new(grouped, vec![], window, cx).searchable(true) }); -ComboBox::new(&state) +Combobox::new(&state) ``` ### Implementing `SearchableListItem` -Built-in implementations of `SearchableListItem` exist for `String`, `SharedString`, and `&'static str`. For custom types implement the trait: +Built-in implementations exist for `String`, `SharedString`, and `&'static str`. For custom types implement the trait: ```rust #[derive(Clone)] @@ -123,7 +148,7 @@ impl SearchableListItem for MyItem { ### Custom Check Icon ```rust -ComboBox::new(&state) +Combobox::new(&state) .check_icon(Icon::new(IconName::CircleCheck)) ``` @@ -132,7 +157,7 @@ ComboBox::new(&state) Render a persistent action at the bottom of the dropdown (e.g. an "Add new" button): ```rust -ComboBox::new(&state) +Combobox::new(&state) .footer(|_, cx| { Button::new("add-new") .ghost() @@ -146,47 +171,28 @@ ComboBox::new(&state) ### Custom Trigger -Override the entire trigger element. You control the label, icons, and layout. `TriggerCtx` exposes selection state, open/disabled flags, and the current size: +Override the entire trigger element. `ComboboxTriggerCtx` exposes the current selection, open/disabled flags, and size: ```rust -ComboBox::new(&state) +Combobox::new(&state) .render_trigger(|ctx, _, cx| { h_flex() .w_full() .items_center() .gap_2() - .when_some(ctx.selected_item, |this, item| { - this.child( - div() - .bg(cx.theme().accent) - .rounded_sm() - .px_1p5() - .py_0p5() - .text_sm() - .child(item.title()), - ) - }) - .when(ctx.selected_item.is_none(), |this| { + .when(ctx.selection.is_empty(), |this| { this.text_color(cx.theme().muted_foreground) .child("Select...") }) - .into_any_element() - }) -``` - -### Custom Item Renderer - -Override how each item row is drawn. When set, the automatic trailing check icon is suppressed — your closure controls the full row: - -```rust -ComboBox::new(&state) - .render_item(|item: &MyItem, is_selected, _, cx| { - h_flex() - .w_full() - .gap_2() - .items_center() - .child(Icon::new(item.icon.clone()).small()) - .child(div().child(item.title())) + .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() }) ``` @@ -194,130 +200,61 @@ ComboBox::new(&state) ### Sizes ```rust -ComboBox::new(&state).large() -ComboBox::new(&state) // medium (default) -ComboBox::new(&state).small() +Combobox::new(&state).large() +Combobox::new(&state) // medium (default) +Combobox::new(&state).small() ``` ### Cleanable ```rust -ComboBox::new(&state).cleanable(true) // show clear button when value is selected +Combobox::new(&state).cleanable(true) // show clear button when a value is selected ``` ### Disabled ```rust -ComboBox::new(&state).disabled(true) +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::Confirm(value) => { - // value is Option + ComboboxEvent::Change(values) => { + // fired on every toggle + } + ComboboxEvent::Confirm(values) => { + // fired when the dropdown closes } } }); ``` -### Mutating +### Mutating Programmatically ```rust -// Set by index +// Replace the entire selection state.update(cx, |s, cx| { - s.set_selected_index(Some(IndexPath::default()), window, cx); + s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], cx); }); -// Set by value (requires Value: PartialEq) +// Add / remove individual items state.update(cx, |s, cx| { - s.set_selected_value(&"my-value".into(), window, cx); + s.add_selected_index(IndexPath::new(1), cx); + s.remove_selected_index(IndexPath::new(0), cx); }); -// Read current value -let value = state.read(cx).selected_value(); // Option<&Value> -``` - -## Multi-Select - -### Basic Multi-Select - -`MultiComboBoxState` holds a `Vec` selection. Selecting an item toggles it; the dropdown stays open until dismissed. - -```rust -let state = cx.new(|cx| { - MultiComboBoxState::new( - SearchableVec::new(vec!["React", "Vue", "Angular"]), - vec!["React"], // pre-selected - window, - cx, - ) - .searchable(true) -}); - -MultiComboBox::new(&state) - .placeholder("Select frameworks") -``` - -### Custom Multi-Select Trigger - -`MultiTriggerCtx` exposes `selected_values: &[Value]`: - -```rust -MultiComboBox::new(&state) - .render_trigger(|ctx, _, cx| { - if ctx.selected_values.is_empty() { - return div() - .text_color(cx.theme().muted_foreground) - .child("Select...") - .into_any_element(); - } - - h_flex() - .flex_wrap() - .gap_1() - .children(ctx.selected_values.iter().map(|val| { - div() - .rounded_sm() - .border_1() - .border_color(cx.theme().border) - .px_1p5() - .py_0p5() - .text_sm() - .child(*val) - })) - .into_any_element() - }) -``` - -### Multi-Select Events - -```rust -cx.subscribe_in(&state, window, |view, _, event, window, cx| { - match event { - MultiComboBoxEvent::Change(values) => { - // fired on every toggle - } - MultiComboBoxEvent::Confirm(values) => { - // fired when the dropdown closes - } - } -}); -``` - -### Mutating Multi-Select - -```rust +// Clear all selections state.update(cx, |s, cx| { - s.add_value("Vue", cx); - s.remove_value(&"React", cx); s.clear_selection(cx); - s.set_selected_values(vec!["Angular", "Svelte"], cx); }); -let values = state.read(cx).selected_values(); // &[Value] +// Read current values +let values = state.read(cx).selected_values(); // Vec ``` ## Keyboard Shortcuts diff --git a/docs/zh-CN/docs/components/combobox.md b/docs/zh-CN/docs/components/combobox.md index c71da8a4e0..29c6653764 100644 --- a/docs/zh-CN/docs/components/combobox.md +++ b/docs/zh-CN/docs/components/combobox.md @@ -5,19 +5,25 @@ description: 带有可搜索下拉列表的自动补全输入组件。 # Combobox -ComboBox 允许用户从可搜索的列表中选择一个(或多个)值。 +支持从可搜索列表中选择一个或多个值的下拉选择组件。 -与 [Select](select) 相比,`ComboBox` 额外支持自定义触发器渲染和自定义列表项渲染,便于构建富交互的选择 UI。 +## Select 与 Combobox 的区别 -`MultiComboBox` 是多选变体——点击列表项会切换其选中状态,下拉菜单保持展开直到用户主动关闭。 +| 功能 | Select | Combobox | +| --- | --- | --- | +| 可搜索 | ✓(可选) | ✓(可选) | +| 多选 | — | ✓(`.multiple(true)`) | +| 自定义触发器渲染 | — | ✓ | +| 自定义列表项渲染 | — | ✓ | +| 底部操作插槽 | — | ✓ | + +需要简单单选时用 `Select`;需要多选、完全自定义触发器或自定义列表项渲染时用 `Combobox`。 ## 导入 ```rust -use gpui_component::combo_box::{ - ComboBox, ComboBoxState, ComboBoxEvent, - MultiComboBox, MultiComboBoxState, MultiComboBoxEvent, - TriggerCtx, MultiTriggerCtx, +use gpui_component::combobox::{ + Combobox, ComboboxState, ComboboxEvent, ComboboxTriggerCtx, }; use gpui_component::searchable_list::{ SearchableListItem, SearchableVec, SearchableGroup, @@ -30,28 +36,47 @@ use gpui_component::searchable_list::{ ```rust let state = cx.new(|cx| { - ComboBoxState::new( + ComboboxState::new( SearchableVec::new(vec!["Next.js", "SvelteKit", "Nuxt.js"]), - None, // 无初始选中 + vec![], // 无初始选中 window, cx, ) .searchable(true) }); -ComboBox::new(&state) +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, Some(IndexPath::default()), window, cx) + ComboboxState::new(items, vec![IndexPath::new(0)], window, cx) }); ``` @@ -72,10 +97,10 @@ let grouped = SearchableVec::new(vec![ ]); let state = cx.new(|cx| { - ComboBoxState::new(grouped, None, window, cx).searchable(true) + ComboboxState::new(grouped, vec![], window, cx).searchable(true) }); -ComboBox::new(&state) +Combobox::new(&state) ``` ### 实现 `SearchableListItem` @@ -123,7 +148,7 @@ impl SearchableListItem for MyItem { ### 自定义勾选图标 ```rust -ComboBox::new(&state) +Combobox::new(&state) .check_icon(Icon::new(IconName::CircleCheck)) ``` @@ -132,7 +157,7 @@ ComboBox::new(&state) 在下拉菜单底部渲染一个固定操作项(如"新建"按钮): ```rust -ComboBox::new(&state) +Combobox::new(&state) .footer(|_, cx| { Button::new("add-new") .ghost() @@ -146,47 +171,28 @@ ComboBox::new(&state) ### 自定义触发器 -完全覆盖触发器元素的渲染。`TriggerCtx` 包含当前选中状态、开关标志和尺寸信息: +完全覆盖触发器元素的渲染。`ComboboxTriggerCtx` 包含当前选中状态、开关标志和尺寸信息: ```rust -ComboBox::new(&state) +Combobox::new(&state) .render_trigger(|ctx, _, cx| { h_flex() .w_full() .items_center() .gap_2() - .when_some(ctx.selected_item, |this, item| { - this.child( - div() - .bg(cx.theme().accent) - .rounded_sm() - .px_1p5() - .py_0p5() - .text_sm() - .child(item.title()), - ) - }) - .when(ctx.selected_item.is_none(), |this| { + .when(ctx.selection.is_empty(), |this| { this.text_color(cx.theme().muted_foreground) .child("请选择...") }) - .into_any_element() - }) -``` - -### 自定义列表项渲染 - -覆盖每行列表项的渲染方式。设置后自动隐藏默认的尾部勾选图标,由闭包完全控制行内容: - -```rust -ComboBox::new(&state) - .render_item(|item: &MyItem, is_selected, _, cx| { - h_flex() - .w_full() - .gap_2() - .items_center() - .child(Icon::new(item.icon.clone()).small()) - .child(div().child(item.title())) + .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() }) ``` @@ -194,30 +200,35 @@ ComboBox::new(&state) ### 尺寸 ```rust -ComboBox::new(&state).large() -ComboBox::new(&state) // 默认(medium) -ComboBox::new(&state).small() +Combobox::new(&state).large() +Combobox::new(&state) // 默认(medium) +Combobox::new(&state).small() ``` ### 可清除 ```rust -ComboBox::new(&state).cleanable(true) // 有选中值时显示清除按钮 +Combobox::new(&state).cleanable(true) // 有选中值时显示清除按钮 ``` ### 禁用状态 ```rust -ComboBox::new(&state).disabled(true) +Combobox::new(&state).disabled(true) ``` ### 事件监听 +`Change`(每次切换时触发)和 `Confirm`(下拉菜单关闭时触发)均携带完整的选中值列表 `Vec`。 + ```rust cx.subscribe_in(&state, window, |view, _, event, window, cx| { match event { - ComboBoxEvent::Confirm(value) => { - // value 为 Option + ComboboxEvent::Change(values) => { + // 每次切换时触发 + } + ComboboxEvent::Confirm(values) => { + // 下拉菜单关闭时触发 } } }); @@ -226,98 +237,24 @@ cx.subscribe_in(&state, window, |view, _, event, window, cx| { ### 程序化操控 ```rust -// 通过索引设置 +// 替换整个选中集合 state.update(cx, |s, cx| { - s.set_selected_index(Some(IndexPath::default()), window, cx); + s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], cx); }); -// 通过值设置(需要 Value: PartialEq) +// 增加 / 移除单个选项 state.update(cx, |s, cx| { - s.set_selected_value(&"my-value".into(), window, cx); + s.add_selected_index(IndexPath::new(1), cx); + s.remove_selected_index(IndexPath::new(0), cx); }); -// 读取当前值 -let value = state.read(cx).selected_value(); // Option<&Value> -``` - -## 多选 - -### 基础多选 - -`MultiComboBoxState` 保存 `Vec` 的选中集合。点击列表项切换其选中状态,下拉菜单保持展开直到关闭。 - -```rust -let state = cx.new(|cx| { - MultiComboBoxState::new( - SearchableVec::new(vec!["React", "Vue", "Angular"]), - vec!["React"], // 预选项 - window, - cx, - ) - .searchable(true) -}); - -MultiComboBox::new(&state) - .placeholder("选择框架") -``` - -### 自定义多选触发器 - -`MultiTriggerCtx` 提供 `selected_values: &[Value]`: - -```rust -MultiComboBox::new(&state) - .render_trigger(|ctx, _, cx| { - if ctx.selected_values.is_empty() { - return div() - .text_color(cx.theme().muted_foreground) - .child("请选择...") - .into_any_element(); - } - - h_flex() - .flex_wrap() - .gap_1() - .children(ctx.selected_values.iter().map(|val| { - div() - .rounded_sm() - .border_1() - .border_color(cx.theme().border) - .px_1p5() - .py_0p5() - .text_sm() - .child(*val) - })) - .into_any_element() - }) -``` - -### 多选事件 - -```rust -cx.subscribe_in(&state, window, |view, _, event, window, cx| { - match event { - MultiComboBoxEvent::Change(values) => { - // 每次切换时触发 - } - MultiComboBoxEvent::Confirm(values) => { - // 下拉菜单关闭时触发 - } - } -}); -``` - -### 程序化操控多选 - -```rust +// 清空选中 state.update(cx, |s, cx| { - s.add_value("Vue", cx); - s.remove_value(&"React", cx); s.clear_selection(cx); - s.set_selected_values(vec!["Angular", "Svelte"], cx); }); -let values = state.read(cx).selected_values(); // &[Value] +// 读取当前值 +let values = state.read(cx).selected_values(); // Vec ``` ## 键盘快捷键 From d73ce54aa4a1b3fa807f3f0880a0046b099c3ebb Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 14 May 2026 14:23:26 +0800 Subject: [PATCH 08/10] combobox: API consistency fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename trigger_icon() → icon() to match Select's convention - Add selected_value() → Option convenience for single-select - Add window param to set_selected_indices() to match set_selected_index() in Select - Restrict handle_item_select to pub(crate) — internal/test use only Co-Authored-By: Claude Sonnet 4.6 --- crates/ui/src/combobox.rs | 16 +++++++++++----- docs/docs/components/combobox.md | 7 +++++-- docs/zh-CN/docs/components/combobox.md | 7 +++++-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/ui/src/combobox.rs b/crates/ui/src/combobox.rs index c4451bbddf..9b80ae5f76 100644 --- a/crates/ui/src/combobox.rs +++ b/crates/ui/src/combobox.rs @@ -139,7 +139,6 @@ where let state = SearchableListState::new( delegate, selected_indices, - // on_confirm — delegate to handle_item_select for mode-specific semantics move |selected_index, _secondary, window, cx| { cx.defer_in(window, { let weak_confirm = weak_confirm.clone(); @@ -295,6 +294,13 @@ where 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() @@ -304,6 +310,7 @@ where pub fn set_selected_indices( &mut self, indices: impl IntoIterator, + _window: &mut Window, cx: &mut Context, ) { self.state.set_selected_indices(indices, cx); @@ -357,7 +364,8 @@ where /// 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. - pub fn handle_item_select( + #[allow(dead_code)] + pub(crate) fn handle_item_select( &mut self, ix: IndexPath, window: &mut Window, @@ -748,7 +756,7 @@ where } /// Override the trigger chevron icon. - pub fn trigger_icon(mut self, icon: impl Into) -> Self { + pub fn icon(mut self, icon: impl Into) -> Self { self.options.trigger_icon = Some(icon.into()); self } @@ -1145,8 +1153,6 @@ mod tests { state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx)); assert_eq!(state.read(cx).selected_values(), &["Rust"]); - // Adding a second index manually (bypasses mode logic) is still possible via - // the public API; mode only governs clicks routed through handle_item_select. state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx)); assert_eq!(state.read(cx).selected_values(), &["Rust", "Go"]); }); diff --git a/docs/docs/components/combobox.md b/docs/docs/components/combobox.md index 4c9846d62c..883f961a73 100644 --- a/docs/docs/components/combobox.md +++ b/docs/docs/components/combobox.md @@ -239,7 +239,7 @@ cx.subscribe_in(&state, window, |view, _, event, window, cx| { ```rust // Replace the entire selection state.update(cx, |s, cx| { - s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], cx); + s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], window, cx); }); // Add / remove individual items @@ -253,8 +253,11 @@ state.update(cx, |s, cx| { s.clear_selection(cx); }); -// Read current values +// 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 diff --git a/docs/zh-CN/docs/components/combobox.md b/docs/zh-CN/docs/components/combobox.md index 29c6653764..a4d07cdc2c 100644 --- a/docs/zh-CN/docs/components/combobox.md +++ b/docs/zh-CN/docs/components/combobox.md @@ -239,7 +239,7 @@ cx.subscribe_in(&state, window, |view, _, event, window, cx| { ```rust // 替换整个选中集合 state.update(cx, |s, cx| { - s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], cx); + s.set_selected_indices(vec![IndexPath::new(0), IndexPath::new(2)], window, cx); }); // 增加 / 移除单个选项 @@ -253,8 +253,11 @@ 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 ``` ## 键盘快捷键 From f514b6ec66c2cac27c84ac7b2e2b04bf8257daef Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 14 May 2026 14:53:22 +0800 Subject: [PATCH 09/10] select, combobox: Accept impl IntoElement in empty/footer/render_trigger closures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers no longer need .into_any_element() — the conversion happens inside the builder method. Co-Authored-By: Claude Sonnet 4.6 --- crates/story/src/stories/select_story.rs | 1 - crates/ui/src/combobox.rs | 22 +++++++++++++++------- crates/ui/src/select.rs | 7 +++++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/story/src/stories/select_story.rs b/crates/story/src/stories/select_story.rs index 6331c25d54..893f03fb16 100644 --- a/crates/story/src/stories/select_story.rs +++ b/crates/story/src/stories/select_story.rs @@ -263,7 +263,6 @@ impl Render for SelectStory { .justify_center() .text_color(cx.theme().muted_foreground) .child("No Data") - .into_any_element() }), ), ) diff --git a/crates/ui/src/combobox.rs b/crates/ui/src/combobox.rs index 9b80ae5f76..495deecdd1 100644 --- a/crates/ui/src/combobox.rs +++ b/crates/ui/src/combobox.rs @@ -786,8 +786,11 @@ where } /// Set a custom closure that renders the empty-state element. - pub fn empty(mut self, builder: impl Fn(&mut Window, &App) -> AnyElement + 'static) -> Self { - self.empty = Some(Box::new(builder)); + 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 } @@ -798,17 +801,22 @@ where } /// Override the entire trigger element. - pub fn render_trigger( + pub fn render_trigger( mut self, - f: impl Fn(&ComboboxTriggerCtx, &mut Window, &mut App) -> AnyElement + 'static, + f: impl Fn(&ComboboxTriggerCtx, &mut Window, &mut App) -> E + 'static, ) -> Self { - self.render_trigger = Some(Box::new(f)); + 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) -> AnyElement + 'static) -> Self { - self.footer = Some(Box::new(f)); + 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 } } diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index 86e184f628..a84ebe1d0e 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -657,8 +657,11 @@ where } /// Set a custom closure that renders the empty-state element. - pub fn empty(mut self, builder: impl Fn(&mut Window, &App) -> AnyElement + 'static) -> Self { - self.empty = Some(Box::new(builder)); + 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 } From 30e23a7d88dc1a84fa3b59f3f34194349fac9cae Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 14 May 2026 14:54:54 +0800 Subject: [PATCH 10/10] =?UTF-8?q?searchable=5Flist:=20Rename=20SearchableL?= =?UTF-8?q?istItemEl=20=E2=86=92=20SearchableListItemElement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "El" abbreviation is not allowed by project convention. Co-Authored-By: Claude Sonnet 4.6 --- crates/ui/src/searchable_list.rs | 2 +- crates/ui/src/searchable_list/adapter.rs | 8 ++++---- crates/ui/src/searchable_list/delegate.rs | 2 +- crates/ui/src/searchable_list/item.rs | 16 ++++++++-------- crates/ui/src/select.rs | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/crates/ui/src/searchable_list.rs b/crates/ui/src/searchable_list.rs index 53a56ff752..aa29de47bf 100644 --- a/crates/ui/src/searchable_list.rs +++ b/crates/ui/src/searchable_list.rs @@ -8,6 +8,6 @@ mod vec; pub(crate) use adapter::SearchableListAdapter; pub use change::SearchableListChange; pub use delegate::{SearchableListDelegate, SearchableListItem}; -pub use item::SearchableListItemEl; +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 index d4077d8742..8c88dfe4e2 100644 --- a/crates/ui/src/searchable_list/adapter.rs +++ b/crates/ui/src/searchable_list/adapter.rs @@ -5,7 +5,7 @@ use crate::{ list::{ListDelegate, ListState}, }; -use super::{delegate::{SearchableListDelegate, SearchableListItem as _}, item::SearchableListItemEl}; +use super::{delegate::{SearchableListDelegate, SearchableListItem as _}, item::SearchableListItemElement}; /// Bridges a [`SearchableListDelegate`] into the [`ListDelegate`] protocol. /// @@ -61,7 +61,7 @@ impl SearchableListAdapter { } impl ListDelegate for SearchableListAdapter { - type Item = SearchableListItemEl; + type Item = SearchableListItemElement; fn sections_count(&self, cx: &App) -> usize { self.delegate.sections_count(cx) @@ -115,7 +115,7 @@ impl ListDelegate for SearchableListAdapter if let Some(el) = self.delegate.render_item(ix, item, is_checked, window, cx) { return Some( - SearchableListItemEl::new(ix.row) + SearchableListItemElement::new(ix.row) .disabled(disabled) .with_size(size) .child(el), @@ -132,7 +132,7 @@ impl ListDelegate for SearchableListAdapter .child(item.render(window, cx).into_any_element()); Some( - SearchableListItemEl::new(ix.row) + SearchableListItemElement::new(ix.row) .checked(is_checked) .check_icon(check_icon) .disabled(disabled) diff --git a/crates/ui/src/searchable_list/delegate.rs b/crates/ui/src/searchable_list/delegate.rs index ab0839674c..6a8894b0fd 100644 --- a/crates/ui/src/searchable_list/delegate.rs +++ b/crates/ui/src/searchable_list/delegate.rs @@ -83,7 +83,7 @@ pub trait SearchableListDelegate: Sized + 'static { /// Override the row content for the item at `ix`. /// - /// When `Some(_)` is returned, the adapter suppresses its default `SearchableListItemEl` + /// 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. /// diff --git a/crates/ui/src/searchable_list/item.rs b/crates/ui/src/searchable_list/item.rs index 93e1c8e9af..b2ba9655dc 100644 --- a/crates/ui/src/searchable_list/item.rs +++ b/crates/ui/src/searchable_list/item.rs @@ -15,7 +15,7 @@ use crate::{ /// - `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 SearchableListItemEl { +pub struct SearchableListItemElement { id: ElementId, size: Size, style: StyleRefinement, @@ -29,7 +29,7 @@ pub struct SearchableListItemEl { check_icon: Option, } -impl SearchableListItemEl { +impl SearchableListItemElement { pub fn new(ix: usize) -> Self { Self { id: ("searchable-list-item", ix).into(), @@ -56,20 +56,20 @@ impl SearchableListItemEl { } } -impl ParentElement for SearchableListItemEl { +impl ParentElement for SearchableListItemElement { fn extend(&mut self, elements: impl IntoIterator) { self.children.extend(elements); } } -impl Disableable for SearchableListItemEl { +impl Disableable for SearchableListItemElement { fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } } -impl Selectable for SearchableListItemEl { +impl Selectable for SearchableListItemElement { fn selected(mut self, selected: bool) -> Self { self.selected = selected; self @@ -80,20 +80,20 @@ impl Selectable for SearchableListItemEl { } } -impl Sizable for SearchableListItemEl { +impl Sizable for SearchableListItemElement { fn with_size(mut self, size: impl Into) -> Self { self.size = size.into(); self } } -impl Styled for SearchableListItemEl { +impl Styled for SearchableListItemElement { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } -impl RenderOnce for SearchableListItemEl { +impl RenderOnce for SearchableListItemElement { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { h_flex() .id(self.id) diff --git a/crates/ui/src/select.rs b/crates/ui/src/select.rs index a84ebe1d0e..1ff78b37e4 100644 --- a/crates/ui/src/select.rs +++ b/crates/ui/src/select.rs @@ -28,8 +28,8 @@ pub use crate::searchable_list::SearchableGroup as SelectGroup; 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 [`SearchableListItemEl`]. -pub use crate::searchable_list::SearchableListItemEl as SelectListItem; +/// 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;