feat(vdisplay): Stage 5 layout foundation — arrangement engine + /display/layout + group placement

§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) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 12:28:46 +00:00
parent a5dc3134de
commit e0f15822ae
8 changed files with 804 additions and 49 deletions
+110 -2
View File
@@ -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": { "/api/v1/display/release": {
"post": { "post": {
"tags": [ "tags": [
@@ -1775,7 +1827,12 @@
"backend", "backend",
"mode", "mode",
"state", "state",
"sessions" "sessions",
"group",
"display_index",
"x",
"y",
"topology"
], ],
"properties": { "properties": {
"backend": { "backend": {
@@ -1789,6 +1846,12 @@
], ],
"description": "Short client label, when the owner tracks it." "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": { "expires_in_ms": {
"type": [ "type": [
"integer", "integer",
@@ -1798,6 +1861,21 @@
"description": "Milliseconds until a lingering display is torn down (absent when active/pinned).", "description": "Milliseconds until a lingering display is torn down (absent when active/pinned).",
"minimum": 0 "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": { "mode": {
"type": "string", "type": "string",
"description": "`WIDTHxHEIGHT@HZ`." "description": "`WIDTHxHEIGHT@HZ`."
@@ -1817,6 +1895,20 @@
"state": { "state": {
"type": "string", "type": "string",
"description": "`active` | `lingering` | `pinned`." "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": "`{\"<identity_slot>\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits.",
"additionalProperties": {
"$ref": "#/components/schemas/Position"
},
"propertyNames": {
"type": "string"
}
}
}
},
"DisplayPolicy": { "DisplayPolicy": {
"type": "object", "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`].", "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": { "items": {
"type": "string" "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": { "presets": {
"type": "array", "type": "array",
+83 -5
View File
@@ -160,6 +160,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(set_display_settings)) .routes(routes!(set_display_settings))
.routes(routes!(get_display_state)) .routes(routes!(get_display_state))
.routes(routes!(release_display)) .routes(routes!(release_display))
.routes(routes!(set_display_layout))
.routes(routes!(get_status)) .routes(routes!(get_status))
.routes(routes!(get_local_summary)) .routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients)) .routes(routes!(list_paired_clients))
@@ -988,9 +989,10 @@ struct DisplaySettingsState {
effective: crate::vdisplay::policy::EffectivePolicy, effective: crate::vdisplay::policy::EffectivePolicy,
/// Every named preset and what it expands to (for the picker's preview). /// Every named preset and what it expands to (for the picker's preview).
presets: Vec<PresetInfo>, presets: Vec<PresetInfo>,
/// Option names this build enforces right now (e.g. `keep_alive`, `topology`). The remaining /// Option names this build enforces right now. All five axes are now acted on (keep_alive +
/// stored options (`mode_conflict`, `identity`, `layout`) land in later stages — surfaced so the /// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console
/// console can mark them "coming soon" instead of implying they already take effect. /// 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<String>, enforced: Vec<String>,
} }
@@ -1031,7 +1033,13 @@ fn display_settings_state() -> DisplaySettingsState {
settings, settings,
configured, configured,
presets, 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, sessions: u32,
/// Short client label, when the owner tracks it. /// Short client label, when the owner tracks it.
client: Option<String>, client: Option<String>,
/// 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<u32>,
/// Effective topology for this display's group (`extend` | `primary` | `exclusive`).
topology: String,
} }
/// The host's managed virtual displays right now. /// The host's managed virtual displays right now.
@@ -1166,6 +1186,12 @@ async fn get_display_state() -> Json<DisplayStateResponse> {
expires_in_ms: d.expires_in_ms, expires_in_ms: d.expires_in_ms,
sessions: d.sessions, sessions: d.sessions,
client: d.client, 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(), .collect(),
}) })
@@ -1195,6 +1221,53 @@ async fn release_display(
Json(ReleaseDisplayResult { released }) 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 {
/// `{"<identity_slot>": {"x": …, "y": …}}` — where each arranged display's top-left sits.
#[serde(default)]
positions: std::collections::BTreeMap<String, crate::vdisplay::policy::Position>,
}
/// 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<DisplayLayoutRequest>) -> 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 /// Live host status
#[utoipa::path( #[utoipa::path(
get, get,
@@ -2740,7 +2813,12 @@ mod tests {
.iter() .iter()
.filter_map(|v| v.as_str()) .filter_map(|v| v.as_str())
.collect(); .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). // `gaming-rig` expands to keep_alive: forever → rejected at Stage 0 (before any write).
let put = axum::http::Request::put("/api/v1/display/settings") let put = axum::http::Request::put("/api/v1/display/settings")
+22
View File
@@ -64,6 +64,23 @@ pub trait VirtualDisplay: Send {
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual /// 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. /// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {} 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<u32> {
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). /// Compositors punktfunk knows how to drive (plan §6).
@@ -729,6 +746,11 @@ pub(crate) mod lifecycle;
#[path = "vdisplay/registry.rs"] #[path = "vdisplay/registry.rs"]
pub(crate) mod registry; 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` /// 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 /// 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 /// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
@@ -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<u32>,
/// 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<Placement> {
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<u32>, 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");
}
}
@@ -75,6 +75,13 @@ const MAX_VERSION: u32 = 5;
#[derive(Default)] #[derive(Default)]
pub struct KwinDisplay { pub struct KwinDisplay {
client_fp: Option<[u8; 32]>, 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<u32>,
/// The base output name the last `create` used (`punktfunk` / `punktfunk-<id>`) — so
/// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-<name>`.
last_name: Option<String>,
} }
impl KwinDisplay { impl KwinDisplay {
@@ -92,20 +99,45 @@ impl VirtualDisplay for KwinDisplay {
self.client_fp = fingerprint; self.client_fp = fingerprint;
} }
fn last_identity_slot(&self) -> Option<u32> {
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.<name>.position.<x>,<y>`.
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<VirtualOutput> { fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id → // Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id →
// `punktfunk-<id>` (KWin exposes `Virtual-punktfunk-<id>`, whose per-output config KWin // `punktfunk-<id>` (KWin exposes `Virtual-punktfunk-<id>`, whose per-output config KWin
// persists by name). Shared / anonymous → the base `punktfunk` (today's single name). Linux // 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 // 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`. // 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, self.client_fp,
(mode.width, mode.height), (mode.width, mode.height),
crate::vdisplay::policy::Identity::Shared, 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}"), Some(id) => format!("{VOUT_NAME}-{id}"),
None => VOUT_NAME.to_string(), 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::<Result<u32, String>>(); let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone(); let stop_thread = stop.clone();
@@ -149,6 +181,8 @@ impl VirtualDisplay for KwinDisplay {
} }
Topology::Extend | Topology::Auto => Vec::new(), 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 { Ok(VirtualOutput {
node_id, node_id,
remote_fd: None, remote_fd: None,
@@ -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<String, Position>) -> 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 /// 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. /// table — the docs' preset table mirrors this and the `presets_match_doc` test guards the shape.
pub fn preset_fields(preset: Preset) -> Option<EffectivePolicy> { pub fn preset_fields(preset: Preset) -> Option<EffectivePolicy> {
@@ -526,6 +549,33 @@ mod tests {
assert_eq!(p.max_displays, 16); 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] #[test]
fn partial_json_fills_defaults() { fn partial_json_fills_defaults() {
// A hand-written file with only a couple of fields loads, the rest defaulting. // A hand-written file with only a couple of fields loads, the rest defaulting.
+292 -13
View File
@@ -40,6 +40,19 @@ pub struct DisplayInfo {
pub sessions: u32, pub sessions: u32,
/// Short client label (cert-fp prefix / peer), when the owner tracks it. /// Short client label (cert-fp prefix / peer), when the owner tracks it.
pub client: Option<String>, pub client: Option<String>,
/// 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<u32>,
/// The effective topology for this display's group (`"extend"` | `"primary"` | `"exclusive"`).
pub topology: String,
} }
/// The live display set for the mgmt `/display/state` endpoint. /// The live display set for the mgmt `/display/state` endpoint.
@@ -48,6 +61,19 @@ pub struct Snapshot {
pub displays: Vec<DisplayInfo>, pub displays: Vec<DisplayInfo>,
} }
/// 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 /// 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) /// 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* /// 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 { pub fn snapshot() -> Snapshot {
#[cfg(target_os = "windows")] #[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() let displays = super::manager::snapshot()
.map(|i| DisplayInfo { .map(|i| DisplayInfo {
slot: i.gen, slot: i.gen,
@@ -83,6 +112,11 @@ pub fn snapshot() -> Snapshot {
expires_in_ms: i.expires_in_ms, expires_in_ms: i.expires_in_ms,
sessions: i.sessions, sessions: i.sessions,
client: None, client: None,
group: 1,
display_index: 0,
position: (0, 0),
identity_slot: None,
topology: topology_str(),
}) })
.into_iter() .into_iter()
.collect(); .collect();
@@ -137,7 +171,7 @@ mod linux {
use super::DisplayInfo; use super::DisplayInfo;
use crate::vdisplay::lifecycle::{self, Release}; 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}; use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput};
/// One pooled display: the lifecycle state + the backend's REAL keepalive (kept alive here so the /// 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)>, preferred_mode: Option<(u32, u32, u32)>,
mode: Mode, mode: Mode,
backend: &'static str, 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<u32>,
/// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease /// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease
/// — its entry was reused + re-stamped — is a no-op). /// — its entry was reused + re-stamped — is a no-op).
gen: u64, gen: u64,
@@ -273,6 +311,9 @@ mod linux {
// Create a fresh display (NOT under the lock — `vd.create` blocks + spawns threads). // Create a fresh display (NOT under the lock — `vd.create` blocks + spawns threads).
let real = vd.create(mode)?; 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 // 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 // portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The
@@ -297,9 +338,49 @@ mod linux {
preferred_mode, preferred_mode,
mode, mode,
backend, backend,
identity_slot,
gen, 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)) Ok(output_for(node_id, preferred_mode, gen))
} }
@@ -343,15 +424,28 @@ 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<u32>,
state: &'static str,
expires_in_ms: Option<u64>,
sessions: u32,
}
pub(super) fn snapshot() -> Vec<DisplayInfo> { pub(super) fn snapshot() -> Vec<DisplayInfo> {
let Some(r) = REG.get() else { let Some(r) = REG.get() else {
return Vec::new(); return Vec::new();
}; };
let now = Instant::now(); let now = Instant::now();
r.entries
.lock() // Flatten the live/kept entries under the lock (skip Idle — never stored anyway).
.unwrap() let rows: Vec<Row> = {
.iter() let es = r.entries.lock().unwrap();
es.iter()
.filter_map(|e| { .filter_map(|e| {
let (state, expires_in_ms, sessions) = match e.life { let (state, expires_in_ms, sessions) = match e.life {
lifecycle::State::Active { refs } => ("active", None, refs), lifecycle::State::Active { refs } => ("active", None, refs),
@@ -361,20 +455,104 @@ mod linux {
0, 0,
), ),
lifecycle::State::Pinned => ("pinned", None, 0), lifecycle::State::Pinned => ("pinned", None, 0),
// Idle entries are never stored (removed on teardown).
lifecycle::State::Idle => return None, lifecycle::State::Idle => return None,
}; };
Some(DisplayInfo { Some(Row {
slot: e.gen, gen: e.gen,
backend: e.backend.to_string(), backend: e.backend,
mode: (e.mode.width, e.mode.height, e.mode.refresh_hz), mode: e.mode,
state: state.to_string(), identity_slot: e.identity_slot,
state,
expires_in_ms, expires_in_ms,
sessions, sessions,
client: None,
}) })
}) })
.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<crate::vdisplay::layout::Member> =
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<Row>,
layout_policy: &Layout,
topology: &str,
) -> Vec<DisplayInfo> {
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<DisplayInfo> = 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<usize> = rows
.iter()
.enumerate()
.filter(|(_, row)| row.backend == *backend)
.map(|(i, _)| i)
.collect();
idx.sort_by_key(|&i| rows[i].gen);
let members: Vec<Member> = 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<u64>) -> usize { pub(super) fn force_release(slot: Option<u64>) -> usize {
@@ -415,4 +593,105 @@ mod linux {
release(self.gen); 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<u32>) -> 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));
}
}
} }
+54 -12
View File
@@ -1,8 +1,11 @@
# Virtual-display management & lifecycle policy — design # Virtual-display management & lifecycle policy — design
> **Status (2026-07-05):** **Stages 04 DONE + on-glass validated; Stage 5 STARTED** (branch > **Status (2026-07-05):** **Stages 04 DONE + on-glass validated; Stage 5 IN PROGRESS** (branch
> `display-mgmt-stage0`, not yet merged). See the **Status — handoff** block under §11 for the > `display-mgmt-stage0`, not yet merged). Stage 5 so far: group-aware KWin `exclusive` (§6.1) + the
> per-stage state, the key decisions (notably the Windows `reject` default), and what's left. > **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 > This doc designs a **policy layer on top of the
> existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive > existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive
> after disconnect), topology (primary / exclusive), conflict handling (what happens when a second > 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 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`), 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). 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 (`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:** **Decisions / deltas from this plan as written — read before continuing:**
- **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two - **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 `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 IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503
unit-tested, Moonlight-pending. 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 restore (incl. the name-filter fix), layout auto-row + manual, `/display/layout`, console
arrangement table. Cheap: rides Stages 13 infrastructure, no protocol change. arrangement table. Cheap: rides Stages 13 infrastructure, no protocol change.
**Done so far:** KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by **Done so far:**
the `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group - KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by the
primary, unit-tested. **TODO:** Mutter + wlroots group-aware analogues (Mutter is more involved — its `Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group primary,
sole-monitor `ApplyMonitorsConfig` must include ALL group virtuals, not just its own); layout unit-tested.
auto-row + manual + `/display/layout` + console table; per-group topology restore (restore the - **Layout engine** (`vdisplay/layout.rs::arrange`): pure auto-row (left-to-right in acquire order,
physical only when the group's LAST member drops); gamescope groups (single-output → decline extras). 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.<n>.position.<x>,<y>`): 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; *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, drag a window across; disconnect one → its slot lingers per policy, sibling unaffected,
restore only after both drop. restore only after both drop.