fix(menu): draw macOS menu shortcuts in the native shortcut column (#2086)

On macOS the menu bar drew each IDE item's shortcut as text after its label, since only Windows renders that tab as a column. IDE items now carry the shortcut of their first keymap binding as a native key equivalent, so it appears in the shortcut column; chords and double modifiers show none. The JS keymap still dispatches every shortcut: a menu event fired by a key equivalent is dropped, because that key reaches the menu only after the keymap declined it. IDE items also do not run while a native sheet or modal is open. A forced enablement refresh is no longer lost when a context update reschedules it.

Co-authored-by: lleviathe <[email protected]>
Цей коміт міститься в:
lleviathe authored and GitHub committed 2026-11-09 01:27:34 +08:00
1 джерело 25cd480c30
коміт 32f5ef20a0
7 files changed
+294 -92

No files matched your search

+2 -2
Переглянути файл
@@ -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]
+9 -1
Переглянути файл
@@ -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.
+17
Переглянути файл
@@ -172,3 +172,20 @@ unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: S
}
static APP: std::sync::OnceLock<AppHandle> = 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())
}
+140 -61
Переглянути файл
@@ -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.<id>` → label (ids of submenus, actions and `native.*` roles).
pub labels: HashMap<String, String>,
/// Action id → display shortcut from the JS keymap, e.g. `⇧⌘O`.
pub shortcuts: HashMap<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).
pub accelerators: HashMap<String, String>,
/// [`Entry::Dynamic`] slot → its items, already localised.
#[serde(default)]
pub dynamic: HashMap<String, Vec<DynamicItem>>,
@@ -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<MenuConfig>,
/// Action ids the focused window reports as disabled (no context / not registered).
pub disabled: Mutex<HashSet<String>>,
/// 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<Option<Vec<RenderedMenu>>>,
}
@@ -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<RenderedItem>,
}
/// 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<RenderedItem>,
},
/// `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<String>,
},
/// Always disabled (`No Recent Projects`).
Placeholder {
@@ -1087,7 +1102,7 @@ fn prepared_spec<R: Runtime>(app: &AppHandle<R>, os: Os) -> Vec<Entry> {
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<RenderedMenu> {
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<R: Runtime>(
}
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<R: Runtime>(
}
(
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<R: Runtime>(
app: &AppHandle<R>,
menu: &Menu<R>,
@@ -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<RenderedItem> = 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!(
+8 -5
Переглянути файл
@@ -973,8 +973,8 @@ export const commands = {
/** A window vetoed the quit (user cancelled its unsaved-changes prompt). */
appCancelQuit: () => __TAURI_INVOKE<void>("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<void>("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.<id>` → 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[] },
};
+81 -12
Переглянути файл
@@ -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();
+37 -11
Переглянути файл
@@ -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<string, string> = {};
const accelerators: Record<string, string> = {};
const seen = new Set<string>();
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<string> | null = null;
let lastDisabled = "";
let forceEnablement = false;
let enableTimer: ReturnType<typeof setTimeout> | 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);