mirror of
https://github.com/AtomUI/AtomUI.git
synced 2026-10-25 02:30:32 +08:00
596 lines
18 KiB
C#
596 lines
18 KiB
C#
using System.Reflection;
|
|
using System.Threading;
|
|
using AtomUI.Controls;
|
|
using AtomUI.Controls.Primitives;
|
|
using AtomUI.Icons.AntDesign;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
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 menu = new AtomUI.Desktop.Controls.NavMenu
|
|
{
|
|
Mode = NavMenuMode.Inline,
|
|
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();
|
|
|
|
var parentContainer = (NavMenuItem)menu.ContainerFromItem(parent)!;
|
|
parentContainer.Open();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var childContainer = (NavMenuItem)parentContainer.ContainerFromItem(child)!;
|
|
menu.InteractionHandler.ShouldNotBeNull();
|
|
menu.InteractionHandler.Select(childContainer);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
menu.SelectedItem.ShouldBeSameAs(child);
|
|
parentContainer.IsSubMenuOpen.ShouldBeTrue();
|
|
parentContainer.IsInSelectedPath.ShouldBeTrue();
|
|
|
|
menu.IsInlineCollapsed = true;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
menu.Mode.ShouldBe(NavMenuMode.Inline);
|
|
menu.EffectiveMode.ShouldBe(NavMenuMode.Vertical);
|
|
menu.SelectedItem.ShouldBeSameAs(child);
|
|
parentContainer.IsSubMenuOpen.ShouldBeFalse(
|
|
"entering collapsed state closes inline child visuals in the root tree.");
|
|
parentContainer.IsInSelectedPath.ShouldBeTrue(
|
|
"selected path is a selection state and must survive inline collapsed toggles.");
|
|
|
|
menu.IsInlineCollapsed = false;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
menu.EffectiveMode.ShouldBe(NavMenuMode.Inline);
|
|
menu.SelectedItem.ShouldBeSameAs(child);
|
|
parentContainer.IsSubMenuOpen.ShouldBeTrue(
|
|
"leaving collapsed state restores the cached inline open path.");
|
|
parentContainer.IsInSelectedPath.ShouldBeTrue();
|
|
}
|
|
finally
|
|
{
|
|
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.InteractionHandler.Select(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();
|
|
}
|
|
}
|
|
|
|
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 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();
|
|
}
|
|
}
|