From e82e3ba68cb3f450dd3013aec7b2c5a4599b3125 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Fri, 14 Aug 2026 13:42:12 +0200 Subject: [PATCH] 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 --- ILSpy.Tests/MainWindow/MainMenuTests.cs | 42 +++++++++++++++++++++++++ ILSpy/Views/MainMenu.axaml.cs | 34 ++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/ILSpy.Tests/MainWindow/MainMenuTests.cs b/ILSpy.Tests/MainWindow/MainMenuTests.cs index 393381414..009516885 100644 --- a/ILSpy.Tests/MainWindow/MainMenuTests.cs +++ b/ILSpy.Tests/MainWindow/MainMenuTests.cs @@ -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(); + 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 ?? "") : $"{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 diff --git a/ILSpy/Views/MainMenu.axaml.cs b/ILSpy/Views/MainMenu.axaml.cs index f230be65b..1c6786c0b 100644 --- a/ILSpy/Views/MainMenu.axaml.cs +++ b/ILSpy/Views/MainMenu.axaml.cs @@ -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)