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 <mikayla@zed.dev>
This commit is contained in:
tims 2024-12-19 03:57:25 +05:30 committed by GitHub
parent 298b9df589
commit f7a7866d4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 189 additions and 135 deletions

View file

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

View file

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