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