app
KiCad - the 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.
← Commit history
0.9.287: Wiki #71 (Ray, 0.9.286 run): (1) the posted Enter now goes FIRST to the window holding keyboard focus in KiCad's GUI thread (GetGUIThreadInfo), and wxDataView children count as tree candidates regardless of height, so a short Libraries pane no longer yields zero targets; (2) a navigation that ran out of the call budget returns navigation_unconfirmed_within_budget with the trace and _foreground instead of the 'still coming up' contract about an editor that was already driven; (3) an editor the bridge opened after the library file was last written is never judged stale again, so repeated calls stop re-opening it (staleVeto says so); (4) symbol_editor_did_not_open carries pathwaysTried; navigation_failed responses carry errorCode and resolved fields.
1 file changed
+94−5
handlers/open_symbol_editor.py+94−5@@ -342,6 +342,7 @@ def _find_kicad_manager() -> int | None: # --------------------------------------------------------------------------- _LAST_FG_REPORT: dict = {}+_EDITOR_OPENED_AT: dict = {} # hwnd -> epoch seconds when this module saw it appear def _caption_new_frame(user_top: dict | None, rescan: bool = False) -> None:@@ -554,6 +555,29 @@ def _edit_text(hwnd: int) -> str: return "?" +def _gui_thread_focus(frame_hwnd: int) -> int | None:+ """hwnd holding keyboard focus inside the thread that owns `frame_hwnd`+ (GetGUIThreadInfo; cross-process, no focus change). None if unreadable."""+ try:+ tid = user32.GetWindowThreadProcessId(int(frame_hwnd), None)+ if not tid:+ return None++ class GUITHREADINFO(ctypes.Structure):+ _fields_ = [("cbSize", ctypes.wintypes.DWORD), ("flags", ctypes.wintypes.DWORD),+ ("hwndActive", ctypes.wintypes.HWND), ("hwndFocus", ctypes.wintypes.HWND),+ ("hwndCapture", ctypes.wintypes.HWND), ("hwndMenuOwner", ctypes.wintypes.HWND),+ ("hwndMoveSize", ctypes.wintypes.HWND), ("hwndCaret", ctypes.wintypes.HWND),+ ("rcCaret", ctypes.wintypes.RECT)]+ gti = GUITHREADINFO()+ gti.cbSize = ctypes.sizeof(GUITHREADINFO)+ if not user32.GetGUIThreadInfo(tid, ctypes.byref(gti)):+ return None+ return int(gti.hwndFocus) if gti.hwndFocus else None+ except Exception: # pylint: disable=broad-except+ return None++ def _background_navigate_win32(sym_hwnd: int, filter_text: str, verify: str, verify_lib: str = "") -> bool: """v0.9.83 (#20/#21): plugin-free BACKGROUND symbol load. WM_SETTEXT into the@@ -608,6 +632,23 @@ def _background_navigate_win32(sym_hwnd: int, filter_text: str, verify: str, windows under the Libraries pane (wx generic controls share bland class names, so geometry is the discriminator).""" cands = []+ # Wiki #71 (Ray, 0.9.286): his screenshot showed the row SELECTED and+ # carrying the focus rectangle while the posted Enter never activated it.+ # The height>200 geometry filter below is DPI/layout dependent (a short+ # Libraries pane on a laptop yields ZERO candidates and Enter goes to+ # nobody). So ask Windows which child of KiCad's GUI thread has the+ # keyboard focus (GetGUIThreadInfo works across processes) and post+ # there FIRST; that is the exact window a human's Enter would reach.+ try:+ focus = _gui_thread_focus(sym_hwnd)+ if focus and focus != edit:+ fr = ctypes.wintypes.RECT()+ user32.GetWindowRect(focus, ctypes.byref(fr))+ cands.append((focus, fr))+ trace["focusHwnd"] = int(focus)+ trace["focusClass"] = _win_class(focus)+ except Exception: # pylint: disable=broad-except+ pass try: pane = user32.GetParent(user32.GetParent(edit)) or user32.GetParent(edit) WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.wintypes.HWND,@@ -615,7 +656,9 @@ def _background_navigate_win32(sym_hwnd: int, filter_text: str, verify: str, def cb(hwnd, _l): r = ctypes.wintypes.RECT() user32.GetWindowRect(hwnd, ctypes.byref(r))- if (r.bottom - r.top) > 200 and hwnd != edit:+ if hwnd == edit or any(h == hwnd for h, _ in cands):+ return True+ if (r.bottom - r.top) > 200 or _win_class(hwnd) in ("wxDataView", "wxDataViewCtrl"): cands.append((hwnd, r)) return True cbp = WNDENUMPROC(cb)@@ -895,6 +938,10 @@ def _handle_open_symbol_editor_inner(kicad_info: dict, args: dict) -> dict: _existing = _find_symbol_editor() except Exception: # pylint: disable=broad-except _existing = None+ try:+ _pt = list(pathways_tried) # assigned later in the enclosing function; bound at call time+ except NameError:+ _pt = None if not _existing: return { "success": False,@@ -903,6 +950,7 @@ def _handle_open_symbol_editor_inner(kicad_info: dict, args: dict) -> dict: "retryAfterMs": 5000, "phase": phase, "elapsedSec": int(time.monotonic() - _t0),+ "pathwaysTried": _pt, "error": ("No Symbol Editor window exists. The bridge asked KiCad to open one " "and none appeared, so this is not a slow cold start - nothing is " "coming up."),@@ -1191,6 +1239,7 @@ def _handle_open_symbol_editor_inner(kicad_info: dict, args: dict) -> dict: global _LAST_FG_REPORT if _user_top is not None and sym_hwnd: _LAST_FG_REPORT = _guard_new_frame(sym_hwnd, _user_top)+ _EDITOR_OPENED_AT[int(sym_hwnd)] = time.time() elif _user_top is not None: _LAST_FG_REPORT = {"restored": False, "why": "no editor frame appeared"} else:@@ -1278,10 +1327,30 @@ def _handle_open_symbol_editor_inner(kicad_info: dict, args: dict) -> dict: # The first attempt is what warms the tree, so this is a fresh-tree retry, # not a duplicate of a hopeless call. if _out_of_budget():- # #54: the editor is up but a cold index made the first navigate slow;- # return the starting contract rather than risk dying mid-retry - the- # caller's next call navigates a warm tree in seconds.- return _starting_contract("navigate-retry-on-cold-index")+ # Wiki #71 (Ray, 0.9.286): this used to return the "still coming up"+ # contract, which threw away a navigation that had ALREADY run against+ # an editor that was up (his screenshot: filter typed, row selected and+ # focused). "Starting" is not what happened. Say what did, with the+ # trace, and let the caller re-call a warm editor.+ return {+ "success": False,+ "errorCode": "navigation_unconfirmed_within_budget",+ "retryable": True,+ "retryAfterMs": 3000,+ "elapsedSec": int(time.monotonic() - _t0),+ "editorOpened": True,+ "rescanned": rescanned,+ "navigation": dict(_LAST_NAV_TRACE),+ "_foreground": dict(_LAST_FG_REPORT),+ **_resolved_fields(_get_symbol_editor_title(), library_name),+ "error": (f"The Symbol Editor is open and one background navigation ran, but the title did not "+ f"confirm '{search_text}' before this call's time budget ended "+ f"(title: {_get_symbol_editor_title() or 'unknown'})."),+ "_hint": ("Re-call with the SAME args: the editor is warm now, so the whole call is the "+ "navigation alone (seconds). Read navigation.steps and navigation.treeCandidates: "+ "zero candidates means Enter reached no window (report it with the trace); "+ "focusHwnd is where a human's Enter would land."),+ } _step(f"first pass did not confirm; retrying now the tree is warm", 90) if _background_navigate_win32(sym_hwnd, _filter, _verify, library_name): return {@@ -1317,6 +1386,23 @@ def _handle_open_symbol_editor_inner(kicad_info: dict, args: dict) -> dict: "a bridge bug worth reporting, not a library problem."), } stale = _symbol_on_disk_but_not_in_editor(kicad_info, symbol_name or search_text)+ # Wiki #71 (Ray, 0.9.286): three identical calls each re-opened the editor+ # and blew the budget, because "symbol on disk + editor exists" read as+ # stale EVERY time. An editor this module opened AFTER the library file+ # was last written has already indexed that symbol; a failed navigation+ # against it is a navigation problem, and reopening cannot help.+ _stale_veto = ""+ try:+ _opened = _EDITOR_OPENED_AT.get(int(sym_hwnd or 0))+ if stale and _opened:+ import adom_library as _al+ _paths = _al._get_paths(kicad_info)+ if _paths and Path(_paths[1]).exists() and Path(_paths[1]).stat().st_mtime < _opened:+ stale = False+ _stale_veto = ("the editor was opened by the bridge after the library file was last "+ "written, so its index already holds the symbol; reopening would not help")+ except Exception: # pylint: disable=broad-except+ pass # 0.9.211 (wiki #35, Colby's root cause). We KNOW the remedy here, so # handing it back as a required argument is passing our own retry out@@ -1385,6 +1471,9 @@ def _handle_open_symbol_editor_inner(kicad_info: dict, args: dict) -> dict: "attempts": 2, "navigation": dict(_LAST_NAV_TRACE), "_foreground": dict(_LAST_FG_REPORT),+ "errorCode": "navigation_failed",+ "staleVeto": _stale_veto or None,+ **_resolved_fields(_get_symbol_editor_title(), library_name), "error": f"Symbol Editor open but '{search_text}' didn't load in the background " f"after two attempts (title: {_get_symbol_editor_title() or 'unknown'})", "_hint": ("Two background attempts did not confirm, so a third is unlikely to "