From 3c3a04d8cdec77c0ec9a5299dbd01f53d4cf4233 Mon Sep 17 00:00:00 2001 From: Nikita Tsukanov Date: Mon, 3 Aug 2026 15:58:11 +0500 Subject: [PATCH] Fix TODO entry from 2019 (#21889) * Add non-overlay popup support to the headless platform When AvaloniaHeadlessPlatformOptions.UsePlatformPopups is enabled, popups are hosted in dedicated headless top-levels positioned via ManagedPopupPositioner instead of the parent's overlay layer. HeadlessWindowExtensions.GetOpenPopups exposes the popups open above a given toplevel. PointToScreen/PointToClient now account for the window position, so popup placement can be asserted in desktop coordinates. Co-Authored-By: Claude Fable 5 * Rename UsePlatformPopups to OverlayPopups for consistency with other platforms Inverted semantics to match Win32/X11/macOS options; defaults to true for now so existing headless test suites keep overlay popup behavior. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../AvaloniaHeadlessPlatform.cs | 14 +- .../HeadlessWindowExtensions.cs | 13 ++ .../Avalonia.Headless/HeadlessWindowImpl.cs | 53 ++++- .../Avalonia.Headless/IHeadlessWindow.cs | 3 + .../Avalonia.Headless.UnitTests/PopupTests.cs | 185 ++++++++++++++++++ .../TestApplication.cs | 3 +- 6 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 tests/Avalonia.Headless.UnitTests/PopupTests.cs diff --git a/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs b/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs index 838301a6b5..9d5624d5ed 100644 --- a/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs +++ b/src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs @@ -14,9 +14,9 @@ namespace Avalonia.Headless internal static Compositor? Compositor { get; private set; } private static IRenderTimer? s_renderTimer; - private class HeadlessWindowingPlatform(PixelFormat frameBufferFormat) : IWindowingPlatform + private class HeadlessWindowingPlatform(PixelFormat frameBufferFormat, bool overlayPopups) : IWindowingPlatform { - public IWindowImpl CreateWindow() => new HeadlessWindowImpl(false, frameBufferFormat); + public IWindowImpl CreateWindow() => new HeadlessWindowImpl(frameBufferFormat, overlayPopups); public ITopLevelImpl CreateEmbeddableTopLevel() => CreateEmbeddableWindow(); public IWindowImpl CreateEmbeddableWindow() => throw new PlatformNotSupportedException(); @@ -49,7 +49,7 @@ namespace Avalonia.Headless .Bind().ToSingleton() .Bind().ToConstant(new KeyboardDevice()) .Bind().ToConstant(Rendering.RenderLoop.FromTimer(s_renderTimer)) - .Bind().ToConstant(new HeadlessWindowingPlatform(opts.FrameBufferFormat)) + .Bind().ToConstant(new HeadlessWindowingPlatform(opts.FrameBufferFormat, opts.OverlayPopups)) .Bind().ToSingleton() .Bind().ToConstant(new KeyGestureFormatInfo(new Dictionary() { })); Compositor = new Compositor( null); @@ -115,6 +115,14 @@ namespace Avalonia.Headless /// Gets or sets the pixel format to be used for the headless Window framebuffers. /// public PixelFormat FrameBufferFormat { get; set; } = PixelFormat.Rgba8888; + + /// + /// 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 to access them. + /// + // TODO13: Change the default to false to match the other desktop platforms. + public bool OverlayPopups { get; set; } = true; } public static class AvaloniaHeadlessPlatformExtensions diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs index 8bb3bf4f34..17fe5aa199 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.Platform; @@ -114,6 +115,18 @@ public static class HeadlessWindowExtensions DragDropEffects effects, RawInputModifiers modifiers = RawInputModifiers.None) => RunJobsOnImpl(topLevel, w => w.DragDrop(point, type, data, effects, modifiers)); + /// + /// 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. + /// + /// + /// Only popups hosted in dedicated headless top-levels are returned, which requires disabling + /// . Popups hosted in the overlay + /// layer are part of the parent's visual tree and are not tracked by the platform. + /// + public static IReadOnlyList GetOpenPopups(this TopLevel topLevel) => + GetImpl(topLevel).GetOpenPopups(); + /// /// Changes the render scaling (DPI) of the headless window/toplevel. /// This simulates a DPI change, triggering scaling changed notifications and a layout pass. diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs index 0036288ac0..efc2c08a89 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs @@ -26,11 +26,14 @@ namespace Avalonia.Headless private WriteableBitmap? _lastRenderedFrame; private readonly object _sync = new object(); private readonly PixelFormat _frameBufferFormat; + private readonly bool _overlayPopups; + private readonly HeadlessWindowImpl? _popupParent; + private readonly IPopupPositioner? _popupPositioner; + private readonly List _openPopups = new(); public bool IsPopup { get; } - public HeadlessWindowImpl(bool isPopup, PixelFormat frameBufferFormat) + public HeadlessWindowImpl(PixelFormat frameBufferFormat, bool overlayPopups) { - IsPopup = isPopup; Surfaces = [this]; _keyboard = AvaloniaLocator.Current.GetRequiredService(); _screen = new HeadlessScreensStub(); @@ -38,10 +41,28 @@ namespace Avalonia.Headless MouseDevice = new MouseDevice(_mousePointer); ClientSize = new Size(1024, 768); _frameBufferFormat = frameBufferFormat; + _overlayPopups = overlayPopups; + } + + private HeadlessWindowImpl(HeadlessWindowImpl popupParent) + : this(popupParent._frameBufferFormat, popupParent._overlayPopups) + { + IsPopup = true; + _popupParent = popupParent; + _popupPositioner = new ManagedPopupPositioner( + new ManagedPopupPositionerPopupImplHelper(popupParent, PopupMoveResize)); + } + + private void PopupMoveResize(PixelPoint position, Size size, double scaling) + { + Position = position; + PositionChanged?.Invoke(position); + DoResize(size, WindowResizeReason.Unspecified); } public void Dispose() { + _popupParent?._openPopups.Remove(this); Closed?.Invoke(); _lastRenderedFrame?.Dispose(); _lastRenderedFrame = null; @@ -68,9 +89,9 @@ namespace Avalonia.Headless public IInputRoot? InputRoot { get; set; } - public Point PointToClient(PixelPoint point) => point.ToPoint(RenderScaling); + public Point PointToClient(PixelPoint point) => (point - Position).ToPoint(RenderScaling); - public PixelPoint PointToScreen(Point point) => PixelPoint.FromPoint(point, RenderScaling); + public PixelPoint PointToScreen(Point point) => PixelPoint.FromPoint(point, RenderScaling) + Position; public void SetCursor(ICursorImpl? cursor) { @@ -82,6 +103,9 @@ 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++; @@ -91,6 +115,7 @@ namespace Avalonia.Headless public void Hide() { + _popupParent?._openPopups.Remove(this); Dispatcher.UIThread.Post(() => Deactivated?.Invoke(), DispatcherPriority.Input); } @@ -239,8 +264,7 @@ namespace Avalonia.Headless private ulong Timestamp => (ulong)_st.ElapsedMilliseconds; - // TODO: Hook recent Popup changes. - IPopupPositioner IPopupImpl.PopupPositioner => null!; + IPopupPositioner IPopupImpl.PopupPositioner => _popupPositioner!; public Size MaxAutoSizeHint => new Size(1920, 1080); @@ -379,10 +403,21 @@ namespace Avalonia.Headless PositionChanged?.Invoke(point); } - public IPopupImpl? CreatePopup() + public IPopupImpl? CreatePopup() => _overlayPopups ? null : new HeadlessWindowImpl(this); + + public IReadOnlyList GetOpenPopups() { - // TODO: Hook recent Popup changes. - return null; + if (_openPopups.Count == 0) + return Array.Empty(); + + var result = new List(_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) diff --git a/src/Headless/Avalonia.Headless/IHeadlessWindow.cs b/src/Headless/Avalonia.Headless/IHeadlessWindow.cs index 44ac0a5ace..5e2dd6a13d 100644 --- a/src/Headless/Avalonia.Headless/IHeadlessWindow.cs +++ b/src/Headless/Avalonia.Headless/IHeadlessWindow.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Input.Raw; using Avalonia.Media.Imaging; @@ -17,5 +19,6 @@ 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 GetOpenPopups(); } } diff --git a/tests/Avalonia.Headless.UnitTests/PopupTests.cs b/tests/Avalonia.Headless.UnitTests/PopupTests.cs new file mode 100644 index 0000000000..2a1536ce50 --- /dev/null +++ b/tests/Avalonia.Headless.UnitTests/PopupTests.cs @@ -0,0 +1,185 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; + +namespace Avalonia.Headless.UnitTests; + +public class PopupTests +{ +#if NUNIT + [AvaloniaTest] +#elif XUNIT + [AvaloniaFact] +#endif + public void Point_To_Screen_Respects_Window_Position() + { + var window = new Window { Width = 100, Height = 100 }; + window.Position = new PixelPoint(100, 200); + window.Show(); + Dispatcher.UIThread.RunJobs(); + + AssertHelper.Equal(new PixelPoint(110, 220), window.PointToScreen(new Point(10, 20))); + AssertHelper.Equal(new Point(10, 20), window.PointToClient(new PixelPoint(110, 220))); + + window.Close(); + } + +#if NUNIT + [AvaloniaTest] +#elif XUNIT + [AvaloniaFact] +#endif + public void Popup_Uses_Dedicated_TopLevel_And_Is_Discoverable() + { + var target = new Border { Background = Brushes.Red }; + var popup = new Popup + { + PlacementTarget = target, + Child = new Border { Width = 20, Height = 20 } + }; + var window = new Window + { + Width = 100, + Height = 100, + Content = new Panel { Children = { target, popup } } + }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + AssertHelper.Equal(0, window.GetOpenPopups().Count); + + 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); + + window.Close(); + } + +#if NUNIT + [AvaloniaTest] +#elif XUNIT + [AvaloniaFact] +#endif + public void Can_Click_Button_Inside_Platform_Popup() + { + 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.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); + + AssertHelper.Equal(1, clickCount); + + window.Close(); + } + +#if NUNIT + [AvaloniaTest] +#elif XUNIT + [AvaloniaFact] +#endif + public void Popup_Placement_Respects_Window_Position() + { + 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]; + var expected = target.PointToScreen(new Point(0, target.Bounds.Height)); + AssertHelper.Equal(expected, popupRoot.PointToScreen(default)); + + window.Close(); + } + +#if NUNIT + [AvaloniaTest] +#elif XUNIT + [AvaloniaFact] +#endif + public void Nested_Popup_Is_Child_Of_Popup_Root() + { + var nestedTarget = new Border { Width = 20, Height = 20, Background = Brushes.Green }; + var nestedPopup = new Popup + { + PlacementTarget = nestedTarget, + Child = new Border { Width = 10, Height = 10 } + }; + var target = new Border { Background = Brushes.Red }; + var popup = new Popup + { + PlacementTarget = target, + Child = new Panel { Children = { nestedTarget, nestedPopup } } + }; + var window = new Window + { + Width = 100, + Height = 100, + Content = new Panel { Children = { target, popup } } + }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + popup.Open(); + Dispatcher.UIThread.RunJobs(); + 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); + + window.Close(); + } +} diff --git a/tests/Avalonia.Headless.UnitTests/TestApplication.cs b/tests/Avalonia.Headless.UnitTests/TestApplication.cs index db23c521d4..4899292110 100644 --- a/tests/Avalonia.Headless.UnitTests/TestApplication.cs +++ b/tests/Avalonia.Headless.UnitTests/TestApplication.cs @@ -14,6 +14,7 @@ public class TestApplication : Application .UseSkia() .UseHeadless(new AvaloniaHeadlessPlatformOptions { - UseHeadlessDrawing = false + UseHeadlessDrawing = false, + OverlayPopups = false }); }