diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index ab26c5e135..cf7ef0deca 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -1,4 +1,4 @@ - + @@ -169,6 +169,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll current/Avalonia/lib/net10.0/Avalonia.Controls.dll + + CP0006 + M:Avalonia.Platform.IPopupImpl.SetHitTestVisible(System.Boolean) + baseline/Avalonia/lib/net10.0/Avalonia.Controls.dll + current/Avalonia/lib/net10.0/Avalonia.Controls.dll + CP0006 M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(System.Nullable{Avalonia.Platform.PlatformThemeVariant}) @@ -199,10 +205,16 @@ baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll + + CP0006 + M:Avalonia.Platform.IPopupImpl.SetHitTestVisible(System.Boolean) + baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll + current/Avalonia/lib/net8.0/Avalonia.Controls.dll + CP0006 M:Avalonia.Platform.ITopLevelImpl.SetFrameThemeVariant(System.Nullable{Avalonia.Platform.PlatformThemeVariant}) baseline/Avalonia/lib/net8.0/Avalonia.Controls.dll current/Avalonia/lib/net8.0/Avalonia.Controls.dll - + \ No newline at end of file diff --git a/native/Avalonia.Native/src/OSX/PopupImpl.mm b/native/Avalonia.Native/src/OSX/PopupImpl.mm index 13131bf91e..8a188c89b3 100644 --- a/native/Avalonia.Native/src/OSX/PopupImpl.mm +++ b/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(); diff --git a/src/Avalonia.Controls/Platform/IPopupImpl.cs b/src/Avalonia.Controls/Platform/IPopupImpl.cs index 388c447243..58bb164426 100644 --- a/src/Avalonia.Controls/Platform/IPopupImpl.cs +++ b/src/Avalonia.Controls/Platform/IPopupImpl.cs @@ -13,5 +13,12 @@ namespace Avalonia.Platform void SetWindowManagerAddShadowHint(bool enabled); void TakeFocus(); + + /// + /// 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. + /// + void SetHitTestVisible(bool isHitTestVisible); } } diff --git a/src/Avalonia.Controls/Primitives/IPopupHost.cs b/src/Avalonia.Controls/Primitives/IPopupHost.cs index 3e9967f235..df6b325558 100644 --- a/src/Avalonia.Controls/Primitives/IPopupHost.cs +++ b/src/Avalonia.Controls/Primitives/IPopupHost.cs @@ -57,6 +57,11 @@ namespace Avalonia.Controls.Primitives /// bool Topmost { get; set; } + /// + /// Gets or sets whether the popup takes part in pointer hit testing. + /// + bool IsHitTestVisible { get; set; } + /// /// Gets or sets a transform that will be applied to the popup. /// diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 86d4fb746e..75dd723fe3 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -159,7 +159,6 @@ namespace Avalonia.Controls.Primitives /// static Popup() { - IsHitTestVisibleProperty.OverrideDefaultValue(false); ChildProperty.Changed.AddClassHandler((x, e) => x.ChildChanged(e)); IsOpenProperty.Changed.AddClassHandler((x, e) => x.IsOpenChanged((AvaloniaPropertyChangedEventArgs)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(); } + else if (change.Property == IsHitTestVisibleProperty) + { + _openState.PopupHost.IsHitTestVisible = change.GetNewValue(); + } } } diff --git a/src/Avalonia.Controls/Primitives/PopupRoot.cs b/src/Avalonia.Controls/Primitives/PopupRoot.cs index d1fd1317f8..93a30b06a3 100644 --- a/src/Avalonia.Controls/Primitives/PopupRoot.cs +++ b/src/Avalonia.Controls/Primitives/PopupRoot.cs @@ -62,6 +62,7 @@ namespace Avalonia.Controls.Primitives { ParentTopLevel = parent; impl.SetWindowManagerAddShadowHint(WindowManagerAddShadowHint); + impl.SetHitTestVisible(IsHitTestVisible); } /// @@ -219,6 +220,10 @@ namespace Avalonia.Controls.Primitives { PlatformImpl?.SetTopmost(change.GetNewValue()); } + else if (change.Property == IsHitTestVisibleProperty) + { + PlatformImpl?.SetHitTestVisible(change.GetNewValue()); + } } } } diff --git a/src/Avalonia.DesignerSupport/Remote/Stubs.cs b/src/Avalonia.DesignerSupport/Remote/Stubs.cs index 97fda7d8d1..6843911df4 100644 --- a/src/Avalonia.DesignerSupport/Remote/Stubs.cs +++ b/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 diff --git a/src/Avalonia.Native/PopupImpl.cs b/src/Avalonia.Native/PopupImpl.cs index 222fa63497..ffee48d3ad 100644 --- a/src/Avalonia.Native/PopupImpl.cs +++ b/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; diff --git a/src/Avalonia.Native/avn.idl b/src/Avalonia.Native/avn.idl index 32d3f4286e..91a252c020 100644 --- a/src/Avalonia.Native/avn.idl +++ b/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] diff --git a/src/Avalonia.Wayland/PopupImpl.Sink.cs b/src/Avalonia.Wayland/PopupImpl.Sink.cs index 8576f2ba60..2e1e8eb746 100644 --- a/src/Avalonia.Wayland/PopupImpl.Sink.cs +++ b/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() diff --git a/src/Avalonia.Wayland/PopupImpl.cs b/src/Avalonia.Wayland/PopupImpl.cs index 0edd318f1b..e0cfb75ddc 100644 --- a/src/Avalonia.Wayland/PopupImpl.cs +++ b/src/Avalonia.Wayland/PopupImpl.cs @@ -33,6 +33,7 @@ internal partial class PopupImpl : WindowBaseImpl, IPopupImpl private WaylandSurfaceCreateResult? _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); + } + /// /// Bridges Avalonia's positioner contract to . /// diff --git a/src/Avalonia.Wayland/Server/Persistent/IWSurface.cs b/src/Avalonia.Wayland/Server/Persistent/IWSurface.cs index 94a6d1b009..8783db836e 100644 --- a/src/Avalonia.Wayland/Server/Persistent/IWSurface.cs +++ b/src/Avalonia.Wayland/Server/Persistent/IWSurface.cs @@ -58,4 +58,12 @@ internal interface IWSurface /// Resets the IME state (clears any pending preedit/commit). void ResetTextInput(); + + /// + /// 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. + /// + void SetHitTestVisible(bool value); } diff --git a/src/Avalonia.Wayland/Server/Persistent/WSurface.cs b/src/Avalonia.Wayland/Server/Persistent/WSurface.cs index 0fd1745864..1db7515b74 100644 --- a/src/Avalonia.Wayland/Server/Persistent/WSurface.cs +++ b/src/Avalonia.Wayland/Server/Persistent/WSurface.cs @@ -29,6 +29,7 @@ class WSurface : IPersistentWaylandObject, IWSurface, IWaylandFramebufferSurface protected double? PreferredFractionalScale { get; private set; } protected List Outputs { get; } = new(); private WlCallback? _frameCallback; + private bool _hitTestVisible = true; private double _currentScale = 1; private const double ScaleEpsilon = 1e-6; private readonly List _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(); + } + + /// + /// 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. + /// + /// + /// 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 + /// () and the attach+commit that + /// promotes it are one synchronous block inside the render pass. + /// + protected virtual bool CanCommitOutOfBand => true; + + /// + /// 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. + /// + 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 /// True iff this surface is currently mapped (xdg-shell sense). 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); diff --git a/src/Avalonia.X11/X11Enums.cs b/src/Avalonia.X11/X11Enums.cs index 9d671ae6d2..3325984966 100644 --- a/src/Avalonia.X11/X11Enums.cs +++ b/src/Avalonia.X11/X11Enums.cs @@ -107,4 +107,15 @@ namespace Avalonia.X11 CWColormap = (1 << 13), CWCursor = (1 << 14), } + + /// + /// Shape kinds from the X Nonrectangular Window Shape extension, as accepted by + /// XFixesSetWindowShapeRegion. + /// + internal enum ShapeKind + { + ShapeBounding = 0, + ShapeClip = 1, + ShapeInput = 2, + } } diff --git a/src/Avalonia.X11/X11Info.cs b/src/Avalonia.X11/X11Info.cs index cc4ec67a3a..67abadc0e3 100644 --- a/src/Avalonia.X11/X11Info.cs +++ b/src/Avalonia.X11/X11Info.cs @@ -32,6 +32,11 @@ namespace Avalonia.X11 public bool HasXim { get; } public bool HasXSync { get; } + /// + /// Whether XFixes is usable, which is what window input shapes are set through. + /// + 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; diff --git a/src/Avalonia.X11/X11Window.cs b/src/Avalonia.X11/X11Window.cs index 1ed61248d0..5337e99a4e 100644 --- a/src/Avalonia.X11/X11Window.cs +++ b/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; diff --git a/src/Avalonia.X11/XLib.cs b/src/Avalonia.X11/XLib.cs index 108ae257c4..0e56bcea5e 100644 --- a/src/Avalonia.X11/XLib.cs +++ b/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, diff --git a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs index efc2c08a89..b3a6bb16cc 100644 --- a/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs +++ b/src/Headless/Avalonia.Headless/HeadlessWindowImpl.cs @@ -478,7 +478,11 @@ namespace Avalonia.Headless } - public void TakeFocus() + public void TakeFocus() + { + } + + public void SetHitTestVisible(bool isHitTestVisible) { } } diff --git a/src/Windows/Avalonia.Win32/PopupImpl.cs b/src/Windows/Avalonia.Win32/PopupImpl.cs index ee2feedffc..0e3a40f12a 100644 --- a/src/Windows/Avalonia.Win32/PopupImpl.cs +++ b/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; diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs index ee051649cd..c73c188627 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupRootTests.cs +++ b/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().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().Object); + var target = CreateTarget(new Window(), impl.Object); + + target.IsHitTestVisible = false; + + impl.Verify(x => x.SetHitTestVisible(false)); + } + } + [Fact] public void PopupRoot_StylingParent_Is_Popup() { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index dcf9b6f653..3cca86d3b4 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/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() {