use anyhow::{Context, Result};
use image::RgbaImage;
use pixelcoords_core::geometry::{Point, Size};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CoordSpace {
Logical,
Physical,
}
impl CoordSpace {
pub const fn label(self) -> &'static str {
match self {
CoordSpace::Logical => "logical",
CoordSpace::Physical => "physical",
}
}
}
pub const NATIVE_COORD_SPACE: CoordSpace = if cfg!(target_os = "macos") {
CoordSpace::Logical
} else {
CoordSpace::Physical
};
pub fn from_physical(v: i32, scale: f64, space: CoordSpace) -> i32 {
match space {
CoordSpace::Logical => (f64::from(v) / scale).round() as i32,
CoordSpace::Physical => v,
}
}
pub fn to_physical(v: i32, scale: f64, space: CoordSpace) -> i32 {
match space {
CoordSpace::Logical => (f64::from(v) * scale) as i32,
CoordSpace::Physical => v,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MonitorInfo {
pub index: usize,
pub name: String,
pub primary: bool,
pub origin: Point,
pub size_native: Size,
pub scale: f64,
}
impl MonitorInfo {
pub fn origin_physical(&self) -> Point {
Point::new(
to_physical(self.origin.x, self.scale, NATIVE_COORD_SPACE),
to_physical(self.origin.y, self.scale, NATIVE_COORD_SPACE),
)
}
pub fn size_physical(&self) -> Size {
Size::new(
to_physical(self.size_native.w, self.scale, NATIVE_COORD_SPACE),
to_physical(self.size_native.h, self.scale, NATIVE_COORD_SPACE),
)
}
}
fn normalize_primary(monitors: &mut [MonitorInfo]) {
if monitors.iter().filter(|m| m.primary).count() == 1 {
return;
}
let at_origin = monitors
.iter()
.position(|m| m.origin == Point::new(0, 0))
.unwrap_or(0);
for (index, monitor) in monitors.iter_mut().enumerate() {
monitor.primary = index == at_origin;
}
}
fn ensure_same_monitor(index: usize, expected: &str, found: &str) -> Result<()> {
anyhow::ensure!(
expected == found,
"monitor {index} was {expected:?} when enumerated and is {found:?} now — \
the display layout changed mid-capture; re-run to pick it up"
);
Ok(())
}
fn ensure_drawable(width: u32, height: u32, index: usize, name: &str) -> Result<()> {
anyhow::ensure!(
width > 0 && height > 0,
"monitor {index} ({name}) captured as {width}x{height} — a capture with no area \
cannot be marked up"
);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowInfo {
pub id: u32,
pub title: String,
pub app: String,
pub z: i32,
pub origin: Point,
pub size_native: Size,
}
pub trait CaptureProvider {
fn monitors(&self) -> Result<Vec<MonitorInfo>>;
fn capture(&self, monitor: &MonitorInfo) -> Result<RgbaImage>;
fn windows(&self) -> Result<Vec<WindowInfo>>;
}
pub struct XcapCapture;
impl XcapCapture {
fn all() -> Result<Vec<xcap::Monitor>> {
let monitors = xcap::Monitor::all().context("enumerating monitors");
#[cfg(target_os = "linux")]
let monitors = monitors.map_err(crate::linux::explain_enumeration_failure);
monitors
}
}
impl CaptureProvider for XcapCapture {
fn monitors(&self) -> Result<Vec<MonitorInfo>> {
let mut out = Vec::new();
for (index, m) in Self::all()?.into_iter().enumerate() {
out.push(MonitorInfo {
index,
name: m.name().with_context(|| format!("monitor {index}: name"))?,
primary: m
.is_primary()
.with_context(|| format!("monitor {index}: is_primary"))?,
origin: Point::new(
m.x().with_context(|| format!("monitor {index}: x"))?,
m.y().with_context(|| format!("monitor {index}: y"))?,
),
size_native: Size::new(
m.width()
.with_context(|| format!("monitor {index}: width"))?
as i32,
m.height()
.with_context(|| format!("monitor {index}: height"))?
as i32,
),
scale: f64::from(
m.scale_factor()
.with_context(|| format!("monitor {index}: scale_factor"))?,
),
});
}
anyhow::ensure!(!out.is_empty(), "no monitors found");
normalize_primary(&mut out);
Ok(out)
}
fn capture(&self, monitor: &MonitorInfo) -> Result<RgbaImage> {
let monitors = Self::all()?;
let m = monitors
.get(monitor.index)
.with_context(|| format!("monitor {} disappeared", monitor.index))?;
let name = m
.name()
.with_context(|| format!("monitor {}: name", monitor.index))?;
ensure_same_monitor(monitor.index, &monitor.name, &name)?;
let img = m
.capture_image()
.with_context(|| format!("capturing monitor {} ({})", monitor.index, monitor.name))?;
ensure_drawable(img.width(), img.height(), monitor.index, &monitor.name)?;
Ok(img)
}
fn windows(&self) -> Result<Vec<WindowInfo>> {
#[cfg(target_os = "linux")]
anyhow::ensure!(
!crate::linux::is_wayland(),
"window enumeration is unavailable on Wayland, which does not \
expose window geometry to applications — `windows` and --target \
need an X11 session (log out and pick one at the login screen), \
or freeze a single window with --pick, which works on Wayland \
via the desktop portal's own picker"
);
let mut out = Vec::new();
for w in xcap::Window::all().context(
"enumerating windows (on X11 this needs an EWMH window manager \
running — bare X servers like Xvfb have none)",
)? {
let info = (|| -> xcap::XCapResult<Option<WindowInfo>> {
if w.is_minimized()? {
return Ok(None);
}
Ok(Some(WindowInfo {
id: w.id()?,
title: w.title()?,
app: w.app_name()?,
z: w.z()?,
origin: Point::new(w.x()?, w.y()?),
size_native: Size::new(w.width()? as i32, w.height()? as i32),
}))
})();
match info {
Ok(Some(info)) => out.push(info),
Ok(None) => {}
Err(e) => log::debug!("skipping window: {e}"),
}
}
#[cfg(target_os = "linux")]
crate::linux::strip_invisible_borders(&mut out);
Ok(out)
}
}
#[cfg(test)]
pub struct FakeCapture;
#[cfg(test)]
impl CaptureProvider for FakeCapture {
fn monitors(&self) -> Result<Vec<MonitorInfo>> {
Ok(vec![MonitorInfo {
index: 0,
name: "Fake Display".into(),
primary: true,
origin: Point::new(0, 0),
size_native: Size::new(100, 60),
scale: 1.0,
}])
}
fn capture(&self, _monitor: &MonitorInfo) -> Result<RgbaImage> {
Ok(RgbaImage::from_pixel(100, 60, image::Rgba([1, 2, 3, 255])))
}
fn windows(&self) -> Result<Vec<WindowInfo>> {
Ok(vec![WindowInfo {
id: 1,
title: "Fake Window".into(),
app: "FakeApp".into(),
z: 0,
origin: Point::new(10, 10),
size_native: Size::new(50, 30),
}])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn to_physical_scales_only_logical_space() {
assert_eq!(to_physical(20, 2.0, CoordSpace::Logical), 40);
assert_eq!(to_physical(20, 2.0, CoordSpace::Physical), 20);
assert_eq!(to_physical(-100, 1.5, CoordSpace::Logical), -150);
assert_eq!(to_physical(-100, 1.5, CoordSpace::Physical), -100);
}
#[test]
fn from_physical_inverts_to_physical() {
for space in [CoordSpace::Logical, CoordSpace::Physical] {
for v in [-1920, -1, 0, 1, 982, 1512] {
let physical = to_physical(v, 2.0, space);
assert_eq!(from_physical(physical, 2.0, space), v, "{space:?} {v}");
}
}
}
#[test]
fn coord_space_labels_name_their_own_space() {
assert_eq!(CoordSpace::Logical.label(), "logical");
assert_eq!(CoordSpace::Physical.label(), "physical");
}
#[test]
fn native_label_follows_the_platform() {
let expected = if cfg!(target_os = "macos") {
"logical"
} else {
"physical"
};
assert_eq!(NATIVE_COORD_SPACE.label(), expected);
}
#[test]
fn monitor_normalization_follows_native_space() {
let m = MonitorInfo {
index: 0,
name: "M".into(),
primary: true,
origin: Point::new(100, 50),
size_native: Size::new(800, 600),
scale: 2.0,
};
let expected_origin = Point::new(
to_physical(100, 2.0, NATIVE_COORD_SPACE),
to_physical(50, 2.0, NATIVE_COORD_SPACE),
);
assert_eq!(m.origin_physical(), expected_origin);
assert_eq!(
m.size_physical(),
Size::new(
to_physical(800, 2.0, NATIVE_COORD_SPACE),
to_physical(600, 2.0, NATIVE_COORD_SPACE)
)
);
}
fn monitor(primary: bool, ox: i32, oy: i32) -> MonitorInfo {
MonitorInfo {
index: 0,
name: "Display".into(),
primary,
origin: Point::new(ox, oy),
size_native: Size::new(1920, 1080),
scale: 1.0,
}
}
fn primary_flags(monitors: &[MonitorInfo]) -> Vec<bool> {
monitors.iter().map(|m| m.primary).collect()
}
#[test]
fn the_same_monitor_passes_the_identity_check() {
assert!(ensure_same_monitor(0, "Virtual-1", "Virtual-1").is_ok());
}
#[test]
fn a_swapped_monitor_is_refused_rather_than_captured() {
let err = ensure_same_monitor(1, "DP-1", "HDMI-2").unwrap_err();
let message = format!("{err}");
assert!(message.contains("DP-1"), "{message}");
assert!(message.contains("HDMI-2"), "{message}");
}
#[test]
fn a_normal_capture_is_drawable() {
assert!(ensure_drawable(1920, 1200, 0, "Virtual-1").is_ok());
}
#[test]
fn an_empty_capture_is_refused_instead_of_panicking_later() {
for (w, h) in [(0, 1200), (1920, 0), (0, 0)] {
let err = ensure_drawable(w, h, 0, "Virtual-1").unwrap_err();
assert!(format!("{err}").contains("no area"), "{w}x{h}");
}
}
#[test]
fn a_lone_monitor_is_primary_even_when_randr_says_otherwise() {
let mut monitors = vec![monitor(false, 0, 0)];
normalize_primary(&mut monitors);
assert_eq!(primary_flags(&monitors), vec![true]);
}
#[test]
fn with_no_primary_the_monitor_at_the_origin_wins() {
let mut monitors = vec![monitor(false, -1920, 0), monitor(false, 0, 0)];
normalize_primary(&mut monitors);
assert_eq!(primary_flags(&monitors), vec![false, true]);
}
#[test]
fn with_no_primary_and_no_monitor_at_the_origin_the_first_wins() {
let mut monitors = vec![monitor(false, 100, 100), monitor(false, 2020, 100)];
normalize_primary(&mut monitors);
assert_eq!(primary_flags(&monitors), vec![true, false]);
}
#[test]
fn several_primaries_are_reduced_to_the_one_at_the_origin() {
let mut monitors = vec![monitor(true, -1920, 0), monitor(true, 0, 0)];
normalize_primary(&mut monitors);
assert_eq!(primary_flags(&monitors), vec![false, true]);
}
#[test]
fn a_backend_that_names_one_primary_is_left_alone() {
let mut monitors = vec![monitor(true, -1920, 0), monitor(false, 0, 0)];
normalize_primary(&mut monitors);
assert_eq!(primary_flags(&monitors), vec![true, false]);
}
#[test]
fn normalizing_is_idempotent() {
let mut monitors = vec![monitor(false, 0, 0), monitor(false, 1920, 0)];
normalize_primary(&mut monitors);
let once = primary_flags(&monitors);
normalize_primary(&mut monitors);
assert_eq!(primary_flags(&monitors), once);
}
#[test]
fn fake_capture_matches_its_monitor() {
let provider = FakeCapture;
let monitors = provider.monitors().unwrap();
let img = provider.capture(&monitors[0]).unwrap();
assert_eq!(
(img.width() as i32, img.height() as i32),
(monitors[0].size_native.w, monitors[0].size_native.h)
);
}
}