1use gpui::{
2 AnyElement, App, Bounds, ClickEvent, Context, DismissEvent, Edges, ElementId, Entity,
3 EventEmitter, FocusHandle, Focusable, Hsla, InteractiveElement, IntoElement, Length,
4 MouseDownEvent, ParentElement, Pixels, Render, RenderOnce, SharedString,
5 StatefulInteractiveElement, StyleRefinement, Styled, Window, deferred, div,
6 prelude::FluentBuilder, px, rems,
7};
8
9use rust_i18n::t;
10
11pub use crate::select::Caret;
12
13use crate::ThemeStyled as _;
14use crate::{
15 ActiveTheme, Disableable, ElementExt as _, Icon, IconName, IndexPath, Sizable, Size,
16 StyleSized, StyledExt, h_flex,
17 input::{clear_button, input_style},
18 list::{List, ListState},
19 searchable_list::{
20 SearchableListAdapter, SearchableListChange, SearchableListDelegate, SearchableListItem,
21 SearchableListState,
22 },
23 sizing::DROPDOWN_LIST_PADDING,
24 v_flex,
25};
26use gpui_base::{Combobox as BaseCombobox, GlobalState};
27
28pub struct ComboboxTriggerContext<'a, D: SearchableListDelegate + 'static> {
35 selection: &'a [(IndexPath, D::Item)],
36 placeholder: Option<&'a SharedString>,
37 open: bool,
38 disabled: bool,
39 size: Size,
40}
41
42impl<'a, D: SearchableListDelegate + 'static> ComboboxTriggerContext<'a, D> {
43 pub fn selection(&self) -> &'a [(IndexPath, D::Item)] {
45 self.selection
46 }
47
48 pub fn placeholder(&self) -> Option<&'a SharedString> {
49 self.placeholder
50 }
51
52 pub fn is_open(&self) -> bool {
54 self.open
55 }
56
57 pub fn is_disabled(&self) -> bool {
58 self.disabled
59 }
60
61 pub fn size(&self) -> Size {
62 self.size
63 }
64}
65
66pub type ComboboxChange = SearchableListChange;
70
71struct ComboboxOptions {
74 style: StyleRefinement,
75 size: Size,
76 cleanable: bool,
77 placeholder: Option<SharedString>,
78 search_placeholder: Option<SharedString>,
79 menu_width: Length,
80 menu_max_h: Length,
81 disabled: bool,
82 appearance: bool,
83 focus_ring_enabled: bool,
84 trigger_icon: Option<Icon>,
85 check_icon: Option<Icon>,
86}
87
88impl Default for ComboboxOptions {
89 fn default() -> Self {
90 Self {
91 style: StyleRefinement::default(),
92 size: Size::default(),
93 cleanable: false,
94 placeholder: None,
95 search_placeholder: None,
96 menu_width: Length::Auto,
97 menu_max_h: rems(20.).into(),
98 disabled: false,
99 appearance: true,
100 focus_ring_enabled: true,
101 trigger_icon: None,
102 check_icon: None,
103 }
104 }
105}
106
107pub struct ComboboxState<D: SearchableListDelegate + 'static>
111where
112 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
113{
114 pub(crate) state: SearchableListState<D>,
115
116 multiple: bool,
118 searchable: bool,
119 trigger_icon: Option<Icon>,
120 check_icon: Option<Icon>,
121 render_trigger: Option<
122 Box<dyn Fn(&ComboboxTriggerContext<D>, &mut Window, &mut App) -> AnyElement + 'static>,
123 >,
124 footer: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
125 focus_ring_enabled: bool,
126}
127
128pub enum ComboboxEvent<D: SearchableListDelegate + 'static>
130where
131 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
132{
133 Change(Vec<<D::Item as SearchableListItem>::Value>),
135 Confirm(Vec<<D::Item as SearchableListItem>::Value>),
137}
138
139impl<D> ComboboxState<D>
140where
141 D: SearchableListDelegate + 'static,
142 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
143{
144 pub fn new(
146 delegate: D,
147 selected_indices: Vec<IndexPath>,
148 window: &mut Window,
149 cx: &mut Context<Self>,
150 ) -> Self {
151 let weak = cx.entity().downgrade();
152 let weak_confirm = weak.clone();
153 let weak_cancel = weak.clone();
154 let weak_empty = weak;
155
156 let state = SearchableListState::new(
157 delegate,
158 selected_indices,
159 move |selected_index, _secondary, window, cx| {
160 cx.defer_in(window, {
161 let weak_confirm = weak_confirm.clone();
162 move |list_state, window, cx| {
163 let Some(index) = selected_index else {
164 return;
165 };
166
167 let Some(item) = list_state.delegate().delegate.item(index).cloned() else {
168 return;
169 };
170
171 let ix = index;
172
173 let Some(weak) = weak_confirm.upgrade() else {
174 return;
175 };
176
177 let (multiple, mut selection) = {
178 let s = weak.read(cx);
179 (s.multiple, s.state.selection.clone())
180 };
181
182 let changes = Self::selection_changes(multiple, &selection, ix, &item);
183
184 let before_values: Vec<_> = selection
185 .iter()
186 .map(|(_, item)| item.value().clone())
187 .collect();
188
189 list_state
192 .delegate_mut()
193 .delegate
194 .on_will_change(&mut selection, &changes);
195
196 let after_values: Vec<_> = selection
197 .iter()
198 .map(|(_, item)| item.value().clone())
199 .collect();
200 let changed = before_values != after_values;
201 let should_close = !multiple
202 && (changed
203 || selection
204 .iter()
205 .any(|(_, selected)| selected.value() == item.value()));
206
207 let new_selection = weak_confirm.update(cx, |this, cx| {
208 this.state.selection = selection;
209
210 if changed {
211 cx.emit(ComboboxEvent::Change(this.selected_values()));
212 cx.notify();
213 }
214
215 if should_close {
216 cx.emit(ComboboxEvent::Confirm(this.selected_values()));
217 this.set_open(false, cx);
218 this.focus(window, cx);
219 }
220
221 this.state.selection.clone()
222 });
223
224 if let Ok(new_selection) = new_selection {
226 list_state
227 .delegate_mut()
228 .update_selection_snapshot(new_selection.clone());
229
230 if should_close {
231 list_state
232 .delegate_mut()
233 .delegate
234 .on_confirm(&new_selection);
235 }
236 }
237 }
238 });
239 },
240 move |_final_selected_index, window, cx| {
242 cx.defer_in(window, {
243 let weak_cancel = weak_cancel.clone();
244 move |_list_state, window, cx| {
245 _ = weak_cancel.update(cx, |this, cx| {
246 if !this.state.open {
249 return;
250 }
251 cx.emit(ComboboxEvent::Confirm(this.selected_values()));
252 this.set_open(false, cx);
253 this.focus(window, cx);
254 });
255 }
256 });
257 },
258 move |window, cx| {
260 if let Some(empty) = weak_empty
261 .upgrade()
262 .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx)))
263 {
264 empty
265 } else {
266 h_flex()
267 .justify_center()
268 .py_6()
269 .text_color(cx.theme().muted_foreground.opacity(0.6))
270 .child(Icon::new(IconName::Inbox).size(px(28.)))
271 .into_any_element()
272 }
273 },
274 Self::on_blur,
275 window,
276 cx,
277 );
278
279 Self {
280 state,
281 multiple: false,
282 searchable: false,
283 trigger_icon: None,
284 check_icon: None,
285 render_trigger: None,
286 footer: None,
287 focus_ring_enabled: true,
288 }
289 }
290
291 pub fn multiple(mut self, multiple: bool) -> Self {
296 self.multiple = multiple;
297 self
298 }
299
300 pub fn searchable(mut self, searchable: bool) -> Self {
302 self.searchable = searchable;
303 self
304 }
305
306 pub fn selected_values(&self) -> Vec<<D::Item as SearchableListItem>::Value> {
308 self.state.selected_values()
309 }
310
311 pub fn selected_value(&self) -> Option<<D::Item as SearchableListItem>::Value> {
315 self.state.selected_values().into_iter().next()
316 }
317
318 pub fn selection(&self) -> &[(IndexPath, D::Item)] {
320 self.state.selection()
321 }
322
323 pub fn set_selected_values(
329 &mut self,
330 values: &[<D::Item as SearchableListItem>::Value],
331 window: &mut Window,
332 cx: &mut Context<Self>,
333 ) {
334 self.state.clear_query(window, cx);
335
336 let selected_indices = {
337 let list = self.state.list.read(cx);
338 let delegate = &list.delegate().delegate;
339
340 values
341 .iter()
342 .filter_map(|value| delegate.position(value))
343 .collect::<Vec<_>>()
344 };
345
346 self.set_selected_indices(selected_indices, window, cx);
347 }
348
349 pub fn set_selected_indices(
351 &mut self,
352 indices: impl IntoIterator<Item = IndexPath>,
353 _window: &mut Window,
354 cx: &mut Context<Self>,
355 ) {
356 self.state.set_selected_indices(indices, cx);
357 self.state.sync_snapshot(cx);
358 cx.notify();
359 }
360
361 pub fn add_selected_index(&mut self, index: IndexPath, cx: &mut Context<Self>) -> bool {
363 let added = self.state.add_selected_index(index, cx);
364
365 if added {
366 self.state.sync_snapshot(cx);
367 cx.notify();
368 }
369
370 added
371 }
372
373 pub fn remove_selected_index(&mut self, index: IndexPath, cx: &mut Context<Self>) -> bool {
375 let removed = self.state.remove_selected_index(index);
376
377 if removed {
378 self.state.sync_snapshot(cx);
379 }
380
381 removed
382 }
383
384 pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
386 self.state.selection.clear();
387 self.state.sync_snapshot(cx);
388 cx.emit(ComboboxEvent::Change(self.selected_values()));
389 cx.notify();
390 }
391
392 pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>) {
394 self.state.list.update(cx, |list, _| {
395 list.delegate_mut().delegate = items;
396 });
397 }
398
399 pub fn focus(&self, window: &mut Window, cx: &mut App) {
401 self.state.focus_handle.focus(window, cx);
402 }
403
404 pub fn query(&self, cx: &App) -> SharedString {
406 self.state.list.read(cx).query_input.read(cx).value()
407 }
408
409 pub fn set_query(&self, query: impl Into<SharedString>, window: &mut Window, cx: &mut App) {
411 let query = query.into();
412 self.state.list.update(cx, |list, cx| {
413 list.set_query(query.as_ref(), window, cx);
414 });
415 }
416
417 fn selection_changes(
418 multiple: bool,
419 selection: &[(IndexPath, D::Item)],
420 ix: IndexPath,
421 item: &D::Item,
422 ) -> Vec<SearchableListChange> {
423 let is_selected = selection
424 .iter()
425 .any(|(_, selected_item)| selected_item.value() == item.value());
426
427 if multiple {
428 if is_selected {
429 vec![SearchableListChange::Deselect { index: ix }]
430 } else {
431 vec![SearchableListChange::Select { index: ix }]
432 }
433 } else {
434 let mut changes: Vec<SearchableListChange> = selection
435 .iter()
436 .map(|(cur_ix, _)| SearchableListChange::Deselect { index: *cur_ix })
437 .collect();
438 changes.push(SearchableListChange::Select { index: ix });
439 changes
440 }
441 }
442
443 #[allow(dead_code)]
447 pub(crate) fn handle_item_select(
448 &mut self,
449 ix: IndexPath,
450 window: &mut Window,
451 cx: &mut Context<Self>,
452 ) {
453 let Some(item) = self
454 .state
455 .list
456 .read(cx)
457 .delegate()
458 .delegate
459 .item(ix)
460 .cloned()
461 else {
462 return;
463 };
464
465 let changes = Self::selection_changes(self.multiple, &self.state.selection, ix, &item);
466
467 let mut selection = self.state.selection.clone();
468 let before_values: Vec<_> = selection
469 .iter()
470 .map(|(_, item)| item.value().clone())
471 .collect();
472
473 self.state.list.update(cx, |list, _cx| {
474 list.delegate_mut()
475 .delegate
476 .on_will_change(&mut selection, &changes);
477 });
478
479 let after_values: Vec<_> = selection
480 .iter()
481 .map(|(_, item)| item.value().clone())
482 .collect();
483 let changed = before_values != after_values;
484 let should_close = !self.multiple
485 && (changed
486 || selection
487 .iter()
488 .any(|(_, selected)| selected.value() == item.value()));
489
490 self.state.selection = selection;
491 self.state.sync_snapshot(cx);
492
493 if changed {
494 cx.emit(ComboboxEvent::Change(self.selected_values()));
495 cx.notify();
496 }
497
498 if should_close {
499 let final_selection = self.state.selection.clone();
500 self.state.list.update(cx, |list, _cx| {
501 list.delegate_mut().delegate.on_confirm(&final_selection);
502 });
503
504 cx.emit(ComboboxEvent::Confirm(self.selected_values()));
505 self.set_open(false, cx);
506 self.focus(window, cx);
507 }
508 }
509
510 fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
511 if self.state.list.read(cx).is_focused(window, cx)
512 || self.state.focus_handle.is_focused(window)
513 {
514 return;
515 }
516
517 self.set_open(false, cx);
518 cx.notify();
519 }
520
521 fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
522 cx.stop_propagation();
523
524 self.set_open(!self.state.open, cx);
525
526 if self.state.open {
527 self.state.list.focus_handle(cx).focus(window, cx);
528 } else {
529 cx.emit(ComboboxEvent::Confirm(self.selected_values()));
530 self.focus(window, cx);
531 }
532
533 cx.notify();
534 }
535
536 fn dismiss(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
542 if !self.state.open || self.state.bounds.contains(&event.position) {
543 return;
544 }
545
546 cx.stop_propagation();
547 cx.emit(ComboboxEvent::Confirm(self.selected_values()));
548
549 self.set_open(false, cx);
550 self.focus(window, cx);
551 cx.notify();
552 }
553
554 fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
555 self.state.open = open;
556 self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
557
558 cx.notify();
559 }
560
561 fn clean(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
562 cx.stop_propagation();
563 self.clear_selection(cx);
564 }
565
566 fn default_trigger_body(&self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
567 let placeholder_text = self
568 .state
569 .placeholder
570 .clone()
571 .unwrap_or_else(|| t!("Combobox.placeholder").into());
572
573 if self.state.selection.is_empty() {
574 return div()
575 .text_color(cx.theme().muted_foreground)
576 .child(placeholder_text)
577 .into_any_element();
578 }
579
580 if self.multiple {
581 let items: Vec<SharedString> = self
582 .state
583 .selection
584 .iter()
585 .map(|(_, i)| i.title())
586 .collect();
587
588 div()
589 .w_full()
590 .overflow_hidden()
591 .whitespace_nowrap()
592 .truncate()
593 .child(items.join(", "))
594 .into_any_element()
595 } else {
596 let title = self
597 .state
598 .selection
599 .first()
600 .map(|(_, i)| i.title())
601 .unwrap_or_default();
602
603 div()
604 .w_full()
605 .overflow_hidden()
606 .whitespace_nowrap()
607 .truncate()
608 .child(title)
609 .into_any_element()
610 }
611 }
612}
613
614impl<D> Render for ComboboxState<D>
615where
616 D: SearchableListDelegate + 'static,
617 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
618{
619 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
620 let searchable = self.searchable;
621 let is_focused = self.state.focus_handle.is_focused(window);
622 let show_clean = self.state.cleanable && !self.state.selection.is_empty();
623 let bounds = self.state.bounds;
624 let allow_open = !self.state.disabled;
625 let outline_visible = self.state.open || (is_focused && !self.state.disabled);
626 let disabled = self.state.disabled;
627
628 let (bg, fg) = input_style(disabled, cx);
629
630 self.state.list.update(cx, |list, cx| {
631 list.set_searchable(searchable, cx);
632 list.delegate_mut().size = self.state.size;
633 list.delegate_mut().check_icon = self.check_icon.clone();
634 });
635
636 let selection = &self.state.selection;
637 let placeholder = self.state.placeholder.as_ref();
638 let open = self.state.open;
639 let size = self.state.size;
640 let has_custom_trigger = self.render_trigger.is_some();
641
642 let trigger_body = if let Some(render_trigger) = &self.render_trigger {
643 let trigger = ComboboxTriggerContext {
644 selection,
645 placeholder,
646 open,
647 disabled,
648 size,
649 };
650
651 render_trigger(&trigger, window, cx)
652 } else {
653 self.default_trigger_body(window, cx)
654 };
655
656 let trailing: AnyElement = if has_custom_trigger {
657 div().into_any_element()
658 } else if show_clean {
659 clear_button(cx)
660 .map(|this| {
661 if disabled {
662 this.disabled(true)
663 } else {
664 this.on_click(cx.listener(Self::clean))
665 }
666 })
667 .into_any_element()
668 } else if let Some(icon) = self.trigger_icon.clone() {
669 icon.xsmall()
670 .text_color(cx.theme().muted_foreground)
671 .into_any_element()
672 } else {
673 Caret::new(size)
674 .text_color(cx.theme().muted_foreground)
675 .into_any_element()
676 };
677
678 let toggle_handler: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>> =
679 if allow_open {
680 Some(Box::new(cx.listener(Self::toggle_menu)))
681 } else {
682 None
683 };
684
685 let footer_el = self.footer.as_ref().map(|f| f(window, cx));
686
687 let dismiss_handler: Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static> =
688 Box::new(cx.listener(Self::dismiss));
689
690 div().size_full().relative().child(
691 div()
692 .relative()
693 .on_prepaint({
694 let state = cx.entity();
695 move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)
696 })
697 .child(render_trigger_container(
698 disabled,
699 self.state.appearance,
700 self.focus_ring_enabled,
701 self.state.size,
702 &self.state.style,
703 bg,
704 fg,
705 outline_visible,
706 allow_open,
707 trigger_body,
708 trailing,
709 toggle_handler,
710 window,
711 cx,
712 ))
713 .when(self.state.open, |this| {
714 this.child(
715 deferred(render_popup_shell(
716 ("combobox-popup", cx.entity_id()),
717 &self.state.list,
718 self.state.menu_width,
719 self.state.search_placeholder.clone(),
720 self.state.size,
721 self.state.menu_max_h,
722 bounds,
723 footer_el,
724 dismiss_handler,
725 cx,
726 ))
727 .with_priority(gpui_base::POPUP_PRIORITY),
728 )
729 }),
730 )
731 }
732}
733
734impl<D> EventEmitter<ComboboxEvent<D>> for ComboboxState<D>
735where
736 D: SearchableListDelegate + 'static,
737 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
738{
739}
740impl<D> EventEmitter<DismissEvent> for ComboboxState<D>
741where
742 D: SearchableListDelegate + 'static,
743 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
744{
745}
746
747impl<D> Focusable for ComboboxState<D>
748where
749 D: SearchableListDelegate + 'static,
750 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
751{
752 fn focus_handle(&self, cx: &App) -> FocusHandle {
753 if self.state.open {
754 self.state.list.focus_handle(cx)
755 } else {
756 self.state.focus_handle.clone()
757 }
758 }
759}
760
761#[derive(IntoElement)]
768pub struct Combobox<D: SearchableListDelegate + 'static>
769where
770 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
771{
772 id: ElementId,
773 state: Entity<ComboboxState<D>>,
774 options: ComboboxOptions,
775 render_trigger: Option<
776 Box<dyn Fn(&ComboboxTriggerContext<D>, &mut Window, &mut App) -> AnyElement + 'static>,
777 >,
778 footer: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
779 empty: Option<Box<dyn Fn(&mut Window, &App) -> AnyElement + 'static>>,
780}
781
782impl<D> Combobox<D>
783where
784 D: SearchableListDelegate + 'static,
785 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
786{
787 pub fn new(state: &Entity<ComboboxState<D>>) -> Self {
788 Self {
789 id: ("multi-combo-box", state.entity_id()).into(),
790 state: state.clone(),
791 options: ComboboxOptions::default(),
792 render_trigger: None,
793 footer: None,
794 empty: None,
795 }
796 }
797
798 pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
800 self.options.menu_width = width.into();
801 self
802 }
803
804 pub fn menu_max_h(mut self, max_h: impl Into<Length>) -> Self {
806 self.options.menu_max_h = max_h.into();
807 self
808 }
809
810 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
812 self.options.placeholder = Some(placeholder.into());
813 self
814 }
815
816 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
818 self.options.trigger_icon = Some(icon.into());
819 self
820 }
821
822 pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
824 self.options.check_icon = Some(icon.into());
825 self
826 }
827
828 pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
830 self.options.search_placeholder = Some(placeholder.into());
831 self
832 }
833
834 pub fn cleanable(mut self, cleanable: bool) -> Self {
836 self.options.cleanable = cleanable;
837 self
838 }
839
840 pub fn disabled(mut self, disabled: bool) -> Self {
842 self.options.disabled = disabled;
843 self
844 }
845
846 pub fn empty<E: IntoElement + 'static>(
848 mut self,
849 builder: impl Fn(&mut Window, &App) -> E + 'static,
850 ) -> Self {
851 self.empty = Some(Box::new(move |window, cx| {
852 builder(window, cx).into_any_element()
853 }));
854 self
855 }
856
857 pub fn appearance(mut self, appearance: bool) -> Self {
859 self.options.appearance = appearance;
860 self
861 }
862
863 pub fn render_trigger<E: IntoElement + 'static>(
865 mut self,
866 f: impl Fn(&ComboboxTriggerContext<D>, &mut Window, &mut App) -> E + 'static,
867 ) -> Self {
868 self.render_trigger = Some(Box::new(move |trigger, window, cx| {
869 f(trigger, window, cx).into_any_element()
870 }));
871 self
872 }
873
874 pub fn footer<E: IntoElement + 'static>(
876 mut self,
877 f: impl Fn(&mut Window, &mut App) -> E + 'static,
878 ) -> Self {
879 self.footer = Some(Box::new(move |window, cx| f(window, cx).into_any_element()));
880 self
881 }
882}
883
884impl<D> Sizable for Combobox<D>
885where
886 D: SearchableListDelegate + 'static,
887 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
888{
889 fn with_size(mut self, size: impl Into<Size>) -> Self {
890 self.options.size = size.into();
891 self
892 }
893}
894
895impl<D> crate::FocusableExt for Combobox<D>
896where
897 D: SearchableListDelegate + 'static,
898 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
899{
900 fn focus_ring(mut self, enabled: bool) -> Self {
901 self.options.focus_ring_enabled = enabled;
902 self
903 }
904
905 fn is_focus_ring_enabled(&self) -> bool {
906 self.options.focus_ring_enabled
907 }
908}
909
910impl<D> Styled for Combobox<D>
911where
912 D: SearchableListDelegate + 'static,
913 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
914{
915 fn style(&mut self) -> &mut StyleRefinement {
916 &mut self.options.style
917 }
918}
919
920impl<D> RenderOnce for Combobox<D>
921where
922 D: SearchableListDelegate + 'static,
923 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
924{
925 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
926 let disabled = self.options.disabled;
927 let focus_handle = self.state.read(cx).state.focus_handle.clone();
928 let render_trigger = self.render_trigger;
929 let footer = self.footer;
930 let empty = self.empty;
931 let opts = self.options;
932
933 self.state.update(cx, |this, _| {
934 this.state.style = opts.style;
935 this.state.size = opts.size;
936 this.state.cleanable = opts.cleanable;
937 this.state.placeholder = opts.placeholder;
938 this.state.search_placeholder = opts.search_placeholder;
939 this.state.menu_width = opts.menu_width;
940 this.state.menu_max_h = opts.menu_max_h;
941 this.state.disabled = opts.disabled;
942 this.state.appearance = opts.appearance;
943 this.focus_ring_enabled = opts.focus_ring_enabled;
944 this.trigger_icon = opts.trigger_icon;
945 this.check_icon = opts.check_icon;
946 this.render_trigger = render_trigger;
947 this.footer = footer;
948
949 if let Some(empty) = empty {
950 this.state.empty = Some(empty);
951 }
952 });
953
954 let is_open = self.state.read(cx).state.open;
955 let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
956 let open_state = self.state.clone();
957 let confirm_state = self.state.clone();
958
959 BaseCombobox::new(self.id)
960 .open(is_open)
961 .disabled(disabled)
962 .focus_handle(&focus_handle)
963 .content_focus_handle(&content_focus_handle)
964 .on_open_change(move |open, _, cx| {
965 open_state.update(cx, |state, cx| state.set_open(open, cx));
966 })
967 .on_dismiss(move |_, cx| {
970 confirm_state.update(cx, |state, cx| {
971 cx.emit(ComboboxEvent::Confirm(state.selected_values()));
972 });
973 })
974 .size_full()
975 .child(self.state)
976 }
977}
978
979#[allow(clippy::too_many_arguments)]
983fn render_trigger_container(
984 disabled: bool,
985 appearance: bool,
986 focus_ring_enabled: bool,
987 size: Size,
988 style: &StyleRefinement,
989 bg: Hsla,
990 fg: Hsla,
991 outline_visible: bool,
992 allow_open: bool,
993 trigger_body: AnyElement,
994 trailing: AnyElement,
995 toggle_handler: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
996 window: &Window,
997 cx: &mut App,
998) -> impl IntoElement {
999 div()
1000 .id("input")
1001 .relative()
1002 .flex()
1003 .items_center()
1004 .justify_between()
1005 .border_1()
1006 .border_color(cx.theme().transparent)
1007 .when(appearance, |this| {
1008 this.bg(bg)
1009 .text_color(fg)
1010 .when(disabled, |this| this.opacity(0.5))
1011 .border_color(cx.theme().input)
1012 .rounded(cx.theme().radius)
1013 })
1014 .input_size(size)
1015 .input_text_size(size)
1016 .refine_style(style)
1017 .when(outline_visible && appearance, |this| {
1018 this.border_1().border_color(cx.theme().ring)
1019 })
1020 .when(
1021 outline_visible && appearance && focus_ring_enabled,
1022 |this| this.focus_ring_style(window, cx),
1023 )
1024 .when(allow_open, |this| {
1025 this.when_some(toggle_handler, |this, handler| this.on_click(handler))
1026 })
1027 .child(
1028 h_flex()
1029 .id("inner")
1030 .w_full()
1031 .min_w_0()
1032 .overflow_hidden()
1033 .whitespace_nowrap()
1034 .items_center()
1035 .justify_between()
1036 .gap_1()
1037 .child(
1038 div()
1039 .flex_1()
1040 .min_w_0()
1041 .overflow_hidden()
1042 .whitespace_nowrap()
1043 .child(trigger_body),
1044 )
1045 .child(trailing),
1046 )
1047}
1048
1049#[allow(clippy::too_many_arguments)]
1051fn render_popup_shell<D: SearchableListDelegate + 'static>(
1052 id: impl Into<ElementId>,
1053 list: &Entity<ListState<SearchableListAdapter<D>>>,
1054 menu_width: Length,
1055 search_placeholder: Option<SharedString>,
1056 size: Size,
1057 menu_max_h: Length,
1058 bounds: Bounds<Pixels>,
1059 footer_el: Option<AnyElement>,
1060 dismiss_handler: Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>,
1061 cx: &mut App,
1062) -> AnyElement {
1063 let has_footer = footer_el.is_some();
1064
1065 crate::popover::dropdown_popup(
1066 id,
1067 bounds,
1068 v_flex()
1069 .occlude()
1070 .map(|this| match menu_width {
1071 Length::Auto => this.w(bounds.size.width),
1072 Length::Definite(w) => this.w(w),
1073 })
1074 .popover_style(cx)
1075 .child(
1076 List::new(list)
1077 .when_some(search_placeholder, |this, placeholder| {
1078 this.search_placeholder(placeholder)
1079 })
1080 .with_size(size)
1081 .max_h(menu_max_h)
1082 .paddings(Edges::all(DROPDOWN_LIST_PADDING)),
1083 )
1084 .when(has_footer, |this| {
1085 this.child(
1086 div()
1087 .border_t_1()
1088 .border_color(cx.theme().border)
1089 .p_1()
1090 .when_some(footer_el, |this, el| this.child(el)),
1091 )
1092 })
1093 .on_mouse_down_out(dismiss_handler),
1094 cx,
1095 )
1096 .into_any_element()
1097}
1098
1099#[cfg(test)]
1102mod tests {
1103 use std::{cell::Cell, rc::Rc};
1104
1105 use gpui::{
1106 AppContext as _, Bounds, Context, Entity, Modifiers, MouseButton, MouseDownEvent, Pixels,
1107 Point, Subscription, TestAppContext, point, px, size,
1108 };
1109
1110 use crate::{
1111 IndexPath,
1112 combobox::{Combobox, ComboboxEvent, ComboboxState},
1113 searchable_list::{
1114 SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState,
1115 SearchableVec,
1116 },
1117 };
1118
1119 struct TestComboboxEventCollector {
1120 event_count: Rc<Cell<usize>>,
1121 _subscription: Subscription,
1122 }
1123
1124 impl TestComboboxEventCollector {
1125 fn new(
1126 state: &Entity<ComboboxState<SearchableVec<&'static str>>>,
1127 cx: &mut Context<Self>,
1128 ) -> Self {
1129 let event_count = Rc::new(Cell::new(0));
1130 let event_count_for_subscription = event_count.clone();
1131 let _subscription = cx.subscribe(
1132 state,
1133 move |_, _, _: &ComboboxEvent<SearchableVec<&'static str>>, _| {
1134 event_count_for_subscription.set(event_count_for_subscription.get() + 1);
1135 },
1136 );
1137
1138 Self {
1139 event_count,
1140 _subscription,
1141 }
1142 }
1143 }
1144
1145 #[gpui::test]
1146 fn test_combo_box_builder(cx: &mut TestAppContext) {
1147 cx.update(crate::init);
1148 let cx = cx.add_empty_window();
1149 cx.update(|window, cx| {
1150 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1151 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true));
1152
1153 let _cb = Combobox::new(&state)
1154 .placeholder("Select language")
1155 .search_placeholder("Search...")
1156 .menu_width(gpui::px(300.))
1157 .menu_max_h(gpui::rems(15.))
1158 .cleanable(true)
1159 .disabled(false)
1160 .appearance(true);
1161 });
1162 }
1163
1164 #[gpui::test]
1165 fn test_combo_box_search_filters_items(cx: &mut TestAppContext) {
1166 cx.update(crate::init);
1167 let cx = cx.add_empty_window();
1168 cx.update(|window, cx| {
1169 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1170 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true));
1171
1172 let count_before = state
1173 .read(cx)
1174 .state
1175 .list
1176 .read(cx)
1177 .delegate()
1178 .delegate
1179 .items_count(0);
1180 assert_eq!(count_before, 3);
1181
1182 state.update(cx, |s, cx| {
1183 s.state.list.update(cx, |list, cx| {
1184 let _ = list
1185 .delegate_mut()
1186 .delegate
1187 .perform_search("Rust", window, cx);
1188 });
1189 });
1190
1191 let count_after = state
1192 .read(cx)
1193 .state
1194 .list
1195 .read(cx)
1196 .delegate()
1197 .delegate
1198 .items_count(0);
1199 assert_eq!(count_after, 1);
1200 });
1201 }
1202
1203 #[gpui::test]
1204 fn test_combo_box_set_query_updates_text_and_filters_items(cx: &mut TestAppContext) {
1205 cx.update(crate::init);
1206 let cx = cx.add_empty_window();
1207 cx.update(|window, cx| {
1208 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1209 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true));
1210
1211 state.update(cx, |state, cx| state.set_query(" Rust ", window, cx));
1212
1213 assert_eq!(state.read(cx).query(cx).as_ref(), " Rust ");
1214 assert_eq!(
1215 state
1216 .read(cx)
1217 .state
1218 .list
1219 .read(cx)
1220 .delegate()
1221 .delegate
1222 .items_count(0),
1223 1,
1224 );
1225 });
1226 }
1227
1228 #[gpui::test]
1229 fn test_multi_combo_box_builder(cx: &mut TestAppContext) {
1230 cx.update(crate::init);
1231 let cx = cx.add_empty_window();
1232 cx.update(|window, cx| {
1233 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1234 let state = cx.new(|cx| {
1235 ComboboxState::new(items, vec![IndexPath::new(0)], window, cx)
1236 .multiple(true)
1237 .searchable(true)
1238 });
1239
1240 let _cb = Combobox::new(&state)
1241 .placeholder("Select frameworks")
1242 .search_placeholder("Search...")
1243 .menu_width(gpui::px(300.))
1244 .cleanable(true)
1245 .disabled(false);
1246
1247 assert_eq!(state.read(cx).selected_values(), vec!["React"]);
1248 });
1249 }
1250
1251 #[gpui::test]
1252 fn test_combo_box_set_selected_values_uses_current_delegate(cx: &mut TestAppContext) {
1253 cx.update(crate::init);
1254 let cx = cx.add_empty_window();
1255 cx.update(|window, cx| {
1256 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1257 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1258
1259 state.update(cx, |state, cx| {
1260 state.set_selected_values(&["Vue", "Missing"], window, cx);
1261
1262 assert_eq!(state.selected_values(), vec!["Vue"]);
1263 assert_eq!(
1264 state
1265 .selection()
1266 .iter()
1267 .map(|(index, _)| *index)
1268 .collect::<Vec<_>>(),
1269 vec![IndexPath::new(1)],
1270 );
1271 assert_eq!(
1272 state
1273 .state
1274 .list
1275 .read(cx)
1276 .delegate()
1277 .selection_snapshot
1278 .as_slice(),
1279 state.selection(),
1280 );
1281
1282 state.set_items(SearchableVec::new(vec!["Vue", "Rust", "Go"]), window, cx);
1283 state.set_selected_values(&["Go", "Vue"], window, cx);
1284
1285 assert_eq!(state.selected_values(), vec!["Go", "Vue"]);
1286 assert_eq!(
1287 state
1288 .selection()
1289 .iter()
1290 .map(|(index, _)| *index)
1291 .collect::<Vec<_>>(),
1292 vec![IndexPath::new(2), IndexPath::new(0)],
1293 );
1294 assert_eq!(
1295 state
1296 .state
1297 .list
1298 .read(cx)
1299 .delegate()
1300 .selection_snapshot
1301 .as_slice(),
1302 state.selection(),
1303 );
1304
1305 state.set_selected_values(&[], window, cx);
1306
1307 assert!(state.selection().is_empty());
1308 assert!(
1309 state
1310 .state
1311 .list
1312 .read(cx)
1313 .delegate()
1314 .selection_snapshot
1315 .is_empty()
1316 );
1317 });
1318 });
1319 }
1320
1321 #[gpui::test]
1324 fn test_combo_box_set_selected_values_keeps_values_hidden_by_the_search(
1325 cx: &mut TestAppContext,
1326 ) {
1327 cx.update(crate::init);
1328 let cx = cx.add_empty_window();
1329 cx.update(|window, cx| {
1330 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1331 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1332
1333 state.update(cx, |state, cx| {
1334 state.set_selected_values(&["React", "Vue"], window, cx);
1335 state.state.list.update(cx, |list, cx| {
1336 list.set_query("Angular", window, cx);
1337 });
1338
1339 state.set_selected_values(&["React", "Vue", "Angular"], window, cx);
1340
1341 assert_eq!(
1342 state.selected_values(),
1343 vec!["React", "Vue", "Angular"],
1344 "a value the search had hidden must survive being reapplied"
1345 );
1346 assert_eq!(
1347 state
1348 .selection()
1349 .iter()
1350 .map(|(index, _)| *index)
1351 .collect::<Vec<_>>(),
1352 vec![IndexPath::new(0), IndexPath::new(1), IndexPath::new(2)],
1353 "clearing the query puts every selected item back in view"
1354 );
1355 });
1356 });
1357 }
1358
1359 #[gpui::test]
1360 fn test_combo_box_set_selected_values_does_not_emit_events(cx: &mut TestAppContext) {
1361 cx.update(crate::init);
1362 let cx = cx.add_empty_window();
1363 let state = cx.update(|window, cx| {
1364 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1365 cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true))
1366 });
1367 let collector = cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx)));
1368
1369 cx.update(|window, cx| {
1370 state.update(cx, |state, cx| {
1371 state.set_selected_values(&["React", "Vue"], window, cx);
1372 });
1373 });
1374
1375 cx.update(|_, cx| {
1376 assert_eq!(collector.read(cx).event_count.get(), 0);
1377 });
1378 }
1379
1380 #[gpui::test]
1381 fn test_combo_box_initial_selection_seeds_cursor(cx: &mut TestAppContext) {
1382 cx.update(crate::init);
1383 let cx = cx.add_empty_window();
1384 cx.update(|window, cx| {
1385 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1386 let state = cx.new(|cx| {
1387 ComboboxState::new(items, vec![IndexPath::new(1)], window, cx).multiple(true)
1388 });
1389
1390 let state_ref = state.read(cx);
1391 assert_eq!(
1392 state_ref.state.list.read(cx).selected_index(),
1393 Some(IndexPath::new(1)),
1394 "initial selected_indices should seed ListState.selected_index, not just the snapshot",
1395 );
1396 assert_eq!(state_ref.selected_values(), vec!["Vue"]);
1397 });
1398 }
1399
1400 #[gpui::test]
1401 fn test_multi_combo_box_toggle(cx: &mut TestAppContext) {
1402 cx.update(crate::init);
1403 let cx = cx.add_empty_window();
1404 cx.update(|window, cx| {
1405 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1406 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1407
1408 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1409 assert_eq!(state.read(cx).selected_values(), &["React"]);
1410
1411 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx));
1412 assert_eq!(state.read(cx).selected_values(), &["React", "Vue"]);
1413
1414 state.update(cx, |s, cx| s.remove_selected_index(IndexPath::new(0), cx));
1415 assert_eq!(state.read(cx).selected_values(), &["Vue"]);
1416 });
1417 }
1418
1419 #[gpui::test]
1420 fn test_multi_combo_box_search_selection_uses_value_identity(cx: &mut TestAppContext) {
1421 cx.update(crate::init);
1422 let cx = cx.add_empty_window();
1423 cx.update(|window, cx| {
1424 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1425 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1426
1427 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1428 assert_eq!(state.read(cx).selected_values(), &["React"]);
1429
1430 state.update(cx, |s, cx| {
1431 s.state.list.update(cx, |list, cx| {
1432 let _ = list
1433 .delegate_mut()
1434 .delegate
1435 .perform_search("Vue", window, cx);
1436 });
1437 });
1438
1439 state.read_with(cx, |s, cx| {
1440 let selection = s.state.selection.clone();
1441 let list = s.state.list.read(cx);
1442 let delegate = &list.delegate().delegate;
1443 let ix = IndexPath::new(0);
1444 let item = delegate.item(ix).expect("filtered item exists");
1445
1446 assert_eq!(item.value(), &"Vue");
1447 assert!(
1448 !delegate.is_item_checked(ix, item, &selection, cx),
1449 "filtered row 0 should not inherit React's checked state",
1450 );
1451 });
1452
1453 state.update(cx, |s, cx| {
1454 s.handle_item_select(IndexPath::new(0), window, cx);
1455 });
1456 assert_eq!(state.read(cx).selected_values(), &["React", "Vue"]);
1457 });
1458 }
1459
1460 #[gpui::test]
1461 fn test_multi_combo_box_search_deselects_by_value(cx: &mut TestAppContext) {
1462 cx.update(crate::init);
1463 let cx = cx.add_empty_window();
1464 cx.update(|window, cx| {
1465 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1466 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1467
1468 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1469
1470 state.update(cx, |s, cx| {
1471 s.state.list.update(cx, |list, cx| {
1472 let _ = list
1473 .delegate_mut()
1474 .delegate
1475 .perform_search("React", window, cx);
1476 });
1477 });
1478
1479 state.update(cx, |s, cx| {
1480 s.handle_item_select(IndexPath::new(0), window, cx);
1481 });
1482 assert!(state.read(cx).selected_values().is_empty());
1483 });
1484 }
1485
1486 #[gpui::test]
1487 fn test_searchable_list_default_change_uses_value_identity(cx: &mut TestAppContext) {
1488 cx.update(crate::init);
1489 let cx = cx.add_empty_window();
1490 cx.update(|window, cx| {
1491 let mut delegate = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1492 let mut selection = vec![(IndexPath::new(1), "Vue")];
1493
1494 let _ = delegate.perform_search("Vue", window, cx);
1495 delegate.on_will_change(
1496 &mut selection,
1497 &[SearchableListChange::Deselect {
1498 index: IndexPath::new(0),
1499 }],
1500 );
1501 assert!(selection.is_empty());
1502
1503 delegate.on_will_change(
1504 &mut selection,
1505 &[SearchableListChange::Select {
1506 index: IndexPath::new(0),
1507 }],
1508 );
1509 assert_eq!(selection, vec![(IndexPath::new(0), "Vue")]);
1510 });
1511 }
1512
1513 #[gpui::test]
1514 fn test_multi_combo_box_clear(cx: &mut TestAppContext) {
1515 cx.update(crate::init);
1516 let cx = cx.add_empty_window();
1517 cx.update(|window, cx| {
1518 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1519 let state = cx.new(|cx| {
1520 ComboboxState::new(
1521 items,
1522 vec![IndexPath::new(0), IndexPath::new(1)],
1523 window,
1524 cx,
1525 )
1526 .multiple(true)
1527 });
1528
1529 assert_eq!(state.read(cx).selected_values().len(), 2);
1530 state.update(cx, |s, cx| s.clear_selection(cx));
1531 assert!(state.read(cx).selected_values().is_empty());
1532 });
1533 }
1534
1535 #[gpui::test]
1536 fn test_single_combo_box_mode(cx: &mut TestAppContext) {
1537 cx.update(crate::init);
1538 let cx = cx.add_empty_window();
1539 cx.update(|window, cx| {
1540 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1541 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx));
1542
1543 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1545 assert_eq!(state.read(cx).selected_values(), &["Rust"]);
1546
1547 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx));
1548 assert_eq!(state.read(cx).selected_values(), &["Rust", "Go"]);
1549 });
1550 }
1551
1552 struct VetoDelegate(SearchableVec<&'static str>);
1554
1555 impl SearchableListDelegate for VetoDelegate {
1556 type Item = &'static str;
1557
1558 fn items_count(&self, section: usize) -> usize {
1559 self.0.items_count(section)
1560 }
1561
1562 fn item(&self, ix: IndexPath) -> Option<&&'static str> {
1563 self.0.item(ix)
1564 }
1565
1566 fn position<V>(&self, value: &V) -> Option<IndexPath>
1567 where
1568 &'static str: SearchableListItem<Value = V>,
1569 V: PartialEq,
1570 {
1571 self.0.position(value)
1572 }
1573
1574 fn on_will_change(
1575 &mut self,
1576 _selection: &mut Vec<(IndexPath, &'static str)>,
1577 _changes: &[SearchableListChange],
1578 ) {
1579 }
1581 }
1582
1583 #[gpui::test]
1584 fn test_on_will_change_veto(cx: &mut TestAppContext) {
1585 cx.update(crate::init);
1586 let cx = cx.add_empty_window();
1587 cx.update(|window, cx| {
1588 let delegate = VetoDelegate(SearchableVec::new(vec!["Rust", "Go", "C++"]));
1589 let state = cx.new(|cx| ComboboxState::new(delegate, vec![], window, cx));
1590
1591 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1593 assert_eq!(state.read(cx).selected_values(), &["Rust"]);
1594
1595 state.update(cx, |s, cx| {
1597 s.handle_item_select(IndexPath::new(1), window, cx);
1598 });
1599
1600 assert_eq!(state.read(cx).selected_values(), &["Rust"]);
1602 });
1603 }
1604
1605 struct RedirectDelegate {
1606 items: SearchableVec<&'static str>,
1607 confirmations: Rc<Cell<usize>>,
1608 }
1609
1610 impl SearchableListDelegate for RedirectDelegate {
1611 type Item = &'static str;
1612
1613 fn items_count(&self, section: usize) -> usize {
1614 self.items.items_count(section)
1615 }
1616
1617 fn item(&self, ix: IndexPath) -> Option<&&'static str> {
1618 self.items.item(ix)
1619 }
1620
1621 fn position<V>(&self, value: &V) -> Option<IndexPath>
1622 where
1623 &'static str: SearchableListItem<Value = V>,
1624 V: PartialEq,
1625 {
1626 self.items.position(value)
1627 }
1628
1629 fn on_will_change(
1630 &mut self,
1631 selection: &mut Vec<(IndexPath, &'static str)>,
1632 _: &[SearchableListChange],
1633 ) {
1634 *selection = vec![(IndexPath::new(2), "C++")];
1635 }
1636
1637 fn on_confirm(&mut self, selection: &[(IndexPath, &'static str)]) {
1638 assert_eq!(selection[0].1, "C++");
1639 self.confirmations.set(self.confirmations.get() + 1);
1640 }
1641 }
1642
1643 #[gpui::test]
1644 fn single_select_confirms_delegate_replacement(cx: &mut TestAppContext) {
1645 use crate::list::ListDelegate as _;
1646 cx.update(crate::init);
1647 let cx = cx.add_empty_window();
1648 for through_list in [false, true] {
1649 let confirmations = Rc::new(Cell::new(0));
1650 let state = cx.update(|window, cx| {
1651 cx.new(|cx| {
1652 ComboboxState::new(
1653 RedirectDelegate {
1654 items: SearchableVec::new(vec!["Rust", "Go", "C++"]),
1655 confirmations: confirmations.clone(),
1656 },
1657 vec![IndexPath::new(0)],
1658 window,
1659 cx,
1660 )
1661 })
1662 });
1663 let events = Rc::new(Cell::new(0));
1664 let event_count = events.clone();
1665 let _subscription = cx.update(|_, cx| {
1666 cx.subscribe(&state, move |_, _: &ComboboxEvent<RedirectDelegate>, _| {
1667 event_count.set(event_count.get() + 1);
1668 })
1669 });
1670 cx.update(|window, cx| {
1671 state.update(cx, |state, cx| {
1672 state.set_open(true, cx);
1673 if through_list {
1674 state.state.list.update(cx, |list, cx| {
1675 list.set_selected_index(Some(IndexPath::new(1)), window, cx);
1676 list.delegate_mut().confirm(false, window, cx);
1677 });
1678 } else {
1679 state.handle_item_select(IndexPath::new(1), window, cx);
1680 }
1681 });
1682 });
1683 cx.run_until_parked();
1684 cx.update(|_, cx| {
1685 assert_eq!(state.read(cx).selected_values(), vec!["C++"]);
1686 assert!(!state.read(cx).state.open);
1687 assert_eq!(confirmations.get(), 1);
1688 assert_eq!(events.get(), 2, "one Change and one Confirm");
1689 });
1690 }
1691 }
1692
1693 fn left_press(position: Point<Pixels>) -> MouseDownEvent {
1694 MouseDownEvent {
1695 button: MouseButton::Left,
1696 position,
1697 modifiers: Modifiers::default(),
1698 click_count: 1,
1699 first_mouse: false,
1700 }
1701 }
1702
1703 #[gpui::test]
1704 fn test_combo_box_dismiss_ignores_press_on_trigger(cx: &mut TestAppContext) {
1705 cx.update(crate::init);
1706 let cx = cx.add_empty_window();
1707 let state = cx.update(|window, cx| {
1708 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1709 cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true))
1710 });
1711 let collector = cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx)));
1712
1713 cx.update(|window, cx| {
1714 state.update(cx, |state, cx| {
1715 state.state.bounds = Bounds {
1716 origin: point(px(10.), px(10.)),
1717 size: size(px(200.), px(32.)),
1718 };
1719 state.set_open(true, cx);
1720 state.dismiss(&left_press(point(px(20.), px(20.))), window, cx);
1721 });
1722 });
1723
1724 cx.update(|_, cx| {
1725 assert!(
1726 state.read(cx).state.open,
1727 "a press on the trigger must reach nested controls, so the menu stays open \
1728 and `toggle_menu` closes it on release instead",
1729 );
1730 assert_eq!(collector.read(cx).event_count.get(), 0);
1731 });
1732
1733 cx.update(|window, cx| {
1734 state.update(cx, |state, cx| {
1735 state.dismiss(&left_press(point(px(20.), px(200.))), window, cx);
1736 });
1737 });
1738
1739 cx.update(|_, cx| {
1740 assert!(!state.read(cx).state.open);
1741 assert_eq!(
1742 collector.read(cx).event_count.get(),
1743 1,
1744 "dismissing from outside the trigger emits Confirm",
1745 );
1746 });
1747 }
1748
1749 #[allow(unused)]
1751 fn _uses_state<D: SearchableListDelegate + 'static>(_: &SearchableListState<D>)
1752 where
1753 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
1754 {
1755 }
1756 fn assert_filtered_row_replacement(cx: &mut TestAppContext, through_list: bool) {
1757 use crate::list::ListDelegate as _;
1758
1759 cx.update(crate::init);
1760 let cx = cx.add_empty_window();
1761 let state = cx.update(|window, cx| {
1762 cx.new(|cx| {
1763 ComboboxState::new(
1764 SearchableVec::new(vec!["React", "Vue"]),
1765 vec![IndexPath::new(0)],
1766 window,
1767 cx,
1768 )
1769 .searchable(true)
1770 })
1771 });
1772 let collector = cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx)));
1773
1774 cx.update(|window, cx| {
1775 state.update(cx, |state, cx| {
1776 state.set_open(true, cx);
1777 state.state.list.update(cx, |list, cx| {
1778 let _ = list
1779 .delegate_mut()
1780 .delegate
1781 .perform_search("Vue", window, cx);
1782 list.set_selected_index(Some(IndexPath::new(0)), window, cx);
1783 });
1784 assert_eq!(state.selected_values(), vec!["React"]);
1785 assert_eq!(
1786 state
1787 .state
1788 .list
1789 .read(cx)
1790 .delegate()
1791 .delegate
1792 .item(IndexPath::new(0)),
1793 Some(&"Vue"),
1794 );
1795 if through_list {
1796 state.state.list.update(cx, |list, cx| {
1797 list.delegate_mut().confirm(false, window, cx);
1798 });
1799 } else {
1800 state.handle_item_select(IndexPath::new(0), window, cx);
1801 }
1802 });
1803 });
1804
1805 cx.update(|_, cx| {
1806 assert_eq!(state.read(cx).selected_values(), vec!["Vue"]);
1807 assert!(
1808 !state.read(cx).state.open,
1809 "single selection must close the popover"
1810 );
1811 assert_eq!(collector.read(cx).event_count.get(), 2);
1812 });
1813 }
1814
1815 #[gpui::test]
1816 fn test_filtered_row_replacement_direct(cx: &mut TestAppContext) {
1817 assert_filtered_row_replacement(cx, false);
1818 }
1819
1820 #[gpui::test]
1821 fn test_filtered_row_replacement_list_confirm(cx: &mut TestAppContext) {
1822 assert_filtered_row_replacement(cx, true);
1823 }
1824
1825 #[gpui::test]
1826 fn current_item_acceptance_closes_without_change(cx: &mut TestAppContext) {
1827 use crate::list::ListDelegate as _;
1828 cx.update(crate::init);
1829 let cx = cx.add_empty_window();
1830 for through_list in [false, true] {
1831 let state = cx.update(|window, cx| {
1832 cx.new(|cx| {
1833 ComboboxState::new(
1834 SearchableVec::new(vec!["React", "Vue"]),
1835 vec![IndexPath::new(0)],
1836 window,
1837 cx,
1838 )
1839 })
1840 });
1841 let collector =
1842 cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx)));
1843 cx.update(|window, cx| {
1844 state.update(cx, |state, cx| {
1845 state.set_open(true, cx);
1846 if through_list {
1847 state.state.list.update(cx, |list, cx| {
1848 list.delegate_mut().confirm(false, window, cx)
1849 });
1850 } else {
1851 state.handle_item_select(IndexPath::new(0), window, cx);
1852 }
1853 })
1854 });
1855 cx.run_until_parked();
1856 cx.update(|_, cx| {
1857 assert!(!state.read(cx).state.open);
1858 assert_eq!(state.read(cx).selected_values(), vec!["React"]);
1859 assert_eq!(
1860 collector.read(cx).event_count.get(),
1861 1,
1862 "only Confirm, no Change"
1863 );
1864 });
1865 }
1866 }
1867
1868 #[cfg(feature = "gpui-fast")]
1870 mod retained {
1871 use std::{cell::Cell, rc::Rc};
1872
1873 use gpui::{
1874 AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
1875 TestAppContext, Window, div, px,
1876 };
1877
1878 use crate::{
1879 combobox::{Combobox, ComboboxState},
1880 searchable_list::SearchableVec,
1881 };
1882
1883 struct Page {
1886 state: Entity<ComboboxState<SearchableVec<&'static str>>>,
1887 renders: Rc<Cell<usize>>,
1888 }
1889
1890 impl Render for Page {
1891 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1892 self.renders.set(self.renders.get() + 1);
1893 let selected = self.state.read(cx).selected_values();
1894 div()
1895 .w(px(240.))
1896 .child(Combobox::new(&self.state))
1897 .child(format!("{selected:?}"))
1898 }
1899 }
1900
1901 #[gpui::test]
1904 fn drawing_a_combobox_again_leaves_the_page_around_it_retained(cx: &mut TestAppContext) {
1905 cx.update(crate::init);
1906 let renders = Rc::new(Cell::new(0));
1907 let (_, cx) = cx.add_window_view({
1908 let renders = renders.clone();
1909 move |window, cx| {
1910 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1911 Page {
1912 state: cx.new(|cx| ComboboxState::new(items, vec![], window, cx)),
1913 renders,
1914 }
1915 }
1916 });
1917 cx.update(|window, cx| window.draw(cx).clear(cx));
1918 cx.update(|window, cx| window.draw(cx).clear(cx));
1919 let rendered = renders.get();
1920 for _ in 0..3 {
1921 cx.update(|window, cx| window.draw(cx).clear(cx));
1922 }
1923 assert_eq!(renders.get(), rendered, "the page was built again");
1924 }
1925 }
1926}