Skip to main content

gpui_kit_material/components/
fab_menu.rs

1//! M3 Expressive FAB menu: a FAB that opens a stack of related actions.
2//!
3//! Opening morphs the FAB into the round close button (container → solid
4//! color, 16dp → full corners, icon turning into the close icon) while the
5//! items, end-aligned above it, expand from the FAB's size into labelled
6//! pills, nearest first, on the default spatial spring. Closing reverses
7//! the stagger from the farthest item. Under reduced motion every change
8//! finishes at once.
9//!
10//! The toggle and items are [`MaterialButton`]s, so activation, focus,
11//! state layers, ripples and focus rings are the button's. The caller owns
12//! `open`; Escape inside the menu requests closing.
13//!
14//! [`FabMenuPresentation::Menu`] presents the same items as a baseline
15//! [`MaterialMenu`] opening from the FAB in Kit's popover (the menu the web
16//! uses); `Auto` picks the expressive list in compact windows (under
17//! 600dp) and the menu in wider ones.
18use 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/// The FAB menu color styles: the closed FAB and the items use the
33/// container color, the close button the solid color.
34#[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    /// (container style, solid style).
52    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/// How a FAB menu presents its items.
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
79pub enum FabMenuPresentation {
80    /// The M3 Expressive list of pills stacked above the FAB.
81    #[default]
82    List,
83    /// A baseline menu opening from the FAB.
84    Menu,
85    /// The list in compact windows, the menu from 600dp up.
86    Auto,
87}
88
89impl FabMenuPresentation {
90    pub const ALL: [Self; 3] = [Self::List, Self::Menu, Self::Auto];
91    /// The presentation used in a window `width` design px wide.
92    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
101/// The compact window class's upper bound, in dp.
102const COMPACT_WIDTH: f32 = 600.;
103
104/// Which side the items align to: the FAB's end (a bottom-end FAB) or
105/// start (a bottom-start FAB).
106#[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/// One action of a FAB menu. Rendered on its own (e.g. a building-block
117/// specimen) it is the open item in its `color`; inside a menu it takes the
118/// menu's color.
119#[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    /// An item showing only its label.
141    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    /// The color of a stand-alone item (a menu's items take its color).
152    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    /// This item as a baseline menu entry.
171    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    /// The item at a 0 … 1 `expansion` from FAB size to labelled pill;
182    /// only an `open` menu's items can be activated.
183    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
224/// Delay between neighbouring items' entrances and exits.
225const STAGGER: Duration = Duration::from_millis(30);
226/// Space between the items and the button, and between items.
227const BUTTON_GAP: f32 = 8.;
228const ITEM_GAP: f32 = 4.;
229
230impl FabMenu {
231    /// `icon` is the closed FAB's; `label` names the toggle for assistive
232    /// technology.
233    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    /// The FAB's size (regular by default); open, it is the round close
266    /// button of the same size.
267    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    /// Align the items to the FAB's end (default) or start edge.
276    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    /// Render the toggle as a static specimen in one interaction state.
289    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
290        self.preview = Some(state);
291        self
292    }
293    /// The requested open state (toggle activation, Escape).
294    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
300/// The entrance (exit) delay of item `index` of `count`, counted from the
301/// top: opening starts nearest the FAB, closing farthest from it.
302fn 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
307/// The toggle icon for a 0 (FAB icon) … 1 (close icon) morph amount: the
308/// outgoing icon turns a quarter away as the incoming one turns in.
309fn 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    /// The baseline presentation: the FAB opens a menu of the items in
320    /// Kit's popover, above the FAB and aligned to its `align` edge.
321    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            // `MenuPopup` toggles on the pointer press over the FAB, so its
337            // click handles only keys (handling the pointer click too would
338            // re-toggle once the press re-rendered `open`).
339            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            // Render runs in the parent's id scope, so key each item's
386            // expansion under the menu's own id: two menus with the same
387            // item ids must not share a transition.
388            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            // A settled closed menu lays out no items.
400            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                    // A closed sibling menu whose items reuse the same ids.
523                    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        // A transition shared with the closed twin would be recreated at its
597        // target every frame and jump open instead of growing.
598        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        // The label-only first item sits at the top, start-aligned.
799        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}