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