master
Colby Knox Merge PR #309: kicad-lane: 1.0.66, teardrops and V-score panelize ef016ca 14d ago
//! UI Automation in process, through the `uiautomation` crate (Windows UIA COM). Replaces
//! handlers/uia.py (a PowerShell child running System.Windows.Automation, with its
//! 0xC0000409 teardown crashes and a console blip) and the UIA half of navigate_symbol.ps1.
//!
//! What the Python did, kept exactly:
//! - exact-name FindFirst first, then a bounded FindAll over all descendants filtered by
//!   case-insensitive substring;
//! - for an invoke, prefer a match that ACTUALLY supports InvokePattern: KiCad exposes the
//!   label "Footprint Editor" and the toolbar button with the same name, and grabbing the
//!   label yields not_invokable. SelectionItem.Select is the fallback for menu-like items;
//! - for a set-value, prefer a ValuePattern match; an empty name means "the element for
//!   this hwnd itself" (the filter Edit, navigate_symbol.ps1 step 1);
//! - no focus, no cursor. wx may still self-activate its frame after a state change
//!   (measured in the old foreground_guard.py), which is the etiquette layer's job.
//!
//! UIA calls block on the provider (KiCad's thread), so every entry point runs the WM_NULL
//! probe first and refuses a hung window.

use uiautomation::core::UICacheRequest;
use uiautomation::patterns::{UIInvokePattern, UIPatternType, UISelectionItemPattern, UITogglePattern, UIValuePattern};
use uiautomation::types::{ControlType, Handle, TreeScope, UIProperty};
use uiautomation::variants::Variant;
use uiautomation::{UIAutomation, UIElement};
use windows::Win32::Foundation::{HWND, RPC_E_CHANGED_MODE};
use windows::Win32::System::Com::{CoInitializeEx, CoUninitialize, COINIT_MULTITHREADED};

use super::{hwnd_u64, require_responsive, strip_amp};

/// Upper bound on descendants inspected in a substring search (the symbol editor's
/// library tree alone can expose thousands of rows).
const MAX_ELEMENTS: usize = 4000;

/// COM apartment for the calling thread, released on drop. A thread that already runs
/// an STA keeps it (RPC_E_CHANGED_MODE is not an error for us).
struct Com(bool);

impl Com {
    fn init() -> Result<Com, String> {
        // SAFETY: CoInitializeEx on the current thread; balanced by CoUninitialize in Drop
        // only when this call actually took a reference.
        let hr = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
        if hr.is_ok() {
            Ok(Com(true))
        } else if hr == RPC_E_CHANGED_MODE {
            Ok(Com(false))
        } else {
            Err(format!("CoInitializeEx: {hr}"))
        }
    }
}

