Browse Source

Project the main menu into the macOS system menu bar via NativeMenu

The MainMenu UserControl previously built a regular Avalonia Menu of
MenuItems, which on macOS would render inline in the window instead
of in the system menu bar -- not what Mac users expect. Avalonia's
NativeMenu + NativeMenuBar is the cross-platform abstraction: on
macOS the menu is projected into the system bar, on Windows / Linux
NativeMenuBar's presenter renders the same items inline. The MEF
registry, theme submenu, tool-pane toggles, and dynamic tab list all
flow through unchanged; only the leaf widget type swaps from MenuItem
to NativeMenuItem. KeyModifiers.Control is translated to Meta on
macOS so the system menu bar shows Cmd glyphs instead of Ctrl.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
408ce300d8
  1. 28
      ILSpy.Tests/Docking/PreviewTabPromotionTests.cs
  2. 97
      ILSpy.Tests/MainWindow/MainMenuTests.cs
  3. 29
      ILSpy.Tests/MainWindow/WindowMenuOpenDocumentsTests.cs
  4. 52
      ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs
  5. 1
      ILSpy/App.axaml
  6. 41
      ILSpy/Views/MainMenu.axaml
  7. 318
      ILSpy/Views/MainMenu.axaml.cs
  8. 6
      ILSpy/Views/MainWindow.axaml
  9. 1
      ILSpy/Views/MainWindow.axaml.cs

28
ILSpy.Tests/Docking/PreviewTabPromotionTests.cs

