Files

984 lines
36 KiB
C#

using System.Windows.Input;
using AtomUI.Animations;
using AtomUI.Desktop.Controls.DesignTokens;
using Avalonia;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Shouldly;
using Xunit;
using AvaloniaWindow = Avalonia.Controls.Window;
namespace AtomUI.Desktop.Controls.Tests.NavMenu;
public class NavMenuItemActivationTests
{
static NavMenuItemActivationTests()
{
AvaloniaTestApp.EnsureInitialized();
}
[Fact]
public void Press_Only_Does_Not_Select_Or_Dispatch()
{
var fixture = CreateInlineLeafFixture();
try
{
Press(fixture.Window, fixture.SecondContainer);
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode,
"press must not commit selection");
fixture.SecondContainer.IsSelected.ShouldBeFalse();
fixture.ClickedItems.ShouldBeEmpty();
fixture.SelectedNodes.ShouldBeEmpty();
fixture.Command.ExecuteCount.ShouldBe(0);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Press_And_Release_On_Same_Item_Commits_With_Order_Guarantee()
{
var fixture = CreateInlineLeafFixture();
try
{
var order = new List<string>();
fixture.Menu.NavMenuNodeSelected += (_, _) =>
{
order.Add("NodeSelected");
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.SecondNode,
"event subscribers must observe the committed SelectedItem");
};
fixture.Command.ExecuteAction = () =>
order.Add("Command:" + (fixture.Menu.SelectedItem == fixture.SecondNode ? "new" : "old"));
fixture.Menu.NavMenuItemClick += (_, _) => order.Add("ItemClick");
Click(fixture.Window, fixture.SecondContainer);
order.ShouldBe(new[] { "NodeSelected", "Command:new", "ItemClick" });
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.SecondNode);
fixture.SecondContainer.IsSelected.ShouldBeTrue();
fixture.FirstContainer.IsSelected.ShouldBeFalse();
fixture.ClickedItems.Count.ShouldBe(1);
fixture.ClickedItems[0].ShouldBe(fixture.SecondContainer);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Release_On_Different_Item_Does_Not_Commit()
{
var fixture = CreateInlineLeafFixture();
try
{
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
var thirdPoint = CenterOf(fixture.ThirdContainer, fixture.Window);
fixture.Window.MouseMove(secondPoint);
fixture.Window.MouseDown(secondPoint, MouseButton.Left);
fixture.Window.MouseMove(thirdPoint);
fixture.Window.MouseUp(thirdPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode);
fixture.SecondContainer.IsSelected.ShouldBeFalse();
fixture.ThirdContainer.IsSelected.ShouldBeFalse();
fixture.ClickedItems.ShouldBeEmpty();
fixture.Command.ExecuteCount.ShouldBe(0);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Release_Below_Menu_Does_Not_Commit()
{
var fixture = CreateInlineLeafFixture();
try
{
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
var outsideMenu = new Point(secondPoint.X, 200);
fixture.Window.MouseMove(secondPoint);
fixture.Window.MouseDown(secondPoint, MouseButton.Left);
fixture.Window.MouseMove(outsideMenu);
fixture.Window.MouseUp(outsideMenu, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode);
fixture.SecondContainer.IsSelected.ShouldBeFalse();
fixture.ClickedItems.ShouldBeEmpty();
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Press_Drag_Out_And_Back_Then_Release_Commits_Once()
{
var fixture = CreateInlineLeafFixture();
try
{
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
var thirdPoint = CenterOf(fixture.ThirdContainer, fixture.Window);
fixture.Window.MouseMove(secondPoint);
fixture.Window.MouseDown(secondPoint, MouseButton.Left);
fixture.Window.MouseMove(thirdPoint);
Dispatcher.UIThread.RunJobs();
fixture.Window.MouseMove(secondPoint);
fixture.Window.MouseUp(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.SecondNode);
fixture.SecondContainer.IsSelected.ShouldBeTrue();
fixture.ClickedItems.Count.ShouldBe(1);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Repeated_Commit_On_Same_Node_Raises_Selected_Once_And_Click_Every_Time()
{
var fixture = CreateInlineLeafFixture();
try
{
Click(fixture.Window, fixture.SecondContainer);
Click(fixture.Window, fixture.SecondContainer);
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.SecondNode);
fixture.SelectedNodes.Count.ShouldBe(1,
"re-committing the same node must not re-raise NavMenuNodeSelected");
fixture.ClickedItems.Count.ShouldBe(2,
"every legal activation raises NavMenuItemClick");
fixture.Command.ExecuteCount.ShouldBe(2);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Selection_Redirected_During_Commit_Does_Not_Publish_Or_Invoke_The_Superseded_Item()
{
var fixture = CreateInlineLeafFixture();
try
{
fixture.Menu.PropertyChanged += (_, args) =>
{
if (args.Property == AtomUI.Desktop.Controls.NavMenu.SelectedItemProperty &&
ReferenceEquals(args.NewValue, fixture.SecondNode))
{
fixture.Menu.SelectedItem = fixture.ThirdNode;
}
};
Click(fixture.Window, fixture.SecondContainer);
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.ThirdNode);
fixture.ThirdContainer.IsSelected.ShouldBeTrue();
fixture.SelectedNodes.Count.ShouldBe(1,
"the superseded second-node commit must not publish a stale selected event");
fixture.SelectedNodes[0].ShouldBeSameAs(fixture.ThirdNode);
fixture.Command.ExecuteCount.ShouldBe(0,
"a superseded activation must not execute only part of its invocation pipeline");
fixture.ClickedItems.ShouldBeEmpty(
"a superseded activation must not raise NavMenuItemClick");
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Selection_Redirected_By_Selected_Event_Stops_The_Original_Invocation()
{
var fixture = CreateInlineLeafFixture();
try
{
fixture.Menu.NavMenuNodeSelected += (_, args) =>
{
if (ReferenceEquals(args.NavMenuNode, fixture.SecondNode))
{
fixture.Menu.SelectedItem = fixture.ThirdNode;
}
};
Click(fixture.Window, fixture.SecondContainer);
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.ThirdNode);
fixture.Command.ExecuteCount.ShouldBe(0,
"selection-event reentrancy must stop the superseded node's command");
fixture.ClickedItems.ShouldBeEmpty(
"selection-event reentrancy must stop the superseded node's item-click event");
fixture.SelectedNodes.Count.ShouldBe(2);
fixture.SelectedNodes[0].ShouldBeSameAs(fixture.SecondNode);
fixture.SelectedNodes[1].ShouldBeSameAs(fixture.ThirdNode);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Unrelated_Capture_Lost_Does_Not_Cancel_The_Transaction()
{
var fixture = CreateInlineLeafFixture();
try
{
Press(fixture.Window, fixture.SecondContainer);
fixture.SecondContainer.IsPointerHold.ShouldBeTrue();
var secondHeader = GetHeader(fixture.SecondContainer);
secondHeader.RaiseEvent(new PointerCaptureLostEventArgs(
secondHeader,
new Avalonia.Input.Pointer(Avalonia.Input.Pointer.GetNextFreeId(), PointerType.Mouse, true)));
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeTrue(
"capture loss for a different pointer must not terminate the active pointer transaction");
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
fixture.Window.MouseUp(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.SecondNode);
fixture.ClickedItems.Count.ShouldBe(1);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Captured_Pointer_Lost_Cancels_The_Transaction()
{
var fixture = CreateInlineLeafFixture();
try
{
var pointer = BeginSyntheticPress(fixture, fixture.SecondContainer);
fixture.SecondContainer.IsPointerHold.ShouldBeTrue();
var secondHeader = GetHeader(fixture.SecondContainer);
secondHeader.RaiseEvent(new PointerCaptureLostEventArgs(
secondHeader,
pointer));
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeFalse();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode);
fixture.ClickedItems.ShouldBeEmpty();
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Right_Button_Press_Does_Not_Start_Transaction()
{
var fixture = CreateInlineLeafFixture();
try
{
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
fixture.Window.MouseMove(secondPoint);
fixture.Window.MouseDown(secondPoint, MouseButton.Right);
Dispatcher.UIThread.RunJobs();
fixture.Window.MouseUp(secondPoint, MouseButton.Right);
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeFalse();
fixture.SecondContainer.IsSelected.ShouldBeFalse();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode);
fixture.ClickedItems.ShouldBeEmpty();
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Disabled_After_Press_Does_Not_Commit()
{
var fixture = CreateInlineLeafFixture();
try
{
Press(fixture.Window, fixture.SecondContainer);
fixture.SecondNode.IsEnabled = false;
Dispatcher.UIThread.RunJobs();
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
fixture.Window.MouseUp(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode);
fixture.ClickedItems.ShouldBeEmpty();
fixture.Command.ExecuteCount.ShouldBe(0);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Inline_Parent_Toggles_Submenu_On_Release_Only()
{
var fixture = CreateInlineParentFixture();
try
{
Press(fixture.Window, fixture.ParentContainer);
fixture.ParentContainer.IsSubMenuOpen.ShouldBeFalse(
"press must not toggle the submenu");
ReleaseAt(fixture.Window, fixture.ParentContainer);
fixture.ParentContainer.IsSubMenuOpen.ShouldBeTrue(
"a legal release toggles the submenu open");
Click(fixture.Window, fixture.ParentContainer);
fixture.ParentContainer.IsSubMenuOpen.ShouldBeFalse(
"a second legal release toggles the submenu closed");
fixture.ClickedItems.Count.ShouldBe(2,
"every parent activation raises NavMenuItemClick");
fixture.Menu.SelectedItem.ShouldBeNull(
"parent activation must not change SelectedItem");
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Default_Parent_Opens_Popup_On_Release()
{
var fixture = CreateVerticalParentFixture();
try
{
Press(fixture.Window, fixture.ParentContainer);
fixture.ParentContainer.IsSubMenuOpen.ShouldBeFalse(
"press must not open the popup");
ReleaseAt(fixture.Window, fixture.ParentContainer);
RunDispatcherJobsUntil(() => fixture.ParentContainer.IsSubMenuOpen);
fixture.ParentContainer.IsSubMenuOpen.ShouldBeTrue(
"a legal release opens the popup");
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Keyboard_Enter_Commits_Leaf_With_Same_Order_As_Pointer()
{
var fixture = CreateInlineLeafFixture();
try
{
var order = new List<string>();
fixture.Menu.NavMenuNodeSelected += (_, _) =>
{
order.Add("NodeSelected");
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.SecondNode,
"keyboard commit must expose the new SelectedItem to event subscribers");
};
fixture.Command.ExecuteAction = () =>
order.Add("Command:" + (fixture.Menu.SelectedItem == fixture.SecondNode ? "new" : "old"));
fixture.Menu.Focus(NavigationMethod.Tab).ShouldBeTrue();
PressKey(fixture.Window, Key.Down, PhysicalKey.ArrowDown);
PressKey(fixture.Window, Key.Enter, PhysicalKey.Enter);
order.ShouldBe(new[] { "NodeSelected", "Command:new" });
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.SecondNode);
fixture.ClickedItems.Count.ShouldBe(1);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Keyboard_Enter_On_Inline_Parent_Toggles_And_Clicks()
{
var fixture = CreateInlineParentFixture();
try
{
fixture.Menu.SelectedItem.ShouldBeNull();
fixture.Menu.Focus(NavigationMethod.Tab).ShouldBeTrue();
PressKey(fixture.Window, Key.Down, PhysicalKey.ArrowDown);
PressKey(fixture.Window, Key.Enter, PhysicalKey.Enter);
fixture.ParentContainer.IsSubMenuOpen.ShouldBeTrue(
"Enter on an inline parent toggles the submenu open");
fixture.ClickedItems.Count.ShouldBe(1,
"Enter on a parent dispatches NavMenuItemClick once");
fixture.Menu.SelectedItem.ShouldBeNull(
"parent activation must not change SelectedItem");
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Pointer_Transaction_Does_Not_Replace_Keyboard_Active_Item()
{
var fixture = CreateInlineLeafFixture();
try
{
fixture.Menu.Focus(NavigationMethod.Tab).ShouldBeTrue();
PressKey(fixture.Window, Key.Down, PhysicalKey.ArrowDown);
fixture.SecondContainer.IsKeyboardActive.ShouldBeTrue(
"keyboard navigation must establish its own active item");
Press(fixture.Window, fixture.ThirdContainer);
fixture.SecondContainer.IsKeyboardActive.ShouldBeTrue(
"pointer press must not replace the keyboard navigation owner");
fixture.ThirdContainer.IsPointerHold.ShouldBeTrue(
"the pointer candidate must own only the hold visual");
var outsideMenu = new Point(CenterOf(fixture.ThirdContainer, fixture.Window).X, 200);
fixture.Window.MouseMove(outsideMenu);
fixture.Window.MouseUp(outsideMenu, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsKeyboardActive.ShouldBeTrue(
"cancelling the pointer transaction must preserve keyboard state");
fixture.ThirdContainer.IsPointerHold.ShouldBeFalse();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Press_Focuses_Item_When_Mode_Allows_Item_Focus()
{
var vertical = CreateVerticalParentFixture();
try
{
var leafFixture = vertical;
Press(leafFixture.Window, leafFixture.ParentContainer);
leafFixture.ParentContainer.IsFocused.ShouldBeTrue(
"press must move real focus to the pressed item in modes where items are focusable");
}
finally
{
vertical.Window.Close();
}
}
[Fact]
public void Pointer_Hold_Uses_Selected_Background_Without_Changing_Foreground_Or_Selection()
{
var fixture = CreateInlineLeafFixture();
try
{
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
fixture.Window.MouseMove(secondPoint);
Dispatcher.UIThread.RunJobs();
var secondHeader = GetHeader(fixture.SecondContainer);
secondHeader.IsPointerOver.ShouldBeTrue();
var hoverBackground = secondHeader.Background;
var hoverForeground = secondHeader.Foreground;
var selectedHeader = GetHeader(fixture.FirstContainer);
var selectedBackground = selectedHeader.Background;
var selectedForeground = selectedHeader.Foreground;
selectedBackground.ShouldNotBe(hoverBackground,
"the reference pressed background is the selected background, not hover");
selectedForeground.ShouldNotBe(hoverForeground,
"the test must distinguish the unchanged hover foreground from selected foreground");
fixture.Window.MouseDown(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsKeyboardActive.ShouldBeFalse(
"pointer hold must not mutate the independent keyboard navigation state");
fixture.SecondContainer.IsPointerHold.ShouldBeTrue();
secondHeader.Background.ShouldBe(selectedBackground,
"pressed background must match selected while selection remains uncommitted");
secondHeader.Foreground.ShouldBe(hoverForeground,
"pressed must not change the text color");
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode);
fixture.SecondContainer.IsSelected.ShouldBeFalse();
fixture.Window.MouseUp(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeFalse();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.SecondNode);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Item_Background_Transition_Uses_Overridden_NavMenu_Easing_Token()
{
var node = new NavMenuNode { Header = "Item", ItemKey = "item" };
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = true
};
menu.Items.Add(node);
var expectedEasing = new LinearEasing();
var window = new AvaloniaWindow { Width = 320, Height = 240, Content = menu };
window.Resources[NavMenuTokenKind.ItemBackgroundMotionEasing] = expectedEasing;
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var container = menu.ContainerFromItem(node).ShouldBeOfType<NavMenuItem>();
var transition = GetHeader(container).Transitions.ShouldNotBeNull()
.OfType<SolidColorBrushTransition>()
.Single(item => item.Property == BaseNavMenuItemHeader.BackgroundProperty);
transition.Easing.ShouldBeOfType<LinearEasing>();
}
finally
{
window.Close();
}
}
[Fact]
public void Pointer_Transaction_Does_Not_Expose_An_Intermediate_Background()
{
var fixture = CreateInlineLeafFixture();
try
{
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
fixture.Window.MouseMove(secondPoint);
Dispatcher.UIThread.RunJobs();
var secondHeader = GetHeader(fixture.SecondContainer);
var expectedPressedBackground = GetHeader(fixture.FirstContainer).Background;
var backgroundChanges = new List<object?>();
secondHeader.PropertyChanged += (_, args) =>
{
if (args.Property == BaseNavMenuItemHeader.BackgroundProperty)
{
backgroundChanges.Add(args.NewValue);
}
};
fixture.Window.MouseDown(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
backgroundChanges.ShouldAllBe(background => Equals(background, expectedPressedBackground),
"press must transition directly from hover to the selected-background hold visual");
secondHeader.Background.ShouldBe(expectedPressedBackground);
backgroundChanges.Clear();
fixture.Window.MouseUp(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
var selectedBackground = secondHeader.Background;
backgroundChanges.ShouldAllBe(background => Equals(background, selectedBackground),
"release must preserve the selected-background hold visual until logical selection commits");
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.SecondNode);
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Pointer_Hold_Captures_The_Hand_Cursor_Header()
{
var fixture = CreateInlineLeafFixture();
try
{
var secondHeader = GetHeader(fixture.SecondContainer);
secondHeader.Cursor.ShouldNotBeNull().ToString().ShouldContain("Hand");
var pointer = BeginSyntheticPress(fixture, fixture.SecondContainer);
pointer.Captured.ShouldBeSameAs(secondHeader,
"the capture owner determines the cursor shown for the duration of pointer hold");
fixture.SecondContainer.IsPointerHold.ShouldBeTrue();
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void Handler_Replacement_Cancels_The_Pointer_Transaction()
{
var fixture = CreateInlineLeafFixture();
try
{
Press(fixture.Window, fixture.SecondContainer);
fixture.SecondContainer.IsPointerHold.ShouldBeTrue();
var releasePoint = CenterOf(fixture.SecondContainer, fixture.Window);
fixture.Menu.Mode = NavMenuMode.Vertical;
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeFalse(
"detaching the old handler must clear its pointer-owned hold state");
fixture.Window.MouseUp(releasePoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode);
fixture.ClickedItems.ShouldBeEmpty();
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void PointerHold_Visual_State_Lifecycle()
{
var fixture = CreateInlineLeafFixture();
try
{
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
var thirdPoint = CenterOf(fixture.ThirdContainer, fixture.Window);
fixture.Window.MouseMove(secondPoint);
fixture.Window.MouseDown(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeTrue(
"press must set the transaction hold visual");
fixture.SecondContainer.IsSelected.ShouldBeFalse();
fixture.Window.MouseMove(thirdPoint);
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeFalse(
"dragging away must clear the transaction hold visual");
fixture.Window.MouseMove(secondPoint);
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeTrue(
"moving back must restore the transaction hold visual");
fixture.Window.MouseUp(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeFalse(
"commit must end the transaction hold visual");
fixture.SecondContainer.IsSelected.ShouldBeTrue();
}
finally
{
fixture.Window.Close();
}
}
[Fact]
public void New_Press_Replaces_Old_Transaction()
{
var fixture = CreateInlineLeafFixture();
try
{
Press(fixture.Window, fixture.SecondContainer);
fixture.SecondContainer.IsPointerHold.ShouldBeTrue();
var thirdHeader = fixture.ThirdContainer.ItemHeader
?? throw new InvalidOperationException("item header is not realized");
var syntheticSource = thirdHeader.GetVisualDescendants()
.OfType<Control>()
.FirstOrDefault(control => control.IsHitTestVisible)
?? (Control)thirdHeader;
var syntheticPoint = thirdHeader.TranslatePoint(
new Point(thirdHeader.Bounds.Width / 2, thirdHeader.Bounds.Height / 2),
fixture.Window).ShouldNotBeNull();
var syntheticPointer = new Avalonia.Input.Pointer(
Avalonia.Input.Pointer.GetNextFreeId(), PointerType.Mouse, true);
var pressArgs = new PointerPressedEventArgs(
syntheticSource,
syntheticPointer,
fixture.Window,
syntheticPoint,
100,
new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed),
KeyModifiers.None);
syntheticSource.RaiseEvent(pressArgs);
Dispatcher.UIThread.RunJobs();
fixture.SecondContainer.IsPointerHold.ShouldBeFalse(
"a new press must cancel the previous transaction visual");
fixture.ThirdContainer.IsPointerHold.ShouldBeTrue(
"a new press must start a transaction on the new target");
var secondPoint = CenterOf(fixture.SecondContainer, fixture.Window);
fixture.Window.MouseUp(secondPoint, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
fixture.Menu.SelectedItem.ShouldBeSameAs(fixture.FirstNode,
"a release from the replaced pointer must not commit");
fixture.ClickedItems.ShouldBeEmpty();
}
finally
{
fixture.Window.Close();
}
}
#region fixtures and helpers
private static BaseNavMenuItemHeader GetHeader(NavMenuItem item) =>
item.ItemHeader as BaseNavMenuItemHeader
?? throw new InvalidOperationException("item header is not realized");
private sealed class LeafFixture
{
public required AvaloniaWindow Window;
public required AtomUI.Desktop.Controls.NavMenu Menu;
public required NavMenuNode FirstNode;
public required NavMenuNode SecondNode;
public required NavMenuNode ThirdNode;
public required NavMenuItem FirstContainer;
public required NavMenuItem SecondContainer;
public required NavMenuItem ThirdContainer;
public required TrackingCommand Command;
public required List<INavMenuItem> ClickedItems;
public required List<INavMenuNode> SelectedNodes;
}
private sealed class ParentFixture
{
public required AvaloniaWindow Window;
public required AtomUI.Desktop.Controls.NavMenu Menu;
public required NavMenuItem ParentContainer;
public required List<INavMenuItem> ClickedItems;
}
private static LeafFixture CreateInlineLeafFixture()
{
var command = new TrackingCommand();
var first = new NavMenuNode { Header = "First", ItemKey = "first" };
var second = new NavMenuNode { Header = "Second", ItemKey = "second", Command = command };
var third = new NavMenuNode { Header = "Third", ItemKey = "third" };
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false
};
menu.Items.Add(first);
menu.Items.Add(second);
menu.Items.Add(third);
var clickedItems = new List<INavMenuItem>();
var selectedNodes = new List<INavMenuNode>();
var window = new AvaloniaWindow { Width = 320, Height = 240, Content = menu };
window.Show();
Dispatcher.UIThread.RunJobs();
menu.SelectedItem = first;
Dispatcher.UIThread.RunJobs();
menu.NavMenuItemClick += (_, args) => clickedItems.Add(args.NavMenuItem);
menu.NavMenuNodeSelected += (_, args) => selectedNodes.Add(args.NavMenuNode);
return new LeafFixture
{
Window = window,
Menu = menu,
FirstNode = first,
SecondNode = second,
ThirdNode = third,
FirstContainer = menu.ContainerFromItem(first).ShouldBeOfType<NavMenuItem>(),
SecondContainer = menu.ContainerFromItem(second).ShouldBeOfType<NavMenuItem>(),
ThirdContainer = menu.ContainerFromItem(third).ShouldBeOfType<NavMenuItem>(),
Command = command,
ClickedItems = clickedItems,
SelectedNodes = selectedNodes
};
}
private static ParentFixture CreateInlineParentFixture()
{
var parent = new NavMenuNode { Header = "Parent", ItemKey = "parent" };
parent.Children.Add(new NavMenuNode { Header = "Child", ItemKey = "child" });
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false
};
menu.Items.Add(parent);
var clickedItems = new List<INavMenuItem>();
menu.NavMenuItemClick += (_, args) => clickedItems.Add(args.NavMenuItem);
var window = new AvaloniaWindow { Width = 320, Height = 240, Content = menu };
window.Show();
Dispatcher.UIThread.RunJobs();
return new ParentFixture
{
Window = window,
Menu = menu,
ParentContainer = menu.ContainerFromItem(parent).ShouldBeOfType<NavMenuItem>(),
ClickedItems = clickedItems
};
}
private static ParentFixture CreateVerticalParentFixture()
{
var parent = new NavMenuNode { Header = "Parent", ItemKey = "parent" };
parent.Children.Add(new NavMenuNode { Header = "Child", ItemKey = "child" });
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Vertical,
IsMotionEnabled = false
};
menu.Items.Add(parent);
var clickedItems = new List<INavMenuItem>();
menu.NavMenuItemClick += (_, args) => clickedItems.Add(args.NavMenuItem);
var visualLayerManager = new Avalonia.Controls.Primitives.VisualLayerManager { Child = menu };
var overlayProperty = typeof(Avalonia.Controls.Primitives.VisualLayerManager).GetProperty(
"EnablePopupOverlayLayer",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
overlayProperty.ShouldNotBeNull();
overlayProperty.SetValue(visualLayerManager, true);
var window = new AvaloniaWindow { Width = 320, Height = 240, Content = visualLayerManager };
window.Show();
Dispatcher.UIThread.RunJobs();
return new ParentFixture
{
Window = window,
Menu = menu,
ParentContainer = menu.ContainerFromItem(parent).ShouldBeOfType<NavMenuItem>(),
ClickedItems = clickedItems
};
}
private static Point CenterOf(Control control, AvaloniaWindow window)
{
var header = control.ShouldBeOfType<NavMenuItem>().ItemHeader
?? throw new InvalidOperationException("item header is not realized");
return header.TranslatePoint(
new Point(header.Bounds.Width / 2, header.Bounds.Height / 2),
window).ShouldNotBeNull();
}
private static void Press(AvaloniaWindow window, Control control)
{
var point = CenterOf(control, window);
window.MouseMove(point);
window.MouseDown(point, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
}
private static IPointer BeginSyntheticPress(LeafFixture fixture, NavMenuItem item)
{
var header = item.ItemHeader
?? throw new InvalidOperationException("item header is not realized");
var source = header.GetVisualDescendants()
.OfType<Control>()
.FirstOrDefault(control => control.IsHitTestVisible)
?? (Control)header;
var point = header.TranslatePoint(
new Point(header.Bounds.Width / 2, header.Bounds.Height / 2),
fixture.Window).ShouldNotBeNull();
var pointer = new Avalonia.Input.Pointer(
Avalonia.Input.Pointer.GetNextFreeId(), PointerType.Mouse, true);
source.RaiseEvent(new PointerPressedEventArgs(
source,
pointer,
fixture.Window,
point,
100,
new PointerPointProperties(RawInputModifiers.LeftMouseButton, PointerUpdateKind.LeftButtonPressed),
KeyModifiers.None));
Dispatcher.UIThread.RunJobs();
return pointer;
}
private static void ReleaseAt(AvaloniaWindow window, Control control)
{
var point = CenterOf(control, window);
window.MouseMove(point);
window.MouseUp(point, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
}
private static void Click(AvaloniaWindow window, Control control)
{
var point = CenterOf(control, window);
window.MouseMove(point);
window.MouseDown(point, MouseButton.Left);
window.MouseUp(point, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
}
private static void PressKey(AvaloniaWindow window, Key key, PhysicalKey physicalKey)
{
window.KeyPress(key, RawInputModifiers.None, physicalKey, null);
Dispatcher.UIThread.RunJobs();
}
private static void RunDispatcherJobsUntil(Func<bool> condition, int maxPasses = 128)
{
for (var i = 0; i < maxPasses && !condition(); i++)
{
Dispatcher.UIThread.RunJobs();
}
}
private sealed class TrackingCommand : ICommand
{
public int ExecuteCount { get; private set; }
public Action? ExecuteAction { get; set; }
public bool CanExecute(object? parameter) => true;
public void Execute(object? parameter)
{
ExecuteCount++;
ExecuteAction?.Invoke();
}
public event EventHandler? CanExecuteChanged
{
add { }
remove { }
}
}
#endregion
}