bezel-ui 0.1.8

SwiftUI-flavored components for gpui — popovers, menus, buttons, toggles, and loaders
Documentation
//! [`Menubar`] — the in-window bar: a strip of titles that drop menus.
//!
//! Not the *native* one. On macOS that is `cx.set_menus` and four lines in an
//! app's `main`, which is where it belongs; this is the bar an app with a custom
//! titlebar draws for itself, and the one every other platform expects to see
//! inside the window.
//!
//! An entity, on the line [`crate::date::Calendar`] drew: it owns which menu is
//! down and where the keyboard is inside it — state the app has no opinion
//! about — and reports the one thing the app wants, through [`MenubarEvent`].
//! The menus are data the app hands over, shaped like gpui's own `Menu` and
//! `MenuItem` so an app drawing both bars writes them the same way. It does not
//! *take* those types: they carry a boxed action, and reporting an index leaves
//! dispatch with the app, the way [`crate::combobox`] and [`crate::palette`]
//! already do.
//!
//! What makes it a menubar rather than a row of dropdowns is that one menu being
//! open changes what the others do: sliding the pointer onto a sibling title
//! switches to it with no click, and `left`/`right` cross between menus without
//! leaving the keyboard.
//!
//! ```ignore
//! ui::menubar::init(cx);   // once, at startup
//! let bar = cx.new(|cx| Menubar::new(vec![
//!     Menu::new("File", vec![
//!         Item::action("New Window").with_keystroke("⌘N"),
//!         Item::submenu("Open Recent", vec![Item::action("bezel.md")]),
//!         Item::Separator,
//!         Item::action("Close").with_keystroke("⌘W").disabled(),
//!     ]),
//! ], cx));
//! cx.subscribe(&bar, |_, bar, event, cx| match event {
//!     MenubarEvent::Selected { menu, path } => { /* dispatch */ }
//! })
//! .detach();
//! ```

use gpui::{
    App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
    div, prelude::*, px,
};

use theme::{TextStyle, Theme, Typeset};

use crate::{
    menu::{self, Item},
    popover,
};

/// One menu on the bar.
#[derive(Clone, Debug)]
pub struct Menu {
    pub title: SharedString,
    pub items: Vec<Item>,
}

impl Menu {
    pub fn new(title: impl Into<SharedString>, items: Vec<Item>) -> Self {
        Self {
            title: title.into(),
            items,
        }
    }

    /// The item a [`MenubarEvent::Selected`] path names, submenus walked.
    pub fn at(&self, path: &[usize]) -> Option<&Item> {
        menu::at(&self.items, path)
    }
}

// ---------------------------------------------------------------------------
// The bar
// ---------------------------------------------------------------------------

actions!(
    bezel_menubar,
    [PrevMenu, NextMenu, PrevItem, NextItem, Confirm, Dismiss]
);

/// The key context the bar claims, closed as well as open — `enter` on a
/// focused-but-closed bar drops its first menu.
pub const KEY_CONTEXT: &str = "Menubar";

/// Install the bar's bindings. Call once, alongside [`crate::input::init`].
///
/// `left`/`right` cross between menus and `up`/`down` walk the rows, which is
/// the one arrangement every platform's menubar agrees on. With a submenu in
/// reach they open and close it first, and only cross once there is no level
/// left to move through. Nothing claims `alt` to focus the bar: that is a
/// Windows convention, and a component library that binds a chord it is unsure
/// of takes it away from every app downstream.
pub fn init(cx: &mut App) {
    let ctx = Some(KEY_CONTEXT);
    cx.bind_keys([
        KeyBinding::new("left", PrevMenu, ctx),
        KeyBinding::new("right", NextMenu, ctx),
        KeyBinding::new("up", PrevItem, ctx),
        KeyBinding::new("down", NextItem, ctx),
        KeyBinding::new("enter", Confirm, ctx),
        KeyBinding::new("escape", Dismiss, ctx),
    ]);
}

/// What the bar reports: an item chosen, by its place in the menus it was given.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MenubarEvent {
    /// `path` is a row index per level, outermost first — one entry for a
    /// top-level row, two for a row in a submenu. [`Menu::at`] turns it back
    /// into the item.
    Selected { menu: usize, path: Vec<usize> },
}