@ -491,33 +491,25 @@ public class PreviewTabPromotionTests
} }
[AvaloniaTest] [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 // Regression: the MainMenu builder must emit separators at MenuCategory boundaries
// MenuCategory boundaries (Open / Save / Remove / Exit in File menu, etc.) but the // (Open / Save / Remove / Exit groups in the File menu, ...). With the menu now living
// Simple-theme default rendered them nearly invisible against the menu chrome. // as a NativeMenu, separator rendering is the platform's job; this test pins only
// App.axaml applies a Style for MenuItem Separator that gives them a visible // the structural claim that separators are inserted between groups.
// brush + height. This test pins that style at the rendered-Separator level.
var window = AppComposition.Current.GetExport<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var fileMenu = window.GetVisualDescendants().OfType<global::Avalonia.Controls.MenuItem>() var nativeMenu = global::Avalonia.Controls.NativeMenu.GetMenu(window)
.Single(m => (m.Tag as string) == "_File"); ?? throw new System.InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");
fileMenu.Open(); var fileMenu = nativeMenu.Items.OfType<global::Avalonia.Controls.NativeMenuItem>()
global::Avalonia.Threading.Dispatcher.UIThread.RunJobs(); .Single(m => string.Equals(m.Header, ICSharpCode.ILSpy.Properties.Resources._File, System.StringComparison.Ordinal));
await Waiters.WaitForAsync(() => fileMenu.Items.Count > 0, System.TimeSpan.FromSeconds(5));
var separators = fileMenu.Items.OfType<global::Avalonia.Controls.Separator>().ToList(); var separators = fileMenu.Menu!.Items.OfType<global::Avalonia.Controls.NativeMenuItemSeparator>().ToList();
separators.Should().HaveCountGreaterThanOrEqualTo(2, separators.Should().HaveCountGreaterThanOrEqualTo(2,
"File menu's MenuCategory groups (Open / Save / Remove / Exit) must produce at least two separators"); "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] [AvaloniaTest]

97
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 // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Headless.NUnit; using Avalonia.Headless.NUnit;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.VisualTree;
using AwesomeAssertions; using AwesomeAssertions;
@ -45,39 +44,83 @@ public class MainMenuTests
[AvaloniaTest] [AvaloniaTest]
public void MainMenu_top_level_items_are_File_View_Window_in_order() public void MainMenu_top_level_items_are_File_View_Window_in_order()
{ {
var mainMenu = new global::ILSpy.MainMenu(); var window = AppComposition.Current.GetExport<MainWindow>();
var menu = mainMenu.FindControl<Menu>("MainMenuRoot"); window.Show();
menu.Should().NotBeNull();
var nativeMenu = NativeMenu.GetMenu(window)
?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");
var headers = menu!.Items.OfType<MenuItem>().Select(m => m.Header as string).ToList(); var headers = nativeMenu.Items.OfType<NativeMenuItem>().Select(i => i.Header).ToList();
headers.Should().Equal("_File", "_View", "_Window"); headers.Should().Equal("_File", "_View", "_Window", "_Help");
} }
[AvaloniaTest] [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 // MEF metadata's InputGestureText="Ctrl+O" on File -> Open must flow through
// [ExportMainMenuCommand]; verifies both the displayed gesture (right side of the menu // MainMenu.Attach into NativeMenuItem.Gesture. On macOS Avalonia projects this
// item) and the actual HotKey are wired up so Ctrl+O fires from the keyboard too. // 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<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var menu = await window.WaitForComponent<Menu>(); var nativeMenu = NativeMenu.GetMenu(window)
await Waiters.WaitForAsync(() => ?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");
menu.Items.OfType<MenuItem>().Any(m => (string?)m.Tag == nameof(Resources._File))
&& menu.Items.OfType<MenuItem>().Single(m => (string?)m.Tag == nameof(Resources._File)) var fileMenu = nativeMenu.Items.OfType<NativeMenuItem>()
.Items.OfType<MenuItem>().Any()); .Single(m => string.Equals(m.Header, Resources._File, StringComparison.Ordinal));
var openItem = fileMenu.Menu!.Items.OfType<NativeMenuItem>()
// Act — locate the Open MenuItem under File. .Single(m => string.Equals(m.Header, Resources._Open, StringComparison.Ordinal));
var fileMenu = menu.Items.OfType<MenuItem>().Single(m => (string?)m.Tag == nameof(Resources._File));
var openItem = fileMenu.Items.OfType<MenuItem>() openItem.Gesture.Should().NotBeNull();
.Single(m => string.Equals(m.Header as string, Resources._Open, System.StringComparison.Ordinal)); openItem.Gesture!.Should().Be(KeyGesture.Parse("Ctrl+O"));
}
// Assert — both display gesture and hot-key bind to Ctrl+O.
openItem.InputGesture.Should().NotBeNull(); // Avalonia's macOS NativeMenu bridge maps NativeMenuItem to NSMenuItem and sets
openItem.InputGesture!.Should().Be(KeyGesture.Parse("Ctrl+O")); // NSMenuItem.action ONLY when Command != null. Without it, NSMenuValidation marks
openItem.HotKey.Should().Be(KeyGesture.Parse("Ctrl+O")); // 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<MainWindow>();
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<string>();
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<string> 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 ?? "<unnamed>") : $"{parentPath} > {item.Header}";
if (item.Menu is { Items.Count: > 0 } sub)
{
CollectLeavesMissingCommand(sub, path, missing);
}
else if (item.Command == null)
{
missing.Add(path);
}
}
} }
} }

