mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-28 23:08:36 +08:00
1125 lines
41 KiB
C#
1125 lines
41 KiB
C#
using Avalonia;
|
|
using Avalonia.Animation;
|
|
using Avalonia.Collections;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Media;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using AtomCardTabControl = AtomUI.Desktop.Controls.CardTabControl;
|
|
using AtomCardTabStrip = AtomUI.Desktop.Controls.CardTabStrip;
|
|
using AtomTabControl = AtomUI.Desktop.Controls.TabControl;
|
|
using AtomTabItem = AtomUI.Desktop.Controls.TabItem;
|
|
using AtomTabStrip = AtomUI.Desktop.Controls.TabStrip;
|
|
using AtomTabStripItem = AtomUI.Desktop.Controls.TabStripItem;
|
|
using AvaloniaWindow = Avalonia.Controls.Window;
|
|
|
|
namespace AtomUI.Desktop.Controls.Tests.TabControl;
|
|
|
|
public class TabReorderTests
|
|
{
|
|
static TabReorderTests()
|
|
{
|
|
AvaloniaTestApp.EnsureInitialized();
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Drag_Reorders_Writable_ItemsSource_And_Keeps_Selected_Item()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new AvaloniaList<TabItemData> { first, second, third };
|
|
var events = new List<TabReorderedEventArgs>();
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source,
|
|
SelectedItem = second
|
|
};
|
|
tabControl.TabReordered += (_, args) => events.Add(args);
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
DragContainerAfter(window, tabControl, 1, 2);
|
|
|
|
source.ShouldBe([first, third, second]);
|
|
tabControl.SelectedItem.ShouldBeSameAs(second);
|
|
tabControl.SelectedIndex.ShouldBe(2);
|
|
events.Count.ShouldBe(1);
|
|
events[0].Item.ShouldBeSameAs(second);
|
|
events[0].OldIndex.ShouldBe(1);
|
|
events[0].NewIndex.ShouldBe(2);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Reordering_Event_Can_Cancel_Commit()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new AvaloniaList<TabItemData> { first, second, third };
|
|
var reorderedCount = 0;
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source
|
|
};
|
|
tabControl.TabReordering += (_, args) => args.Cancel = true;
|
|
tabControl.TabReordered += (_, _) => reorderedCount++;
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
DragContainerAfter(window, tabControl, 1, 2);
|
|
|
|
source.ShouldBe([first, second, third]);
|
|
reorderedCount.ShouldBe(0);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Drag_Near_Overflow_Edge_AutoScrolls_Header_Viewport()
|
|
{
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 180,
|
|
Height = 140,
|
|
IsTabReorderEnabled = true
|
|
};
|
|
for (var i = 0; i < 12; i++)
|
|
{
|
|
tabControl.Items.Add(new AtomTabItem
|
|
{
|
|
Header = $"Tab {i + 1}",
|
|
Content = $"Content {i + 1}"
|
|
});
|
|
}
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var scrollViewer = GetVisualDescendant<BaseTabScrollViewer>(tabControl);
|
|
RunJobsUntil(() => scrollViewer.Extent.Width > scrollViewer.Viewport.Width);
|
|
scrollViewer.Offset.X.ShouldBe(0);
|
|
|
|
var source = GetContainer<AtomTabItem>(tabControl, 0);
|
|
DragContainerToScrollEndEdge(window, source, scrollViewer);
|
|
|
|
scrollViewer.Offset.X.ShouldBeGreaterThan(0);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Drag_Keeps_Dragged_Tab_Anchored_When_Header_Viewport_Scrolls()
|
|
{
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 180,
|
|
Height = 140,
|
|
IsTabReorderEnabled = true
|
|
};
|
|
for (var i = 0; i < 12; i++)
|
|
{
|
|
tabControl.Items.Add(new AtomTabItem
|
|
{
|
|
Header = $"Tab {i + 1}",
|
|
Content = $"Content {i + 1}"
|
|
});
|
|
}
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var scrollViewer = GetVisualDescendant<BaseTabScrollViewer>(tabControl);
|
|
RunJobsUntil(() => scrollViewer.Extent.Width > scrollViewer.Viewport.Width);
|
|
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 0);
|
|
var dragPoint = BeginDragOnTrack(window, tabControl.TabStripPlacement, dragged);
|
|
var beforeOffset = scrollViewer.Offset.X;
|
|
var beforeTranslate = GetTranslateX(dragged);
|
|
|
|
scrollViewer.Offset = new Vector(beforeOffset + 24, scrollViewer.Offset.Y);
|
|
Dispatcher.UIThread.RunJobs();
|
|
var offsetDelta = scrollViewer.Offset.X - beforeOffset;
|
|
offsetDelta.ShouldBeGreaterThan(0);
|
|
|
|
GetTranslateX(dragged).ShouldBe(
|
|
beforeTranslate + offsetDelta,
|
|
1.0,
|
|
"dragged tab preview must compensate header viewport scroll without waiting for another pointer move");
|
|
|
|
ReleasePointer(window, dragPoint);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Drag_Applies_Chrome_Like_Live_Reorder_Preview_Before_Release()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new AvaloniaList<TabItemData> { first, second, third };
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 1);
|
|
var shifted = GetContainer<AtomTabItem>(tabControl, 2);
|
|
dragged.IsMotionEnabled = false;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabControl.TabStripPlacement, dragged, shifted);
|
|
|
|
GetTranslateX(dragged).ShouldBeGreaterThan(0);
|
|
GetTranslateY(dragged).ShouldBe(0, "horizontal tab drag must stay on the tab track");
|
|
WaitForTranslateXLessThan(shifted, 0);
|
|
WaitForOpaqueBackground(dragged);
|
|
IsEffectivelyTransparent(dragged.Background).ShouldBeFalse("dragged tab must keep an opaque surface while it overlaps sibling tabs");
|
|
source.ShouldBe(
|
|
[first, second, third],
|
|
"live preview should not commit the logical source before pointer release");
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
source.ShouldBe(
|
|
[first, third, second],
|
|
"pointer release should commit the previewed target exactly once");
|
|
GetTranslateX(dragged).ShouldBe(0);
|
|
GetTranslateX(shifted).ShouldBe(0);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Drag_Shifts_Sibling_To_Actual_Line_Tab_Layout_Slot()
|
|
{
|
|
var source = new AvaloniaList<TabItemData>
|
|
{
|
|
new() { Header = "first" },
|
|
new() { Header = "second" },
|
|
new() { Header = "third" }
|
|
};
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 1);
|
|
var shifted = GetContainer<AtomTabItem>(tabControl, 2);
|
|
var expectedShift = TranslateToWindow(dragged, default, window).X -
|
|
TranslateToWindow(shifted, default, window).X;
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabControl.TabStripPlacement, dragged, shifted);
|
|
|
|
WaitForTranslateXCloseTo(shifted, expectedShift);
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void CardTabControl_Drag_Shifts_Sibling_To_Actual_Card_Tab_Layout_Slot()
|
|
{
|
|
var source = new AvaloniaList<TabItemData>
|
|
{
|
|
new() { Header = "first" },
|
|
new() { Header = "second" },
|
|
new() { Header = "third" }
|
|
};
|
|
var tabControl = new AtomCardTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 1);
|
|
var shifted = GetContainer<AtomTabItem>(tabControl, 2);
|
|
var expectedShift = TranslateToWindow(dragged, default, window).X -
|
|
TranslateToWindow(shifted, default, window).X;
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabControl.TabStripPlacement, dragged, shifted);
|
|
|
|
WaitForTranslateXCloseTo(shifted, expectedShift);
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Drag_Uses_Half_Threshold_For_Sibling_Shift_And_Commit()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new AvaloniaList<TabItemData> { first, second, third };
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 0);
|
|
var target = GetContainer<AtomTabItem>(tabControl, 1);
|
|
|
|
var beforeHalf = DragContainerToPreviewNearTargetHalf(
|
|
window,
|
|
tabControl.TabStripPlacement,
|
|
dragged,
|
|
target,
|
|
crossesHalf: false);
|
|
|
|
GetTranslateX(target).ShouldBe(
|
|
0,
|
|
"sibling tabs must not move before the dragged tab crosses the covered tab half-width threshold");
|
|
source.ShouldBe([first, second, third]);
|
|
|
|
ReleasePointer(window, beforeHalf);
|
|
source.ShouldBe(
|
|
[first, second, third],
|
|
"partial sibling displacement before the half-overlap threshold must not commit a reorder");
|
|
GetTranslateX(target).ShouldBe(0);
|
|
|
|
var draggedAgain = GetContainer<AtomTabItem>(tabControl, 0);
|
|
var targetAgain = GetContainer<AtomTabItem>(tabControl, 1);
|
|
var afterHalf = DragContainerToPreviewNearTargetHalf(
|
|
window,
|
|
tabControl.TabStripPlacement,
|
|
draggedAgain,
|
|
targetAgain,
|
|
crossesHalf: true);
|
|
GetTranslateX(draggedAgain).ShouldBeGreaterThan(0);
|
|
WaitForTranslateXLessThan(targetAgain, 0);
|
|
|
|
ReleasePointer(window, afterHalf);
|
|
source.ShouldBe([second, first, third]);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Drag_Animates_Sibling_Live_Reorder_Transform_And_Restores_Transitions()
|
|
{
|
|
var source = new AvaloniaList<TabItemData>
|
|
{
|
|
new() { Header = "first" },
|
|
new() { Header = "second" },
|
|
new() { Header = "third" }
|
|
};
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 0);
|
|
var shifted = GetContainer<AtomTabItem>(tabControl, 1);
|
|
var originalTransform = shifted.RenderTransform;
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabControl.TabStripPlacement, dragged, shifted);
|
|
|
|
var previewTransform = shifted.RenderTransform.ShouldBeOfType<TranslateTransform>();
|
|
previewTransform.Transitions.ShouldNotBeNull("sibling displacement should use short transform axis transitions instead of jumping instantly");
|
|
previewTransform.Transitions!.Any(IsTranslateTransformTransition).ShouldBeTrue();
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
|
|
ReferenceEquals(shifted.RenderTransform, originalTransform)
|
|
.ShouldBeTrue("temporary reorder transforms and their transitions must be removed after the drag session");
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Drag_Animates_Sibling_Return_To_Original_Position_During_Live_Reorder()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new AvaloniaList<TabItemData> { first, second, third };
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 0);
|
|
var shifted = GetContainer<AtomTabItem>(tabControl, 1);
|
|
|
|
DragContainerToPreviewNearTargetHalf(
|
|
window,
|
|
tabControl.TabStripPlacement,
|
|
dragged,
|
|
shifted,
|
|
crossesHalf: true);
|
|
WaitForTranslateXLessThan(shifted, 0);
|
|
|
|
var beforeHalf = DragPointerNearTargetHalf(
|
|
window,
|
|
tabControl.TabStripPlacement,
|
|
dragged,
|
|
shifted,
|
|
crossesHalf: false);
|
|
|
|
var previewTransform = shifted.RenderTransform.ShouldBeOfType<TranslateTransform>();
|
|
previewTransform.Transitions.ShouldNotBeNull("returning sibling tab should keep transform transitions during live reorder");
|
|
previewTransform.Transitions!.Any(IsTranslateTransformTransition).ShouldBeTrue();
|
|
|
|
ReleasePointer(window, beforeHalf);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Drag_Restores_All_Line_Tab_Transforms_After_Commit()
|
|
{
|
|
var source = new AvaloniaList<TabItemData>
|
|
{
|
|
new() { Header = "first" },
|
|
new() { Header = "second" },
|
|
new() { Header = "third" },
|
|
new() { Header = "fourth" }
|
|
};
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 520,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source,
|
|
SelectedItem = source[1]
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
DragContainerAfter(window, tabControl, 2, 3);
|
|
|
|
for (var i = 0; i < tabControl.ItemCount; i++)
|
|
{
|
|
var container = GetContainer<AtomTabItem>(tabControl, i);
|
|
GetTranslateX(container).ShouldBe(0, $"tab container {i} must not keep horizontal preview offset after commit");
|
|
GetTranslateY(container).ShouldBe(0, $"tab container {i} must not keep vertical preview offset after commit");
|
|
}
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Selected_Indicator_Follows_Dragged_Selected_Tab_During_Live_Reorder()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new AvaloniaList<TabItemData> { first, second, third };
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = source,
|
|
SelectedItem = first
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 0);
|
|
var shifted = GetContainer<AtomTabItem>(tabControl, 1);
|
|
var beforeIndicatorX = GetTransformOffsetX(tabControl.SelectedIndicatorRenderTransform);
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabControl.TabStripPlacement, dragged, shifted);
|
|
|
|
GetTransformOffsetX(tabControl.SelectedIndicatorRenderTransform)
|
|
.ShouldBe(beforeIndicatorX + GetTranslateX(dragged), 0.5);
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Selected_Indicator_Does_Not_Lag_When_Dragging_Selected_Tab_With_Motion_Enabled()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new AvaloniaList<TabItemData> { first, second, third };
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
IsMotionEnabled = true,
|
|
ItemsSource = source,
|
|
SelectedItem = first
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 0);
|
|
var shifted = GetContainer<AtomTabItem>(tabControl, 1);
|
|
var beforeIndicatorX = GetTransformOffsetX(tabControl.SelectedIndicatorRenderTransform);
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabControl.TabStripPlacement, dragged, shifted);
|
|
|
|
GetTransformOffsetX(tabControl.SelectedIndicatorRenderTransform)
|
|
.ShouldBe(beforeIndicatorX + GetTranslateX(dragged), 0.5);
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Selected_Indicator_Does_Not_Jump_To_Stale_Layout_When_Releasing_Selected_Tab_Reorder()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new AvaloniaList<TabItemData> { first, second, third };
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
IsMotionEnabled = true,
|
|
ItemsSource = source,
|
|
SelectedItem = second
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 1);
|
|
var shifted = GetContainer<AtomTabItem>(tabControl, 2);
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabControl.TabStripPlacement, dragged, shifted);
|
|
|
|
window.MouseUp(releasePoint, MouseButton.Left);
|
|
|
|
var releaseIndicatorX = GetTransformOffsetX(tabControl.SelectedIndicatorRenderTransform);
|
|
|
|
Dispatcher.UIThread.RunJobs();
|
|
WaitForSelectedIndicatorXCloseTo(tabControl, releaseIndicatorX);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabStrip_Selected_Indicator_Does_Not_Lag_When_Dragging_Selected_Tab_With_Motion_Enabled()
|
|
{
|
|
var first = new AtomTabStripItem { Content = "first" };
|
|
var second = new AtomTabStripItem { Content = "second" };
|
|
var third = new AtomTabStripItem { Content = "third" };
|
|
var tabStrip = new AtomTabStrip
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
IsMotionEnabled = true
|
|
};
|
|
tabStrip.Items.Add(first);
|
|
tabStrip.Items.Add(second);
|
|
tabStrip.Items.Add(third);
|
|
tabStrip.SelectedItem = first;
|
|
|
|
ShowInWindow(tabStrip, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabStripItem>(tabStrip, 0);
|
|
var shifted = GetContainer<AtomTabStripItem>(tabStrip, 1);
|
|
var beforeIndicatorX = GetTransformOffsetX(tabStrip.SelectedIndicatorRenderTransform);
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabStrip.TabStripPlacement, dragged, shifted);
|
|
|
|
GetTransformOffsetX(tabStrip.SelectedIndicatorRenderTransform)
|
|
.ShouldBe(beforeIndicatorX + GetTranslateX(dragged), 0.5);
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabStrip_Selected_Indicator_Does_Not_Jump_To_Stale_Layout_When_Releasing_Selected_Tab_Reorder()
|
|
{
|
|
var first = new AtomTabStripItem { Content = "first" };
|
|
var second = new AtomTabStripItem { Content = "second" };
|
|
var third = new AtomTabStripItem { Content = "third" };
|
|
var tabStrip = new AtomTabStrip
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
IsMotionEnabled = true
|
|
};
|
|
tabStrip.Items.Add(first);
|
|
tabStrip.Items.Add(second);
|
|
tabStrip.Items.Add(third);
|
|
tabStrip.SelectedItem = second;
|
|
|
|
ShowInWindow(tabStrip, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabStripItem>(tabStrip, 1);
|
|
var shifted = GetContainer<AtomTabStripItem>(tabStrip, 2);
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabStrip.TabStripPlacement, dragged, shifted);
|
|
|
|
window.MouseUp(releasePoint, MouseButton.Left);
|
|
|
|
var releaseIndicatorX = GetTransformOffsetX(tabStrip.SelectedIndicatorRenderTransform);
|
|
|
|
Dispatcher.UIThread.RunJobs();
|
|
WaitForSelectedIndicatorXCloseTo(tabStrip, releaseIndicatorX);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabControl_Selected_Indicator_Follows_Shifted_Selected_Tab_During_Live_Reorder()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new AvaloniaList<TabItemData> { first, second, third };
|
|
var tabControl = new AtomTabControl
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
IsMotionEnabled = false,
|
|
ItemsSource = source,
|
|
SelectedItem = second
|
|
};
|
|
|
|
ShowInWindow(tabControl, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabItem>(tabControl, 0);
|
|
var shifted = GetContainer<AtomTabItem>(tabControl, 1);
|
|
var beforeIndicatorX = GetTransformOffsetX(tabControl.SelectedIndicatorRenderTransform);
|
|
var expectedShift = TranslateToWindow(dragged, default, window).X -
|
|
TranslateToWindow(shifted, default, window).X;
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabControl.TabStripPlacement, dragged, shifted);
|
|
|
|
GetTransformOffsetX(tabControl.SelectedIndicatorRenderTransform)
|
|
.ShouldBe(beforeIndicatorX + expectedShift, 0.5);
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void CardTabControl_Template_Applies_When_Reorder_Is_Enabled()
|
|
{
|
|
var tabControl = new AtomCardTabControl
|
|
{
|
|
Width = 240,
|
|
Height = 140,
|
|
IsTabReorderEnabled = true
|
|
};
|
|
tabControl.Items.Add(new AtomTabItem { Header = "Tab 1", Content = "Content 1" });
|
|
tabControl.Items.Add(new AtomTabItem { Header = "Tab 2", Content = "Content 2" });
|
|
|
|
ShowInWindow(tabControl, _ =>
|
|
{
|
|
GetVisualDescendant<BaseTabScrollViewer>(tabControl).ShouldNotBeNull();
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabStrip_ReadOnly_ItemsSource_Drag_Cleans_Up_Without_Reordered_Event()
|
|
{
|
|
var first = new TabItemData { Header = "first" };
|
|
var second = new TabItemData { Header = "second" };
|
|
var third = new TabItemData { Header = "third" };
|
|
var source = new[] { first, second, third };
|
|
var reorderedCount = 0;
|
|
var tabStrip = new AtomTabStrip
|
|
{
|
|
Width = 360,
|
|
IsTabReorderEnabled = true,
|
|
ItemsSource = source
|
|
};
|
|
tabStrip.TabReordered += (_, _) => reorderedCount++;
|
|
|
|
ShowInWindow(tabStrip, window =>
|
|
{
|
|
DragContainerAfter(window, tabStrip, 1, 2);
|
|
|
|
source.ShouldBe([first, second, third]);
|
|
reorderedCount.ShouldBe(0);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabStrip_Left_Placement_Drag_Uses_Vertical_Axis()
|
|
{
|
|
var first = new AtomTabStripItem { Content = "first" };
|
|
var second = new AtomTabStripItem { Content = "second" };
|
|
var third = new AtomTabStripItem { Content = "third" };
|
|
var tabStrip = new AtomTabStrip
|
|
{
|
|
Width = 160,
|
|
Height = 220,
|
|
IsTabReorderEnabled = true,
|
|
TabStripPlacement = Dock.Left
|
|
};
|
|
tabStrip.Items.Add(first);
|
|
tabStrip.Items.Add(second);
|
|
tabStrip.Items.Add(third);
|
|
tabStrip.SelectedItem = second;
|
|
|
|
ShowInWindow(tabStrip, window =>
|
|
{
|
|
DragContainerAfter(window, tabStrip, 0, 2);
|
|
|
|
tabStrip.Items.Cast<object?>().ShouldBe([second, third, first]);
|
|
tabStrip.SelectedItem.ShouldBeSameAs(second);
|
|
tabStrip.SelectedIndex.ShouldBe(0);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabStrip_Left_Placement_Drag_Applies_Vertical_Live_Reorder_Preview_Before_Release()
|
|
{
|
|
var first = new AtomTabStripItem { Content = "first" };
|
|
var second = new AtomTabStripItem { Content = "second" };
|
|
var third = new AtomTabStripItem { Content = "third" };
|
|
var tabStrip = new AtomTabStrip
|
|
{
|
|
Width = 160,
|
|
Height = 220,
|
|
IsTabReorderEnabled = true,
|
|
TabStripPlacement = Dock.Left
|
|
};
|
|
tabStrip.Items.Add(first);
|
|
tabStrip.Items.Add(second);
|
|
tabStrip.Items.Add(third);
|
|
|
|
ShowInWindow(tabStrip, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabStripItem>(tabStrip, 0);
|
|
var shifted = GetContainer<AtomTabStripItem>(tabStrip, 1);
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(window, tabStrip.TabStripPlacement, dragged, shifted);
|
|
|
|
GetTranslateX(dragged).ShouldBe(0, "vertical tab drag must stay on the tab track");
|
|
GetTranslateY(dragged).ShouldBeGreaterThan(0);
|
|
WaitForTranslateYLessThan(shifted, 0);
|
|
tabStrip.Items.Cast<object?>().ShouldBe(
|
|
[first, second, third],
|
|
"live preview should not commit the logical source before pointer release");
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
tabStrip.Items.Cast<object?>().ShouldBe(
|
|
[second, first, third],
|
|
"pointer release should commit the previewed target exactly once");
|
|
GetTranslateY(dragged).ShouldBe(0);
|
|
GetTranslateY(shifted).ShouldBe(0);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabStrip_Left_Placement_Drag_Ignores_Secondary_Axis_Pointer_Offset()
|
|
{
|
|
var first = new AtomTabStripItem { Content = "first" };
|
|
var second = new AtomTabStripItem { Content = "second" };
|
|
var third = new AtomTabStripItem { Content = "third" };
|
|
var tabStrip = new AtomTabStrip
|
|
{
|
|
Width = 220,
|
|
Height = 220,
|
|
IsTabReorderEnabled = true,
|
|
TabStripPlacement = Dock.Left
|
|
};
|
|
tabStrip.Items.Add(first);
|
|
tabStrip.Items.Add(second);
|
|
tabStrip.Items.Add(third);
|
|
|
|
ShowInWindow(tabStrip, window =>
|
|
{
|
|
var dragged = GetContainer<AtomTabStripItem>(tabStrip, 0);
|
|
var shifted = GetContainer<AtomTabStripItem>(tabStrip, 1);
|
|
|
|
var releasePoint = DragContainerToPreviewAfter(
|
|
window,
|
|
tabStrip.TabStripPlacement,
|
|
dragged,
|
|
shifted,
|
|
secondaryAxisOffset: 48);
|
|
|
|
GetTranslateX(dragged).ShouldBe(0, "vertical tab drag must ignore horizontal pointer drift");
|
|
GetTranslateY(dragged).ShouldBeGreaterThan(0);
|
|
WaitForTranslateYLessThan(shifted, 0);
|
|
|
|
ReleasePointer(window, releasePoint);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void TabStrip_Left_Placement_Drag_Keeps_Dragged_Tab_Anchored_When_Header_Viewport_Scrolls()
|
|
{
|
|
var tabStrip = new AtomTabStrip
|
|
{
|
|
Width = 160,
|
|
Height = 140,
|
|
IsTabReorderEnabled = true,
|
|
TabStripPlacement = Dock.Left
|
|
};
|
|
for (var i = 0; i < 12; i++)
|
|
{
|
|
tabStrip.Items.Add(new AtomTabStripItem { Content = $"Tab {i + 1}" });
|
|
}
|
|
|
|
ShowInWindow(tabStrip, window =>
|
|
{
|
|
var scrollViewer = GetVisualDescendant<BaseTabScrollViewer>(tabStrip);
|
|
RunJobsUntil(() => scrollViewer.Extent.Height > scrollViewer.Viewport.Height);
|
|
|
|
var dragged = GetContainer<AtomTabStripItem>(tabStrip, 0);
|
|
var dragPoint = BeginDragOnTrack(window, tabStrip.TabStripPlacement, dragged);
|
|
var beforeOffset = scrollViewer.Offset.Y;
|
|
var beforeTranslate = GetTranslateY(dragged);
|
|
|
|
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, beforeOffset + 24);
|
|
Dispatcher.UIThread.RunJobs();
|
|
var offsetDelta = scrollViewer.Offset.Y - beforeOffset;
|
|
offsetDelta.ShouldBeGreaterThan(0);
|
|
|
|
GetTranslateY(dragged).ShouldBe(
|
|
beforeTranslate + offsetDelta,
|
|
1.0,
|
|
"dragged tab preview must compensate header viewport scroll without waiting for another pointer move");
|
|
|
|
ReleasePointer(window, dragPoint);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void CardTabStrip_Template_Applies_When_Reorder_Is_Enabled()
|
|
{
|
|
var tabStrip = new AtomCardTabStrip
|
|
{
|
|
Width = 240,
|
|
Height = 140,
|
|
IsTabReorderEnabled = true
|
|
};
|
|
tabStrip.Items.Add(new AtomTabStripItem { Content = "Tab 1" });
|
|
tabStrip.Items.Add(new AtomTabStripItem { Content = "Tab 2" });
|
|
|
|
ShowInWindow(tabStrip, _ =>
|
|
{
|
|
GetVisualDescendant<BaseTabScrollViewer>(tabStrip).ShouldNotBeNull();
|
|
});
|
|
}
|
|
|
|
private static void DragContainerAfter(AvaloniaWindow window, AtomTabControl tabControl, int sourceIndex, int targetIndex)
|
|
{
|
|
var source = GetContainer<AtomTabItem>(tabControl, sourceIndex);
|
|
var target = GetContainer<AtomTabItem>(tabControl, targetIndex);
|
|
DragCenterToTrailingEdge(window, tabControl.TabStripPlacement, source, target);
|
|
}
|
|
|
|
private static void DragContainerAfter(AvaloniaWindow window, AtomTabStrip tabStrip, int sourceIndex, int targetIndex)
|
|
{
|
|
var source = GetContainer<AtomTabStripItem>(tabStrip, sourceIndex);
|
|
var target = GetContainer<AtomTabStripItem>(tabStrip, targetIndex);
|
|
DragCenterToTrailingEdge(window, tabStrip.TabStripPlacement, source, target);
|
|
}
|
|
|
|
private static T GetContainer<T>(ItemsControl owner, int index)
|
|
where T : Control
|
|
{
|
|
RunJobsUntil(() => owner.ContainerFromIndex(index) is T);
|
|
return owner.ContainerFromIndex(index).ShouldBeOfType<T>();
|
|
}
|
|
|
|
private static void DragCenterToTrailingEdge(AvaloniaWindow window, Dock placement, Control source, Control target)
|
|
{
|
|
var start = TranslateToWindow(source, new Point(source.Bounds.Width / 2, source.Bounds.Height / 2), window);
|
|
var end = placement is Dock.Top or Dock.Bottom
|
|
? TranslateToWindow(target, new Point(target.Bounds.Width + 12, target.Bounds.Height / 2), window)
|
|
: TranslateToWindow(target, new Point(target.Bounds.Width / 2, target.Bounds.Height + 12), window);
|
|
|
|
window.MouseMove(start);
|
|
window.MouseDown(start, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
window.MouseMove(end);
|
|
Dispatcher.UIThread.RunJobs();
|
|
window.MouseUp(end, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static Point DragContainerToPreviewAfter(
|
|
AvaloniaWindow window,
|
|
Dock placement,
|
|
Control source,
|
|
Control target,
|
|
double secondaryAxisOffset = 0)
|
|
{
|
|
var start = TranslateToWindow(source, new Point(source.Bounds.Width / 2, source.Bounds.Height / 2), window);
|
|
var end = GetNearTargetHalfPoint(window, placement, source, target, crossesHalf: true);
|
|
if (placement is Dock.Top or Dock.Bottom)
|
|
{
|
|
end = new Point(end.X, end.Y + secondaryAxisOffset);
|
|
}
|
|
else
|
|
{
|
|
end = new Point(end.X + secondaryAxisOffset, end.Y);
|
|
}
|
|
|
|
window.MouseMove(start);
|
|
window.MouseDown(start, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
window.MouseMove(end);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
return end;
|
|
}
|
|
|
|
private static Point BeginDragOnTrack(AvaloniaWindow window, Dock placement, Control source)
|
|
{
|
|
var start = TranslateToWindow(source, new Point(source.Bounds.Width / 2, source.Bounds.Height / 2), window);
|
|
var dragPoint = placement is Dock.Top or Dock.Bottom
|
|
? new Point(start.X + 24, start.Y)
|
|
: new Point(start.X, start.Y + 24);
|
|
|
|
window.MouseMove(start);
|
|
window.MouseDown(start, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
window.MouseMove(dragPoint);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
return dragPoint;
|
|
}
|
|
|
|
private static Point DragContainerToPreviewNearTargetHalf(
|
|
AvaloniaWindow window,
|
|
Dock placement,
|
|
Control source,
|
|
Control target,
|
|
bool crossesHalf)
|
|
{
|
|
var start = TranslateToWindow(source, new Point(source.Bounds.Width / 2, source.Bounds.Height / 2), window);
|
|
var end = GetNearTargetHalfPoint(window, placement, source, target, crossesHalf);
|
|
|
|
window.MouseMove(start);
|
|
window.MouseDown(start, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
window.MouseMove(end);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
return end;
|
|
}
|
|
|
|
private static Point DragPointerNearTargetHalf(
|
|
AvaloniaWindow window,
|
|
Dock placement,
|
|
Control source,
|
|
Control target,
|
|
bool crossesHalf)
|
|
{
|
|
var end = GetNearTargetHalfPoint(window, placement, source, target, crossesHalf);
|
|
window.MouseMove(end);
|
|
Dispatcher.UIThread.RunJobs();
|
|
return end;
|
|
}
|
|
|
|
private static Point GetNearTargetHalfPoint(
|
|
AvaloniaWindow window,
|
|
Dock placement,
|
|
Control source,
|
|
Control target,
|
|
bool crossesHalf)
|
|
{
|
|
var start = TranslateToWindow(source, new Point(source.Bounds.Width / 2, source.Bounds.Height / 2), window);
|
|
var sourceOrigin = TranslateToWindow(source, default, window);
|
|
var targetOrigin = TranslateToWindow(target, default, window);
|
|
var edgeOffset = crossesHalf ? 2 : -2;
|
|
|
|
if (placement is Dock.Top or Dock.Bottom)
|
|
{
|
|
var sourceTrailing = sourceOrigin.X + source.Bounds.Width;
|
|
var targetMidpoint = targetOrigin.X + target.Bounds.Width / 2;
|
|
var delta = targetMidpoint - sourceTrailing + edgeOffset;
|
|
return new Point(start.X + delta, targetOrigin.Y + target.Bounds.Height / 2);
|
|
}
|
|
else
|
|
{
|
|
var sourceTrailing = sourceOrigin.Y + source.Bounds.Height;
|
|
var targetMidpoint = targetOrigin.Y + target.Bounds.Height / 2;
|
|
var delta = targetMidpoint - sourceTrailing + edgeOffset;
|
|
return new Point(targetOrigin.X + target.Bounds.Width / 2, start.Y + delta);
|
|
}
|
|
}
|
|
|
|
private static void ReleasePointer(AvaloniaWindow window, Point point)
|
|
{
|
|
window.MouseUp(point, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static double GetTranslateX(Control control)
|
|
{
|
|
return control.RenderTransform is TranslateTransform transform ? transform.X : 0;
|
|
}
|
|
|
|
private static double GetTranslateY(Control control)
|
|
{
|
|
return control.RenderTransform is TranslateTransform transform ? transform.Y : 0;
|
|
}
|
|
|
|
private static void WaitForTranslateXLessThan(Control control, double value)
|
|
{
|
|
for (var i = 0; i < 32 && GetTranslateX(control) >= value; i++)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
Thread.Sleep(10);
|
|
}
|
|
GetTranslateX(control).ShouldBeLessThan(value);
|
|
}
|
|
|
|
private static void WaitForTranslateYLessThan(Control control, double value)
|
|
{
|
|
for (var i = 0; i < 32 && GetTranslateY(control) >= value; i++)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
Thread.Sleep(10);
|
|
}
|
|
GetTranslateY(control).ShouldBeLessThan(value);
|
|
}
|
|
|
|
private static void WaitForTranslateXCloseTo(Control control, double value)
|
|
{
|
|
for (var i = 0; i < 256 && Math.Abs(GetTranslateX(control) - value) > 0.5; i++)
|
|
{
|
|
AvaloniaHeadlessPlatform.ForceRenderTimerTick(1);
|
|
Dispatcher.UIThread.RunJobs();
|
|
Thread.Sleep(10);
|
|
}
|
|
GetTranslateX(control).ShouldBe(value, 0.5);
|
|
}
|
|
|
|
private static bool IsEffectivelyTransparent(IBrush? brush)
|
|
{
|
|
return brush is null ||
|
|
brush.Opacity <= 0 ||
|
|
brush is ISolidColorBrush solidColorBrush && solidColorBrush.Color.A == 0;
|
|
}
|
|
|
|
private static bool IsTranslateTransformTransition(ITransition transition)
|
|
{
|
|
return transition.Property == TranslateTransform.XProperty ||
|
|
transition.Property == TranslateTransform.YProperty;
|
|
}
|
|
|
|
private static double GetTransformOffsetX(ITransform? transform)
|
|
{
|
|
return transform?.Value.M31 ?? 0;
|
|
}
|
|
|
|
private static void WaitForSelectedIndicatorXCloseTo(AtomTabControl tabControl, double value)
|
|
{
|
|
for (var i = 0; i < 128 && Math.Abs(GetTransformOffsetX(tabControl.SelectedIndicatorRenderTransform) - value) > 0.5; i++)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
Thread.Sleep(10);
|
|
}
|
|
GetTransformOffsetX(tabControl.SelectedIndicatorRenderTransform).ShouldBe(value, 0.5);
|
|
}
|
|
|
|
private static void WaitForSelectedIndicatorXCloseTo(AtomTabStrip tabStrip, double value)
|
|
{
|
|
for (var i = 0; i < 128 && Math.Abs(GetTransformOffsetX(tabStrip.SelectedIndicatorRenderTransform) - value) > 0.5; i++)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
Thread.Sleep(10);
|
|
}
|
|
GetTransformOffsetX(tabStrip.SelectedIndicatorRenderTransform).ShouldBe(value, 0.5);
|
|
}
|
|
|
|
private static void WaitForOpaqueBackground(AtomTabItem control)
|
|
{
|
|
for (var i = 0; i < 32; i++)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
if (!IsEffectivelyTransparent(control.Background))
|
|
{
|
|
return;
|
|
}
|
|
Thread.Sleep(10);
|
|
}
|
|
}
|
|
|
|
private static void DragContainerToScrollEndEdge(AvaloniaWindow window, Control source, ScrollViewer scrollViewer)
|
|
{
|
|
var start = TranslateToWindow(source, new Point(source.Bounds.Width / 2, source.Bounds.Height / 2), window);
|
|
var scrollViewerOrigin = TranslateToWindow(scrollViewer, default, window);
|
|
var end = new Point(
|
|
scrollViewerOrigin.X + scrollViewer.Bounds.Width - 2,
|
|
scrollViewerOrigin.Y + scrollViewer.Bounds.Height / 2);
|
|
|
|
window.MouseMove(start);
|
|
window.MouseDown(start, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
for (var i = 0; i < 4; i++)
|
|
{
|
|
window.MouseMove(end);
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
window.MouseUp(end, MouseButton.Left);
|
|
Dispatcher.UIThread.RunJobs();
|
|
}
|
|
|
|
private static Point TranslateToWindow(Control source, Point point, AvaloniaWindow window)
|
|
{
|
|
var translated = source.TranslatePoint(point, window);
|
|
translated.ShouldNotBeNull();
|
|
return translated.Value;
|
|
}
|
|
|
|
private static T GetVisualDescendant<T>(Control owner)
|
|
where T : Visual
|
|
{
|
|
RunJobsUntil(() => owner.GetVisualDescendants().OfType<T>().Any());
|
|
return owner.GetVisualDescendants().OfType<T>().First();
|
|
}
|
|
|
|
private static void ShowInWindow(Control content, Action<AvaloniaWindow> assertion)
|
|
{
|
|
var window = new AvaloniaWindow
|
|
{
|
|
Width = 520,
|
|
Height = 360,
|
|
Content = content
|
|
};
|
|
|
|
try
|
|
{
|
|
window.Show();
|
|
Dispatcher.UIThread.RunJobs();
|
|
assertion(window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
}
|
|
|
|
private static void RunJobsUntil(Func<bool> condition, int maxPasses = 128)
|
|
{
|
|
for (var i = 0; i < maxPasses; i++)
|
|
{
|
|
Dispatcher.UIThread.RunJobs();
|
|
if (condition())
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|