Skip to main content

herogpui_components/
menu_bar.rs

1//! `MenuBar` — a horizontal application menu bar (HeroGPUI extension; HeroUI
2//! v3 has no menu bar, only the trigger-anchored `Dropdown`).
3//!
4//! Each top-level item opens the Dropdown's [`Menu`] panel unchanged — rows,
5//! sections, submenus, typeahead, keyboard navigation, disabled keys, the
6//! enter/exit motion and the topmost-overlay Escape / outside-press
7//! arbitration — below its trigger. The bar adds the menubar behaviour on
8//! top:
9//!
10//! - **Roving focus.** One trigger is in the tab order. Left and Right move
11//!   the focus between the top-level items (wrapping, skipping disabled
12//!   ones), Home and End jump to the first and last.
13//! - **Opening.** A press, Enter, Space or Down opens a trigger's menu; the
14//!   keyboard paths focus its first item.
15//! - **Switching.** While a menu is open, Left and Right open the previous or
16//!   next top-level menu instead (Right on an item with a submenu opens the
17//!   submenu first, as in the Dropdown), and hovering another trigger switches
18//!   to its menu.
19//! - **Closing.** Escape, choosing an item, a press outside, or pressing the
20//!   open trigger again closes the menu, and the focus returns to its trigger.
21//!
22//! ```
23//! use herogpui_components::{MenuBar, MenuBarMenu, MenuItem};
24//!
25//! let bar = MenuBar::new(
26//!     "app-menu",
27//!     vec![
28//!         MenuBarMenu::new(
29//!             "file",
30//!             "File",
31//!             vec![MenuItem::new("new", "New").shortcut("⌘N"), MenuItem::new("open", "Open")],
32//!         ),
33//!         MenuBarMenu::new("edit", "Edit", vec![MenuItem::new("undo", "Undo")]),
34//!     ],
35//! )
36//! .on_action(|menu, item, _window, _cx| println!("{menu} → {item}"));
37//! ```
38
39use std::cell::Cell;
40use std::rc::Rc;
41use std::sync::Arc;
42
43use gpui::{
44    div, prelude::FluentBuilder as _, px, App, Bounds, InteractiveElement, IntoElement,
45    MouseButton, ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement,
46    Styled, Window,
47};
48use herogpui_core::{element_id, Placement};
49use herogpui_theme::ActiveTheme;
50
51use crate::a11y::{self, A11y as _};
52use crate::{Menu, MenuItem};
53
54type ActionCallback = Arc<dyn Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static>;
55type OpenCallback = Arc<dyn Fn(&Option<SharedString>, &mut Window, &mut App) + 'static>;
56
57/// One top-level item of a [`MenuBar`] and the menu it opens.
58#[must_use = "builder methods return a new value; pass it on to its component"]
59pub struct MenuBarMenu {
60    key: SharedString,
61    label: SharedString,
62    items: Vec<MenuItem>,
63    disabled_keys: Vec<SharedString>,
64    is_disabled: bool,
65}
66
67impl MenuBarMenu {
68    /// A top-level item `label` whose menu holds `items`. `key` identifies
69    /// it in [`MenuBar::on_action`] and [`MenuBar::on_open_change`].
70    pub fn new(
71        key: impl Into<SharedString>,
72        label: impl Into<SharedString>,
73        items: Vec<MenuItem>,
74    ) -> Self {
75        Self {
76            key: key.into(),
77            label: label.into(),
78            items,
79            disabled_keys: Vec::new(),
80            is_disabled: false,
81        }
82    }
83
84    /// Items of this menu that render dimmed and cannot be chosen or focused.
85    pub fn disabled_keys(
86        mut self,
87        keys: impl IntoIterator<Item = impl Into<SharedString>>,
88    ) -> Self {
89        self.disabled_keys = keys.into_iter().map(Into::into).collect();
90        self
91    }
92
93    /// A disabled top-level item renders dimmed, never opens, and is skipped
94    /// by the arrow keys.
95    pub fn is_disabled(mut self, v: bool) -> Self {
96        self.is_disabled = v;
97        self
98    }
99}
100
101/// A horizontal bar of menus. See the [module docs](self).
102#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
103#[derive(IntoElement)]
104pub struct MenuBar {
105    id: gpui::ElementId,
106    menus: Vec<MenuBarMenu>,
107    on_action: Option<ActionCallback>,
108    on_open_change: Option<OpenCallback>,
109}
110
111impl MenuBar {
112    /// A menu bar over `menus`. `id` keys the bar's state; give every
113    /// instance its own.
114    pub fn new(id: impl Into<gpui::ElementId>, menus: Vec<MenuBarMenu>) -> Self {
115        Self {
116            id: id.into(),
117            menus,
118            on_action: None,
119            on_open_change: None,
120        }
121    }
122
123    /// Runs with the open menu's key and the chosen item's key; the menu
124    /// then closes.
125    pub fn on_action(
126        mut self,
127        f: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static,
128    ) -> Self {
129        self.on_action = Some(Arc::new(f));
130        self
131    }
132
133    /// Reports the key of the menu that opened, or `None` when the bar
134    /// closed. Switching between menus reports the new key.
135    pub fn on_open_change(
136        mut self,
137        f: impl Fn(&Option<SharedString>, &mut Window, &mut App) + 'static,
138    ) -> Self {
139        self.on_open_change = Some(Arc::new(f));
140        self
141    }
142}
143
144/// Everything the event closures share, cloned into each.
145#[derive(Clone)]
146struct BarState {
147    open: gpui::Entity<Option<usize>>,
148    active: gpui::Entity<usize>,
149    focus_first: gpui::Entity<bool>,
150    handles: Rc<Vec<gpui::FocusHandle>>,
151    keys: Rc<Vec<SharedString>>,
152    enabled: Rc<Vec<bool>>,
153    on_open_change: Option<OpenCallback>,
154}
155
156impl BarState {
157    /// Opens menu `ix` (or closes the bar with `None`), reporting the change.
158    fn set_open(&self, ix: Option<usize>, keyboard: bool, window: &mut Window, cx: &mut App) {
159        if *self.open.read(cx) == ix {
160            return;
161        }
162        self.focus_first.update(cx, |v, _| *v = keyboard);
163        self.open.update(cx, |v, cx| {
164            *v = ix;
165            cx.notify();
166        });
167        if let Some(ix) = ix {
168            self.set_active(ix, cx);
169        }
170        if let Some(cb) = &self.on_open_change {
171            cb(&ix.map(|ix| self.keys[ix].clone()), window, cx);
172        }
173    }
174
175    fn set_active(&self, ix: usize, cx: &mut App) {
176        self.active.update(cx, |v, cx| {
177            if *v != ix {
178                *v = ix;
179                cx.notify();
180            }
181        });
182    }
183
184    /// The next enabled index from `from` in direction `step` (wrapping).
185    fn step(&self, from: usize, forward: bool) -> Option<usize> {
186        let n = self.enabled.len();
187        (1..=n)
188            .map(|d| {
189                if forward {
190                    (from + d) % n
191                } else {
192                    (from + n - d % n) % n
193                }
194            })
195            .find(|&ix| self.enabled[ix])
196    }
197
198    fn focus_trigger(&self, ix: usize, window: &mut Window, cx: &mut App) {
199        self.set_active(ix, cx);
200        window.focus(&self.handles[ix], cx);
201    }
202}
203
204impl RenderOnce for MenuBar {
205    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
206        let base = self.id.clone();
207        let count = self.menus.len();
208        let open =
209            window.use_keyed_state(element_id::scoped(&base, "open"), cx, |_, _| None::<usize>);
210        let active = window.use_keyed_state(element_id::scoped(&base, "active"), cx, |_, _| 0);
211        let focus_first =
212            window.use_keyed_state(element_id::scoped(&base, "focus-first"), cx, |_, _| false);
213        let mut handles = Vec::with_capacity(count);
214        let mut trigger_bounds = Vec::with_capacity(count);
215        for ix in 0..count {
216            let part = format!("trigger-{ix}");
217            handles.push(
218                window
219                    .use_keyed_state(
220                        element_id::scoped(&base, format!("{part}-focus")),
221                        cx,
222                        |_, cx| cx.focus_handle(),
223                    )
224                    .read(cx)
225                    .clone(),
226            );
227            trigger_bounds.push(
228                window
229                    .use_keyed_state(
230                        element_id::scoped(&base, format!("{part}-bounds")),
231                        cx,
232                        |_, _| Rc::new(Cell::new(None::<Bounds<Pixels>>)),
233                    )
234                    .read(cx)
235                    .clone(),
236            );
237        }
238        let enabled: Vec<bool> = self.menus.iter().map(|m| !m.is_disabled).collect();
239        // A menu that went away or became disabled closes: the stored state
240        // forgets it too (so hover and Left/Right rove instead of reopening),
241        // and the caller hears about it after this render.
242        let stored_open = *open.read(cx);
243        let open_ix = stored_open.filter(|&ix| enabled.get(ix).copied().unwrap_or(false));
244        if stored_open.is_some() && open_ix.is_none() {
245            open.update(cx, |v, _| *v = None);
246            if let Some(cb) = self.on_open_change.clone() {
247                window.defer(cx, move |window, cx| cb(&None, window, cx));
248            }
249        }
250        let active_ix = {
251            let at = *active.read(cx);
252            if enabled.get(at).copied().unwrap_or(false) {
253                at
254            } else {
255                enabled.iter().position(|e| *e).unwrap_or(0)
256            }
257        };
258        let (phase, overlay_token) = crate::util::overlay_scope(
259            window,
260            cx,
261            element_id::scoped(&base, "phase"),
262            open_ix.is_some(),
263            true,
264        );
265        // The last open menu stays mounted through its exit motion.
266        let shown =
267            window.use_keyed_state(element_id::scoped(&base, "shown"), cx, |_, _| None::<usize>);
268        if open_ix.is_some() && *shown.read(cx) != open_ix {
269            shown.update(cx, |v, _| *v = open_ix);
270        }
271        let shown_ix = open_ix.or(*shown.read(cx));
272
273        let state = BarState {
274            open,
275            active,
276            focus_first: focus_first.clone(),
277            handles: Rc::new(handles),
278            keys: Rc::new(self.menus.iter().map(|m| m.key.clone()).collect()),
279            enabled: Rc::new(enabled),
280            on_open_change: self.on_open_change.clone(),
281        };
282
283        let colors = cx.colors().clone();
284        let radius = crate::util::control_radius(cx);
285        let focus_visible = crate::util::focus_visible(cx);
286        let cursor = crate::util::interactive_cursor(cx);
287
288        let mut bar = div()
289            .id(element_id::scoped(&base, "bar"))
290            .a11y(a11y::Role::MenuBar)
291            .flex()
292            .flex_row()
293            .items_center()
294            .gap(px(2.))
295            .p(px(4.))
296            .rounded(radius + px(4.))
297            .bg(colors.surface.background)
298            .text_sm();
299
300        for (ix, menu) in self.menus.iter().enumerate() {
301            let handle = state.handles[ix].clone().tab_stop(ix == active_ix);
302            let is_open = open_ix == Some(ix);
303            let disabled = menu.is_disabled;
304            let focused = handle.is_focused(window);
305            let bounds_probe = trigger_bounds[ix].clone();
306            let mut trigger = div()
307                .id(element_id::indexed(&base, "trigger", ix))
308                .a11y_named(
309                    a11y::Role::MenuItem,
310                    &a11y::Name::labelled(menu.label.clone()),
311                )
312                .a11y_expanded(is_open)
313                .relative()
314                .flex()
315                .items_center()
316                .h(px(28.))
317                .px(px(10.))
318                .rounded(radius)
319                .text_color(colors.foreground)
320                .child(menu.label.clone())
321                .child(
322                    gpui::canvas(
323                        move |bounds, _, _| bounds_probe.set(Some(bounds)),
324                        |_, _, _, _| {},
325                    )
326                    .absolute()
327                    .inset_0(),
328                )
329                .debug_selector({
330                    let name = format!("menu-bar-trigger-{}", menu.key);
331                    move || name
332                });
333            if disabled {
334                trigger = trigger.opacity(0.5);
335            } else {
336                trigger = trigger
337                    .track_focus(&handle)
338                    .cursor(cursor)
339                    .hover(|s| s.bg(colors.default.color))
340                    .when(is_open, |t| t.bg(colors.default.color))
341                    .when(focused && focus_visible, |t| {
342                        t.child(crate::util::focus_ring_overlay(radius, false, cx))
343                    });
344                trigger = crate::util::record_focus_bounds(trigger, &handle, window, cx);
345                // Only a closed trigger sees its press: while its menu is
346                // open, the press lands outside the menu panel, whose
347                // capture-phase outside-press dismissal closes the menu and
348                // stops the event before it reaches this listener.
349                let press = state.clone();
350                trigger = trigger.on_mouse_down(MouseButton::Left, move |_, window, cx| {
351                    crate::util::set_focus_visible(false, cx);
352                    press.focus_trigger(ix, window, cx);
353                    press.set_open(Some(ix), false, window, cx);
354                    cx.stop_propagation();
355                });
356                let hover = state.clone();
357                trigger = trigger.on_hover(move |hovered, window, cx| {
358                    let open_now = *hover.open.read(cx);
359                    if *hovered && open_now.is_some() && open_now != Some(ix) {
360                        hover.focus_trigger(ix, window, cx);
361                        hover.set_open(Some(ix), false, window, cx);
362                    }
363                });
364                let keys = state.clone();
365                trigger = trigger.on_key_down(move |event, window, cx| {
366                    let key = event.keystroke.key.as_str();
367                    if !matches!(key, "enter" | "space" | "down") {
368                        return;
369                    }
370                    crate::util::set_focus_visible(true, cx);
371                    keys.set_open(Some(ix), true, window, cx);
372                    cx.stop_propagation();
373                });
374            }
375            bar = bar.child(trigger);
376        }
377
378        let mut menus = self.menus;
379        let nav = state.clone();
380        let mut root = div()
381            .relative()
382            .flex()
383            .child(bar)
384            .on_key_down(move |event, window, cx| {
385                let m = &event.keystroke.modifiers;
386                if m.control || m.alt || m.platform || m.shift {
387                    return;
388                }
389                let key = event.keystroke.key.as_str();
390                let open_now = *nav.open.read(cx);
391                let from =
392                    open_now.or_else(|| nav.handles.iter().position(|h| h.is_focused(window)));
393                let Some(from) = from else {
394                    return;
395                };
396                let target = match key {
397                    "right" => nav.step(from, true),
398                    "left" => nav.step(from, false),
399                    "home" if open_now.is_none() => nav.enabled.iter().position(|e| *e),
400                    "end" if open_now.is_none() => nav.enabled.iter().rposition(|e| *e),
401                    _ => return,
402                };
403                let Some(target) = target else {
404                    return;
405                };
406                crate::util::set_focus_visible(true, cx);
407                if open_now.is_some() {
408                    nav.set_open(Some(target), true, window, cx);
409                    // The switched-to menu takes the focus as it mounts.
410                    nav.set_active(target, cx);
411                } else {
412                    nav.focus_trigger(target, window, cx);
413                }
414                cx.stop_propagation();
415            });
416
417        if let (Some(ix), true) = (shown_ix, phase != crate::util::OverlayPhase::Closed) {
418            if ix < menus.len() {
419                let spec = menus.swap_remove(ix);
420                let menu_key = spec.key.clone();
421                let mut menu = Menu::new(
422                    element_id::scoped(&base, format!("menu-content-{}", spec.key)),
423                    spec.items,
424                )
425                .id(element_id::scoped(&base, format!("menu-{}", spec.key)))
426                .dropdown_composition()
427                .focus_first(focus_first)
428                .exiting(phase == crate::util::OverlayPhase::Exiting)
429                .disabled_keys(spec.disabled_keys)
430                .overlay_token(overlay_token);
431                if let Some(on_action) = self.on_action {
432                    menu = menu
433                        .on_action(move |key, window, cx| on_action(&menu_key, key, window, cx));
434                }
435                let dismiss = state;
436                menu = menu.on_dismiss(move |_refocus, window, cx| {
437                    let was = *dismiss.open.read(cx);
438                    dismiss.set_open(None, false, window, cx);
439                    // Pinned gpui activates an element on key up only when
440                    // the key also went down on it, so returning the focus to
441                    // the trigger inside an Enter pick does not reopen it.
442                    if let Some(was) = was {
443                        dismiss.focus_trigger(was, window, cx);
444                    }
445                });
446                root = root.child(crate::util::floating(
447                    crate::popover::popover_with_resolved_placement(
448                        trigger_bounds[ix].clone(),
449                        Placement::BottomStart,
450                        px(4.),
451                        None,
452                        menu.panel_debug_label("menu-bar-menu"),
453                    ),
454                ));
455            }
456        }
457        root
458    }
459}