mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-24 10:12:35 +08:00
482 lines
20 KiB
C#
482 lines
20 KiB
C#
using System.Reflection;
|
|
using System.Runtime.CompilerServices;
|
|
using AtomUI.Controls.Primitives;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Presenters;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Controls.Templates;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Markup.Xaml.MarkupExtensions;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using AvaloniaButton = Avalonia.Controls.Button;
|
|
using AvaloniaWindow = Avalonia.Controls.Window;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.TabControl;
|
|
|
|
public class TabOverflowMenuReuseTests
|
|
{
|
|
private const string ReuseBrushKey = "TabOverflowReuseBrush";
|
|
|
|
static TabOverflowMenuReuseTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
public enum OwnerKind { TabControl, CardTabControl, TabStrip, CardTabStrip }
|
|
|
|
// Item projection differs between the two owner bases; the cache itself is shared.
|
|
public static TheoryData<OwnerKind> BaseOwnerKinds =>
|
|
[OwnerKind.TabControl, OwnerKind.TabStrip];
|
|
|
|
public static TheoryData<OwnerKind> SharedViewerOwner => [OwnerKind.TabControl];
|
|
|
|
[Theory]
|
|
[MemberData(nameof(BaseOwnerKinds))]
|
|
public void Reopening_Reuses_Empty_Containers_And_Their_Templates_With_Current_State_And_Resources(OwnerKind kind)
|
|
{
|
|
WithOwner(kind, (owner, _, window) =>
|
|
{
|
|
var viewer = GetViewer(owner);
|
|
var menu = Open(viewer, window);
|
|
var firstContainers = GetContainers(menu);
|
|
firstContainers.Length.ShouldBe(menu.ItemCount);
|
|
firstContainers.Length.ShouldBeGreaterThan(5);
|
|
var templateRoots = firstContainers.ToDictionary(item => item,
|
|
item => item.GetVisualChildren().ShouldHaveSingleItem());
|
|
var menuSize = menu.Bounds.Size;
|
|
menu.Resources[ReuseBrushKey] = Brushes.Red;
|
|
foreach (var item in firstContainers)
|
|
{
|
|
item.Bind(TemplatedControl.BackgroundProperty, new DynamicResourceExtension(ReuseBrushKey));
|
|
item.Background.ShouldBeSameAs(Brushes.Red);
|
|
}
|
|
|
|
Close(viewer, window);
|
|
menu.ItemCount.ShouldBe(0);
|
|
menu.Resources[ReuseBrushKey] = Brushes.Blue;
|
|
owner.SelectedIndex = owner.ItemCount - 1;
|
|
SetItemState((Control)owner.Items[1]!, isEnabled: false, isClosable: true);
|
|
SetItemState((Control)owner.Items[2]!, isEnabled: true, isClosable: false);
|
|
DrainLayout(window);
|
|
|
|
Open(viewer, window).ShouldBeSameAs(menu);
|
|
var reopened = GetContainers(menu);
|
|
reopened.Length.ShouldBe(firstContainers.Length);
|
|
menu.Bounds.Width.ShouldBe(menuSize.Width, 0.5);
|
|
menu.Bounds.Height.ShouldBe(menuSize.Height, 0.5);
|
|
foreach (var item in reopened)
|
|
{
|
|
firstContainers.ShouldContain(item);
|
|
item.GetVisualChildren().ShouldHaveSingleItem().ShouldBeSameAs(templateRoots[item]);
|
|
item.Background.ShouldBeSameAs(Brushes.Blue);
|
|
var snapshot = item.OverflowItem.ShouldNotBeNull();
|
|
item.Header.ShouldBe(snapshot.Header);
|
|
item.HeaderTemplate.ShouldBeSameAs(snapshot.HeaderTemplate);
|
|
item.IsEnabled.ShouldBe(snapshot.IsEnabled);
|
|
item.IsClosable.ShouldBe(snapshot.IsClosable);
|
|
item.IsSelected.ShouldBe(snapshot.IsSelected);
|
|
}
|
|
reopened.Single(item => ReferenceEquals(item.OverflowItem!.Item, owner.Items[1])).IsEnabled.ShouldBeFalse();
|
|
reopened.Single(item => ReferenceEquals(item.OverflowItem!.Item, owner.Items[2])).IsClosable.ShouldBeFalse();
|
|
reopened.Count(item => item.IsSelected).ShouldBe(1);
|
|
|
|
var firstEnabled = reopened.First(item => item.IsEnabled);
|
|
var lastEnabled = reopened.Last(item => item.IsEnabled);
|
|
firstEnabled.Focus().ShouldBeTrue();
|
|
window.KeyPress(Key.End, RawInputModifiers.None, PhysicalKey.End, null);
|
|
DrainLayout(window);
|
|
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(lastEnabled);
|
|
window.KeyPress(Key.Home, RawInputModifiers.None, PhysicalKey.Home, null);
|
|
DrainLayout(window);
|
|
window.FocusManager.GetFocusedElement().ShouldBeSameAs(firstEnabled);
|
|
});
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SharedViewerOwner))]
|
|
public void The_Empty_Cache_Shrinks_To_The_Most_Recent_Snapshot(OwnerKind kind)
|
|
{
|
|
WithOwner(kind, (owner, _, window) =>
|
|
{
|
|
var result = OpenLargeThenSmall(owner, window);
|
|
CollectGarbage();
|
|
|
|
result.Menu.ItemCount.ShouldBe(0);
|
|
result.ContainerReferences.Count(reference => reference.IsAlive).ShouldBe(result.LatestCount);
|
|
result.LatestCount.ShouldBeLessThan(result.ContainerReferences.Length);
|
|
GC.KeepAlive(result.Menu);
|
|
});
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(BaseOwnerKinds))]
|
|
public void Closed_Cached_Containers_Release_Snapshots_Headers_Templates_And_Template_DataContexts(OwnerKind kind)
|
|
{
|
|
WithOwner(kind, (owner, _, window) =>
|
|
{
|
|
var result = OpenAndClearPayloads(owner, window);
|
|
foreach (var container in result.Containers)
|
|
{
|
|
container.OverflowItem.ShouldBeNull();
|
|
container.Header.ShouldBeNull();
|
|
container.HeaderTemplate.ShouldBeNull();
|
|
container.Content.ShouldBeNull();
|
|
container.ContentTemplate.ShouldBeNull();
|
|
container.DataContext.ShouldBeNull();
|
|
var presenter = HeaderPresenter(container);
|
|
presenter.Content.ShouldBeNull();
|
|
presenter.ContentTemplate.ShouldBeNull();
|
|
presenter.Child.ShouldBeNull();
|
|
presenter.DataContext.ShouldBeNull();
|
|
}
|
|
|
|
// Avalonia 12.1.2 keeps the last rendered composition tree reachable from a
|
|
// detached cached root: DetachFromCompositor clears DrawList but not
|
|
// CompositionVisual.Children, and Border retains _borderVisual. The cached
|
|
// menu therefore holds the previous frame's composition chain — including
|
|
// removed header template children — until the next open rebuilds it.
|
|
// Reopen once with fresh items, then every old payload must be collectible.
|
|
for (var index = 0; index < 12; index++)
|
|
{
|
|
owner.Items.Add(CreateReplacementItem(kind, $"Replacement {index:00}"));
|
|
}
|
|
DrainLayout(window);
|
|
var viewer = GetViewer(owner);
|
|
Open(viewer, window);
|
|
CommitPendingVisualChanges();
|
|
Close(viewer, window);
|
|
CommitPendingVisualChanges();
|
|
|
|
CollectGarbage();
|
|
result.Payloads.Where(reference => reference.Reference.IsAlive)
|
|
.Select(reference => reference.Name)
|
|
.ShouldBeEmpty(RetainedPayloadPath(result, owner, window));
|
|
GC.KeepAlive(result.Containers);
|
|
GC.KeepAlive(result.Menu);
|
|
});
|
|
}
|
|
|
|
private static string RetainedPayloadPath(PayloadReferences result, Control owner, AvaloniaWindow window)
|
|
{
|
|
return result.Payloads.Any(reference => reference.Reference.IsAlive)
|
|
? DescribeRetainedHeaderPath(result, owner, window)
|
|
: string.Empty;
|
|
}
|
|
|
|
private static Control CreateReplacementItem(OwnerKind kind, string header)
|
|
{
|
|
Control item = kind is OwnerKind.TabControl or OwnerKind.CardTabControl
|
|
? new AtomUI.Desktop.Controls.TabItem()
|
|
: new TabStripItem();
|
|
SetHeader(item, header, null);
|
|
SetItemState(item, isEnabled: true, isClosable: false);
|
|
return item;
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(SharedViewerOwner))]
|
|
public void Full_Teardown_Releases_The_Menu_And_Its_Empty_Container_Cache(OwnerKind kind)
|
|
{
|
|
WithOwner(kind, (owner, _, window) =>
|
|
{
|
|
var references = CreateTornDownReferences(owner, window);
|
|
CollectGarbage();
|
|
|
|
references.Where(reference => reference.Reference.IsAlive)
|
|
.Select(reference => reference.Name).ShouldBeEmpty();
|
|
GetViewer(owner).OverflowPopupContext.ShouldBeNull();
|
|
GetViewer(owner).OverflowPopupRoot.ShouldBeNull();
|
|
});
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static CacheReferences OpenLargeThenSmall(SelectingItemsControl owner, AvaloniaWindow window)
|
|
{
|
|
var viewer = GetViewer(owner);
|
|
var menu = Open(viewer, window);
|
|
var references = GetContainers(menu).Select(item => new WeakReference(item)).ToArray();
|
|
Close(viewer, window);
|
|
while (owner.Items.Count > 4) owner.Items.RemoveAt(owner.Items.Count - 1);
|
|
DrainLayout(window);
|
|
Open(viewer, window).ShouldBeSameAs(menu);
|
|
var latestCount = menu.ItemCount;
|
|
latestCount.ShouldBeGreaterThan(0);
|
|
Close(viewer, window);
|
|
CommitPendingVisualChanges();
|
|
return new CacheReferences(menu, references, latestCount);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static PayloadReferences OpenAndClearPayloads(SelectingItemsControl owner, AvaloniaWindow window)
|
|
{
|
|
var graph = new object();
|
|
var template = new PayloadTemplate(graph);
|
|
foreach (var item in owner.Items.Cast<Control>())
|
|
{
|
|
SetHeader(item, new HeaderPayload("Wide header with retained data", graph), template);
|
|
}
|
|
DrainLayout(window);
|
|
var viewer = GetViewer(owner);
|
|
var menu = Open(viewer, window);
|
|
var containers = GetContainers(menu);
|
|
var snapshot = viewer.OverflowPopupContext!.Items;
|
|
var references = new List<NamedReference>
|
|
{
|
|
new("Header graph", new WeakReference(graph)),
|
|
new("Header template", new WeakReference(template)),
|
|
new("Snapshot array", new WeakReference(snapshot))
|
|
};
|
|
for (var index = 0; index < containers.Length; index++)
|
|
{
|
|
var container = containers[index];
|
|
references.Add(new NamedReference($"Snapshot item {index}", new WeakReference(container.OverflowItem!)));
|
|
references.Add(new NamedReference($"Header {index}", new WeakReference(container.Header!)));
|
|
references.Add(new NamedReference($"Header template child {index}",
|
|
new WeakReference(HeaderPresenter(container).Child.ShouldNotBeNull())));
|
|
}
|
|
Close(viewer, window);
|
|
foreach (var item in owner.Items.Cast<Control>()) SetHeader(item, "Replacement header", null);
|
|
owner.Items.Clear();
|
|
DrainLayout(window);
|
|
CommitPendingVisualChanges();
|
|
return new PayloadReferences(menu, containers, references.ToArray());
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static NamedReference[] CreateTornDownReferences(SelectingItemsControl owner, AvaloniaWindow window)
|
|
{
|
|
var viewer = GetViewer(owner);
|
|
var menu = Open(viewer, window);
|
|
var containers = GetContainers(menu);
|
|
var references = containers
|
|
.Select((item, index) => new NamedReference($"Container {index}", new WeakReference(item))).ToList();
|
|
references.Add(new NamedReference("Menu", new WeakReference(menu)));
|
|
Close(viewer, window);
|
|
viewer.CloseForLifecycle();
|
|
DrainLayout(window);
|
|
CommitPendingVisualChanges();
|
|
return references.ToArray();
|
|
}
|
|
|
|
private static void CommitPendingVisualChanges()
|
|
{
|
|
// Match the headless input helpers: committing a frame can enqueue work
|
|
// for another frame, including removal of detached composition visuals.
|
|
for (var iteration = 0; iteration < 10; iteration++)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
AvaloniaHeadlessPlatform.ForceRenderTimerTick();
|
|
if (!Dispatcher.UIThread.HasJobsWithPriority(DispatcherPriority.SystemIdle))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
throw new InvalidOperationException("Pending visual changes did not settle within ten frames.");
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static string DescribeRetainedHeaderPath(PayloadReferences result, Control owner, AvaloniaWindow window)
|
|
{
|
|
var target = result.Payloads.FirstOrDefault(reference =>
|
|
reference.Reference.IsAlive && reference.Name.StartsWith("Header ", StringComparison.Ordinal))?.Reference.Target;
|
|
if (target is null) return "No live header payload to describe.";
|
|
|
|
var paths = new Dictionary<object, string>(ReferenceEqualityComparer.Instance)
|
|
{
|
|
[result.Menu] = "Menu",
|
|
[owner] = "Owner",
|
|
[window] = "Window"
|
|
};
|
|
var queue = new Queue<(object Value, int Depth)>();
|
|
queue.Enqueue((result.Menu, 0));
|
|
queue.Enqueue((owner, 0));
|
|
queue.Enqueue((window, 0));
|
|
for (var index = 0; index < result.Containers.Length; index++)
|
|
{
|
|
paths[result.Containers[index]] = $"Container {index}";
|
|
queue.Enqueue((result.Containers[index], 0));
|
|
}
|
|
|
|
while (queue.TryDequeue(out var entry) && paths.Count < 600000)
|
|
{
|
|
if (entry.Depth >= 80) continue;
|
|
foreach (var (name, child) in ReadReferences(entry.Value))
|
|
{
|
|
if (child is null || paths.ContainsKey(child)) continue;
|
|
var type = child.GetType();
|
|
if (type.IsPrimitive || type.IsEnum || child is string or Type or MemberInfo or Assembly or Module ||
|
|
type.FullName?.StartsWith("System.WeakReference", StringComparison.Ordinal) == true) continue;
|
|
|
|
var path = paths[entry.Value] + $".{name}({type.Name})";
|
|
if (ReferenceEquals(child, target)) return path;
|
|
paths[child] = path;
|
|
queue.Enqueue((child, entry.Depth + 1));
|
|
}
|
|
}
|
|
return $"No strong path to the retained header child from Menu/Owner/Window/Containers; visited {paths.Count} objects.";
|
|
|
|
static IEnumerable<(string Name, object? Value)> ReadReferences(object value)
|
|
{
|
|
if (value is Array array)
|
|
{
|
|
var index = 0;
|
|
foreach (var item in array) yield return ($"[{index++}]", item);
|
|
yield break;
|
|
}
|
|
for (var type = value.GetType(); type is not null; type = type.BaseType)
|
|
{
|
|
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public |
|
|
BindingFlags.NonPublic | BindingFlags.DeclaredOnly))
|
|
{
|
|
object? child;
|
|
try { child = field.GetValue(value); }
|
|
catch { continue; }
|
|
yield return (field.Name, child);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static SelectingItemsControl CreateOwner(OwnerKind kind)
|
|
{
|
|
SelectingItemsControl owner = kind switch
|
|
{
|
|
OwnerKind.TabControl => new AtomUI.Desktop.Controls.TabControl { IsMotionEnabled = false },
|
|
OwnerKind.CardTabControl => new CardTabControl { IsMotionEnabled = false },
|
|
OwnerKind.TabStrip => new AtomUI.Desktop.Controls.TabStrip { IsMotionEnabled = false },
|
|
OwnerKind.CardTabStrip => new CardTabStrip { IsMotionEnabled = false },
|
|
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
|
};
|
|
owner.Width = 100;
|
|
owner.Height = 240;
|
|
owner.SelectedIndex = 0;
|
|
for (var index = 0; index < 12; index++)
|
|
{
|
|
Control item = kind is OwnerKind.TabControl or OwnerKind.CardTabControl
|
|
? new AtomUI.Desktop.Controls.TabItem()
|
|
: new TabStripItem();
|
|
SetHeader(item, $"Document {index:00} with a wide header", null);
|
|
SetItemState(item, index != 9, index % 3 == 0);
|
|
owner.Items.Add(item);
|
|
}
|
|
return owner;
|
|
}
|
|
|
|
private static void SetHeader(Control item, object header, IDataTemplate? template)
|
|
{
|
|
if (item is AtomUI.Desktop.Controls.TabItem tab)
|
|
{
|
|
tab.Header = header;
|
|
tab.HeaderTemplate = template;
|
|
}
|
|
else
|
|
{
|
|
var strip = (TabStripItem)item;
|
|
strip.Content = header;
|
|
strip.ContentTemplate = template;
|
|
}
|
|
}
|
|
|
|
private static void SetItemState(Control item, bool isEnabled, bool isClosable)
|
|
{
|
|
item.IsEnabled = isEnabled;
|
|
if (item is AtomUI.Desktop.Controls.TabItem tab) tab.IsClosable = isClosable;
|
|
else ((TabStripItem)item).IsClosable = isClosable;
|
|
}
|
|
|
|
private static TabScrollViewer GetViewer(Control owner) =>
|
|
owner.GetVisualDescendants().OfType<TabScrollViewer>().ShouldHaveSingleItem();
|
|
|
|
private static TabOverflowMenuItem[] GetContainers(TabOverflowMenu menu) =>
|
|
Enumerable.Range(0, menu.ItemCount)
|
|
.Select(index => menu.ContainerFromIndex(index).ShouldBeOfType<TabOverflowMenuItem>()).ToArray();
|
|
|
|
private static ContentPresenter HeaderPresenter(Control item) =>
|
|
item.GetVisualDescendants().OfType<ContentPresenter>()
|
|
.Single(presenter => presenter.Name == "ItemTextPresenter");
|
|
|
|
private static TabOverflowMenu Open(TabScrollViewer viewer, AvaloniaWindow window)
|
|
{
|
|
var indicator = viewer.GetVisualDescendants().OfType<IconButton>()
|
|
.Single(button => button.Name == "PART_ScrollMenuIndicator");
|
|
indicator.IsVisible.ShouldBeTrue();
|
|
indicator.RaiseEvent(new RoutedEventArgs(AvaloniaButton.ClickEvent, indicator));
|
|
DrainLayout(window);
|
|
viewer.IsOverflowPopupOpen.ShouldBeTrue();
|
|
var menu = viewer.OverflowPopupRoot.ShouldBeOfType<TabOverflowMenu>();
|
|
foreach (var container in GetContainers(menu)) container.IsMotionEnabled = false;
|
|
return menu;
|
|
}
|
|
|
|
private static void Close(TabScrollViewer viewer, AvaloniaWindow window)
|
|
{
|
|
viewer.CloseOverflowPopup();
|
|
DrainLayout(window);
|
|
viewer.IsOverflowPopupOpen.ShouldBeFalse();
|
|
viewer.OverflowPopupContext!.Items.ShouldBeEmpty();
|
|
}
|
|
|
|
private static void WithOwner(OwnerKind kind,
|
|
Action<SelectingItemsControl, ScopeAwareOverlayLayerPanel, AvaloniaWindow> assertion)
|
|
{
|
|
var owner = CreateOwner(kind);
|
|
var host = new ScopeAwareOverlayLayerPanel();
|
|
host.Children.Add(owner);
|
|
var layers = new VisualLayerManager { Child = host };
|
|
typeof(VisualLayerManager).GetProperty("EnablePopupOverlayLayer",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(layers, true);
|
|
var window = new AvaloniaWindow { Width = 480, Height = 360, Content = layers };
|
|
try
|
|
{
|
|
window.Show();
|
|
DrainLayout(window);
|
|
assertion(owner, host, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
}
|
|
|
|
private static void DrainLayout(AvaloniaWindow window)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
window.UpdateLayout();
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static void CollectGarbage()
|
|
{
|
|
for (var iteration = 0; iteration < 3; iteration++)
|
|
{
|
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
|
|
GC.WaitForPendingFinalizers();
|
|
}
|
|
}
|
|
|
|
private sealed record HeaderPayload(string Text, object Graph);
|
|
|
|
private sealed class PayloadTemplate(object graph) : IDataTemplate
|
|
{
|
|
public bool Match(object? data) => data is HeaderPayload;
|
|
|
|
public Control Build(object? data) => new Border
|
|
{
|
|
Width = 180, Height = 24, DataContext = data, Tag = graph,
|
|
Child = new TextBlock { Text = (data as HeaderPayload)?.Text }
|
|
};
|
|
}
|
|
|
|
private sealed record CacheReferences(TabOverflowMenu Menu, WeakReference[] ContainerReferences, int LatestCount);
|
|
private sealed record PayloadReferences(TabOverflowMenu Menu, TabOverflowMenuItem[] Containers, NamedReference[] Payloads);
|
|
private sealed record NamedReference(string Name, WeakReference Reference);
|
|
}
|