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.
← Commit history
v0.9.64: NEVER steal the user's screen. navigate_symbol.ps1 rewritten to UIA ValuePattern + PostMessage (was foregrounding, moving the mouse and SendKeys-typing over the user); send_key/click now post to the window by default; launch_editor.ps1 de-foregrounded. Focus paths are opt-in and must warn first.
7 files changed
+895−797
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.63+0.9.64
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.63",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-kicad-bridge/0.9.63/adom-bridge-kicad-v0.9.63.zip",- "sha256": "dc2c2a2fe92105b553c27e77ee33ed1079bc71d9f5ea52d8d1d85541c32b20a7",- "size": 352487,+ "version": "0.9.64",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-kicad-bridge/0.9.64/adom-bridge-kicad-v0.9.64.zip",+ "sha256": "a73eea1edd4a215a77a9badc396f639a724f1fa3e12e88a8714dc54bee7d8b66",+ "size": 354935, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-07-20T11:25:35Z",+ "released_at": "2026-07-20T11:39:29Z", "hero": "https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.64.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.63",+ "version": "0.9.64", "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.", "homepage": "https://wiki.adom.inc/adom/adom-desktop", "author": "Adom Inc.",
handlers/kicad_ui.py+809−693@@ -1,693 +1,809 @@-"""Win32-based tools for interacting with KiCad's UI. - -Windows: provides screenshot, click, and keyboard input against KiCad windows -(project manager, eeschema, pcbnew, footprint editor, symbol editor, 3D viewer, -and their dialogs). Mirrors the Fusion 360 UI handler at -plugins/fusion360/handlers/fusion_ui.py. - -macOS: handlers return "not implemented" responses. Faithful UI scripting -against another app on macOS requires Accessibility-framework permission, -which we don't request. The screenshot path is handled by the Tauri app's -CoreGraphics implementation in src-tauri/src/screenshot/macos.rs instead. -""" - -import ctypes -import ctypes.wintypes -import os -import struct -import sys -import time - -IS_WINDOWS = sys.platform == "win32" -IS_MACOS = sys.platform == "darwin" - -# ctypes.Structure / Union / WINFUNCTYPE are cross-platform; only ctypes.windll -# is Windows-only. So the module-level INPUT/MOUSEINPUT/etc. structs below -# load fine on macOS — they just never get used because handlers branch -# at the entry point. -if IS_WINDOWS: - user32 = ctypes.windll.user32 - gdi32 = ctypes.windll.gdi32 - - # Enable DPI awareness so GetWindowRect returns physical pixel coordinates - try: - ctypes.windll.shcore.SetProcessDpiAwareness(2) - except Exception: - try: - user32.SetProcessDPIAware() - except Exception: - pass -else: - # Sentinels so module-level expressions referencing user32/gdi32 don't - # NameError. Helper functions that touch these are guarded by IS_WINDOWS - # at call time via the handler entry points. - user32 = None - gdi32 = None - -# --- Constants --- - -BI_RGB = 0 -DIB_RGB_COLORS = 0 -GW_OWNER = 4 -PW_RENDERFULLCONTENT = 0x00000002 - -INPUT_MOUSE = 0 -INPUT_KEYBOARD = 1 -MOUSEEVENTF_ABSOLUTE = 0x8000 -MOUSEEVENTF_MOVE = 0x0001 -MOUSEEVENTF_LEFTDOWN = 0x0002 -MOUSEEVENTF_LEFTUP = 0x0004 -KEYEVENTF_KEYUP = 0x0002 - -VK_MAP = { - "enter": 0x0D, "return": 0x0D, - "escape": 0x1B, "esc": 0x1B, - "tab": 0x09, - "space": 0x20, - "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27, - "backspace": 0x08, "delete": 0x2E, - "home": 0x24, "end": 0x23, - "pageup": 0x21, "pagedown": 0x22, - "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, - "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77, - "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B, -} - -SCREENSHOT_DIR = "C:/tmp/adom-desktop-screenshots" - -# KiCad window title keywords (wxWidgets windows use class wxWindowNR) -KICAD_TITLE_KEYWORDS = [ - "KiCad", "Symbol Editor", "Footprint Editor", "3D Viewer", - "eeschema", "pcbnew", "Schematic Editor", "PCB Editor", -] - -# Keep callback references alive to prevent GC -_callbacks = [] - - -# --- SendInput structures --- - -class MOUSEINPUT(ctypes.Structure): - _fields_ = [ - ("dx", ctypes.wintypes.LONG), - ("dy", ctypes.wintypes.LONG), - ("mouseData", ctypes.wintypes.DWORD), - ("dwFlags", ctypes.wintypes.DWORD), - ("time", ctypes.wintypes.DWORD), - ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)), - ] - - -class KEYBDINPUT(ctypes.Structure): - _fields_ = [ - ("wVk", ctypes.wintypes.WORD), - ("wScan", ctypes.wintypes.WORD), - ("dwFlags", ctypes.wintypes.DWORD), - ("time", ctypes.wintypes.DWORD), - ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)), - ] - - -class _INPUT_UNION(ctypes.Union): - _fields_ = [ - ("mi", MOUSEINPUT), - ("ki", KEYBDINPUT), - ] - - -class INPUT(ctypes.Structure): - _fields_ = [ - ("type", ctypes.wintypes.DWORD), - ("union", _INPUT_UNION), - ] - - -def _send_input(*inputs): - """Send one or more INPUT structures via SendInput.""" - n = len(inputs) - arr = (INPUT * n)(*inputs) - user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT)) - - -# --- Window enumeration --- - -def _get_window_title(hwnd) -> str: - length = user32.GetWindowTextLengthW(hwnd) - if length <= 0: - return "" - buf = ctypes.create_unicode_buffer(length + 1) - user32.GetWindowTextW(hwnd, buf, length + 1) - return buf.value - - -def _get_class_name(hwnd) -> str: - buf = ctypes.create_unicode_buffer(256) - user32.GetClassNameW(hwnd, buf, 256) - return buf.value - - -def _get_window_rect(hwnd) -> dict: - rect = ctypes.wintypes.RECT() - user32.GetWindowRect(hwnd, ctypes.byref(rect)) - return { - "left": rect.left, "top": rect.top, - "right": rect.right, "bottom": rect.bottom, - "width": rect.right - rect.left, - "height": rect.bottom - rect.top, - } - - -def _find_all_kicad_windows() -> list: - """Find all visible KiCad-related windows. - - Returns list of dicts with hwnd, title, className, rect, ownerHwnd. - """ - results = [] - seen = set() - WNDENUMPROC = ctypes.WINFUNCTYPE( - ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM - ) - - def callback(hwnd, _lparam): - if not user32.IsWindowVisible(hwnd): - return True - title = _get_window_title(hwnd) - class_name = _get_class_name(hwnd) - - # Match KiCad windows by title keywords or wxWindowNR class with owner - is_kicad = any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS) - - # Also catch #32770 (Win32 dialog) windows owned by KiCad - if not is_kicad and class_name == "#32770": - owner = user32.GetWindow(hwnd, GW_OWNER) - if owner and owner not in seen: - # Check if owner is a KiCad window - owner_title = _get_window_title(owner) - is_kicad = any(kw.lower() in owner_title.lower() for kw in KICAD_TITLE_KEYWORDS) - - if is_kicad and hwnd not in seen: - seen.add(hwnd) - owner = user32.GetWindow(hwnd, GW_OWNER) - results.append({ - "hwnd": hwnd, - "title": title, - "className": class_name, - "rect": _get_window_rect(hwnd), - "ownerHwnd": owner if owner else 0, - }) - return True - - cb = WNDENUMPROC(callback) - _callbacks.append(cb) - user32.EnumWindows(cb, 0) - _callbacks.remove(cb) - return results - - -def _find_foreground_kicad_hwnd() -> int: - """Find the foreground KiCad window, or the first KiCad window found.""" - fg = user32.GetForegroundWindow() - if fg: - title = _get_window_title(fg) - if any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS): - return fg - - # Fallback: first KiCad window - windows = _find_all_kicad_windows() - if windows: - return windows[0]["hwnd"] - return 0 - - -# --- Screenshot --- - -def _write_bmp(path: str, width: int, height: int, pixel_data: bytes): - """Write a BMP file from raw BGR pixel data (bottom-up).""" - row_size_24 = (width * 3 + 3) & ~3 - pixel_size_24 = row_size_24 * height - file_size = 14 + 40 + pixel_size_24 - - with open(path, "wb") as f: - f.write(b"BM") - f.write(struct.pack("<I", file_size)) - f.write(struct.pack("<HH", 0, 0)) - f.write(struct.pack("<I", 14 + 40)) - f.write(struct.pack("<I", 40)) - f.write(struct.pack("<i", width)) - f.write(struct.pack("<i", height)) - f.write(struct.pack("<HH", 1, 24)) - f.write(struct.pack("<I", BI_RGB)) - f.write(struct.pack("<I", pixel_size_24)) - f.write(struct.pack("<ii", 2835, 2835)) - f.write(struct.pack("<II", 0, 0)) - - src_row_size = width * 4 - for y in range(height): - row_start = y * src_row_size - row_24 = bytearray() - for x in range(width): - px = row_start + x * 4 - row_24 += pixel_data[px:px + 3] - padding = row_size_24 - len(row_24) - if padding > 0: - row_24 += b"\x00" * padding - f.write(bytes(row_24)) - - -def _screenshot_hwnd(hwnd: int, label: str = "") -> dict: - """Capture a window by HWND using PrintWindow. Returns {success, savedTo, sizeKB}.""" - rect = _get_window_rect(hwnd) - width = rect["width"] - height = rect["height"] - - if width <= 0 or height <= 0: - return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"} - - hdc_screen = user32.GetDC(hwnd) - hdc_mem = gdi32.CreateCompatibleDC(hdc_screen) - hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height) - old_bmp = gdi32.SelectObject(hdc_mem, hbmp) - user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT) - - class BITMAPINFOHEADER(ctypes.Structure): - _fields_ = [ - ("biSize", ctypes.wintypes.DWORD), - ("biWidth", ctypes.wintypes.LONG), - ("biHeight", ctypes.wintypes.LONG), - ("biPlanes", ctypes.wintypes.WORD), - ("biBitCount", ctypes.wintypes.WORD), - ("biCompression", ctypes.wintypes.DWORD), - ("biSizeImage", ctypes.wintypes.DWORD), - ("biXPelsPerMeter", ctypes.wintypes.LONG), - ("biYPelsPerMeter", ctypes.wintypes.LONG), - ("biClrUsed", ctypes.wintypes.DWORD), - ("biClrImportant", ctypes.wintypes.DWORD), - ] - - bmi = BITMAPINFOHEADER() - bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER) - bmi.biWidth = width - bmi.biHeight = height - bmi.biPlanes = 1 - bmi.biBitCount = 32 - bmi.biCompression = BI_RGB - - buf_size = width * height * 4 - pixel_buf = ctypes.create_string_buffer(buf_size) - gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS) - - gdi32.SelectObject(hdc_mem, old_bmp) - gdi32.DeleteObject(hbmp) - gdi32.DeleteDC(hdc_mem) - user32.ReleaseDC(hwnd, hdc_screen) - - os.makedirs(SCREENSHOT_DIR, exist_ok=True) - timestamp = int(time.time() * 1000) - # Slugify the label so filenames have NO spaces/punctuation (clean for a - # cloud caller to pull). v0.8.8: "pcb_editor pic_programmer Read Only" -> "pcb-editor-pic-programmer-read-only". - slug = "".join(c if (c.isalnum() or c in "._-") else "-" for c in (label or "")).strip("-").lower() - while "--" in slug: - slug = slug.replace("--", "-") - suffix = f"-{slug}" if slug else "" - - # v0.8.8: default to PNG (container-side callers want PNG; was lossless WEBP, - # which forced a host-side pull + convert). WEBP only on explicit fmt="webp". - try: - from PIL import Image - img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA") - img = img.transpose(Image.FLIP_TOP_BOTTOM) - MAX_DIM = 1568 - if max(width, height) > MAX_DIM: - ratio = MAX_DIM / max(width, height) - img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS) - path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.png") - img.save(path, "PNG", optimize=True) - except ImportError: - path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.bmp") - _write_bmp(path, width, height, pixel_buf.raw) - - size_kb = os.path.getsize(path) / 1024 - posix = path.replace("\\", "/") - return { - "success": True, - # `fullPath` is the field the CLI's universal screenshot auto-pull keys on — - # it copies the host file to the cloud container's /tmp/ad-shots/ and adds - # localFullPath, so a cloud caller can Read it directly (no pull_file hop). - # `savedTo` kept for back-compat. - "savedTo": posix, - "fullPath": posix, - "sizeKB": round(size_kb, 1), - } - - -# --- Public handler functions --- - -def handle_screenshot_all(kicad_info: dict, args: dict) -> dict: - """Screenshot all KiCad windows (main + editors + dialogs). - - Windows: enumerates windows via Win32 and captures each via PrintWindow. - macOS: not implemented at the plugin level. The Tauri app exposes - desktop_screenshot_screen / desktop_screenshot_window via CoreGraphics — - callers should use those instead. - """ - if IS_MACOS: - return { - "success": False, - "error": "kicad_screenshot_all is not implemented on macOS", - "_hint": ("Use desktop_screenshot_screen or desktop_screenshot_window " - "(via the Tauri app) — those use CoreGraphics and capture " - "any visible window including KiCad."), - } - if not IS_WINDOWS: - return {"success": False, "error": f"screenshot_all not implemented on {sys.platform!r}"} - - from handlers.close_windows import handle_window_info - - info = handle_window_info(kicad_info, {}) - data = info.get("data", {}) - - # Collect windows in order: editors first, project manager, dialogs last - windows = [] - for ed in data.get("editors", []): - windows.append({"hwnd": ed["hwnd"], "title": ed["title"], "type": "editor"}) - pm = data.get("projectManager") - if pm: - windows.append({"hwnd": pm["hwnd"], "title": pm["title"], "type": "main"}) - for dlg in data.get("modalDialogs", []): - windows.append({"hwnd": dlg["hwnd"], "title": dlg["title"], "type": "dialog"}) - - if not windows: - return {"success": False, "error": "No KiCad windows found."} - - screenshots = [] - for w in windows: - hwnd = w["hwnd"] - title = w["title"] - win_type = w["type"] - - # Sanitize title for filename - safe_title = "".join(c if c.isalnum() or c in "-_ " else "" for c in title)[:40].strip() - label = f"{win_type}-{safe_title}" if safe_title else win_type - - result = _screenshot_hwnd(hwnd, label) - entry = { - "type": win_type, - "title": title, - "hwnd": hwnd, - } - if result.get("success"): - entry["savedTo"] = result["savedTo"] - entry["fullPath"] = result.get("fullPath", result["savedTo"]) - entry["sizeKB"] = result["sizeKB"] - else: - entry["error"] = result.get("error", "screenshot failed") - - screenshots.append(entry) - - return { - "success": True, - "output": f"Captured {len(screenshots)} KiCad window(s)", - "data": {"screenshots": screenshots}, - } - - -# v0.7.1+: modifier VKs for combo parsing in handle_send_key. The four standard -# Windows modifiers; combos like "ctrl+shift+f10" or "alt+3" decompose into a -# list of these wrapping the base key. -_MODIFIER_VK = { - "ctrl": 0x11, "control": 0x11, - "alt": 0x12, "menu": 0x12, - "shift": 0x10, - "win": 0x5B, "super": 0x5B, "lwin": 0x5B, -} - - -def _parse_modifier_combo(key: str) -> tuple[list[int], str] | None: - """Parse a 'ctrl+alt+f10' style combo. - - Returns (modifier_vks, base_key) where base_key is the trailing token after - the last "+". Modifiers must come BEFORE the base key. Returns None if `key` - isn't a combo (no "+" or only one token). - """ - if "+" not in key: - return None - tokens = [t.strip().lower() for t in key.split("+") if t.strip()] - if len(tokens) < 2: - return None - *mods, base = tokens - mod_vks: list[int] = [] - for m in mods: - vk = _MODIFIER_VK.get(m) - if vk is None: - # Not a known modifier — bail and let the caller treat the whole - # thing as an unknown key. - return None - mod_vks.append(vk) - return mod_vks, base - - -def handle_send_key(kicad_info: dict, args: dict) -> dict: - """Send a keystroke to the foreground KiCad window. - - Supports modifier-combo syntax (v0.7.1+): "ctrl+s", "alt+3", "shift+f10", - "ctrl+shift+s". Modifiers: ctrl/control, alt/menu, shift, win/super/lwin. - The base key after the last "+" is parsed via the same VK_MAP / single-char - path as standalone keys. - - Windows-only: macOS UI scripting against another app requires Accessibility - permission, which we don't request. Ask the user to interact directly. - """ - if not IS_WINDOWS: - return { - "success": False, - "error": "kicad_send_key is not implemented on macOS", - "_hint": ("On macOS, ask the user to press the key in the KiCad window " - "directly. UI scripting needs Accessibility permission."), - } - - key = args.get("key", "").strip() - if not key: - return {"success": False, "error": "Missing required arg: key"} - - target_hwnd = args.get("hwnd") - if target_hwnd: - target_hwnd = int(target_hwnd) - else: - target_hwnd = _find_foreground_kicad_hwnd() - - if not target_hwnd: - return {"success": False, "error": "No KiCad window found to send key to."} - - title = _get_window_title(target_hwnd) - - # Bring to front - user32.SetForegroundWindow(target_hwnd) - time.sleep(0.1) - - # v0.7.1+: detect modifier combos first. If "key" parses as one (e.g. - # "alt+3"), wrap the base key in mod-down/up events. Falls through to the - # original path if it's a single key or unknown-modifier combo. - combo = _parse_modifier_combo(key) - if combo is not None: - mod_vks, base = combo - # Resolve the base key to a VK via the same path as a standalone key. - base_vk = VK_MAP.get(base) - base_shift = False - if base_vk is None and len(base) == 1: - vk_scan = user32.VkKeyScanW(ord(base)) - base_vk = vk_scan & 0xFF - base_shift = bool((vk_scan >> 8) & 0x01) - if base_vk == 0xFF: - return {"success": False, "error": f"Cannot map base character '{base}' to a virtual key."} - if base_vk is None: - return {"success": False, "error": f"Unknown base key '{base}' in combo '{key}'."} - - # Build the input sequence: ALL modifiers down, base down + up, modifiers up - # (reverse order on the way up so the OS sees clean nesting). - inputs: list[INPUT] = [] - # If the base char itself needs Shift AND Shift wasn't already in the combo, - # add it. (Caller intent: "shift+s" already has shift; "alt+!" should add shift.) - if base_shift and 0x10 not in mod_vks: - mod_vks = mod_vks + [0x10] - for vk in mod_vks: - d = INPUT(); d.type = INPUT_KEYBOARD; d.union.ki.wVk = vk - inputs.append(d) - kd = INPUT(); kd.type = INPUT_KEYBOARD; kd.union.ki.wVk = base_vk - inputs.append(kd) - ku = INPUT(); ku.type = INPUT_KEYBOARD; ku.union.ki.wVk = base_vk - ku.union.ki.dwFlags = KEYEVENTF_KEYUP - inputs.append(ku) - for vk in reversed(mod_vks): - u = INPUT(); u.type = INPUT_KEYBOARD; u.union.ki.wVk = vk - u.union.ki.dwFlags = KEYEVENTF_KEYUP - inputs.append(u) - - _send_input(*inputs) - return { - "success": True, - "output": f"Sent '{key}' to {title}", - "data": {"key": key, "modifiers": mod_vks, "baseVk": base_vk, - "baseShift": base_shift, "hwnd": target_hwnd, "title": title}, - "_hint": ("Sent (back-compat chord path). For modifier chords prefer AD's " - "desktop_press_key — it owns full chord parsing and AD's focus-first " - "delivery. Keep kicad_send_key for single keys to an EXACT hwnd (e.g. " - "Enter/Escape to a specific dialog)."), - } - - key_lower = key.lower().strip() - vk = VK_MAP.get(key_lower) - - if vk is None: - if len(key) == 1: - vk_scan = user32.VkKeyScanW(ord(key)) - vk = vk_scan & 0xFF - shift = (vk_scan >> 8) & 0x01 - if vk == 0xFF: - return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."} - - inputs = [] - if shift: - s_down = INPUT() - s_down.type = INPUT_KEYBOARD - s_down.union.ki.wVk = 0x10 - inputs.append(s_down) - - kd = INPUT() - kd.type = INPUT_KEYBOARD - kd.union.ki.wVk = vk - inputs.append(kd) - - ku = INPUT() - ku.type = INPUT_KEYBOARD - ku.union.ki.wVk = vk - ku.union.ki.dwFlags = KEYEVENTF_KEYUP - inputs.append(ku) - - if shift: - s_up = INPUT() - s_up.type = INPUT_KEYBOARD - s_up.union.ki.wVk = 0x10 - s_up.union.ki.dwFlags = KEYEVENTF_KEYUP - inputs.append(s_up) - - _send_input(*inputs) - return {"success": True, - "output": f"Sent '{key}' to {title}", - "data": {"key": key, "vk": vk, "shift": bool(shift), - "hwnd": target_hwnd, "title": title}} - else: - return {"success": False, "error": f"Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."} - - kd = INPUT() - kd.type = INPUT_KEYBOARD - kd.union.ki.wVk = vk - - ku = INPUT() - ku.type = INPUT_KEYBOARD - ku.union.ki.wVk = vk - ku.union.ki.dwFlags = KEYEVENTF_KEYUP - - _send_input(kd, ku) - return {"success": True, - "output": f"Sent '{key_lower}' to {title}", - "data": {"key": key_lower, "vk": vk, - "hwnd": target_hwnd, "title": title}} - - -def handle_click(kicad_info: dict, args: dict) -> dict: - """Click at coordinates within a KiCad window. - - Windows-only: macOS UI scripting against another app requires Accessibility - permission, which we don't request. Ask the user to click directly. - """ - if not IS_WINDOWS: - return { - "success": False, - "error": "kicad_click is not implemented on macOS", - "_hint": ("On macOS, ask the user to click in the KiCad window directly. " - "UI scripting needs Accessibility permission."), - } - - hwnd = args.get("hwnd") - if not hwnd: - return {"success": False, "error": "Missing required arg: hwnd"} - hwnd = int(hwnd) - - x = args.get("x") - y = args.get("y") - if x is None or y is None: - return {"success": False, "error": "Missing required args: x, y"} - - relative = args.get("relative", True) - - # Footgun guard: relative coords are FRACTIONS of the window (0.0–1.0). If a caller - # leaves relative=True (the default) but passes pixel values (e.g. x=676), the math - # below lands the click FAR off-screen — which at best misses and at worst hits a - # random window's close/controls and can take KiCad down. Refuse clearly instead. - if relative: - try: - if float(x) > 1.0 or float(y) > 1.0 or float(x) < 0 or float(y) < 0: - return {"success": False, - "error": f"relative=True expects x/y as fractions 0.0–1.0 of the window, " - f"but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels.", - "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ≈ {\"x\":0.35,\"y\":0.09}. " - "For exact pixel coords add \"relative\": false."} - except (TypeError, ValueError): - return {"success": False, "error": f"x/y must be numbers, got x={x!r}, y={y!r}"} - - title = _get_window_title(hwnd) - if not title: - return {"success": False, "error": f"HWND {hwnd} is not a valid window."} - - rect = _get_window_rect(hwnd) - width = rect["width"] - height = rect["height"] - - # Bring to front - user32.SetForegroundWindow(hwnd) - time.sleep(0.1) - - if relative: - screen_x = rect["left"] + int(float(x) * width) - screen_y = rect["top"] + int(float(y) * height) - else: - screen_x = rect["left"] + int(x) - screen_y = rect["top"] + int(y) - - # Convert to absolute coordinates for SendInput (0-65535 range) - screen_w = user32.GetSystemMetrics(0) - screen_h = user32.GetSystemMetrics(1) - abs_x = int(screen_x * 65535 / screen_w) - abs_y = int(screen_y * 65535 / screen_h) - - move = INPUT() - move.type = INPUT_MOUSE - move.union.mi.dx = abs_x - move.union.mi.dy = abs_y - move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE - - down = INPUT() - down.type = INPUT_MOUSE - down.union.mi.dx = abs_x - down.union.mi.dy = abs_y - down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN - - up = INPUT() - up.type = INPUT_MOUSE - up.union.mi.dx = abs_x - up.union.mi.dy = abs_y - up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP - - _send_input(move, down, up) - - return { - "success": True, - "output": f"Clicked ({screen_x}, {screen_y}) on {title}", - "data": { - "clickedAt": {"screenX": screen_x, "screenY": screen_y}, - "windowOffset": {"x": screen_x - rect["left"], "y": screen_y - rect["top"]}, - "relative": relative, - "hwnd": hwnd, - "title": title, - }, - } +"""Win32-based tools for interacting with KiCad's UI.++Windows: provides screenshot, click, and keyboard input against KiCad windows+(project manager, eeschema, pcbnew, footprint editor, symbol editor, 3D viewer,+and their dialogs). Mirrors the Fusion 360 UI handler at+plugins/fusion360/handlers/fusion_ui.py.++macOS: handlers return "not implemented" responses. Faithful UI scripting+against another app on macOS requires Accessibility-framework permission,+which we don't request. The screenshot path is handled by the Tauri app's+CoreGraphics implementation in src-tauri/src/screenshot/macos.rs instead.+"""++import ctypes+import ctypes.wintypes+import os+import struct+import sys+import time++IS_WINDOWS = sys.platform == "win32"+IS_MACOS = sys.platform == "darwin"++# ctypes.Structure / Union / WINFUNCTYPE are cross-platform; only ctypes.windll+# is Windows-only. So the module-level INPUT/MOUSEINPUT/etc. structs below+# load fine on macOS — they just never get used because handlers branch+# at the entry point.+if IS_WINDOWS:+ user32 = ctypes.windll.user32+ gdi32 = ctypes.windll.gdi32++ # Enable DPI awareness so GetWindowRect returns physical pixel coordinates+ try:+ ctypes.windll.shcore.SetProcessDpiAwareness(2)+ except Exception:+ try:+ user32.SetProcessDPIAware()+ except Exception:+ pass+else:+ # Sentinels so module-level expressions referencing user32/gdi32 don't+ # NameError. Helper functions that touch these are guarded by IS_WINDOWS+ # at call time via the handler entry points.+ user32 = None+ gdi32 = None++# --- Constants ---++BI_RGB = 0+DIB_RGB_COLORS = 0+GW_OWNER = 4+PW_RENDERFULLCONTENT = 0x00000002++INPUT_MOUSE = 0+INPUT_KEYBOARD = 1+MOUSEEVENTF_ABSOLUTE = 0x8000+MOUSEEVENTF_MOVE = 0x0001+MOUSEEVENTF_LEFTDOWN = 0x0002+MOUSEEVENTF_LEFTUP = 0x0004+KEYEVENTF_KEYUP = 0x0002++VK_MAP = {+ "enter": 0x0D, "return": 0x0D,+ "escape": 0x1B, "esc": 0x1B,+ "tab": 0x09,+ "space": 0x20,+ "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,+ "backspace": 0x08, "delete": 0x2E,+ "home": 0x24, "end": 0x23,+ "pageup": 0x21, "pagedown": 0x22,+ "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,+ "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,+ "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,+}++SCREENSHOT_DIR = "C:/tmp/adom-desktop-screenshots"++# KiCad window title keywords (wxWidgets windows use class wxWindowNR)+KICAD_TITLE_KEYWORDS = [+ "KiCad", "Symbol Editor", "Footprint Editor", "3D Viewer",+ "eeschema", "pcbnew", "Schematic Editor", "PCB Editor",+]++# Keep callback references alive to prevent GC+_callbacks = []+++# --- SendInput structures ---++class MOUSEINPUT(ctypes.Structure):+ _fields_ = [+ ("dx", ctypes.wintypes.LONG),+ ("dy", ctypes.wintypes.LONG),+ ("mouseData", ctypes.wintypes.DWORD),+ ("dwFlags", ctypes.wintypes.DWORD),+ ("time", ctypes.wintypes.DWORD),+ ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),+ ]+++class KEYBDINPUT(ctypes.Structure):+ _fields_ = [+ ("wVk", ctypes.wintypes.WORD),+ ("wScan", ctypes.wintypes.WORD),+ ("dwFlags", ctypes.wintypes.DWORD),+ ("time", ctypes.wintypes.DWORD),+ ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),+ ]+++class _INPUT_UNION(ctypes.Union):+ _fields_ = [+ ("mi", MOUSEINPUT),+ ("ki", KEYBDINPUT),+ ]+++class INPUT(ctypes.Structure):+ _fields_ = [+ ("type", ctypes.wintypes.DWORD),+ ("union", _INPUT_UNION),+ ]+++def _send_input(*inputs):+ """Send one or more INPUT structures via SendInput."""+ n = len(inputs)+ arr = (INPUT * n)(*inputs)+ user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT))+++# --- Window enumeration ---++def _get_window_title(hwnd) -> str:+ length = user32.GetWindowTextLengthW(hwnd)+ if length <= 0:+ return ""+ buf = ctypes.create_unicode_buffer(length + 1)+ user32.GetWindowTextW(hwnd, buf, length + 1)+ return buf.value+++def _get_class_name(hwnd) -> str:+ buf = ctypes.create_unicode_buffer(256)+ user32.GetClassNameW(hwnd, buf, 256)+ return buf.value+++def _get_window_rect(hwnd) -> dict:+ rect = ctypes.wintypes.RECT()+ user32.GetWindowRect(hwnd, ctypes.byref(rect))+ return {+ "left": rect.left, "top": rect.top,+ "right": rect.right, "bottom": rect.bottom,+ "width": rect.right - rect.left,+ "height": rect.bottom - rect.top,+ }+++def _find_all_kicad_windows() -> list:+ """Find all visible KiCad-related windows.++ Returns list of dicts with hwnd, title, className, rect, ownerHwnd.+ """+ results = []+ seen = set()+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ if not user32.IsWindowVisible(hwnd):+ return True+ title = _get_window_title(hwnd)+ class_name = _get_class_name(hwnd)++ # Match KiCad windows by title keywords or wxWindowNR class with owner+ is_kicad = any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS)++ # Also catch #32770 (Win32 dialog) windows owned by KiCad+ if not is_kicad and class_name == "#32770":+ owner = user32.GetWindow(hwnd, GW_OWNER)+ if owner and owner not in seen:+ # Check if owner is a KiCad window+ owner_title = _get_window_title(owner)+ is_kicad = any(kw.lower() in owner_title.lower() for kw in KICAD_TITLE_KEYWORDS)++ if is_kicad and hwnd not in seen:+ seen.add(hwnd)+ owner = user32.GetWindow(hwnd, GW_OWNER)+ results.append({+ "hwnd": hwnd,+ "title": title,+ "className": class_name,+ "rect": _get_window_rect(hwnd),+ "ownerHwnd": owner if owner else 0,+ })+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumWindows(cb, 0)+ _callbacks.remove(cb)+ return results+++def _find_foreground_kicad_hwnd() -> int:+ """Find the foreground KiCad window, or the first KiCad window found."""+ fg = user32.GetForegroundWindow()+ if fg:+ title = _get_window_title(fg)+ if any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS):+ return fg++ # Fallback: first KiCad window+ windows = _find_all_kicad_windows()+ if windows:+ return windows[0]["hwnd"]+ return 0+++# --- Screenshot ---++def _write_bmp(path: str, width: int, height: int, pixel_data: bytes):+ """Write a BMP file from raw BGR pixel data (bottom-up)."""+ row_size_24 = (width * 3 + 3) & ~3+ pixel_size_24 = row_size_24 * height+ file_size = 14 + 40 + pixel_size_24++ with open(path, "wb") as f:+ f.write(b"BM")+ f.write(struct.pack("<I", file_size))+ f.write(struct.pack("<HH", 0, 0))+ f.write(struct.pack("<I", 14 + 40))+ f.write(struct.pack("<I", 40))+ f.write(struct.pack("<i", width))+ f.write(struct.pack("<i", height))+ f.write(struct.pack("<HH", 1, 24))+ f.write(struct.pack("<I", BI_RGB))+ f.write(struct.pack("<I", pixel_size_24))+ f.write(struct.pack("<ii", 2835, 2835))+ f.write(struct.pack("<II", 0, 0))++ src_row_size = width * 4+ for y in range(height):+ row_start = y * src_row_size+ row_24 = bytearray()+ for x in range(width):+ px = row_start + x * 4+ row_24 += pixel_data[px:px + 3]+ padding = row_size_24 - len(row_24)+ if padding > 0:+ row_24 += b"\x00" * padding+ f.write(bytes(row_24))+++def _screenshot_hwnd(hwnd: int, label: str = "") -> dict:+ """Capture a window by HWND using PrintWindow. Returns {success, savedTo, sizeKB}."""+ rect = _get_window_rect(hwnd)+ width = rect["width"]+ height = rect["height"]++ if width <= 0 or height <= 0:+ return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"}++ hdc_screen = user32.GetDC(hwnd)+ hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)+ hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)+ old_bmp = gdi32.SelectObject(hdc_mem, hbmp)+ user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)++ class BITMAPINFOHEADER(ctypes.Structure):+ _fields_ = [+ ("biSize", ctypes.wintypes.DWORD),+ ("biWidth", ctypes.wintypes.LONG),+ ("biHeight", ctypes.wintypes.LONG),+ ("biPlanes", ctypes.wintypes.WORD),+ ("biBitCount", ctypes.wintypes.WORD),+ ("biCompression", ctypes.wintypes.DWORD),+ ("biSizeImage", ctypes.wintypes.DWORD),+ ("biXPelsPerMeter", ctypes.wintypes.LONG),+ ("biYPelsPerMeter", ctypes.wintypes.LONG),+ ("biClrUsed", ctypes.wintypes.DWORD),+ ("biClrImportant", ctypes.wintypes.DWORD),+ ]++ bmi = BITMAPINFOHEADER()+ bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)+ bmi.biWidth = width+ bmi.biHeight = height+ bmi.biPlanes = 1+ bmi.biBitCount = 32+ bmi.biCompression = BI_RGB++ buf_size = width * height * 4+ pixel_buf = ctypes.create_string_buffer(buf_size)+ gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)++ gdi32.SelectObject(hdc_mem, old_bmp)+ gdi32.DeleteObject(hbmp)+ gdi32.DeleteDC(hdc_mem)+ user32.ReleaseDC(hwnd, hdc_screen)++ os.makedirs(SCREENSHOT_DIR, exist_ok=True)+ timestamp = int(time.time() * 1000)+ # Slugify the label so filenames have NO spaces/punctuation (clean for a+ # cloud caller to pull). v0.8.8: "pcb_editor pic_programmer Read Only" -> "pcb-editor-pic-programmer-read-only".+ slug = "".join(c if (c.isalnum() or c in "._-") else "-" for c in (label or "")).strip("-").lower()+ while "--" in slug:+ slug = slug.replace("--", "-")+ suffix = f"-{slug}" if slug else ""++ # v0.8.8: default to PNG (container-side callers want PNG; was lossless WEBP,+ # which forced a host-side pull + convert). WEBP only on explicit fmt="webp".+ try:+ from PIL import Image+ img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")+ img = img.transpose(Image.FLIP_TOP_BOTTOM)+ MAX_DIM = 1568+ if max(width, height) > MAX_DIM:+ ratio = MAX_DIM / max(width, height)+ img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)+ path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.png")+ img.save(path, "PNG", optimize=True)+ except ImportError:+ path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.bmp")+ _write_bmp(path, width, height, pixel_buf.raw)++ size_kb = os.path.getsize(path) / 1024+ posix = path.replace("\\", "/")+ return {+ "success": True,+ # `fullPath` is the field the CLI's universal screenshot auto-pull keys on —+ # it copies the host file to the cloud container's /tmp/ad-shots/ and adds+ # localFullPath, so a cloud caller can Read it directly (no pull_file hop).+ # `savedTo` kept for back-compat.+ "savedTo": posix,+ "fullPath": posix,+ "sizeKB": round(size_kb, 1),+ }+++# --- Public handler functions ---++def handle_screenshot_all(kicad_info: dict, args: dict) -> dict:+ """Screenshot all KiCad windows (main + editors + dialogs).++ Windows: enumerates windows via Win32 and captures each via PrintWindow.+ macOS: not implemented at the plugin level. The Tauri app exposes+ desktop_screenshot_screen / desktop_screenshot_window via CoreGraphics —+ callers should use those instead.+ """+ if IS_MACOS:+ return {+ "success": False,+ "error": "kicad_screenshot_all is not implemented on macOS",+ "_hint": ("Use desktop_screenshot_screen or desktop_screenshot_window "+ "(via the Tauri app) — those use CoreGraphics and capture "+ "any visible window including KiCad."),+ }+ if not IS_WINDOWS:+ return {"success": False, "error": f"screenshot_all not implemented on {sys.platform!r}"}++ from handlers.close_windows import handle_window_info++ info = handle_window_info(kicad_info, {})+ data = info.get("data", {})++ # Collect windows in order: editors first, project manager, dialogs last+ windows = []+ for ed in data.get("editors", []):+ windows.append({"hwnd": ed["hwnd"], "title": ed["title"], "type": "editor"})+ pm = data.get("projectManager")+ if pm:+ windows.append({"hwnd": pm["hwnd"], "title": pm["title"], "type": "main"})+ for dlg in data.get("modalDialogs", []):+ windows.append({"hwnd": dlg["hwnd"], "title": dlg["title"], "type": "dialog"})++ if not windows:+ return {"success": False, "error": "No KiCad windows found."}++ screenshots = []+ for w in windows:+ hwnd = w["hwnd"]+ title = w["title"]+ win_type = w["type"]++ # Sanitize title for filename+ safe_title = "".join(c if c.isalnum() or c in "-_ " else "" for c in title)[:40].strip()+ label = f"{win_type}-{safe_title}" if safe_title else win_type++ result = _screenshot_hwnd(hwnd, label)+ entry = {+ "type": win_type,+ "title": title,+ "hwnd": hwnd,+ }+ if result.get("success"):+ entry["savedTo"] = result["savedTo"]+ entry["fullPath"] = result.get("fullPath", result["savedTo"])+ entry["sizeKB"] = result["sizeKB"]+ else:+ entry["error"] = result.get("error", "screenshot failed")++ screenshots.append(entry)++ return {+ "success": True,+ "output": f"Captured {len(screenshots)} KiCad window(s)",+ "data": {"screenshots": screenshots},+ }+++# v0.7.1+: modifier VKs for combo parsing in handle_send_key. The four standard+# Windows modifiers; combos like "ctrl+shift+f10" or "alt+3" decompose into a+# list of these wrapping the base key.+_MODIFIER_VK = {+ "ctrl": 0x11, "control": 0x11,+ "alt": 0x12, "menu": 0x12,+ "shift": 0x10,+ "win": 0x5B, "super": 0x5B, "lwin": 0x5B,+}+++def _parse_modifier_combo(key: str) -> tuple[list[int], str] | None:+ """Parse a 'ctrl+alt+f10' style combo.++ Returns (modifier_vks, base_key) where base_key is the trailing token after+ the last "+". Modifiers must come BEFORE the base key. Returns None if `key`+ isn't a combo (no "+" or only one token).+ """+ if "+" not in key:+ return None+ tokens = [t.strip().lower() for t in key.split("+") if t.strip()]+ if len(tokens) < 2:+ return None+ *mods, base = tokens+ mod_vks: list[int] = []+ for m in mods:+ vk = _MODIFIER_VK.get(m)+ if vk is None:+ # Not a known modifier — bail and let the caller treat the whole+ # thing as an unknown key.+ return None+ mod_vks.append(vk)+ return mod_vks, base++++# ---------------------------------------------------------------------------+# BACKGROUND INPUT (John, 2026-07-20)+#+# SendInput/keybd_event/mouse_event are GLOBAL: they go to whatever window owns+# focus, so using them forces us to foreground KiCad — stealing the user's screen+# and, for clicks, physically moving their mouse. That is unacceptable while+# someone is working.+#+# PostMessage targets ONE window's message queue. It needs no focus, works while+# KiCad is buried behind other apps, and can never leak a keystroke into the+# user's active window. It is the default for every verb now.+#+# Trade-off (be honest about it): posted messages bypass the global keyboard+# state, so modifier CHORDS (Ctrl+Shift+E) are unreliable this way — the target+# checks GetKeyState() and sees no Ctrl held. For chords, prefer the embedded+# plugin's WM_COMMAND pathway (handlers/bridge_client.py), which is both+# deterministic AND background. Only if neither works should a caller opt into+# the focus path, and then it MUST warn the user first.+# ---------------------------------------------------------------------------+WM_KEYDOWN, WM_KEYUP, WM_CHAR = 0x0100, 0x0101, 0x0102+WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MOUSEMOVE = 0x0201, 0x0202, 0x0200+++def _post_key(hwnd: int, vk: int, char: str | None = None) -> bool:+ """Deliver one key to `hwnd` without focus. True if posted."""+ try:+ user32.PostMessageW(hwnd, WM_KEYDOWN, vk, 0)+ if char:+ user32.PostMessageW(hwnd, WM_CHAR, ord(char), 0)+ user32.PostMessageW(hwnd, WM_KEYUP, vk, 0)+ return True+ except Exception:+ return False+++def _post_click(hwnd: int, cx: int, cy: int) -> bool:+ """Click at CLIENT coords inside `hwnd` without focus and WITHOUT moving the+ user's physical mouse pointer."""+ try:+ lp = (cy << 16) | (cx & 0xFFFF)+ user32.PostMessageW(hwnd, WM_MOUSEMOVE, 0, lp)+ user32.PostMessageW(hwnd, WM_LBUTTONDOWN, 1, lp)+ user32.PostMessageW(hwnd, WM_LBUTTONUP, 0, lp)+ return True+ except Exception:+ return False+++def _foreground_warning(action: str) -> dict:+ """The payload a verb returns when it genuinely cannot work in the background."""+ return {+ "success": False,+ "error": f"{action} needs the foreground, which would take over the user's screen",+ "errorCode": "foreground_required",+ "_hint": (+ "STOP and ask the user first: \"I need your screen for about 5 seconds to "+ f"{action} — ok?\" Send a desktop notification (adom-desktop notify_user) so "+ "they see it even if they're in another app, and only then retry with "+ "{\"allowForeground\": true}. Never take the screen silently — the user may be "+ "mid-sentence in another window. Prefer the background routes first: the "+ "embedded-plugin WM_COMMAND pathway (kicad_bridge_call) for menu actions, and "+ "kicad_send_key/kicad_click which post directly to the window."+ ),+ }+++def handle_send_key(kicad_info: dict, args: dict) -> dict:+ """Send a keystroke to the foreground KiCad window.++ Supports modifier-combo syntax (v0.7.1+): "ctrl+s", "alt+3", "shift+f10",+ "ctrl+shift+s". Modifiers: ctrl/control, alt/menu, shift, win/super/lwin.+ The base key after the last "+" is parsed via the same VK_MAP / single-char+ path as standalone keys.++ Windows-only: macOS UI scripting against another app requires Accessibility+ permission, which we don't request. Ask the user to interact directly.+ """+ if not IS_WINDOWS:+ return {+ "success": False,+ "error": "kicad_send_key is not implemented on macOS",+ "_hint": ("On macOS, ask the user to press the key in the KiCad window "+ "directly. UI scripting needs Accessibility permission."),+ }++ key = args.get("key", "").strip()+ if not key:+ return {"success": False, "error": "Missing required arg: key"}++ target_hwnd = args.get("hwnd")+ if target_hwnd:+ target_hwnd = int(target_hwnd)+ else:+ target_hwnd = _find_foreground_kicad_hwnd()++ if not target_hwnd:+ return {"success": False, "error": "No KiCad window found to send key to."}++ title = _get_window_title(target_hwnd)++ # NEVER steal the user's screen. Show the window without activating it+ # (SW_SHOWNOACTIVATE) and deliver input to THIS hwnd's own message queue.+ # (John, 2026-07-20 — the bridge was foregrounding KiCad and typing over him+ # while he worked.) Keys posted this way arrive even when KiCad is behind+ # other apps, and can never land in the user's active window by mistake.+ try:+ user32.ShowWindow(target_hwnd, 4) # SW_SHOWNOACTIVATE+ except Exception:+ pass+ time.sleep(0.05)++ allow_fg = bool(args.get("allowForeground"))++ # DEFAULT PATH: post the key straight to KiCad's window — no focus, no screen+ # theft, works while the window is buried. Chords still need the focus path+ # (posted messages don't set global modifier state), so those are gated.+ combo_probe = _parse_modifier_combo(key)+ if not allow_fg:+ if combo_probe is not None:+ return _foreground_warning(f"send the key chord '{key}' to KiCad")+ vk = VK_MAP.get(key)+ ch = key if (vk is None and len(key) == 1) else None+ if vk is None and ch:+ vk = user32.VkKeyScanW(ord(ch)) & 0xFF+ if vk is None:+ return {"success": False, "error": f"Unknown key: {key}"}+ if _post_key(target_hwnd, vk, ch):+ return {"success": True, "output": f"Posted '{key}' to '{title}' (background)",+ "data": {"hwnd": target_hwnd, "title": title, "method": "postmessage", "foreground": False},+ "_hint": ("Delivered without touching the user's focus. If KiCad ignored it, "+ "the control may need real input — prefer the plugin WM_COMMAND "+ "pathway; only as a last resort warn the user and retry with "+ "allowForeground:true.")}+ return {"success": False, "error": f"Could not post '{key}' to the window"}++ # v0.7.1+: detect modifier combos first. If "key" parses as one (e.g.+ # "alt+3"), wrap the base key in mod-down/up events. Falls through to the+ # original path if it's a single key or unknown-modifier combo.+ combo = _parse_modifier_combo(key)+ if combo is not None:+ mod_vks, base = combo+ # Resolve the base key to a VK via the same path as a standalone key.+ base_vk = VK_MAP.get(base)+ base_shift = False+ if base_vk is None and len(base) == 1:+ vk_scan = user32.VkKeyScanW(ord(base))+ base_vk = vk_scan & 0xFF+ base_shift = bool((vk_scan >> 8) & 0x01)+ if base_vk == 0xFF:+ return {"success": False, "error": f"Cannot map base character '{base}' to a virtual key."}+ if base_vk is None:+ return {"success": False, "error": f"Unknown base key '{base}' in combo '{key}'."}++ # Build the input sequence: ALL modifiers down, base down + up, modifiers up+ # (reverse order on the way up so the OS sees clean nesting).+ inputs: list[INPUT] = []+ # If the base char itself needs Shift AND Shift wasn't already in the combo,+ # add it. (Caller intent: "shift+s" already has shift; "alt+!" should add shift.)+ if base_shift and 0x10 not in mod_vks:+ mod_vks = mod_vks + [0x10]+ for vk in mod_vks:+ d = INPUT(); d.type = INPUT_KEYBOARD; d.union.ki.wVk = vk+ inputs.append(d)+ kd = INPUT(); kd.type = INPUT_KEYBOARD; kd.union.ki.wVk = base_vk+ inputs.append(kd)+ ku = INPUT(); ku.type = INPUT_KEYBOARD; ku.union.ki.wVk = base_vk+ ku.union.ki.dwFlags = KEYEVENTF_KEYUP+ inputs.append(ku)+ for vk in reversed(mod_vks):+ u = INPUT(); u.type = INPUT_KEYBOARD; u.union.ki.wVk = vk+ u.union.ki.dwFlags = KEYEVENTF_KEYUP+ inputs.append(u)++ _send_input(*inputs)+ return {+ "success": True,+ "output": f"Sent '{key}' to {title}",+ "data": {"key": key, "modifiers": mod_vks, "baseVk": base_vk,+ "baseShift": base_shift, "hwnd": target_hwnd, "title": title},+ "_hint": ("Sent (back-compat chord path). For modifier chords prefer AD's "+ "desktop_press_key — it owns full chord parsing and AD's focus-first "+ "delivery. Keep kicad_send_key for single keys to an EXACT hwnd (e.g. "+ "Enter/Escape to a specific dialog)."),+ }++ key_lower = key.lower().strip()+ vk = VK_MAP.get(key_lower)++ if vk is None:+ if len(key) == 1:+ vk_scan = user32.VkKeyScanW(ord(key))+ vk = vk_scan & 0xFF+ shift = (vk_scan >> 8) & 0x01+ if vk == 0xFF:+ return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."}++ inputs = []+ if shift:+ s_down = INPUT()+ s_down.type = INPUT_KEYBOARD+ s_down.union.ki.wVk = 0x10+ inputs.append(s_down)++ kd = INPUT()+ kd.type = INPUT_KEYBOARD+ kd.union.ki.wVk = vk+ inputs.append(kd)++ ku = INPUT()+ ku.type = INPUT_KEYBOARD+ ku.union.ki.wVk = vk+ ku.union.ki.dwFlags = KEYEVENTF_KEYUP+ inputs.append(ku)++ if shift:+ s_up = INPUT()+ s_up.type = INPUT_KEYBOARD+ s_up.union.ki.wVk = 0x10+ s_up.union.ki.dwFlags = KEYEVENTF_KEYUP+ inputs.append(s_up)++ _send_input(*inputs)+ return {"success": True,+ "output": f"Sent '{key}' to {title}",+ "data": {"key": key, "vk": vk, "shift": bool(shift),+ "hwnd": target_hwnd, "title": title}}+ else:+ return {"success": False, "error": f"Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."}++ kd = INPUT()+ kd.type = INPUT_KEYBOARD+ kd.union.ki.wVk = vk++ ku = INPUT()+ ku.type = INPUT_KEYBOARD+ ku.union.ki.wVk = vk+ ku.union.ki.dwFlags = KEYEVENTF_KEYUP++ _send_input(kd, ku)+ return {"success": True,+ "output": f"Sent '{key_lower}' to {title}",+ "data": {"key": key_lower, "vk": vk,+ "hwnd": target_hwnd, "title": title}}+++def handle_click(kicad_info: dict, args: dict) -> dict:+ """Click at coordinates within a KiCad window.++ Windows-only: macOS UI scripting against another app requires Accessibility+ permission, which we don't request. Ask the user to click directly.+ """+ if not IS_WINDOWS:+ return {+ "success": False,+ "error": "kicad_click is not implemented on macOS",+ "_hint": ("On macOS, ask the user to click in the KiCad window directly. "+ "UI scripting needs Accessibility permission."),+ }++ hwnd = args.get("hwnd")+ if not hwnd:+ return {"success": False, "error": "Missing required arg: hwnd"}+ hwnd = int(hwnd)++ x = args.get("x")+ y = args.get("y")+ if x is None or y is None:+ return {"success": False, "error": "Missing required args: x, y"}++ relative = args.get("relative", True)++ # Footgun guard: relative coords are FRACTIONS of the window (0.0–1.0). If a caller+ # leaves relative=True (the default) but passes pixel values (e.g. x=676), the math+ # below lands the click FAR off-screen — which at best misses and at worst hits a+ # random window's close/controls and can take KiCad down. Refuse clearly instead.+ if relative:+ try:+ if float(x) > 1.0 or float(y) > 1.0 or float(x) < 0 or float(y) < 0:+ return {"success": False,+ "error": f"relative=True expects x/y as fractions 0.0–1.0 of the window, "+ f"but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels.",+ "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ≈ {\"x\":0.35,\"y\":0.09}. "+ "For exact pixel coords add \"relative\": false."}+ except (TypeError, ValueError):+ return {"success": False, "error": f"x/y must be numbers, got x={x!r}, y={y!r}"}++ title = _get_window_title(hwnd)+ if not title:+ return {"success": False, "error": f"HWND {hwnd} is not a valid window."}++ rect = _get_window_rect(hwnd)+ width = rect["width"]+ height = rect["height"]++ # Background click: no SetForegroundWindow, and crucially no SetCursorPos —+ # moving the user's physical mouse is the rudest thing this bridge can do.+ try:+ user32.ShowWindow(hwnd, 4) # SW_SHOWNOACTIVATE+ except Exception:+ pass+ time.sleep(0.05)++ if relative:+ screen_x = rect["left"] + int(float(x) * width)+ screen_y = rect["top"] + int(float(y) * height)+ else:+ screen_x = rect["left"] + int(x)+ screen_y = rect["top"] + int(y)++ # DEFAULT PATH: post the click to the window in CLIENT coords. The user's+ # physical pointer never moves.+ if not bool(args.get("allowForeground")):+ cx, cy = screen_x - rect["left"], screen_y - rect["top"]+ if _post_click(hwnd, cx, cy):+ return {"success": True,+ "output": f"Posted click at ({cx},{cy}) in '{title}' (background)",+ "data": {"hwnd": hwnd, "title": title, "client": [cx, cy],+ "method": "postmessage", "foreground": False},+ "_hint": ("Clicked without moving the user's mouse or taking focus. If the "+ "canvas ignored it, that control may need real input — warn the "+ "user you need the screen for ~5s, then retry allowForeground:true.")}+ return {"success": False, "error": "Could not post the click to the window"}++ # Convert to absolute coordinates for SendInput (0-65535 range)+ screen_w = user32.GetSystemMetrics(0)+ screen_h = user32.GetSystemMetrics(1)+ abs_x = int(screen_x * 65535 / screen_w)+ abs_y = int(screen_y * 65535 / screen_h)++ move = INPUT()+ move.type = INPUT_MOUSE+ move.union.mi.dx = abs_x+ move.union.mi.dy = abs_y+ move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE++ down = INPUT()+ down.type = INPUT_MOUSE+ down.union.mi.dx = abs_x+ down.union.mi.dy = abs_y+ down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN++ up = INPUT()+ up.type = INPUT_MOUSE+ up.union.mi.dx = abs_x+ up.union.mi.dy = abs_y+ up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP++ _send_input(move, down, up)++ return {+ "success": True,+ "output": f"Clicked ({screen_x}, {screen_y}) on {title}",+ "data": {+ "clickedAt": {"screenX": screen_x, "screenY": screen_y},+ "windowOffset": {"x": screen_x - rect["left"], "y": screen_y - rect["top"]},+ "relative": relative,+ "hwnd": hwnd,+ "title": title,+ },+ }
launch_editor.ps1+5−9@@ -42,15 +42,11 @@ if ($KiCadHwnd -eq 0) { $hwnd = [IntPtr]$KiCadHwnd -# Alt-key trick for foreground permission-[Win32Launch]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)-[Win32Launch]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)-Start-Sleep -Milliseconds 50--# Bring KiCad to foreground-[Win32Launch]::ShowWindow($hwnd, 9) | Out-Null-[Win32Launch]::SetForegroundWindow($hwnd) | Out-Null-Start-Sleep -Milliseconds 500+# BACKGROUND ONLY (John, 2026-07-20). UIA InvokePattern drives a control through+# the accessibility tree, which needs NO focus — so we never take the user's+# screen. SW_SHOWNOACTIVATE (4) just un-minimises so the window is screenshottable.+[Win32Launch]::ShowWindow($hwnd, 4) | Out-Null+Start-Sleep -Milliseconds 200 # Use UI Automation to find the launcher text $root = [System.Windows.Automation.AutomationElement]::FromHandle($hwnd)
navigate_symbol.ps1+74−88@@ -7,7 +7,29 @@ param( [string]$SymbolName ) -Add-Type -AssemblyName System.Windows.Forms+# ============================================================================+# BACKGROUND-ONLY symbol navigation.+#+# The previous version did ALL of this just to type one symbol name:+# * an Alt-key trick to steal foreground permission,+# * ShowWindow(SW_RESTORE) + SetForegroundWindow -> took over the screen,+# * SetCursorPos + mouse_event -> MOVED THE USER'S MOUSE,+# * SendKeys::SendWait -> typed into whatever had focus.+# If the user was working, we typed on top of them.+# (John, 2026-07-20: "when you take over my screen to type into the kicad window+# it's crazy rude" — he was running five other projects at the time.)+#+# Everything below is WINDOW-targeted instead of FOCUS-targeted, so it works+# while KiCad sits behind other apps and the user keeps typing elsewhere:+# 1. UIA ValuePattern.SetValue() — set the search box text with no focus.+# 2. WM_SETTEXT via SendMessage — fallback for controls without ValuePattern.+# 3. PostMessage(WM_KEYDOWN/UP) — keys delivered to a specific hwnd, never to+# the shared foreground input queue.+# No keybd_event, no SendKeys, no mouse, no SetForegroundWindow. Ever.+# ============================================================================++Add-Type -AssemblyName UIAutomationClient+Add-Type -AssemblyName UIAutomationTypes Add-Type @" using System;@@ -15,119 +37,83 @@ using System.Runtime.InteropServices; using System.Text; public class Win32Nav {+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]+ public static extern IntPtr SendMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, string lParam); [DllImport("user32.dll")]- public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);+ public static extern bool PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll")]- public static extern bool SetForegroundWindow(IntPtr hWnd);+ public static extern int GetWindowTextLengthW(IntPtr hWnd);+ [DllImport("user32.dll", CharSet = CharSet.Unicode)]+ public static extern int GetWindowTextW(IntPtr hWnd, StringBuilder lpString, int nMaxCount); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);- [DllImport("user32.dll")]- public static extern IntPtr GetForegroundWindow();- [DllImport("user32.dll")]- public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);- [DllImport("user32.dll")]- public static extern bool SetCursorPos(int X, int Y);- [DllImport("user32.dll")]- public static extern void mouse_event(uint dwFlags, int dx, int dy, uint dwData, UIntPtr dwExtraInfo);- [DllImport("user32.dll")]- public static extern int GetWindowTextW(IntPtr hWnd, StringBuilder sb, int max);- [DllImport("user32.dll")]- public static extern int GetWindowTextLengthW(IntPtr hWnd);-- [StructLayout(LayoutKind.Sequential)]- public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } } "@ -$hwnd = [IntPtr]$SymEditorHwnd-$searchEdit = [IntPtr]$SearchEditHwnd+$WM_SETTEXT = 0x000C+$WM_KEYDOWN = 0x0100+$WM_KEYUP = 0x0101+$VK_RETURN = 0x0D -# Get search box screen coordinates-$rect = New-Object Win32Nav+RECT-[Win32Nav]::GetWindowRect($searchEdit, [ref]$rect) | Out-Null-$clickX = [int](($rect.Left + $rect.Right) / 2)-$clickY = [int](($rect.Top + $rect.Bottom) / 2)+$hwnd = [IntPtr]$SymEditorHwnd+$editHwnd = [IntPtr]$SearchEditHwnd+$method = "none" -if ($clickX -eq 0 -and $clickY -eq 0) {- Write-Output "ERROR:Search edit has zero coordinates"- exit 1-}+# SW_SHOWNOACTIVATE (4): un-minimise so the window can be screenshotted, but do+# NOT activate it. This is the strongest thing the bridge is allowed to do.+[Win32Nav]::ShowWindow($hwnd, 4) | Out-Null -# Wrap everything in try/finally to ensure modifier keys are ALWAYS released.-# A stuck Alt/Ctrl/Shift key breaks Alt+Tab and window focus on the desktop.+# --- 1. UIA ValuePattern: set the text on the control itself, no focus ------- try {+ $el = [System.Windows.Automation.AutomationElement]::FromHandle($editHwnd)+ if ($el -ne $null) {+ $vp = $null+ if ($el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$vp)) {+ $vp.SetValue($SymbolName)+ $method = "uia-valuepattern"+ }+ }+} catch { }++# --- 2. Fallback: WM_SETTEXT straight to the edit control -------------------+if ($method -eq "none") {+ try {+ [Win32Nav]::SendMessageW($editHwnd, $WM_SETTEXT, [IntPtr]::Zero, $SymbolName) | Out-Null+ $method = "wm_settext"+ } catch { }+} -# Alt-key trick to gain foreground permission-[Win32Nav]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)-[Win32Nav]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)-Start-Sleep -Milliseconds 50--# Bring Symbol Editor to foreground-[Win32Nav]::ShowWindow($hwnd, 9) | Out-Null-[Win32Nav]::SetForegroundWindow($hwnd) | Out-Null-Start-Sleep -Milliseconds 300--# Verify foreground-$fg = [Win32Nav]::GetForegroundWindow()-if ($fg -ne $hwnd) {- Write-Output "ERROR:Could not get foreground focus"+if ($method -eq "none") {+ Write-Output "ERROR:Could not set search text without focus (no ValuePattern, WM_SETTEXT failed)" exit 1 } -# Click on the search box to give it input focus-[Win32Nav]::SetCursorPos($clickX, $clickY) | Out-Null-Start-Sleep -Milliseconds 50-[Win32Nav]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) # LEFTDOWN-Start-Sleep -Milliseconds 30-[Win32Nav]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) # LEFTUP-Start-Sleep -Milliseconds 200+Start-Sleep -Milliseconds 250 -# Select all + delete existing text-[System.Windows.Forms.SendKeys]::SendWait("^a")-Start-Sleep -Milliseconds 50-[System.Windows.Forms.SendKeys]::SendWait("{DELETE}")-Start-Sleep -Milliseconds 150+# --- 3. Commit the filter: Enter POSTED to the edit control ------------------+# PostMessage targets that control's own message queue, so it needs no focus and+# cannot land in another app if the user switches windows mid-call.+[Win32Nav]::PostMessageW($editHwnd, $WM_KEYDOWN, [IntPtr]$VK_RETURN, [IntPtr]::Zero) | Out-Null+[Win32Nav]::PostMessageW($editHwnd, $WM_KEYUP, [IntPtr]$VK_RETURN, [IntPtr]::Zero) | Out-Null+Start-Sleep -Milliseconds 900 -# Type the symbol name (SendKeys escaping for special chars)-$escaped = $SymbolName -replace '([+^%~{}()])', '{$1}'-[System.Windows.Forms.SendKeys]::SendWait($escaped)-Start-Sleep -Milliseconds 500+# NOTE: loading the highlighted symbol into the canvas (the old Ctrl+Shift+E) is+# deliberately NOT done here. A ctrl chord can't be delivered reliably by+# PostMessage without focus, and the caller has a better fully-background route:+# the embedded-plugin WM_COMMAND pathway. This script only does the focus-free part. -# Press Enter to trigger the search/filter in the tree-[System.Windows.Forms.SendKeys]::SendWait("{ENTER}")-Start-Sleep -Milliseconds 1000--# Send Ctrl+Shift+E to open the filtered symbol-[System.Windows.Forms.SendKeys]::SendWait("^+e")-Start-Sleep -Milliseconds 1500--} finally {- # ALWAYS release all modifier keys to prevent stuck-key issues- [Win32Nav]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) # Alt up- [Win32Nav]::keybd_event(0xA4, 0, 2, [UIntPtr]::Zero) # Left Alt up- [Win32Nav]::keybd_event(0xA5, 0, 2, [UIntPtr]::Zero) # Right Alt up- [Win32Nav]::keybd_event(0x11, 0, 2, [UIntPtr]::Zero) # Ctrl up- [Win32Nav]::keybd_event(0xA2, 0, 2, [UIntPtr]::Zero) # Left Ctrl up- [Win32Nav]::keybd_event(0xA3, 0, 2, [UIntPtr]::Zero) # Right Ctrl up- [Win32Nav]::keybd_event(0x10, 0, 2, [UIntPtr]::Zero) # Shift up- [Win32Nav]::keybd_event(0xA0, 0, 2, [UIntPtr]::Zero) # Left Shift up- [Win32Nav]::keybd_event(0xA1, 0, 2, [UIntPtr]::Zero) # Right Shift up-}--# Check if the symbol loaded $len = [Win32Nav]::GetWindowTextLengthW($hwnd) if ($len -gt 0) { $sb = New-Object System.Text.StringBuilder($len + 1) [Win32Nav]::GetWindowTextW($hwnd, $sb, $len + 1) | Out-Null $title = $sb.ToString()- if ($title -match [regex]::Escape($SymbolName)) {- Write-Output "OK:$title"+ Write-Output "OK:$title|method=$method" } elseif ($title -notmatch "no symbol loaded") {- Write-Output "PARTIAL:$title"+ Write-Output "PARTIAL:$title|method=$method" } else {- Write-Output "NOLOAD:$title"+ Write-Output "NOLOAD:$title|method=$method" } } else {- Write-Output "NOLOAD:Could not read title"+ Write-Output "PARTIAL:|method=$method" }