1use super::{
19 FabColor, FabSize, InteractionPreview, MaterialButton, MaterialMenu, MaterialMenuItem,
20 MenuPopup, MenuStyle,
21 button::{Form, Palette, Roles},
22 fab::{FAB_ELEVATION, revealed_label},
23};
24use crate::{
25 icons::{Icon, IconName},
26 motion::{self, MotionToken},
27 tokens::typography::{BASE_PX, TypeRole, rem},
28};
29use gpui::{prelude::*, *};
30use std::{rc::Rc, time::Duration};
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
35pub enum FabMenuColor {
36 #[default]
37 Primary,
38 Secondary,
39 Tertiary,
40}
41
42impl FabMenuColor {
43 pub const ALL: [Self; 3] = [Self::Primary, Self::Secondary, Self::Tertiary];
44 pub const fn name(self) -> &'static str {
45 match self {
46 Self::Primary => "primary",
47 Self::Secondary => "secondary",
48 Self::Tertiary => "tertiary",
49 }
50 }
51 const fn styles(self) -> (FabColor, FabColor) {
53 match self {
54 Self::Primary => (FabColor::PrimaryContainer, FabColor::Primary),
55 Self::Secondary => (FabColor::SecondaryContainer, FabColor::Secondary),
56 Self::Tertiary => (FabColor::TertiaryContainer, FabColor::Tertiary),
57 }
58 }
59 fn item_palette(self) -> Palette {
60 let (container, content) = self.styles().0.roles();
61 Palette {
62 rest: Roles {
63 container: Some(container),
64 content,
65 },
66 selected: None,
67 disabled_container: true,
68 elevation: (0, 0),
69 }
70 }
71 fn toggle_palette(self, open: bool) -> Palette {
72 let (closed, opened) = self.styles();
73 if open { opened } else { closed }.palette(FAB_ELEVATION)
74 }
75}
76
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
79pub enum FabMenuPresentation {
80 #[default]
82 List,
83 Menu,
85 Auto,
87}
88
89impl FabMenuPresentation {
90 pub const ALL: [Self; 3] = [Self::List, Self::Menu, Self::Auto];
91 pub fn resolve(self, width: f32) -> Self {
93 match self {
94 Self::Auto if width < COMPACT_WIDTH => Self::List,
95 Self::Auto => Self::Menu,
96 other => other,
97 }
98 }
99}
100
101const COMPACT_WIDTH: f32 = 600.;
103
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
107pub enum FabMenuAlign {
108 #[default]
109 End,
110 Start,
111}
112
113type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
114type ToggleHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
115
116#[derive(gpui::IntoElement)]
120pub struct FabMenuItem {
121 id: ElementId,
122 icon: Option<Icon>,
123 label: SharedString,
124 color: FabMenuColor,
125 preview: Option<InteractionPreview>,
126 on_click: Option<ClickHandler>,
127}
128
129impl FabMenuItem {
130 pub fn new(
131 id: impl Into<ElementId>,
132 icon: impl Into<Icon>,
133 label: impl Into<SharedString>,
134 ) -> Self {
135 Self {
136 icon: Some(icon.into()),
137 ..Self::label_only(id, label)
138 }
139 }
140 pub fn label_only(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
142 Self {
143 id: id.into(),
144 icon: None,
145 label: label.into(),
146 color: FabMenuColor::Primary,
147 preview: None,
148 on_click: None,
149 }
150 }
151 pub fn color(mut self, color: FabMenuColor) -> Self {
153 self.color = color;
154 self
155 }
156 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
157 self.preview = Some(state);
158 self
159 }
160 pub fn on_click(
161 mut self,
162 callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
163 ) -> Self {
164 self.on_click = Some(Rc::new(callback));
165 self
166 }
167}
168
169impl FabMenuItem {
170 fn menu_item(self) -> MaterialMenuItem {
172 let mut item = MaterialMenuItem::new(self.label);
173 if let Some(icon) = self.icon {
174 item = item.leading(icon.size(rem(24.)));
175 }
176 if let Some(callback) = self.on_click {
177 item = item.on_click(move |event, window, cx| callback(event, window, cx));
178 }
179 item
180 }
181 fn button(self, expansion: f32, open: bool, window: &Window) -> MaterialButton {
184 let label = revealed_label(&self.label, TypeRole::TitleMedium, expansion, window);
185 let mut button = MaterialButton::new(self.id)
186 .form(Form::FabMenuItem { expansion })
187 .palette(self.color.item_palette())
188 .when_some(self.icon, |button, icon| button.icon(icon))
189 .accessibility_label(self.label)
190 .children(label)
191 .opacity(expansion.clamp(0., 1.));
192 if let Some(state) = self.preview {
193 button = button.preview_state(state);
194 }
195 if let Some(callback) = self.on_click.filter(|_| open) {
196 button = button.on_click(move |event, window, cx| callback(event, window, cx));
197 }
198 button
199 }
200}
201
202impl RenderOnce for FabMenuItem {
203 fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement {
204 self.button(1., true, window)
205 }
206}
207
208#[derive(gpui::IntoElement)]
209pub struct FabMenu {
210 id: ElementId,
211 icon: Icon,
212 close_icon: Icon,
213 label: SharedString,
214 color: FabMenuColor,
215 size: FabSize,
216 presentation: FabMenuPresentation,
217 align: FabMenuAlign,
218 open: bool,
219 items: Vec<FabMenuItem>,
220 preview: Option<InteractionPreview>,
221 on_toggle: Option<ToggleHandler>,
222}
223
224const STAGGER: Duration = Duration::from_millis(30);
226const BUTTON_GAP: f32 = 8.;
228const ITEM_GAP: f32 = 4.;
229
230impl FabMenu {
231 pub fn new(
234 id: impl Into<ElementId>,
235 icon: impl Into<Icon>,
236 label: impl Into<SharedString>,
237 ) -> Self {
238 Self {
239 id: id.into(),
240 icon: icon.into(),
241 close_icon: IconName::Close.into(),
242 label: label.into(),
243 color: FabMenuColor::Primary,
244 size: FabSize::Regular,
245 presentation: FabMenuPresentation::List,
246 align: FabMenuAlign::End,
247 open: false,
248 items: Vec::new(),
249 preview: None,
250 on_toggle: None,
251 }
252 }
253 pub fn close_icon(mut self, icon: impl Into<Icon>) -> Self {
254 self.close_icon = icon.into();
255 self
256 }
257 pub fn color(mut self, color: FabMenuColor) -> Self {
258 self.color = color;
259 self
260 }
261 pub fn open(mut self, open: bool) -> Self {
262 self.open = open;
263 self
264 }
265 pub fn size(mut self, size: FabSize) -> Self {
268 self.size = size;
269 self
270 }
271 pub fn presentation(mut self, presentation: FabMenuPresentation) -> Self {
272 self.presentation = presentation;
273 self
274 }
275 pub fn align(mut self, align: FabMenuAlign) -> Self {
277 self.align = align;
278 self
279 }
280 pub fn item(mut self, item: FabMenuItem) -> Self {
281 self.items.push(item);
282 self
283 }
284 pub fn items(mut self, items: impl IntoIterator<Item = FabMenuItem>) -> Self {
285 self.items.extend(items);
286 self
287 }
288 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
290 self.preview = Some(state);
291 self
292 }
293 pub fn on_toggle(mut self, callback: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
295 self.on_toggle = Some(Rc::new(callback));
296 self
297 }
298}
299
300fn stagger(index: usize, count: usize, open: bool) -> Duration {
303 let steps = if open { count - 1 - index } else { index };
304 STAGGER * steps as u32
305}
306
307fn toggle_icon(icon: Icon, close: Icon, morph: f32) -> Icon {
310 let quarter = std::f32::consts::FRAC_PI_2;
311 if morph >= 0.5 {
312 close.rotate(-(1. - morph) * quarter)
313 } else {
314 icon.rotate(morph * quarter)
315 }
316}
317
318impl FabMenu {
319 fn render_menu(self) -> AnyElement {
322 let open = self.open;
323 let mut toggle = MaterialButton::new((self.id.clone(), "fab-menu-toggle"))
324 .form(Form::FabMenuToggle {
325 open: false,
326 size: self.size,
327 })
328 .palette(self.color.toggle_palette(false))
329 .icon(self.icon)
330 .accessibility_label(self.label)
331 .aria_expanded(open);
332 if let Some(state) = self.preview {
333 toggle = toggle.preview_state(state);
334 }
335 if let Some(callback) = self.on_toggle.clone() {
336 toggle = toggle.on_click(move |event, window, cx| {
340 if matches!(event, ClickEvent::Keyboard(_)) {
341 callback(&!open, window, cx)
342 }
343 });
344 }
345 let menu = MaterialMenu::new((self.id.clone(), "fab-menu-list"))
346 .style(MenuStyle::Baseline)
347 .items(self.items.into_iter().map(FabMenuItem::menu_item));
348 let mut popup = MenuPopup::new(self.id.clone(), toggle, menu)
349 .open(open)
350 .anchor(match self.align {
351 FabMenuAlign::End => Anchor::BottomRight,
352 FabMenuAlign::Start => Anchor::BottomLeft,
353 });
354 if let Some(callback) = self.on_toggle {
355 popup = popup.on_open_change(move |open, window, cx| callback(open, window, cx));
356 }
357 popup.into_any_element()
358 }
359}
360
361impl RenderOnce for FabMenu {
362 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
363 let width =
364 f32::from(window.viewport_size().width) * BASE_PX / f32::from(window.rem_size());
365 if self.presentation.resolve(width) == FabMenuPresentation::Menu {
366 return self.render_menu();
367 }
368 let open = self.open;
369 let count = self.items.len();
370 let curve = motion::curve(MotionToken::DefaultSpatial);
371 let start = self.align == FabMenuAlign::Start;
372 let mut column = div()
373 .flex()
374 .flex_col()
375 .map(|column| {
376 if start {
377 column.items_start()
378 } else {
379 column.items_end()
380 }
381 })
382 .gap(rem(ITEM_GAP));
383 let mut any_item = false;
384 for (index, item) in self.items.into_iter().enumerate() {
385 let expansion = window.with_element_namespace(self.id.clone(), |window| {
389 gpui_base::transition(
390 ElementId::from((item.id.clone(), "fab-menu-expansion")),
391 if open { 1f32 } else { 0. },
392 gpui_base::Transition::new(curve.duration())
393 .ease(curve.ease())
394 .delay(stagger(index, count, open)),
395 window,
396 cx,
397 )
398 });
399 if !open && expansion <= 0. {
401 continue;
402 }
403 any_item = true;
404 column = column.child(
405 item.color(self.color)
406 .button(expansion, open, window)
407 .tab_stop(open),
408 );
409 }
410 let morph = motion::token_value(
411 (self.id.clone(), "fab-menu-icon"),
412 if open { 1. } else { 0. },
413 MotionToken::FastSpatial,
414 window,
415 cx,
416 );
417 let mut toggle = MaterialButton::new((self.id.clone(), "fab-menu-toggle"))
418 .form(Form::FabMenuToggle {
419 open,
420 size: self.size,
421 })
422 .palette(self.color.toggle_palette(open))
423 .icon(toggle_icon(self.icon, self.close_icon, morph))
424 .accessibility_label(self.label)
425 .aria_expanded(open);
426 if let Some(state) = self.preview {
427 toggle = toggle.preview_state(state);
428 }
429 let on_toggle = self.on_toggle;
430 if let Some(callback) = on_toggle.clone() {
431 toggle = toggle.on_click(move |_, window, cx| callback(&!open, window, cx));
432 }
433 div()
434 .id(self.id)
435 .flex()
436 .flex_col()
437 .map(|menu| {
438 if start {
439 menu.items_start()
440 } else {
441 menu.items_end()
442 }
443 })
444 .gap(rem(BUTTON_GAP))
445 .when_some(on_toggle.filter(|_| open), |menu, callback| {
446 menu.on_key_down(move |event, window, cx| {
447 if event.keystroke.key == "escape" {
448 cx.stop_propagation();
449 callback(&false, window, cx);
450 }
451 })
452 })
453 .when(any_item, |menu| menu.child(column))
454 .child(toggle)
455 .into_any_element()
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::{FabMenu, FabMenuColor, FabMenuItem, stagger};
462 use crate::{color::ColorRole, icons::IconName, motion};
463 use gpui::{
464 Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
465 TestAppContext, VisualTestContext, Window, div, prelude::FluentBuilder as _, px,
466 };
467 use std::time::Duration;
468
469 #[core::prelude::v1::test]
470 fn items_stagger_from_the_fab_and_close_from_the_top() {
471 assert_eq!(stagger(2, 3, true), Duration::ZERO, "nearest opens first");
472 assert_eq!(stagger(0, 3, true), Duration::from_millis(60));
473 assert_eq!(
474 stagger(0, 3, false),
475 Duration::ZERO,
476 "farthest closes first"
477 );
478 assert_eq!(stagger(2, 3, false), Duration::from_millis(60));
479 }
480
481 #[core::prelude::v1::test]
482 fn the_close_button_takes_the_solid_color() {
483 let closed = FabMenuColor::Tertiary.toggle_palette(false);
484 let open = FabMenuColor::Tertiary.toggle_palette(true);
485 assert_eq!(closed.rest.container, Some(ColorRole::TERTIARY_CONTAINER));
486 assert_eq!(open.rest.container, Some(ColorRole::TERTIARY));
487 assert_eq!(
488 FabMenuColor::Tertiary.item_palette().rest.container,
489 Some(ColorRole::TERTIARY_CONTAINER)
490 );
491 }
492
493 struct Harness {
494 open: bool,
495 twin: bool,
496 clicks: usize,
497 }
498 impl Render for Harness {
499 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
500 window.set_rem_size(px(14.));
501 let click = cx.listener(|this, _: &gpui::ClickEvent, _, cx| {
502 this.clicks += 1;
503 cx.notify();
504 });
505 let click = std::rc::Rc::new(click);
506 div()
507 .flex()
508 .items_start()
509 .p(px(40.))
510 .child(
511 div().flex().debug_selector(|| "menu".into()).child(
512 FabMenu::new("menu", IconName::Check, "Actions")
513 .open(self.open)
514 .items((0..3usize).map(|index| {
515 let click = click.clone();
516 FabMenuItem::new(("item", index), IconName::Check, "Action")
517 .on_click(move |event, window, cx| click(event, window, cx))
518 })),
519 ),
520 )
521 .when(self.twin, |row| {
522 row.child(div().flex().debug_selector(|| "twin".into()).child(
524 FabMenu::new("twin", IconName::Check, "Actions").items((0..3usize).map(
525 |index| FabMenuItem::new(("item", index), IconName::Check, "Action"),
526 )),
527 ))
528 })
529 }
530 }
531
532 fn draw(cx: &mut VisualTestContext) {
533 cx.update(|window, cx| {
534 window.refresh();
535 window.draw(cx).clear(cx);
536 });
537 }
538
539 #[gpui::test]
540 fn opening_grows_the_stack_and_closing_removes_it(cx: &mut TestAppContext) {
541 cx.update(|cx| {
542 gpui_base::init(cx);
543 cx.set_reduce_motion(false);
544 motion::set_motion_scheme(motion::MotionScheme::Expressive);
545 });
546 let (view, cx) = cx.add_window_view(|_, _| Harness {
547 open: false,
548 twin: false,
549 clicks: 0,
550 });
551 draw(cx);
552 let closed = cx.debug_bounds("menu").unwrap().size;
553 assert_eq!(closed.height, px(56.));
554 view.update(cx, |view, cx| {
555 view.open = true;
556 cx.notify();
557 });
558 draw(cx);
559 cx.executor().advance_clock(Duration::from_secs(2));
560 draw(cx);
561 draw(cx);
562 let open = cx.debug_bounds("menu").unwrap().size;
563 assert_eq!(open.height, px(56. * 4. + 8. + 4. * 2.));
564 assert!(open.width > px(56.));
565 view.update(cx, |view, cx| {
566 view.open = false;
567 cx.notify();
568 });
569 draw(cx);
570 cx.executor().advance_clock(Duration::from_secs(2));
571 draw(cx);
572 draw(cx);
573 assert_eq!(cx.debug_bounds("menu").unwrap().size.height, px(56.));
574 }
575
576 #[gpui::test]
577 fn sibling_menus_with_equal_item_ids_open_independently(cx: &mut TestAppContext) {
578 cx.update(|cx| {
579 gpui_base::init(cx);
580 cx.set_reduce_motion(false);
581 motion::set_motion_scheme(motion::MotionScheme::Expressive);
582 });
583 let (view, cx) = cx.add_window_view(|_, _| Harness {
584 open: false,
585 twin: true,
586 clicks: 0,
587 });
588 draw(cx);
589 view.update(cx, |view, cx| {
590 view.open = true;
591 cx.notify();
592 });
593 draw(cx);
594 cx.executor().advance_clock(Duration::from_millis(60));
595 draw(cx);
596 let growing = cx.debug_bounds("menu").unwrap().size.width;
599 cx.executor().advance_clock(Duration::from_secs(2));
600 draw(cx);
601 draw(cx);
602 let open = cx.debug_bounds("menu").unwrap().size.width;
603 assert!(
604 growing > px(56.) && growing < open,
605 "{growing:?} of {open:?}"
606 );
607 assert_eq!(cx.debug_bounds("twin").unwrap().size.height, px(56.));
608 }
609
610 #[gpui::test]
611 fn a_closing_menu_ignores_clicks_on_its_fading_items(cx: &mut TestAppContext) {
612 cx.update(|cx| {
613 gpui_base::init(cx);
614 cx.set_reduce_motion(false);
615 });
616 let (view, cx) = cx.add_window_view(|_, _| Harness {
617 open: true,
618 twin: false,
619 clicks: 0,
620 });
621 draw(cx);
622 cx.executor().advance_clock(Duration::from_secs(2));
623 draw(cx);
624 draw(cx);
625 let menu = cx.debug_bounds("menu").unwrap();
626 let first = gpui::point(menu.right() - px(28.), menu.top() + px(28.));
627 cx.simulate_click(first, gpui::Modifiers::default());
628 draw(cx);
629 view.read_with(cx, |view, _| {
630 assert_eq!(view.clicks, 1, "an open item clicks")
631 });
632 view.update(cx, |view, cx| {
633 view.open = false;
634 cx.notify();
635 });
636 draw(cx);
637 cx.executor().advance_clock(Duration::from_millis(20));
638 draw(cx);
639 cx.simulate_click(first, gpui::Modifiers::default());
640 draw(cx);
641 view.read_with(cx, |view, _| assert_eq!(view.clicks, 1));
642 }
643}
644
645#[cfg(test)]
646mod presentation_tests {
647 use super::{FabMenu, FabMenuAlign, FabMenuItem, FabMenuPresentation};
648 use crate::{components::FabSize, icons::IconName};
649 use gpui::{
650 Context, InteractiveElement as _, IntoElement, Modifiers, ParentElement as _, Render,
651 Styled as _, TestAppContext, VisualTestContext, Window, div, prelude::FluentBuilder as _,
652 px, size,
653 };
654 use std::{cell::RefCell, rc::Rc};
655
656 #[core::prelude::v1::test]
657 fn auto_lists_in_compact_windows_and_menus_in_wider_ones() {
658 let auto = FabMenuPresentation::Auto;
659 assert_eq!(auto.resolve(412.), FabMenuPresentation::List);
660 assert_eq!(auto.resolve(840.), FabMenuPresentation::Menu);
661 assert_eq!(
662 FabMenuPresentation::List.resolve(1200.),
663 FabMenuPresentation::List
664 );
665 assert_eq!(
666 FabMenuPresentation::Menu.resolve(300.),
667 FabMenuPresentation::Menu
668 );
669 }
670
671 struct Harness {
672 open: bool,
673 presentation: FabMenuPresentation,
674 align: FabMenuAlign,
675 size: FabSize,
676 picked: Rc<RefCell<Vec<usize>>>,
677 }
678 impl Render for Harness {
679 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
680 window.set_rem_size(px(14.));
681 div()
682 .size_full()
683 .flex()
684 .items_end()
685 .map(|row| match self.align {
686 FabMenuAlign::End => row.justify_end(),
687 FabMenuAlign::Start => row.justify_start(),
688 })
689 .p(px(40.))
690 .child(
691 div().flex().debug_selector(|| "menu".into()).child(
692 FabMenu::new("menu", IconName::Check, "Actions")
693 .presentation(self.presentation)
694 .align(self.align)
695 .size(self.size)
696 .open(self.open)
697 .on_toggle(cx.listener(|this, open: &bool, _, cx| {
698 this.open = *open;
699 cx.notify();
700 }))
701 .items((0..3usize).map(|index| {
702 let picked = self.picked.clone();
703 let item = if index == 0 {
704 FabMenuItem::label_only(("item", index), "First action")
705 } else {
706 FabMenuItem::new(("item", index), IconName::Check, "Action")
707 };
708 item.on_click(move |_, _, _| picked.borrow_mut().push(index))
709 })),
710 ),
711 )
712 }
713 }
714
715 fn settle(cx: &mut VisualTestContext) {
716 for _ in 0..3 {
717 cx.executor()
718 .advance_clock(std::time::Duration::from_millis(800));
719 cx.update(|window, cx| {
720 window.refresh();
721 window.draw(cx).clear(cx);
722 });
723 }
724 }
725
726 fn setup(
727 cx: &mut TestAppContext,
728 presentation: FabMenuPresentation,
729 align: FabMenuAlign,
730 size: FabSize,
731 ) -> (gpui::Entity<Harness>, &mut VisualTestContext) {
732 cx.update(|cx| {
733 gpui_kit::init(cx);
734 crate::init(cx);
735 cx.set_reduce_motion(true);
736 });
737 cx.add_window_view(move |_, _| Harness {
738 open: false,
739 presentation,
740 align,
741 size,
742 picked: Rc::default(),
743 })
744 }
745
746 #[gpui::test]
747 fn the_menu_presentation_opens_a_baseline_menu_whose_items_act(cx: &mut TestAppContext) {
748 let (view, cx) = setup(
749 cx,
750 FabMenuPresentation::Menu,
751 FabMenuAlign::End,
752 FabSize::Regular,
753 );
754 settle(cx);
755 let fab = cx.debug_bounds("menu").unwrap();
756 assert_eq!(fab.size, size(px(56.), px(56.)));
757 cx.simulate_click(fab.center(), Modifiers::default());
758 settle(cx);
759 cx.update(|_, cx| assert!(view.read(cx).open));
760 let surface = cx.debug_bounds("menu-popup-surface").unwrap();
761 assert!(
762 surface.bottom() <= fab.top(),
763 "the menu opens above the FAB"
764 );
765 assert_eq!(surface.right(), fab.right(), "end-aligned");
766 assert_eq!(
767 cx.debug_bounds("menu").unwrap().size,
768 fab.size,
769 "the FAB stays"
770 );
771 cx.simulate_keystrokes("down down enter");
772 settle(cx);
773 cx.update(|_, cx| {
774 assert_eq!(*view.read(cx).picked.borrow(), vec![1]);
775 assert!(!view.read(cx).open, "an item closes the menu");
776 });
777 }
778
779 #[gpui::test]
780 fn a_start_aligned_list_grows_from_a_sized_fab(cx: &mut TestAppContext) {
781 let (view, cx) = setup(
782 cx,
783 FabMenuPresentation::List,
784 FabMenuAlign::Start,
785 FabSize::Large,
786 );
787 settle(cx);
788 let closed = cx.debug_bounds("menu").unwrap();
789 assert_eq!(closed.size, size(px(96.), px(96.)));
790 view.update(cx, |view, cx| {
791 view.open = true;
792 cx.notify();
793 });
794 settle(cx);
795 let open = cx.debug_bounds("menu").unwrap();
796 assert_eq!(open.left(), closed.left(), "items align to the start");
797 assert_eq!(open.size.height, px(96. + 8. + 56. * 3. + 4. * 2.));
798 let first = gpui::point(open.left() + px(28.), open.top() + px(28.));
800 cx.simulate_click(first, Modifiers::default());
801 settle(cx);
802 cx.update(|_, cx| assert_eq!(*view.read(cx).picked.borrow(), vec![0]));
803 }
804}