mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
The document tab strip can now flow tabs onto multiple rows or stay a single scrolling row; the mode is toggled by the mouse wheel over the strip and persisted in SessionSettings. In single-line mode an overflow dropdown at the strip's trailing edge lists every open document for quick switching. Popups render in the window overlay layer (OverlayPopups) so the dropdown's menu doesn't open as a native X11 child window that loses focus and self-dismisses before it becomes visible. Assisted-by: Claude:claude-opus-4-8:Claude Codepull/3755/head
8 changed files with 552 additions and 42 deletions
@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
|
||||
// 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; |
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
|
||||
using Avalonia; |
||||
using Avalonia.Controls; |
||||
using Avalonia.Controls.Primitives; |
||||
using Avalonia.Headless; |
||||
using Avalonia.Input; |
||||
using Avalonia.Headless.NUnit; |
||||
using Avalonia.Threading; |
||||
using Avalonia.VisualTree; |
||||
|
||||
using AwesomeAssertions; |
||||
|
||||
using Dock.Avalonia.Controls; |
||||
|
||||
using ILSpy; |
||||
using ILSpy.AppEnv; |
||||
using ILSpy.TextView; |
||||
using ILSpy.ViewModels; |
||||
using ILSpy.Views; |
||||
|
||||
using NUnit.Framework; |
||||
|
||||
namespace ICSharpCode.ILSpy.Tests.Docking; |
||||
|
||||
/// <summary>
|
||||
/// The document tab strip toggles between a single scrolling row and multiple wrapped rows, driven by
|
||||
/// the persisted <see cref="SessionSettings.MultiLineDocumentTabs"/> setting and the mouse wheel. The
|
||||
/// overflow dropdown that lists open documents shows only in single-line mode.
|
||||
/// </summary>
|
||||
[TestFixture] |
||||
public class DocumentTabStripModeTests |
||||
{ |
||||
static SessionSettings Settings => AppComposition.Current.GetExport<SettingsService>().SessionSettings; |
||||
|
||||
static async Task<(MainWindow window, DocumentTabStrip strip)> BootWithTabsAsync(bool multiLine) |
||||
{ |
||||
var (window, vm) = await TestHarness.BootAsync(1); |
||||
Settings.MultiLineDocumentTabs = multiLine; |
||||
for (int i = 0; i < 30; i++) |
||||
vm.DockWorkspace.OpenNewTab(new DecompilerTabPageModel { Title = $"Tab {i:00}" }); |
||||
await Pump(window); |
||||
var strip = window.GetVisualDescendants().OfType<DocumentTabStrip>().First(); |
||||
return (window, strip); |
||||
} |
||||
|
||||
static async Task Pump(MainWindow window) |
||||
{ |
||||
for (int i = 0; i < 12; i++) |
||||
{ |
||||
Dispatcher.UIThread.RunJobs(); |
||||
window.UpdateLayout(); |
||||
await Task.Delay(20); |
||||
} |
||||
} |
||||
|
||||
static Button? Dropdown(DocumentTabStrip strip) |
||||
=> strip.GetVisualDescendants().OfType<Button>() |
||||
.FirstOrDefault(b => (b.Tag as string) == "DocumentSwitcherDropdown"); |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Single_Line_Mode_Has_No_Wrap_Panel_And_A_Visible_Dropdown() |
||||
{ |
||||
var saved = Settings.MultiLineDocumentTabs; |
||||
try |
||||
{ |
||||
var (_, strip) = await BootWithTabsAsync(multiLine: false); |
||||
|
||||
strip.GetVisualDescendants().OfType<WrapPanel>().Should().BeEmpty( |
||||
"single-line mode keeps the default StackPanel item layout"); |
||||
Dropdown(strip).Should().NotBeNull("the overflow dropdown is injected on the strip"); |
||||
Dropdown(strip)!.IsVisible.Should().BeTrue("the dropdown shows in single-line mode"); |
||||
} |
||||
finally |
||||
{ Settings.MultiLineDocumentTabs = saved; } |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Multi_Line_Mode_Wraps_And_Hides_The_Dropdown() |
||||
{ |
||||
var saved = Settings.MultiLineDocumentTabs; |
||||
try |
||||
{ |
||||
var (_, strip) = await BootWithTabsAsync(multiLine: true); |
||||
|
||||
strip.GetVisualDescendants().OfType<WrapPanel>().Should().NotBeEmpty( |
||||
"multi-line mode flows tabs through a WrapPanel"); |
||||
Dropdown(strip)!.IsVisible.Should().BeFalse( |
||||
"every tab is visible across rows, so the dropdown hides"); |
||||
} |
||||
finally |
||||
{ Settings.MultiLineDocumentTabs = saved; } |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Mouse_Wheel_Over_The_Strip_Toggles_The_Mode() |
||||
{ |
||||
var saved = Settings.MultiLineDocumentTabs; |
||||
try |
||||
{ |
||||
var (window, strip) = await BootWithTabsAsync(multiLine: false); |
||||
var point = strip.TranslatePoint(new Point(8, 8), window) ?? new Point(8, 8); |
||||
|
||||
window.MouseWheel(point, new Vector(0, 1)); |
||||
await Pump(window); |
||||
Settings.MultiLineDocumentTabs.Should().BeTrue("wheel up expands to multiple rows"); |
||||
|
||||
window.MouseWheel(point, new Vector(0, -1)); |
||||
await Pump(window); |
||||
Settings.MultiLineDocumentTabs.Should().BeFalse("wheel down collapses to a single row"); |
||||
} |
||||
finally |
||||
{ Settings.MultiLineDocumentTabs = saved; } |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Clicking_The_Dropdown_Opens_A_Populated_Menu() |
||||
{ |
||||
var saved = Settings.MultiLineDocumentTabs; |
||||
try |
||||
{ |
||||
var (window, strip) = await BootWithTabsAsync(multiLine: false); |
||||
var button = Dropdown(strip); |
||||
button.Should().NotBeNull(); |
||||
|
||||
var menu = button!.ContextMenu!; |
||||
bool opened = false; |
||||
menu.Opened += (_, _) => opened = true; |
||||
|
||||
// Drive a real pointer click through the input pipeline (open is deferred a dispatcher
|
||||
// turn, which Pump runs), so this would have caught the menu failing to open on a live click.
|
||||
var centre = button.TranslatePoint(new Point(button.Bounds.Width / 2, button.Bounds.Height / 2), window) |
||||
?? new Point(8, 8); |
||||
window.MouseDown(centre, MouseButton.Left); |
||||
window.MouseUp(centre, MouseButton.Left); |
||||
await Pump(window); |
||||
|
||||
opened.Should().BeTrue("clicking the dropdown must open its menu"); |
||||
menu.Items.OfType<MenuItem>().Should().NotBeEmpty("the open menu lists the strip's documents"); |
||||
} |
||||
finally |
||||
{ Settings.MultiLineDocumentTabs = saved; } |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Picking_A_Menu_Entry_Switches_To_That_Document() |
||||
{ |
||||
var saved = Settings.MultiLineDocumentTabs; |
||||
try |
||||
{ |
||||
var (window, strip) = await BootWithTabsAsync(multiLine: false); |
||||
var button = Dropdown(strip); |
||||
var menu = button!.ContextMenu!; |
||||
|
||||
var centre = button.TranslatePoint(new Point(button.Bounds.Width / 2, button.Bounds.Height / 2), window) |
||||
?? new Point(8, 8); |
||||
window.MouseDown(centre, MouseButton.Left); |
||||
window.MouseUp(centre, MouseButton.Left); |
||||
await Pump(window); |
||||
|
||||
var target = strip.Items.OfType<ContentTabPage>().Last(); |
||||
var targetItem = menu.Items.OfType<MenuItem>().Single(i => (string?)i.Header == target.Title); |
||||
targetItem.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(MenuItem.ClickEvent)); |
||||
await Pump(window); |
||||
|
||||
strip.SelectedItem.Should().Be(target, "picking a document switches the strip to it"); |
||||
} |
||||
finally |
||||
{ Settings.MultiLineDocumentTabs = saved; } |
||||
} |
||||
} |
||||
@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
|
||||
// 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.Xml.Linq; |
||||
|
||||
using AwesomeAssertions; |
||||
|
||||
using ILSpy; |
||||
|
||||
using NUnit.Framework; |
||||
|
||||
namespace ICSharpCode.ILSpy.Tests.Settings; |
||||
|
||||
/// <summary>
|
||||
/// The document-tab layout mode persists across sessions: it defaults to single-line (false) and
|
||||
/// round-trips through the SessionSettings XML.
|
||||
/// </summary>
|
||||
[TestFixture] |
||||
public class SessionSettingsMultiLineTabsTests |
||||
{ |
||||
static SessionSettings Load(XElement section) |
||||
{ |
||||
var settings = new SessionSettings(); |
||||
settings.LoadFromXml(section); |
||||
return settings; |
||||
} |
||||
|
||||
[Test] |
||||
public void Defaults_To_Single_Line_When_Absent() |
||||
{ |
||||
Load(new XElement("SessionSettings")).MultiLineDocumentTabs.Should().BeFalse(); |
||||
} |
||||
|
||||
[Test] |
||||
public void Round_Trips_Through_Xml() |
||||
{ |
||||
var settings = Load(new XElement("SessionSettings")); |
||||
settings.MultiLineDocumentTabs = true; |
||||
|
||||
var reloaded = Load(settings.SaveToXml()); |
||||
|
||||
reloaded.MultiLineDocumentTabs.Should().BeTrue("the persisted mode must survive save + load"); |
||||
} |
||||
} |
||||
@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
|
||||
// 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 Avalonia; |
||||
using Avalonia.Controls; |
||||
using Avalonia.Controls.Primitives; |
||||
using Avalonia.Layout; |
||||
using Avalonia.Media; |
||||
using Avalonia.Threading; |
||||
using Avalonia.VisualTree; |
||||
|
||||
using Dock.Avalonia.Controls; |
||||
|
||||
using ILSpy.AppEnv; |
||||
using ILSpy.ViewModels; |
||||
|
||||
namespace ILSpy.Themes |
||||
{ |
||||
/// <summary>
|
||||
/// Injects a single overflow dropdown button at the trailing edge of each
|
||||
/// <see cref="DocumentTabStrip"/>, listing that strip's open documents for quick switching when
|
||||
/// many tabs don't fit. Only shown in single-line mode (when
|
||||
/// <see cref="SessionSettings.MultiLineDocumentTabs"/> is false); in multi-line mode every tab is
|
||||
/// already visible, so the dropdown hides. Dock's tab-strip template is compiled into the theme
|
||||
/// DLL, so -- like the sibling tab behaviours -- the button is injected at runtime, here into the
|
||||
/// strip's trailing DockPanel as a right-docked child. Picking an entry sets the strip's
|
||||
/// SelectedItem, which activates that document. Uses a <see cref="ContextMenu"/> (the menu type the
|
||||
/// app themes and renders reliably) opened on the next dispatcher turn after the click.
|
||||
/// </summary>
|
||||
public static class DocumentSwitcherDropdownBehavior |
||||
{ |
||||
const string DropdownTag = "DocumentSwitcherDropdown"; |
||||
|
||||
public static readonly AttachedProperty<bool> EnableProperty = |
||||
AvaloniaProperty.RegisterAttached<DocumentTabStrip, bool>( |
||||
"Enable", |
||||
typeof(DocumentSwitcherDropdownBehavior)); |
||||
|
||||
public static void SetEnable(DocumentTabStrip element, bool value) |
||||
=> element.SetValue(EnableProperty, value); |
||||
|
||||
public static bool GetEnable(DocumentTabStrip element) |
||||
=> element.GetValue(EnableProperty); |
||||
|
||||
static DocumentSwitcherDropdownBehavior() |
||||
{ |
||||
EnableProperty.Changed.AddClassHandler<DocumentTabStrip>(OnEnableChanged); |
||||
} |
||||
|
||||
static void OnEnableChanged(DocumentTabStrip strip, AvaloniaPropertyChangedEventArgs e) |
||||
{ |
||||
if (e.NewValue is not true) |
||||
return; |
||||
|
||||
var settings = TryGetSessionSettings(); |
||||
if (settings is not null) |
||||
{ |
||||
System.ComponentModel.PropertyChangedEventHandler handler = (_, args) => { |
||||
if (args.PropertyName == nameof(SessionSettings.MultiLineDocumentTabs)) |
||||
UpdateVisibility(strip, settings.MultiLineDocumentTabs); |
||||
}; |
||||
settings.PropertyChanged += handler; |
||||
strip.DetachedFromVisualTree += (_, _) => settings.PropertyChanged -= handler; |
||||
} |
||||
|
||||
strip.TemplateApplied += (_, _) => Inject(strip); |
||||
strip.AttachedToVisualTree += (_, _) => Inject(strip); |
||||
Inject(strip); |
||||
} |
||||
|
||||
static void Inject(DocumentTabStrip strip) |
||||
{ |
||||
if (FindButton(strip) is not null) |
||||
return; |
||||
var scroller = strip.GetVisualDescendants().OfType<ScrollViewer>() |
||||
.FirstOrDefault(s => s.Name == "PART_ScrollViewer"); |
||||
if (scroller?.GetVisualAncestors().OfType<DockPanel>().FirstOrDefault() is not { } dockPanel) |
||||
return; |
||||
|
||||
// Down-chevron glyph as a filled Path so it renders the same on every platform (a font
|
||||
// glyph was tofu on Linux/macOS); Fill follows the strip Foreground for theme parity.
|
||||
var glyph = new Avalonia.Controls.Shapes.Path { |
||||
Data = StreamGeometry.Parse("M0,0 L8,0 L4,5 Z"), |
||||
Width = 8, |
||||
Height = 5, |
||||
HorizontalAlignment = HorizontalAlignment.Center, |
||||
VerticalAlignment = VerticalAlignment.Center, |
||||
}; |
||||
glyph.Bind(Avalonia.Controls.Shapes.Shape.FillProperty, |
||||
new Avalonia.Data.Binding(nameof(TemplatedControl.Foreground)) { Source = strip }); |
||||
|
||||
var menu = new ContextMenu { Placement = PlacementMode.BottomEdgeAlignedRight }; |
||||
var button = new Button { |
||||
Tag = DropdownTag, |
||||
Content = glyph, |
||||
// Flat: no border/box, transparent fill -- it reads as an affordance on the strip,
|
||||
// not a raised button.
|
||||
BorderThickness = new Thickness(0), |
||||
Background = Brushes.Transparent, |
||||
Padding = new Thickness(6, 2), |
||||
Margin = new Thickness(2, 0), |
||||
VerticalAlignment = VerticalAlignment.Center, |
||||
Focusable = false, |
||||
IsTabStop = false, |
||||
}; |
||||
ToolTip.SetTip(button, "Switch to document"); |
||||
// Rebuild the list each time the menu opens so it always reflects the current documents:
|
||||
// in the click handler (the left-click path) and on Opening (the right-click path).
|
||||
menu.Opening += (_, _) => PopulateMenu(strip, menu); |
||||
button.ContextMenu = menu; |
||||
// Open on the next dispatcher turn -- not inline in the click, whose pointer-release would
|
||||
// otherwise be seen by the just-opened menu's light-dismiss layer as a click-outside and
|
||||
// close it immediately.
|
||||
button.Click += (_, _) => Dispatcher.UIThread.Post(() => { |
||||
PopulateMenu(strip, menu); |
||||
menu.Open(button); |
||||
}); |
||||
|
||||
// Dock to the right; inserting first reserves the trailing edge before the other docked
|
||||
// children are laid out, so the button lands at the far right regardless of their order.
|
||||
DockPanel.SetDock(button, Avalonia.Controls.Dock.Right); |
||||
dockPanel.Children.Insert(0, button); |
||||
|
||||
UpdateVisibility(strip, TryGetSessionSettings()?.MultiLineDocumentTabs ?? false); |
||||
} |
||||
|
||||
static void PopulateMenu(DocumentTabStrip strip, ContextMenu menu) |
||||
{ |
||||
menu.Items.Clear(); |
||||
foreach (var tab in strip.Items.OfType<ContentTabPage>()) |
||||
{ |
||||
var captured = tab; |
||||
var item = new MenuItem { |
||||
Header = string.IsNullOrEmpty(tab.Title) ? "(untitled)" : tab.Title, |
||||
// Mark the active document so the list doubles as an at-a-glance overview.
|
||||
FontWeight = ReferenceEquals(strip.SelectedItem, tab) ? FontWeight.Bold : FontWeight.Normal, |
||||
}; |
||||
item.Click += (_, _) => strip.SelectedItem = captured; |
||||
menu.Items.Add(item); |
||||
} |
||||
} |
||||
|
||||
static void UpdateVisibility(DocumentTabStrip strip, bool multiLine) |
||||
{ |
||||
if (FindButton(strip) is { } button) |
||||
button.IsVisible = !multiLine; |
||||
} |
||||
|
||||
static Button? FindButton(DocumentTabStrip strip) |
||||
=> strip.GetVisualDescendants().OfType<Button>() |
||||
.FirstOrDefault(b => (b.Tag as string) == DropdownTag); |
||||
|
||||
static SettingsService? TryGetSettingsService() |
||||
{ |
||||
try |
||||
{ return AppComposition.Current.GetExport<SettingsService>(); } |
||||
catch { return null; } |
||||
} |
||||
|
||||
static SessionSettings? TryGetSessionSettings() => TryGetSettingsService()?.SessionSettings; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue