Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions contrib/gnome-shell/window-geometry@displayxr.org/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
88 changes: 70 additions & 18 deletions contrib/gnome-shell/window-geometry@displayxr.org/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
43 changes: 32 additions & 11 deletions docs/specs/runtime/wayland-window-geometry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions scripts/test_gnome_extension_move_sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading