Skip to main content

gpui_component/
select.rs

1use gpui::{
2    AnyElement, App, ClickEvent, Context, DismissEvent, Edges, ElementId, Entity, EventEmitter,
3    FocusHandle, Focusable, Hsla, InteractiveElement, IntoElement, Length, ParentElement, Render,
4    RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window,
5    deferred, div, prelude::FluentBuilder, px, rems,
6};
7use gpui_base::TestSupportExt as _;
8use rust_i18n::t;
9
10use crate::ThemeStyled as _;
11use crate::{
12    ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Sizable, Size,
13    StyleSized, StyledExt,
14    actions::Cancel,
15    h_flex,
16    input::{clear_button, input_style},
17    list::List,
18    searchable_list::{
19        SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState,
20    },
21    sizing::DROPDOWN_LIST_PADDING,
22    v_flex,
23};
24use gpui_base::{GlobalState, Select as BaseSelect};
25
26// MARK: Public re-exports for back-compat
27
28/// Re-exported for backward compatibility. New code should prefer [`SearchableGroup`].
29pub use crate::searchable_list::SearchableGroup as SelectGroup;
30/// Re-exported for backward compatibility. New code should prefer [`SearchableListDelegate`].
31pub use crate::searchable_list::SearchableListDelegate as SelectDelegate;
32/// Re-exported for backward compatibility. New code should prefer [`SearchableListItem`].
33pub use crate::searchable_list::SearchableListItem as SelectItem;
34/// Re-exported for backward compatibility. New code should prefer [`SearchableListItemElement`].
35pub use crate::searchable_list::SearchableListItemElement as SelectListItem;
36/// Re-exported for backward compatibility.
37pub use crate::searchable_list::SearchableVec;
38
39#[derive(IntoElement)]
40pub struct Caret {
41    size: Size,
42    color: Option<Hsla>,
43}
44
45impl Caret {
46    /// Create a select caret sized for its trigger.
47    pub fn new(size: Size) -> Self {
48        Self { size, color: None }
49    }
50
51    /// Set the caret color.
52    pub fn text_color(mut self, color: Hsla) -> Self {
53        self.color = Some(color);
54        self
55    }
56}
57
58impl RenderOnce for Caret {
59    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
60        Icon::new(IconName::ChevronDown)
61            .with_size(match self.size {
62                Size::XSmall => Size::XSmall,
63                Size::Small => Size::Small,
64                _ => Size::Medium,
65            })
66            .when_some(self.color, |this, color| this.text_color(color))
67    }
68}
69
70/// Events emitted by [`SelectState`].
71pub enum SelectEvent<D: SearchableListDelegate + 'static>
72where
73    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
74{
75    Confirm(Option<<D::Item as SearchableListItem>::Value>),
76}
77
78// MARK: SelectOptions (builder only — applied to SearchableListState during render)
79
80struct SelectOptions {
81    style: StyleRefinement,
82    size: Size,
83    icon: Option<Icon>,
84    cleanable: bool,
85    placeholder: Option<SharedString>,
86    accessibility_label: Option<SharedString>,
87    title_prefix: Option<SharedString>,
88    search_placeholder: Option<SharedString>,
89    menu_width: Length,
90    menu_max_h: Length,
91    scroll_bounce: Option<bool>,
92    disabled: bool,
93    appearance: bool,
94    focus_ring_enabled: bool,
95}
96
97impl Default for SelectOptions {
98    fn default() -> Self {
99        Self {
100            style: StyleRefinement::default(),
101            size: Size::default(),
102            icon: None,
103            cleanable: false,
104            placeholder: None,
105            accessibility_label: None,
106            title_prefix: None,
107            menu_width: Length::Auto,
108            menu_max_h: rems(20.).into(),
109            scroll_bounce: None,
110            disabled: false,
111            appearance: true,
112            focus_ring_enabled: true,
113            search_placeholder: None,
114        }
115    }
116}
117
118// MARK: SelectState
119
120/// State of the [`Select`] component.
121///
122/// Emits [`DismissEvent`] when an open menu closes, including after a selection is confirmed.
123pub struct SelectState<D: SearchableListDelegate + 'static>
124where
125    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
126{
127    pub(crate) state: SearchableListState<D>,
128
129    // Select-specific fields
130    searchable: bool,
131    icon: Option<Icon>,
132    title_prefix: Option<SharedString>,
133    focus_ring_enabled: bool,
134}
135
136/// A Select element.
137#[derive(IntoElement)]
138pub struct Select<D: SearchableListDelegate + 'static>
139where
140    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
141{
142    id: ElementId,
143    state: Entity<SelectState<D>>,
144    options: SelectOptions,
145    empty: Option<Box<dyn Fn(&mut Window, &App) -> AnyElement + 'static>>,
146}
147
148impl<D> SelectState<D>
149where
150    D: SearchableListDelegate + 'static,
151    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
152{
153    /// Create a new Select state.
154    pub fn new(
155        delegate: D,
156        selected_index: Option<IndexPath>,
157        window: &mut Window,
158        cx: &mut Context<Self>,
159    ) -> Self {
160        let weak = cx.entity().downgrade();
161        let weak_confirm = weak.clone();
162        let weak_cancel = weak.clone();
163        let weak_empty = weak;
164
165        let selected_indices = selected_index.into_iter().collect::<Vec<_>>();
166
167        let state = SearchableListState::new(
168            delegate,
169            selected_indices,
170            // on_confirm — commit the selection
171            move |selected_index, _secondary, window, cx| {
172                cx.defer_in(window, {
173                    let weak_confirm = weak_confirm.clone();
174                    move |list_state, window, cx| {
175                        let mut selection = weak_confirm
176                            .upgrade()
177                            .map(|e| e.read(cx).state.selection.clone())
178                            .unwrap_or_default();
179
180                        let changes = {
181                            let mut changes: Vec<SearchableListChange> = selection
182                                .iter()
183                                .map(|(ix, _)| SearchableListChange::Deselect { index: *ix })
184                                .collect();
185
186                            if let Some(ix) = selected_index {
187                                changes.push(SearchableListChange::Select { index: ix });
188                            }
189
190                            changes
191                        };
192
193                        // on_will_change is called directly — entity-handle access would
194                        // re-enter the ListState lock that defer_in holds for this callback.
195                        list_state
196                            .delegate_mut()
197                            .delegate
198                            .on_will_change(&mut selection, &changes);
199
200                        let confirmed = weak_confirm.update(cx, |this, cx| {
201                            this.state.selection = selection;
202
203                            let final_value =
204                                this.state.selection.first().map(|(_, i)| i.value().clone());
205
206                            cx.emit(SelectEvent::Confirm(final_value.clone()));
207                            cx.notify();
208                            this.set_open(false, cx);
209                            this.focus(window, cx);
210
211                            (this.state.selection.clone(), final_value)
212                        });
213
214                        // Clear the query through list_state directly — an entity-handle
215                        // update here would re-enter the ListState lock held above.
216                        // The committed index pointed into the filtered view, so resolve
217                        // the confirmed value in the restored full list; otherwise the
218                        // cursor and the mark would disagree on the next open.
219                        if let Ok((mut new_selection, final_value)) = confirmed {
220                            if !list_state.query_input.read(cx).value().is_empty() {
221                                list_state.set_query("", window, cx);
222                            }
223
224                            if let Some(ix) = final_value
225                                .as_ref()
226                                .and_then(|value| list_state.delegate().delegate.position(value))
227                            {
228                                list_state.set_selected_index(Some(ix), window, cx);
229                                if let Some((slot, _)) = new_selection.first_mut() {
230                                    *slot = ix;
231                                }
232                                _ = weak_confirm.update(cx, |this, cx| {
233                                    if let Some((slot, _)) = this.state.selection.first_mut() {
234                                        *slot = ix;
235                                    }
236                                    cx.notify();
237                                });
238                            }
239
240                            list_state
241                                .delegate_mut()
242                                .update_selection_snapshot(new_selection.clone());
243                            list_state
244                                .delegate_mut()
245                                .delegate
246                                .on_confirm(&new_selection);
247                        }
248                    }
249                });
250            },
251            // on_cancel — clear the query, restore cursor to committed index, close
252            move |_final_selected_index, window, cx| {
253                cx.defer_in(window, {
254                    let weak_cancel = weak_cancel.clone();
255                    move |list_state, window, cx| {
256                        let committed_ix = weak_cancel
257                            .upgrade()
258                            .and_then(|e| e.read(cx).state.selection.first().map(|(ix, _)| *ix));
259
260                        if !list_state.query_input.read(cx).value().is_empty() {
261                            list_state.set_query("", window, cx);
262                        }
263                        list_state.set_selected_index(committed_ix, window, cx);
264
265                        _ = weak_cancel.update(cx, |this, cx| {
266                            this.set_open(false, cx);
267                            this.focus(window, cx);
268                        });
269                    }
270                });
271            },
272            // on_render_empty
273            move |window, cx| {
274                if let Some(empty) = weak_empty
275                    .upgrade()
276                    .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx)))
277                {
278                    empty
279                } else {
280                    h_flex()
281                        .justify_center()
282                        .py_6()
283                        .text_color(cx.theme().muted_foreground.opacity(0.6))
284                        .child(Icon::new(IconName::Inbox).size(px(28.)))
285                        .into_any_element()
286                }
287            },
288            Self::on_blur,
289            window,
290            cx,
291        );
292
293        Self {
294            state,
295            searchable: false,
296            icon: None,
297            title_prefix: None,
298            focus_ring_enabled: true,
299        }
300    }
301
302    /// Sets whether the dropdown menu is searchable, default is `false`.
303    ///
304    /// When `true`, a search input appears at the top of the dropdown menu.
305    pub fn searchable(mut self, searchable: bool) -> Self {
306        self.searchable = searchable;
307        self
308    }
309
310    /// Set the selected index for the select.
311    pub fn set_selected_index(
312        &mut self,
313        selected_index: Option<IndexPath>,
314        window: &mut Window,
315        cx: &mut Context<Self>,
316    ) {
317        self.state.list.update(cx, |list, cx| {
318            list._set_selected_index(selected_index, window, cx);
319        });
320
321        let item = selected_index
322            .and_then(|ix| self.state.list.read(cx).delegate().delegate.item(ix))
323            .map(|i| i.clone());
324
325        self.state.selection = match (selected_index, item) {
326            (Some(ix), Some(item)) => vec![(ix, item)],
327            _ => vec![],
328        };
329        self.state.sync_snapshot(cx);
330    }
331
332    /// Set selected value for the select.
333    ///
334    /// Looks up the position from the delegate and sets the selected index accordingly.
335    /// Passes `None` when the value is not found.
336    ///
337    /// The delegate looks the value up in its matched items, so an active search query is
338    /// cleared first to get an index into the full item list.
339    pub fn set_selected_value(
340        &mut self,
341        selected_value: &<D::Item as SearchableListItem>::Value,
342        window: &mut Window,
343        cx: &mut Context<Self>,
344    ) {
345        self.state.clear_query(window, cx);
346
347        let selected_index = self
348            .state
349            .list
350            .read(cx)
351            .delegate()
352            .delegate
353            .position(selected_value);
354
355        self.set_selected_index(selected_index, window, cx);
356    }
357
358    /// Replace the delegate (item data) for the select state.
359    pub fn set_items(&mut self, items: D, window: &mut Window, cx: &mut Context<Self>)
360    where
361        D: SearchableListDelegate + 'static,
362    {
363        let mut selection = self.state.selection.clone();
364        self.state.list.update(cx, |list, cx| {
365            let cursor = list
366                .selected_index()
367                .and_then(|ix| list.delegate().delegate.item(ix))
368                .and_then(|item| items.position(item.value()));
369            // A value the new items no longer have is dropped, as
370            // `set_selected_value` does, rather than kept at an index that
371            // now points at another item.
372            selection.retain_mut(|(ix, item)| {
373                let Some(position) = items.position(item.value()) else {
374                    return false;
375                };
376                let Some(replacement) = items.item(position) else {
377                    return false;
378                };
379                *ix = position;
380                *item = replacement.clone();
381                true
382            });
383            list.delegate_mut().delegate = items;
384            list.set_selected_index(cursor, window, cx);
385            list.delegate_mut()
386                .update_selection_snapshot(selection.clone());
387            cx.notify();
388        });
389        self.state.selection = selection;
390        cx.notify();
391    }
392
393    /// Get the current selected index.
394    pub fn selected_index(&self, cx: &App) -> Option<IndexPath> {
395        self.state.list.read(cx).selected_index()
396    }
397
398    /// Get the current selected value.
399    pub fn selected_value(&self) -> Option<&<D::Item as SearchableListItem>::Value> {
400        self.state.selection.first().map(|(_, i)| i.value())
401    }
402
403    /// Focus the select trigger input.
404    pub fn focus(&self, window: &mut Window, cx: &mut App) {
405        self.state.focus_handle.focus(window, cx);
406    }
407
408    fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
409        if self.state.list.read(cx).is_focused(window, cx)
410            || self.state.focus_handle.is_focused(window)
411        {
412            return;
413        }
414
415        self.clear_query_and_restore_cursor(window, cx);
416        self.set_open(false, cx);
417        cx.notify();
418    }
419
420    fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
421        cx.stop_propagation();
422
423        self.set_open(!self.state.open, cx);
424
425        if self.state.open {
426            self.state.list.focus_handle(cx).focus(window, cx);
427        } else {
428            self.clear_query_and_restore_cursor(window, cx);
429        }
430
431        cx.notify();
432    }
433
434    fn escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
435        if !self.state.open {
436            cx.propagate();
437            return;
438        }
439
440        cx.stop_propagation();
441        self.clear_query_and_restore_cursor(window, cx);
442        self.set_open(false, cx);
443        self.focus(window, cx);
444        cx.notify();
445    }
446
447    /// Drop the search query and move the cursor back to the committed
448    /// selection, so the next open shows every item. Call on every menu
449    /// close that does not go through the confirm/cancel callbacks.
450    fn clear_query_and_restore_cursor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
451        self.state.clear_query(window, cx);
452
453        let committed_ix = self.state.selection.first().map(|(ix, _)| *ix);
454        self.state.list.update(cx, |list, cx| {
455            if list.selected_index() != committed_ix {
456                list.set_selected_index(committed_ix, window, cx);
457            }
458        });
459    }
460
461    fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
462        let dismissed = self.state.open && !open;
463        self.state.open = open;
464        self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
465
466        if dismissed {
467            cx.emit(DismissEvent);
468        }
469        cx.notify();
470    }
471
472    fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
473        cx.stop_propagation();
474        self.set_selected_index(None, window, cx);
475        cx.emit(SelectEvent::Confirm(None));
476    }
477
478    fn display_title(&mut self, _: &Window, cx: &mut Context<Self>) -> impl IntoElement {
479        let default_title = div().text_color(cx.theme().muted_foreground).child(
480            self.state
481                .placeholder
482                .clone()
483                .unwrap_or_else(|| t!("Select.placeholder").into()),
484        );
485
486        let Some(selected_index) = self.selected_index(cx) else {
487            return default_title;
488        };
489
490        let Some(title) = self
491            .state
492            .list
493            .read(cx)
494            .delegate()
495            .delegate
496            .item(selected_index)
497            .map(|item| {
498                if let Some(el) = item.display_title() {
499                    el
500                } else if let Some(prefix) = self.title_prefix.as_ref() {
501                    format!("{}{}", prefix, item.title()).into_any_element()
502                } else {
503                    item.title().into_any_element()
504                }
505            })
506        else {
507            return default_title;
508        };
509
510        div()
511            .when(self.state.disabled, |this| {
512                this.text_color(cx.theme().muted_foreground)
513            })
514            .child(title)
515    }
516
517    fn accessibility_value(&self) -> SharedString {
518        let Some((_, item)) = self.state.selection.first() else {
519            return self
520                .state
521                .placeholder
522                .clone()
523                .unwrap_or_else(|| t!("Select.placeholder").into());
524        };
525
526        if let Some(prefix) = self.title_prefix.as_ref() {
527            format!("{}{}", prefix, item.title()).into()
528        } else {
529            item.title()
530        }
531    }
532}
533
534impl<D> Render for SelectState<D>
535where
536    D: SearchableListDelegate + 'static,
537    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
538{
539    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
540        let searchable = self.searchable;
541        let is_focused = self.state.focus_handle.is_focused(window);
542        let show_clean = self.state.cleanable && self.selected_index(cx).is_some();
543        let bounds = self.state.bounds;
544        let allow_open = !(self.state.open || self.state.disabled);
545        let outline_visible = self.state.open || (is_focused && !self.state.disabled);
546
547        let (bg, fg) = input_style(self.state.disabled, cx);
548
549        self.state.list.update(cx, |list, cx| {
550            list.set_searchable(searchable, cx);
551            list.delegate_mut().size = self.state.size;
552        });
553
554        div().size_full().relative().child(
555            div()
556                .relative()
557                .on_prepaint({
558                    let state = cx.entity();
559                    move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)
560                })
561                .child(
562                    div()
563                        .id("input")
564                        .test_support()
565                        .relative()
566                        .flex()
567                        .items_center()
568                        .justify_between()
569                        .border_1()
570                        .border_color(cx.theme().transparent)
571                        .when(self.state.appearance, |this| {
572                            this.bg(bg)
573                                .text_color(fg)
574                                .when(self.state.disabled, |this| this.opacity(0.5))
575                                .border_color(cx.theme().input)
576                                .rounded(cx.theme().radius)
577                        })
578                        .input_size(self.state.size)
579                        .input_text_size(self.state.size)
580                        .refine_style(&self.state.style)
581                        .when(outline_visible && self.state.appearance, |this| {
582                            this.border_1().border_color(cx.theme().ring)
583                        })
584                        .when(
585                            outline_visible && self.state.appearance && self.focus_ring_enabled,
586                            |this| this.focus_ring_style(window, cx),
587                        )
588                        .when(allow_open, |this| {
589                            this.on_click(cx.listener(Self::toggle_menu))
590                        })
591                        .child(
592                            h_flex()
593                                .id("inner")
594                                .w_full()
595                                .min_w_0()
596                                .overflow_hidden()
597                                .whitespace_nowrap()
598                                .items_center()
599                                .justify_between()
600                                .gap_1()
601                                .child(
602                                    div()
603                                        .id("title")
604                                        .flex_1()
605                                        .min_w_0()
606                                        .overflow_hidden()
607                                        .whitespace_nowrap()
608                                        .truncate()
609                                        .child(self.display_title(window, cx)),
610                                )
611                                .when(show_clean, |this| {
612                                    this.child(clear_button(cx).map(|this| {
613                                        if self.state.disabled {
614                                            this.disabled(true)
615                                        } else {
616                                            this.on_click(cx.listener(Self::clean))
617                                        }
618                                    }))
619                                })
620                                .when(!show_clean, |this| {
621                                    let icon = match self.icon.clone() {
622                                        Some(icon) => icon
623                                            .xsmall()
624                                            .text_color(cx.theme().muted_foreground)
625                                            .into_any_element(),
626                                        None => Caret::new(self.state.size)
627                                            .text_color(cx.theme().muted_foreground)
628                                            .into_any_element(),
629                                    };
630
631                                    this.child(icon)
632                                }),
633                        ),
634                )
635                .when(self.state.open, |this| {
636                    this.child(
637                        deferred(crate::popover::dropdown_popup(
638                            ("select-popup", cx.entity_id()),
639                            bounds,
640                            v_flex()
641                                .occlude()
642                                .map(|this| match self.state.menu_width {
643                                    Length::Auto => this.w(bounds.size.width),
644                                    Length::Definite(w) => this.w(w),
645                                })
646                                .popover_style(cx)
647                                .child(
648                                    List::new(&self.state.list)
649                                        .when_some(
650                                            self.state.search_placeholder.clone(),
651                                            |this, placeholder| {
652                                                this.search_placeholder(placeholder)
653                                            },
654                                        )
655                                        .with_size(self.state.size)
656                                        .max_h(self.state.menu_max_h)
657                                        .when_some(self.state.scroll_bounce, |list, on| {
658                                            list.scroll_bounce(on)
659                                        })
660                                        .paddings(Edges::all(DROPDOWN_LIST_PADDING)),
661                                )
662                                .on_mouse_down_out(cx.listener(|this, _, window, cx| {
663                                    this.escape(&Cancel, window, cx);
664                                })),
665                            cx,
666                        ))
667                        .with_priority(gpui_base::POPUP_PRIORITY),
668                    )
669                }),
670        )
671    }
672}
673
674impl<D> Select<D>
675where
676    D: SearchableListDelegate + 'static,
677    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
678{
679    pub fn new(state: &Entity<SelectState<D>>) -> Self {
680        Self {
681            id: ("select", state.entity_id()).into(),
682            state: state.clone(),
683            options: SelectOptions::default(),
684            empty: None,
685        }
686    }
687
688    /// Sets an explicit identity for the select root.
689    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
690        self.id = id.into();
691        self
692    }
693
694    /// Set the width of the dropdown menu, default: `Length::Auto`.
695    pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
696        self.options.menu_width = width.into();
697        self
698    }
699
700    /// Set the max height of the dropdown menu, default: 20rem.
701    pub fn menu_max_h(mut self, max_h: impl Into<Length>) -> Self {
702        self.options.menu_max_h = max_h.into();
703        self
704    }
705
706    /// Set the placeholder shown when no value is selected.
707    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
708        self.options.placeholder = Some(placeholder.into());
709        self
710    }
711
712    /// Set the name a screen reader announces for the select.
713    ///
714    /// The placeholder and selected value are not used as the accessible name,
715    /// because they describe the current value rather than the control itself.
716    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
717        self.options.accessibility_label = Some(label.into());
718        self
719    }
720
721    /// Override the trailing icon, replacing the default chevron.
722    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
723        self.options.icon = Some(icon.into());
724        self
725    }
726
727    /// Set a label prefix shown before the selected title in the trigger.
728    ///
729    /// e.g. `title_prefix("Country: ")` → "Country: United States"
730    pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
731        self.options.title_prefix = Some(prefix.into());
732        self
733    }
734
735    /// Show a clear button when a value is selected.
736    pub fn cleanable(mut self, cleanable: bool) -> Self {
737        self.options.cleanable = cleanable;
738        self
739    }
740
741    /// Set the placeholder text for the search input.
742    pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
743        self.options.search_placeholder = Some(placeholder.into());
744        self
745    }
746
747    /// Bounce the dropdown list at its vertical boundaries. On by default on iOS,
748    /// Android and macOS, off elsewhere; reduced motion disables displacement.
749    pub fn scroll_bounce(mut self, enabled: bool) -> Self {
750        self.options.scroll_bounce = Some(enabled);
751        self
752    }
753
754    /// Set the disabled state.
755    pub fn disabled(mut self, disabled: bool) -> Self {
756        self.options.disabled = disabled;
757        self
758    }
759
760    /// Set a custom closure that renders the empty-state element.
761    pub fn empty<E: IntoElement + 'static>(
762        mut self,
763        builder: impl Fn(&mut Window, &App) -> E + 'static,
764    ) -> Self {
765        self.empty = Some(Box::new(move |window, cx| {
766            builder(window, cx).into_any_element()
767        }));
768        self
769    }
770
771    /// Control whether the trigger shows a border and background (`true` by default).
772    pub fn appearance(mut self, appearance: bool) -> Self {
773        self.options.appearance = appearance;
774        self
775    }
776}
777
778impl<D> Sizable for Select<D>
779where
780    D: SearchableListDelegate + 'static,
781    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
782{
783    fn with_size(mut self, size: impl Into<Size>) -> Self {
784        self.options.size = size.into();
785        self
786    }
787}
788
789impl<D> crate::FocusableExt for Select<D>
790where
791    D: SearchableListDelegate + 'static,
792    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
793{
794    fn focus_ring(mut self, enabled: bool) -> Self {
795        self.options.focus_ring_enabled = enabled;
796        self
797    }
798
799    fn is_focus_ring_enabled(&self) -> bool {
800        self.options.focus_ring_enabled
801    }
802}
803
804impl<D> EventEmitter<SelectEvent<D>> for SelectState<D>
805where
806    D: SearchableListDelegate + 'static,
807    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
808{
809}
810
811impl<D> EventEmitter<DismissEvent> for SelectState<D>
812where
813    D: SearchableListDelegate + 'static,
814    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
815{
816}
817
818impl<D> Focusable for SelectState<D>
819where
820    D: SearchableListDelegate + 'static,
821    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
822{
823    fn focus_handle(&self, cx: &App) -> FocusHandle {
824        if self.state.open {
825            self.state.list.focus_handle(cx)
826        } else {
827            self.state.focus_handle.clone()
828        }
829    }
830}
831
832impl<D> Styled for Select<D>
833where
834    D: SearchableListDelegate + 'static,
835    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
836{
837    fn style(&mut self) -> &mut StyleRefinement {
838        &mut self.options.style
839    }
840}
841
842impl<D> RenderOnce for Select<D>
843where
844    D: SearchableListDelegate + 'static,
845    <D::Item as SearchableListItem>::Value: PartialEq + Clone,
846{
847    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
848        let disabled = self.options.disabled;
849        let accessibility_label = self.options.accessibility_label.clone();
850        let focus_handle = self.state.read(cx).state.focus_handle.clone();
851        let empty = self.empty;
852        let opts = self.options;
853
854        self.state.update(cx, |this, _| {
855            this.state.style = opts.style;
856            this.state.size = opts.size;
857            this.state.cleanable = opts.cleanable;
858            this.state.placeholder = opts.placeholder;
859            this.state.search_placeholder = opts.search_placeholder;
860            this.state.menu_width = opts.menu_width;
861            this.state.menu_max_h = opts.menu_max_h;
862            this.state.scroll_bounce = opts.scroll_bounce;
863            this.state.disabled = opts.disabled;
864            this.state.appearance = opts.appearance;
865            this.focus_ring_enabled = opts.focus_ring_enabled;
866            this.icon = opts.icon;
867            this.title_prefix = opts.title_prefix;
868
869            if let Some(empty) = empty {
870                this.state.empty = Some(empty);
871            }
872        });
873
874        let is_open = self.state.read(cx).state.open;
875        let accessibility_value = self.state.read(cx).accessibility_value();
876        let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
877        let open_state = self.state.clone();
878
879        BaseSelect::new(self.id)
880            .open(is_open)
881            .disabled(disabled)
882            .when_some(accessibility_label, |this, label| {
883                this.accessibility_label(label)
884            })
885            .focus_handle(&focus_handle)
886            .content_focus_handle(&content_focus_handle)
887            .accessibility_value(accessibility_value)
888            .on_open_change(move |open, window, cx| {
889                open_state.update(cx, |state, cx| {
890                    if !open {
891                        state.clear_query_and_restore_cursor(window, cx);
892                    }
893                    state.set_open(open, cx);
894                });
895            })
896            .size_full()
897            .child(self.state)
898    }
899}
900
901// MARK: Tests
902
903#[cfg(test)]
904mod tests {
905    use gpui::{AppContext as _, RenderOnce as _, TestAppContext};
906
907    use crate::{
908        IndexPath,
909        searchable_list::{SearchableListDelegate as _, SearchableVec},
910        select::{Select, SelectGroup, SelectState},
911    };
912
913    #[gpui::test]
914    fn an_explicit_accessibility_label_does_not_replace_the_placeholder(cx: &mut TestAppContext) {
915        cx.update(crate::init);
916        let cx = cx.add_empty_window();
917        cx.update(|window, cx| {
918            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
919            let state = cx.new(|cx| SelectState::new(items, None, window, cx));
920
921            let plain = Select::new(&state).placeholder("Choose a language");
922            assert_eq!(plain.options.accessibility_label, None);
923            assert_eq!(
924                plain.options.placeholder.as_deref(),
925                Some("Choose a language")
926            );
927
928            let named = Select::new(&state)
929                .placeholder("Choose a language")
930                .accessibility_label("Programming language");
931            assert_eq!(
932                named.options.accessibility_label.as_deref(),
933                Some("Programming language")
934            );
935            assert_eq!(
936                named.options.placeholder.as_deref(),
937                Some("Choose a language"),
938                "an accessible name must not change what is drawn"
939            );
940        });
941    }
942
943    #[gpui::test]
944    fn test_select_initial_selection_seeds_cursor(cx: &mut TestAppContext) {
945        cx.update(crate::init);
946        let cx = cx.add_empty_window();
947        cx.update(|window, cx| {
948            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
949            let state = cx.new(|cx| SelectState::new(items, Some(IndexPath::new(1)), window, cx));
950
951            assert_eq!(
952                state.read(cx).selected_index(cx),
953                Some(IndexPath::new(1)),
954                "initial cursor should be seeded on ListState so display_title can read it",
955            );
956            assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
957        });
958    }
959
960    #[gpui::test]
961    fn test_select_initial_grouped_selection_seeds_cursor(cx: &mut TestAppContext) {
962        cx.update(crate::init);
963        let cx = cx.add_empty_window();
964        cx.update(|window, cx| {
965            let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
966            groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
967            groups.push(SelectGroup::new("B").items(["Banana", "Blueberry", "Blackberry"]));
968
969            let initial = IndexPath::new(1).section(1);
970            let state = cx.new(|cx| SelectState::new(groups, Some(initial), window, cx));
971
972            assert_eq!(state.read(cx).selected_index(cx), Some(initial));
973            assert_eq!(state.read(cx).selected_value(), Some(&"Blueberry"));
974        });
975    }
976
977    #[gpui::test]
978    fn test_select_set_selected_value_clears_search_query(cx: &mut TestAppContext) {
979        cx.update(crate::init);
980        let cx = cx.add_empty_window();
981        cx.update(|window, cx| {
982            let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
983            let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
984            let list = state.read(cx).state.list.clone();
985
986            list.update(cx, |list, cx| list.set_query("Rust", window, cx));
987            assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
988
989            state.update(cx, |state, cx| {
990                state.set_selected_value(&"Go", window, cx);
991            });
992
993            assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
994            assert_eq!(state.read(cx).selected_index(cx), Some(IndexPath::new(1)));
995            assert_eq!(list.read(cx).query_input.read(cx).value(), "");
996        });
997    }
998
999    #[gpui::test]
1000    fn test_select_set_selected_value_clears_grouped_search_query(cx: &mut TestAppContext) {
1001        cx.update(crate::init);
1002        let cx = cx.add_empty_window();
1003        cx.update(|window, cx| {
1004            let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
1005            groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
1006            groups.push(SelectGroup::new("B").items(["Banana", "Blueberry"]));
1007
1008            let state = cx.new(|cx| SelectState::new(groups, None, window, cx).searchable(true));
1009            let list = state.read(cx).state.list.clone();
1010
1011            list.update(cx, |list, cx| list.set_query("Blue", window, cx));
1012            state.update(cx, |state, cx| {
1013                state.set_selected_value(&"Banana", window, cx);
1014            });
1015
1016            assert_eq!(state.read(cx).selected_value(), Some(&"Banana"));
1017            assert_eq!(
1018                state.read(cx).selected_index(cx),
1019                Some(IndexPath::new(0).section(1)),
1020            );
1021        });
1022    }
1023
1024    #[gpui::test]
1025    fn test_select_accessibility_value_tracks_placeholder_and_selection(cx: &mut TestAppContext) {
1026        cx.update(crate::init);
1027        let window = cx.add_empty_window();
1028        window.update(|window, cx| {
1029            let items = SearchableVec::new(vec!["Rust", "Go"]);
1030            let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
1031
1032            _ = Select::new(&state)
1033                .placeholder("Choose a language")
1034                .accessibility_label("Programming language")
1035                .render(window, cx);
1036            assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
1037
1038            state.update(cx, |state, cx| {
1039                state.set_selected_value(&"Rust", window, cx);
1040            });
1041            assert_eq!(state.read(cx).accessibility_value(), "Rust");
1042
1043            let list = state.read(cx).state.list.clone();
1044            list.update(cx, |list, cx| list.set_query("Go", window, cx));
1045            assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
1046            // Filtering changes the available rows, not the committed value.
1047            assert_eq!(state.read(cx).accessibility_value(), "Rust");
1048
1049            _ = Select::new(&state)
1050                .placeholder("Choose a language")
1051                .title_prefix("Language: ")
1052                .render(window, cx);
1053            assert_eq!(state.read(cx).accessibility_value(), "Language: Rust");
1054
1055            state.update(cx, |state, cx| state.set_selected_index(None, window, cx));
1056            assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
1057
1058            _ = Select::new(&state).render(window, cx);
1059            assert_eq!(
1060                state.read(cx).accessibility_value(),
1061                rust_i18n::t!("Select.placeholder").to_string(),
1062            );
1063        });
1064    }
1065
1066    #[gpui::test]
1067    fn replacing_items_reconciles_the_cursor_and_selection(cx: &mut TestAppContext) {
1068        cx.update(crate::init);
1069        let cx = cx.add_empty_window();
1070        cx.update(|window, cx| {
1071            let state = cx.new(|cx| {
1072                SelectState::new(
1073                    vec!["main", "topic", "release"],
1074                    Some(IndexPath::new(0)),
1075                    window,
1076                    cx,
1077                )
1078            });
1079            let list = state.read(cx).state.list.clone();
1080            list.update(cx, |list, cx| {
1081                list.set_selected_index(Some(IndexPath::new(1)), window, cx)
1082            });
1083            state.update(cx, |state, cx| {
1084                state.set_items(vec!["release", "main", "topic"], window, cx)
1085            });
1086            assert_eq!(state.read(cx).selected_index(cx), Some(IndexPath::new(2)));
1087            assert_eq!(state.read(cx).selected_value(), Some(&"main"));
1088            assert_eq!(state.read(cx).state.selection[0].0, IndexPath::new(1));
1089            assert_eq!(
1090                list.read(cx).delegate().selection_snapshot[0].0,
1091                IndexPath::new(1)
1092            );
1093            state.update(cx, |state, cx| {
1094                state.set_items(vec!["main", "replacement"], window, cx)
1095            });
1096            assert_eq!(state.read(cx).selected_index(cx), None);
1097            assert_eq!(state.read(cx).selected_value(), Some(&"main"));
1098
1099            // A selected value the new items no longer have is dropped.
1100            state.update(cx, |state, cx| {
1101                state.set_items(vec!["replacement", "other"], window, cx)
1102            });
1103            assert_eq!(state.read(cx).selected_value(), None);
1104            assert!(state.read(cx).state.selection.is_empty());
1105            assert!(list.read(cx).delegate().selection_snapshot.is_empty());
1106        });
1107    }
1108
1109    #[derive(Clone)]
1110    struct RemoteChoice {
1111        value: &'static str,
1112        title: &'static str,
1113        unavailable: bool,
1114    }
1115
1116    impl super::SelectItem for RemoteChoice {
1117        type Value = &'static str;
1118        fn value(&self) -> &&'static str {
1119            &self.value
1120        }
1121        fn title(&self) -> gpui::SharedString {
1122            self.title.into()
1123        }
1124        fn disabled(&self) -> bool {
1125            self.unavailable
1126        }
1127    }
1128
1129    #[gpui::test]
1130    fn disabled_items_cannot_be_confirmed(cx: &mut TestAppContext) {
1131        use crate::list::ListDelegate as _;
1132        cx.update(crate::init);
1133        let cx = cx.add_empty_window();
1134        for unavailable in [true, false] {
1135            let state = cx.update(|window, cx| {
1136                let state = cx.new(|cx| {
1137                    SelectState::new(
1138                        vec![RemoteChoice {
1139                            value: "main",
1140                            title: "Main",
1141                            unavailable,
1142                        }],
1143                        Some(IndexPath::new(0)),
1144                        window,
1145                        cx,
1146                    )
1147                });
1148                state.update(cx, |state, cx| state.set_open(true, cx));
1149                let list = state.read(cx).state.list.clone();
1150                list.update(cx, |list, cx| {
1151                    list.delegate_mut().confirm(false, window, cx)
1152                });
1153                state
1154            });
1155            cx.run_until_parked();
1156            cx.update(|_, cx| assert_eq!(state.read(cx).state.open, unavailable));
1157        }
1158    }
1159
1160    /// A page holding a select under retained views.
1161    #[cfg(feature = "gpui-fast")]
1162    mod retained {
1163        use std::{cell::Cell, rc::Rc};
1164
1165        use gpui::{
1166            AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
1167            TestAppContext, Window, div, px,
1168        };
1169
1170        use crate::{
1171            searchable_list::SearchableVec,
1172            select::{Select, SelectState},
1173        };
1174
1175        /// A page that shows a select and what it selected, counting its
1176        /// renders.
1177        struct Page {
1178            state: Entity<SelectState<SearchableVec<&'static str>>>,
1179            renders: Rc<Cell<usize>>,
1180        }
1181
1182        impl Render for Page {
1183            fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1184                self.renders.set(self.renders.get() + 1);
1185                let selected = self.state.read(cx).selected_index(cx);
1186                div()
1187                    .w(px(240.))
1188                    .child(Select::new(&self.state))
1189                    .child(format!("{selected:?}"))
1190            }
1191        }
1192
1193        /// Drawing a select again changes nothing in it: the page around it,
1194        /// which reads its selection, is not built again for it.
1195        #[gpui::test]
1196        fn drawing_a_select_again_leaves_the_page_around_it_retained(cx: &mut TestAppContext) {
1197            cx.update(crate::init);
1198            let renders = Rc::new(Cell::new(0));
1199            let (_, cx) = cx.add_window_view({
1200                let renders = renders.clone();
1201                move |window, cx| {
1202                    let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1203                    Page {
1204                        state: cx.new(|cx| SelectState::new(items, None, window, cx)),
1205                        renders,
1206                    }
1207                }
1208            });
1209            cx.update(|window, cx| window.draw(cx).clear(cx));
1210            cx.update(|window, cx| window.draw(cx).clear(cx));
1211            let rendered = renders.get();
1212            for _ in 0..3 {
1213                cx.update(|window, cx| window.draw(cx).clear(cx));
1214            }
1215            assert_eq!(renders.get(), rendered, "the page was built again");
1216        }
1217    }
1218}