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