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:
+110
-2
@@ -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": "`{\"<identity_slot>\": {\"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",
|
||||
|
||||
@@ -160,6 +160,7 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, 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<PresetInfo>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
@@ -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<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.
|
||||
@@ -1166,6 +1186,12 @@ async fn get_display_state() -> Json<DisplayStateResponse> {
|
||||
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 {
|
||||
/// `{"<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
|
||||
#[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")
|
||||
|
||||
@@ -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<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).
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
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<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 {
|
||||
@@ -92,20 +99,45 @@ impl VirtualDisplay for KwinDisplay {
|
||||
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> {
|
||||
// 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
|
||||
// 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::<Result<u32, String>>();
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
/// table — the docs' preset table mirrors this and the `presets_match_doc` test guards the shape.
|
||||
pub fn preset_fields(preset: Preset) -> Option<EffectivePolicy> {
|
||||
@@ -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.
|
||||
|
||||
@@ -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<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.
|
||||
@@ -48,6 +61,19 @@ pub struct Snapshot {
|
||||
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
|
||||
/// 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<u32>,
|
||||
/// 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,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> {
|
||||
let Some(r) = REG.get() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let now = Instant::now();
|
||||
r.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
|
||||
// Flatten the live/kept entries under the lock (skip Idle — never stored anyway).
|
||||
let rows: Vec<Row> = {
|
||||
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),
|
||||
@@ -361,20 +455,104 @@ mod linux {
|
||||
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(),
|
||||
Some(Row {
|
||||
gen: e.gen,
|
||||
backend: e.backend,
|
||||
mode: e.mode,
|
||||
identity_slot: e.identity_slot,
|
||||
state,
|
||||
expires_in_ms,
|
||||
sessions,
|
||||
client: None,
|
||||
})
|
||||
})
|
||||
.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 {
|
||||
@@ -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<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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.<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;
|
||||
drag a window across; disconnect one → its slot lingers per policy, sibling unaffected,
|
||||
restore only after both drop.
|
||||
|
||||
Reference in New Issue
Block a user