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: 7 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ jobs:
# file; no GNOME Shell needed.
run: gjs scripts/test_gnome_extension_pointer_drag.js

- name: Stage space follows mutter's layout mode
# Unit test for StageScale in lib.js (extension version 11): the
# snapshot's layout_mode + monitor.device_scale, read from the stage
# view scales. In mutter's PHYSICAL layout the stage is device px and
# the monitor scale must not be applied. Plain gjs.
run: gjs scripts/test_gnome_extension_stage_scale.js

cnsdk-pin-guard:
runs-on: ubuntu-latest
steps:
Expand Down
13 changes: 13 additions & 0 deletions contrib/gnome-shell/window-geometry@displayxr.org/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,19 @@ systemctl --user unset-environment DISPLAYXR_DEBUG # then log out/in again
hold then; a tag going away ends it on that frame, the actor snapping to the
window. An app that always keeps its tag mapped (every in-process DisplayXR
app) behaves exactly as with version 9. See the spec, §9.9.
- Version 11, no interface change: the snapshot says **which space its
coordinates are in**. mutter has two monitor layout modes. In LOGICAL
(fractional scaling; mutter 50's default) the stage is logical px. In PHYSICAL
(Ubuntu 24.04 / GNOME 46 at an integer scale, out of the box) the stage
*is* device px. There `monitor.scale` is 2 for a 3840x2160 monitor at 200 %,
and its rect is still 3840x2160. The snapshot gains a top-level
`layout_mode` (`"logical"` / `"physical"`) and `monitor.device_scale`, the
factor that converts stage px to device px (the stage view's scale: the
monitor scale in LOGICAL, 1 in PHYSICAL). A consumer must convert by
`device_scale`, never by `scale`. Both are left out when they cannot be told
(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.

Verify capture exclusion is live:

Expand Down
124 changes: 108 additions & 16 deletions contrib/gnome-shell/window-geometry@displayxr.org/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@
// JSON schema (version 1):
// {
// "version": 1,
// "layout_mode": "logical", // ext v11+: mutter's layout mode,
// // "logical" | "physical"; absent
// // when it cannot be told (every
// // monitor at scale 1: both agree)
// "windows": [
// {
// "pid": 1234,
Expand All @@ -134,7 +138,11 @@
// "xwayland": false,
// "frame": [x, y, w, h], // Meta.Window.get_frame_rect()
// "buffer": [x, y, w, h], // Meta.Window.get_buffer_rect()
// "monitor": { "x": 0, "y": 0, "w": 3840, "h": 2160, "scale": 1.0 },
// "monitor": { "x": 0, "y": 0, "w": 3840, "h": 2160, "scale": 1.0,
// "device_scale": 1.0 }, // ext v11+: device px per
// // stage px on this monitor (the
// // stage view's scale). Absent
// // when it cannot be told.
// "capture_excluded": false, // ext v2+: CaptureExclusion1 active
// "lattice_drop": false, // ext v7+: the last drag ended ON
// // the drag lattice and the window
Expand All @@ -152,12 +160,22 @@
// ]
// }
//
// Coordinates are Mutter's global (stage) coordinates — logical pixels. At
// monitor scale 1.0 (the only mode windowed weaving supports anyway) these are
// physical desktop pixels, the same space X11's root coordinates live in. The
// runtime anchors to "buffer" (the main surface — where its pixels land) and
// falls back to "frame" (the window geometry, which includes a client-side
// title bar) only when "buffer" is absent (displayxr-runtime#1654).
// Coordinates are Mutter's global (stage) coordinates. Which space that is
// depends on mutter's layout mode (extension version 11 says which):
// * LOGICAL (fractional scaling, mutter 50's default): logical px; device
// px = stage px x the monitor scale.
// * PHYSICAL (Ubuntu 24.04 / GNOME 46 at an integer scale): the stage IS
// device px, and "scale" only says how big clients draw. A
// 3840x2160 monitor at 200 % is a 3840x2160 rect with scale 2.
// So "scale" is NOT the conversion factor; "device_scale" is. A consumer that
// multiplies by "scale" reads that monitor as 7680x4320. Every
// field kept its meaning ("stage coordinates", "mutter's monitor scale"), so
// the additions are additive and the schema stays version 1; a consumer
// without them should ask mutter (org.gnome.Mutter.DisplayConfig
// GetCurrentState "layout-mode": 1 logical, 2 physical). The runtime anchors
// to "buffer" (the main surface — where its pixels land) and falls back to
// "frame" (the window geometry, which includes a client-side title bar) only
// when "buffer" is absent (displayxr-runtime#1654).
//
// ── Portability rules for edits to this file ────────────────────────────────
//
Expand Down Expand Up @@ -547,6 +565,46 @@
},
};

