Skip to content

feat(#332): Linux click-through and right-drag move for the transparent overlay - #346

Merged
dfattal merged 3 commits into
DisplayXR:mainfrom
byungjul:feat/linux-click-through
Oct 2, 2026
Merged

dfattal merged 3 commits into
DisplayXR:mainfrom
byungjul:feat/linux-click-through

Conversation

@byungjul

@byungjul byungjul commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Part of #332 (Linux transparent overlay: click-through and window handling).

What changes

On Linux, in a transparent session:

  • Click-through. Clicks outside the avatar reach the desktop or the window behind. Clicks on the avatar, a HUD or the speech bubble go to the app, even after another window was focused.
  • Right-drag move. Holding the right button on the avatar and dragging moves the window, as on Windows.
  • Unity's window is kept above other windows while the overlay is up, as the Windows overlay HWND is WS_EX_TOPMOST.

How

Click-through: an XShape input region on Unity's window. The weave overlay is an ARGB override-redirect top-level that takes no input; Unity's cloaked window beneath it gets the clicks. The Windows click-through exports now exist on Linux with the same arguments and semantics: displayxr_set_overlay_hit_mask, _hit_rect, _hit_active, _surround_rect and _surround_mask. They build the same region (silhouette mask, the AABB until the first mask, the surround rect/mask). Instead of SetWindowRgn, the region becomes the input shape of Unity's window. hit_mask(NULL) resets it (the whole window catches), as on Windows.

Linux-specific differences:

  • Live HUDs (window-space UI slots) are unioned in, so a HUD outside the avatar takes its own clicks without an app call. The rect is in window fractions, as the layer and the apps' HUD routers read it, 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. The mask is rendered through zone 0's frustum. Without this, lenovo-avatar (a 3D zone below a 2D bubble band) got a silhouette stretched over the band. displayxr_win32.c maps that case to the full client (David is filing that on the Windows side).
  • Unity's window is undecorated while cloaked. mutter takes input over a decorated window's whole frame and ignored the input shape. Clicks beside the avatar never reached the desktop, and the invisible title bar caught clicks too. Unframing also hands the client the frame's geometry (+74 px tall on GNOME, which Unity then saved, so the window grew every launch), so the client's origin and size are put back. A one-time check logs it if the WM didn't honour the size.
  • Unity's window is kept above (_NET_WM_STATE_ABOVE). The visible overlay is always on top, but once another window was focused, the WM raised it over Unity's window. A click on a HUD or the bubble the user could see then went to that window instead.

All three window changes have the cloak's lifetime: they are set at overlay creation and restored in displayxr_linux_destroy_weave_window, so the #334 invariant holds.

Right-drag move. displayxr_linux_drag_window(right_pressed) is called every frame. It follows the root pointer and moves Unity's window with _NET_MOVERESIZE_WINDOW (StaticGravity); the overlay follows it in displayxr_linux_track_window. The drag ends when Unity reports the button up, or when XQueryPointer says Button3 is up (a WM grab can eat the release), or at teardown.

Threads. The exports run on Unity's main thread and use their own connection, s_hit_dpy, guarded by a mutex that the teardown reset also takes. Nothing reachable from them touches s_dpy (the graphics thread's and the runtime's connection). That includes dxr_prov_get_zone_rect_px(0), which without a zone falls back to a live XGetGeometry(s_dpy). The region code reads the new cached dxr_prov_get_active_zone_rect_px instead.

C#. DisplayXRTransparentOverlay runs the hit-test, pointer events (onPointerEnter/Down/Up/Click) and silhouette mask on Linux, as on Windows/macOS. It skips all of it when the session fell back to the opaque child (displayxr_linux_click_through_active). The five click-through bindings in DisplayXRNative move from the Windows-only block to a Windows + Linux block, with unchanged declarations.

Platforms

  • Linux: behaviour change, transparent sessions only. Opaque sessions keep Unity's default window: every export is a no-op there.
  • Windows / macOS: no behaviour change. In native, only displayxr_linux.c, a Linux-only block in displayxr_exports.h, and the new cached getter in displayxr_provider_session.cpp (unused on Windows/macOS) change. The C# changes are a binding move and new #elif UNITY_STANDALONE_LINUX branches.

Testing

Ubuntu 26.04 (GNOME, XWayland), RTX 4090 Laptop, Leia DS1. App: the lenovo-avatar Linux player. Runtime: v2.21.11 with #1783/#1784 applied, so HUDs show outside the avatar.

Checked by hand:

  • A click outside the avatar reaches the window behind, including in the strip where the hidden title bar was.
  • Clicks on the avatar work; right-drag moves the window, and the weave is still correct after the move.
  • With another window focused and overlapping, a click on a HUD outside the avatar or on the bubble goes to the app, not to that window. Before the keep-above change it went to the window.
  • Quitting and relaunching keeps the window's position and size (840×1448).

From the log, xwininfo/xprop and XShapeGetRectangles:

Not covered: a transparent app without a 3D zone (lenovo always has one; that path now reads only cached state), and other WMs (KDE, X11 sessions without XWayland).

lenovo-avatar follow-up: it pushes its bubble surround rect only under UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN. My test build widened that guard locally. The app change has to wait for a plugin release with these Linux bindings.

🤖 Generated with Claude Code

@byungjul
byungjul requested a review from dfattal as a code owner October 2, 2026 06:36

@dfattal dfattal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. This is a solid port: the region math matches displayxr_win32.c, the platform guards are clean, and the undecorate lifetime keeps the #334 invariant (session-fail goes through LifecycleStop). One blocking issue, the rest are follow-ups.

Blocking

B1. The per-frame region build reaches s_dpy from Unity's main thread. That's the hazard #335 was built to avoid, reached indirectly here.

  • lin_rects_add_wsui (displayxr_linux.c:~935) runs on every hit_mask / hit_rect call, so every frame, as does the single-zone fallback in hit_mask (:~1020). Both call dxr_prov_get_zone_rect_px(0, …).
  • With no 3D zone active, that goes ps_window_size → displayxr_linux_window_size → XGetGeometry(s_dpy, …), a synchronous round trip on the connection the graphics thread uses every frame (displayxr_linux_track_window from GfxPopulateNextFrameDesc, and the render-rect ps_window_size) and the runtime's Vulkan surface borrows. Nothing calls XInitThreads.
  • lenovo-avatar has a zone active, so ps_zone_active() short-circuits and never touches X. That's why your testing didn't hit it. Any transparent Linux app without a zone would.
  • Fix: on Linux, don't call dxr_prov_get_zone_rect_px(0) unless a zone is active, and use (0, 0, dst_w, dst_h) from C# for the HUD frame (it's the same window). Or read a provider-cached size. The fix for #291 adds exactly that, dxr_prov_get_composited_size (an atomic filled wherever ps_window_size runs, so never a live X query from the caller); it's coming in a PR shortly. Please also add a one-line comment that nothing on the main thread may reach s_dpy.

