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:
@@ -272,6 +272,58 @@ fn hyprctl(args: &[&str]) -> Result<String> {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Every head Hyprland reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// `hyprctl -j monitors all` (rather than plain `monitors`) so **disabled** heads are listed too —
|
||||
/// a picker that silently omits the monitor the operator is trying to name is worse than one that
|
||||
/// shows it greyed out. Hyprland reports geometry post-transform in logical pixels, which is the
|
||||
/// space `crate::monitors` documents.
|
||||
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
|
||||
let raw = hyprctl(&["-j", "monitors", "all"])?;
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&raw).context("parse hyprctl -j monitors all")?;
|
||||
let mut out: Vec<_> = parsed
|
||||
.as_array()
|
||||
.context("hyprctl monitors: not an array")?
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let connector = m.get("name")?.as_str()?.to_string();
|
||||
let num = |k: &str| m.get(k).and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let description = m
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.unwrap_or(&connector)
|
||||
.to_string();
|
||||
Some(crate::monitors::PhysicalMonitor {
|
||||
connector,
|
||||
description,
|
||||
width: num("width").max(0) as u32,
|
||||
height: num("height").max(0) as u32,
|
||||
// `refreshRate` is Hz as a float.
|
||||
refresh_mhz: (m.get("refreshRate").and_then(|v| v.as_f64()).unwrap_or(0.0) * 1000.0)
|
||||
as u32,
|
||||
x: num("x") as i32,
|
||||
y: num("y") as i32,
|
||||
scale: m
|
||||
.get("scale")
|
||||
.and_then(|v| v.as_f64())
|
||||
.filter(|s| *s > 0.0)
|
||||
.unwrap_or(1.0),
|
||||
primary: m.get("focused").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
enabled: !m.get("disabled").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
// Our headless outputs are named `PF-<n>` (see `next_output_name`).
|
||||
managed: m
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|n| n.starts_with("PF-")),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Run a `hyprctl` **dispatch** command (`output …`, `keyword …`, `eval …`) that reports success by
|
||||
/// printing `ok`. hyprctl often exits 0 even when the command is rejected, printing the error to
|
||||
/// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the
|
||||
|
||||
@@ -131,6 +131,14 @@ struct DeviceState {
|
||||
name: Option<String>,
|
||||
uuid: Option<String>,
|
||||
enabled: bool,
|
||||
/// Top-left in the compositor's global logical space, from the `geometry` event — the key that
|
||||
/// makes a head identifiable when two of them share a size (`monitors::PhysicalMonitor`).
|
||||
position: (i32, i32),
|
||||
/// `make` / `model` from the same event, for the picker label.
|
||||
make: Option<String>,
|
||||
model: Option<String>,
|
||||
/// Logical scale from the `scale` event. `None` ⇒ the protocol's documented default of 1.
|
||||
scale: Option<f64>,
|
||||
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
|
||||
priority: Option<u32>,
|
||||
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
|
||||
@@ -237,6 +245,15 @@ impl Dispatch<OutputDevice, u32> for State {
|
||||
match event {
|
||||
DeviceEvent::Name { name } => entry.name = Some(name),
|
||||
DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
|
||||
DeviceEvent::Geometry {
|
||||
x, y, make, model, ..
|
||||
} => {
|
||||
entry.position = (x, y);
|
||||
entry.make = Some(make);
|
||||
entry.model = Some(model);
|
||||
}
|
||||
// `fixed` decodes to f64 in wayland-rs.
|
||||
DeviceEvent::Scale { factor } => entry.scale = Some(factor),
|
||||
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
|
||||
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
|
||||
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
|
||||
@@ -455,6 +472,56 @@ fn mode_spec(dims: (u32, u32, u32)) -> String {
|
||||
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
|
||||
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
||||
|
||||
/// Every head KWin reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// Reuses the same bounded enumerate-only session the topology path opens — no configuration is
|
||||
/// built and nothing is applied, so this is a pure read. A device that never completed its `done`
|
||||
/// burst is skipped rather than reported half-read (its geometry would be a guess, and geometry is
|
||||
/// exactly what callers key on).
|
||||
pub(crate) fn list_monitors() -> anyhow::Result<Vec<crate::monitors::PhysicalMonitor>> {
|
||||
let session = Session::open().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"KWin did not answer kde_output_management_v2 (not a KWin session, the protocol is \
|
||||
not advertised to this client, or the compositor is wedged)"
|
||||
)
|
||||
})?;
|
||||
let mut out: Vec<_> = session
|
||||
.state
|
||||
.devices
|
||||
.values()
|
||||
.filter(|d| d.seen_done)
|
||||
.filter_map(|d| {
|
||||
let connector = d.name.clone()?;
|
||||
let dims = session.current_dims(d);
|
||||
let label = match (d.make.as_deref(), d.model.as_deref()) {
|
||||
(Some(make), Some(model)) if !make.is_empty() || !model.is_empty() => {
|
||||
format!("{make} {model}").trim().to_string()
|
||||
}
|
||||
_ => connector.clone(),
|
||||
};
|
||||
Some(crate::monitors::PhysicalMonitor {
|
||||
managed: connector.starts_with(MANAGED_PREFIX),
|
||||
description: label,
|
||||
// A disabled output has no current mode — report 0s rather than inventing one.
|
||||
width: dims.map(|d| d.0).unwrap_or(0),
|
||||
height: dims.map(|d| d.1).unwrap_or(0),
|
||||
refresh_mhz: dims.map(|d| d.2).unwrap_or(0),
|
||||
x: d.position.0,
|
||||
y: d.position.1,
|
||||
scale: d.scale.filter(|s| *s > 0.0).unwrap_or(1.0),
|
||||
// KWin ranks outputs; 1 is the primary.
|
||||
primary: d.priority == Some(1),
|
||||
enabled: d.enabled,
|
||||
connector,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// The device globals arrive in bind order, which is not stable across runs; sort by desktop
|
||||
// position so a picker (and a log line) reads left-to-right the way the desk looks.
|
||||
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the
|
||||
/// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`.
|
||||
/// See the module docs for why this is done in-process instead of via `kscreen-doctor`.
|
||||
|
||||
@@ -811,6 +811,61 @@ fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
|
||||
.map(|l| l.2)
|
||||
}
|
||||
|
||||
/// Every head Mutter reports, for [`crate::monitors::list`].
|
||||
///
|
||||
/// A pure `GetCurrentState` read on its own short-lived connection + runtime — no session, no
|
||||
/// `ApplyMonitorsConfig`, so it never touches the topology and never contends [`TOPOLOGY_LOCK`].
|
||||
/// Geometry comes from the **logical** monitors (`state.2`), which is the coordinate space that
|
||||
/// matters (see `crate::monitors`); a monitor absent from every logical monitor is disabled, and is
|
||||
/// reported as such at the origin rather than dropped.
|
||||
pub(crate) fn list_monitors() -> Result<Vec<crate::monitors::PhysicalMonitor>> {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.context("build tokio runtime (monitor enumeration)")?;
|
||||
let state = rt.block_on(async {
|
||||
let dc = display_config().await?;
|
||||
get_state(&dc).await
|
||||
})?;
|
||||
let mut out: Vec<_> = state
|
||||
.1
|
||||
.iter()
|
||||
.map(|(spec, _modes, _props)| {
|
||||
let (connector, vendor, product, _serial) = spec;
|
||||
// The logical monitor carrying this connector: its (x, y), scale and primary flag.
|
||||
let logical = state
|
||||
.2
|
||||
.iter()
|
||||
.find(|l| l.5.iter().any(|s| &s.0 == connector));
|
||||
let (w, h, refresh) = current_mode_full(&state, connector)
|
||||
.map(|(_id, w, h, hz)| (w.max(0) as u32, h.max(0) as u32, (hz * 1000.0) as u32))
|
||||
.unwrap_or((0, 0, 0));
|
||||
let label = format!("{vendor} {product}");
|
||||
crate::monitors::PhysicalMonitor {
|
||||
connector: connector.clone(),
|
||||
description: if label.trim().is_empty() {
|
||||
connector.clone()
|
||||
} else {
|
||||
label.trim().to_string()
|
||||
},
|
||||
width: w,
|
||||
height: h,
|
||||
refresh_mhz: refresh,
|
||||
x: logical.map(|l| l.0).unwrap_or(0),
|
||||
y: logical.map(|l| l.1).unwrap_or(0),
|
||||
scale: logical.map(|l| l.2).filter(|s| *s > 0.0).unwrap_or(1.0),
|
||||
primary: logical.map(|l| l.4).unwrap_or(false),
|
||||
enabled: logical.is_some(),
|
||||
// Mutter names a `RecordVirtual` monitor indistinguishably from a physical one —
|
||||
// no prefix to key on, and the connector is minted per session. See the field doc.
|
||||
managed: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|m| (m.x, m.y, m.connector.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read the virtual output's current scale and, when the user changed it (GNOME Settings
|
||||
/// mid-stream), persist it under the client's `scale_key` so the next connect reapplies it.
|
||||
/// Best-effort: read failures (teardown races, shell restart) are silently skipped.
|
||||
|
||||
@@ -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