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. 314
      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 @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var fileMenu = window.GetVisualDescendants().OfType<global::Avalonia.Controls.MenuItem>()
.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<global::Avalonia.Controls.NativeMenuItem>()
.Single(m => string.Equals(m.Header, ICSharpCode.ILSpy.Properties.Resources._File, System.StringComparison.Ordinal));
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,
"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]

97
ILSpy.Tests/MainWindow/MainMenuTests.cs

@ -16,13 +16,12 @@ @@ -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 @@ -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<Menu>("MainMenuRoot");
menu.Should().NotBeNull();
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 headers = menu!.Items.OfType<MenuItem>().Select(m => m.Header as string).ToList();
headers.Should().Equal("_File", "_View", "_Window");
var headers = nativeMenu.Items.OfType<NativeMenuItem>().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.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
// Arrange — boot MainWindow and wait for the File menu to be populated by MEF.
var nativeMenu = NativeMenu.GetMenu(window)
?? throw new InvalidOperationException("MainMenu.Attach should have set NativeMenu on the window");
var fileMenu = nativeMenu.Items.OfType<NativeMenuItem>()
.Single(m => string.Equals(m.Header, Resources._File, StringComparison.Ordinal));
var openItem = fileMenu.Menu!.Items.OfType<NativeMenuItem>()
.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<MainWindow>();
window.Show();
var menu = await window.WaitForComponent<Menu>();
await Waiters.WaitForAsync(() =>
menu.Items.OfType<MenuItem>().Any(m => (string?)m.Tag == nameof(Resources._File))
&& menu.Items.OfType<MenuItem>().Single(m => (string?)m.Tag == nameof(Resources._File))
.Items.OfType<MenuItem>().Any());
// Act — locate the Open MenuItem under File.
var fileMenu = menu.Items.OfType<MenuItem>().Single(m => (string?)m.Tag == nameof(Resources._File));
var openItem = fileMenu.Items.OfType<MenuItem>()
.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 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 @@ @@ -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 @@ -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<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
@ -57,31 +58,27 @@ public class WindowMenuOpenDocumentsTests @@ -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<AssemblyTreeNode>("System.Private.Uri");
vm.DockWorkspace.OpenNodeInNewTab(secondAsm);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var menu = await window.WaitForComponent<Menu>();
var windowMenu = menu.Items.OfType<MenuItem>()
.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<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!;
await Waiters.WaitForAsync(
() => documentsDock.VisibleDockables!.OfType<ContentTabPage>().All(t => !string.IsNullOrEmpty(t.Title))
&& documentsDock.VisibleDockables!.OfType<ContentTabPage>()
.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>()
.Select(t => t.Title)
.ToList();
var menuTitles = windowMenu.Items.OfType<MenuItem>()
.Select(mi => mi.Header as string)
var menuTitles = windowMenu.Menu!.Items.OfType<NativeMenuItem>()
.Select(mi => mi.Header)
.ToList();
tabTitles.Should().HaveCount(2);

52
ILSpy.Tests/Navigation/BrowseBackForwardCommandTests.cs

@ -92,15 +92,13 @@ public class BrowseBackForwardCommandTests @@ -92,15 +92,13 @@ public class BrowseBackForwardCommandTests
var secondMethod = typeNode.Children.OfType<MethodTreeNode>()
.First(m => m.MethodDefinition.Name == "Empty");
// Locate the View > Back menu item.
var menu = await window.WaitForComponent<Menu>();
var viewMenu = menu.Items.OfType<MenuItem>()
.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<MenuItem>().Any());
var backItem = viewMenu.Items.OfType<MenuItem>()
.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<NativeMenuItem>()
.Single(m => string.Equals(m.Header, Resources._View, System.StringComparison.Ordinal));
var backItem = viewMenu.Menu!.Items.OfType<NativeMenuItem>()
.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 @@ -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<MainWindow>();
window.Show();
var menu = await window.WaitForComponent<Menu>();
var viewMenu = menu.Items.OfType<MenuItem>()
.Single(m => (string?)m.Tag == nameof(Resources._View));
viewMenu.Open();
await Waiters.WaitForAsync(() =>
viewMenu.Items.OfType<MenuItem>().Any(m => (string?)m.Header == Resources.Back)
&& viewMenu.Items.OfType<MenuItem>().Any(m => (string?)m.Header == Resources.Forward));
var backItem = viewMenu.Items.OfType<MenuItem>()
.Single(m => string.Equals(m.Header as string, Resources.Back, System.StringComparison.Ordinal));
var forwardItem = viewMenu.Items.OfType<MenuItem>()
.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<NativeMenuItem>()
.Single(m => string.Equals(m.Header, Resources._View, System.StringComparison.Ordinal));
var backItem = viewMenu.Menu!.Items.OfType<NativeMenuItem>()
.Single(m => string.Equals(m.Header, Resources.Back, System.StringComparison.Ordinal));
var forwardItem = viewMenu.Menu!.Items.OfType<NativeMenuItem>()
.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"));
}
}

