visual-cortex-capture 0.2.0

Screen-capture abstraction for visual-cortex: frames, regions, rates, the FrameSource trait, and the macOS ScreenCaptureKit backend.
Documentation
use crate::error::CaptureError;

/// A resolved, pixel-space rectangle, guaranteed in-bounds by `Region::resolve`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PxRect {
    pub x: u32,
    pub y: u32,
    pub w: u32,
    pub h: u32,
}

/// A watch region. Fractional (`Percent`) regions survive resolution changes;
/// validation happens at `resolve` time against actual frame dimensions.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Region {
    Full,
    Percent { x: f32, y: f32, w: f32, h: f32 },
    Px { x: u32, y: u32, w: u32, h: u32 },
}

impl Region {
    pub fn percent(x: f32, y: f32, w: f32, h: f32) -> Self {
        Region::Percent { x, y, w, h }
    }

    pub fn px(x: u32, y: u32, w: u32, h: u32) -> Self {
        Region::Px { x, y, w, h }
    }

    pub fn resolve(&self, frame_w: u32, frame_h: u32) -> Result<PxRect, CaptureError> {
        if frame_w == 0 || frame_h == 0 {
            return Err(CaptureError::InvalidRegion("zero-sized frame".into()));
        }
        let rect = match *self {
            Region::Full => PxRect {
                x: 0,
                y: 0,
                w: frame_w,
                h: frame_h,
            },
            Region::Px { x, y, w, h } => PxRect { x, y, w, h },
            Region::Percent { x, y, w, h } => {
                let valid = (0.0..=1.0).contains(&x)
                    && (0.0..=1.0).contains(&y)
                    && w > 0.0
                    && h > 0.0
                    // Epsilon guards against float rounding when x+w should equal
                    // exactly 1.0 (e.g. thirds: 1.0/3.0 * 3.0 ≈ 1.0000001).
                    && x + w <= 1.0 + f32::EPSILON
                    && y + h <= 1.0 + f32::EPSILON;
                if !valid {
                    return Err(CaptureError::InvalidRegion(format!(
                        "percent region out of range: x={x} y={y} w={w} h={h}"
                    )));
                }
                // Invariant: px <= frame_w-1 (resp. py), so frame_w - px >= 1 and the
                // clamp below can never see min > max. Do not weaken the .min() caps.
                let px = ((x * frame_w as f32).round() as u32).min(frame_w - 1);
                let py = ((y * frame_h as f32).round() as u32).min(frame_h - 1);
                let pw = ((w * frame_w as f32).round() as u32).clamp(1, frame_w - px);
                let ph = ((h * frame_h as f32).round() as u32).clamp(1, frame_h - py);
                PxRect {
                    x: px,
                    y: py,
                    w: pw,
                    h: ph,
                }
            }
        };
        // For Px regions this is the primary validation; for Percent it is
        // defense-in-depth only (the clamps above already guarantee in-bounds).
        let in_bounds = rect.w > 0
            && rect.h > 0
            && rect.x.checked_add(rect.w).is_some_and(|r| r <= frame_w)
            && rect.y.checked_add(rect.h).is_some_and(|b| b <= frame_h);
        if !in_bounds {
            return Err(CaptureError::InvalidRegion(format!(
                "{rect:?} out of bounds for {frame_w}x{frame_h}"
            )));
        }
        Ok(rect)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn full_resolves_to_whole_frame() {
        assert_eq!(
            Region::Full.resolve(1920, 1080).unwrap(),
            PxRect {
                x: 0,
                y: 0,
                w: 1920,
                h: 1080
            }
        );
    }

    #[test]
    fn percent_resolves_and_rounds() {
        let r = Region::percent(0.05, 0.03, 0.25, 0.06)
            .resolve(1920, 1080)
            .unwrap();
        assert_eq!(
            r,
            PxRect {
                x: 96,
                y: 32,
                w: 480,
                h: 65
            }
        );
    }

    #[test]
    fn percent_clamps_rounding_overflow() {
        // 0.5 * 101 rounds to 51 for both x-offset and width; must clamp, not error
        let r = Region::percent(0.5, 0.0, 0.5, 1.0)
            .resolve(101, 10)
            .unwrap();
        assert_eq!(r.x, 51);
        assert_eq!(r.x + r.w, 101);
    }

    #[test]
    fn percent_out_of_range_errors() {
        assert!(Region::percent(0.8, 0.0, 0.4, 0.5)
            .resolve(100, 100)
            .is_err()); // x+w > 1
        assert!(Region::percent(0.0, 0.0, 0.0, 0.5)
            .resolve(100, 100)
            .is_err()); // zero width
        assert!(Region::percent(-0.1, 0.0, 0.5, 0.5)
            .resolve(100, 100)
            .is_err());
    }

    #[test]
    fn px_out_of_bounds_errors() {
        assert!(Region::px(90, 0, 20, 10).resolve(100, 100).is_err());
        assert_eq!(
            Region::px(10, 20, 30, 40).resolve(100, 100).unwrap(),
            PxRect {
                x: 10,
                y: 20,
                w: 30,
                h: 40
            }
        );
    }

    #[test]
    fn percent_at_frame_edge_stays_in_bounds() {
        // x rounds to the last pixel; width must clamp to 1, not panic or error
        let r = Region::percent(0.999, 0.0, 0.001, 1.0)
            .resolve(100, 10)
            .unwrap();
        assert_eq!(
            r,
            PxRect {
                x: 99,
                y: 0,
                w: 1,
                h: 10
            }
        );
        // 1x1 frame, full-coverage percent region
        let r = Region::percent(0.0, 0.0, 1.0, 1.0).resolve(1, 1).unwrap();
        assert_eq!(
            r,
            PxRect {
                x: 0,
                y: 0,
                w: 1,
                h: 1
            }
        );
    }

    #[test]
    fn percent_rejects_nan() {
        assert!(Region::percent(f32::NAN, 0.0, 0.5, 0.5)
            .resolve(100, 100)
            .is_err());
        assert!(Region::percent(0.0, 0.0, f32::NAN, 0.5)
            .resolve(100, 100)
            .is_err());
    }
}