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
0.9.340: land the shipped runtime source for handlers/kicad_windows.py (tour fixes 0.9.318-0.9.339 never reached master…
d65e4be
2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
"""ONE place that answers "which KiCad windows exist right now".
Six modules each had their own EnumWindows + filter loop (close_windows.py,
open_symbol_editor.py, open_footprint_editor.py, kicad_ui.py, kicad_log.py,
server.py). They agreed by luck, not by construction, and a fix to one — say,
starting to include owned popups so an error dialog is visible — silently did
not reach the others. This is the single source of truth.
AD CORE FIRST, LOCAL FALLBACK
-----------------------------
The list comes from AD's `desktop_list_windows` when AD is reachable. That is
the 2026-09-01 audit rule (John): the bridge owns KiCad, AD owns Windows. It
also buys something the local path never had — the **z-order** field, which is
the only honest way to tell whether an action stole the user's foreground (see
skills/kicad-uia: response fields lie about this, z-order does not).
When AD is not reachable we enumerate locally, because window discovery is on
the path of nearly every verb and must not become a hard dependency on a
loopback call.
WHY THE CACHE EXISTS (the re-entrancy answer)
---------------------------------------------
AD calls this bridge; a bridge calling back into AD is re-entrant, and a verb
that looks up windows five times would make five loopback round trips (~8ms
each, plus AD's own enumeration) inside a call AD is already blocked on. A
short TTL collapses that to ONE lookup per verb invocation while staying fresh
enough for "what is on screen now": windows do not appear and vanish inside
250ms, and every mutating operation calls invalidate() anyway.
Do NOT raise the TTL to "make things faster". The staleness window is a
correctness budget: `kicad_errors` reading a dialog that closed 900ms ago
reports a failure that is no longer on screen.
"""
from __future__ import annotations
import time
_TTL_S = 0.25
_cache: dict = {"at": 0.0, "rows": None, "source": ""}
def invalidate() -> None:
"""Call after anything that opens, closes, or retitles a KiCad window."""
_cache["at"] = 0.0
_cache["rows"] = None
def _from_ad() -> list | None:
"""AD's view: hwnd, title, className, rect, kind, and z. None if unreachable."""
try:
from handlers import ad_client
r = ad_client.call("desktop_list_windows", {}, timeout=6) or {}
except Exception: # pylint: disable=broad-except
return None
# The direct API hands the verb payload back UNWRAPPED (top-level
# `windows`; measured on arav-rog 2026-09-05, where this returned None and
# the tour fell back to a local list with no z-order, so no beat could
# verify or foreground). Older shapes wrap it in output / data. Accept all.
out = r.get("output") if isinstance(r, dict) and "output" in r else r
if isinstance(out, str):
try:
import json as _j
out = _j.loads(out)
except Exception: # pylint: disable=broad-except
return None
data = (out or {}).get("data") if isinstance(out, dict) else None
wins = (data or {}).get("windows") if isinstance(data, dict) else None
if not isinstance(wins, list) and isinstance(out, dict):
wins = out.get("windows")
if not isinstance(wins, list):
return None
rows = []
for w in wins:
if not isinstance(w, dict) or not w.get("hwnd"):
continue
rect = w.get("rect") or {}
rows.append({
"hwnd": int(w["hwnd"]),
"title": w.get("title") or "",
"className": w.get("className") or "",
"rect": {"x": rect.get("left", rect.get("x", 0)),
"y": rect.get("top", rect.get("y", 0)),
"width": rect.get("width", 0), "height": rect.get("height", 0)},
"z": w.get("z"),
"kind": w.get("kind"),
# carried through so 'in front' can skip minimized windows and shell popups
"minimized": bool(w.get("minimized")), "maximized": bool(w.get("maximized")),
"image": w.get("image") or "", "pid": w.get("pid"),
})
return rows
def _from_local() -> list:
"""Local EnumWindows. The fallback, and the only path when AD is old."""
try:
from handlers.kicad_ui import _find_all_kicad_windows
return list(_find_all_kicad_windows() or [])
except Exception: # pylint: disable=broad-except
return []
def _is_kicad(row: dict) -> bool:
"""Owned by a KiCad executable. Uses the ONE predicate everybody already
shares (win_focus.is_kicad_window, which checks the owning exe) rather than
matching on window titles — titles are localised and lie ('[no symbol
loaded]' while a symbol is selected)."""
try:
from handlers.win_focus import is_kicad_window
return bool(is_kicad_window(row["hwnd"]))
except Exception: # pylint: disable=broad-except
t = (row.get("title") or "").lower()
return any(k in t for k in ("kicad", "pcb editor", "schematic editor",
"symbol editor", "footprint editor", "3d viewer"))
def find(all_windows: bool = False, fresh: bool = False) -> list:
"""KiCad windows now. all_windows=True returns every top-level window
(needed when hunting a dialog whose owner is unclear)."""
now = time.monotonic()
if fresh or _cache["rows"] is None or (now - _cache["at"]) > _TTL_S:
rows = _from_ad()
src = "ad:desktop_list_windows"
if rows is None:
rows, src = _from_local(), "local:EnumWindows"
_cache.update({"at": now, "rows": rows, "source": src})
rows = _cache["rows"] or []
return list(rows) if all_windows else [r for r in rows if _is_kicad(r)]
def source() -> str:
"""Which path produced the cached list — for a verb that wants to say so."""
return _cache.get("source") or "(not yet queried)"
def _owner_hwnd(hwnd: int) -> int:
"""GW_OWNER for a window, 0 if it owns itself. AD's window list does not
carry ownership, and the project manager is identified BY being unowned, so
this one bit stays local."""
try:
import ctypes
return int(ctypes.windll.user32.GetWindow(int(hwnd), 4) or 0) # GW_OWNER
except Exception: # pylint: disable=broad-except
return 0
def find_project_manager() -> int | None:
"""The KiCad project-manager window, or None.
This exact function was duplicated byte-for-byte in open_symbol_editor.py
and open_footprint_editor.py. It is the manager when the title says KiCad,
does NOT name a sub-tool, and the window is unowned (an editor or dialog is
owned by, or titled after, its tool)."""
for row in find():
title = (row.get("title") or "")
low = title.lower()
if "kicad" not in low:
continue
if any(k in low for k in ("editor", "viewer", "calculator")):
continue
if _owner_hwnd(row["hwnd"]) == 0:
return row["hwnd"]
return None