use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
div, prelude::*, px,
};
use theme::{Theme, ink};
use crate::popover;
#[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,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Item {
Action {
label: SharedString,
keystroke: Option<SharedString>,
enabled: bool,
},
Separator,
}
impl Item {
pub fn action(label: impl Into<SharedString>) -> Self {
Item::Action {
label: label.into(),
keystroke: None,
enabled: true,
}
}
pub fn with_keystroke(self, keystroke: impl Into<SharedString>) -> Self {
match self {
Item::Action { label, enabled, .. } => Item::Action {
label,
keystroke: Some(keystroke.into()),
enabled,
},
Item::Separator => Item::Separator,
}
}
pub fn disabled(self) -> Self {
match self {
Item::Action {
label, keystroke, ..
} => Item::Action {
label,
keystroke,
enabled: false,
},
Item::Separator => Item::Separator,
}
}
pub fn selectable(&self) -> bool {
matches!(self, Item::Action { enabled: true, .. })
}
}
pub fn next_selectable(items: &[Item], from: Option<usize>, delta: isize) -> Option<usize> {
let count = items.len();
if count == 0 {
return None;
}
let step = if delta >= 0 { 1 } else { -1 };
let wrap = |at: usize| (at as isize + step).rem_euclid(count as isize) as usize;
let mut at = match from {
None if step > 0 => 0,
None => count - 1,
Some(at) => wrap(at.min(count - 1)),
};
for _ in 0..count {
if items[at].selectable() {
return Some(at);
}
at = wrap(at);
}
None
}
actions!(
bezel_menubar,
[PrevMenu, NextMenu, PrevItem, NextItem, Confirm, Dismiss]
);
pub const KEY_CONTEXT: &str = "Menubar";
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),
]);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenubarEvent {
Selected { menu: usize, item: usize },
}
pub struct Menubar {
menus: Vec<Menu>,
open: popover::Popup<usize>,
highlighted: Option<usize>,
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(),
highlighted: None,
focus_handle: cx.focus_handle().tab_stop(true),
}
}
pub fn open_menu(&self) -> Option<usize> {
self.open.as_open().copied()
}
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.highlighted = None;
window.focus(&self.focus_handle, cx);
cx.notify();
}
fn toggle(&mut self, menu: usize, window: &mut Window, cx: &mut Context<Self>) {
if self.open.take_press_was_open() {
self.close(cx);
} else {
self.show(menu, window, cx);
}
}
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.highlighted = None;
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);
}
self.highlighted = None;
cx.notify();
}
fn choose(&mut self, menu: usize, item: usize, cx: &mut Context<Self>) {
cx.emit(MenubarEvent::Selected { menu, item });
self.close(cx);
}
fn step_item(&mut self, delta: isize, cx: &mut Context<Self>) {
let Some(menu) = self.open_menu() else { return };
self.highlighted = next_selectable(&self.menus[menu].items, self.highlighted, 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.highlighted = None;
cx.notify();
}
fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
match (self.open_menu(), self.highlighted) {
(Some(menu), Some(item)) => self.choose(menu, item, cx),
(None, _) if !self.menus.is_empty() => self.show(0, window, cx),
_ => {}
}
}
fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
self.close(cx);
}
fn card(&self, menu: usize, theme: &Theme, cx: &mut Context<Self>) -> gpui::AnyElement {
let view = cx.entity_id();
popover::popover_card(theme)
.min_w(px(180.0))
.children(
self.menus[menu]
.items
.iter()
.enumerate()
.map(|(index, item)| match item {
Item::Separator => popover::divider().into_any_element(),
Item::Action {
label,
keystroke,
enabled: false,
} => {
disabled_row(theme, label.clone(), keystroke.clone()).into_any_element()
}
Item::Action {
label, keystroke, ..
} => popover::menu_row_nav(
theme,
false,
self.highlighted == Some(index),
SharedString::from(format!("menubar-{view}-{menu}-{index}")),
)
.justify_between()
.id(SharedString::from(format!("item-{menu}-{index}")))
.on_click(cx.listener(move |bar, _, _, cx| bar.choose(menu, index, cx)))
.child(label.clone())
.when_some(keystroke.clone(), |row, keystroke| {
row.child(popover::kbd_hint(theme, &keystroke))
})
.into_any_element(),
}),
)
.on_mouse_down_out(cx.listener(|bar, _, _, cx| bar.close(cx)))
.into_any_element()
}
}
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_size(px(13.0))
.cursor_pointer()
.child(label.into());
if open {
title.bg(ink(0.08)).text_color(theme.text)
} else {
title
.text_color(theme.text_muted)
.hover(|s| s.bg(ink(0.05)).text_color(theme.text))
}
}
pub fn menubar() -> gpui::Div {
div().flex().flex_row().items_center().gap(px(2.0))
}
fn disabled_row(theme: &Theme, label: SharedString, keystroke: Option<SharedString>) -> gpui::Div {
div()
.flex()
.flex_row()
.items_center()
.justify_between()
.gap(px(10.0))
.px(px(8.0))
.py(px(6.0))
.rounded(px(Theme::inset_radius(
Theme::SURFACE_RADIUS,
popover::MENU_PAD,
)))
.text_size(px(13.0))
.text_color(theme.text_faint)
.child(label)
.when_some(keystroke, |row, keystroke| {
row.child(popover::kbd_hint(theme, &keystroke))
})
}
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();
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.step_menu(-1, cx)))
.on_action(cx.listener(|bar, _: &NextMenu, _, cx| bar.step_menu(1, 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,
))
})
}))
}
}