Skip to main content

pixelactions_core/
stream.rs

1//! Placing a session's physical pixel inside a Wayland input region.
2//!
3//! Every other platform takes absolute coordinates in a space this crate
4//! can name at compile time — logical points on macOS, physical pixels on
5//! Windows and X11. Wayland does not. Absolute pointer motion is bounded
6//! by a **region** the compositor grants at runtime, derived from the
7//! screencast stream the user consented to share, and expressed in the
8//! compositor's *logical* pixels. "Exact physical pixel" on Wayland
9//! therefore means "exact pixel within a region you were granted".
10//!
11//! So the last hop of the conversion cannot live in [`crate::convert`]
12//! with the others: it needs a runtime fact. What lives here is the
13//! arithmetic — regions and monitors in, a placement out — so the part
14//! that decides where to click stays testable without a compositor. The
15//! injector supplies the regions; it does no math of its own.
16//!
17//! Two rules carried over from [`crate::convert`], for the same reason:
18//! the divisor is the **containing monitor's** scale as the session
19//! recorded it, never a desktop-wide one; and a point that does not land
20//! is **refused**, never clamped. A clamped point clicks somewhere
21//! plausible and wrong, which is worse than an error.
22
23use pixelcoords_core::session::MonitorRecord;
24use serde::Serialize;
25
26/// One absolute-input region, as the compositor described it.
27///
28/// Sizes and offsets are the compositor's logical pixels. `scale` is the
29/// scale it reports for the region; it is recorded so `doctor` can show
30/// it and so a mismatch against the session is visible, but the mapping
31/// deliberately does **not** divide by it — see [`place`].
32#[derive(Debug, Clone, PartialEq, Serialize)]
33pub struct Region {
34    pub offset_x: i32,
35    pub offset_y: i32,
36    pub width: i32,
37    pub height: i32,
38    pub scale: f64,
39    /// Ties this region to a screencast stream, when the compositor says
40    /// so. Optional in the protocol, so never relied on for correctness.
41    pub mapping_id: Option<String>,
42}
43
44impl Region {
45    /// The logical size a monitor of this physical size and scale would
46    /// occupy — the quantity a region is matched against.
47    fn logical_size_of(monitor: &MonitorRecord) -> (i32, i32) {
48        let scale = if monitor.scale > 0.0 {
49            monitor.scale
50        } else {
51            1.0
52        };
53        (
54            (f64::from(monitor.size_px.w) / scale).round() as i32,
55            (f64::from(monitor.size_px.h) / scale).round() as i32,
56        )
57    }
58
59    /// Whether this region plausibly *is* the given monitor.
60    ///
61    /// Compared in logical pixels derived from the session's own scale
62    /// rather than from `self.scale`: the session is the authoritative
63    /// record of a monitor's DPI factor, and compositors have been
64    /// observed reporting a region scale of 1.0 regardless.
65    fn covers(&self, monitor: &MonitorRecord) -> bool {
66        Self::logical_size_of(monitor) == (self.width, self.height)
67    }
68
69    /// Whether a logical point falls inside this region.
70    ///
71    /// Test-only on purpose: `place` needs no bounds check because
72    /// `covers` makes one impossible to fail, and this is what the test
73    /// that pins that invariant checks against. Shipping it as public API
74    /// would imply callers should be testing bounds themselves.
75    #[cfg(test)]
76    fn contains(&self, x: f64, y: f64) -> bool {
77        let left = f64::from(self.offset_x);
78        let top = f64::from(self.offset_y);
79        x >= left
80            && y >= top
81            && x < left + f64::from(self.width)
82            && y < top + f64::from(self.height)
83    }
84}
85
86/// A point resolved into one region's logical space.
87#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
88pub struct Placement {
89    /// Index into the regions that were offered — which one to send to.
90    pub region: usize,
91    pub x: f64,
92    pub y: f64,
93}
94
95/// Why a physical point could not be placed in any granted region.
96///
97/// Every variant is a refusal. There is deliberately no "closest region"
98/// fallback: the whole promise of this tool is that a click lands where a
99/// human marked it, and a near miss breaks that promise silently.
100// Not `Eq`: two variants carry the coordinates that failed, and those are
101// f64 because that is what a scaled division produces.
102#[derive(Debug, Clone, PartialEq)]
103pub enum PlaceError {
104    /// The point is in a gap between monitors, or off the desktop.
105    OutsideEveryMonitor { x: i32, y: i32 },
106    /// Nothing was granted that looks like the monitor the point is on —
107    /// the usual cause is consenting to share one screen of several.
108    NoRegionForMonitor { monitor: usize },
109    /// More than one granted region matches the monitor, so choosing one
110    /// would be a guess.
111    AmbiguousRegion { monitor: usize, matches: usize },
112}
113
114impl std::fmt::Display for PlaceError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            Self::OutsideEveryMonitor { x, y } => write!(
118                f,
119                "the point ({x}, {y}) is not on any monitor the session describes"
120            ),
121            Self::NoRegionForMonitor { monitor } => write!(
122                f,
123                "no shared region matches monitor {monitor} — the grant covers a \
124                 different screen, so re-run and share the screen the region is on"
125            ),
126            Self::AmbiguousRegion { monitor, matches } => write!(
127                f,
128                "{matches} shared regions match monitor {monitor}, so which one to \
129                 aim at would be a guess — share a single screen instead"
130            ),
131        }
132    }
133}
134
135impl std::error::Error for PlaceError {}
136
137/// Place a global physical point into the logical space of whichever
138/// granted region covers its monitor.
139///
140/// The arithmetic is deliberately small: subtract the monitor's physical
141/// origin, divide by that monitor's scale to get logical pixels, then add
142/// the region's logical offset. The region's offset *is* the monitor's
143/// position in the compositor's logical layout, which is why no global
144/// logical layout has to be known or guessed.
145pub fn place(
146    monitors: &[MonitorRecord],
147    regions: &[Region],
148    global_x: i32,
149    global_y: i32,
150) -> Result<Placement, PlaceError> {
151    let Some(monitor) = crate::convert::monitor_at(monitors, global_x, global_y) else {
152        return Err(PlaceError::OutsideEveryMonitor {
153            x: global_x,
154            y: global_y,
155        });
156    };
157    let mut matching = regions
158        .iter()
159        .enumerate()
160        .filter(|(_, region)| region.covers(monitor));
161    let Some((index, region)) = matching.next() else {
162        return Err(PlaceError::NoRegionForMonitor {
163            monitor: monitor.index,
164        });
165    };
166    let extra = matching.count();
167    if extra > 0 {
168        return Err(PlaceError::AmbiguousRegion {
169            monitor: monitor.index,
170            matches: extra + 1,
171        });
172    }
173
174    let scale = if monitor.scale > 0.0 {
175        monitor.scale
176    } else {
177        1.0
178    };
179    // No bounds check follows, and none is needed: `covers` already
180    // established that the region's logical size *equals* this monitor's,
181    // and a point on the monitor is at most `size - 1` physical pixels
182    // from its origin — so the divided offset always lands inside. A
183    // guard here would be a branch no input can reach.
184    let x = f64::from(region.offset_x) + f64::from(global_x - monitor.origin_px.x) / scale;
185    let y = f64::from(region.offset_y) + f64::from(global_y - monitor.origin_px.y) / scale;
186    Ok(Placement {
187        region: index,
188        x,
189        y,
190    })
191}
192
193/// Turn a point in a region's logical space back into a global physical
194/// pixel.
195///
196/// The inverse exists for one reason: the kill switch. Where a compositor
197/// can report the pointer's position it reports it in the region's space,
198/// and the corner check happens in the session's physical grid — so the
199/// reading has to come back before it can be judged. Keeping the inverse
200/// next to the forward mapping is how they stay consistent.
201pub fn unplace(
202    monitors: &[MonitorRecord],
203    regions: &[Region],
204    placement: Placement,
205) -> Option<(f64, f64)> {
206    let region = regions.get(placement.region)?;
207    let monitor = monitors.iter().find(|monitor| region.covers(monitor))?;
208    let scale = if monitor.scale > 0.0 {
209        monitor.scale
210    } else {
211        1.0
212    };
213    Some((
214        f64::from(monitor.origin_px.x) + (placement.x - f64::from(region.offset_x)) * scale,
215        f64::from(monitor.origin_px.y) + (placement.y - f64::from(region.offset_y)) * scale,
216    ))
217}
218
219#[cfg(test)]
220mod tests {
221    use pixelcoords_core::geometry::{Point, Size};
222
223    use super::*;
224
225    fn monitor(index: usize, origin: (i32, i32), size: (i32, i32), scale: f64) -> MonitorRecord {
226        MonitorRecord {
227            index,
228            name: format!("display {index}"),
229            primary: index == 0,
230            origin_px: Point::new(origin.0, origin.1),
231            size_px: Size::new(size.0, size.1),
232            scale,
233        }
234    }
235
236    fn region(offset: (i32, i32), size: (i32, i32), scale: f64) -> Region {
237        Region {
238            offset_x: offset.0,
239            offset_y: offset.1,
240            width: size.0,
241            height: size.1,
242            scale,
243            mapping_id: None,
244        }
245    }
246
247    /// The shape this was first verified against by hand: one 1x monitor,
248    /// one region, offsets zero. The mapping is the identity, and if this
249    /// ever stops being true the manual verification stops meaning
250    /// anything.
251    #[test]
252    fn a_single_unscaled_monitor_maps_one_to_one() {
253        let monitors = vec![monitor(0, (0, 0), (1800, 1130), 1.0)];
254        let regions = vec![region((0, 0), (1800, 1130), 1.0)];
255        let placed = place(&monitors, &regions, 900, 565).expect("centre of the only screen");
256        assert_eq!(placed.region, 0);
257        assert!((placed.x - 900.0).abs() < f64::EPSILON);
258        assert!((placed.y - 565.0).abs() < f64::EPSILON);
259    }
260
261    #[test]
262    fn a_scaled_monitor_divides_by_the_sessions_scale() {
263        // A 2x panel: 3600x2260 physical is 1800x1130 logical, which is
264        // the size the compositor grants.
265        let monitors = vec![monitor(0, (0, 0), (3600, 2260), 2.0)];
266        let regions = vec![region((0, 0), (1800, 1130), 2.0)];
267        let placed = place(&monitors, &regions, 1624, 880).expect("inside");
268        assert!((placed.x - 812.0).abs() < f64::EPSILON);
269        assert!((placed.y - 440.0).abs() < f64::EPSILON);
270    }
271
272    /// The reason `covers` ignores the region's own scale: a compositor
273    /// reporting 1.0 for a 2x screen must not change where we click.
274    #[test]
275    fn a_region_scale_of_one_on_a_retina_screen_does_not_move_the_click() {
276        let monitors = vec![monitor(0, (0, 0), (3600, 2260), 2.0)];
277        let honest = vec![region((0, 0), (1800, 1130), 2.0)];
278        let understated = vec![region((0, 0), (1800, 1130), 1.0)];
279        assert_eq!(
280            place(&monitors, &honest, 1624, 880).expect("honest"),
281            place(&monitors, &understated, 1624, 880).expect("understated")
282        );
283    }
284
285    #[test]
286    fn a_mixed_dpi_layout_uses_each_monitors_own_scale() {
287        let monitors = vec![
288            monitor(0, (0, 0), (3600, 2260), 2.0),
289            monitor(1, (3600, 0), (1920, 1080), 1.0),
290        ];
291        // Logical layout puts the 1x screen right of the 2x one's 1800.
292        let regions = vec![
293            region((0, 0), (1800, 1130), 2.0),
294            region((1800, 0), (1920, 1080), 1.0),
295        ];
296        let on_retina = place(&monitors, &regions, 1624, 880).expect("monitor 0");
297        assert_eq!(on_retina.region, 0);
298        assert!((on_retina.x - 812.0).abs() < f64::EPSILON);
299
300        let on_external = place(&monitors, &regions, 4000, 500).expect("monitor 1");
301        assert_eq!(on_external.region, 1);
302        // 4000 physical is 400 into a 1x screen, at logical offset 1800.
303        assert!((on_external.x - 2200.0).abs() < f64::EPSILON);
304        assert!((on_external.y - 500.0).abs() < f64::EPSILON);
305    }
306
307    #[test]
308    fn a_display_left_of_primary_carries_negative_origins() {
309        let monitors = vec![
310            monitor(0, (0, 0), (1920, 1080), 1.0),
311            monitor(1, (-2560, 0), (2560, 1440), 2.0),
312        ];
313        let regions = vec![
314            region((0, 0), (1920, 1080), 1.0),
315            region((-1280, 0), (1280, 720), 2.0),
316        ];
317        let placed = place(&monitors, &regions, -1280, 720).expect("inside monitor 1");
318        assert_eq!(placed.region, 1);
319        // 1280 physical into the 2x screen is 640 logical, from -1280.
320        assert!((placed.x - -640.0).abs() < f64::EPSILON);
321        assert!((placed.y - 360.0).abs() < f64::EPSILON);
322    }
323
324    #[test]
325    fn a_point_off_every_monitor_is_refused() {
326        let monitors = vec![monitor(0, (0, 0), (1800, 1130), 1.0)];
327        let regions = vec![region((0, 0), (1800, 1130), 1.0)];
328        assert_eq!(
329            place(&monitors, &regions, 5000, 5000),
330            Err(PlaceError::OutsideEveryMonitor { x: 5000, y: 5000 })
331        );
332    }
333
334    /// Sharing one screen of two is the common consent mistake, and it
335    /// has to name the fix rather than click on the wrong screen.
336    #[test]
337    fn sharing_the_wrong_screen_is_refused_by_name() {
338        let monitors = vec![
339            monitor(0, (0, 0), (1920, 1080), 1.0),
340            monitor(1, (1920, 0), (2560, 1440), 1.0),
341        ];
342        let regions = vec![region((0, 0), (1920, 1080), 1.0)];
343        assert_eq!(
344            place(&monitors, &regions, 3000, 700),
345            Err(PlaceError::NoRegionForMonitor { monitor: 1 })
346        );
347    }
348
349    #[test]
350    fn two_identical_screens_shared_at_once_are_ambiguous_not_guessed() {
351        let monitors = vec![
352            monitor(0, (0, 0), (1920, 1080), 1.0),
353            monitor(1, (1920, 0), (1920, 1080), 1.0),
354        ];
355        // Same logical size, so nothing distinguishes them.
356        let regions = vec![
357            region((0, 0), (1920, 1080), 1.0),
358            region((1920, 0), (1920, 1080), 1.0),
359        ];
360        assert_eq!(
361            place(&monitors, &regions, 100, 100),
362            Err(PlaceError::AmbiguousRegion {
363                monitor: 0,
364                matches: 2
365            })
366        );
367    }
368
369    #[test]
370    fn no_regions_at_all_is_refused() {
371        let monitors = vec![monitor(0, (0, 0), (1800, 1130), 1.0)];
372        assert_eq!(
373            place(&monitors, &[], 100, 100),
374            Err(PlaceError::NoRegionForMonitor { monitor: 0 })
375        );
376    }
377
378    /// The invariant that lets `place` skip a bounds check: whatever the
379    /// scale or origin, every point on a covered monitor lands inside the
380    /// region. Stated over the awkward cases rather than assumed.
381    #[test]
382    fn every_point_on_a_covered_monitor_lands_inside_its_region() {
383        let cases = [
384            ((0, 0), (1800, 1130), 1.0, (0, 0)),
385            ((0, 0), (3600, 2260), 2.0, (0, 0)),
386            ((-2560, -100), (2560, 1440), 2.0, (-1280, -50)),
387            // Sizes that do not divide evenly by the scale.
388            ((0, 0), (1801, 1131), 2.0, (0, 0)),
389            ((0, 0), (1799, 1129), 2.0, (400, 300)),
390            // A downscaled display, where logical is larger than physical.
391            ((0, 0), (1000, 800), 0.5, (0, 0)),
392        ];
393        for (origin, size, scale, offset) in cases {
394            let monitors = vec![monitor(0, origin, size, scale)];
395            let logical = Region::logical_size_of(&monitors[0]);
396            let regions = vec![region(offset, (logical.0, logical.1), scale)];
397            // The extremes are where an off-by-one would show up.
398            let corners = [
399                (origin.0, origin.1),
400                (origin.0 + size.0 - 1, origin.1),
401                (origin.0, origin.1 + size.1 - 1),
402                (origin.0 + size.0 - 1, origin.1 + size.1 - 1),
403            ];
404            for (x, y) in corners {
405                let placed = place(&monitors, &regions, x, y)
406                    .unwrap_or_else(|error| panic!("({x}, {y}) on {size:?}@{scale}: {error}"));
407                assert!(
408                    regions[0].contains(placed.x, placed.y),
409                    "({x}, {y}) on {size:?}@{scale} mapped to ({}, {}), outside {:?}",
410                    placed.x,
411                    placed.y,
412                    regions[0]
413                );
414            }
415        }
416    }
417
418    #[test]
419    fn the_far_edge_belongs_to_the_region_but_one_past_it_does_not() {
420        let monitors = vec![monitor(0, (0, 0), (1800, 1130), 1.0)];
421        let regions = vec![region((0, 0), (1800, 1130), 1.0)];
422        assert!(place(&monitors, &regions, 1799, 1129).is_ok());
423        // 1800 is off this monitor entirely, so it fails earlier.
424        assert_eq!(
425            place(&monitors, &regions, 1800, 0),
426            Err(PlaceError::OutsideEveryMonitor { x: 1800, y: 0 })
427        );
428    }
429
430    #[test]
431    fn a_nonsense_scale_is_treated_as_one_rather_than_dividing_by_zero() {
432        let monitors = vec![monitor(0, (0, 0), (1800, 1130), 0.0)];
433        let regions = vec![region((0, 0), (1800, 1130), 1.0)];
434        let placed = place(&monitors, &regions, 900, 565).expect("scale 0 falls back to 1");
435        assert!((placed.x - 900.0).abs() < f64::EPSILON);
436    }
437
438    #[test]
439    fn unplace_returns_the_physical_pixel_it_came_from() {
440        let monitors = vec![
441            monitor(0, (0, 0), (3600, 2260), 2.0),
442            monitor(1, (3600, 0), (1920, 1080), 1.0),
443        ];
444        let regions = vec![
445            region((0, 0), (1800, 1130), 2.0),
446            region((1800, 0), (1920, 1080), 1.0),
447        ];
448        for (x, y) in [(1624, 880), (4000, 500), (0, 0)] {
449            let placed = place(&monitors, &regions, x, y).expect("inside");
450            let (back_x, back_y) = unplace(&monitors, &regions, placed).expect("reversible");
451            assert!((back_x - f64::from(x)).abs() < 1e-9, "x for ({x}, {y})");
452            assert!((back_y - f64::from(y)).abs() < 1e-9, "y for ({x}, {y})");
453        }
454    }
455
456    #[test]
457    fn unplace_of_an_unknown_region_is_nothing_rather_than_a_panic() {
458        let monitors = vec![monitor(0, (0, 0), (1800, 1130), 1.0)];
459        let regions = vec![region((0, 0), (1800, 1130), 1.0)];
460        let bogus = Placement {
461            region: 7,
462            x: 0.0,
463            y: 0.0,
464        };
465        assert!(unplace(&monitors, &regions, bogus).is_none());
466    }
467
468    #[test]
469    fn the_refusals_all_say_something_useful() {
470        let messages = [
471            PlaceError::OutsideEveryMonitor { x: 1, y: 2 }.to_string(),
472            PlaceError::NoRegionForMonitor { monitor: 1 }.to_string(),
473            PlaceError::AmbiguousRegion {
474                monitor: 0,
475                matches: 2,
476            }
477            .to_string(),
478        ];
479        for message in messages {
480            assert!(message.len() > 30, "too terse: {message}");
481        }
482    }
483}