Closes #19837 This PR is a continuation of [linux: Implement Menus](https://github.com/zed-industries/zed/pull/21873) and should only be reviewed once the existing PR is merged. I created this as a separate PR as the existing PR was already reviewed but is yet to merge, and also it was my initial plan to do it in separate parts because of the scope of it. This will also help reviewing code faster. This PR adds two new types of keyboard shortcuts to make menu navigation easier: 1. `Alt + Z` for Zed, `Alt + F` for File, `Alt + S` for Selection, and so on to open a specific menu with this combination. This mimics VSCode and IntelliJ. 2. `Arrow Left/Right` when any menu is open. This will trigger the current menu to close, and the previous/next to open respectively. First and last element cycling is handled. `Arrow Up/Down` to navigate menu entries is already there in existing work. https://github.com/user-attachments/assets/976aea48-4e20-4c19-850d-4d205a4bead2 Release Notes: - Added keyboard navigation for menus on Linux (left/right). If you wish to open menus with keyboard shortcuts add the following to your user keymap: ```json { "context": "Workspace", "bindings": { "alt-z": ["app_menu::OpenApplicationMenu", "Zed"], "alt-f": ["app_menu::OpenApplicationMenu", "File"], "alt-e": ["app_menu::OpenApplicationMenu", "Edit"], "alt-s": ["app_menu::OpenApplicationMenu", "Selection"], "alt-v": ["app_menu::OpenApplicationMenu", "View"], "alt-g": ["app_menu::OpenApplicationMenu", "Go"], "alt-w": ["app_menu::OpenApplicationMenu", "Window"], "alt-h": ["app_menu::OpenApplicationMenu", "Help"] } } ``` --------- Co-authored-by: Peter Tripp <peter@zed.dev>
271 lines
9.6 KiB
Rust
271 lines
9.6 KiB
Rust
use gpui::{impl_actions, OwnedMenu, OwnedMenuItem, View};
|
|
use serde::Deserialize;
|
|
use smallvec::SmallVec;
|
|
use ui::{prelude::*, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip};
|
|
|
|
impl_actions!(
|
|
app_menu,
|
|
[OpenApplicationMenu, NavigateApplicationMenuInDirection,]
|
|
);
|
|
|
|
#[derive(Clone, Deserialize, PartialEq, Default)]
|
|
pub struct OpenApplicationMenu(String);
|
|
|
|
#[derive(Clone, Deserialize, PartialEq, Default)]
|
|
pub struct NavigateApplicationMenuInDirection(String);
|
|
|
|
#[derive(Clone)]
|
|
struct MenuEntry {
|
|
menu: OwnedMenu,
|
|
handle: PopoverMenuHandle<ContextMenu>,
|
|
}
|
|
|
|
pub struct ApplicationMenu {
|
|
entries: SmallVec<[MenuEntry; 8]>,
|
|
pending_menu_open: Option<String>,
|
|
}
|
|
|
|
impl ApplicationMenu {
|
|
pub fn new(cx: &mut ViewContext<Self>) -> Self {
|
|
let menus = cx.get_menus().unwrap_or_default();
|
|
Self {
|
|
entries: menus
|
|
.into_iter()
|
|
.map(|menu| MenuEntry {
|
|
menu,
|
|
handle: PopoverMenuHandle::default(),
|
|
})
|
|
.collect(),
|
|
pending_menu_open: None,
|
|
}
|
|
}
|
|
|
|
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| {
|
|
// Grab current focus handle so menu can shown items in context with the focused element
|
|
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
|
|
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| {
|
|
if *hover_enter && !current_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();
|
|
cx.defer(move |cx| handle.show(cx));
|
|
}
|
|
})
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn open_menu(&mut self, action: &OpenApplicationMenu, _cx: &mut ViewContext<Self>) {
|
|
self.pending_menu_open = Some(action.0.clone());
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub fn navigate_menus_in_direction(
|
|
&mut self,
|
|
action: &NavigateApplicationMenuInDirection,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
let current_index = self
|
|
.entries
|
|
.iter()
|
|
.position(|entry| entry.handle.is_deployed());
|
|
let Some(current_index) = current_index else {
|
|
return;
|
|
};
|
|
|
|
let next_index = match action.0.as_str() {
|
|
"Left" => {
|
|
if current_index == 0 {
|
|
self.entries.len() - 1
|
|
} else {
|
|
current_index - 1
|
|
}
|
|
}
|
|
"Right" => {
|
|
if current_index == self.entries.len() - 1 {
|
|
0
|
|
} else {
|
|
current_index + 1
|
|
}
|
|
}
|
|
_ => return,
|
|
};
|
|
|
|
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(move |_, cx| next_handle.show(cx));
|
|
}
|
|
|
|
pub fn all_menus_shown(&self) -> bool {
|
|
self.entries.iter().any(|entry| entry.handle.is_deployed())
|
|
|| self.pending_menu_open.is_some()
|
|
}
|
|
}
|
|
|
|
impl Render for ApplicationMenu {
|
|
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
|
let all_menus_shown = self.all_menus_shown();
|
|
|
|
if let Some(pending_menu_open) = self.pending_menu_open.take() {
|
|
if 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
|
|
cx.window_context().on_next_frame(move |cx| {
|
|
handles_to_hide.iter().for_each(|handle| handle.hide(cx));
|
|
cx.defer(move |cx| handle_to_show.show(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(move |_, cx| handle_to_show.show(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)),
|
|
)
|
|
})
|
|
}
|
|
}
|