diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index ac940ceeb..7e79375f4 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -246,8 +246,8 @@ pub fn app_cancel_quit(app: AppHandle, windows: State<'_, WindowsState>) { }); } -/// Sets the menu's labels / shortcut text and updates the menu when the visible -/// items changed. Shortcuts for actions that are not on the menu must not call +/// Sets the menu's labels / accelerators and updates the menu when the visible +/// items changed. Accelerators of actions that are not on the menu must not call /// `set_menu`, which redraws the macOS menu bar. #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 70a0ba851..d2f7e1d76 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -305,7 +305,15 @@ fn window_state_flags() -> tauri_plugin_window_state::StateFlags { /// Routes IDE menu items to the focused window's frontend (`executeAction`). fn on_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) { - dispatch_menu_action(app, event.id().as_ref()); + let id = event.id().as_ref(); + // A keymap accelerator only draws the shortcut: its key reached the menu because the keymap declined it. + #[cfg(target_os = "macos")] + if !menu::fires_natively(&menu::spec(menu::Os::Mac), id) + && (macos::handling_key_press() || macos::native_modal_open()) + { + return; + } + dispatch_menu_action(app, id); } /// Runs menu item `id` as if it were chosen: in the focused window's frontend, or here without one. diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index d9205d8db..af1052f8a 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -172,3 +172,20 @@ unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: S } static APP: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Whether AppKit is handling a key press right now. A menu item performed at this point +/// was chosen by its key equivalent: a click, arrow keys plus Return and accessibility +/// presses all run with a non-key current event. +pub fn handling_key_press() -> bool { + let Some(mtm) = MainThreadMarker::new() else { return false }; + objc2_app_kit::NSApplication::sharedApplication(mtm) + .currentEvent() + .is_some_and(|event| event.r#type() == objc2_app_kit::NSEventType::KeyDown) +} + +/// Native dialogs block the workbench even when their menu event no longer carries a key press. +pub fn native_modal_open() -> bool { + let Some(mtm) = MainThreadMarker::new() else { return false }; + let application = objc2_app_kit::NSApplication::sharedApplication(mtm); + application.modalWindow().is_some() || application.windows().iter().any(|window| window.attachedSheet().is_some()) +} diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 2e78951f6..6437c9ed7 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -6,16 +6,16 @@ //! frontend runs `executeAction(id)`. //! //! # Accelerators -//! A native accelerator (macOS key equivalent, Win32 accelerator table, GTK -//! accel group) is handled by the OS *before* the webview sees the key, so it -//! would bypass the JS keymap (`when` clauses, chords, user overrides). IDE -//! items therefore never get a native accelerator: their shortcut is appended -//! to the label after a tab (`Save All\t⌘S`), which Win32 renders as the -//! accelerator column and macOS/GTK as trailing text. The shortcut text comes -//! from the frontend keymap ([`MenuConfig::shortcuts`]), so the JS keymap stays -//! the single source of truth. Native accelerators are only used for keys the -//! webview never handles: the macOS clipboard/undo roles (required for text -//! inputs), Hide, Minimize, Quit and window cycling. +//! The JS keymap is the only dispatcher of IDE shortcuts (`when` clauses, chords, +//! user overrides). On macOS a focused WKWebView receives every key event before +//! the menu, and the menu performs a key equivalent only when the page did not +//! handle the key. IDE items therefore carry the native accelerator of their first +//! keymap binding ([`MenuConfig::accelerators`]) so the menu draws the shortcut in +//! its own column, and a menu event that such a key equivalent fires is dropped +//! ([`fires_natively`]): the keymap has already declined that key. Bindings with no +//! native form (chords, double modifiers) show no shortcut, as in IDEA. Items with +//! a spec accelerator fire natively for keys the keymap never binds: Quit and +//! window cycling, next to the macOS clipboard/undo roles that text inputs need. use parking_lot::Mutex; use serde::{Deserialize, Serialize}; @@ -138,9 +138,10 @@ pub enum Entry { Action { id: &'static str, label: &'static str, - /// Native accelerator (Tauri syntax). Only for keys the webview never handles. + /// Native accelerator (Tauri syntax) that fires natively ([`fires_natively`]) and wins + /// over the keymap's. Only for keys the webview never handles. accel: Option<&'static str>, - /// Shortcut text shown when the frontend keymap has none for this id. + /// Shortcut text after the label, for the Windows/Linux edit items without a keymap binding. hint: Option<&'static str>, }, Native(Native), @@ -904,15 +905,16 @@ pub fn action_ids(spec: &[Entry]) -> Vec<&'static str> { out } -/// Localised labels and displayed shortcuts, pushed by the frontend. +/// Localised labels and keymap accelerators, pushed by the frontend. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct MenuConfig { pub locale: String, /// `menu.` → label (ids of submenus, actions and `native.*` roles). pub labels: HashMap, - /// Action id → display shortcut from the JS keymap, e.g. `⇧⌘O`. - pub shortcuts: HashMap, + /// Action id → native accelerator of its first keymap binding, e.g. `Shift+Meta+O`. + /// Absent when that binding has no native form (chord, double modifier). + pub accelerators: HashMap, /// [`Entry::Dynamic`] slot → its items, already localised. #[serde(default)] pub dynamic: HashMap>, @@ -946,19 +948,21 @@ impl MenuConfig { self.dynamic.get(slot).map(Vec::as_slice).unwrap_or_default() } - /// A dynamic item's text: its label, plus the action's shortcut unless it carries an argument. - fn dynamic_text(&self, item: &DynamicItem) -> String { - match self.shortcuts.get(&item.action).filter(|s| item.arg.is_none() && !s.is_empty()) { - Some(shortcut) => format!("{}\t{shortcut}", item.label), - None => item.label.clone(), - } + /// The keymap accelerator shown on the item of action `id`. + fn accelerator(&self, id: &str) -> Option<&str> { + self.accelerators.get(id).map(String::as_str).filter(|a| !a.is_empty()) } - /// Item text with the keymap shortcut appended after a tab (display only). + /// A dynamic item's accelerator: the action's, unless the item carries an argument. + fn dynamic_accelerator(&self, item: &DynamicItem) -> Option<&str> { + item.arg.is_none().then(|| self.accelerator(&item.action)).flatten() + } + + /// Item text: the label, with the spec's hint after a tab (Windows/Linux edit items). pub fn item_text(&self, id: &str, default: &str, hint: Option<&str>) -> String { let label = self.label(id, default); - match self.shortcuts.get(id).map(String::as_str).filter(|s| !s.is_empty()).or(hint) { - Some(shortcut) => format!("{label}\t{shortcut}"), + match hint { + Some(hint) => format!("{label}\t{hint}"), None => label, } } @@ -969,7 +973,7 @@ pub struct MenuState { pub config: Mutex, /// Action ids the focused window reports as disabled (no context / not registered). pub disabled: Mutex>, - /// Visible menu last installed. Shortcuts for actions that are not on the menu + /// Visible menu last installed. Accelerators of actions that are not on the menu /// are not part of this, so they must not redraw the macOS menu bar. rendered: Mutex>>, } @@ -985,6 +989,18 @@ pub fn store_config(state: &MenuState, next: MenuConfig) -> bool { true } +/// Whether the item `id` performs its own key equivalent: only items with a spec accelerator. +/// A keymap accelerator is drawn for display, and its key reached the menu because the +/// keymap declined it, so an event it fires is dropped. +#[cfg(any(target_os = "macos", test))] +pub fn fires_natively(spec: &[Entry], id: &str) -> bool { + spec.iter().any(|entry| match entry { + Entry::Submenu { children, .. } => fires_natively(children, id), + Entry::Action { id: action, accel, .. } => *action == id && accel.is_some(), + _ => false, + }) +} + /// The action an item id stands for, if it is an IDE action item. fn item_action(id: &str) -> Option<&str> { if id.starts_with(RECENT_PREFIX) || id == "recent.none" || id.starts_with("submenu:") { @@ -1052,7 +1068,7 @@ struct RenderedMenu { items: Vec, } -/// A visible menu row. Unused keymap shortcuts are intentionally absent. +/// A visible menu row. Unused keymap accelerators are intentionally absent. #[derive(Debug, Clone, PartialEq, Eq)] enum RenderedItem { Submenu { @@ -1060,12 +1076,11 @@ enum RenderedItem { title: String, items: Vec, }, - /// `accel` is set only for items that own a native accelerator; their shortcut - /// is then not copied from [`MenuConfig::shortcuts`]. + /// `accel` is the spec's accelerator, or else the keymap's from [`MenuConfig::accelerators`]. Action { id: String, text: String, - accel: Option<&'static str>, + accel: Option, }, /// Always disabled (`No Recent Projects`). Placeholder { @@ -1087,7 +1102,7 @@ fn prepared_spec(app: &AppHandle, os: Os) -> Vec { entries } -/// Visible items for `entries`. Shortcuts that no item displays do not affect the result. +/// Visible items for `entries`. Accelerators that no item displays do not affect the result. fn render_bar(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) -> Vec { entries .iter() @@ -1109,16 +1124,18 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) - title: config.label(id, label), items: render_items(children, config, recent), }), - Entry::Action { id, label, accel, hint } => { - let text = match accel { - Some(_) => config.label(id, label), - None => config.item_text(id, label, *hint), - }; - out.push(RenderedItem::Action { id: (*id).into(), text, accel: *accel }); - } + Entry::Action { id, label, accel, hint } => out.push(RenderedItem::Action { + id: (*id).into(), + text: config.item_text(id, label, *hint), + accel: accel.or_else(|| config.accelerator(id)).map(Into::into), + }), Entry::Dynamic(slot) => { for item in config.dynamic_items(slot) { - out.push(RenderedItem::Action { id: item.item_id(), text: config.dynamic_text(item), accel: None }); + out.push(RenderedItem::Action { + id: item.item_id(), + text: item.label.clone(), + accel: config.dynamic_accelerator(item).map(Into::into), + }); } } Entry::Native(kind) => { @@ -1143,13 +1160,13 @@ fn render_items(entries: &[Entry], config: &MenuConfig, recent: &[RecentItem]) - out } -fn structure_key(item: &RenderedItem) -> (u8, &str, Option<&str>) { +fn structure_key(item: &RenderedItem) -> (u8, &str) { match item { - RenderedItem::Submenu { id, .. } => (0, *id, None), - RenderedItem::Action { id, accel, .. } => (1, id.as_str(), *accel), - RenderedItem::Placeholder { id, .. } => (2, id.as_str(), None), - RenderedItem::Native { kind, .. } => (3, kind.key(), None), - RenderedItem::Separator => (4, "", None), + RenderedItem::Submenu { id, .. } => (0, *id), + RenderedItem::Action { id, .. } => (1, id.as_str()), + RenderedItem::Placeholder { id, .. } => (2, id.as_str()), + RenderedItem::Native { kind, .. } => (3, kind.key()), + RenderedItem::Separator => (4, ""), } } @@ -1208,7 +1225,7 @@ fn append_rendered( } RenderedItem::Action { id, text, accel } => { let enabled = item_enabled(id, disabled); - parent.append(&MenuItem::with_id(app, id, text, enabled, *accel)?)?; + parent.append(&MenuItem::with_id(app, id, text, enabled, accel.as_deref())?)?; } RenderedItem::Placeholder { id, text } => { parent.append(&MenuItem::with_id(app, id, text, false, None::<&str>)?)?; @@ -1322,14 +1339,25 @@ fn sync_item( } ( MenuItemKind::MenuItem(item), - _, - RenderedItem::Action { text, .. } | RenderedItem::Placeholder { text, .. }, - ) => item.set_text(text).is_ok(), + RenderedItem::Action { text: previous_text, accel: previous_accel, .. }, + RenderedItem::Action { text, accel, .. }, + ) => { + accelerator_patchable(previous_accel.as_deref(), accel.as_deref()) + && (previous_text == text || item.set_text(text).is_ok()) + && (previous_accel == accel || item.set_accelerator(accel.as_deref()).is_ok()) + } + (MenuItemKind::MenuItem(item), _, RenderedItem::Placeholder { text, .. }) => item.set_text(text).is_ok(), (MenuItemKind::Predefined(item), _, RenderedItem::Native { text, .. }) => item.set_text(text).is_ok(), _ => false, } } +/// Whether an item's accelerator can change in place. On macOS muda's `set_accelerator(None)` +/// keeps the old key equivalent, so an item that loses its accelerator is built again. +fn accelerator_patchable(previous: Option<&str>, next: Option<&str>) -> bool { + previous == next || next.is_some() +} + fn patch_menu( app: &AppHandle, menu: &Menu, @@ -1553,9 +1581,9 @@ mod tests { let base = MenuConfig::default(); let mut extra = base.clone(); // Registered by a large project's language actions, but not a menu item. - extra.shortcuts.insert("java.compile".into(), "⌘K".into()); - // Shown by the native accelerator, not by the keymap text. - extra.shortcuts.insert("window.next".into(), "⌘`".into()); + extra.accelerators.insert("java.compile".into(), "Meta+K".into()); + // The spec's accelerator wins over the keymap's. + extra.accelerators.insert("window.next".into(), "Meta+Alt+N".into()); assert_eq!(render_bar(&entries, &base, &[]), render_bar(&entries, &extra, &[])); } @@ -1564,7 +1592,7 @@ mod tests { let entries = spec(Os::Mac); let base = MenuConfig::default(); let mut with_maven = base.clone(); - with_maven.shortcuts.insert("toolwindow.maven".into(), "⌘9".into()); + with_maven.accelerators.insert("toolwindow.maven".into(), "Meta+9".into()); assert_eq!(render_bar(&entries, &base, &[]), render_bar(&entries, &with_maven, &[])); with_maven.dynamic.insert(DYNAMIC_TOOL_WINDOWS.into(), vec![dynamic_item("toolwindow.maven", None, "Maven")]); assert_eq!(changed_menus(&render_bar(&entries, &base, &[]), &render_bar(&entries, &with_maven, &[])), ["view"]); @@ -1585,9 +1613,12 @@ mod tests { fn visible_label_or_shortcut_changes_only_its_menu() { let entries = spec(Os::Mac); let base = MenuConfig::default(); - let mut shortcuts = base.clone(); - shortcuts.shortcuts.insert("file.save".into(), "⌘S".into()); - assert_eq!(changed_menus(&render_bar(&entries, &base, &[]), &render_bar(&entries, &shortcuts, &[])), ["file"]); + let mut accelerators = base.clone(); + accelerators.accelerators.insert("file.save".into(), "Meta+S".into()); + assert_eq!( + changed_menus(&render_bar(&entries, &base, &[]), &render_bar(&entries, &accelerators, &[])), + ["file"] + ); let mut labels = base.clone(); labels.labels.insert("menu.view".into(), "视图".into()); @@ -1698,16 +1729,64 @@ mod tests { } #[test] - fn labels_and_shortcuts_come_from_config() { + fn labels_come_from_config() { let mut config = MenuConfig::default(); assert_eq!(config.item_text("file.save", "Save All", None), "Save All"); assert_eq!(config.item_text("edit.copy", "Copy", Some("Ctrl+C")), "Copy\tCtrl+C"); config.labels.insert("menu.file.save".into(), "全部保存".into()); - config.shortcuts.insert("file.save".into(), "⌘S".into()); - assert_eq!(config.item_text("file.save", "Save All", None), "全部保存\t⌘S"); + assert_eq!(config.item_text("file.save", "Save All", None), "全部保存"); assert_eq!(config.label("file", "File"), "File"); } + fn rendered_action<'a>(items: &'a [RenderedItem], id: &str) -> Option<&'a RenderedItem> { + items.iter().find_map(|item| match item { + RenderedItem::Submenu { items, .. } => rendered_action(items, id), + RenderedItem::Action { id: item_id, .. } if item_id == id => Some(item), + _ => None, + }) + } + + /// The macOS menu draws a key equivalent in its own column; text after a tab is drawn + /// right after the label instead. + #[test] + fn keymap_shortcuts_are_native_accelerators_not_label_text() { + let mut config = MenuConfig::default(); + config.accelerators.insert("file.save".into(), "Meta+S".into()); + config.accelerators.insert("app.quit".into(), "Meta+Alt+Q".into()); + let bar = render_bar(&spec(Os::Mac), &config, &[]); + let items: Vec = bar.into_iter().flat_map(|menu| menu.items).collect(); + let item = |id| rendered_action(&items, id).cloned(); + assert_eq!( + item("file.save"), + Some(RenderedItem::Action { + id: "file.save".into(), + text: "Save All".into(), + accel: Some("Meta+S".into()) + }) + ); + assert!(matches!(item("app.quit"), Some(RenderedItem::Action { accel: Some(a), .. }) if a == "CmdOrCtrl+Q")); + assert!( + matches!(item("navigate.searchEverywhere"), Some(RenderedItem::Action { accel: None, text, .. }) if !text.contains('\t')) + ); + } + + #[test] + fn an_item_that_loses_its_accelerator_is_built_again() { + assert!(!accelerator_patchable(Some("Meta+L"), None)); + assert!(accelerator_patchable(Some("Meta+L"), Some("Ctrl+Alt+G"))); + assert!(accelerator_patchable(None, Some("Meta+L"))); + assert!(accelerator_patchable(None, None)); + } + + #[test] + fn only_spec_accelerators_fire_natively() { + let spec = spec(Os::Mac); + assert!(fires_natively(&spec, "app.quit")); + assert!(fires_natively(&spec, "window.next")); + assert!(!fires_natively(&spec, "file.save")); + assert!(!fires_natively(&spec, "dyn:toolwindow.project")); + } + #[test] fn without_action_drops_item_and_dangling_separators() { for os in ALL_OS { @@ -1844,9 +1923,9 @@ mod tests { DYNAMIC_TOOL_WINDOWS.into(), vec![dynamic_item("toolwindow.project", None, "Project"), dynamic_item("toolwindow.git", None, "Git")], ); - config.shortcuts.insert("toolwindow.project".into(), "⌘1".into()); - assert_eq!(config.dynamic_text(&config.dynamic[DYNAMIC_TOOL_WINDOWS][0]), "Project\t⌘1"); - assert_eq!(config.dynamic_text(&dynamic_item("toolwindow.project", Some("x"), "X")), "X"); + config.accelerators.insert("toolwindow.project".into(), "Meta+1".into()); + assert_eq!(config.dynamic_accelerator(&config.dynamic[DYNAMIC_TOOL_WINDOWS][0]), Some("Meta+1")); + assert_eq!(config.dynamic_accelerator(&dynamic_item("toolwindow.project", Some("x"), "X")), None); let spec = vec![sub("toolWindows", "Tool Windows", vec![Entry::Dynamic(DYNAMIC_TOOL_WINDOWS), SEP])]; let MenuNode::Submenu { children, .. } = &model(&spec, &[], &config)[0] else { panic!("not a submenu") }; assert!( diff --git a/src/bindings.ts b/src/bindings.ts index be0c013cc..5000d6847 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -973,8 +973,8 @@ export const commands = { /** A window vetoed the quit (user cancelled its unsaved-changes prompt). */ appCancelQuit: () => __TAURI_INVOKE("app_cancel_quit"), /** - * Sets the menu's labels / shortcut text and updates the menu when the visible - * items changed. Shortcuts for actions that are not on the menu must not call + * Sets the menu's labels / accelerators and updates the menu when the visible + * items changed. Accelerators of actions that are not on the menu must not call * `set_menu`, which redraws the macOS menu bar. */ menuConfigure: (config: MenuConfig) => __TAURI_INVOKE("menu_configure", { config }), @@ -5663,13 +5663,16 @@ export type MemoryCategory = /** Debug adapters, Run processes, terminal shells and other children. */ "process"; -/** Localised labels and displayed shortcuts, pushed by the frontend. */ +/** Localised labels and keymap accelerators, pushed by the frontend. */ export type MenuConfig = { locale: string, /** `menu.` → label (ids of submenus, actions and `native.*` roles). */ labels: { [key in string]: string }, - /** Action id → display shortcut from the JS keymap, e.g. `⇧⌘O`. */ - shortcuts: { [key in string]: string }, + /** + * Action id → native accelerator of its first keymap binding, e.g. `Shift+Meta+O`. + * Absent when that binding has no native form (chord, double modifier). + */ + accelerators: { [key in string]: string }, /** [`Entry::Dynamic`] slot → its items, already localised. */ dynamic?: { [key in string]: DynamicItem[] }, }; diff --git a/src/features/app/__tests__/menuBridge.test.ts b/src/features/app/__tests__/menuBridge.test.ts index c4530f332..cb0330c11 100644 --- a/src/features/app/__tests__/menuBridge.test.ts +++ b/src/features/app/__tests__/menuBridge.test.ts @@ -2,20 +2,57 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { registerAction } from "@/platform/actions"; import { _resetI18nForTests, setLocale } from "@/platform/i18n"; import { useKeymap } from "@/platform/keymap"; +import * as ipc from "@/platform/ipc"; import { resetDistractionFree, setDistractionFree } from "@/workbench/distractionFree"; import { registerToolWindow } from "@/workbench/contributions"; import { _reloadLayouts, saveLayout } from "@/workbench/toolwindows/layouts"; -import { setContext } from "@/platform/context"; +import { clearContext, setContext } from "@/platform/context"; import { buildMenuConfig, disabledMenuActions, dynamicMenuItems, + installMenuBridge, + menuAccelerator, menuActionIds, - menuShortcut, routeMenuEvent, } from "../menuBridge"; +vi.mock("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ listen: async () => () => {} }), +})); + describe("menu state from the frontend", () => { + it("refreshes the focused window's enablement even if context changes during the debounce", async () => { + vi.useFakeTimers(); + vi.spyOn(ipc, "isTauri").mockReturnValue(true); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(ipc.commands, "appBuildInfo").mockRejectedValue(new Error("No build info in tests")); + vi.spyOn(ipc.commands, "menuConfigure").mockResolvedValue(); + vi.spyOn(ipc.commands, "menuModel").mockResolvedValue([ + { kind: "action", id: "test.menuFocus", label: "Test", hint: null, arg: null }, + ]); + const pushDisabled = vi.spyOn(ipc.commands, "menuSetDisabled").mockResolvedValue(); + const offAction = registerAction({ id: "test.menuFocus", title: "Test", run: () => {} }); + const dispose = installMenuBridge(); + try { + await vi.advanceTimersByTimeAsync(120); + expect(pushDisabled).toHaveBeenCalledWith([]); + pushDisabled.mockClear(); + + window.dispatchEvent(new Event("focus")); + setContext("testMenuFocusNoise", true); + await vi.advanceTimersByTimeAsync(120); + + expect(pushDisabled).toHaveBeenCalledExactlyOnceWith([]); + } finally { + dispose(); + offAction(); + clearContext("testMenuFocusNoise"); + vi.useRealTimers(); + vi.restoreAllMocks(); + } + }); + it("lists registered tool windows by title and saved layouts", () => { localStorage.clear(); _reloadLayouts(); @@ -98,17 +135,16 @@ describe("menu event routing", () => { }); describe("menu config", () => { - it("carries keymap shortcuts as display text", () => { + it("carries keymap shortcuts as native accelerators", () => { const mac = buildMenuConfig("macos"); - expect(mac.shortcuts["file.save"]).toBe("⌘S"); - expect(mac.shortcuts["navigate.gotoFile"]).toBe("⇧⌘O"); - expect(mac.shortcuts["navigate.searchEverywhere"]).toBe("⇧⇧"); - expect(buildMenuConfig("windows").shortcuts["navigate.gotoFile"]).toBe("Shift+Meta+O"); + expect(mac.accelerators["file.save"]).toBe("Meta+S"); + expect(mac.accelerators["navigate.gotoFile"]).toBe("Shift+Meta+O"); + expect(mac.accelerators["navigate.searchEverywhere"]).toBeUndefined(); }); it("follows user keymap overrides", () => { useKeymap.setState({ overrides: { "file.save": ["Ctrl+Alt+S"] } }); - expect(buildMenuConfig("macos").shortcuts["file.save"]).toBe("⌃⌥S"); + expect(buildMenuConfig("macos").accelerators["file.save"]).toBe("Ctrl+Alt+S"); }); it("sends localised labels with platform-specific variants", async () => { @@ -122,16 +158,49 @@ describe("menu config", () => { expect(zh.labels["menu.file.save"]).toBe("全部保存"); }); - it("formats chords", () => { - expect(menuShortcut("Meta+Alt+N Meta+Alt+N", "linux")).toBe("Alt+Meta+N, Alt+Meta+N"); - expect(menuShortcut("Shift Shift", "windows")).toBe("Shift, Shift"); + it("gives keys a native menu can show as they are", () => { + for (const binding of [ + "Meta+[", + "Shift+Meta+Backspace", + "Meta+Delete", + "Meta+F12", + "Alt+F1", + "Ctrl+Shift+Down", + "Alt+Enter", + "Escape", + "Meta+,", + "Alt+Meta+PageDown", + "Meta+NumpadAdd", + "Meta+NumpadMultiply", + "F13", + "Shift+F24", + "Meta+`", + "Meta+\\", + "Meta+'", + "Meta+;", + ]) + expect(menuAccelerator(binding)).toBe(binding); + expect(menuAccelerator("shift+meta+o")).toBe("Shift+Meta+O"); + }); + + it("leaves out bindings a native menu cannot show", () => { + for (const binding of [ + "Shift Shift", + "Ctrl Ctrl", + "Alt Alt+Up", + "Meta+Alt+N Meta+Alt+N", + "Meta+NumpadMultiply 1", + "Meta+Button1", + "SwipeLeft", + ]) + expect(menuAccelerator(binding)).toBeUndefined(); }); }); describe("window-scoped menu labels", () => { it("flips Enter / Exit Distraction Free Mode with the window's mode", () => { expect(buildMenuConfig("macos").labels["menu.editor.toggleZen"]).toBe("Enter Distraction Free Mode"); - expect(buildMenuConfig("macos").shortcuts["editor.toggleZen"]).toBeUndefined(); + expect(buildMenuConfig("macos").accelerators["editor.toggleZen"]).toBeUndefined(); setDistractionFree(true); expect(buildMenuConfig("macos").labels["menu.editor.toggleZen"]).toBe("Exit Distraction Free Mode"); resetDistractionFree(); diff --git a/src/features/app/menuBridge.ts b/src/features/app/menuBridge.ts index 9f1b54c46..6434f461c 100644 --- a/src/features/app/menuBridge.ts +++ b/src/features/app/menuBridge.ts @@ -4,14 +4,21 @@ * The backend emits `openidea://menu` `{ action, arg }` to the focused window when * an IDE menu item is clicked; we run `executeAction(action, arg)`. In the * other direction we push localised labels (`menu.*` catalog keys) and the - * shortcut text of every keymap binding, so the menu shows the JS keymap's - * shortcuts without registering native accelerators (see src-tauri/src/menu.rs). + * native accelerator of every keymap binding, which the macOS menu shows as a key + * equivalent while the JS keymap stays the only dispatcher (see src-tauri/src/menu.rs). */ import { executeActionFrom, getAction, isActionEnabled, useActions } from "@/platform/actions"; import { useContextKeys } from "@/platform/context"; import { keysWithPrefix, t, tx, useI18n } from "@/platform/i18n"; import { commands, isTauri, type DynamicItem, type MenuConfig, type MenuNode } from "@/platform/ipc"; -import { effectiveBindings, formatBinding, primaryKeyBinding, useKeymap } from "@/platform/keymap"; +import { + effectiveBindings, + isMouseBinding, + normalizeBinding, + parseDoubleModifier, + primaryKeyBinding, + useKeymap, +} from "@/platform/keymap"; import { useProject } from "@/platform/project"; import { useContributions, useMenuSlotItems } from "@/workbench/contributions"; import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree"; @@ -38,9 +45,22 @@ export function routeMenuEvent( return run(payload.action, payload.arg ?? undefined); } -/** Shortcut text for menus: macOS symbols, `Ctrl+Shift+O` style elsewhere. */ -export function menuShortcut(binding: string, os: HostOs): string { - return formatBinding(binding, os === "macos"); +/** + * Keymap key names a macOS key equivalent can show. Mirrors the names muda's accelerator + * parser accepts: Tauri drops an accelerator it cannot parse, and the item shows no shortcut. + */ +const NATIVE_KEY = + /^([A-Z0-9]|F([1-9]|1[0-9]|2[0-4])|[[\]`\\,./;'=-]|Space|Enter|Escape|Tab|Backspace|Delete|Up|Down|Left|Right|Home|End|PageUp|PageDown|Numpad(Add|Subtract|Multiply|Divide))$/; + +/** + * The binding as a native menu accelerator (`Shift+Meta+O`), or undefined when a menu + * cannot show it: chords, double modifiers, mouse buttons and gestures, as in IDEA. + */ +export function menuAccelerator(binding: string): string | undefined { + if (isMouseBinding(binding) || parseDoubleModifier(binding)) return undefined; + const stroke = normalizeBinding(binding); + if (stroke.includes(" ")) return undefined; + return NATIVE_KEY.test(stroke.split("+").pop()!) ? stroke : undefined; } export function buildMenuConfig(os: HostOs): MenuConfig { @@ -48,13 +68,16 @@ export function buildMenuConfig(os: HostOs): MenuConfig { // Per-window state some labels follow (View › Appearance › Enter / Exit Distraction Free Mode). const params = { os, distractionFree: isDistractionFree() ? "on" : "off" }; for (const key of keysWithPrefix("menu.")) labels[key] = t(key, params); - const shortcuts: Record = {}; + const accelerators: Record = {}; + const seen = new Set(); for (const [, id] of effectiveBindings()) { - if (id in shortcuts) continue; + if (seen.has(id)) continue; + seen.add(id); const first = primaryKeyBinding(id); - if (first) shortcuts[id] = menuShortcut(first, os); + const accelerator = first && menuAccelerator(first); + if (accelerator) accelerators[id] = accelerator; } - return { locale: useI18n.getState().locale, labels, shortcuts, dynamic: dynamicMenuItems() }; + return { locale: useI18n.getState().locale, labels, accelerators, dynamic: dynamicMenuItems() }; } /** View › Tool Windows (every registered window, by title) and Window › Tool Window Layouts. */ @@ -121,15 +144,18 @@ export function installMenuBridge(): () => void { // what cannot run). Recomputed shortly after context changes, sent only when it changed. let ids: Set | null = null; let lastDisabled = ""; + let forceEnablement = false; let enableTimer: ReturnType | undefined; const pushEnablement = (force = false) => { + forceEnablement ||= force; clearTimeout(enableTimer); enableTimer = setTimeout(() => { if (!ids || disposed || !document.hasFocus()) return; const dynamic = Object.values(dynamicMenuItems()).flatMap((items) => items.map((d) => d.action)); const disabled = disabledMenuActions(new Set([...ids, ...dynamic])); const key = disabled.join(","); - if (!force && key === lastDisabled) return; + if (!forceEnablement && key === lastDisabled) return; + forceEnablement = false; lastDisabled = key; void commands.menuSetDisabled(disabled); }, 120);