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:
parent
298b9df589
commit
f7a7866d4a
3 changed files with 189 additions and 135 deletions
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue