master
John Lauer 0.9.339: Tour motion: zoom depth per beat (one step for a single part, two plus one for a sheet or board) b87c1d7 2mo ago
"""Motion for the KiCad tour (John, 2026-09-03: "for each step you need to add
motion like zooming in/out, panning, and on 3D you must go isometric and
rotate around the chip or board, cuz we're going to record a video").

ab exposes mouse move, click and keys, but no drag or wheel, so the bridge
sends the motion itself with SendInput (it runs on the desktop). Two rules:

1. Input only goes to OUR window while it is the foreground window. Every
   gesture re-checks GetForegroundWindow; the moment the user switches away,
   the choreography stops. Nothing ever lands in a window the user chose.
2. `abort()` is polled between gestures so a STOP from the remote ends the
   motion within one gesture.

Gestures use KiCad's own mouse grammar, identical in every editor:
    wheel               zoom at the cursor
    middle-button drag  pan (2D editors)
    left-button drag    orbit (3D viewer)
    Home                zoom to fit / reset view
"""

from __future__ import annotations

import ctypes
import ctypes.wintypes
import sys
import time

IS_WINDOWS = sys.platform == "win32"
if IS_WINDOWS:
    user32 = ctypes.windll.user32
else:  # pragma: no cover
    user32 = None

MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP = 0x0002, 0x0004
MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP = 0x0020, 0x0040
MOUSEEVENTF_WHEEL = 0x0800
KEYEVENTF_KEYUP = 0x0002
VK_HOME = 0x24
WHEEL_DELTA = 120


def _inputs():
    from handlers.kicad_ui import INPUT, INPUT_MOUSE, INPUT_KEYBOARD, _send_input
    return INPUT, INPUT_MOUSE, INPUT_KEYBOARD, _send_input


def _rect(hwnd: int):
    r = ctypes.wintypes.RECT()
    user32.GetWindowRect(int(hwnd), ctypes.byref(r))
    return r.left, r.top, r.right, r.bottom


def is_front(hwnd: int) -> bool:
    try:
        return int(user32.GetForegroundWindow()) == int(hwnd)
    except Exception:  # pylint: disable=broad-except
        return False


def _abs(x: int, y: int):
    """Screen pixel -> SendInput absolute (0..65535 over the virtual screen)."""
    SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN = 76, 77, 78, 79
    vx = user32.GetSystemMetrics(SM_XVIRTUALSCREEN); vy = user32.GetSystemMetrics(SM_YVIRTUALSCREEN)
    vw = user32.GetSystemMetrics(SM_CXVIRTUALSCREEN) or 1; vh = user32.GetSystemMetrics(SM_CYVIRTUALSCREEN) or 1
    return int((x - vx) * 65535 / vw), int((y - vy) * 65535 / vh)


def _mouse(flags: int, x: int | None = None, y: int | None = None, data: int = 0) -> None:
    INPUT, INPUT_MOUSE, _, send = _inputs()
    inp = INPUT(); inp.type = INPUT_MOUSE
    mi = inp.union.mi
    if x is not None and y is not None:
        ax, ay = _abs(x, y)
        mi.dx, mi.dy = ax, ay
        flags |= MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE
        # SendInput ABSOLUTE maps to the PRIMARY monitor unless VIRTUALDESK is set
        flags |= 0x4000  # MOUSEEVENTF_VIRTUALDESK
    mi.mouseData = ctypes.c_ulong(data & 0xFFFFFFFF).value
    mi.dwFlags = flags
    send(inp)


def move(x: int, y: int) -> None:
    _mouse(0, x, y)


def wheel(clicks: int, x: int, y: int) -> None:
    """clicks > 0 zooms in (wheel up), < 0 out."""
    move(x, y); time.sleep(0.05)
    for _ in range(abs(clicks)):
        _mouse(MOUSEEVENTF_WHEEL, data=(WHEEL_DELTA if clicks > 0 else -WHEEL_DELTA))
        time.sleep(0.16)


