Browse Source

Propagate Popup.IsHitTestVisible to IPopupImpl to skip hit-testing popups on the OS level if available (#21916)

* Propagate Popup.IsHitTestVisible to IPopupImpl to skip hit-testing popups on the OS level if available

* I hate our api diff system
pull/21921/head
Nikita Tsukanov 2 months ago
committed by GitHub
parent
commit
9aed2da669
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 16
      api/Avalonia.nupkg.xml
  2. 11
      native/Avalonia.Native/src/OSX/PopupImpl.mm
  3. 7
      src/Avalonia.Controls/Platform/IPopupImpl.cs
  4. 5
      src/Avalonia.Controls/Primitives/IPopupHost.cs
  5. 6
      src/Avalonia.Controls/Primitives/Popup.cs
  6. 5
      src/Avalonia.Controls/Primitives/PopupRoot.cs
  7. 2
      src/Avalonia.DesignerSupport/Remote/Stubs.cs
  8. 8
      src/Avalonia.Native/PopupImpl.cs
  9. 2
      src/Avalonia.Native/avn.idl
  10. 4
      src/Avalonia.Wayland/PopupImpl.Sink.cs
  11. 7
      src/Avalonia.Wayland/PopupImpl.cs
  12. 8
      src/Avalonia.Wayland/Server/Persistent/IWSurface.cs
  13. 71
      src/Avalonia.Wayland/Server/Persistent/WSurface.cs
  14. 11
      src/Avalonia.X11/X11Enums.cs
  15. 17
      src/Avalonia.X11/X11Info.cs
  16. 28
      src/Avalonia.X11/X11Window.cs
  17. 18
      src/Avalonia.X11/XLib.cs
  18. 6
      src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs
  19. 8
      src/Windows/Avalonia.Win32/PopupImpl.cs
  20. 27
      tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs
  21. 42
      tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs

16
api/Avalonia.nupkg.xml

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?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>
@ -169,6 +169,12 @@
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Controls.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.IPopupImpl.SetHitTestVisible(System.Boolean)</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Controls.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(System.Nullable{Avalonia.Platform.PlatformThemeVariant})</Target>
@ -199,10 +205,16 @@
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Controls.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.IPopupImpl.SetHitTestVisible(System.Boolean)</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Controls.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(System.Nullable{Avalonia.Platform.PlatformThemeVariant})</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Controls.dll</Right>
</Suppression>
</Suppressions>
</Suppressions>

11
native/Avalonia.Native/src/OSX/PopupImpl.mm

@ -43,6 +43,17 @@ public:
return WindowBaseImpl::Show(activate, true);
}
virtual HRESULT SetHitTestVisible(bool value) override
{
START_COM_CALL;
@autoreleasepool
{
[Window setIgnoresMouseEvents:!value];
return S_OK;
}
}
virtual bool ShouldTakeFocusOnShow() override
{
auto parent = Parent.tryGet();

7
src/Avalonia.Controls/Platform/IPopupImpl.cs

@ -13,5 +13,12 @@ namespace Avalonia.Platform
void SetWindowManagerAddShadowHint(bool enabled);
void TakeFocus();
/// <summary>
/// Sets whether the popup window takes part in pointer hit testing. When false, the
/// native window is made input-transparent so that pointer input passes through to
/// whatever is behind it.
/// </summary>
void SetHitTestVisible(bool isHitTestVisible);
}
}

5
src/Avalonia.Controls/Primitives/IPopupHost.cs

@ -57,6 +57,11 @@ namespace Avalonia.Controls.Primitives
/// </summary>
bool Topmost { get; set; }
/// <summary>
/// Gets or sets whether the popup takes part in pointer hit testing.
/// </summary>
bool IsHitTestVisible { get; set; }
/// <summary>
/// Gets or sets a transform that will be applied to the popup.
/// </summary>

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

