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
26pub use crate::searchable_list::SearchableGroup as SelectGroup;
30pub use crate::searchable_list::SearchableListDelegate as SelectDelegate;
32pub use crate::searchable_list::SearchableListItem as SelectItem;
34pub use crate::searchable_list::SearchableListItemElement as SelectListItem;
36pub use crate::searchable_list::SearchableVec;
38
39#[derive(IntoElement)]
40pub struct Caret {
41 size: Size,
42 color: Option<Hsla>,
43}
44
45impl Caret {
46 pub fn new(size: Size) -> Self {
48 Self { size, color: None }
49 }
50
51 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
70pub 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
78struct 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 scroll_bounce: Option<bool>,
92 disabled: bool,
93 appearance: bool,
94 focus_ring_enabled: bool,
95}
96
97impl Default for SelectOptions {
98 fn default() -> Self {
99 Self {
100 style: StyleRefinement::default(),
101 size: Size::default(),
102 icon: None,
103 cleanable: false,
104 placeholder: None,
105 accessibility_label: None,
106 title_prefix: None,
107 menu_width: Length::Auto,
108 menu_max_h: rems(20.).into(),
109 scroll_bounce: None,
110 disabled: false,
111 appearance: true,
112 focus_ring_enabled: true,
113 search_placeholder: None,
114 }
115 }
116}
117
118pub struct SelectState<D: SearchableListDelegate + 'static>
124where
125 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
126{
127 pub(crate) state: SearchableListState<D>,
128
129 searchable: bool,
131 icon: Option<Icon>,
132 title_prefix: Option<SharedString>,
133 focus_ring_enabled: bool,
134}
135
136#[derive(IntoElement)]
138pub struct Select<D: SearchableListDelegate + 'static>
139where
140 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
141{
142 id: ElementId,
143 state: Entity<SelectState<D>>,
144 options: SelectOptions,
145 empty: Option<Box<dyn Fn(&mut Window, &App) -> AnyElement + 'static>>,
146}
147
148impl<D> SelectState<D>
149where
150 D: SearchableListDelegate + 'static,
151 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
152{
153 pub fn new(
155 delegate: D,
156 selected_index: Option<IndexPath>,
157 window: &mut Window,
158 cx: &mut Context<Self>,
159 ) -> Self {
160 let weak = cx.entity().downgrade();
161 let weak_confirm = weak.clone();
162 let weak_cancel = weak.clone();
163 let weak_empty = weak;
164
165 let selected_indices = selected_index.into_iter().collect::<Vec<_>>();
166
167 let state = SearchableListState::new(
168 delegate,
169 selected_indices,
170 move |selected_index, _secondary, window, cx| {
172 cx.defer_in(window, {
173 let weak_confirm = weak_confirm.clone();
174 move |list_state, window, cx| {
175 let mut selection = weak_confirm
176 .upgrade()
177 .map(|e| e.read(cx).state.selection.clone())
178 .unwrap_or_default();
179
180 let changes = {
181 let mut changes: Vec<SearchableListChange> = selection
182 .iter()
183 .map(|(ix, _)| SearchableListChange::Deselect { index: *ix })
184 .collect();
185
186 if let Some(ix) = selected_index {
187 changes.push(SearchableListChange::Select { index: ix });
188 }
189
190 changes
191 };
192
193 list_state
196 .delegate_mut()
197 .delegate
198 .on_will_change(&mut selection, &changes);
199
200 let confirmed = weak_confirm.update(cx, |this, cx| {
201 this.state.selection = selection;
202
203 let final_value =
204 this.state.selection.first().map(|(_, i)| i.value().clone());
205
206 cx.emit(SelectEvent::Confirm(final_value.clone()));
207 cx.notify();
208 this.set_open(false, cx);
209 this.focus(window, cx);
210
211 (this.state.selection.clone(), final_value)
212 });
213
214 if let Ok((mut new_selection, final_value)) = confirmed {
220 if !list_state.query_input.read(cx).value().is_empty() {
221 list_state.set_query("", window, cx);
222 }
223
224 if let Some(ix) = final_value
225 .as_ref()
226 .and_then(|value| list_state.delegate().delegate.position(value))
227 {
228 list_state.set_selected_index(Some(ix), window, cx);
229 if let Some((slot, _)) = new_selection.first_mut() {
230 *slot = ix;
231 }
232 _ = weak_confirm.update(cx, |this, cx| {
233 if let Some((slot, _)) = this.state.selection.first_mut() {
234 *slot = ix;
235 }
236 cx.notify();
237 });
238 }
239
240 list_state
241 .delegate_mut()
242 .update_selection_snapshot(new_selection.clone());
243 list_state
244 .delegate_mut()
245 .delegate
246 .on_confirm(&new_selection);
247 }
248 }
249 });
250 },
251 move |_final_selected_index, window, cx| {
253 cx.defer_in(window, {
254 let weak_cancel = weak_cancel.clone();
255 move |list_state, window, cx| {
256 let committed_ix = weak_cancel
257 .upgrade()
258 .and_then(|e| e.read(cx).state.selection.first().map(|(ix, _)| *ix));
259
260 if !list_state.query_input.read(cx).value().is_empty() {
261 list_state.set_query("", window, cx);
262 }
263 list_state.set_selected_index(committed_ix, window, cx);
264
265 _ = weak_cancel.update(cx, |this, cx| {
266 this.set_open(false, cx);
267 this.focus(window, cx);
268 });
269 }
270 });
271 },
272 move |window, cx| {
274 if let Some(empty) = weak_empty
275 .upgrade()
276 .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx)))
277 {
278 empty
279 } else {
280 h_flex()
281 .justify_center()
282 .py_6()
283 .text_color(cx.theme().muted_foreground.opacity(0.6))
284 .child(Icon::new(IconName::Inbox).size(px(28.)))
285 .into_any_element()
286 }
287 },
288 Self::on_blur,
289 window,
290 cx,
291 );
292
293 Self {
294 state,
295 searchable: false,
296 icon: None,
297 title_prefix: None,
298 focus_ring_enabled: true,
299 }
300 }
301
302 pub fn searchable(mut self, searchable: bool) -> Self {
306 self.searchable = searchable;
307 self
308 }
309
310 pub fn set_selected_index(
312 &mut self,
313 selected_index: Option<IndexPath>,
314 window: &mut Window,
315 cx: &mut Context<Self>,
316 ) {
317 self.state.list.update(cx, |list, cx| {
318 list._set_selected_index(selected_index, window, cx);
319 });
320
321 let item = selected_index
322 .and_then(|ix| self.state.list.read(cx).delegate().delegate.item(ix))
323 .map(|i| i.clone());
324
325 self.state.selection = match (selected_index, item) {
326 (Some(ix), Some(item)) => vec![(ix, item)],
327 _ => vec![],
328 };
329 self.state.sync_snapshot(cx);
330 }
331
332 pub fn set_selected_value(
340 &mut self,
341 selected_value: &<D::Item as SearchableListItem>::Value,
342 window: &mut Window,
343 cx: &mut Context<Self>,
344 ) {
345 self.state.clear_query(window, cx);
346
347 let selected_index = self
348 .state
349 .list
350 .read(cx)
351 .delegate()
352 .delegate
353 .position(selected_value);
354
355 self.set_selected_index(selected_index, window, cx);
356 }
357
358 pub fn set_items(&mut self, items: D, window: &mut Window, cx: &mut Context<Self>)
360 where
361 D: SearchableListDelegate + 'static,
362 {
363 let mut selection = self.state.selection.clone();
364 self.state.list.update(cx, |list, cx| {
365 let cursor = list
366 .selected_index()
367 .and_then(|ix| list.delegate().delegate.item(ix))
368 .and_then(|item| items.position(item.value()));
369 selection.retain_mut(|(ix, item)| {
373 let Some(position) = items.position(item.value()) else {
374 return false;
375 };
376 let Some(replacement) = items.item(position) else {
377 return false;
378 };
379 *ix = position;
380 *item = replacement.clone();
381 true
382 });
383 list.delegate_mut().delegate = items;
384 list.set_selected_index(cursor, window, cx);
385 list.delegate_mut()
386 .update_selection_snapshot(selection.clone());
387 cx.notify();
388 });
389 self.state.selection = selection;
390 cx.notify();
391 }
392
393 pub fn selected_index(&self, cx: &App) -> Option<IndexPath> {
395 self.state.list.read(cx).selected_index()
396 }
397
398 pub fn selected_value(&self) -> Option<&<D::Item as SearchableListItem>::Value> {
400 self.state.selection.first().map(|(_, i)| i.value())
401 }
402
403 pub fn focus(&self, window: &mut Window, cx: &mut App) {
405 self.state.focus_handle.focus(window, cx);
406 }
407
408 fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
409 if self.state.list.read(cx).is_focused(window, cx)
410 || self.state.focus_handle.is_focused(window)
411 {
412 return;
413 }
414
415 self.clear_query_and_restore_cursor(window, cx);
416 self.set_open(false, cx);
417 cx.notify();
418 }
419
420 fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
421 cx.stop_propagation();
422
423 self.set_open(!self.state.open, cx);
424
425 if self.state.open {
426 self.state.list.focus_handle(cx).focus(window, cx);
427 } else {
428 self.clear_query_and_restore_cursor(window, cx);
429 }
430
431 cx.notify();
432 }
433
434 fn escape(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
435 if !self.state.open {
436 cx.propagate();
437 return;
438 }
439
440 cx.stop_propagation();
441 self.clear_query_and_restore_cursor(window, cx);
442 self.set_open(false, cx);
443 self.focus(window, cx);
444 cx.notify();
445 }
446
447 fn clear_query_and_restore_cursor(&mut self, window: &mut Window, cx: &mut Context<Self>) {
451 self.state.clear_query(window, cx);
452
453 let committed_ix = self.state.selection.first().map(|(ix, _)| *ix);
454 self.state.list.update(cx, |list, cx| {
455 if list.selected_index() != committed_ix {
456 list.set_selected_index(committed_ix, window, cx);
457 }
458 });
459 }
460
461 fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
462 let dismissed = self.state.open && !open;
463 self.state.open = open;
464 self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
465
466 if dismissed {
467 cx.emit(DismissEvent);
468 }
469 cx.notify();
470 }
471
472 fn clean(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
473 cx.stop_propagation();
474 self.set_selected_index(None, window, cx);
475 cx.emit(SelectEvent::Confirm(None));
476 }
477
478 fn display_title(&mut self, _: &Window, cx: &mut Context<Self>) -> impl IntoElement {
479 let default_title = div().text_color(cx.theme().muted_foreground).child(
480 self.state
481 .placeholder
482 .clone()
483 .unwrap_or_else(|| t!("Select.placeholder").into()),
484 );
485
486 let Some(selected_index) = self.selected_index(cx) else {
487 return default_title;
488 };
489
490 let Some(title) = self
491 .state
492 .list
493 .read(cx)
494 .delegate()
495 .delegate
496 .item(selected_index)
497 .map(|item| {
498 if let Some(el) = item.display_title() {
499 el
500 } else if let Some(prefix) = self.title_prefix.as_ref() {
501 format!("{}{}", prefix, item.title()).into_any_element()
502 } else {
503 item.title().into_any_element()
504 }
505 })
506 else {
507 return default_title;
508 };
509
510 div()
511 .when(self.state.disabled, |this| {
512 this.text_color(cx.theme().muted_foreground)
513 })
514 .child(title)
515 }
516
517 fn accessibility_value(&self) -> SharedString {
518 let Some((_, item)) = self.state.selection.first() else {
519 return self
520 .state
521 .placeholder
522 .clone()
523 .unwrap_or_else(|| t!("Select.placeholder").into());
524 };
525
526 if let Some(prefix) = self.title_prefix.as_ref() {
527 format!("{}{}", prefix, item.title()).into()
528 } else {
529 item.title()
530 }
531 }
532}
533
534impl<D> Render for SelectState<D>
535where
536 D: SearchableListDelegate + 'static,
537 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
538{
539 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
540 let searchable = self.searchable;
541 let is_focused = self.state.focus_handle.is_focused(window);
542 let show_clean = self.state.cleanable && self.selected_index(cx).is_some();
543 let bounds = self.state.bounds;
544 let allow_open = !(self.state.open || self.state.disabled);
545 let outline_visible = self.state.open || (is_focused && !self.state.disabled);
546
547 let (bg, fg) = input_style(self.state.disabled, cx);
548
549 self.state.list.update(cx, |list, cx| {
550 list.set_searchable(searchable, cx);
551 list.delegate_mut().size = self.state.size;
552 });
553
554 div().size_full().relative().child(
555 div()
556 .relative()
557 .on_prepaint({
558 let state = cx.entity();
559 move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)
560 })
561 .child(
562 div()
563 .id("input")
564 .test_support()
565 .relative()
566 .flex()
567 .items_center()
568 .justify_between()
569 .border_1()
570 .border_color(cx.theme().transparent)
571 .when(self.state.appearance, |this| {
572 this.bg(bg)
573 .text_color(fg)
574 .when(self.state.disabled, |this| this.opacity(0.5))
575 .border_color(cx.theme().input)
576 .rounded(cx.theme().radius)
577 })
578 .input_size(self.state.size)
579 .input_text_size(self.state.size)
580 .refine_style(&self.state.style)
581 .when(outline_visible && self.state.appearance, |this| {
582 this.border_1().border_color(cx.theme().ring)
583 })
584 .when(
585 outline_visible && self.state.appearance && self.focus_ring_enabled,
586 |this| this.focus_ring_style(window, cx),
587 )
588 .when(allow_open, |this| {
589 this.on_click(cx.listener(Self::toggle_menu))
590 })
591 .child(
592 h_flex()
593 .id("inner")
594 .w_full()
595 .min_w_0()
596 .overflow_hidden()
597 .whitespace_nowrap()
598 .items_center()
599 .justify_between()
600 .gap_1()
601 .child(
602 div()
603 .id("title")
604 .flex_1()
605 .min_w_0()
606 .overflow_hidden()
607 .whitespace_nowrap()
608 .truncate()
609 .child(self.display_title(window, cx)),
610 )
611 .when(show_clean, |this| {
612 this.child(clear_button(cx).map(|this| {
613 if self.state.disabled {
614 this.disabled(true)
615 } else {
616 this.on_click(cx.listener(Self::clean))
617 }
618 }))
619 })
620 .when(!show_clean, |this| {
621 let icon = match self.icon.clone() {
622 Some(icon) => icon
623 .xsmall()
624 .text_color(cx.theme().muted_foreground)
625 .into_any_element(),
626 None => Caret::new(self.state.size)
627 .text_color(cx.theme().muted_foreground)
628 .into_any_element(),
629 };
630
631 this.child(icon)
632 }),
633 ),
634 )
635 .when(self.state.open, |this| {
636 this.child(
637 deferred(crate::popover::dropdown_popup(
638 ("select-popup", cx.entity_id()),
639 bounds,
640 v_flex()
641 .occlude()
642 .map(|this| match self.state.menu_width {
643 Length::Auto => this.w(bounds.size.width),
644 Length::Definite(w) => this.w(w),
645 })
646 .popover_style(cx)
647 .child(
648 List::new(&self.state.list)
649 .when_some(
650 self.state.search_placeholder.clone(),
651 |this, placeholder| {
652 this.search_placeholder(placeholder)
653 },
654 )
655 .with_size(self.state.size)
656 .max_h(self.state.menu_max_h)
657 .when_some(self.state.scroll_bounce, |list, on| {
658 list.scroll_bounce(on)
659 })
660 .paddings(Edges::all(DROPDOWN_LIST_PADDING)),
661 )
662 .on_mouse_down_out(cx.listener(|this, _, window, cx| {
663 this.escape(&Cancel, window, cx);
664 })),
665 cx,
666 ))
667 .with_priority(gpui_base::POPUP_PRIORITY),
668 )
669 }),
670 )
671 }
672}
673
674impl<D> Select<D>
675where
676 D: SearchableListDelegate + 'static,
677 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
678{
679 pub fn new(state: &Entity<SelectState<D>>) -> Self {
680 Self {
681 id: ("select", state.entity_id()).into(),
682 state: state.clone(),
683 options: SelectOptions::default(),
684 empty: None,
685 }
686 }
687
688 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
690 self.id = id.into();
691 self
692 }
693
694 pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
696 self.options.menu_width = width.into();
697 self
698 }
699
700 pub fn menu_max_h(mut self, max_h: impl Into<Length>) -> Self {
702 self.options.menu_max_h = max_h.into();
703 self
704 }
705
706 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
708 self.options.placeholder = Some(placeholder.into());
709 self
710 }
711
712 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
717 self.options.accessibility_label = Some(label.into());
718 self
719 }
720
721 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
723 self.options.icon = Some(icon.into());
724 self
725 }
726
727 pub fn title_prefix(mut self, prefix: impl Into<SharedString>) -> Self {
731 self.options.title_prefix = Some(prefix.into());
732 self
733 }
734
735 pub fn cleanable(mut self, cleanable: bool) -> Self {
737 self.options.cleanable = cleanable;
738 self
739 }
740
741 pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
743 self.options.search_placeholder = Some(placeholder.into());
744 self
745 }
746
747 pub fn scroll_bounce(mut self, enabled: bool) -> Self {
750 self.options.scroll_bounce = Some(enabled);
751 self
752 }
753
754 pub fn disabled(mut self, disabled: bool) -> Self {
756 self.options.disabled = disabled;
757 self
758 }
759
760 pub fn empty<E: IntoElement + 'static>(
762 mut self,
763 builder: impl Fn(&mut Window, &App) -> E + 'static,
764 ) -> Self {
765 self.empty = Some(Box::new(move |window, cx| {
766 builder(window, cx).into_any_element()
767 }));
768 self
769 }
770
771 pub fn appearance(mut self, appearance: bool) -> Self {
773 self.options.appearance = appearance;
774 self
775 }
776}
777
778impl<D> Sizable for Select<D>
779where
780 D: SearchableListDelegate + 'static,
781 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
782{
783 fn with_size(mut self, size: impl Into<Size>) -> Self {
784 self.options.size = size.into();
785 self
786 }
787}
788
789impl<D> crate::FocusableExt for Select<D>
790where
791 D: SearchableListDelegate + 'static,
792 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
793{
794 fn focus_ring(mut self, enabled: bool) -> Self {
795 self.options.focus_ring_enabled = enabled;
796 self
797 }
798
799 fn is_focus_ring_enabled(&self) -> bool {
800 self.options.focus_ring_enabled
801 }
802}
803
804impl<D> EventEmitter<SelectEvent<D>> for SelectState<D>
805where
806 D: SearchableListDelegate + 'static,
807 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
808{
809}
810
811impl<D> EventEmitter<DismissEvent> for SelectState<D>
812where
813 D: SearchableListDelegate + 'static,
814 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
815{
816}
817
818impl<D> Focusable for SelectState<D>
819where
820 D: SearchableListDelegate + 'static,
821 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
822{
823 fn focus_handle(&self, cx: &App) -> FocusHandle {
824 if self.state.open {
825 self.state.list.focus_handle(cx)
826 } else {
827 self.state.focus_handle.clone()
828 }
829 }
830}
831
832impl<D> Styled for Select<D>
833where
834 D: SearchableListDelegate + 'static,
835 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
836{
837 fn style(&mut self) -> &mut StyleRefinement {
838 &mut self.options.style
839 }
840}
841
842impl<D> RenderOnce for Select<D>
843where
844 D: SearchableListDelegate + 'static,
845 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
846{
847 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
848 let disabled = self.options.disabled;
849 let accessibility_label = self.options.accessibility_label.clone();
850 let focus_handle = self.state.read(cx).state.focus_handle.clone();
851 let empty = self.empty;
852 let opts = self.options;
853
854 self.state.update(cx, |this, _| {
855 this.state.style = opts.style;
856 this.state.size = opts.size;
857 this.state.cleanable = opts.cleanable;
858 this.state.placeholder = opts.placeholder;
859 this.state.search_placeholder = opts.search_placeholder;
860 this.state.menu_width = opts.menu_width;
861 this.state.menu_max_h = opts.menu_max_h;
862 this.state.scroll_bounce = opts.scroll_bounce;
863 this.state.disabled = opts.disabled;
864 this.state.appearance = opts.appearance;
865 this.focus_ring_enabled = opts.focus_ring_enabled;
866 this.icon = opts.icon;
867 this.title_prefix = opts.title_prefix;
868
869 if let Some(empty) = empty {
870 this.state.empty = Some(empty);
871 }
872 });
873
874 let is_open = self.state.read(cx).state.open;
875 let accessibility_value = self.state.read(cx).accessibility_value();
876 let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
877 let open_state = self.state.clone();
878
879 BaseSelect::new(self.id)
880 .open(is_open)
881 .disabled(disabled)
882 .when_some(accessibility_label, |this, label| {
883 this.accessibility_label(label)
884 })
885 .focus_handle(&focus_handle)
886 .content_focus_handle(&content_focus_handle)
887 .accessibility_value(accessibility_value)
888 .on_open_change(move |open, window, cx| {
889 open_state.update(cx, |state, cx| {
890 if !open {
891 state.clear_query_and_restore_cursor(window, cx);
892 }
893 state.set_open(open, cx);
894 });
895 })
896 .size_full()
897 .child(self.state)
898 }
899}
900
901#[cfg(test)]
904mod tests {
905 use gpui::{AppContext as _, RenderOnce as _, TestAppContext};
906
907 use crate::{
908 IndexPath,
909 searchable_list::{SearchableListDelegate as _, SearchableVec},
910 select::{Select, SelectGroup, SelectState},
911 };
912
913 #[gpui::test]
914 fn an_explicit_accessibility_label_does_not_replace_the_placeholder(cx: &mut TestAppContext) {
915 cx.update(crate::init);
916 let cx = cx.add_empty_window();
917 cx.update(|window, cx| {
918 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
919 let state = cx.new(|cx| SelectState::new(items, None, window, cx));
920
921 let plain = Select::new(&state).placeholder("Choose a language");
922 assert_eq!(plain.options.accessibility_label, None);
923 assert_eq!(
924 plain.options.placeholder.as_deref(),
925 Some("Choose a language")
926 );
927
928 let named = Select::new(&state)
929 .placeholder("Choose a language")
930 .accessibility_label("Programming language");
931 assert_eq!(
932 named.options.accessibility_label.as_deref(),
933 Some("Programming language")
934 );
935 assert_eq!(
936 named.options.placeholder.as_deref(),
937 Some("Choose a language"),
938 "an accessible name must not change what is drawn"
939 );
940 });
941 }
942
943 #[gpui::test]
944 fn test_select_initial_selection_seeds_cursor(cx: &mut TestAppContext) {
945 cx.update(crate::init);
946 let cx = cx.add_empty_window();
947 cx.update(|window, cx| {
948 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
949 let state = cx.new(|cx| SelectState::new(items, Some(IndexPath::new(1)), window, cx));
950
951 assert_eq!(
952 state.read(cx).selected_index(cx),
953 Some(IndexPath::new(1)),
954 "initial cursor should be seeded on ListState so display_title can read it",
955 );
956 assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
957 });
958 }
959
960 #[gpui::test]
961 fn test_select_initial_grouped_selection_seeds_cursor(cx: &mut TestAppContext) {
962 cx.update(crate::init);
963 let cx = cx.add_empty_window();
964 cx.update(|window, cx| {
965 let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
966 groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
967 groups.push(SelectGroup::new("B").items(["Banana", "Blueberry", "Blackberry"]));
968
969 let initial = IndexPath::new(1).section(1);
970 let state = cx.new(|cx| SelectState::new(groups, Some(initial), window, cx));
971
972 assert_eq!(state.read(cx).selected_index(cx), Some(initial));
973 assert_eq!(state.read(cx).selected_value(), Some(&"Blueberry"));
974 });
975 }
976
977 #[gpui::test]
978 fn test_select_set_selected_value_clears_search_query(cx: &mut TestAppContext) {
979 cx.update(crate::init);
980 let cx = cx.add_empty_window();
981 cx.update(|window, cx| {
982 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
983 let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
984 let list = state.read(cx).state.list.clone();
985
986 list.update(cx, |list, cx| list.set_query("Rust", window, cx));
987 assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
988
989 state.update(cx, |state, cx| {
990 state.set_selected_value(&"Go", window, cx);
991 });
992
993 assert_eq!(state.read(cx).selected_value(), Some(&"Go"));
994 assert_eq!(state.read(cx).selected_index(cx), Some(IndexPath::new(1)));
995 assert_eq!(list.read(cx).query_input.read(cx).value(), "");
996 });
997 }
998
999 #[gpui::test]
1000 fn test_select_set_selected_value_clears_grouped_search_query(cx: &mut TestAppContext) {
1001 cx.update(crate::init);
1002 let cx = cx.add_empty_window();
1003 cx.update(|window, cx| {
1004 let mut groups: SearchableVec<SelectGroup<&'static str>> = SearchableVec::new(vec![]);
1005 groups.push(SelectGroup::new("A").items(["Apple", "Avocado"]));
1006 groups.push(SelectGroup::new("B").items(["Banana", "Blueberry"]));
1007
1008 let state = cx.new(|cx| SelectState::new(groups, None, window, cx).searchable(true));
1009 let list = state.read(cx).state.list.clone();
1010
1011 list.update(cx, |list, cx| list.set_query("Blue", window, cx));
1012 state.update(cx, |state, cx| {
1013 state.set_selected_value(&"Banana", window, cx);
1014 });
1015
1016 assert_eq!(state.read(cx).selected_value(), Some(&"Banana"));
1017 assert_eq!(
1018 state.read(cx).selected_index(cx),
1019 Some(IndexPath::new(0).section(1)),
1020 );
1021 });
1022 }
1023
1024 #[gpui::test]
1025 fn test_select_accessibility_value_tracks_placeholder_and_selection(cx: &mut TestAppContext) {
1026 cx.update(crate::init);
1027 let window = cx.add_empty_window();
1028 window.update(|window, cx| {
1029 let items = SearchableVec::new(vec!["Rust", "Go"]);
1030 let state = cx.new(|cx| SelectState::new(items, None, window, cx).searchable(true));
1031
1032 _ = Select::new(&state)
1033 .placeholder("Choose a language")
1034 .accessibility_label("Programming language")
1035 .render(window, cx);
1036 assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
1037
1038 state.update(cx, |state, cx| {
1039 state.set_selected_value(&"Rust", window, cx);
1040 });
1041 assert_eq!(state.read(cx).accessibility_value(), "Rust");
1042
1043 let list = state.read(cx).state.list.clone();
1044 list.update(cx, |list, cx| list.set_query("Go", window, cx));
1045 assert_eq!(list.read(cx).delegate().delegate.items_count(0), 1);
1046 assert_eq!(state.read(cx).accessibility_value(), "Rust");
1048
1049 _ = Select::new(&state)
1050 .placeholder("Choose a language")
1051 .title_prefix("Language: ")
1052 .render(window, cx);
1053 assert_eq!(state.read(cx).accessibility_value(), "Language: Rust");
1054
1055 state.update(cx, |state, cx| state.set_selected_index(None, window, cx));
1056 assert_eq!(state.read(cx).accessibility_value(), "Choose a language");
1057
1058 _ = Select::new(&state).render(window, cx);
1059 assert_eq!(
1060 state.read(cx).accessibility_value(),
1061 rust_i18n::t!("Select.placeholder").to_string(),
1062 );
1063 });
1064 }
1065
1066 #[gpui::test]
1067 fn replacing_items_reconciles_the_cursor_and_selection(cx: &mut TestAppContext) {
1068 cx.update(crate::init);
1069 let cx = cx.add_empty_window();
1070 cx.update(|window, cx| {
1071 let state = cx.new(|cx| {
1072 SelectState::new(
1073 vec!["main", "topic", "release"],
1074 Some(IndexPath::new(0)),
1075 window,
1076 cx,
1077 )
1078 });
1079 let list = state.read(cx).state.list.clone();
1080 list.update(cx, |list, cx| {
1081 list.set_selected_index(Some(IndexPath::new(1)), window, cx)
1082 });
1083 state.update(cx, |state, cx| {
1084 state.set_items(vec!["release", "main", "topic"], window, cx)
1085 });
1086 assert_eq!(state.read(cx).selected_index(cx), Some(IndexPath::new(2)));
1087 assert_eq!(state.read(cx).selected_value(), Some(&"main"));
1088 assert_eq!(state.read(cx).state.selection[0].0, IndexPath::new(1));
1089 assert_eq!(
1090 list.read(cx).delegate().selection_snapshot[0].0,
1091 IndexPath::new(1)
1092 );
1093 state.update(cx, |state, cx| {
1094 state.set_items(vec!["main", "replacement"], window, cx)
1095 });
1096 assert_eq!(state.read(cx).selected_index(cx), None);
1097 assert_eq!(state.read(cx).selected_value(), Some(&"main"));
1098
1099 state.update(cx, |state, cx| {
1101 state.set_items(vec!["replacement", "other"], window, cx)
1102 });
1103 assert_eq!(state.read(cx).selected_value(), None);
1104 assert!(state.read(cx).state.selection.is_empty());
1105 assert!(list.read(cx).delegate().selection_snapshot.is_empty());
1106 });
1107 }
1108
1109 #[derive(Clone)]
1110 struct RemoteChoice {
1111 value: &'static str,
1112 title: &'static str,
1113 unavailable: bool,
1114 }
1115
1116 impl super::SelectItem for RemoteChoice {
1117 type Value = &'static str;
1118 fn value(&self) -> &&'static str {
1119 &self.value
1120 }
1121 fn title(&self) -> gpui::SharedString {
1122 self.title.into()
1123 }
1124 fn disabled(&self) -> bool {
1125 self.unavailable
1126 }
1127 }
1128
1129 #[gpui::test]
1130 fn disabled_items_cannot_be_confirmed(cx: &mut TestAppContext) {
1131 use crate::list::ListDelegate as _;
1132 cx.update(crate::init);
1133 let cx = cx.add_empty_window();
1134 for unavailable in [true, false] {
1135 let state = cx.update(|window, cx| {
1136 let state = cx.new(|cx| {
1137 SelectState::new(
1138 vec![RemoteChoice {
1139 value: "main",
1140 title: "Main",
1141 unavailable,
1142 }],
1143 Some(IndexPath::new(0)),
1144 window,
1145 cx,
1146 )
1147 });
1148 state.update(cx, |state, cx| state.set_open(true, cx));
1149 let list = state.read(cx).state.list.clone();
1150 list.update(cx, |list, cx| {
1151 list.delegate_mut().confirm(false, window, cx)
1152 });
1153 state
1154 });
1155 cx.run_until_parked();
1156 cx.update(|_, cx| assert_eq!(state.read(cx).state.open, unavailable));
1157 }
1158 }
1159
1160 #[cfg(feature = "gpui-fast")]
1162 mod retained {
1163 use std::{cell::Cell, rc::Rc};
1164
1165 use gpui::{
1166 AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
1167 TestAppContext, Window, div, px,
1168 };
1169
1170 use crate::{
1171 searchable_list::SearchableVec,
1172 select::{Select, SelectState},
1173 };
1174
1175 struct Page {
1178 state: Entity<SelectState<SearchableVec<&'static str>>>,
1179 renders: Rc<Cell<usize>>,
1180 }
1181
1182 impl Render for Page {
1183 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1184 self.renders.set(self.renders.get() + 1);
1185 let selected = self.state.read(cx).selected_index(cx);
1186 div()
1187 .w(px(240.))
1188 .child(Select::new(&self.state))
1189 .child(format!("{selected:?}"))
1190 }
1191 }
1192
1193 #[gpui::test]
1196 fn drawing_a_select_again_leaves_the_page_around_it_retained(cx: &mut TestAppContext) {
1197 cx.update(crate::init);
1198 let renders = Rc::new(Cell::new(0));
1199 let (_, cx) = cx.add_window_view({
1200 let renders = renders.clone();
1201 move |window, cx| {
1202 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1203 Page {
1204 state: cx.new(|cx| SelectState::new(items, None, window, cx)),
1205 renders,
1206 }
1207 }
1208 });
1209 cx.update(|window, cx| window.draw(cx).clear(cx));
1210 cx.update(|window, cx| window.draw(cx).clear(cx));
1211 let rendered = renders.get();
1212 for _ in 0..3 {
1213 cx.update(|window, cx| window.draw(cx).clear(cx));
1214 }
1215 assert_eq!(renders.get(), rendered, "the page was built again");
1216 }
1217 }
1218}