From f7a7866d4a188d5de2f1f999da9bc3f82983ba57 Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Thu, 19 Dec 2024 03:57:25 +0530 Subject: [PATCH] linux: Implement Menus (#21873) Closes #19837 This PR implements menus for Linux and Windows, inspired by JetBrains IDEs. Thanks to @notpeter for the inspiration. https://github.com/user-attachments/assets/7267fcdf-fec5-442e-a53b-281f89471095 I plan to complete this in multiple parts. While this PR delivers a fully functional menus, there are many UX improvements that can be done. So, this is part 1 of 3. **This PR**: - [x] Clicking the application menu opens the first menu popup. This also shows other available menus. - [x] While a menu is open, hovering over other menus opens them without needing a click. - [x] Up/down arrow keys works out of the box. Thanks GPUI. **Future - Part 2**: - Add keybinding support to open specific menus using `Option + first character of menu item`. - Add support for left/right arrow keys to move between menus. **Future - Part 3**: - Implement nested context menus in GPUI for submenus. (I haven't checked if this already exists). --------- Co-authored-by: Mikayla --- crates/title_bar/src/application_menu.rs | 296 +++++++++++++---------- crates/title_bar/src/title_bar.rs | 25 +- crates/zed/src/zed/app_menus.rs | 3 + 3 files changed, 189 insertions(+), 135 deletions(-) diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index ef13655bdbb37a7a4bbcfe9fd2482fd2cba672bd..636af6a6d0ef166498af218906fb96344dfa54ef 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -1,145 +1,181 @@ -use ui::{prelude::*, ContextMenu, NumericStepper, PopoverMenu, PopoverMenuHandle, Tooltip}; +use gpui::{OwnedMenu, OwnedMenuItem, View}; +use smallvec::SmallVec; +use ui::{prelude::*, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip}; + +#[derive(Clone)] +struct MenuEntry { + menu: OwnedMenu, + handle: PopoverMenuHandle, +} pub struct ApplicationMenu { - context_menu_handle: PopoverMenuHandle, + entries: SmallVec<[MenuEntry; 8]>, } impl ApplicationMenu { - pub fn new(_: &mut ViewContext) -> Self { + pub fn new(cx: &mut ViewContext) -> Self { + let menus = cx.get_menus().unwrap_or_default(); Self { - context_menu_handle: PopoverMenuHandle::default(), + entries: 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, cx: &mut WindowContext<'_>) -> View { + ContextMenu::build(cx, |menu, cx| { + let menu = menu.when_some(cx.focused(), |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, .. } => menu.action(name, action), + OwnedMenuItem::Submenu(submenu) => { + submenu + .items + .into_iter() + .fold(menu, |menu, item| match item { + OwnedMenuItem::Separator => menu.separator(), + OwnedMenuItem::Action { name, action, .. } => { + menu.action(name, action) + } + OwnedMenuItem::Submenu(_) => 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 + // Hence, we generate ids based on the menu name + div() + .id(SharedString::from(format!("{}-menu-item", menu_name))) + .occlude() + .child( + PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name))) + .menu(move |cx| Self::build_menu_from_items(entry.clone(), cx).into()) + .trigger( + IconButton::new( + SharedString::from(format!("{}-menu-trigger", menu_name)), + ui::IconName::Menu, + ) + .style(ButtonStyle::Subtle) + .icon_size(IconSize::Small) + .when(!handle.is_deployed(), |this| { + this.tooltip(|cx| Tooltip::text("Open Application Menu", cx)) + }), + ) + .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(SharedString::from(format!("{}-menu-item", menu_name))) + .occlude() + .child( + PopoverMenu::new(SharedString::from(format!("{}-menu-popover", menu_name))) + .menu(move |cx| Self::build_menu_from_items(entry.clone(), cx).into()) + .trigger( + Button::new( + SharedString::from(format!("{}-menu-trigger", menu_name)), + menu_name.clone(), + ) + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small), + ) + .with_handle(current_handle.clone()), + ) + .on_hover(move |hover_enter, cx| { + // Skip if menu is already open to avoid focus issue + if *hover_enter && !current_handle.is_deployed() { + all_handles.iter().for_each(|h| h.hide(cx)); + + // Defer to prevent focus race condition with the previously open menu + let handle = current_handle.clone(); + cx.defer(move |w| handle.show(w)); + } + }) + } + + pub fn is_any_deployed(&self) -> bool { + self.entries.iter().any(|entry| entry.handle.is_deployed()) } } impl Render for ApplicationMenu { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - PopoverMenu::new("application-menu") - .menu(move |cx| { - ContextMenu::build(cx, move |menu, cx| { - menu.header("Workspace") - .action( - "Open Command Palette", - Box::new(zed_actions::command_palette::Toggle), - ) - .when_some(cx.focused(), |menu, focused| menu.context(focused)) - .custom_row(move |cx| { - h_flex() - .gap_2() - .w_full() - .justify_between() - .cursor(gpui::CursorStyle::Arrow) - .child(Label::new("Buffer Font Size")) - .child( - NumericStepper::new( - "buffer-font-size", - theme::get_buffer_font_size(cx).to_string(), - |_, cx| { - cx.dispatch_action(Box::new( - zed_actions::DecreaseBufferFontSize, - )) - }, - |_, cx| { - cx.dispatch_action(Box::new( - zed_actions::IncreaseBufferFontSize, - )) - }, - ) - .reserve_space_for_reset(true) - .when( - theme::has_adjusted_buffer_font_size(cx), - |stepper| { - stepper.on_reset(|_, cx| { - cx.dispatch_action(Box::new( - zed_actions::ResetBufferFontSize, - )) - }) - }, - ), - ) - .into_any_element() - }) - .custom_row(move |cx| { - h_flex() - .gap_2() - .w_full() - .justify_between() - .cursor(gpui::CursorStyle::Arrow) - .child(Label::new("UI Font Size")) - .child( - NumericStepper::new( - "ui-font-size", - theme::get_ui_font_size(cx).to_string(), - |_, cx| { - cx.dispatch_action(Box::new( - zed_actions::DecreaseUiFontSize, - )) - }, - |_, cx| { - cx.dispatch_action(Box::new( - zed_actions::IncreaseUiFontSize, - )) - }, - ) - .reserve_space_for_reset(true) - .when( - theme::has_adjusted_ui_font_size(cx), - |stepper| { - stepper.on_reset(|_, cx| { - cx.dispatch_action(Box::new( - zed_actions::ResetUiFontSize, - )) - }) - }, - ), - ) - .into_any_element() - }) - .header("Project") - .action( - "Add Folder to Project...", - Box::new(workspace::AddFolderToProject), - ) - .action("Open a new Project...", Box::new(workspace::Open)) - .action( - "Open Recent Projects...", - Box::new(zed_actions::OpenRecent { - create_new_window: false, - }), - ) - .header("Help") - .action("About Zed", Box::new(zed_actions::About)) - .action("Welcome", Box::new(workspace::Welcome)) - .link( - "Documentation", - Box::new(zed_actions::OpenBrowser { - url: "https://zed.dev/docs".into(), - }), - ) - .action( - "Give Feedback", - Box::new(zed_actions::feedback::GiveFeedback), - ) - .action("Check for Updates", Box::new(auto_update::Check)) - .action("View Telemetry", Box::new(zed_actions::OpenTelemetryLog)) - .action( - "View Dependency Licenses", - Box::new(zed_actions::OpenLicenses), - ) - .separator() - .action("Quit", Box::new(zed_actions::Quit)) - }) - .into() + let is_any_deployed = self.is_any_deployed(); + div() + .flex() + .flex_row() + .gap_x_1() + .when(!is_any_deployed && !self.entries.is_empty(), |this| { + this.child(self.render_application_menu(&self.entries[0])) + }) + .when(is_any_deployed, |this| { + this.children( + self.entries + .iter() + .map(|entry| self.render_standard_menu(entry)), + ) }) - .trigger( - IconButton::new("application-menu", ui::IconName::Menu) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .when(!self.context_menu_handle.is_deployed(), |this| { - this.tooltip(|cx| Tooltip::text("Open Application Menu", cx)) - }), - ) - .with_handle(self.context_menu_handle.clone()) - .into_any_element() } } diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index a9b650c57e040d0f3b948a7a537f498bc491f8ff..799d76cef30a2ca1a4eb705a307d3b366e1363f9 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -134,10 +134,19 @@ impl Render for TitleBar { .child( h_flex() .gap_1() - .when_some(self.application_menu.clone(), |this, menu| this.child(menu)) - .children(self.render_project_host(cx)) - .child(self.render_project_name(cx)) - .children(self.render_project_branch(cx)) + .when_some(self.application_menu.clone(), |this, menu| { + let is_any_menu_deployed = menu.read(cx).is_any_deployed(); + this.child(menu).when(!is_any_menu_deployed, |this| { + this.children(self.render_project_host(cx)) + .child(self.render_project_name(cx)) + .children(self.render_project_branch(cx)) + }) + }) + .when(self.application_menu.is_none(), |this| { + this.children(self.render_project_host(cx)) + .child(self.render_project_name(cx)) + .children(self.render_project_branch(cx)) + }) .on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation()), ) .child(self.render_collaborator_list(cx)) @@ -216,7 +225,13 @@ impl TitleBar { let platform_style = PlatformStyle::platform(); let application_menu = match platform_style { - PlatformStyle::Mac => None, + PlatformStyle::Mac => { + if option_env!("ZED_USE_CROSS_PLATFORM_MENU").is_some() { + Some(cx.new_view(ApplicationMenu::new)) + } else { + None + } + } PlatformStyle::Linux | PlatformStyle::Windows => { Some(cx.new_view(ApplicationMenu::new)) } diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index 8586df57f2842938d9323fb53b95f36174fb99ca..bd4fc1d6d1d63c42f8fd94bdcef7be15235e02e3 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -38,8 +38,11 @@ pub fn app_menus() -> Vec { MenuItem::action("Extensions", zed_actions::Extensions), MenuItem::action("Install CLI", install_cli::Install), MenuItem::separator(), + #[cfg(target_os = "macos")] MenuItem::action("Hide Zed", super::Hide), + #[cfg(target_os = "macos")] MenuItem::action("Hide Others", super::HideOthers), + #[cfg(target_os = "macos")] MenuItem::action("Show All", super::ShowAll), MenuItem::action("Quit", Quit), ],