Files

1406 lines
47 KiB
C#

using System.Reflection;
using AtomUI.Controls;
using AtomUI.Controls.Primitives;
using AtomUI.Icons.AntDesign;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Layout;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Shouldly;
using Xunit;
namespace AtomUI.Desktop.Controls.Tests.NavMenu;
public class NavMenuInlineCollapsedTests
{
static NavMenuInlineCollapsedTests()
{
AvaloniaTestApp.EnsureInitialized();
}
[Fact]
public void InlineCollapsedWidth_Defaults_To_Token_Value_And_Can_Be_Overridden_Locally()
{
var defaultMenu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
IsInlineCollapsed = true,
Width = 240
};
defaultMenu.Items.Add(new NavMenuNode
{
Header = "Default",
ItemKey = "default"
});
var customMenu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
IsInlineCollapsed = true,
InlineCollapsedWidth = 72
};
customMenu.Items.Add(new NavMenuNode
{
Header = "Custom",
ItemKey = "custom"
});
var stack = new StackPanel
{
Children =
{
defaultMenu,
customMenu
}
};
var window = new Avalonia.Controls.Window
{
Width = 320,
Height = 240,
Content = stack
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
defaultMenu.InlineCollapsedWidth.ShouldBe(48);
defaultMenu.Bounds.Width.ShouldBe(48, 0.5);
customMenu.Bounds.Width.ShouldBe(72, 0.5);
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Toggle_Restores_Explicit_Expanded_Width()
{
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
MinWidth = 160,
Width = 240
};
menu.Items.Add(new NavMenuNode
{
Header = "Default",
ItemKey = "default"
});
var window = new Avalonia.Controls.Window
{
Width = 320,
Height = 240,
Content = menu
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
menu.Bounds.Width.ShouldBe(240, 0.5);
menu.IsInlineCollapsed = true;
Dispatcher.UIThread.RunJobs();
menu.Bounds.Width.ShouldBe(48, 0.5);
menu.IsInlineCollapsed = false;
Dispatcher.UIThread.RunJobs();
menu.Width.ShouldBe(240);
menu.Bounds.Width.ShouldBe(240, 0.5);
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Toggle_With_Motion_Animates_Width_Instead_Of_Jumping_To_Target()
{
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = true,
IsInlineCollapsed = false,
Width = 300
};
menu.Items.Add(new NavMenuNode
{
Header = "Default",
ItemKey = "default"
});
var window = new Avalonia.Controls.Window
{
Width = 420,
Height = 240,
Content = menu
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
menu.Bounds.Width.ShouldBe(300, 0.5);
menu.IsInlineCollapsed = true;
Dispatcher.UIThread.RunJobs();
Thread.Sleep(20);
Dispatcher.UIThread.RunJobs();
menu.Bounds.Width.ShouldBeGreaterThan(menu.InlineCollapsedWidth,
"motion enabled inline collapsed should expose an intermediate layout width instead of jumping from expanded width to the collapsed target in one frame.");
WaitForWidthBetween(menu, menu.InlineCollapsedWidth, 300);
WaitForWidth(menu, menu.InlineCollapsedWidth);
WaitForInlineCollapsedLayoutWidthCleared(menu);
menu.Bounds.Width.ShouldBe(menu.InlineCollapsedWidth, 0.5);
menu.GetBaseValue(Layoutable.WidthProperty).Value.ShouldBe(300);
double.IsNaN(menu.InlineCollapsedLayoutWidth).ShouldBeTrue(
"the internal animated layout width must be cleared after collapse motion completes so the collapsed steady state is controlled by InlineCollapsedWidth.");
menu.IsInlineCollapsed = false;
Dispatcher.UIThread.RunJobs();
Thread.Sleep(20);
Dispatcher.UIThread.RunJobs();
menu.Bounds.Width.ShouldBeLessThan(300);
WaitForWidthBetween(menu, menu.InlineCollapsedWidth, 300);
WaitForWidth(menu, 300);
WaitForInlineCollapsedLayoutWidthCleared(menu);
menu.Bounds.Width.ShouldBe(300, 0.5);
menu.GetBaseValue(Layoutable.WidthProperty).Value.ShouldBe(300);
double.IsNaN(menu.InlineCollapsedLayoutWidth).ShouldBeTrue(
"the internal animated layout width must be cleared after expand motion completes so future Width changes are not constrained by stale animation state.");
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Coerces_Effective_Width_And_Preserves_Base_Width_For_Expand()
{
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
MinWidth = 160,
Width = 240
};
menu.Items.Add(new NavMenuNode
{
Header = "Default",
ItemKey = "default"
});
var window = new Avalonia.Controls.Window
{
Width = 320,
Height = 240,
Content = menu
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
menu.GetBaseValue(Layoutable.WidthProperty).Value.ShouldBe(240);
menu.MinWidth.ShouldBe(160);
menu.IsInlineCollapsed = true;
Dispatcher.UIThread.RunJobs();
menu.Width.ShouldBe(48);
menu.MinWidth.ShouldBe(48);
menu.GetBaseValue(Layoutable.WidthProperty).Value.ShouldBe(240);
menu.GetBaseValue(Layoutable.MinWidthProperty).Value.ShouldBe(160);
menu.IsInlineCollapsed = false;
Dispatcher.UIThread.RunJobs();
menu.Width.ShouldBe(240);
menu.MinWidth.ShouldBe(160);
menu.GetBaseValue(Layoutable.WidthProperty).Value.ShouldBe(240);
menu.GetBaseValue(Layoutable.MinWidthProperty).Value.ShouldBe(160);
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Uses_Effective_Vertical_Mode_Without_Changing_Public_Mode()
{
var child = new NavMenuNode
{
Header = "Child",
ItemKey = "child"
};
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
};
menu.Items.Add(parent);
var window = new Avalonia.Controls.Window
{
Width = 320,
Height = 240,
Content = CreatePopupOverlayHost(menu)
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
menu.Mode.ShouldBe(NavMenuMode.Inline);
menu.EffectiveMode.ShouldBe(NavMenuMode.Vertical);
menu.InteractionHandler.ShouldBeOfType<DefaultNavMenuInteractionHandler>();
var parentContainer = (NavMenuItem)menu.ContainerFromItem(parent)!;
parentContainer.Mode.ShouldBe(NavMenuMode.Vertical);
((INavMenuItem)parentContainer).Open();
Dispatcher.UIThread.RunJobs();
parentContainer.Popup.ShouldNotBeNull();
parentContainer.Popup.IsOpen.ShouldBeTrue(
"inline collapsed reuses the popup branch instead of realizing inline child visuals in the root tree.");
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Toggle_Preserves_SelectedItem_And_Restores_Open_Inline_Path()
{
var child = new NavMenuNode
{
Header = "Child",
ItemKey = "child"
};
var parent = new NavMenuNode
{
Header = "Parent",
ItemKey = "parent"
};
parent.Children.Add(child);
var root = new NavMenuNode
{
Header = "Root",
ItemKey = "root"
};
root.Children.Add(parent);
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = true,
Width = 240
};
menu.Items.Add(root);
var window = new Avalonia.Controls.Window
{
Width = 320,
Height = 240,
Content = CreatePopupOverlayHost(menu)
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var rootContainer = (NavMenuItem)menu.ContainerFromItem(root)!;
rootContainer.Open();
Dispatcher.UIThread.RunJobs();
var parentContainer = (NavMenuItem)rootContainer.ContainerFromItem(parent)!;
parentContainer.Open();
Dispatcher.UIThread.RunJobs();
var childContainer = (NavMenuItem)parentContainer.ContainerFromItem(child)!;
menu.InteractionHandler.ShouldNotBeNull();
menu.SelectNavMenuItem(childContainer);
Dispatcher.UIThread.RunJobs();
menu.SelectedItem.ShouldBeSameAs(child);
rootContainer.IsSubMenuOpen.ShouldBeTrue();
parentContainer.IsSubMenuOpen.ShouldBeTrue();
rootContainer.IsInSelectedPath.ShouldBeTrue();
parentContainer.IsInSelectedPath.ShouldBeTrue();
for (var cycle = 0; cycle < 2; cycle++)
{
menu.IsInlineCollapsed = true;
WaitForWidth(menu, menu.InlineCollapsedWidth);
menu.Mode.ShouldBe(NavMenuMode.Inline);
menu.EffectiveMode.ShouldBe(NavMenuMode.Vertical);
menu.SelectedItem.ShouldBeSameAs(child);
rootContainer.IsSubMenuOpen.ShouldBeFalse();
parentContainer.IsSubMenuOpen.ShouldBeFalse(
"entering collapsed state closes inline child visuals in the root tree.");
rootContainer.IsInSelectedPath.ShouldBeTrue();
menu.IsInlineCollapsed = false;
Dispatcher.UIThread.RunJobs();
rootContainer = menu.ContainerFromItem(root).ShouldBeOfType<NavMenuItem>();
parentContainer = rootContainer.ContainerFromItem(parent).ShouldBeOfType<NavMenuItem>();
parentContainer.IsSubMenuOpen.ShouldBeTrue(
$"expand cycle {cycle + 1} must restore the nested path before width motion completes.");
WaitForWidth(menu, 240);
rootContainer = menu.ContainerFromItem(root).ShouldBeOfType<NavMenuItem>();
parentContainer = rootContainer.ContainerFromItem(parent).ShouldBeOfType<NavMenuItem>();
childContainer = parentContainer.ContainerFromItem(child).ShouldBeOfType<NavMenuItem>();
menu.EffectiveMode.ShouldBe(NavMenuMode.Inline);
menu.SelectedItem.ShouldBeSameAs(child);
rootContainer.IsSubMenuOpen.ShouldBeTrue(
$"leaving collapsed state must restore the root inline open path on cycle {cycle + 1}.");
parentContainer.IsSubMenuOpen.ShouldBeTrue(
$"leaving collapsed state must restore the cached inline open path on cycle {cycle + 1}.");
rootContainer.IsInSelectedPath.ShouldBeTrue();
parentContainer.IsInSelectedPath.ShouldBeTrue();
childContainer.IsSelected.ShouldBeTrue();
}
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Toggle_Then_Selecting_Sibling_Clears_Previous_Realized_Selection()
{
var firstChild = new NavMenuNode
{
Header = "First",
ItemKey = "first"
};
var secondChild = new NavMenuNode
{
Header = "Second",
ItemKey = "second"
};
var parent = new NavMenuNode
{
Header = "Parent",
ItemKey = "parent"
};
parent.Children.Add(firstChild);
parent.Children.Add(secondChild);
var root = new NavMenuNode
{
Header = "Root",
ItemKey = "root"
};
root.Children.Add(parent);
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = true,
Width = 240
};
menu.Items.Add(root);
var window = new Avalonia.Controls.Window
{
Width = 320,
Height = 240,
Content = CreatePopupOverlayHost(menu)
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var rootContainer = menu.ContainerFromItem(root).ShouldBeOfType<NavMenuItem>();
rootContainer.Open();
Dispatcher.UIThread.RunJobs();
var parentContainer = rootContainer.ContainerFromItem(parent).ShouldBeOfType<NavMenuItem>();
parentContainer.Open();
Dispatcher.UIThread.RunJobs();
var firstContainer = parentContainer.ContainerFromItem(firstChild).ShouldBeOfType<NavMenuItem>();
menu.InteractionHandler.ShouldNotBeNull();
menu.SelectNavMenuItem(firstContainer);
Dispatcher.UIThread.RunJobs();
for (var cycle = 0; cycle < 2; cycle++)
{
menu.IsInlineCollapsed = true;
WaitForWidth(menu, menu.InlineCollapsedWidth);
menu.IsInlineCollapsed = false;
WaitForWidth(menu, 240);
}
rootContainer = menu.ContainerFromItem(root).ShouldBeOfType<NavMenuItem>();
parentContainer = rootContainer.ContainerFromItem(parent).ShouldBeOfType<NavMenuItem>();
firstContainer = parentContainer.ContainerFromItem(firstChild).ShouldBeOfType<NavMenuItem>();
var secondContainer = parentContainer.ContainerFromItem(secondChild).ShouldBeOfType<NavMenuItem>();
firstContainer.IsSelected.ShouldBeTrue();
menu.InteractionHandler.ShouldNotBeNull();
menu.SelectNavMenuItem(secondContainer);
Dispatcher.UIThread.RunJobs();
menu.SelectedItem.ShouldBeSameAs(secondChild);
firstContainer.IsSelected.ShouldBeFalse(
"selecting a new leaf must clear the previously selected leaf after template recreation.");
secondContainer.IsSelected.ShouldBeTrue();
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Opening_Popup_Projects_Selected_State_To_New_Leaf_Container()
{
var borderBeam = new NavMenuNode
{
Header = "BorderBeam",
ItemKey = "border-beam"
};
var splash = new NavMenuNode
{
Header = "Splash",
ItemKey = "splash"
};
var other = new NavMenuNode
{
Header = "Other",
ItemKey = "other"
};
other.Children.Add(borderBeam);
other.Children.Add(splash);
var components = new NavMenuNode
{
Header = "Components",
ItemKey = "components"
};
components.Children.Add(other);
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = true,
Width = 240
};
menu.Items.Add(components);
var window = new Avalonia.Controls.Window
{
Width = 480,
Height = 320,
Content = CreatePopupOverlayHost(menu)
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
var otherContainer = componentsContainer.ContainerFromItem(other).ShouldBeOfType<NavMenuItem>();
otherContainer.Open();
Dispatcher.UIThread.RunJobs();
var splashContainer = otherContainer.ContainerFromItem(splash).ShouldBeOfType<NavMenuItem>();
menu.InteractionHandler.ShouldNotBeNull();
menu.SelectNavMenuItem(splashContainer);
Dispatcher.UIThread.RunJobs();
menu.SelectedItem.ShouldBeSameAs(splash);
splashContainer.IsSelected.ShouldBeTrue();
menu.IsInlineCollapsed = true;
WaitForWidth(menu, menu.InlineCollapsedWidth);
componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
for (var cycle = 0; cycle < 2; cycle++)
{
menu.InteractionHandler.ShouldNotBeNull();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
otherContainer = componentsContainer.ContainerFromItem(other).ShouldBeOfType<NavMenuItem>();
otherContainer.Open();
Dispatcher.UIThread.RunJobs();
splashContainer = otherContainer.ContainerFromItem(splash).ShouldBeOfType<NavMenuItem>();
menu.SelectedItem.ShouldBeSameAs(splash);
splashContainer.IsSelected.ShouldBeTrue(
$"popup realization cycle {cycle + 1} must project the persistent selected node to its current leaf container.");
otherContainer.Close();
componentsContainer.Close();
Dispatcher.UIThread.RunJobs();
}
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Opening_Sibling_Submenu_Preserves_Selected_Path()
{
var space = new NavMenuNode
{
Header = "Space",
ItemKey = "space"
};
var layout = new NavMenuNode
{
Header = "Layout",
ItemKey = "layout"
};
layout.Children.Add(space);
var autoComplete = new NavMenuNode
{
Header = "AutoComplete",
ItemKey = "auto-complete"
};
var dataEntry = new NavMenuNode
{
Header = "Data Entry",
ItemKey = "data-entry"
};
dataEntry.Children.Add(autoComplete);
var components = new NavMenuNode
{
Header = "Components",
ItemKey = "components"
};
components.Children.Add(layout);
components.Children.Add(dataEntry);
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
Width = 240
};
menu.Items.Add(components);
var window = new Avalonia.Controls.Window
{
Width = 480,
Height = 320,
Content = CreatePopupOverlayHost(menu)
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
var layoutContainer = componentsContainer.ContainerFromItem(layout).ShouldBeOfType<NavMenuItem>();
layoutContainer.Open();
Dispatcher.UIThread.RunJobs();
var spaceContainer = layoutContainer.ContainerFromItem(space).ShouldBeOfType<NavMenuItem>();
menu.InteractionHandler.ShouldNotBeNull();
menu.SelectNavMenuItem(spaceContainer);
Dispatcher.UIThread.RunJobs();
menu.IsInlineCollapsed = true;
WaitForWidth(menu, menu.InlineCollapsedWidth);
componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
layoutContainer = componentsContainer.ContainerFromItem(layout).ShouldBeOfType<NavMenuItem>();
var dataEntryContainer = componentsContainer.ContainerFromItem(dataEntry).ShouldBeOfType<NavMenuItem>();
layoutContainer.IsInSelectedPath.ShouldBeTrue();
dataEntryContainer.Open();
Dispatcher.UIThread.RunJobs();
menu.SelectedItem.ShouldBeSameAs(space);
layoutContainer.IsInSelectedPath.ShouldBeTrue(
"opening a sibling submenu must not replace the route's selected path.");
dataEntryContainer.IsInSelectedPath.ShouldBeFalse();
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Selecting_Leaf_In_Sibling_Branch_Clears_Previous_Selected_Path()
{
var space = new NavMenuNode
{
Header = "Space",
ItemKey = "space"
};
var layout = new NavMenuNode
{
Header = "Layout",
ItemKey = "layout"
};
layout.Children.Add(space);
var buttonSpinner = new NavMenuNode
{
Header = "ButtonSpinner",
ItemKey = "button-spinner"
};
var navigation = new NavMenuNode
{
Header = "Navigation",
ItemKey = "navigation"
};
navigation.Children.Add(buttonSpinner);
var components = new NavMenuNode
{
Header = "Components",
ItemKey = "components"
};
components.Children.Add(layout);
components.Children.Add(navigation);
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
Width = 240
};
menu.Items.Add(components);
var window = new Avalonia.Controls.Window
{
Width = 480,
Height = 320,
Content = CreatePopupOverlayHost(menu)
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
var layoutContainer = componentsContainer.ContainerFromItem(layout).ShouldBeOfType<NavMenuItem>();
layoutContainer.Open();
Dispatcher.UIThread.RunJobs();
var spaceContainer = layoutContainer.ContainerFromItem(space).ShouldBeOfType<NavMenuItem>();
menu.InteractionHandler.ShouldNotBeNull();
menu.SelectNavMenuItem(spaceContainer);
Dispatcher.UIThread.RunJobs();
menu.IsInlineCollapsed = true;
WaitForWidth(menu, menu.InlineCollapsedWidth);
componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
layoutContainer = componentsContainer.ContainerFromItem(layout).ShouldBeOfType<NavMenuItem>();
var navigationContainer = componentsContainer.ContainerFromItem(navigation).ShouldBeOfType<NavMenuItem>();
layoutContainer.IsInSelectedPath.ShouldBeTrue();
navigationContainer.Open();
Dispatcher.UIThread.RunJobs();
layoutContainer.IsSubMenuOpen.ShouldBeFalse(
"opening the sibling branch must close the popup that contains the previous selected leaf.");
var buttonSpinnerContainer = navigationContainer.ContainerFromItem(buttonSpinner)
.ShouldBeOfType<NavMenuItem>();
menu.SelectNavMenuItem(buttonSpinnerContainer);
Dispatcher.UIThread.RunJobs();
menu.SelectedItem.ShouldBeSameAs(buttonSpinner);
buttonSpinnerContainer.IsSelected.ShouldBeTrue();
navigationContainer.IsInSelectedPath.ShouldBeTrue();
layoutContainer.IsInSelectedPath.ShouldBeFalse(
"selecting a leaf in a sibling popup branch must clear the previous branch's selected-path state even when its leaf container is no longer realized.");
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Clearing_Unrealized_Selection_Clears_Previous_Selected_Path()
{
var space = new NavMenuNode
{
Header = "Space",
ItemKey = "space"
};
var layout = new NavMenuNode
{
Header = "Layout",
ItemKey = "layout"
};
layout.Children.Add(space);
var navigation = new NavMenuNode
{
Header = "Navigation",
ItemKey = "navigation"
};
navigation.Children.Add(new NavMenuNode
{
Header = "ButtonSpinner",
ItemKey = "button-spinner"
});
var components = new NavMenuNode
{
Header = "Components",
ItemKey = "components"
};
components.Children.Add(layout);
components.Children.Add(navigation);
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
Width = 240
};
menu.Items.Add(components);
var window = new Avalonia.Controls.Window
{
Width = 480,
Height = 320,
Content = CreatePopupOverlayHost(menu)
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
var layoutContainer = componentsContainer.ContainerFromItem(layout).ShouldBeOfType<NavMenuItem>();
layoutContainer.Open();
Dispatcher.UIThread.RunJobs();
var spaceContainer = layoutContainer.ContainerFromItem(space).ShouldBeOfType<NavMenuItem>();
menu.InteractionHandler.ShouldNotBeNull();
menu.SelectNavMenuItem(spaceContainer);
Dispatcher.UIThread.RunJobs();
menu.IsInlineCollapsed = true;
WaitForWidth(menu, menu.InlineCollapsedWidth);
componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
layoutContainer = componentsContainer.ContainerFromItem(layout).ShouldBeOfType<NavMenuItem>();
var navigationContainer = componentsContainer.ContainerFromItem(navigation).ShouldBeOfType<NavMenuItem>();
layoutContainer.IsInSelectedPath.ShouldBeTrue();
navigationContainer.Open();
Dispatcher.UIThread.RunJobs();
layoutContainer.IsSubMenuOpen.ShouldBeFalse();
menu.SelectedItem = null;
Dispatcher.UIThread.RunJobs();
layoutContainer.IsInSelectedPath.ShouldBeFalse(
"clearing a selection must remove the previous selected-path state even when the selected leaf container is no longer realized.");
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Pointer_Opening_Sibling_Submenu_Does_Not_Project_Selected_State()
{
var space = new NavMenuNode
{
Header = "Space",
ItemKey = "space"
};
var layout = new NavMenuNode
{
Header = "Layout",
ItemKey = "layout"
};
layout.Children.Add(space);
var dataEntry = new NavMenuNode
{
Header = "Data Entry",
ItemKey = "data-entry"
};
dataEntry.Children.Add(new NavMenuNode
{
Header = "AutoComplete",
ItemKey = "auto-complete"
});
var components = new NavMenuNode
{
Header = "Components",
ItemKey = "components"
};
components.Children.Add(layout);
components.Children.Add(dataEntry);
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
Width = 240
};
menu.Items.Add(components);
var window = new Avalonia.Controls.Window
{
Width = 480,
Height = 320,
Content = CreatePopupOverlayHost(menu)
};
var originalShowDelay = DefaultNavMenuInteractionHandler.MenuShowDelay;
DefaultNavMenuInteractionHandler.MenuShowDelay = TimeSpan.Zero;
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
var layoutContainer = componentsContainer.ContainerFromItem(layout).ShouldBeOfType<NavMenuItem>();
layoutContainer.Open();
Dispatcher.UIThread.RunJobs();
var spaceContainer = layoutContainer.ContainerFromItem(space).ShouldBeOfType<NavMenuItem>();
menu.InteractionHandler.ShouldNotBeNull();
menu.SelectNavMenuItem(spaceContainer);
Dispatcher.UIThread.RunJobs();
menu.IsInlineCollapsed = true;
WaitForWidth(menu, menu.InlineCollapsedWidth);
componentsContainer = menu.ContainerFromItem(components).ShouldBeOfType<NavMenuItem>();
componentsContainer.Open();
Dispatcher.UIThread.RunJobs();
var dataEntryContainer = componentsContainer.ContainerFromItem(dataEntry).ShouldBeOfType<NavMenuItem>();
var dataEntryHeader = GetItemHeader(dataEntryContainer);
MouseMove(dataEntryHeader, window);
Dispatcher.UIThread.RunJobs();
dataEntryContainer.IsSubMenuOpen.ShouldBeTrue(
"the regression scenario must reach the nested popup-open state shown in the Gallery screenshot.");
dataEntryContainer.Popup.ShouldNotBeNull();
dataEntryContainer.Popup.IsOpen.ShouldBeTrue();
dataEntryContainer.IsSelected.ShouldBeFalse();
dataEntryContainer.IsInSelectedPath.ShouldBeFalse();
dataEntryContainer.IsKeyboardActive.ShouldBeFalse();
menu.SelectedItem.ShouldBeSameAs(space);
}
finally
{
DefaultNavMenuInteractionHandler.MenuShowDelay = originalShowDelay;
window.Close();
}
}
[Fact]
public void InlineCollapsed_Handler_Recreation_Does_Not_Leave_Previous_Item_Selected()
{
var option1 = new NavMenuNode
{
Header = "Option 1",
ItemKey = "Option1",
Icon = new PieChartOutlined()
};
var option2 = new NavMenuNode
{
Header = "Option 2",
ItemKey = "Option2",
Icon = new PieChartOutlined()
};
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
DefaultSelectedPath = new TreeNodePath("/Option1")
};
menu.Items.Add(option1);
menu.Items.Add(option2);
var window = new Avalonia.Controls.Window
{
Width = 320,
Height = 240,
Content = menu
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var option1Container = (NavMenuItem)menu.ContainerFromItem(option1)!;
var option2Container = (NavMenuItem)menu.ContainerFromItem(option2)!;
option1Container.IsSelected.ShouldBeTrue();
option2Container.IsSelected.ShouldBeFalse();
menu.IsInlineCollapsed = true;
Dispatcher.UIThread.RunJobs();
menu.InteractionHandler.ShouldNotBeNull();
menu.SelectNavMenuItem(option2Container);
Dispatcher.UIThread.RunJobs();
option1Container.IsSelected.ShouldBeFalse(
"recreating the interaction handler for inline collapsed mode must not lose the previous selected item, otherwise the next selection cannot clear it.");
option2Container.IsSelected.ShouldBeTrue();
menu.SelectedItem.ShouldBeSameAs(option2);
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Does_Not_Affect_Vertical_Or_Horizontal_Effective_Mode()
{
foreach (var mode in new[] { NavMenuMode.Vertical, NavMenuMode.Horizontal })
{
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = mode,
IsInlineCollapsed = true
};
menu.EffectiveMode.ShouldBe(mode);
}
}
[Fact]
public void InlineCollapsed_Icon_Is_Centered_In_Item_Header()
{
var item = new NavMenuNode
{
Header = "Dashboard",
ItemKey = "dashboard",
Icon = new PieChartOutlined()
};
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
IsInlineCollapsed = true,
IsDarkStyle = true
};
menu.Items.Add(item);
menu.SelectedItem = item;
var window = new Avalonia.Controls.Window
{
Width = 240,
Height = 240,
Content = menu
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var itemContainer = (Control)menu.ContainerFromItem(item)!;
var itemHeader = GetItemHeader(itemContainer);
var frame = itemHeader.GetVisualDescendants()
.OfType<Border>()
.Single(border => border.Name == "Frame");
var iconPresenter = itemHeader.GetVisualDescendants()
.OfType<IconPresenter>()
.Single(presenter => presenter.Name == "ItemIconPresenter");
var iconPosition = iconPresenter.TranslatePoint(default, frame);
iconPosition.ShouldNotBeNull();
var frameCenter = frame.Bounds.Width / 2;
var iconCenter = iconPosition.Value.X + iconPresenter.Bounds.Width / 2;
iconCenter.ShouldBe(frameCenter, 0.5,
"the collapsed inline header hides text and indicator visuals, so the icon should be centered in the visible item frame rather than in the original text-layout column.");
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Root_Group_Items_Use_Collapsed_Icon_Layout_Through_Transparent_Groups()
{
var item = new NavMenuNode
{
Header = "Dashboard",
ItemKey = "dashboard",
Icon = new PieChartOutlined()
};
var nestedItem = new NavMenuNode
{
Header = "Projects",
ItemKey = "projects",
Icon = new PieChartOutlined()
};
var nestedGroup = new NavMenuGroup
{
Header = "Nested workspace"
};
nestedGroup.Entries.Add(nestedItem);
var group = new NavMenuGroup
{
Header = "Workspace"
};
group.Entries.Add(item);
group.Entries.Add(nestedGroup);
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
IsInlineCollapsed = true
};
menu.Items.Add(group);
var window = new Avalonia.Controls.Window
{
Width = 240,
Height = 240,
Content = menu
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var groupContainer = (ItemsControl)menu.ContainerFromItem(group)!;
var itemContainer = (Control)groupContainer.ContainerFromItem(item)!;
var itemHeader = GetItemHeader(itemContainer);
var nestedGroupContainer = (ItemsControl)groupContainer.ContainerFromItem(nestedGroup)!;
var nestedItemContainer = (Control)nestedGroupContainer.ContainerFromItem(nestedItem)!;
var nestedItemHeader = GetItemHeader(nestedItemContainer);
var frame = itemHeader.GetVisualDescendants()
.OfType<Border>()
.Single(border => border.Name == "Frame");
var iconPresenter = itemHeader.GetVisualDescendants()
.OfType<IconPresenter>()
.Single(presenter => presenter.Name == "ItemIconPresenter");
var nestedIconPresenter = nestedItemHeader.GetVisualDescendants()
.OfType<IconPresenter>()
.Single(presenter => presenter.Name == "ItemIconPresenter");
var iconPosition = iconPresenter.TranslatePoint(default, frame);
iconPosition.ShouldNotBeNull();
var frameCenter = frame.Bounds.Width / 2;
var iconCenter = iconPosition.Value.X + iconPresenter.Bounds.Width / 2;
itemHeader.IsInlineCollapsed.ShouldBeTrue(
"a group is structurally transparent, so a root group item must receive the root inline-collapsed state.");
nestedItemHeader.IsInlineCollapsed.ShouldBeTrue(
"nested transparent root groups must continue forwarding the root inline-collapsed state.");
iconPresenter.Bounds.Width.ShouldBe(16, 0.5,
"root group items must use the same CollapsedIconSize as direct root items.");
iconPresenter.Bounds.Height.ShouldBe(16, 0.5,
"root group items must use the same CollapsedIconSize as direct root items.");
iconCenter.ShouldBe(frameCenter, 0.5,
"root group items must center their collapsed icon in the visible item frame.");
nestedIconPresenter.Bounds.Width.ShouldBe(16, 0.5,
"transparent group depth must not change the root collapsed icon size.");
nestedIconPresenter.Bounds.Height.ShouldBe(16, 0.5,
"transparent group depth must not change the root collapsed icon size.");
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Hides_Footer_And_Restores_It_After_Expand()
{
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
IsInlineCollapsed = true,
Footer = new TextBlock { Text = "Current workspace" }
};
menu.Items.Add(new NavMenuNode
{
Header = "Dashboard",
ItemKey = "dashboard"
});
var window = new Avalonia.Controls.Window
{
Width = 240,
Height = 240,
Content = menu
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var footerPresenter = menu.GetVisualDescendants()
.OfType<ContentPresenter>()
.Single(presenter => presenter.Name == "PART_FooterPresenter");
footerPresenter.IsVisible.ShouldBeFalse(
"the expanded footer has no collapsed representation and must not render partial content inside the collapsed width.");
menu.IsInlineCollapsed = false;
Dispatcher.UIThread.RunJobs();
footerPresenter.IsVisible.ShouldBeTrue(
"expanding the menu must restore the configured footer without rebuilding its content.");
}
finally
{
window.Close();
}
}
[Fact]
public void InlineCollapsed_Popup_Group_Item_Keeps_Normal_Vertical_Icon_Layout()
{
var child = new NavMenuNode
{
Header = "Profile",
ItemKey = "profile",
Icon = new PieChartOutlined()
};
var group = new NavMenuGroup
{
Header = "Account"
};
group.Entries.Add(child);
var parent = new NavMenuNode
{
Header = "Settings",
ItemKey = "settings",
Icon = new PieChartOutlined()
};
parent.Entries.Add(group);
var menu = new AtomUI.Desktop.Controls.NavMenu
{
Mode = NavMenuMode.Inline,
IsMotionEnabled = false,
IsInlineCollapsed = true
};
menu.Items.Add(parent);
var window = new Avalonia.Controls.Window
{
Width = 480,
Height = 320,
Content = CreatePopupOverlayHost(menu)
};
try
{
window.Show();
Dispatcher.UIThread.RunJobs();
var parentContainer = (NavMenuItem)menu.ContainerFromItem(parent)!;
parentContainer.Open();
Dispatcher.UIThread.RunJobs();
var groupContainer = (ItemsControl)parentContainer.ContainerFromItem(group)!;
var childContainer = (Control)groupContainer.ContainerFromItem(child)!;
var childHeader = GetItemHeader(childContainer);
var iconPresenter = childHeader.GetVisualDescendants()
.OfType<IconPresenter>()
.Single(presenter => presenter.Name == "ItemIconPresenter");
childHeader.IsInlineCollapsed.ShouldBeFalse(
"groups inside a popup are not root-transparent entries and must keep normal vertical item visuals.");
iconPresenter.Bounds.Width.ShouldBe(14, 0.5,
"popup group items must keep ItemIconSize instead of the root-only CollapsedIconSize.");
iconPresenter.Bounds.Height.ShouldBe(14, 0.5,
"popup group items must keep ItemIconSize instead of the root-only CollapsedIconSize.");
}
finally
{
window.Close();
}
}
private static VisualLayerManager CreatePopupOverlayHost(Control content)
{
var visualLayerManager = new VisualLayerManager
{
Child = content
};
EnablePopupOverlayLayer(visualLayerManager);
return visualLayerManager;
}
private static void EnablePopupOverlayLayer(VisualLayerManager visualLayerManager)
{
var property = typeof(VisualLayerManager).GetProperty(
"EnablePopupOverlayLayer",
BindingFlags.Instance | BindingFlags.NonPublic);
property.ShouldNotBeNull();
property.SetValue(visualLayerManager, true);
}
private static BaseNavMenuItemHeader GetItemHeader(Control container)
{
var field = container.GetType().GetField(
"_itemHeader",
BindingFlags.Instance | BindingFlags.NonPublic);
field.ShouldNotBeNull();
return (BaseNavMenuItemHeader)field.GetValue(container)!;
}
private static void MouseMove(Control control, Avalonia.Controls.Window window)
{
var point = control.TranslatePoint(
new Point(control.Bounds.Width / 2, control.Bounds.Height / 2),
window);
point.ShouldNotBeNull();
window.MouseMove(point.Value);
}
private static void WaitForWidth(Control control, double expectedWidth)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < deadline)
{
Dispatcher.UIThread.RunJobs(DispatcherPriority.SystemIdle);
if (Math.Abs(control.Bounds.Width - expectedWidth) <= 0.5)
{
return;
}
Thread.Sleep(20);
}
Dispatcher.UIThread.RunJobs(DispatcherPriority.SystemIdle);
}
private static void WaitForWidthBetween(Control control, double lowerBound, double upperBound)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1);
while (DateTime.UtcNow < deadline)
{
Dispatcher.UIThread.RunJobs(DispatcherPriority.SystemIdle);
if (control.Bounds.Width > lowerBound + 0.5 &&
control.Bounds.Width < upperBound - 0.5)
{
return;
}
Thread.Sleep(10);
}
control.Bounds.Width.ShouldBeGreaterThan(lowerBound);
control.Bounds.Width.ShouldBeLessThan(upperBound);
}
private static void WaitForInlineCollapsedLayoutWidthCleared(AtomUI.Desktop.Controls.NavMenu menu)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1);
while (DateTime.UtcNow < deadline)
{
Dispatcher.UIThread.RunJobs(DispatcherPriority.SystemIdle);
if (double.IsNaN(menu.InlineCollapsedLayoutWidth))
{
return;
}
Thread.Sleep(10);
}
double.IsNaN(menu.InlineCollapsedLayoutWidth).ShouldBeTrue();
}
}