Skip to content

Report whether a menu event came from the accelerator #393

Description

@kohii

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions