Motivation
An app that resolves keyboard shortcuts itself cannot let the OS run a menu item's accelerator, because the OS decides on the item's enabled state alone.
Our app resolves every shortcut in the webview against a context expression, the way VS Code does: a binding fires only when its when clause holds. A menu item's enabled state is deliberately broader, because the item should stay clickable in situations where the key should not fire.
A concrete case. Edit > Clear is bound to Delete. It is enabled whenever the grid is writable and not mid-edit, but the keybinding also requires the grid itself to hold focus, because Delete belongs to the text caret when focus is in a text input. Put the caret in our Find field and press Delete: the app would delete a character, but the OS fires the menu item and clears grid cells.
Neither knob on the item can express this. Narrowing the enabled state also makes the item unclickable. Dropping the accelerator also drops the shortcut's display, since on macOS the key equivalent is both the binding and the label.
Proposal
Tell the handler that an activation came from the accelerator rather than a click. The app can then run the command for a click, and for an accelerator hand the chord to its own resolver.
// today
MenuEvent { id: MenuId }
// proposed
MenuEvent { id: MenuId, triggered_by_accelerator: bool }
Menus stay completely static: nothing has to be added or removed as app state changes, and the shortcut is always displayed.
Prior art
Electron's KeyboardEvent structure carries triggeredByAccelerator, "whether an accelerator was used to trigger the event as opposed to another user gesture like mouse click", with no platform annotation.
VS Code is built on it (vs/platform/menubar/electron-main/menubar.ts):
const click = (menuItem, window, event) => {
const userSettingsLabel = menuItem ? menuItem.userSettingsLabel : null;
if (userSettingsLabel && event.triggeredByAccelerator) {
this.runActionInRenderer({ type: 'keybinding', userSettingsLabel });
} else {
this.runActionInRenderer({ type: 'commandId', commandId });
}
};
The accelerator branch replays the chord into the renderer, where normal keybinding resolution runs. This has been in production on macOS for years.
Implementation notes
I have not written this, so treat it as a starting point.
- macOS:
NSApp.currentEvent at action time distinguishes a keyDown from a mouse event.
- Windows:
WM_COMMAND already carries it. HIWORD(wParam) is 1 for an accelerator, 0 for a menu selection.
- GTK: I have not traced this backend.
Adding a field to MenuEvent breaks construction outside the crate; a getter on an opaque struct would avoid that.
Alternatives considered
register_accelerator: bool, matching Electron's MenuItem.registerAccelerator. Electron documents it as Linux and Windows only, and macOS looks genuinely harder: menuHasKeyEquivalent:forEvent:target:action: answers for a whole NSMenu, so suppressing one item means matching the rest by hand.
- Menu open/close events, letting the app suppress the accelerator while the menu is shut and restore it for display while open. The macOS side would be small since
MudaMenuDelegate is already installed on every NSMenu, but it makes every accelerator dynamic and pushes the state management into the app.
Happy to open a PR if you would take one, and to hear if you would shape the API differently.
Motivation
An app that resolves keyboard shortcuts itself cannot let the OS run a menu item's accelerator, because the OS decides on the item's enabled state alone.
Our app resolves every shortcut in the webview against a context expression, the way VS Code does: a binding fires only when its
whenclause holds. A menu item's enabled state is deliberately broader, because the item should stay clickable in situations where the key should not fire.A concrete case.
Edit > Clearis bound toDelete. It is enabled whenever the grid is writable and not mid-edit, but the keybinding also requires the grid itself to hold focus, becauseDeletebelongs to the text caret when focus is in a text input. Put the caret in our Find field and pressDelete: the app would delete a character, but the OS fires the menu item and clears grid cells.Neither knob on the item can express this. Narrowing the enabled state also makes the item unclickable. Dropping the accelerator also drops the shortcut's display, since on macOS the key equivalent is both the binding and the label.
Proposal
Tell the handler that an activation came from the accelerator rather than a click. The app can then run the command for a click, and for an accelerator hand the chord to its own resolver.
Menus stay completely static: nothing has to be added or removed as app state changes, and the shortcut is always displayed.
Prior art
Electron's
KeyboardEventstructure carriestriggeredByAccelerator, "whether an accelerator was used to trigger the event as opposed to another user gesture like mouse click", with no platform annotation.VS Code is built on it (
vs/platform/menubar/electron-main/menubar.ts):The accelerator branch replays the chord into the renderer, where normal keybinding resolution runs. This has been in production on macOS for years.
Implementation notes
I have not written this, so treat it as a starting point.
NSApp.currentEventat action time distinguishes akeyDownfrom a mouse event.WM_COMMANDalready carries it.HIWORD(wParam)is1for an accelerator,0for a menu selection.Adding a field to
MenuEventbreaks construction outside the crate; a getter on an opaque struct would avoid that.Alternatives considered
register_accelerator: bool, matching Electron'sMenuItem.registerAccelerator. Electron documents it as Linux and Windows only, and macOS looks genuinely harder:menuHasKeyEquivalent:forEvent:target:action:answers for a wholeNSMenu, so suppressing one item means matching the rest by hand.MudaMenuDelegateis already installed on everyNSMenu, but it makes every accelerator dynamic and pushes the state management into the app.Happy to open a PR if you would take one, and to hear if you would shape the API differently.