From e0f15822ae852ff8f66dc0da9931c5cb7bb5cc1a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 12:28:46 +0000 Subject: [PATCH] =?UTF-8?q?feat(vdisplay):=20Stage=205=20layout=20foundati?= =?UTF-8?q?on=20=E2=80=94=20arrangement=20engine=20+=20/display/layout=20+?= =?UTF-8?q?=20group=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §6A layout, riding the Stages 1-3 registry with no protocol change: - vdisplay/layout.rs: pure arrangement engine — auto-row (left-to-right in acquire order, top-aligned) + manual (per-identity-slot offsets, auto-row fallback for unpinned members). Unit-tested. - Registry group model (Linux): group = backend (one desktop per compositor session). /display/state groups entries, orders by acquire (gen), and computes each member's position via the engine (pure `assemble_displays`, unit-tested). DisplayInfo carries group/display_index/position/identity_slot/topology. The backend reports its resolved slot via the new VirtualDisplay::last_identity_slot (KWin only), so the arrangement + state key on per-client identity. - Registry-driven position apply: new VirtualDisplay::apply_position(x,y) (default no-op; KWin drives kscreen-doctor). Right after create the registry computes the new display's position over its whole group (pure `position_for_new`, unit-tested) and applies it — one seam for BOTH deterministic auto-row AND manual placement. Guarded: the origin (0,0) is skipped, so a single-display / first-of-group session (and every non-KWin backend) issues no positioning — the historical single-display path is unchanged. On-glass-validation-pending. - PUT /api/v1/display/layout: persists the console's manual arrangement via the pure EffectivePolicy::with_manual_layout transform (locks current effective behavior into explicit Custom fields + sets a manual layout, so arranging is orthogonal to the other axes). OpenAPI regenerated. - /display/settings `enforced` now lists all five axes (keep_alive, topology, mode_conflict [Stage 4], identity [Stage 3], layout [Stage 5]) — was stale at keep_alive+topology; the console reads it to know which controls are live. Still Stage-5 TODO (design/display-management.md §11): Mutter/wlroots group-aware analogues, per-group topology restore, the web arrangement table, gamescope decline. cargo build/test/clippy/fmt green; OpenAPI in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/openapi.json | 112 +++++- crates/punktfunk-host/src/mgmt.rs | 88 ++++- crates/punktfunk-host/src/vdisplay.rs | 22 ++ crates/punktfunk-host/src/vdisplay/layout.rs | 142 ++++++++ .../punktfunk-host/src/vdisplay/linux/kwin.rs | 38 +- crates/punktfunk-host/src/vdisplay/policy.rs | 50 +++ .../punktfunk-host/src/vdisplay/registry.rs | 335 ++++++++++++++++-- design/display-management.md | 66 +++- 8 files changed, 804 insertions(+), 49 deletions(-) create mode 100644 crates/punktfunk-host/src/vdisplay/layout.rs diff --git a/api/openapi.json b/api/openapi.json index f41f8a9..24ed735 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -138,6 +138,58 @@ } } }, + "/api/v1/display/layout": { + "put": { + "tags": [ + "display" + ], + "summary": "Arrange virtual displays", + "description": "Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor\ngroup (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block\nand switched to manual mode; applied from the next connect (a live group re-applies on its next\nacquire). Locks in the current effective behavior as explicit fields, so arranging displays never\nsilently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.", + "operationId": "setDisplayLayout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplayLayoutRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Layout stored; the new settings state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DisplaySettingsState" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Layout could not be persisted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/display/release": { "post": { "tags": [ @@ -1775,7 +1827,12 @@ "backend", "mode", "state", - "sessions" + "sessions", + "group", + "display_index", + "x", + "y", + "topology" ], "properties": { "backend": { @@ -1789,6 +1846,12 @@ ], "description": "Short client label, when the owner tracks it." }, + "display_index": { + "type": "integer", + "format": "int32", + "description": "This display's ordinal within its group, in acquire order (0-based).", + "minimum": 0 + }, "expires_in_ms": { "type": [ "integer", @@ -1798,6 +1861,21 @@ "description": "Milliseconds until a lingering display is torn down (absent when active/pinned).", "minimum": 0 }, + "group": { + "type": "integer", + "format": "int32", + "description": "Display group (shared desktop) id — several displays with the same group form one desktop (§6A).", + "minimum": 0 + }, + "identity_slot": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).", + "minimum": 0 + }, "mode": { "type": "string", "description": "`WIDTHxHEIGHT@HZ`." @@ -1817,6 +1895,20 @@ "state": { "type": "string", "description": "`active` | `lingering` | `pinned`." + }, + "topology": { + "type": "string", + "description": "Effective topology for this display's group (`extend` | `primary` | `exclusive`)." + }, + "x": { + "type": "integer", + "format": "int32", + "description": "Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2)." + }, + "y": { + "type": "integer", + "format": "int32", + "description": "Desktop-space top-left `y`." } } }, @@ -2128,6 +2220,22 @@ } } }, + "DisplayLayoutRequest": { + "type": "object", + "description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).", + "properties": { + "positions": { + "type": "object", + "description": "`{\"\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits.", + "additionalProperties": { + "$ref": "#/components/schemas/Position" + }, + "propertyNames": { + "type": "string" + } + } + } + }, "DisplayPolicy": { "type": "object", "description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].", @@ -2188,7 +2296,7 @@ "items": { "type": "string" }, - "description": "Option names this build enforces right now (e.g. `keep_alive`, `topology`). The remaining\nstored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the\nconsole can mark them \"coming soon\" instead of implying they already take effect." + "description": "Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `/display/state`)." }, "presets": { "type": "array", diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index f5cf7c1..8d8db35 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -160,6 +160,7 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(set_display_settings)) .routes(routes!(get_display_state)) .routes(routes!(release_display)) + .routes(routes!(set_display_layout)) .routes(routes!(get_status)) .routes(routes!(get_local_summary)) .routes(routes!(list_paired_clients)) @@ -988,9 +989,10 @@ struct DisplaySettingsState { effective: crate::vdisplay::policy::EffectivePolicy, /// Every named preset and what it expands to (for the picker's preview). presets: Vec, - /// Option names this build enforces right now (e.g. `keep_alive`, `topology`). The remaining - /// stored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the - /// console can mark them "coming soon" instead of implying they already take effect. + /// Option names this build enforces right now. All five axes are now acted on (keep_alive + + /// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console + /// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout + /// position apply being KWin-only, is reported per display in `/display/state`). enforced: Vec, } @@ -1031,7 +1033,13 @@ fn display_settings_state() -> DisplaySettingsState { settings, configured, presets, - enforced: vec!["keep_alive".into(), "topology".into()], + enforced: vec![ + "keep_alive".into(), + "topology".into(), + "mode_conflict".into(), + "identity".into(), + "layout".into(), + ], } } @@ -1114,6 +1122,18 @@ struct ApiDisplayInfo { sessions: u32, /// Short client label, when the owner tracks it. client: Option, + /// Display group (shared desktop) id — several displays with the same group form one desktop (§6A). + group: u32, + /// This display's ordinal within its group, in acquire order (0-based). + display_index: u32, + /// Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2). + x: i32, + /// Desktop-space top-left `y`. + y: i32, + /// Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous). + identity_slot: Option, + /// Effective topology for this display's group (`extend` | `primary` | `exclusive`). + topology: String, } /// The host's managed virtual displays right now. @@ -1166,6 +1186,12 @@ async fn get_display_state() -> Json { expires_in_ms: d.expires_in_ms, sessions: d.sessions, client: d.client, + group: d.group, + display_index: d.display_index, + x: d.position.0, + y: d.position.1, + identity_slot: d.identity_slot, + topology: d.topology, }) .collect(), }) @@ -1195,6 +1221,53 @@ async fn release_display( Json(ReleaseDisplayResult { released }) } +/// Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot +/// id as a string (the same id `/display/state` reports as `identity_slot`). +#[derive(Deserialize, ToSchema)] +struct DisplayLayoutRequest { + /// `{"": {"x": …, "y": …}}` — where each arranged display's top-left sits. + #[serde(default)] + positions: std::collections::BTreeMap, +} + +/// Arrange virtual displays +/// +/// Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor +/// group (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block +/// and switched to manual mode; applied from the next connect (a live group re-applies on its next +/// acquire). Locks in the current effective behavior as explicit fields, so arranging displays never +/// silently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2. +#[utoipa::path( + put, + path = "/display/layout", + tag = "display", + operation_id = "setDisplayLayout", + request_body = DisplayLayoutRequest, + responses( + (status = OK, description = "Layout stored; the new settings state", body = DisplaySettingsState), + (status = INTERNAL_SERVER_ERROR, description = "Layout could not be persisted", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn set_display_layout(ApiJson(req): ApiJson) -> Response { + let store = crate::vdisplay::policy::prefs(); + // Lock the current effective behavior into explicit fields + set the manual arrangement (pure + // transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy + // axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.) + let policy = store.get().effective().with_manual_layout(req.positions); + if let Err(e) = store.set(policy) { + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("persist display layout: {e:#}"), + ); + } + tracing::info!( + positions = display_settings_state().settings.layout.positions.len(), + "management API: display layout updated" + ); + Json(display_settings_state()).into_response() +} + /// Live host status #[utoipa::path( get, @@ -2740,7 +2813,12 @@ mod tests { .iter() .filter_map(|v| v.as_str()) .collect(); - assert!(enforced.contains(&"keep_alive") && enforced.contains(&"topology")); + // All five axes are enforced now (Stages 0-5). + assert!(enforced.contains(&"keep_alive")); + assert!(enforced.contains(&"topology")); + assert!(enforced.contains(&"mode_conflict")); + assert!(enforced.contains(&"identity")); + assert!(enforced.contains(&"layout")); // `gaming-rig` expands to keep_alive: forever → rejected at Stage 0 (before any write). let put = axum::http::Request::put("/api/v1/display/settings") diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index d427f25..9884d60 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -64,6 +64,23 @@ pub trait VirtualDisplay: Send { /// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual /// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity. fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {} + /// The stable identity slot the backend resolved for the most recent [`create`](Self::create) — + /// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The + /// registry reads it right after `create` to key the display's group **arrangement** (manual + /// per-slot positions) and to label the mgmt `/display/state` slot. Default `None`: a backend + /// with no per-client identity (Mutter/wlroots/gamescope) always auto-rows. Only KWin (per-slot + /// output naming) reports a real slot on Linux. + fn last_identity_slot(&self) -> Option { + None + } + /// Place the most-recently-[created](Self::create) output at `(x, y)` in the desktop coordinate + /// space (design `display-management.md` §6.2 — layout). The registry, which owns the display + /// **group**, computes the position from the whole group (auto-row or the console's manual + /// arrangement) and calls this right after `create`. Default no-op: only backends that can position + /// an output (KWin) implement it; the registry never calls it for the desktop origin `(0, 0)`, so a + /// single-display / first-of-group session issues no positioning at all. Best-effort — a failure + /// leaves the compositor's default placement. + fn apply_position(&mut self, _x: i32, _y: i32) {} } /// Compositors punktfunk knows how to drive (plan §6). @@ -729,6 +746,11 @@ pub(crate) mod lifecycle; #[path = "vdisplay/registry.rs"] pub(crate) mod registry; +// The pure display-arrangement engine (auto-row / manual → per-member positions), platform-neutral +// and unit-tested; the registry (state readout) and the KWin position apply consume it. +#[path = "vdisplay/layout.rs"] +pub(crate) mod layout; + /// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto` /// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test /// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected diff --git a/crates/punktfunk-host/src/vdisplay/layout.rs b/crates/punktfunk-host/src/vdisplay/layout.rs new file mode 100644 index 0000000..55f8b74 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/layout.rs @@ -0,0 +1,142 @@ +//! Pure display-**arrangement** engine (design: `design/display-management.md` §6.2). Given a +//! group's members (in acquire order) and the `layout` policy, compute each member's top-left +//! origin in the desktop coordinate space. No I/O, no OS types — the registry (for the +//! `/display/state` readout) and the per-backend position apply both consume it, so the auto-row / +//! manual math is defined and tested in exactly one place (the `pick_gamescope_mode` / `wiring_plan` +//! discipline). +//! +//! * **auto-row** — left-to-right in acquire order, top-aligned: member *i* sits at +//! `x = Σ widths[0..i]`, `y = 0`. This is what compositors mostly do by default, made +//! deterministic. +//! * **manual** — per-identity-slot offsets from [`Layout::positions`] (console-arranged): a member +//! whose stable identity slot has a stored position sits there; a member with no pin (no stored +//! position, or a shared/anonymous identity that has no slot) falls back to its auto-row origin, so +//! a half-arranged group never collapses everything onto the origin. +//! +//! Group membership + acquire order live in the registry ([`super::registry`]); this file only turns +//! that ordered member list into positions. + +use super::policy::{Layout, LayoutMode}; + +/// One display in a group, as the arranger sees it (given in acquire order). +#[derive(Clone, Copy, Debug)] +pub struct Member { + /// Stable per-client identity slot — the manual-layout key. `None` for a shared/anonymous + /// identity (no per-client slot), which can't carry a manual pin and therefore always auto-rows. + pub identity_slot: Option, + /// Pixel width, for auto-row `x` accumulation. Clamped at 0 (a bogus negative never shifts a + /// sibling left). + pub width: i32, +} + +/// A member's resolved desktop-space top-left origin. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Placement { + pub x: i32, + pub y: i32, +} + +/// The auto-row origin of member `i`: the summed width of every prior member, top-aligned. +fn auto_row_x(members: &[Member], i: usize) -> i32 { + members[..i].iter().map(|m| m.width.max(0)).sum() +} + +/// Arrange `members` (in acquire order) per `layout`, returning one [`Placement`] per member in the +/// same order. Pure — the single source of truth for auto-row / manual placement, shared by the +/// state readout and (KWin) the per-backend position apply. +pub fn arrange(members: &[Member], layout: &Layout) -> Vec { + members + .iter() + .enumerate() + .map(|(i, m)| { + let auto = Placement { + x: auto_row_x(members, i), + y: 0, + }; + match layout.mode { + LayoutMode::AutoRow => auto, + // A pinned member sits at its stored offset; an unpinned one falls back to auto-row. + LayoutMode::Manual => m + .identity_slot + .and_then(|slot| layout.positions.get(&slot.to_string())) + .map(|p| Placement { x: p.x, y: p.y }) + .unwrap_or(auto), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vdisplay::policy::Position; + use std::collections::BTreeMap; + + fn m(slot: Option, width: i32) -> Member { + Member { + identity_slot: slot, + width, + } + } + + fn manual(pairs: &[(&str, i32, i32)]) -> Layout { + let mut positions = BTreeMap::new(); + for (k, x, y) in pairs { + positions.insert(k.to_string(), Position { x: *x, y: *y }); + } + Layout { + mode: LayoutMode::Manual, + positions, + } + } + + #[test] + fn auto_row_accumulates_widths_top_aligned() { + let members = [m(Some(1), 2560), m(Some(2), 1920), m(None, 1280)]; + let out = arrange(&members, &Layout::default()); // default = AutoRow + assert_eq!( + out, + vec![ + Placement { x: 0, y: 0 }, + Placement { x: 2560, y: 0 }, + Placement { x: 4480, y: 0 }, + ] + ); + } + + #[test] + fn manual_honors_pins_by_identity_slot() { + let members = [m(Some(1), 2560), m(Some(7), 1920)]; + // Client 7 arranged to the LEFT of client 1 (crossing order reversed vs auto-row). + let layout = manual(&[("1", 1920, 0), ("7", 0, 0)]); + let out = arrange(&members, &layout); + assert_eq!(out[0], Placement { x: 1920, y: 0 }); + assert_eq!(out[1], Placement { x: 0, y: 0 }); + } + + #[test] + fn manual_unpinned_and_slotless_fall_back_to_auto_row() { + let members = [m(Some(1), 2560), m(Some(9), 1920), m(None, 1280)]; + // Only slot 1 is pinned; slot 9 has no stored pin; the third has no slot at all. + let layout = manual(&[("1", 100, 50)]); + let out = arrange(&members, &layout); + assert_eq!(out[0], Placement { x: 100, y: 50 }, "pinned"); + assert_eq!(out[1], Placement { x: 2560, y: 0 }, "unpinned → auto-row"); + assert_eq!(out[2], Placement { x: 4480, y: 0 }, "slotless → auto-row"); + } + + #[test] + fn empty_group_is_empty() { + assert!(arrange(&[], &Layout::default()).is_empty()); + assert!(arrange(&[], &manual(&[("1", 0, 0)])).is_empty()); + } + + #[test] + fn negative_width_never_shifts_siblings_left() { + let members = [m(Some(1), -100), m(Some(2), 1920)]; + let out = arrange(&members, &Layout::default()); + let origin = Placement { x: 0, y: 0 }; + assert_eq!(out[0], origin); + assert_eq!(out[1], origin, "clamped width contributes 0"); + } +} diff --git a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs index f559b51..86a03cf 100644 --- a/crates/punktfunk-host/src/vdisplay/linux/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/linux/kwin.rs @@ -75,6 +75,13 @@ const MAX_VERSION: u32 = 5; #[derive(Default)] pub struct KwinDisplay { client_fp: Option<[u8; 32]>, + /// The identity slot the last [`create`](VirtualDisplay::create) resolved (the per-client id, or + /// `None` for shared/anonymous) — reported to the registry via [`last_identity_slot`] so it can key + /// the group arrangement + `/display/state` slot to the same id this backend named the output with. + last_slot: Option, + /// The base output name the last `create` used (`punktfunk` / `punktfunk-`) — so + /// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-`. + last_name: Option, } impl KwinDisplay { @@ -92,20 +99,45 @@ impl VirtualDisplay for KwinDisplay { self.client_fp = fingerprint; } + fn last_identity_slot(&self) -> Option { + self.last_slot + } + + fn apply_position(&mut self, x: i32, y: i32) { + let Some(name) = self.last_name.clone() else { + return; + }; + let output = format!("Virtual-{name}"); + // kscreen-doctor position syntax: `output..position.,`. + let ok = std::process::Command::new("kscreen-doctor") + .arg(format!("output.{output}.position.{x},{y}")) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if ok { + tracing::info!(output, x, y, "KWin: placed output in the desktop layout"); + } else { + tracing::warn!(output, x, y, "KWin: output position apply failed"); + } + } + fn create(&mut self, mode: Mode) -> Result { // Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id → // `punktfunk-` (KWin exposes `Virtual-punktfunk-`, whose per-output config KWin // persists by name). Shared / anonymous → the base `punktfunk` (today's single name). Linux // defaults to Shared when unconfigured, so this is a no-op change until a policy opts in — AND // it fixes the latent clash where two concurrent sessions both used `Virtual-punktfunk`. - let name = match crate::vdisplay::identity::resolve_slot( + let slot = crate::vdisplay::identity::resolve_slot( self.client_fp, (mode.width, mode.height), crate::vdisplay::policy::Identity::Shared, - ) { + ); + self.last_slot = slot; // reported to the registry for the group arrangement + state slot + let name = match slot { Some(id) => format!("{VOUT_NAME}-{id}"), None => VOUT_NAME.to_string(), }; + self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout) let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); @@ -149,6 +181,8 @@ impl VirtualDisplay for KwinDisplay { } Topology::Extend | Topology::Auto => Vec::new(), }; + // Layout position (§6.2) is applied by the registry via `apply_position` right after create + // (it owns the display group, so it computes auto-row / manual placement over the whole group). Ok(VirtualOutput { node_id, remote_fd: None, diff --git a/crates/punktfunk-host/src/vdisplay/policy.rs b/crates/punktfunk-host/src/vdisplay/policy.rs index 1987037..18b9df5 100644 --- a/crates/punktfunk-host/src/vdisplay/policy.rs +++ b/crates/punktfunk-host/src/vdisplay/policy.rs @@ -273,6 +273,29 @@ impl DisplayPolicy { } } +impl EffectivePolicy { + /// Build a persistable `Custom` [`DisplayPolicy`] that keeps THIS effective behavior but replaces + /// the arrangement with a **manual** layout at `positions` — the `/display/layout` endpoint's + /// transform, factored out pure so arranging displays stays orthogonal to the other axes and is + /// unit-tested without touching the global store. (`Custom` so the explicit fields — incl. the new + /// layout — rule; a named preset would ignore them.) + pub fn with_manual_layout(&self, positions: BTreeMap) -> DisplayPolicy { + DisplayPolicy { + version: 1, + preset: Preset::Custom, + keep_alive: self.keep_alive, + topology: self.topology, + mode_conflict: self.mode_conflict, + identity: self.identity, + layout: Layout { + mode: LayoutMode::Manual, + positions, + }, + max_displays: self.max_displays, + } + } +} + /// The field bundle a named preset expands to; `None` for [`Preset::Custom`]. The single expansion /// table — the docs' preset table mirrors this and the `presets_match_doc` test guards the shape. pub fn preset_fields(preset: Preset) -> Option { @@ -526,6 +549,33 @@ mod tests { assert_eq!(p.max_displays, 16); } + #[test] + fn with_manual_layout_preserves_behavior_and_sets_positions() { + // Start from a preset's effective behavior (workstation: 5-min linger, exclusive, per-client). + let eff = DisplayPolicy { + preset: Preset::Workstation, + ..DisplayPolicy::default() + } + .effective(); + let mut positions = BTreeMap::new(); + positions.insert("1".to_string(), Position { x: 0, y: 0 }); + positions.insert("7".to_string(), Position { x: 2560, y: 0 }); + let p = eff.with_manual_layout(positions); + // Preset drops to Custom so the explicit fields (incl. the layout) rule… + assert_eq!(p.preset, Preset::Custom); + // …every other behavior axis is preserved verbatim… + assert_eq!(p.keep_alive, eff.keep_alive); + assert_eq!(p.topology, eff.topology); + assert_eq!(p.mode_conflict, eff.mode_conflict); + assert_eq!(p.identity, eff.identity); + assert_eq!(p.max_displays, eff.max_displays); + // …and the arrangement is the manual layout we asked for, surviving the effective round-trip. + let e2 = p.effective(); + assert_eq!(e2.layout.mode, LayoutMode::Manual); + let want = Position { x: 2560, y: 0 }; + assert_eq!(e2.layout.positions.get("7"), Some(&want)); + } + #[test] fn partial_json_fills_defaults() { // A hand-written file with only a couple of fields loads, the rest defaulting. diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index 977b89d..94c975e 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -40,6 +40,19 @@ pub struct DisplayInfo { pub sessions: u32, /// Short client label (cert-fp prefix / peer), when the owner tracks it. pub client: Option, + /// Display **group** (shared desktop) id (design §6.1): Linux gives every backend session one + /// group; Windows is single-group (`1`). + pub group: u32, + /// This display's ordinal within its group, in acquire order (0-based) — the §6A "which monitor". + pub display_index: u32, + /// Desktop-space top-left origin `(x, y)` (design §6.2): auto-row, or the console's manual + /// arrangement when configured. + pub position: (i32, i32), + /// The stable per-client identity slot keying this display's persistent config + manual layout + /// (§5.4); `None` for a shared/anonymous identity. + pub identity_slot: Option, + /// The effective topology for this display's group (`"extend"` | `"primary"` | `"exclusive"`). + pub topology: String, } /// The live display set for the mgmt `/display/state` endpoint. @@ -48,6 +61,19 @@ pub struct Snapshot { pub displays: Vec, } +/// The effective display topology as a lowercase string for the snapshot (`effective_topology` +/// resolves `Auto` away; the arm is defensive). +fn topology_str() -> String { + use super::policy::Topology; + match super::effective_topology() { + Topology::Extend => "extend", + Topology::Primary => "primary", + Topology::Exclusive => "exclusive", + Topology::Auto => "auto", + } + .to_string() +} + /// Acquire a virtual display for a session: reuse a kept (lingering/pinned) display of the same /// backend + mode if one exists, else create a fresh one. Returns a [`VirtualOutput`](super::VirtualOutput) /// the capturer consumes as before — but its `keepalive` is a registry lease, so the *display* @@ -74,6 +100,9 @@ pub fn acquire( pub fn snapshot() -> Snapshot { #[cfg(target_os = "windows")] { + // Windows is single-monitor at this stage (§6.6 multi-monitor is Stage 7): one group, index 0, + // origin. Its per-client identity lives in the driver (EDID serial / ConnectorIndex), not + // surfaced here yet. let displays = super::manager::snapshot() .map(|i| DisplayInfo { slot: i.gen, @@ -83,6 +112,11 @@ pub fn snapshot() -> Snapshot { expires_in_ms: i.expires_in_ms, sessions: i.sessions, client: None, + group: 1, + display_index: 0, + position: (0, 0), + identity_slot: None, + topology: topology_str(), }) .into_iter() .collect(); @@ -137,7 +171,7 @@ mod linux { use super::DisplayInfo; use crate::vdisplay::lifecycle::{self, Release}; - use crate::vdisplay::policy::{self, Linger}; + use crate::vdisplay::policy::{self, Layout, Linger}; use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput}; /// One pooled display: the lifecycle state + the backend's REAL keepalive (kept alive here so the @@ -152,6 +186,10 @@ mod linux { preferred_mode: Option<(u32, u32, u32)>, mode: Mode, backend: &'static str, + /// The identity slot the backend resolved for this display (KWin per-slot naming; `None` for + /// shared/anonymous or a backend with no per-client identity) — keys the group arrangement + + /// the `/display/state` slot. Captured at create; kept across a keep-alive reuse. + identity_slot: Option, /// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease /// — its entry was reused + re-stamped — is a no-op). gen: u64, @@ -273,6 +311,9 @@ mod linux { // Create a fresh display (NOT under the lock — `vd.create` blocks + spawns threads). let real = vd.create(mode)?; + // The identity slot the backend just resolved (KWin per-slot naming; `None` elsewhere) — keys + // the group arrangement (manual per-slot positions) + the state slot. + let identity_slot = vd.last_identity_slot(); // wlroots (remote_fd = Some, sandboxed xdpw portal) can't be kept without re-opening the // portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The @@ -297,9 +338,49 @@ mod linux { preferred_mode, mode, backend, + identity_slot, gen, }; - r.entries.lock().unwrap().push(entry); + + // Compute this new display's position in its group (design §6.2) BEFORE pushing, then push + // under the same lock: the group is the same-backend entries; the new one appends last + // (rightmost under auto-row). `position_for_new` is pure; the lock is held only across it + // (I/O-free) — the backend apply is below, outside the lock. + let position = { + use crate::vdisplay::layout::Member; + let layout_policy = policy::prefs() + .configured_effective() + .map(|e| e.layout) + .unwrap_or_default(); + let mut es = r.entries.lock().unwrap(); + let existing: Vec<(u64, Member)> = es + .iter() + .filter(|e| e.backend == backend) + .map(|e| { + ( + e.gen, + Member { + identity_slot: e.identity_slot, + width: e.mode.width as i32, + }, + ) + }) + .collect(); + let new_member = Member { + identity_slot, + width: mode.width as i32, + }; + let pos = position_for_new(existing, new_member, &layout_policy); + es.push(entry); + pos + }; + // Place the new output (design §6.2), best-effort, OUTSIDE the lock (kscreen blocks). Skip the + // desktop origin `(0, 0)` — it's the compositor default, so a single-display / first-of-group + // session (and every non-KWin backend, which no-ops `apply_position`) issues no positioning at + // all: the historical single-display path is untouched. *On-glass-validation-pending.* + if (position.x, position.y) != (0, 0) { + vd.apply_position(position.x, position.y); + } Ok(output_for(node_id, preferred_mode, gen)) } @@ -343,38 +424,135 @@ mod linux { } } + /// One live/kept display, flattened out of the pool under the lock — so the group + arrangement + /// math (which calls the layout engine) runs OUTSIDE the lock. + struct Row { + gen: u64, + backend: &'static str, + mode: Mode, + identity_slot: Option, + state: &'static str, + expires_in_ms: Option, + sessions: u32, + } + pub(super) fn snapshot() -> Vec { let Some(r) = REG.get() else { return Vec::new(); }; let now = Instant::now(); - r.entries - .lock() - .unwrap() - .iter() - .filter_map(|e| { - let (state, expires_in_ms, sessions) = match e.life { - lifecycle::State::Active { refs } => ("active", None, refs), - lifecycle::State::Lingering { until } => ( - "lingering", - Some(until.saturating_duration_since(now).as_millis() as u64), - 0, - ), - lifecycle::State::Pinned => ("pinned", None, 0), - // Idle entries are never stored (removed on teardown). - lifecycle::State::Idle => return None, - }; - Some(DisplayInfo { - slot: e.gen, - backend: e.backend.to_string(), - mode: (e.mode.width, e.mode.height, e.mode.refresh_hz), - state: state.to_string(), - expires_in_ms, - sessions, - client: None, + + // Flatten the live/kept entries under the lock (skip Idle — never stored anyway). + let rows: Vec = { + let es = r.entries.lock().unwrap(); + es.iter() + .filter_map(|e| { + let (state, expires_in_ms, sessions) = match e.life { + lifecycle::State::Active { refs } => ("active", None, refs), + lifecycle::State::Lingering { until } => ( + "lingering", + Some(until.saturating_duration_since(now).as_millis() as u64), + 0, + ), + lifecycle::State::Pinned => ("pinned", None, 0), + lifecycle::State::Idle => return None, + }; + Some(Row { + gen: e.gen, + backend: e.backend, + mode: e.mode, + identity_slot: e.identity_slot, + state, + expires_in_ms, + sessions, + }) }) - }) - .collect() + .collect() + }; + + let topology = super::topology_str(); + // The arrangement policy: the console's manual layout when configured, else auto-row. + let layout_policy: Layout = policy::prefs() + .configured_effective() + .map(|e| e.layout) + .unwrap_or_default(); + + assemble_displays(rows, &layout_policy, &topology) + } + + /// The desktop position for a display just appended to its group (design §6.2): the group's + /// `existing` members (each with its acquire `gen`) plus `new` last, ordered by `gen`, arranged by + /// the pure [`layout`] engine, taking the new member's placement. Pure — so the append-in-acquire- + /// order + auto-row/manual arrangement is unit-tested independent of the pool/global. + fn position_for_new( + mut existing: Vec<(u64, crate::vdisplay::layout::Member)>, + new: crate::vdisplay::layout::Member, + layout_policy: &Layout, + ) -> crate::vdisplay::layout::Placement { + existing.sort_by_key(|(g, _)| *g); + let mut members: Vec = + existing.into_iter().map(|(_, m)| m).collect(); + members.push(new); + *crate::vdisplay::layout::arrange(&members, layout_policy) + .last() + .expect("members is non-empty (just pushed `new`)") + } + + /// Group the flattened rows into the mgmt `/display/state` view (design §6.1/§6.2): group = backend + /// (one desktop per compositor session), ordered by acquire (`gen`), with each member's position + /// from the pure [`layout`] engine. Pure — no I/O, no global — so the grouping / ordering / position + /// assignment is unit-tested against synthetic rows. + fn assemble_displays( + rows: Vec, + layout_policy: &Layout, + topology: &str, + ) -> Vec { + use crate::vdisplay::layout::{self, Member}; + + // Small stable group ids by sorted backend name — deterministic; in practice a host runs one + // live backend → group 1. + let mut backends: Vec<&'static str> = rows.iter().map(|row| row.backend).collect(); + backends.sort_unstable(); + backends.dedup(); + + let mut out: Vec = Vec::new(); + for (gi, backend) in backends.iter().enumerate() { + // This group's members in acquire order (gen ascending) → display_index + arrangement. + let mut idx: Vec = rows + .iter() + .enumerate() + .filter(|(_, row)| row.backend == *backend) + .map(|(i, _)| i) + .collect(); + idx.sort_by_key(|&i| rows[i].gen); + let members: Vec = idx + .iter() + .map(|&i| Member { + identity_slot: rows[i].identity_slot, + width: rows[i].mode.width as i32, + }) + .collect(); + let places = layout::arrange(&members, layout_policy); + for (ord, &i) in idx.iter().enumerate() { + let row = &rows[i]; + let p = places[ord]; + out.push(DisplayInfo { + slot: row.gen, + backend: row.backend.to_string(), + mode: (row.mode.width, row.mode.height, row.mode.refresh_hz), + state: row.state.to_string(), + expires_in_ms: row.expires_in_ms, + sessions: row.sessions, + client: None, + group: gi as u32 + 1, + display_index: ord as u32, + position: (p.x, p.y), + identity_slot: row.identity_slot, + topology: topology.to_string(), + }); + } + } + out } pub(super) fn force_release(slot: Option) -> usize { @@ -415,4 +593,105 @@ mod linux { release(self.gen); } } + + #[cfg(test)] + mod tests { + use super::*; + use crate::vdisplay::policy::{Layout, LayoutMode, Position}; + use std::collections::BTreeMap; + + fn row(gen: u64, backend: &'static str, w: u32, slot: Option) -> Row { + Row { + gen, + backend, + mode: Mode { + width: w, + height: 1080, + refresh_hz: 60, + }, + identity_slot: slot, + state: "active", + expires_in_ms: None, + sessions: 1, + } + } + + #[test] + fn groups_by_backend_and_auto_rows_in_acquire_order() { + // Two KWin displays (acquired gen 5 then gen 2 — deliberately out of vec order) + a Mutter one. + let rows = vec![ + row(5, "kwin", 2560, Some(1)), + row(2, "kwin", 1920, Some(7)), + row(9, "mutter", 3840, None), + ]; + let out = assemble_displays(rows, &Layout::default(), "exclusive"); + + let kwin: Vec<&DisplayInfo> = out.iter().filter(|d| d.backend == "kwin").collect(); + assert_eq!(kwin.len(), 2); + assert_eq!(kwin[0].slot, 2); // lower gen (earlier acquire) sorts to index 0 + assert_eq!(kwin[0].display_index, 0); + assert_eq!(kwin[0].position, (0, 0)); + assert_eq!(kwin[1].slot, 5); + assert_eq!(kwin[1].display_index, 1); + assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px gen-2 display + assert_eq!(kwin[0].topology, "exclusive"); + + // A distinct backend is a distinct group. + let mutter = out.iter().find(|d| d.backend == "mutter").unwrap(); + assert_ne!(mutter.group, kwin[0].group); + assert_eq!(mutter.display_index, 0); + assert_eq!(mutter.position, (0, 0)); + } + + #[test] + fn position_for_new_appends_right_in_acquire_order() { + use crate::vdisplay::layout::{Member, Placement}; + let m = |slot, w| Member { + identity_slot: slot, + width: w, + }; + // Existing group (given out of gen order): gen 8 @ 1920 acquired AFTER gen 3 @ 2560. + let existing = vec![(8, m(Some(2), 1920)), (3, m(Some(1), 2560))]; + // A new 1280-wide display appends to the right of 2560 + 1920. + let pos = position_for_new(existing, m(Some(5), 1280), &Layout::default()); + assert_eq!(pos, Placement { x: 4480, y: 0 }); + // First-of-group lands at the origin (so the registry skips the apply). + let first = position_for_new(vec![], m(None, 3840), &Layout::default()); + assert_eq!(first, Placement { x: 0, y: 0 }); + } + + #[test] + fn position_for_new_honors_a_manual_pin() { + use crate::vdisplay::layout::{Member, Placement}; + let mut positions = BTreeMap::new(); + positions.insert("5".to_string(), Position { x: 100, y: 200 }); + let layout = Layout { + mode: LayoutMode::Manual, + positions, + }; + let new = Member { + identity_slot: Some(5), + width: 1280, + }; + let pos = position_for_new(vec![(1, new)], new, &layout); + assert_eq!(pos, Placement { x: 100, y: 200 }); + } + + #[test] + fn manual_layout_keys_positions_by_identity_slot() { + // Client 7 arranged to the LEFT of client 1 (reversed vs. auto-row). + let rows = vec![row(1, "kwin", 2560, Some(1)), row(2, "kwin", 1920, Some(7))]; + let mut positions = BTreeMap::new(); + positions.insert("1".to_string(), Position { x: 1920, y: 0 }); + positions.insert("7".to_string(), Position { x: 0, y: 0 }); + let layout = Layout { + mode: LayoutMode::Manual, + positions, + }; + let out = assemble_displays(rows, &layout, "extend"); + let by_slot = |s: u32| out.iter().find(|d| d.identity_slot == Some(s)).unwrap(); + assert_eq!(by_slot(1).position, (1920, 0)); + assert_eq!(by_slot(7).position, (0, 0)); + } + } } diff --git a/design/display-management.md b/design/display-management.md index 06818f7..b6d3038 100644 --- a/design/display-management.md +++ b/design/display-management.md @@ -1,8 +1,11 @@ # Virtual-display management & lifecycle policy — design -> **Status (2026-07-05):** **Stages 0–4 DONE + on-glass validated; Stage 5 STARTED** (branch -> `display-mgmt-stage0`, not yet merged). See the **Status — handoff** block under §11 for the -> per-stage state, the key decisions (notably the Windows `reject` default), and what's left. +> **Status (2026-07-05):** **Stages 0–4 DONE + on-glass validated; Stage 5 IN PROGRESS** (branch +> `display-mgmt-stage0`, not yet merged). Stage 5 so far: group-aware KWin `exclusive` (§6.1) + the +> **layout foundation** — a pure arrangement engine (`vdisplay/layout.rs`, auto-row + manual), the +> `PUT /display/layout` mgmt endpoint, group/position/index surfaced in `/display/state`, and KWin +> manual-position apply. See the **Status — handoff** block under §11 for the per-stage state, the key +> decisions (notably the Windows `reject` default), and what's left. > This doc designs a **policy layer on top of the > existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive > after disconnect), topology (primary / exclusive), conflict handling (what happens when a second @@ -667,9 +670,17 @@ GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `f via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`), 4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies). -- **Stage 5: STARTED** — only the critical §6.1 **group-aware exclusive** fix for KWin has landed +- **Stage 5: IN PROGRESS.** Landed: (a) the §6.1 **group-aware exclusive** fix for KWin (`kwin.rs` `MANAGED_PREFIX` + first-slot-wins), unit-tested but NOT yet driven by two concurrent - sessions on-glass. Everything else in Stage 5 is TODO. + sessions on-glass; (b) the **layout foundation** — a pure arrangement engine + (`vdisplay/layout.rs::arrange`, auto-row + manual, unit-tested), a group model in the Linux registry + (group = backend; `/display/state` now carries `group`/`display_index`/`position`/`identity_slot`/ + `topology`, positions computed via the engine), the `PUT /api/v1/display/layout` endpoint (persists a + manual arrangement via the pure `EffectivePolicy::with_manual_layout` transform), and KWin + **manual-position apply** at create (`apply_manual_position`, guarded + best-effort — a no-op under + auto-row / an unpinned slot, so the default path is untouched). The registry reads the backend's + resolved slot via a new `VirtualDisplay::last_identity_slot` (only KWin reports one), so the + arrangement + state honestly key on per-client identity. Still TODO in Stage 5 (below). **Decisions / deltas from this plan as written — read before continuing:** - **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two @@ -723,15 +734,46 @@ Stage-5 group-aware exclusive. `join`/silent-reconfigure originally planned** — see the handoff Decisions above (single-capturer IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503 unit-tested, Moonlight-pending. -- **Stage 5 — §6A multi-client monitors. [STARTED]** Display groups, group-aware exclusive/primary/ +- **Stage 5 — §6A multi-client monitors. [IN PROGRESS]** Display groups, group-aware exclusive/primary/ restore (incl. the name-filter fix), layout auto-row + manual, `/display/layout`, console arrangement table. Cheap: rides Stages 1–3 infrastructure, no protocol change. - **Done so far:** KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by - the `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group - primary, unit-tested. **TODO:** Mutter + wlroots group-aware analogues (Mutter is more involved — its - sole-monitor `ApplyMonitorsConfig` must include ALL group virtuals, not just its own); layout - auto-row + manual + `/display/layout` + console table; per-group topology restore (restore the - physical only when the group's LAST member drops); gamescope groups (single-output → decline extras). + **Done so far:** + - KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by the + `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group primary, + unit-tested. + - **Layout engine** (`vdisplay/layout.rs::arrange`): pure auto-row (left-to-right in acquire order, + top-aligned) + manual (per-identity-slot offsets, auto-row fallback for unpinned members), + unit-tested. `manual_position` helper for a single backend-local apply. + - **Registry group model** (Linux): group = backend (one desktop per compositor session); the + `/display/state` snapshot groups entries, orders by acquire (gen), and computes each member's + `position` via the engine. `DisplayInfo` now carries `group` / `display_index` / `position` / + `identity_slot` / `topology`. The backend reports its resolved slot via the new + `VirtualDisplay::last_identity_slot` (KWin only), so the arrangement + state key on per-client identity. + - **`PUT /api/v1/display/layout`**: persists the console's manual arrangement (positions keyed by + identity slot) via the pure `EffectivePolicy::with_manual_layout` transform (locks the current + effective behavior into explicit `Custom` fields + sets a manual layout — arranging is orthogonal to + the other axes). OpenAPI regenerated. + - **Registry-driven position apply** (`VirtualDisplay::apply_position(x, y)`, default no-op; KWin + implements it via `kscreen-doctor output..position.,`): the registry owns the group, so + right after `create` it computes the new display's position over the whole group via the pure + `position_for_new` (existing same-backend members in acquire order + the new one appended last → + `layout::arrange` → the new member's placement) and calls `apply_position`. This makes **both** + auto-row (deterministic left-to-right, not just the compositor's default) **and** manual placement + go through one seam. Guarded: the registry skips the desktop origin `(0, 0)`, so a single-display / + first-of-group session (and every non-KWin backend, which no-ops `apply_position`) issues no + positioning at all — the historical single-display path is byte-for-byte unchanged. `position_for_new` + is unit-tested. *On-glass-validation-pending (kscreen positioning of a live virtual output).* + **TODO (still Stage 5):** + - **Mutter + wlroots group-aware analogues** (Mutter is more involved — its sole-monitor + `ApplyMonitorsConfig` must include ALL group virtuals, not just its own; it can't name-filter like + KWin — the registry must tell it which pre-existing connectors are managed siblings). + - **Per-group topology restore** (restore the physical only when the group's LAST member drops): KWin's + `exclusive` acquire is group-aware, but its RESTORE is still per-display (the `StopGuard` re-enables + the physical on its own teardown), so the first sibling out re-enables the physical while a sibling is + still exclusive. The clean fix moves the restore into a registry group record (run once, when the + group empties, ordered BEFORE the last member's output is reclaimed so KWin never sees zero outputs). + Needs two-session on-glass to validate — deferred alongside the group-aware-exclusive on-glass item. + - Console arrangement table (web, x/y first); gamescope groups (single-output → decline extras, §6B). *Validate:* two clients (probe + GTK) on the headless KDE box forming a 2-output desktop; drag a window across; disconnect one → its slot lingers per policy, sibling unaffected, restore only after both drop.