pub struct Menubar {
    menus: Vec<Menu>,
    /// Which title is down. One popup for the whole bar rather than one each:
    /// exactly one menu can be open, and saying so in the type is what makes
    /// switching between them a single assignment.
    open: popover::Popup<usize>,
    /// Where the keyboard and the pointer both are inside the open menu, and
    /// which of its submenus are down. Cleared whenever the menu changes, so a
    /// fresh menu opens with nothing highlighted rather than with the last
    /// one's row number pointing at whatever now sits there.
    cursor: menu::Cursor,
    focus_handle: FocusHandle,
}

impl EventEmitter<MenubarEvent> for Menubar {}

impl Menubar {
    pub fn new(menus: Vec<Menu>, cx: &mut Context<Self>) -> Self {
        Self {
            menus,
            open: popover::Popup::default(),
            cursor: menu::Cursor::default(),
            // One stop for the whole bar: the menus are keyboard-driven from
            // here, so no row takes focus of its own.
            focus_handle: cx.focus_handle().tab_stop(true),
        }
    }

    /// Which menu is down, `None` while none is (or while one is closing).
    pub fn open_menu(&self) -> Option<usize> {
        self.open.as_open().copied()
    }

    /// Where the pointer and the keyboard are in the open menu — which
    /// submenus are down, and which row is live.
    pub fn cursor(&self) -> &menu::Cursor {
        &self.cursor
    }

    /// The menus as given. [`MenubarEvent`] reports a place in this list, so
    /// this is how a host turns one back into the item it named — without
    /// keeping a second copy that could drift from the bar's.
    pub fn menus(&self) -> &[Menu] {
        &self.menus
    }

    fn show(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
        self.open.open(menu);
        self.cursor.clear();
        window.focus(&self.focus_handle, cx);
        cx.notify();
    }

    fn toggle(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
        // The note was taken on mouse-down and only counts for *this* title, so
        // pressing a different one switches menus instead of being swallowed by
        // the dismissal that same press caused.
        if self.open.take_press_was_open() {
            self.close(cx);
        } else {
            self.show(menu, window, cx);
        }
    }

    /// The rule that makes a bar a bar: with one menu already down, the pointer
    /// crossing a sibling title opens it. With none down, hovering does nothing
    /// — a menubar that dropped a menu at the mere passage of the mouse would be
    /// unusable.
    fn hover_switch(&mut self, menu: usize, cx: &mut Context<Self>) {
        if self.open.is_open() && self.open_menu() != Some(menu) {
            self.open.open(menu);
            self.cursor.clear();
            cx.notify();
        }
    }

    fn close(&mut self, cx: &mut Context<Self>) {
        if self.open.begin_close() {
            popover::reap_popup(cx, |bar: &mut Self| &mut bar.open);
        }
        // Before the exit plays, not after: a submenu paints on a layer of its
        // own and would hang there, unfaded, over the menu dissolving under it.
        self.cursor.clear();
        cx.notify();
    }

    fn choose(&mut self, menu: usize, path: Vec<usize>, cx: &mut Context<Self>) {
        cx.emit(MenubarEvent::Selected { menu, path });
        self.close(cx);
    }

    fn step_item(&mut self, delta: isize, cx: &mut Context<Self>) {
        let Some(menu) = self.open_menu() else { return };
        self.cursor.step(&self.menus[menu].items, delta);
        cx.notify();
    }

    fn step_menu(&mut self, delta: isize, cx: &mut Context<Self>) {
        let Some(menu) = self.open_menu() else { return };
        let count = self.menus.len() as isize;
        if count == 0 {
            return;
        }
        self.open
            .open((menu as isize + delta).rem_euclid(count) as usize);
        self.cursor.clear();
        cx.notify();
    }

    /// `right`: into the submenu under the cursor if there is one, else across
    /// to the next menu. A submenu row that swallowed `right` without opening
    /// would be a dead key on the one row that has somewhere to go.
    fn go_deeper(&mut self, cx: &mut Context<Self>) {
        let Some(menu) = self.open_menu() else { return };
        if self.cursor.descend(&self.menus[menu].items) {
            cx.notify();
        } else {
            self.step_menu(1, cx);
        }
    }

    /// `left`: out of the innermost submenu, else back to the previous menu.
    fn go_shallower(&mut self, cx: &mut Context<Self>) {
        if self.cursor.ascend() {
            cx.notify();
        } else {
            self.step_menu(-1, cx);
        }
    }

