use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, Subscription, actions}; use project::DisableAiSettings; use settings::{Settings, SettingsStore}; use workspace::AccessibleMode; use schemars::JsonSchema; use serde::Deserialize; use smallvec::SmallVec; use ui::{ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*}; use platform_title_bar::TitleBarSettings; actions!( app_menu, [ /// Activates the menu on the right in the client-side application menu. /// /// Does not apply to platform menu bars (e.g. on macOS). ActivateMenuRight, /// Activates the menu on the left in the client-side application menu. /// /// Does not apply to platform menu bars (e.g. on macOS). ActivateMenuLeft ] ); /// Opens the named menu in the client-side application menu. /// /// Does not apply to platform menu bars (e.g. on macOS). #[derive(Clone, Deserialize, JsonSchema, PartialEq, Default, Action)] #[action(namespace = app_menu)] pub struct OpenApplicationMenu(String); #[cfg(not(target_os = "macos"))] pub enum ActivateDirection { Left, Right, } #[derive(Clone)] struct MenuEntry { menu: OwnedMenu, handle: PopoverMenuHandle, } pub struct ApplicationMenu { entries: SmallVec<[MenuEntry; 8]>, pending_menu_open: Option, _settings_subscription: Subscription, } impl ApplicationMenu { pub fn new(_: &mut Window, cx: &mut Context) -> Self { let menus = cx.get_menus().unwrap_or_default(); let entries = Self::build_entries(menus); let mut disable_ai = DisableAiSettings::get_global(cx).disable_ai; let settings_subscription = cx.observe_global::(move |application_menu, cx| { let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai; if new_disable_ai != disable_ai { disable_ai = new_disable_ai; for entry in &application_menu.entries { if entry.handle.is_deployed() { entry.handle.hide(cx); } } let menus = cx.get_menus().unwrap_or_default(); application_menu.entries = Self::build_entries(menus); } cx.notify(); }); Self { entries, pending_menu_open: None, _settings_subscription: settings_subscription, } } fn build_entries(menus: Vec) -> SmallVec<[MenuEntry; 8]> { menus .into_iter() .map(|menu| MenuEntry { menu, handle: PopoverMenuHandle::default(), }) .collect() } fn sanitize_menu_items(items: Vec) -> Vec { let mut cleaned = Vec::new(); let mut last_was_separator = false; for item in items { match item { OwnedMenuItem::Separator => { if !last_was_separator { cleaned.push(item); last_was_separator = true; } } OwnedMenuItem::Submenu(submenu) => { // Skip empty submenus if !submenu.items.is_empty() { cleaned.push(OwnedMenuItem::Submenu(submenu)); last_was_separator = false; } } item => { cleaned.push(item); last_was_separator = false; } } } // Remove trailing separator if let Some(OwnedMenuItem::Separator) = cleaned.last() { cleaned.pop(); } cleaned } fn build_menu_from_items( entry: MenuEntry, window: &mut Window, cx: &mut App, ) -> Entity { ContextMenu::build(window, cx, |menu, window, cx| { // Grab current focus handle so menu can shown items in context with the focused element let menu = menu.when_some(window.focused(cx), |menu, focused| menu.context(focused)); let sanitized_items = Self::sanitize_menu_items(entry.menu.items); sanitized_items .into_iter() .fold(menu, |menu, item| match item { OwnedMenuItem::Separator => menu.separator(), OwnedMenuItem::Action { name, action, checked, disabled, .. } => menu.action_checked_with_disabled(name, action, checked, disabled), OwnedMenuItem::Submenu(submenu) => { submenu .items .into_iter() .fold(menu, |menu, item| match item { OwnedMenuItem::Separator => menu.separator(), OwnedMenuItem::Action { name, action, checked, disabled, .. } => menu .action_checked_with_disabled(name, action, checked, disabled), OwnedMenuItem::Submenu(_) => menu, OwnedMenuItem::SystemMenu(_) => { // A system menu doesn't make sense in this context, so ignore it menu } }) } OwnedMenuItem::SystemMenu(_) => { // A system menu doesn't make sense in this context, so ignore it menu } }) }) } fn render_application_menu(&self, entry: &MenuEntry) -> impl IntoElement { let handle = entry.handle.clone(); let menu_name = entry.menu.name.clone(); let entry = entry.clone(); // Application menu must have same ids as first menu item in standard menu div() .id(format!("{}-menu-item", menu_name)) .occlude() .child( PopoverMenu::new(format!("{}-menu-popover", menu_name)) .menu(move |window, cx| { Self::build_menu_from_items(entry.clone(), window, cx).into() }) .trigger_with_tooltip( IconButton::new( SharedString::from(format!("{}-menu-trigger", menu_name)), ui::IconName::Menu, ) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) .tab_index(0isize) .aria_label("Application menu"), Tooltip::text("Open Application Menu"), ) .with_handle(handle), ) } fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement { let current_handle = entry.handle.clone(); let menu_name = entry.menu.name.clone(); let entry = entry.clone(); let all_handles: Vec<_> = self .entries .iter() .map(|entry| entry.handle.clone()) .collect(); div() .id(format!("{}-menu-item", menu_name)) .occlude() .child( PopoverMenu::new(format!("{}-menu-popover", menu_name)) .menu(move |window, cx| { Self::build_menu_from_items(entry.clone(), window, cx).into() }) .trigger( Button::new( SharedString::from(format!("{}-menu-trigger", menu_name)), menu_name, ) .style(ButtonStyle::Subtle) .label_size(LabelSize::Small) .tab_index(0isize), ) .with_handle(current_handle.clone()), ) .on_hover(move |hover_enter, window, cx| { if *hover_enter && !current_handle.is_deployed() && (open_menus_on_hover(cx) || all_handles.iter().any(|handle| handle.is_deployed())) { all_handles.iter().for_each(|h| h.hide(cx)); // We need to defer this so that this menu handle can take focus from the previous menu let handle = current_handle.clone(); window.defer(cx, move |window, cx| handle.show(window, cx)); } }) } #[cfg(not(target_os = "macos"))] pub fn open_menu( &mut self, action: &OpenApplicationMenu, _window: &mut Window, _cx: &mut Context, ) { self.pending_menu_open = Some(action.0.clone()); } #[cfg(not(target_os = "macos"))] pub fn navigate_menus_in_direction( &mut self, direction: ActivateDirection, window: &mut Window, cx: &mut Context, ) { let current_index = self .entries .iter() .position(|entry| entry.handle.is_deployed()); let Some(current_index) = current_index else { // No menu is open, so there is nothing to switch between. Let the // arrow key continue to the title bar's toolbar navigation so it // can move focus between title bar controls. Without this, the // `left`/`right` bindings in the `ApplicationMenu` context would // silently swallow the key as a no-op. cx.propagate(); return; }; let next_index = match direction { ActivateDirection::Left => { if current_index == 0 { self.entries.len() - 1 } else { current_index - 1 } } ActivateDirection::Right => { if current_index == self.entries.len() - 1 { 0 } else { current_index + 1 } } }; self.entries[current_index].handle.hide(cx); // We need to defer this so that this menu handle can take focus from the previous menu let next_handle = self.entries[next_index].handle.clone(); cx.defer_in(window, move |_, window, cx| next_handle.show(window, cx)); } pub fn all_menus_shown(&self, cx: &mut Context) -> bool { show_menus(cx) || self.entries.iter().any(|entry| entry.handle.is_deployed()) || self.pending_menu_open.is_some() // In accessible mode, keep the full menu bar expanded so every menu // is individually reachable and labeled for assistive technology. || cx.accessible_mode() } } pub(crate) fn show_menus(cx: &mut App) -> bool { TitleBarSettings::get_global(cx).show_menus && (cfg!(not(target_os = "macos")) || option_env!("ZED_USE_CROSS_PLATFORM_MENU").is_some()) } pub(crate) fn open_menus_on_hover(cx: &mut App) -> bool { TitleBarSettings::get_global(cx).open_menus_on_hover } impl Render for ApplicationMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let all_menus_shown = self.all_menus_shown(cx); if let Some(pending_menu_open) = self.pending_menu_open.take() && let Some(entry) = self .entries .iter() .find(|entry| entry.menu.name == pending_menu_open && !entry.handle.is_deployed()) { let handle_to_show = entry.handle.clone(); let handles_to_hide: Vec<_> = self .entries .iter() .filter(|e| e.menu.name != pending_menu_open && e.handle.is_deployed()) .map(|e| e.handle.clone()) .collect(); if handles_to_hide.is_empty() { // We need to wait for the next frame to show all menus first, // before we can handle show/hide operations window.on_next_frame(move |window, cx| { handles_to_hide.iter().for_each(|handle| handle.hide(cx)); window.defer(cx, move |window, cx| handle_to_show.show(window, cx)); }); } else { // Since menus are already shown, we can directly handle show/hide operations handles_to_hide.iter().for_each(|handle| handle.hide(cx)); cx.defer_in(window, move |_, window, cx| handle_to_show.show(window, cx)); } } div() .key_context("ApplicationMenu") .flex() .flex_row() .gap_x_1() .when(!all_menus_shown && !self.entries.is_empty(), |this| { this.child(self.render_application_menu(&self.entries[0])) }) .when(all_menus_shown, |this| { this.children( self.entries .iter() .map(|entry| self.render_standard_menu(entry)), ) }) } } #[cfg(test)] mod tests { use gpui::{Action, Menu, MenuItem, OwnedMenu, OwnedMenuItem, TestAppContext, UpdateGlobal}; use project::DisableAiSettings; use settings::{Settings, SettingsStore}; use crate::application_menu::{ApplicationMenu, MenuEntry, OpenApplicationMenu}; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); DisableAiSettings::register(cx); theme_settings::init(theme::LoadThemes::JustBase, cx); }); } fn owned_menu(name: &str, item_name: &str) -> OwnedMenu { OwnedMenu { name: name.into(), disabled: false, items: vec![OwnedMenuItem::Action { name: item_name.to_string(), action: OpenApplicationMenu(String::new()).boxed_clone(), os_action: None, checked: false, disabled: false, }], } } fn has_item(entries: &[MenuEntry], menu_name: &str, item_name: &str) -> bool { entries.iter().any(|entry| { entry.menu.name == menu_name && entry.menu.items.iter().any( |item| matches!(item, OwnedMenuItem::Action { name, .. } if name == item_name), ) }) } fn menu_entry<'a>(entries: &'a [MenuEntry], menu_name: &str) -> &'a MenuEntry { entries .iter() .find(|entry| entry.menu.name == menu_name) .expect("expected menu") } fn view_menu(include_agent_panel: bool) -> Menu { let mut items = Vec::new(); if include_agent_panel { items.push(MenuItem::action( "Agent Panel", OpenApplicationMenu(String::new()), )); } items.push(MenuItem::action( "Diagnostics", OpenApplicationMenu(String::new()), )); Menu { name: "View".into(), items, disabled: false, } } #[test] fn test_build_entries_reflects_current_menu_data() { let with_agent_panel = ApplicationMenu::build_entries(vec![owned_menu("View", "Agent Panel")]); assert!(has_item(&with_agent_panel, "View", "Agent Panel")); let without_agent_panel = ApplicationMenu::build_entries(vec![owned_menu("View", "Diagnostics")]); assert!(!has_item(&without_agent_panel, "View", "Agent Panel")); } #[gpui::test] fn test_entries_refresh_on_settings_change(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { cx.set_menus(vec![view_menu(true)]); }); let (app_menu, cx) = cx.add_window_view(|window, cx| ApplicationMenu::new(window, cx)); let view_handle = cx.update(|window, cx| { let view_handle = app_menu.read_with(cx, |app_menu, _| { menu_entry(&app_menu.entries, "View").handle.clone() }); view_handle.show(window, cx); view_handle }); assert!(view_handle.is_deployed()); assert!(app_menu.read_with(cx, |app_menu, _| has_item( &app_menu.entries, "View", "Agent Panel" ))); assert!(app_menu.read_with(cx, |app_menu, _| has_item( &app_menu.entries, "View", "Diagnostics" ))); cx.update(|_, cx| { cx.set_menus(vec![view_menu(false)]); DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); }); assert!(!view_handle.is_deployed()); assert!( !app_menu.read_with(cx, |app_menu, _| has_item( &app_menu.entries, "View", "Agent Panel" )), "expected Agent Panel is not in the View menu" ); assert!(app_menu.read_with(cx, |app_menu, _| has_item( &app_menu.entries, "View", "Diagnostics" ))); cx.update(|_, cx| { cx.set_menus(vec![view_menu(true)]); DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); }); assert!(app_menu.read_with(cx, |app_menu, _| has_item( &app_menu.entries, "View", "Agent Panel" ))); assert!(app_menu.read_with(cx, |app_menu, _| has_item( &app_menu.entries, "View", "Diagnostics" ))); } #[gpui::test] fn test_entries_preserve_deployed_menu_on_unrelated_settings_change(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { cx.set_menus(vec![ Menu::new("Zed").items([MenuItem::action( "Settings", OpenApplicationMenu(String::new()), )]), view_menu(true), ]); }); let (app_menu, cx) = cx.add_window_view(|window, cx| ApplicationMenu::new(window, cx)); let zed_handle = cx.update(|window, cx| { let zed_handle = app_menu.read_with(cx, |app_menu, _| { menu_entry(&app_menu.entries, "Zed").handle.clone() }); zed_handle.show(window, cx); zed_handle }); cx.run_until_parked(); assert!(zed_handle.is_deployed()); cx.update(|window, cx| { let view_handle = app_menu.read_with(cx, |app_menu, _| { menu_entry(&app_menu.entries, "View").handle.clone() }); zed_handle.hide(cx); view_handle.show(window, cx); }); cx.run_until_parked(); assert!(!zed_handle.is_deployed()); assert!(app_menu.read_with(cx, |app_menu, _| { menu_entry(&app_menu.entries, "View").handle.is_deployed() })); cx.update(|_, cx| { SettingsStore::update_global(cx, |_, _| {}); }); cx.run_until_parked(); assert!(app_menu.read_with(cx, |app_menu, _| { menu_entry(&app_menu.entries, "View").handle.is_deployed() })); } }