Browse Source

Implement TopLevel.OpenedPopups (#22074)

* Implement TopLevel.OpenedPopups

* Delete Headless `GetOpenPopups`

* Reorganize popup tests between Headless/Primitives layers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add Popup.OpenedPopups to keep opened popups in tree structure

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Help NUnit suppressing CS8777

* Update suppresions

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pull/22025/head
Max Katz 1 month ago
committed by GitHub
parent
commit
97dd7857fd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 16
      api/Avalonia.Headless.nupkg.xml
  2. 40
      src/Avalonia.Controls/Primitives/Popup.cs
  3. 3
      src/Avalonia.Controls/Primitives/PopupRoot.cs
  4. 14
      src/Avalonia.Controls/TopLevel.cs
  5. 2
      src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs
  6. 12
      src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
  7. 21
      src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
  8. 1
      src/Headless/Avalonia.Headless/IHeadlessWindow.cs
  9. 114
      tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
  10. 16
      tests/Avalonia.Headless.UnitTests/AssertHelper.cs
  11. 6
      tests/Avalonia.Headless.UnitTests/MouseDeviceTests.cs
  12. 115
      tests/Avalonia.Headless.UnitTests/PopupTests.cs
  13. 1
      tests/Avalonia.UnitTests/MockWindowingPlatform.cs

16
api/Avalonia.Headless.nupkg.xml

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Headless.HeadlessWindowExtensions.GetOpenPopups(Avalonia.Controls.TopLevel)</Target>
<Left>baseline/Avalonia.Headless/lib/net10.0/Avalonia.Headless.dll</Left>
<Right>current/Avalonia.Headless/lib/net10.0/Avalonia.Headless.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Headless.HeadlessWindowExtensions.GetOpenPopups(Avalonia.Controls.TopLevel)</Target>
<Left>baseline/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll</Left>
<Right>current/Avalonia.Headless/lib/net8.0/Avalonia.Headless.dll</Right>
</Suppression>
</Suppressions>

40
src/Avalonia.Controls/Primitives/Popup.cs

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Reactive;
@ -153,6 +154,7 @@ namespace Avalonia.Controls.Primitives
private bool _isUsingOverlayLayer;
private PopupOpenState? _openState;
private Action<IPopupHost?>? _popupHostChangedHandler;
private List<Popup>? _openedPopups;
/// <summary>
/// Initializes static members of the <see cref="Popup"/> class.
@ -177,6 +179,11 @@ namespace Avalonia.Controls.Primitives
internal IPopupHost? Host => _openState?.PopupHost;
/// <summary>
/// Gets the popups that are currently open directly inside this popup, in the order they were opened.
/// </summary>
public IReadOnlyList<Popup> OpenedPopups => _openedPopups ?? (IReadOnlyList<Popup>)[];
/// <summary>
/// Gets or sets a hint to the window manager that a shadow should be added to the popup.
/// </summary>
@ -575,7 +582,12 @@ namespace Avalonia.Controls.Primitives
}
}
_openState = new PopupOpenState(placementTarget, topLevel, popupHost, cleanupPopup);
_openState = new PopupOpenState(placementTarget, topLevel, popupHost, cleanupPopup, FindParentPopup(placementTarget));
if (_openState.ParentPopup is { } parentPopup)
parentPopup.AddOpenedPopup(this);
else
topLevel.AddOpenedPopup(this);
WindowManagerAddShadowHintChanged(popupHost, WindowManagerAddShadowHint);
@ -844,6 +856,11 @@ namespace Avalonia.Controls.Primitives
return;
}
if (_openState.ParentPopup is { } parentPopup)
parentPopup.RemoveOpenedPopup(this);
else
_openState.TopLevel.RemoveOpenedPopup(this);
_openState.Dispose();
_openState = null;
@ -1063,22 +1080,41 @@ namespace Avalonia.Controls.Primitives
}
}
internal void AddOpenedPopup(Popup popup) => (_openedPopups ??= new List<Popup>(capacity: 2)).Add(popup);
internal void RemoveOpenedPopup(Popup popup) => _openedPopups?.Remove(popup);
private static Popup? FindParentPopup(Visual placementTarget)
{
foreach (var visual in placementTarget.GetSelfAndVisualAncestors())
{
if (visual is IPopupHost)
return (visual as StyledElement)?.Parent as Popup;
}
return null;
}
private class PopupOpenState : IDisposable
{
private readonly IDisposable _cleanup;
private IDisposable? _presenterCleanup;
private Control _placementTarget;
public PopupOpenState(Control placementTarget, TopLevel topLevel, IPopupHost popupHost, IDisposable cleanup)
public PopupOpenState(Control placementTarget, TopLevel topLevel, IPopupHost popupHost, IDisposable cleanup,
Popup? parentPopup)
{
PlacementTarget = placementTarget;
TopLevel = topLevel;
ParentPopup = parentPopup;
PopupHost = popupHost;
_cleanup = cleanup;
}
public TopLevel TopLevel { get; }
public Popup? ParentPopup { get; }
public Control PlacementTarget
{
get => _placementTarget;

3
src/Avalonia.Controls/Primitives/PopupRoot.cs

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using Avalonia.Automation.Peers;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Diagnostics;
@ -122,6 +123,8 @@ namespace Avalonia.Controls.Primitives
public TopLevel ParentTopLevel { get; }
public override IReadOnlyList<Popup> OpenedPopups => (Parent as Popup)?.OpenedPopups ?? [];
/// <inheritdoc/>
public void Dispose()
{

14
src/Avalonia.Controls/TopLevel.cs

@ -132,6 +132,7 @@ namespace Avalonia.Controls
private TargetWeakEventSubscriber<TopLevel, ResourcesChangedEventArgs>? _resourcesChangesSubscriber;
private IStorageProvider? _storageProvider;
private Screens? _screens;
private List<Popup>? _openedPopups;
private readonly PresentationSource _source;
private readonly TopLevelHost _topLevelHost;
internal TopLevelHost TopLevelHost => _topLevelHost;
@ -559,6 +560,14 @@ namespace Avalonia.Controls
// TODO: Un-private
private IPlatformSettings? PlatformSettings => AvaloniaLocator.Current.GetService<IPlatformSettings>();
/// <summary>
/// Gets the popups that are currently open directly in this top level, in the order they were opened.
/// </summary>
/// <remarks>
/// Use <see cref="Popup.OpenedPopups"/> for nested popups.
/// </remarks>
public virtual IReadOnlyList<Popup> OpenedPopups => _openedPopups ?? (IReadOnlyList<Popup>)[];
/// <summary>
/// Gets the <see cref="TopLevel" /> for which the given <see cref="Visual"/> is hosted in.
/// </summary>
@ -708,6 +717,7 @@ namespace Avalonia.Controls
LayoutManager.Dispose();
_platformImplBindings.Clear();
_openedPopups = null;
}
/// <summary>
@ -724,6 +734,10 @@ namespace Avalonia.Controls
Renderer.Resized(clientSize);
}
internal void AddOpenedPopup(Popup popup) => (_openedPopups ??= new List<Popup>(capacity: 2)).Add(popup);
internal void RemoveOpenedPopup(Popup popup) => _openedPopups?.Remove(popup);
/// <summary>
/// Handles a window scaling change notification from
/// <see cref="ITopLevelImpl.ScalingChanged"/>.

2
src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs

@ -121,8 +121,6 @@ namespace Avalonia.Headless
/// <summary>
/// Embeds popups to the window when set to true. The default value is true.
/// When disabled, popups are hosted in dedicated headless top-levels that are not part of
/// the parent's visual tree; use <see cref="HeadlessWindowExtensions.GetOpenPopups"/> to access them.
/// </summary>
// TODO13: Change the default to false to match the other desktop platforms.
public bool OverlayPopups { get; set; } = true;

12
src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs

@ -115,18 +115,6 @@ public static class HeadlessWindowExtensions
DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None) =>
RunJobsOnImpl(topLevel, w => w.DragDrop(point, type, data, effects, modifiers));
/// <summary>
/// Returns the popups currently open directly above this toplevel, in z-order (bottom to top).
/// For popups nested in another popup, call this method on that popup's toplevel.
/// </summary>
/// <remarks>
/// Only popups hosted in dedicated headless top-levels are returned, which requires disabling
/// <see cref="AvaloniaHeadlessPlatformOptions.OverlayPopups"/>. Popups hosted in the overlay
/// layer are part of the parent's visual tree and are not tracked by the platform.
/// </remarks>
public static IReadOnlyList<TopLevel> GetOpenPopups(this TopLevel topLevel) =>
GetImpl(topLevel).GetOpenPopups();
/// <summary>
/// Changes the render scaling (DPI) of the headless window/toplevel.
/// This simulates a DPI change, triggering scaling changed notifications and a layout pass.

