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.
← Commit history
v0.9.59: (1) place_footprint accepts legacy (module ...) footprints (many chip-fetcher/EAGLE parts) — normalize to (footprint), so they can be placed on a preview board. (2) FP-editor 3D viewer now opens via the reverse-bridge plugin (the FP editor is a pcbnew frame — resolve its frame_index + live 3D-Viewer menu id), replacing the broken Alt+3 path that raised UnboundLocalError on VK_MENU and needed stolen focus. (3) kicad_click guards against pixel coords passed with relative=True (was clicking off-screen → could crash KiCad).
7 files changed
+783−709
adom-bridge-kicad-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.58",- "url": "https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/adom-bridge-kicad-v0.9.58.zip",- "sha256": "4f1429232a577407fa2b818951d9212d97ea5ca8d4075bab522bed50a0b7ebe9",- "size": 642059,+ "version": "0.9.59",+ "url": "https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/adom-bridge-kicad-v0.9.59.zip",+ "sha256": "f8affb58064486c62cdeef1c0c5eb441cbaed92b1557af09b676e925f8d2e2c7",+ "size": 643494, "verbPrefixes": [ "kicad_" ],
adom-bridge-kicad-v0.9.59.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.58",+ "version": "0.9.59", "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/bridge_client.py+20@@ -452,6 +452,26 @@ def try_wm_command_via_bridge(exe_name: str, menu_id: int, frame_index: int = 0, } +def resolve_frame_index_via_bridge(exe_name: str, title_contains: str,+ pid: int | None = None, timeout: float = 10.0) -> int | None:+ """Return the index of the plugin's frame whose title contains `title_contains`+ (case-insensitive), or None. In KiCad 10 the Footprint Editor is a frame INSIDE+ the pcbnew process, so `list_frames` exposes it at some index (usually 1 when a+ board is also open); `wm_command` honors that frame_index even though+ `get_menu_ids` currently only reports frame 0."""+ meta, err = _resolve_plugin(exe_name, pid)+ if err:+ return None+ rpc = call(meta["port"], "list_frames", {}, timeout=timeout)+ if "error" in rpc:+ return None+ needle = title_contains.lower()+ for i, fr in enumerate((rpc.get("result") or {}).get("frames", [])):+ if needle in str(fr.get("frameTitle", "")).lower():+ return i+ return None++ def resolve_menu_id_via_bridge(exe_name: str, contains: str, frame_index: int = 0, pid: int | None = None, timeout: float = 10.0) -> int | None: """Look up a live menu-command id by matching text — menu ids DRIFT across KiCad
handlers/kicad_ui.py+693−674@@ -1,674 +1,693 @@-"""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},- }-- 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)-- 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 + + +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, + }, + }
handlers/open_footprint_editor.py+49−29@@ -685,43 +685,63 @@ def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict: # Try Footprint Editor (only when chosen / sole open editor) if fp_hwnd: WM_COMMAND = 0x0111- # KiCad's 3D-viewer WM_COMMAND id differs by version (20500 was KiCad 9;- # KiCad 10 renumbered it), and the Footprint Editor isn't a plugin host so- # we can't query its live menu ids. The ROBUST path is the Alt+3 keyboard- # accelerator — the universal "open 3D viewer" shortcut that works in BOTH- # pcbnew and the Footprint Editor regardless of KiCad version. Try that- # first (foreground the FP window so the keystroke lands), then fall back to- # the legacy WM_COMMAND ids. (Fix: FP→3D silently failed on KiCad 10.)- VK_3 = 0x33- _force_foreground(fp_hwnd)- time.sleep(0.2)+ fp_pathways: list[str] = []+ # BACKGROUND path (preferred): in KiCad 10 the Footprint Editor is a frame+ # INSIDE the pcbnew process, so the reverse-bridge plugin can drive it. Find+ # that frame's index (list_frames) and post the live-resolved "3D Viewer"+ # menu id to it (wm_command honors frame_index). No foreground, no keystroke.+ # (Replaces the old Alt+3 path, which needed focus we deliberately don't take+ # — and referenced VK_MENU as a bare global, raising UnboundLocalError because+ # the pcbnew branch below assigns VK_MENU, making it function-local.) try:- user32.keybd_event(VK_MENU, 0, 0, 0) # Alt down- user32.keybd_event(VK_3, 0, 0, 0) # 3 down- user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0)- finally:- user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up ALWAYS- time.sleep(2.5)- windows = _find_windows_by_title("3D Viewer")- if windows:- return {- "success": True,- "output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",- "pathway": "fp_alt3",- }- # Legacy WM_COMMAND fallbacks (KiCad 9 id 20500; try the pcbnew id 20563 too).- for cmd_id in (20500, 20563):- user32.PostMessageW(fp_hwnd, WM_COMMAND, cmd_id, 0)- time.sleep(2.0)+ from handlers.bridge_client import (resolve_frame_index_via_bridge,+ resolve_menu_id_via_bridge,+ try_wm_command_via_bridge)+ fp_fi = resolve_frame_index_via_bridge("pcbnew", "Footprint Editor")+ if fp_fi is not None:+ mid = resolve_menu_id_via_bridge("pcbnew", "3D Viewer", frame_index=fp_fi) or 20568+ fp_pathways.append(f"plugin(frame={fp_fi},menuid={mid})")+ r = try_wm_command_via_bridge(exe_name="pcbnew", menu_id=mid, frame_index=fp_fi)+ if r.get("success"):+ time.sleep(3.0)+ windows = _find_windows_by_title("3D Viewer")+ if windows:+ return {+ "success": True,+ "output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",+ "pathway": "plugin",+ "pathwaysTried": fp_pathways,+ }+ else:+ fp_pathways.append("plugin-no-fp-frame")+ except Exception as e: # pylint: disable=broad-except+ fp_pathways.append(f"plugin-error:{type(e).__name__}")++ # Opt-in disruptive fallback: Alt+3 keystroke (needs the FP window foregrounded,+ # so it steals focus) — only when the caller explicitly allows it.+ if args.get("allowFocusSteal"):+ VK_MENU_LOCAL = 0x12+ VK_3 = 0x33+ user32.SetForegroundWindow(fp_hwnd)+ time.sleep(0.2)+ try:+ user32.keybd_event(VK_MENU_LOCAL, 0, 0, 0)+ user32.keybd_event(VK_3, 0, 0, 0)+ user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0)+ finally:+ user32.keybd_event(VK_MENU_LOCAL, 0, KEYEVENTF_KEYUP, 0)+ fp_pathways.append("alt3_focussteal")+ time.sleep(2.5) windows = _find_windows_by_title("3D Viewer") if windows: return { "success": True, "output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",- "pathway": f"fp_wm_command_{cmd_id}",+ "pathway": "fp_alt3",+ "pathwaysTried": fp_pathways, } - # FP path failed silently (WM_COMMAND posted but no 3D Viewer appeared).+ # FP path failed (plugin frame absent AND no focus-steal fallback). # If caller asked for 'auto' AND a PCB editor is also open, fall through # to PCB attempt rather than dead-ending here. if editor_choice == "auto" and pcb_hwnd is None:
handlers/place_footprint.py+16−1@@ -48,8 +48,16 @@ def handle_place_footprint(kicad_info: dict, args: dict) -> dict: fp_text = base64.b64decode(file_content).decode("utf-8", "replace").strip() except Exception as e: return {"success": False, "error": f"could not decode fileContent: {e}"}+ # Accept legacy `(module ...)` footprints (KiCad ≤5 / EAGLE-imported / many+ # chip-fetcher parts still ship these). KiCad auto-migrates them on load, but the+ # board *file* parser wants the modern `(footprint ...)` keyword, so normalize:+ # rename the wrapper and quote the top-level (layer X) token the splice relies on.+ if fp_text.startswith("(module"):+ fp_text = re.sub(r'^\(module\b', '(footprint', fp_text, count=1)+ # quote an unquoted top-level layer name: (layer F.Cu) -> (layer "F.Cu")+ fp_text = re.sub(r'\(layer\s+([A-Za-z][\w.]*)\s*\)', r'(layer "\1")', fp_text, count=1) if not fp_text.startswith("(footprint"):- return {"success": False, "error": "fileContent is not a .kicad_mod (must start with '(footprint')"}+ return {"success": False, "error": "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')"} fp_name = args.get("footprintName") or "" if not fp_name:@@ -91,9 +99,16 @@ def handle_place_footprint(kicad_info: dict, args: dict) -> dict: # which KILLS the child on timeout. Popen fire-and-forget, DETACHED_PROCESS # so pcbnew outlives the bridge and inherits no console handle. flags = 0x00000008 if sys.platform == "win32" else 0 # DETACHED_PROCESS+ _si = None+ try:+ from handlers import win_focus+ _si = win_focus.background_startupinfo() # SW_SHOWNOACTIVATE — open behind the user+ except Exception:+ _si = None subprocess.Popen( [pcbnew_exe, str(out_path)], creationflags=flags,+ startupinfo=_si, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,