def drag(button: str, x0: int, y0: int, x1: int, y1: int, steps: int = 16, dt: float = 0.03) -> None:
    down, up = ((MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP) if button == "middle"
                else (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP))
    move(x0, y0); time.sleep(0.06)
    _mouse(down); time.sleep(0.05)
    for i in range(1, steps + 1):
        move(int(x0 + (x1 - x0) * i / steps), int(y0 + (y1 - y0) * i / steps))
        time.sleep(dt)
    _mouse(up); time.sleep(0.05)


def key(vk: int) -> None:
    INPUT, _, INPUT_KEYBOARD, send = _inputs()
    d = INPUT(); d.type = INPUT_KEYBOARD; d.union.ki.wVk = vk
    u = INPUT(); u.type = INPUT_KEYBOARD; u.union.ki.wVk = vk; u.union.ki.dwFlags = KEYEVENTF_KEYUP
    send(d, u)


def _canvas_center(hwnd: int):
    """The drawing canvas sits right of the editors' left panels: aim at 62%
    across and 55% down the window, which is canvas in every KiCad editor
    and in the 3D viewer (which has no side panel)."""
    l, t, r, b = _rect(hwnd)
    return int(l + (r - l) * 0.62), int(t + (b - t) * 0.55), (r - l), (b - t)


def _menu_ids(hwnd: int) -> dict:
    """The 3D viewer's View-menu command ids, resolved from the live menu bar
    (labels are stable across KiCad 8/9/10; ids are not). Empty when the walk
    fails (then the caller falls back to mouse drags)."""
    try:
        from handlers import win_menu
    except Exception:  # pylint: disable=broad-except
        return {}
    ids = {}
    for key, words in {"fit": ("zoom", "fit"), "zoom_in": ("zoom", "in"), "zoom_out": ("zoom", "out"),
                       "rotx_cw": ("rotate x", "clockwise"), "rotx_ccw": ("rotate x", "counter"),
                       "rotz_cw": ("rotate z", "clockwise"), "rotz_ccw": ("rotate z", "counter")}.items():
        try:
            cid, _label = win_menu.find_menu_command(hwnd, *words)
            if cid:
                ids[key] = int(cid)
        except Exception:  # pylint: disable=broad-except
            pass
    # "zoom in" also matches "Zoom In" only if "counter" did not swallow "clockwise" ids: guard the pairs
    if ids.get("rotx_cw") and ids.get("rotx_cw") == ids.get("rotx_ccw"):
        ids.pop("rotx_ccw", None)
    if ids.get("rotz_cw") and ids.get("rotz_cw") == ids.get("rotz_ccw"):
        ids.pop("rotz_ccw", None)
    return ids


