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/// How well a query matched a monitor name. Ranked worst to best, so
72/// deriving `Ord` gives the comparison the selector wants.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
74enum NameMatch {
75    Contains,
76    StartsWith,
77    Exact,
78}
79
80/// Pick the monitors whose names match `query`, best rank only.
81///
82/// Same discipline as [`select`] — case-insensitive, exact beats prefix
83/// beats substring — but the return shape differs on purpose. Windows are
84/// stacked, so `select` can always break a tie with z-order and hand back a
85/// single winner. Monitors have no such ordering: two displays matching a
86/// query equally well is a question only the user can answer, and silently
87/// picking one would freeze the wrong screen. So this returns **every**
88/// candidate at the best rank and leaves the caller to reject more than one.
89///
90/// Indices are into `names`, in the order given. An empty query matches
91/// nothing, as with windows.
92pub fn select_monitors(query: &str, names: &[String]) -> Vec<usize> {
93    let query = query.trim().to_lowercase();
94    if query.is_empty() {
95        return Vec::new();
96    }
97    let ranked: Vec<(NameMatch, usize)> = names
98        .iter()
99        .enumerate()
100        .filter_map(|(i, name)| {
101            let name = name.to_lowercase();
102            if name == query {
103                return Some((NameMatch::Exact, i));
104            }
105            if name.starts_with(&query) {
106                return Some((NameMatch::StartsWith, i));
107            }
108            if name.contains(&query) {
109                return Some((NameMatch::Contains, i));
110            }
111            None
112        })
113        .collect();
114    let Some(best) = ranked.iter().map(|(kind, _)| *kind).max() else {
115        return Vec::new();
116    };
117    ranked
118        .into_iter()
119        .filter(|(kind, _)| *kind == best)
120        .map(|(_, i)| i)
121        .collect()
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    fn win(title: &str, app: &str, z: i32) -> WindowCandidate {
129        WindowCandidate {
130            title: title.into(),
131            app: app.into(),
132            z,
133        }
134    }
135
136    #[test]
137    fn exact_title_beats_substring() {
138        let candidates = [
139            win("Notepad - notes.txt", "Notepad", 5),
140            win("Notepad", "Notepad", 1),
141        ];
142        assert_eq!(select("notepad", &candidates), Some(1));
143    }
144
145    #[test]
146    fn title_match_beats_app_match() {
147        let candidates = [
148            win("Untitled", "Safari", 9),
149            win("Safari release notes", "TextEdit", 1),
150        ];
151        assert_eq!(select("safari", &candidates), Some(1));
152    }
153
154    #[test]
155    fn front_most_wins_within_a_kind() {
156        let candidates = [
157            win("report draft", "Word", 1),
158            win("report final", "Word", 7),
159            win("report old", "Word", 3),
160        ];
161        assert_eq!(select("report", &candidates), Some(1));
162    }
163
164    #[test]
165    fn matching_is_case_insensitive() {
166        let candidates = [win("My APP Window", "Thing", 0)];
167        assert_eq!(select("my app", &candidates), Some(0));
168    }
169
170    #[test]
171    fn no_match_and_empty_query_return_none() {
172        let candidates = [win("Something", "App", 0)];
173        assert_eq!(select("zzz", &candidates), None);
174        assert_eq!(select("", &candidates), None);
175        assert_eq!(select("   ", &candidates), None);
176        assert_eq!(select("x", &[]), None);
177    }
178
179    fn names(list: &[&str]) -> Vec<String> {
180        list.iter().map(|s| (*s).to_string()).collect()
181    }
182
183    #[test]
184    fn monitor_exact_beats_prefix_beats_substring() {
185        let all = names(&["DELL U2723QE Secondary", "DELL U2723QE", "Old DELL U2723QE"]);
186        assert_eq!(select_monitors("dell u2723qe", &all), vec![1]);
187    }
188
189    #[test]
190    fn a_monitor_prefix_beats_a_substring() {
191        let all = names(&["Thunderbolt Display", "Built-in Thunderbolt"]);
192        assert_eq!(select_monitors("thunderbolt", &all), vec![0]);
193    }
194
195    #[test]
196    fn monitor_matching_is_case_insensitive_and_trims() {
197        let all = names(&["Built-in Retina Display"]);
198        assert_eq!(select_monitors("  BUILT-IN  ", &all), vec![0]);
199    }
200
201    #[test]
202    fn every_equally_good_monitor_comes_back_so_the_caller_can_refuse() {
203        // Two identical panels, one query, no z-order to separate them.
204        // Returning both is the point: picking one would freeze the wrong
205        // screen and never say so.
206        let all = names(&["DELL U2723QE", "DELL U2723QE"]);
207        assert_eq!(select_monitors("dell", &all), vec![0, 1]);
208    }
209
210    #[test]
211    fn a_better_rank_still_wins_over_several_worse_ones() {
212        // Ambiguity is only ambiguity at the same rank — an exact match
213        // resolves a query that three substrings also matched.
214        let all = names(&["DELL U2723QE Left", "DELL", "DELL U2723QE Right"]);
215        assert_eq!(select_monitors("dell", &all), vec![1]);
216    }
217
218    #[test]
219    fn no_monitor_match_and_empty_query_return_nothing() {
220        let all = names(&["Built-in Retina Display"]);
221        assert!(select_monitors("zzz", &all).is_empty());
222        assert!(select_monitors("", &all).is_empty());
223        assert!(select_monitors("   ", &all).is_empty());
224        assert!(select_monitors("x", &[]).is_empty());
225    }
226}