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
+54 -12
View File
@@ -1,8 +1,11 @@
# Virtual-display management & lifecycle policy — design
> **Status (2026-07-05):** **Stages 04 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 04 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 13 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.