app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
"""uia.py — BACKGROUND UI Automation for KiCad, via .NET UIAutomation (PowerShell).
Why: opening the Footprint Editor / 3D viewer used to fall back to clicking a
toolbar icon (moves the user's MOUSE) or an Alt+3 keystroke (needs the window
FOREGROUNDED). Both hijack the desktop. UIA's Invoke pattern triggers a control
PROGRAMMATICALLY — no foreground, no focus steal, no cursor movement. KiCad is
wxWidgets but exposes an invokable UIA tree, so its toolbar buttons / menu items
are reachable by accessible name.
Self-contained: shells out to `powershell` (present on every Windows box) which
loads System.Windows.Automation. NOT Bridge's gated `run_script` — this is the bridge's
OWN subprocess (same as how it runs kicad-cli), so no shell-approval is involved.
UIA is a mature, stable API (unlike the thin/undocumented Win32 menu-ID path).
"""
import json
import subprocess
import sys
IS_WINDOWS = sys.platform == "win32"
# PowerShell that: attaches to a window by hwnd, finds a control by exact name or
# name-substring (optionally filtered by control type), and Invokes it (or, for
# SetValue, sets its text). Emits a one-line JSON result. All background.
_PS_TEMPLATE = r'''
$ErrorActionPreference = 'Stop'
try {
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
$AE = [System.Windows.Automation.AutomationElement]
$TS = [System.Windows.Automation.TreeScope]
$root = $AE::FromHandle([IntPtr]__HWND__)
if ($root -eq $null) { '{"ok":false,"reason":"window_not_found"}'; exit }
$name = __NAME__
$contains = __CONTAINS__
$action = '__ACTION__'
$text = __TEXT__
$target = $null
if ($name) {
$cond = New-Object System.Windows.Automation.PropertyCondition($AE::NameProperty, $name)
$target = $root.FindFirst($TS::Descendants, $cond)
}
if ($target -eq $null -and $contains) {
# bounded FindAll, filter by name substring. For an invoke we must pick a control
# that ACTUALLY supports InvokePattern — KiCad exposes the label "Footprint Editor"
# AND the toolbar button with the same name; grabbing the label yields
# not_invokable. So when action=invoke, prefer an invokable match (fall back to
# first substring match only if none is invokable).
$all = $root.FindAll($TS::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
$firstMatch = $null
foreach ($el in $all) {
$n = $el.Current.Name
if ($n -and $n.ToLower().Contains($contains.ToLower())) {
if ($firstMatch -eq $null) { $firstMatch = $el }
if ($action -eq 'invoke') {
$tmp = $null
if ($el.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$tmp)) { $target = $el; break }
} elseif ($action -eq 'setvalue') {
$tmp = $null
if ($el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$tmp)) { $target = $el; break }
} else { $target = $el; break }
}
}
if ($target -eq $null) { $target = $firstMatch }
}
if ($target -eq $null) { '{"ok":false,"reason":"control_not_found"}'; exit }
$foundName = $target.Current.Name
if ($action -eq 'invoke') {
$p = $null
if ($target.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$p)) {
$p.Invoke()
'{"ok":true,"action":"invoke","name":' + ($foundName | ConvertTo-Json) + '}'
} else {
# some KiCad menu items expose ExpandCollapse or are only Selectable
$sp = $null
if ($target.TryGetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern, [ref]$sp)) {
$sp.Select(); '{"ok":true,"action":"select","name":' + ($foundName | ConvertTo-Json) + '}'
} else { '{"ok":false,"reason":"not_invokable","name":' + ($foundName | ConvertTo-Json) + '}' }
}
} elseif ($action -eq 'setvalue') {
$vp = $null
if ($target.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) {
$vp.SetValue($text)
'{"ok":true,"action":"setvalue","name":' + ($foundName | ConvertTo-Json) + '}'
} else { '{"ok":false,"reason":"not_settable","name":' + ($foundName | ConvertTo-Json) + '}' }
} else {
'{"ok":true,"action":"find","name":' + ($foundName | ConvertTo-Json) + ',"found":true}'
}
} catch {
'{"ok":false,"reason":"exception","error":' + ($_.Exception.Message | ConvertTo-Json) + '}'
}
'''
def _ps_json(value) -> str:
"""Render a Python value as a PowerShell literal (string or $null)."""
if value is None:
return "$null"
return json.dumps(str(value)) # JSON string == a valid PS single/double-quoted string
def _run(hwnd: int, action: str, name=None, contains=None, text=None, timeout: float = 8.0) -> dict:
if not IS_WINDOWS:
return {"ok": False, "reason": "not_windows"}
script = (_PS_TEMPLATE
.replace("__HWND__", str(int(hwnd)))
.replace("__NAME__", _ps_json(name))
.replace("__CONTAINS__", _ps_json(contains))
.replace("__ACTION__", action)
.replace("__TEXT__", _ps_json(text)))
try:
proc = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True, text=True, timeout=timeout,
)
out = (proc.stdout or "").strip().splitlines()
for line in reversed(out): # last non-empty line is our JSON
line = line.strip()
if line.startswith("{"):
return json.loads(line)
return {"ok": False, "reason": "no_output", "stderr": (proc.stderr or "")[:200]}
except subprocess.TimeoutExpired:
return {"ok": False, "reason": "timeout"}
except Exception as e: # pylint: disable=broad-except
return {"ok": False, "reason": "run_failed", "error": str(e)[:200]}
def uia_invoke(hwnd: int, name: str = None, contains: str = None, timeout: float = 8.0) -> dict:
"""Background-Invoke a control (toolbar button / menu item) in the given window,
matched by exact accessible name OR name substring. No foreground, no cursor.
Returns {ok, action, name} / {ok:False, reason}."""
return _run(hwnd, "invoke", name=name, contains=contains, timeout=timeout)
def uia_set_value(hwnd: int, text: str, name: str = None, contains: str = None, timeout: float = 8.0) -> dict:
"""Background-SetValue on a text control (e.g. a search box) — no focus/typing."""
return _run(hwnd, "setvalue", name=name, contains=contains, text=text, timeout=timeout)
def uia_find(hwnd: int, name: str = None, contains: str = None, timeout: float = 6.0) -> dict:
"""Read-only: is the control present in the UIA tree? {ok, found, name}."""
return _run(hwnd, "find", name=name, contains=contains, timeout=timeout)