@ -159,7 +159,6 @@ namespace Avalonia.Controls.Primitives
/// </summary>
static Popup()
{
IsHitTestVisibleProperty.OverrideDefaultValue<Popup>(false);
ChildProperty.Changed.AddClassHandler<Popup>((x, e) => x.ChildChanged(e));
IsOpenProperty.Changed.AddClassHandler<Popup>((x, e) => x.IsOpenChanged((AvaloniaPropertyChangedEventArgs<bool>)e));
}
@ -451,6 +450,7 @@ namespace Avalonia.Controls.Primitives
UpdateHostSizing(popupHost, topLevel, placementTarget);
popupHost.Topmost = Topmost;
popupHost.IsHitTestVisible = IsHitTestVisible;
popupHost.SetChild(Child);
((ISetLogicalParent)popupHost).SetParent(this);
@ -691,6 +691,10 @@ namespace Avalonia.Controls.Primitives
{
_openState.PopupHost.Topmost = change.GetNewValue<bool>();
}
else if (change.Property == IsHitTestVisibleProperty)
{
_openState.PopupHost.IsHitTestVisible = change.GetNewValue<bool>();
}
}
}

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

@ -62,6 +62,7 @@ namespace Avalonia.Controls.Primitives
{
ParentTopLevel = parent;
impl.SetWindowManagerAddShadowHint(WindowManagerAddShadowHint);
impl.SetHitTestVisible(IsHitTestVisible);
}
/// <summary>
@ -219,6 +220,10 @@ namespace Avalonia.Controls.Primitives
{
PlatformImpl?.SetTopmost(change.GetNewValue<bool>());
}
else if (change.Property == IsHitTestVisibleProperty)
{
PlatformImpl?.SetHitTestVisible(change.GetNewValue<bool>());
}
}
}
}

2
src/Avalonia.DesignerSupport/Remote/Stubs.cs

@ -215,6 +215,8 @@ namespace Avalonia.DesignerSupport.Remote
return null;
}
public void TakeFocus() { }
public void SetHitTestVisible(bool isHitTestVisible) { }
}
class ClipboardStub : IClipboard

8
src/Avalonia.Native/PopupImpl.cs

