diff --git a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs index 0c56f8552..b5ea16ee2 100644 --- a/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs +++ b/ILSpy.Tests/Docking/PreviewTabPromotionTests.cs @@ -491,33 +491,25 @@ public class PreviewTabPromotionTests } [AvaloniaTest] - public async Task File_Menu_Separators_Have_A_Visible_Background() + public async Task File_Menu_Separates_MenuCategory_Groups() { - // Regression for the "no groups in the menus" report: separators were inserted at - // MenuCategory boundaries (Open / Save / Remove / Exit in File menu, etc.) but the - // Simple-theme default rendered them nearly invisible against the menu chrome. - // App.axaml applies a Style for MenuItem Separator that gives them a visible - // brush + height. This test pins that style at the rendered-Separator level. + // Regression: the MainMenu builder must emit separators at MenuCategory boundaries + // (Open / Save / Remove / Exit groups in the File menu, ...). With the menu now living + // as a NativeMenu, separator rendering is the platform's job; this test pins only + // the structural claim that separators are inserted between groups. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); - var fileMenu = window.GetVisualDescendants().OfType() - .Single(m => (m.Tag as string) == "_File"); - fileMenu.Open(); - global::Avalonia.Threading.Dispatcher.UIThread.RunJobs(); - await Waiters.WaitForAsync(() => fileMenu.Items.Count > 0, System.TimeSpan.FromSeconds(5)); + var nativeMenu = global::Avalonia.Controls.NativeMenu.GetMenu(window) + ?? throw new System.InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window"); + var fileMenu = nativeMenu.Items.OfType() + .Single(m => string.Equals(m.Header, ICSharpCode.ILSpy.Properties.Resources._File, System.StringComparison.Ordinal)); - var separators = fileMenu.Items.OfType().ToList(); + var separators = fileMenu.Menu!.Items.OfType().ToList(); separators.Should().HaveCountGreaterThanOrEqualTo(2, "File menu's MenuCategory groups (Open / Save / Remove / Exit) must produce at least two separators"); - - foreach (var sep in separators) - { - sep.Background.Should().NotBeNull( - "the App.axaml MenuItem Separator style must set a visible Background brush"); - } } [AvaloniaTest] diff --git a/ILSpy.Tests/MainWindow/MainMenuTests.cs b/ILSpy.Tests/MainWindow/MainMenuTests.cs index 54f623d7c..a10e59699 100644 --- a/ILSpy.Tests/MainWindow/MainMenuTests.cs +++ b/ILSpy.Tests/MainWindow/MainMenuTests.cs @@ -16,13 +16,12 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System; using System.Linq; -using System.Threading.Tasks; using Avalonia.Controls; using Avalonia.Headless.NUnit; using Avalonia.Input; -using Avalonia.VisualTree; using AwesomeAssertions; @@ -45,39 +44,83 @@ public class MainMenuTests [AvaloniaTest] public void MainMenu_top_level_items_are_File_View_Window_in_order() { - var mainMenu = new global::ILSpy.MainMenu(); - var menu = mainMenu.FindControl("MainMenuRoot"); - menu.Should().NotBeNull(); + 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 headers = menu!.Items.OfType().Select(m => m.Header as string).ToList(); - headers.Should().Equal("_File", "_View", "_Window"); + var headers = nativeMenu.Items.OfType().Select(i => i.Header).ToList(); + headers.Should().Equal("_File", "_View", "_Window", "_Help"); } [AvaloniaTest] - public async Task Main_Menu_Items_Display_Input_Gestures() + public void File_Open_Carries_The_Ctrl_O_Gesture() { - // File → Open carries an InputGestureText="Ctrl+O" attribute on its - // [ExportMainMenuCommand]; verifies both the displayed gesture (right side of the menu - // item) and the actual HotKey are wired up so Ctrl+O fires from the keyboard too. + // MEF metadata's InputGestureText="Ctrl+O" on File -> Open must flow through + // MainMenu.Attach into NativeMenuItem.Gesture. On macOS Avalonia projects this + // into the system menu bar; on Windows / Linux NativeMenuBar renders inline. - // Arrange — boot MainWindow and wait for the File menu to be populated by MEF. var window = AppComposition.Current.GetExport(); window.Show(); - var menu = await window.WaitForComponent(); - await Waiters.WaitForAsync(() => - menu.Items.OfType().Any(m => (string?)m.Tag == nameof(Resources._File)) - && menu.Items.OfType().Single(m => (string?)m.Tag == nameof(Resources._File)) - .Items.OfType().Any()); - - // Act — locate the Open MenuItem under File. - var fileMenu = menu.Items.OfType().Single(m => (string?)m.Tag == nameof(Resources._File)); - var openItem = fileMenu.Items.OfType() - .Single(m => string.Equals(m.Header as string, Resources._Open, System.StringComparison.Ordinal)); - - // Assert — both display gesture and hot-key bind to Ctrl+O. - openItem.InputGesture.Should().NotBeNull(); - openItem.InputGesture!.Should().Be(KeyGesture.Parse("Ctrl+O")); - openItem.HotKey.Should().Be(KeyGesture.Parse("Ctrl+O")); + var nativeMenu = NativeMenu.GetMenu(window) + ?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window"); + + var fileMenu = nativeMenu.Items.OfType() + .Single(m => string.Equals(m.Header, Resources._File, StringComparison.Ordinal)); + var openItem = fileMenu.Menu!.Items.OfType() + .Single(m => string.Equals(m.Header, Resources._Open, StringComparison.Ordinal)); + + openItem.Gesture.Should().NotBeNull(); + openItem.Gesture!.Should().Be(KeyGesture.Parse("Ctrl+O")); + } + + // 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 + // means any IsChecked TwoWay binding silently never fires either. So every leaf + // NativeMenuItem (one that doesn't open a submenu) must have Command set. + [AvaloniaTest] + public void Every_Leaf_NativeMenuItem_Has_A_Command_So_macOS_Clicks_Through() + { + 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 leavesMissingCommand = new System.Collections.Generic.List(); + CollectLeavesMissingCommand(nativeMenu, parentPath: "", leavesMissingCommand); + + leavesMissingCommand.Should().BeEmpty( + "every leaf NativeMenuItem must set Command, otherwise on macOS the item is " + + "disabled by NSMenuValidation and clicks never reach Avalonia. Sites historically " + + "missing this: MakeRadio (ApiVis radios), ToolPaneMenuItem checkboxes, and " + + "TabPageMenuItem radios in AppendWindowDynamicContent / AppendTabSection."); + } + + static void CollectLeavesMissingCommand(NativeMenu menu, string parentPath, System.Collections.Generic.List missing) + { + foreach (var element in menu.Items) + { + // NativeMenuItemSeparator inherits from NativeMenuItem in Avalonia 12 (its + // Header defaults to "-"), so the type filter has to exclude separators + // explicitly - otherwise they look like leaves-without-Command and trip + // the assertion. + if (element is NativeMenuItemSeparator) + continue; + if (element is not NativeMenuItem item) + continue; + var path = string.IsNullOrEmpty(parentPath) ? (item.Header ?? "") : $"{parentPath} > {item.Header}"; + if (item.Menu is { Items.Count: > 0 } sub) + { + CollectLeavesMissingCommand(sub, path, missing); + } + else if (item.Command == null) + { + missing.Add(path); + } + } } } diff --git a/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs b/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs index df956d323..56a6c3473 100644 --- a/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs +++ b/ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs @@ -16,6 +16,7 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System; using System.Linq; using System.Threading.Tasks; @@ -42,12 +43,12 @@ public class WindowMenuOpenDocumentsTests [AvaloniaTest] public async Task Window_Menu_Lists_Every_Open_Document_Tab() { - // The Window menu must surface one entry per open document tab so the user can - // jump between tabs from the keyboard. Mirrors WPF's MainMenu.InitWindowMenu - // tab-section (which composed `dockWorkspace.TabPages` into the menu's ItemsSource). + // The Window menu surfaces one entry per open document tab so the user can jump + // between tabs from the keyboard. The entries are NativeMenuItems whose Header is + // bound to TabPageMenuItem.Title; this test reads the NativeMenu source-of-truth + // (NativeMenuBar's visual presenter renders these on Windows / Linux, and the + // system menu bar projects them on macOS). - // Arrange — boot the window, wait for assemblies, then select a node so the persistent - // MainTab carries a meaningful title. var window = AppComposition.Current.GetExport(); window.Show(); var vm = (MainWindowViewModel)window.DataContext!; @@ -57,31 +58,27 @@ public class WindowMenuOpenDocumentsTests vm.AssemblyTreeModel.SelectNode(firstAsm); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - // Open a second tab via the production "open in new tab" path. var secondAsm = vm.AssemblyTreeModel.FindNode("System.Private.Uri"); vm.DockWorkspace.OpenNodeInNewTab(secondAsm); await vm.DockWorkspace.WaitForDecompiledTextAsync(); - var menu = await window.WaitForComponent(); - var windowMenu = menu.Items.OfType() - .Single(m => (string?)m.Tag == nameof(Resources._Window)); + var nativeMenu = NativeMenu.GetMenu(window) + ?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window"); + var windowMenu = nativeMenu.Items.OfType() + .Single(m => string.Equals(m.Header, Resources._Window, StringComparison.Ordinal)); - // Wait for the menu to be populated — the tabs section is built lazily on Loaded / - // when the collection changes. Match on the second tab's runtime Title rather than the - // tree node's text so the assertion tracks whatever ContentTabPage actually surfaces. var documentsDock = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!; await Waiters.WaitForAsync( () => documentsDock.VisibleDockables!.OfType().All(t => !string.IsNullOrEmpty(t.Title)) && documentsDock.VisibleDockables!.OfType() .Select(t => t.Title) - .All(title => windowMenu.Items.OfType().Any(mi => string.Equals(mi.Header as string, title)))); + .All(title => windowMenu.Menu!.Items.OfType().Any(mi => string.Equals(mi.Header, title)))); - // Assert — both tabs are listed by their Title in the Window menu. var tabTitles = documentsDock.VisibleDockables!.OfType() .Select(t => t.Title) .ToList(); - var menuTitles = windowMenu.Items.OfType() - .Select(mi => mi.Header as string) + var menuTitles = windowMenu.Menu!.Items.OfType() + .Select(mi => mi.Header) .ToList(); tabTitles.Should().HaveCount(2); diff --git a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs index bc8230fb9..a95fc7d75 100644 --- a/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs +++ b/ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs @@ -92,15 +92,13 @@ public class BrowseBackForwardCommandTests var secondMethod = typeNode.Children.OfType() .First(m => m.MethodDefinition.Name == "Empty"); - // Locate the View > Back menu item. - var menu = await window.WaitForComponent(); - var viewMenu = menu.Items.OfType() - .Single(m => (string?)m.Tag == nameof(Resources._View)); - // expand the View menu so its child items are realised. - viewMenu.Open(); - await Waiters.WaitForAsync(() => viewMenu.Items.OfType().Any()); - var backItem = viewMenu.Items.OfType() - .Single(m => string.Equals(m.Header as string, Resources.Back, System.StringComparison.Ordinal)); + // Locate the View > Back NativeMenuItem. + var nativeMenu = NativeMenu.GetMenu(window) + ?? throw new System.InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window"); + var viewMenu = nativeMenu.Items.OfType() + .Single(m => string.Equals(m.Header, Resources._View, System.StringComparison.Ordinal)); + var backItem = viewMenu.Menu!.Items.OfType() + .Single(m => string.Equals(m.Header, Resources.Back, System.StringComparison.Ordinal)); // Initially nothing on the stack — back must be disabled. backItem.Command!.CanExecute(null).Should().BeFalse( @@ -124,30 +122,24 @@ public class BrowseBackForwardCommandTests } [AvaloniaTest] - public async Task BrowseBack_MenuItem_Carries_The_Alt_Left_Hot_Key_And_Gesture() + public void BrowseBack_MenuItem_Carries_The_Alt_Left_Gesture() { - // MEF metadata's InputGestureText must be picked up by the menu builder and applied to - // both InputGesture (displayed text) and HotKey (window-wide accelerator). + // MEF metadata's InputGestureText flows through MainMenu.Attach into the + // NativeMenuItem.Gesture property -- this is what NativeMenuBar renders inline on + // Windows / Linux and what macOS projects into the system menu bar. - // Arrange — boot, locate the View menu, wait for both nav items to materialise. var window = AppComposition.Current.GetExport(); window.Show(); - var menu = await window.WaitForComponent(); - var viewMenu = menu.Items.OfType() - .Single(m => (string?)m.Tag == nameof(Resources._View)); - viewMenu.Open(); - await Waiters.WaitForAsync(() => - viewMenu.Items.OfType().Any(m => (string?)m.Header == Resources.Back) - && viewMenu.Items.OfType().Any(m => (string?)m.Header == Resources.Forward)); - var backItem = viewMenu.Items.OfType() - .Single(m => string.Equals(m.Header as string, Resources.Back, System.StringComparison.Ordinal)); - var forwardItem = viewMenu.Items.OfType() - .Single(m => string.Equals(m.Header as string, Resources.Forward, System.StringComparison.Ordinal)); - - // Assert — Alt+Left / Alt+Right round-trip through both InputGesture and HotKey. - backItem.InputGesture.Should().Be(KeyGesture.Parse("Alt+Left")); - backItem.HotKey.Should().Be(KeyGesture.Parse("Alt+Left")); - forwardItem.InputGesture.Should().Be(KeyGesture.Parse("Alt+Right")); - forwardItem.HotKey.Should().Be(KeyGesture.Parse("Alt+Right")); + var nativeMenu = NativeMenu.GetMenu(window) + ?? throw new System.InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window"); + var viewMenu = nativeMenu.Items.OfType() + .Single(m => string.Equals(m.Header, Resources._View, System.StringComparison.Ordinal)); + var backItem = viewMenu.Menu!.Items.OfType() + .Single(m => string.Equals(m.Header, Resources.Back, System.StringComparison.Ordinal)); + var forwardItem = viewMenu.Menu!.Items.OfType() + .Single(m => string.Equals(m.Header, Resources.Forward, System.StringComparison.Ordinal)); + + backItem.Gesture.Should().Be(KeyGesture.Parse("Alt+Left")); + forwardItem.Gesture.Should().Be(KeyGesture.Parse("Alt+Right")); } } diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml index 41a20a06a..8146cdd88 100644 --- a/ILSpy/App.axaml +++ b/ILSpy/App.axaml @@ -1,6 +1,7 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/ILSpy/Views/MainMenu.axaml.cs b/ILSpy/Views/MainMenu.axaml.cs index 8ca7be51f..22222d5c9 100644 --- a/ILSpy/Views/MainMenu.axaml.cs +++ b/ILSpy/Views/MainMenu.axaml.cs @@ -16,6 +16,7 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +using System; using System.Collections.Generic; using System.Composition; using System.Linq; @@ -25,6 +26,10 @@ using global::Avalonia.Controls; using global::Avalonia.Data; using global::Avalonia.Input; +using CommunityToolkit.Mvvm.Input; + +using ICSharpCode.ILSpy.Properties; + using ILSpy.AppEnv; using ILSpy.Commands; using ILSpy.Docking; @@ -33,219 +38,264 @@ using ILSpy.ViewModels; namespace ILSpy; -public partial class MainMenu : UserControl +/// +/// Builds the application's main menu as a NativeMenu and attaches it to a window. +/// A NativeMenuBar control rendered inside the window decides at runtime whether to +/// draw the menu inline (Windows / Linux) or hand it off to the macOS system menu bar. +/// +public static class MainMenu { - bool initialized; - - public MainMenu() + public static void Attach(Window window) { - InitializeComponent(); + ArgumentNullException.ThrowIfNull(window); - // Tests / design-time previews don't bootstrap the composition host; bail out so the - // XAML can still render (the static View entries don't need DI). - if (!TryGetSettingsService(out var settings)) + // Tests and design-time previews don't bootstrap the composition host. Without it + // we have nothing to populate the menu with, so bail and let the empty NativeMenuBar + // in the XAML render as a thin spacer. + if (!TryGetExports(out var settings, out var registry, out var dockWorkspace, out var setThemeCommand)) return; - DataContext = settings.SessionSettings; - Loaded += (_, _) => InitializeMenus(); - } + var menu = new NativeMenu(); + var topLevelByTag = new Dictionary(StringComparer.Ordinal); - void InitializeMenus() - { - if (initialized) - return; - initialized = true; + // Pre-build the three known top-level slots so MEF commands with ParentMenuID set + // to "_File" / "_View" / "_Window" attach to them. Additional top-level groups + // can still be created later from MEF metadata. + AddTopLevel(menu, topLevelByTag, "_File", nameof(Resources._File)); + var viewItem = AddTopLevel(menu, topLevelByTag, "_View", nameof(Resources._View)); + var windowItem = AddTopLevel(menu, topLevelByTag, "_Window", nameof(Resources._Window)); + + PopulateViewMenu(viewItem.Menu!, settings.SessionSettings, setThemeCommand); + AppendRegistryCommands(menu, topLevelByTag, registry.Commands); + AppendWindowDynamicContent(windowItem.Menu!, dockWorkspace); - var registry = AppComposition.Current.GetExport(); - var dockWorkspace = AppComposition.Current.GetExport(); - var setThemeCommand = AppComposition.Current.GetExport(); + if (OperatingSystem.IsMacOS()) + TranslateGesturesForMacOS(menu); - PopulateThemeSubmenu(setThemeCommand); - InitMainMenu(MainMenuRoot, registry.Commands); - InitWindowMenu(WindowMenuItem, dockWorkspace); + NativeMenu.SetMenu(window, menu); } - static bool TryGetSettingsService(out SettingsService settings) + static bool TryGetExports( + out SettingsService settings, + out MainMenuCommandRegistry registry, + out DockWorkspace dockWorkspace, + out SetThemeCommand setThemeCommand) { try { settings = AppComposition.Current.GetExport(); - return settings != null; + registry = AppComposition.Current.GetExport(); + dockWorkspace = AppComposition.Current.GetExport(); + setThemeCommand = AppComposition.Current.GetExport(); + return true; } catch { settings = null!; + registry = null!; + dockWorkspace = null!; + setThemeCommand = null!; return false; } } - void PopulateThemeSubmenu(SetThemeCommand setThemeCommand) + static NativeMenuItem AddTopLevel(NativeMenu root, Dictionary byTag, string menuId, string resourceKey) + { + var item = new NativeMenuItem { + Header = ResourceHelper.GetString(resourceKey), + Menu = new NativeMenu(), + }; + byTag[menuId] = item; + root.Items.Add(item); + return item; + } + + static void PopulateViewMenu(NativeMenu view, SessionSettings session, SetThemeCommand setThemeCommand) { - var current = (SessionSettings)DataContext!; - var items = new List(); - foreach (var theme in ThemeManager.AllThemes) + var languageSettings = session.LanguageSettings; + + view.Items.Add(MakeRadio(Resources.Show_publiconlyTypesMembers, languageSettings, nameof(LanguageSettings.ApiVisPublicOnly))); + view.Items.Add(MakeRadio(Resources.Show_internalTypesMembers, languageSettings, nameof(LanguageSettings.ApiVisPublicAndInternal))); + view.Items.Add(MakeRadio(Resources.Show_allTypesAndMembers, languageSettings, nameof(LanguageSettings.ApiVisAll))); + + view.Items.Add(new NativeMenuItemSeparator()); + + var theme = new NativeMenuItem { + Header = Resources.Theme, + Menu = new NativeMenu(), + }; + PopulateThemeSubmenu(theme.Menu!, session, setThemeCommand); + view.Items.Add(theme); + } + + static NativeMenuItem MakeRadio(string header, object source, string path) + { + var item = new NativeMenuItem { + Header = header, + ToggleType = MenuItemToggleType.Radio, + }; + // IsChecked is purely for *displaying* the checkmark (OneWay). The write + // path lives in Command, because Avalonia's macOS NativeMenu bridge maps + // NativeMenuItem -> NSMenuItem only when Command != null; without it the + // item gets greyed out by NSMenuValidation and no click ever reaches the + // IsChecked binding. Mutual exclusion across sibling radios is handled by + // the bound property's setter. + item.Bind(NativeMenuItem.IsCheckedProperty, new Binding(path) { + Source = source, + Mode = BindingMode.OneWay, + }); + var property = source.GetType().GetProperty(path); + item.Command = new RelayCommand(() => property?.SetValue(source, true)); + return item; + } + + static void PopulateThemeSubmenu(NativeMenu theme, SessionSettings session, SetThemeCommand setThemeCommand) + { + foreach (var themeName in ThemeManager.AllThemes) { - var item = new MenuItem { - Header = theme, + var item = new NativeMenuItem { + Header = themeName, Command = setThemeCommand, - CommandParameter = theme, + CommandParameter = themeName, ToggleType = MenuItemToggleType.Radio, }; - // Track the active theme via a one-way binding on Theme; setting from the menu - // flows back through CommandParameter, not through IsChecked. - item.Bind(MenuItem.IsCheckedProperty, new Binding(nameof(SessionSettings.Theme)) { - Source = current, + item.Bind(NativeMenuItem.IsCheckedProperty, new Binding(nameof(SessionSettings.Theme)) { + Source = session, Converter = ThemeEqualityConverter.Instance, - ConverterParameter = theme, + ConverterParameter = themeName, Mode = BindingMode.OneWay, }); - items.Add(item); + theme.Items.Add(item); } - ThemeMenuItem.ItemsSource = items; } - static void InitMainMenu(Menu mainMenu, IReadOnlyList> mainMenuCommands) + static void AppendRegistryCommands( + NativeMenu rootMenu, + Dictionary topLevelByTag, + IReadOnlyList> commands) { - var parentMenuItems = new Dictionary(); - var menuGroups = mainMenuCommands + var menuGroups = commands .OrderBy(c => c.Metadata?.MenuOrder) .GroupBy(c => c.Metadata?.ParentMenuID ?? string.Empty) .ToArray(); - foreach (var menu in menuGroups) + foreach (var group in menuGroups) { - var parentMenuItem = GetOrAddParentMenuItem(menu.Key, menu.Key); - foreach (var category in menu.GroupBy(c => c.Metadata?.MenuCategory)) + var parentItem = GetOrAddParentItem(group.Key, group.Key); + var parentMenu = parentItem.Menu!; + + foreach (var category in group.GroupBy(c => c.Metadata?.MenuCategory)) { - if (parentMenuItem.Items.Count > 0) - parentMenuItem.Items.Add(new Separator { Tag = category.Key }); + if (parentMenu.Items.Count > 0) + parentMenu.Items.Add(new NativeMenuItemSeparator()); foreach (var entry in category) { var entryMenuId = entry.Metadata?.MenuID; if (entryMenuId != null && menuGroups.Any(g => g.Key == entryMenuId)) { - // This entry's contract is the parent of another group — surface it as a submenu. - var nested = GetOrAddParentMenuItem(entryMenuId, entry.Metadata?.Header); + // This entry is itself the parent of another group; surface it as a submenu. + var nested = GetOrAddParentItem(entryMenuId, entry.Metadata?.Header); nested.Header = ResourceHelper.GetString(entry.Metadata?.Header); - parentMenuItem.Items.Add(nested); + parentMenu.Items.Add(nested); } else { var command = entry.CreateExport().Value; - - var menuItem = new MenuItem { - Command = command, - Tag = entry.Metadata?.MenuID, + var menuItem = new NativeMenuItem { Header = ResourceHelper.GetString(entry.Metadata?.Header), + Command = command, IsEnabled = entry.Metadata?.IsEnabled ?? true, }; - // Wire up the keyboard accelerator if the export declared one. HotKey - // registers the window-scoped shortcut; InputGesture is what actually - // renders the gesture text on the right side of the menu item — both - // are needed for "Ctrl+O" to appear AND fire from the keyboard. if (TryParseGesture(entry.Metadata?.InputGestureText, out var gesture)) - { - menuItem.HotKey = gesture; - menuItem.InputGesture = gesture; - } + menuItem.Gesture = gesture; if (command is IProvideParameterBinding parameterBinding) - menuItem.Bind(MenuItem.CommandParameterProperty, parameterBinding.ParameterBinding); + menuItem.Bind(NativeMenuItem.CommandParameterProperty, parameterBinding.ParameterBinding); - parentMenuItem.Items.Add(menuItem); + parentMenu.Items.Add(menuItem); } } } } - // Attach any newly-created top-level menus (those not already declared in XAML). - foreach (var item in parentMenuItems.Values.Where(item => item.Parent == null)) - mainMenu.Items.Add(item); - - MenuItem GetOrAddParentMenuItem(string menuId, string? resourceKey) + NativeMenuItem GetOrAddParentItem(string menuId, string? resourceKey) { - if (!parentMenuItems.TryGetValue(menuId, out var parentMenuItem)) + if (!topLevelByTag.TryGetValue(menuId, out var existing)) { - var topLevelMenuItem = mainMenu.Items.OfType().FirstOrDefault(m => (string?)m.Tag == menuId); - if (topLevelMenuItem == null) - { - parentMenuItem = new MenuItem { - Header = ResourceHelper.GetString(resourceKey), - Tag = menuId, - }; - parentMenuItems.Add(menuId, parentMenuItem); - } - else - { - parentMenuItems.Add(menuId, topLevelMenuItem); - parentMenuItem = topLevelMenuItem; - } + existing = new NativeMenuItem { + Header = ResourceHelper.GetString(resourceKey), + Menu = new NativeMenu(), + }; + topLevelByTag[menuId] = existing; + rootMenu.Items.Add(existing); } - return parentMenuItem; + return existing; } } - static void InitWindowMenu(MenuItem windowMenuItem, DockWorkspace dockWorkspace) + static void AppendWindowDynamicContent(NativeMenu windowMenu, DockWorkspace dockWorkspace) { - // At this point InitMainMenu has already appended any MEF-driven Window-menu commands - // (CloseAllDocuments / ResetLayout). Append tool-pane toggles after a separator so - // they sit at the bottom of the Window menu. + // Tool-pane toggles sit below any MEF-driven Window commands (CloseAllDocuments / ResetLayout). if (dockWorkspace.ToolPaneMenuItems.Count > 0) { - if (windowMenuItem.Items.Count > 0) - windowMenuItem.Items.Add(new Separator()); + if (windowMenu.Items.Count > 0) + windowMenu.Items.Add(new NativeMenuItemSeparator()); foreach (var pane in dockWorkspace.ToolPaneMenuItems) { - var item = new MenuItem { + var item = new NativeMenuItem { Header = pane.Title, ToggleType = MenuItemToggleType.CheckBox, - DataContext = pane, }; - item.Bind(MenuItem.IsCheckedProperty, new Binding(nameof(ToolPaneMenuItem.IsPaneVisible)) { - Mode = BindingMode.TwoWay, + // OneWay binding + Command — see MakeRadio for why TwoWay alone + // doesn't drive macOS clickability. + item.Bind(NativeMenuItem.IsCheckedProperty, new Binding(nameof(ToolPaneMenuItem.IsPaneVisible)) { + Source = pane, + Mode = BindingMode.OneWay, }); - windowMenuItem.Items.Add(item); + var capturedPane = pane; + item.Command = new RelayCommand(() => capturedPane.IsPaneVisible = !capturedPane.IsPaneVisible); + windowMenu.Items.Add(item); } } - // Tab section: one MenuItem per open document. Separator only if other items already - // exist so we don't lead with a stray rule when both prior blocks were empty. - AppendTabSection(windowMenuItem, dockWorkspace); + AppendTabSection(windowMenu, dockWorkspace); } - static void AppendTabSection(MenuItem windowMenuItem, DockWorkspace dockWorkspace) + static void AppendTabSection(NativeMenu windowMenu, DockWorkspace dockWorkspace) { var tabItems = dockWorkspace.TabPageMenuItems; - Separator? separator = null; - var perItem = new Dictionary(); + NativeMenuItemSeparator? separator = null; + var perItem = new Dictionary(); void EnsureSeparator() { if (separator != null) return; - if (windowMenuItem.Items.Count == 0) + if (windowMenu.Items.Count == 0) return; - separator = new Separator(); - windowMenuItem.Items.Add(separator); + separator = new NativeMenuItemSeparator(); + windowMenu.Items.Add(separator); } - MenuItem CreateMenuItem(TabPageMenuItem vm) + NativeMenuItem CreateMenuItem(TabPageMenuItem vm) { - var item = new MenuItem { + var item = new NativeMenuItem { Header = vm.Title, ToggleType = MenuItemToggleType.Radio, - DataContext = vm, }; - // Header tracks the tab's Title (e.g. swaps from "(no selection)" → "MyType" when - // the user navigates). - item.Bind(MenuItem.HeaderProperty, new Binding(nameof(TabPageMenuItem.Title))); - // IsActive: setter activates the tab, getter mirrors the dock's ActiveDockable. - item.Bind(MenuItem.IsCheckedProperty, new Binding(nameof(TabPageMenuItem.IsActive)) { - Mode = BindingMode.TwoWay, + item.Bind(NativeMenuItem.HeaderProperty, new Binding(nameof(TabPageMenuItem.Title)) { + Source = vm, + }); + // OneWay binding + Command — see MakeRadio for why TwoWay alone + // doesn't drive macOS clickability. + item.Bind(NativeMenuItem.IsCheckedProperty, new Binding(nameof(TabPageMenuItem.IsActive)) { + Source = vm, + Mode = BindingMode.OneWay, }); + item.Command = new RelayCommand(() => vm.IsActive = true); return item; } @@ -254,19 +304,18 @@ public partial class MainMenu : UserControl EnsureSeparator(); var menuItem = CreateMenuItem(vm); perItem.Add(vm, menuItem); - windowMenuItem.Items.Add(menuItem); + windowMenu.Items.Add(menuItem); } void RemoveItem(TabPageMenuItem vm) { if (!perItem.TryGetValue(vm, out var menuItem)) return; - windowMenuItem.Items.Remove(menuItem); + windowMenu.Items.Remove(menuItem); perItem.Remove(vm); - // Drop the separator if no tabs remain — the next AddItem will re-create one. if (perItem.Count == 0 && separator != null) { - windowMenuItem.Items.Remove(separator); + windowMenu.Items.Remove(separator); separator = null; } } @@ -288,14 +337,7 @@ public partial class MainMenu : UserControl RemoveItem(vm); break; case System.Collections.Specialized.NotifyCollectionChangedAction.Reset: - foreach (var vm in perItem.Keys.ToList()) - RemoveItem(vm); - foreach (var vm in tabItems) - AddItem(vm); - break; case System.Collections.Specialized.NotifyCollectionChangedAction.Move: - // Move keeps identity; the menu's relative order matters only for the tab - // listing. Rebuild the tab subrange to keep them aligned with the tab strip. foreach (var vm in perItem.Keys.ToList()) RemoveItem(vm); foreach (var vm in tabItems) @@ -305,6 +347,27 @@ public partial class MainMenu : UserControl }; } + // macOS renders KeyModifiers.Control as the caret glyph; users expect Cmd for menu + // shortcuts. Avalonia maps Meta to Cmd on Apple platforms (and to Win on Windows / + // Linux), so swapping Control for Meta gives the right symbol on macOS without + // changing keyboard semantics elsewhere -- this code only runs on macOS. + static void TranslateGesturesForMacOS(NativeMenu menu) + { + foreach (var item in menu.Items) + { + if (item is NativeMenuItem ni) + { + if (ni.Gesture is { } g && (g.KeyModifiers & KeyModifiers.Control) != 0) + { + var modifiers = (g.KeyModifiers & ~KeyModifiers.Control) | KeyModifiers.Meta; + ni.Gesture = new KeyGesture(g.Key, modifiers); + } + if (ni.Menu != null) + TranslateGesturesForMacOS(ni.Menu); + } + } + } + static bool TryParseGesture(string? text, out KeyGesture gesture) { gesture = null!; @@ -325,17 +388,16 @@ public partial class MainMenu : UserControl { public static readonly ThemeEqualityConverter Instance = new(); - public object Convert(object? value, System.Type targetType, object? parameter, System.Globalization.CultureInfo culture) + public object Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) { var v = value as string; var p = parameter as string; - // Treat null/empty as the default theme so the default item still highlights at startup. if (string.IsNullOrEmpty(v)) v = ThemeManager.Current.DefaultTheme; - return string.Equals(v, p, System.StringComparison.OrdinalIgnoreCase); + return string.Equals(v, p, StringComparison.OrdinalIgnoreCase); } - public object? ConvertBack(object? value, System.Type targetType, object? parameter, System.Globalization.CultureInfo culture) + public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => BindingOperations.DoNothing; } } diff --git a/ILSpy/Views/MainWindow.axaml b/ILSpy/Views/MainWindow.axaml index 3ed9d08ee..758add74a 100644 --- a/ILSpy/Views/MainWindow.axaml +++ b/ILSpy/Views/MainWindow.axaml @@ -31,8 +31,10 @@ - - + + diff --git a/ILSpy/Views/MainWindow.axaml.cs b/ILSpy/Views/MainWindow.axaml.cs index 2720ee073..5930f5102 100644 --- a/ILSpy/Views/MainWindow.axaml.cs +++ b/ILSpy/Views/MainWindow.axaml.cs @@ -55,6 +55,7 @@ namespace ILSpy.Views AppLog.Mark("MainWindow XAML inflation about to start (DataContext set)"); InitializeComponent(); AppLog.Mark("MainWindow XAML inflation done"); + ILSpy.MainMenu.Attach(this); ApplySessionSettings(settingsService.SessionSettings); Opened += async (_, _) => { AppLog.Mark("MainWindow.Opened fired");