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