Skip to main content

term_wm_layout_engine/
mouse_coord.rs

1use crate::LayoutRect;
2
3/// A mouse cursor position with an explicit coordinate space tag.
4///
5/// Always `CoordSpace::Screen` for dispatched events. The `space` field
6/// exists to prevent accidental mixing of coordinate systems — a compile-time
7/// reminder that all positions are in absolute screen coordinates and
8/// should never be mutated during dispatch.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct MousePosition {
11    /// Column in cells (signed — no u16 underflow on out-of-bounds drag).
12    pub column: i16,
13    /// Row in cells (signed — no u16 underflow on out-of-bounds drag).
14    pub row: i16,
15    /// Coordinate space tag. Always `Screen` for dispatched events.
16    pub space: CoordSpace,
17}
18
19/// Distinguishes coordinate spaces to prevent accidental mixing.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CoordSpace {
22    /// Absolute position on the screen (0,0 = top-left).
23    /// All dispatched mouse events use this space.
24    Screen,
25}
26
27impl MousePosition {
28    /// Returns `true` if this position lies within `area` in screen coordinates.
29    ///
30    /// This is the unified replacement for ad-hoc `rect_contains()` checks
31    /// scattered across component `handle_events` methods. Because the
32    /// hitbox registry already guarantees the component was the hit target,
33    /// this is used only for sub-widget coordinate gallity checks.
34    pub fn is_inside(&self, area: LayoutRect) -> bool {
35        self.column >= area.x as i16
36            && self.column < (area.x.saturating_add(i32::from(area.width))) as i16
37            && self.row >= area.y as i16
38            && self.row < (area.y.saturating_add(i32::from(area.height))) as i16
39    }
40
41    /// Convert this screen-space position to local (area-relative) coordinates.
42    ///
43    /// Returns `Some((col, row))` if the position is inside `area`, or `None`
44    /// if outside. This replaces the pattern `mouse.column - last_area.x` with
45    /// a single checked operation.
46    ///
47    /// Unlike bare subtraction, this does not underflow or produce
48    /// nonsensical values for out-of-bounds positions.
49    pub fn to_local(&self, area: LayoutRect) -> Option<(u16, u16)> {
50        if !self.is_inside(area) {
51            return None;
52        }
53        let local_col = self.column.saturating_sub(area.x as i16);
54        let local_row = self.row.saturating_sub(area.y as i16);
55        // Both values are guaranteed non-negative and within area bounds
56        // because is_inside already checked containment.
57        Some((local_col as u16, local_row as u16))
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn is_inside_returns_true_for_contained_point() {
67        let area = LayoutRect {
68            x: 5,
69            y: 10,
70            width: 20,
71            height: 30,
72        };
73        let pos = MousePosition {
74            column: 10,
75            row: 15,
76            space: CoordSpace::Screen,
77        };
78        assert!(pos.is_inside(area));
79    }
80
81    #[test]
82    fn is_inside_returns_false_for_outside_point() {
83        let area = LayoutRect {
84            x: 5,
85            y: 10,
86            width: 20,
87            height: 30,
88        };
89        let pos = MousePosition {
90            column: 100,
91            row: 100,
92            space: CoordSpace::Screen,
93        };
94        assert!(!pos.is_inside(area));
95    }
96
97    #[test]
98    fn is_inside_handles_edge_boundary() {
99        let area = LayoutRect {
100            x: 5,
101            y: 10,
102            width: 20,
103            height: 30,
104        };
105        // Just inside
106        let pos = MousePosition {
107            column: 5,
108            row: 10,
109            space: CoordSpace::Screen,
110        };
111        assert!(pos.is_inside(area));
112        // Just outside (right edge)
113        let pos = MousePosition {
114            column: 25,
115            row: 10,
116            space: CoordSpace::Screen,
117        };
118        assert!(!pos.is_inside(area));
119        // Just outside (bottom edge)
120        let pos = MousePosition {
121            column: 5,
122            row: 40,
123            space: CoordSpace::Screen,
124        };
125        assert!(!pos.is_inside(area));
126    }
127
128    #[test]
129    fn to_local_returns_correct_offset() {
130        let area = LayoutRect {
131            x: 10,
132            y: 20,
133            width: 50,
134            height: 60,
135        };
136        let pos = MousePosition {
137            column: 25,
138            row: 35,
139            space: CoordSpace::Screen,
140        };
141        let local = pos.to_local(area).unwrap();
142        assert_eq!(local, (15, 15));
143    }
144
145    #[test]
146    fn to_local_returns_none_when_outside() {
147        let area = LayoutRect {
148            x: 10,
149            y: 20,
150            width: 50,
151            height: 60,
152        };
153        let pos = MousePosition {
154            column: 5,
155            row: 35,
156            space: CoordSpace::Screen,
157        };
158        assert!(pos.to_local(area).is_none());
159    }
160
161    #[test]
162    fn negative_coordinates_dont_underflow() {
163        let area = LayoutRect {
164            x: 10,
165            y: 20,
166            width: 50,
167            height: 60,
168        };
169        let pos = MousePosition {
170            column: -5,
171            row: -10,
172            space: CoordSpace::Screen,
173        };
174        assert!(!pos.is_inside(area));
175        assert!(pos.to_local(area).is_none());
176    }
177}