Browse Source

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pull/21918/head
Nikita Tsukanov 2 months ago
committed by GitHub
parent
commit
3c3a04d8cd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      src/Headless/Avalonia.Headless/AvaloniaHeadlessPlatform.cs
  2. 13
      src/Headless/Avalonia.Headless/HeadlessWindowExtensions.cs
  3. 53
      src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
  4. 3
      src/Headless/Avalonia.Headless/IHeadlessWindow.cs
  5. 185
      tests/Avalonia.Headless.UnitTests/PopupTests.cs
  6. 3
      tests/Avalonia.Headless.UnitTests/TestApplication.cs

14
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<IPlatformIconLoader>().ToSingleton<HeadlessIconLoaderStub>()
.Bind<IKeyboardDevice>().ToConstant(new KeyboardDevice())
.Bind<IRenderLoop>().ToConstant(Rendering.RenderLoop.FromTimer(s_renderTimer))
.Bind<IWindowingPlatform>().ToConstant(new HeadlessWindowingPlatform(opts.FrameBufferFormat))
.Bind<IWindowingPlatform>().ToConstant(new HeadlessWindowingPlatform(opts.FrameBufferFormat, opts.OverlayPopups))
.Bind<PlatformHotkeyConfiguration>().ToSingleton<PlatformHotkeyConfiguration>()
.Bind<KeyGestureFormatInfo>().ToConstant(new KeyGestureFormatInfo(new Dictionary<Key, string>() { }));
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.
/// </summary>
public PixelFormat FrameBufferFormat { get; set; } = PixelFormat.Rgba8888;
/// <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;
}
public static class AvaloniaHeadlessPlatformExtensions

13
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));
/// <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.

53
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<HeadlessWindowImpl> _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<IKeyboardDevice>();
_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<TopLevel> GetOpenPopups()
{
// TODO: Hook recent Popup changes.
return null;
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)

3
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<TopLevel> GetOpenPopups();
}
}

185
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();
}
}

3
tests/Avalonia.Headless.UnitTests/TestApplication.cs

@ -14,6 +14,7 @@ public class TestApplication : Application
.UseSkia()
.UseHeadless(new AvaloniaHeadlessPlatformOptions
{
UseHeadlessDrawing = false
UseHeadlessDrawing = false,
OverlayPopups = false
});
}

Loading…
Cancel
Save