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