/*
* ── Which space the stage is in (extension version 11) ──
*
* Pure logic, no GI: scripts/test_gnome_extension_stage_scale.js drives it
* under plain gjs.
*
* mutter has two monitor layout modes. In LOGICAL the stage is logical px
* and each stage view (one per CRTC) is painted at its monitor's scale; in
* PHYSICAL the stage is device px and every view's scale is 1, whatever
* the monitor scale says. The layout mode itself is not introspected, but
* the view scales give it away: any view scale other than 1 means
* LOGICAL; every view at 1 while some monitor is scaled means PHYSICAL;
* with every monitor at 1 the two agree and nothing needs telling.
*/
const StageScale = {
EPS: 1e-4,

//! 'logical' | 'physical' | null (cannot be told, or need not be).
layoutMode(viewScales, monitorScales) {
const off1 = v => Math.abs(v - 1) > StageScale.EPS;
if (!viewScales || viewScales.length === 0)
return null;
if (viewScales.some(off1))
return 'logical';
if ((monitorScales ?? []).some(off1))
return 'physical';
return null;
},

//! Device px per stage px on a monitor at @p monitorScale, or null
//! when the layout mode is not known and the monitor is scaled.
deviceScale(layout, monitorScale) {
if (layout === 'physical')
return 1;
if (layout === 'logical' || Math.abs(monitorScale - 1) <= StageScale.EPS)
return monitorScale;
return null;
},
};

globalThis.displayxrWindowGeometry = {
//! gi: {Clutter, GObject, Meta, Gio, GLib} — however the caller's
//! shell spells the import. Returns {WindowGeometryService}.
Expand All @@ -561,9 +619,34 @@
MoveSyncChoice,
//! The pointer drag's pure logic, exported for its unit test.
PointerDrag,
//! The stage-space resolution, exported for its unit test.
StageScale,
};

