1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55enum Units {
56 Physical,
57 Logical,
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 }
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 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 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
122fn 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
153fn 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 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 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
206fn 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 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 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 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 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 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 assert!(emit(&file, EmitFormat::Xdotool, Platform::Linux, None).is_ok());
456 }
457}