use std::{
cell::RefCell,
collections::{HashMap, HashSet},
rc::Rc,
};
use gpui_kit::{
App, AppContext as _, Bounds, Context, ElementId, Entity, EventEmitter, FocusHandle,
InteractiveElement as _, IntoElement, MouseButton, OwnedMenu, ParentElement as _, Pixels,
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription,
Window,
base::{
Align, Disableable, ElementExt as _, GlobalState, Selectable as _, TestSupportExt as _,
actions::{SelectDown, SelectLeft, SelectRight},
h_flex,
},
div,
prelude::FluentBuilder as _,
};
use super::{BAR_CONTEXT, MenuAnchor, MenuEntry, MenuEvent, MenuState, panel::MenuPanels};
use crate::{ActiveTheme as _, Button, ButtonSize};
#[derive(Clone)]
#[non_exhaustive]
pub struct MenuBarMenu {
key: SharedString,
label: SharedString,
disabled: bool,
entries: Vec<MenuEntry>,
}
impl MenuBarMenu {
pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
key: key.into(),
label: label.into(),
disabled: false,
entries: Vec::new(),
}
}
pub fn entries(mut self, entries: impl IntoIterator<Item = impl Into<MenuEntry>>) -> Self {
self.entries.extend(entries.into_iter().map(Into::into));
self
}
pub fn key(&self) -> &SharedString {
&self.key
}
pub fn label(&self) -> &SharedString {
&self.label
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
}
impl Disableable for MenuBarMenu {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl From<OwnedMenu> for MenuBarMenu {
fn from(menu: OwnedMenu) -> Self {
let mut used = HashSet::new();
let entries: Vec<MenuEntry> = menu
.items
.into_iter()
.filter_map(|item| MenuEntry::from_app_item_keyed(item, &mut used))
.collect();
Self::new(menu.name.clone(), menu.name)
.disabled(menu.disabled)
.entries(entries)
}
}
pub struct MenuBarState {
menus: Vec<MenuBarMenu>,
open: Option<usize>,
menu: Entity<MenuState>,
context: Option<FocusHandle>,
titles: Rc<RefCell<HashMap<SharedString, Bounds<Pixels>>>>,
title_focus: Vec<FocusHandle>,
highlighted: usize,
_subscriptions: Vec<Subscription>,
}
impl EventEmitter<MenuEvent> for MenuBarState {}
impl MenuBarState {
pub fn new(
menus: impl IntoIterator<Item = impl Into<MenuBarMenu>>,
cx: &mut Context<Self>,
) -> Self {
let menu = cx.new(MenuState::new);
let this = cx.weak_entity();
menu.update(cx, |menu, _| {
menu.set_step_handler(Rc::new(move |delta, window, cx| {
this.update(cx, |bar, cx| bar.step(delta, window, cx)).ok();
}))
});
let subscriptions = vec![
cx.observe(&menu, |_, _, cx| cx.notify()),
cx.subscribe(&menu, |this, menu, event: &MenuEvent, cx| {
if *event == MenuEvent::Closed && !menu.read(cx).is_open() {
this.open = None;
this.context = None;
}
cx.emit(event.clone());
cx.notify();
}),
];
let menus: Vec<MenuBarMenu> = menus.into_iter().map(Into::into).collect();
let title_focus = menus.iter().map(|_| cx.focus_handle()).collect();
Self {
menus,
open: None,
menu,
context: None,
titles: Rc::default(),
title_focus,
highlighted: 0,
_subscriptions: subscriptions,
}
}
pub fn from_app_menus(cx: &mut Context<Self>) -> Self {
let menus = cx.get_menus().unwrap_or_default();
Self::new(menus, cx)
}
pub fn menus(&self) -> &[MenuBarMenu] {
&self.menus
}
pub fn set_menus(
&mut self,
menus: impl IntoIterator<Item = impl Into<MenuBarMenu>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.close(window, cx);
self.menus = menus.into_iter().map(Into::into).collect();
self.title_focus = self.menus.iter().map(|_| cx.focus_handle()).collect();
self.highlighted = self.highlighted.min(self.menus.len().saturating_sub(1));
cx.notify();
}
pub fn open_menu(&self) -> Option<&SharedString> {
Some(self.menus.get(self.open?)?.key())
}
pub fn is_open(&self) -> bool {
self.open.is_some()
}
pub fn menu(&self) -> &Entity<MenuState> {
&self.menu
}
pub fn open(&mut self, key: &str, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.menus.iter().position(|menu| menu.key == key) {
self.open_at(index, false, window, cx);
}
}
pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.menu.update(cx, |menu, cx| menu.close(window, cx));
}
fn open_at(
&mut self,
index: usize,
keyboard: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(menu) = self.menus.get(index).filter(|menu| !menu.disabled) else {
return;
};
if !self.menu.read(cx).is_open() {
self.context = window.focused(cx);
}
let bounds = self
.titles
.borrow()
.get(&menu.key)
.copied()
.unwrap_or_default();
let anchor = MenuAnchor::Trigger(bounds, Align::Start);
let entries = menu.entries.clone();
let context = self.context.clone();
self.menu.update(cx, |menu, cx| {
match &context {
Some(context) => menu.open_for(entries, anchor, context, window, cx),
None => menu.open(entries, anchor, window, cx),
}
if keyboard {
menu.highlight_edge(false, cx);
}
});
self.open = Some(index);
self.highlighted = index;
cx.notify();
}
fn move_highlight(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
if let Some(index) = self.neighbour(self.highlighted, delta) {
self.highlighted = index;
self.title_focus[index].focus(window, cx);
cx.notify();
}
}
fn open_highlighted(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.open_at(self.highlighted, true, window, cx);
}
fn neighbour(&self, from: usize, delta: isize) -> Option<usize> {
let count = self.menus.len() as isize;
let mut index = from as isize;
for _ in 0..count {
index = (index + delta).rem_euclid(count);
if !self.menus[index as usize].disabled {
return Some(index as usize);
}
}
None
}
fn step(&mut self, delta: isize, window: &mut Window, cx: &mut Context<Self>) {
let Some(open) = self.open else {
return;
};
if let Some(index) = self.neighbour(open, delta).filter(|index| *index != open) {
self.open_at(index, true, window, cx);
}
}
}
#[derive(IntoElement)]
pub struct MenuBar {
id: ElementId,
state: Entity<MenuBarState>,
plain: bool,
}
impl MenuBar {
pub fn new(id: impl Into<ElementId>, state: &Entity<MenuBarState>) -> Self {
Self {
id: id.into(),
state: state.clone(),
plain: false,
}
}
pub fn plain(mut self) -> Self {
self.plain = true;
self
}
}
impl RenderOnce for MenuBar {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let (frame_fill, frame_border, frame_radius, touch) = (
theme.select_trigger,
theme.select_trigger_border,
ButtonSize::Default.geometry(theme).radius,
theme.touch,
);
let (title_ink, open_fill) = (theme.foreground(), theme.popover_accent);
let (menus, open, menu, titles, title_focus, highlighted) = {
let state = self.state.read(cx);
let open = state.open.filter(|_| state.menu.read(cx).is_open());
(
state.menus.clone(),
open,
state.menu.clone(),
state.titles.clone(),
state.title_focus.clone(),
state.highlighted,
)
};
let bar_id = self.id.clone();
let on = |f: fn(&mut MenuBarState, &mut Window, &mut Context<MenuBarState>)| {
let state = self.state.clone();
move |window: &mut Window, cx: &mut App| {
state.update(cx, |state, cx| f(state, window, cx))
}
};
let (left, right, down) = (
on(|state, window, cx| state.move_highlight(-1, window, cx)),
on(|state, window, cx| state.move_highlight(1, window, cx)),
on(|state, window, cx| state.open_highlighted(window, cx)),
);
h_flex()
.id(self.id.clone())
.test_support()
.key_context(BAR_CONTEXT)
.role(Role::MenuBar)
.when(open.is_none(), |this| {
this.on_action(move |_: &SelectLeft, window, cx| left(window, cx))
.on_action(move |_: &SelectRight, window, cx| right(window, cx))
.on_action(move |_: &SelectDown, window, cx| down(window, cx))
})
.flex_shrink_0()
.items_center()
.when(!self.plain, |this| {
this.p_1()
.rounded(frame_radius)
.bg(frame_fill)
.border_1()
.border_color(frame_border)
})
.children(menus.into_iter().enumerate().map(|(index, entry)| {
let is_open = open == Some(index);
let disabled = entry.disabled;
let key = entry.key.clone();
let titles = titles.clone();
let press = self.state.clone();
let hover = self.state.clone();
let keyboard = self.state.clone();
let title = Button::new(ElementId::NamedChild(bar_id.clone().into(), key.clone()))
.ghost()
.size(ButtonSize::Default)
.label(entry.label.clone())
.accessibility_label(entry.label)
.with_role(Role::MenuItem)
.with_expanded(is_open)
.open(is_open)
.disabled(disabled)
.when(!disabled, |this| this.text_color(title_ink))
.when(is_open, |this| this.bg(open_fill))
.track_focus(&title_focus[index])
.tab_stop(index == highlighted)
.on_click(move |event, window, cx| {
if event.is_keyboard() {
keyboard.update(cx, |state, cx| {
state.highlighted = index;
state.open_at(index, true, window, cx)
});
}
})
.when(!disabled, |this| {
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
GlobalState::suppress_text_selection(cx);
if open == Some(index) {
press.update(cx, |state, cx| state.close(window, cx));
} else {
press.update(cx, |state, cx| {
state.open_at(index, false, window, cx)
});
}
})
.when(!touch, |this| {
this.on_mouse_move(move |_, window, cx| {
let switch = {
let state = hover.read(cx);
state.menu.read(cx).is_open() && state.open != Some(index)
};
if switch {
hover.update(cx, |state, cx| {
state.open_at(index, false, window, cx)
});
}
})
})
});
div()
.relative()
.flex_shrink_0()
.child(title)
.on_prepaint(move |bounds, _, _| {
titles.borrow_mut().insert(key, bounds);
})
}))
.child(MenuPanels::new(
ElementId::NamedChild(self.id.into(), "menus".into()),
&menu,
))
}
}