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 rust_i18n::t;
8
9use crate::ThemeStyled as _;
10use crate::{
11 ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Sizable, Size,
12 StyleSized, StyledExt,
13 actions::Cancel,
14 h_flex,
15 input::{clear_button, input_style},
16 list::List,
17 searchable_list::{
18 SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState,
19 },
20 v_flex,
21};
22use gpui_base::{GlobalState, Select as BaseSelect};
23
24pub use crate::searchable_list::SearchableGroup as SelectGroup;
28pub use crate::searchable_list::SearchableListDelegate as SelectDelegate;
30pub use crate::searchable_list::SearchableListItem as SelectItem;
32pub use crate::searchable_list::SearchableListItemElement as SelectListItem;
34pub use crate::searchable_list::SearchableVec;
36
37#[derive(IntoElement)]
38pub struct Caret {
39 size: Size,
40 color: Option<Hsla>,
41}
42
43impl Caret {
44 pub fn new(size: Size) -> Self {
46 Self { size, color: None }
47 }
48
49 pub fn text_color(mut self, color: Hsla) -> Self {
51 self.color = Some(color);
52 self
53 }
54}
55
56impl RenderOnce for Caret {
57 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
58 Icon::new(IconName::ChevronDown)
59 .with_size(match self.size {
60 Size::XSmall => Size::XSmall,
61 Size::Small => Size::Small,
62 _ => Size::Medium,
63 })
64 .when_some(self.color, |this, color| this.text_color(color))
65 }
66}
67
68pub enum SelectEvent<D: SearchableListDelegate + 'static>
70where
71 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
72{
73 Confirm(Option<<D::Item as SearchableListItem>::Value>),
74}
75
76struct SelectOptions {
79 style: StyleRefinement,
80 size: Size,
81 icon: Option<Icon>,
82 cleanable: bool,
83 placeholder: Option<SharedString>,
84 accessibility_label: Option<SharedString>,
85 title_prefix: Option<SharedString>,
86 search_placeholder: Option<SharedString>,
87 menu_width: Length,
88 menu_max_h: Length,
89 disabled: bool,
90 appearance: bool,
91 focus_ring_enabled: bool,
92}
93
94impl Default for SelectOptions {
95 fn default() -> Self {
96 Self {
97 style: StyleRefinement::default(),
98 size: Size::default(),
99 icon: None,
100 cleanable: false,
101 placeholder: None,
102 accessibility_label: None,
103 title_prefix: None,
104 menu_width: Length::Auto,
105 menu_max_h: rems(20.).into(),
106 disabled: false,
107 appearance: true,
108 focus_ring_enabled: true,
109 search_placeholder: None,
110 }
111 }
112}
113
114pub struct SelectState<D: SearchableListDelegate + 'static>
118where
119 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
120{
121 pub(crate) state: SearchableListState<D>,
122
123 searchable: bool,
125 icon: Option<Icon>,
126 title_prefix: Option<SharedString>,
127 focus_ring_enabled: bool,
128}
129
130#[derive(IntoElement)]
132pub struct Select<D: SearchableListDelegate + 'static>
133where
134 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
135{
136 id: ElementId,
137 state: Entity<SelectState<D>>,
138 options: SelectOptions,
139 empty: Option<Box<dyn Fn(&mut Window, &App) -> AnyElement + 'static>>,
140}
141
142impl<D> SelectState<D>
143where
144 D: SearchableListDelegate + 'static,
145 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
146{
147 pub fn new(
149 delegate: D,
150 selected_index: Option<IndexPath>,
151 window: &mut Window,
152 cx: &mut Context<Self>,
153 ) -> Self {
154 let weak = cx.entity().downgrade();
155 let weak_confirm = weak.clone();
156 let weak_cancel = weak.clone();
157 let weak_empty = weak;
158
159 let selected_indices = selected_index.into_iter().collect::<Vec<_>>();
160
161 let state = SearchableListState::new(
162 delegate,
163 selected_indices,
164 move |selected_index, _secondary, window, cx| {
166 cx.defer_in(window, {
167 let weak_confirm = weak_confirm.clone();
168 move |list_state, window, cx| {
169 let mut selection = weak_confirm
170 .upgrade()
171 .map(|e| e.read(cx).state.selection.clone())
172 .unwrap_or_default();
173
174 let changes = {
175 let mut changes: Vec<SearchableListChange> = selection
176 .iter()
177 .map(|(ix, _)| SearchableListChange::Deselect { index: *ix })
178 .collect();
179
180 if let Some(ix) = selected_index {
181 changes.push(SearchableListChange::Select { index: ix });
182 }
183
184 changes
185 };
186
187 list_state
190 .delegate_mut()
191 .delegate
192 .on_will_change(&mut selection, &changes);
193
194 let new_selection = weak_confirm.update(cx, |this, cx| {
195 this.state.selection = selection;
196
197 let final_value =
198 this.state.selection.first().map(|(_, i)| i.value().clone());
199
200 cx.emit(SelectEvent::Confirm(final_value));
201 cx.notify();
202 this.set_open(false, cx);
203 this.focus(window, cx);
204
205 this.state.selection.clone()
206 });
207
208 if let Ok(new_selection) = new_selection {
210 list_state
211 .delegate_mut()
212 .update_selection_snapshot(new_selection.clone());
213 list_state
214 .delegate_mut()
215 .delegate
216 .on_confirm(&new_selection);
217 }
218 }
219 });
220 },
221 move |_final_selected_index, window, cx| {
223 cx.defer_in(window, {
224 let weak_cancel = weak_cancel.clone();
225 move |list_state, window, cx| {
226 let committed_ix = weak_cancel
227 .upgrade()
228 .and_then(|e| e.read(cx).state.selection.first().map(|(ix, _)| *ix));
229
230 list_state.set_selected_index(committed_ix, window, cx);
231
232 _ = weak_cancel.update(cx, |this, cx| {
233 this.set_open(false, cx);
234 this.focus(window, cx);
235 });
236 }
237 });
238 },
239 move |window, cx| {
241 if let Some(empty) = weak_empty
242 .upgrade()
243 .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx)))
244 {
245 empty
246 } else {
247 h_flex()
248 .justify_center()
249 .py_6()
250 .text_color(cx.theme().muted_foreground.opacity(0.6))
251 .child(Icon::new(IconName::Inbox).size(px(28.)))
252 .into_any_element()
253 }
254 },
255 Self::on_blur,
256 window,
257 cx,
258 );
259
260 Self {
261 state,
262 searchable: false,
263 icon: None,
264 title_prefix: None,
265 focus_ring_enabled: true,
266 }
267 }
268
269 pub fn searchable(mut self, searchable: bool) -> Self {
273 self.searchable = searchable;
274 self
275 }
276
277 pub fn set_selected_index(
279 &mut self,
280 selected_index: Option<IndexPath>,
281 window: &mut Window,
282 cx: &mut Context<Self>,
283 ) {
284 self.state.list.update(cx, |list, cx| {
285 list._set_selected_index(selected_index, window, cx);
286 });
287
288 let item = selected_index
289 .and_then(|ix| self.state.list.read(cx).delegate().delegate.item(ix))
290 .map(|i| i.clone());
291
292 self.state.selection = match (selected_index, item) {
293 (Some(ix), Some(item)) => vec![(ix, item)],
294 _ => vec![],
295 };
296 self.state.sync_snapshot(cx);
297 }
298
299 pub fn set_selected_value(
307 &mut self,
308 selected_value: &<D::Item as SearchableListItem>::Value,
309 window: &mut Window,
310 cx: &mut Context<Self>,
311 ) {
312 self.state.clear_query(window, cx);
313
314 let selected_index = self
315 .state
316 .list
317 .read(cx)
318 .delegate()
319 .delegate
320 .position(selected_value);
321
322 self.set_selected_index(selected_index, window, cx);
323 }
324
325 pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>)
327 where
328 D: SearchableListDelegate + 'static,
329 {
330 self.state.list.update(cx, |list, _| {
331 list.delegate_mut().delegate = items;
332 });
333 }
334
335 pub fn selected_index(&self, cx: &App) -> Option<IndexPath> {
337 self.state.list.read(cx).selected_index()
338 }
339
340 pub fn selected_value(&self) -> Option<&<D::Item as SearchableListItem>::Value> {
342 self.state.selection.first().map(|(_, i)| i.value())
343 }
344
345 pub fn focus(&self, window: &mut Window, cx: &mut App) {
347 self.state.focus_handle.focus(window, cx);
348 }
349
350 fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
351 if self.state.list.read(cx).is_focused(window, cx)
352 || self.state.focus_handle.is_focused(window)
353 {
354 return;
355 }
356
357 let committed_ix = self.state.selection.first().map(|(ix, _)| *ix);
358 if self.selected_index(cx) != committed_ix {
359 self.state.list.update(cx, |list, cx| {
360 list.set_selected_index(committed_ix, window, cx);
361 });
362 }
363
364 self.set_open(false, cx);
365 cx.notify();
366 }
367
368 fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
369 cx.stop_propagation();
370
371 self.set_open(!self.state.open, cx);
372
373 if self.state.open {
374 self.state.list.focus_handle(cx).focus(window, cx);
375 }
376
377 cx.notify();
378 }
379
380 fn escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
381 if !self.state.open {
382 cx.propagate();
383 return;
384 }
385
386 cx.stop_propagation();
387 self.set_open(false, cx);
388 self.focus(window, cx);
389 cx.notify();
390 }
391
392 fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
393 self.state.open = open;
394 self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
395
396 cx.notify();
397 }
398
399 fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
400 cx.stop_propagation();
401 self.set_selected_index(None, window, cx);
402 cx.emit(SelectEvent::Confirm(None));
403 }
404
405 fn display_title(&mut self, _: &Window, cx: &mut Context<Self>) -> impl IntoElement {
406 let default_title = div().text_color(cx.theme().muted_foreground).child(
407 self.state
408 .placeholder
409 .clone()
410 .unwrap_or_else(|| t!("Select.placeholder").into()),
411 );
412
413 let Some(selected_index) = self.selected_index(cx) else {
414 return default_title;
415 };
416
417 let Some(title) = self
418 .state
419 .list
420 .read(cx)
421 .delegate()
422 .delegate
423 .item(selected_index)
424 .map(|item| {
425 if let Some(el) = item.display_title() {
426 el
427 } else if let Some(prefix) = self.title_prefix.as_ref() {
428 format!("{}{}", prefix, item.title()).into_any_element()
429 } else {
430 item.title().into_any_element()
431 }
432 })
433 else {
434 return default_title;
435 };
436
437 div()
438 .when(self.state.disabled, |this| {
439 this.text_color(cx.theme().muted_foreground)
440 })
441 .child(title)
442 }
443}
444
445impl<D> Render for SelectState<D>
446where
447 D: SearchableListDelegate + 'static,
448 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
449{
450 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
451 let searchable = self.searchable;
452 let is_focused = self.state.focus_handle.is_focused(window);
453 let show_clean = self.state.cleanable && self.selected_index(cx).is_some();
454 let bounds = self.state.bounds;
455 let allow_open = !(self.state.open || self.state.disabled);
456 let outline_visible = self.state.open || (is_focused && !self.state.disabled);
457
458 let (bg, fg) = input_style(self.state.disabled, cx);
459
460 self.state.list.update(cx, |list, cx| {
461 list.set_searchable(searchable, cx);
462 list.delegate_mut().size = self.state.size;
463 });
464
465 div().size_full().relative().child(
466 div()
467 .relative()
468 .on_prepaint({
469 let state = cx.entity();
470 move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)
471 })
472 .child(
473 div()
474 .id("input")
475 .relative()
476 .flex()
477 .items_center()
478 .justify_between()
479 .border_1()
480 .border_color(cx.theme().transparent)
481 .when(self.state.appearance, |this| {
482 this.bg(bg)
483 .text_color(fg)
484 .when(self.state.disabled, |this| this.opacity(0.5))
485 .border_color(cx.theme().input)
486 .rounded(cx.theme().radius)
487 })
488 .input_size(self.state.size)
489 .input_text_size(self.state.size)
490 .refine_style(&self.state.style)
491 .when(outline_visible && self.state.appearance, |this| {
492 this.border_1().border_color(cx.theme().ring)
493 })
494 .when(
495 outline_visible && self.state.appearance && self.focus_ring_enabled,
496 |this| this.focus_ring_style(window, cx),
497 )
498 .when(allow_open, |this| {
499 this.on_click(cx.listener(Self::toggle_menu))
500 })
501 .child(
502 h_flex()
503 .id("inner")
504 .w_full()
505 .min_w_0()
506 .overflow_hidden()
507 .whitespace_nowrap()
508 .items_center()
509 .justify_between()
510 .gap_1()
511 .child(
512 div()
513 .id("title")
514 .flex_1()
515 .min_w_0()
516 .overflow_hidden()
517 .whitespace_nowrap()
518 .truncate()
519 .child(self.display_title(window, cx)),
520 )
521 .when(show_clean, |this| {
522 this.child(clear_button(cx).map(|this| {
523 if self.state.disabled {
524 this.disabled(true)
525 } else {
526 this.on_click(cx.listener(Self::clean))
527 }
528 }))
529 })
530 .when(!show_clean, |this| {
531 let icon = match self.icon.clone() {
532 Some(icon) => icon
533 .xsmall()
534 .text_color(cx.theme().muted_foreground)
535 .into_any_element(),
536 None => Caret::new(self.state.size)
537 .text_color(cx.theme().muted_foreground)
538 .into_any_element(),
539 };
540
541 this.child(icon)
542 }),
543 ),
544 )
545 .when(self.state.open, |this| {
546 this.child(
547 deferred(crate::popover::dropdown_popup(
548 ("select-popup", cx.entity_id()),
549 bounds,
550 v_flex()
551 .occlude()
552 .map(|this| match self.state.menu_width {
553 Length::Auto => this.w(bounds.size.width + px(2.)),
554 Length::Definite(w) => this.w(w),
555 })
556 .popover_style(cx)
557 .child(
558 List::new(&self.state.list)
559 .when_some(
560 self.state.search_placeholder.clone(),
561 |this, placeholder| {
562 this.search_placeholder(placeholder)
563 },
564 )
565 .with_size(self.state.size)
566 .max_h(self.state.menu_max_h)
567 .paddings(Edges::all(px(4.))),
568 )
569 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
570 this.escape(&Cancel, window, cx);
571 })),
572 cx,
573 ))
574 .with_priority(gpui_base::POPUP_PRIORITY),
575 )
576 }),
577 )
578 }
579}
580
581impl<D> Select<D>
582where
583 D: SearchableListDelegate + 'static,
584 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
585{
586 pub fn new(state: &Entity<SelectState<D>>) -> Self {
587 Self {
588 id: ("select", state.entity_id()).into(),
589 state: state.clone(),
590 options: SelectOptions::default(),
591 empty: None,
592 }
593 }
594
595 pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
597 self.options.menu_width = width.into();
598 self
599 }
600
601 pub fn menu_max_h(mut self, max_h: impl Into<Length>) -> Self {
603 self.options.menu_max_h = max_h.into();
604 self
605 }
606
607 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
609 self.options.placeholder = Some(placeholder.into());
610 self
611 }
612
613 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
618 self.options.accessibility_label = Some(label.into());
619 self
620 }
621
622 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
624 self.options.icon = Some(icon.into());
625 self
626 }
627
628 pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
632 self.options.title_prefix = Some(prefix.into());
633 self
634 }
635
636 pub fn cleanable(mut self, cleanable: bool) -> Self {
638 self.options.cleanable = cleanable;
639 self
640 }
641
642 pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
644 self.options.search_placeholder = Some(placeholder.into());
645 self
646 }
647
648 pub fn disabled(mut self, disabled: bool) -> Self {
650 self.options.disabled = disabled;
651 self
652 }
653
654 pub fn empty<E: IntoElement + 'static>(
656 mut self,
657 builder: impl Fn(&mut Window, &App) -> E + 'static,
658 ) -> Self {
659 self.empty = Some(Box::new(move |window, cx| {
660 builder(window, cx).into_any_element()
661 }));
662 self
663 }
664
665 pub fn appearance(mut self, appearance: bool) -> Self {
667 self.options.appearance = appearance;
668 self
669 }
670}
671
672impl<D> Sizable for Select<D>
673where
674 D: SearchableListDelegate + 'static,
675 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
676{
677 fn with_size(mut self, size: impl Into<Size>) -> Self {
678 self.options.size = size.into();
679 self
680 }
681}
682
683impl<D> crate::FocusableExt for Select<D>
684where
685 D: SearchableListDelegate + 'static,
686 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
687{
688 fn focus_ring(mut self, enabled: bool) -> Self {
689 self.options.focus_ring_enabled = enabled;
690 self
691 }
692
693 fn is_focus_ring_enabled(&self) -> bool {
694 self.options.focus_ring_enabled
695 }
696}
697
698impl<D> EventEmitter<SelectEvent<D>> for SelectState<D>
699where
700 D: SearchableListDelegate + 'static,
701 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
702{
703}
704
705impl<D> EventEmitter<DismissEvent> for SelectState<D>
706where
707 D: SearchableListDelegate + 'static,
708 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
709{
710}
711
712impl<D> Focusable for SelectState<D>
713where
714 D: SearchableListDelegate + 'static,
715 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
716{
717 fn focus_handle(&self, cx: &App) -> FocusHandle {
718 if self.state.open {
719 self.state.list.focus_handle(cx)
720 } else {
721 self.state.focus_handle.clone()
722 }
723 }
724}
725
726impl<D> Styled for Select<D>
727where
728 D: SearchableListDelegate + 'static,
729 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
730{
731 fn style(&mut self) -> &mut StyleRefinement {
732 &mut self.options.style
733 }
734}
735
736impl<D> RenderOnce for Select<D>
737where
738 D: SearchableListDelegate + 'static,
739 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
740{
741 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
742 let disabled = self.options.disabled;
743 let accessibility_label = self.options.accessibility_label.clone();
744 let focus_handle = self.state.read(cx).state.focus_handle.clone();
745 let empty = self.empty;
746 let opts = self.options;
747
748 self.state.update(cx, |this, _| {
749 this.state.style = opts.style;
750 this.state.size = opts.size;
751 this.state.cleanable = opts.cleanable;
752 this.state.placeholder = opts.placeholder;
753 this.state.search_placeholder = opts.search_placeholder;
754 this.state.menu_width = opts.menu_width;
755 this.state.menu_max_h = opts.menu_max_h;
756 this.state.disabled = opts.disabled;
757 this.state.appearance = opts.appearance;
758 this.focus_ring_enabled = opts.focus_ring_enabled;
759 this.icon = opts.icon;
760 this.title_prefix = opts.title_prefix;
761
762 if let Some(empty) = empty {
763 this.state.empty = Some(empty);
764 }
765 });
766
767 let is_open = self.state.read(cx).state.open;
768 let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
769 let open_state = self.state.clone();
770
771 BaseSelect::new(self.id)
772 .open(is_open)
773 .disabled(disabled)
774 .when_some(accessibility_label, |this, label| {
775 this.accessibility_label(label)
776 })
777 .focus_handle(&focus_handle)
778 .content_focus_handle(&content_focus_handle)
779 .on_open_change(move |open, _, cx| {
780 open_state.update(cx, |state, cx| state.set_open(open, cx));
781 })
782 .size_full()
783 .child(self.state)
784 }
785}
786
787#[cfg(test)]
790mod tests {
791 use gpui::{AppContext as _, TestAppContext};
792
793 use crate::{
794 IndexPath,
795 searchable_list::{SearchableListDelegate as _, SearchableVec},
796 select::{Select, SelectGroup, SelectState},
797 };
798
799 #[gpui::test]
800 fn an_explicit_accessibility_label_does_not_replace_the_placeholder(cx: &mut TestAppContext) {
801 cx.update(crate::init);
802 let cx = cx.add_empty_window();
803 cx.update(|window, cx| {
804 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
805 let state = cx.new(|cx| SelectState::new(items, None, window, cx));
806
807 let plain = Select::new(&state).placeholder("Choose a language");
808 assert_eq!(plain.options.accessibility_label, None);
809 assert_eq!(
810 plain.options.placeholder.as_deref(),
811 Some("Choose a language")
812 );
813
814 let named = Select::new(&state)
815 .placeholder("Choose a language")
816 .accessibility_label("Programming language");
817 assert_eq!(
818 named.options.accessibility_label.as_deref(),
819 Some("Programming language")
820 );
821 assert_eq!(
822 named.options.placeholder.as_deref(),
823 Some("Choose a language"),
824 "an accessible name must not change what is drawn"
825 );
826 });
827 }
828
829 #[gpui::test]
830 fn test_select_initial_selection_seeds_cursor(cx: &mut TestAppContext) {
831 cx.update(crate::init);
832 let cx = cx.add_empty_window();
833 cx.update(|window, cx| {
834 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
835 let state = cx.new(|cx| SelectState::new(items, Some(IndexPath::new(1)), window, cx));
836
837 assert_eq!(
838 state.read(cx).selected_index(cx),
839 Some(IndexPath::new(1)),
840 "initial cursor should be seeded on ListState so display_title can read it",
841 );
842 assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
843 });
844 }
845
846 #[gpui::test]
847 fn test_select_initial_grouped_selection_seeds_cursor(cx: &mut TestAppContext) {
848 cx.update(crate::init);
849 let cx = cx.add_empty_window();
850 cx.update(|window, cx| {
851 let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
852 groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
853 groups.push(SelectGroup::new("B").items(["Banana", "Blueberry", "Blackberry"]));
854
855 let initial = IndexPath::new(1).section(1);
856 let state = cx.new(|cx| SelectState::new(groups, Some(initial), window, cx));
857
858 assert_eq!(state.read(cx).selected_index(cx), Some(initial));
859 assert_eq!(state.read(cx).selected_value(), Some(&"Blueberry"));
860 });
861 }
862
863 #[gpui::test]
864 fn test_select_set_selected_value_clears_search_query(cx: &mut TestAppContext) {
865 cx.update(crate::init);
866 let cx = cx.add_empty_window();
867 cx.update(|window, cx| {
868 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
869 let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
870 let list = state.read(cx).state.list.clone();
871
872 list.update(cx, |list, cx| list.set_query("Rust", window, cx));
873 assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
874
875 state.update(cx, |state, cx| {
876 state.set_selected_value(&"Go", window, cx);
877 });
878
879 assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
880 assert_eq!(state.read(cx).selected_index(cx), Some(IndexPath::new(1)));
881 assert_eq!(list.read(cx).query_input.read(cx).value(), "");
882 });
883 }
884
885 #[gpui::test]
886 fn test_select_set_selected_value_clears_grouped_search_query(cx: &mut TestAppContext) {
887 cx.update(crate::init);
888 let cx = cx.add_empty_window();
889 cx.update(|window, cx| {
890 let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
891 groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
892 groups.push(SelectGroup::new("B").items(["Banana", "Blueberry"]));
893
894 let state = cx.new(|cx| SelectState::new(groups, None, window, cx).searchable(true));
895 let list = state.read(cx).state.list.clone();
896
897 list.update(cx, |list, cx| list.set_query("Blue", window, cx));
898 state.update(cx, |state, cx| {
899 state.set_selected_value(&"Banana", window, cx);
900 });
901
902 assert_eq!(state.read(cx).selected_value(), Some(&"Banana"));
903 assert_eq!(
904 state.read(cx).selected_index(cx),
905 Some(IndexPath::new(0).section(1)),
906 );
907 });
908 }
909}