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}
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
53struct Target {
55 comment: String,
56 point: Point,
57}
58
59pub 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 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 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
115fn 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
146fn 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 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 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
184fn 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 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 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 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 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 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 assert!(emit(&file, EmitFormat::Xdotool, Platform::Linux, None).is_ok());
431 }
432}