diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2d18d6505..1c707ca6e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -171,10 +171,11 @@ jobs: run: gjs scripts/test_gnome_extension_lattice.js - name: Move-sync frame logic - # Unit test for MoveSyncChoice in lib.js (extension versions 9-10, + # Unit test for MoveSyncChoice in lib.js (extension versions 9-12, # #1748): the frame-tag decode (against the runtime's encode), which - # position the actor is painted at, the stalled-app fallback, and the - # v10 tag gate (no tag -> no hold; tag mid-drag; tag lost). Plain gjs. + # position the actor is painted at, the stalled-app fallback, the + # v10 tag gate (no tag -> no hold; tag mid-drag; tag lost), and the + # v12 surface pick (main surface by texture, not size). Plain gjs. run: gjs scripts/test_gnome_extension_move_sync.js - name: Pointer drag ends on its button diff --git a/contrib/gnome-shell/window-geometry@displayxr.org/README.md b/contrib/gnome-shell/window-geometry@displayxr.org/README.md index a12c477d2..4558f52cc 100644 --- a/contrib/gnome-shell/window-geometry@displayxr.org/README.md +++ b/contrib/gnome-shell/window-geometry@displayxr.org/README.md @@ -250,6 +250,15 @@ systemctl --user unset-environment DISPLAYXR_DEBUG # then log out/in again (every monitor at scale 1, where the two modes agree). The drag-lattice choice and the stamp audit weigh device px by the same factor. The schema stays `version: 1`, because no field changed meaning. See the spec, §4.1. +- Version 12, no interface change: the move-sync tag is decoded against the + window's **main surface found by its texture** (`MetaWindowActor.get_texture()` + is the toplevel surface actor's content), no longer against the largest + surface actor. Under libdecor-gtk — GLFW 3.4's and SDL2/SDL3's stock + decorations on GNOME — the shadow subsurface is larger than the content, so + versions 9–11 decoded every tag against the shadow, resolved nothing, and + timed out on every title-bar drag (stale frames, visible 3D stutter; found + on the LeiaSR OpenGL example). Apps that draw their own chrome, the test + apps and the browser among them, were never affected. See the spec, §9.2. Verify capture exclusion is live: diff --git a/contrib/gnome-shell/window-geometry@displayxr.org/lib.js b/contrib/gnome-shell/window-geometry@displayxr.org/lib.js index a76db7d73..a44ff7343 100644 --- a/contrib/gnome-shell/window-geometry@displayxr.org/lib.js +++ b/contrib/gnome-shell/window-geometry@displayxr.org/lib.js @@ -308,6 +308,54 @@ return [MoveSyncChoice.mod(tagX - mainX, m), MoveSyncChoice.mod(tagY - mainY, m)]; }, + /* + * Which of a window's surface actors are the tag and the MAIN + * surface (extension version 12). @p surfaces: one {w, h, content} + * per actor under the window actor with a non-empty size, in tree + * order; @p texture: the main surface's texture + * (MetaWindowActor.get_texture(), a MetaShapedTexture), or null. + * + * The tag is the 1x1 surface. The main surface is the actor whose + * CONTENT is that texture: mutter sets the main surface's + * MetaShapedTexture as its surface actor's content and hands the + * same object out from the window actor, so the pairing is + * structural — the toplevel's own surface, the one the tag is a + * subsurface of (runtime and present-owner alike), whatever else + * the client stacks around it. + * + * Versions 9-11 took the LARGEST surface instead. Under + * libdecor-gtk (GLFW 3.4's and SDL's stock decorations on GNOME) + * the shadow subsurface is larger than the content, so every tag + * decoded against the shadow's origin, no frame ever resolved, and + * the hold timed out on every title-bar drag — stale frames and 3D + * stutter (found on the LeiaSR OpenGL example). The largest surface + * stands in only without a texture (none handed out, or no actor + * carrying it), where it is still the version 9-11 behaviour. + * + * Returns {main, tag, byTexture}: indices into @p surfaces (-1 when + * absent) and whether the main surface was found by its texture. + */ + pick(surfaces, texture) { + let main = -1, tag = -1, best = -1, byTexture = false; + for (let i = 0; i < surfaces.length; i++) { + const s = surfaces[i]; + if (s.w === 1 && s.h === 1) { + tag = i; + continue; + } + if (byTexture) + continue; + if (texture && s.content === texture) { + main = i; + byTexture = true; + } else if (s.w * s.h > best) { + best = s.w * s.h; + main = i; + } + } + return {main, tag, byTexture}; + }, + //! The newest entry of @p hist ({x, y, t}, oldest first) whose //! position matches @p tag, or null. resolve(hist, tag) { @@ -1782,31 +1830,35 @@ //! The tag of the buffer the window shows NOW: [x, y] or null. _readTag(actor) { + // Every surface actor under the window actor, with its + // position relative to the window actor. mutter 45-50 keep + // them flat under one container; summing the offsets covers + // a nested tree as well. const surfaces = []; - const walk = a => { + const walk = (a, ox, oy) => { for (const ch of a.get_children()) { const [w, h] = ch.get_size(); + const [x, y] = ch.get_position(); if (w > 0 && h > 0) - surfaces.push(ch); - walk(ch); + surfaces.push({actor: ch, w, h, x: ox + x, y: oy + y, content: ch.get_content()}); + walk(ch, ox + x, oy + y); } }; - walk(actor); - let main = null, tag = null, best = -1; - for (const a of surfaces) { - const [w, h] = a.get_size(); - if (w === 1 && h === 1) { - tag = a; - } else if (w * h > best) { - best = w * h; - main = a; - } - } - if (!tag || !main || !tag.visible) + walk(actor, 0, 0); + // The main surface by its texture (version 12), not by size: + // see MoveSyncChoice.pick. + const texture = actor.get_texture?.() ?? null; + const {main, tag, byTexture} = MoveSyncChoice.pick(surfaces, texture); + if (main < 0 || tag < 0 || !surfaces[tag].actor.visible) return null; - const [tx, ty] = tag.get_position(); - const [mx, my] = main.get_position(); - return MoveSyncChoice.decode(tx, ty, mx, my); + if (this._debug && this._mainRule !== byTexture) { + this._mainRule = byTexture; + log(`displayxr: move sync: main surface found by ${byTexture + ? 'its texture' : 'size (no texture match — a decorated window may decode wrong)'} ` + + `(${surfaces.length} surface actors)`); + } + const t = surfaces[tag], m = surfaces[main]; + return MoveSyncChoice.decode(t.x, t.y, m.x, m.y); } _beforeUpdate() { diff --git a/contrib/gnome-shell/window-geometry@displayxr.org/metadata.json b/contrib/gnome-shell/window-geometry@displayxr.org/metadata.json index 012db44ff..cd555bf3f 100644 --- a/contrib/gnome-shell/window-geometry@displayxr.org/metadata.json +++ b/contrib/gnome-shell/window-geometry@displayxr.org/metadata.json @@ -2,7 +2,7 @@ "uuid": "window-geometry@displayxr.org", "name": "DisplayXR Window Geometry", "description": "Publishes per-window global geometry over the session D-Bus so the DisplayXR runtime can anchor the lenticular interlacing phase to a window's panel position under Wayland (windowed weaving), and lets a process exclude its own windows from off-screen stage paints (ScreenCast RecordArea, screenshots) — the GNOME equivalent of WDA_EXCLUDEFROMCAPTURE, so a transparent 3D window can capture the desktop behind itself. Wayland clients cannot know their own absolute position or hide from capture; the compositor can. See displayxr-runtime#817.", - "version": 11, + "version": 12, "shell-version": ["42", "43", "44", "45", "46", "47", "48", "49", "50"], "url": "https://github.com/DisplayXR/displayxr-runtime" } diff --git a/docs/specs/runtime/wayland-window-geometry.md b/docs/specs/runtime/wayland-window-geometry.md index 7a1294db1..f77f36ddd 100644 --- a/docs/specs/runtime/wayland-window-geometry.md +++ b/docs/specs/runtime/wayland-window-geometry.md @@ -959,6 +959,24 @@ The encoding is `comp_vk_native_wl_move_sync_encode` (pinned by `MoveSyncChoice.decode` (pinned by `scripts/test_gnome_extension_move_sync.js` against a transcription of the same function). +The extension reads the tag off the window's surface actors (`_readTag`): the +1x1 actor is the tag, and the **main surface** is the actor whose content is +the window's texture — `MetaWindowActor.get_texture()` is the main surface's +`MetaShapedTexture`, and mutter sets that same object as the surface actor's +content, so the pairing is structural: the toplevel's own surface, the one the +tag is a subsurface of (runtime and present-owner alike, §9.8). The tag is +decoded as its position minus the main surface's, both relative to the window +actor (`MoveSyncChoice.pick`, extension version 12, pinned by the same test). +Versions 9–11 took the **largest** surface actor instead. Under libdecor-gtk — +GLFW 3.4's and SDL2/SDL3's stock decorations on GNOME — the shadow subsurface +is larger than the content, so every tag decoded against the shadow's origin, +no frame ever resolved against the window's history, and the hold timed out on +every title-bar drag: stale frames and visible 3D stutter, found on the LeiaSR +OpenGL example (the same drag was smooth with libdecor disabled). An app that +draws its own chrome in a smaller subsurface, as the test apps and the browser +do, was never affected. Without a texture match (none handed out, or no actor +carrying it — not seen on mutter 45–50) the largest actor still stands in. + ### 9.3 Placement rules (`MoveSyncChoice` in `lib.js`) The extension resolves the tag against the positions the window had in the @@ -1147,17 +1165,20 @@ extension) it should keep the tag mapped, or accept the hitch. tags nothing); `DXR_WL_TEST_TAG=toggle:ON_MS:OFF_MS` maps it for `ON_MS`, then unmaps it for `OFF_MS`, repeating. The registration is unchanged. -**Decoding against the parent surface: not possible from GJS on GNOME 50.** -The tag is decoded as its offset from the largest surface actor of the window -(§9.2). The parent surface would be the exact reference, but mutter 50 -flattens a window's surface actors into siblings under one -`MetaSurfaceContainerActorWayland` (the tag's Clutter parent is the -container, not its parent surface), `MetaSurfaceActorWayland` exposes no -surface accessor or property to GObject introspection, and `Meta.WaylandSurface` -exposes only `get_window()`. Only the sibling order hints at the parent (a -`place_below` tag sits just before it), which a client may reorder. The -largest-surface rule stays; a present-owner keeps its tag a subsurface of the -surface that carries the woven content, and that surface the largest. +**Decoding against the parent surface: not possible from GJS on GNOME 50 — +the main surface is found by its texture instead (version 12).** The parent +surface would be the exact reference, but mutter 50 flattens a window's +surface actors into siblings under one `MetaSurfaceContainerActorWayland` (the +tag's Clutter parent is the container, not its parent surface), +`MetaSurfaceActorWayland` exposes no surface accessor or property to GObject +introspection, and `Meta.WaylandSurface` exposes only `get_window()`. Only the +sibling order hints at the parent (a `place_below` tag sits just before it), +which a client may reorder. What *is* reachable is the toplevel's own surface: +`MetaWindowActor.get_texture()` returns its `MetaShapedTexture`, which is that +surface actor's content (§9.2). So a present-owner keeps its tag a subsurface +of the toplevel's (root) surface — the browser's root surface already is — and +that surface no longer has to be the largest; versions 9–11 decoded against +the largest surface, which a client-side shadow bigger than the content broke. **Measured** (the §9.7 harness, private headless GNOME Shell 50.1, `cube_handle_vk_linux` on sim_display only, stamp audit after every paint; diff --git a/scripts/test_gnome_extension_move_sync.js b/scripts/test_gnome_extension_move_sync.js index 15e39d596..3d6dc27ea 100644 --- a/scripts/test_gnome_extension_move_sync.js +++ b/scripts/test_gnome_extension_move_sync.js @@ -28,6 +28,10 @@ // frame, shown where it was woven for; a tag going away releases at once // to the window; toggling; and an always-tagged app is placed exactly // as without the gate. +// 8. The surface pick (version 12): the main surface is the actor carrying +// the window's texture, not the largest one — under libdecor-gtk the +// shadow is larger than the content and the old rule decoded every tag +// against it; the size rule remains the fallback without a texture. 'use strict'; const GLib = imports.gi.GLib; @@ -294,6 +298,46 @@ print('7. the tag gate (version 10)'); } } +print('8. surface pick (version 12): the main surface by its texture, not by size'); +{ + const T = {name: 'main texture'}, S = {name: 'shadow texture'}, B = {name: 'bar texture'}; + const G = {name: 'tag texture'}; + // An app drawing its own chrome (the test apps, the browser): the content + // surface, a smaller title bar above it, the tag below it. + const own = [{w: 1200, h: 900, content: T}, {w: 1200, h: 40, content: B}, {w: 1, h: 1, content: G}]; + let r = MS.pick(own, T); + check(r.main === 0 && r.tag === 2 && r.byTexture, 'own chrome: the content is main (by texture), the 1x1 the tag'); + r = MS.pick(own, null); + check(r.main === 0 && r.tag === 2 && !r.byTexture, 'own chrome, no texture: the largest is main (the version 9-11 rule)'); + // libdecor-gtk (GLFW 3.4 / SDL stock decorations): a shadow subsurface + // LARGER than the content, a title bar, the content, the tag. + const decorated = [{w: 1240, h: 980, content: S}, {w: 1200, h: 40, content: B}, + {w: 1200, h: 900, content: T}, {w: 1, h: 1, content: G}]; + r = MS.pick(decorated, T); + check(r.main === 2 && r.tag === 3 && r.byTexture, 'libdecor: the content is main, not the larger shadow'); + r = MS.pick(decorated, null); + check(r.main === 0 && !r.byTexture, 'libdecor, no texture: the shadow wins by size (what versions 9-11 did)'); + r = MS.pick(decorated, {name: 'a texture no actor carries'}); + check(r.main === 0 && !r.byTexture, 'texture carried by no actor: falls back to size'); + check(MS.pick([{w: 1200, h: 900, content: T}], T).tag === -1, 'no 1x1 actor: no tag'); + check(MS.pick([{w: 1, h: 1, content: G}], T).main === -1, 'only a tag: no main'); + check(MS.pick([{w: 1, h: 1, content: G}, {w: 1200, h: 900, content: T}], T).main === 1, + 'a 1x1 actor is never the main surface, whatever it carries'); + // End to end at the libdecor layout, positions relative to the window + // actor: shadow at (-20,-20), bar at (0,-40), content at (0,0), the tag + // at content + encode(woven). + const woven = [1234, -77]; + const pos = [[-20, -20], [0, -40], [0, 0], [cEncode(woven[0]), cEncode(woven[1])]]; + r = MS.pick(decorated, T); + let d = MS.decode(pos[r.tag][0], pos[r.tag][1], pos[r.main][0], pos[r.main][1]); + check(d[0] === cEncode(woven[0]) && d[1] === cEncode(woven[1]), + 'libdecor: the tag decodes to the woven position against the content'); + const old = MS.pick(decorated, null); + d = MS.decode(pos[r.tag][0], pos[r.tag][1], pos[old.main][0], pos[old.main][1]); + check(d[0] === cEncode(woven[0] + 20) && d[1] === cEncode(woven[1] + 20), + 'libdecor: against the shadow it decoded 20 px off (the version 9-11 bug)'); +} + if (failed) { print(`test_gnome_extension_move_sync: ${failed} check(s) FAILED`); imports.system.exit(1);