From 0e4029755aab7bea779286c4241734917daf734d Mon Sep 17 00:00:00 2001 From: David Date: Wed, 7 Oct 2026 23:12:35 -0700 Subject: [PATCH] fix(gnome-shell): move-sync tag decodes against the main surface by its texture, not the largest actor (extension v12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _readTag took the LARGEST surface actor of the window as the main surface and decoded the 1x1 tag as its offset from it. 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 position history, and the hold timed out on every title-bar drag: stale frames and visible 3D stutter. Found by the LeiaSR session on its OpenGL example (SR PR 447 hardware pass); the same drag was smooth with libdecor disabled. Apps that draw their own chrome (the test apps, the browser) never hit it. The main surface is now found structurally: MetaWindowActor.get_texture() is the toplevel surface's MetaShapedTexture, and mutter sets that same object as the surface actor's content, so the actor whose content is the texture is the toplevel's own surface — the one the tag is a subsurface of for the runtime and for the browser's present-owner tag alike. The pick is a pure function (MoveSyncChoice.pick), unit-tested with the libdecor layout; the size rule remains the fallback without a texture (the 9-11 behaviour). Positions are summed down the actor tree so a nested tree decodes the same as mutter 45-50's flat one. A debug log names which rule applied. Verified on the §9.7 headless GNOME Shell 50.1 harness (scale 2, slow_h / fast_h / reverse): "main surface found by its texture", 0 mismatch, 0 timeouts, every drag settled. The libdecor case itself needs a hardware pass with a GLFW/SDL app after the extension is reloaded (login cycle). Spec §9.2 / §9.8 and the README version log updated; lint comment too. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/lint.yml | 7 +- .../window-geometry@displayxr.org/README.md | 9 ++ .../window-geometry@displayxr.org/lib.js | 88 +++++++++++++++---- .../metadata.json | 2 +- docs/specs/runtime/wayland-window-geometry.md | 43 ++++++--- scripts/test_gnome_extension_move_sync.js | 44 ++++++++++ 6 files changed, 160 insertions(+), 33 deletions(-) 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);