app
Adom Desktop - KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809
"""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,
},
}