@ -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 . _F ile ) ) ;
initialized = true ;
var viewItem = AddTopLevel ( menu , topLevelByTag , "_View" , nameof ( Resources . _ View ) ) ;
var windowItem = AddTopLevel ( menu , topLevelByTag , "_Window" , nameof ( Resources . _ Window ) ) ;
var registry = AppComposition . Current . GetExport < MainMenuCommandRegistry > ( ) ;
PopulateViewMenu ( viewItem . Menu ! , settings . SessionSettings , setThemeCommand ) ;
var dockWorkspace = AppComposition . Current . GetExport < DockWorkspace > ( ) ;
AppendRegistryCommands ( menu , topLevelByTag , registry . Commands ) ;
var setThemeCommand = AppComposition . Current . GetExport < SetThemeCommand > ( ) ;
AppendWindowDynamicContent ( windowItem . Menu ! , dockWorkspace ) ;
PopulateThemeSubmenu ( setThemeCommand ) ;
if ( OperatingSystem . IsMacOS ( ) )
InitMainMenu ( MainMenuRoot , registry . Commands ) ;
TranslateGesturesForMacOS ( menu ) ;
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
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 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 ! ;
foreach ( var themeName in ThemeManager . AllThemes )
var items = new List < MenuItem > ( ) ;
foreach ( var theme 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 . I tems. 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 ) )
{
{
var topLevelMenuItem = mainMenu . Items . OfType < MenuItem > ( ) . FirstOrDefault ( m = > ( string? ) m . Tag = = menuId ) ;
if ( ! topLevelByTag . TryGetValue ( menuId , out var existing ) )
if ( topLevelMenuItem = = null )
{
{
parentMenuItem = new MenuItem {
existing = new NativeMenuItem {
Header = ResourceHelper . GetString ( resourceKey ) ,
Header = ResourceHelper . GetString ( resourceKey ) ,
Tag = menuId ,
Menu = new NativeMenu ( ) ,
} ;
} ;
parentMenuItems . Add ( menuId , parentMenuItem ) ;
topLevelByTag [ menuId ] = existing ;
rootMenu . Items . Add ( existing ) ;
}
}
else
return existing ;
{
parentMenuItems . Add ( menuId , topLevelMenuItem ) ;
parentMenuItem = topLevelMenuItem ;
}
}
return parentMenuItem ;
}
}
}
}
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 NativeMenuItem Separator( ) ) ;
foreach ( var pane in dockWorkspace . ToolPaneMenuItems )
foreach ( var pane in dockWorkspace . ToolPaneMenuItems )
{
{
var item = new MenuItem {
var item = new Native MenuItem {
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 ( Native Menu windowMenu , DockWorkspace dockWorkspace )
{
{
var tabItems = dockWorkspace . TabPageMenuItems ;
var tabItems = dockWorkspace . TabPageMenuItems ;
Separator ? separator = null ;
NativeMenuItem Separator? separator = null ;
var perItem = new Dictionary < TabPageMenuItem , MenuItem > ( ) ;
var perItem = new Dictionary < TabPageMenuItem , Native MenuItem> ( ) ;
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 NativeMenuItem Separator( ) ;
windowMenuItem . Items . Add ( separator ) ;
windowMenu . Items . Add ( separator ) ;
}
}
MenuItem CreateMenuItem ( TabPageMenuItem vm )
Native MenuItem CreateMenuItem ( TabPageMenuItem vm )
{
{
var item = new MenuItem {
var item = new Native MenuItem {
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 . S tringComparison . 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 ;
}
}
}
}