Skip to main content

pixelactions_core/
convert.rs

1//! Coordinate-space conversion — the part most tools get wrong.
2//!
3//! A pixelcoords session records **physical pixels** plus each monitor's
4//! DPI `scale`. Input APIs disagree about what they want:
5//!
6//! | Platform | Input API speaks |
7//! |----------|------------------|
8//! | macOS (`CGEvent`) | logical points, global space, origin top-left |
9//! | Windows (`SendInput`) | physical pixels, normalized across the virtual desktop |
10//! | Linux/X11 (`XTEST`) | physical pixels on the root window |
11//!
12//! So the same saved coordinate needs a different conversion per platform.
13//! Getting this wrong doesn't error — it clicks the wrong place, which is
14//! why the conversion lives here, alone, and is property-tested.
15
16use pixelcoords_core::geometry::Point;
17use pixelcoords_core::session::MonitorRecord;
18use pixelcoords_core::space::{Platform, Resolved, Units, logical_of};
19use serde::{Deserialize, Serialize};
20
21/// The coordinate space a consumer wants a point in.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum Space {
25    /// What this platform's input API expects: logical on macOS,
26    /// physical on Windows and X11.
27    Auto,
28    /// Physical pixels — the session's own grid.
29    Physical,
30    /// Logical points — physical divided by the monitor's scale.
31    Logical,
32}
33
34/// A point in a named space, carrying the monitor it was resolved against
35/// so a caller can report — or re-derive — the conversion.
36#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37pub struct ResolvedPoint {
38    pub x: f64,
39    pub y: f64,
40    pub space: Space,
41    pub monitor: usize,
42    pub scale: f64,
43}
44
45/// The OS this was compiled for, as `pixelcoords-core` names it.
46const fn native_platform() -> Platform {
47    if cfg!(target_os = "macos") {
48        return Platform::MacOs;
49    }
50    if cfg!(target_os = "windows") {
51        return Platform::Windows;
52    }
53    Platform::Linux
54}
55
56/// The space `Space::Auto` resolves to on the platform this was compiled
57/// for. Stated once — **and not here**.
58///
59/// The rule (macOS logical, Windows and X11 physical) belongs to
60/// `pixelcoords_core::space::Units`, which is where `pixelcoords resolve`
61/// and `emit`'s per-format table read it from. This function used to
62/// state it a second time, in a second repository, with its own comment
63/// explaining it. Two copies of one rule is one rule and one future bug.
64pub const fn native_space() -> Space {
65    match Units::Auto.resolve(native_platform()) {
66        Resolved::Logical => Space::Logical,
67        Resolved::Physical => Space::Physical,
68    }
69}
70
71/// Which monitor contains a global physical point.
72///
73/// Returns `None` when the point falls in a gap between monitors or
74/// outside every monitor — a real possibility on L-shaped layouts, and a
75/// refusal rather than a guess.
76pub fn monitor_at(
77    monitors: &[MonitorRecord],
78    global_x: i32,
79    global_y: i32,
80) -> Option<&MonitorRecord> {
81    monitors.iter().find(|m| {
82        let right = m.origin_px.x + m.size_px.w;
83        let bottom = m.origin_px.y + m.size_px.h;
84        global_x >= m.origin_px.x
85            && global_x < right
86            && global_y >= m.origin_px.y
87            && global_y < bottom
88    })
89}
90
91/// Convert a global physical point into `space`, using the scale of the
92/// monitor that contains it.
93///
94/// Logical conversion divides by that monitor's scale — per monitor, not
95/// per desktop, so mixed-DPI layouts come out right. The division is
96/// `pixelcoords_core::space::logical_of`, so this tool and
97/// `pixelcoords resolve` cannot answer the same question differently.
98///
99/// **The answer is a whole coordinate, and that is a change.** This used
100/// to divide in `f64` and carry the fraction, on the argument that
101/// rounding is the enemy of a click landing where it was aimed. Measured
102/// at the boundary that actually matters, it is the other way round:
103/// every injector converts to an integer before it synthesizes anything,
104/// macOS by truncating, so the fraction was never spent — it was
105/// discarded, one step later, less accurately. Truncating is never better
106/// than rounding and is sometimes twice as bad (scale 3.0, physical 1625:
107/// 2 px of error against 1). Rounding once, here, is both more accurate
108/// and the same answer `pixelcoords resolve --units auto` gives.
109pub fn to_space(
110    monitors: &[MonitorRecord],
111    global_x: i32,
112    global_y: i32,
113    space: Space,
114) -> Option<ResolvedPoint> {
115    let monitor = monitor_at(monitors, global_x, global_y)?;
116    let resolved = match space {
117        Space::Auto => native_space(),
118        other => other,
119    };
120    let physical = Point::new(global_x, global_y);
121    let point = match resolved {
122        Space::Logical => logical_of(physical, monitor.scale),
123        _ => physical,
124    };
125    Some(ResolvedPoint {
126        x: f64::from(point.x),
127        y: f64::from(point.y),
128        space: resolved,
129        monitor: monitor.index,
130        scale: monitor.scale,
131    })
132}
133
134/// Whether a point already expressed in `space` sits within `margin` of
135/// any monitor's corner.
136///
137/// This is the kill switch. The instinct when automation goes wrong is
138/// to grab the mouse, and a corner is the one place a human can reach
139/// without aiming — pyautogui proved the pattern, and it costs no
140/// listener thread, no extra permission, and no global hotkey.
141///
142/// It is unambiguous here for a reason specific to this tool: a flow
143/// only ever moves the cursor to a *marked region's* click point, and no
144/// human marks a region in the dead corner of a screen. A cursor found
145/// there is evidence of a person, not of us.
146///
147/// The comparison happens in the input space rather than in physical
148/// pixels because that is the space the cursor is read in — converting
149/// the corners forward avoids needing an inverse conversion that would
150/// have to guess which monitor an unplaced point belongs to.
151pub fn near_screen_corner(
152    monitors: &[MonitorRecord],
153    space: Space,
154    x: f64,
155    y: f64,
156    margin: f64,
157) -> bool {
158    corners(monitors, space)
159        .iter()
160        .any(|corner| (corner.x - x).abs() <= margin && (corner.y - y).abs() <= margin)
161}
162
163/// Every monitor's four corners, converted into `space`.
164///
165/// The far edges use `size - 1`: a monitor 3600 pixels wide has its last
166/// column at 3599, and asking about 3600 would land in the next monitor
167/// or off the desktop entirely.
168pub fn corners(monitors: &[MonitorRecord], space: Space) -> Vec<ResolvedPoint> {
169    let mut corners = Vec::with_capacity(monitors.len() * 4);
170    for monitor in monitors {
171        let left = monitor.origin_px.x;
172        let top = monitor.origin_px.y;
173        let right = left + monitor.size_px.w - 1;
174        let bottom = top + monitor.size_px.h - 1;
175        for (x, y) in [(left, top), (right, top), (left, bottom), (right, bottom)] {
176            if let Some(point) = to_space(monitors, x, y, space) {
177                corners.push(point);
178            }
179        }
180    }
181    corners
182}
183
184#[cfg(test)]
185mod tests {
186    use pixelcoords_core::geometry::{Point, Size};
187
188    use super::*;
189
190    fn monitor(index: usize, origin: (i32, i32), size: (i32, i32), scale: f64) -> MonitorRecord {
191        MonitorRecord {
192            index,
193            name: format!("display {index}"),
194            primary: index == 0,
195            origin_px: Point::new(origin.0, origin.1),
196            size_px: Size::new(size.0, size.1),
197            scale,
198        }
199    }
200
201    #[test]
202    fn every_monitor_contributes_four_corners() {
203        let monitors = retina_plus_external();
204        assert_eq!(
205            corners(&monitors, Space::Physical).len(),
206            monitors.len() * 4
207        );
208    }
209
210    #[test]
211    fn a_cursor_slammed_into_a_corner_trips_the_kill_switch() {
212        let monitors = vec![monitor(0, (0, 0), (3024, 1964), 2.0)];
213        // Logical space: the physical bottom-right (3023, 1963) is
214        // (1511.5, 981.5) once divided by the scale.
215        assert!(near_screen_corner(
216            &monitors,
217            Space::Logical,
218            1511.5,
219            981.5,
220            10.0
221        ));
222        assert!(near_screen_corner(
223            &monitors,
224            Space::Logical,
225            0.0,
226            0.0,
227            10.0
228        ));
229        // Just inside the margin, from the top-right corner.
230        assert!(near_screen_corner(
231            &monitors,
232            Space::Logical,
233            1503.0,
234            8.0,
235            10.0
236        ));
237    }
238
239    #[test]
240    fn the_middle_of_the_screen_is_not_a_corner() {
241        let monitors = vec![monitor(0, (0, 0), (3024, 1964), 2.0)];
242        assert!(!near_screen_corner(
243            &monitors,
244            Space::Logical,
245            700.0,
246            500.0,
247            10.0
248        ));
249        // An edge is not a corner — only the four extremes count.
250        assert!(!near_screen_corner(
251            &monitors,
252            Space::Logical,
253            700.0,
254            0.0,
255            10.0
256        ));
257    }
258
259    #[test]
260    fn a_corner_of_any_monitor_counts_not_just_the_primary() {
261        let monitors = retina_plus_external();
262        let external = &monitors[1];
263        let corner = to_space(
264            &monitors,
265            external.origin_px.x + external.size_px.w - 1,
266            external.origin_px.y + external.size_px.h - 1,
267            Space::Physical,
268        )
269        .expect("a monitor contains its own corner");
270        assert!(near_screen_corner(
271            &monitors,
272            Space::Physical,
273            corner.x,
274            corner.y,
275            2.0
276        ));
277    }
278
279    fn retina_plus_external() -> Vec<MonitorRecord> {
280        vec![
281            monitor(0, (0, 0), (3024, 1964), 2.0),
282            monitor(1, (3024, 0), (1920, 1080), 1.0),
283        ]
284    }
285
286    #[test]
287    fn logical_conversion_divides_by_the_containing_monitors_scale() {
288        let monitors = retina_plus_external();
289        let point = to_space(&monitors, 1624, 880, Space::Logical).expect("inside monitor 0");
290        assert_eq!(point.monitor, 0);
291        assert!((point.x - 812.0).abs() < f64::EPSILON);
292        assert!((point.y - 440.0).abs() < f64::EPSILON);
293    }
294
295    #[test]
296    fn a_mixed_dpi_layout_converts_per_monitor_not_per_desktop() {
297        let monitors = retina_plus_external();
298        // Same request, a point on the 1x external display: unchanged.
299        let point = to_space(&monitors, 4000, 500, Space::Logical).expect("inside monitor 1");
300        assert_eq!(point.monitor, 1);
301        assert!((point.x - 4000.0).abs() < f64::EPSILON);
302        assert!((point.y - 500.0).abs() < f64::EPSILON);
303    }
304
305    #[test]
306    fn physical_space_is_the_identity() {
307        let monitors = retina_plus_external();
308        let point = to_space(&monitors, 1624, 880, Space::Physical).expect("inside monitor 0");
309        assert!((point.x - 1624.0).abs() < f64::EPSILON);
310        assert!((point.y - 880.0).abs() < f64::EPSILON);
311        assert_eq!(point.space, Space::Physical);
312    }
313
314    #[test]
315    fn a_point_in_a_gap_between_monitors_resolves_to_nothing() {
316        // Two monitors with a hole between them: 0 ends at x=1920, 1
317        // starts at x=2000.
318        let monitors = vec![
319            monitor(0, (0, 0), (1920, 1080), 1.0),
320            monitor(1, (2000, 0), (1920, 1080), 1.0),
321        ];
322        assert!(monitor_at(&monitors, 1960, 500).is_none());
323        assert!(to_space(&monitors, 1960, 500, Space::Auto).is_none());
324    }
325
326    #[test]
327    fn monitor_bounds_are_half_open_so_edges_belong_to_exactly_one() {
328        let monitors = retina_plus_external();
329        // x=3024 is the first pixel of monitor 1, not the last of 0.
330        assert_eq!(monitor_at(&monitors, 3023, 0).expect("m0").index, 0);
331        assert_eq!(monitor_at(&monitors, 3024, 0).expect("m1").index, 1);
332    }
333
334    #[test]
335    fn auto_resolves_to_the_platforms_own_space() {
336        let monitors = retina_plus_external();
337        let point = to_space(&monitors, 1624, 880, Space::Auto).expect("inside monitor 0");
338        assert_eq!(point.space, native_space());
339        // The recorded space is never Auto — callers see what they got.
340        assert_ne!(point.space, Space::Auto);
341    }
342
343    #[test]
344    fn negative_origins_are_handled() {
345        // A display placed left of the primary carries a negative origin.
346        let monitors = vec![
347            monitor(0, (0, 0), (1920, 1080), 1.0),
348            monitor(1, (-1920, 0), (1920, 1080), 2.0),
349        ];
350        let point = to_space(&monitors, -960, 500, Space::Logical).expect("inside monitor 1");
351        assert_eq!(point.monitor, 1);
352        assert!((point.x - -480.0).abs() < f64::EPSILON);
353    }
354}