def choreograph_3d_menu(hwnd: int, abort=None, log=None, measure=None) -> dict | None:
    """Deterministic 3D motion through KiCad's own View menu (WM_COMMAND, 10
    degrees per rotate, no mouse, no foreground needed): mouse drags over-rotated
    the chip viewer and did nothing in the board viewer on 2026-09-03 because
    drag sensitivity and the window under the cursor are not ours to control.
    Returns None when the menu could not be resolved (caller falls back)."""
    from handlers import win_menu
    ids = _menu_ids(hwnd)
    if not all(k in ids for k in ("rotx_cw", "rotz_cw", "zoom_in", "zoom_out")):
        return None
    rep = {"kind": "3d", "method": "menu", "gestures": 0, "aborted": None, "ids": ids}
    plan = ([("extents", "fit", 1, 0.6)] if "fit" in ids else []) + [
        ("tilt", "rotx_cw", 3, 0.28),          # ~30 degrees toward isometric
        ("turn", "rotz_cw", 4, 0.28),          # ~40 degrees of yaw
        ("zoom-in", "zoom_in", 1, 0.5),        # one measured step, never a close-up
        ("orbit", "rotz_cw", 6, 0.32),         # a slow 60 degree orbit
        ("zoom-out", "zoom_out", 1, 0.5),
        ("orbit-back", "rotz_ccw" if "rotz_ccw" in ids else "rotz_cw", 4, 0.32),
    ]   # NO trailing Zoom to Fit: in the 3D viewer it also RESETS the orientation, so the
        # beat ended top-down on John's laptop (2026-09-03). Zoom in/out net to zero, so the
        # board is still framed at the opening fit, now isometric.
    for name, key, reps, dt in plan:
        for _ in range(reps):
            if abort and abort():
                rep["aborted"] = f"stop requested during {name}"
                return rep
            if not win_menu.invoke_menu_command(hwnd, ids[key]):
                rep["aborted"] = f"menu command refused during {name}"
                return rep
            rep["gestures"] += 1
            time.sleep(dt)
        if log:
            log(f"motion 3d(menu): {name}")
        time.sleep(0.4)
        # Framing check (John, 2026-09-05): after every step look at the canvas.
        # Board clipped on two opposite edges -> one zoom-out; board tiny -> one
        # zoom-in. Recorded so the run says what it corrected.
        if measure and name not in ("extents",):
            try:
                m = measure() or {}
                t = set(m.get("touches") or [])
                if ({"left", "right"} <= t or {"top", "bottom"} <= t) and "zoom_out" in ids:
                    win_menu.invoke_menu_command(hwnd, ids["zoom_out"]); time.sleep(0.5)
                    rep.setdefault("framed", []).append(f"{name}: clipped {sorted(t)} -> zoom out")
                elif (m.get("coverage") or 0) < 0.10 and m.get("bbox") and "zoom_in" in ids:
                    win_menu.invoke_menu_command(hwnd, ids["zoom_in"]); time.sleep(0.5)
                    rep.setdefault("framed", []).append(f"{name}: coverage {m.get('coverage')} -> zoom in")
                rep["lastCoverage"] = m.get("coverage")
            except Exception:  # pylint: disable=broad-except
                pass
    return rep


