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