linux: Implement Menus (#21873)

tims and Mikayla created

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 <mikayla@zed.dev>

Change summary

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(-)

Detailed changes

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<ContextMenu>,
+}
 
 pub struct ApplicationMenu {
-    context_menu_handle: PopoverMenuHandle<ContextMenu>,
+    entries: SmallVec<[MenuEntry; 8]>,
 }
 
 impl ApplicationMenu {
-    pub fn new(_: &mut ViewContext<Self>) -> Self {
+    pub fn new(cx: &mut ViewContext<Self>) -> 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<OwnedMenuItem>) -> Vec<OwnedMenuItem> {
+        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> {
+        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<Self>) -> 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()
     }
 }

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))
             }

crates/zed/src/zed/app_menus.rs 🔗

@@ -38,8 +38,11 @@ pub fn app_menus() -> Vec<Menu> {
                 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),
             ],