Browse Source

Add single/multi-line document tabs with an overflow dropdown

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 Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
bc5a4f3934
  1. 192
      ILSpy.Tests/Docking/DocumentTabStripModeTests.cs
  2. 13
      ILSpy.Tests/Docking/MultiRowTabStripTests.cs
  3. 59
      ILSpy.Tests/Settings/SessionSettingsMultiLineTabsTests.cs
  4. 5
      ILSpy/App.axaml
  5. 7
      ILSpy/Program.cs
  6. 10
      ILSpy/SessionSettings.cs
  7. 179
      ILSpy/Themes/DocumentSwitcherDropdownBehavior.cs
  8. 83
      ILSpy/Themes/MultiRowTabStripBehavior.cs

192
ILSpy.Tests/Docking/DocumentTabStripModeTests.cs

@ -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; }
}
}

13
ILSpy.Tests/Docking/MultiRowTabStripTests.cs

@ -28,6 +28,7 @@ using AwesomeAssertions; @@ -28,6 +28,7 @@ using AwesomeAssertions;
using Dock.Avalonia.Controls;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.TextView;
using ILSpy.ViewModels;
@ -38,20 +39,25 @@ using NUnit.Framework; @@ -38,20 +39,25 @@ using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Docking;
/// <summary>
/// PROTOTYPE check: with MultiRowTabStripBehavior enabled (App.axaml), many document tabs flow onto
/// multiple rows (a WrapPanel) instead of a single scrolling row.
/// With multi-line mode enabled (<see cref="SessionSettings.MultiLineDocumentTabs"/>), many document
/// tabs flow onto multiple rows (a WrapPanel) instead of a single scrolling row.
/// </summary>
[TestFixture]
public class MultiRowTabStripTests
{
[AvaloniaTest]
public async Task Many_Document_Tabs_Wrap_To_Multiple_Rows()
{
var settings = AppComposition.Current.GetExport<SettingsService>().SessionSettings;
var saved = settings.MultiLineDocumentTabs;
try
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
settings.MultiLineDocumentTabs = true;
for (int i = 0; i < 40; i++)
vm.DockWorkspace.OpenNewTab(new DecompilerTabPageModel { Title = $"Tab number {i:00}" });
@ -75,4 +81,7 @@ public class MultiRowTabStripTests @@ -75,4 +81,7 @@ public class MultiRowTabStripTests
rows.Count.Should().BeGreaterThan(1, "40 tabs must wrap onto more than one row");
}
finally
{ settings.MultiLineDocumentTabs = saved; }
}
}

59
ILSpy.Tests/Settings/SessionSettingsMultiLineTabsTests.cs

@ -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");
}
}

5
ILSpy/App.axaml

@ -285,9 +285,12 @@ @@ -285,9 +285,12 @@
<Style Selector="dockControls|DocumentTabStrip, dockControls|ToolTabStrip">
<Setter Property="Background" Value="{DynamicResource ILSpy.DockTabStripBackground}" />
</Style>
<!-- PROTOTYPE: lay document tabs out on multiple rows instead of a single scrolling row. -->
<!-- Single-line (scrolling) vs multi-line (wrapped) document tabs; the mouse wheel over the
strip toggles the mode and it persists. In single-line mode an overflow dropdown lists
every open document for quick switching. -->
<Style Selector="dockControls|DocumentTabStrip">
<Setter Property="themes:MultiRowTabStripBehavior.Enable" Value="True" />
<Setter Property="themes:DocumentSwitcherDropdownBehavior.Enable" Value="True" />
</Style>
<!-- The full-width accent line under the document tab strip is the DocumentControl
template's Panel#PART_DocumentSeperator. The Simple theme fills it with the blue

7
ILSpy/Program.cs

@ -35,7 +35,12 @@ namespace ILSpy @@ -35,7 +35,12 @@ namespace ILSpy
public static AppBuilder BuildAvaloniaApp()
{
var builder = AppBuilder.Configure<App>()
.UsePlatformDetect();
.UsePlatformDetect()
// Render popups (menus, flyouts, tooltips) in the window's own overlay layer instead of
// separate native child windows. On X11/XWayland native popups can lose focus and
// self-dismiss within ~100ms of opening (observed on the document-strip overflow
// dropdown: it opened and closed before becoming visible).
.With(new X11PlatformOptions { OverlayPopups = true });
#if DEBUG
// Enables and attaches the Avalonia 12 DevTools bridge (it calls AttachDeveloperTools
// internally, so no separate App-level attach is needed -- a second call throws

10
ILSpy/SessionSettings.cs

@ -56,6 +56,14 @@ namespace ILSpy @@ -56,6 +56,14 @@ namespace ILSpy
[ObservableProperty]
private string? currentCulture;
/// <summary>
/// When true the document tab strip flows its tabs onto multiple rows; when false (the
/// default) it stays a single scrolling row with an overflow dropdown. Toggled by the mouse
/// wheel over the strip and persisted so the choice survives across sessions.
/// </summary>
[ObservableProperty]
private bool multiLineDocumentTabs;
/// <summary>
/// Path to the previously-selected tree node (one ToString() per ancestor, root-first).
/// Used to restore the selection on the next launch.
@ -91,6 +99,7 @@ namespace ILSpy @@ -91,6 +99,7 @@ namespace ILSpy
Theme = (string?)section.Element(nameof(Theme));
var culture = (string?)section.Element(nameof(CurrentCulture));
CurrentCulture = string.IsNullOrEmpty(culture) ? null : culture;
MultiLineDocumentTabs = (bool?)section.Element(nameof(MultiLineDocumentTabs)) ?? false;
var bounds = section.Element("WindowBounds");
if (bounds != null)
@ -141,6 +150,7 @@ namespace ILSpy @@ -141,6 +150,7 @@ namespace ILSpy
section.Add(new XElement(nameof(Theme), Theme));
if (!string.IsNullOrEmpty(CurrentCulture))
section.Add(new XElement(nameof(CurrentCulture), CurrentCulture));
section.Add(new XElement(nameof(MultiLineDocumentTabs), MultiLineDocumentTabs));
if (FilterStates.Count > 0)
{

179
ILSpy/Themes/DocumentSwitcherDropdownBehavior.cs

@ -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;
}
}

