refactor(host/W6.2): extract virtual-display orchestration into the pf-vdisplay crate
vdisplay.rs + vdisplay/* (the per-compositor Linux backends — KWin zkde-screencast,
wlroots swaymsg, Mutter RemoteDesktop, Hyprland — and the Windows IddCx/pf-vdisplay
driver backend, behind one VirtualDisplay trait; the mode-conflict admission
registry, the display policy/identity/custom-preset state, and the session-env /
gamescope routing) move into crates/pf-vdisplay (plan §W6). The DDC/CI panel-power
control (used only here) and the KWin zkde protocol XML move with it. This
completes the host-crate decomposition: capture, encode, inject, and vdisplay are
now four subsystem crates over the shared leaves, and punktfunk-host is the
orchestrator (serve/supervisor + native + gamestream + mgmt).
Coupling breaks (all down-only, cargo-tree acyclic):
- capture::dxgi identity -> pf_frame::dxgi; win_display/monitor_devnode/
console_session_mismatch -> pf-win-display leaf; can_open_another_session ->
pf-encode (the NVENC session-budget admission gate — acyclic peer edge).
- The registry's DisplayCreated/DisplayReleased emits into the host SSE event bus
invert to a leaf hook: pf-vdisplay emits a neutral DisplayEvent to a
host-registered DISPLAY_EVENT_SINK, so it never reaches the orchestrator's
events module.
- The IddCx driver module is renamed pf_vdisplay -> driver (its old name collided
with the crate name through the host's `mod vdisplay` shim glob).
The host keeps `mod vdisplay { pub use pf_vdisplay::* }` so every crate::vdisplay::*
path (serve/mgmt/native/the capture FrameChannelSender seam) is unchanged; the
heavy deps (wayland/ashpd/tokio + the zkde protocol) moved with the crate.
Co-authored: a fail-closed IOCTL-reply-length security fix (reject short/zeroed
pf-vdisplay driver replies before trusting protocol_version/target_id/wudf_pid/luid,
security-review 2026-07-17) rides this commit in the moved driver module.
Verified: Linux clippy -D warnings (pf-vdisplay + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-vdisplay 63/63 + host 167/167 tests; Windows clippy -D warnings
(pf-vdisplay --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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::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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user