@ -8,6 +8,7 @@ namespace Avalonia.Native
class PopupImpl : WindowBaseImpl, IPopupImpl
{
private readonly ITopLevelImpl _parent;
private readonly IAvnPopup _native;
public PopupImpl(IAvaloniaNativeFactory factory,
ITopLevelImpl parent) : base(factory)
@ -16,7 +17,7 @@ namespace Avalonia.Native
using (var e = new PopupEvents(this))
{
Init(new MacOSTopLevelHandle(factory.CreatePopup(e)));
Init(new MacOSTopLevelHandle(_native = factory.CreatePopup(e)));
}
PopupPositioner = new ManagedPopupPositioner(new ManagedPopupPositionerPopupImplHelper(parent, MoveResize));
@ -87,6 +88,11 @@ namespace Avalonia.Native
{
}
public void SetHitTestVisible(bool isHitTestVisible)
{
_native.SetHitTestVisible(isHitTestVisible.AsComBool());
}
public void TakeFocus()
{
var parent = _parent;

2
src/Avalonia.Native/avn.idl

@ -791,7 +791,7 @@ interface IAvnWindowBase : IAvnTopLevel
[uuid(83e588f3-6981-4e48-9ea0-e1e569f79a91), cpp-virtual-inherits]
interface IAvnPopup : IAvnWindowBase
{
HRESULT SetHitTestVisible(bool value);
}
[uuid(cab661de-49d6-4ead-b59c-eac9b2b6c28d), cpp-virtual-inherits]

4
src/Avalonia.Wayland/PopupImpl.Sink.cs

@ -43,6 +43,10 @@ partial class PopupImpl
// Re-apply cursor (defaults to Arrow on a fresh worker WSurface).
if (Parent.CurrentCursor is not null)
Parent.ApplyCurrentCursor(_surfaceProxy);
// A fresh worker WSurface starts hit-test visible.
if (!Parent._isHitTestVisible)
_surfaceProxy.SetHitTestVisible(false);
}
protected override void DisconnectFromSurface()

7
src/Avalonia.Wayland/PopupImpl.cs

@ -33,6 +33,7 @@ internal partial class PopupImpl : WindowBaseImpl, IPopupImpl
private WaylandSurfaceCreateResult<WXdgPopupProxy>? _handle;
private WXdgPopupProxy? _surfaceProxy;
private XdgPopupPositionerParams? _lastPositioner;
private bool _isHitTestVisible = true;
public PopupImpl(WaylandWorkerClient client, WindowBaseImpl parent) : base(client)
{
@ -108,6 +109,12 @@ internal partial class PopupImpl : WindowBaseImpl, IPopupImpl
{
}
public void SetHitTestVisible(bool isHitTestVisible)
{
_isHitTestVisible = isHitTestVisible;
_surfaceProxy?.SetHitTestVisible(isHitTestVisible);
}
/// <summary>
/// Bridges Avalonia's positioner contract to <see cref="UpdatePositioner"/>.
/// </summary>

8
src/Avalonia.Wayland/Server/Persistent/IWSurface.cs

@ -58,4 +58,12 @@ internal interface IWSurface
/// <summary>Resets the IME state (clears any pending preedit/commit).</summary>
void ResetTextInput();
/// <summary>
/// Sets whether the surface takes part in pointer/touch hit testing. When
/// false an empty wl_region is installed as the input region, so the
/// compositor routes input to whatever is behind. Cached and re-applied on
/// reconnect.
/// </summary>
void SetHitTestVisible(bool value);
}

71
src/Avalonia.Wayland/Server/Persistent/WSurface.cs

@ -29,6 +29,7 @@ class WSurface : IPersistentWaylandObject, IWSurface, IWaylandFramebufferSurface
protected double? PreferredFractionalScale { get; private set; }
protected List<WaylandOutputsTracker.Output> Outputs { get; } = new();
private WlCallback? _frameCallback;
private bool _hitTestVisible = true;
private double _currentScale = 1;
private const double ScaleEpsilon = 1e-6;
private readonly List<IDisposable> _activeRenderTargets = new();
@ -115,6 +116,65 @@ class WSurface : IPersistentWaylandObject, IWSurface, IWaylandFramebufferSurface
public virtual void ResetTextInput() => TextInputV3?.Reset(this);
public void SetHitTestVisible(bool value)
{
if (_hitTestVisible == value)
return;
_hitTestVisible = value;
// No-op while disconnected; OnConnected replays the cached value.
if (WlSurface == null)
return;
ApplyInputRegion();
// Toggling hit-test visibility changes nothing about what's drawn, so an
// idle surface may never attach another buffer. Commit out of band to
// promote the region — a commit without an attach is well-defined.
if (CanCommitOutOfBand)
WlSurface.Commit();
}
/// <summary>
/// Whether committing this surface outside of a buffer attach is legal right
/// now. When it isn't, staged double-buffered state stays pending and is
/// promoted by the surface's own next commit.
/// </summary>
/// <remarks>
/// Such a commit can't promote another frame's half-staged state: UI→worker
/// proxy calls land as compositor server jobs, which ServerCompositor.RenderCore
/// drains before it renders any target, while the per-frame staging
/// (<see cref="OnBeforeNewBufferAttached"/>) and the attach+commit that
/// promotes it are one synchronous block inside the render pass.
/// </remarks>
protected virtual bool CanCommitOutOfBand => true;
/// <summary>
/// Installs the input region matching the cached hit-test state. An empty
/// region makes the compositor route pointer/touch input to whatever is
/// behind this surface.
/// </summary>
private void ApplyInputRegion()
{
if (WlSurface == null)
return;
if (_hitTestVisible)
{
// null is the protocol default: an infinite input region.
WlSurface.SetInputRegion(null!);
return;
}
var region = Globals!.WlCompositor.CreateRegion();
try
{
WlSurface.SetInputRegion(region);
}
finally
{
region.Destroy();
region.Dispose();
}
}
public virtual void OnConnected(WaylandConnection connection, WaylandGlobals globals)
{
Connection = connection;
@ -127,6 +187,11 @@ class WSurface : IPersistentWaylandObject, IWSurface, IWaylandFramebufferSurface
WlSurface, new FractionalScaleListener(this), connection.Queue);
Viewport = globals.Viewporter!.GetViewport(WlSurface);
}
// Re-apply the cached input region on (re)connect. It's double-buffered
// state, promoted by the next commit — which happens before the surface
// can receive any input.
ApplyInputRegion();
}
private IPlatformRenderSurface[]? _renderSurfaces;
@ -346,6 +411,12 @@ class WXdgShellSurface : WSurface, IWXdgShellSurface
/// <summary>True iff this surface is currently mapped (xdg-shell sense).</summary>
internal bool IsMapped => _mapped;
// Committing an xdg_surface before it has a role, or before its initial
// configure has been acked, is a protocol error. Until we're mapped, staged
// state rides along on the commit that assigns the role (TryAttachToParent)
// or on the first buffer attach.
protected override bool CanCommitOutOfBand => _mapped;
internal void RegisterPendingChildPopup(WXdgPopup popup) => _pendingChildPopups.Add(popup);
internal void UnregisterPendingChildPopup(WXdgPopup popup) => _pendingChildPopups.Remove(popup);

