using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; using Shouldly; using Xunit; using AvaloniaScrollViewer = Avalonia.Controls.ScrollViewer; using AvaloniaWindow = Avalonia.Controls.Window; namespace AtomUI.Desktop.Controls.Tests.TabControl; public class TabContainerLayoutReviewTests { static TabContainerLayoutReviewTests() { AvaloniaTestApp.EnsureInitialized(); } public static TheoryData OwnerPlacementCases { get { var cases = new TheoryData(); foreach (var strip in new[] { false, true }) { foreach (var placement in new[] { Dock.Top, Dock.Bottom, Dock.Left, Dock.Right }) { cases.Add(strip, placement); } } return cases; } } // Degenerate arrange uses the same branch for both sides of each axis. public static TheoryData NarrowArrangeAxisCases => new() { { false, Dock.Top }, { true, Dock.Left } }; [Theory] [MemberData(nameof(OwnerPlacementCases))] public void Centered_Card_Tabs_Reserve_The_Add_Button_Without_Unnecessary_Overflow(bool strip, Dock placement) { var owner = CreateCardOwner(strip, placement, itemCount: 3); owner.Width = 900; owner.Height = 600; if (owner is CardTabControl tabs) { tabs.TabAlignmentCenter = true; } else { ((CardTabStrip)owner).TabAlignmentCenter = true; } ShowInWindow(owner, window => { var viewer = FindViewer(owner); var addButton = FindAddButton(owner); var panel = owner.GetVisualDescendants().OfType().ShouldHaveSingleItem(); var horizontal = placement is Dock.Top or Dock.Bottom; addButton.IsVisible.ShouldBeTrue(); (horizontal ? viewer.ScrollBarMaximum.X : viewer.ScrollBarMaximum.Y).ShouldBe(0, 0.5); viewer.GetVisualDescendants().OfType() .Single(button => button.Name == "PART_ScrollMenuIndicator").IsVisible.ShouldBeFalse(); var viewerBounds = BoundsIn(viewer, panel); var buttonBounds = BoundsIn(addButton, panel); if (horizontal) { buttonBounds.Left.ShouldBeGreaterThanOrEqualTo(viewerBounds.Right); buttonBounds.Right.ShouldBeLessThanOrEqualTo(panel.Bounds.Width + 0.5); } else { buttonBounds.Top.ShouldBeGreaterThanOrEqualTo(viewerBounds.Bottom); buttonBounds.Bottom.ShouldBeLessThanOrEqualTo(panel.Bounds.Height + 0.5); } AvaloniaHeadlessPlatform.ForceRenderTimerTick(); Dispatcher.UIThread.RunJobs(); var buttonPoint = addButton.TranslatePoint(new Point(addButton.Bounds.Width / 2, addButton.Bounds.Height / 2), window).ShouldNotBeNull(); var hit = window.InputHitTest(buttonPoint) as Visual; (ReferenceEquals(hit, addButton) || hit?.GetVisualAncestors().Contains(addButton) == true).ShouldBeTrue(); }); } [Theory] [MemberData(nameof(NarrowArrangeAxisCases))] public void Card_Tabs_Tolerate_Arrange_Smaller_Than_The_Measured_Add_Button(bool strip, Dock placement) { var owner = CreateCardOwner(strip, placement, itemCount: 3); owner.Width = double.NaN; owner.Height = double.NaN; ShowInWindow(owner, _ => { var addButton = FindAddButton(owner); var horizontal = placement is Dock.Top or Dock.Bottom; (horizontal ? addButton.DesiredSize.Width : addButton.DesiredSize.Height).ShouldBeGreaterThan(20); owner.Measure(new Size(900, 600)); Should.NotThrow(() => owner.Arrange(new Rect(0, 0, 20, 20))); var panel = owner.GetVisualDescendants().OfType().ShouldHaveSingleItem(); foreach (var child in new Control[] { panel, FindViewer(owner), addButton }) { double.IsFinite(child.Bounds.Width).ShouldBeTrue(); double.IsFinite(child.Bounds.Height).ShouldBeTrue(); child.Bounds.Width.ShouldBeGreaterThanOrEqualTo(0); child.Bounds.Height.ShouldBeGreaterThanOrEqualTo(0); child.Bounds.X.ShouldBeGreaterThanOrEqualTo(0); child.Bounds.Y.ShouldBeGreaterThanOrEqualTo(0); } }); } [Theory] [MemberData(nameof(OwnerPlacementCases))] public void Line_Tabs_Center_The_Visible_Tab_Group_Along_The_Placement_Axis(bool strip, Dock placement) { var owner = CreateLineOwner(strip, placement); ShowInWindow(owner, _ => { var wrapper = owner.GetVisualDescendants().OfType() .Single(panel => panel.Name is "PART_AlignWrapper" or "AlignWrapper"); var first = owner.ContainerFromIndex(0).ShouldNotBeNull(); var last = owner.ContainerFromIndex(2).ShouldNotBeNull(); var firstBounds = BoundsIn(first, wrapper); var lastBounds = BoundsIn(last, wrapper); if (placement is Dock.Top or Dock.Bottom) { firstBounds.Left.ShouldBeGreaterThan(0); lastBounds.Right.ShouldBeLessThan(wrapper.Bounds.Width); ((firstBounds.Left + lastBounds.Right) / 2).ShouldBe(wrapper.Bounds.Width / 2, 0.5); } else { firstBounds.Top.ShouldBeGreaterThan(0); lastBounds.Bottom.ShouldBeLessThan(wrapper.Bounds.Height); ((firstBounds.Top + lastBounds.Bottom) / 2).ShouldBe(wrapper.Bounds.Height / 2, 0.5); } }); } [Theory] [InlineData(false, FlowDirection.LeftToRight)] [InlineData(false, FlowDirection.RightToLeft)] [InlineData(true, FlowDirection.LeftToRight)] [InlineData(true, FlowDirection.RightToLeft)] public void Horizontal_Wheel_Follows_Flow_Direction_While_Vertical_Wheel_Keeps_Logical_Order( bool strip, FlowDirection flowDirection) { var owner = CreateCardOwner(strip, Dock.Top, itemCount: 12); owner.FlowDirection = flowDirection; ShowInWindow(owner, window => { var viewer = FindViewer(owner); var middle = viewer.ScrollBarMaximum.X / 2; middle.ShouldBeGreaterThan(50); viewer.Offset = new Vector(middle, 0); window.UpdateLayout(); WheelOverPresenter(window, viewer, new Vector(1, 0)); if (flowDirection == FlowDirection.RightToLeft) viewer.Offset.X.ShouldBeGreaterThan(middle); else viewer.Offset.X.ShouldBeLessThan(middle); viewer.Offset = new Vector(middle, 0); window.UpdateLayout(); WheelOverPresenter(window, viewer, new Vector(0, -1)); viewer.Offset.X.ShouldBeGreaterThan(middle); }); } [Theory] [InlineData(false, false, false)] [InlineData(false, false, true)] [InlineData(false, true, false)] [InlineData(false, true, true)] [InlineData(true, false, false)] [InlineData(true, false, true)] [InlineData(true, true, false)] [InlineData(true, true, true)] public void Rtl_Horizontal_Wheel_Chains_Only_At_The_Requested_Boundary( bool strip, bool atEnd, bool chaining) { var owner = CreateCardOwner(strip, Dock.Top, itemCount: 12); owner.FlowDirection = FlowDirection.RightToLeft; var outer = new AvaloniaScrollViewer { HorizontalScrollBarVisibility = ScrollBarVisibility.Hidden, VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, Content = new Border { Width = 900, Height = 220, Child = owner } }; ShowInWindow(outer, window => { var viewer = FindViewer(owner); viewer.IsScrollChainingEnabled = chaining; var boundary = atEnd ? viewer.ScrollBarMaximum.X : 0; viewer.Offset = new Vector(boundary, 0); var outerMiddle = outer.ScrollBarMaximum.X / 2; outerMiddle.ShouldBeGreaterThan(50); outer.Offset = new Vector(outerMiddle, 0); window.UpdateLayout(); WheelOverPresenter(window, viewer, new Vector(atEnd ? 1 : -1, 0)); viewer.Offset.X.ShouldBe(boundary, 0.5); if (!chaining) outer.Offset.X.ShouldBe(outerMiddle, 0.5); else if (atEnd) outer.Offset.X.ShouldBeLessThan(outerMiddle); else outer.Offset.X.ShouldBeGreaterThan(outerMiddle); }, width: 500, height: 260); } private static ItemsControl CreateLineOwner(bool strip, Dock placement) { if (strip) { var owner = new AtomUI.Desktop.Controls.TabStrip { Width = 900, Height = 600, TabStripPlacement = placement, TabAlignmentCenter = true, IsMotionEnabled = false, SelectedIndex = 0 }; for (var index = 0; index < 3; index++) { owner.Items.Add(new TabStripItem { Content = new Border { Width = 100, Height = 24 } }); } return owner; } var tabs = new AtomUI.Desktop.Controls.TabControl { Width = 900, Height = 600, TabStripPlacement = placement, TabAlignmentCenter = true, IsMotionEnabled = false, SelectedIndex = 0 }; for (var index = 0; index < 3; index++) { tabs.Items.Add(new AtomUI.Desktop.Controls.TabItem { Header = new Border { Width = 100, Height = 24 }, Content = $"Content {index}" }); } return tabs; } private static Control CreateCardOwner(bool strip, Dock placement, int itemCount) { if (strip) { var owner = new CardTabStrip { Width = 340, Height = 220, TabStripPlacement = placement, IsShowAddTabButton = true, IsMotionEnabled = false, SelectedIndex = 0 }; for (var index = 0; index < itemCount; index++) { owner.Items.Add(new TabStripItem { Content = new Border { Width = 100, Height = 24 } }); } return owner; } var tabs = new CardTabControl { Width = 340, Height = 220, TabStripPlacement = placement, IsShowAddTabButton = true, IsMotionEnabled = false, SelectedIndex = 0 }; for (var index = 0; index < itemCount; index++) { tabs.Items.Add(new AtomUI.Desktop.Controls.TabItem { Header = new Border { Width = 100, Height = 24 }, Content = $"Content {index}" }); } return tabs; } private static TabScrollViewer FindViewer(Control owner) => owner.GetVisualDescendants().OfType().ShouldHaveSingleItem(); private static IconButton FindAddButton(Control owner) => owner.GetVisualDescendants().OfType().Single(button => button.Name == "PART_AddTabButton"); private static Rect BoundsIn(Control control, Visual relativeTo) => new Rect(control.Bounds.Size).TransformToAABB(control.TransformToVisual(relativeTo).ShouldNotBeNull()); private static void WheelOverPresenter(AvaloniaWindow window, TabScrollViewer viewer, Vector delta) { var presenter = viewer.GetVisualDescendants().OfType().ShouldHaveSingleItem(); var point = presenter.TranslatePoint(new Point(presenter.Bounds.Width / 2, presenter.Bounds.Height / 2), window).ShouldNotBeNull(); window.MouseMove(point); window.MouseWheel(point, delta, RawInputModifiers.None); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); } private static void ShowInWindow(Control content, Action assertion, double width = 900, double height = 600) { var window = new AvaloniaWindow { Width = width, Height = height, Content = content }; try { window.Show(); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); assertion(window); } finally { window.Close(); Dispatcher.UIThread.RunJobs(); } } }