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