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