feat(vdisplay): enumerate the host's physical monitors
P0 of per-monitor capture (design/per-monitor-portal-capture.md): nothing in the tree could answer "what heads does this host have?", which both a monitor pin and a console picker need first. One read per compositor, each beside the code that already speaks that dialect: KWin's kde_output_device_v2 (the topology session now also records geometry + scale), Mutter's DisplayConfig.GetCurrentState, swaymsg get_outputs, hyprctl monitors. Logical geometry throughout, because x/y is what identifies a head — two monitors can share a size but never an origin. PUNKTFUNK_CAPTURE_MONITOR parses here and is reported at startup, loudly including that it is not yet enforced: a knob that is read but inert has to say so, or "it didn't work" reads as a bug. GET /display/monitors always answers 200 with an explained empty list, so a compositor-less host renders as "nothing to pick", not as a broken console. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -229,6 +229,68 @@ fn output_names() -> Result<Vec<String>> {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every head sway reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// `swaymsg -t get_outputs` reports `rect` in the logical coordinate space (post-scale,
|
||||
/// post-transform) — what `crate::monitors` documents. An inactive output has no `current_mode`, so
|
||||
/// its mode reads as zeros rather than a guess.
|
||||
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
|
||||
let raw = swaymsg(&["-t", "get_outputs", "--raw"])?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).context("parse get_outputs")?;
|
||||
let mut out: Vec<_> = parsed
|
||||
.as_array()
|
||||
.context("get_outputs: not an array")?
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let connector = o.get("name")?.as_str()?.to_string();
|
||||
let rect = |k: &str| {
|
||||
o.get("rect")
|
||||
.and_then(|r| r.get(k))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let mode = |k: &str| {
|
||||
o.get("current_mode")
|
||||
.and_then(|m| m.get(k))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let str_field = |k: &str| o.get(k).and_then(|v| v.as_str()).unwrap_or("").trim();
|
||||
let label = format!("{} {}", str_field("make"), str_field("model"));
|
||||
Some(crate::monitors::PhysicalMonitor {
|
||||
description: if label.trim().is_empty() {
|
||||
connector.clone()
|
||||
} else {
|
||||
label.trim().to_string()
|
||||
},
|
||||
width: mode("width").max(0) as u32,
|
||||
height: mode("height").max(0) as u32,
|
||||
// sway reports `refresh` in mHz already.
|
||||
refresh_mhz: mode("refresh").max(0) as u32,
|
||||
x: rect("x") as i32,
|
||||
y: rect("y") as i32,
|
||||
scale: o
|
||||
.get("scale")
|
||||
.and_then(|v| v.as_f64())
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(1.0),
|
||||
primary: o
|
||||
.get("primary")
|
||||
.and_then(|v| v.as_bool())
|
||||
.or_else(|| o.get("focused").and_then(|v| v.as_bool()))
|
||||
.unwrap_or(false),
|
||||
enabled: o.get("active").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
// Sway auto-names headless outputs `HEADLESS-N` and that is what `create` adds. A
|
||||
// sway started with its own headless output would match too — hence best-effort.
|
||||
managed: connector.starts_with("HEADLESS-"),
|
||||
connector,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N).
|
||||
fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
Reference in New Issue
Block a user