diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1800fed72..2d18d6505 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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: diff --git a/contrib/gnome-shell/window-geometry@displayxr.org/README.md b/contrib/gnome-shell/window-geometry@displayxr.org/README.md index fdd04ceb3..a12c477d2 100644 --- a/contrib/gnome-shell/window-geometry@displayxr.org/README.md +++ b/contrib/gnome-shell/window-geometry@displayxr.org/README.md @@ -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: diff --git a/contrib/gnome-shell/window-geometry@displayxr.org/lib.js b/contrib/gnome-shell/window-geometry@displayxr.org/lib.js index 8e1ea30c6..a76db7d73 100644 --- a/contrib/gnome-shell/window-geometry@displayxr.org/lib.js +++ b/contrib/gnome-shell/window-geometry@displayxr.org/lib.js @@ -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, @@ -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 @@ -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 ──────────────────────────────── // @@ -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}. @@ -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 = ` @@ -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, @@ -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}); @@ -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) @@ -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(), @@ -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() { diff --git a/contrib/gnome-shell/window-geometry@displayxr.org/metadata.json b/contrib/gnome-shell/window-geometry@displayxr.org/metadata.json index e8652d359..012db44ff 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": 10, + "version": 11, "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 5f919f839..af6fba447 100644 --- a/docs/specs/runtime/wayland-window-geometry.md +++ b/docs/specs/runtime/wayland-window-geometry.md @@ -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), @@ -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 | @@ -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. diff --git a/scripts/test_gnome_extension_stage_scale.js b/scripts/test_gnome_extension_stage_scale.js new file mode 100644 index 000000000..ed548d908 --- /dev/null +++ b/scripts/test_gnome_extension_stage_scale.js @@ -0,0 +1,98 @@ +// Copyright 2026, The DisplayXR Project +// SPDX-License-Identifier: BSL-1.0 +// +// Unit test for the GNOME Shell extension's stage-space resolution +// (StageScale in contrib/gnome-shell/window-geometry@displayxr.org/lib.js, +// extension version 11). +// +// Hermetic: plain gjs, no GNOME Shell. StageScale lives outside lib.js's +// GI-dependent build(), so this drives the shipped code directly. +// +// gjs scripts/test_gnome_extension_stage_scale.js +// +// The inputs are measured on a headless mutter 50.1 with one 3840x2160 +// virtual monitor, switching the layout mode with `gdctl set --layout-mode`: +// +// layout scale monitor geometry stage view scale +// physical 2 3840x2160 1 +// logical 2 1920x1080 2 +// logical 1.5 2560x1440 1.5 +// physical 1 3840x2160 1 +// +// What it checks: +// 1. The layout mode is read from the view scales: any view scale != 1 is +// LOGICAL, every view at 1 with a scaled monitor is PHYSICAL, and with +// every monitor at 1 (or no view information) nothing is claimed. +// 2. The device scale (device px per stage px): 1 in PHYSICAL whatever the +// monitor scale, the monitor scale in LOGICAL, and nothing for a scaled +// monitor when the mode is unknown (the consumer then asks mutter). +// 3. The measured monitors convert to the panel's 3840x2160 in every row. +'use strict'; + +const GLib = imports.gi.GLib; + +const here = GLib.path_get_dirname(imports.system.programPath ?? imports.system.programInvocationName); +imports.searchPath.unshift(GLib.build_filenamev([here, '..', 'contrib', 'gnome-shell', + 'window-geometry@displayxr.org'])); +imports.lib; // side effect: globalThis.displayxrWindowGeometry +const SS = globalThis.displayxrWindowGeometry.StageScale; + +let failed = 0; +function check(cond, what) { + if (cond) { + print(` ok ${what}`); + } else { + print(` FAIL ${what}`); + failed++; + } +} + +print('1. layout mode from the stage views'); +{ + check(SS.layoutMode([1], [2]) === 'physical', 'scale 2, view at 1 -> physical (the reported box)'); + check(SS.layoutMode([2], [2]) === 'logical', 'scale 2, view at 2 -> logical'); + check(SS.layoutMode([1.5], [1.5]) === 'logical', 'scale 1.5, view at 1.5 -> logical'); + check(SS.layoutMode([1, 2], [1, 2]) === 'logical', 'mixed 1 + 2, views 1 + 2 -> logical'); + check(SS.layoutMode([1, 1], [1, 2]) === 'physical', 'mixed 1 + 2, views both 1 -> physical'); + check(SS.layoutMode([1], [1]) === null, 'every monitor at 1 -> not claimed (both modes agree)'); + check(SS.layoutMode([], [2]) === null, 'no views -> not claimed'); + check(SS.layoutMode(null, [2]) === null, 'no view API -> not claimed'); + check(SS.layoutMode([1.00001], [2]) === 'physical', 'float noise on a view scale is ignored'); +} + +print('2. device px per stage px'); +{ + check(SS.deviceScale('physical', 2) === 1, 'physical, scale 2 -> 1'); + check(SS.deviceScale('physical', 1.5) === 1, 'physical, any scale -> 1'); + check(SS.deviceScale('logical', 2) === 2, 'logical, scale 2 -> 2'); + check(SS.deviceScale('logical', 1.25) === 1.25, 'logical, scale 1.25 -> 1.25'); + check(SS.deviceScale(null, 1) === 1, 'unknown, scale 1 -> 1 (true in both modes)'); + check(SS.deviceScale(null, 2) === null, 'unknown, scale 2 -> not published'); +} + +print('3. the measured monitors are 3840x2160 device px in every mode'); +{ + const rows = [ + {layout: 'physical', scale: 2, w: 3840, h: 2160, views: [1]}, + {layout: 'logical', scale: 2, w: 1920, h: 1080, views: [2]}, + {layout: 'logical', scale: 1.5, w: 2560, h: 1440, views: [1.5]}, + {layout: 'physical', scale: 1, w: 3840, h: 2160, views: [1]}, + ]; + for (const r of rows) { + const mode = SS.layoutMode(r.views, [r.scale]); + const k = SS.deviceScale(mode, r.scale); + check(k !== null && Math.round(r.w * k) === 3840 && Math.round(r.h * k) === 2160, + `${r.layout} at ${r.scale}: ${r.w}x${r.h} stage -> 3840x2160 device`); + check(Math.round(r.w * r.scale) === 3840 || r.layout === 'physical', + `${r.layout} at ${r.scale}: the monitor scale alone is right only in logical`); + } + // The reported failure, pinned: physical at 2 multiplied by the monitor + // scale is 7680x4320. + check(3840 * 2 === 7680, 'physical at 2 x monitor scale = 7680 (what a scale-multiplying consumer reads)'); +} + +if (failed) { + print(`test_gnome_extension_stage_scale: ${failed} check(s) FAILED`); + imports.system.exit(1); +} +print('test_gnome_extension_stage_scale: all checks passed'); diff --git a/src/xrt/auxiliary/util/u_wayland_layout.h b/src/xrt/auxiliary/util/u_wayland_layout.h new file mode 100644 index 000000000..1293e02fd --- /dev/null +++ b/src/xrt/auxiliary/util/u_wayland_layout.h @@ -0,0 +1,133 @@ +// Copyright 2026, The DisplayXR Project +// SPDX-License-Identifier: BSL-1.0 +/*! + * @file + * @brief Which space Mutter's stage coordinates are in: the factor that turns + * them into device pixels. + * @ingroup aux_util + * + * ## Mutter's two layout modes: the monitor scale is not always the factor + * + * @ref u_wayland_geom.h converts "logical" rects by a monitor's scale. For a + * stage-coordinate publisher (the `window-geometry@displayxr.org` GNOME Shell + * extension) "logical" means Mutter's STAGE coordinates, and Mutter has two + * layout modes that put the stage in different spaces: + * + * - LOGICAL (`layout-mode` 1): the stage is in logical px. A 3840x2160 + * monitor at scale 2 is a 1920x1080 stage rect, and device px are stage px + * x the monitor scale. Every fractional-scaling desktop is in this mode + * (older GNOME enables it with the `scale-monitor-framebuffer` + * experimental feature; mutter 50 starts in it by default). + * - PHYSICAL (`layout-mode` 2): the stage is in DEVICE px. The same monitor + * at scale 2 is a 3840x2160 stage rect, and the scale only says how big + * clients draw. Ubuntu 24.04 / GNOME 46 at an integer scale, out of the + * box. + * + * Multiplying stage coordinates by the monitor scale is right in the first and + * doubles everything in the second: a window on a 3840x2160 panel at 200 % + * reads as 7680x4320 on a 7680x4320 output, so it is "not on the panel" and + * never weaves. The factor is therefore resolved per layout mode — + * @ref u_wl_stage_to_device_scale — and THAT is what goes into + * `u_wl_monitor::scale` for a stage-coordinate publisher. + * + * A client's own `wl_output.mode / xdg_output.logical_size` needs no such + * care: Mutter reports the stage size as the logical size in both modes, so + * that ratio already is the stage factor (measured: 1.0 in PHYSICAL at 200 %). + * + * Separate from u_wayland_geom.h on purpose: that header is shared byte-for-byte + * with displayxr-common (test_apps/common/dxr_linux_window_aux_guard.cmake), and + * this rule is the runtime's to apply at its boundary. + * + * Pure arithmetic, no Wayland or D-Bus dependency, no platform guard. Pinned by + * `tests/tests_aux_wayland_geom.cpp`. + */ + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/*! + * Mutter's monitor layout mode: which space its stage coordinates are in (see + * the file comment). The values are Mutter's own `layout-mode` + * (org.gnome.Mutter.DisplayConfig.GetCurrentState). + * + * @ingroup aux_util + */ +enum u_wl_layout_mode +{ + //! Not known: no publisher said, and no compositor answered. + U_WL_LAYOUT_MODE_UNKNOWN = 0, + //! Stage = logical px; device = stage x the monitor scale. + U_WL_LAYOUT_MODE_LOGICAL = 1, + //! Stage = device px, whatever the monitor scale. + U_WL_LAYOUT_MODE_PHYSICAL = 2, +}; + +/*! + * The layout mode as the `window-geometry@displayxr.org` payload spells it + * (`"layout_mode": "logical" | "physical"`). Anything else is UNKNOWN. + * + * @ingroup aux_util + */ +static inline enum u_wl_layout_mode +u_wl_layout_mode_from_string(const char *s) +{ + if (s == NULL) { + return U_WL_LAYOUT_MODE_UNKNOWN; + } + // No needed; the two spellings are short. + const char *logical = "logical"; + const char *physical = "physical"; + size_t i = 0; + for (i = 0; s[i] != '\0' && s[i] == logical[i]; i++) { + } + if (s[i] == '\0' && logical[i] == '\0') { + return U_WL_LAYOUT_MODE_LOGICAL; + } + for (i = 0; s[i] != '\0' && s[i] == physical[i]; i++) { + } + if (s[i] == '\0' && physical[i] == '\0') { + return U_WL_LAYOUT_MODE_PHYSICAL; + } + return U_WL_LAYOUT_MODE_UNKNOWN; +} + +/*! + * Device px per STAGE px on one monitor: the factor that converts a + * stage-coordinate publisher's rects (the GNOME Shell extension's) to device + * pixels. + * + * In order of authority: + * 1. @p published_device_scale, when the publisher states it (extension + * version 11+: the scale of the stage view painting that monitor, which + * IS the factor in either layout mode); + * 2. PHYSICAL layout: 1.0 — the stage already is device px, and the + * monitor scale must NOT be applied; + * 3. otherwise (LOGICAL, or not known): @p monitor_scale. Unknown keeps the + * behaviour every earlier consumer had, which is right on every + * fractionally-scaled desktop (those are always LOGICAL). + * + * @return the factor, or 0.0 when none of the three is usable. + * + * @ingroup aux_util + */ +static inline double +u_wl_stage_to_device_scale(double monitor_scale, double published_device_scale, enum u_wl_layout_mode mode) +{ + if (published_device_scale > 0.0) { + return published_device_scale; + } + if (mode == U_WL_LAYOUT_MODE_PHYSICAL) { + return 1.0; + } + return monitor_scale > 0.0 ? monitor_scale : 0.0; +} + +#ifdef __cplusplus +} +#endif diff --git a/src/xrt/compositor/vk_native/comp_vk_native_wl_geom.c b/src/xrt/compositor/vk_native/comp_vk_native_wl_geom.c index a1c13c423..62cd4d89b 100644 --- a/src/xrt/compositor/vk_native/comp_vk_native_wl_geom.c +++ b/src/xrt/compositor/vk_native/comp_vk_native_wl_geom.c @@ -19,6 +19,7 @@ #include "util/u_logging.h" #include "util/u_wayland_geom.h" +#include "util/u_wayland_layout.h" #include "util/u_json.h" #include "util/u_misc.h" #include "os/os_time.h" @@ -43,6 +44,13 @@ //! The publisher comes and goes (see wlg_pump), so we also watch who owns its //! well-known name. The daemon supports arg0 matching, so this delivers only //! transitions of OUR name — not every name change on the session bus. +//! Mutter's monitor configuration (the layout-mode fallback, see +//! wlg_query_mutter_layout_mode). MonitorsChanged marks the cached answer stale. +#define MUTTER_DC_BUS_NAME "org.gnome.Mutter.DisplayConfig" +#define MUTTER_DC_OBJ_PATH "/org/gnome/Mutter/DisplayConfig" +#define MUTTER_DC_IFACE "org.gnome.Mutter.DisplayConfig" +#define MUTTER_DC_MATCH_RULE "type='signal',interface='" MUTTER_DC_IFACE "',member='MonitorsChanged'" + #define WLG_OWNER_MATCH_RULE \ "type='signal',sender='" DBUS_SERVICE_DBUS "',interface='" DBUS_INTERFACE_DBUS \ "'," \ @@ -60,6 +68,9 @@ // query time, not parse time, so the cache mirrors the full snapshot). // // EVERY geometry field here is LOGICAL — this is the wire payload, unconverted. +// "Logical" means Mutter's STAGE coordinates, which are device px in Mutter's +// PHYSICAL layout mode: the factor that converts them is resolved per window +// at the boundary (u_wl_stage_to_device_scale), never assumed to be mon_scale. // The `logical_` prefix is not decoration: the 2026-09-20 faults all came from // two coordinate spaces sharing one variable name, so the cache keeps the // payload's space in the name and the conversion happens at exactly one place @@ -77,6 +88,10 @@ struct wlg_window //! Mutter's FRACTIONAL monitor scale (Meta.Display.get_monitor_scale) — //! 1.6667 on the measured box's laptop. Never an integer wl_output.scale. float mon_scale; + //! Device px per stage px on that monitor, as the publisher states it + //! (extension v11+, `monitor.device_scale`: the scale of the stage view + //! painting it). 0 when not published — then the layout mode decides. + float mon_device_scale; //! False when Mutter published no `monitor` object (window on no monitor). bool have_monitor; //! Buffer rect, LOGICAL px, same coordinates (Meta.Window.get_buffer_rect): @@ -112,6 +127,19 @@ struct comp_vk_native_wl_geom bool warned_schema; //!< one-shot WARN guard (publisher schema too new) bool warned_no_monitor; //!< one-shot WARN guard (payload carried no monitor rect) bool warned_no_placement; //!< one-shot WARN guard (publisher has no WindowPlacement1) + bool warned_layout; //!< one-shot WARN guard (PHYSICAL layout / fallback source) + + //! The snapshot's top-level `layout_mode` (extension v11+); UNKNOWN when + //! an older publisher omits it. + enum u_wl_layout_mode payload_layout_mode; + //! Mutter's own answer (DisplayConfig.GetCurrentState `layout-mode`), + //! asked only when the publisher states neither the device scale nor the + //! layout mode: an extension older than version 11, e.g. the one still + //! loaded in a session that has not logged out since the package upgrade. + enum u_wl_layout_mode mutter_layout_mode; + //! The cached Mutter answer must be (re)fetched: never asked, or + //! MonitorsChanged since. + bool mutter_layout_stale; //! Last surface-vs-frame comparison logged (logical px), on change only. int32_t logged_surface_w, logged_surface_h, logged_frame_w, logged_frame_h, logged_inset_y; int64_t next_retry_ns; //!< earliest monotonic time for the next blocking GetWindows retry @@ -162,6 +190,14 @@ wlg_parse_snapshot(struct comp_vk_native_wl_geom *g, const char *json) return; } + // Extension v11+: which space the stage coordinates below are in. Absent + // from an older publisher (UNKNOWN), which then gets the Mutter fallback. + char layout_mode[16] = {0}; + g->payload_layout_mode = U_WL_LAYOUT_MODE_UNKNOWN; + if (u_json_get_string_into_array(u_json_get(root, "layout_mode"), layout_mode, sizeof(layout_mode))) { + g->payload_layout_mode = u_wl_layout_mode_from_string(layout_mode); + } + const cJSON *windows = u_json_get(root, "windows"); if (!cJSON_IsArray(windows)) { cJSON_Delete(root); @@ -248,6 +284,11 @@ wlg_parse_snapshot(struct comp_vk_native_wl_geom *g, const char *json) if (u_json_get_float(u_json_get(monitor, "scale"), &scale) && scale > 0.0f) { out->mon_scale = scale; } + float device_scale = 0.0f; + if (u_json_get_float(u_json_get(monitor, "device_scale"), &device_scale) && + device_scale > 0.0f) { + out->mon_device_scale = device_scale; + } } count++; @@ -311,6 +352,70 @@ wlg_handle_owner_changed(struct comp_vk_native_wl_geom *g, DBusMessage *msg) return false; } +/*! + * Mutter's layout mode, straight from Mutter: the `layout-mode` property of + * org.gnome.Mutter.DisplayConfig.GetCurrentState + * `(u serial, a(...) monitors, a(...) logical_monitors, a{sv} properties)`. + * + * The fallback for a publisher that states neither `monitor.device_scale` nor + * `layout_mode` (extension version 10 and older). Bounded and blocking, so it + * runs once, and again only after MonitorsChanged — never per frame. + * + * @return UNKNOWN when Mutter does not answer (not GNOME, or a Mutter too old + * to report the property); callers then keep the monitor scale. + */ +static enum u_wl_layout_mode +wlg_query_mutter_layout_mode(struct comp_vk_native_wl_geom *g, int timeout_ms) +{ + DBusMessage *call = + dbus_message_new_method_call(MUTTER_DC_BUS_NAME, MUTTER_DC_OBJ_PATH, MUTTER_DC_IFACE, "GetCurrentState"); + if (call == NULL) { + return U_WL_LAYOUT_MODE_UNKNOWN; + } + DBusMessage *reply = dbus_connection_send_with_reply_and_block(g->conn, call, timeout_ms, NULL); + dbus_message_unref(call); + if (reply == NULL) { + return U_WL_LAYOUT_MODE_UNKNOWN; + } + + enum u_wl_layout_mode mode = U_WL_LAYOUT_MODE_UNKNOWN; + DBusMessageIter it; + if (dbus_message_iter_init(reply, &it)) { + // Skip serial, monitors and logical monitors to the properties dict. + int skipped = 0; + while (skipped < 3 && dbus_message_iter_next(&it)) { + skipped++; + } + if (skipped == 3 && dbus_message_iter_get_arg_type(&it) == DBUS_TYPE_ARRAY) { + DBusMessageIter dict; + dbus_message_iter_recurse(&it, &dict); + while (dbus_message_iter_get_arg_type(&dict) == DBUS_TYPE_DICT_ENTRY) { + DBusMessageIter entry, value; + const char *key = NULL; + dbus_message_iter_recurse(&dict, &entry); + if (dbus_message_iter_get_arg_type(&entry) == DBUS_TYPE_STRING) { + dbus_message_iter_get_basic(&entry, &key); + } + if (key != NULL && strcmp(key, "layout-mode") == 0 && dbus_message_iter_next(&entry) && + dbus_message_iter_get_arg_type(&entry) == DBUS_TYPE_VARIANT) { + dbus_message_iter_recurse(&entry, &value); + if (dbus_message_iter_get_arg_type(&value) == DBUS_TYPE_UINT32) { + dbus_uint32_t v = 0; + dbus_message_iter_get_basic(&value, &v); + mode = v == U_WL_LAYOUT_MODE_LOGICAL ? U_WL_LAYOUT_MODE_LOGICAL + : v == U_WL_LAYOUT_MODE_PHYSICAL ? U_WL_LAYOUT_MODE_PHYSICAL + : U_WL_LAYOUT_MODE_UNKNOWN; + } + break; + } + dbus_message_iter_next(&dict); + } + } + } + dbus_message_unref(reply); + return mode; +} + //! Drain pending bus messages without blocking; keep the latest snapshot. static void wlg_pump(struct comp_vk_native_wl_geom *g) @@ -333,6 +438,9 @@ wlg_pump(struct comp_vk_native_wl_geom *g) } } else if (dbus_message_is_signal(msg, DBUS_INTERFACE_DBUS, "NameOwnerChanged")) { owner_appeared |= wlg_handle_owner_changed(g, msg); + } else if (dbus_message_is_signal(msg, MUTTER_DC_IFACE, "MonitorsChanged")) { + // The layout mode can change with the monitor configuration. + g->mutter_layout_stale = true; } dbus_message_unref(msg); } @@ -532,6 +640,14 @@ comp_vk_native_wl_geom_create(void) err.message); dbus_error_free(&err); } + // Only consulted for a publisher older than version 11 (see + // wlg_query_mutter_layout_mode); the signal is rare, so subscribe always. + g->mutter_layout_stale = true; + dbus_bus_add_match(g->conn, MUTTER_DC_MATCH_RULE, &err); + if (dbus_error_is_set(&err)) { + U_LOG_W("wl_geom: MonitorsChanged add_match failed (%s)", err.message); + dbus_error_free(&err); + } dbus_connection_flush(g->conn); if (!wlg_request_snapshot(g, 200)) { @@ -620,12 +736,47 @@ comp_vk_native_wl_geom_get_window_rect(struct comp_vk_native_wl_geom *g, struct return false; } + /* + * WHICH factor. The payload is Mutter's stage coordinates, and the stage + * is logical px only in Mutter's LOGICAL layout mode. In its PHYSICAL + * mode (Ubuntu 24.04 / GNOME 46 at an integer scale, out of the box) the + * stage IS device px and the monitor scale only says how big clients + * draw: multiplying by it read a 3840x2160 panel at 200 % as 7680x4320, + * so the window was "not on the panel" and never wove. + */ + enum u_wl_layout_mode layout = g->payload_layout_mode; + const char *layout_src = "the geometry service"; + if (best->mon_device_scale <= 0.0f && layout == U_WL_LAYOUT_MODE_UNKNOWN) { + if (g->mutter_layout_stale) { + g->mutter_layout_stale = false; + g->mutter_layout_mode = wlg_query_mutter_layout_mode(g, 100); + } + layout = g->mutter_layout_mode; + layout_src = "Mutter DisplayConfig (the geometry service predates version 11)"; + } + const double device_scale = + u_wl_stage_to_device_scale((double)best->mon_scale, (double)best->mon_device_scale, layout); + if (!(device_scale > 0.0)) { + return false; + } + + if (!g->warned_layout && (layout == U_WL_LAYOUT_MODE_PHYSICAL || + (best->mon_device_scale > 0.0f && best->mon_device_scale != best->mon_scale))) { + g->warned_layout = true; + U_LOG_W( + "wl_geom: Mutter is in its PHYSICAL layout mode (from %s): window geometry is already DEVICE " + "px, so the monitor scale %.4f is not applied (factor %.4f).", + best->mon_device_scale > 0.0f ? "the geometry service's device_scale" : layout_src, + (double)best->mon_scale, device_scale); + } + const struct u_wl_monitor mon = { .logical_x = best->mon_logical_x, .logical_y = best->mon_logical_y, .logical_w = best->mon_logical_w, .logical_h = best->mon_logical_h, - .scale = (double)best->mon_scale, + // Device px per STAGE px — the monitor scale only in LOGICAL layout. + .scale = device_scale, .mode_w = 0, // Mutter publishes the fractional scale, not the mode. .mode_h = 0, }; @@ -661,11 +812,15 @@ comp_vk_native_wl_geom_get_window_rect(struct comp_vk_native_wl_geom *g, struct // a scaled desktop is now a supported configuration for the phase // feed, not a degradation. U_LOG_I( - "wl_geom: monitor scale %.4f — window content logical %d,%d %dx%d on a %dx%d logical monitor " - "converts to DEVICE %d,%d %dx%d on a %dx%d px monitor (#1596)", - (double)best->mon_scale, content_logical.logical_x, content_logical.logical_y, - content_logical.logical_w, content_logical.logical_h, best->mon_logical_w, best->mon_logical_h, - win_px.x, win_px.y, win_px.w, win_px.h, mon_w_px, mon_h_px); + "wl_geom: monitor scale %.4f, %s layout, factor %.4f — window content stage %d,%d %dx%d on a " + "%dx%d stage monitor converts to DEVICE %d,%d %dx%d on a %dx%d px monitor (#1596)", + (double)best->mon_scale, + layout == U_WL_LAYOUT_MODE_PHYSICAL ? "PHYSICAL" + : layout == U_WL_LAYOUT_MODE_LOGICAL ? "LOGICAL" + : "unknown", + device_scale, content_logical.logical_x, content_logical.logical_y, content_logical.logical_w, + content_logical.logical_h, best->mon_logical_w, best->mon_logical_h, win_px.x, win_px.y, win_px.w, + win_px.h, mon_w_px, mon_h_px); } out_rect->frame_logical_x = best->logical_x; @@ -685,7 +840,7 @@ comp_vk_native_wl_geom_get_window_rect(struct comp_vk_native_wl_geom *g, struct out_rect->height_px = (uint32_t)win_px.h; out_rect->monitor_width_px = (uint32_t)mon_w_px; out_rect->monitor_height_px = (uint32_t)mon_h_px; - out_rect->scale = best->mon_scale; + out_rect->scale = (float)device_scale; /* * The committed SURFACE vs the FRAME. Two different reasons they differ, @@ -710,13 +865,11 @@ comp_vk_native_wl_geom_get_window_rect(struct comp_vk_native_wl_geom *g, struct out_rect->surface_within_frame = true; out_rect->frame_inset_top_px = 0; if (best->have_buffer) { - out_rect->surface_width_px = - (uint32_t)u_wl_logical_to_px(best->buffer_logical_w, (double)best->mon_scale); - out_rect->surface_height_px = - (uint32_t)u_wl_logical_to_px(best->buffer_logical_h, (double)best->mon_scale); + out_rect->surface_width_px = (uint32_t)u_wl_logical_to_px(best->buffer_logical_w, device_scale); + out_rect->surface_height_px = (uint32_t)u_wl_logical_to_px(best->buffer_logical_h, device_scale); out_rect->surface_within_frame = u_wl_surface_within_frame(&frame_logical, &buffer_logical); const int32_t inset_y = best->buffer_logical_y - best->logical_y; - out_rect->frame_inset_top_px = u_wl_logical_to_px(inset_y, (double)best->mon_scale); + out_rect->frame_inset_top_px = u_wl_logical_to_px(inset_y, device_scale); if (best->buffer_logical_w != g->logged_surface_w || best->buffer_logical_h != g->logged_surface_h || best->logical_w != g->logged_frame_w || best->logical_h != g->logged_frame_h || inset_y != g->logged_inset_y) { diff --git a/src/xrt/compositor/vk_native/comp_vk_native_wl_geom.h b/src/xrt/compositor/vk_native/comp_vk_native_wl_geom.h index aea817407..b7c2942aa 100644 --- a/src/xrt/compositor/vk_native/comp_vk_native_wl_geom.h +++ b/src/xrt/compositor/vk_native/comp_vk_native_wl_geom.h @@ -24,6 +24,15 @@ * fractionally-scaled desktop produced no present origin at all rather than a * converted one. * + * "Logical" is Mutter's STAGE coordinates, and the stage is logical px only in + * Mutter's LOGICAL layout mode. In its PHYSICAL mode (Ubuntu 24.04 / GNOME 46 + * at an integer scale) the stage already is device px, and the monitor scale + * must not be applied: it read a 3840x2160 panel at 200 % as 7680x4320. The + * factor is therefore resolved per window (u_wl_stage_to_device_scale): the + * publisher's `monitor.device_scale`, else its `layout_mode`, else Mutter's own + * `layout-mode` (DisplayConfig.GetCurrentState, for a publisher older than + * extension version 11), else the monitor scale. + * * Degradation: extension absent / bus unreachable / no matching window / the * window is on no monitor → comp_vk_native_wl_geom_get_window_rect returns * false and the compositor stays display-scoped, exactly the pre-#817 Wayland @@ -84,7 +93,9 @@ struct comp_vk_native_wl_window_rect //! at all — the check that would have caught the 2026-09-20 session, where //! the surface fullscreened on the laptop and wove anyway. uint32_t monitor_width_px, monitor_height_px; - //! The factor applied. 1.0 means the wire values were already device px. + //! The factor applied: device px per STAGE px (the `*_logical` fields + //! below). The monitor scale in Mutter's LOGICAL layout mode, 1.0 in its + //! PHYSICAL mode — where the wire values already are device px. float scale; //! The committed SURFACE's size in DEVICE px (the compositor's buffer rect //! for the window), or 0 when the publisher does not report it. Differs diff --git a/tests/tests_aux_wayland_geom.cpp b/tests/tests_aux_wayland_geom.cpp index f3e6c368b..561dfafd6 100644 --- a/tests/tests_aux_wayland_geom.cpp +++ b/tests/tests_aux_wayland_geom.cpp @@ -21,6 +21,7 @@ #include "catch_amalgamated.hpp" #include "util/u_wayland_geom.h" +#include "util/u_wayland_layout.h" // The measured layout, named once. static constexpr int32_t kLaptopLogicalW = 1728; @@ -493,6 +494,119 @@ TEST_CASE("any scale: a whole-logical-pixel move lands where Mutter draws it") } +/* + * + * Mutter's layout modes: the stage is LOGICAL px in one and DEVICE px in the + * other. Measured on a headless mutter 50.1 with a 3840x2160 virtual monitor, + * switching only the layout mode (gdctl set --layout-mode). + * + */ + +//! The extension's monitor object for that 3840x2160 monitor, as published in +//! each mode, with the stage->device factor resolved the way the runtime does. +static struct u_wl_monitor +monitor_as_published(int32_t stage_w, int32_t stage_h, double monitor_scale, enum u_wl_layout_mode mode) +{ + struct u_wl_monitor m = {}; + m.logical_w = stage_w; + m.logical_h = stage_h; + m.scale = u_wl_stage_to_device_scale(monitor_scale, 0.0, mode); + return m; +} + +TEST_CASE("PHYSICAL layout at 200 %: the stage already is device px") +{ + // layout-mode 2, scale 2: Meta.Display.get_monitor_geometry() is the full + // 3840x2160 and get_monitor_scale() is 2. + REQUIRE(u_wl_stage_to_device_scale(2.0, 0.0, U_WL_LAYOUT_MODE_PHYSICAL) == 1.0); + + const struct u_wl_monitor m = monitor_as_published(3840, 2160, 2.0, U_WL_LAYOUT_MODE_PHYSICAL); + int32_t w = 0, h = 0; + REQUIRE(u_wl_monitor_size_px(&m, &w, &h)); + REQUIRE(w == 3840); + REQUIRE(h == 2160); + + // The measured windowed cube: content 1600x900 at stage (1120, 720), its + // committed buffer 1600x900 device px. + struct u_wl_rect_px r = {}; + REQUIRE(u_wl_window_rect_px_on_monitor(&m, 1120, 720, 1600, 900, &r)); + REQUIRE(r.x == 1120); + REQUIRE(r.y == 720); + REQUIRE(r.w == 1600); + REQUIRE(r.h == 900); + REQUIRE(u_wl_present_is_1to1(1600, 900, (uint32_t)r.w, (uint32_t)r.h)); + REQUIRE(u_wl_monitor_is_panel(&m, 0, 0, 3840, 2160, NULL)); +} + +TEST_CASE("PHYSICAL layout with the monitor scale applied is the reported failure") +{ + // What every consumer did before the layout mode was known: x2 on top of + // device px. The panel reads as a 7680x4320 output and the window as twice + // its buffer, so the surface is "not on the panel" and never weaves. + struct u_wl_monitor m = {}; + m.logical_w = 3840; + m.logical_h = 2160; + m.scale = u_wl_stage_to_device_scale(2.0, 0.0, U_WL_LAYOUT_MODE_UNKNOWN); + REQUIRE(m.scale == 2.0); + int32_t w = 0, h = 0; + REQUIRE(u_wl_monitor_size_px(&m, &w, &h)); + REQUIRE(w == 7680); + REQUIRE(h == 4320); + REQUIRE_FALSE(u_wl_monitor_is_panel(&m, 0, 0, 3840, 2160, NULL)); +} + +TEST_CASE("LOGICAL layout: the monitor scale is the factor, at every scale") +{ + // {stage w, stage h, scale}: the same 3840x2160 monitor in layout-mode 1. + // 1.5 and 2.0 are measured; the rest are the scales GNOME Settings offers + // for a 3840x2160 output. + struct + { + int32_t w, h; + double scale; + } cases[] = { + {3840, 2160, 1.0}, {3072, 1728, 1.25}, {2560, 1440, 1.5}, {2194, 1234, 1.75}, {1920, 1080, 2.0}, + }; + for (const auto &c : cases) { + CAPTURE(c.scale); + REQUIRE(u_wl_stage_to_device_scale(c.scale, 0.0, U_WL_LAYOUT_MODE_LOGICAL) == c.scale); + const struct u_wl_monitor m = monitor_as_published(c.w, c.h, c.scale, U_WL_LAYOUT_MODE_LOGICAL); + int32_t w = 0, h = 0; + REQUIRE(u_wl_monitor_size_px(&m, &w, &h)); + REQUIRE(w == 3840); + REQUIRE(h == 2160); + REQUIRE(u_wl_monitor_is_panel(&m, 0, 0, 3840, 2160, NULL)); + } +} + +TEST_CASE("an unknown layout mode keeps the monitor scale (pre-#1596 behaviour)") +{ + REQUIRE(u_wl_stage_to_device_scale(5.0 / 3.0, 0.0, U_WL_LAYOUT_MODE_UNKNOWN) == 5.0 / 3.0); + REQUIRE(u_wl_stage_to_device_scale(0.0, 0.0, U_WL_LAYOUT_MODE_UNKNOWN) == 0.0); + REQUIRE(u_wl_stage_to_device_scale(-1.0, 0.0, U_WL_LAYOUT_MODE_LOGICAL) == 0.0); +} + +TEST_CASE("a published device scale wins over the mode and the monitor scale") +{ + // Extension v11 publishes the stage view's scale: 1 in PHYSICAL, the + // monitor scale in LOGICAL. It is authoritative either way. + REQUIRE(u_wl_stage_to_device_scale(2.0, 1.0, U_WL_LAYOUT_MODE_UNKNOWN) == 1.0); + REQUIRE(u_wl_stage_to_device_scale(2.0, 1.0, U_WL_LAYOUT_MODE_LOGICAL) == 1.0); + REQUIRE(u_wl_stage_to_device_scale(1.5, 1.5, U_WL_LAYOUT_MODE_PHYSICAL) == 1.5); +} + +TEST_CASE("the payload's layout_mode string") +{ + REQUIRE(u_wl_layout_mode_from_string("logical") == U_WL_LAYOUT_MODE_LOGICAL); + REQUIRE(u_wl_layout_mode_from_string("physical") == U_WL_LAYOUT_MODE_PHYSICAL); + REQUIRE(u_wl_layout_mode_from_string("") == U_WL_LAYOUT_MODE_UNKNOWN); + REQUIRE(u_wl_layout_mode_from_string("logic") == U_WL_LAYOUT_MODE_UNKNOWN); + REQUIRE(u_wl_layout_mode_from_string("physicalx") == U_WL_LAYOUT_MODE_UNKNOWN); + REQUIRE(u_wl_layout_mode_from_string("PHYSICAL") == U_WL_LAYOUT_MODE_UNKNOWN); + REQUIRE(u_wl_layout_mode_from_string(NULL) == U_WL_LAYOUT_MODE_UNKNOWN); +} + + /* * * Off-panel bands: a window spanning the panel and another monitor (#1654).