use std::fmt::Write as _;
use thiserror::Error;
use crate::geometry::Point;
use crate::session::{SelectionRecord, SessionFile};
use crate::space::{Resolved, logical_of};
pub use crate::space::Platform;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmitFormat {
Pyautogui,
Cliclick,
Xdotool,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum EmitError {
#[error("the session has no selections to emit")]
NoSelections,
#[error("no selection is labeled {requested:?}; labels in this session: {available:?}")]
UnknownLabel {
requested: String,
available: Vec<String>,
},
#[error(
"selection {selection} references monitor {monitor}, which the \
session does not describe"
)]
UnknownMonitor { selection: usize, monitor: usize },
}
struct Target {
comment: String,
point: Point,
}
pub fn emit(
session: &SessionFile,
format: EmitFormat,
platform: Platform,
label: Option<&str>,
) -> Result<String, EmitError> {
match format {
EmitFormat::Pyautogui => pyautogui(session, platform, label),
EmitFormat::Cliclick => cliclick(session, label),
EmitFormat::Xdotool => xdotool(session, label),
}
}
fn pyautogui(
session: &SessionFile,
platform: Platform,
label: Option<&str>,
) -> Result<String, EmitError> {
let (units, space_note) = match platform {
Platform::MacOs => (Resolved::Logical, "logical points (macOS)"),
Platform::Windows => (Resolved::Physical, "physical pixels (Windows)"),
Platform::Linux => (Resolved::Physical, "physical pixels (X11)"),
};
let targets = click_targets(session, units, label)?;
let mut out = header("#", session, space_note);
out.push_str("import pyautogui\n");
for t in targets {
let _ = write!(
out,
"\n# {}\npyautogui.click({}, {})\n",
t.comment, t.point.x, t.point.y
);
}
Ok(out)
}
fn cliclick(session: &SessionFile, label: Option<&str>) -> Result<String, EmitError> {
let targets = click_targets(session, Resolved::Logical, label)?;
let mut out = header("#", session, "logical points (macOS)");
for t in targets {
let _ = writeln!(
out,
"cliclick c:{},{} # {}",
cliclick_coord(t.point.x),
cliclick_coord(t.point.y),
t.comment
);
}
Ok(out)
}
fn cliclick_coord(v: i32) -> String {
if v < 0 {
return format!("={v}");
}
v.to_string()
}
fn xdotool(session: &SessionFile, label: Option<&str>) -> Result<String, EmitError> {
let targets = click_targets(session, Resolved::Physical, label)?;
let mut out = header("#", session, "physical pixels (X11)");
for t in targets {
let _ = writeln!(
out,
"xdotool mousemove {} {} click 1 # {}",
t.point.x, t.point.y, t.comment
);
}
Ok(out)
}
fn header(prefix: &str, session: &SessionFile, space_note: &str) -> String {
format!(
"{prefix} generated by pixelcoords from a session captured {}\n\
{prefix} coordinates: {space_note} — run on the machine and \
monitor layout that was captured\n",
session.created_utc
)
}
fn click_targets(
session: &SessionFile,
units: Resolved,
label: Option<&str>,
) -> Result<Vec<Target>, EmitError> {
if session.selections.is_empty() {
return Err(EmitError::NoSelections);
}
let wanted = crate::session::select_by_label(session, label);
if wanted.is_empty() {
return Err(EmitError::UnknownLabel {
requested: label.unwrap_or_default().to_string(),
available: crate::session::distinct_labels(session.selections.iter()),
});
}
wanted
.into_iter()
.map(|(index, record)| {
let physical = record.global_px.click_point();
let point = match units {
Resolved::Physical => physical,
Resolved::Logical => to_logical(session, index, record, physical)?,
};
Ok(Target {
comment: describe(index, record),
point,
})
})
.collect()
}
fn to_logical(
session: &SessionFile,
index: usize,
record: &SelectionRecord,
physical: Point,
) -> Result<Point, EmitError> {
let monitor = session
.monitors
.iter()
.find(|m| m.index == record.monitor)
.ok_or(EmitError::UnknownMonitor {
selection: index,
monitor: record.monitor,
})?;
Ok(logical_of(physical, monitor.scale))
}
fn describe(index: usize, record: &SelectionRecord) -> String {
let shape = match record.shape {
crate::geometry::ToolKind::Rect => "rect",
crate::geometry::ToolKind::Circle => "circle",
crate::geometry::ToolKind::Ellipse => "ellipse",
crate::geometry::ToolKind::Polygon
| crate::geometry::ToolKind::Freehand
| crate::geometry::ToolKind::Poly => "poly",
crate::geometry::ToolKind::Triangle => "triangle",
};
if record.label.is_empty() {
return format!("selection {index} — {shape} on monitor {}", record.monitor);
}
format!("{} — {shape} on monitor {}", record.label, record.monitor)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::{Rect, Shape, Size};
use crate::selection::Selection;
use crate::session::MonitorRecord;
fn monitor(index: usize, ox: i32, oy: i32, scale: f64) -> MonitorRecord {
MonitorRecord {
index,
name: format!("Display {index}"),
primary: index == 0,
origin_px: Point::new(ox, oy),
size_px: Size::new(1920, 1080),
scale,
}
}
fn labeled(shape: Shape, monitor: usize, label: &str) -> Selection {
let mut sel = Selection::new(shape, monitor);
sel.label = label.into();
sel
}
fn session(monitors: Vec<MonitorRecord>, selections: &[Selection]) -> SessionFile {
let crops: Vec<String> = (0..selections.len()).map(|i| format!("c{i}.png")).collect();
SessionFile::build(
"test",
"2026-07-27T11:35:42Z".into(),
monitors,
selections,
&crops,
None,
)
}
#[test]
fn pyautogui_on_macos_emits_logical_points() {
let file = session(
vec![monitor(0, 0, 0, 2.0)],
&[labeled(Shape::Rect(Rect::new(80, 40, 40, 40)), 0, "submit")],
);
let out = emit(&file, EmitFormat::Pyautogui, Platform::MacOs, None).unwrap();
assert_eq!(
out,
"# generated by pixelcoords from a session captured 2026-07-27T11:35:42Z\n\
# coordinates: logical points (macOS) — run on the machine and \
monitor layout that was captured\n\
import pyautogui\n\
\n\
# submit — rect on monitor 0\n\
pyautogui.click(50, 30)\n"
);
}
#[test]
fn pyautogui_elsewhere_emits_physical_pixels() {
let file = session(
vec![monitor(0, 0, 0, 2.0)],
&[labeled(Shape::Rect(Rect::new(80, 40, 40, 40)), 0, "submit")],
);
for (platform, note) in [
(Platform::Windows, "physical pixels (Windows)"),
(Platform::Linux, "physical pixels (X11)"),
] {
let out = emit(&file, EmitFormat::Pyautogui, platform, None).unwrap();
assert!(out.contains("pyautogui.click(100, 60)"), "got: {out}");
assert!(out.contains(note), "got: {out}");
}
}
#[test]
fn cliclick_escapes_negative_logical_coordinates() {
let file = session(
vec![monitor(0, -3840, 0, 2.0)],
&[labeled(Shape::Rect(Rect::new(2020, 20, 40, 40)), 0, "back")],
);
let out = emit(&file, EmitFormat::Cliclick, Platform::MacOs, None).unwrap();
assert!(
out.contains("cliclick c:=-900,20 # back — rect on monitor 0"),
"got: {out}"
);
}
#[test]
fn xdotool_emits_physical_pixels_untouched() {
let file = session(
vec![monitor(0, 0, 0, 2.0)],
&[labeled(
Shape::Circle {
cx: 500,
cy: 300,
r: 25,
},
0,
"dot",
)],
);
let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap();
assert!(
out.contains("xdotool mousemove 500 300 click 1 # dot — circle on monitor 0"),
"got: {out}"
);
}
#[test]
fn mixed_dpi_scales_each_selection_by_its_own_monitor() {
let file = session(
vec![monitor(0, 0, 0, 1.0), monitor(1, 1920, 0, 2.0)],
&[
labeled(Shape::Rect(Rect::new(100, 100, 20, 20)), 0, "left"),
labeled(Shape::Rect(Rect::new(100, 100, 20, 20)), 1, "right"),
],
);
let out = emit(&file, EmitFormat::Pyautogui, Platform::MacOs, None).unwrap();
assert!(out.contains("pyautogui.click(110, 110)"), "got: {out}");
assert!(out.contains("pyautogui.click(1015, 55)"), "got: {out}");
}
#[test]
fn triangles_click_their_centroid_and_unlabeled_selections_get_names() {
let file = session(
vec![monitor(0, 0, 0, 1.0)],
&[labeled(
Shape::Triangle {
ax: 30,
ay: 0,
bx: 0,
by: 60,
cx: 60,
cy: 60,
},
0,
"",
)],
);
let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap();
assert!(
out.contains("xdotool mousemove 30 40 click 1 # selection 0 — triangle on monitor 0"),
"got: {out}"
);
}
#[test]
fn a_rotated_rect_clicks_its_pivot() {
let mut sel = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 10)), 0);
sel.rot_deg = 90;
let file = session(vec![monitor(0, 0, 0, 1.0)], &[sel]);
let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap();
assert!(
out.contains("xdotool mousemove 30 15 click 1"),
"got: {out}"
);
}
#[test]
fn a_label_filter_emits_only_matching_selections() {
let file = session(
vec![monitor(0, 0, 0, 1.0)],
&[
labeled(Shape::Rect(Rect::new(0, 0, 10, 10)), 0, "Cancel"),
labeled(Shape::Rect(Rect::new(100, 100, 10, 10)), 0, "Submit"),
],
);
let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, Some("submit")).unwrap();
assert!(out.contains("xdotool mousemove 105 105"), "got: {out}");
assert!(!out.contains("mousemove 5 5"), "got: {out}");
assert!(out.contains("Submit — rect on monitor 0"), "got: {out}");
let err = emit(&file, EmitFormat::Xdotool, Platform::Linux, Some("send")).unwrap_err();
assert_eq!(
err,
EmitError::UnknownLabel {
requested: "send".into(),
available: vec!["Cancel".into(), "Submit".into()],
}
);
}
#[test]
fn an_empty_session_is_an_error() {
let file = session(vec![monitor(0, 0, 0, 1.0)], &[]);
let err = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap_err();
assert_eq!(err, EmitError::NoSelections);
}
#[test]
fn a_selection_on_an_undescribed_monitor_is_an_error_for_logical_units() {
let file = session(
vec![monitor(0, 0, 0, 2.0)],
&[labeled(Shape::Rect(Rect::new(0, 0, 10, 10)), 3, "orphan")],
);
let err = emit(&file, EmitFormat::Cliclick, Platform::MacOs, None).unwrap_err();
assert_eq!(
err,
EmitError::UnknownMonitor {
selection: 0,
monitor: 3,
}
);
assert!(emit(&file, EmitFormat::Xdotool, Platform::Linux, None).is_ok());
}
}