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