diff --git a/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs b/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs new file mode 100644 index 000000000..2b7021b31 --- /dev/null +++ b/ILSpy.Tests/MainWindow/MainToolBarLayoutTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless.NUnit; +using Avalonia.VisualTree; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Properties; + +using ILSpy; +using ILSpy.AppEnv; +using ILSpy.Search; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// Pins the left-to-right order of the MainToolBar to match the WPF original. WPF lays out +/// its toolbar by hand-coded XAML interleaved with two MEF injection points: Navigation goes +/// at the very start, Open goes right after, then come the AssemblyList combo + Manage button, +/// then the visibility toggles, then the language combos, and View-category commands (Sort, +/// CollapseAll, Search) are appended after a separator at the very end (see +/// ILSpy/Controls/MainToolBar.xaml + InitToolbar in MainToolBar.xaml.cs:55). +/// +[TestFixture] +public class MainToolBarLayoutTests +{ + [AvaloniaTest] + public async Task MainToolBar_Has_ManageAssemblyListsButton_Right_Of_AssemblyListComboBox() + { + // WPF places the Manage-Lists icon button immediately right of the assembly-list combo + // (MainToolBar.xaml lines 44-49). The Avalonia port previously only surfaced this + // command under the File menu — verify the inline toolbar affordance is wired and that + // clicking it pops the ManageAssemblyListsDialog. + + var window = AppComposition.Current.GetExport(); + window.Show(); + + var toolbar = await window.WaitForComponent(); + var combo = toolbar.GetVisualDescendants().OfType() + .Single(c => c.Name == "AssemblyListComboBox"); + var assemblyListGrid = combo.FindAncestorOfType()!; + + // Walk the StackPanel children from the AssemblyList grid forward; the very next + // non-separator child must be a Button bound to the manage-lists command. + var rootPanel = toolbar.GetVisualDescendants().OfType() + .Single(s => s.Name == "ToolbarRoot"); + var children = rootPanel.Children.ToList(); + var assemblyListIndex = children.IndexOf(assemblyListGrid); + assemblyListIndex.Should().BeGreaterThan(-1); + + var nextSibling = children.Skip(assemblyListIndex + 1) + .OfType + @@ -213,22 +237,6 @@ - - - - - - - - @@ -263,6 +271,10 @@ + + diff --git a/ILSpy/Views/MainToolBar.axaml.cs b/ILSpy/Views/MainToolBar.axaml.cs index 97ade57e7..b661a1d11 100644 --- a/ILSpy/Views/MainToolBar.axaml.cs +++ b/ILSpy/Views/MainToolBar.axaml.cs @@ -17,6 +17,8 @@ // DEALINGS IN THE SOFTWARE. using System; +using System.Collections.Generic; +using System.Composition; using System.Linq; using System.Reflection; @@ -39,7 +41,9 @@ public partial class MainToolBar : UserControl public MainToolBar() { InitializeComponent(); - Loaded += (_, _) => { + // AttachedToVisualTree fires reliably in both the live app and the Avalonia.Headless + // test harness; Loaded did not in headless, leaving the MEF anchors un-replaced. + AttachedToVisualTree += (_, _) => { InitializeButtons(); WireHistoryUpdates(); }; @@ -91,42 +95,104 @@ public partial class MainToolBar : UserControl return; initialized = true; - ToolbarCommandRegistry registry; + ToolbarCommandRegistry? toolbarRegistry = null; + MainMenuCommandRegistry? menuRegistry = null; try { - registry = AppComposition.Current.GetExport(); + toolbarRegistry = AppComposition.Current.GetExport(); + menuRegistry = AppComposition.Current.GetExport(); } catch { - // Design-time / test contexts that bypass composition just keep the static layout. - return; + // Design-time / test contexts that bypass composition keep the static layout. } - int insertAt = ToolbarRoot.Children.IndexOf(ToolbarMefAnchor); - if (insertAt < 0) - return; + if (toolbarRegistry != null) + DispatchToolbarCommands(toolbarRegistry); + + if (menuRegistry != null) + WireManageAssemblyListsButton(menuRegistry); + } - // Insert one Button per [ExportToolbarCommand], grouped by Category, separated by - // a thin Separator between categories. Order within a category honours ToolbarOrder. - var groups = registry.Commands + void DispatchToolbarCommands(ToolbarCommandRegistry registry) + { + // Mirrors the WPF dispatch in MainToolBar.xaml.cs:55-90 — Navigation is hand-coded + // (Back/Forward SplitButtons in the AXAML), Open goes at OpenCategoryAnchor, every + // other category (View etc.) is appended after ViewCategoryAnchor. + var byCategory = registry.Commands .GroupBy(c => c.Metadata.ToolbarCategory ?? string.Empty) - .OrderBy(g => g.Key); - bool firstGroup = true; - foreach (var group in groups) + .ToDictionary(g => g.Key, g => g.OrderBy(c => c.Metadata.ToolbarOrder).ToList()); + + var openCategory = nameof(ICSharpCode.ILSpy.Properties.Resources.Open); + var viewCategory = nameof(ICSharpCode.ILSpy.Properties.Resources.View); + + if (byCategory.TryGetValue(openCategory, out var openCommands)) + ReplaceAnchor(OpenCategoryAnchor, openCommands, leadingSeparator: false); + + // "View" group + any other unknown categories go at the View anchor with a leading + // Separator so they read as a distinct trailing group. + var trailingCategories = byCategory + .Where(kv => kv.Key != openCategory) + .OrderBy(kv => kv.Key == viewCategory ? 0 : 1) + .ThenBy(kv => kv.Key) + .ToList(); + + if (trailingCategories.Count == 0) + { + // Hide the bare anchor so we don't leave a trailing Separator on its own. + ToolbarRoot.Children.Remove(ViewCategoryAnchor); + return; + } + + var firstTrailingCommands = trailingCategories[0].Value; + ReplaceAnchor(ViewCategoryAnchor, firstTrailingCommands, leadingSeparator: true); + + // Any additional categories (rare, only via plugins) get their own separator + buttons + // appended at the end so the WPF "else: add Separator + commands" branch is preserved. + for (int i = 1; i < trailingCategories.Count; i++) { - if (!firstGroup) - ToolbarRoot.Children.Insert(insertAt++, new Separator()); - firstGroup = false; - foreach (var entry in group.OrderBy(c => c.Metadata.ToolbarOrder)) + ToolbarRoot.Children.Add(new Separator()); + foreach (var entry in trailingCategories[i].Value) { var button = BuildButton(entry); if (button != null) - ToolbarRoot.Children.Insert(insertAt++, button); + ToolbarRoot.Children.Add(button); } } } - static Button? BuildButton(System.Composition.ExportFactory entry) + void ReplaceAnchor( + Separator anchor, + IReadOnlyList> commands, + bool leadingSeparator) + { + var index = ToolbarRoot.Children.IndexOf(anchor); + if (index < 0) + return; + ToolbarRoot.Children.RemoveAt(index); + int insertAt = index; + if (leadingSeparator) + ToolbarRoot.Children.Insert(insertAt++, new Separator()); + foreach (var entry in commands) + { + var button = BuildButton(entry); + if (button != null) + ToolbarRoot.Children.Insert(insertAt++, button); + } + } + + void WireManageAssemblyListsButton(MainMenuCommandRegistry registry) + { + // Match the export by its main-menu header — File > Manage Assembly Lists. + var entry = registry.Commands.FirstOrDefault( + c => c.Metadata.Header == nameof(ICSharpCode.ILSpy.Properties.Resources.ManageAssembly_Lists)); + if (entry == null) + return; + ManageAssemblyListsButton.Command = entry.CreateExport().Value; + ToolTip.SetTip(ManageAssemblyListsButton, ICSharpCode.ILSpy.Properties.Resources.ManageAssemblyLists); + } + + static Button? BuildButton(ExportFactory entry) { var command = entry.CreateExport().Value; var button = new Button { @@ -158,7 +224,7 @@ public partial class MainToolBar : UserControl { if (string.IsNullOrEmpty(iconPath)) return null; - var name = iconPath.StartsWith("Images/", System.StringComparison.Ordinal) + var name = iconPath.StartsWith("Images/", StringComparison.Ordinal) ? iconPath["Images/".Length..] : iconPath; var prop = typeof(Images.Images).GetField(name, BindingFlags.Public | BindingFlags.Static);