Skip to main content

pixelcoords_core/
matcher.rs

1//! Window-target matching for `--target`: given the windows visible at
2//! freeze time, deterministically pick the one the query means.
3//!
4//! Policy (ported in spirit from the predecessor's exact-or-substring
5//! matcher): all comparison is case-insensitive; better match kinds always
6//! beat worse ones; within a kind the front-most window (highest z) wins,
7//! then the lowest enumeration index. Title matches outrank app-name
8//! matches so `--target "Notepad"` prefers a window *titled* Notepad over
9//! any window merely owned by Notepad.exe.
10
11/// A visible window at freeze time, in enumeration order.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct WindowCandidate {
14    pub title: String,
15    pub app: String,
16    /// Stacking order; higher is closer to the front.
17    pub z: i32,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21enum MatchKind {
22    AppContains,
23    AppExact,
24    TitleContains,
25    TitleStartsWith,
26    TitleExact,
27}
28
29fn match_kind(query: &str, candidate: &WindowCandidate) -> Option<MatchKind> {
30    let q = query.to_lowercase();
31    let title = candidate.title.to_lowercase();
32    let app = candidate.app.to_lowercase();
33    // Ranked policy, best match first — each guard returns as soon as it
34    // applies.
35    if title == q {
36        return Some(MatchKind::TitleExact);
37    }
38    if title.starts_with(&q) {
39        return Some(MatchKind::TitleStartsWith);
40    }
41    if title.contains(&q) {
42        return Some(MatchKind::TitleContains);
43    }
44    if app == q {
45        return Some(MatchKind::AppExact);
46    }
47    if app.contains(&q) {
48        return Some(MatchKind::AppContains);
49    }
50    None
51}
52
53/// Pick the best candidate for `query`. Returns the index into
54/// `candidates`, or `None` when nothing matches. Empty queries match
55/// nothing.
56pub fn select(query: &str, candidates: &[WindowCandidate]) -> Option<usize> {
57    let query = query.trim();
58    if query.is_empty() {
59        return None;
60    }
61    candidates
62        .iter()
63        .enumerate()
64        .filter_map(|(i, c)| {
65            match_kind(query, c).map(|kind| ((kind, c.z, std::cmp::Reverse(i)), i))
66        })
67        .max_by_key(|(key, _)| *key)
68        .map(|(_, i)| i)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    fn win(title: &str, app: &str, z: i32) -> WindowCandidate {
76        WindowCandidate {
77            title: title.into(),
78            app: app.into(),
79            z,
80        }
81    }
82
83    #[test]
84    fn exact_title_beats_substring() {
85        let candidates = [
86            win("Notepad - notes.txt", "Notepad", 5),
87            win("Notepad", "Notepad", 1),
88        ];
89        assert_eq!(select("notepad", &candidates), Some(1));
90    }
91
92    #[test]
93    fn title_match_beats_app_match() {
94        let candidates = [
95            win("Untitled", "Safari", 9),
96            win("Safari release notes", "TextEdit", 1),
97        ];
98        assert_eq!(select("safari", &candidates), Some(1));
99    }
100
101    #[test]
102    fn front_most_wins_within_a_kind() {
103        let candidates = [
104            win("report draft", "Word", 1),
105            win("report final", "Word", 7),
106            win("report old", "Word", 3),
107        ];
108        assert_eq!(select("report", &candidates), Some(1));
109    }
110
111    #[test]
112    fn matching_is_case_insensitive() {
113        let candidates = [win("My APP Window", "Thing", 0)];
114        assert_eq!(select("my app", &candidates), Some(0));
115    }
116
117    #[test]
118    fn no_match_and_empty_query_return_none() {
119        let candidates = [win("Something", "App", 0)];
120        assert_eq!(select("zzz", &candidates), None);
121        assert_eq!(select("", &candidates), None);
122        assert_eq!(select("   ", &candidates), None);
123        assert_eq!(select("x", &[]), None);
124    }
125}