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}