Browse Source

Fix #3993: register main-menu gestures as window key bindings

NativeMenuItem.Gesture is display-only when NativeMenuBar renders the
menu inline on Windows/Linux: the managed fallback binds it to
MenuItem.InputGesture, which never handles input. Only macOS's system
menu bar actually executes its key equivalents, so Ctrl+O, Ctrl+S and
F5 showed in the menu but did nothing. The Avalonia docs call this out
explicitly: InputGesture only displays the text and must be paired with
a KeyBinding for the shortcut to function.

Assisted-by: Claude:claude-fable-5:Claude Code
pull/4013/head
Christoph Wille 1 month ago
parent
commit
e82e3ba68c
  1. 42
      ILSpy.Tests/MainWindow/MainMenuTests.cs
  2. 34
      ILSpy/Views/MainMenu.axaml.cs

42
ILSpy.Tests/MainWindow/MainMenuTests.cs

@ -95,6 +95,48 @@ public class MainMenuTests @@ -95,6 +95,48 @@ public class MainMenuTests
openItem.Gesture!.Should().Be(expected);
}
// NativeMenuItem.Gesture is display-only when NativeMenuBar renders the menu inline
// (the managed fallback binds it to MenuItem.InputGesture, which never handles input),
// so every menu gesture must also be registered as a window-level KeyBinding or the
// shortcut silently does nothing on Windows / Linux (issue #3993: Ctrl+O, Ctrl+S, F5).
[AvaloniaTest]
public void Menu_Gestures_Are_Registered_As_Window_KeyBindings()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var nativeMenu = NativeMenu.GetMenu(window)
?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");
var gestureItems = new System.Collections.Generic.List<(string Path, NativeMenuItem Item)>();
CollectItemsWithGesture(nativeMenu, parentPath: "", gestureItems);
gestureItems.Should().NotBeEmpty("File > Open (Ctrl+O), Reload (F5) and Save (Ctrl+S) declare InputGestureText");
foreach (var (path, item) in gestureItems)
{
window.KeyBindings.Should().Contain(
kb => Equals(kb.Gesture, item.Gesture) && ReferenceEquals(kb.Command, item.Command),
$"the gesture {item.Gesture} shown on '{path}' must actually invoke the item's command");
}
}
static void CollectItemsWithGesture(NativeMenu menu, string parentPath, System.Collections.Generic.List<(string, NativeMenuItem)> result)
{
foreach (var element in menu.Items)
{
if (element is NativeMenuItemSeparator || element is not NativeMenuItem item)
continue;
var path = string.IsNullOrEmpty(parentPath) ? (item.Header ?? "<unnamed>") : $"{parentPath} > {item.Header}";
// Mirrors RegisterGestureKeyBindings: only items with BOTH a gesture and a command
// get a key binding, so a display-only gesture must not fail the assertion.
if (item.Gesture != null && item.Command != null)
result.Add((path, item));
if (item.Menu is { Items.Count: > 0 } sub)
CollectItemsWithGesture(sub, path, result);
}
}
// Avalonia's macOS NativeMenu bridge maps NativeMenuItem to NSMenuItem and sets
// NSMenuItem.action ONLY when Command != null. Without it, NSMenuValidation marks
// the item disabled (greyed out) and no click ever reaches managed code - which

34
ILSpy/Views/MainMenu.axaml.cs

@ -76,9 +76,43 @@ public static class MainMenu @@ -76,9 +76,43 @@ public static class MainMenu
PromoteHelpToMacAppMenu(menu, topLevelByTag);
}
RegisterGestureKeyBindings(window, menu);
NativeMenu.SetMenu(window, menu);
}
// NativeMenuItem.Gesture is display-only when NativeMenuBar renders the menu inline:
// the managed fallback binds it to MenuItem.InputGesture, which never handles input.
// So each gesture is registered as a window-level KeyBinding too - that is what makes
// Ctrl+O / Ctrl+S / F5 actually fire on Windows and Linux. On macOS the system menu
// bar consumes its key equivalents before they reach the window, so these bindings
// stay dormant there. Runs after TranslateGesturesForMacOS so the bound gesture always
// matches the one the menu displays. Duplicates of the KeyBindings declared in
// MainWindow.axaml (e.g. Alt+Left) are harmless: KeyboardDevice stops dispatching
// once a binding marks the event handled, so only the first match executes.
static void RegisterGestureKeyBindings(Window window, NativeMenu menu)
{
foreach (var element in menu.Items)
{
if (element is NativeMenuItemSeparator || element is not NativeMenuItem item)
continue;
if (item.Gesture is { } gesture && item.Command is { } command)
{
var keyBinding = new KeyBinding {
Gesture = gesture,
Command = command,
};
// CommandParameter must track the item's property, not snapshot it: for
// IProvideParameterBinding commands the item's parameter is itself a binding
// that only resolves once the menu lives in a visual tree, so a value copied
// here would be null when the shortcut fires.
keyBinding.Bind(KeyBinding.CommandParameterProperty, item.GetObservable(NativeMenuItem.CommandParameterProperty));
window.KeyBindings.Add(keyBinding);
}
if (item.Menu != null)
RegisterGestureKeyBindings(window, item.Menu);
}
}
// macOS convention puts About / Check for Updates under the bold app-named menu
// next to the Apple logo, not under a separate "Help" top-level. The Cocoa exporter
// samples Application.Current's NativeMenu ONCE at startup (before this window exists)

Loading…
Cancel
Save