Skip to main content

ui/
menubar.rs

1//! [`Menubar`] — the in-window bar: a strip of titles that drop menus.
2//!
3//! Not the *native* one. On macOS that is `cx.set_menus` and four lines in an
4//! app's `main`, which is where it belongs; this is the bar an app with a custom
5//! titlebar draws for itself, and the one every other platform expects to see
6//! inside the window.
7//!
8//! An entity, on the line [`crate::date::Calendar`] drew: it owns which menu is
9//! down and where the keyboard is inside it — state the app has no opinion
10//! about — and reports the one thing the app wants, through [`MenubarEvent`].
11//! The menus are data the app hands over, shaped like gpui's own `Menu` and
12//! `MenuItem` so an app drawing both bars writes them the same way. It does not
13//! *take* those types: they carry a boxed action, and reporting an index leaves
14//! dispatch with the app, the way [`crate::combobox`] and [`crate::palette`]
15//! already do.
16//!
17//! What makes it a menubar rather than a row of dropdowns is that one menu being
18//! open changes what the others do: sliding the pointer onto a sibling title
19//! switches to it with no click, and `left`/`right` cross between menus without
20//! leaving the keyboard.
21//!
22//! ```ignore
23//! ui::menubar::init(cx);   // once, at startup
24//! let bar = cx.new(|cx| Menubar::new(vec![
25//!     Menu::new("File", vec![
26//!         Item::action("New Window").with_keystroke("⌘N"),
27//!         Item::Separator,
28//!         Item::action("Close").with_keystroke("⌘W").disabled(),
29//!     ]),
30//! ], cx));
31//! cx.subscribe(&bar, |_, _, event, _| match event {
32//!     MenubarEvent::Selected { menu, item } => { /* dispatch */ }
33//! })
34//! .detach();
35//! ```
36
37use gpui::{
38    App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
39    div, prelude::*, px,
40};
41
42use theme::{TextStyle, Theme, Typeset};
43
44use crate::{
45    menu::{self, Item},
46    popover,
47};
48
49/// One menu on the bar.
50#[derive(Clone, Debug)]
51pub struct Menu {
52    pub title: SharedString,
53    pub items: Vec<Item>,
54}
55
56impl Menu {
57    pub fn new(title: impl Into<SharedString>, items: Vec<Item>) -> Self {
58        Self {
59            title: title.into(),
60            items,
61        }
62    }
63}
64
65// ---------------------------------------------------------------------------
66// The bar
67// ---------------------------------------------------------------------------
68
69actions!(
70    bezel_menubar,
71    [PrevMenu, NextMenu, PrevItem, NextItem, Confirm, Dismiss]
72);
73
74/// The key context the bar claims, closed as well as open — `enter` on a
75/// focused-but-closed bar drops its first menu.
76pub const KEY_CONTEXT: &str = "Menubar";
77
78/// Install the bar's bindings. Call once, alongside [`crate::input::init`].
79///
80/// `left`/`right` cross between menus and `up`/`down` walk the rows, which is
81/// the one arrangement every platform's menubar agrees on. Nothing claims `alt`
82/// to focus the bar: that is a Windows convention, and a component library that
83/// binds a chord it is unsure of takes it away from every app downstream.
84pub fn init(cx: &mut App) {
85    let ctx = Some(KEY_CONTEXT);
86    cx.bind_keys([
87        KeyBinding::new("left", PrevMenu, ctx),
88        KeyBinding::new("right", NextMenu, ctx),
89        KeyBinding::new("up", PrevItem, ctx),
90        KeyBinding::new("down", NextItem, ctx),
91        KeyBinding::new("enter", Confirm, ctx),
92        KeyBinding::new("escape", Dismiss, ctx),
93    ]);
94}
95
96/// What the bar reports: an item chosen, by its place in the menus it was given.
97#[derive(Clone, Copy, Debug, PartialEq, Eq)]
98pub enum MenubarEvent {
99    Selected { menu: usize, item: usize },
100}
101
102pub struct Menubar {
103    menus: Vec<Menu>,
104    /// Which title is down. One popup for the whole bar rather than one each:
105    /// exactly one menu can be open, and saying so in the type is what makes
106    /// switching between them a single assignment.
107    open: popover::Popup<usize>,
108    /// Where the keyboard is inside the open menu. Cleared whenever the menu
109    /// changes, so a fresh menu opens with nothing highlighted rather than with
110    /// the last one's row number pointing at whatever now sits there.
111    highlighted: Option<usize>,
112    focus_handle: FocusHandle,
113}
114
115impl EventEmitter<MenubarEvent> for Menubar {}
116
117impl Menubar {
118    pub fn new(menus: Vec<Menu>, cx: &mut Context<Self>) -> Self {
119        Self {
120            menus,
121            open: popover::Popup::default(),
122            highlighted: None,
123            // One stop for the whole bar: the menus are keyboard-driven from
124            // here, so no row takes focus of its own.
125            focus_handle: cx.focus_handle().tab_stop(true),
126        }
127    }
128
129    /// Which menu is down, `None` while none is (or while one is closing).
130    pub fn open_menu(&self) -> Option<usize> {
131        self.open.as_open().copied()
132    }
133
134    /// The menus as given. [`MenubarEvent`] reports a place in this list, so
135    /// this is how a host turns one back into the item it named — without
136    /// keeping a second copy that could drift from the bar's.
137    pub fn menus(&self) -> &[Menu] {
138        &self.menus
139    }
140
141    fn show(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
142        self.open.open(menu);
143        self.highlighted = None;
144        window.focus(&self.focus_handle, cx);
145        cx.notify();
146    }
147
148    fn toggle(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
149        // The note was taken on mouse-down and only counts for *this* title, so
150        // pressing a different one switches menus instead of being swallowed by
151        // the dismissal that same press caused.
152        if self.open.take_press_was_open() {
153            self.close(cx);
154        } else {
155            self.show(menu, window, cx);
156        }
157    }
158
159    /// The rule that makes a bar a bar: with one menu already down, the pointer
160    /// crossing a sibling title opens it. With none down, hovering does nothing
161    /// — a menubar that dropped a menu at the mere passage of the mouse would be
162    /// unusable.
163    fn hover_switch(&mut self, menu: usize, cx: &mut Context<Self>) {
164        if self.open.is_open() && self.open_menu() != Some(menu) {
165            self.open.open(menu);
166            self.highlighted = None;
167            cx.notify();
168        }
169    }
170
171    fn close(&mut self, cx: &mut Context<Self>) {
172        if self.open.begin_close() {
173            popover::reap_popup(cx, |bar: &mut Self| &mut bar.open);
174        }
175        self.highlighted = None;
176        cx.notify();
177    }
178
179    fn choose(&mut self, menu: usize, item: usize, cx: &mut Context<Self>) {
180        cx.emit(MenubarEvent::Selected { menu, item });
181        self.close(cx);
182    }
183
184    fn step_item(&mut self, delta: isize, cx: &mut Context<Self>) {
185        let Some(menu) = self.open_menu() else { return };
186        self.highlighted = menu::next_selectable(&self.menus[menu].items, self.highlighted, delta);
187        cx.notify();
188    }
189
190    fn step_menu(&mut self, delta: isize, cx: &mut Context<Self>) {
191        let Some(menu) = self.open_menu() else { return };
192        let count = self.menus.len() as isize;
193        if count == 0 {
194            return;
195        }
196        self.open
197            .open((menu as isize + delta).rem_euclid(count) as usize);
198        self.highlighted = None;
199        cx.notify();
200    }
201
202    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
203        match (self.open_menu(), self.highlighted) {
204            (Some(menu), Some(item)) => self.choose(menu, item, cx),
205            // Closed, `enter` drops the first menu — the same key means "act on
206            // this control" either way, which is what makes the bar reachable
207            // by keyboard at all.
208            (None, _) if !self.menus.is_empty() => self.show(0, window, cx),
209            _ => {}
210        }
211    }
212
213    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
214        self.close(cx);
215    }
216
217    fn card(&self, menu: usize, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
218        menu::card(
219            theme,
220            SharedString::from(format!("menu-{menu}")),
221            &self.menus[menu].items,
222            self.highlighted,
223            cx,
224            move |bar, item, _, cx| bar.choose(menu, item, cx),
225        )
226        .on_mouse_down_out(cx.listener(|bar, _, _, cx| bar.close(cx)))
227        .into_any_element()
228    }
229}
230
231/// One title on the strip. Lit while its own menu is down.
232pub fn menubar_title(theme: &Theme, label: impl Into<SharedString>, open: bool) -> gpui::Div {
233    let title = div()
234        .px(px(8.0))
235        .py(px(3.0))
236        .rounded(px(Theme::control_radius()))
237        .text_style(TextStyle::Body)
238        .cursor_pointer()
239        .child(label.into());
240    if open {
241        title.bg(theme.element_active).text_color(theme.text)
242    } else {
243        // A plain hover style, not a `motion::hover_blend` fade key: the fade
244        // installs an `on_hover` *listener*, and gpui allows only one per
245        // element — the switch below needs it.
246        title
247            .text_color(theme.text_muted)
248            .hover(|s| s.bg(theme.element_hover).text_color(theme.text))
249    }
250}
251
252/// The strip the titles sit on.
253pub fn menubar() -> gpui::Div {
254    div().flex().flex_row().items_center().gap(px(2.0))
255}
256
257impl Focusable for Menubar {
258    fn focus_handle(&self, _: &App) -> FocusHandle {
259        self.focus_handle.clone()
260    }
261}
262
263impl Render for Menubar {
264    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
265        let theme = Theme::of(cx).clone();
266        // `get`, not `as_open`: the card stays mounted through the exit phase.
267        let mounted = self.open.get().copied();
268        let closing = self.open.closing_since();
269
270        menubar()
271            .key_context(KEY_CONTEXT)
272            .track_focus(&self.focus_handle)
273            .on_action(cx.listener(|bar, _: &PrevMenu, _, cx| bar.step_menu(-1, cx)))
274            .on_action(cx.listener(|bar, _: &NextMenu, _, cx| bar.step_menu(1, cx)))
275            .on_action(cx.listener(|bar, _: &PrevItem, _, cx| bar.step_item(-1, cx)))
276            .on_action(cx.listener(|bar, _: &NextItem, _, cx| bar.step_item(1, cx)))
277            .on_action(cx.listener(Self::confirm))
278            .on_action(cx.listener(Self::dismiss))
279            .children((0..self.menus.len()).map(|menu| {
280                let down = mounted == Some(menu);
281                let card = down.then(|| self.card(menu, &theme, cx));
282                div()
283                    .relative()
284                    .id(SharedString::from(format!("menubar-title-{menu}")))
285                    .on_mouse_down(
286                        gpui::MouseButton::Left,
287                        cx.listener(move |bar, _, _, _| {
288                            bar.open.note_trigger_press_matching(|open| *open == menu)
289                        }),
290                    )
291                    .on_click(cx.listener(move |bar, _, window, cx| bar.toggle(menu, window, cx)))
292                    .on_hover(cx.listener(move |bar, hovered: &bool, _, cx| {
293                        if *hovered {
294                            bar.hover_switch(menu, cx);
295                        }
296                    }))
297                    .child(menubar_title(&theme, self.menus[menu].title.clone(), down))
298                    .when_some(card, |title, card| {
299                        title.child(popover::anchored_menu_below(
300                            SharedString::from(format!("menubar-menu-{menu}")),
301                            card,
302                            closing,
303                        ))
304                    })
305            }))
306    }
307}