Skip to main content

pixelcoords_core/
space.rs

1//! Where a coordinate is measured from, and what units it is in.
2//!
3//! These are two independent questions and the CLI spells them as two
4//! flags. An origin says which corner `(0, 0)` is; units say whether one
5//! step is a device pixel or a logical point. Folding them into one
6//! vocabulary — the shape `resolve` was first drafted with — cannot
7//! express `--space monitor --units logical`, which is a perfectly
8//! ordinary thing to ask for on a Retina secondary display.
9//!
10//! The arithmetic lives here; the monitor lookup does not. Each caller
11//! finds its own monitor record and raises its own error for a missing
12//! one, because a shared error type for that would belong to nobody.
13
14use crate::geometry::Point;
15
16/// Which origin a coordinate is measured from.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Origin {
19    /// The desktop's own grid: `global_px`.
20    Global,
21    /// One monitor's top-left, by its index in the session: `px`.
22    Monitor(usize),
23    /// The `--target` window's top-left: `window_px`.
24    Window,
25}
26
27impl Origin {
28    /// The name this origin carries in JSON output.
29    #[must_use]
30    pub const fn label(self) -> &'static str {
31        match self {
32            Self::Global => "global",
33            Self::Monitor(_) => "monitor",
34            Self::Window => "window",
35        }
36    }
37}
38
39/// The OS a coordinate will be handed to. Passed as a value rather than
40/// read from `cfg!` so core stays platform-free and one headless test run
41/// can cover all three.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Platform {
44    MacOs,
45    Windows,
46    Linux,
47}
48
49/// The units a coordinate is expressed in, as spelled on the CLI.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Units {
52    Physical,
53    Logical,
54    /// Whatever this platform's input APIs expect. The one value most
55    /// callers want, and the reason the flag exists: the mismatch it
56    /// hides is where consumers of these coordinates go wrong.
57    Auto,
58}
59
60/// `Units` with `Auto` already answered — the only thing the arithmetic
61/// accepts, so "did I resolve auto?" cannot be forgotten at a call site.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Resolved {
64    Physical,
65    Logical,
66}
67
68impl Units {
69    /// macOS input APIs speak logical points; Windows and X11 speak
70    /// physical pixels. This is the same split `emit`'s per-format table
71    /// documents, stated once.
72    #[must_use]
73    pub const fn resolve(self, platform: Platform) -> Resolved {
74        match self {
75            Self::Physical => Resolved::Physical,
76            Self::Logical => Resolved::Logical,
77            Self::Auto => match platform {
78                Platform::MacOs => Resolved::Logical,
79                Platform::Windows | Platform::Linux => Resolved::Physical,
80            },
81        }
82    }
83}
84
85/// Physical pixels to logical points at `scale`.
86///
87/// Monitor origins were divided by this same per-monitor factor when the
88/// session was written, so the conversion inverts cleanly even when two
89/// displays disagree about scale.
90#[must_use]
91pub fn logical_of(physical: Point, scale: f64) -> Point {
92    Point::new(
93        (f64::from(physical.x) / scale).round() as i32,
94        (f64::from(physical.y) / scale).round() as i32,
95    )
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn auto_follows_the_platforms_input_api() {
104        assert_eq!(Units::Auto.resolve(Platform::MacOs), Resolved::Logical);
105        assert_eq!(Units::Auto.resolve(Platform::Windows), Resolved::Physical);
106        assert_eq!(Units::Auto.resolve(Platform::Linux), Resolved::Physical);
107    }
108
109    #[test]
110    fn an_explicit_unit_ignores_the_platform() {
111        for platform in [Platform::MacOs, Platform::Windows, Platform::Linux] {
112            assert_eq!(Units::Physical.resolve(platform), Resolved::Physical);
113            assert_eq!(Units::Logical.resolve(platform), Resolved::Logical);
114        }
115    }
116
117    #[test]
118    fn logical_halves_a_retina_point_and_leaves_scale_one_alone() {
119        assert_eq!(logical_of(Point::new(100, 50), 2.0), Point::new(50, 25));
120        assert_eq!(logical_of(Point::new(100, 50), 1.0), Point::new(100, 50));
121    }
122
123    #[test]
124    fn logical_rounds_rather_than_truncating() {
125        // 1.5 physical at scale 2 is 0.75 logical: 1, not 0. Truncation
126        // would bias every odd coordinate toward the origin.
127        assert_eq!(logical_of(Point::new(3, 3), 2.0), Point::new(2, 2));
128        assert_eq!(logical_of(Point::new(-3, -3), 2.0), Point::new(-2, -2));
129    }
130
131    #[test]
132    fn origins_label_themselves_for_json() {
133        assert_eq!(Origin::Global.label(), "global");
134        assert_eq!(Origin::Monitor(3).label(), "monitor");
135        assert_eq!(Origin::Window.label(), "window");
136    }
137}