дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
25cd480c30
коміт
32f5ef20a0
7 files changed
+294
-92
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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[] },
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Заблокувати користувача