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