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}