Skip to main content

gpui_macos/
menu.rs

1//! A keyboard menu composed from the base Popover and Button primitives.
2use crate::{ActiveTheme, ButtonVariant, IconName, button, icon, keystroke_label};
3use gpui_kit::base::{ElementExt, Popover, Popup};
4use gpui_kit::rems;
5use gpui_kit::{
6    Action, Anchor, AnyElement, App, Bounds, ElementId, Entity, Focusable, KeyDownEvent,
7    ParentElement, Pixels, Point, SharedString, Window, anchored, deferred, div, point, prelude::*,
8    px,
9};
10use std::{rc::Rc, time::Duration};
11
12/// What a row does when chosen, shared by both panels and every handler.
13type Select = Rc<dyn Fn(usize, &mut Window, &mut App)>;
14
15/// Row height, and the gap that follows it, in rems.
16const ROW_HEIGHT: f32 = 1.75;
17const ROW_GAP: f32 = 0.125;
18/// A separator costs its padding on both sides plus its own hairline.
19const SEPARATOR_HEIGHT: f32 = 0.5625;
20const MENU_PADDING: f32 = 0.375;
21const SUBMENU_WIDTH: f32 = 10.625;
22/// Space between the menu and its submenu, in rems.
23const SUBMENU_GAP: f32 = 0.25;
24
25#[derive(Clone)]
26pub struct MenuItem {
27    pub label: SharedString,
28    pub icon: Option<IconName>,
29    pub shortcut: Option<SharedString>,
30    /// Shows the keymap's binding for this action, taking precedence over `shortcut`.
31    pub action: Option<Rc<dyn Action>>,
32    pub disabled: bool,
33    pub checked: Option<bool>,
34    pub separator_before: bool,
35    /// Child rows, each paired with the index reported to `on_select`.
36    pub children: Vec<(usize, MenuItem)>,
37}
38impl MenuItem {
39    pub fn new(label: impl Into<SharedString>) -> Self {
40        Self {
41            label: label.into(),
42            icon: None,
43            shortcut: None,
44            action: None,
45            disabled: false,
46            checked: None,
47            separator_before: false,
48            children: Vec::new(),
49        }
50    }
51    /// Open a second panel beside this row instead of selecting it.
52    ///
53    /// Each child carries the index `on_select` receives, so a submenu choice
54    /// is reported like any other item and the parent needs no index of its own.
55    pub fn submenu(mut self, children: Vec<(usize, MenuItem)>) -> Self {
56        self.children = children;
57        self
58    }
59    /// Display an exclusive choice with its check on the trailing edge.
60    pub fn checked(mut self, checked: bool) -> Self {
61        self.checked = Some(checked);
62        self
63    }
64    pub fn separator_before(mut self) -> Self {
65        self.separator_before = true;
66        self
67    }
68    pub fn icon(mut self, icon: IconName) -> Self {
69        self.icon = Some(icon);
70        self
71    }
72    pub fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
73        self.shortcut = Some(shortcut.into());
74        self
75    }
76    /// Show the shortcut bound to `action` in the keymap, so the label always
77    /// matches the platform and any user rebinding. Display only: selection is
78    /// still reported through `on_select`.
79    pub fn action(mut self, action: impl Action) -> Self {
80        self.action = Some(Rc::new(action));
81        self
82    }
83    /// Native menu shortcut text: the action's binding, else the literal shortcut.
84    fn render_shortcut(&self, window: &Window, cx: &App) -> Option<AnyElement> {
85        self.action
86            .as_ref()
87            .and_then(|action| window.highest_precedence_binding_for_action(action.as_ref()))
88            .map(|binding| {
89                binding
90                    .keystrokes()
91                    .iter()
92                    .map(|stroke| keystroke_label(stroke.modifiers(), stroke.key()).to_string())
93                    .collect::<Vec<_>>()
94                    .join(" ")
95                    .into()
96            })
97            .or_else(|| self.shortcut.clone())
98            .map(|label: SharedString| {
99                div()
100                    .font_family(cx.macos().font.clone())
101                    .text_size(rems(0.75))
102                    .opacity(0.72)
103                    .child(label)
104                    .into_any_element()
105            })
106    }
107    pub fn disabled(mut self, disabled: bool) -> Self {
108        self.disabled = disabled;
109        self
110    }
111}
112
113/// Compact action menu. Main command-launcher menus can override row geometry separately.
114/// Base positions the popup, dismisses outside/Escape and restores trigger focus.
115pub fn menu(
116    id: impl Into<ElementId>,
117    trigger: impl gpui_kit::base::Selectable
118    + IntoElement
119    + gpui_kit::StatefulInteractiveElement
120    + 'static,
121    items: Vec<MenuItem>,
122    on_select: impl Fn(usize, &mut Window, &mut App) + 'static,
123) -> Popover {
124    let on_select = Rc::new(on_select);
125    Popover::new(id)
126        .trigger_clickable(trigger)
127        // The submenu floats outside the menu's bounds, so dismissal on an
128        // outside press is handled below where both panels are known.
129        .overlay_closable(false)
130        .content_factory(move |popover, window, cx| {
131            if crate::native::is_glass_popup(window) {
132                let first = window.use_keyed_state("menu-native-focus", cx, |_, _| true);
133                if *first.read(cx) {
134                    first.update(cx, |first, _| *first = false);
135                    popover.focus_handle(cx).focus(window, cx);
136                }
137            }
138            let t = cx.macos().clone();
139            let cursor = window.use_keyed_state("menu-cursor", cx, |_, _| {
140                items.iter().position(|item| !item.disabled)
141            });
142            let focus = window
143                .use_keyed_state("menu-focus", cx, |_, cx| cx.focus_handle())
144                .read(cx)
145                .clone();
146            if popover.focus_handle(cx).is_focused(window) {
147                focus.focus(window, cx);
148            }
149            // The open submenu, and the row highlighted inside it.
150            let page = window.use_keyed_state("menu-page", cx, |_, _| None::<usize>);
151            let child_cursor = window.use_keyed_state("submenu-cursor", cx, |_, _| None::<usize>);
152            // Last painted bounds of each panel, used to place the submenu
153            // beside the menu and to tell presses inside it from outside ones.
154            let menu_bounds = window.use_keyed_state("menu-bounds", cx, |_, _| Bounds::default());
155            let submenu_bounds =
156                window.use_keyed_state("submenu-bounds", cx, |_, _| None::<Bounds<Pixels>>);
157
158            // Selection is one decision for both panels: open a submenu, or
159            // dismiss and report. Every code path below routes through it, so
160            // opening a submenu never closes the menu.
161            let displayed = items.clone();
162            let leaf_select = on_select.clone();
163            let select_page = page.clone();
164            let select_child = child_cursor.clone();
165            let select_close = cx.entity();
166            let command_owner = popover.owner_window();
167            let on_select: Select = Rc::new(move |index, window, cx| {
168                if let Some(parent) = *select_page.read(cx)
169                    && let Some(child) = *select_child.read(cx)
170                {
171                    if let Some((action, item)) = displayed[parent].children.get(child)
172                        && !item.disabled
173                    {
174                        select_close.update(cx, |state, cx| state.dismiss(window, cx));
175                        dispatch_selection(command_owner, leaf_select.clone(), *action, window, cx);
176                    }
177                    return;
178                }
179                let Some(item) = displayed.get(index) else {
180                    return;
181                };
182                if !item.children.is_empty() {
183                    select_page.update(cx, |page, cx| {
184                        *page = Some(index);
185                        cx.notify();
186                    });
187                    select_child.update(cx, |child, cx| {
188                        *child = item.children.iter().position(|(_, item)| !item.disabled);
189                        cx.notify();
190                    });
191                    window.refresh();
192                } else if !item.disabled {
193                    select_close.update(cx, |state, cx| state.dismiss(window, cx));
194                    dispatch_selection(command_owner, leaf_select.clone(), index, window, cx);
195                }
196            });
197
198            let has_icons = items.iter().any(|item| item.icon.is_some());
199            let open_page = *page.read(cx);
200            let keyboard_items = items.clone();
201            let keyboard_page = page.clone();
202            let keyboard_child = child_cursor.clone();
203            let keyboard_cursor = cursor.clone();
204            let keyboard_select = on_select.clone();
205            let confirm_cursor = cursor.clone();
206            let confirm_select = on_select.clone();
207            let submenu_items = items.clone();
208            let submenu_cursor = child_cursor.clone();
209            let submenu_page = page.clone();
210            let submenu_select = on_select.clone();
211            let main = div()
212                .id("menu-items")
213                .debug_selector(|| "macos-menu-content".into())
214                .role(gpui_kit::Role::Menu)
215                .track_focus(&focus)
216                .w(rems(15.))
217                .p(rems(MENU_PADDING))
218                .flex()
219                .flex_col()
220                .gap(rems(ROW_GAP))
221                .border_1()
222                .rounded(rems(0.75))
223                .border_color(t.glass_border())
224                .bg(if crate::native::is_glass_popup(window) {
225                    gpui_kit::transparent_black()
226                } else {
227                    let mut fill = t.glass_fill();
228                    fill.a = 1.;
229                    fill
230                })
231                .shadow(t.popover_shadow())
232                .when(crate::native::is_glass_popup(window), |menu| {
233                    menu.border_0().shadow_none()
234                })
235                .text_color(t.foreground)
236                .font_family(t.font.clone())
237                .text_size(rems(0.8125))
238                .on_action(move |_: &gpui_kit::base::actions::Confirm, window, cx| {
239                    cx.stop_propagation();
240                    if let Some(index) = *confirm_cursor.read(cx) {
241                        confirm_select(index, window, cx);
242                        window.refresh();
243                    }
244                })
245                .on_key_down(move |event: &KeyDownEvent, window, cx| {
246                    if event.keystroke.modifiers.modified() {
247                        return;
248                    }
249                    let key = event.keystroke.key.as_str();
250                    let current = *keyboard_cursor.read(cx);
251                    if key == "left" && keyboard_page.read(cx).is_some() {
252                        keyboard_page.update(cx, |page, cx| {
253                            *page = None;
254                            cx.notify();
255                        });
256                        keyboard_child.update(cx, |child, cx| {
257                            *child = None;
258                            cx.notify();
259                        });
260                        window.refresh();
261                    } else if key == "right" {
262                        if keyboard_page.read(cx).is_none()
263                            && let Some(index) =
264                                current.filter(|&i| !keyboard_items[i].children.is_empty())
265                        {
266                            keyboard_select(index, window, cx);
267                        }
268                    } else if matches!(key, "enter" | "space") {
269                        if let Some(index) = current.filter(|&i| !keyboard_items[i].disabled) {
270                            keyboard_select(index, window, cx);
271                            window.refresh();
272                        }
273                    } else if matches!(key, "down" | "j" | "up" | "k" | "home" | "end") {
274                        // While a submenu is open the arrows belong to it.
275                        if let Some(parent) = *keyboard_page.read(cx) {
276                            let enabled: Vec<_> = keyboard_items[parent]
277                                .children
278                                .iter()
279                                .enumerate()
280                                .filter_map(|(i, (_, item))| (!item.disabled).then_some(i))
281                                .collect();
282                            let next = next_item(&enabled, *keyboard_child.read(cx), key);
283                            keyboard_child.update(cx, |child, cx| {
284                                *child = next;
285                                cx.notify();
286                            });
287                            window.refresh();
288                            cx.stop_propagation();
289                            return;
290                        }
291                        let enabled: Vec<_> = keyboard_items
292                            .iter()
293                            .enumerate()
294                            .filter_map(|(i, item)| (!item.disabled).then_some(i))
295                            .collect();
296                        let next = next_item(&enabled, current, key);
297                        keyboard_cursor.update(cx, |cursor, cx| {
298                            *cursor = next;
299                            cx.notify();
300                        });
301                        window.refresh();
302                    } else {
303                        return;
304                    }
305                    cx.stop_propagation();
306                })
307                .children(items.clone().into_iter().enumerate().map(|(index, item)| {
308                    let hover_cursor = cursor.clone();
309                    let hover_page = page.clone();
310                    let hover_child = child_cursor.clone();
311                    let item_has_children = !item.children.is_empty();
312                    let click_page = page.clone();
313                    let click_child = child_cursor.clone();
314                    let current = *cursor.read(cx) == Some(index);
315                    let select = on_select.clone();
316                    let shortcut = item.render_shortcut(window, cx);
317                    let row = button(("menu-item", index), "", ButtonVariant::Secondary, cx)
318                        .debug_selector(move || format!("macos-menu-item-{index}"))
319                        .accessibility_label(item.label.clone())
320                        .role(gpui_kit::Role::MenuItem)
321                        .when_some(item.checked, |row, checked| {
322                            row.role(gpui_kit::Role::MenuItemRadio)
323                                .aria_toggled(if checked {
324                                    gpui_kit::accesskit::Toggled::True
325                                } else {
326                                    gpui_kit::accesskit::Toggled::False
327                                })
328                        })
329                        .focusable(false)
330                        .disabled(item.disabled)
331                        .fill_duration(Duration::ZERO)
332                        .w_full()
333                        .h(rems(ROW_HEIGHT))
334                        .py(rems(0.))
335                        .px(rems(0.5))
336                        .rounded(rems(0.375))
337                        .border_0()
338                        .shadow_none()
339                        .justify_start()
340                        .bg(if current {
341                            t.accent
342                        } else {
343                            t.foreground.opacity(0.)
344                        })
345                        .text_color(if current { t.on_accent } else { t.foreground })
346                        .hover(|s| s.bg(t.accent).text_color(t.on_accent))
347                        .active(|s| s.bg(t.accent).text_color(t.on_accent))
348                        .when(has_icons, |row| {
349                            row.child(
350                                div()
351                                    .w(rems(0.875))
352                                    .flex_shrink_0()
353                                    .when_some(item.icon, |slot, name| {
354                                        slot.child(icon(name).size(rems(0.875)))
355                                    }),
356                            )
357                        })
358                        .child(div().flex_1().min_w_0().child(item.label))
359                        .when(item_has_children, |row| {
360                            row.child(icon(IconName::ChevronRight).size(rems(0.875)))
361                        })
362                        .when_some(item.checked, |row, checked| {
363                            row.child(div().w(rems(0.875)).when(checked, |slot| {
364                                slot.child(icon(IconName::Check).size(rems(0.875)))
365                            }))
366                        })
367                        .children(shortcut)
368                        .on_hover(move |hovered, window, cx| {
369                            if *hovered && !item.disabled {
370                                hover_page.update(cx, |page, cx| {
371                                    *page = item_has_children.then_some(index);
372                                    cx.notify();
373                                });
374                                hover_child.update(cx, |child, cx| {
375                                    *child = None;
376                                    cx.notify();
377                                });
378                                hover_cursor.update(cx, |cursor, cx| {
379                                    *cursor = Some(index);
380                                    cx.notify();
381                                });
382                                window.refresh();
383                            }
384                        })
385                        .on_click(move |_, window, cx| {
386                            // A pointer selects the row under it, not whatever
387                            // the keyboard left highlighted in a submenu.
388                            click_page.update(cx, |page, _| *page = None);
389                            click_child.update(cx, |child, _| *child = None);
390                            select(index, window, cx);
391                            window.refresh();
392                        });
393                    div()
394                        .when(item.separator_before, |group| {
395                            group.child(div().py(rems(0.25)).child(crate::separator(cx)))
396                        })
397                        .child(row)
398                }));
399            let dismiss_bounds = submenu_bounds.clone();
400            let dismiss = cx.entity();
401            if open_page.is_none() {
402                submenu_bounds.update(cx, |slot, _| *slot = None);
403            }
404            let submenu = open_page.map(|parent| {
405                let rem = window.rem_size();
406                let native = crate::native::is_glass_popup(window);
407                // Native children may extend beyond their owning popup's
408                // viewport. Resolve against the display in owner-local points;
409                // the child presenter translates those points to screen space.
410                let viewport = if native {
411                    window.display(cx).map(|display| {
412                        let display = display.bounds();
413                        Bounds::new(display.origin - window.bounds().origin, display.size)
414                    })
415                } else {
416                    None
417                }
418                .unwrap_or_else(|| Bounds::new(point(px(0.), px(0.)), window.viewport_size()));
419                let (anchor, origin) = submenu_placement(
420                    *menu_bounds.read(cx),
421                    rems(submenu_offset(&submenu_items, parent)).to_pixels(rem),
422                    rems(SUBMENU_WIDTH).to_pixels(rem),
423                    rems(SUBMENU_GAP).to_pixels(rem),
424                    viewport.right(),
425                );
426                if native {
427                    return native_submenu(
428                        parent,
429                        SubmenuPosition {
430                            anchor,
431                            origin,
432                            viewport,
433                        },
434                        submenu_items[parent].children.clone(),
435                        submenu_cursor,
436                        submenu_page,
437                        submenu_select,
438                    )
439                    .into_any_element();
440                }
441                let record_submenu = submenu_bounds.clone();
442                deferred(
443                    anchored()
444                        .anchor(anchor)
445                        .position(origin)
446                        .snap_to_window_with_margin(px(4.))
447                        .child(
448                            // Measured on this unpadded wrapper: the probe
449                            // `on_prepaint` adds is absolutely positioned, so on
450                            // the padded panel it would be inset.
451                            div()
452                                .on_prepaint(move |bounds, _, cx| {
453                                    record_submenu.update(cx, |slot, _| *slot = Some(bounds))
454                                })
455                                .child(submenu_panel(
456                                    &submenu_items[parent].children,
457                                    parent,
458                                    submenu_cursor,
459                                    submenu_page,
460                                    submenu_select,
461                                    window,
462                                    cx,
463                                )),
464                        ),
465                )
466                // Above the popover surface itself, which paints at POPUP_PRIORITY.
467                .with_priority(gpui_kit::base::POPUP_PRIORITY + 1)
468                .into_any_element()
469            });
470            div()
471                .on_mouse_down_out(move |_, window, cx| {
472                    let inside_submenu = dismiss_bounds
473                        .read(cx)
474                        .is_some_and(|bounds| bounds.contains(&window.mouse_position()));
475                    if !inside_submenu {
476                        dismiss.update(cx, |state, cx| state.dismiss(window, cx));
477                        window.refresh();
478                    }
479                })
480                // Measured on this unpadded wrapper for the same reason.
481                .on_prepaint(move |bounds, _, cx| menu_bounds.update(cx, |slot, _| *slot = bounds))
482                .child(main)
483                .children(submenu)
484        })
485}
486
487/// Window commands belong to the original trigger's window even when the
488/// pointer selected a row in a separate native popup or submenu window.
489fn dispatch_selection(
490    owner: Option<gpui_kit::AnyWindowHandle>,
491    select: Select,
492    index: usize,
493    window: &mut Window,
494    cx: &mut App,
495) {
496    if let Some(owner) = owner
497        && owner.window_id() != window.window_handle().window_id()
498    {
499        cx.defer(move |cx| {
500            let _ = owner.update(cx, |_, window, cx| {
501                select(index, window, cx);
502                window.refresh();
503            });
504        });
505    } else {
506        select(index, window, cx);
507    }
508}
509
510/// A retained factory lets Base present the submenu in its own child panel.
511/// The invisible trigger supplies a corner rather than a second visual row.
512/// Keyboard navigation remains in the primary menu, sharing cursor entities.
513struct SubmenuPosition {
514    anchor: Anchor,
515    origin: Point<Pixels>,
516    viewport: Bounds<Pixels>,
517}
518
519fn native_submenu(
520    parent: usize,
521    position: SubmenuPosition,
522    items: Vec<(usize, MenuItem)>,
523    cursor: Entity<Option<usize>>,
524    page: Entity<Option<usize>>,
525    select: Select,
526) -> Popup {
527    let dismiss_page = page.clone();
528    let dismiss_cursor = cursor.clone();
529    Popup::new(("submenu-popup", parent), div().size_0())
530        .absolute()
531        .left(position.origin.x)
532        .top(position.origin.y)
533        .size_0()
534        .anchor(position.anchor)
535        .viewport_bounds(position.viewport)
536        .focus(false)
537        .content_factory(move |window, cx| {
538            submenu_panel(
539                &items,
540                parent,
541                cursor.clone(),
542                page.clone(),
543                select.clone(),
544                window,
545                cx,
546            )
547            .into_any_element()
548        })
549        .on_dismiss(move |window, cx| {
550            // Closing an old panel after hovering another parent must not close
551            // the replacement submenu.
552            if *dismiss_page.read(cx) == Some(parent) {
553                dismiss_page.update(cx, |page, cx| {
554                    *page = None;
555                    cx.notify();
556                });
557                dismiss_cursor.update(cx, |cursor, cx| {
558                    *cursor = None;
559                    cx.notify();
560                });
561                window.refresh();
562            }
563        })
564}
565
566/// Where the submenu opens: beside the menu, level with the row that opened
567/// it. It prefers the right and flips to the left when it would run past the
568/// viewport, the way a desktop menu does. `top` is the opening row's offset
569/// from the top of the menu.
570fn submenu_placement(
571    menu: Bounds<Pixels>,
572    top: Pixels,
573    width: Pixels,
574    gap: Pixels,
575    viewport_width: Pixels,
576) -> (Anchor, Point<Pixels>) {
577    let top = menu.top() + top;
578    if menu.right() + gap + width <= viewport_width {
579        (Anchor::TopLeft, point(menu.right() + gap, top))
580    } else {
581        (Anchor::TopRight, point(menu.left() - gap, top))
582    }
583}
584
585/// The floating panel listing a submenu's rows. It shares the menu's cursor
586/// model: `cursor` is the highlighted row, and choosing a row routes through
587/// `select` with the parent index so both panels make one decision.
588fn submenu_panel(
589    items: &[(usize, MenuItem)],
590    parent: usize,
591    cursor: Entity<Option<usize>>,
592    page: Entity<Option<usize>>,
593    select: Select,
594    window: &Window,
595    cx: &App,
596) -> impl IntoElement {
597    let t = cx.macos().clone();
598    let has_icons = items.iter().any(|(_, item)| item.icon.is_some());
599    let highlighted = *cursor.read(cx);
600    div()
601        .id("submenu")
602        .debug_selector(|| "macos-submenu-content".into())
603        .role(gpui_kit::Role::Menu)
604        .occlude()
605        .w(rems(SUBMENU_WIDTH))
606        .p(rems(MENU_PADDING))
607        .flex()
608        .flex_col()
609        .gap(rems(ROW_GAP))
610        .border_1()
611        .rounded(rems(0.75))
612        .border_color(t.glass_border())
613        .bg(if crate::native::is_glass_popup(window) {
614            gpui_kit::transparent_black()
615        } else {
616            t.glass_fill()
617        })
618        .shadow(t.popover_shadow())
619        .when(crate::native::is_glass_popup(window), |menu| {
620            menu.border_0().shadow_none()
621        })
622        .text_color(t.foreground)
623        .font_family(t.font.clone())
624        .text_size(rems(0.8125))
625        .children(items.iter().enumerate().map(|(index, (_, item))| {
626            let hover_cursor = cursor.clone();
627            let click_cursor = cursor.clone();
628            let click_page = page.clone();
629            let select = select.clone();
630            let disabled = item.disabled;
631            button(("submenu-item", index), "", ButtonVariant::Secondary, cx)
632                .debug_selector(move || format!("macos-submenu-item-{index}"))
633                .accessibility_label(item.label.clone())
634                .role(gpui_kit::Role::MenuItem)
635                .when_some(item.checked, |row, checked| {
636                    row.role(gpui_kit::Role::MenuItemRadio)
637                        .aria_toggled(if checked {
638                            gpui_kit::accesskit::Toggled::True
639                        } else {
640                            gpui_kit::accesskit::Toggled::False
641                        })
642                })
643                .focusable(false)
644                .disabled(disabled)
645                .fill_duration(Duration::ZERO)
646                .w_full()
647                .h(rems(ROW_HEIGHT))
648                .py(rems(0.))
649                .px(rems(0.5))
650                .rounded(rems(0.375))
651                .border_0()
652                .shadow_none()
653                .justify_start()
654                .bg(if highlighted == Some(index) {
655                    t.accent
656                } else {
657                    t.foreground.opacity(0.)
658                })
659                .text_color(if highlighted == Some(index) {
660                    t.on_accent
661                } else {
662                    t.foreground
663                })
664                .hover(|s| s.bg(t.accent).text_color(t.on_accent))
665                .active(|s| s.bg(t.accent).text_color(t.on_accent))
666                .when(has_icons, |row| {
667                    row.child(
668                        div()
669                            .w(rems(0.875))
670                            .flex_shrink_0()
671                            .when_some(item.icon, |slot, name| {
672                                slot.child(icon(name).size(rems(0.875)))
673                            }),
674                    )
675                })
676                .child(div().flex_1().min_w_0().child(item.label.clone()))
677                .when_some(item.checked, |row, checked| {
678                    row.child(div().w(rems(0.875)).when(checked, |slot| {
679                        slot.child(icon(IconName::Check).size(rems(0.875)))
680                    }))
681                })
682                .children(item.render_shortcut(window, cx))
683                .on_hover(move |hovered, window, cx| {
684                    if *hovered && !disabled {
685                        hover_cursor.update(cx, |cursor, cx| {
686                            *cursor = Some(index);
687                            cx.notify();
688                        });
689                        window.refresh();
690                    }
691                })
692                .on_click(move |_, window, cx| {
693                    click_page.update(cx, |page, _| *page = Some(parent));
694                    click_cursor.update(cx, |cursor, _| *cursor = Some(index));
695                    select(parent, window, cx);
696                    window.refresh();
697                })
698        }))
699}
700
701/// How far the submenu sits below the top of the menu, so its first row lines
702/// up with the parent row that opened it. Both panels share the same border
703/// and padding, so only the rows above the parent count.
704fn submenu_offset(items: &[MenuItem], parent: usize) -> f32 {
705    let separator = |item: &MenuItem| {
706        if item.separator_before {
707            SEPARATOR_HEIGHT
708        } else {
709            0.
710        }
711    };
712    items
713        .iter()
714        .take(parent)
715        .map(|item| ROW_HEIGHT + ROW_GAP + separator(item))
716        .sum::<f32>()
717        + items.get(parent).map_or(0., separator)
718}
719
720fn next_item(enabled: &[usize], current: Option<usize>, key: &str) -> Option<usize> {
721    if enabled.is_empty() {
722        return None;
723    }
724    let position = current.and_then(|i| enabled.iter().position(|&item| item == i));
725    let index = match key {
726        "home" => 0,
727        "end" => enabled.len() - 1,
728        "up" | "k" => position.map_or(enabled.len() - 1, |i| {
729            (i + enabled.len() - 1) % enabled.len()
730        }),
731        _ => position.map_or(0, |i| (i + 1) % enabled.len()),
732    };
733    Some(enabled[index])
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use gpui_kit::base::{PopupContentFactory, PopupPresenter};
740    use gpui_kit::{AppContext, Context, Render, TestAppContext, VisualTestContext, size};
741    use std::cell::RefCell;
742
743    struct SubmenuContent(PopupContentFactory);
744
745    impl Render for SubmenuContent {
746        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
747            (self.0)(window, cx)
748        }
749    }
750
751    struct NativeSubmenuHarness {
752        page: Entity<Option<usize>>,
753        cursor: Entity<Option<usize>>,
754        commands: Rc<RefCell<Vec<(usize, gpui_kit::AnyWindowHandle)>>>,
755    }
756
757    impl Render for NativeSubmenuHarness {
758        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
759            let owner = window.window_handle();
760            let commands = self.commands.clone();
761            let select: Select = Rc::new(move |index, window, cx| {
762                let commands = commands.clone();
763                dispatch_selection(
764                    Some(owner),
765                    Rc::new(move |index, window, _| {
766                        commands.borrow_mut().push((index, window.window_handle()));
767                    }),
768                    index,
769                    window,
770                    cx,
771                );
772            });
773            div().size_full().child(native_submenu(
774                2,
775                SubmenuPosition {
776                    anchor: Anchor::TopLeft,
777                    origin: point(px(244.), px(40.)),
778                    viewport: Bounds::new(point(px(-600.), px(-400.)), size(px(1800.), px(1200.))),
779                },
780                vec![(20, MenuItem::new("PDF")), (21, MenuItem::new("Image"))],
781                self.cursor.clone(),
782                self.page.clone(),
783                select,
784            ))
785        }
786    }
787
788    #[gpui_kit::test]
789    fn native_submenu_uses_a_separate_panel_and_dispatches_to_original_owner(
790        cx: &mut TestAppContext,
791    ) {
792        cx.update(crate::init);
793        let pane = Rc::new(RefCell::new(
794            None::<crate::native::GlassPanelHandle<SubmenuContent>>,
795        ));
796        let resolved = Rc::new(RefCell::new(Vec::new()));
797        let pane_for_hook = pane.clone();
798        let observed = resolved.clone();
799        cx.update(move |cx| {
800            cx.set_global(PopupPresenter(Rc::new(move |request, owner, cx| {
801                observed.borrow_mut().push(request.bounds);
802                assert!(
803                    !request.focus,
804                    "Submenu keeps keyboard navigation in the main menu"
805                );
806                if pane_for_hook.borrow().is_none() {
807                    let dismiss = request.on_dismiss;
808                    let panel = crate::native::open_glass_panel(
809                        crate::native::GlassPanelOptions::new(request.bounds),
810                        owner,
811                        cx,
812                        |_, cx| cx.new(|_| SubmenuContent(request.content)),
813                        move |owner, cx| {
814                            if let Some(dismiss) = &dismiss {
815                                dismiss(owner, cx);
816                            }
817                        },
818                    )
819                    .unwrap();
820                    *pane_for_hook.borrow_mut() = Some(panel);
821                }
822                true
823            })));
824        });
825        let commands = Rc::new(RefCell::new(Vec::new()));
826        let (view, owner_cx) = cx.add_window_view({
827            let commands = commands.clone();
828            move |window, cx| {
829                window.resize(size(px(240.), px(160.)));
830                NativeSubmenuHarness {
831                    page: cx.new(|_| Some(2)),
832                    cursor: cx.new(|_| None),
833                    commands,
834                }
835            }
836        });
837        let owner_handle = owner_cx.update(|window, cx| {
838            window.draw(cx).clear(cx);
839            window.window_handle()
840        });
841        let bounds = resolved.borrow()[0];
842        assert_eq!(bounds.origin, point(px(244.), px(40.)));
843        assert!(
844            bounds.left() > px(240.),
845            "Child is outside the small owner's viewport"
846        );
847        assert!(owner_cx.debug_bounds("macos-submenu-content").is_none());
848        let child_handle = pane.borrow().as_ref().unwrap().window_handle();
849        let mut child_cx = VisualTestContext::from_window(child_handle, owner_cx);
850        child_cx.update(|window, cx| window.draw(cx).clear(cx));
851        let target = child_cx
852            .debug_bounds("macos-submenu-item-1")
853            .unwrap()
854            .center();
855        child_cx.simulate_click(target, Default::default());
856        child_cx.run_until_parked();
857        assert_eq!(&*commands.borrow(), &[(2, owner_handle)]);
858        assert_eq!(
859            view.read_with(&child_cx, |view, cx| *view.cursor.read(cx)),
860            Some(1)
861        );
862        child_cx.update(|window, _| window.remove_window());
863        child_cx.run_until_parked();
864        view.read_with(&child_cx, |view, cx| {
865            assert_eq!(*view.page.read(cx), None);
866            assert_eq!(*view.cursor.read(cx), None);
867        });
868    }
869
870    #[test]
871    fn navigation_skips_disabled_and_wraps() {
872        assert_eq!(next_item(&[0, 2], Some(0), "down"), Some(2));
873        assert_eq!(next_item(&[0, 2], Some(2), "down"), Some(0));
874        assert_eq!(next_item(&[0, 2], Some(0), "up"), Some(2));
875        assert_eq!(next_item(&[], None, "home"), None);
876    }
877
878    #[test]
879    fn submenu_opens_right_and_flips_left_at_the_viewport_edge() {
880        let menu = Bounds::new(point(px(100.), px(40.)), gpui_kit::size(px(240.), px(160.)));
881        let open = |viewport| submenu_placement(menu, px(30.), px(170.), px(4.), viewport);
882
883        // Room on the right: the submenu's left edge sits past the menu.
884        assert_eq!(open(px(800.)), (Anchor::TopLeft, point(px(344.), px(70.))));
885        // Exactly enough room still opens to the right.
886        assert_eq!(open(px(514.)).0, Anchor::TopLeft);
887        // One pixel short: its right edge sits before the menu instead.
888        assert_eq!(open(px(513.)), (Anchor::TopRight, point(px(96.), px(70.))));
889    }
890
891    #[test]
892    fn submenu_lines_up_with_the_row_that_opened_it() {
893        let items = vec![
894            MenuItem::new("first"),
895            MenuItem::new("second").separator_before(),
896            MenuItem::new("third"),
897        ];
898        assert_eq!(submenu_offset(&items, 0), 0.);
899        assert_eq!(
900            submenu_offset(&items, 1),
901            ROW_HEIGHT + ROW_GAP + SEPARATOR_HEIGHT
902        );
903        assert_eq!(
904            submenu_offset(&items, 2),
905            2. * (ROW_HEIGHT + ROW_GAP) + SEPARATOR_HEIGHT
906        );
907    }
908
909    #[test]
910    fn submenu_children_carry_their_own_action_index() {
911        let item = MenuItem::new("Theme").submenu(vec![
912            (20, MenuItem::new("System").checked(true)),
913            (21, MenuItem::new("Light")),
914        ]);
915        assert_eq!(item.children.len(), 2);
916        assert_eq!(item.children[0].0, 20);
917        assert_eq!(item.children[1].0, 21);
918    }
919}