11
src/Avalonia.X11/X11Enums.cs

@ -107,4 +107,15 @@ namespace Avalonia.X11
CWColormap = (1 << 13),
CWCursor = (1 << 14),
}
/// <summary>
/// Shape kinds from the X Nonrectangular Window Shape extension, as accepted by
/// XFixesSetWindowShapeRegion.
/// </summary>
internal enum ShapeKind
{
ShapeBounding = 0,
ShapeClip = 1,
ShapeInput = 2,
}
}

17
src/Avalonia.X11/X11Info.cs

@ -32,6 +32,11 @@ namespace Avalonia.X11
public bool HasXim { get; }
public bool HasXSync { get; }
/// <summary>
/// Whether XFixes is usable, which is what window input shapes are set through.
/// </summary>
public bool HasXFixes { get; }
public IntPtr DefaultFontSet { get; }
public bool HasXkb { get; }
@ -126,6 +131,18 @@ namespace Avalonia.X11
//Ignore, XSync is not supported
}
try
{
// Input shapes need XFixes 2.0 or newer.
HasXFixes = XFixesQueryExtension(display, out _, out _) != 0
&& XFixesQueryVersion(display, out var fixesMajor, out _) != 0
&& fixesMajor >= 2;
}
catch
{
//Ignore, XFixes is not supported
}
try
{
var xkbMajor = 1;

28
src/Avalonia.X11/X11Window.cs

@ -1679,6 +1679,34 @@ namespace Avalonia.X11
{
}
public void SetHitTestVisible(bool isHitTestVisible)
{
if (!_x11.HasXFixes)
return;
// An empty input region makes the server route pointer input to whatever is behind
// the window. None (0) restores the default input shape, i.e. the whole window.
var region = IntPtr.Zero;
if (!isHitTestVisible)
{
var rect = default(XRectangle);
region = XFixesCreateRegion(_x11.Display, &rect, 0);
}
XFixesSetWindowShapeRegion(_x11.Display, _handle, ShapeKind.ShapeInput, 0, 0, region);
// The render window is a child of _handle when a GPU backend is in use. Shaping the
// parent alone doesn't take the child out of the input hierarchy, so it would keep
// capturing the pointer.
if (_renderHandle != _handle)
XFixesSetWindowShapeRegion(_x11.Display, _renderHandle, ShapeKind.ShapeInput, 0, 0, region);
if (region != IntPtr.Zero)
XFixesDestroyRegion(_x11.Display, region);
XFlush(_x11.Display);
}
public WindowTransparencyLevel TransparencyLevel =>
_transparencyHelper?.CurrentLevel ?? WindowTransparencyLevel.None;

18
src/Avalonia.X11/XLib.cs

@ -21,6 +21,7 @@ namespace Avalonia.X11
private const string libX11Ext = "libXext.so.6";
private const string libXInput = "libXi.so.6";
private const string libXCursor = "libXcursor.so.1";
private const string libXFixes = "libXfixes.so.3";
public const IntPtr AnyPropertyType = 0;
@ -610,6 +611,23 @@ namespace Avalonia.X11
[DllImport(libX11Ext)]
public static extern int XSyncSetCounter(IntPtr dpy, IntPtr counter, XSyncValue value);
[DllImport(libXFixes)]
public static extern int XFixesQueryExtension(IntPtr dpy, out int event_base_return, out int error_base_return);
[DllImport(libXFixes)]
public static extern int XFixesQueryVersion(IntPtr dpy, out int major_version_return,
out int minor_version_return);
[DllImport(libXFixes)]
public static extern IntPtr XFixesCreateRegion(IntPtr dpy, XRectangle* rectangles, int nrectangles);
[DllImport(libXFixes)]
public static extern void XFixesSetWindowShapeRegion(IntPtr dpy, IntPtr win, ShapeKind shape_kind,
int x_off, int y_off, IntPtr region);
[DllImport(libXFixes)]
public static extern void XFixesDestroyRegion(IntPtr dpy, IntPtr region);
[DllImport(libX11Randr)]
public static extern int XRRQueryVersion(IntPtr dpy,
out int major_version_return,

6
src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs

@ -478,7 +478,11 @@ namespace Avalonia.Headless
}
public void TakeFocus()
public void TakeFocus()
{
}
public void SetHitTestVisible(bool isHitTestVisible)
{
}
}

