visual-cortex-capture 0.7.0

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

/// What to capture: a display by index, or a window selected by id or title.
///
/// Construction is infallible — a matching window need not exist yet (it may
/// appear later and reattach). Resolution failure surfaces at capture time as
/// [`CaptureError::TargetNotFound`](crate::CaptureError::TargetNotFound), and
/// title selectors that match more than one window as
/// [`CaptureError::AmbiguousTarget`](crate::CaptureError::AmbiguousTarget).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
    /// The Nth display reported by the platform (0 = first).
    Display(usize),
    /// The window whose title matches exactly. Errs if several do.
    WindowTitled(String),
    /// A window by OS window id (see `list_targets`); immune to duplicate
    /// titles.
    WindowId(u32),
    /// The window whose title contains this substring, case-insensitively.
    /// Errs if several do.
    WindowContaining(String),
}

impl Target {
    /// Capture the display at `index` (0 = first).
    pub fn display(index: usize) -> Self {
        Target::Display(index)
    }

    /// Capture the window whose title exactly equals `title`.
    pub fn window_titled(title: impl Into<String>) -> Self {
        Target::WindowTitled(title.into())
    }

    /// Capture the window with this OS window id (see `list_targets`).
    pub fn window_id(id: u32) -> Self {
        Target::WindowId(id)
    }

    /// Capture the window whose title contains `substring`
    /// (case-insensitive) — e.g. `window_containing("YouTube")` instead of
    /// reproducing a full Chrome tab title.
    pub fn window_containing(substring: impl Into<String>) -> Self {
        Target::WindowContaining(substring.into())
    }
}

/// How a window-flavored [`Target`] picks among enumerated windows.
// Consumed by the macOS scap backend; headless tests keep it honest on
// every platform, but the lib target is dead code off-macOS until another
// backend lands (M5).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) enum WindowSelector<'a> {
    Exact(&'a str),
    Containing(&'a str),
    Id(u32),
}

#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
impl WindowSelector<'_> {
    fn describe(&self) -> String {
        match self {
            WindowSelector::Exact(t) => format!("window titled {t:?}"),
            WindowSelector::Containing(s) => format!("window containing {s:?}"),
            WindowSelector::Id(id) => format!("window id {id}"),
        }
    }
}

/// Pure window-selection logic over `(id, title)` pairs, so it is testable
/// without a live window server. Zero matches → `TargetNotFound`; more than
/// one → `AmbiguousTarget` naming the candidates (disambiguate with
/// [`Target::window_id`]).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub(crate) fn select_window(
    windows: &[(u32, String)],
    selector: &WindowSelector<'_>,
) -> Result<u32, CaptureError> {
    let matches: Vec<&(u32, String)> = match selector {
        WindowSelector::Id(id) => windows.iter().filter(|(wid, _)| wid == id).collect(),
        WindowSelector::Exact(title) => windows.iter().filter(|(_, t)| t == title).collect(),
        WindowSelector::Containing(sub) => {
            let needle = sub.to_lowercase();
            windows
                .iter()
                .filter(|(_, t)| t.to_lowercase().contains(&needle))
                .collect()
        }
    };
    match matches.as_slice() {
        [] => Err(CaptureError::TargetNotFound(selector.describe())),
        [(id, _)] => Ok(*id),
        many => {
            let candidates = many
                .iter()
                .map(|(id, title)| format!("{id}: {title:?}"))
                .collect::<Vec<_>>()
                .join(", ");
            Err(CaptureError::AmbiguousTarget(format!(
                "{} matches {} windows ({candidates}); disambiguate with Target::window_id",
                selector.describe(),
                many.len(),
            )))
        }
    }
}

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

    #[test]
    fn constructors_build_expected_variants() {
        assert_eq!(Target::display(2), Target::Display(2));
        assert_eq!(
            Target::window_titled("Elden Ring"),
            Target::WindowTitled("Elden Ring".to_string())
        );
        assert_eq!(Target::window_id(42), Target::WindowId(42));
        assert_eq!(
            Target::window_containing("YouTube"),
            Target::WindowContaining("YouTube".to_string())
        );
    }

    fn windows() -> Vec<(u32, String)> {
        vec![
            (10, "Item-0".to_string()),
            (11, "Item-0".to_string()),
            (20, "Diablo IV - Twitch — Google Chrome".to_string()),
            (30, "Terminal".to_string()),
        ]
    }

    #[test]
    fn exact_single_match_wins() {
        let sel = WindowSelector::Exact("Terminal");
        assert_eq!(select_window(&windows(), &sel).unwrap(), 30);
    }

    #[test]
    fn exact_duplicate_titles_are_ambiguous() {
        let sel = WindowSelector::Exact("Item-0");
        let err = select_window(&windows(), &sel).unwrap_err();
        let msg = err.to_string();
        assert!(matches!(err, CaptureError::AmbiguousTarget(_)));
        assert!(msg.contains("10") && msg.contains("11"), "got: {msg}");
        assert!(msg.contains("window_id"), "must point at the fix: {msg}");
    }

    #[test]
    fn containing_is_case_insensitive() {
        let sel = WindowSelector::Containing("google chrome");
        assert_eq!(select_window(&windows(), &sel).unwrap(), 20);
    }

    #[test]
    fn containing_multiple_matches_are_ambiguous() {
        let sel = WindowSelector::Containing("item");
        assert!(matches!(
            select_window(&windows(), &sel),
            Err(CaptureError::AmbiguousTarget(_))
        ));
    }

    #[test]
    fn zero_matches_are_not_found() {
        let sel = WindowSelector::Containing("Balatro");
        assert!(matches!(
            select_window(&windows(), &sel),
            Err(CaptureError::TargetNotFound(_))
        ));
    }

    #[test]
    fn id_selector_hits_and_misses() {
        assert_eq!(
            select_window(&windows(), &WindowSelector::Id(20)).unwrap(),
            20
        );
        assert!(matches!(
            select_window(&windows(), &WindowSelector::Id(999)),
            Err(CaptureError::TargetNotFound(_))
        ));
    }
}