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 v_flex,
24};
25use gpui_base::{Combobox as BaseCombobox, GlobalState};
26
27pub struct ComboboxTriggerContext<'a, D: SearchableListDelegate + 'static> {
34 selection: &'a [(IndexPath, D::Item)],
35 placeholder: Option<&'a SharedString>,
36 open: bool,
37 disabled: bool,
38 size: Size,
39}
40
41impl<'a, D: SearchableListDelegate + 'static> ComboboxTriggerContext<'a, D> {
42 pub fn selection(&self) -> &'a [(IndexPath, D::Item)] {
44 self.selection
45 }
46
47 pub fn placeholder(&self) -> Option<&'a SharedString> {
48 self.placeholder
49 }
50
51 pub fn is_open(&self) -> bool {
53 self.open
54 }
55
56 pub fn is_disabled(&self) -> bool {
57 self.disabled
58 }
59
60 pub fn size(&self) -> Size {
61 self.size
62 }
63}
64
65pub type ComboboxChange = SearchableListChange;
69
70struct ComboboxOptions {
73 style: StyleRefinement,
74 size: Size,
75 cleanable: bool,
76 placeholder: Option<SharedString>,
77 search_placeholder: Option<SharedString>,
78 menu_width: Length,
79 menu_max_h: Length,
80 disabled: bool,
81 appearance: bool,
82 focus_ring_enabled: bool,
83 trigger_icon: Option<Icon>,
84 check_icon: Option<Icon>,
85}
86
87impl Default for ComboboxOptions {
88 fn default() -> Self {
89 Self {
90 style: StyleRefinement::default(),
91 size: Size::default(),
92 cleanable: false,
93 placeholder: None,
94 search_placeholder: None,
95 menu_width: Length::Auto,
96 menu_max_h: rems(20.).into(),
97 disabled: false,
98 appearance: true,
99 focus_ring_enabled: true,
100 trigger_icon: None,
101 check_icon: None,
102 }
103 }
104}
105
106pub struct ComboboxState<D: SearchableListDelegate + 'static>
110where
111 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
112{
113 pub(crate) state: SearchableListState<D>,
114
115 multiple: bool,
117 searchable: bool,
118 trigger_icon: Option<Icon>,
119 check_icon: Option<Icon>,
120 render_trigger: Option<
121 Box<dyn Fn(&ComboboxTriggerContext<D>, &mut Window, &mut App) -> AnyElement + 'static>,
122 >,
123 footer: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
124 focus_ring_enabled: bool,
125}
126
127pub enum ComboboxEvent<D: SearchableListDelegate + 'static>
129where
130 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
131{
132 Change(Vec<<D::Item as SearchableListItem>::Value>),
134 Confirm(Vec<<D::Item as SearchableListItem>::Value>),
136}
137
138impl<D> ComboboxState<D>
139where
140 D: SearchableListDelegate + 'static,
141 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
142{
143 pub fn new(
145 delegate: D,
146 selected_indices: Vec<IndexPath>,
147 window: &mut Window,
148 cx: &mut Context<Self>,
149 ) -> Self {
150 let weak = cx.entity().downgrade();
151 let weak_confirm = weak.clone();
152 let weak_cancel = weak.clone();
153 let weak_empty = weak;
154
155 let state = SearchableListState::new(
156 delegate,
157 selected_indices,
158 move |selected_index, _secondary, window, cx| {
159 cx.defer_in(window, {
160 let weak_confirm = weak_confirm.clone();
161 move |list_state, window, cx| {
162 let Some(index) = selected_index else {
163 return;
164 };
165
166 let Some(item) = list_state.delegate().delegate.item(index).cloned() else {
167 return;
168 };
169
170 let ix = index;
171
172 let Some(weak) = weak_confirm.upgrade() else {
173 return;
174 };
175
176 let (multiple, mut selection) = {
177 let s = weak.read(cx);
178 (s.multiple, s.state.selection.clone())
179 };
180
181 let changes = Self::selection_changes(multiple, &selection, ix, &item);
182
183 let before_indices: Vec<IndexPath> =
184 selection.iter().map(|(ix, _)| *ix).collect();
185
186 list_state
189 .delegate_mut()
190 .delegate
191 .on_will_change(&mut selection, &changes);
192
193 let after_indices: Vec<IndexPath> =
194 selection.iter().map(|(ix, _)| *ix).collect();
195 let changed = before_indices != after_indices;
196 let should_close = changed && !multiple;
197
198 let new_selection = weak_confirm.update(cx, |this, cx| {
199 this.state.selection = selection;
200
201 if changed {
202 cx.emit(ComboboxEvent::Change(this.selected_values()));
203 cx.notify();
204 }
205
206 if should_close {
207 cx.emit(ComboboxEvent::Confirm(this.selected_values()));
208 this.set_open(false, cx);
209 this.focus(window, cx);
210 }
211
212 this.state.selection.clone()
213 });
214
215 if let Ok(new_selection) = new_selection {
217 list_state
218 .delegate_mut()
219 .update_selection_snapshot(new_selection.clone());
220
221 if should_close {
222 list_state
223 .delegate_mut()
224 .delegate
225 .on_confirm(&new_selection);
226 }
227 }
228 }
229 });
230 },
231 move |_final_selected_index, window, cx| {
233 cx.defer_in(window, {
234 let weak_cancel = weak_cancel.clone();
235 move |_list_state, window, cx| {
236 _ = weak_cancel.update(cx, |this, cx| {
237 cx.emit(ComboboxEvent::Confirm(this.selected_values()));
238 this.set_open(false, cx);
239 this.focus(window, cx);
240 });
241 }
242 });
243 },
244 move |window, cx| {
246 if let Some(empty) = weak_empty
247 .upgrade()
248 .and_then(|e| e.read(cx).state.empty.as_ref().map(|f| f(window, cx)))
249 {
250 empty
251 } else {
252 h_flex()
253 .justify_center()
254 .py_6()
255 .text_color(cx.theme().muted_foreground.opacity(0.6))
256 .child(Icon::new(IconName::Inbox).size(px(28.)))
257 .into_any_element()
258 }
259 },
260 Self::on_blur,
261 window,
262 cx,
263 );
264
265 Self {
266 state,
267 multiple: false,
268 searchable: false,
269 trigger_icon: None,
270 check_icon: None,
271 render_trigger: None,
272 footer: None,
273 focus_ring_enabled: true,
274 }
275 }
276
277 pub fn multiple(mut self, multiple: bool) -> Self {
282 self.multiple = multiple;
283 self
284 }
285
286 pub fn searchable(mut self, searchable: bool) -> Self {
288 self.searchable = searchable;
289 self
290 }
291
292 pub fn selected_values(&self) -> Vec<<D::Item as SearchableListItem>::Value> {
294 self.state.selected_values()
295 }
296
297 pub fn selected_value(&self) -> Option<<D::Item as SearchableListItem>::Value> {
301 self.state.selected_values().into_iter().next()
302 }
303
304 pub fn selection(&self) -> &[(IndexPath, D::Item)] {
306 self.state.selection()
307 }
308
309 pub fn set_selected_values(
315 &mut self,
316 values: &[<D::Item as SearchableListItem>::Value],
317 window: &mut Window,
318 cx: &mut Context<Self>,
319 ) {
320 self.state.clear_query(window, cx);
321
322 let selected_indices = {
323 let list = self.state.list.read(cx);
324 let delegate = &list.delegate().delegate;
325
326 values
327 .iter()
328 .filter_map(|value| delegate.position(value))
329 .collect::<Vec<_>>()
330 };
331
332 self.set_selected_indices(selected_indices, window, cx);
333 }
334
335 pub fn set_selected_indices(
337 &mut self,
338 indices: impl IntoIterator<Item = IndexPath>,
339 _window: &mut Window,
340 cx: &mut Context<Self>,
341 ) {
342 self.state.set_selected_indices(indices, cx);
343 self.state.sync_snapshot(cx);
344 cx.notify();
345 }
346
347 pub fn add_selected_index(&mut self, index: IndexPath, cx: &mut Context<Self>) -> bool {
349 let added = self.state.add_selected_index(index, cx);
350
351 if added {
352 self.state.sync_snapshot(cx);
353 cx.notify();
354 }
355
356 added
357 }
358
359 pub fn remove_selected_index(&mut self, index: IndexPath, cx: &mut Context<Self>) -> bool {
361 let removed = self.state.remove_selected_index(index);
362
363 if removed {
364 self.state.sync_snapshot(cx);
365 }
366
367 removed
368 }
369
370 pub fn clear_selection(&mut self, cx: &mut Context<Self>) {
372 self.state.selection.clear();
373 self.state.sync_snapshot(cx);
374 cx.emit(ComboboxEvent::Change(self.selected_values()));
375 cx.notify();
376 }
377
378 pub fn set_items(&mut self, items: D, _: &mut Window, cx: &mut Context<Self>) {
380 self.state.list.update(cx, |list, _| {
381 list.delegate_mut().delegate = items;
382 });
383 }
384
385 pub fn focus(&self, window: &mut Window, cx: &mut App) {
387 self.state.focus_handle.focus(window, cx);
388 }
389
390 pub fn query(&self, cx: &App) -> SharedString {
392 self.state.list.read(cx).query_input.read(cx).value()
393 }
394
395 pub fn set_query(&self, query: impl Into<SharedString>, window: &mut Window, cx: &mut App) {
397 let query = query.into();
398 self.state.list.update(cx, |list, cx| {
399 list.set_query(query.as_ref(), window, cx);
400 });
401 }
402
403 fn selection_changes(
404 multiple: bool,
405 selection: &[(IndexPath, D::Item)],
406 ix: IndexPath,
407 item: &D::Item,
408 ) -> Vec<SearchableListChange> {
409 let is_selected = selection
410 .iter()
411 .any(|(_, selected_item)| selected_item.value() == item.value());
412
413 if multiple {
414 if is_selected {
415 vec![SearchableListChange::Deselect { index: ix }]
416 } else {
417 vec![SearchableListChange::Select { index: ix }]
418 }
419 } else {
420 let mut changes: Vec<SearchableListChange> = selection
421 .iter()
422 .map(|(cur_ix, _)| SearchableListChange::Deselect { index: *cur_ix })
423 .collect();
424 changes.push(SearchableListChange::Select { index: ix });
425 changes
426 }
427 }
428
429 #[allow(dead_code)]
433 pub(crate) fn handle_item_select(
434 &mut self,
435 ix: IndexPath,
436 window: &mut Window,
437 cx: &mut Context<Self>,
438 ) {
439 let Some(item) = self
440 .state
441 .list
442 .read(cx)
443 .delegate()
444 .delegate
445 .item(ix)
446 .cloned()
447 else {
448 return;
449 };
450
451 let changes = Self::selection_changes(self.multiple, &self.state.selection, ix, &item);
452
453 let mut selection = self.state.selection.clone();
454 let before_indices: Vec<IndexPath> = selection.iter().map(|(ix, _)| *ix).collect();
455
456 self.state.list.update(cx, |list, _cx| {
457 list.delegate_mut()
458 .delegate
459 .on_will_change(&mut selection, &changes);
460 });
461
462 let after_indices: Vec<IndexPath> = selection.iter().map(|(ix, _)| *ix).collect();
463 let changed = before_indices != after_indices;
464 let should_close = changed && !self.multiple;
465
466 self.state.selection = selection;
467 self.state.sync_snapshot(cx);
468
469 if changed {
470 cx.emit(ComboboxEvent::Change(self.selected_values()));
471 cx.notify();
472 }
473
474 if should_close {
475 let final_selection = self.state.selection.clone();
476 self.state.list.update(cx, |list, _cx| {
477 list.delegate_mut().delegate.on_confirm(&final_selection);
478 });
479
480 cx.emit(ComboboxEvent::Confirm(self.selected_values()));
481 self.set_open(false, cx);
482 self.focus(window, cx);
483 }
484 }
485
486 fn on_blur(&mut self, window: &mut Window, cx: &mut Context<Self>) {
487 if self.state.list.read(cx).is_focused(window, cx)
488 || self.state.focus_handle.is_focused(window)
489 {
490 return;
491 }
492
493 self.set_open(false, cx);
494 cx.notify();
495 }
496
497 fn toggle_menu(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
498 cx.stop_propagation();
499
500 self.set_open(!self.state.open, cx);
501
502 if self.state.open {
503 self.state.list.focus_handle(cx).focus(window, cx);
504 } else {
505 cx.emit(ComboboxEvent::Confirm(self.selected_values()));
506 self.focus(window, cx);
507 }
508
509 cx.notify();
510 }
511
512 fn dismiss(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
518 if !self.state.open || self.state.bounds.contains(&event.position) {
519 return;
520 }
521
522 cx.stop_propagation();
523 cx.emit(ComboboxEvent::Confirm(self.selected_values()));
524
525 self.set_open(false, cx);
526 self.focus(window, cx);
527 cx.notify();
528 }
529
530 fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
531 self.state.open = open;
532 self.state.deferred_context = open.then(|| GlobalState::register_deferred_popover(cx));
533
534 cx.notify();
535 }
536
537 fn clean(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
538 cx.stop_propagation();
539 self.clear_selection(cx);
540 }
541
542 fn default_trigger_body(&self, _window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
543 let placeholder_text = self
544 .state
545 .placeholder
546 .clone()
547 .unwrap_or_else(|| t!("Combobox.placeholder").into());
548
549 if self.state.selection.is_empty() {
550 return div()
551 .text_color(cx.theme().muted_foreground)
552 .child(placeholder_text)
553 .into_any_element();
554 }
555
556 if self.multiple {
557 let items: Vec<SharedString> = self
558 .state
559 .selection
560 .iter()
561 .map(|(_, i)| i.title())
562 .collect();
563
564 div()
565 .w_full()
566 .overflow_hidden()
567 .whitespace_nowrap()
568 .truncate()
569 .child(items.join(", "))
570 .into_any_element()
571 } else {
572 let title = self
573 .state
574 .selection
575 .first()
576 .map(|(_, i)| i.title())
577 .unwrap_or_default();
578
579 div()
580 .w_full()
581 .overflow_hidden()
582 .whitespace_nowrap()
583 .truncate()
584 .child(title)
585 .into_any_element()
586 }
587 }
588}
589
590impl<D> Render for ComboboxState<D>
591where
592 D: SearchableListDelegate + 'static,
593 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
594{
595 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
596 let searchable = self.searchable;
597 let is_focused = self.state.focus_handle.is_focused(window);
598 let show_clean = self.state.cleanable && !self.state.selection.is_empty();
599 let bounds = self.state.bounds;
600 let allow_open = !self.state.disabled;
601 let outline_visible = self.state.open || (is_focused && !self.state.disabled);
602 let disabled = self.state.disabled;
603
604 let (bg, fg) = input_style(disabled, cx);
605
606 self.state.list.update(cx, |list, cx| {
607 list.set_searchable(searchable, cx);
608 list.delegate_mut().size = self.state.size;
609 list.delegate_mut().check_icon = self.check_icon.clone();
610 });
611
612 let selection = &self.state.selection;
613 let placeholder = self.state.placeholder.as_ref();
614 let open = self.state.open;
615 let size = self.state.size;
616 let has_custom_trigger = self.render_trigger.is_some();
617
618 let trigger_body = if let Some(render_trigger) = &self.render_trigger {
619 let trigger = ComboboxTriggerContext {
620 selection,
621 placeholder,
622 open,
623 disabled,
624 size,
625 };
626
627 render_trigger(&trigger, window, cx)
628 } else {
629 self.default_trigger_body(window, cx)
630 };
631
632 let trailing: AnyElement = if has_custom_trigger {
633 div().into_any_element()
634 } else if show_clean {
635 clear_button(cx)
636 .map(|this| {
637 if disabled {
638 this.disabled(true)
639 } else {
640 this.on_click(cx.listener(Self::clean))
641 }
642 })
643 .into_any_element()
644 } else if let Some(icon) = self.trigger_icon.clone() {
645 icon.xsmall()
646 .text_color(cx.theme().muted_foreground)
647 .into_any_element()
648 } else {
649 Caret::new(size)
650 .text_color(cx.theme().muted_foreground)
651 .into_any_element()
652 };
653
654 let toggle_handler: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>> =
655 if allow_open {
656 Some(Box::new(cx.listener(Self::toggle_menu)))
657 } else {
658 None
659 };
660
661 let footer_el = self.footer.as_ref().map(|f| f(window, cx));
662
663 let dismiss_handler: Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static> =
664 Box::new(cx.listener(Self::dismiss));
665
666 div().size_full().relative().child(
667 div()
668 .relative()
669 .on_prepaint({
670 let state = cx.entity();
671 move |bounds, _, cx| state.update(cx, |r, _| r.state.bounds = bounds)
672 })
673 .child(render_trigger_container(
674 disabled,
675 self.state.appearance,
676 self.focus_ring_enabled,
677 self.state.size,
678 &self.state.style,
679 bg,
680 fg,
681 outline_visible,
682 allow_open,
683 trigger_body,
684 trailing,
685 toggle_handler,
686 window,
687 cx,
688 ))
689 .when(self.state.open, |this| {
690 this.child(
691 deferred(render_popup_shell(
692 ("combobox-popup", cx.entity_id()),
693 &self.state.list,
694 self.state.menu_width,
695 self.state.search_placeholder.clone(),
696 self.state.size,
697 self.state.menu_max_h,
698 bounds,
699 footer_el,
700 dismiss_handler,
701 cx,
702 ))
703 .with_priority(gpui_base::POPUP_PRIORITY),
704 )
705 }),
706 )
707 }
708}
709
710impl<D> EventEmitter<ComboboxEvent<D>> for ComboboxState<D>
711where
712 D: SearchableListDelegate + 'static,
713 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
714{
715}
716impl<D> EventEmitter<DismissEvent> for ComboboxState<D>
717where
718 D: SearchableListDelegate + 'static,
719 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
720{
721}
722
723impl<D> Focusable for ComboboxState<D>
724where
725 D: SearchableListDelegate + 'static,
726 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
727{
728 fn focus_handle(&self, cx: &App) -> FocusHandle {
729 if self.state.open {
730 self.state.list.focus_handle(cx)
731 } else {
732 self.state.focus_handle.clone()
733 }
734 }
735}
736
737#[derive(IntoElement)]
744pub struct Combobox<D: SearchableListDelegate + 'static>
745where
746 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
747{
748 id: ElementId,
749 state: Entity<ComboboxState<D>>,
750 options: ComboboxOptions,
751 render_trigger: Option<
752 Box<dyn Fn(&ComboboxTriggerContext<D>, &mut Window, &mut App) -> AnyElement + 'static>,
753 >,
754 footer: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>>,
755 empty: Option<Box<dyn Fn(&mut Window, &App) -> AnyElement + 'static>>,
756}
757
758impl<D> Combobox<D>
759where
760 D: SearchableListDelegate + 'static,
761 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
762{
763 pub fn new(state: &Entity<ComboboxState<D>>) -> Self {
764 Self {
765 id: ("multi-combo-box", state.entity_id()).into(),
766 state: state.clone(),
767 options: ComboboxOptions::default(),
768 render_trigger: None,
769 footer: None,
770 empty: None,
771 }
772 }
773
774 pub fn menu_width(mut self, width: impl Into<Length>) -> Self {
776 self.options.menu_width = width.into();
777 self
778 }
779
780 pub fn menu_max_h(mut self, max_h: impl Into<Length>) -> Self {
782 self.options.menu_max_h = max_h.into();
783 self
784 }
785
786 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
788 self.options.placeholder = Some(placeholder.into());
789 self
790 }
791
792 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
794 self.options.trigger_icon = Some(icon.into());
795 self
796 }
797
798 pub fn check_icon(mut self, icon: impl Into<Icon>) -> Self {
800 self.options.check_icon = Some(icon.into());
801 self
802 }
803
804 pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
806 self.options.search_placeholder = Some(placeholder.into());
807 self
808 }
809
810 pub fn cleanable(mut self, cleanable: bool) -> Self {
812 self.options.cleanable = cleanable;
813 self
814 }
815
816 pub fn disabled(mut self, disabled: bool) -> Self {
818 self.options.disabled = disabled;
819 self
820 }
821
822 pub fn empty<E: IntoElement + 'static>(
824 mut self,
825 builder: impl Fn(&mut Window, &App) -> E + 'static,
826 ) -> Self {
827 self.empty = Some(Box::new(move |window, cx| {
828 builder(window, cx).into_any_element()
829 }));
830 self
831 }
832
833 pub fn appearance(mut self, appearance: bool) -> Self {
835 self.options.appearance = appearance;
836 self
837 }
838
839 pub fn render_trigger<E: IntoElement + 'static>(
841 mut self,
842 f: impl Fn(&ComboboxTriggerContext<D>, &mut Window, &mut App) -> E + 'static,
843 ) -> Self {
844 self.render_trigger = Some(Box::new(move |trigger, window, cx| {
845 f(trigger, window, cx).into_any_element()
846 }));
847 self
848 }
849
850 pub fn footer<E: IntoElement + 'static>(
852 mut self,
853 f: impl Fn(&mut Window, &mut App) -> E + 'static,
854 ) -> Self {
855 self.footer = Some(Box::new(move |window, cx| f(window, cx).into_any_element()));
856 self
857 }
858}
859
860impl<D> Sizable for Combobox<D>
861where
862 D: SearchableListDelegate + 'static,
863 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
864{
865 fn with_size(mut self, size: impl Into<Size>) -> Self {
866 self.options.size = size.into();
867 self
868 }
869}
870
871impl<D> crate::FocusableExt for Combobox<D>
872where
873 D: SearchableListDelegate + 'static,
874 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
875{
876 fn focus_ring(mut self, enabled: bool) -> Self {
877 self.options.focus_ring_enabled = enabled;
878 self
879 }
880
881 fn is_focus_ring_enabled(&self) -> bool {
882 self.options.focus_ring_enabled
883 }
884}
885
886impl<D> Styled for Combobox<D>
887where
888 D: SearchableListDelegate + 'static,
889 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
890{
891 fn style(&mut self) -> &mut StyleRefinement {
892 &mut self.options.style
893 }
894}
895
896impl<D> RenderOnce for Combobox<D>
897where
898 D: SearchableListDelegate + 'static,
899 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
900{
901 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
902 let disabled = self.options.disabled;
903 let focus_handle = self.state.read(cx).state.focus_handle.clone();
904 let render_trigger = self.render_trigger;
905 let footer = self.footer;
906 let empty = self.empty;
907 let opts = self.options;
908
909 self.state.update(cx, |this, _| {
910 this.state.style = opts.style;
911 this.state.size = opts.size;
912 this.state.cleanable = opts.cleanable;
913 this.state.placeholder = opts.placeholder;
914 this.state.search_placeholder = opts.search_placeholder;
915 this.state.menu_width = opts.menu_width;
916 this.state.menu_max_h = opts.menu_max_h;
917 this.state.disabled = opts.disabled;
918 this.state.appearance = opts.appearance;
919 this.focus_ring_enabled = opts.focus_ring_enabled;
920 this.trigger_icon = opts.trigger_icon;
921 this.check_icon = opts.check_icon;
922 this.render_trigger = render_trigger;
923 this.footer = footer;
924
925 if let Some(empty) = empty {
926 this.state.empty = Some(empty);
927 }
928 });
929
930 let is_open = self.state.read(cx).state.open;
931 let content_focus_handle = self.state.read(cx).state.list.focus_handle(cx);
932 let open_state = self.state.clone();
933 let confirm_state = self.state.clone();
934
935 BaseCombobox::new(self.id)
936 .open(is_open)
937 .disabled(disabled)
938 .focus_handle(&focus_handle)
939 .content_focus_handle(&content_focus_handle)
940 .on_open_change(move |open, _, cx| {
941 open_state.update(cx, |state, cx| state.set_open(open, cx));
942 })
943 .on_dismiss(move |_, cx| {
946 confirm_state.update(cx, |state, cx| {
947 cx.emit(ComboboxEvent::Confirm(state.selected_values()));
948 });
949 })
950 .size_full()
951 .child(self.state)
952 }
953}
954
955#[allow(clippy::too_many_arguments)]
959fn render_trigger_container(
960 disabled: bool,
961 appearance: bool,
962 focus_ring_enabled: bool,
963 size: Size,
964 style: &StyleRefinement,
965 bg: Hsla,
966 fg: Hsla,
967 outline_visible: bool,
968 allow_open: bool,
969 trigger_body: AnyElement,
970 trailing: AnyElement,
971 toggle_handler: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
972 window: &Window,
973 cx: &mut App,
974) -> impl IntoElement {
975 div()
976 .id("input")
977 .relative()
978 .flex()
979 .items_center()
980 .justify_between()
981 .border_1()
982 .border_color(cx.theme().transparent)
983 .when(appearance, |this| {
984 this.bg(bg)
985 .text_color(fg)
986 .when(disabled, |this| this.opacity(0.5))
987 .border_color(cx.theme().input)
988 .rounded(cx.theme().radius)
989 })
990 .input_size(size)
991 .input_text_size(size)
992 .refine_style(style)
993 .when(outline_visible && appearance, |this| {
994 this.border_1().border_color(cx.theme().ring)
995 })
996 .when(
997 outline_visible && appearance && focus_ring_enabled,
998 |this| this.focus_ring_style(window, cx),
999 )
1000 .when(allow_open, |this| {
1001 this.when_some(toggle_handler, |this, handler| this.on_click(handler))
1002 })
1003 .child(
1004 h_flex()
1005 .id("inner")
1006 .w_full()
1007 .min_w_0()
1008 .overflow_hidden()
1009 .whitespace_nowrap()
1010 .items_center()
1011 .justify_between()
1012 .gap_1()
1013 .child(
1014 div()
1015 .flex_1()
1016 .min_w_0()
1017 .overflow_hidden()
1018 .whitespace_nowrap()
1019 .child(trigger_body),
1020 )
1021 .child(trailing),
1022 )
1023}
1024
1025#[allow(clippy::too_many_arguments)]
1027fn render_popup_shell<D: SearchableListDelegate + 'static>(
1028 id: impl Into<ElementId>,
1029 list: &Entity<ListState<SearchableListAdapter<D>>>,
1030 menu_width: Length,
1031 search_placeholder: Option<SharedString>,
1032 size: Size,
1033 menu_max_h: Length,
1034 bounds: Bounds<Pixels>,
1035 footer_el: Option<AnyElement>,
1036 dismiss_handler: Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>,
1037 cx: &mut App,
1038) -> AnyElement {
1039 let has_footer = footer_el.is_some();
1040
1041 crate::popover::dropdown_popup(
1042 id,
1043 bounds,
1044 v_flex()
1045 .occlude()
1046 .map(|this| match menu_width {
1047 Length::Auto => this.w(bounds.size.width + px(2.)),
1048 Length::Definite(w) => this.w(w),
1049 })
1050 .popover_style(cx)
1051 .child(
1052 List::new(list)
1053 .when_some(search_placeholder, |this, placeholder| {
1054 this.search_placeholder(placeholder)
1055 })
1056 .with_size(size)
1057 .max_h(menu_max_h)
1058 .paddings(Edges::all(px(4.))),
1059 )
1060 .when(has_footer, |this| {
1061 this.child(
1062 div()
1063 .border_t_1()
1064 .border_color(cx.theme().border)
1065 .p_1()
1066 .when_some(footer_el, |this, el| this.child(el)),
1067 )
1068 })
1069 .on_mouse_down_out(dismiss_handler),
1070 cx,
1071 )
1072 .into_any_element()
1073}
1074
1075#[cfg(test)]
1078mod tests {
1079 use std::{cell::Cell, rc::Rc};
1080
1081 use gpui::{
1082 AppContext as _, Bounds, Context, Entity, Modifiers, MouseButton, MouseDownEvent, Pixels,
1083 Point, Subscription, TestAppContext, point, px, size,
1084 };
1085
1086 use crate::{
1087 IndexPath,
1088 combobox::{Combobox, ComboboxEvent, ComboboxState},
1089 searchable_list::{
1090 SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState,
1091 SearchableVec,
1092 },
1093 };
1094
1095 struct TestComboboxEventCollector {
1096 event_count: Rc<Cell<usize>>,
1097 _subscription: Subscription,
1098 }
1099
1100 impl TestComboboxEventCollector {
1101 fn new(
1102 state: &Entity<ComboboxState<SearchableVec<&'static str>>>,
1103 cx: &mut Context<Self>,
1104 ) -> Self {
1105 let event_count = Rc::new(Cell::new(0));
1106 let event_count_for_subscription = event_count.clone();
1107 let _subscription = cx.subscribe(
1108 state,
1109 move |_, _, _: &ComboboxEvent<SearchableVec<&'static str>>, _| {
1110 event_count_for_subscription.set(event_count_for_subscription.get() + 1);
1111 },
1112 );
1113
1114 Self {
1115 event_count,
1116 _subscription,
1117 }
1118 }
1119 }
1120
1121 #[gpui::test]
1122 fn test_combo_box_builder(cx: &mut TestAppContext) {
1123 cx.update(crate::init);
1124 let cx = cx.add_empty_window();
1125 cx.update(|window, cx| {
1126 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1127 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true));
1128
1129 let _cb = Combobox::new(&state)
1130 .placeholder("Select language")
1131 .search_placeholder("Search...")
1132 .menu_width(gpui::px(300.))
1133 .menu_max_h(gpui::rems(15.))
1134 .cleanable(true)
1135 .disabled(false)
1136 .appearance(true);
1137 });
1138 }
1139
1140 #[gpui::test]
1141 fn test_combo_box_search_filters_items(cx: &mut TestAppContext) {
1142 cx.update(crate::init);
1143 let cx = cx.add_empty_window();
1144 cx.update(|window, cx| {
1145 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1146 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true));
1147
1148 let count_before = state
1149 .read(cx)
1150 .state
1151 .list
1152 .read(cx)
1153 .delegate()
1154 .delegate
1155 .items_count(0);
1156 assert_eq!(count_before, 3);
1157
1158 state.update(cx, |s, cx| {
1159 s.state.list.update(cx, |list, cx| {
1160 let _ = list
1161 .delegate_mut()
1162 .delegate
1163 .perform_search("Rust", window, cx);
1164 });
1165 });
1166
1167 let count_after = state
1168 .read(cx)
1169 .state
1170 .list
1171 .read(cx)
1172 .delegate()
1173 .delegate
1174 .items_count(0);
1175 assert_eq!(count_after, 1);
1176 });
1177 }
1178
1179 #[gpui::test]
1180 fn test_combo_box_set_query_updates_text_and_filters_items(cx: &mut TestAppContext) {
1181 cx.update(crate::init);
1182 let cx = cx.add_empty_window();
1183 cx.update(|window, cx| {
1184 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1185 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).searchable(true));
1186
1187 state.update(cx, |state, cx| state.set_query(" Rust ", window, cx));
1188
1189 assert_eq!(state.read(cx).query(cx).as_ref(), " Rust ");
1190 assert_eq!(
1191 state
1192 .read(cx)
1193 .state
1194 .list
1195 .read(cx)
1196 .delegate()
1197 .delegate
1198 .items_count(0),
1199 1,
1200 );
1201 });
1202 }
1203
1204 #[gpui::test]
1205 fn test_multi_combo_box_builder(cx: &mut TestAppContext) {
1206 cx.update(crate::init);
1207 let cx = cx.add_empty_window();
1208 cx.update(|window, cx| {
1209 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1210 let state = cx.new(|cx| {
1211 ComboboxState::new(items, vec![IndexPath::new(0)], window, cx)
1212 .multiple(true)
1213 .searchable(true)
1214 });
1215
1216 let _cb = Combobox::new(&state)
1217 .placeholder("Select frameworks")
1218 .search_placeholder("Search...")
1219 .menu_width(gpui::px(300.))
1220 .cleanable(true)
1221 .disabled(false);
1222
1223 assert_eq!(state.read(cx).selected_values(), vec!["React"]);
1224 });
1225 }
1226
1227 #[gpui::test]
1228 fn test_combo_box_set_selected_values_uses_current_delegate(cx: &mut TestAppContext) {
1229 cx.update(crate::init);
1230 let cx = cx.add_empty_window();
1231 cx.update(|window, cx| {
1232 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1233 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1234
1235 state.update(cx, |state, cx| {
1236 state.set_selected_values(&["Vue", "Missing"], window, cx);
1237
1238 assert_eq!(state.selected_values(), vec!["Vue"]);
1239 assert_eq!(
1240 state
1241 .selection()
1242 .iter()
1243 .map(|(index, _)| *index)
1244 .collect::<Vec<_>>(),
1245 vec![IndexPath::new(1)],
1246 );
1247 assert_eq!(
1248 state
1249 .state
1250 .list
1251 .read(cx)
1252 .delegate()
1253 .selection_snapshot
1254 .as_slice(),
1255 state.selection(),
1256 );
1257
1258 state.set_items(SearchableVec::new(vec!["Vue", "Rust", "Go"]), window, cx);
1259 state.set_selected_values(&["Go", "Vue"], window, cx);
1260
1261 assert_eq!(state.selected_values(), vec!["Go", "Vue"]);
1262 assert_eq!(
1263 state
1264 .selection()
1265 .iter()
1266 .map(|(index, _)| *index)
1267 .collect::<Vec<_>>(),
1268 vec![IndexPath::new(2), IndexPath::new(0)],
1269 );
1270 assert_eq!(
1271 state
1272 .state
1273 .list
1274 .read(cx)
1275 .delegate()
1276 .selection_snapshot
1277 .as_slice(),
1278 state.selection(),
1279 );
1280
1281 state.set_selected_values(&[], window, cx);
1282
1283 assert!(state.selection().is_empty());
1284 assert!(
1285 state
1286 .state
1287 .list
1288 .read(cx)
1289 .delegate()
1290 .selection_snapshot
1291 .is_empty()
1292 );
1293 });
1294 });
1295 }
1296
1297 #[gpui::test]
1300 fn test_combo_box_set_selected_values_keeps_values_hidden_by_the_search(
1301 cx: &mut TestAppContext,
1302 ) {
1303 cx.update(crate::init);
1304 let cx = cx.add_empty_window();
1305 cx.update(|window, cx| {
1306 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1307 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1308
1309 state.update(cx, |state, cx| {
1310 state.set_selected_values(&["React", "Vue"], window, cx);
1311 state.state.list.update(cx, |list, cx| {
1312 list.set_query("Angular", window, cx);
1313 });
1314
1315 state.set_selected_values(&["React", "Vue", "Angular"], window, cx);
1316
1317 assert_eq!(
1318 state.selected_values(),
1319 vec!["React", "Vue", "Angular"],
1320 "a value the search had hidden must survive being reapplied"
1321 );
1322 assert_eq!(
1323 state
1324 .selection()
1325 .iter()
1326 .map(|(index, _)| *index)
1327 .collect::<Vec<_>>(),
1328 vec![IndexPath::new(0), IndexPath::new(1), IndexPath::new(2)],
1329 "clearing the query puts every selected item back in view"
1330 );
1331 });
1332 });
1333 }
1334
1335 #[gpui::test]
1336 fn test_combo_box_set_selected_values_does_not_emit_events(cx: &mut TestAppContext) {
1337 cx.update(crate::init);
1338 let cx = cx.add_empty_window();
1339 let state = cx.update(|window, cx| {
1340 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1341 cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true))
1342 });
1343 let collector = cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx)));
1344
1345 cx.update(|window, cx| {
1346 state.update(cx, |state, cx| {
1347 state.set_selected_values(&["React", "Vue"], window, cx);
1348 });
1349 });
1350
1351 cx.update(|_, cx| {
1352 assert_eq!(collector.read(cx).event_count.get(), 0);
1353 });
1354 }
1355
1356 #[gpui::test]
1357 fn test_combo_box_initial_selection_seeds_cursor(cx: &mut TestAppContext) {
1358 cx.update(crate::init);
1359 let cx = cx.add_empty_window();
1360 cx.update(|window, cx| {
1361 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1362 let state = cx.new(|cx| {
1363 ComboboxState::new(items, vec![IndexPath::new(1)], window, cx).multiple(true)
1364 });
1365
1366 let state_ref = state.read(cx);
1367 assert_eq!(
1368 state_ref.state.list.read(cx).selected_index(),
1369 Some(IndexPath::new(1)),
1370 "initial selected_indices should seed ListState.selected_index, not just the snapshot",
1371 );
1372 assert_eq!(state_ref.selected_values(), vec!["Vue"]);
1373 });
1374 }
1375
1376 #[gpui::test]
1377 fn test_multi_combo_box_toggle(cx: &mut TestAppContext) {
1378 cx.update(crate::init);
1379 let cx = cx.add_empty_window();
1380 cx.update(|window, cx| {
1381 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1382 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1383
1384 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1385 assert_eq!(state.read(cx).selected_values(), &["React"]);
1386
1387 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx));
1388 assert_eq!(state.read(cx).selected_values(), &["React", "Vue"]);
1389
1390 state.update(cx, |s, cx| s.remove_selected_index(IndexPath::new(0), cx));
1391 assert_eq!(state.read(cx).selected_values(), &["Vue"]);
1392 });
1393 }
1394
1395 #[gpui::test]
1396 fn test_multi_combo_box_search_selection_uses_value_identity(cx: &mut TestAppContext) {
1397 cx.update(crate::init);
1398 let cx = cx.add_empty_window();
1399 cx.update(|window, cx| {
1400 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1401 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1402
1403 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1404 assert_eq!(state.read(cx).selected_values(), &["React"]);
1405
1406 state.update(cx, |s, cx| {
1407 s.state.list.update(cx, |list, cx| {
1408 let _ = list
1409 .delegate_mut()
1410 .delegate
1411 .perform_search("Vue", window, cx);
1412 });
1413 });
1414
1415 state.read_with(cx, |s, cx| {
1416 let selection = s.state.selection.clone();
1417 let list = s.state.list.read(cx);
1418 let delegate = &list.delegate().delegate;
1419 let ix = IndexPath::new(0);
1420 let item = delegate.item(ix).expect("filtered item exists");
1421
1422 assert_eq!(item.value(), &"Vue");
1423 assert!(
1424 !delegate.is_item_checked(ix, item, &selection, cx),
1425 "filtered row 0 should not inherit React's checked state",
1426 );
1427 });
1428
1429 state.update(cx, |s, cx| {
1430 s.handle_item_select(IndexPath::new(0), window, cx);
1431 });
1432 assert_eq!(state.read(cx).selected_values(), &["React", "Vue"]);
1433 });
1434 }
1435
1436 #[gpui::test]
1437 fn test_multi_combo_box_search_deselects_by_value(cx: &mut TestAppContext) {
1438 cx.update(crate::init);
1439 let cx = cx.add_empty_window();
1440 cx.update(|window, cx| {
1441 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1442 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true));
1443
1444 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1445
1446 state.update(cx, |s, cx| {
1447 s.state.list.update(cx, |list, cx| {
1448 let _ = list
1449 .delegate_mut()
1450 .delegate
1451 .perform_search("React", window, cx);
1452 });
1453 });
1454
1455 state.update(cx, |s, cx| {
1456 s.handle_item_select(IndexPath::new(0), window, cx);
1457 });
1458 assert!(state.read(cx).selected_values().is_empty());
1459 });
1460 }
1461
1462 #[gpui::test]
1463 fn test_searchable_list_default_change_uses_value_identity(cx: &mut TestAppContext) {
1464 cx.update(crate::init);
1465 let cx = cx.add_empty_window();
1466 cx.update(|window, cx| {
1467 let mut delegate = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1468 let mut selection = vec![(IndexPath::new(1), "Vue")];
1469
1470 let _ = delegate.perform_search("Vue", window, cx);
1471 delegate.on_will_change(
1472 &mut selection,
1473 &[SearchableListChange::Deselect {
1474 index: IndexPath::new(0),
1475 }],
1476 );
1477 assert!(selection.is_empty());
1478
1479 delegate.on_will_change(
1480 &mut selection,
1481 &[SearchableListChange::Select {
1482 index: IndexPath::new(0),
1483 }],
1484 );
1485 assert_eq!(selection, vec![(IndexPath::new(0), "Vue")]);
1486 });
1487 }
1488
1489 #[gpui::test]
1490 fn test_multi_combo_box_clear(cx: &mut TestAppContext) {
1491 cx.update(crate::init);
1492 let cx = cx.add_empty_window();
1493 cx.update(|window, cx| {
1494 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1495 let state = cx.new(|cx| {
1496 ComboboxState::new(
1497 items,
1498 vec![IndexPath::new(0), IndexPath::new(1)],
1499 window,
1500 cx,
1501 )
1502 .multiple(true)
1503 });
1504
1505 assert_eq!(state.read(cx).selected_values().len(), 2);
1506 state.update(cx, |s, cx| s.clear_selection(cx));
1507 assert!(state.read(cx).selected_values().is_empty());
1508 });
1509 }
1510
1511 #[gpui::test]
1512 fn test_single_combo_box_mode(cx: &mut TestAppContext) {
1513 cx.update(crate::init);
1514 let cx = cx.add_empty_window();
1515 cx.update(|window, cx| {
1516 let items = SearchableVec::new(vec!["Rust", "Go", "C++"]);
1517 let state = cx.new(|cx| ComboboxState::new(items, vec![], window, cx));
1518
1519 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1521 assert_eq!(state.read(cx).selected_values(), &["Rust"]);
1522
1523 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(1), cx));
1524 assert_eq!(state.read(cx).selected_values(), &["Rust", "Go"]);
1525 });
1526 }
1527
1528 struct VetoDelegate(SearchableVec<&'static str>);
1530
1531 impl SearchableListDelegate for VetoDelegate {
1532 type Item = &'static str;
1533
1534 fn items_count(&self, section: usize) -> usize {
1535 self.0.items_count(section)
1536 }
1537
1538 fn item(&self, ix: IndexPath) -> Option<&&'static str> {
1539 self.0.item(ix)
1540 }
1541
1542 fn position<V>(&self, value: &V) -> Option<IndexPath>
1543 where
1544 &'static str: SearchableListItem<Value = V>,
1545 V: PartialEq,
1546 {
1547 self.0.position(value)
1548 }
1549
1550 fn on_will_change(
1551 &mut self,
1552 _selection: &mut Vec<(IndexPath, &'static str)>,
1553 _changes: &[SearchableListChange],
1554 ) {
1555 }
1557 }
1558
1559 #[gpui::test]
1560 fn test_on_will_change_veto(cx: &mut TestAppContext) {
1561 cx.update(crate::init);
1562 let cx = cx.add_empty_window();
1563 cx.update(|window, cx| {
1564 let delegate = VetoDelegate(SearchableVec::new(vec!["Rust", "Go", "C++"]));
1565 let state = cx.new(|cx| ComboboxState::new(delegate, vec![], window, cx));
1566
1567 state.update(cx, |s, cx| s.add_selected_index(IndexPath::new(0), cx));
1569 assert_eq!(state.read(cx).selected_values(), &["Rust"]);
1570
1571 state.update(cx, |s, cx| {
1573 s.handle_item_select(IndexPath::new(1), window, cx);
1574 });
1575
1576 assert_eq!(state.read(cx).selected_values(), &["Rust"]);
1578 });
1579 }
1580
1581 fn left_press(position: Point<Pixels>) -> MouseDownEvent {
1582 MouseDownEvent {
1583 button: MouseButton::Left,
1584 position,
1585 modifiers: Modifiers::default(),
1586 click_count: 1,
1587 first_mouse: false,
1588 }
1589 }
1590
1591 #[gpui::test]
1592 fn test_combo_box_dismiss_ignores_press_on_trigger(cx: &mut TestAppContext) {
1593 cx.update(crate::init);
1594 let cx = cx.add_empty_window();
1595 let state = cx.update(|window, cx| {
1596 let items = SearchableVec::new(vec!["React", "Vue", "Angular"]);
1597 cx.new(|cx| ComboboxState::new(items, vec![], window, cx).multiple(true))
1598 });
1599 let collector = cx.update(|_, cx| cx.new(|cx| TestComboboxEventCollector::new(&state, cx)));
1600
1601 cx.update(|window, cx| {
1602 state.update(cx, |state, cx| {
1603 state.state.bounds = Bounds {
1604 origin: point(px(10.), px(10.)),
1605 size: size(px(200.), px(32.)),
1606 };
1607 state.set_open(true, cx);
1608 state.dismiss(&left_press(point(px(20.), px(20.))), window, cx);
1609 });
1610 });
1611
1612 cx.update(|_, cx| {
1613 assert!(
1614 state.read(cx).state.open,
1615 "a press on the trigger must reach nested controls, so the menu stays open \
1616 and `toggle_menu` closes it on release instead",
1617 );
1618 assert_eq!(collector.read(cx).event_count.get(), 0);
1619 });
1620
1621 cx.update(|window, cx| {
1622 state.update(cx, |state, cx| {
1623 state.dismiss(&left_press(point(px(20.), px(200.))), window, cx);
1624 });
1625 });
1626
1627 cx.update(|_, cx| {
1628 assert!(!state.read(cx).state.open);
1629 assert_eq!(
1630 collector.read(cx).event_count.get(),
1631 1,
1632 "dismissing from outside the trigger emits Confirm",
1633 );
1634 });
1635 }
1636
1637 #[allow(unused)]
1639 fn _uses_state<D: SearchableListDelegate + 'static>(_: &SearchableListState<D>)
1640 where
1641 <D::Item as SearchableListItem>::Value: PartialEq + Clone,
1642 {
1643 }
1644}