using System.Reflection; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; using Shouldly; using Xunit; using AvaloniaWindow = Avalonia.Controls.Window; namespace AtomUI.Desktop.Controls.Tests.NavMenu; public class NavMenuInteractionHandlerTests { static NavMenuInteractionHandlerTests() { AvaloniaTestApp.EnsureInitialized(); } [Fact] public void Layout_Induced_Pointer_Exit_Does_Not_Schedule_Submenu_Close() { var fixture = CreateScrollableHorizontalMenuFixture(); var delayedRuns = new List(); var handler = new DefaultNavMenuInteractionHandler(null, (action, _) => { var run = new ScheduledRun(action); delayedRuns.Add(run); return run; }); try { fixture.Window.Show(); Dispatcher.UIThread.RunJobs(); ReplaceInteractionHandler(fixture.Menu, handler); var item = fixture.Menu.ContainerFromItem(fixture.Submenu).ShouldBeOfType(); item.Open(); Dispatcher.UIThread.RunJobs(); item.IsSubMenuOpen.ShouldBeTrue(); fixture.ScrollViewer.Offset = new Vector(0, 20); Dispatcher.UIThread.RunJobs(); RaisePointerExited(item, fixture.Window, timestamp: 0); Dispatcher.UIThread.RunJobs(); delayedRuns.Count.ShouldBe(0); item.IsSubMenuOpen.ShouldBeTrue(); } finally { fixture.Window.Close(); } } [Fact] public void Pointer_Move_Exit_Schedules_Submenu_Close() { var fixture = CreateScrollableHorizontalMenuFixture(); var delayedRuns = new List(); var handler = new DefaultNavMenuInteractionHandler(null, (action, _) => { var run = new ScheduledRun(action); delayedRuns.Add(run); return run; }); try { fixture.Window.Show(); Dispatcher.UIThread.RunJobs(); ReplaceInteractionHandler(fixture.Menu, handler); var item = fixture.Menu.ContainerFromItem(fixture.Submenu).ShouldBeOfType(); item.Open(); Dispatcher.UIThread.RunJobs(); item.IsSubMenuOpen.ShouldBeTrue(); RaisePointerExited(item, fixture.Window, timestamp: 42); Dispatcher.UIThread.RunJobs(); delayedRuns.Count.ShouldBe(1); delayedRuns[0].Run(); Dispatcher.UIThread.RunJobs(); item.IsSubMenuOpen.ShouldBeFalse(); } finally { fixture.Window.Close(); } } [Fact] public void Removing_An_Item_Disposes_Its_Pending_Delayed_Open() { var fixture = CreateScrollableHorizontalMenuFixture(); var delayedRuns = new List(); var handler = new DefaultNavMenuInteractionHandler(null, (action, _) => { var run = new ScheduledRun(action); delayedRuns.Add(run); return run; }); try { fixture.Window.Show(); Dispatcher.UIThread.RunJobs(); ReplaceInteractionHandler(fixture.Menu, handler); var item = fixture.Menu.ContainerFromItem(fixture.Submenu).ShouldBeOfType(); RaisePointerEntered(item, fixture.Window, timestamp: 42); Dispatcher.UIThread.RunJobs(); delayedRuns.Count.ShouldBe(1); delayedRuns[0].IsDisposed.ShouldBeFalse(); fixture.Menu.Items.Remove(fixture.Submenu); Dispatcher.UIThread.RunJobs(); fixture.Window.UpdateLayout(); delayedRuns[0].IsDisposed.ShouldBeTrue( "A delayed hover action must not retain or later act on a recycled container."); } finally { fixture.Window.Close(); } } [Fact] public void Removing_An_Item_Disposes_Its_Pending_Delayed_Close() { var fixture = CreateScrollableHorizontalMenuFixture(); var delayedRuns = new List(); var handler = new DefaultNavMenuInteractionHandler(null, (action, _) => { var run = new ScheduledRun(action); delayedRuns.Add(run); return run; }); try { fixture.Window.Show(); Dispatcher.UIThread.RunJobs(); ReplaceInteractionHandler(fixture.Menu, handler); var item = fixture.Menu.ContainerFromItem(fixture.Submenu).ShouldBeOfType(); item.Open(); Dispatcher.UIThread.RunJobs(); RaisePointerExited(item, fixture.Window, timestamp: 42); Dispatcher.UIThread.RunJobs(); delayedRuns.Count.ShouldBe(1); delayedRuns[0].IsDisposed.ShouldBeFalse(); fixture.Menu.Items.Remove(fixture.Submenu); Dispatcher.UIThread.RunJobs(); fixture.Window.UpdateLayout(); delayedRuns[0].IsDisposed.ShouldBeTrue( "A delayed close action must not retain or later act on a recycled container."); } finally { fixture.Window.Close(); } } [Fact] public void Pointer_Enter_On_A_Grouped_Leaf_Closes_An_Open_Item_In_Another_Root_Group() { var openNode = CreateSubmenuNode("Open"); var leaf = new NavMenuNode { Header = "Leaf" }; var firstGroup = new NavMenuGroup { Header = "First group" }; firstGroup.Entries.Add(openNode); var secondGroup = new NavMenuGroup { Header = "Second group" }; secondGroup.Entries.Add(leaf); var menu = new AtomUI.Desktop.Controls.NavMenu { Mode = NavMenuMode.Vertical, IsMotionEnabled = false }; menu.Items.Add(firstGroup); menu.Items.Add(secondGroup); var overlay = new VisualLayerManager { Child = menu }; EnablePopupOverlayLayer(overlay); var window = new AvaloniaWindow { Width = 320, Height = 240, Content = overlay }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var openContainer = menu.ContainerFromItem(firstGroup) .ShouldBeOfType() .ContainerFromItem(openNode) .ShouldBeOfType(); var leafContainer = menu.ContainerFromItem(secondGroup) .ShouldBeOfType() .ContainerFromItem(leaf) .ShouldBeOfType(); openContainer.Open(); Dispatcher.UIThread.RunJobs(); openContainer.IsSubMenuOpen.ShouldBeTrue(); RaisePointerEntered(leafContainer, window, timestamp: 42); RunDispatcherJobsUntil(() => !openContainer.IsSubMenuOpen); openContainer.IsSubMenuOpen.ShouldBeFalse(); } finally { window.Close(); } } [Fact] public void Layout_Induced_Pointer_Enter_Does_Not_Schedule_Submenu_Open() { var fixture = CreateScrollableHorizontalMenuFixture(); var delayedRuns = new List(); var handler = new DefaultNavMenuInteractionHandler(null, (action, _) => { var run = new ScheduledRun(action); delayedRuns.Add(run); return run; }); try { fixture.Window.Show(); Dispatcher.UIThread.RunJobs(); ReplaceInteractionHandler(fixture.Menu, handler); var item = fixture.Menu.ContainerFromItem(fixture.Submenu).ShouldBeOfType(); item.IsSubMenuOpen.ShouldBeFalse(); fixture.ScrollViewer.Offset = new Vector(0, 20); Dispatcher.UIThread.RunJobs(); RaisePointerEntered(item, fixture.Window, timestamp: 0); Dispatcher.UIThread.RunJobs(); delayedRuns.Count.ShouldBe(0); item.IsSubMenuOpen.ShouldBeFalse(); } finally { fixture.Window.Close(); } } [Fact] public void Default_Handler_Detach_Disposes_Pending_Delayed_Open() { var scheduledRuns = new List(); var handler = new DefaultNavMenuInteractionHandler(null, (_, _) => { var disposable = new TrackingDisposable(); scheduledRuns.Add(disposable); return disposable; }); var menu = new AtomUI.Desktop.Controls.NavMenu(); handler.AttachCore(menu); handler.OpenWithDelay(new NavMenuItem()); scheduledRuns.Count.ShouldBe(1); scheduledRuns[0].IsDisposed.ShouldBeFalse(); handler.DetachCore(menu); scheduledRuns[0].IsDisposed.ShouldBeTrue(); } [Fact] public void Removing_The_Pressed_Item_Invalidates_It_Before_Pointer_Release() { var first = new NavMenuNode { Header = "First", ItemKey = "first" }; var second = new NavMenuNode { Header = "Second", ItemKey = "second" }; var menu = new AtomUI.Desktop.Controls.NavMenu { Mode = NavMenuMode.Inline, IsMotionEnabled = false }; menu.Items.Add(first); menu.Items.Add(second); var clickedItems = new List(); menu.NavMenuItemClick += (_, args) => clickedItems.Add(args.NavMenuItem); var window = new AvaloniaWindow { Width = 320, Height = 240, Content = menu }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var firstContainer = menu.ContainerFromItem(first).ShouldBeOfType(); var header = firstContainer.ItemHeader.ShouldNotBeNull(); var point = header.TranslatePoint( new Point(header.Bounds.Width / 2, header.Bounds.Height / 2), window).ShouldNotBeNull(); window.MouseMove(point); window.MouseDown(point, MouseButton.Left); Dispatcher.UIThread.RunJobs(); menu.Items.Remove(first); Dispatcher.UIThread.RunJobs(); window.UpdateLayout(); window.MouseUp(point, MouseButton.Left); Dispatcher.UIThread.RunJobs(); clickedItems.ShouldBeEmpty( "Releasing after the pressed container was recycled must not click the removed entry."); } finally { window.Close(); } } [Fact] public void Inline_Accordion_Closes_A_Previously_Open_Top_Level_Item_Through_Groups() { var first = CreateSubmenuNode("First"); var second = CreateSubmenuNode("Second"); var firstGroup = new NavMenuGroup { Header = "First group" }; firstGroup.Entries.Add(first); var secondGroup = new NavMenuGroup { Header = "Second group" }; secondGroup.Entries.Add(second); var menu = new AtomUI.Desktop.Controls.NavMenu { Mode = NavMenuMode.Inline, IsAccordionMode = true, IsMotionEnabled = false }; menu.Items.Add(firstGroup); menu.Items.Add(new NavMenuDivider()); menu.Items.Add(secondGroup); var window = new AvaloniaWindow { Width = 320, Height = 240, Content = menu }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var firstContainer = menu.ContainerFromItem(firstGroup) .ShouldBeOfType() .ContainerFromItem(first) .ShouldBeOfType(); var secondContainer = menu.ContainerFromItem(secondGroup) .ShouldBeOfType() .ContainerFromItem(second) .ShouldBeOfType(); firstContainer.Open(); Dispatcher.UIThread.RunJobs(); firstContainer.IsSubMenuOpen.ShouldBeTrue(); secondContainer.Open(); Dispatcher.UIThread.RunJobs(); secondContainer.IsSubMenuOpen.ShouldBeTrue(); firstContainer.IsSubMenuOpen.ShouldBeFalse(); } finally { window.Close(); } } [Fact] public void Vertical_Grouped_Sibling_Submenus_Remain_Mutually_Exclusive() { var first = CreateSubmenuNode("First"); var second = CreateSubmenuNode("Second"); var group = new NavMenuGroup { Header = "Section" }; group.Entries.Add(first); group.Entries.Add(second); var parent = new NavMenuNode { Header = "Parent" }; parent.Entries.Add(group); var menu = new AtomUI.Desktop.Controls.NavMenu { Mode = NavMenuMode.Vertical, IsMotionEnabled = false }; menu.Items.Add(parent); var overlay = new VisualLayerManager { Child = menu }; EnablePopupOverlayLayer(overlay); var window = new AvaloniaWindow { Width = 320, Height = 240, Content = overlay }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var parentContainer = menu.ContainerFromItem(parent).ShouldBeOfType(); parentContainer.Open(); Dispatcher.UIThread.RunJobs(); menu.ExecutePendingContainerLayout(parentContainer); var groupContainer = parentContainer.ContainerFromItem(group).ShouldBeOfType(); var firstContainer = groupContainer.ContainerFromItem(first).ShouldBeOfType(); var secondContainer = groupContainer.ContainerFromItem(second).ShouldBeOfType(); firstContainer.Open(); Dispatcher.UIThread.RunJobs(); firstContainer.IsSubMenuOpen.ShouldBeTrue(); secondContainer.Open(); Dispatcher.UIThread.RunJobs(); secondContainer.IsSubMenuOpen.ShouldBeTrue(); firstContainer.IsSubMenuOpen.ShouldBeFalse(); } finally { window.Close(); } } [Fact] public void Close_Recursively_Closes_Grouped_Descendant_Submenus() { var child = CreateSubmenuNode("Child"); var group = new NavMenuGroup { Header = "Section" }; group.Entries.Add(child); var parent = new NavMenuNode { Header = "Parent" }; parent.Entries.Add(group); var menu = new AtomUI.Desktop.Controls.NavMenu { Mode = NavMenuMode.Inline, IsMotionEnabled = false }; menu.Items.Add(parent); var visualLayerManager = new VisualLayerManager { Child = menu }; EnablePopupOverlayLayer(visualLayerManager); var window = new AvaloniaWindow { Width = 320, Height = 240, Content = visualLayerManager }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var parentContainer = menu.ContainerFromItem(parent).ShouldBeOfType(); parentContainer.Open(); Dispatcher.UIThread.RunJobs(); var groupContainer = parentContainer.ContainerFromItem(group).ShouldBeOfType(); var childContainer = groupContainer.ContainerFromItem(child).ShouldBeOfType(); childContainer.Open(); Dispatcher.UIThread.RunJobs(); childContainer.IsSubMenuOpen.ShouldBeTrue(); parentContainer.Close(); RunDispatcherJobsUntil(() => !parentContainer.IsSubMenuOpen); parentContainer.IsSubMenuOpen.ShouldBeFalse(); childContainer.IsSubMenuOpen.ShouldBeFalse(); } finally { window.Close(); } } [Fact] public void InlineCollapsed_TopLevel_Lost_Platform_Focus_Closes_Popups_Without_Clearing_Selection() { var leaf = new NavMenuNode { Header = "Leaf", ItemKey = "leaf" }; var child = new NavMenuNode { Header = "Child", ItemKey = "child" }; child.Children.Add(leaf); var parent = new NavMenuNode { Header = "Parent", ItemKey = "parent" }; parent.Children.Add(child); var menu = new AtomUI.Desktop.Controls.NavMenu { Mode = NavMenuMode.Inline, IsInlineCollapsed = true, IsMotionEnabled = false, Width = 240 }; menu.Items.Add(parent); var visualLayerManager = new VisualLayerManager { Child = menu }; EnablePopupOverlayLayer(visualLayerManager); var window = new AvaloniaWindow { Width = 320, Height = 240, Content = visualLayerManager }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var parentContainer = menu.ContainerFromItem(parent).ShouldBeOfType(); parentContainer.Open(); Dispatcher.UIThread.RunJobs(); menu.ExecutePendingContainerLayout(parentContainer); var childContainer = parentContainer.ContainerFromItem(child).ShouldBeOfType(); childContainer.Open(); Dispatcher.UIThread.RunJobs(); menu.ExecutePendingContainerLayout(childContainer); var leafContainer = childContainer.ContainerFromItem(leaf).ShouldBeOfType(); menu.InteractionHandler.ShouldNotBeNull(); menu.SelectNavMenuItem(leafContainer); Dispatcher.UIThread.RunJobs(); menu.SelectedItem.ShouldBeSameAs(leaf); parentContainer.IsInSelectedPath.ShouldBeTrue(); childContainer.IsInSelectedPath.ShouldBeTrue(); var handler = menu.InteractionHandler.ShouldBeOfType(); var lostFocusMethod = typeof(DefaultNavMenuInteractionHandler).GetMethod( "TopLevelLostPlatformFocus", BindingFlags.Instance | BindingFlags.NonPublic); lostFocusMethod.ShouldNotBeNull(); lostFocusMethod.Invoke(handler, null); RunDispatcherJobsUntil(() => !parentContainer.IsSubMenuOpen); parentContainer.IsSubMenuOpen.ShouldBeFalse(); menu.SelectedItem.ShouldBeSameAs(leaf); parentContainer.IsInSelectedPath.ShouldBeTrue(); parentContainer.Open(); Dispatcher.UIThread.RunJobs(); menu.ExecutePendingContainerLayout(parentContainer); var reopenedChildContainer = parentContainer.ContainerFromItem(child).ShouldBeOfType(); reopenedChildContainer.IsInSelectedPath.ShouldBeTrue(); reopenedChildContainer.Open(); Dispatcher.UIThread.RunJobs(); menu.ExecutePendingContainerLayout(reopenedChildContainer); var reopenedLeafContainer = reopenedChildContainer.ContainerFromItem(leaf).ShouldBeOfType(); reopenedLeafContainer.IsSelected.ShouldBeTrue(); parentContainer.IsInSelectedPath.ShouldBeTrue(); } finally { window.Close(); } } [Fact] public void Public_Close_Clears_Selection_While_Internal_Popup_Close_Does_Not() { var leaf = new NavMenuNode { Header = "Leaf", ItemKey = "leaf" }; var parent = new NavMenuNode { Header = "Parent", ItemKey = "parent" }; parent.Children.Add(leaf); var menu = new AtomUI.Desktop.Controls.NavMenu { Mode = NavMenuMode.Vertical, IsMotionEnabled = false }; menu.Items.Add(parent); var visualLayerManager = new VisualLayerManager { Child = menu }; EnablePopupOverlayLayer(visualLayerManager); var window = new AvaloniaWindow { Width = 320, Height = 240, Content = visualLayerManager }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var parentContainer = menu.ContainerFromItem(parent).ShouldBeOfType(); parentContainer.Open(); Dispatcher.UIThread.RunJobs(); var leafContainer = parentContainer.ContainerFromItem(leaf).ShouldBeOfType(); menu.InteractionHandler.ShouldNotBeNull(); menu.SelectNavMenuItem(leafContainer); Dispatcher.UIThread.RunJobs(); menu.Close(); RunDispatcherJobsUntil(() => !parentContainer.IsSubMenuOpen); menu.SelectedItem.ShouldBeNull(); parentContainer.IsInSelectedPath.ShouldBeFalse(); } finally { window.Close(); } } [Fact] public void Mode_Change_Closes_Popup_Without_Clearing_Selection() { var leaf = new NavMenuNode { Header = "Leaf", ItemKey = "leaf" }; var parent = new NavMenuNode { Header = "Parent", ItemKey = "parent" }; parent.Children.Add(leaf); var menu = new AtomUI.Desktop.Controls.NavMenu { Mode = NavMenuMode.Vertical, IsMotionEnabled = false }; menu.Items.Add(parent); var visualLayerManager = new VisualLayerManager { Child = menu }; EnablePopupOverlayLayer(visualLayerManager); var window = new AvaloniaWindow { Width = 320, Height = 240, Content = visualLayerManager }; try { window.Show(); Dispatcher.UIThread.RunJobs(); var parentContainer = menu.ContainerFromItem(parent).ShouldBeOfType(); parentContainer.Open(); Dispatcher.UIThread.RunJobs(); var leafContainer = parentContainer.ContainerFromItem(leaf).ShouldBeOfType(); menu.InteractionHandler.ShouldNotBeNull(); menu.SelectNavMenuItem(leafContainer); Dispatcher.UIThread.RunJobs(); menu.Mode = NavMenuMode.Horizontal; Dispatcher.UIThread.RunJobs(); menu.SelectedItem.ShouldBeSameAs(leaf); var modeChangedParentContainer = menu.ContainerFromItem(parent).ShouldBeOfType(); modeChangedParentContainer.IsSubMenuOpen.ShouldBeFalse(); modeChangedParentContainer.IsInSelectedPath.ShouldBeTrue(); modeChangedParentContainer.Open(); Dispatcher.UIThread.RunJobs(); modeChangedParentContainer.ContainerFromItem(leaf) .ShouldBeOfType() .IsSelected.ShouldBeTrue(); } finally { window.Close(); } } private static void EnablePopupOverlayLayer(VisualLayerManager visualLayerManager) { var property = typeof(VisualLayerManager).GetProperty( "EnablePopupOverlayLayer", BindingFlags.Instance | BindingFlags.NonPublic); property.ShouldNotBeNull(); property.SetValue(visualLayerManager, true); } private static MenuFixture CreateScrollableHorizontalMenuFixture() { var submenu = new NavMenuNode { Header = "Parent" }; submenu.Children.Add(new NavMenuNode { Header = "Child" }); var menu = new AtomUI.Desktop.Controls.NavMenu { Mode = NavMenuMode.Horizontal, IsMotionEnabled = false }; menu.Items.Add(submenu); var stackPanel = new StackPanel(); stackPanel.Children.Add(new Border { Height = 80 }); stackPanel.Children.Add(menu); stackPanel.Children.Add(new Border { Height = 600 }); var scrollViewer = new ScrollViewer { Height = 240, Content = stackPanel }; var visualLayerManager = new VisualLayerManager { Child = scrollViewer }; EnablePopupOverlayLayer(visualLayerManager); var window = new AvaloniaWindow { Width = 360, Height = 240, Content = visualLayerManager }; return new MenuFixture(window, scrollViewer, menu, submenu); } private static NavMenuNode CreateSubmenuNode(string header) { var node = new NavMenuNode { Header = header }; node.Children.Add(new NavMenuNode { Header = $"{header} child" }); return node; } private static void RunDispatcherJobsUntil(Func condition, int maxPasses = 128) { for (var i = 0; i < maxPasses && !condition(); i++) { Dispatcher.UIThread.RunJobs(); } } private static void ReplaceInteractionHandler( AtomUI.Desktop.Controls.NavMenu menu, DefaultNavMenuInteractionHandler handler) { menu.InteractionHandler.ShouldNotBeNull(); menu.InteractionHandler!.Detach(menu); var property = typeof(AtomUI.Desktop.Controls.NavMenu).GetProperty( "InteractionHandler", BindingFlags.Instance | BindingFlags.NonPublic); property.ShouldNotBeNull(); property.SetValue(menu, handler); handler.AttachCore(menu); } private static void RaisePointerExited(NavMenuItem item, AvaloniaWindow window, ulong timestamp) { RaisePointerTransition(InputElement.PointerExitedEvent, item, window, timestamp); } private static void RaisePointerEntered(NavMenuItem item, AvaloniaWindow window, ulong timestamp) { RaisePointerTransition(InputElement.PointerEnteredEvent, item, window, timestamp); } private static void RaisePointerTransition( RoutedEvent routedEvent, NavMenuItem item, AvaloniaWindow window, ulong timestamp) { var pointer = new Avalonia.Input.Pointer( Avalonia.Input.Pointer.GetNextFreeId(), PointerType.Mouse, true); var position = item.TranslatePoint( new Point(item.Bounds.Width + 1, item.Bounds.Height + 1), window) ?? default; var args = new PointerEventArgs( routedEvent, item, pointer, window, position, timestamp, PointerPointProperties.None, KeyModifiers.None); item.RaiseEvent(args); } private sealed record MenuFixture( AvaloniaWindow Window, ScrollViewer ScrollViewer, AtomUI.Desktop.Controls.NavMenu Menu, NavMenuNode Submenu); private sealed class ScheduledRun : IDisposable { private readonly Action _action; public ScheduledRun(Action action) { _action = action; } public bool IsDisposed { get; private set; } public void Run() { if (!IsDisposed) { _action(); } } public void Dispose() { IsDisposed = true; } } private sealed class TrackingDisposable : IDisposable { public bool IsDisposed { get; private set; } public void Dispose() { IsDisposed = true; } } }