master
John Lauer v0.9.144: focusDebug introspection in kicad_state (sweep/hook/sentinel liveness, inflight/guard counters, parked/baseli… abafa38 2mo ago
"""state.py — kicad_state: KiCad's full state of the union in one read-only call.

Issue #22 (John, 2026-08-14, adopted from the Bridge SDK suggested pattern):
a generic title-match screenshot returns ONE window, but KiCad is a multi-window
app — so callers (web pages, AIs that can't reach the desktop filesystem) got a
cropped sub-window as "proof". Only the bridge knows the real window set.

kicad_state returns, in one response:
  - `shots[]`  — a screenshot of EVERY window KiCad owns right now, captured by
    real hwnd, inline as base64 PNG (`image`) plus the host file path.
  - `windows[]` — hwnd/title/class/rect for each (no images when includeImages=false).
  - `editors`  — the plugin's logical editor inventory when a plugin is alive
    (kind/frameTitle per editor), else the reason it isn't.
  - `modalDialogs` + `running` + `activeVersion` — enough to decide the next move.

Read-only, non-gated, cheap to poll (images are the only cost — cap via maxWidth).
"""
import base64
import os
import sys

IS_WINDOWS = sys.platform == "win32"


def handle_state(kicad_info: dict, args: dict) -> dict:
    """Args (all optional):
      includeImages: bool (default true) — inline base64 PNGs in shots[]
      maxWidth: int (default 1100) — downscale cap for inline images
    """
    if not IS_WINDOWS:
        return {"success": False, "error": f"kicad_state not implemented on {sys.platform!r}"}

    from handlers.kicad_ui import _find_all_kicad_windows, _screenshot_hwnd

    include_images = args.get("includeImages", True)
    max_width = int(args.get("maxWidth") or 1100)

    windows = _find_all_kicad_windows()
    running = len(windows) > 0

    shots = []
    if include_images:
        for w in windows:
            entry = {"title": w.get("title"), "hwnd": w.get("hwnd"),
                     "className": w.get("className")}
            try:
                shot = _screenshot_hwnd(w["hwnd"], label="state")
                path = shot.get("fullPath") or shot.get("path")
                if shot.get("success") and path and path.lower().endswith(".png"):
                    if max_width:
                        try:
                            from PIL import Image
                            img = Image.open(path)
                            if img.width > max_width:
                                r = max_width / img.width
                                img = img.resize((max_width, int(img.height * r)))
                                img.save(path, "PNG", optimize=True)
                            entry["width"], entry["height"] = img.width, img.height
                        except Exception:
                            pass
                    with open(path, "rb") as f:
                        entry["image"] = base64.b64encode(f.read()).decode()
                    entry["path"] = path
                else:
                    entry["error"] = shot.get("error") or "capture failed (non-png fallback?)"
            except Exception as e:  # pylint: disable=broad-except
                entry["error"] = f"{type(e).__name__}: {e}"
            shots.append(entry)

    editors: dict = {}
    try:
        from handlers.bridge_client import handle_open_editors
        editors = handle_open_editors(kicad_info, {})
    except Exception as e:  # pylint: disable=broad-except
        editors = {"success": False, "error": str(e)}

    modal = []
    try:
        from handlers.close_windows import _win_scan_kicad_dialogs
        modal = [{"hwnd": d.get("hwnd"), "title": d.get("title"), "body": d.get("body")}
                 for d in _win_scan_kicad_dialogs(kicad_info) if d.get("isDialog")]
    except Exception:
        pass

    focus_events = []
    user_idle_s = None
    try:
        from handlers import win_focus
        focus_events = win_focus.get_focus_events()[-40:]
        user_idle_s = round(win_focus._seconds_since_user_input(), 1)
    except Exception:
        pass

    return {
        "success": True,
        "running": running,
        "focusEvents": focus_events,
        # PRESENCE GATE (John, 2026-08-17: a test suite foregrounding windows
        # while he worked "MASSIVELY DISRUPTED" him). Automation that spawns
        # windows MUST check this first and defer while the user is active:
        # < 600 means a human touched this machine in the last 10 minutes.
        "userIdleSeconds": user_idle_s,
        "focusDebug": (lambda: __import__("handlers.win_focus", fromlist=["x"]).get_focus_debug())() if True else None,
        "activeVersion": kicad_info.get("version"),
        "windows": [{k: w.get(k) for k in ("hwnd", "title", "className", "rect")}
                    for w in windows],
        "shots": shots,
        "editors": {
            "kicadRunning": editors.get("kicadRunning"),
            "list": editors.get("editors"),
            "summary": editors.get("summary"),
        },
        "modalDialogs": modal,
        "_hint": ("focusEvents is the focus sentinel log: a self-raise (bounced) entry is a PREVENTED foreground steal; user-foregrounded means the human chose it. "
                  "The full state of the union: every KiCad-owned window is in shots[] as inline "
                  "base64 PNG (render them all — one window is NOT the app). modalDialogs non-empty "
                  "means something is blocking; editors.list is the plugin's logical inventory "
                  "(null while the editor's Python is still binding, issue #21). Poll before/after "
                  "an action to confirm it landed. includeImages:false for a cheap logical poll."),
    }