    /// What the pointer did to the open menu. Only a cursor that actually moved
    /// is worth a frame — `on_mouse_move` reports every pixel.
    fn hit(&mut self, hit: menu::Hit, cx: &mut Context<Self>) {
        let Some(menu) = self.open_menu() else { return };
        match hit {
            menu::Hit::Point(path) => {
                if self.cursor.point_at(&self.menus[menu].items, &path) {
                    cx.notify();
                }
            }
            menu::Hit::Choose(path) => self.choose(menu, path, cx),
            menu::Hit::Dismiss => self.close(cx),
        }
    }

    fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
        match (self.open_menu(), self.cursor.path()) {
            // A submenu row's `enter` opens it, the way `right` does; only an
            // action row is a choice.
            (Some(menu), Some(path)) => {
                if self.cursor.descend(&self.menus[menu].items) {
                    cx.notify();
                } else {
                    self.choose(menu, path, cx);
                }
            }
            // Closed, `enter` drops the first menu — the same key means "act on
            // this control" either way, which is what makes the bar reachable
            // by keyboard at all.
            (None, _) if !self.menus.is_empty() => self.show(0, window, cx),
            _ => {}
        }
    }

    /// `escape` closes one level at a time, the bar itself last.
    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
        if self.cursor.ascend() {
            cx.notify();
        } else {
            self.close(cx);
        }
    }

    fn card(&self, menu: usize, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
        menu::card(
            theme,
            SharedString::from(format!("menu-{menu}")),
            &self.menus[menu].items,
            &self.cursor,
            cx,
            |bar, hit, _, cx| bar.hit(hit, cx),
        )
        .into_any_element()
    }
}

/// One title on the strip. Lit while its own menu is down.
pub fn menubar_title(theme: &Theme, label: impl Into<SharedString>, open: bool) -> gpui::Div {
    let title = div()
        .px(px(8.0))
        .py(px(3.0))
        .rounded(px(Theme::control_radius()))
        .text_style(TextStyle::Body)
        .cursor_pointer()
        .child(label.into());
    if open {
        title.bg(theme.element_active).text_color(theme.text)
    } else {
        // A plain hover style, not a `motion::hover_blend` fade key: the fade
        // installs an `on_hover` *listener*, and gpui allows only one per
        // element — the switch below needs it.
        title
            .text_color(theme.text_muted)
            .hover(|s| s.bg(theme.element_hover).text_color(theme.text))
    }
}

/// The strip the titles sit on.
pub fn menubar() -> gpui::Div {
    div().flex().flex_row().items_center().gap(px(2.0))
}

impl Focusable for Menubar {
    fn focus_handle(&self, _: &App) -> FocusHandle {
        self.focus_handle.clone()
    }
}

impl Render for Menubar {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = Theme::of(cx).clone();
        // `get`, not `as_open`: the card stays mounted through the exit phase.
        let mounted = self.open.get().copied();
        let closing = self.open.closing_since();

        menubar()
            .key_context(KEY_CONTEXT)
            .track_focus(&self.focus_handle)
            .on_action(cx.listener(|bar, _: &PrevMenu, _, cx| bar.go_shallower(cx)))
            .on_action(cx.listener(|bar, _: &NextMenu, _, cx| bar.go_deeper(cx)))
            .on_action(cx.listener(|bar, _: &PrevItem, _, cx| bar.step_item(-1, cx)))
            .on_action(cx.listener(|bar, _: &NextItem, _, cx| bar.step_item(1, cx)))
            .on_action(cx.listener(Self::confirm))
            .on_action(cx.listener(Self::dismiss))
            .children((0..self.menus.len()).map(|menu| {
                let down = mounted == Some(menu);
                let card = down.then(|| self.card(menu, &theme, cx));
                div()
                    .relative()
                    .id(SharedString::from(format!("menubar-title-{menu}")))
                    .on_mouse_down(
                        gpui::MouseButton::Left,
                        cx.listener(move |bar, _, _, _| {
                            bar.open.note_trigger_press_matching(|open| *open == menu)
                        }),
                    )
                    .on_click(cx.listener(move |bar, _, window, cx| bar.toggle(menu, window, cx)))
                    .on_hover(cx.listener(move |bar, hovered: &bool, _, cx| {
                        if *hovered {
                            bar.hover_switch(menu, cx);
                        }
                    }))
                    .child(menubar_title(&theme, self.menus[menu].title.clone(), down))
                    .when_some(card, |title, card| {
                        title.child(popover::anchored_menu_below(
                            SharedString::from(format!("menubar-menu-{menu}")),
                            card,
                            closing,
                        ))
                    })
            }))
    }
}