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 motion::{Fade, Painter};
43use theme::{Theme, ink};
44
45use crate::popover;
46
47/// One menu on the bar.
48#[derive(Clone, Debug)]
49pub struct Menu {
50    pub title: SharedString,
51    pub items: Vec<Item>,
52}
53
54impl Menu {
55    pub fn new(title: impl Into<SharedString>, items: Vec<Item>) -> Self {
56        Self {
57            title: title.into(),
58            items,
59        }
60    }
61}
62
63/// A row in a menu.
64///
65/// Deliberately not a struct with an `is_separator` flag: a separator has no
66/// label, no accelerator and nothing to enable, and every one of those fields
67/// would have to be answered anyway.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub enum Item {
70    Action {
71        label: SharedString,
72        /// The accelerator to *print* — the binding itself is the app's, and
73        /// bezel never dispatches it. A menu that showed a keystroke it did not
74        /// own would be documenting a lie.
75        keystroke: Option<SharedString>,
76        enabled: bool,
77    },
78    Separator,
79}
80
81impl Item {
82    pub fn action(label: impl Into<SharedString>) -> Self {
83        Item::Action {
84            label: label.into(),
85            keystroke: None,
86            enabled: true,
87        }
88    }
89
90    /// No-ops on a separator, which has nothing to hang a keystroke on.
91    pub fn with_keystroke(self, keystroke: impl Into<SharedString>) -> Self {
92        match self {
93            Item::Action { label, enabled, .. } => Item::Action {
94                label,
95                keystroke: Some(keystroke.into()),
96                enabled,
97            },
98            Item::Separator => Item::Separator,
99        }
100    }
101
102    pub fn disabled(self) -> Self {
103        match self {
104            Item::Action {
105                label, keystroke, ..
106            } => Item::Action {
107                label,
108                keystroke,
109                enabled: false,
110            },
111            Item::Separator => Item::Separator,
112        }
113    }
114
115    /// Whether the keyboard and the pointer can land here at all.
116    pub fn selectable(&self) -> bool {
117        matches!(self, Item::Action { enabled: true, .. })
118    }
119}
120
121/// The next row the keyboard can land on, `delta` deciding the direction:
122/// separators and disabled rows are stepped straight over, and both ends wrap.
123/// `from` of `None` enters the menu at the edge the direction comes from.
124///
125/// [`popover::menu_step`] cannot do this — it counts rows and knows nothing
126/// about which of them can be landed on. `None` back means *nothing* in the menu
127/// is selectable, which is the one shape that would otherwise spin forever.
128pub fn next_selectable(items: &[Item], from: Option<usize>, delta: isize) -> Option<usize> {
129    let count = items.len();
130    if count == 0 {
131        return None;
132    }
133    let step = if delta >= 0 { 1 } else { -1 };
134    let wrap = |at: usize| (at as isize + step).rem_euclid(count as isize) as usize;
135    // Entering, the first candidate is the edge itself; moving, it is the row
136    // after the one you are on.
137    let mut at = match from {
138        None if step > 0 => 0,
139        None => count - 1,
140        Some(at) => wrap(at.min(count - 1)),
141    };
142    for _ in 0..count {
143        if items[at].selectable() {
144            return Some(at);
145        }
146        at = wrap(at);
147    }
148    None
149}
150
151// ---------------------------------------------------------------------------
152// The bar
153// ---------------------------------------------------------------------------
154
155actions!(
156    bezel_menubar,
157    [PrevMenu, NextMenu, PrevItem, NextItem, Confirm, Dismiss]
158);
159
160/// The key context the bar claims, closed as well as open — `enter` on a
161/// focused-but-closed bar drops its first menu.
162pub const KEY_CONTEXT: &str = "Menubar";
163
164/// Install the bar's bindings. Call once, alongside [`crate::input::init`].
165///
166/// `left`/`right` cross between menus and `up`/`down` walk the rows, which is
167/// the one arrangement every platform's menubar agrees on. Nothing claims `alt`
168/// to focus the bar: that is a Windows convention, and a component library that
169/// binds a chord it is unsure of takes it away from every app downstream.
170pub fn init(cx: &mut App) {
171    let ctx = Some(KEY_CONTEXT);
172    cx.bind_keys([
173        KeyBinding::new("left", PrevMenu, ctx),
174        KeyBinding::new("right", NextMenu, ctx),
175        KeyBinding::new("up", PrevItem, ctx),
176        KeyBinding::new("down", NextItem, ctx),
177        KeyBinding::new("enter", Confirm, ctx),
178        KeyBinding::new("escape", Dismiss, ctx),
179    ]);
180}
181
182/// What the bar reports: an item chosen, by its place in the menus it was given.
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub enum MenubarEvent {
185    Selected { menu: usize, item: usize },
186}
187
188pub struct Menubar {
189    menus: Vec<Menu>,
190    /// Which title is down. One popup for the whole bar rather than one each:
191    /// exactly one menu can be open, and saying so in the type is what makes
192    /// switching between them a single assignment.
193    open: popover::Popup<usize>,
194    /// Where the keyboard is inside the open menu. Cleared whenever the menu
195    /// changes, so a fresh menu opens with nothing highlighted rather than with
196    /// the last one's row number pointing at whatever now sits there.
197    highlighted: Option<usize>,
198    focus_handle: FocusHandle,
199}
200
201impl EventEmitter<MenubarEvent> for Menubar {}
202
203impl Menubar {
204    pub fn new(menus: Vec<Menu>, cx: &mut Context<Self>) -> Self {
205        Self {
206            menus,
207            open: popover::Popup::default(),
208            highlighted: None,
209            // One stop for the whole bar: the menus are keyboard-driven from
210            // here, so no row takes focus of its own.
211            focus_handle: cx.focus_handle().tab_stop(true),
212        }
213    }
214
215    /// Which menu is down, `None` while none is (or while one is closing).
216    pub fn open_menu(&self) -> Option<usize> {
217        self.open.as_open().copied()
218    }
219
220    /// The menus as given. [`MenubarEvent`] reports a place in this list, so
221    /// this is how a host turns one back into the item it named — without
222    /// keeping a second copy that could drift from the bar's.
223    pub fn menus(&self) -> &[Menu] {
224        &self.menus
225    }
226
227    fn show(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
228        self.open.open(menu);
229        self.highlighted = None;
230        window.focus(&self.focus_handle, cx);
231        cx.notify();
232    }
233
234    fn toggle(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
235        // The note was taken on mouse-down and only counts for *this* title, so
236        // pressing a different one switches menus instead of being swallowed by
237        // the dismissal that same press caused.
238        if self.open.take_press_was_open() {
239            self.close(cx);
240        } else {
241            self.show(menu, window, cx);
242        }
243    }
244
245    /// The rule that makes a bar a bar: with one menu already down, the pointer
246    /// crossing a sibling title opens it. With none down, hovering does nothing
247    /// — a menubar that dropped a menu at the mere passage of the mouse would be
248    /// unusable.
249    fn hover_switch(&mut self, menu: usize, cx: &mut Context<Self>) {
250        if self.open.is_open() && self.open_menu() != Some(menu) {
251            self.open.open(menu);
252            self.highlighted = None;
253            cx.notify();
254        }
255    }
256
257    fn close(&mut self, cx: &mut Context<Self>) {
258        if self.open.begin_close() {
259            popover::reap_popup(cx, |bar: &mut Self| &mut bar.open);
260        }
261        self.highlighted = None;
262        cx.notify();
263    }
264
265    fn choose(&mut self, menu: usize, item: usize, cx: &mut Context<Self>) {
266        cx.emit(MenubarEvent::Selected { menu, item });
267        self.close(cx);
268    }
269
270    fn step_item(&mut self, delta: isize, cx: &mut Context<Self>) {
271        let Some(menu) = self.open_menu() else { return };
272        self.highlighted = next_selectable(&self.menus[menu].items, self.highlighted, delta);
273        cx.notify();
274    }
275
276    fn step_menu(&mut self, delta: isize, cx: &mut Context<Self>) {
277        let Some(menu) = self.open_menu() else { return };
278        let count = self.menus.len() as isize;
279        if count == 0 {
280            return;
281        }
282        self.open
283            .open((menu as isize + delta).rem_euclid(count) as usize);
284        self.highlighted = None;
285        cx.notify();
286    }
287
288    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
289        match (self.open_menu(), self.highlighted) {
290            (Some(menu), Some(item)) => self.choose(menu, item, cx),
291            // Closed, `enter` drops the first menu — the same key means "act on
292            // this control" either way, which is what makes the bar reachable
293            // by keyboard at all.
294            (None, _) if !self.menus.is_empty() => self.show(0, window, cx),
295            _ => {}
296        }
297    }
298
299    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
300        self.close(cx);
301    }
302
303    fn card(&self, menu: usize, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
304        // Fade keys are a process-wide map, so they carry the entity id — an app
305        // may hold more than one bar.
306        let view = Painter::of(cx);
307        popover::popover_card(theme)
308            .min_w(px(180.0))
309            .children(
310                self.menus[menu]
311                    .items
312                    .iter()
313                    .enumerate()
314                    .map(|(index, item)| match item {
315                        Item::Separator => popover::divider().into_any_element(),
316                        Item::Action {
317                            label,
318                            keystroke,
319                            enabled: false,
320                        } => {
321                            disabled_row(theme, label.clone(), keystroke.clone()).into_any_element()
322                        }
323                        Item::Action {
324                            label, keystroke, ..
325                        } => popover::menu_row_nav(
326                            theme,
327                            false,
328                            self.highlighted == Some(index),
329                            Fade::new(view, format!("menubar-{menu}-{index}")),
330                        )
331                        .justify_between()
332                        .id(SharedString::from(format!("item-{menu}-{index}")))
333                        .on_click(cx.listener(move |bar, _, _, cx| bar.choose(menu, index, cx)))
334                        .child(label.clone())
335                        .when_some(keystroke.clone(), |row, keystroke| {
336                            row.child(popover::kbd_hint(theme, &keystroke))
337                        })
338                        .into_any_element(),
339                    }),
340            )
341            .on_mouse_down_out(cx.listener(|bar, _, _, cx| bar.close(cx)))
342            .into_any_element()
343    }
344}
345
346/// One title on the strip. Lit while its own menu is down.
347pub fn menubar_title(theme: &Theme, label: impl Into<SharedString>, open: bool) -> gpui::Div {
348    let title = div()
349        .px(px(8.0))
350        .py(px(3.0))
351        .rounded(px(Theme::control_radius()))
352        .text_size(px(13.0))
353        .cursor_pointer()
354        .child(label.into());
355    if open {
356        title.bg(ink(0.08)).text_color(theme.text)
357    } else {
358        // A plain hover style, not a `motion::hover_blend` fade key: the fade
359        // installs an `on_hover` *listener*, and gpui allows only one per
360        // element — the switch below needs it.
361        title
362            .text_color(theme.text_muted)
363            .hover(|s| s.bg(ink(0.05)).text_color(theme.text))
364    }
365}
366
367/// The strip the titles sit on.
368pub fn menubar() -> gpui::Div {
369    div().flex().flex_row().items_center().gap(px(2.0))
370}
371
372/// A row that cannot be chosen: [`popover::menu_row`]'s metrics without its
373/// hover fade or its click, because a disabled row that lit under the pointer
374/// would be inviting a press that does nothing.
375fn disabled_row(theme: &Theme, label: SharedString, keystroke: Option<SharedString>) -> gpui::Div {
376    div()
377        .flex()
378        .flex_row()
379        .items_center()
380        .justify_between()
381        .gap(px(10.0))
382        .px(px(8.0))
383        .py(px(6.0))
384        // The disabled twin of `popover::menu_row`, in the same card — so it
385        // takes its corners from the same rule rather than a matching literal.
386        .rounded(px(Theme::inset_radius(
387            Theme::surface_radius(),
388            popover::MENU_PAD,
389        )))
390        .text_size(px(13.0))
391        .text_color(theme.text_faint)
392        .child(label)
393        .when_some(keystroke, |row, keystroke| {
394            row.child(popover::kbd_hint(theme, &keystroke))
395        })
396}
397
398impl Focusable for Menubar {
399    fn focus_handle(&self, _: &App) -> FocusHandle {
400        self.focus_handle.clone()
401    }
402}
403
404impl Render for Menubar {
405    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
406        let theme = Theme::of(cx).clone();
407        // `get`, not `as_open`: the card stays mounted through the exit phase.
408        let mounted = self.open.get().copied();
409        let closing = self.open.closing_since();
410
411        menubar()
412            .key_context(KEY_CONTEXT)
413            .track_focus(&self.focus_handle)
414            .on_action(cx.listener(|bar, _: &PrevMenu, _, cx| bar.step_menu(-1, cx)))
415            .on_action(cx.listener(|bar, _: &NextMenu, _, cx| bar.step_menu(1, cx)))
416            .on_action(cx.listener(|bar, _: &PrevItem, _, cx| bar.step_item(-1, cx)))
417            .on_action(cx.listener(|bar, _: &NextItem, _, cx| bar.step_item(1, cx)))
418            .on_action(cx.listener(Self::confirm))
419            .on_action(cx.listener(Self::dismiss))
420            .children((0..self.menus.len()).map(|menu| {
421                let down = mounted == Some(menu);
422                let card = down.then(|| self.card(menu, &theme, cx));
423                div()
424                    .relative()
425                    .id(SharedString::from(format!("menubar-title-{menu}")))
426                    .on_mouse_down(
427                        gpui::MouseButton::Left,
428                        cx.listener(move |bar, _, _, _| {
429                            bar.open.note_trigger_press_matching(|open| *open == menu)
430                        }),
431                    )
432                    .on_click(cx.listener(move |bar, _, window, cx| bar.toggle(menu, window, cx)))
433                    .on_hover(cx.listener(move |bar, hovered: &bool, _, cx| {
434                        if *hovered {
435                            bar.hover_switch(menu, cx);
436                        }
437                    }))
438                    .child(menubar_title(&theme, self.menus[menu].title.clone(), down))
439                    .when_some(card, |title, card| {
440                        title.child(popover::anchored_menu_below(
441                            SharedString::from(format!("menubar-menu-{menu}")),
442                            card,
443                            closing,
444                        ))
445                    })
446            }))
447    }
448}