app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
v0.9.106: chords resolve via the menu-bar accelerator table -> WM_COMMAND (background); Alt+3 keystroke fallbacks delet…
82dee79
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
"""win_menu.py — resolve a KiCad menu command id straight from the Win32 menu bar
and fire it with PostMessage(WM_COMMAND). Fully PLUGIN-FREE and FOCUS-FREE.
Issue #26: the 3D viewer opens via a menu command. The old paths needed either
the in-process plugin (binds minutes-late in editors) or an Alt+3 keystroke
(needs foreground). But wxFrame menus on Windows ARE native Win32 menus attached
with SetMenu — so we can walk the menu bar with GetMenu/GetSubMenu, find the item
by text, read its command id, and PostMessage it. No plugin, no focus, no cursor.
"""
import ctypes
import sys
IS_WINDOWS = sys.platform == "win32"
if IS_WINDOWS:
user32 = ctypes.windll.user32
MF_BYPOSITION = 0x0400
WM_COMMAND = 0x0111
def find_menu_command(hwnd: int, *contains: str, max_depth: int = 4):
"""Walk the window's Win32 menu bar; return the command id of the first leaf
item whose text contains ALL of `contains` (case-insensitive), else None.
Returns (cmd_id, menu_text) or (None, reason)."""
if not IS_WINDOWS or not hwnd:
return None, "no-win32"
user32.GetMenu.restype = ctypes.c_void_p
user32.GetSubMenu.restype = ctypes.c_void_p
hmenu = user32.GetMenu(ctypes.c_void_p(hwnd))
if not hmenu:
return None, "no-menu" # wx custom/opaque menu, or wrong hwnd
wants = [c.lower() for c in contains]
def _text(menu, i):
buf = ctypes.create_unicode_buffer(256)
user32.GetMenuStringW(ctypes.c_void_p(menu), i, buf, 256, MF_BYPOSITION)
return buf.value or ""
def _walk(menu, depth):
n = user32.GetMenuItemCount(ctypes.c_void_p(menu))
for i in range(max(0, n)):
txt = _text(menu, i).replace("&", "")
sub = user32.GetSubMenu(ctypes.c_void_p(menu), i)
if sub:
if depth < max_depth:
hit = _walk(sub, depth + 1)
if hit is not None:
return hit
else:
low = txt.lower()
if all(w in low for w in wants):
cid = user32.GetMenuItemID(ctypes.c_void_p(menu), i)
if cid and cid != -1:
return cid, txt
return None
hit = _walk(hmenu, 0)
if hit is None:
return None, "not-found"
return hit
def invoke_menu_command(hwnd: int, cmd_id: int) -> bool:
"""PostMessage WM_COMMAND — triggers the menu action with NO focus/cursor."""
if not IS_WINDOWS or not hwnd or not cmd_id:
return False
try:
user32.PostMessageW(ctypes.c_void_p(hwnd), WM_COMMAND, cmd_id, 0)
return True
except Exception:
return False
def open_via_menu(hwnd: int, *contains: str):
"""Convenience: resolve `contains` in the window's menu and fire it.
Returns {ok, cmdId, menuText} or {ok:False, reason}."""
cid, info = find_menu_command(hwnd, *contains)
if cid is None:
return {"ok": False, "reason": info}
ok = invoke_menu_command(hwnd, cid)
return {"ok": ok, "cmdId": cid, "menuText": info}
def _norm_accel(text: str) -> str:
"""Normalize an accelerator for comparison: 'Ctrl+Shift+S' -> 'ctrl+shift+s'."""
return "".join((text or "").lower().split())
def find_by_accelerator(hwnd: int, chord: str, max_depth: int = 4):
"""Resolve a keyboard CHORD (e.g. 'ctrl+s', 'alt+3') to the menu command that
carries it as an accelerator — wx menu items render as 'Save\tCtrl+S', so the
menu bar doubles as a chord->command table. Returns (cmd_id, menu_text) or
(None, reason). This lets send_key deliver chords via PostMessage(WM_COMMAND)
— fully background — instead of real keystrokes that need the foreground
(John 2026-08-15: use menu navigation instead of send_key)."""
if not IS_WINDOWS or not hwnd:
return None, "no-win32"
user32.GetMenu.restype = ctypes.c_void_p
user32.GetSubMenu.restype = ctypes.c_void_p
hmenu = user32.GetMenu(ctypes.c_void_p(hwnd))
if not hmenu:
return None, "no-menu"
want = _norm_accel(chord)
def _text(menu, i):
buf = ctypes.create_unicode_buffer(256)
user32.GetMenuStringW(ctypes.c_void_p(menu), i, buf, 256, MF_BYPOSITION)
return buf.value or ""
def _walk(menu, depth):
n = user32.GetMenuItemCount(ctypes.c_void_p(menu))
for i in range(max(0, n)):
raw = _text(menu, i)
sub = user32.GetSubMenu(ctypes.c_void_p(menu), i)
if sub:
if depth < max_depth:
hit = _walk(sub, depth + 1)
if hit is not None:
return hit
elif "\t" in raw:
label, accel = raw.split("\t", 1)
if _norm_accel(accel) == want:
cid = user32.GetMenuItemID(ctypes.c_void_p(menu), i)
if cid and cid != -1:
return cid, label.replace("&", "")
return None
hit = _walk(hmenu, 0)
if hit is None:
return None, "no-accelerator-match"
return hit