29
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 // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -42,12 +43,12 @@ public class WindowMenuOpenDocumentsTests
[AvaloniaTest] [AvaloniaTest]
public async Task Window_Menu_Lists_Every_Open_Document_Tab() 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 // The Window menu surfaces one entry per open document tab so the user can jump
// jump between tabs from the keyboard. Mirrors WPF's MainMenu.InitWindowMenu // between tabs from the keyboard. The entries are NativeMenuItems whose Header is
// tab-section (which composed `dockWorkspace.TabPages` into the menu's ItemsSource). // 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<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var vm = (MainWindowViewModel)window.DataContext!; var vm = (MainWindowViewModel)window.DataContext!;
@ -57,31 +58,27 @@ public class WindowMenuOpenDocumentsTests
vm.AssemblyTreeModel.SelectNode(firstAsm); vm.AssemblyTreeModel.SelectNode(firstAsm);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
// Open a second tab via the production "open in new tab" path.
var secondAsm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Private.Uri"); var secondAsm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Private.Uri");
vm.DockWorkspace.OpenNodeInNewTab(secondAsm); vm.DockWorkspace.OpenNodeInNewTab(secondAsm);
await vm.DockWorkspace.WaitForDecompiledTextAsync(); await vm.DockWorkspace.WaitForDecompiledTextAsync();
var menu = await window.WaitForComponent<Menu>(); var nativeMenu = NativeMenu.GetMenu(window)
var windowMenu = menu.Items.OfType<MenuItem>() ?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");
.Single(m => (string?)m.Tag == nameof(Resources._Window)); var windowMenu = nativeMenu.Items.OfType<NativeMenuItem>()
.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!; var documentsDock = ((ILSpyDockFactory)vm.DockWorkspace.Factory).Documents!;
await Waiters.WaitForAsync( await Waiters.WaitForAsync(
() => documentsDock.VisibleDockables!.OfType<ContentTabPage>().All(t => !string.IsNullOrEmpty(t.Title)) () => documentsDock.VisibleDockables!.OfType<ContentTabPage>().All(t => !string.IsNullOrEmpty(t.Title))
&& documentsDock.VisibleDockables!.OfType<ContentTabPage>() && documentsDock.VisibleDockables!.OfType<ContentTabPage>()
.Select(t => t.Title) .Select(t => t.Title)
.All(title => windowMenu.Items.OfType<MenuItem>().Any(mi => string.Equals(mi.Header as string, title)))); .All(title => windowMenu.Menu!.Items.OfType<NativeMenuItem>().Any(mi => string.Equals(mi.Header, title))));
// Assert — both tabs are listed by their Title in the Window menu.
var tabTitles = documentsDock.VisibleDockables!.OfType<ContentTabPage>() var tabTitles = documentsDock.VisibleDockables!.OfType<ContentTabPage>()
.Select(t => t.Title) .Select(t => t.Title)
.ToList(); .ToList();
var menuTitles = windowMenu.Items.OfType<MenuItem>() var menuTitles = windowMenu.Menu!.Items.OfType<NativeMenuItem>()
.Select(mi => mi.Header as string) .Select(mi => mi.Header)
.ToList(); .ToList();
tabTitles.Should().HaveCount(2); tabTitles.Should().HaveCount(2);

52
ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs

@ -92,15 +92,13 @@ public class BrowseBackForwardCommandTests
var secondMethod = typeNode.Children.OfType<MethodTreeNode>() var secondMethod = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty"); .First(m => m.MethodDefinition.Name == "Empty");
// Locate the View > Back menu item. // Locate the View > Back NativeMenuItem.
var menu = await window.WaitForComponent<Menu>(); var nativeMenu = NativeMenu.GetMenu(window)
var viewMenu = menu.Items.OfType<MenuItem>() ?? throw new System.InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");
.Single(m => (string?)m.Tag == nameof(Resources._View)); var viewMenu = nativeMenu.Items.OfType<NativeMenuItem>()
// expand the View menu so its child items are realised. .Single(m => string.Equals(m.Header, Resources._View, System.StringComparison.Ordinal));
viewMenu.Open(); var backItem = viewMenu.Menu!.Items.OfType<NativeMenuItem>()
await Waiters.WaitForAsync(() => viewMenu.Items.OfType<MenuItem>().Any()); .Single(m => string.Equals(m.Header, Resources.Back, System.StringComparison.Ordinal));
var backItem = viewMenu.Items.OfType<MenuItem>()
.Single(m => string.Equals(m.Header as string, Resources.Back, System.StringComparison.Ordinal));
// Initially nothing on the stack — back must be disabled. // Initially nothing on the stack — back must be disabled.
backItem.Command!.CanExecute(null).Should().BeFalse( backItem.Command!.CanExecute(null).Should().BeFalse(
@ -124,30 +122,24 @@ public class BrowseBackForwardCommandTests
} }
[AvaloniaTest] [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 // MEF metadata's InputGestureText flows through MainMenu.Attach into the
// both InputGesture (displayed text) and HotKey (window-wide accelerator). // 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<MainWindow>(); var window = AppComposition.Current.GetExport<MainWindow>();
window.Show(); window.Show();
var menu = await window.WaitForComponent<Menu>(); var nativeMenu = NativeMenu.GetMenu(window)
var viewMenu = menu.Items.OfType<MenuItem>() ?? throw new System.InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");
.Single(m => (string?)m.Tag == nameof(Resources._View)); var viewMenu = nativeMenu.Items.OfType<NativeMenuItem>()
viewMenu.Open(); .Single(m => string.Equals(m.Header, Resources._View, System.StringComparison.Ordinal));
await Waiters.WaitForAsync(() => var backItem = viewMenu.Menu!.Items.OfType<NativeMenuItem>()
viewMenu.Items.OfType<MenuItem>().Any(m => (string?)m.Header == Resources.Back) .Single(m => string.Equals(m.Header, Resources.Back, System.StringComparison.Ordinal));
&& viewMenu.Items.OfType<MenuItem>().Any(m => (string?)m.Header == Resources.Forward)); var forwardItem = viewMenu.Menu!.Items.OfType<NativeMenuItem>()
var backItem = viewMenu.Items.OfType<MenuItem>() .Single(m => string.Equals(m.Header, Resources.Forward, System.StringComparison.Ordinal));
.Single(m => string.Equals(m.Header as string, Resources.Back, System.StringComparison.Ordinal));
var forwardItem = viewMenu.Items.OfType<MenuItem>() backItem.Gesture.Should().Be(KeyGesture.Parse("Alt+Left"));
.Single(m => string.Equals(m.Header as string, Resources.Forward, System.StringComparison.Ordinal)); forwardItem.Gesture.Should().Be(KeyGesture.Parse("Alt+Right"));
// 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"));
} }
} }

