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
"""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