Follow-ups (non-blocking)

  • N1. Stuck drag. The drag only ends when the managed IsRightPressed reads false. If a WM grab (Super/overview, a keybinding) eats the release, the window follows the cursor with no button held. lin_root_pointer already does XQueryPointer: end the drag when !(mask & Button3Mask). Also clear s_drag_active in displayxr_linux_destroy_weave_window.
  • N2. hit_mask(NULL) differs from Windows. Windows does SetWindowRgn(NULL), so the whole window becomes clickable. Linux keeps the last silhouette as the input region. Reset the input shape there, or document the difference.
  • N3. Teardown reset goes out on a different X connection. lin_reset_input_region uses s_dpy, while every region set used s_hit_dpy. Order is only guaranteed within one connection. Reset on s_hit_dpy + XSync. Also make lin_hit_ready require the XShapeCombineMask symbol, so the reset can't silently no-op.
  • N4. No X error handler. An async BadWindow exits the process. Exposure is limited (only our windows and Unity's), but the new per-frame s_hit_dpy requests on s_win add some. Consider a scoped trap around them.
  • N5. Geometry restore depends on the WM. It depends on the WM honouring _NET_MOVERESIZE_WINDOW size under StaticGravity, and measured is read once at LifecycleStart. One XGetGeometry check on the next track_window tick, logging a mismatch, would catch a regression of the +74 px growth.
  • N6. HUD union uses the full layer rect (widened by disparity), regardless of alpha. That's fine at today's sizes; please document it.
  • N7. Linux maps a single-zone sub-rect mask into zone 0's rect, while Windows maps it to the full client. Windows probably has the misalignment you describe, so I'll file it on our side.
  • N8. Nits: DXR_WSUI_MAX_SLOTS duplicates the header (include it or _Static_assert). The hit and focus X connections could be shared, since both are main-thread only. The C# Linux path calls TryGetStereoMatrices twice per frame, and it still renders and reads back the silhouette when the session fell back to the opaque child.

Note: Screen.* is the right space on Linux for this PR's region, since it lives on Unity's own window. That's unlike Windows, where Screen.* describes the cloaked window (#291).

🤖 Generated with Claude Code

byungjul and others added 2 commits October 2, 2026 16:30
…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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…ubble 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 <noreply@anthropic.com>
@byungjul
byungjul force-pushed the feat/linux-click-through branch from 0e32b7f to 16b8c61 Compare October 2, 2026 07:39
@byungjul

byungjul commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed (rebased on main, which now has #345):

B1 (main thread reaching s_dpy): fixed. The region code no longer calls dxr_prov_get_zone_rect_px(0). It reads the new dxr_prov_get_active_zone_rect_px, which returns zone 0's rect only while a zone is active, from cached state, and never queries the window. Without a zone it uses (0, 0, dst_w, dst_h). HUD rects now use the window size passed in (dst_w/dst_h, or the cached s_ow/s_oh on the AABB path). The file has a THREADS note saying nothing on the main thread may reach s_dpy. Once your dxr_prov_get_composited_size lands, I can switch to it.

Follow-ups:

  • N1: done. The drag also ends when XQueryPointer reports Button3 up, and at teardown.
  • N2: done. hit_mask(NULL) resets the input shape (whole window catches), as on Windows.
  • N3: done. The reset goes out on s_hit_dpy with an XSync, under a mutex that every s_hit_dpy user takes. A flag cleared at teardown stops a late push from re-shaping. lin_hit_ready now also requires XShapeCombineMask.
  • N4: not done. XSetErrorHandler is process-global, and libX11 is shared with Unity, so a trap would also swallow Unity's own X errors. Our requests only target our overlay and Unity's window, which outlives them. If you'd rather have it, I'll add a narrow trap.
  • N5: done. One size check about 60 ticks after undecorating, logged on mismatch.
  • N6: documented, in the code and in click-through-mask.md.
  • N7: thanks for filing it.
  • N8:
    • The header is now included.
    • The stereo views are read once per frame.
    • The C# path skips everything on the opaque fallback (displayxr_linux_click_through_active).
    • The hit and focus connections stay separate, since only the hit one needs the mutex.

Two more fixes from DS1 testing:

  • HUD rects were in zone space; they are now in window space. That's how the layer and the apps' routers read them. In zone space they landed about 200 px low, and HUD clicks fell through.
  • Unity's window is now kept above while cloaked (_NET_WM_STATE_ABOVE, the counterpart of the Windows overlay's WS_EX_TOPMOST). After another window got focus, the WM raised it over Unity's window, and clicks on a visible HUD or bubble went to that window. Verified on the DS1: with an overlapping window focused, HUD and bubble clicks now go to the app.

The PR description is updated.

@dfattal dfattal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B1 verified: dxr_prov_get_active_zone_rect_px reads only cached session state and never queries the window, so the main thread no longer reaches s_dpy. N1–N3, N5, N6 and N8 look good, and the reasoning on N4 holds (XSetErrorHandler is process-global and libX11 is shared with Unity). The zone→window-space HUD fix and _NET_WM_STATE_ABOVE are good catches. Once #349 lands, switching the HUD frame to dxr_prov_get_composited_size makes sense as a follow-up. Merging.

@dfattal
dfattal merged commit dbb89da into DisplayXR:main Oct 2, 2026
8 checks passed
dfattal added a commit that referenced this pull request Oct 2, 2026
…from the merged source

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dfattal added a commit that referenced this pull request Oct 2, 2026
…350) (#351)

SetWindowRgn is a visual clip, not only a hit mask, so a window-space UI
HUD off the avatar silhouette was cut away. Every region the transparent
overlay builds (hit mask + surround union, and the AABB hit-rect path
before the first mask) now unions the client-pixel rect of each live wsui
slot, natively and with no app call, mirroring the Linux input region
(#346).

The fractions are of the whole window client, not zone 0. The runtime
draws the layer into each per-view tile, and the tile spans the full
window in both plain-projection and zones frames. The rect is widened by
|disparity|/2 per side and scaled by the same client size the silhouette
uses. A slot counts only while a texture is registered, so a disabled HUD
drops out. HUD rects feed the region hash (and bypass the hit-rect
hysteresis), so toggling re-applies the region and a static HUD costs no
SetWindowRgn.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants