using System.Reflection; using System.Runtime.CompilerServices; using AtomUI.Controls.Primitives; 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 BaseOwnerKinds => [OwnerKind.TabControl, OwnerKind.TabStrip]; public static TheoryData 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()) { 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 { 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()) 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(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().ShouldHaveSingleItem(); private static TabOverflowMenuItem[] GetContainers(TabOverflowMenu menu) => Enumerable.Range(0, menu.ItemCount) .Select(index => menu.ContainerFromIndex(index).ShouldBeOfType()).ToArray(); private static ContentPresenter HeaderPresenter(Control item) => item.GetVisualDescendants().OfType() .Single(presenter => presenter.Name == "ItemTextPresenter"); private static TabOverflowMenu Open(TabScrollViewer viewer, AvaloniaWindow window) { var indicator = viewer.GetVisualDescendants().OfType() .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(); 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 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); }