← 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.

John Lauer ·b236e40cc6 ·3mo ago ·parent 8c308f3
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" }