Skip to main content

gpui_component/
combobox.rs

1use gpui::{
2    AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, Edges, ElementId, Entity,
3    EventEmitter, FocusHandle, Focusable, Hsla, InteractiveElement, IntoElement, Length,
4    MouseDownEvent, ParentElement, Pixels, Render, RenderOnce, SharedString,
5    StatefulInteractiveElement, StyleRefinement, Styled, Window, deferred, div,
6    prelude::FluentBuilder, px, rems,
7};
8
9use rust_i18n::t;
10
11pub use crate::select::Caret;
12
13use crate::ThemeStyled as _;
14use crate::{
15    ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Sizable, Size,
16    StyleSized, StyledExt, h_flex,
17    input::{clear_button, input_style},
18    list::{List, ListState},
19    searchable_list::{
20        SearchableListAdapter, SearchableListChange, SearchableListDelegate, SearchableListItem,
21        SearchableListState,
22    },
23    v_flex,
24};
25use gpui_base::{Combobox as BaseCombobox, GlobalState};
26
27// MARK: ComboboxTriggerContext
28
29/// Context passed to the `render_trigger` closure on [`Combobox`].
30///
31/// The fields are private and reached through the methods below, so that a new
32/// one can be added without breaking the trigger renderers.
33pub struct ComboboxTriggerContext<'a, D: SearchableListDelegate + 'static> {
34    selection: &'a [(IndexPath, D::Item)],
35    placeholder: Option<&'a SharedString>,
36    open: bool,
37    disabled: bool,
38    size: Size,
39}
40
41impl<'a, D: SearchableListDelegate + 'static> ComboboxTriggerContext<'a, D> {
42    /// The items currently selected, empty when the combobox has no value.
43    pub fn selection(&self) -> &'a [(IndexPath, D::Item)] {
44        self.selection
45    }
46
47    pub fn placeholder(&self) -> Option<&'a SharedString> {
48        self.placeholder
49    }
50
51    /// Whether the dropdown list is showing.
52    pub fn is_open(&self) -> bool {
53        self.open
54    }
55
56    pub fn is_disabled(&self) -> bool {
57        self.disabled
58    }
59
60    pub fn size(&self) -> Size {
61        self.size
62    }
63}
64
65// MARK: ComboboxChange
66
67/// Back-compat alias — new code should use [`SearchableListChange`] directly.
68pub type ComboboxChange = SearchableListChange;
69
70// MARK: ComboboxOptions
71
72struct ComboboxOptions {
73    style: StyleRefinement,
74    size: Size,
75    cleanable: bool,
76    placeholder: Option<SharedString>,
77    search_placeholder: Option<SharedString>,
78    menu_width: Length,
79    menu_max_h: Length,
80    disabled: bool,
81    appearance: bool,
82    focus_ring_enabled: bool,
83    trigger_icon: Option<Icon>,
84    check_icon: Option<Icon>,
85}
86
87impl Default for ComboboxOptions {
88    fn default() -> Self {
89        Self {
90            style: StyleRefinement::default(),
91            size: Size::default(),
92            cleanable: false,
93            placeholder: None,
94            search_placeholder: None,
95            menu_width: Length::Auto,
96            menu_max_h: rems(20.).into(),
97            disabled: false,
98            appearance: true,
99            focus_ring_enabled: true,
100            trigger_icon: None,
101            check_icon: None,
102        }
103    }
104}
105
106// MARK: ComboboxState
107
108/// State of the [`Combobox`] component.
109pub struct ComboboxState<D: SearchableListDelegate + 'static>
110where
111    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
112{
113    pub(crate) state: SearchableListState<D>,
114
115    // Combobox-specific fields
116    multiple: bool,
117    searchable: bool,
118    trigger_icon: Option<Icon>,
119    check_icon: Option<Icon>,
120    render_trigger: Option<
121        Box<dyn Fn(&ComboboxTriggerContext<D>, &mut Window, &mut App) -> AnyElement + 'static>,
122    >,
123    footer: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
124    focus_ring_enabled: bool,
125}
126
127/// Events emitted by [`ComboboxState`].
128pub enum ComboboxEvent<D: SearchableListDelegate + 'static>
129where
130    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
131{
132    /// Emitted on every toggle (item added or removed).
133    Change(Vec<<D::Item as SearchableListItem>::Value>),
134    /// Emitted when the popover closes.
135    Confirm(Vec<<D::Item as SearchableListItem>::Value>),
136}
137
138impl<D> ComboboxState<D>
139where
140    D: SearchableListDelegate + 'static,
141    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
142{
143    /// Create a new `Combobox` state.
144    pub fn new(
145        delegate: D,
146        selected_indices: Vec<IndexPath>,
147        window: &mut Window,
148        cx: &mut Context<Self>,
149    ) -> Self {
150        let weak = cx.entity().downgrade();
151        let weak_confirm = weak.clone();
152        let weak_cancel = weak.clone();
153        let weak_empty = weak;
154
155        let state = SearchableListState::new(
156            delegate,
157            selected_indices,
158            move |selected_index, _secondary, window, cx| {
159                cx.defer_in(window, {
160                    let weak_confirm = weak_confirm.clone();
161                    move |list_state, window, cx| {
162                        let Some(index) = selected_index else {
163                            return;
164                        };
165
166                        let Some(item) = list_state.delegate().delegate.item(index).cloned() else {
167                            return;
168                        };
169
170                        let ix = index;
171
172                        let Some(weak) = weak_confirm.upgrade() else {
173                            return;
174                        };
175
176                        let (multiple, mut selection) = {
177                            let s = weak.read(cx);
178                            (s.multiple, s.state.selection.clone())
179                        };
180
181                        let changes = Self::selection_changes(multiple, &selection, ix, &item);
182
183                        let before_indices: Vec<IndexPath> =
184                            selection.iter().map(|(ix, _)| *ix).collect();
185
186                        // on_will_change is called directly — entity-handle access would
187                        // re-enter the ListState lock that defer_in holds for this callback.
188                        list_state
189                            .delegate_mut()
190                            .delegate
191                            .on_will_change(&mut selection, &changes);
192
193                        let after_indices: Vec<IndexPath> =
194                            selection.iter().map(|(ix, _)| *ix).collect();
195                        let changed = before_indices != after_indices;
196                        let should_close = changed && !multiple;
197
198                        let new_selection = weak_confirm.update(cx, |this, cx| {
199                            this.state.selection = selection;
200
201                            if changed {
202                                cx.emit(ComboboxEvent::Change(this.selected_values()));
203                                cx.notify();
204                            }
205
206                            if should_close {
207                                cx.emit(ComboboxEvent::Confirm(this.selected_values()));
208                                this.set_open(false, cx);
209                                this.focus(window, cx);
210                            }
211
212                            this.state.selection.clone()
213                        });
214
215                        // Sync snapshot and fire on_confirm directly — same re-entrancy guard.
216                        if let Ok(new_selection) = new_selection {
217                            list_state
218                                .delegate_mut()
219                                .update_selection_snapshot(new_selection.clone());
220
221                            if should_close {
222                                list_state
223                                    .delegate_mut()
224                                    .delegate
225                                    .on_confirm(&new_selection);
226                            }
227                        }
228                    }
229                });
230            },
231            // on_cancel — close and emit Confirm with current values
232            move |_final_selected_index, window, cx| {
233                cx.defer_in(window, {
234                    let weak_cancel = weak_cancel.clone();
235                    move |_list_state, window, cx| {
236                        _ = weak_cancel.update(cx, |this, cx| {
237                            cx.emit(ComboboxEvent::Confirm(this.selected_values()));
238                            this.set_open(false, cx);
239                            this.focus(window, cx);
240                        });
241                    }
242                });
243            },
244            // on_render_empty
245            move |window, cx| {
246                if let Some(empty) = weak_empty
247                    .upgrade()
248                    .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx)))
249                {
250                    empty
251                } else {
252                    h_flex()
253                        .justify_center()
254                        .py_6()
255                        .text_color(cx.theme().muted_foreground.opacity(0.6))
256                        .child(Icon::new(IconName::Inbox).size(px(28.)))
257                        .into_any_element()
258                }
259            },
260            Self::on_blur,
261            window,
262            cx,
263        );
264
265        Self {
266            state,
267            multiple: false,
268            searchable: false,
269            trigger_icon: None,
270            check_icon: None,
271            render_trigger: None,
272            footer: None,
273            focus_ring_enabled: true,
274        }
275    }
276
277    /// Enable multi-select mode.
278    ///
279    /// When `true`, clicking an item toggles it in the selection and the popover stays open.
280    /// When `false` (default), clicking an item replaces the selection and closes the popover.
281    pub fn multiple(mut self, multiple: bool) -> Self {
282        self.multiple = multiple;
283        self
284    }
285
286    /// Enable or disable the search input at the top of the dropdown.
287    pub fn searchable(mut self, searchable: bool) -> Self {
288        self.searchable = searchable;
289        self
290    }
291
292    /// Return the currently selected values.
293    pub fn selected_values(&self) -> Vec<<D::Item as SearchableListItem>::Value> {
294        self.state.selected_values()
295    }
296
297    /// Return the first selected value, or `None` when nothing is selected.
298    ///
299    /// Convenience for single-select mode (`.multiple(false)`).
300    pub fn selected_value(&self) -> Option<<D::Item as SearchableListItem>::Value> {
301        self.state.selected_values().into_iter().next()
302    }
303
304    /// Return the currently selected `(IndexPath, Item)` pairs.
305    pub fn selection(&self) -> &[(IndexPath, D::Item)] {
306        self.state.selection()
307    }
308
309    /// Replace the entire selection set by item values.
310    ///
311    /// Values are resolved through the current delegate. Values that cannot be resolved are
312    /// ignored. This updates the committed selection and snapshot without emitting a
313    /// [`ComboboxEvent`].
314    pub fn set_selected_values(
315        &mut self,
316        values: &[<D::Item as SearchableListItem>::Value],
317        window: &mut Window,
318        cx: &mut Context<Self>,
319    ) {
320        self.state.clear_query(window, cx);
321
322        let selected_indices = {
323            let list = self.state.list.read(cx);
324            let delegate = &list.delegate().delegate;
325
326            values
327                .iter()
328                .filter_map(|value| delegate.position(value))
329                .collect::<Vec<_>>()
330        };
331
332        self.set_selected_indices(selected_indices, window, cx);
333    }
334
335    /// Replace the entire selection set.
336    pub fn set_selected_indices(
337        &mut self,
338        indices: impl IntoIterator<Item = IndexPath>,
339        _window: &mut Window,
340        cx: &mut Context<Self>,
341    ) {
342        self.state.set_selected_indices(indices, cx);
343        self.state.sync_snapshot(cx);
344        cx.notify();
345    }
346
347    /// Add a single index to the selection, if not already present, returning whether it was added.
348    pub fn add_selected_index(&mut self, index: IndexPath, cx: &mut Context<Self>) -> bool {
349        let added = self.state.add_selected_index(index, cx);
350
351        if added {
352            self.state.sync_snapshot(cx);
353            cx.notify();
354        }
355
356        added
357    }
358
359    /// Remove a single index from the selection, returning whether it was removed.
360    pub fn remove_selected_index(&mut self, index: IndexPath, cx: &mut Context<Self>) -> bool {
361        let removed = self.state.remove_selected_index(index);
362
363        if removed {
364            self.state.sync_snapshot(cx);
365        }
366
367        removed
368    }
369
370    /// Clear all selected values.
371    pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
372        self.state.selection.clear();
373        self.state.sync_snapshot(cx);
374        cx.emit(ComboboxEvent::Change(self.selected_values()));
375        cx.notify();
376    }
377
378    /// Replace the underlying delegate (item data source).
379    pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>) {
380        self.state.list.update(cx, |list, _| {
381            list.delegate_mut().delegate = items;
382        });
383    }
384
385    /// Focus the trigger.
386    pub fn focus(&self, window: &mut Window, cx: &mut App) {
387        self.state.focus_handle.focus(window, cx);
388    }
389
390    /// Returns the search query.
391    pub fn query(&self, cx: &App) -> SharedString {
392        self.state.list.read(cx).query_input.read(cx).value()
393    }
394
395    /// Sets the search query and updates the filtered items.
396    pub fn set_query(&self, query: impl Into<SharedString>, window: &mut Window, cx: &mut App) {
397        let query = query.into();
398        self.state.list.update(cx, |list, cx| {
399            list.set_query(query.as_ref(), window, cx);
400        });
401    }
402
403    fn selection_changes(
404        multiple: bool,
405        selection: &[(IndexPath, D::Item)],
406        ix: IndexPath,
407        item: &D::Item,
408    ) -> Vec<SearchableListChange> {
409        let is_selected = selection
410            .iter()
411            .any(|(_, selected_item)| selected_item.value() == item.value());
412
413        if multiple {
414            if is_selected {
415                vec![SearchableListChange::Deselect { index: ix }]
416            } else {
417                vec![SearchableListChange::Select { index: ix }]
418            }
419        } else {
420            let mut changes: Vec<SearchableListChange> = selection
421                .iter()
422                .map(|(cur_ix, _)| SearchableListChange::Deselect { index: *cur_ix })
423                .collect();
424            changes.push(SearchableListChange::Select { index: ix });
425            changes
426        }
427    }
428
429    /// Process an item click: single-select replaces the selection and closes; multi-select toggles.
430    ///
431    /// Calls `delegate.on_will_change` before committing and `delegate.on_confirm` when closing.
432    #[allow(dead_code)]
433    pub(crate) fn handle_item_select(
434        &mut self,
435        ix: IndexPath,
436        window: &mut Window,
437        cx: &mut Context<Self>,
438    ) {
439        let Some(item) = self
440            .state
441            .list
442            .read(cx)
443            .delegate()
444            .delegate
445            .item(ix)
446            .cloned()
447        else {
448            return;
449        };
450
451        let changes = Self::selection_changes(self.multiple, &self.state.selection, ix, &item);
452
453        let mut selection = self.state.selection.clone();
454        let before_indices: Vec<IndexPath> = selection.iter().map(|(ix, _)| *ix).collect();
455
456        self.state.list.update(cx, |list, _cx| {
457            list.delegate_mut()
458                .delegate
459                .on_will_change(&mut selection, &changes);
460        });
461
462        let after_indices: Vec<IndexPath> = selection.iter().map(|(ix, _)| *ix).collect();
463        let changed = before_indices != after_indices;
464        let should_close = changed && !self.multiple;
465
466        self.state.selection = selection;
467        self.state.sync_snapshot(cx);
468
469        if changed {
470            cx.emit(ComboboxEvent::Change(self.selected_values()));
471            cx.notify();
472        }
473
474        if should_close {
475            let final_selection = self.state.selection.clone();
476            self.state.list.update(cx, |list, _cx| {
477                list.delegate_mut().delegate.on_confirm(&final_selection);
478            });
479
480            cx.emit(ComboboxEvent::Confirm(self.selected_values()));
481            self.set_open(false, cx);
482            self.focus(window, cx);
483        }
484    }
485
486    fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
487        if self.state.list.read(cx).is_focused(window, cx)
488            || self.state.focus_handle.is_focused(window)
489        {
490            return;
491        }
492
493        self.set_open(false, cx);
494        cx.notify();
495    }
496
497    fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
498        cx.stop_propagation();
499
500        self.set_open(!self.state.open, cx);
501
502        if self.state.open {
503            self.state.list.focus_handle(cx).focus(window, cx);
504        } else {
505            cx.emit(ComboboxEvent::Confirm(self.selected_values()));
506            self.focus(window, cx);
507        }
508
509        cx.notify();
510    }
511
512    /// Close the menu when a press lands outside the popup.
513    ///
514    /// A press on the trigger is left to propagate: swallowing it here would keep nested
515    /// controls (a tag remove button, the clear button) from ever seeing the press, and
516    /// `toggle_menu` closes the menu on release anyway.
517    fn dismiss(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
518        if !self.state.open || self.state.bounds.contains(&event.position) {
519            return;
520        }
521
522        cx.stop_propagation();
523        cx.emit(ComboboxEvent::Confirm(self.selected_values()));
524
525        self.set_open(false, cx);
526        self.focus(window, cx);
527        cx.notify();
528    }
529
530    fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
531        self.state.open = open;
532        self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
533
534        cx.notify();
535    }
536
537    fn clean(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
538        cx.stop_propagation();
539        self.clear_selection(cx);
540    }
541
542    fn default_trigger_body(&self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
543        let placeholder_text = self
544            .state
545            .placeholder
546            .clone()
547            .unwrap_or_else(|| t!("Combobox.placeholder").into());
548
549        if self.state.selection.is_empty() {
550            return div()
551                .text_color(cx.theme().muted_foreground)
552                .child(placeholder_text)
553                .into_any_element();
554        }
555
556        if self.multiple {
557            let items: Vec<SharedString> = self
558                .state
559                .selection
560                .iter()
561                .map(|(_, i)| i.title())
562                .collect();
563
564            div()
565                .w_full()
566                .overflow_hidden()
567                .whitespace_nowrap()
568                .truncate()
569                .child(items.join(", "))
570                .into_any_element()
571        } else {
572            let title = self
573                .state
574                .selection
575                .first()
576                .map(|(_, i)| i.title())
577                .unwrap_or_default();
578
579            div()
580                .w_full()
581                .overflow_hidden()
582                .whitespace_nowrap()
583                .truncate()
584                .child(title)
585                .into_any_element()
586        }
587    }
588}
589
590impl<D> Render for ComboboxState<D>
591where
592    D: SearchableListDelegate + 'static,
593    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
594{
595    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
596        let searchable = self.searchable;
597        let is_focused = self.state.focus_handle.is_focused(window);
598        let show_clean = self.state.cleanable && !self.state.selection.is_empty();
599        let bounds = self.state.bounds;
600        let allow_open = !self.state.disabled;
601        let outline_visible = self.state.open || (is_focused && !self.state.disabled);
602        let disabled = self.state.disabled;
603
604        let (bg, fg) = input_style(disabled, cx);
605
606        self.state.list.update(cx, |list, cx| {
607            list.set_searchable(searchable, cx);
608            list.delegate_mut().size = self.state.size;
609            list.delegate_mut().check_icon = self.check_icon.clone();
610        });
611
612        let selection = &self.state.selection;
613        let placeholder = self.state.placeholder.as_ref();
614        let open = self.state.open;
615        let size = self.state.size;
616        let has_custom_trigger = self.render_trigger.is_some();
617
618        let trigger_body = if let Some(render_trigger) = &self.render_trigger {
619            let trigger = ComboboxTriggerContext {
620                selection,
621                placeholder,
622                open,
623                disabled,
624                size,
625            };
626
627            render_trigger(&trigger, window, cx)
628        } else {
629            self.default_trigger_body(window, cx)
630        };
631
632        let trailing: AnyElement = if has_custom_trigger {
633            div().into_any_element()
634        } else if show_clean {
635            clear_button(cx)
636                .map(|this| {
637                    if disabled {
638                        this.disabled(true)
639                    } else {
640                        this.on_click(cx.listener(Self::clean))
641                    }
642                })
643                .into_any_element()
644        } else if let Some(icon) = self.trigger_icon.clone() {
645            icon.xsmall()
646                .text_color(cx.theme().muted_foreground)
647                .into_any_element()
648        } else {
649            Caret::new(size)
650                .text_color(cx.theme().muted_foreground)
651                .into_any_element()
652        };
653
654        let toggle_handler: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>> =
655            if allow_open {
656                Some(Box::new(cx.listener(Self::toggle_menu)))
657            } else {
658                None
659            };
660
661        let footer_el = self.footer.as_ref().map(|f| f(window, cx));
662
663        let dismiss_handler: Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static> =
664            Box::new(cx.listener(Self::dismiss));
665
666        div().size_full().relative().child(
667            div()
668                .relative()
669                .on_prepaint({
670                    let state = cx.entity();
671                    move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)
672                })
673                .child(render_trigger_container(
674                    disabled,
675                    self.state.appearance,
676                    self.focus_ring_enabled,
677                    self.state.size,
678                    &self.state.style,
679                    bg,
680                    fg,
681                    outline_visible,
682                    allow_open,
683                    trigger_body,
684                    trailing,
685                    toggle_handler,
686                    window,
687                    cx,
688                ))
689                .when(self.state.open, |this| {
690                    this.child(
691                        deferred(render_popup_shell(
692                            ("combobox-popup", cx.entity_id()),
693                            &self.state.list,
694                            self.state.menu_width,
695                            self.state.search_placeholder.clone(),
696                            self.state.size,
697                            self.state.menu_max_h,
698                            bounds,
699                            footer_el,
700                            dismiss_handler,
701                            cx,
702                        ))
703                        .with_priority(gpui_base::POPUP_PRIORITY),
704                    )
705                }),
706        )
707    }
708}
709
710impl<D> EventEmitter<ComboboxEvent<D>> for ComboboxState<D>
711where
712    D: SearchableListDelegate + 'static,
713    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
714{
715}
716impl<D> EventEmitter<DismissEvent> for ComboboxState<D>
717where
718    D: SearchableListDelegate + 'static,
719    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
720{
721}
722
723impl<D> Focusable for ComboboxState<D>
724where
725    D: SearchableListDelegate + 'static,
726    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
727{
728    fn focus_handle(&self, cx: &App) -> FocusHandle {
729        if self.state.open {
730            self.state.list.focus_handle(cx)
731        } else {
732            self.state.focus_handle.clone()
733        }
734    }
735}
736
737// MARK: Combobox element
738
739/// A combo box with support for single and multi-select.
740///
741/// Clicking an item toggles it in the selection; the dropdown stays open until the user
742/// presses Escape or clicks outside.
743#[derive(IntoElement)]
744pub struct Combobox<D: SearchableListDelegate + 'static>
745where
746    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
747{
748    id: ElementId,
749    state: Entity<ComboboxState<D>>,
750    options: ComboboxOptions,
751    render_trigger: Option<
752        Box<dyn Fn(&ComboboxTriggerContext<D>, &mut Window, &mut App) -> AnyElement + 'static>,
753    >,
754    footer: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
755    empty: Option<Box<dyn Fn(&mut Window, &App) -> AnyElement + 'static>>,
756}
757
758impl<D> Combobox<D>
759where
760    D: SearchableListDelegate + 'static,
761    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
762{
763    pub fn new(state: &Entity<ComboboxState<D>>) -> Self {
764        Self {
765            id: ("multi-combo-box", state.entity_id()).into(),
766            state: state.clone(),
767            options: ComboboxOptions::default(),
768            render_trigger: None,
769            footer: None,
770            empty: None,
771        }
772    }
773
774    /// Set the width of the dropdown menu.
775    pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
776        self.options.menu_width = width.into();
777        self
778    }
779
780    /// Set the maximum height of the dropdown menu.
781    pub fn menu_max_h(mut self, max_h: impl Into<Length>) -> Self {
782        self.options.menu_max_h = max_h.into();
783        self
784    }
785
786    /// Set the placeholder text shown when no items are selected.
787    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
788        self.options.placeholder = Some(placeholder.into());
789        self
790    }
791
792    /// Override the trigger chevron icon.
793    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
794        self.options.trigger_icon = Some(icon.into());
795        self
796    }
797
798    /// Override the trailing check icon shown next to selected items.
799    pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
800        self.options.check_icon = Some(icon.into());
801        self
802    }
803
804    /// Set the placeholder text for the search input.
805    pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
806        self.options.search_placeholder = Some(placeholder.into());
807        self
808    }
809
810    /// Show a clear button when at least one item is selected.
811    pub fn cleanable(mut self, cleanable: bool) -> Self {
812        self.options.cleanable = cleanable;
813        self
814    }
815
816    /// Set the disabled state.
817    pub fn disabled(mut self, disabled: bool) -> Self {
818        self.options.disabled = disabled;
819        self
820    }
821
822    /// Set a custom closure that renders the empty-state element.
823    pub fn empty<E: IntoElement + 'static>(
824        mut self,
825        builder: impl Fn(&mut Window, &App) -> E + 'static,
826    ) -> Self {
827        self.empty = Some(Box::new(move |window, cx| {
828            builder(window, cx).into_any_element()
829        }));
830        self
831    }
832
833    /// Control whether the trigger shows a border and background.
834    pub fn appearance(mut self, appearance: bool) -> Self {
835        self.options.appearance = appearance;
836        self
837    }
838
839    /// Override the entire trigger element.
840    pub fn render_trigger<E: IntoElement + 'static>(
841        mut self,
842        f: impl Fn(&ComboboxTriggerContext<D>, &mut Window, &mut App) -> E + 'static,
843    ) -> Self {
844        self.render_trigger = Some(Box::new(move |trigger, window, cx| {
845            f(trigger, window, cx).into_any_element()
846        }));
847        self
848    }
849
850    /// Render an element below a separator at the bottom of the dropdown.
851    pub fn footer<E: IntoElement + 'static>(
852        mut self,
853        f: impl Fn(&mut Window, &mut App) -> E + 'static,
854    ) -> Self {
855        self.footer = Some(Box::new(move |window, cx| f(window, cx).into_any_element()));
856        self
857    }
858}
859
860impl<D> Sizable for Combobox<D>
861where
862    D: SearchableListDelegate + 'static,
863    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
864{
865    fn with_size(mut self, size: impl Into<Size>) -> Self {
866        self.options.size = size.into();
867        self
868    }
869}
870
871impl<D> crate::FocusableExt for Combobox<D>
872where
873    D: SearchableListDelegate + 'static,
874    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
875{
876    fn focus_ring(mut self, enabled: bool) -> Self {
877        self.options.focus_ring_enabled = enabled;
878        self
879    }
880
881    fn is_focus_ring_enabled(&self) -> bool {
882        self.options.focus_ring_enabled
883    }
884}
885
886impl<D> Styled for Combobox<D>
887where
888    D: SearchableListDelegate + 'static,
889    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
890{
891    fn style(&mut self) -> &mut StyleRefinement {
892        &mut self.options.style
893    }
894}
895
896impl<D> RenderOnce for Combobox<D>
897where
898    D: SearchableListDelegate + 'static,
899    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
900{
901    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
902        let disabled = self.options.disabled;
903        let focus_handle = self.state.read(cx).state.focus_handle.clone();
904        let render_trigger = self.render_trigger;
905        let footer = self.footer;
906        let empty = self.empty;
907        let opts = self.options;
908
909        self.state.update(cx, |this, _| {
910            this.state.style = opts.style;
911            this.state.size = opts.size;
912            this.state.cleanable = opts.cleanable;
913            this.state.placeholder = opts.placeholder;
914            this.state.search_placeholder = opts.search_placeholder;
915            this.state.menu_width = opts.menu_width;
916            this.state.menu_max_h = opts.menu_max_h;
917            this.state.disabled = opts.disabled;
918            this.state.appearance = opts.appearance;
919            this.focus_ring_enabled = opts.focus_ring_enabled;
920            this.trigger_icon = opts.trigger_icon;
921            this.check_icon = opts.check_icon;
922            this.render_trigger = render_trigger;
923            this.footer = footer;
924
925            if let Some(empty) = empty {
926                this.state.empty = Some(empty);
927            }
928        });
929
930        let is_open = self.state.read(cx).state.open;
931        let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
932        let open_state = self.state.clone();
933        let confirm_state = self.state.clone();
934
935        BaseCombobox::new(self.id)
936            .open(is_open)
937            .disabled(disabled)
938            .focus_handle(&focus_handle)
939            .content_focus_handle(&content_focus_handle)
940            .on_open_change(move |open, _, cx| {
941                open_state.update(cx, |state, cx| state.set_open(open, cx));
942            })
943            // This combobox commits its pending selection when the popup
944            // closes, so it listens for dismissal rather than Confirm.
945            .on_dismiss(move |_, cx| {
946                confirm_state.update(cx, |state, cx| {
947                    cx.emit(ComboboxEvent::Confirm(state.selected_values()));
948                });
949            })
950            .size_full()
951            .child(self.state)
952    }
953}
954
955// MARK: Rendering helpers
956
957/// Renders the styled trigger container.
958#[allow(clippy::too_many_arguments)]
959fn render_trigger_container(
960    disabled: bool,
961    appearance: bool,
962    focus_ring_enabled: bool,
963    size: Size,
964    style: &StyleRefinement,
965    bg: Hsla,
966    fg: Hsla,
967    outline_visible: bool,
968    allow_open: bool,
969    trigger_body: AnyElement,
970    trailing: AnyElement,
971    toggle_handler: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
972    window: &Window,
973    cx: &mut App,
974) -> impl IntoElement {
975    div()
976        .id("input")
977        .relative()
978        .flex()
979        .items_center()
980        .justify_between()
981        .border_1()
982        .border_color(cx.theme().transparent)
983        .when(appearance, |this| {
984            this.bg(bg)
985                .text_color(fg)
986                .when(disabled, |this| this.opacity(0.5))
987                .border_color(cx.theme().input)
988                .rounded(cx.theme().radius)
989        })
990        .input_size(size)
991        .input_text_size(size)
992        .refine_style(style)
993        .when(outline_visible && appearance, |this| {
994            this.border_1().border_color(cx.theme().ring)
995        })
996        .when(
997            outline_visible && appearance && focus_ring_enabled,
998            |this| this.focus_ring_style(window, cx),
999        )
1000        .when(allow_open, |this| {
1001            this.when_some(toggle_handler, |this, handler| this.on_click(handler))
1002        })
1003        .child(
1004            h_flex()
1005                .id("inner")
1006                .w_full()
1007                .min_w_0()
1008                .overflow_hidden()
1009                .whitespace_nowrap()
1010                .items_center()
1011                .justify_between()
1012                .gap_1()
1013                .child(
1014                    div()
1015                        .flex_1()
1016                        .min_w_0()
1017                        .overflow_hidden()
1018                        .whitespace_nowrap()
1019                        .child(trigger_body),
1020                )
1021                .child(trailing),
1022        )
1023}
1024
1025/// Renders the deferred anchored popup shell containing the searchable list and optional footer.
1026#[allow(clippy::too_many_arguments)]
1027fn render_popup_shell<D: SearchableListDelegate + 'static>(
1028    id: impl Into<ElementId>,
1029    list: &Entity<ListState<SearchableListAdapter<D>>>,
1030    menu_width: Length,
1031    search_placeholder: Option<SharedString>,
1032    size: Size,
1033    menu_max_h: Length,
1034    bounds: Bounds<Pixels>,
1035    footer_el: Option<AnyElement>,
1036    dismiss_handler: Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>,
1037    cx: &mut App,
1038) -> AnyElement {
1039    let has_footer = footer_el.is_some();
1040
1041    crate::popover::dropdown_popup(
1042        id,
1043        bounds,
1044        v_flex()
1045            .occlude()
1046            .map(|this| match menu_width {
1047                Length::Auto => this.w(bounds.size.width + px(2.)),
1048                Length::Definite(w) => this.w(w),
1049            })
1050            .popover_style(cx)
1051            .child(
1052                List::new(list)
1053                    .when_some(search_placeholder, |this, placeholder| {
1054                        this.search_placeholder(placeholder)
1055                    })
1056                    .with_size(size)
1057                    .max_h(menu_max_h)
1058                    .paddings(Edges::all(px(4.))),
1059            )
1060            .when(has_footer, |this| {
1061                this.child(
1062                    div()
1063                        .border_t_1()
1064                        .border_color(cx.theme().border)
1065                        .p_1()
1066                        .when_some(footer_el, |this, el| this.child(el)),
1067                )
1068            })
1069            .on_mouse_down_out(dismiss_handler),
1070        cx,
1071    )
1072    .into_any_element()
1073}
1074
1075// MARK: Tests
1076
1077#[cfg(test)]
1078mod tests {
1079    use std::{cell::Cell, rc::Rc};
1080
1081    use gpui::{
1082        AppContext as _, Bounds, Context, Entity, Modifiers, MouseButton, MouseDownEvent, Pixels,
1083        Point, Subscription, TestAppContext, point, px, size,
1084    };
1085
1086    use crate::{
1087        IndexPath,
1088        combobox::{Combobox, ComboboxEvent, ComboboxState},
1089        searchable_list::{
1090            SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState,
1091            SearchableVec,
1092        },
1093    };
1094
1095    struct TestComboboxEventCollector {
1096        event_count: Rc<Cell<usize>>,
1097        _subscription: Subscription,
1098    }
1099
1100    impl TestComboboxEventCollector {
1101        fn new(
1102            state: &Entity<ComboboxState<SearchableVec<&'static str>>>,
1103            cx: &mut Context<Self>,
1104        ) -> Self {
1105            let event_count = Rc::new(Cell::new(0));
1106            let event_count_for_subscription = event_count.clone();
1107            let _subscription = cx.subscribe(
1108                state,
1109                move |_, _, _: &ComboboxEvent<SearchableVec<&'static str>>, _| {
1110                    event_count_for_subscription.set(event_count_for_subscription.get() + 1);
1111                },
1112            );
1113
1114            Self {
1115                event_count,
1116                _subscription,
1117            }
1118        }
1119    }
1120
1121    #[gpui::test]
1122    fn test_combo_box_builder(cx: &mut TestAppContext) {
1123        cx.update(crate::init);
1124        let cx = cx.add_empty_window();
1125        cx.update(|window, cx| {
1126            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1127            let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true));
1128
1129            let _cb = Combobox::new(&state)
1130                .placeholder("Select language")
1131                .search_placeholder("Search...")
1132                .menu_width(gpui::px(300.))
1133                .menu_max_h(gpui::rems(15.))
1134                .cleanable(true)
1135                .disabled(false)
1136                .appearance(true);
1137        });
1138    }
1139
1140    #[gpui::test]
1141    fn test_combo_box_search_filters_items(cx: &mut TestAppContext) {
1142        cx.update(crate::init);
1143        let cx = cx.add_empty_window();
1144        cx.update(|window, cx| {
1145            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1146            let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true));
1147
1148            let count_before = state
1149                .read(cx)
1150                .state
1151                .list
1152                .read(cx)
1153                .delegate()
1154                .delegate
1155                .items_count(0);
1156            assert_eq!(count_before, 3);
1157
1158            state.update(cx, |s, cx| {
1159                s.state.list.update(cx, |list, cx| {
1160                    let _ = list
1161                        .delegate_mut()
1162                        .delegate
1163                        .perform_search("Rust", window, cx);
1164                });
1165            });
1166
1167            let count_after = state
1168                .read(cx)
1169                .state
1170                .list
1171                .read(cx)
1172                .delegate()
1173                .delegate
1174                .items_count(0);
1175            assert_eq!(count_after, 1);
1176        });
1177    }
1178
1179    #[gpui::test]
1180    fn test_combo_box_set_query_updates_text_and_filters_items(cx: &mut TestAppContext) {
1181        cx.update(crate::init);
1182        let cx = cx.add_empty_window();
1183        cx.update(|window, cx| {
1184            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1185            let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true));
1186
1187            state.update(cx, |state, cx| state.set_query(" Rust ", window, cx));
1188
1189            assert_eq!(state.read(cx).query(cx).as_ref(), " Rust ");
1190            assert_eq!(
1191                state
1192                    .read(cx)
1193                    .state
1194                    .list
1195                    .read(cx)
1196                    .delegate()
1197                    .delegate
1198                    .items_count(0),
1199                1,
1200            );
1201        });
1202    }
1203
1204    #[gpui::test]
1205    fn test_multi_combo_box_builder(cx: &mut TestAppContext) {
1206        cx.update(crate::init);
1207        let cx = cx.add_empty_window();
1208        cx.update(|window, cx| {
1209            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1210            let state = cx.new(|cx| {
1211                ComboboxState::new(items, vec![IndexPath::new(0)], window, cx)
1212                    .multiple(true)
1213                    .searchable(true)
1214            });
1215
1216            let _cb = Combobox::new(&state)
1217                .placeholder("Select frameworks")
1218                .search_placeholder("Search...")
1219                .menu_width(gpui::px(300.))
1220                .cleanable(true)
1221                .disabled(false);
1222
1223            assert_eq!(state.read(cx).selected_values(), vec!["React"]);
1224        });
1225    }
1226
1227    #[gpui::test]
1228    fn test_combo_box_set_selected_values_uses_current_delegate(cx: &mut TestAppContext) {
1229        cx.update(crate::init);
1230        let cx = cx.add_empty_window();
1231        cx.update(|window, cx| {
1232            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1233            let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1234
1235            state.update(cx, |state, cx| {
1236                state.set_selected_values(&["Vue", "Missing"], window, cx);
1237
1238                assert_eq!(state.selected_values(), vec!["Vue"]);
1239                assert_eq!(
1240                    state
1241                        .selection()
1242                        .iter()
1243                        .map(|(index, _)| *index)
1244                        .collect::<Vec<_>>(),
1245                    vec![IndexPath::new(1)],
1246                );
1247                assert_eq!(
1248                    state
1249                        .state
1250                        .list
1251                        .read(cx)
1252                        .delegate()
1253                        .selection_snapshot
1254                        .as_slice(),
1255                    state.selection(),
1256                );
1257
1258                state.set_items(SearchableVec::new(vec!["Vue", "Rust", "Go"]), window, cx);
1259                state.set_selected_values(&["Go", "Vue"], window, cx);
1260
1261                assert_eq!(state.selected_values(), vec!["Go", "Vue"]);
1262                assert_eq!(
1263                    state
1264                        .selection()
1265                        .iter()
1266                        .map(|(index, _)| *index)
1267                        .collect::<Vec<_>>(),
1268                    vec![IndexPath::new(2), IndexPath::new(0)],
1269                );
1270                assert_eq!(
1271                    state
1272                        .state
1273                        .list
1274                        .read(cx)
1275                        .delegate()
1276                        .selection_snapshot
1277                        .as_slice(),
1278                    state.selection(),
1279                );
1280
1281                state.set_selected_values(&[], window, cx);
1282
1283                assert!(state.selection().is_empty());
1284                assert!(
1285                    state
1286                        .state
1287                        .list
1288                        .read(cx)
1289                        .delegate()
1290                        .selection_snapshot
1291                        .is_empty()
1292                );
1293            });
1294        });
1295    }
1296
1297    /// A value projected in from outside is not a position in the filtered view,
1298    /// so an active search must not decide which values can be selected.
1299    #[gpui::test]
1300    fn test_combo_box_set_selected_values_keeps_values_hidden_by_the_search(
1301        cx: &mut TestAppContext,
1302    ) {
1303        cx.update(crate::init);
1304        let cx = cx.add_empty_window();
1305        cx.update(|window, cx| {
1306            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1307            let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1308
1309            state.update(cx, |state, cx| {
1310                state.set_selected_values(&["React", "Vue"], window, cx);
1311                state.state.list.update(cx, |list, cx| {
1312                    list.set_query("Angular", window, cx);
1313                });
1314
1315                state.set_selected_values(&["React", "Vue", "Angular"], window, cx);
1316
1317                assert_eq!(
1318                    state.selected_values(),
1319                    vec!["React", "Vue", "Angular"],
1320                    "a value the search had hidden must survive being reapplied"
1321                );
1322                assert_eq!(
1323                    state
1324                        .selection()
1325                        .iter()
1326                        .map(|(index, _)| *index)
1327                        .collect::<Vec<_>>(),
1328                    vec![IndexPath::new(0), IndexPath::new(1), IndexPath::new(2)],
1329                    "clearing the query puts every selected item back in view"
1330                );
1331            });
1332        });
1333    }
1334
1335    #[gpui::test]
1336    fn test_combo_box_set_selected_values_does_not_emit_events(cx: &mut TestAppContext) {
1337        cx.update(crate::init);
1338        let cx = cx.add_empty_window();
1339        let state = cx.update(|window, cx| {
1340            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1341            cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true))
1342        });
1343        let collector = cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx)));
1344
1345        cx.update(|window, cx| {
1346            state.update(cx, |state, cx| {
1347                state.set_selected_values(&["React", "Vue"], window, cx);
1348            });
1349        });
1350
1351        cx.update(|_, cx| {
1352            assert_eq!(collector.read(cx).event_count.get(), 0);
1353        });
1354    }
1355
1356    #[gpui::test]
1357    fn test_combo_box_initial_selection_seeds_cursor(cx: &mut TestAppContext) {
1358        cx.update(crate::init);
1359        let cx = cx.add_empty_window();
1360        cx.update(|window, cx| {
1361            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1362            let state = cx.new(|cx| {
1363                ComboboxState::new(items, vec![IndexPath::new(1)], window, cx).multiple(true)
1364            });
1365
1366            let state_ref = state.read(cx);
1367            assert_eq!(
1368                state_ref.state.list.read(cx).selected_index(),
1369                Some(IndexPath::new(1)),
1370                "initial selected_indices should seed ListState.selected_index, not just the snapshot",
1371            );
1372            assert_eq!(state_ref.selected_values(), vec!["Vue"]);
1373        });
1374    }
1375
1376    #[gpui::test]
1377    fn test_multi_combo_box_toggle(cx: &mut TestAppContext) {
1378        cx.update(crate::init);
1379        let cx = cx.add_empty_window();
1380        cx.update(|window, cx| {
1381            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1382            let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1383
1384            state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1385            assert_eq!(state.read(cx).selected_values(), &["React"]);
1386
1387            state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx));
1388            assert_eq!(state.read(cx).selected_values(), &["React", "Vue"]);
1389
1390            state.update(cx, |s, cx| s.remove_selected_index(IndexPath::new(0), cx));
1391            assert_eq!(state.read(cx).selected_values(), &["Vue"]);
1392        });
1393    }
1394
1395    #[gpui::test]
1396    fn test_multi_combo_box_search_selection_uses_value_identity(cx: &mut TestAppContext) {
1397        cx.update(crate::init);
1398        let cx = cx.add_empty_window();
1399        cx.update(|window, cx| {
1400            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1401            let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1402
1403            state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1404            assert_eq!(state.read(cx).selected_values(), &["React"]);
1405
1406            state.update(cx, |s, cx| {
1407                s.state.list.update(cx, |list, cx| {
1408                    let _ = list
1409                        .delegate_mut()
1410                        .delegate
1411                        .perform_search("Vue", window, cx);
1412                });
1413            });
1414
1415            state.read_with(cx, |s, cx| {
1416                let selection = s.state.selection.clone();
1417                let list = s.state.list.read(cx);
1418                let delegate = &list.delegate().delegate;
1419                let ix = IndexPath::new(0);
1420                let item = delegate.item(ix).expect("filtered item exists");
1421
1422                assert_eq!(item.value(), &"Vue");
1423                assert!(
1424                    !delegate.is_item_checked(ix, item, &selection, cx),
1425                    "filtered row 0 should not inherit React's checked state",
1426                );
1427            });
1428
1429            state.update(cx, |s, cx| {
1430                s.handle_item_select(IndexPath::new(0), window, cx);
1431            });
1432            assert_eq!(state.read(cx).selected_values(), &["React", "Vue"]);
1433        });
1434    }
1435
1436    #[gpui::test]
1437    fn test_multi_combo_box_search_deselects_by_value(cx: &mut TestAppContext) {
1438        cx.update(crate::init);
1439        let cx = cx.add_empty_window();
1440        cx.update(|window, cx| {
1441            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1442            let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1443
1444            state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1445
1446            state.update(cx, |s, cx| {
1447                s.state.list.update(cx, |list, cx| {
1448                    let _ = list
1449                        .delegate_mut()
1450                        .delegate
1451                        .perform_search("React", window, cx);
1452                });
1453            });
1454
1455            state.update(cx, |s, cx| {
1456                s.handle_item_select(IndexPath::new(0), window, cx);
1457            });
1458            assert!(state.read(cx).selected_values().is_empty());
1459        });
1460    }
1461
1462    #[gpui::test]
1463    fn test_searchable_list_default_change_uses_value_identity(cx: &mut TestAppContext) {
1464        cx.update(crate::init);
1465        let cx = cx.add_empty_window();
1466        cx.update(|window, cx| {
1467            let mut delegate = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1468            let mut selection = vec![(IndexPath::new(1), "Vue")];
1469
1470            let _ = delegate.perform_search("Vue", window, cx);
1471            delegate.on_will_change(
1472                &mut selection,
1473                &[SearchableListChange::Deselect {
1474                    index: IndexPath::new(0),
1475                }],
1476            );
1477            assert!(selection.is_empty());
1478
1479            delegate.on_will_change(
1480                &mut selection,
1481                &[SearchableListChange::Select {
1482                    index: IndexPath::new(0),
1483                }],
1484            );
1485            assert_eq!(selection, vec![(IndexPath::new(0), "Vue")]);
1486        });
1487    }
1488
1489    #[gpui::test]
1490    fn test_multi_combo_box_clear(cx: &mut TestAppContext) {
1491        cx.update(crate::init);
1492        let cx = cx.add_empty_window();
1493        cx.update(|window, cx| {
1494            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1495            let state = cx.new(|cx| {
1496                ComboboxState::new(
1497                    items,
1498                    vec![IndexPath::new(0), IndexPath::new(1)],
1499                    window,
1500                    cx,
1501                )
1502                .multiple(true)
1503            });
1504
1505            assert_eq!(state.read(cx).selected_values().len(), 2);
1506            state.update(cx, |s, cx| s.clear_selection(cx));
1507            assert!(state.read(cx).selected_values().is_empty());
1508        });
1509    }
1510
1511    #[gpui::test]
1512    fn test_single_combo_box_mode(cx: &mut TestAppContext) {
1513        cx.update(crate::init);
1514        let cx = cx.add_empty_window();
1515        cx.update(|window, cx| {
1516            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1517            let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx));
1518
1519            // Default mode is Single.
1520            state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1521            assert_eq!(state.read(cx).selected_values(), &["Rust"]);
1522
1523            state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx));
1524            assert_eq!(state.read(cx).selected_values(), &["Rust", "Go"]);
1525        });
1526    }
1527
1528    // Delegate that vetoes all selections via on_will_change by ignoring the changes.
1529    struct VetoDelegate(SearchableVec<&'static str>);
1530
1531    impl SearchableListDelegate for VetoDelegate {
1532        type Item = &'static str;
1533
1534        fn items_count(&self, section: usize) -> usize {
1535            self.0.items_count(section)
1536        }
1537
1538        fn item(&self, ix: IndexPath) -> Option<&&'static str> {
1539            self.0.item(ix)
1540        }
1541
1542        fn position<V>(&self, value: &V) -> Option<IndexPath>
1543        where
1544            &'static str: SearchableListItem<Value = V>,
1545            V: PartialEq,
1546        {
1547            self.0.position(value)
1548        }
1549
1550        fn on_will_change(
1551            &mut self,
1552            _selection: &mut Vec<(IndexPath, &'static str)>,
1553            _changes: &[SearchableListChange],
1554        ) {
1555            // Leave selection unchanged — acts as a veto.
1556        }
1557    }
1558
1559    #[gpui::test]
1560    fn test_on_will_change_veto(cx: &mut TestAppContext) {
1561        cx.update(crate::init);
1562        let cx = cx.add_empty_window();
1563        cx.update(|window, cx| {
1564            let delegate = VetoDelegate(SearchableVec::new(vec!["Rust", "Go", "C++"]));
1565            let state = cx.new(|cx| ComboboxState::new(delegate, vec![], window, cx));
1566
1567            // Pre-select an item directly so we can verify veto prevents changes.
1568            state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1569            assert_eq!(state.read(cx).selected_values(), &["Rust"]);
1570
1571            // Simulate a click on index 1 via handle_item_select; on_will_change vetoes it.
1572            state.update(cx, |s, cx| {
1573                s.handle_item_select(IndexPath::new(1), window, cx);
1574            });
1575
1576            // Selection must remain unchanged because on_will_change left it unmodified.
1577            assert_eq!(state.read(cx).selected_values(), &["Rust"]);
1578        });
1579    }
1580
1581    fn left_press(position: Point<Pixels>) -> MouseDownEvent {
1582        MouseDownEvent {
1583            button: MouseButton::Left,
1584            position,
1585            modifiers: Modifiers::default(),
1586            click_count: 1,
1587            first_mouse: false,
1588        }
1589    }
1590
1591    #[gpui::test]
1592    fn test_combo_box_dismiss_ignores_press_on_trigger(cx: &mut TestAppContext) {
1593        cx.update(crate::init);
1594        let cx = cx.add_empty_window();
1595        let state = cx.update(|window, cx| {
1596            let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1597            cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true))
1598        });
1599        let collector = cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx)));
1600
1601        cx.update(|window, cx| {
1602            state.update(cx, |state, cx| {
1603                state.state.bounds = Bounds {
1604                    origin: point(px(10.), px(10.)),
1605                    size: size(px(200.), px(32.)),
1606                };
1607                state.set_open(true, cx);
1608                state.dismiss(&left_press(point(px(20.), px(20.))), window, cx);
1609            });
1610        });
1611
1612        cx.update(|_, cx| {
1613            assert!(
1614                state.read(cx).state.open,
1615                "a press on the trigger must reach nested controls, so the menu stays open \
1616                 and `toggle_menu` closes it on release instead",
1617            );
1618            assert_eq!(collector.read(cx).event_count.get(), 0);
1619        });
1620
1621        cx.update(|window, cx| {
1622            state.update(cx, |state, cx| {
1623                state.dismiss(&left_press(point(px(20.), px(200.))), window, cx);
1624            });
1625        });
1626
1627        cx.update(|_, cx| {
1628            assert!(!state.read(cx).state.open);
1629            assert_eq!(
1630                collector.read(cx).event_count.get(),
1631                1,
1632                "dismissing from outside the trigger emits Confirm",
1633            );
1634        });
1635    }
1636
1637    // Suppress unused import warning for SearchableListState in test module.
1638    #[allow(unused)]
1639    fn _uses_state<D: SearchableListDelegate + 'static>(_: &SearchableListState<D>)
1640    where
1641        <D::Item as SearchableListItem>::Value: PartialEq + Clone,
1642    {
1643    }
1644}