impl Drop for Com {
    fn drop(&mut self) {
        if self.0 {
            // SAFETY: matches the successful CoInitializeEx above; all COM objects created
            // between the two are dropped before this runs (they live inside the function).
            unsafe { CoUninitialize() };
        }
    }
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum Want {
    Invoke,
    Value,
}

fn prop_true(el: &UIElement, p: UIProperty) -> bool {
    el.get_cached_property_value(p).ok().and_then(|v| v.try_into().ok()).unwrap_or(false)
}

fn supports(el: &UIElement, want: Want) -> bool {
    match want {
        Want::Invoke => prop_true(el, UIProperty::IsInvokePatternAvailable) || prop_true(el, UIProperty::IsSelectionItemPatternAvailable),
        Want::Value => prop_true(el, UIProperty::IsValuePatternAvailable),
    }
}

fn cache(ua: &UIAutomation) -> Result<UICacheRequest, String> {
    let c = ua.create_cache_request().map_err(|e| format!("UIA cache request: {e}"))?;
    for p in [
        UIProperty::Name,
        UIProperty::ControlType,
        UIProperty::IsInvokePatternAvailable,
        UIProperty::IsSelectionItemPatternAvailable,
        UIProperty::IsValuePatternAvailable,
    ] {
        c.add_property(p).map_err(|e| format!("UIA cache request: {e}"))?;
    }
    for p in [UIPatternType::Invoke, UIPatternType::SelectionItem, UIPatternType::Value] {
        let _ = c.add_pattern(p);
    }
    Ok(c)
}

fn root_of(ua: &UIAutomation, h: HWND) -> Result<UIElement, String> {
    ua.element_from_handle(Handle::from(hwnd_u64(h) as isize)).map_err(|e| format!("UIA ElementFromHandle({}): {e}", hwnd_u64(h)))
}

/// Exact-name FindFirst (one cross-process query), then the bounded substring sweep.
/// Returns the element and its name. Prefers an element that supports the wanted pattern,
/// falling back to the first name match so the caller can report not_invokable honestly.
fn find(ua: &UIAutomation, root: &UIElement, name: &str, want: Want) -> Result<Option<(UIElement, String)>, String> {
    let req = cache(ua)?;
    if let Ok(cond) = ua.create_property_condition(UIProperty::Name, Variant::from(name), None) {
        if let Ok(all) = root.find_all_build_cache(TreeScope::Descendants, &cond, &req) {
            if let Some(el) = all.iter().find(|el| supports(el, want)).or(all.first()) {
                return Ok(Some((el.clone(), el.get_cached_name().unwrap_or_else(|_| name.to_string()))));
            }
        }
    }
    let needle = name.to_lowercase();
    let cond = ua.create_true_condition().map_err(|e| format!("UIA condition: {e}"))?;
    let all = match root.find_all_build_cache(TreeScope::Descendants, &cond, &req) {
        Ok(v) => v,
        Err(e) => return Err(format!("UIA FindAll under {}: {e}", root.get_name().unwrap_or_default())),
    };
    let mut first: Option<(UIElement, String)> = None;
    for el in all.iter().take(MAX_ELEMENTS) {
        let n = el.get_cached_name().unwrap_or_default();
        if n.is_empty() || !n.to_lowercase().contains(&needle) {
            continue;
        }
        if supports(el, want) {
            return Ok(Some((el.clone(), n)));
        }
        if first.is_none() {
            first = Some((el.clone(), n));
        }
    }
    Ok(first)
}

/// Invoke (or Select) the first descendant whose name contains `name`. Returns
/// "invoke:<name>" or "select:<name>" naming the element that took the action.
pub fn invoke(h: HWND, name: &str) -> Result<String, String> {
    require_responsive(h, "uia_invoke", 500)?;
    let _com = Com::init()?;
    let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;
    let root = root_of(&ua, h)?;
    let Some((el, found)) = find(&ua, &root, name, Want::Invoke)? else {
        return Err(format!("control_not_found: no descendant of {} named like {name:?}", hwnd_u64(h)));
    };
    if let Ok(p) = el.get_pattern::<UIInvokePattern>() {
        p.invoke().map_err(|e| format!("Invoke on {found:?}: {e}"))?;
        return Ok(format!("invoke:{found}"));
    }
    if let Ok(p) = el.get_pattern::<UISelectionItemPattern>() {
        p.select().map_err(|e| format!("Select on {found:?}: {e}"))?;
        return Ok(format!("select:{found}"));
    }
    Err(format!("not_invokable: {found:?} supports neither InvokePattern nor SelectionItemPattern"))
}

/// ValuePattern.SetValue on the descendant whose name contains `name`, or on the element
/// for `h` itself when `name` is empty (the library filter Edit).
pub fn set_value(h: HWND, name: &str, text: &str) -> Result<(), String> {
    require_responsive(h, "uia_set_value", 500)?;
    let _com = Com::init()?;
    let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;
    let root = root_of(&ua, h)?;
    let (el, found) = if name.is_empty() {
        let n = root.get_name().unwrap_or_default();
        (root, n)
    } else {
        match find(&ua, &root, name, Want::Value)? {
            Some(x) => x,
            None => return Err(format!("control_not_found: no descendant of {} named like {name:?}", hwnd_u64(h))),
        }
    };
    let p = el.get_pattern::<UIValuePattern>().map_err(|_| format!("not_settable: {found:?} has no ValuePattern"))?;
    p.set_value(text).map_err(|e| format!("SetValue on {found:?}: {e}"))
}

/// Dialog-button fallback for `click_button`: a Button-typed descendant whose caption
/// (mnemonic stripped, trimmed, lower case) is EXACTLY one of `labels`, invoked. Used when
/// the dialog draws its buttons itself (wx, not a Win32 `Button` child), so BM_CLICK had
/// nothing to hit. Exact match on purpose: a substring "ok" must never land on "Look".
pub fn click_button(dialog: HWND, labels: &[&str]) -> Result<String, String> {
    require_responsive(dialog, "uia click_button", 500)?;
    let _com = Com::init()?;
    let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;
    let root = root_of(&ua, dialog)?;
    let req = cache(&ua)?;
    let cond = ua
        .create_property_condition(UIProperty::ControlType, Variant::from(ControlType::Button as i32), None)
        .map_err(|e| format!("UIA condition: {e}"))?;
    let all = root.find_all_build_cache(TreeScope::Descendants, &cond, &req).map_err(|e| format!("UIA FindAll(Button): {e}"))?;
    let wanted: Vec<String> = labels.iter().map(|l| l.trim().to_lowercase()).collect();
    let mut seen = Vec::new();
    for el in all.iter().take(MAX_ELEMENTS) {
        let n = el.get_cached_name().unwrap_or_default();
        let key = strip_amp(&n).trim().to_lowercase();
        if !wanted.contains(&key) {
            if !n.is_empty() {
                seen.push(n);
            }
            continue;
        }
        let p = el.get_pattern::<UIInvokePattern>().map_err(|_| format!("not_invokable: button {n:?} has no InvokePattern"))?;
        p.invoke().map_err(|e| format!("Invoke on {n:?}: {e}"))?;
        return Ok(format!("uia_invoke:{n}"));
    }
    Err(format!("control_not_found: no UIA button {labels:?} on {} (buttons seen: {seen:?})", hwnd_u64(dialog)))
}

/// Set the check box named exactly `name` (mnemonic stripped, case-insensitive) to `on` through
/// its TogglePattern. Answers "set:<name>=on|off" when it toggled, "unchanged:<name>" otherwise.
pub fn set_checked(h: HWND, name: &str, on: bool) -> Result<String, String> {
    require_responsive(h, "uia_set_checked", 500)?;
    let _com = Com::init()?;
    let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;
    let root = root_of(&ua, h)?;
    let req = cache(&ua)?;
    let cond = ua
        .create_property_condition(UIProperty::ControlType, Variant::from(ControlType::CheckBox as i32), None)
        .map_err(|e| format!("UIA condition: {e}"))?;
    let all = root.find_all_build_cache(TreeScope::Descendants, &cond, &req).map_err(|e| format!("UIA FindAll(CheckBox): {e}"))?;
    let want = name.trim().to_lowercase();
    let mut seen = Vec::new();
    for el in all.iter().take(MAX_ELEMENTS) {
        let n = el.get_cached_name().unwrap_or_default();
        if strip_amp(&n).trim().to_lowercase() != want {
            seen.push(n);
            continue;
        }
        let p = el.get_pattern::<UITogglePattern>().map_err(|_| format!("not_toggleable: {n:?} has no TogglePattern"))?;
        let is_on = matches!(p.get_toggle_state().map_err(|e| format!("ToggleState of {n:?}: {e}"))?, uiautomation::types::ToggleState::On);
        if is_on == on {
            return Ok(format!("unchanged:{n}"));
        }
        p.toggle().map_err(|e| format!("Toggle on {n:?}: {e}"))?;
        return Ok(format!("set:{n}={}", if on { "on" } else { "off" }));
    }
    Err(format!("control_not_found: no check box {name:?} on {} (check boxes seen: {seen:?})", hwnd_u64(h)))
}

/// Names of descendants containing `name` (all named descendants when empty), bounded.
pub fn find_names(h: HWND, name: &str, limit: usize) -> Result<Vec<String>, String> {
    require_responsive(h, "uia_find", 500)?;
    let _com = Com::init()?;
    let ua = UIAutomation::new_direct().map_err(|e| format!("UIA init: {e}"))?;
    let root = root_of(&ua, h)?;
    let req = cache(&ua)?;
    let cond = ua.create_true_condition().map_err(|e| format!("UIA condition: {e}"))?;
    let all = root.find_all_build_cache(TreeScope::Descendants, &cond, &req).map_err(|e| format!("UIA FindAll: {e}"))?;
    let needle = name.to_lowercase();
    let mut out = Vec::new();
    for el in all.iter().take(MAX_ELEMENTS) {
        let n = el.get_cached_name().unwrap_or_default();
        if !n.is_empty() && (needle.is_empty() || n.to_lowercase().contains(&needle)) {
            out.push(n);
            if out.len() >= limit.max(1) {
                break;
            }
        }
    }
    Ok(out)
}