8
src/Windows/Avalonia.Win32/PopupImpl.cs

@ -13,6 +13,7 @@ namespace Avalonia.Win32
{
private readonly IWindowBaseImpl? _parent;
private bool _dropShadowHint = true;
private bool _isHitTestVisible = true;
private Size? _maxAutoSize;
@ -87,6 +88,8 @@ namespace Avalonia.Win32
goto default;
case UnmanagedMethods.WindowsMessage.WM_MOUSEACTIVATE:
return (IntPtr)UnmanagedMethods.MouseActivate.MA_NOACTIVATE;
case UnmanagedMethods.WindowsMessage.WM_NCHITTEST when !_isHitTestVisible:
return (IntPtr)UnmanagedMethods.HitTestValues.HTTRANSPARENT;
default:
return base.WndProc(hWnd, msg, wParam, lParam);
}
@ -152,6 +155,11 @@ namespace Avalonia.Win32
EnableBoxShadow(Handle.Handle, enabled);
}
public void SetHitTestVisible(bool isHitTestVisible)
{
_isHitTestVisible = isHitTestVisible;
}
public void TakeFocus()
{
var parent = _parent;

27
tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs

@ -42,6 +42,33 @@ namespace Avalonia.Controls.UnitTests.Primitives
}
}
[Fact]
public void PopupRoot_Forwards_Initial_IsHitTestVisible_To_Impl()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var impl = MockWindowingPlatform.CreatePopupMock(new Mock<IWindowBaseImpl>().Object);
CreateTarget(new Window(), impl.Object);
impl.Verify(x => x.SetHitTestVisible(true));
}
}
[Fact]
public void PopupRoot_Forwards_IsHitTestVisible_Changes_To_Impl()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var impl = MockWindowingPlatform.CreatePopupMock(new Mock<IWindowBaseImpl>().Object);
var target = CreateTarget(new Window(), impl.Object);
target.IsHitTestVisible = false;
impl.Verify(x => x.SetHitTestVisible(false));
}
}
[Fact]
public void PopupRoot_StylingParent_Is_Popup()
{

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

@ -1318,6 +1318,48 @@ namespace Avalonia.Controls.UnitTests.Primitives
return result;
}
[Fact]
public void Popup_IsHitTestVisible_Defaults_To_True()
{
using (CreateServices())
{
Assert.True(new Popup().IsHitTestVisible);
}
}
[Fact]
public void Popup_Forwards_IsHitTestVisible_To_Host_On_Open()
{
using (CreateServices())
{
var target = new Popup { IsHitTestVisible = false };
var window = PreparedWindow(target);
window.Show();
target.Open();
Assert.False(target.Host!.IsHitTestVisible);
}
}
[Fact]
public void Popup_Forwards_IsHitTestVisible_Changes_To_Open_Host()
{
using (CreateServices())
{
var target = new Popup();
var window = PreparedWindow(target);
window.Show();
target.Open();
Assert.True(target.Host!.IsHitTestVisible);
target.IsHitTestVisible = false;
Assert.False(target.Host!.IsHitTestVisible);
}
}
[Fact]
public void Popup_Open_With_Correct_IsUsingOverlayLayer_And_Disabled_OverlayLayer()
{

Loading…
Cancel
Save