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