Skip to main content

visual_cortex_capture/
region.rs

1use crate::error::CaptureError;
2
3/// A resolved, pixel-space rectangle, guaranteed in-bounds by `Region::resolve`.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct PxRect {
6    pub x: u32,
7    pub y: u32,
8    pub w: u32,
9    pub h: u32,
10}
11
12/// A watch region. Fractional (`Percent`) regions survive resolution changes;
13/// validation happens at `resolve` time against actual frame dimensions.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum Region {
16    Full,
17    Percent { x: f32, y: f32, w: f32, h: f32 },
18    Px { x: u32, y: u32, w: u32, h: u32 },
19}
20
21impl Region {
22    pub fn percent(x: f32, y: f32, w: f32, h: f32) -> Self {
23        Region::Percent { x, y, w, h }
24    }
25
26    pub fn px(x: u32, y: u32, w: u32, h: u32) -> Self {
27        Region::Px { x, y, w, h }
28    }
29
30    pub fn resolve(&self, frame_w: u32, frame_h: u32) -> Result<PxRect, CaptureError> {
31        if frame_w == 0 || frame_h == 0 {
32            return Err(CaptureError::InvalidRegion("zero-sized frame".into()));
33        }
34        let rect = match *self {
35            Region::Full => PxRect {
36                x: 0,
37                y: 0,
38                w: frame_w,
39                h: frame_h,
40            },
41            Region::Px { x, y, w, h } => PxRect { x, y, w, h },
42            Region::Percent { x, y, w, h } => {
43                let valid = (0.0..=1.0).contains(&x)
44                    && (0.0..=1.0).contains(&y)
45                    && w > 0.0
46                    && h > 0.0
47                    // Epsilon guards against float rounding when x+w should equal
48                    // exactly 1.0 (e.g. thirds: 1.0/3.0 * 3.0 ≈ 1.0000001).
49                    && x + w <= 1.0 + f32::EPSILON
50                    && y + h <= 1.0 + f32::EPSILON;
51                if !valid {
52                    return Err(CaptureError::InvalidRegion(format!(
53                        "percent region out of range: x={x} y={y} w={w} h={h}"
54                    )));
55                }
56                // Invariant: px <= frame_w-1 (resp. py), so frame_w - px >= 1 and the
57                // clamp below can never see min > max. Do not weaken the .min() caps.
58                let px = ((x * frame_w as f32).round() as u32).min(frame_w - 1);
59                let py = ((y * frame_h as f32).round() as u32).min(frame_h - 1);
60                let pw = ((w * frame_w as f32).round() as u32).clamp(1, frame_w - px);
61                let ph = ((h * frame_h as f32).round() as u32).clamp(1, frame_h - py);
62                PxRect {
63                    x: px,
64                    y: py,
65                    w: pw,
66                    h: ph,
67                }
68            }
69        };
70        // For Px regions this is the primary validation; for Percent it is
71        // defense-in-depth only (the clamps above already guarantee in-bounds).
72        let in_bounds = rect.w > 0
73            && rect.h > 0
74            && rect.x.checked_add(rect.w).is_some_and(|r| r <= frame_w)
75            && rect.y.checked_add(rect.h).is_some_and(|b| b <= frame_h);
76        if !in_bounds {
77            return Err(CaptureError::InvalidRegion(format!(
78                "{rect:?} out of bounds for {frame_w}x{frame_h}"
79            )));
80        }
81        Ok(rect)
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn full_resolves_to_whole_frame() {
91        assert_eq!(
92            Region::Full.resolve(1920, 1080).unwrap(),
93            PxRect {
94                x: 0,
95                y: 0,
96                w: 1920,
97                h: 1080
98            }
99        );
100    }
101
102    #[test]
103    fn percent_resolves_and_rounds() {
104        let r = Region::percent(0.05, 0.03, 0.25, 0.06)
105            .resolve(1920, 1080)
106            .unwrap();
107        assert_eq!(
108            r,
109            PxRect {
110                x: 96,
111                y: 32,
112                w: 480,
113                h: 65
114            }
115        );
116    }
117
118    #[test]
119    fn percent_clamps_rounding_overflow() {
120        // 0.5 * 101 rounds to 51 for both x-offset and width; must clamp, not error
121        let r = Region::percent(0.5, 0.0, 0.5, 1.0)
122            .resolve(101, 10)
123            .unwrap();
124        assert_eq!(r.x, 51);
125        assert_eq!(r.x + r.w, 101);
126    }
127
128    #[test]
129    fn percent_out_of_range_errors() {
130        assert!(Region::percent(0.8, 0.0, 0.4, 0.5)
131            .resolve(100, 100)
132            .is_err()); // x+w > 1
133        assert!(Region::percent(0.0, 0.0, 0.0, 0.5)
134            .resolve(100, 100)
135            .is_err()); // zero width
136        assert!(Region::percent(-0.1, 0.0, 0.5, 0.5)
137            .resolve(100, 100)
138            .is_err());
139    }
140
141    #[test]
142    fn px_out_of_bounds_errors() {
143        assert!(Region::px(90, 0, 20, 10).resolve(100, 100).is_err());
144        assert_eq!(
145            Region::px(10, 20, 30, 40).resolve(100, 100).unwrap(),
146            PxRect {
147                x: 10,
148                y: 20,
149                w: 30,
150                h: 40
151            }
152        );
153    }
154
155    #[test]
156    fn percent_at_frame_edge_stays_in_bounds() {
157        // x rounds to the last pixel; width must clamp to 1, not panic or error
158        let r = Region::percent(0.999, 0.0, 0.001, 1.0)
159            .resolve(100, 10)
160            .unwrap();
161        assert_eq!(
162            r,
163            PxRect {
164                x: 99,
165                y: 0,
166                w: 1,
167                h: 10
168            }
169        );
170        // 1x1 frame, full-coverage percent region
171        let r = Region::percent(0.0, 0.0, 1.0, 1.0).resolve(1, 1).unwrap();
172        assert_eq!(
173            r,
174            PxRect {
175                x: 0,
176                y: 0,
177                w: 1,
178                h: 1
179            }
180        );
181    }
182
183    #[test]
184    fn percent_rejects_nan() {
185        assert!(Region::percent(f32::NAN, 0.0, 0.5, 0.5)
186            .resolve(100, 100)
187            .is_err());
188        assert!(Region::percent(0.0, 0.0, f32::NAN, 0.5)
189            .resolve(100, 100)
190            .is_err());
191    }
192}