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