1use 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
22pub use crate::space::Platform;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum EmitFormat {
32 Pyautogui,
33 Cliclick,
34 Xdotool,
35 Powershell,
38 Applescript,
40 Ydotool,
42}
43
44#[derive(Debug, Error, PartialEq, Eq)]
45pub enum EmitError {
46 #[error("the session has no selections to emit")]
47 NoSelections,
48 #[error("no selection is labeled {requested:?}; labels in this session: {available:?}")]
49 UnknownLabel {
50 requested: String,
51 available: Vec<String>,
52 },
53 #[error(
54 "selection {selection} references monitor {monitor}, which the \
55 session does not describe"
56 )]
57 UnknownMonitor { selection: usize, monitor: usize },
58}
59
60struct Target {
62 comment: String,
63 point: Point,
64}
65
66pub 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 EmitFormat::Powershell => powershell(session, label),
79 EmitFormat::Applescript => applescript(session, label),
80 EmitFormat::Ydotool => ydotool(session, label),
81 }
82}
83
84fn pyautogui(
85 session: &SessionFile,
86 platform: Platform,
87 label: Option<&str>,
88) -> Result<String, EmitError> {
89 let (units, space_note) = match platform {
90 Platform::MacOs => (Resolved::Logical, "logical points (macOS)"),
91 Platform::Windows => (Resolved::Physical, "physical pixels (Windows)"),
94 Platform::Linux => (Resolved::Physical, "physical pixels (X11)"),
95 };
96 let targets = click_targets(session, units, label)?;
97 let mut out = header("#", session, space_note);
98 out.push_str("import pyautogui\n");
99 for t in targets {
100 let _ = write!(
102 out,
103 "\n# {}\npyautogui.click({}, {})\n",
104 t.comment, t.point.x, t.point.y
105 );
106 }
107 Ok(out)
108}
109
110fn cliclick(session: &SessionFile, label: Option<&str>) -> Result<String, EmitError> {
111 let targets = click_targets(session, Resolved::Logical, label)?;
112 let mut out = header("#", session, "logical points (macOS)");
113 for t in targets {
114 let _ = writeln!(
115 out,
116 "cliclick c:{},{} # {}",
117 cliclick_coord(t.point.x),
118 cliclick_coord(t.point.y),
119 t.comment
120 );
121 }
122 Ok(out)
123}
124
125fn cliclick_coord(v: i32) -> String {
128 if v < 0 {
129 return format!("={v}");
130 }
131 v.to_string()
132}
133
134fn xdotool(session: &SessionFile, label: Option<&str>) -> Result<String, EmitError> {
135 let targets = click_targets(session, Resolved::Physical, label)?;
136 let mut out = header("#", session, "physical pixels (X11)");
137 for t in targets {
138 let _ = writeln!(
139 out,
140 "xdotool mousemove {} {} click 1 # {}",
141 t.point.x, t.point.y, t.comment
142 );
143 }
144 Ok(out)
145}
146
147fn powershell(session: &SessionFile, label: Option<&str>) -> Result<String, EmitError> {
155 let targets = click_targets(session, Resolved::Physical, label)?;
156 let mut out = header("#", session, "physical pixels (Windows)");
157 out.push_str(
158 "\nAdd-Type @\"\n\
159 using System;\n\
160 using System.Runtime.InteropServices;\n\
161 public class PixelCoords {\n\
162 \x20 [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x, int y);\n\
163 \x20 [DllImport(\"user32.dll\")] public static extern void mouse_event(uint f, uint x, uint y, uint d, int i);\n\
164 }\n\
165 \"@\n",
166 );
167 for t in targets {
168 let _ = write!(
170 out,
171 "\n# {}\n\
172 [PixelCoords]::SetCursorPos({}, {})\n\
173 [PixelCoords]::mouse_event(0x0002, 0, 0, 0, 0)\n\
174 [PixelCoords]::mouse_event(0x0004, 0, 0, 0, 0)\n",
175 t.comment, t.point.x, t.point.y
176 );
177 }
178 Ok(out)
179}
180
181fn applescript(session: &SessionFile, label: Option<&str>) -> Result<String, EmitError> {
188 let targets = click_targets(session, Resolved::Logical, label)?;
189 let mut out = header("--", session, "logical points (macOS)");
190 out.push_str(
191 "-- System Events clicking needs Accessibility permission:\n\
192 -- System Settings > Privacy & Security > Accessibility\n\
193 \ntell application \"System Events\"\n",
194 );
195 for t in targets {
196 let _ = write!(
197 out,
198 "\t-- {}\n\tclick at {{{}, {}}}\n",
199 t.comment, t.point.x, t.point.y
200 );
201 }
202 out.push_str("end tell\n");
203 Ok(out)
204}
205
206fn ydotool(session: &SessionFile, label: Option<&str>) -> Result<String, EmitError> {
212 let targets = click_targets(session, Resolved::Physical, label)?;
213 let mut out = header("#", session, "physical pixels (Wayland)");
214 out.push_str(
215 "# needs the ydotoold daemon running and permission on its socket —\n\
216 # this is setup on your side, not something the snippet can do\n",
217 );
218 for t in targets {
219 let _ = writeln!(
221 out,
222 "ydotool mousemove --absolute -x {} -y {} && ydotool click 0xC0 # {}",
223 t.point.x, t.point.y, t.comment
224 );
225 }
226 Ok(out)
227}
228
229fn header(prefix: &str, session: &SessionFile, space_note: &str) -> String {
230 format!(
231 "{prefix} generated by pixelcoords from a session captured {}\n\
232 {prefix} coordinates: {space_note} — run on the machine and \
233 monitor layout that was captured\n",
234 session.created_utc
235 )
236}
237
238fn click_targets(
242 session: &SessionFile,
243 units: Resolved,
244 label: Option<&str>,
245) -> Result<Vec<Target>, EmitError> {
246 if session.selections.is_empty() {
247 return Err(EmitError::NoSelections);
248 }
249 let wanted = crate::session::select_by_label(session, label);
250 if wanted.is_empty() {
251 return Err(EmitError::UnknownLabel {
253 requested: label.unwrap_or_default().to_string(),
254 available: crate::session::distinct_labels(session.selections.iter()),
255 });
256 }
257 wanted
258 .into_iter()
259 .map(|(index, record)| {
260 let physical = record.global_px.click_point();
264 let point = match units {
265 Resolved::Physical => physical,
266 Resolved::Logical => to_logical(session, index, record, physical)?,
267 };
268 Ok(Target {
269 comment: describe(index, record),
270 point,
271 })
272 })
273 .collect()
274}
275
276fn to_logical(
280 session: &SessionFile,
281 index: usize,
282 record: &SelectionRecord,
283 physical: Point,
284) -> Result<Point, EmitError> {
285 let monitor = session
286 .monitors
287 .iter()
288 .find(|m| m.index == record.monitor)
289 .ok_or(EmitError::UnknownMonitor {
290 selection: index,
291 monitor: record.monitor,
292 })?;
293 Ok(logical_of(physical, monitor.scale))
294}
295
296fn describe(index: usize, record: &SelectionRecord) -> String {
297 let shape = match record.shape {
298 crate::geometry::ToolKind::Rect => "rect",
299 crate::geometry::ToolKind::Circle => "circle",
300 crate::geometry::ToolKind::Ellipse => "ellipse",
301 crate::geometry::ToolKind::Polygon
302 | crate::geometry::ToolKind::Freehand
303 | crate::geometry::ToolKind::Poly => "poly",
304 crate::geometry::ToolKind::Triangle => "triangle",
305 crate::geometry::ToolKind::Measure => "measure",
309 };
310 if record.label.is_empty() {
311 return format!("selection {index} — {shape} on monitor {}", record.monitor);
312 }
313 format!("{} — {shape} on monitor {}", record.label, record.monitor)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::geometry::{Rect, Shape, Size};
320 use crate::selection::Selection;
321 use crate::session::MonitorRecord;
322
323 fn monitor(index: usize, ox: i32, oy: i32, scale: f64) -> MonitorRecord {
324 MonitorRecord {
325 index,
326 name: format!("Display {index}"),
327 primary: index == 0,
328 origin_px: Point::new(ox, oy),
329 size_px: Size::new(1920, 1080),
330 scale,
331 }
332 }
333
334 fn labeled(shape: Shape, monitor: usize, label: &str) -> Selection {
335 let mut sel = Selection::new(shape, monitor);
336 sel.label = label.into();
337 sel
338 }
339
340 fn session(monitors: Vec<MonitorRecord>, selections: &[Selection]) -> SessionFile {
341 let crops: Vec<String> = (0..selections.len()).map(|i| format!("c{i}.png")).collect();
342 SessionFile::build(
343 "test",
344 "2026-07-27T11:35:42Z".into(),
345 monitors,
346 selections,
347 &crops,
348 None,
349 )
350 }
351
352 fn mixed_dpi() -> SessionFile {
358 session(
359 vec![monitor(0, 0, 0, 1.0), monitor(1, 1920, 0, 2.0)],
360 &[
361 labeled(Shape::Rect(Rect::new(800, 400, 100, 80)), 0, "left"),
362 labeled(Shape::Rect(Rect::new(100, 200, 40, 60)), 1, "right"),
363 ],
364 )
365 }
366
367 #[test]
368 fn powershell_emits_physical_pixels_and_one_preamble() {
369 let out = emit(
370 &mixed_dpi(),
371 EmitFormat::Powershell,
372 Platform::Windows,
373 None,
374 )
375 .unwrap();
376 assert_eq!(
377 out,
378 "# generated by pixelcoords from a session captured 2026-07-27T11:35:42Z\n\
379 # coordinates: physical pixels (Windows) — run on the machine and \
380 monitor layout that was captured\n\
381 \n\
382 Add-Type @\"\n\
383 using System;\n\
384 using System.Runtime.InteropServices;\n\
385 public class PixelCoords {\n\
386 \x20 [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x, int y);\n\
387 \x20 [DllImport(\"user32.dll\")] public static extern void mouse_event(uint f, uint x, uint y, uint d, int i);\n\
388 }\n\
389 \"@\n\
390 \n\
391 # left — rect on monitor 0\n\
392 [PixelCoords]::SetCursorPos(850, 440)\n\
393 [PixelCoords]::mouse_event(0x0002, 0, 0, 0, 0)\n\
394 [PixelCoords]::mouse_event(0x0004, 0, 0, 0, 0)\n\
395 \n\
396 # right — rect on monitor 1\n\
397 [PixelCoords]::SetCursorPos(2040, 230)\n\
398 [PixelCoords]::mouse_event(0x0002, 0, 0, 0, 0)\n\
399 [PixelCoords]::mouse_event(0x0004, 0, 0, 0, 0)\n"
400 );
401 assert_eq!(
402 out.matches("Add-Type").count(),
403 1,
404 "pasting Add-Type twice for one type is an error, not a no-op"
405 );
406 }
407
408 #[test]
409 fn applescript_emits_logical_points_per_monitor_scale() {
410 let out = emit(&mixed_dpi(), EmitFormat::Applescript, Platform::MacOs, None).unwrap();
411 assert_eq!(
412 out,
413 "-- generated by pixelcoords from a session captured 2026-07-27T11:35:42Z\n\
414 -- coordinates: logical points (macOS) — run on the machine and \
415 monitor layout that was captured\n\
416 -- System Events clicking needs Accessibility permission:\n\
417 -- System Settings > Privacy & Security > Accessibility\n\
418 \n\
419 tell application \"System Events\"\n\
420 \t-- left — rect on monitor 0\n\
421 \tclick at {850, 440}\n\
422 \t-- right — rect on monitor 1\n\
423 \tclick at {1020, 115}\n\
424 end tell\n"
425 );
426 }
427
428 #[test]
429 fn ydotool_emits_physical_pixels_with_the_daemon_caveat() {
430 let out = emit(&mixed_dpi(), EmitFormat::Ydotool, Platform::Linux, None).unwrap();
431 assert_eq!(
432 out,
433 "# generated by pixelcoords from a session captured 2026-07-27T11:35:42Z\n\
434 # coordinates: physical pixels (Wayland) — run on the machine and \
435 monitor layout that was captured\n\
436 # needs the ydotoold daemon running and permission on its socket —\n\
437 # this is setup on your side, not something the snippet can do\n\
438 ydotool mousemove --absolute -x 850 -y 440 && ydotool click 0xC0 \
439 # left — rect on monitor 0\n\
440 ydotool mousemove --absolute -x 2040 -y 230 && ydotool click 0xC0 \
441 # right — rect on monitor 1\n"
442 );
443 }
444
445 #[test]
446 fn each_format_applies_its_own_convention_to_the_same_session() {
447 let file = mixed_dpi();
452 let physical = [
453 EmitFormat::Xdotool,
454 EmitFormat::Powershell,
455 EmitFormat::Ydotool,
456 ];
457 for format in physical {
458 let out = emit(&file, format, Platform::Linux, None).unwrap();
459 assert!(out.contains("2040"), "{format:?} should be physical");
460 assert!(!out.contains("1020"), "{format:?} must not halve");
461 }
462 for format in [EmitFormat::Cliclick, EmitFormat::Applescript] {
463 let out = emit(&file, format, Platform::MacOs, None).unwrap();
464 assert!(out.contains("1020"), "{format:?} should be logical");
465 assert!(
466 out.contains("850"),
467 "{format:?}: the scale-1 monitor must not move"
468 );
469 }
470 }
471
472 #[test]
473 fn a_label_filter_reaches_the_new_formats_too() {
474 let file = mixed_dpi();
475 for format in [
476 EmitFormat::Powershell,
477 EmitFormat::Applescript,
478 EmitFormat::Ydotool,
479 ] {
480 let out = emit(&file, format, Platform::MacOs, Some("right")).unwrap();
481 assert!(out.contains("right"), "{format:?}");
482 assert!(!out.contains("# left"), "{format:?} emitted the wrong one");
483
484 let err = emit(&file, format, Platform::MacOs, Some("nope")).unwrap_err();
485 assert!(matches!(err, EmitError::UnknownLabel { .. }), "{format:?}");
486 }
487 }
488
489 #[test]
490 fn pyautogui_on_macos_emits_logical_points() {
491 let file = session(
493 vec![monitor(0, 0, 0, 2.0)],
494 &[labeled(Shape::Rect(Rect::new(80, 40, 40, 40)), 0, "submit")],
495 );
496 let out = emit(&file, EmitFormat::Pyautogui, Platform::MacOs, None).unwrap();
497 assert_eq!(
498 out,
499 "# generated by pixelcoords from a session captured 2026-07-27T11:35:42Z\n\
500 # coordinates: logical points (macOS) — run on the machine and \
501 monitor layout that was captured\n\
502 import pyautogui\n\
503 \n\
504 # submit — rect on monitor 0\n\
505 pyautogui.click(50, 30)\n"
506 );
507 }
508
509 #[test]
510 fn pyautogui_elsewhere_emits_physical_pixels() {
511 let file = session(
512 vec![monitor(0, 0, 0, 2.0)],
513 &[labeled(Shape::Rect(Rect::new(80, 40, 40, 40)), 0, "submit")],
514 );
515 for (platform, note) in [
516 (Platform::Windows, "physical pixels (Windows)"),
517 (Platform::Linux, "physical pixels (X11)"),
518 ] {
519 let out = emit(&file, EmitFormat::Pyautogui, platform, None).unwrap();
520 assert!(out.contains("pyautogui.click(100, 60)"), "got: {out}");
521 assert!(out.contains(note), "got: {out}");
522 }
523 }
524
525 #[test]
526 fn cliclick_escapes_negative_logical_coordinates() {
527 let file = session(
530 vec![monitor(0, -3840, 0, 2.0)],
531 &[labeled(Shape::Rect(Rect::new(2020, 20, 40, 40)), 0, "back")],
532 );
533 let out = emit(&file, EmitFormat::Cliclick, Platform::MacOs, None).unwrap();
534 assert!(
535 out.contains("cliclick c:=-900,20 # back — rect on monitor 0"),
536 "got: {out}"
537 );
538 }
539
540 #[test]
541 fn xdotool_emits_physical_pixels_untouched() {
542 let file = session(
543 vec![monitor(0, 0, 0, 2.0)],
544 &[labeled(
545 Shape::Circle {
546 cx: 500,
547 cy: 300,
548 r: 25,
549 },
550 0,
551 "dot",
552 )],
553 );
554 let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap();
555 assert!(
556 out.contains("xdotool mousemove 500 300 click 1 # dot — circle on monitor 0"),
557 "got: {out}"
558 );
559 }
560
561 #[test]
562 fn mixed_dpi_scales_each_selection_by_its_own_monitor() {
563 let file = session(
564 vec![monitor(0, 0, 0, 1.0), monitor(1, 1920, 0, 2.0)],
565 &[
566 labeled(Shape::Rect(Rect::new(100, 100, 20, 20)), 0, "left"),
567 labeled(Shape::Rect(Rect::new(100, 100, 20, 20)), 1, "right"),
568 ],
569 );
570 let out = emit(&file, EmitFormat::Pyautogui, Platform::MacOs, None).unwrap();
571 assert!(out.contains("pyautogui.click(110, 110)"), "got: {out}");
574 assert!(out.contains("pyautogui.click(1015, 55)"), "got: {out}");
575 }
576
577 #[test]
578 fn triangles_click_their_centroid_and_unlabeled_selections_get_names() {
579 let file = session(
580 vec![monitor(0, 0, 0, 1.0)],
581 &[labeled(
582 Shape::Triangle {
583 ax: 30,
584 ay: 0,
585 bx: 0,
586 by: 60,
587 cx: 60,
588 cy: 60,
589 },
590 0,
591 "",
592 )],
593 );
594 let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap();
595 assert!(
596 out.contains("xdotool mousemove 30 40 click 1 # selection 0 — triangle on monitor 0"),
597 "got: {out}"
598 );
599 }
600
601 #[test]
602 fn a_rotated_rect_clicks_its_pivot() {
603 let mut sel = Selection::new(Shape::Rect(Rect::new(10, 10, 40, 10)), 0);
604 sel.rot_deg = 90;
605 let file = session(vec![monitor(0, 0, 0, 1.0)], &[sel]);
606 let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap();
607 assert!(
610 out.contains("xdotool mousemove 30 15 click 1"),
611 "got: {out}"
612 );
613 }
614
615 #[test]
616 fn a_label_filter_emits_only_matching_selections() {
617 let file = session(
618 vec![monitor(0, 0, 0, 1.0)],
619 &[
620 labeled(Shape::Rect(Rect::new(0, 0, 10, 10)), 0, "Cancel"),
621 labeled(Shape::Rect(Rect::new(100, 100, 10, 10)), 0, "Submit"),
622 ],
623 );
624 let out = emit(&file, EmitFormat::Xdotool, Platform::Linux, Some("submit")).unwrap();
625 assert!(out.contains("xdotool mousemove 105 105"), "got: {out}");
626 assert!(!out.contains("mousemove 5 5"), "got: {out}");
627 assert!(out.contains("Submit — rect on monitor 0"), "got: {out}");
629
630 let err = emit(&file, EmitFormat::Xdotool, Platform::Linux, Some("send")).unwrap_err();
631 assert_eq!(
632 err,
633 EmitError::UnknownLabel {
634 requested: "send".into(),
635 available: vec!["Cancel".into(), "Submit".into()],
636 }
637 );
638 }
639
640 #[test]
641 fn an_empty_session_is_an_error() {
642 let file = session(vec![monitor(0, 0, 0, 1.0)], &[]);
643 let err = emit(&file, EmitFormat::Xdotool, Platform::Linux, None).unwrap_err();
644 assert_eq!(err, EmitError::NoSelections);
645 }
646
647 #[test]
648 fn a_selection_on_an_undescribed_monitor_is_an_error_for_logical_units() {
649 let file = session(
650 vec![monitor(0, 0, 0, 2.0)],
651 &[labeled(Shape::Rect(Rect::new(0, 0, 10, 10)), 3, "orphan")],
652 );
653 let err = emit(&file, EmitFormat::Cliclick, Platform::MacOs, None).unwrap_err();
654 assert_eq!(
655 err,
656 EmitError::UnknownMonitor {
657 selection: 0,
658 monitor: 3,
659 }
660 );
661 assert!(emit(&file, EmitFormat::Xdotool, Platform::Linux, None).is_ok());
664 }
665}