Skip to main content

automata_windows/
taskbar.rs

1/// Taskbar inspection and focus-recovery utilities.
2///
3/// Win10: running app buttons live under a toolbar named "Running applications"
4///        inside `Shell_TrayWnd`.
5///
6/// Win11: the taskbar root is a pane named "Taskbar".  System buttons (Start,
7///        Task View, Search, …) live under a child pane with `id=TaskbarFrame`.
8///        App buttons (pinned / running) live in a sibling container and carry
9///        name suffixes:
10///          - "File Explorer pinned"                    → pinned, 0 windows
11///          - "File Explorer - 1 running window pinned" → pinned, 1 window
12///          - "Notepad - 2 running windows"             → not pinned, 2 windows
13///
14/// The multi-window thumbnail picker on Win11 is activated by the mouse click,
15/// then navigated with Tab×2 (focus first thumbnail), Right×N, Enter.
16
17#[cfg(target_os = "windows")]
18pub use windows_impl::*;
19
20#[cfg(target_os = "windows")]
21mod windows_impl {
22    use std::thread;
23    use std::time::{Duration, Instant};
24
25    use uiautomation::controls::ControlType;
26    use uiautomation::inputs::Mouse;
27    use uiautomation::patterns::UIInvokePattern;
28    use uiautomation::types::{Point, TreeScope, UIProperty};
29    use uiautomation::variants::Variant;
30    use uiautomation::{UIAutomation, UIElement};
31    use windows::Win32::UI::WindowsAndMessaging::{
32        FindWindowExW, GetForegroundWindow, GetWindowThreadProcessId,
33    };
34
35    use crate::process::get_process_name;
36    use crate::util::{find_named_timeout, invoke_or_click};
37
38    /// A button in the taskbar app area.
39    #[derive(Debug, Clone)]
40    pub struct TaskbarButton {
41        /// Raw UIA button name (e.g. `"Notepad - 1 running window"`).
42        pub name: String,
43        /// Display name with running/pinned suffixes stripped.
44        pub display_name: String,
45        /// Number of open windows (0 = pinned shortcut with no open windows).
46        pub window_count: u32,
47        /// PID of the process that owns this button element.
48        pub pid: u32,
49        /// Button centre — used as fallback for mouse click.
50        pub cx: i32,
51        pub cy: i32,
52        /// True when discovered via the Win11 TaskbarFrame path.
53        /// Controls which thumbnail picker strategy is used on click.
54        pub win11: bool,
55        /// The underlying UIA element — used for `Invoke`.
56        pub element: UIElement,
57    }
58
59    /// Enumerate all app buttons in the taskbar (pinned + running).
60    ///
61    /// Tries the Win10 "Running applications" toolbar first; falls back to the
62    /// Win11 `name=Taskbar` pane approach if that toolbar is not found.
63    ///
64    /// Returns an empty vec if the taskbar is not found (e.g. headless machine).
65    pub fn list_taskbar_buttons() -> Vec<TaskbarButton> {
66        let Ok(auto) = UIAutomation::new_direct() else {
67            return vec![];
68        };
69        let Ok(root) = auto.get_root_element() else {
70            return vec![];
71        };
72
73        // Win11 also has a "Running applications" pane but it is always empty —
74        // only use the Win10 result if it actually contains buttons.
75        if let Some(buttons) = collect_buttons_win10(&auto, &root) {
76            if !buttons.is_empty() {
77                return buttons;
78            }
79        }
80        collect_buttons_win11(&auto, &root).unwrap_or_default()
81    }
82
83    // ── Win10 ──────────────────────────────────────────────────────────────────
84
85    /// Find the "Running applications" toolbar and return its direct button children.
86    fn collect_buttons_win10(auto: &UIAutomation, root: &UIElement) -> Option<Vec<TaskbarButton>> {
87        let name_cond = auto
88            .create_property_condition(
89                UIProperty::Name,
90                Variant::from("Running applications"),
91                None,
92            )
93            .ok()?;
94        let toolbar = root.find_first(TreeScope::Descendants, &name_cond).ok()?;
95        let btn_cond = button_or_split_or_menu(auto)?;
96        let buttons = toolbar.find_all(TreeScope::Children, &btn_cond).ok()?;
97        Some(to_taskbar_buttons(buttons, false))
98    }
99
100    // ── Win11 ──────────────────────────────────────────────────────────────────
101
102    /// Win11 structure:
103    ///   desktop → pane "Taskbar" → pane "" → pane id=TaskbarFrame → buttons
104    ///
105    /// Find `id=TaskbarFrame`, take its direct Button children, and exclude
106    /// known system buttons (Start, Task View, Search, …).
107    fn collect_buttons_win11(auto: &UIAutomation, root: &UIElement) -> Option<Vec<TaskbarButton>> {
108        let frame_cond = auto
109            .create_property_condition(
110                UIProperty::AutomationId,
111                Variant::from("TaskbarFrame"),
112                None,
113            )
114            .ok()?;
115        let frame = root.find_first(TreeScope::Descendants, &frame_cond).ok()?;
116
117        let btn_cond = button_or_split_or_menu(auto)?;
118        let children = frame.find_all(TreeScope::Children, &btn_cond).ok()?;
119
120        let system_names: &[&str] = &["start", "search", "task view", "chat", "copilot"];
121
122        let app_buttons: Vec<UIElement> = children
123            .into_iter()
124            .filter(|el| {
125                let name = el.get_name().unwrap_or_default().to_lowercase();
126                !name.is_empty()
127                    && !system_names.iter().any(|s| name == *s)
128                    && !name.starts_with("widgets")
129            })
130            .collect();
131
132        Some(to_taskbar_buttons(app_buttons, true))
133    }
134
135    // ── Shared helpers ─────────────────────────────────────────────────────────
136
137    fn to_taskbar_buttons(elements: Vec<UIElement>, win11: bool) -> Vec<TaskbarButton> {
138        elements
139            .iter()
140            .filter_map(|btn| {
141                let name = btn.get_name().unwrap_or_default();
142                if name.is_empty() {
143                    return None;
144                }
145                let rect = btn.get_bounding_rectangle().ok()?;
146                // Elements with degenerate bounds are off-screen / invisible — skip.
147                if rect.get_width() == 0 && rect.get_height() == 0 {
148                    return None;
149                }
150                let cx = rect.get_left() + rect.get_width() / 2;
151                let cy = rect.get_top() + rect.get_height() / 2;
152                let display_name = strip_app_suffixes(&name);
153                let window_count = parse_window_count(&name);
154                let pid = btn.get_process_id().unwrap_or(0);
155                Some(TaskbarButton {
156                    name,
157                    display_name,
158                    window_count,
159                    pid,
160                    cx,
161                    cy,
162                    win11,
163                    element: btn.clone(),
164                })
165            })
166            .collect()
167    }
168
169    fn button_or_split_or_menu(auto: &UIAutomation) -> Option<uiautomation::core::UICondition> {
170        let c_btn = auto
171            .create_property_condition(
172                UIProperty::ControlType,
173                Variant::from(ControlType::Button as i32),
174                None,
175            )
176            .ok()?;
177        let c_split = auto
178            .create_property_condition(
179                UIProperty::ControlType,
180                Variant::from(ControlType::SplitButton as i32),
181                None,
182            )
183            .ok()?;
184        let c_menu = auto
185            .create_property_condition(
186                UIProperty::ControlType,
187                Variant::from(ControlType::MenuItem as i32),
188                None,
189            )
190            .ok()?;
191        let or1 = auto.create_or_condition(c_btn, c_split).ok()?;
192        auto.create_or_condition(or1, c_menu).ok()
193    }
194
195    // ── Focus ──────────────────────────────────────────────────────────────────
196
197    /// Click the taskbar button by display name and confirm the target process
198    /// is in the foreground afterwards.
199    pub fn focus_app_by_name(
200        button: &str,
201        process_name: &str,
202        window_index: usize,
203    ) -> Result<(), String> {
204        let button_lower = button.to_lowercase();
205
206        let buttons = list_taskbar_buttons();
207        if buttons.is_empty() {
208            return Err("taskbar not found or has no running app buttons".into());
209        }
210
211        let btn = buttons
212            .iter()
213            .find(|b| b.display_name.to_lowercase() == button_lower)
214            .ok_or_else(|| {
215                format!(
216                    "no taskbar button named '{button}' \
217                     (buttons visible: {})",
218                    buttons
219                        .iter()
220                        .map(|b| b.display_name.as_str())
221                        .collect::<Vec<_>>()
222                        .join(", ")
223                )
224            })?;
225
226        // Mouse-click opens the thumbnail picker for multi-window apps.
227        Mouse::new()
228            .click(Point::new(btn.cx, btn.cy))
229            .map_err(|e| format!("taskbar click failed: {e}"))?;
230
231        // If a thumbnail picker appears, activate the item at window_index.
232        // Use the picker strategy that matches where the button was discovered.
233        let picker_timeout = Duration::from_secs(2);
234        if btn.win11 {
235            pick_win11_thumbnail(window_index, picker_timeout);
236        } else {
237            pick_win10_task_switcher(window_index, picker_timeout);
238        }
239
240        // Confirm the target process is now foreground.
241        let process_lower = process_name.to_lowercase();
242        let deadline = Instant::now() + Duration::from_secs(2);
243        loop {
244            let fg_pid = unsafe {
245                let fg = GetForegroundWindow();
246                let mut pid = 0u32;
247                GetWindowThreadProcessId(fg, Some(&mut pid));
248                pid
249            };
250            let fg_proc = get_process_name(fg_pid as i32)
251                .unwrap_or_default()
252                .to_lowercase();
253            if fg_proc == process_lower {
254                return Ok(());
255            }
256            if Instant::now() >= deadline {
257                break;
258            }
259            thread::sleep(Duration::from_millis(50));
260        }
261
262        Err(format!(
263            "focus not confirmed: process '{process_name}' is not in the foreground"
264        ))
265    }
266
267    fn pick_win10_task_switcher(index: usize, timeout: Duration) -> bool {
268        let Ok(auto) = UIAutomation::new_direct() else {
269            return false;
270        };
271        let Ok(root) = auto.get_root_element() else {
272            return false;
273        };
274        let Some(switcher) = find_named_timeout(&auto, &root, "Task Switcher", timeout) else {
275            return false;
276        };
277        let Ok(item_cond) = auto.create_property_condition(
278            UIProperty::ControlType,
279            Variant::from(ControlType::ListItem as i32),
280            None,
281        ) else {
282            return false;
283        };
284        let Ok(items) = switcher.find_all(TreeScope::Descendants, &item_cond) else {
285            return false;
286        };
287        let target = items.get(index).or_else(|| items.iter().next());
288        if let Some(el) = target {
289            let _ = invoke_or_click(el);
290            true
291        } else {
292            false
293        }
294    }
295
296    /// Win11: the thumbnail picker is already open from the mouse click.
297    /// Tab×2 focuses the first thumbnail, Right×index navigates to the target,
298    /// Enter activates it.
299    fn pick_win11_thumbnail(index: usize, timeout: Duration) {
300        use windows::Win32::UI::Input::KeyboardAndMouse::{
301            INPUT, INPUT_KEYBOARD, KEYBD_EVENT_FLAGS, KEYBDINPUT, KEYEVENTF_KEYUP, SendInput,
302            VK_RETURN, VK_RIGHT, VK_TAB,
303        };
304
305        // Wait for the thumbnail popup (Xaml_WindowedPopupClass / "PopupHost") to appear.
306        let class_wide: Vec<u16> = "Xaml_WindowedPopupClass\0".encode_utf16().collect();
307        let title_wide: Vec<u16> = "PopupHost\0".encode_utf16().collect();
308        let deadline = Instant::now() + timeout;
309        loop {
310            let found = unsafe {
311                FindWindowExW(
312                    None,
313                    None,
314                    windows::core::PCWSTR(class_wide.as_ptr()),
315                    windows::core::PCWSTR(title_wide.as_ptr()),
316                )
317            };
318            if found.is_ok_and(|h| !h.is_invalid()) {
319                break;
320            }
321            if Instant::now() >= deadline {
322                return; // picker never appeared — single-window app, click already focused it
323            }
324            thread::sleep(Duration::from_millis(50));
325        }
326
327        fn key(vk: windows::Win32::UI::Input::KeyboardAndMouse::VIRTUAL_KEY, up: bool) -> INPUT {
328            INPUT {
329                r#type: INPUT_KEYBOARD,
330                Anonymous: windows::Win32::UI::Input::KeyboardAndMouse::INPUT_0 {
331                    ki: KEYBDINPUT {
332                        wVk: vk,
333                        wScan: 0,
334                        dwFlags: if up {
335                            KEYEVENTF_KEYUP
336                        } else {
337                            KEYBD_EVENT_FLAGS(0)
338                        },
339                        time: 0,
340                        dwExtraInfo: 0,
341                    },
342                },
343            }
344        }
345
346        let mut inputs: Vec<INPUT> = Vec::new();
347        // Tab×2 — focus the first thumbnail.
348        for _ in 0..2 {
349            inputs.push(key(VK_TAB, false));
350            inputs.push(key(VK_TAB, true));
351        }
352        // Right×index — move to the target thumbnail.
353        for _ in 0..index {
354            inputs.push(key(VK_RIGHT, false));
355            inputs.push(key(VK_RIGHT, true));
356        }
357        // Enter — activate.
358        inputs.push(key(VK_RETURN, false));
359        inputs.push(key(VK_RETURN, true));
360
361        unsafe {
362            SendInput(&inputs, std::mem::size_of::<INPUT>() as i32);
363        }
364    }
365
366    // ── Show Desktop ───────────────────────────────────────────────────────────
367
368    pub fn show_desktop() -> Result<(), String> {
369        let Ok(auto) = UIAutomation::new_direct() else {
370            return Err("UIAutomation init failed".into());
371        };
372        let Ok(root) = auto.get_root_element() else {
373            return Err("could not get desktop root".into());
374        };
375        let Ok(name_cond) =
376            auto.create_property_condition(UIProperty::Name, Variant::from("Show desktop"), None)
377        else {
378            return Err("could not create name condition".into());
379        };
380        let btn = root
381            .find_first(TreeScope::Descendants, &name_cond)
382            .map_err(|_| "Show desktop button not found".to_string())?;
383        btn.get_pattern::<UIInvokePattern>()
384            .and_then(|p| p.invoke())
385            .map_err(|e| format!("Show desktop invoke failed: {e}"))
386    }
387
388    // ── Name parsing ───────────────────────────────────────────────────────────
389
390    /// Strip Win10/Win11 suffixes and return the plain app display name.
391    ///
392    /// Win11 examples:
393    ///   "File Explorer pinned"                    → "File Explorer"
394    ///   "File Explorer - 1 running window pinned" → "File Explorer"
395    ///   "Notepad - 2 running windows"             → "Notepad"
396    ///
397    /// Win10 example:
398    ///   "Notepad - 1 running window"              → "Notepad"
399    fn strip_app_suffixes(name: &str) -> String {
400        // Strip " pinned" suffix (Win11 only).
401        let name = name.strip_suffix(" pinned").unwrap_or(name);
402        // Strip " - N running window[s]" suffix.
403        if let Some(pos) = name.rfind(" - ") {
404            let suffix = &name[pos + 3..];
405            if suffix.ends_with("running window") || suffix.ends_with("running windows") {
406                return name[..pos].to_string();
407            }
408        }
409        name.to_string()
410    }
411
412    /// Parse the open-window count from a raw button name.
413    ///
414    ///   "File Explorer pinned"                    → 0
415    ///   "File Explorer - 1 running window pinned" → 1
416    ///   "Notepad - 2 running windows"             → 2
417    ///   "Terminal" (no suffix)                    → 1
418    pub fn parse_window_count(name: &str) -> u32 {
419        let was_pinned_only = name.ends_with(" pinned") && !name.contains(" running window");
420        // Strip " pinned" suffix before parsing the running-window count.
421        let name = name.strip_suffix(" pinned").unwrap_or(name);
422        if let Some(pos) = name.rfind(" - ") {
423            let suffix = &name[pos + 3..];
424            if suffix.ends_with("running window") || suffix.ends_with("running windows") {
425                if let Some(n_str) = suffix.splitn(2, ' ').next() {
426                    if let Ok(n) = n_str.parse::<u32>() {
427                        return n;
428                    }
429                }
430            }
431        }
432        // "File Explorer pinned" (no running-window clause) → 0 open windows.
433        if was_pinned_only { 0 } else { 1 }
434    }
435}
436
437// ── Non-Windows stubs ──────────────────────────────────────────────────────────
438
439#[cfg(not(target_os = "windows"))]
440#[derive(Debug, Clone)]
441pub struct TaskbarButton {
442    pub name: String,
443    pub display_name: String,
444    pub window_count: u32,
445    pub pid: u32,
446    pub cx: i32,
447    pub cy: i32,
448    pub win11: bool,
449}
450
451#[cfg(not(target_os = "windows"))]
452pub fn list_taskbar_buttons() -> Vec<TaskbarButton> {
453    vec![]
454}
455
456#[cfg(not(target_os = "windows"))]
457pub fn parse_window_count(_name: &str) -> u32 {
458    0
459}
460
461#[cfg(not(target_os = "windows"))]
462pub fn focus_app_by_name(
463    _button: &str,
464    _process_name: &str,
465    _window_index: usize,
466) -> Result<(), String> {
467    Err("ListTaskbar / FocusApp is only supported on Windows".into())
468}
469
470#[cfg(not(target_os = "windows"))]
471pub fn show_desktop() -> Result<(), String> {
472    Err("ShowDesktop is only supported on Windows".into())
473}