83
ILSpy/Themes/MultiRowTabStripBehavior.cs

@ -16,26 +16,33 @@ @@ -16,26 +16,33 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.VisualTree;
using Dock.Avalonia.Controls;
using ILSpy.AppEnv;
namespace ILSpy.Themes
{
/// <summary>
/// PROTOTYPE: makes the document tab strip lay its tabs out on multiple rows instead of a single
/// scrolling row. Dock's <see cref="DocumentTabStrip"/> hardcodes a horizontal StackPanel inside a
/// PART_ScrollViewer in its theme template and ignores an ItemsPanel set via a Style, so this
/// behaviour sets the WrapPanel ItemsPanel directly and disables the scroll-viewer's horizontal
/// scrolling (so the WrapPanel gets a bounded width and actually wraps). Toggle via the attached
/// Enable property in App.axaml.
/// Switches the document tab strip between single-line (a single horizontally scrolling row, the
/// default) and multi-line (tabs wrapped onto several rows) layout. The mode is read from and
/// written to <see cref="SessionSettings.MultiLineDocumentTabs"/>, so it persists across sessions,
/// and the mouse wheel over the strip flips it: wheel up expands to multiple rows, wheel down
/// collapses back to a single row. Dock's <see cref="DocumentTabStrip"/> hardcodes a horizontal
/// StackPanel inside a PART_ScrollViewer in its theme template and ignores an ItemsPanel set via a
/// Style, so this behaviour swaps the ItemsPanel directly and toggles the scroll-viewer's scroll
/// directions to match. Toggle via the attached Enable property in App.axaml.
/// </summary>
public static class MultiRowTabStripBehavior
{
@ -60,25 +67,71 @@ namespace ILSpy.Themes @@ -60,25 +67,71 @@ namespace ILSpy.Themes
if (e.NewValue is not true)
return;
// The presenter builds its panel from ItemsPanel; setting it here (before/at styling time)
// makes the tabs flow through a WrapPanel.
strip.ItemsPanel = new FuncTemplate<Panel?>(
() => new WrapPanel { Orientation = Orientation.Horizontal });
// The wheel toggles the persisted mode. Handle it so it doesn't also scroll the tabs.
strip.AddHandler(InputElement.PointerWheelChangedEvent, OnPointerWheel,
RoutingStrategies.Tunnel);
// The PART_ScrollViewer only exists once the template is applied. Disabling its horizontal
// scroll constrains the WrapPanel to the strip width so it wraps onto new rows.
strip.TemplateApplied += (_, _) => ConstrainScroller(strip);
ConstrainScroller(strip);
var settings = TryGetSessionSettings();
PropertyChangedEventHandler? settingsHandler = null;
if (settings is not null)
{
settingsHandler = (_, args) => {
if (args.PropertyName == nameof(SessionSettings.MultiLineDocumentTabs))
ApplyMode(strip, settings.MultiLineDocumentTabs);
};
settings.PropertyChanged += settingsHandler;
strip.DetachedFromVisualTree += (_, _) => {
if (settingsHandler is not null)
settings.PropertyChanged -= settingsHandler;
};
}
static void ConstrainScroller(DocumentTabStrip strip)
strip.TemplateApplied += (_, _) => ApplyMode(strip, CurrentMultiLine());
ApplyMode(strip, CurrentMultiLine());
}
static void OnPointerWheel(object? sender, PointerWheelEventArgs e)
{
var settings = TryGetSessionSettings();
if (settings is null || e.Delta.Y == 0)
return;
// Idempotent set: rolling further in the same direction keeps the same mode rather than
// flip-flopping, so a multi-detent gesture settles cleanly.
settings.MultiLineDocumentTabs = e.Delta.Y > 0;
e.Handled = true;
}
static void ApplyMode(DocumentTabStrip strip, bool multiLine)
{
// The presenter builds its panel from ItemsPanel; swapping it re-flows the tabs.
strip.ItemsPanel = multiLine
? new FuncTemplate<Panel?>(() => new WrapPanel { Orientation = Orientation.Horizontal })
: new FuncTemplate<Panel?>(() => new StackPanel { Orientation = Orientation.Horizontal });
var scroller = strip.GetVisualDescendants().OfType<ScrollViewer>()
.FirstOrDefault(s => s.Name == "PART_ScrollViewer");
if (scroller is null)
return;
if (multiLine)
{
// Disabling horizontal scroll constrains the WrapPanel to the strip width so it wraps.
scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Auto;
}
else
{
scroller.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;
scroller.VerticalScrollBarVisibility = ScrollBarVisibility.Disabled;
}
}
static bool CurrentMultiLine() => TryGetSessionSettings()?.MultiLineDocumentTabs ?? false;
static SessionSettings? TryGetSessionSettings()
{
try
{ return AppComposition.Current.GetExport<SettingsService>().SessionSettings; }
catch { return null; }
}
}
}

Loading…
Cancel
Save