app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
//! 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)
}