Skip to main content

pixelcoords_core/
emit.rs

1//! Ready-to-paste click snippets from a session — the logic behind
2//! `pixelcoords emit`.
3//!
4//! Each emitter encodes one automation tool's coordinate convention in
5//! exactly one place, because that conversion is where hand-written glue
6//! gets silently burned: pyautogui speaks logical points on macOS but
7//! physical pixels on Windows and X11; cliclick speaks logical points;
8//! xdotool speaks physical pixels. Coordinates are the session's own
9//! `global_px`, divided by the selection's monitor scale only where the
10//! target tool wants logical points. Sessions are machine-local, so a
11//! snippet is meant to run on the machine and monitor layout that was
12//! captured.
13
14use std::fmt::Write as _;
15
16use thiserror::Error;
17
18use crate::geometry::Point;
19use crate::session::{SelectionRecord, SessionFile};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EmitFormat {
23    Pyautogui,
24    Cliclick,
25    Xdotool,
26}
27
28/// The OS the snippet will run on. Only pyautogui branches on it — the
29/// other tools each exist on a single platform.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Platform {
32    MacOs,
33    Windows,
34    Linux,
35}
36
37#[derive(Debug, Error, PartialEq, Eq)]
38pub enum EmitError {
39    #[error("the session has no selections to emit")]
40    NoSelections,
41    #[error("no selection is labeled {requested:?}; labels in this session: {available:?}")]
42    UnknownLabel {
43        requested: String,
44        available: Vec<String>,
45    },
46    #[error(
47        "selection {selection} references monitor {monitor}, which the \
48         session does not describe"
49    )]
50    UnknownMonitor { selection: usize, monitor: usize },
51}
52
53/// The units a target tool expects its coordinates in.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum Units {
56    Physical,
57    Logical,
58}
59
60/// One click the snippet will perform.
61struct Target {
62    comment: String,
63    point: Point,
64}
65
66/// Render the session's selections as a ready-to-paste snippet for
67/// `format`, ending with a newline.
68pub fn emit(
69    session: &SessionFile,
70    format: EmitFormat,
71    platform: Platform,
72    label: Option<&str>,
73) -> Result<String, EmitError> {
74    match format {
75        EmitFormat::Pyautogui => pyautogui(session, platform, label),
76        EmitFormat::Cliclick => cliclick(session, label),
77        EmitFormat::Xdotool => xdotool(session, label),
78    }
79}
80
81fn pyautogui(
82    session: &SessionFile,
83    platform: Platform,
84    label: Option<&str>,
85) -> Result<String, EmitError> {
86    let (units, space_note) = match platform {
87        Platform::MacOs => (Units::Logical, "logical points (macOS)"),
88        // pyautogui makes its process DPI-aware on import, so it addresses
89        // true physical pixels on Windows.
90        Platform::Windows => (Units::Physical, "physical pixels (Windows)"),
91        Platform::Linux => (Units::Physical, "physical pixels (X11)"),
92    };
93    let targets = click_targets(session, units, label)?;
94    let mut out = header("#", session, space_note);
95    out.push_str("import pyautogui\n");
96    for t in targets {
97        // Writing to a String cannot fail.
98        let _ = write!(
99            out,
100            "\n# {}\npyautogui.click({}, {})\n",
101            t.comment, t.point.x, t.point.y
102        );
103    }
104    Ok(out)
105}
106
107fn cliclick(session: &SessionFile, label: Option<&str>) -> Result<String, EmitError> {
108    let targets = click_targets(session, Units::Logical, label)?;
109    let mut out = header("#", session, "logical points (macOS)");
110    for t in targets {
111        let _ = writeln!(
112            out,
113            "cliclick c:{},{}  # {}",
114            cliclick_coord(t.point.x),
115            cliclick_coord(t.point.y),
116            t.comment
117        );
118    }
119    Ok(out)
120}
121
122/// cliclick parses a bare leading `-` as an option; its documented escape
123/// for negative coordinates is an `=` prefix.
124fn cliclick_coord(v: i32) -> String {
125    if v < 0 {
126        return format!("={v}");
127    }
128    v.to_string()
129}
130
131fn xdotool(session: &SessionFile, label: Option<&str>) -> Result<String, EmitError> {
132    let targets = click_targets(session, Units::Physical, label)?;
133    let mut out = header("#", session, "physical pixels (X11)");
134    for t in targets {
135        let _ = writeln!(
136            out,
137            "xdotool mousemove {} {} click 1  # {}",
138            t.point.x, t.point.y, t.comment
139        );
140    }
141    Ok(out)
142}
143
144fn header(prefix: &str, session: &SessionFile, space_note: &str) -> String {
145    format!(
146        "{prefix} generated by pixelcoords from a session captured {}\n\
147         {prefix} coordinates: {space_note} — run on the machine and \
148         monitor layout that was captured\n",
149        session.created_utc
150    )
151}
152
153/// Every selection's click point in global coordinates, converted to the
154/// requested units via its own monitor's scale — mixed-DPI setups scale
155/// each selection independently.
156fn click_targets(
157    session: &SessionFile,
158    units: Units,
159    label: Option<&str>,
160) -> Result<Vec<Target>, EmitError> {
161    if session.selections.is_empty() {
162        return Err(EmitError::NoSelections);
163    }
164    let wanted: Vec<(usize, &SelectionRecord)> = session
165        .selections
166        .iter()
167        .enumerate()
168        .filter(|(_, record)| label.is_none_or(|wanted| record.label.eq_ignore_ascii_case(wanted)))
169        .collect();
170    if wanted.is_empty() {
171        // Only a label filter can empty a non-empty session.
172        let mut available: Vec<String> = Vec::new();
173        for record in &session.selections {
174            let known = record.label.is_empty()
175                || available
176                    .iter()
177                    .any(|l| l.eq_ignore_ascii_case(&record.label));
178            if !known {
179                available.push(record.label.clone());
180            }
181        }
182        return Err(EmitError::UnknownLabel {
183            requested: label.unwrap_or_default().to_string(),
184            available,
185        });
186    }
187    wanted
188        .into_iter()
189        .map(|(index, record)| {
190            // The click point of the stored global shape. Rect rotation
191            // pivots on the bbox center — the click point itself — so
192            // `rot_deg` cannot move it; triangles store rotation baked.
193            let physical = record.global_px.click_point();
194            let point = match units {
195                Units::Physical => physical,
196                Units::Logical => to_logical(session, index, record, physical)?,
197            };
198            Ok(Target {
199                comment: describe(index, record),
200                point,
201            })
202        })
203        .collect()
204}
205
206/// `global_px` divided by the selection's monitor scale. Monitor origins
207/// were scaled by that same per-monitor factor when the session was
208/// written, so the division inverts cleanly even across mixed DPI.
209fn to_logical(
210    session: &SessionFile,
211    index: usize,
212    record: &SelectionRecord,
213    physical: Point,
214) -> Result<Point, EmitError> {
215    let monitor = session
216        .monitors
217        .iter()
218        .find(|m| m.index == record.monitor)
219        .ok_or(EmitError::UnknownMonitor {
220            selection: index,
221            monitor: record.monitor,
222        })?;
223    Ok(Point::new(
224        (f64::from(physical.x) / monitor.scale).round() as i32,
225        (f64::from(physical.y) / monitor.scale).round() as i32,
226    ))
227}
228
229fn describe(index: usize, record: &SelectionRecord) -> String {
230    let shape = match record.shape {
231        crate::geometry::ToolKind::Rect => "rect",
232        crate::geometry::ToolKind::Circle => "circle",
233        crate::geometry::ToolKind::Ellipse => "ellipse",
234        crate::geometry::ToolKind::Polygon
235        | crate::geometry::ToolKind::Freehand
236        | crate::geometry::ToolKind::Poly => "poly",
237        crate::geometry::ToolKind::Triangle => "triangle",
238    };
239    if record.label.is_empty() {
240        return format!("selection {index} — {shape} on monitor {}", record.monitor);
241    }
242    format!("{} — {shape} on monitor {}", record.label, record.monitor)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::geometry::{Rect, Shape, Size};
249    use crate::selection::Selection;
250    use crate::session::MonitorRecord;
251
252    fn monitor(index: usize, ox: i32, oy: i32, scale: f64) -> MonitorRecord {
253        MonitorRecord {
254            index,
255            name: format!("Display {index}"),
256            primary: index == 0,
257            origin_px: Point::new(ox, oy),
258            size_px: Size::new(1920, 1080),
259            scale,
260        }
261    }
262
263    fn labeled(shape: Shape, monitor: usize, label: &str) -> Selection {
264        let mut sel = Selection::new(shape, monitor);
265        sel.label = label.into();
266        sel
267    }
268
269    fn session(monitors: Vec<MonitorRecord>, selections: &[Selection]) -> SessionFile {
270        let crops: Vec<String> = (0..selections.len()).map(|i| format!("c{i}.png")).collect();
271        SessionFile::build(
272            "test",
273            "2026-07-27T11:35:42Z".into(),
274            monitors,
275            selections,
276            &crops,
277            None,
278        )
279    }
280
281    #[test]
282    fn pyautogui_on_macos_emits_logical_points() {
283        // Rect center at physical (100, 60) on a 2x monitor -> (50, 30).
284        let file = session(
285            vec![monitor(0, 0, 0, 2.0)],
286            &[labeled(Shape::Rect(Rect::new(80, 40, 40, 40)), 0, "submit")],
287        );
288        let out = emit(&file, EmitFormat::Pyautogui, Platform::MacOs, None).unwrap();
289        assert_eq!(
290            out,
291            "# generated by pixelcoords from a session captured 2026-07-27T11:35:42Z\n\
292             # coordinates: logical points (macOS) — run on the machine and \
293             monitor layout that was captured\n\
294             import pyautogui\n\
295             \n\
296             # submit — rect on monitor 0\n\
297             pyautogui.click(50, 30)\n"
298        );
299    }
300
301    #[test]
302    fn pyautogui_elsewhere_emits_physical_pixels() {
303        let file = session(
304            vec![monitor(0, 0, 0, 2.0)],
305            &[labeled(Shape::Rect(Rect::new(80, 40, 40, 40)), 0, "submit")],
306        );
307        for (platform, note) in [
308            (Platform::Windows, "physical pixels (Windows)"),
309            (Platform::Linux, "physical pixels (X11)"),
310        ] {
311            let out = emit(&file, EmitFormat::Pyautogui, platform, None).unwrap();
312            assert!(out.contains("pyautogui.click(100, 60)"), "got: {out}");
313            assert!(out.contains(note), "got: {out}");
314        }
315    }
316
317    #[test]
318    fn cliclick_escapes_negative_logical_coordinates() {
319        // A monitor left of the primary: global physical (-1800, 40) at
320        // scale 2 -> logical (-900, 20), with cliclick's `=` escape.
321        let file = session(
322            vec![monitor(0, -3840, 0, 2.0)],
323            &[labeled(Shape::Rect(Rect::new(2020, 20, 40, 40)), 0, "back")],
324        );
325        let out = emit(&file, EmitFormat::Cliclick, Platform::MacOs, None).unwrap();
326        assert!(
327            out.contains("cliclick c:=-900,20  # back — rect on monitor 0"),
328            "got: {out}"
329        );
330    }
331
332    #[test]
333    fn xdotool_emits_physical_pixels_untouched() {
334        let file = session(
335            vec![monitor(0, 0, 0, 2.0)],
336            &[labeled(
337                Shape::Circle {
338                    cx: 500,
339                    cy: 300,
340                    r: 25,
341                },
342                0,
343                "dot",
344            )],
345        );
346        let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap();
347        assert!(
348            out.contains("xdotool mousemove 500 300 click 1  # dot — circle on monitor 0"),
349            "got: {out}"
350        );
351    }
352
353    #[test]
354    fn mixed_dpi_scales_each_selection_by_its_own_monitor() {
355        let file = session(
356            vec![monitor(0, 0, 0, 1.0), monitor(1, 1920, 0, 2.0)],
357            &[
358                labeled(Shape::Rect(Rect::new(100, 100, 20, 20)), 0, "left"),
359                labeled(Shape::Rect(Rect::new(100, 100, 20, 20)), 1, "right"),
360            ],
361        );
362        let out = emit(&file, EmitFormat::Pyautogui, Platform::MacOs, None).unwrap();
363        // Monitor 0 at scale 1: physical (110, 110) stays put. Monitor 1 at
364        // scale 2: global physical (2030, 110) -> logical (1015, 55).
365        assert!(out.contains("pyautogui.click(110, 110)"), "got: {out}");
366        assert!(out.contains("pyautogui.click(1015, 55)"), "got: {out}");
367    }
368
369    #[test]
370    fn triangles_click_their_centroid_and_unlabeled_selections_get_names() {
371        let file = session(
372            vec![monitor(0, 0, 0, 1.0)],
373            &[labeled(
374                Shape::Triangle {
375                    ax: 30,
376                    ay: 0,
377                    bx: 0,
378                    by: 60,
379                    cx: 60,
380                    cy: 60,
381                },
382                0,
383                "",
384            )],
385        );
386        let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap();
387        assert!(
388            out.contains("xdotool mousemove 30 40 click 1  # selection 0 — triangle on monitor 0"),
389            "got: {out}"
390        );
391    }
392
393    #[test]
394    fn a_rotated_rect_clicks_its_pivot() {
395        let mut sel = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 10)), 0);
396        sel.rot_deg = 90;
397        let file = session(vec![monitor(0, 0, 0, 1.0)], &[sel]);
398        let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap();
399        // The pivot (30, 15) is rotation-invariant, so the click lands
400        // inside the silhouette at any angle.
401        assert!(
402            out.contains("xdotool mousemove 30 15 click 1"),
403            "got: {out}"
404        );
405    }
406
407    #[test]
408    fn a_label_filter_emits_only_matching_selections() {
409        let file = session(
410            vec![monitor(0, 0, 0, 1.0)],
411            &[
412                labeled(Shape::Rect(Rect::new(0, 0, 10, 10)), 0, "Cancel"),
413                labeled(Shape::Rect(Rect::new(100, 100, 10, 10)), 0, "Submit"),
414            ],
415        );
416        let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, Some("submit")).unwrap();
417        assert!(out.contains("xdotool mousemove 105 105"), "got: {out}");
418        assert!(!out.contains("mousemove 5 5"), "got: {out}");
419        // The comment keeps the selection's original session index.
420        assert!(out.contains("Submit — rect on monitor 0"), "got: {out}");
421
422        let err = emit(&file, EmitFormat::Xdotool, Platform::Linux, Some("send")).unwrap_err();
423        assert_eq!(
424            err,
425            EmitError::UnknownLabel {
426                requested: "send".into(),
427                available: vec!["Cancel".into(), "Submit".into()],
428            }
429        );
430    }
431
432    #[test]
433    fn an_empty_session_is_an_error() {
434        let file = session(vec![monitor(0, 0, 0, 1.0)], &[]);
435        let err = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap_err();
436        assert_eq!(err, EmitError::NoSelections);
437    }
438
439    #[test]
440    fn a_selection_on_an_undescribed_monitor_is_an_error_for_logical_units() {
441        let file = session(
442            vec![monitor(0, 0, 0, 2.0)],
443            &[labeled(Shape::Rect(Rect::new(0, 0, 10, 10)), 3, "orphan")],
444        );
445        let err = emit(&file, EmitFormat::Cliclick, Platform::MacOs, None).unwrap_err();
446        assert_eq!(
447            err,
448            EmitError::UnknownMonitor {
449                selection: 0,
450                monitor: 3,
451            }
452        );
453        // Physical units never look the monitor up, so the same session
454        // still emits for xdotool.
455        assert!(emit(&file, EmitFormat::Xdotool, Platform::Linux, None).is_ok());
456    }
457}