From dad0a585ea94f9b8f8e367543579d734c3b6a253 Mon Sep 17 00:00:00 2001 From: byungjul Date: Fri, 2 Oct 2026 15:30:26 +0900 Subject: [PATCH 1/3] feat(#332): Linux click-through and right-drag move for the transparent overlay Clicks outside the avatar now reach the desktop on Linux, and a right-button drag on the avatar moves the window, as on Windows. - Click-through: the Windows click-through exports (hit_mask, hit_rect, hit_active, surround_rect/_mask) now exist on Linux with the same semantics. They build an XShape INPUT region on Unity's cloaked window (the overlay above it takes no input). Live window-space UI layers are unioned in, so a HUD outside the avatar still takes its clicks. A single zone with a sub-rect maps the mask into that zone's rect. - Unity's window is undecorated while cloaked: mutter takes input over a decorated window's whole frame and ignored the input shape. Its client origin and size are kept, and decorations come back with the cloak. - Right-drag move: displayxr_linux_drag_window follows the root pointer and moves Unity's window with _NET_MOVERESIZE_WINDOW; the overlay follows it. - C#: DisplayXRTransparentOverlay runs the hit-test, pointer events and the silhouette mask on Linux too. The click-through bindings in DisplayXRNative move to a Windows + Linux block. Co-Authored-By: Claude Opus 5.5 --- Runtime/DisplayXRNative.cs | 194 +++++---- Runtime/DisplayXRTransparentOverlay.cs | 127 +++++- docs~/architecture/click-through-mask.md | 30 ++ native~/displayxr_exports.h | 20 + native~/displayxr_linux.c | 510 ++++++++++++++++++++++- 5 files changed, 790 insertions(+), 91 deletions(-) diff --git a/Runtime/DisplayXRNative.cs b/Runtime/DisplayXRNative.cs index 43689d6..fe57de0 100644 --- a/Runtime/DisplayXRNative.cs +++ b/Runtime/DisplayXRNative.cs @@ -355,6 +355,104 @@ public static extern void displayxr_set_transparent_overlay( [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] public static extern void displayxr_set_window_decorated(int decorated); + /// + /// (#131) Put the transparent overlay into fixed full-screen, + /// app-managed window mode. enabled=1 sizes the overlay HWND to its + /// monitor at the aligned origin and DISABLES the native right-drag + /// move so the app owns all window interaction via virtual rects (3D + /// canvas sub-rect via displayxr_set_canvas_rect + 2D surround). This + /// turns move/resize into pure rectangle math (no OS resize, no + /// phase-snap, no mid-resize content drop). enabled=0 restores native + /// move. Windows transparent overlay only; no-op elsewhere. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void displayxr_set_overlay_fullscreen(int enabled); + + /// + /// (#131) Opt in to a born-fullscreen transparent overlay. Must be called + /// BEFORE the overlay is created — call as early as possible, e.g. from a + /// RuntimeInitializeOnLoadMethod(BeforeSplashScreen). The overlay is then + /// created already covering its monitor, so the app needs no post-creation + /// resize (which recreates the swapchain = a startup flash) and Unity can + /// stay WINDOWED (avoiding fullscreen independent-flip, which bypasses DWM + /// alpha compositing and washes the content out). No-op once the overlay + /// exists. Windows transparent overlay only. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void displayxr_set_fullscreen_overlay_pref(int enabled); + + /// + /// (#131) Set the transparent overlay's mouse cursor shape (applied each + /// mouse move over the overlay client area). Shapes: 0=arrow, 1=size-WE, + /// 2=size-NS, 3=size-NWSE, 4=size-NESW, 5=size-all (move). The region + /// editor uses it for resize affordances on window edges/corners and + /// region lines. Windows transparent overlay only; no-op elsewhere. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void displayxr_set_overlay_cursor(int shape); + + /// + /// Read cursor position (overlay-client coords, top-left origin) and + /// mouse button state. Designed for transparent overlay mode where + /// Unity's New Input System Mouse.current.position is frozen because + /// the cloaked Unity HWND isn't OS-foreground (documented Unity + /// limitation). Returns (-1, -1) for clientX/Y if no overlay HWND. + /// Buttons: bit 0 = left, 1 = right, 2 = middle. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void displayxr_get_overlay_pointer( + out int clientX, out int clientY, out int buttons); + + /// + /// (v1.2.2) Atomically read + zero the overlay's accumulated mouse- + /// wheel delta. Returns Win32 raw units (120 per notch; positive = + /// wheel forward). Always 0 in opaque mode (no transparent overlay). + /// The plugin no longer self-resizes the overlay on wheel events; + /// apps consume this value and decide what to do with it. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int displayxr_consume_overlay_wheel_delta(); + + /// + /// (display-zones port) Atomically read + zero the overlay's close- + /// request flag. Returns 1 once after the user pressed the decorated + /// overlay's close (X) button or Alt+F4. The plugin swallows the + /// overlay's WM_CLOSE (it does NOT destroy the overlay HWND, which would + /// leave cloaked Unity running headless) and raises this flag instead. + /// Apps poll it each frame and call Application.Quit() so the whole + /// process shuts down cleanly. Always 0 when no transparent overlay. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int displayxr_consume_overlay_close_request(); + + /// + /// (display-zones port) Resize the managed overlay window to width x + /// height px via SetWindowPos (#61-bracketed, clamped to a min size). + /// The reliable resize path on the SR display — the weaver subclass eats + /// mouse edge interactions on the non-activating overlay, but + /// SetWindowPos is never intercepted. Apps drive it from a keyboard + /// shortcut. No-op when there is no managed overlay window. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void displayxr_resize_overlay(int width, int height); + + /// + /// (display-zones port) Get/set the managed overlay window's screen-space + /// top-left, for app-side window-position persistence. Set is #61-bracketed + /// (size unchanged). No-op when there is no managed overlay window. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void displayxr_get_overlay_position(out int x, out int y); + + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern void displayxr_set_overlay_position(int x, int y); +#endif + + // Click-through region (#57/#131 on Windows, #332 on Linux). Same exports + // on both: Windows builds a SetWindowRgn on the overlay HWND, Linux an XShape + // input region on Unity's window (see displayxr_linux.c). On Linux they are + // no-ops outside transparent mode. +#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN || UNITY_STANDALONE_LINUX /// /// Set the rectangular hit-test region of the overlay. Coords /// are overlay client-space pixels (top-left origin). @@ -445,98 +543,18 @@ public static extern void displayxr_set_overlay_surround_rect( public static extern void displayxr_set_overlay_surround_mask( IntPtr mask, int mask_w, int mask_h, int dst_x, int dst_y, int dst_w, int dst_h); +#endif +#if UNITY_STANDALONE_LINUX && !UNITY_EDITOR /// - /// (#131) Put the transparent overlay into fixed full-screen, - /// app-managed window mode. enabled=1 sizes the overlay HWND to its - /// monitor at the aligned origin and DISABLES the native right-drag - /// move so the app owns all window interaction via virtual rects (3D - /// canvas sub-rect via displayxr_set_canvas_rect + 2D surround). This - /// turns move/resize into pure rectangle math (no OS resize, no - /// phase-snap, no mid-resize content drop). enabled=0 restores native - /// move. Windows transparent overlay only; no-op elsewhere. - /// - [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - public static extern void displayxr_set_overlay_fullscreen(int enabled); - - /// - /// (#131) Opt in to a born-fullscreen transparent overlay. Must be called - /// BEFORE the overlay is created — call as early as possible, e.g. from a - /// RuntimeInitializeOnLoadMethod(BeforeSplashScreen). The overlay is then - /// created already covering its monitor, so the app needs no post-creation - /// resize (which recreates the swapchain = a startup flash) and Unity can - /// stay WINDOWED (avoiding fullscreen independent-flip, which bypasses DWM - /// alpha compositing and washes the content out). No-op once the overlay - /// exists. Windows transparent overlay only. - /// - [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - public static extern void displayxr_set_fullscreen_overlay_pref(int enabled); - - /// - /// (#131) Set the transparent overlay's mouse cursor shape (applied each - /// mouse move over the overlay client area). Shapes: 0=arrow, 1=size-WE, - /// 2=size-NS, 3=size-NWSE, 4=size-NESW, 5=size-all (move). The region - /// editor uses it for resize affordances on window edges/corners and - /// region lines. Windows transparent overlay only; no-op elsewhere. - /// - [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - public static extern void displayxr_set_overlay_cursor(int shape); - - /// - /// Read cursor position (overlay-client coords, top-left origin) and - /// mouse button state. Designed for transparent overlay mode where - /// Unity's New Input System Mouse.current.position is frozen because - /// the cloaked Unity HWND isn't OS-foreground (documented Unity - /// limitation). Returns (-1, -1) for clientX/Y if no overlay HWND. - /// Buttons: bit 0 = left, 1 = right, 2 = middle. - /// - [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - public static extern void displayxr_get_overlay_pointer( - out int clientX, out int clientY, out int buttons); - - /// - /// (v1.2.2) Atomically read + zero the overlay's accumulated mouse- - /// wheel delta. Returns Win32 raw units (120 per notch; positive = - /// wheel forward). Always 0 in opaque mode (no transparent overlay). - /// The plugin no longer self-resizes the overlay on wheel events; - /// apps consume this value and decide what to do with it. - /// - [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - public static extern int displayxr_consume_overlay_wheel_delta(); - - /// - /// (display-zones port) Atomically read + zero the overlay's close- - /// request flag. Returns 1 once after the user pressed the decorated - /// overlay's close (X) button or Alt+F4. The plugin swallows the - /// overlay's WM_CLOSE (it does NOT destroy the overlay HWND, which would - /// leave cloaked Unity running headless) and raises this flag instead. - /// Apps poll it each frame and call Application.Quit() so the whole - /// process shuts down cleanly. Always 0 when no transparent overlay. - /// - [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - public static extern int displayxr_consume_overlay_close_request(); - - /// - /// (display-zones port) Resize the managed overlay window to width x - /// height px via SetWindowPos (#61-bracketed, clamped to a min size). - /// The reliable resize path on the SR display — the weaver subclass eats - /// mouse edge interactions on the non-activating overlay, but - /// SetWindowPos is never intercepted. Apps drive it from a keyboard - /// shortcut. No-op when there is no managed overlay window. - /// - [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - public static extern void displayxr_resize_overlay(int width, int height); - - /// - /// (display-zones port) Get/set the managed overlay window's screen-space - /// top-left, for app-side window-position persistence. Set is #61-bracketed - /// (size unchanged). No-op when there is no managed overlay window. + /// (#332) Right-drag move of the Linux transparent overlay. Call every + /// frame with the right button's state; the first pressed call starts + /// the drag, later ones move Unity's window (the overlay follows) by + /// how far the pointer has moved, the first released call ends it. + /// Returns 1 while a drag is in progress. No-op outside transparent mode. /// [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - public static extern void displayxr_get_overlay_position(out int x, out int y); - - [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] - public static extern void displayxr_set_overlay_position(int x, int y); + public static extern int displayxr_linux_drag_window(int right_pressed); #endif } diff --git a/Runtime/DisplayXRTransparentOverlay.cs b/Runtime/DisplayXRTransparentOverlay.cs index f831ea0..f4e91bc 100644 --- a/Runtime/DisplayXRTransparentOverlay.cs +++ b/Runtime/DisplayXRTransparentOverlay.cs @@ -951,9 +951,130 @@ void LateUpdate() } MAC_HITTEST_DONE:; #endif +#elif UNITY_STANDALONE_LINUX && !UNITY_EDITOR + LinuxClickThroughAndDrag(); #endif } +#if UNITY_STANDALONE_LINUX && !UNITY_EDITOR + bool m_LinuxNativeMissing; + + // (#332) Linux transparent overlay: click-through + right-drag move. + // + // Unity's window is the one that takes input here (the weave overlay above + // it takes none), and the pointer is live (UpdatePointerFromInputSystem). + // So this is the macOS path — cyclopean hit-test, hover and button events — + // plus the two native pieces Windows does in its overlay WndProc: + // - the click-through region: the same AABB + per-pixel silhouette mask the + // Windows path pushes, which native turns into an XShape INPUT region on + // Unity's window (outside it, clicks reach the desktop); + // - right-drag move: native follows the root pointer and moves Unity's + // window while the right button is held. A press can only reach us + // inside the region, so a drag starts on the avatar, a HUD or the bubble. + // Native no-ops all of it outside transparent mode. + void LinuxClickThroughAndDrag() + { + if (m_Camera == null || m_LinuxNativeMissing) return; + if (DisplayXRRigManager.ActiveCamera != null + && DisplayXRRigManager.ActiveCamera != m_Camera) + return; + + try + { + DisplayXRNative.displayxr_linux_drag_window(IsRightPressed ? 1 : 0); + } + catch (EntryPointNotFoundException) + { + // Older native plugin: no click-through or drag on Linux. + m_LinuxNativeMissing = true; + return; + } + + if (clickableRenderers == null || clickableRenderers.Length == 0) + return; + + int overlayW = Screen.width, overlayH = Screen.height; + int clientX = Mathf.RoundToInt(PointerPosition.x); + int clientY = Mathf.RoundToInt(PointerPosition.y); + int buttons = (IsLeftPressed ? 1 : 0) | (IsRightPressed ? 2 : 0); + UpdateBakedHitColliders(); + + Renderer hitRenderer = null; + if (clientX >= 0 && clientY >= 0 + && TryGetStereoMatrices(out Matrix4x4 lv, out Matrix4x4 lp, + out Matrix4x4 rv, out Matrix4x4 rp)) + { + BuildCyclopean(lv, lp, rv, rp, out Matrix4x4 cv, out Matrix4x4 cp); + GetStereoViewport(overlayW, overlayH, + out int vpX, out int vpY, out int vpW, out int vpH); + if (TryBuildEyeRay(clientX, clientY, vpX, vpY, vpW, vpH, cv, cp, out Ray ray)) + { + float bestT = m_Camera.farClipPlane; + for (int i = 0; i < clickableRenderers.Length; i++) + { + var r = clickableRenderers[i]; + if (!IsHitTestable(r)) continue; + if (r is SkinnedMeshRenderer smr) + { + if (TryRayHitBakedSkinnedMesh(ray, bestT, smr, out float t)) + { + hitRenderer = r; + bestT = t; + } + } + else if (Physics.Raycast(ray, out RaycastHit info, bestT) + && info.collider != null + && info.collider.transform.IsChildOf(r.transform)) + { + hitRenderer = r; + bestT = info.distance; + } + } + } + } + + // Same sticky-frame hysteresis as Win32 / macOS. + const int STICKY_FRAMES_LINUX = 8; + if (hitRenderer != null) + { + m_LastHitRenderer = hitRenderer; + m_LastHitFrame = Time.frameCount; + } + else if (m_LastHitRenderer != null + && Time.frameCount - m_LastHitFrame <= STICKY_FRAMES_LINUX) + { + hitRenderer = m_LastHitRenderer; + } + else + { + m_LastHitRenderer = null; + } + + if (hitRenderer != m_HoverRenderer) + { + if (m_HoverRenderer != null) onPointerExit?.Invoke(m_HoverRenderer); + if (hitRenderer != null) onPointerEnter?.Invoke(hitRenderer); + m_HoverRenderer = hitRenderer; + } + DispatchButton(buttons, 1, ref m_LeftWasDown, hitRenderer); + DispatchButton(buttons, 2, ref m_RightWasDown, hitRenderer); + + // The click-through region: AABB until the first silhouette readback + // lands, then the per-pixel mask (OnHitMaskReadback) — as on Windows. + if (TryGetStereoMatrices(out Matrix4x4 lv2, out Matrix4x4 lp2, + out Matrix4x4 rv2, out Matrix4x4 rp2)) + { + BuildCyclopean(lv2, lp2, rv2, rp2, out Matrix4x4 cv2, out Matrix4x4 cp2); + if (TryGetUnionScreenRect(overlayW, overlayH, cv2, cp2, + out int rx, out int ry, out int rw, out int rh)) + { + DisplayXRNative.displayxr_set_overlay_hit_rect(rx, ry, rw, rh); + } + RenderHitMaskAndRequestReadback(lv2, lp2, rv2, rp2, overlayW, overlayH); + } + } +#endif + private void DispatchButton(int buttons, int mask, ref bool wasDown, Renderer hit) { bool isDown = (buttons & mask) != 0; @@ -1779,7 +1900,7 @@ void OnHitMaskReadback(AsyncGPUReadbackRequest req) if (m_HitMaskRT == null) return; var data = req.GetData(); -#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN +#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN || (UNITY_STANDALONE_LINUX && !UNITY_EDITOR) unsafe { IntPtr ptr = (IntPtr)Unity.Collections.LowLevel.Unsafe @@ -1788,6 +1909,7 @@ void OnHitMaskReadback(AsyncGPUReadbackRequest req) ptr, m_HitMaskPendingW, m_HitMaskPendingH, m_HitMaskPendingDstW, m_HitMaskPendingDstH); +#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN // Rear depth budget v3 (#318): the very same silhouette. The runtime // decides how much rear depth this overlay may draw from the desktop @@ -1804,6 +1926,7 @@ void OnHitMaskReadback(AsyncGPUReadbackRequest req) ptr, (uint)m_HitMaskPendingW, (uint)m_HitMaskPendingH, (uint)m_HitMaskPendingW); } +#endif } if (s_DumpHitMask && (m_HitMaskDumpCounter++ % 15) == 0) DumpHitMaskPng(data, m_HitMaskPendingW, m_HitMaskPendingH); @@ -1877,7 +2000,7 @@ void ReleaseHitMaskResources() } // Tell native to drop the mask region (reverts to AABB- // region path for the next hit_rect push). -#if UNITY_STANDALONE_WIN +#if UNITY_STANDALONE_WIN || (UNITY_STANDALONE_LINUX && !UNITY_EDITOR) if (!Application.isEditor) DisplayXRNative.displayxr_set_overlay_hit_mask(IntPtr.Zero, 0, 0, 0, 0); #endif diff --git a/docs~/architecture/click-through-mask.md b/docs~/architecture/click-through-mask.md index 37504cc..d8bc3f9 100644 --- a/docs~/architecture/click-through-mask.md +++ b/docs~/architecture/click-through-mask.md @@ -155,6 +155,35 @@ is woven. the divergence is real source/state, not compiler nondeterminism. (For #166, the local rebuild of v1.24.1 was byte-size-identical to the shipped one — toolchain ruled out in one build.) +## Linux (#332) + +Same C# producer, same exports, different OS primitive. On Linux the weave overlay is an +ARGB override-redirect top-level that takes **no** input (empty XShape input region), and +Unity's cloaked window beneath it is the one clicks reach. So `displayxr_linux.c` builds the +region from the same inputs — silhouette mask, AABB until the first mask, surround +rect/mask — and installs it as the **XShape input region of Unity's window** +(`XShapeCombineRectangles(…, ShapeInput, …)`). Visibility needs nothing: the overlay's alpha +already decides it. + +Differences from Windows, all deliberate: + +- **Live window-space UI layers are unioned in.** A HUD is drawn wherever its rect is, inside + the avatar or not, and must take its own clicks; native knows every slot's rect, so no app + call is needed. +- **A single zone with a sub-rect maps the mask into that zone's rect**, not the full client. + The mask is rendered through zone 0's frustum, so an app that keeps a 2D band beside one + zone (lenovo-avatar's bubble) would otherwise get a silhouette stretched over the band. + (Windows maps that case to the full client — untested there.) +- **Unity's window is undecorated while cloaked.** mutter takes input over a decorated + window's whole frame, ignoring the client's input shape — clicks beside the avatar never + reached the desktop, and the invisible title bar caught clicks too. Undecorating unframes + the window; its client origin and size are put back (unframing hands it the frame's + geometry: +74 px tall on GNOME, which Unity then saved every launch). Restored with the + cloak. +- **Right-drag move** is native too (`displayxr_linux_drag_window`, called every frame with + the right button's state): it follows the root pointer and moves Unity's window with + `_NET_MOVERESIZE_WINDOW`; the overlay follows it in `displayxr_linux_track_window`. + ## Key files | File | Role | @@ -163,6 +192,7 @@ is woven. | `Runtime/Resources/DisplayXRSilhouette.shader` | The silhouette rasteriser. Its clip-space z pin is what keeps the mask independent of the near/far planes — and therefore of the rear depth budget (spec v4 / runtime#1470). | | `Runtime/DisplayXRDepthBudget.cs`, `Runtime/DisplayXRContentBounds.cs` | The other half of the second consumer: the published budget, and the (geometry-derived, clip-independent) bounds rect chained beside the mask. | | `native~/displayxr_win32.c` | `displayxr_set_overlay_hit_mask` — stamp mask into the target rect → `ExtCreateRegion` → `SetWindowRgn`; `region_target_hwnd`/`region_target_ready`. | +| `native~/displayxr_linux.c` | Linux: the same exports → XShape input region on Unity's window; undecorate-while-cloaked; right-drag move (#332). | | `native~/displayxr_native_shared.cpp` | `displayxr_get_canvas_rect_px` (reader) + `displayxr_set_canvas_rect` (writer, re-homed in #166) over the `s_canvas_rect` statics. | | `native~/displayxr_xrprovider/displayxr_provider_session.cpp` | `dxr_prov_get_zone_count`/`dxr_prov_get_zone_rect_px` (multi-zone rects); `dxr_prov_set_3d_zone_rect` (zone weave); publishes the stereo matrices the mask reads. | diff --git a/native~/displayxr_exports.h b/native~/displayxr_exports.h index 7be7800..7f36d2f 100644 --- a/native~/displayxr_exports.h +++ b/native~/displayxr_exports.h @@ -384,6 +384,26 @@ DISPLAYXR_EXPORT void displayxr_linux_set_transparent(int enabled); /// Answers 1 when it cannot tell (no libX11/display, no EWMH, Unity's window not /// found yet) — the fail-open the C# callers already assume. DISPLAYXR_EXPORT int displayxr_is_our_process_foreground(void); + +/// (#332) Linux counterparts of the Windows click-through exports above, with the +/// same arguments and semantics. In transparent mode they shape the INPUT region +/// of Unity's (cloaked) window with XShape instead of calling SetWindowRgn: the +/// silhouette mask, the 2D surround rect/mask, and — Linux only — every live +/// window-space UI layer's rect are unioned. hit_active is a no-op (the input +/// region needs no per-cursor flag). No-ops outside transparent mode. +DISPLAYXR_EXPORT void displayxr_set_overlay_hit_mask(const uint8_t *mask, int mask_w, int mask_h, + int dst_w, int dst_h); +DISPLAYXR_EXPORT void displayxr_set_overlay_hit_rect(int x, int y, int w, int h); +DISPLAYXR_EXPORT void displayxr_set_overlay_hit_active(int active); +DISPLAYXR_EXPORT void displayxr_set_overlay_surround_rect(int x, int y, int w, int h); +DISPLAYXR_EXPORT void displayxr_set_overlay_surround_mask(const uint8_t *mask, int mask_w, + int mask_h, int dst_x, int dst_y, + int dst_w, int dst_h); + +/// (#332) Right-drag move of the transparent overlay. Call every frame with the +/// right button's state; returns 1 while a drag is in progress. Moves Unity's +/// window (the overlay follows it). No-op outside transparent mode. +DISPLAYXR_EXPORT int displayxr_linux_drag_window(int right_pressed); #endif #ifdef __cplusplus diff --git a/native~/displayxr_linux.c b/native~/displayxr_linux.c index 014550b..9b2ab60 100644 --- a/native~/displayxr_linux.c +++ b/native~/displayxr_linux.c @@ -127,6 +127,9 @@ struct XlibApi { int (*XChangeProperty)(XDpy, XWin, XAtom, XAtom, int, int, const unsigned char *, int); int (*XDeleteProperty)(XDpy, XWin, XAtom); XWin (*XGetSelectionOwner)(XDpy, XAtom); + // Click-through + right-drag move (#332) + int (*XQueryPointer)(XDpy, XWin, XWin *, XWin *, int *, int *, int *, int *, unsigned int *); + int (*XSendEvent)(XDpy, XWin, int, long, void *); }; // Xlib structs the transparent overlay needs, mirrored so this TU keeps no X11 @@ -176,6 +179,10 @@ typedef struct { // override-redirect window with the default input region swallows every click). typedef int (*PFN_XShapeCombineRectangles)(XDpy, XWin, int, int, int, void *, int, int, int); static PFN_XShapeCombineRectangles s_shape_combine_rects; +// XShapeCombineMask with a None mask resets a shape to the default (unshaped) — how +// Unity's window gets its full input region back when the overlay goes away (#332). +typedef int (*PFN_XShapeCombineMask)(XDpy, XWin, int, int, int, unsigned long, int); +static PFN_XShapeCombineMask s_shape_combine_mask; static struct XlibApi s_x; static XDpy s_dpy; // our own connection; must outlive the session @@ -189,6 +196,9 @@ static int s_transparent_requested; // set by displayxr_set_transparent_backgro static int s_overlay_is_toplevel; // 1 = ARGB top-level overlay, 0 = opaque child static unsigned long s_overlay_cmap; // colormap of the ARGB overlay +static void lin_reset_input_region(void); // click-through (#332), below +static long long lin_now_ns(void); + // Don't latch a FAILURE forever. The window may simply not be mapped yet on the // first call (LifecycleStart can beat Unity's window creation depending on how // early XR initializes), and caching "not found" from that one early look would @@ -241,6 +251,8 @@ lin_load_xlib(void) XL_SYM(XChangeProperty); XL_SYM(XDeleteProperty); XL_SYM(XGetSelectionOwner); + XL_SYM(XQueryPointer); + XL_SYM(XSendEvent); return 1; } @@ -433,6 +445,7 @@ lin_load_xshape(void) if (!lib) lib = dlopen("libXext.so", RTLD_NOW | RTLD_LOCAL); if (!lib) return 0; *(void **)(&s_shape_combine_rects) = dlsym(lib, "XShapeCombineRectangles"); + *(void **)(&s_shape_combine_mask) = dlsym(lib, "XShapeCombineMask"); return s_shape_combine_rects != NULL; } @@ -460,6 +473,114 @@ lin_set_unity_opacity(int cloak) } } +// EWMH _NET_MOVERESIZE_WINDOW for Unity's window, as a client message to the root. +// StaticGravity (10): x/y are the CLIENT origin, whatever frame the WM adds. Bits +// 8/9: x and y present. Bits 12-13 = 2: from a pager/tool, so the WM honours it +// rather than treating it as an application placement request. +typedef struct { + int type; + unsigned long serial; + int send_event; + void *display; + XWin window; + XAtom message_type; + int format; + long l[5]; + long pad[12]; // XEvent is 24 longs +} LinClientMessage; + +#define LIN_CLIENT_MESSAGE 33 +#define LIN_SUBSTRUCTURE_MASK ((1L << 19) | (1L << 20)) // Notify | Redirect + +static void +lin_moveresize_client(XDpy dpy, int x, int y, unsigned int w, unsigned int h) +{ + XAtom atom = s_x.XInternAtom(dpy, "_NET_MOVERESIZE_WINDOW", 0); + if (!atom) return; + LinClientMessage e; + memset(&e, 0, sizeof(e)); + e.type = LIN_CLIENT_MESSAGE; + e.window = s_win; + e.message_type = atom; + e.format = 32; + e.l[0] = 10 | (1L << 8) | (1L << 9) | (2L << 12); + e.l[1] = x; + e.l[2] = y; + if (w > 0 && h > 0) { // bits 10/11: width and height present + e.l[0] |= (1L << 10) | (1L << 11); + e.l[3] = (long)w; + e.l[4] = (long)h; + } + s_x.XSendEvent(dpy, s_x.XDefaultRootWindow(dpy), 0, LIN_SUBSTRUCTURE_MASK, &e); + s_x.XFlush(dpy); +} + +static void +lin_move_client(XDpy dpy, int x, int y) +{ + lin_moveresize_client(dpy, x, y, 0, 0); +} + +// Undecorate Unity's window while it is cloaked (#332), restore it after. +// +// A decorated X11 window sits inside the WM's frame window, and mutter takes input +// over the WHOLE frame: the input region we give Unity's window (click-through, +// below) was ignored — measured on GNOME/XWayland, a click beside the avatar never +// reached the desktop — and the invisible title bar above the client caught clicks +// as well. Without decorations mutter unframes the window and honours its input +// shape. Unframing hands the client the frame's geometry — up by the title bar and +// taller by as much (measured: +74 px, and Unity saves the size, so it grew on +// every launch) — so the client is put back at its own origin AND size; the +// overlay follows it as always. +// +// Same lifetime as the cloak, for the same reason (see the invariant above): set +// with it at overlay creation, restored with it in displayxr_linux_destroy_weave_window. +static int s_motif_saved; // 1 = s_motif holds the window's own hints +static int s_motif_had; // the window had _MOTIF_WM_HINTS before we touched it +static long s_motif[5]; +static int s_undecorated; + +static void +lin_set_unity_undecorated(int undecorate, int keep_x, int keep_y, unsigned int keep_w, + unsigned int keep_h) +{ + XAtom hints = s_x.XInternAtom(s_dpy, "_MOTIF_WM_HINTS", 0); + if (!hints) return; + if (undecorate) { + if (s_undecorated) return; + XAtom type = 0; + int format = 0; + unsigned long n = 0, after = 0; + unsigned char *prop = NULL; + s_motif_had = 0; + if (s_x.XGetWindowProperty(s_dpy, s_win, hints, 0, 5, 0, 0 /* AnyPropertyType */, &type, + &format, &n, &after, &prop) == 0 /* Success */ && + prop && format == 32 && n == 5) { + memcpy(s_motif, prop, sizeof(s_motif)); + s_motif_had = 1; + } + if (prop) s_x.XFree(prop); + s_motif_saved = 1; + long v[5] = {2 /* MWM_HINTS_DECORATIONS */, 0, 0 /* none */, 0, 0}; + s_x.XChangeProperty(s_dpy, s_win, hints, hints, 32, LIN_PROP_MODE_REPLACE, + (const unsigned char *)v, 5); + lin_moveresize_client(s_dpy, keep_x, keep_y, keep_w, keep_h); + s_undecorated = 1; + lin_log("[DisplayXR-LNX] Unity's window undecorated while cloaked (click-through)\n"); + return; + } + if (!s_undecorated) return; + if (s_motif_saved && s_motif_had) + s_x.XChangeProperty(s_dpy, s_win, hints, hints, 32, LIN_PROP_MODE_REPLACE, + (const unsigned char *)s_motif, 5); + else + s_x.XDeleteProperty(s_dpy, s_win, hints); + lin_moveresize_client(s_dpy, keep_x, keep_y, keep_w, keep_h); + s_undecorated = 0; + s_motif_saved = 0; + lin_log("[DisplayXR-LNX] Unity's window decorations restored\n"); +} + static XWin lin_create_argb_overlay(unsigned int w, unsigned int h, int *out_x, int *out_y) { @@ -524,7 +645,8 @@ displayxr_linux_get_weave_window(void **out_display, unsigned long *out_window) if (!displayxr_linux_get_app_window(&dpy, &unity_win)) return 0; unsigned int w = 0, h = 0; - if (!lin_child_size(&w, &h) || w == 0 || h == 0) { w = 1920; h = 1080; } + int measured = lin_child_size(&w, &h) && w != 0 && h != 0; + if (!measured) { w = 1920; h = 1080; } if (s_transparent_requested) { int x = 0, y = 0; @@ -533,6 +655,8 @@ displayxr_linux_get_weave_window(void **out_display, unsigned long *out_window) s_overlay_is_toplevel = 1; s_ox = x; s_oy = y; lin_set_unity_opacity(1); + // Size only when measured: never "restore" Unity to the 1920x1080 guess. + lin_set_unity_undecorated(1, x, y, measured ? w : 0, measured ? h : 0); s_x.XMapWindow(s_dpy, s_overlay); s_x.XFlush(s_dpy); s_x.XSync(s_dpy, 0); @@ -628,6 +752,11 @@ displayxr_linux_destroy_weave_window(void) { if (s_dpy && s_overlay && s_x.XDestroyWindow) { if (s_overlay_is_toplevel && s_win) { + int cx = s_ox, cy = s_oy; + unsigned int cw = 0, ch = 0; + lin_app_origin(&cx, &cy); // keep the client where the user left it + lin_child_size(&cw, &ch); + lin_set_unity_undecorated(0, cx, cy, cw, ch); lin_set_unity_opacity(0); // The one un-cloak (see the invariant above). Logged on its own line so a // customer log shows the revert happened, not just that the overlay died. @@ -636,6 +765,7 @@ displayxr_linux_destroy_weave_window(void) (unsigned long)s_win); lin_log(m); } + lin_reset_input_region(); // with the cloak: Unity takes clicks everywhere again s_x.XDestroyWindow(s_dpy, s_overlay); if (s_overlay_cmap) s_x.XFreeColormap(s_dpy, s_overlay_cmap); s_x.XFlush(s_dpy); @@ -657,6 +787,384 @@ displayxr_linux_set_transparent(int enabled) s_transparent_requested = enabled != 0; } +// --------------------------------------------------------------------------- +// Click-through + right-drag move (#332) +// --------------------------------------------------------------------------- +// +// CLICK-THROUGH. In transparent mode the overlay takes no input (empty XShape +// input region, above) and Unity's cloaked window beneath it catches every click +// over its whole client area, so nothing outside the avatar reaches the desktop. +// Windows solves this with SetWindowRgn on its overlay; the X11 analogue is an +// XShape INPUT region on Unity's window — only the input shape: what is VISIBLE +// is already decided by the overlay's alpha, and Unity's window is cloaked. +// +// The region is built from the same inputs as the Windows one, with the same +// exports and semantics, so DisplayXRTransparentOverlay and the apps drive both +// the same way: +// - displayxr_set_overlay_hit_mask: the per-pixel silhouette (C# renders it each +// frame), RLE'd into rects and stamped into each 3D zone's rect, or the canvas +// rect, like displayxr_win32.c — except that a single zone with a sub-rect uses +// that rect where Windows uses the full client (see the mask function). +// - displayxr_set_overlay_hit_rect: the AABB, used only until the first mask. +// - displayxr_set_overlay_surround_rect / _mask: 2D surround elements (the +// speech bubble) unioned in. +// Plus one Linux addition: every live window-space UI layer (HUD) is unioned in. +// A HUD is drawn wherever its rect is, inside the avatar or not, and must take +// its own clicks; on Linux the HUD's own rect is known natively, so no app call +// is needed for it. +// +// Only in transparent top-level mode. The opaque child path keeps Unity's +// default input region: there is nothing to click through to. +// +// RIGHT-DRAG MOVE. Windows moves the overlay on a right-button drag inside the +// region (custom capture drag, #57/#61). Here Unity's window gets the press — the +// region decides that, so a press can only start on the avatar, a HUD or the +// bubble — and DisplayXRTransparentOverlay calls displayxr_linux_drag_window each +// frame while the right button is down. We follow the ROOT pointer (XQueryPointer) +// and move Unity's window with _NET_MOVERESIZE_WINDOW (lin_move_client); the +// overlay follows it in +// displayxr_linux_track_window, and the runtime snaps the weave phase to the +// reachable placement lattice (its x11_placement rule). +// +// Main-thread calls (C# P/Invoke), so a connection of their own: s_dpy is shared +// with the runtime's Vulkan surface and the provider's tracking thread. + +typedef struct { + short x, y; + unsigned short width, height; +} LinXRect; + +#define LIN_SHAPE_UNSORTED 0 +#define LIN_HIT_MAX_RECTS 32768 // X request size cap; a real silhouette is far below + +static XDpy s_hit_dpy; +static int s_hit_unavailable; +static int s_hit_mask_active; // a mask has been applied; the AABB stops driving +static unsigned long long s_hit_hash; // last applied region, 0 = none/unknown +static int s_hit_shaped; // Unity's input region is ours (needs a reset) +static int s_surround_valid; +static LinXRect s_surround_rect; +static uint8_t *s_surround_mask; +static int s_surround_mask_w, s_surround_mask_h; +static int s_surround_dst_x, s_surround_dst_y, s_surround_dst_w, s_surround_dst_h; + +extern uint32_t dxr_prov_get_zone_count(void); +extern int dxr_prov_get_zone_rect_px(uint32_t zone, int *x, int *y, int *w, int *h); +extern int displayxr_window_space_ui_get_pending_slot(int slot, void **out_tex, int *out_tex_w, + int *out_tex_h, float *out_x, float *out_y, + float *out_lw, float *out_lh, float *out_disp); +#ifndef DXR_WSUI_MAX_SLOTS +#define DXR_WSUI_MAX_SLOTS 4 // displayxr_window_space_ui.h +#endif + +static int +lin_hit_ready(void) +{ + if (s_hit_unavailable || !s_overlay_is_toplevel || !s_overlay || !s_win) return 0; + if (!s_shape_combine_rects) return 0; + if (!s_hit_dpy) { + s_hit_dpy = s_x.XOpenDisplay(NULL); + if (!s_hit_dpy) { + lin_log("[DisplayXR-LNX] click-through: XOpenDisplay failed — the whole window " + "keeps catching clicks\n"); + s_hit_unavailable = 1; + return 0; + } + } + return 1; +} + +typedef struct { + LinXRect *r; + int n, cap, oom; +} LinRects; + +static void +lin_rects_push(LinRects *v, long x0, long y0, long x1, long y1) +{ + if (v->oom || x1 <= x0 || y1 <= y0) return; + if (v->n >= LIN_HIT_MAX_RECTS) { v->oom = 1; return; } + if (v->n >= v->cap) { + int nc = v->cap ? v->cap * 2 : 1024; + LinXRect *nr = (LinXRect *)realloc(v->r, (size_t)nc * sizeof(LinXRect)); + if (!nr) { v->oom = 1; return; } + v->r = nr; + v->cap = nc; + } + if (x0 < -32768) x0 = -32768; + if (y0 < -32768) y0 = -32768; + if (x1 > 32767) x1 = 32767; + if (y1 > 32767) y1 = 32767; + v->r[v->n].x = (short)x0; + v->r[v->n].y = (short)y0; + v->r[v->n].width = (unsigned short)(x1 - x0); + v->r[v->n].height = (unsigned short)(y1 - y0); + v->n++; +} + +// RLE a mask (non-zero = catch) into rects over [dx,dy,dw,dh], rounding OUTWARD +// (leading edges floor, trailing edges ceil) so the region covers the silhouette — +// the same mapping displayxr_win32.c uses for SetWindowRgn. +static void +lin_rects_from_mask(LinRects *v, const uint8_t *mask, int mw, int mh, long dx, long dy, long dw, + long dh) +{ + for (int my = 0; my < mh; my++) { + const uint8_t *row = mask + (size_t)my * (size_t)mw; + long top = dy + (long long)my * dh / mh; + long bottom = dy + ((long long)(my + 1) * dh + mh - 1) / mh; + int mx = 0; + while (mx < mw) { + while (mx < mw && row[mx] == 0) mx++; + if (mx >= mw) break; + int x0 = mx; + while (mx < mw && row[mx] != 0) mx++; + lin_rects_push(v, dx + (long long)x0 * dw / mw, top, + dx + ((long long)mx * dw + mw - 1) / mw, bottom); + } + } +} + +// Live HUD rects. A window-space layer is placed in fractions of the 3D content +// region (zone 0, or the whole window without one) and shifted by +-disparity/2 +// per eye, so widen by that much on each side. +static void +lin_rects_add_wsui(LinRects *v) +{ + int zx = 0, zy = 0, zw = 0, zh = 0; + if (!dxr_prov_get_zone_rect_px(0, &zx, &zy, &zw, &zh) || zw <= 0 || zh <= 0) return; + for (int slot = 0; slot < DXR_WSUI_MAX_SLOTS; slot++) { + float fx = 0, fy = 0, fw = 0, fh = 0, disp = 0; + if (!displayxr_window_space_ui_get_pending_slot(slot, NULL, NULL, NULL, &fx, &fy, &fw, &fh, + &disp)) + continue; + if (fw <= 0.0f || fh <= 0.0f) continue; + float half = (disp < 0 ? -disp : disp) * 0.5f; + long x0 = zx + (long)((fx - half) * (float)zw); + long y0 = zy + (long)(fy * (float)zh); + long x1 = zx + (long)((fx + fw + half) * (float)zw + 0.999f); + long y1 = zy + (long)((fy + fh) * (float)zh + 0.999f); + lin_rects_push(v, x0, y0, x1, y1); + } +} + +static void +lin_rects_add_surround(LinRects *v) +{ + if (s_surround_valid) + lin_rects_push(v, s_surround_rect.x, s_surround_rect.y, + (long)s_surround_rect.x + s_surround_rect.width, + (long)s_surround_rect.y + s_surround_rect.height); + if (s_surround_mask) + lin_rects_from_mask(v, s_surround_mask, s_surround_mask_w, s_surround_mask_h, + s_surround_dst_x, s_surround_dst_y, s_surround_dst_w, + s_surround_dst_h); +} + +// Install `v` as Unity's input region, unless it is the region already there. +static void +lin_apply_input_region(LinRects *v, const char *what) +{ + unsigned long long h = 1469598103934665603ULL; // FNV-1a 64 + const unsigned char *b = (const unsigned char *)v->r; + size_t nb = (size_t)v->n * sizeof(LinXRect); + for (size_t i = 0; i < nb; i++) { h ^= b[i]; h *= 1099511628211ULL; } + h ^= (unsigned long long)(unsigned)v->n; h *= 1099511628211ULL; + if (h == 0) h = 1; + if (s_hit_shaped && h == s_hit_hash) return; + s_hit_hash = h; + + // n == 0 is a valid region: nothing catches (an empty frame of the avatar). + s_shape_combine_rects(s_hit_dpy, s_win, LIN_SHAPE_INPUT, 0, 0, v->r, v->n, LIN_SHAPE_SET, + LIN_SHAPE_UNSORTED); + s_x.XFlush(s_hit_dpy); + if (!s_hit_shaped) { + char m[160]; + snprintf(m, sizeof(m), "[DisplayXR-LNX] click-through: %s input region on 0x%lx (%d rects)\n", + what, (unsigned long)s_win, v->n); + lin_log(m); + } + s_hit_shaped = 1; +} + +DISPLAYXR_EXPORT void +displayxr_set_overlay_hit_mask(const uint8_t *mask, int mask_w, int mask_h, int dst_w, int dst_h) +{ + if (!lin_hit_ready()) return; + if (mask == NULL || mask_w <= 0 || mask_h <= 0 || dst_w <= 0 || dst_h <= 0) { + // Back to the AABB path: the next hit_rect push drives the region again. + s_hit_mask_active = 0; + s_hit_hash = 0; + return; + } + + LinRects v = {0}; + int zone_count = 1, use_zones = 0; + uint32_t pzc = dxr_prov_get_zone_count(); + if (pzc > 1) { zone_count = (int)pzc; use_zones = 1; } + for (int zi = 0; zi < zone_count; zi++) { + int32_t cvx = 0, cvy = 0; + uint32_t cvw = (uint32_t)dst_w, cvh = (uint32_t)dst_h; + if (use_zones) { + int rx = 0, ry = 0, rw = 0, rh = 0; + if (!dxr_prov_get_zone_rect_px((uint32_t)zi, &rx, &ry, &rw, &rh) || rw <= 0 || rh <= 0) + continue; + cvx = rx; cvy = ry; cvw = (uint32_t)rw; cvh = (uint32_t)rh; + } else if (!displayxr_get_canvas_rect_px(&cvx, &cvy, &cvw, &cvh)) { + // No canvas sub-rect: the 3D zone's own rect (the whole window when no zone + // is set). The mask is rendered through zone 0's frustum, so it belongs in + // that rect — an app that keeps a 2D band beside a single zone (the + // lenovo-avatar bubble) otherwise gets a silhouette stretched over the band. + // displayxr_win32.c maps this case to the full client instead. + int rx = 0, ry = 0, rw = 0, rh = 0; + if (dxr_prov_get_zone_rect_px(0, &rx, &ry, &rw, &rh) && rw > 0 && rh > 0) { + cvx = rx; cvy = ry; cvw = (uint32_t)rw; cvh = (uint32_t)rh; + } + } + lin_rects_from_mask(&v, mask, mask_w, mask_h, cvx, cvy, cvw, cvh); + } + lin_rects_add_surround(&v); + lin_rects_add_wsui(&v); + if (v.oom) { + // Better too much than too little: keep the last region rather than cut the avatar. + free(v.r); + return; + } + lin_apply_input_region(&v, "silhouette"); + s_hit_mask_active = 1; + + // ~1 Hz, like the Windows hit_mask log: it runs every frame. + static long long s_last_log_ns; + long long now = lin_now_ns(); + if (now - s_last_log_ns >= 1000000000LL) { + s_last_log_ns = now; + char m[160]; + snprintf(m, sizeof(m), "[DisplayXR-LNX] hit_mask: mask=%dx%d rects=%d dst=%dx%d\n", mask_w, + mask_h, v.n, dst_w, dst_h); + lin_log(m); + } + free(v.r); +} + +DISPLAYXR_EXPORT void +displayxr_set_overlay_hit_rect(int x, int y, int w, int h) +{ + if (!lin_hit_ready() || s_hit_mask_active) return; // the mask owns the region once it runs + if (w <= 0 || h <= 0) return; + LinRects v = {0}; + lin_rects_push(&v, x, y, (long)x + w, (long)y + h); + lin_rects_add_surround(&v); + lin_rects_add_wsui(&v); + if (!v.oom) lin_apply_input_region(&v, "AABB"); + free(v.r); +} + +// Windows answers WM_NCHITTEST from this; the X input region needs no per-cursor +// flag. Exported so the shared C# call site binds on Linux too. +DISPLAYXR_EXPORT void +displayxr_set_overlay_hit_active(int active) +{ + (void)active; +} + +DISPLAYXR_EXPORT void +displayxr_set_overlay_surround_rect(int x, int y, int w, int h) +{ + if (w <= 0 || h <= 0) { + s_surround_valid = 0; + return; + } + s_surround_rect.x = (short)x; + s_surround_rect.y = (short)y; + s_surround_rect.width = (unsigned short)w; + s_surround_rect.height = (unsigned short)h; + s_surround_valid = 1; +} + +DISPLAYXR_EXPORT void +displayxr_set_overlay_surround_mask(const uint8_t *mask, int mask_w, int mask_h, int dst_x, + int dst_y, int dst_w, int dst_h) +{ + free(s_surround_mask); + s_surround_mask = NULL; + if (mask == NULL || mask_w <= 0 || mask_h <= 0 || dst_w <= 0 || dst_h <= 0) return; + size_t n = (size_t)mask_w * (size_t)mask_h; + s_surround_mask = (uint8_t *)malloc(n); + if (!s_surround_mask) return; + memcpy(s_surround_mask, mask, n); + s_surround_mask_w = mask_w; + s_surround_mask_h = mask_h; + s_surround_dst_x = dst_x; + s_surround_dst_y = dst_y; + s_surround_dst_w = dst_w; + s_surround_dst_h = dst_h; +} + +// Give Unity's window its default input region back. Overlay teardown (s_dpy, the +// provider's thread) — the region must not outlive the cloak it was made for. +static void +lin_reset_input_region(void) +{ + if (s_hit_shaped && s_win && s_shape_combine_mask) { + s_shape_combine_mask(s_dpy, s_win, LIN_SHAPE_INPUT, 0, 0, 0 /* None */, LIN_SHAPE_SET); + lin_log("[DisplayXR-LNX] click-through: Unity's input region reset\n"); + } + s_hit_shaped = 0; + s_hit_hash = 0; + s_hit_mask_active = 0; +} + +// --- right-drag move -------------------------------------------------------- + +static int s_drag_active; +static int s_drag_px, s_drag_py; // root pointer at the press +static int s_drag_wx, s_drag_wy; // Unity's client origin at the press +static int s_drag_last_dx, s_drag_last_dy; // last offset sent, to skip repeats + +static int +lin_root_pointer(int *x, int *y) +{ + XWin r = 0, c = 0; + int wx = 0, wy = 0; + unsigned int mask = 0; + return s_x.XQueryPointer(s_hit_dpy, s_win, &r, &c, x, y, &wx, &wy, &mask) != 0; +} + +/// (#332) Right-drag move for the transparent overlay. Call every frame with the +/// right button's state: the first pressed call starts the drag, later ones move +/// Unity's window by how far the pointer has moved, the first released call ends +/// it. Returns 1 while a drag is in progress. No-op outside transparent mode. +DISPLAYXR_EXPORT int +displayxr_linux_drag_window(int right_pressed) +{ + if (!right_pressed) { + if (s_drag_active) lin_log("[DisplayXR-LNX] drag: end\n"); + s_drag_active = 0; + return 0; + } + if (!lin_hit_ready()) return 0; + int px = 0, py = 0; + if (!lin_root_pointer(&px, &py)) return s_drag_active; + if (!s_drag_active) { + XWin child = 0; + if (!s_x.XTranslateCoordinates(s_hit_dpy, s_win, s_x.XDefaultRootWindow(s_hit_dpy), 0, 0, + &s_drag_wx, &s_drag_wy, &child)) + return 0; + s_drag_px = px; + s_drag_py = py; + s_drag_last_dx = s_drag_last_dy = 0; + s_drag_active = 1; + lin_log("[DisplayXR-LNX] drag: start\n"); + return 1; + } + int dx = px - s_drag_px, dy = py - s_drag_py; + if (dx == s_drag_last_dx && dy == s_drag_last_dy) return 1; + s_drag_last_dx = dx; + s_drag_last_dy = dy; + lin_move_client(s_hit_dpy, s_drag_wx + dx, s_drag_wy + dy); + return 1; +} + // --------------------------------------------------------------------------- // Foreground query (#332) // --------------------------------------------------------------------------- From 05fd069062531a9f850444212bc8006e4b6218fd Mon Sep 17 00:00:00 2001 From: byungjul Date: Fri, 2 Oct 2026 16:30:57 +0900 Subject: [PATCH 2/3] fix(#332): review follow-ups for Linux click-through - No main-thread path reaches s_dpy any more (review B1): the region code reads the new cached dxr_prov_get_active_zone_rect_px instead of dxr_prov_get_zone_rect_px(0), which without a zone fell back to a live XGetGeometry on the connection the graphics thread and runtime share. - HUD rects are placed in window space, as the layer and the apps' routers read them (they landed ~200 px low in zone space; HUD clicks fell through). - s_hit_dpy is guarded by a mutex; the teardown reset goes out on it with an XSync, and a flag stops a late push from re-shaping after teardown. - hit_mask(NULL) resets the input shape (the whole window catches), as Windows does. - The drag also ends when XQueryPointer says Button3 is up (a WM grab can eat the release), and at teardown. - One check that the WM honoured the restored size, logged on mismatch. - C#: skip all of it when the session fell back to the opaque child (new displayxr_linux_click_through_active), and read the stereo views once per frame. - Include displayxr_window_space_ui.h instead of duplicating its constant. Co-Authored-By: Claude Opus 5.5 --- Runtime/DisplayXRNative.cs | 8 + Runtime/DisplayXRTransparentOverlay.cs | 21 +- native~/displayxr_exports.h | 4 + native~/displayxr_linux.c | 263 ++++++++++++------ .../displayxr_provider_session.cpp | 13 + .../displayxr_provider_session.h | 3 + 6 files changed, 221 insertions(+), 91 deletions(-) diff --git a/Runtime/DisplayXRNative.cs b/Runtime/DisplayXRNative.cs index fe57de0..8f77307 100644 --- a/Runtime/DisplayXRNative.cs +++ b/Runtime/DisplayXRNative.cs @@ -555,6 +555,14 @@ public static extern void displayxr_set_overlay_surround_mask( /// [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] public static extern int displayxr_linux_drag_window(int right_pressed); + + /// + /// (#332) 1 while the Linux transparent overlay is up and the click-through + /// exports act; 0 for the opaque child window or without X. Cached state, + /// cheap to poll every frame. + /// + [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] + public static extern int displayxr_linux_click_through_active(); #endif } diff --git a/Runtime/DisplayXRTransparentOverlay.cs b/Runtime/DisplayXRTransparentOverlay.cs index f4e91bc..2e9a450 100644 --- a/Runtime/DisplayXRTransparentOverlay.cs +++ b/Runtime/DisplayXRTransparentOverlay.cs @@ -981,6 +981,9 @@ void LinuxClickThroughAndDrag() try { + // Opaque child window (no compositor, …): nothing to click through to. + if (DisplayXRNative.displayxr_linux_click_through_active() == 0) + return; DisplayXRNative.displayxr_linux_drag_window(IsRightPressed ? 1 : 0); } catch (EntryPointNotFoundException) @@ -999,12 +1002,14 @@ void LinuxClickThroughAndDrag() int buttons = (IsLeftPressed ? 1 : 0) | (IsRightPressed ? 2 : 0); UpdateBakedHitColliders(); + bool haveViews = TryGetStereoMatrices(out Matrix4x4 lv, out Matrix4x4 lp, + out Matrix4x4 rv, out Matrix4x4 rp); + Matrix4x4 cv = Matrix4x4.identity, cp = Matrix4x4.identity; + if (haveViews) BuildCyclopean(lv, lp, rv, rp, out cv, out cp); + Renderer hitRenderer = null; - if (clientX >= 0 && clientY >= 0 - && TryGetStereoMatrices(out Matrix4x4 lv, out Matrix4x4 lp, - out Matrix4x4 rv, out Matrix4x4 rp)) + if (clientX >= 0 && clientY >= 0 && haveViews) { - BuildCyclopean(lv, lp, rv, rp, out Matrix4x4 cv, out Matrix4x4 cp); GetStereoViewport(overlayW, overlayH, out int vpX, out int vpY, out int vpW, out int vpH); if (TryBuildEyeRay(clientX, clientY, vpX, vpY, vpW, vpH, cv, cp, out Ray ray)) @@ -1061,16 +1066,14 @@ void LinuxClickThroughAndDrag() // The click-through region: AABB until the first silhouette readback // lands, then the per-pixel mask (OnHitMaskReadback) — as on Windows. - if (TryGetStereoMatrices(out Matrix4x4 lv2, out Matrix4x4 lp2, - out Matrix4x4 rv2, out Matrix4x4 rp2)) + if (haveViews) { - BuildCyclopean(lv2, lp2, rv2, rp2, out Matrix4x4 cv2, out Matrix4x4 cp2); - if (TryGetUnionScreenRect(overlayW, overlayH, cv2, cp2, + if (TryGetUnionScreenRect(overlayW, overlayH, cv, cp, out int rx, out int ry, out int rw, out int rh)) { DisplayXRNative.displayxr_set_overlay_hit_rect(rx, ry, rw, rh); } - RenderHitMaskAndRequestReadback(lv2, lp2, rv2, rp2, overlayW, overlayH); + RenderHitMaskAndRequestReadback(lv, lp, rv, rp, overlayW, overlayH); } } #endif diff --git a/native~/displayxr_exports.h b/native~/displayxr_exports.h index 7f36d2f..cb3dc0d 100644 --- a/native~/displayxr_exports.h +++ b/native~/displayxr_exports.h @@ -404,6 +404,10 @@ DISPLAYXR_EXPORT void displayxr_set_overlay_surround_mask(const uint8_t *mask, i /// right button's state; returns 1 while a drag is in progress. Moves Unity's /// window (the overlay follows it). No-op outside transparent mode. DISPLAYXR_EXPORT int displayxr_linux_drag_window(int right_pressed); + +/// (#332) 1 while the transparent overlay is up and the click-through exports act; 0 for +/// the opaque child or without X. Cached state only — cheap to poll every frame. +DISPLAYXR_EXPORT int displayxr_linux_click_through_active(void); #endif #ifdef __cplusplus diff --git a/native~/displayxr_linux.c b/native~/displayxr_linux.c index 9b2ab60..85568d9 100644 --- a/native~/displayxr_linux.c +++ b/native~/displayxr_linux.c @@ -63,7 +63,10 @@ #include #include +#include + #include "displayxr_exports.h" +#include "displayxr_window_space_ui.h" extern void dxr_prov_file_log(const char *s); @@ -197,6 +200,7 @@ static int s_overlay_is_toplevel; // 1 = ARGB top-level overlay, 0 = opaque c static unsigned long s_overlay_cmap; // colormap of the ARGB overlay static void lin_reset_input_region(void); // click-through (#332), below +static void lin_enable_click_through(void); static long long lin_now_ns(void); // Don't latch a FAILURE forever. The window may simply not be mapped yet on the @@ -535,6 +539,8 @@ lin_move_client(XDpy dpy, int x, int y) // // Same lifetime as the cloak, for the same reason (see the invariant above): set // with it at overlay creation, restored with it in displayxr_linux_destroy_weave_window. +static int s_geom_check_ticks; // track_window ticks until the size check (0 = none) +static unsigned int s_geom_expect_w, s_geom_expect_h; static int s_motif_saved; // 1 = s_motif holds the window's own hints static int s_motif_had; // the window had _MOTIF_WM_HINTS before we touched it static long s_motif[5]; @@ -566,6 +572,11 @@ lin_set_unity_undecorated(int undecorate, int keep_x, int keep_y, unsigned int k (const unsigned char *)v, 5); lin_moveresize_client(s_dpy, keep_x, keep_y, keep_w, keep_h); s_undecorated = 1; + if (keep_w && keep_h) { // check the WM honoured the size (+74 px growth regression) + s_geom_expect_w = keep_w; + s_geom_expect_h = keep_h; + s_geom_check_ticks = 60; + } lin_log("[DisplayXR-LNX] Unity's window undecorated while cloaked (click-through)\n"); return; } @@ -657,6 +668,7 @@ displayxr_linux_get_weave_window(void **out_display, unsigned long *out_window) lin_set_unity_opacity(1); // Size only when measured: never "restore" Unity to the 1920x1080 guess. lin_set_unity_undecorated(1, x, y, measured ? w : 0, measured ? h : 0); + lin_enable_click_through(); s_x.XMapWindow(s_dpy, s_overlay); s_x.XFlush(s_dpy); s_x.XSync(s_dpy, 0); @@ -714,6 +726,15 @@ displayxr_linux_track_window(void) unsigned int w = 0, h = 0; if (!lin_child_size(&w, &h) || w == 0 || h == 0) return; if (s_overlay_is_toplevel) { + if (s_geom_check_ticks > 0 && --s_geom_check_ticks == 0 && + (w != s_geom_expect_w || h != s_geom_expect_h)) { + char m[192]; + snprintf(m, sizeof(m), + "[DisplayXR-LNX] WARNING: Unity's window is %ux%u after undecorating, " + "expected %ux%u — the WM did not honour the size (it will persist)\n", + w, h, s_geom_expect_w, s_geom_expect_h); + lin_log(m); + } int x = 0, y = 0; if (!lin_app_origin(&x, &y)) return; if (w == s_ow && h == s_oh && x == s_ox && y == s_oy) return; @@ -826,8 +847,19 @@ displayxr_linux_set_transparent(int enabled) // displayxr_linux_track_window, and the runtime snaps the weave phase to the // reachable placement lattice (its x11_placement rule). // -// Main-thread calls (C# P/Invoke), so a connection of their own: s_dpy is shared -// with the runtime's Vulkan surface and the provider's tracking thread. +// THREADS. The exports below run on Unity's main thread (C# P/Invoke), so they use a +// connection of their own, s_hit_dpy. NOTHING reachable from the main thread may touch +// s_dpy: it is used every frame by the graphics thread (displayxr_linux_track_window) +// and borrowed by the runtime's Vulkan surface, and nothing calls XInitThreads. That +// includes indirect paths — dxr_prov_get_zone_rect_px(0) falls back to a live +// XGetGeometry(s_dpy) when no zone is active, so the region code reads the cached +// dxr_prov_get_active_zone_rect_px instead. The one s_hit_dpy request that comes from +// another thread — the input-region reset at overlay teardown — takes s_hit_mutex, +// as does every main-thread use. +// +// HUD rects are the layer's whole rect (widened by +-disparity/2), whatever its alpha: +// the transparent margin of a HUD canvas catches clicks too. Fine at today's HUD +// sizes; a per-pixel HUD mask would need the RT read back. typedef struct { short x, y; @@ -838,6 +870,12 @@ typedef struct { #define LIN_HIT_MAX_RECTS 32768 // X request size cap; a real silhouette is far below static XDpy s_hit_dpy; +static pthread_mutex_t s_hit_mutex = PTHREAD_MUTEX_INITIALIZER; +static int s_hit_enabled; // overlay up; cleared at teardown so a late push can't re-shape +static int s_drag_active; +static int s_drag_px, s_drag_py; // root pointer at the press +static int s_drag_wx, s_drag_wy; // Unity's client origin at the press +static int s_drag_last_dx, s_drag_last_dy; // last offset sent, to skip repeats static int s_hit_unavailable; static int s_hit_mask_active; // a mask has been applied; the AABB stops driving static unsigned long long s_hit_hash; // last applied region, 0 = none/unknown @@ -850,18 +888,17 @@ static int s_surround_dst_x, s_surround_dst_y, s_surround_dst_w, s_surround_dst_ extern uint32_t dxr_prov_get_zone_count(void); extern int dxr_prov_get_zone_rect_px(uint32_t zone, int *x, int *y, int *w, int *h); -extern int displayxr_window_space_ui_get_pending_slot(int slot, void **out_tex, int *out_tex_w, - int *out_tex_h, float *out_x, float *out_y, - float *out_lw, float *out_lh, float *out_disp); -#ifndef DXR_WSUI_MAX_SLOTS -#define DXR_WSUI_MAX_SLOTS 4 // displayxr_window_space_ui.h -#endif +extern int dxr_prov_get_active_zone_rect_px(int *x, int *y, int *w, int *h); + +static void lin_reset_input_region_locked(void); static int lin_hit_ready(void) { - if (s_hit_unavailable || !s_overlay_is_toplevel || !s_overlay || !s_win) return 0; - if (!s_shape_combine_rects) return 0; + if (s_hit_unavailable || !s_hit_enabled || !s_overlay_is_toplevel || !s_overlay || !s_win) + return 0; + // The reset needs XShapeCombineMask: never shape what we could not unshape. + if (!s_shape_combine_rects || !s_shape_combine_mask) return 0; if (!s_hit_dpy) { s_hit_dpy = s_x.XOpenDisplay(NULL); if (!s_hit_dpy) { @@ -925,14 +962,15 @@ lin_rects_from_mask(LinRects *v, const uint8_t *mask, int mw, int mh, long dx, l } } -// Live HUD rects. A window-space layer is placed in fractions of the 3D content -// region (zone 0, or the whole window without one) and shifted by +-disparity/2 -// per eye, so widen by that much on each side. +// Live HUD rects. A window-space layer's x/y/width/height are fractions of the +// WINDOW (DisplayXRWindowSpaceUI and the apps' HUD routers both read them against +// Screen.*), not of the 3D zone — measured on lenovo-avatar, where a zone-relative +// rect landed ~200 px below the drawn HUD and its clicks fell through. Each eye is +// shifted by +-disparity/2, so widen by that much on each side. static void -lin_rects_add_wsui(LinRects *v) +lin_rects_add_wsui(LinRects *v, int win_w, int win_h) { - int zx = 0, zy = 0, zw = 0, zh = 0; - if (!dxr_prov_get_zone_rect_px(0, &zx, &zy, &zw, &zh) || zw <= 0 || zh <= 0) return; + if (win_w <= 0 || win_h <= 0) return; for (int slot = 0; slot < DXR_WSUI_MAX_SLOTS; slot++) { float fx = 0, fy = 0, fw = 0, fh = 0, disp = 0; if (!displayxr_window_space_ui_get_pending_slot(slot, NULL, NULL, NULL, &fx, &fy, &fw, &fh, @@ -940,10 +978,10 @@ lin_rects_add_wsui(LinRects *v) continue; if (fw <= 0.0f || fh <= 0.0f) continue; float half = (disp < 0 ? -disp : disp) * 0.5f; - long x0 = zx + (long)((fx - half) * (float)zw); - long y0 = zy + (long)(fy * (float)zh); - long x1 = zx + (long)((fx + fw + half) * (float)zw + 0.999f); - long y1 = zy + (long)((fy + fh) * (float)zh + 0.999f); + long x0 = (long)((fx - half) * (float)win_w); + long y0 = (long)(fy * (float)win_h); + long x1 = (long)((fx + fw + half) * (float)win_w + 0.999f); + long y1 = (long)((fy + fh) * (float)win_h + 0.999f); lin_rects_push(v, x0, y0, x1, y1); } } @@ -990,12 +1028,13 @@ lin_apply_input_region(LinRects *v, const char *what) DISPLAYXR_EXPORT void displayxr_set_overlay_hit_mask(const uint8_t *mask, int mask_w, int mask_h, int dst_w, int dst_h) { - if (!lin_hit_ready()) return; + pthread_mutex_lock(&s_hit_mutex); + if (!lin_hit_ready()) goto out; if (mask == NULL || mask_w <= 0 || mask_h <= 0 || dst_w <= 0 || dst_h <= 0) { - // Back to the AABB path: the next hit_rect push drives the region again. - s_hit_mask_active = 0; - s_hit_hash = 0; - return; + // As on Windows (SetWindowRgn(NULL)): the whole window catches again, until the + // next hit_rect / hit_mask push shapes it. + lin_reset_input_region_locked(); + goto out; } LinRects v = {0}; @@ -1005,30 +1044,34 @@ displayxr_set_overlay_hit_mask(const uint8_t *mask, int mask_w, int mask_h, int for (int zi = 0; zi < zone_count; zi++) { int32_t cvx = 0, cvy = 0; uint32_t cvw = (uint32_t)dst_w, cvh = (uint32_t)dst_h; - if (use_zones) { - int rx = 0, ry = 0, rw = 0, rh = 0; + int rx = 0, ry = 0, rw = 0, rh = 0; + if (use_zones && zi > 0) { + // Extra zones: cached rects, no window query. if (!dxr_prov_get_zone_rect_px((uint32_t)zi, &rx, &ry, &rw, &rh) || rw <= 0 || rh <= 0) continue; cvx = rx; cvy = ry; cvw = (uint32_t)rw; cvh = (uint32_t)rh; - } else if (!displayxr_get_canvas_rect_px(&cvx, &cvy, &cvw, &cvh)) { - // No canvas sub-rect: the 3D zone's own rect (the whole window when no zone - // is set). The mask is rendered through zone 0's frustum, so it belongs in + } else if (use_zones || !displayxr_get_canvas_rect_px(&cvx, &cvy, &cvw, &cvh)) { + // Zone 0, or no canvas sub-rect: the active zone's own rect, else the whole + // window (dst). The mask is rendered through zone 0's frustum, so it belongs in // that rect — an app that keeps a 2D band beside a single zone (the // lenovo-avatar bubble) otherwise gets a silhouette stretched over the band. - // displayxr_win32.c maps this case to the full client instead. - int rx = 0, ry = 0, rw = 0, rh = 0; - if (dxr_prov_get_zone_rect_px(0, &rx, &ry, &rw, &rh) && rw > 0 && rh > 0) { + // displayxr_win32.c maps the single-zone case to the full client instead. + // NOT dxr_prov_get_zone_rect_px(0): without a zone it queries s_dpy (THREADS). + if (dxr_prov_get_active_zone_rect_px(&rx, &ry, &rw, &rh) && rw > 0 && rh > 0) { cvx = rx; cvy = ry; cvw = (uint32_t)rw; cvh = (uint32_t)rh; } } lin_rects_from_mask(&v, mask, mask_w, mask_h, cvx, cvy, cvw, cvh); } + int n_sil = v.n; lin_rects_add_surround(&v); - lin_rects_add_wsui(&v); + int n_sur = v.n - n_sil; + lin_rects_add_wsui(&v, dst_w, dst_h); + int n_ws = v.n - n_sil - n_sur; if (v.oom) { // Better too much than too little: keep the last region rather than cut the avatar. free(v.r); - return; + goto out; } lin_apply_input_region(&v, "silhouette"); s_hit_mask_active = 1; @@ -1039,24 +1082,31 @@ displayxr_set_overlay_hit_mask(const uint8_t *mask, int mask_w, int mask_h, int if (now - s_last_log_ns >= 1000000000LL) { s_last_log_ns = now; char m[160]; - snprintf(m, sizeof(m), "[DisplayXR-LNX] hit_mask: mask=%dx%d rects=%d dst=%dx%d\n", mask_w, - mask_h, v.n, dst_w, dst_h); + snprintf(m, sizeof(m), + "[DisplayXR-LNX] hit_mask: mask=%dx%d rects=%d (silhouette %d, surround %d, " + "wsui %d) dst=%dx%d\n", + mask_w, mask_h, v.n, n_sil, n_sur, n_ws, dst_w, dst_h); lin_log(m); } free(v.r); +out: + pthread_mutex_unlock(&s_hit_mutex); } DISPLAYXR_EXPORT void displayxr_set_overlay_hit_rect(int x, int y, int w, int h) { - if (!lin_hit_ready() || s_hit_mask_active) return; // the mask owns the region once it runs if (w <= 0 || h <= 0) return; - LinRects v = {0}; - lin_rects_push(&v, x, y, (long)x + w, (long)y + h); - lin_rects_add_surround(&v); - lin_rects_add_wsui(&v); - if (!v.oom) lin_apply_input_region(&v, "AABB"); - free(v.r); + pthread_mutex_lock(&s_hit_mutex); + if (lin_hit_ready() && !s_hit_mask_active) { // the mask owns the region once it runs + LinRects v = {0}; + lin_rects_push(&v, x, y, (long)x + w, (long)y + h); + lin_rects_add_surround(&v); + lin_rects_add_wsui(&v, (int)s_ow, (int)s_oh); + if (!v.oom) lin_apply_input_region(&v, "AABB"); + free(v.r); + } + pthread_mutex_unlock(&s_hit_mutex); } // Windows answers WM_NCHITTEST from this; the X input region needs no per-cursor @@ -1070,43 +1120,49 @@ displayxr_set_overlay_hit_active(int active) DISPLAYXR_EXPORT void displayxr_set_overlay_surround_rect(int x, int y, int w, int h) { + pthread_mutex_lock(&s_hit_mutex); if (w <= 0 || h <= 0) { s_surround_valid = 0; - return; + } else { + s_surround_rect.x = (short)x; + s_surround_rect.y = (short)y; + s_surround_rect.width = (unsigned short)w; + s_surround_rect.height = (unsigned short)h; + s_surround_valid = 1; } - s_surround_rect.x = (short)x; - s_surround_rect.y = (short)y; - s_surround_rect.width = (unsigned short)w; - s_surround_rect.height = (unsigned short)h; - s_surround_valid = 1; + pthread_mutex_unlock(&s_hit_mutex); } DISPLAYXR_EXPORT void displayxr_set_overlay_surround_mask(const uint8_t *mask, int mask_w, int mask_h, int dst_x, int dst_y, int dst_w, int dst_h) { + pthread_mutex_lock(&s_hit_mutex); free(s_surround_mask); s_surround_mask = NULL; - if (mask == NULL || mask_w <= 0 || mask_h <= 0 || dst_w <= 0 || dst_h <= 0) return; size_t n = (size_t)mask_w * (size_t)mask_h; - s_surround_mask = (uint8_t *)malloc(n); - if (!s_surround_mask) return; - memcpy(s_surround_mask, mask, n); - s_surround_mask_w = mask_w; - s_surround_mask_h = mask_h; - s_surround_dst_x = dst_x; - s_surround_dst_y = dst_y; - s_surround_dst_w = dst_w; - s_surround_dst_h = dst_h; + if (mask != NULL && mask_w > 0 && mask_h > 0 && dst_w > 0 && dst_h > 0 && + (s_surround_mask = (uint8_t *)malloc(n)) != NULL) { + memcpy(s_surround_mask, mask, n); + s_surround_mask_w = mask_w; + s_surround_mask_h = mask_h; + s_surround_dst_x = dst_x; + s_surround_dst_y = dst_y; + s_surround_dst_w = dst_w; + s_surround_dst_h = dst_h; + } + pthread_mutex_unlock(&s_hit_mutex); } -// Give Unity's window its default input region back. Overlay teardown (s_dpy, the -// provider's thread) — the region must not outlive the cloak it was made for. +// Give Unity's window its default input region back (the whole window catches). +// Caller holds s_hit_mutex. On s_hit_dpy, the connection every region set went out +// on, so the reset cannot overtake a set still in flight. static void -lin_reset_input_region(void) +lin_reset_input_region_locked(void) { - if (s_hit_shaped && s_win && s_shape_combine_mask) { - s_shape_combine_mask(s_dpy, s_win, LIN_SHAPE_INPUT, 0, 0, 0 /* None */, LIN_SHAPE_SET); + if (s_hit_shaped && s_hit_dpy && s_win && s_shape_combine_mask) { + s_shape_combine_mask(s_hit_dpy, s_win, LIN_SHAPE_INPUT, 0, 0, 0 /* None */, LIN_SHAPE_SET); + s_x.XSync(s_hit_dpy, 0); lin_log("[DisplayXR-LNX] click-through: Unity's input region reset\n"); } s_hit_shaped = 0; @@ -1114,20 +1170,47 @@ lin_reset_input_region(void) s_hit_mask_active = 0; } +// Overlay creation (the provider's thread): from here the main thread may shape. +static void +lin_enable_click_through(void) +{ + pthread_mutex_lock(&s_hit_mutex); + s_hit_enabled = 1; + pthread_mutex_unlock(&s_hit_mutex); +} + +/// (#332) 1 while the transparent overlay is up and the click-through exports act, +/// 0 otherwise (opaque child, no overlay, X unavailable). Cached state only. +DISPLAYXR_EXPORT int +displayxr_linux_click_through_active(void) +{ + return s_hit_enabled && s_overlay_is_toplevel && !s_hit_unavailable; +} + +// Overlay teardown (the provider's thread): the region must not outlive the cloak it +// was made for. Also ends a drag still in progress. +static void +lin_reset_input_region(void) +{ + pthread_mutex_lock(&s_hit_mutex); + s_hit_enabled = 0; + lin_reset_input_region_locked(); + s_drag_active = 0; + pthread_mutex_unlock(&s_hit_mutex); +} + // --- right-drag move -------------------------------------------------------- -static int s_drag_active; -static int s_drag_px, s_drag_py; // root pointer at the press -static int s_drag_wx, s_drag_wy; // Unity's client origin at the press -static int s_drag_last_dx, s_drag_last_dy; // last offset sent, to skip repeats + +#define LIN_BUTTON3_MASK (1u << 10) static int -lin_root_pointer(int *x, int *y) +lin_root_pointer(int *x, int *y, unsigned int *mask) { XWin r = 0, c = 0; int wx = 0, wy = 0; - unsigned int mask = 0; - return s_x.XQueryPointer(s_hit_dpy, s_win, &r, &c, x, y, &wx, &wy, &mask) != 0; + *mask = 0; + return s_x.XQueryPointer(s_hit_dpy, s_win, &r, &c, x, y, &wx, &wy, mask) != 0; } /// (#332) Right-drag move for the transparent overlay. Call every frame with the @@ -1137,32 +1220,48 @@ lin_root_pointer(int *x, int *y) DISPLAYXR_EXPORT int displayxr_linux_drag_window(int right_pressed) { + int active = 0; + pthread_mutex_lock(&s_hit_mutex); if (!right_pressed) { if (s_drag_active) lin_log("[DisplayXR-LNX] drag: end\n"); s_drag_active = 0; - return 0; + goto out; } - if (!lin_hit_ready()) return 0; + if (!lin_hit_ready()) goto out; int px = 0, py = 0; - if (!lin_root_pointer(&px, &py)) return s_drag_active; + unsigned int buttons = 0; + if (!lin_root_pointer(&px, &py, &buttons)) { active = s_drag_active; goto out; } + if (!(buttons & LIN_BUTTON3_MASK)) { + // The server says the button is up even though Unity still reports it held — a + // WM grab (Super, a keybinding) ate the release. End here, or the window would + // follow the cursor with no button down. + if (s_drag_active) lin_log("[DisplayXR-LNX] drag: end (button already released)\n"); + s_drag_active = 0; + goto out; + } if (!s_drag_active) { XWin child = 0; if (!s_x.XTranslateCoordinates(s_hit_dpy, s_win, s_x.XDefaultRootWindow(s_hit_dpy), 0, 0, &s_drag_wx, &s_drag_wy, &child)) - return 0; + goto out; s_drag_px = px; s_drag_py = py; s_drag_last_dx = s_drag_last_dy = 0; s_drag_active = 1; lin_log("[DisplayXR-LNX] drag: start\n"); - return 1; + active = 1; + goto out; } + active = 1; int dx = px - s_drag_px, dy = py - s_drag_py; - if (dx == s_drag_last_dx && dy == s_drag_last_dy) return 1; - s_drag_last_dx = dx; - s_drag_last_dy = dy; - lin_move_client(s_hit_dpy, s_drag_wx + dx, s_drag_wy + dy); - return 1; + if (dx != s_drag_last_dx || dy != s_drag_last_dy) { + s_drag_last_dx = dx; + s_drag_last_dy = dy; + lin_move_client(s_hit_dpy, s_drag_wx + dx, s_drag_wy + dy); + } +out: + pthread_mutex_unlock(&s_hit_mutex); + return active; } // --------------------------------------------------------------------------- diff --git a/native~/displayxr_xrprovider/displayxr_provider_session.cpp b/native~/displayxr_xrprovider/displayxr_provider_session.cpp index dcfe636..130046e 100644 --- a/native~/displayxr_xrprovider/displayxr_provider_session.cpp +++ b/native~/displayxr_xrprovider/displayxr_provider_session.cpp @@ -6531,6 +6531,19 @@ int dxr_prov_get_zone_rect_px(uint32_t zone, int *x, int *y, int *w, int *h) return 1; } +// Zone 0's rect, only while a 3D zone is active; 0 otherwise. Unlike +// dxr_prov_get_zone_rect_px(0) it never falls back to the live window size, so it never +// reaches the platform window query — on Linux an XGetGeometry on the connection the +// graphics thread and the runtime share. Reads cached state only; any thread (#332). +int dxr_prov_get_active_zone_rect_px(int *x, int *y, int *w, int *h) +{ + if (!s_ps.zone_valid || s_ps.zone_caps_ok <= 0) return 0; + if (s_ps.zone_w == 0 || s_ps.zone_h == 0) return 0; + if (x) *x = s_ps.zone_x; if (y) *y = s_ps.zone_y; + if (w) *w = s_ps.zone_w; if (h) *h = s_ps.zone_h; + return 1; +} + // DIAGNOSTIC (DISPLAYXR_PROV_SLICE_COLORS=1): record clears of the acquired swapchain image's // array slice 0 = solid BLUE and slice 1 = solid RED onto the own command list (caller has // already Reset it and will Close + execute). Used to test whether the runtime's texture-mode diff --git a/native~/displayxr_xrprovider/displayxr_provider_session.h b/native~/displayxr_xrprovider/displayxr_provider_session.h index 666b353..778f38a 100644 --- a/native~/displayxr_xrprovider/displayxr_provider_session.h +++ b/native~/displayxr_xrprovider/displayxr_provider_session.h @@ -307,6 +307,9 @@ DISPLAYXR_EXPORT int dxr_prov_get_zone_stereo_matrices(uint32_t zone, float *lv, float *rv, float *rp); /// Fill `zone`'s window-client pixel rect (top-left origin). Returns 1 on success. DISPLAYXR_EXPORT int dxr_prov_get_zone_rect_px(uint32_t zone, int *x, int *y, int *w, int *h); +/// Zone 0's rect only while a 3D zone is active (returns 0 otherwise). Cached state only: +/// never queries the window, so it is safe from Unity's main thread (#332). +DISPLAYXR_EXPORT int dxr_prov_get_active_zone_rect_px(int *x, int *y, int *w, int *h); /// Local2D layer (#166 Phase B): lazily create the provider's Local2D overlay /// swapchain + cross-device bridge sized to w×h and return the Unity-device handle From 16b8c61d5711d9b224ae4263ab6ea23549c59025 Mon Sep 17 00:00:00 2001 From: byungjul Date: Fri, 2 Oct 2026 16:39:47 +0900 Subject: [PATCH 3/3] fix(#332): keep Unity's window above while cloaked, so HUD/bubble clicks land What the user sees is the override-redirect overlay, always on top; what takes the clicks is Unity's window beneath it. After the user focused another window, the WM raised it over Unity's, and a click on a HUD or the bubble went to that window. _NET_WM_STATE_ABOVE keeps Unity's window in the overlay's stacking place, as the Windows overlay HWND is WS_EX_TOPMOST. Same lifetime as the cloak. Docs: the Linux section of click-through-mask.md. Co-Authored-By: Claude Opus 5.5 --- docs~/architecture/click-through-mask.md | 13 +++++++++- native~/displayxr_linux.c | 31 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docs~/architecture/click-through-mask.md b/docs~/architecture/click-through-mask.md index d8bc3f9..df6d31e 100644 --- a/docs~/architecture/click-through-mask.md +++ b/docs~/architecture/click-through-mask.md @@ -169,7 +169,9 @@ Differences from Windows, all deliberate: - **Live window-space UI layers are unioned in.** A HUD is drawn wherever its rect is, inside the avatar or not, and must take its own clicks; native knows every slot's rect, so no app - call is needed. + call is needed. The rect is in **window** fractions (as the layer and the apps' HUD routers + read it — not zone fractions), widened by ±disparity/2, and covers the whole layer rect + whatever its alpha. - **A single zone with a sub-rect maps the mask into that zone's rect**, not the full client. The mask is rendered through zone 0's frustum, so an app that keeps a 2D band beside one zone (lenovo-avatar's bubble) would otherwise get a silhouette stretched over the band. @@ -180,6 +182,15 @@ Differences from Windows, all deliberate: the window; its client origin and size are put back (unframing hands it the frame's geometry: +74 px tall on GNOME, which Unity then saved every launch). Restored with the cloak. +- **Unity's window is kept above other windows while cloaked** (`_NET_WM_STATE_ABOVE`), as the + Windows overlay HWND is `WS_EX_TOPMOST`. The visible overlay is override-redirect and always + on top; without this, focusing another window raised it over Unity's, and clicks on a HUD or + the bubble the user could see went to that window instead. +- **Threads.** The exports run on Unity's main thread and use their own X connection + (`s_hit_dpy`, mutex-guarded). Nothing reachable from them may touch `s_dpy` (the graphics + thread's and the runtime's connection) — including `dxr_prov_get_zone_rect_px(0)`, which falls + back to a live `XGetGeometry(s_dpy)` without a zone; they read the cached + `dxr_prov_get_active_zone_rect_px`. - **Right-drag move** is native too (`displayxr_linux_drag_window`, called every frame with the right button's state): it follows the root pointer and moves Unity's window with `_NET_MOVERESIZE_WINDOW`; the overlay follows it in `displayxr_linux_track_window`. diff --git a/native~/displayxr_linux.c b/native~/displayxr_linux.c index 85568d9..ccba15c 100644 --- a/native~/displayxr_linux.c +++ b/native~/displayxr_linux.c @@ -525,6 +525,35 @@ lin_move_client(XDpy dpy, int x, int y) lin_moveresize_client(dpy, x, y, 0, 0); } +// Keep Unity's window above normal windows while it is cloaked (#332), as the Windows +// overlay HWND is WS_EX_TOPMOST. What the user sees is the override-redirect overlay, +// which is always on top; what takes the clicks is Unity's window beneath it. Once the +// user focused another window, the WM raised that window over Unity's, so a click on a +// HUD or the bubble the user could see went to the window "behind" it instead. EWMH +// _NET_WM_STATE_ABOVE keeps the two in the same stacking place. Same lifetime as the +// cloak (set at overlay creation, cleared in displayxr_linux_destroy_weave_window). +static void +lin_set_unity_above(int above) +{ + XAtom state = s_x.XInternAtom(s_dpy, "_NET_WM_STATE", 0); + XAtom above_atom = s_x.XInternAtom(s_dpy, "_NET_WM_STATE_ABOVE", 0); + if (!state || !above_atom) return; + LinClientMessage e; + memset(&e, 0, sizeof(e)); + e.type = LIN_CLIENT_MESSAGE; + e.window = s_win; + e.message_type = state; + e.format = 32; + e.l[0] = above ? 1 /* _NET_WM_STATE_ADD */ : 0 /* _NET_WM_STATE_REMOVE */; + e.l[1] = (long)above_atom; + e.l[2] = 0; + e.l[3] = 2; // source: pager/tool + s_x.XSendEvent(s_dpy, s_x.XDefaultRootWindow(s_dpy), 0, LIN_SUBSTRUCTURE_MASK, &e); + s_x.XFlush(s_dpy); + lin_log(above ? "[DisplayXR-LNX] Unity's window kept above while cloaked (click-through)\n" + : "[DisplayXR-LNX] Unity's window no longer kept above\n"); +} + // Undecorate Unity's window while it is cloaked (#332), restore it after. // // A decorated X11 window sits inside the WM's frame window, and mutter takes input @@ -668,6 +697,7 @@ displayxr_linux_get_weave_window(void **out_display, unsigned long *out_window) lin_set_unity_opacity(1); // Size only when measured: never "restore" Unity to the 1920x1080 guess. lin_set_unity_undecorated(1, x, y, measured ? w : 0, measured ? h : 0); + lin_set_unity_above(1); lin_enable_click_through(); s_x.XMapWindow(s_dpy, s_overlay); s_x.XFlush(s_dpy); @@ -777,6 +807,7 @@ displayxr_linux_destroy_weave_window(void) unsigned int cw = 0, ch = 0; lin_app_origin(&cx, &cy); // keep the client where the user left it lin_child_size(&cw, &ch); + lin_set_unity_above(0); lin_set_unity_undecorated(0, cx, cy, cw, ch); lin_set_unity_opacity(0); // The one un-cloak (see the invariant above). Logged on its own line so a