Skip to main content

pixelactions_core/
virtualdesk.rs

1//! Normalizing a session's physical pixel into the grid Windows takes.
2//!
3//! `SendInput` with `MOUSEEVENTF_ABSOLUTE` does not accept pixels. It
4//! accepts a number from 0 to 65535 on each axis, and the rectangle that
5//! number is measured against depends on one flag: without
6//! `MOUSEEVENTF_VIRTUALDESK` it is the **primary monitor**, and with it,
7//! the **virtual desktop** — the bounding box of every monitor, whose
8//! origin is the top-left of the primary display and whose coordinates are
9//! therefore negative for anything placed above or to the left of it.
10//!
11//! A session records global physical pixels, which is the virtual desktop's
12//! own space, so the whole conversion is this normalization. It lives here
13//! rather than in the injector for the reason everything else does: the
14//! part that decides where to click must be testable without a screen, and
15//! this particular arithmetic has a famous off-by-one worth pinning for
16//! every input rather than for a few examples.
17//!
18//! **The off-by-one.** Pixels span `0..=(dimension − 1)`, so the divisor is
19//! `dimension − 1`, not `dimension`. Dividing by the full width leaves the
20//! rightmost column and bottom row unreachable and every other pixel
21//! fractionally short — a drift that grows with distance from the origin
22//! and is invisible on a small screen. Rounding rather than truncating is
23//! the other half of the same rule.
24
25use serde::Serialize;
26
27/// The largest value either axis of an absolute mouse event may carry.
28/// Windows' own constant, and the reason the arithmetic below needs 64-bit
29/// intermediates: `65535 × 65535` does not fit in an `i32`.
30const FULL_SCALE: i64 = 65535;
31
32/// The bounding box of every monitor attached to this machine, as Windows
33/// reports it — `SM_XVIRTUALSCREEN`, `SM_YVIRTUALSCREEN`,
34/// `SM_CXVIRTUALSCREEN`, `SM_CYVIRTUALSCREEN`.
35///
36/// The origin is the primary monitor's top-left, so `x` and `y` are
37/// **negative** whenever a monitor sits above or to the left of it. That is
38/// the normal case for a left-hand secondary display, not an error, and it
39/// is the arrangement most likely to be mis-clicked by code that assumes a
40/// desktop starts at (0, 0).
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42pub struct VirtualDesktop {
43    pub x: i32,
44    pub y: i32,
45    pub width: i32,
46    pub height: i32,
47}
48
49impl VirtualDesktop {
50    /// The rightmost and bottom-most pixel that exists — the divisor, and
51    /// the coordinate the off-by-one otherwise makes unreachable.
52    fn last_pixel(self) -> (i64, i64) {
53        (i64::from(self.width) - 1, i64::from(self.height) - 1)
54    }
55
56    /// Whether a global physical point is on this desktop at all.
57    fn contains(self, x: i32, y: i32) -> bool {
58        let right = i64::from(self.x) + i64::from(self.width);
59        let bottom = i64::from(self.y) + i64::from(self.height);
60        i64::from(x) >= i64::from(self.x)
61            && i64::from(y) >= i64::from(self.y)
62            && i64::from(x) < right
63            && i64::from(y) < bottom
64    }
65}
66
67/// Why a point could not be expressed as an absolute mouse event.
68///
69/// Both variants are refusals. Windows clamps an out-of-range absolute
70/// coordinate to the edge of the desktop and clicks there, which is the one
71/// outcome this tool exists to prevent — a click that lands somewhere
72/// plausible and wrong is worse than a run that stops.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum NormalizeError {
75    /// The point is not on this machine's desktop. The usual cause is a
76    /// session captured on a different machine, or on this one before a
77    /// monitor was unplugged or rearranged.
78    Outside {
79        x: i32,
80        y: i32,
81        desktop: VirtualDesktop,
82    },
83    /// Windows reported a desktop with no pixels in it, which happens when
84    /// there is no attached display — a headless server, or an RDP session
85    /// that has been disconnected rather than logged out.
86    Empty { desktop: VirtualDesktop },
87}
88
89impl std::fmt::Display for NormalizeError {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Self::Outside { x, y, desktop } => write!(
93                f,
94                "({x}, {y}) is not a point on this machine's desktop, which spans \
95                 {} × {} from ({}, {}). Windows would clamp an absolute event to the \
96                 nearest edge and click there, so this is refused instead. Re-mark the \
97                 region with pixelcoords on this machine",
98                desktop.width, desktop.height, desktop.x, desktop.y
99            ),
100            Self::Empty { desktop } => write!(
101                f,
102                "Windows reports a virtual desktop of {} × {}, so there is no screen to \
103                 aim at. A disconnected RDP session or a machine with no attached \
104                 display looks like this",
105                desktop.width, desktop.height
106            ),
107        }
108    }
109}
110
111impl std::error::Error for NormalizeError {}
112
113/// Convert a global physical pixel into the absolute pair `SendInput` takes
114/// alongside `MOUSEEVENTF_VIRTUALDESK`.
115///
116/// Rounds to the nearest, and divides by the last pixel rather than the
117/// dimension — see the module note. A single-pixel axis (a degenerate
118/// display, but expressible) has one reachable coordinate, and it is 0.
119pub fn normalize(desktop: VirtualDesktop, x: i32, y: i32) -> Result<(i32, i32), NormalizeError> {
120    if desktop.width <= 0 || desktop.height <= 0 {
121        return Err(NormalizeError::Empty { desktop });
122    }
123    if !desktop.contains(x, y) {
124        return Err(NormalizeError::Outside { x, y, desktop });
125    }
126    let (last_x, last_y) = desktop.last_pixel();
127    Ok((
128        scale(i64::from(x) - i64::from(desktop.x), last_x),
129        scale(i64::from(y) - i64::from(desktop.y), last_y),
130    ))
131}
132
133/// One axis: an offset from the desktop's own origin, over the span of that
134/// axis, times full scale — rounded, in 64-bit, because the numerator
135/// reaches 4.3 billion on a wide desktop and this workspace panics on
136/// overflow rather than wrapping.
137fn scale(offset: i64, span: i64) -> i32 {
138    if span <= 0 {
139        return 0;
140    }
141    ((offset * FULL_SCALE + span / 2) / span) as i32
142}
143
144/// How Windows reads an absolute coordinate back, used to state the
145/// round-trip rule as a test rather than as a comment.
146///
147/// Not `pub`: nothing in the binary needs it, and shipping it would imply
148/// the injector should be checking its own arithmetic at runtime.
149#[cfg(test)]
150fn denormalize(desktop: VirtualDesktop, dx: i32, dy: i32) -> (i32, i32) {
151    let (last_x, last_y) = desktop.last_pixel();
152    let back = |value: i32, span: i64| {
153        if span <= 0 {
154            return 0;
155        }
156        ((i64::from(value) * span + FULL_SCALE / 2) / FULL_SCALE) as i32
157    };
158    (desktop.x + back(dx, last_x), desktop.y + back(dy, last_y))
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    /// A 1920×1080 primary with a 1920×1200 secondary to its left, which is
166    /// the layout that catches everything: negative origins, a desktop
167    /// wider and taller than the primary, and a bottom-right corner that
168    /// belongs to neither monitor alone.
169    const MIXED: VirtualDesktop = VirtualDesktop {
170        x: -1920,
171        y: -120,
172        width: 3840,
173        height: 1200,
174    };
175
176    const PRIMARY_ONLY: VirtualDesktop = VirtualDesktop {
177        x: 0,
178        y: 0,
179        width: 1920,
180        height: 1080,
181    };
182
183    /// The corners are the whole point. The origin must be 0 and the last
184    /// pixel must be full scale — dividing by the dimension rather than the
185    /// dimension minus one yields 65500 here, which is a pixel short of the
186    /// edge and unreachable forever.
187    #[test]
188    fn the_far_corner_is_reachable_at_all() {
189        let (x, y) = normalize(PRIMARY_ONLY, 0, 0).expect("the origin is a pixel");
190        assert_eq!((x, y), (0, 0));
191
192        let (x, y) = normalize(PRIMARY_ONLY, 1919, 1079).expect("the last pixel is a pixel");
193        assert_eq!(
194            (x, y),
195            (65535, 65535),
196            "the rightmost column and bottom row must be addressable"
197        );
198    }
199
200    /// The negative-origin case, which is what `MOUSEEVENTF_VIRTUALDESK`
201    /// exists for. A secondary display placed left of the primary starts at
202    /// a negative x, and its top-left is the desktop's 0 — not the
203    /// primary's.
204    #[test]
205    fn a_display_left_of_the_primary_normalizes_from_the_desktop_origin() {
206        assert_eq!(
207            normalize(MIXED, -1920, -120).expect("the desktop's own corner"),
208            (0, 0)
209        );
210        assert_eq!(
211            normalize(MIXED, 1919, 1079).expect("the far corner"),
212            (65535, 65535)
213        );
214        // The primary monitor's own origin is not the desktop's. It sits
215        // just past the middle of a desktop twice its width — past, not on,
216        // because the divisor is the last pixel rather than the width — and
217        // it round-trips. Normalizing against the primary alone, which is
218        // what enigo does, would send 0 here and click the far left edge of
219        // the secondary display instead.
220        let (x, y) = normalize(MIXED, 0, 0).expect("the primary's origin");
221        assert_eq!((x, y), (32776, 6559));
222        assert_eq!(denormalize(MIXED, x, y), (0, 0));
223    }
224
225    /// Every pixel survives the round trip through Windows' reading of the
226    /// number. This is the off-by-one and the rounding stated as one rule,
227    /// for every pixel on both axes rather than for the corners.
228    #[test]
229    fn every_pixel_maps_back_to_itself() {
230        for desktop in [PRIMARY_ONLY, MIXED] {
231            for step in 0..desktop.width {
232                let x = desktop.x + step;
233                let (dx, dy) = normalize(desktop, x, desktop.y).expect("on the desktop");
234                assert_eq!(
235                    denormalize(desktop, dx, dy).0,
236                    x,
237                    "x={x} came back wrong on {desktop:?}"
238                );
239            }
240            for step in 0..desktop.height {
241                let y = desktop.y + step;
242                let (dx, dy) = normalize(desktop, desktop.x, y).expect("on the desktop");
243                assert_eq!(
244                    denormalize(desktop, dx, dy).1,
245                    y,
246                    "y={y} came back wrong on {desktop:?}"
247                );
248            }
249        }
250    }
251
252    /// Truncating instead of rounding would show up as a point that maps
253    /// back one pixel short. Stated directly, because it is the half of the
254    /// rule the `− 1` alone does not fix.
255    #[test]
256    fn rounding_is_to_the_nearest_not_toward_zero() {
257        // 1000 × 65535 / 1919 = 34150.60…, so the nearest is 34151 and
258        // truncation would give 34150.
259        let (dx, _) = normalize(PRIMARY_ONLY, 1000, 0).expect("on screen");
260        assert_eq!(dx, 34151);
261        assert_eq!(denormalize(PRIMARY_ONLY, dx, 0).0, 1000);
262    }
263
264    /// A point off the desktop is named and refused, never clamped: Windows
265    /// would click the nearest edge, which is the failure this tool exists
266    /// to prevent.
267    #[test]
268    fn a_point_off_the_desktop_is_refused_by_name() {
269        for (x, y) in [(1920, 0), (0, 1080), (-1, 0), (0, -1)] {
270            let error = normalize(PRIMARY_ONLY, x, y).expect_err("off the desktop");
271            let message = error.to_string();
272            assert!(message.contains(&format!("({x}, {y})")), "{message}");
273            assert!(message.contains("clamp"), "says why it refused: {message}");
274            assert!(matches!(error, NormalizeError::Outside { .. }));
275        }
276        // And the same point is fine on a desktop that does contain it.
277        assert!(normalize(MIXED, -1, 0).is_ok());
278    }
279
280    /// No attached display is a state to report, not a division by zero.
281    #[test]
282    fn a_desktop_with_no_pixels_is_refused_rather_than_divided_by() {
283        for desktop in [
284            VirtualDesktop {
285                x: 0,
286                y: 0,
287                width: 0,
288                height: 0,
289            },
290            VirtualDesktop {
291                x: 0,
292                y: 0,
293                width: 1920,
294                height: 0,
295            },
296        ] {
297            let error = normalize(desktop, 0, 0).expect_err("nothing to aim at");
298            assert!(matches!(error, NormalizeError::Empty { .. }));
299            assert!(error.to_string().contains("no screen to aim at"));
300        }
301    }
302
303    /// A single-pixel axis has exactly one coordinate and no span to divide
304    /// by. Degenerate, but expressible, and it must not panic.
305    #[test]
306    fn a_single_pixel_axis_has_one_reachable_coordinate() {
307        let sliver = VirtualDesktop {
308            x: 0,
309            y: 0,
310            width: 1,
311            height: 1,
312        };
313        assert_eq!(normalize(sliver, 0, 0).expect("the only pixel"), (0, 0));
314        assert!(normalize(sliver, 1, 0).is_err());
315    }
316
317    /// The intermediate multiplication reaches 4.3 billion, which does not
318    /// fit in an `i32`, and this workspace panics on overflow in release as
319    /// well as debug. A desktop at the top of the addressable range is the
320    /// case that would find it.
321    #[test]
322    fn a_desktop_at_full_scale_does_not_overflow() {
323        let huge = VirtualDesktop {
324            x: 0,
325            y: 0,
326            width: 65536,
327            height: 65536,
328        };
329        assert_eq!(normalize(huge, 0, 0).expect("the origin"), (0, 0));
330        assert_eq!(
331            normalize(huge, 65535, 65535).expect("the far corner"),
332            (65535, 65535)
333        );
334    }
335}