1
ILSpy/App.axaml

@ -1,6 +1,7 @@
<Application xmlns="https://github.com/avaloniaui" <Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ILSpy.App" x:Class="ILSpy.App"
Name="ILSpy"
xmlns:local="using:ILSpy" xmlns:local="using:ILSpy"
xmlns:tree="using:ILSpy.AssemblyTree" xmlns:tree="using:ILSpy.AssemblyTree"
xmlns:search="using:ILSpy.Search" xmlns:search="using:ILSpy.Search"

41
ILSpy/Views/MainMenu.axaml

@ -1,41 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ILSpy.ViewModels"
xmlns:p="clr-namespace:ICSharpCode.ILSpy.Properties;assembly=ILSpy"
xmlns:local="using:ILSpy"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="25"
x:Class="ILSpy.MainMenu"
x:DataType="local:SessionSettings">
<UserControl.Styles>
<!-- Top-level menu items (File / View / Window): drop the default hover border so
hover only changes the background. -->
<Style Selector="Menu#MainMenuRoot > MenuItem /template/ Border">
<Setter Property="BorderThickness" Value="0" />
</Style>
</UserControl.Styles>
<Menu DockPanel.Dock="Top" Name="MainMenuRoot"
Height="25" Padding="0,0,0,3"
Background="#FFF0F0F0">
<MenuItem Header="{x:Static p:Resources._File}" Tag="_File">
<!-- File menu populated from MEF (see MainMenu.axaml.cs InitMainMenu) -->
</MenuItem>
<MenuItem Header="{x:Static p:Resources._View}" Tag="_View">
<MenuItem Header="{x:Static p:Resources.Show_publiconlyTypesMembers}"
ToggleType="Radio" GroupName="ApiVisibility"
IsChecked="{Binding LanguageSettings.ApiVisPublicOnly, Mode=TwoWay}" />
<MenuItem Header="{x:Static p:Resources.Show_internalTypesMembers}"
ToggleType="Radio" GroupName="ApiVisibility"
IsChecked="{Binding LanguageSettings.ApiVisPublicAndInternal, Mode=TwoWay}" />
<MenuItem Header="{x:Static p:Resources.Show_allTypesAndMembers}"
ToggleType="Radio" GroupName="ApiVisibility"
IsChecked="{Binding LanguageSettings.ApiVisAll, Mode=TwoWay}" />
<Separator />
<MenuItem Header="{x:Static p:Resources.Theme}" Name="ThemeMenuItem" />
</MenuItem>
<MenuItem Header="{x:Static p:Resources._Window}" Tag="_Window" Name="WindowMenuItem">
<!-- Window menu populated from MEF + tool panes (see MainMenu.axaml.cs InitWindowMenu) -->
</MenuItem>
</Menu>
</UserControl>

318
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 // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE. // DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Composition; using System.Composition;
using System.Linq; using System.Linq;
@ -25,6 +26,10 @@ using global::Avalonia.Controls;
using global::Avalonia.Data; using global::Avalonia.Data;
using global::Avalonia.Input; using global::Avalonia.Input;
using CommunityToolkit.Mvvm.Input;
using ICSharpCode.ILSpy.Properties;
using ILSpy.AppEnv; using ILSpy.AppEnv;
using ILSpy.Commands; using ILSpy.Commands;
using ILSpy.Docking; using ILSpy.Docking;
@ -33,219 +38,264 @@ using ILSpy.ViewModels;
namespace ILSpy; namespace ILSpy;
public partial class MainMenu : UserControl /// <summary>
/// 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.
/// </summary>
public static class MainMenu
{ {
bool initialized; public static void Attach(Window window)
public MainMenu()
{ {
InitializeComponent(); ArgumentNullException.ThrowIfNull(window);
// Tests / design-time previews don't bootstrap the composition host; bail out so the // Tests and design-time previews don't bootstrap the composition host. Without it
// XAML can still render (the static View entries don't need DI). // we have nothing to populate the menu with, so bail and let the empty NativeMenuBar
if (!TryGetSettingsService(out var settings)) // in the XAML render as a thin spacer.
if (!TryGetExports(out var settings, out var registry, out var dockWorkspace, out var setThemeCommand))
return; return;
DataContext = settings.SessionSettings; var menu = new NativeMenu();
Loaded += (_, _) => InitializeMenus(); var topLevelByTag = new Dictionary<string, NativeMenuItem>(StringComparer.Ordinal);
}
void InitializeMenus() // 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
if (initialized) // can still be created later from MEF metadata.
return; AddTopLevel(menu, topLevelByTag, "_File", nameof(Resources._File));
initialized = true; 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<MainMenuCommandRegistry>(); if (OperatingSystem.IsMacOS())
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>(); TranslateGesturesForMacOS(menu);
var setThemeCommand = AppComposition.Current.GetExport<SetThemeCommand>();
PopulateThemeSubmenu(setThemeCommand); NativeMenu.SetMenu(window, menu);
InitMainMenu(MainMenuRoot, registry.Commands);
InitWindowMenu(WindowMenuItem, dockWorkspace);
} }
static bool TryGetSettingsService(out SettingsService settings) static bool TryGetExports(
out SettingsService settings,
out MainMenuCommandRegistry registry,
out DockWorkspace dockWorkspace,
out SetThemeCommand setThemeCommand)
{ {
try try
{ {
settings = AppComposition.Current.GetExport<SettingsService>(); settings = AppComposition.Current.GetExport<SettingsService>();
return settings != null; registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
setThemeCommand = AppComposition.Current.GetExport<SetThemeCommand>();
return true;
} }
catch catch
{ {
settings = null!; settings = null!;
registry = null!;
dockWorkspace = null!;
setThemeCommand = null!;
return false; return false;
} }
} }
void PopulateThemeSubmenu(SetThemeCommand setThemeCommand) static NativeMenuItem AddTopLevel(NativeMenu root, Dictionary<string, NativeMenuItem> 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 languageSettings = session.LanguageSettings;
var items = new List<MenuItem>();
foreach (var theme in ThemeManager.AllThemes) 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 { var item = new NativeMenuItem {
Header = theme, Header = themeName,
Command = setThemeCommand, Command = setThemeCommand,
CommandParameter = theme, CommandParameter = themeName,
ToggleType = MenuItemToggleType.Radio, ToggleType = MenuItemToggleType.Radio,
}; };
// Track the active theme via a one-way binding on Theme; setting from the menu item.Bind(NativeMenuItem.IsCheckedProperty, new Binding(nameof(SessionSettings.Theme)) {
// flows back through CommandParameter, not through IsChecked. Source = session,
item.Bind(MenuItem.IsCheckedProperty, new Binding(nameof(SessionSettings.Theme)) {
Source = current,
Converter = ThemeEqualityConverter.Instance, Converter = ThemeEqualityConverter.Instance,
ConverterParameter = theme, ConverterParameter = themeName,
Mode = BindingMode.OneWay, Mode = BindingMode.OneWay,
}); });
items.Add(item); theme.Items.Add(item);
} }
ThemeMenuItem.ItemsSource = items;
} }
static void InitMainMenu(Menu mainMenu, IReadOnlyList<ExportFactory<ICommand, MainMenuCommandMetadata>> mainMenuCommands) static void AppendRegistryCommands(
NativeMenu rootMenu,
Dictionary<string, NativeMenuItem> topLevelByTag,
IReadOnlyList<ExportFactory<ICommand, MainMenuCommandMetadata>> commands)
{ {
var parentMenuItems = new Dictionary<string, MenuItem>(); var menuGroups = commands
var menuGroups = mainMenuCommands
.OrderBy(c => c.Metadata?.MenuOrder) .OrderBy(c => c.Metadata?.MenuOrder)
.GroupBy(c => c.Metadata?.ParentMenuID ?? string.Empty) .GroupBy(c => c.Metadata?.ParentMenuID ?? string.Empty)
.ToArray(); .ToArray();
foreach (var menu in menuGroups) foreach (var group in menuGroups)
{ {
var parentMenuItem = GetOrAddParentMenuItem(menu.Key, menu.Key); var parentItem = GetOrAddParentItem(group.Key, group.Key);
foreach (var category in menu.GroupBy(c => c.Metadata?.MenuCategory)) var parentMenu = parentItem.Menu!;
foreach (var category in group.GroupBy(c => c.Metadata?.MenuCategory))
{ {
if (parentMenuItem.Items.Count > 0) if (parentMenu.Items.Count > 0)
parentMenuItem.Items.Add(new Separator { Tag = category.Key }); parentMenu.Items.Add(new NativeMenuItemSeparator());
foreach (var entry in category) foreach (var entry in category)
{ {
var entryMenuId = entry.Metadata?.MenuID; var entryMenuId = entry.Metadata?.MenuID;
if (entryMenuId != null && menuGroups.Any(g => g.Key == entryMenuId)) if (entryMenuId != null && menuGroups.Any(g => g.Key == entryMenuId))
{ {
// This entry's contract is the parent of another group — surface it as a submenu. // This entry is itself the parent of another group; surface it as a submenu.
var nested = GetOrAddParentMenuItem(entryMenuId, entry.Metadata?.Header); var nested = GetOrAddParentItem(entryMenuId, entry.Metadata?.Header);
nested.Header = ResourceHelper.GetString(entry.Metadata?.Header); nested.Header = ResourceHelper.GetString(entry.Metadata?.Header);
parentMenuItem.Items.Add(nested); parentMenu.Items.Add(nested);
} }
else else
{ {
var command = entry.CreateExport().Value; var command = entry.CreateExport().Value;
var menuItem = new NativeMenuItem {
var menuItem = new MenuItem {
Command = command,
Tag = entry.Metadata?.MenuID,
Header = ResourceHelper.GetString(entry.Metadata?.Header), Header = ResourceHelper.GetString(entry.Metadata?.Header),
Command = command,
IsEnabled = entry.Metadata?.IsEnabled ?? true, 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)) if (TryParseGesture(entry.Metadata?.InputGestureText, out var gesture))
{ menuItem.Gesture = gesture;
menuItem.HotKey = gesture;
menuItem.InputGesture = gesture;
}
if (command is IProvideParameterBinding parameterBinding) 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). NativeMenuItem GetOrAddParentItem(string menuId, string? resourceKey)
foreach (var item in parentMenuItems.Values.Where(item => item.Parent == null))
mainMenu.Items.Add(item);
MenuItem GetOrAddParentMenuItem(string menuId, string? resourceKey)
{ {
if (!parentMenuItems.TryGetValue(menuId, out var parentMenuItem)) if (!topLevelByTag.TryGetValue(menuId, out var existing))
{ {
var topLevelMenuItem = mainMenu.Items.OfType<MenuItem>().FirstOrDefault(m => (string?)m.Tag == menuId); existing = new NativeMenuItem {
if (topLevelMenuItem == null) Header = ResourceHelper.GetString(resourceKey),
{ Menu = new NativeMenu(),
parentMenuItem = new MenuItem { };
Header = ResourceHelper.GetString(resourceKey), topLevelByTag[menuId] = existing;
Tag = menuId, rootMenu.Items.Add(existing);
};
parentMenuItems.Add(menuId, parentMenuItem);
}
else
{
parentMenuItems.Add(menuId, topLevelMenuItem);
parentMenuItem = topLevelMenuItem;
}
} }
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 // Tool-pane toggles sit below any MEF-driven Window commands (CloseAllDocuments / ResetLayout).
// (CloseAllDocuments / ResetLayout). Append tool-pane toggles after a separator so
// they sit at the bottom of the Window menu.
if (dockWorkspace.ToolPaneMenuItems.Count > 0) if (dockWorkspace.ToolPaneMenuItems.Count > 0)
{ {
if (windowMenuItem.Items.Count > 0) if (windowMenu.Items.Count > 0)
windowMenuItem.Items.Add(new Separator()); windowMenu.Items.Add(new NativeMenuItemSeparator());
foreach (var pane in dockWorkspace.ToolPaneMenuItems) foreach (var pane in dockWorkspace.ToolPaneMenuItems)
{ {
var item = new MenuItem { var item = new NativeMenuItem {
Header = pane.Title, Header = pane.Title,
ToggleType = MenuItemToggleType.CheckBox, ToggleType = MenuItemToggleType.CheckBox,
DataContext = pane,
}; };
item.Bind(MenuItem.IsCheckedProperty, new Binding(nameof(ToolPaneMenuItem.IsPaneVisible)) { // OneWay binding + Command — see MakeRadio for why TwoWay alone
Mode = BindingMode.TwoWay, // 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 AppendTabSection(windowMenu, dockWorkspace);
// exist so we don't lead with a stray rule when both prior blocks were empty.
AppendTabSection(windowMenuItem, dockWorkspace);
} }
static void AppendTabSection(MenuItem windowMenuItem, DockWorkspace dockWorkspace) static void AppendTabSection(NativeMenu windowMenu, DockWorkspace dockWorkspace)
{ {
var tabItems = dockWorkspace.TabPageMenuItems; var tabItems = dockWorkspace.TabPageMenuItems;
Separator? separator = null; NativeMenuItemSeparator? separator = null;
var perItem = new Dictionary<TabPageMenuItem, MenuItem>(); var perItem = new Dictionary<TabPageMenuItem, NativeMenuItem>();
void EnsureSeparator() void EnsureSeparator()
{ {
if (separator != null) if (separator != null)
return; return;
if (windowMenuItem.Items.Count == 0) if (windowMenu.Items.Count == 0)
return; return;
separator = new Separator(); separator = new NativeMenuItemSeparator();
windowMenuItem.Items.Add(separator); windowMenu.Items.Add(separator);
} }
MenuItem CreateMenuItem(TabPageMenuItem vm) NativeMenuItem CreateMenuItem(TabPageMenuItem vm)
{ {
var item = new MenuItem { var item = new NativeMenuItem {
Header = vm.Title, Header = vm.Title,
ToggleType = MenuItemToggleType.Radio, ToggleType = MenuItemToggleType.Radio,
DataContext = vm,
}; };
// Header tracks the tab's Title (e.g. swaps from "(no selection)" → "MyType" when item.Bind(NativeMenuItem.HeaderProperty, new Binding(nameof(TabPageMenuItem.Title)) {
// the user navigates). Source = vm,
item.Bind(MenuItem.HeaderProperty, new Binding(nameof(TabPageMenuItem.Title))); });
// IsActive: setter activates the tab, getter mirrors the dock's ActiveDockable. // OneWay binding + Command — see MakeRadio for why TwoWay alone
item.Bind(MenuItem.IsCheckedProperty, new Binding(nameof(TabPageMenuItem.IsActive)) { // doesn't drive macOS clickability.
Mode = BindingMode.TwoWay, item.Bind(NativeMenuItem.IsCheckedProperty, new Binding(nameof(TabPageMenuItem.IsActive)) {
Source = vm,
Mode = BindingMode.OneWay,
}); });
item.Command = new RelayCommand(() => vm.IsActive = true);
return item; return item;
} }
@ -254,19 +304,18 @@ public partial class MainMenu : UserControl
EnsureSeparator(); EnsureSeparator();
var menuItem = CreateMenuItem(vm); var menuItem = CreateMenuItem(vm);
perItem.Add(vm, menuItem); perItem.Add(vm, menuItem);
windowMenuItem.Items.Add(menuItem); windowMenu.Items.Add(menuItem);
} }
void RemoveItem(TabPageMenuItem vm) void RemoveItem(TabPageMenuItem vm)
{ {
if (!perItem.TryGetValue(vm, out var menuItem)) if (!perItem.TryGetValue(vm, out var menuItem))
return; return;
windowMenuItem.Items.Remove(menuItem); windowMenu.Items.Remove(menuItem);
perItem.Remove(vm); perItem.Remove(vm);
// Drop the separator if no tabs remain — the next AddItem will re-create one.
if (perItem.Count == 0 && separator != null) if (perItem.Count == 0 && separator != null)
{ {
windowMenuItem.Items.Remove(separator); windowMenu.Items.Remove(separator);
separator = null; separator = null;
} }
} }
@ -288,14 +337,7 @@ public partial class MainMenu : UserControl
RemoveItem(vm); RemoveItem(vm);
break; break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Reset: 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: 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()) foreach (var vm in perItem.Keys.ToList())
RemoveItem(vm); RemoveItem(vm);
foreach (var vm in tabItems) 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) static bool TryParseGesture(string? text, out KeyGesture gesture)
{ {
gesture = null!; gesture = null!;
@ -325,17 +388,16 @@ public partial class MainMenu : UserControl
{ {
public static readonly ThemeEqualityConverter Instance = new(); 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 v = value as string;
var p = parameter 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)) if (string.IsNullOrEmpty(v))
v = ThemeManager.Current.DefaultTheme; 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; => BindingOperations.DoNothing;
} }
} }

6
ILSpy/Views/MainWindow.axaml

@ -31,8 +31,10 @@
</Window.KeyBindings> </Window.KeyBindings>
<DockPanel> <DockPanel>
<!-- Main menu --> <!-- Main menu. NativeMenu.Menu is set from code (ILSpy.MainMenu.Attach); on macOS
<local:MainMenu DockPanel.Dock="Top" /> Avalonia projects it into the system menu bar and NativeMenuBar renders empty,
on Windows / Linux NativeMenuBar paints the menu inline at the top of the window. -->
<NativeMenuBar DockPanel.Dock="Top" />
<!-- Toolbar --> <!-- Toolbar -->
<local:MainToolBar DockPanel.Dock="Top" /> <local:MainToolBar DockPanel.Dock="Top" />

1
ILSpy/Views/MainWindow.axaml.cs

@ -55,6 +55,7 @@ namespace ILSpy.Views
AppLog.Mark("MainWindow XAML inflation about to start (DataContext set)"); AppLog.Mark("MainWindow XAML inflation about to start (DataContext set)");
InitializeComponent(); InitializeComponent();
AppLog.Mark("MainWindow XAML inflation done"); AppLog.Mark("MainWindow XAML inflation done");
ILSpy.MainMenu.Attach(this);
ApplySessionSettings(settingsService.SessionSettings); ApplySessionSettings(settingsService.SessionSettings);
Opened += async (_, _) => { Opened += async (_, _) => {
AppLog.Mark("MainWindow.Opened fired"); AppLog.Mark("MainWindow.Opened fired");

Loading…
Cancel
Save