21
src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs

@ -27,7 +27,6 @@ namespace Avalonia.Headless
private readonly AvaloniaHeadlessPlatformOptions _options;
private readonly HeadlessWindowImpl? _popupParent;
private readonly IPopupPositioner? _popupPositioner;
private readonly List<HeadlessWindowImpl> _openPopups = new();
public bool IsPopup { get; }
public HeadlessWindowImpl(AvaloniaHeadlessPlatformOptions options)
@ -60,7 +59,6 @@ namespace Avalonia.Headless
public void Dispose()
{
_popupParent?._openPopups.Remove(this);
Closed?.Invoke();
_lastRenderedFrame?.Dispose();
_lastRenderedFrame = null;
@ -101,9 +99,6 @@ namespace Avalonia.Headless
public void Show(bool activate, bool isDialog)
{
if (_popupParent != null && !_popupParent._openPopups.Contains(this))
_popupParent._openPopups.Add(this);
if (activate)
{
ZOrder = _nextGlobalZOrder++;
@ -113,7 +108,6 @@ namespace Avalonia.Headless
public void Hide()
{
_popupParent?._openPopups.Remove(this);
Dispatcher.UIThread.Post(() => Deactivated?.Invoke(), DispatcherPriority.Input);
}
@ -403,21 +397,6 @@ namespace Avalonia.Headless
public IPopupImpl? CreatePopup() => _options.OverlayPopups ? null : new HeadlessWindowImpl(this);
public IReadOnlyList<TopLevel> GetOpenPopups()
{
if (_openPopups.Count == 0)
return Array.Empty<TopLevel>();
var result = new List<TopLevel>(_openPopups.Count);
foreach (var popup in _openPopups)
{
if (popup.InputRoot is PresentationSource { FocusRoot: TopLevel topLevel })
result.Add(topLevel);
}
return result;
}
public void SetWindowManagerAddShadowHint(bool enabled)
{

1
src/Headless/Avalonia.Headless/IHeadlessWindow.cs

@ -19,6 +19,5 @@ namespace Avalonia.Headless
void MouseWheel(Point point, Vector delta, RawInputModifiers modifiers = RawInputModifiers.None);
void DragDrop(Point point, RawDragEventType type, IDataTransfer data, DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None);
void SetRenderScaling(double scaling);
IReadOnlyList<TopLevel> GetOpenPopups();
}
}

114
tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs

@ -1392,6 +1392,109 @@ namespace Avalonia.Controls.UnitTests.Primitives
}
}
[Fact]
public void Opened_Popup_Should_Be_In_OpenedPopups()
{
using (CreateServices())
{
var target = new Popup();
var window = PreparedWindow(target);
target.Open();
Assert.Equal(new[] { target }, window.OpenedPopups);
target.Close();
Assert.Empty(window.OpenedPopups);
}
}
[Fact]
public void Closing_Popup_With_IsOpen_Should_Remove_It_From_OpenedPopups()
{
using (CreateServices())
{
var target = new Popup();
var window = PreparedWindow(target);
target.IsOpen = true;
Assert.Equal(new[] { target }, window.OpenedPopups);
target.IsOpen = false;
Assert.Empty(window.OpenedPopups);
}
}
[Fact]
public void Closing_Window_Should_Clear_OpenedPopups()
{
using (CreateServices())
{
var target = new Popup();
var window = PreparedWindow(target);
target.Open();
window.Close();
Assert.Empty(window.OpenedPopups);
}
}
[Fact]
public void Nested_Popup_Should_Be_In_Parent_Popup_OpenedPopups()
{
using (CreateServices())
{
var nestedTarget = new Border { Width = 20, Height = 20 };
var nestedPopup = new Popup
{
PlacementTarget = nestedTarget,
Child = new Border { Width = 10, Height = 10 }
};
var target = new Border();
var popup = new Popup
{
PlacementTarget = target,
Child = new Panel { Children = { nestedTarget, nestedPopup } }
};
var window = PreparedWindow(new Panel { Children = { target, popup } });
popup.Open();
if (popup.Host is OverlayPopupHost host)
{
//Need to measure/arrange for visual children to show up
//in OverlayPopupHost
host.Measure(Size.Infinity);
host.Arrange(new Rect(host.DesiredSize));
}
nestedPopup.Open();
Assert.Equal([popup], window.OpenedPopups);
Assert.Equal([nestedPopup], popup.OpenedPopups);
Assert.Empty(nestedPopup.OpenedPopups);
if (popup.Host is PopupRoot popupRoot)
{
// A popup root exposes the popups opened by its own popup.
Assert.Equal([nestedPopup], popupRoot.OpenedPopups);
}
nestedPopup.Close();
Assert.Equal([popup], window.OpenedPopups);
Assert.Empty(popup.OpenedPopups);
popup.Close();
Assert.Empty(window.OpenedPopups);
}
}
private IDisposable CreateServices()
{
return UnitTestApplication.Start(TestServices.StyledWindow.With(
@ -1430,13 +1533,22 @@ namespace Avalonia.Controls.UnitTests.Primitives
{
if (UsePopupHost)
return null;
return MockWindowingPlatform.CreatePopupMock(mock.Object).Object;
return CreatePopupMock(mock.Object);
});
return mock.Object;
}, null);
}
private static IPopupImpl CreatePopupMock(IWindowBaseImpl parent)
{
var mock = MockWindowingPlatform.CreatePopupMock(parent);
mock.Setup(x => x.CreatePopup()).Returns(() => CreatePopupMock(mock.Object));
return mock.Object;
}
private static Window PreparedWindow(object? content = null)
{
var w = new Window { Content = content };

16
tests/Avalonia.Headless.UnitTests/AssertHelper.cs

@ -1,5 +1,7 @@
#nullable enable
using System.Diagnostics.CodeAnalysis;
namespace Avalonia.Headless.UnitTests;
internal static class AssertHelper
@ -22,14 +24,26 @@ internal static class AssertHelper
#endif
}
public static void NotNull(object? value)
public static void Null(object? value)
{
#if NUNIT
Assert.That(value, Is.Null);
#elif XUNIT
Assert.Null(value);
#endif
}
public static void NotNull([NotNull] object? value)
{
#if NUNIT
Assert.That(value, Is.Not.Null);
#elif XUNIT
Assert.NotNull(value);
#endif
// NUnit doesn't suppress CS8777 warning on its own
#pragma warning disable CS8777 // Parameter must have a non-null value when exiting.
}
#pragma warning restore CS8777 // Parameter must have a non-null value when exiting.
public static void Equal<T>(T expected, T actual)
{

6
tests/Avalonia.Headless.UnitTests/MouseDeviceTests.cs

@ -65,7 +65,11 @@ public class MouseDeviceTests
// Pressing captures the pointer implicitly on the window's border.
window.MouseDown(new Point(50, 50), MouseButton.Left);
window.GetOpenPopups()[0].MouseMove(new Point(40, 15));
var popupRoot = PopupTests.GetPopupTopLevel(popup);
AssertHelper.NotNull(popupRoot);
popupRoot.MouseMove(new Point(40, 15));
AssertHelper.Same(TestApplication.UsesSharedMouseDevice ? target : popupChild, moveTarget);

115
tests/Avalonia.Headless.UnitTests/PopupTests.cs

@ -32,7 +32,7 @@ public class PopupTests
#elif XUNIT
[AvaloniaFact]
#endif
public void Popup_Uses_Dedicated_TopLevel_And_Is_Discoverable()
public void Popup_Uses_Dedicated_TopLevel()
{
var target = new Border { Background = Brushes.Red };
var popup = new Popup
@ -49,22 +49,22 @@ public class PopupTests
window.Show();
Dispatcher.UIThread.RunJobs();
AssertHelper.Equal(0, window.GetOpenPopups().Count);
AssertHelper.False(popup.IsOpen);
AssertHelper.False(popup.IsUsingOverlayLayer);
AssertHelper.Null(GetPopupTopLevel(popup));
popup.Open();
Dispatcher.UIThread.RunJobs();
AssertHelper.True(popup.IsOpen);
AssertHelper.False(popup.IsUsingOverlayLayer);
AssertHelper.Equal(1, window.GetOpenPopups().Count);
AssertHelper.True(window.GetOpenPopups()[0] is PopupRoot);
popup.Close();
Dispatcher.UIThread.RunJobs();
AssertHelper.Equal(0, window.GetOpenPopups().Count);
AssertHelper.True(GetPopupTopLevel(popup) is PopupRoot);
window.Close();
AssertHelper.False(popup.IsOpen);
AssertHelper.False(popup.IsUsingOverlayLayer);
AssertHelper.Null(GetPopupTopLevel(popup));
}
#if NUNIT
@ -72,31 +72,40 @@ public class PopupTests
#elif XUNIT
[AvaloniaFact]
#endif
public void Can_Click_Button_Inside_Platform_Popup()
public void Popup_Placement_Respects_Window_Position()
{
var clickCount = 0;
var button = new Button { Width = 80, Height = 30 };
button.Click += (_, _) => clickCount++;
var target = new Border { Background = Brushes.Red };
var popup = new Popup { PlacementTarget = target, Child = button };
var target = new Border
{
Width = 20,
Height = 20,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Background = Brushes.Red
};
var popup = new Popup
{
PlacementTarget = target,
Placement = PlacementMode.Bottom,
Child = new Border { Width = 20, Height = 20 }
};
var window = new Window
{
Width = 100,
Height = 100,
Content = new Panel { Children = { target, popup } }
};
window.Position = new PixelPoint(100, 200);
window.Show();
Dispatcher.UIThread.RunJobs();
popup.Open();
Dispatcher.UIThread.RunJobs();
var popupRoot = window.GetOpenPopups()[0];
popupRoot.MouseDown(new Point(40, 15), MouseButton.Left);
popupRoot.MouseUp(new Point(40, 15), MouseButton.Left);
var popupRoot = GetPopupTopLevel(popup);
AssertHelper.NotNull(popupRoot);
AssertHelper.Equal(1, clickCount);
var expected = target.PointToScreen(new Point(0, target.Bounds.Height));
AssertHelper.Equal(expected, popupRoot.PointToScreen(default));
window.Close();
}
@ -106,38 +115,33 @@ public class PopupTests
#elif XUNIT
[AvaloniaFact]
#endif
public void Popup_Placement_Respects_Window_Position()
public void Can_Click_Button_Inside_Platform_Popup()
{
var target = new Border
{
Width = 20,
Height = 20,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Background = Brushes.Red
};
var popup = new Popup
{
PlacementTarget = target,
Placement = PlacementMode.Bottom,
Child = new Border { Width = 20, Height = 20 }
};
var clickCount = 0;
var button = new Button { Width = 80, Height = 30 };
button.Click += (_, _) => clickCount++;
var target = new Border { Background = Brushes.Red };
var popup = new Popup { PlacementTarget = target, Child = button };
var window = new Window
{
Width = 100,
Height = 100,
Content = new Panel { Children = { target, popup } }
};
window.Position = new PixelPoint(100, 200);
window.Show();
Dispatcher.UIThread.RunJobs();
popup.Open();
Dispatcher.UIThread.RunJobs();
var popupRoot = window.GetOpenPopups()[0];
var expected = target.PointToScreen(new Point(0, target.Bounds.Height));
AssertHelper.Equal(expected, popupRoot.PointToScreen(default));
var popupRoot = GetPopupTopLevel(popup);
AssertHelper.NotNull(popupRoot);
popupRoot.MouseDown(new Point(40, 15), MouseButton.Left);
popupRoot.MouseUp(new Point(40, 15), MouseButton.Left);
AssertHelper.Equal(1, clickCount);
window.Close();
}
@ -147,7 +151,7 @@ public class PopupTests
#elif XUNIT
[AvaloniaFact]
#endif
public void Nested_Popup_Is_Child_Of_Popup_Root()
public void Nested_Popup_Is_Owned_By_Parent_Popup()
{
var nestedTarget = new Border { Width = 20, Height = 20, Background = Brushes.Green };
var nestedPopup = new Popup
@ -175,11 +179,34 @@ public class PopupTests
nestedPopup.Open();
Dispatcher.UIThread.RunJobs();
var popupRoot = window.GetOpenPopups()[0];
AssertHelper.Equal(1, window.GetOpenPopups().Count);
AssertHelper.Equal(1, popupRoot.GetOpenPopups().Count);
AssertHelper.True(popupRoot.GetOpenPopups()[0] is PopupRoot);
AssertHelper.Equal(1, window.OpenedPopups.Count);
AssertHelper.Same(popup, window.OpenedPopups[0]);
AssertHelper.Equal(1, popup.OpenedPopups.Count);
AssertHelper.Same(nestedPopup, popup.OpenedPopups[0]);
AssertHelper.Equal(0, nestedPopup.OpenedPopups.Count);
// The nested popup is hosted in the parent popup's own top level.
AssertHelper.Same(GetPopupTopLevel(popup), TopLevel.GetTopLevel(nestedTarget));
nestedPopup.Close();
Dispatcher.UIThread.RunJobs();
AssertHelper.Equal(0, popup.OpenedPopups.Count);
AssertHelper.Equal(1, window.OpenedPopups.Count);
popup.Close();
Dispatcher.UIThread.RunJobs();
AssertHelper.Equal(0, window.OpenedPopups.Count);
window.Close();
}
internal static TopLevel GetPopupTopLevel(Popup popup)
{
AssertHelper.NotNull(popup.Child);
var topLevel = TopLevel.GetTopLevel(popup.Child);
return topLevel;
}
}

1
tests/Avalonia.UnitTests/MockWindowingPlatform.cs

@ -101,6 +101,7 @@ namespace Avalonia.UnitTests
popupImpl.Setup(x => x.Compositor).Returns(compositor);
popupImpl.Setup(x => x.ClientSize).Returns(() => clientSize);
popupImpl.Setup(x => x.MaxAutoSizeHint).Returns(s_screenSize);
popupImpl.Setup(x => x.DesktopScaling).Returns(1);
popupImpl.Setup(x => x.RenderScaling).Returns(1);
popupImpl.Setup(x => x.PopupPositioner).Returns(positioner);
popupImpl.Setup(x => x.Position).Returns(()=>position);

Loading…
Cancel
Save