e0f15822ae
§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>
143 lines
5.5 KiB
Rust
143 lines
5.5 KiB
Rust
//! 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");
|
|
}
|
|
}
|