Skip to main content

screen_record/
select.rs

1use crate::error::CliError;
2use crate::types::WindowInfo;
3
4#[derive(Debug, Clone, Default)]
5pub struct SelectionArgs {
6    pub window_id: Option<u32>,
7    pub app: Option<String>,
8    pub window_name: Option<String>,
9    pub active_window: bool,
10}
11
12pub fn select_window(windows: &[WindowInfo], args: &SelectionArgs) -> Result<WindowInfo, CliError> {
13    if let Some(id) = args.window_id {
14        return windows
15            .iter()
16            .find(|window| window.id == id)
17            .cloned()
18            .ok_or_else(|| CliError::usage(format!("no window found with id {id}")));
19    }
20
21    if args.active_window {
22        return select_frontmost(windows).ok_or_else(|| CliError::usage("no active window found"));
23    }
24
25    let Some(app) = args.app.as_deref() else {
26        return Err(CliError::usage("missing selection flag"));
27    };
28
29    let mut candidates: Vec<WindowInfo> = windows
30        .iter()
31        .filter(|window| contains_case_insensitive(&window.owner_name, app))
32        .cloned()
33        .collect();
34
35    if let Some(window_name) = args.window_name.as_deref() {
36        candidates.retain(|window| contains_case_insensitive(&window.title, window_name));
37    }
38
39    if candidates.is_empty() {
40        return Err(CliError::usage(format!("no windows match --app \"{app}\"")));
41    }
42
43    if args.window_name.is_some() {
44        if candidates.len() == 1 {
45            return Ok(candidates.remove(0));
46        }
47        return Err(ambiguous_app_error(app, &candidates));
48    }
49
50    let frontmost = frontmost_for_app(&candidates);
51    match frontmost {
52        Some(window) => Ok(window),
53        None => Err(ambiguous_app_error(app, &candidates)),
54    }
55}
56
57fn select_frontmost(windows: &[WindowInfo]) -> Option<WindowInfo> {
58    windows
59        .iter()
60        .filter(|window| window.active && window.on_screen)
61        .min_by_key(|window| window.z_order)
62        .cloned()
63        .or_else(|| {
64            windows
65                .iter()
66                .filter(|window| window.on_screen)
67                .min_by_key(|window| window.z_order)
68                .cloned()
69        })
70        .or_else(|| {
71            windows
72                .iter()
73                .filter(|window| window.active)
74                .min_by_key(|window| window.z_order)
75                .cloned()
76        })
77}
78
79fn frontmost_for_app(candidates: &[WindowInfo]) -> Option<WindowInfo> {
80    if let Some(window) = pick_unique_by_z_order(
81        candidates
82            .iter()
83            .filter(|window| window.active && window.on_screen),
84    ) {
85        return Some(window);
86    }
87
88    if let Some(window) =
89        pick_unique_by_z_order(candidates.iter().filter(|window| window.on_screen))
90    {
91        return Some(window);
92    }
93
94    pick_unique_by_z_order(candidates.iter().filter(|window| window.active))
95}
96
97fn contains_case_insensitive(haystack: &str, needle: &str) -> bool {
98    haystack
99        .to_ascii_lowercase()
100        .contains(&needle.to_ascii_lowercase())
101}
102
103fn ambiguous_app_error(app: &str, candidates: &[WindowInfo]) -> CliError {
104    let mut sorted = candidates.to_vec();
105    sorted.sort_by(|a, b| {
106        a.owner_name
107            .cmp(&b.owner_name)
108            .then_with(|| a.title.cmp(&b.title))
109            .then_with(|| a.id.cmp(&b.id))
110    });
111
112    let mut message = format!(
113        "error: multiple windows match --app \"{app}\"\nerror: refine with --window-name or use --window-id"
114    );
115
116    for window in sorted {
117        message.push('\n');
118        message.push_str(&format_window_tsv(&window));
119    }
120
121    CliError::usage(message)
122}
123
124pub fn format_window_tsv(window: &WindowInfo) -> String {
125    format!(
126        "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
127        window.id,
128        normalize_tsv_field(&window.owner_name),
129        normalize_tsv_field(&window.title),
130        window.bounds.x,
131        window.bounds.y,
132        window.bounds.width,
133        window.bounds.height,
134        if window.on_screen { "true" } else { "false" }
135    )
136}
137
138fn pick_unique_by_z_order<'a, I>(iter: I) -> Option<WindowInfo>
139where
140    I: IntoIterator<Item = &'a WindowInfo>,
141{
142    let mut list: Vec<&WindowInfo> = iter.into_iter().collect();
143    if list.is_empty() {
144        return None;
145    }
146    list.sort_by_key(|window| window.z_order);
147    let best = list[0];
148    if list
149        .iter()
150        .skip(1)
151        .any(|window| window.z_order == best.z_order)
152    {
153        return None;
154    }
155    Some(best.clone())
156}
157
158fn normalize_tsv_field(value: &str) -> String {
159    value
160        .chars()
161        .map(|ch| {
162            if ch == '\t' || ch == '\n' || ch == '\r' {
163                ' '
164            } else {
165                ch
166            }
167        })
168        .collect()
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::types::Rect;
175
176    fn window(
177        id: u32,
178        owner: &str,
179        title: &str,
180        on_screen: bool,
181        active: bool,
182        z: usize,
183    ) -> WindowInfo {
184        WindowInfo {
185            id,
186            owner_name: owner.to_string(),
187            title: title.to_string(),
188            bounds: Rect {
189                x: 0,
190                y: 0,
191                width: 100,
192                height: 100,
193            },
194            on_screen,
195            active,
196            owner_pid: 1,
197            z_order: z,
198        }
199    }
200
201    #[test]
202    fn select_by_window_id() {
203        let windows = vec![window(10, "Terminal", "Inbox", true, false, 0)];
204        let args = SelectionArgs {
205            window_id: Some(10),
206            ..SelectionArgs::default()
207        };
208        let selected = select_window(&windows, &args).expect("select window");
209        assert_eq!(selected.id, 10);
210    }
211
212    #[test]
213    fn select_by_app_picks_frontmost() {
214        let windows = vec![
215            window(10, "Terminal", "Inbox", true, false, 1),
216            window(11, "Terminal", "Docs", true, false, 0),
217        ];
218        let args = SelectionArgs {
219            app: Some("Terminal".to_string()),
220            ..SelectionArgs::default()
221        };
222        let selected = select_window(&windows, &args).expect("select window");
223        assert_eq!(selected.id, 11);
224    }
225
226    #[test]
227    fn select_by_app_and_window_name() {
228        let windows = vec![
229            window(10, "Terminal", "Inbox", true, false, 0),
230            window(11, "Terminal", "Docs", true, false, 1),
231        ];
232        let args = SelectionArgs {
233            app: Some("Terminal".to_string()),
234            window_name: Some("Docs".to_string()),
235            ..SelectionArgs::default()
236        };
237        let selected = select_window(&windows, &args).expect("select window");
238        assert_eq!(selected.id, 11);
239    }
240
241    #[test]
242    fn ambiguous_app_selection_errors() {
243        let windows = vec![
244            window(10, "Terminal", "Inbox", false, false, 0),
245            window(11, "Terminal", "Docs", false, false, 1),
246        ];
247        let args = SelectionArgs {
248            app: Some("Terminal".to_string()),
249            ..SelectionArgs::default()
250        };
251        let err = select_window(&windows, &args).expect_err("ambiguous error");
252        assert_eq!(err.exit_code(), 2);
253        assert!(
254            err.to_string()
255                .contains("multiple windows match --app \"Terminal\"")
256        );
257    }
258
259    #[test]
260    fn select_active_window_prefers_active() {
261        let windows = vec![
262            window(10, "Terminal", "Inbox", true, false, 0),
263            window(11, "Terminal", "Docs", true, true, 5),
264        ];
265        let args = SelectionArgs {
266            active_window: true,
267            ..SelectionArgs::default()
268        };
269        let selected = select_window(&windows, &args).expect("select window");
270        assert_eq!(selected.id, 11);
271    }
272
273    #[test]
274    fn select_by_app_prefers_active() {
275        let windows = vec![
276            window(10, "Terminal", "Inbox", true, false, 0),
277            window(11, "Terminal", "Docs", true, true, 3),
278        ];
279        let args = SelectionArgs {
280            app: Some("Terminal".to_string()),
281            ..SelectionArgs::default()
282        };
283        let selected = select_window(&windows, &args).expect("select window");
284        assert_eq!(selected.id, 11);
285    }
286}