function buildModule({Clutter, GObject, Meta, Gio, GLib, Graphene, Mtk, GdkPixbuf, moveSync = true}) {
//! mutter's layout mode right now (StageScale.layoutMode over the
//! stage views and monitor scales). Cheap: a handful of views.
function currentLayoutMode() {
const display = global.display;
const monitorScales = [];
for (let i = 0; i < display.get_n_monitors(); i++)
monitorScales.push(display.get_monitor_scale(i));
let viewScales = null;
try {
viewScales = global.stage.peek_stage_views().map(v => v.get_scale());
} catch (e) {
viewScales = null; // a shell without the API: say nothing
}
return StageScale.layoutMode(viewScales, monitorScales);
}

//! Device px per stage px on monitor @p mon (falls back to the monitor
//! scale, the pre-version-11 assumption, when it cannot be told).
function deviceScaleOf(mon, layout = currentLayoutMode()) {
const monitorScale = global.display.get_monitor_scale(mon);
return StageScale.deviceScale(layout, monitorScale) ?? monitorScale;
}

const IFACE_XML = `
<node>
<interface name="org.displayxr.WindowGeometry1">
Expand Down Expand Up @@ -1092,10 +1175,11 @@
const r = win.get_frame_rect();
const dx = r.x - t.startX, dy = r.y - t.startY;
const inside = dx >= t.minDx && dx <= t.maxDx && dy >= t.minDy && dy <= t.maxDy;
// Device px per logical px where the window is now: the
// choice weighs its errors in device px (LatticeChoice).
// Device px per stage px where the window is now: the
// choice weighs its errors in device px (LatticeChoice). 1 in
// mutter's PHYSICAL layout, whatever the monitor scale.
const mon = win.get_monitor();
const scale = mon >= 0 ? win.get_display().get_monitor_scale(mon) : 1;
const scale = mon >= 0 ? deviceScaleOf(mon) : 1;
LatticeChoice.observe(t.choice, dx, dy, scale);
const best = inside
? LatticeChoice.choose(t.choice, this._candidates(t, dx, dy), dx, dy, scale,
Expand Down Expand Up @@ -1849,7 +1933,7 @@
if (mon < 0)
return;
const g = global.display.get_monitor_geometry(mon);
const scale = global.display.get_monitor_scale(mon);
const scale = deviceScaleOf(mon); // device px per stage px
const cw = Math.ceil(STAMP_BLOCK * STAMP_BLOCKS / scale) + 2;
const chh = Math.ceil(STAMP_BLOCK / scale) + 1;
const clip = new Mtk.Rectangle({x: 0, y: 0, width: cw, height: chh});
Expand Down Expand Up @@ -2419,6 +2503,7 @@
const display = global.display;
const focus = display.focus_window;
const windows = [];
const layout = currentLayoutMode();
for (const actor of global.get_window_actors()) {
const win = actor.meta_window;
if (!win || win.get_window_type() !== Meta.WindowType.NORMAL)
Expand All @@ -2429,10 +2514,13 @@
let monitor = null;
if (mon >= 0) {
const g = display.get_monitor_geometry(mon);
monitor = {
x: g.x, y: g.y, w: g.width, h: g.height,
scale: display.get_monitor_scale(mon),
};
const scale = display.get_monitor_scale(mon);
monitor = {x: g.x, y: g.y, w: g.width, h: g.height, scale};
// v11: the factor a consumer converts by. Absent when
// the layout mode cannot be told.
const deviceScale = StageScale.deviceScale(layout, scale);
if (deviceScale !== null)
monitor.device_scale = deviceScale;
}
windows.push({
pid: win.get_pid(),
Expand All @@ -2449,7 +2537,11 @@
moving: win === this._grabbedWindow,
});
}
return JSON.stringify({version: 1, windows});
const snapshot = {version: 1};
if (layout !== null)
snapshot.layout_mode = layout;
snapshot.windows = windows;
return JSON.stringify(snapshot);
}

GetWindows() {
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": 10,
"version": 11,
"shell-version": ["42", "43", "44", "45", "46", "47", "48", "49", "50"],
"url": "https://github.com/DisplayXR/displayxr-runtime"
}
59 changes: 59 additions & 0 deletions docs/specs/runtime/wayland-window-geometry.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ GNOME Shell (Mutter) DisplayXR runtime process
v1; before #1596 the consumer simply never read four of them. Mutter omits
the `monitor` object only for a window on no monitor.

**`scale` is the factor only in Mutter's LOGICAL layout mode** (extension
version 11, §4.1). "Logical" above means Mutter's *stage* coordinates, and
in Mutter's PHYSICAL layout mode the stage is device px: a 3840x2160
monitor at 200 % is published as `{w: 3840, h: 2160, scale: 2}`. Version 11
therefore adds a top-level `layout_mode` (`"logical"` / `"physical"`) and
`monitor.device_scale`, the device-px-per-stage-px factor (the scale of the
stage view painting the monitor). The consumer converts by `device_scale`,
then by `layout_mode`, then, for an older publisher, by asking Mutter
(`org.gnome.Mutter.DisplayConfig.GetCurrentState` → `layout-mode`: 1
logical, 2 physical). It falls back to `scale` only when none of those
answers (`u_wl_stage_to_device_scale`).

- **Consumer** — `comp_vk_native_wl_geom` (`src/xrt/compositor/vk_native/`,
built when `XRT_HAVE_WAYLAND && XRT_HAVE_DBUS`, libdbus-1). Private
session-bus connection; one blocking `GetWindows` at create (200 ms cap),
Expand All @@ -124,6 +136,7 @@ GNOME Shell (Mutter) DisplayXR runtime process
| Extension not installed/enabled | Snapshot empty; bounded retry every 5 s; display-scoped until it appears |
| No window matches our PID | `get_window_metrics` invalid; display-scoped |
| Monitor scale ≠ 1.0 | **Converted**, not refused (#1596): the rect is multiplied to device pixels using `monitor` + its fractional `scale`, one INFO naming both spaces. Weaving proceeds with a real phase |
| Mutter in its PHYSICAL layout mode (stage = device px; Ubuntu 24.04 / GNOME 46 at an integer scale) | Factor 1, not the monitor scale (§4.1). One WARN naming the source (`device_scale`, `layout_mode`, or Mutter's own `layout-mode` for a publisher older than version 11). Before this, a 3840x2160 panel at 200 % read as 7680x4320, so the window was "not on the panel" and presented flat 2D |
| Payload carries no `monitor` object | Rect **refused**, one WARN; display-scoped. Without it there is no scale to apply and no way to name the space the rect is in. Mutter omits it only for a window on no monitor |
| The window's monitor is not the 3D panel | Rect **refused**, one WARN; display-scoped — *and* the weave itself degrades to flat 2D (#1595), because a surface on another output cannot be 1:1 with this panel by any phase |
| The presented buffer ≠ the window's device extent | Geometry is still valid and still served; the **weave** degrades to flat 2D (#1595), one `NOT_1TO1:` WARN, and the present-origin feed is suppressed so no stale phase stays latched |
Expand Down Expand Up @@ -262,6 +275,52 @@ Consumers refuse a payload whose `version` exceeds what they understand
(`WLG_SCHEMA_VERSION_MAX`) and fall back to display-scoped rather than weave at
a silently wrong phase; a payload with no `version` is treated as v1.

### 4.1 Worked example: Mutter's layout mode (extension version 11)

The clause above says the wire is LOGICAL. That was true only on the desktops
it had been measured on. The wire is Mutter's **stage** coordinates, and Mutter
has two layout modes:

| `layout-mode` | when | stage space | 3840x2160 monitor at 200 % |
|---|---|---|---|
| 1 LOGICAL | fractional scaling enabled; mutter 50's default | logical px | rect 1920x1080, scale 2, stage view scale 2 |
| 2 PHYSICAL | Ubuntu 24.04 / GNOME 46 at an integer scale, out of the box | device px | rect 3840x2160, scale 2, stage view scale 1 |

(Measured on a headless mutter 50.1, switching only `gdctl set --layout-mode`.)
In PHYSICAL the monitor scale says how big clients draw. It is not a
coordinate factor, so a consumer that multiplied by it read the panel as a
7680x4320 output. The window was then never "on the panel": `NOT_1TO1`, flat
2D, lens off.

Version 11 fixes this **additively**. No field changes meaning: `frame`,
`buffer` and `monitor.{x,y,w,h}` remain stage coordinates, and `scale` remains
`get_monitor_scale()`. What is new is two fields that name the stage's space:

- top-level `layout_mode`: `"logical"` / `"physical"`, left out when it
cannot be told. With every monitor at scale 1 the two modes agree.
- `monitor.device_scale`: device px per stage px. This is the scale of the
stage view painting the monitor (`Clutter.StageView.get_scale()`), so it is
the factor in either mode.

Mutter does not introspect its layout mode. The view scales give it away:
any view scale other than 1 means LOGICAL, and every view at 1 while some
monitor is scaled means PHYSICAL (`StageScale` in `lib.js`, pinned by
`scripts/test_gnome_extension_stage_scale.js`). The schema stays `version: 1`.
Bumping it would make every shipped consumer refuse a payload that is still
correct for it on every LOGICAL desktop. A consumer without the new fields
asks Mutter for `layout-mode` instead, as the runtime does for a publisher
older than version 11 (`wlg_query_mutter_layout_mode`). That case is not
hypothetical: after a package upgrade, the running shell keeps the old
extension until the user logs out.

Everything else in the extension already worked in stage coordinates, so it
follows the mode with no change: `MoveWindow`, the pointer drag, and the
move-sync history. The move-sync tag is read from subsurface actor positions,
and those are in *surface* units in both modes. Measured in PHYSICAL at
200 %: the tag actor sits at `(x mod 256, y mod 256)` of the buffer's stage
position, exactly as the runtime set it. The drag-lattice choice and the stamp
audit weigh device px, and now use `device_scale`.

**No reverse dependency.** The extension must remain pure GNOME Shell JS with
no import from, or runtime dependency on, DisplayXR or any vendor stack — that
is what lets any package ship it and any runtime consume it.
Expand Down
Loading
Loading