1
ILSpy/App.axaml

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

41
ILSpy/Views/MainMenu.axaml

@ -1,41 +0,0 @@ @@ -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>

314
ILSpy/Views/MainMenu.axaml.cs

@ -16,6 +16,7 @@ @@ -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; @@ -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; @@ -33,219 +38,264 @@ using ILSpy.ViewModels;
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 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<string, NativeMenuItem>(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));
var registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
var dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
var setThemeCommand = AppComposition.Current.GetExport<SetThemeCommand>();
PopulateViewMenu(viewItem.Menu!, settings.SessionSettings, setThemeCommand);
AppendRegistryCommands(menu, topLevelByTag, registry.Commands);
AppendWindowDynamicContent(windowItem.Menu!, dockWorkspace);
PopulateThemeSubmenu(setThemeCommand);
InitMainMenu(MainMenuRoot, registry.Commands);
InitWindowMenu(WindowMenuItem, dockWorkspace);
if (OperatingSystem.IsMacOS())
TranslateGesturesForMacOS(menu);
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<SettingsService>();
return settings != null;
registry = AppComposition.Current.GetExport<MainMenuCommandRegistry>();
dockWorkspace = AppComposition.Current.GetExport<DockWorkspace>();
setThemeCommand = AppComposition.Current.GetExport<SetThemeCommand>();
return true;
}
catch
{
settings = null!;
registry = null!;
dockWorkspace = null!;
setThemeCommand = null!;
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 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)
{
var current = (SessionSettings)DataContext!;
var items = new List<MenuItem>();
foreach (var theme in ThemeManager.AllThemes)
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<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 = 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)
{
if (!parentMenuItems.TryGetValue(menuId, out var parentMenuItem))
NativeMenuItem GetOrAddParentItem(string menuId, string? resourceKey)
{
var topLevelMenuItem = mainMenu.Items.OfType<MenuItem>().FirstOrDefault(m => (string?)m.Tag == menuId);
if (topLevelMenuItem == null)
if (!topLevelByTag.TryGetValue(menuId, out var existing))
{
parentMenuItem = new MenuItem {
existing = new NativeMenuItem {
Header = ResourceHelper.GetString(resourceKey),
Tag = menuId,
Menu = new NativeMenu(),
};
parentMenuItems.Add(menuId, parentMenuItem);
topLevelByTag[menuId] = existing;
rootMenu.Items.Add(existing);
}
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
// (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<TabPageMenuItem, MenuItem>();
NativeMenuItemSeparator? separator = null;
var perItem = new Dictionary<TabPageMenuItem, NativeMenuItem>();
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 @@ -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 @@ -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 @@ -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 @@ -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;
}
}

6
ILSpy/Views/MainWindow.axaml

@ -31,8 +31,10 @@ @@ -31,8 +31,10 @@
</Window.KeyBindings>
<DockPanel>
<!-- Main menu -->
<local:MainMenu DockPanel.Dock="Top" />
<!-- Main menu. NativeMenu.Menu is set from code (ILSpy.MainMenu.Attach); on macOS
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 -->
<local:MainToolBar DockPanel.Dock="Top" />

1
ILSpy/Views/MainWindow.axaml.cs

@ -55,6 +55,7 @@ namespace ILSpy.Views @@ -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");

Loading…
Cancel
Save