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
25pub use crate::searchable_list::SearchableGroup as SelectGroup;
29pub use crate::searchable_list::SearchableListDelegate as SelectDelegate;
31pub use crate::searchable_list::SearchableListItem as SelectItem;
33pub use crate::searchable_list::SearchableListItemElement as SelectListItem;
35pub use crate::searchable_list::SearchableVec;
37
38#[derive(IntoElement)]
39pub struct Caret {
40 size: Size,
41 color: Option<Hsla>,
42}
43
44impl Caret {
45 pub fn new(size: Size) -> Self {
47 Self { size, color: None }
48 }
49
50 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
69pub 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
77struct 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
115pub struct SelectState<D: SearchableListDelegate + 'static>
119where
120 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
121{
122 pub(crate) state: SearchableListState<D>,
123
124 searchable: bool,
126 icon: Option<Icon>,
127 title_prefix: Option<SharedString>,
128 focus_ring_enabled: bool,
129}
130
131#[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 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 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 list_state
191 .delegate_mut()
192 .delegate
193 .on_will_change(&mut selection, &changes);
194
195 let new_selection = 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));
202 cx.notify();
203 this.set_open(false, cx);
204 this.focus(window, cx);
205
206 this.state.selection.clone()
207 });
208
209 if let Ok(new_selection) = new_selection {
211 list_state
212 .delegate_mut()
213 .update_selection_snapshot(new_selection.clone());
214 list_state
215 .delegate_mut()
216 .delegate
217 .on_confirm(&new_selection);
218 }
219 }
220 });
221 },
222 move |_final_selected_index, window, cx| {
224 cx.defer_in(window, {
225 let weak_cancel = weak_cancel.clone();
226 move |list_state, window, cx| {
227 let committed_ix = weak_cancel
228 .upgrade()
229 .and_then(|e| e.read(cx).state.selection.first().map(|(ix, _)| *ix));
230
231 list_state.set_selected_index(committed_ix, window, cx);
232
233 _ = weak_cancel.update(cx, |this, cx| {
234 this.set_open(false, cx);
235 this.focus(window, cx);
236 });
237 }
238 });
239 },
240 move |window, cx| {
242 if let Some(empty) = weak_empty
243 .upgrade()
244 .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx)))
245 {
246 empty
247 } else {
248 h_flex()
249 .justify_center()
250 .py_6()
251 .text_color(cx.theme().muted_foreground.opacity(0.6))
252 .child(Icon::new(IconName::Inbox).size(px(28.)))
253 .into_any_element()
254 }
255 },
256 Self::on_blur,
257 window,
258 cx,
259 );
260
261 Self {
262 state,
263 searchable: false,
264 icon: None,
265 title_prefix: None,
266 focus_ring_enabled: true,
267 }
268 }
269
270 pub fn searchable(mut self, searchable: bool) -> Self {
274 self.searchable = searchable;
275 self
276 }
277
278 pub fn set_selected_index(
280 &mut self,
281 selected_index: Option<IndexPath>,
282 window: &mut Window,
283 cx: &mut Context<Self>,
284 ) {
285 self.state.list.update(cx, |list, cx| {
286 list._set_selected_index(selected_index, window, cx);
287 });
288
289 let item = selected_index
290 .and_then(|ix| self.state.list.read(cx).delegate().delegate.item(ix))
291 .map(|i| i.clone());
292
293 self.state.selection = match (selected_index, item) {
294 (Some(ix), Some(item)) => vec![(ix, item)],
295 _ => vec![],
296 };
297 self.state.sync_snapshot(cx);
298 }
299
300 pub fn set_selected_value(
308 &mut self,
309 selected_value: &<D::Item as SearchableListItem>::Value,
310 window: &mut Window,
311 cx: &mut Context<Self>,
312 ) {
313 self.state.clear_query(window, cx);
314
315 let selected_index = self
316 .state
317 .list
318 .read(cx)
319 .delegate()
320 .delegate
321 .position(selected_value);
322
323 self.set_selected_index(selected_index, window, cx);
324 }
325
326 pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>)
328 where
329 D: SearchableListDelegate + 'static,
330 {
331 self.state.list.update(cx, |list, _| {
332 list.delegate_mut().delegate = items;
333 });
334 }
335
336 pub fn selected_index(&self, cx: &App) -> Option<IndexPath> {
338 self.state.list.read(cx).selected_index()
339 }
340
341 pub fn selected_value(&self) -> Option<&<D::Item as SearchableListItem>::Value> {
343 self.state.selection.first().map(|(_, i)| i.value())
344 }
345
346 pub fn focus(&self, window: &mut Window, cx: &mut App) {
348 self.state.focus_handle.focus(window, cx);
349 }
350
351 fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
352 if self.state.list.read(cx).is_focused(window, cx)
353 || self.state.focus_handle.is_focused(window)
354 {
355 return;
356 }
357
358 let committed_ix = self.state.selection.first().map(|(ix, _)| *ix);
359 if self.selected_index(cx) != committed_ix {
360 self.state.list.update(cx, |list, cx| {
361 list.set_selected_index(committed_ix, window, cx);
362 });
363 }
364
365 self.set_open(false, cx);
366 cx.notify();
367 }
368
369 fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
370 cx.stop_propagation();
371
372 self.set_open(!self.state.open, cx);
373
374 if self.state.open {
375 self.state.list.focus_handle(cx).focus(window, cx);
376 }
377
378 cx.notify();
379 }
380
381 fn escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
382 if !self.state.open {
383 cx.propagate();
384 return;
385 }
386
387 cx.stop_propagation();
388 self.set_open(false, cx);
389 self.focus(window, cx);
390 cx.notify();
391 }
392
393 fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
394 self.state.open = open;
395 self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
396
397 cx.notify();
398 }
399
400 fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
401 cx.stop_propagation();
402 self.set_selected_index(None, window, cx);
403 cx.emit(SelectEvent::Confirm(None));
404 }
405
406 fn display_title(&mut self, _: &Window, cx: &mut Context<Self>) -> impl IntoElement {
407 let default_title = div().text_color(cx.theme().muted_foreground).child(
408 self.state
409 .placeholder
410 .clone()
411 .unwrap_or_else(|| t!("Select.placeholder").into()),
412 );
413
414 let Some(selected_index) = self.selected_index(cx) else {
415 return default_title;
416 };
417
418 let Some(title) = self
419 .state
420 .list
421 .read(cx)
422 .delegate()
423 .delegate
424 .item(selected_index)
425 .map(|item| {
426 if let Some(el) = item.display_title() {
427 el
428 } else if let Some(prefix) = self.title_prefix.as_ref() {
429 format!("{}{}", prefix, item.title()).into_any_element()
430 } else {
431 item.title().into_any_element()
432 }
433 })
434 else {
435 return default_title;
436 };
437
438 div()
439 .when(self.state.disabled, |this| {
440 this.text_color(cx.theme().muted_foreground)
441 })
442 .child(title)
443 }
444
445 fn accessibility_value(&self) -> SharedString {
446 let Some((_, item)) = self.state.selection.first() else {
447 return self
448 .state
449 .placeholder
450 .clone()
451 .unwrap_or_else(|| t!("Select.placeholder").into());
452 };
453
454 if let Some(prefix) = self.title_prefix.as_ref() {
455 format!("{}{}", prefix, item.title()).into()
456 } else {
457 item.title()
458 }
459 }
460}
461
462impl<D> Render for SelectState<D>
463where
464 D: SearchableListDelegate + 'static,
465 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
466{
467 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
468 let searchable = self.searchable;
469 let is_focused = self.state.focus_handle.is_focused(window);
470 let show_clean = self.state.cleanable && self.selected_index(cx).is_some();
471 let bounds = self.state.bounds;
472 let allow_open = !(self.state.open || self.state.disabled);
473 let outline_visible = self.state.open || (is_focused && !self.state.disabled);
474
475 let (bg, fg) = input_style(self.state.disabled, cx);
476
477 self.state.list.update(cx, |list, cx| {
478 list.set_searchable(searchable, cx);
479 list.delegate_mut().size = self.state.size;
480 });
481
482 div().size_full().relative().child(
483 div()
484 .relative()
485 .on_prepaint({
486 let state = cx.entity();
487 move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)
488 })
489 .child(
490 div()
491 .id("input")
492 .test_support()
493 .relative()
494 .flex()
495 .items_center()
496 .justify_between()
497 .border_1()
498 .border_color(cx.theme().transparent)
499 .when(self.state.appearance, |this| {
500 this.bg(bg)
501 .text_color(fg)
502 .when(self.state.disabled, |this| this.opacity(0.5))
503 .border_color(cx.theme().input)
504 .rounded(cx.theme().radius)
505 })
506 .input_size(self.state.size)
507 .input_text_size(self.state.size)
508 .refine_style(&self.state.style)
509 .when(outline_visible && self.state.appearance, |this| {
510 this.border_1().border_color(cx.theme().ring)
511 })
512 .when(
513 outline_visible && self.state.appearance && self.focus_ring_enabled,
514 |this| this.focus_ring_style(window, cx),
515 )
516 .when(allow_open, |this| {
517 this.on_click(cx.listener(Self::toggle_menu))
518 })
519 .child(
520 h_flex()
521 .id("inner")
522 .w_full()
523 .min_w_0()
524 .overflow_hidden()
525 .whitespace_nowrap()
526 .items_center()
527 .justify_between()
528 .gap_1()
529 .child(
530 div()
531 .id("title")
532 .flex_1()
533 .min_w_0()
534 .overflow_hidden()
535 .whitespace_nowrap()
536 .truncate()
537 .child(self.display_title(window, cx)),
538 )
539 .when(show_clean, |this| {
540 this.child(clear_button(cx).map(|this| {
541 if self.state.disabled {
542 this.disabled(true)
543 } else {
544 this.on_click(cx.listener(Self::clean))
545 }
546 }))
547 })
548 .when(!show_clean, |this| {
549 let icon = match self.icon.clone() {
550 Some(icon) => icon
551 .xsmall()
552 .text_color(cx.theme().muted_foreground)
553 .into_any_element(),
554 None => Caret::new(self.state.size)
555 .text_color(cx.theme().muted_foreground)
556 .into_any_element(),
557 };
558
559 this.child(icon)
560 }),
561 ),
562 )
563 .when(self.state.open, |this| {
564 this.child(
565 deferred(crate::popover::dropdown_popup(
566 ("select-popup", cx.entity_id()),
567 bounds,
568 v_flex()
569 .occlude()
570 .map(|this| match self.state.menu_width {
571 Length::Auto => this.w(bounds.size.width + px(2.)),
572 Length::Definite(w) => this.w(w),
573 })
574 .popover_style(cx)
575 .child(
576 List::new(&self.state.list)
577 .when_some(
578 self.state.search_placeholder.clone(),
579 |this, placeholder| {
580 this.search_placeholder(placeholder)
581 },
582 )
583 .with_size(self.state.size)
584 .max_h(self.state.menu_max_h)
585 .paddings(Edges::all(px(4.))),
586 )
587 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
588 this.escape(&Cancel, window, cx);
589 })),
590 cx,
591 ))
592 .with_priority(gpui_base::POPUP_PRIORITY),
593 )
594 }),
595 )
596 }
597}
598
599impl<D> Select<D>
600where
601 D: SearchableListDelegate + 'static,
602 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
603{
604 pub fn new(state: &Entity<SelectState<D>>) -> Self {
605 Self {
606 id: ("select", state.entity_id()).into(),
607 state: state.clone(),
608 options: SelectOptions::default(),
609 empty: None,
610 }
611 }
612
613 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
615 self.id = id.into();
616 self
617 }
618
619 pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
621 self.options.menu_width = width.into();
622 self
623 }
624
625 pub fn menu_max_h(mut self, max_h: impl Into<Length>) -> Self {
627 self.options.menu_max_h = max_h.into();
628 self
629 }
630
631 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
633 self.options.placeholder = Some(placeholder.into());
634 self
635 }
636
637 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
642 self.options.accessibility_label = Some(label.into());
643 self
644 }
645
646 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
648 self.options.icon = Some(icon.into());
649 self
650 }
651
652 pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
656 self.options.title_prefix = Some(prefix.into());
657 self
658 }
659
660 pub fn cleanable(mut self, cleanable: bool) -> Self {
662 self.options.cleanable = cleanable;
663 self
664 }
665
666 pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
668 self.options.search_placeholder = Some(placeholder.into());
669 self
670 }
671
672 pub fn disabled(mut self, disabled: bool) -> Self {
674 self.options.disabled = disabled;
675 self
676 }
677
678 pub fn empty<E: IntoElement + 'static>(
680 mut self,
681 builder: impl Fn(&mut Window, &App) -> E + 'static,
682 ) -> Self {
683 self.empty = Some(Box::new(move |window, cx| {
684 builder(window, cx).into_any_element()
685 }));
686 self
687 }
688
689 pub fn appearance(mut self, appearance: bool) -> Self {
691 self.options.appearance = appearance;
692 self
693 }
694}
695
696impl<D> Sizable for Select<D>
697where
698 D: SearchableListDelegate + 'static,
699 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
700{
701 fn with_size(mut self, size: impl Into<Size>) -> Self {
702 self.options.size = size.into();
703 self
704 }
705}
706
707impl<D> crate::FocusableExt for Select<D>
708where
709 D: SearchableListDelegate + 'static,
710 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
711{
712 fn focus_ring(mut self, enabled: bool) -> Self {
713 self.options.focus_ring_enabled = enabled;
714 self
715 }
716
717 fn is_focus_ring_enabled(&self) -> bool {
718 self.options.focus_ring_enabled
719 }
720}
721
722impl<D> EventEmitter<SelectEvent<D>> for SelectState<D>
723where
724 D: SearchableListDelegate + 'static,
725 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
726{
727}
728
729impl<D> EventEmitter<DismissEvent> for SelectState<D>
730where
731 D: SearchableListDelegate + 'static,
732 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
733{
734}
735
736impl<D> Focusable for SelectState<D>
737where
738 D: SearchableListDelegate + 'static,
739 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
740{
741 fn focus_handle(&self, cx: &App) -> FocusHandle {
742 if self.state.open {
743 self.state.list.focus_handle(cx)
744 } else {
745 self.state.focus_handle.clone()
746 }
747 }
748}
749
750impl<D> Styled for Select<D>
751where
752 D: SearchableListDelegate + 'static,
753 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
754{
755 fn style(&mut self) -> &mut StyleRefinement {
756 &mut self.options.style
757 }
758}
759
760impl<D> RenderOnce for Select<D>
761where
762 D: SearchableListDelegate + 'static,
763 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
764{
765 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
766 let disabled = self.options.disabled;
767 let accessibility_label = self.options.accessibility_label.clone();
768 let focus_handle = self.state.read(cx).state.focus_handle.clone();
769 let empty = self.empty;
770 let opts = self.options;
771
772 self.state.update(cx, |this, _| {
773 this.state.style = opts.style;
774 this.state.size = opts.size;
775 this.state.cleanable = opts.cleanable;
776 this.state.placeholder = opts.placeholder;
777 this.state.search_placeholder = opts.search_placeholder;
778 this.state.menu_width = opts.menu_width;
779 this.state.menu_max_h = opts.menu_max_h;
780 this.state.disabled = opts.disabled;
781 this.state.appearance = opts.appearance;
782 this.focus_ring_enabled = opts.focus_ring_enabled;
783 this.icon = opts.icon;
784 this.title_prefix = opts.title_prefix;
785
786 if let Some(empty) = empty {
787 this.state.empty = Some(empty);
788 }
789 });
790
791 let is_open = self.state.read(cx).state.open;
792 let accessibility_value = self.state.read(cx).accessibility_value();
793 let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
794 let open_state = self.state.clone();
795
796 BaseSelect::new(self.id)
797 .open(is_open)
798 .disabled(disabled)
799 .when_some(accessibility_label, |this, label| {
800 this.accessibility_label(label)
801 })
802 .focus_handle(&focus_handle)
803 .content_focus_handle(&content_focus_handle)
804 .accessibility_value(accessibility_value)
805 .on_open_change(move |open, _, cx| {
806 open_state.update(cx, |state, cx| state.set_open(open, cx));
807 })
808 .size_full()
809 .child(self.state)
810 }
811}
812
813#[cfg(test)]
816mod tests {
817 use gpui::{AppContext as _, RenderOnce as _, TestAppContext};
818
819 use crate::{
820 IndexPath,
821 searchable_list::{SearchableListDelegate as _, SearchableVec},
822 select::{Select, SelectGroup, SelectState},
823 };
824
825 #[gpui::test]
826 fn an_explicit_accessibility_label_does_not_replace_the_placeholder(cx: &mut TestAppContext) {
827 cx.update(crate::init);
828 let cx = cx.add_empty_window();
829 cx.update(|window, cx| {
830 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
831 let state = cx.new(|cx| SelectState::new(items, None, window, cx));
832
833 let plain = Select::new(&state).placeholder("Choose a language");
834 assert_eq!(plain.options.accessibility_label, None);
835 assert_eq!(
836 plain.options.placeholder.as_deref(),
837 Some("Choose a language")
838 );
839
840 let named = Select::new(&state)
841 .placeholder("Choose a language")
842 .accessibility_label("Programming language");
843 assert_eq!(
844 named.options.accessibility_label.as_deref(),
845 Some("Programming language")
846 );
847 assert_eq!(
848 named.options.placeholder.as_deref(),
849 Some("Choose a language"),
850 "an accessible name must not change what is drawn"
851 );
852 });
853 }
854
855 #[gpui::test]
856 fn test_select_initial_selection_seeds_cursor(cx: &mut TestAppContext) {
857 cx.update(crate::init);
858 let cx = cx.add_empty_window();
859 cx.update(|window, cx| {
860 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
861 let state = cx.new(|cx| SelectState::new(items, Some(IndexPath::new(1)), window, cx));
862
863 assert_eq!(
864 state.read(cx).selected_index(cx),
865 Some(IndexPath::new(1)),
866 "initial cursor should be seeded on ListState so display_title can read it",
867 );
868 assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
869 });
870 }
871
872 #[gpui::test]
873 fn test_select_initial_grouped_selection_seeds_cursor(cx: &mut TestAppContext) {
874 cx.update(crate::init);
875 let cx = cx.add_empty_window();
876 cx.update(|window, cx| {
877 let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
878 groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
879 groups.push(SelectGroup::new("B").items(["Banana", "Blueberry", "Blackberry"]));
880
881 let initial = IndexPath::new(1).section(1);
882 let state = cx.new(|cx| SelectState::new(groups, Some(initial), window, cx));
883
884 assert_eq!(state.read(cx).selected_index(cx), Some(initial));
885 assert_eq!(state.read(cx).selected_value(), Some(&"Blueberry"));
886 });
887 }
888
889 #[gpui::test]
890 fn test_select_set_selected_value_clears_search_query(cx: &mut TestAppContext) {
891 cx.update(crate::init);
892 let cx = cx.add_empty_window();
893 cx.update(|window, cx| {
894 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
895 let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
896 let list = state.read(cx).state.list.clone();
897
898 list.update(cx, |list, cx| list.set_query("Rust", window, cx));
899 assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
900
901 state.update(cx, |state, cx| {
902 state.set_selected_value(&"Go", window, cx);
903 });
904
905 assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
906 assert_eq!(state.read(cx).selected_index(cx), Some(IndexPath::new(1)));
907 assert_eq!(list.read(cx).query_input.read(cx).value(), "");
908 });
909 }
910
911 #[gpui::test]
912 fn test_select_set_selected_value_clears_grouped_search_query(cx: &mut TestAppContext) {
913 cx.update(crate::init);
914 let cx = cx.add_empty_window();
915 cx.update(|window, cx| {
916 let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
917 groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
918 groups.push(SelectGroup::new("B").items(["Banana", "Blueberry"]));
919
920 let state = cx.new(|cx| SelectState::new(groups, None, window, cx).searchable(true));
921 let list = state.read(cx).state.list.clone();
922
923 list.update(cx, |list, cx| list.set_query("Blue", window, cx));
924 state.update(cx, |state, cx| {
925 state.set_selected_value(&"Banana", window, cx);
926 });
927
928 assert_eq!(state.read(cx).selected_value(), Some(&"Banana"));
929 assert_eq!(
930 state.read(cx).selected_index(cx),
931 Some(IndexPath::new(0).section(1)),
932 );
933 });
934 }
935
936 #[gpui::test]
937 fn test_select_accessibility_value_tracks_placeholder_and_selection(cx: &mut TestAppContext) {
938 cx.update(crate::init);
939 let window = cx.add_empty_window();
940 window.update(|window, cx| {
941 let items = SearchableVec::new(vec!["Rust", "Go"]);
942 let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
943
944 _ = Select::new(&state)
945 .placeholder("Choose a language")
946 .accessibility_label("Programming language")
947 .render(window, cx);
948 assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
949
950 state.update(cx, |state, cx| {
951 state.set_selected_value(&"Rust", window, cx);
952 });
953 assert_eq!(state.read(cx).accessibility_value(), "Rust");
954
955 let list = state.read(cx).state.list.clone();
956 list.update(cx, |list, cx| list.set_query("Go", window, cx));
957 assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
958 assert_eq!(state.read(cx).accessibility_value(), "Rust");
960
961 _ = Select::new(&state)
962 .placeholder("Choose a language")
963 .title_prefix("Language: ")
964 .render(window, cx);
965 assert_eq!(state.read(cx).accessibility_value(), "Language: Rust");
966
967 state.update(cx, |state, cx| state.set_selected_index(None, window, cx));
968 assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
969
970 _ = Select::new(&state).render(window, cx);
971 assert_eq!(
972 state.read(cx).accessibility_value(),
973 rust_i18n::t!("Select.placeholder").to_string(),
974 );
975 });
976 }
977}