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    /// Windows with nothing installed: `SetCursorPos` + `mouse_event`
36    /// through a P/Invoke preamble.
37    Powershell,
38    /// macOS with nothing installed: System Events.
39    Applescript,
40    /// The Wayland answer, where xdotool cannot reach.
41    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
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        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        // pyautogui makes its process DPI-aware on import, so it addresses
92        // true physical pixels on Windows.
93        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        // Writing to a String cannot fail.
101        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
125/// cliclick parses a bare leading `-` as an option; its documented escape
126/// for negative coordinates is an `=` prefix.
127fn 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
147/// Windows without Python. The Win32 cursor APIs speak physical pixels on
148/// a per-monitor-DPI-aware process, which is what the session records on
149/// Windows, so no conversion happens here.
150///
151/// The P/Invoke preamble is emitted once rather than per click: pasting
152/// `Add-Type` for the same type twice in one session is an error, not a
153/// no-op, so a per-click preamble would break on the second selection.
154fn 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        // 0x0002 is MOUSEEVENTF_LEFTDOWN, 0x0004 MOUSEEVENTF_LEFTUP.
169        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
181/// macOS without Homebrew. System Events speaks logical points, like
182/// cliclick.
183///
184/// One `tell` block wraps every click rather than one per selection —
185/// the snippet is meant to be pasted whole, and re-entering the same
186/// application context per click is noise a reader has to skip.
187fn 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
206/// The Wayland answer, completing the `--pick` story: xdotool speaks X11
207/// only, and a Wayland compositor will not answer it.
208///
209/// Physical pixels, like xdotool — ydotool writes to an uinput device
210/// below the compositor, so it addresses the raw device grid.
211fn 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        // 0xC0 is ydotool's left-button press-and-release.
220        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
238/// Every selection's click point in global coordinates, converted to the
239/// requested units via its own monitor's scale — mixed-DPI setups scale
240/// each selection independently.
241fn 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        // Only a label filter can empty a non-empty session.
252        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            // The click point of the stored global shape. Rect rotation
261            // pivots on the bbox center — the click point itself — so
262            // `rot_deg` cannot move it; triangles store rotation baked.
263            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
276/// `global_px` through the selection's own monitor scale. The lookup and
277/// its error stay here; the arithmetic is `space::logical_of`, shared
278/// with every other command that has to answer the same question.
279fn 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        // A measure is never stored as a selection — it lives in the
306        // session's `measures` array — so this arm exists to keep the
307        // match total, not because it can be reached.
308        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    /// One selection per monitor on a mixed-DPI desktop: monitor 0 at
353    /// scale 1 and monitor 1 at scale 2, offset to global x=1920. The
354    /// click points are physical (850, 440) and (2040, 230); in logical
355    /// units the second halves to (1020, 115) and the first does not
356    /// move. Every format below is checked against the same two.
357    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        // The point of the table: one session, two monitors at different
448        // scales, and each target gets the units *it* speaks — with the
449        // second selection converted through monitor 1's scale, never a
450        // desktop-wide one.
451        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        // Rect center at physical (100, 60) on a 2x monitor -> (50, 30).
492        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        // A monitor left of the primary: global physical (-1800, 40) at
528        // scale 2 -> logical (-900, 20), with cliclick's `=` escape.
529        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        // Monitor 0 at scale 1: physical (110, 110) stays put. Monitor 1 at
572        // scale 2: global physical (2030, 110) -> logical (1015, 55).
573        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        // The pivot (30, 15) is rotation-invariant, so the click lands
608        // inside the silhouette at any angle.
609        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        // The comment keeps the selection's original session index.
628        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        // Physical units never look the monitor up, so the same session
662        // still emits for xdotool.
663        assert!(emit(&file, EmitFormat::Xdotool, Platform::Linux, None).is_ok());
664    }
665}