def choreograph(hwnd: int, kind: str, abort=None, log=None, check=None, measure=None, focus=None, zoom=(2, 1)) -> dict:
    """Run the beat's motion on `hwnd`. kind: '2d' | '3d'. Returns a report;
    never raises. Stops the instant the window is not in front or abort()."""
    rep = {"kind": kind, "gestures": 0, "aborted": None}
    if not IS_WINDOWS or not hwnd:
        rep["aborted"] = "not windows / no hwnd"
        return rep

    def gate(name: str) -> bool:
        if abort and abort():
            rep["aborted"] = f"stop requested before {name}"; return False
        # GetForegroundWindow can lag AD's z-order by a beat right after a raise
        # (John's laptop, 2026-09-03: the symbol beat aborted at its FIRST
        # gesture while AD had already measured the window on top). Give the
        # OS up to 1.5 s to agree before calling it a user switch.
        for _ in range(6):
            if is_front(hwnd):
                return True
            time.sleep(0.25)
        rep["aborted"] = f"window not in front before {name} (user switched away)"; return False

    if kind == "3d":
        try:
            time.sleep(1.2)                    # let the STEP bodies load before the first turn
            menu_rep = choreograph_3d_menu(hwnd, abort=abort, log=log, measure=measure)
            if menu_rep is not None:
                return menu_rep
        except Exception as e:  # pylint: disable=broad-except
            rep["menuError"] = f"{type(e).__name__}: {e}"
    try:
        cx, cy, w, h = _canvas_center(hwnd)
        dx, dy = int(w * 0.10), int(h * 0.08)
        if kind == "3d":
            # Measured 2026-09-03 on a 3840 px wide viewer: four drags totalling
            # ~0.37 of the width turned the board nearly edge-on (KiCad's trackball
            # is roughly 0.1 degree per pixel). Isometric wants ~30 degrees of
            # tilt and ~40 of yaw, then gentle orbits that keep the board readable.
            tilt = int(h * 0.12)
            yaw = int(w * 0.03)
            plan = [
                ("fit", lambda: key(VK_HOME)),
                ("isometric", lambda: drag("left", cx, cy, cx - int(w * 0.035), cy - tilt, steps=28, dt=0.035)),
                ("orbit-1", lambda: drag("left", cx, cy, cx + yaw, cy, steps=30, dt=0.04)),
                ("zoom-in", lambda: wheel(2, cx, cy)),
                ("orbit-2", lambda: drag("left", cx, cy, cx + yaw, cy, steps=30, dt=0.04)),
                ("zoom-out", lambda: wheel(-2, cx, cy)),
                ("orbit-3", lambda: drag("left", cx, cy, cx - yaw, cy, steps=30, dt=0.04)),
            ]
        else:
            # John, 2026-09-03: "your zooming is too close ... zoom extents, then be
            # smart about how close". Extents first, two wheel clicks in (about
            # 1.7x), a short pan, back out, and END on extents so the frame the
            # user is left with is the whole thing.
            # John, 2026-09-05: "be smart about your zooming/panning cuz you still
            # don't end up showing important stuff". Aim at the featured part: after
            # extents, measure where the drawing sits on the canvas and map the
            # part's fraction (from the design files) onto it; wheel-zoom AT that
            # point (KiCad zooms about the cursor), pan it to the centre, hold,
            # zoom back out, extents.
            aim = {"x": cx, "y": cy}
            def _aim():
                key(VK_HOME); time.sleep(0.7)
                if measure and focus:
                    try:
                        m = measure() or {}
                        bb = m.get("bbox")
                        if bb:
                            aim["x"] = int(bb[0] + float(focus[0]) * (bb[2] - bb[0]))
                            aim["y"] = int(bb[1] + float(focus[1]) * (bb[3] - bb[1]))
                            rep["aim"] = {"x": aim["x"], "y": aim["y"], "bbox": bb, "coverage": m.get("coverage")}
                    except Exception:  # pylint: disable=broad-except
                        pass
            # zoom depth per beat (John, 2026-09-05: the footprint take opened on a
            # giant REFERENCE label). A single part fills the canvas at extents, so
            # it gets one step; a whole sheet or board gets two plus one after centring.
            z1, z2 = (int(zoom[0]), int(zoom[1])) if zoom else (2, 1)
            plan = [
                ("extents", _aim),
                ("zoom-in", lambda: wheel(z1, aim["x"], aim["y"])),
                ("centre", lambda: drag("middle", aim["x"], aim["y"], cx, cy, steps=18)),
            ] + ([("zoom-in-2", lambda: wheel(z2, cx, cy))] if z2 else []) + [
                ("pan-look", lambda: drag("middle", cx, cy, cx - dx, cy - dy, steps=14)),
                ("pan-back", lambda: drag("middle", cx - dx, cy - dy, cx, cy, steps=14)),
                ("zoom-out", lambda: wheel(-(z1 + z2), cx, cy)),
                ("extents-2", lambda: key(VK_HOME)),
            ]
        for name, fn in plan:
            if not gate(name):
                break
            fn()
            rep["gestures"] += 1
            if log:
                log(f"motion {kind}: {name}")
            time.sleep(0.45)
            # John, 2026-09-05: "don't be dumb about your zooming or panning such
            # that content goes off the screen ... analyze your motion". After
            # every 2D gesture that can empty the view, look at the canvas; a
            # blank one means we panned off the content: recenter on extents.
            if check and kind != "3d" and name.startswith(("pan", "zoom-in", "centre")):
                try:
                    if not check():
                        key(VK_HOME); time.sleep(0.45)
                        rep.setdefault("recentered", []).append(name)
                except Exception:  # pylint: disable=broad-except
                    pass
    except Exception as e:  # pylint: disable=broad-except
        rep["error"] = f"{type(e).__name__}: {e}"
    return rep