#!/usr/bin/env python3
"""A mic button that follows each Steam Frame floating window.

The button is our own OpenVR overlay, parked just under the window's
bottom-right corner, which is where the keyboard button sits on the
control bar. Hold the trigger on it to dictate. Release types into
that window. Every mic shows a circle while recording and a spinner
while the transcript is typed, including a take started with Select.

Needs overlay_point.so next to this file or in ~/.local/lib/whisper-frame.
"""

import base64
import ctypes
import json
import math
import os
import socket
import struct
import threading
import time
from pathlib import Path

HOME = Path.home()
LIB = HOME / ".local/lib/whisper-frame"
OPENVR = "/opt/steamvr/bin/linuxarm64/libopenvr_api.so"
OVERLAY_VERSION = b"FnTable:IVROverlay_028"
APP_OVERLAY = 2
STANDING = 1
MOUSE_INPUT = 1
EVENT_SIZE = 60
MOUSE_MOVE = 300
MOUSE_DOWN = 301
MOUSE_UP = 302
FOCUS_ENTER = 303
FOCUS_LEAVE = 304
# A press shorter than this toggles recording and leaves it running.
# A longer press is hold-to-talk: release types the transcript.
CLICK_S = 0.30
LEFT_BUTTON = 1
VISIBLE_IN_DASHBOARD = 1 << 15
SORT_WITH_NON_SCENE = 1 << 14
# Overlay width. The texture is 5:3, so the plate is 5 cm by 3 cm.
BUTTON_METERS = 0.05
# Resize eases the corner, and a drag eases the window center. Both are
# short so the mic stays with the glass while a stepped scene-graph
# sample does not jump it.
SMOOTH_S = 0.01
DRAG_S = 0.01
# Center of the button. Two centimeters left of a flush right edge, and
# this far down from the window's bottom edge.
RIGHT_INSET = BUTTON_METERS / 2 + 0.02
BELOW = 0.03
# Meters toward the viewer along the anchor Z axis. That axis points at
# the viewer; an earlier minus sign pushed the button into the glass.
TOWARD_VIEWER = 0.005
SCAN_MAX = 4096
SCENE_HOST = ("127.0.0.1", 27062)

FN = {
    "find": 0,
    "create": 1,
    "destroy": 3,
    "set_flag": 11,
    "set_color": 14,
    "set_alpha": 16,
    "set_sort": 20,
    "set_width": 22,
    "get_width": 23,
    "set_absolute": 33,
    "show": 43,
    "hide": 44,
    "visible": 45,
    "coords": 46,
    "wait_frame": 47,
    "poll": 48,
    "set_input": 50,
    "get_mouse_scale": 51,
    "set_mouse_scale": 52,
    "set_raw": 62,
}


def ease(current, target, dt, seconds):
    """Exponential approach. A jump past 0.35 m snaps to the target."""
    gap = math.sqrt(sum((target[i] - current[i]) ** 2 for i in range(3)))
    if gap > 0.35:
        return target
    alpha = 1.0 - math.exp(-dt / seconds) if dt and seconds else 1.0
    return tuple(current[i] + (target[i] - current[i]) * alpha for i in range(3))


def log(msg):
    print(f"{time.strftime('%H:%M:%S')} {msg}", flush=True)


def steamvr_ipc():
    """Names of the live SteamVR IPC files. A new name means the runtime restarted."""
    names = []
    try:
        for path in Path("/tmp").iterdir():
            if path.name.startswith("SteamVR-IPCControlFile-"):
                names.append(path.name)
    except OSError:
        return ()
    return tuple(sorted(names))


def control_path():
    runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
    return Path(runtime) / "whisper-ptt.sock"


class WindowScale:
    """Live resize factor for each gamescope window.

    The overlay quad OpenVR reports does not change when the user resizes
    the window. The dashboard scene graph does: the panel scale grows from
    the 0.369 basis. Dividing scale x/y by scale z is the factor on that quad.
    """

    def __init__(self):
        self._lock = threading.Lock()
        self._wanted = set()
        self._factor = {}
        self._pose = {}
        self._center = {}
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def watch(self, seqs):
        with self._lock:
            self._wanted = set(seqs)

    def factor(self, seq):
        with self._lock:
            return self._factor.get(seq, (1.0, 1.0))

    def pose(self, seq):
        """Bottom-right corner of the window, already bent by curvature."""
        with self._lock:
            return self._pose.get(seq)

    def center(self, seq):
        with self._lock:
            return self._center.get(seq)

    def _run(self):
        while True:
            try:
                self._session()
            except Exception as exc:
                log(f"window scale paused ({exc})")
                time.sleep(1)

    def _session(self):
        sock = socket.create_connection(SCENE_HOST, 3)
        try:
            key = base64.b64encode(os.urandom(16)).decode()
            sock.sendall(
                b"GET / HTTP/1.1\r\nHost: 127.0.0.1:27062\r\n"
                b"Upgrade: websocket\r\nConnection: Upgrade\r\n"
                b"Origin: http://127.0.0.1:27062\r\n"
                b"Sec-WebSocket-Key: " + key.encode() + b"\r\n"
                b"Sec-WebSocket-Version: 13\r\n\r\n"
            )
            buf = b""
            while b"\r\n\r\n" not in buf:
                chunk = sock.recv(4096)
                if not chunk:
                    raise ConnectionError("scene graph closed during handshake")
                buf += chunk
            if b" 101 " not in buf.split(b"\r\n", 1)[0]:
                raise ConnectionError("scene graph refused the socket")
            name = f"sgqueryservice/whisper-panel-{os.getpid()}"
            self._send(sock, f"mailbox_open {name}")
            sock.settimeout(0.02)
            self._wsbuf = buf.split(b"\r\n\r\n", 1)[1]
            message_id = 1
            while True:
                started = time.monotonic()
                with self._lock:
                    wanted = list(self._wanted)
                for seq in wanted:
                    base = f"system.standalone::gamescope.gamescope-0.window.{seq}"
                    # The panel scale tracks resize. The bottom-right anchor is
                    # the same corner after the dashboard bends the window.
                    for suffix in ("_Panel", "_BottomRight"):
                        body = {
                            "type": "transform_request",
                            "id": base + suffix,
                            "flPushDistance": 0,
                            "timeoutSec": 0.05,
                            "returnAddress": name,
                            "message_id": message_id,
                        }
                        self._send(sock, "mailbox_send vrcompositor_systemlayer " + json.dumps(body))
                        message_id += 1
                got = 0
                deadline = started + 0.04
                while got < len(wanted) * 2 and time.monotonic() < deadline:
                    frame = self._frame(sock)
                    if frame is None:
                        break
                    if self._take(frame):
                        got += 1
                if not wanted:
                    time.sleep(0.2)
                    continue
                # About 60 samples a second. Faster than that just queues mailbox traffic.
                remain = 0.016 - (time.monotonic() - started)
                if remain > 0:
                    time.sleep(remain)
        finally:
            sock.close()

    def _take(self, frame):
        try:
            data = json.loads(frame)
        except json.JSONDecodeError:
            return False
        if data.get("type") != "transform_response":
            return False
        if data.get("error"):
            return True
        ident = str(data.get("id") or "")
        seq = None
        marker = "gamescope.gamescope-0.window."
        if marker in ident:
            try:
                seq = int(ident.split(marker, 1)[1].split("_", 1)[0])
            except ValueError:
                seq = None
        if seq is None:
            return True
        transform = data.get("transform") or {}
        if ident.endswith("_BottomRight"):
            translation = transform.get("translation") or {}
            rotation = transform.get("rotation") or {}
            fields = (
                translation.get("x"), translation.get("y"), translation.get("z"),
                rotation.get("x"), rotation.get("y"), rotation.get("z"), rotation.get("w"),
            )
            if any(value is None for value in fields):
                return True
            with self._lock:
                self._pose[seq] = fields
            return True
        if not ident.endswith("_Panel"):
            return True
        translation = transform.get("translation") or {}
        cx, cy, cz = translation.get("x"), translation.get("y"), translation.get("z")
        if cx is not None and cy is not None and cz is not None:
            with self._lock:
                self._center[seq] = (cx, cy, cz)
        scale = transform.get("scale") or {}
        sx, sy, sz = scale.get("x"), scale.get("y"), scale.get("z")
        if not sz or sz < 0.05 or not sx or not sy:
            return True
        factor = (sx / sz, sy / sz)
        with self._lock:
            previous = self._factor.get(seq)
            self._factor[seq] = factor
        if previous is None or abs(previous[0] - factor[0]) > 0.02 or abs(previous[1] - factor[1]) > 0.02:
            log(f"window {seq} resize scale {factor[0]:.2f} x {factor[1]:.2f}")
        return True

    def _send(self, sock, text):
        payload = text.encode()
        mask = os.urandom(4)
        header = bytearray([0x81])
        n = len(payload)
        if n < 126:
            header.append(0x80 | n)
        elif n < 65536:
            header.append(0x80 | 126)
            header += n.to_bytes(2, "big")
        else:
            header.append(0x80 | 127)
            header += n.to_bytes(8, "big")
        masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
        sock.sendall(bytes(header) + mask + masked)

    def _read(self, sock, n):
        while len(self._wsbuf) < n:
            try:
                chunk = sock.recv(4096)
            except socket.timeout:
                return None
            if not chunk:
                raise ConnectionError("scene graph closed")
            self._wsbuf += chunk
        out = self._wsbuf[:n]
        self._wsbuf = self._wsbuf[n:]
        return out

    def _frame(self, sock):
        while True:
            hdr = self._read(sock, 2)
            if hdr is None:
                return None
            opcode = hdr[0] & 0x0F
            ln = hdr[1] & 0x7F
            if ln == 126:
                raw = self._read(sock, 2)
                if raw is None:
                    raise ConnectionError("short frame")
                ln = int.from_bytes(raw, "big")
            elif ln == 127:
                raw = self._read(sock, 8)
                if raw is None:
                    raise ConnectionError("short frame")
                ln = int.from_bytes(raw, "big")
            mask = self._read(sock, 4) if hdr[1] & 0x80 else None
            payload = self._read(sock, ln)
            if payload is None:
                raise ConnectionError("short frame")
            if mask:
                payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
            if opcode == 1:
                return payload.decode()
            if opcode == 8:
                raise ConnectionError("scene graph closed")


class Vec2(ctypes.Structure):
    _fields_ = [("x", ctypes.c_float), ("y", ctypes.c_float)]


class Mat(ctypes.Structure):
    _fields_ = [("m", ctypes.c_float * 12)]


def load_openvr():
    lib = ctypes.CDLL(OPENVR)
    err = ctypes.c_int(0)
    lib.VR_InitInternal.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int]
    lib.VR_InitInternal.restype = ctypes.c_uint32
    lib.VR_InitInternal(ctypes.byref(err), APP_OVERLAY)
    if err.value != 0:
        raise SystemExit(f"OpenVR init failed ({err.value})")
    lib.VR_ShutdownInternal.argtypes = []
    lib.VR_GetGenericInterface.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)]
    lib.VR_GetGenericInterface.restype = ctypes.c_void_p
    err = ctypes.c_int(0)
    table = lib.VR_GetGenericInterface(OVERLAY_VERSION, ctypes.byref(err))
    if err.value != 0 or not table:
        lib.VR_ShutdownInternal()
        raise SystemExit(f"IVROverlay_028 unavailable ({err.value})")
    fns = ctypes.cast(table, ctypes.POINTER(ctypes.c_void_p))
    glue_path = LIB / "overlay_point.so"
    if not glue_path.exists():
        glue_path = Path(__file__).resolve().parent / "overlay_point.so"
    glue = ctypes.CDLL(str(glue_path))
    glue.whisper_overlay_point.argtypes = [
        ctypes.c_void_p, ctypes.c_uint64, ctypes.c_int,
        ctypes.c_float, ctypes.c_float, ctypes.POINTER(Mat),
    ]
    glue.whisper_overlay_point.restype = ctypes.c_int
    return lib, fns, glue


def fn(fns, name, restype, *argtypes):
    call = ctypes.CFUNCTYPE(restype, *argtypes)(fns[FN[name]])
    return call


def quat_basis(qx, qy, qz, qw):
    """Local axes of a SteamVR quaternion, as (right, up, z).

    On a window anchor, z points toward the viewer.
    """
    right = (
        1 - 2 * (qy * qy + qz * qz),
        2 * (qx * qy + qz * qw),
        2 * (qx * qz - qy * qw),
    )
    up = (
        2 * (qx * qy - qz * qw),
        1 - 2 * (qx * qx + qz * qz),
        2 * (qy * qz + qx * qw),
    )
    away = (
        2 * (qx * qz + qy * qw),
        2 * (qy * qz - qx * qw),
        1 - 2 * (qx * qx + qy * qy),
    )
    return right, up, away


def _coverage(dist):
    if dist <= -1:
        return 1.0
    if dist >= 1:
        return 0.0
    return max(0.0, min(1.0, 0.5 - dist))


def _masks():
    """Plate and ink coverage for the mic texture."""
    w, h = 150, 90
    radius = 20
    plate = bytearray(w * h)
    ink = bytearray(w * h)

    def rounded_rect(px, py, left, top, right, bottom, rad):
        dx = abs(px - (left + right) / 2) - ((right - left) / 2 - rad)
        dy = abs(py - (top + bottom) / 2) - ((bottom - top) / 2 - rad)
        outside = math.hypot(max(dx, 0), max(dy, 0))
        return outside + min(max(dx, dy), 0) - rad

    def stadium(px, py, cx, top, bottom, rad):
        clamped = min(max(py, top), bottom)
        return math.hypot(px - cx, clamped - py) - rad

    for y in range(h):
        for x in range(w):
            px = x + 0.5
            py = y + 0.5
            cover = _coverage(rounded_rect(px, py, 1, 1, w - 1, h - 1, radius))
            head = _coverage(stadium(px, py, 75, 25, 47, 8))
            stem = _coverage(rounded_rect(px, py, 72.5, 47, 77.5, 59, 1.5))
            foot = _coverage(rounded_rect(px, py, 61, 56, 89, 63, 3.5))
            stand = _coverage(rounded_rect(px, py, 71, 63, 79, 71, 2))
            mark = max(head, stem, foot, stand)
            i = y * w + x
            plate[i] = round(cover * 255)
            ink[i] = round(mark * 255)
    return w, h, plate, ink


def _circle_ink(w, h, radius):
    """Filled circle, used as the recording mark."""
    ink = bytearray(w * h)
    cx = w / 2
    cy = h / 2
    for y in range(h):
        for x in range(w):
            dist = math.hypot(x + 0.5 - cx, y + 0.5 - cy) - radius
            ink[y * w + x] = round(_coverage(dist) * 255)
    return ink


def _spinner_inks(w, h, frames=12, dots=8):
    """A fading tail of dots. Each frame steps the tail forward."""
    cx = w / 2
    cy = h / 2
    orbit = 18
    dot_r = 3.4
    out = []
    for step in range(frames):
        ink = bytearray(w * h)
        for i in range(dots):
            weight = (dots - i) / dots
            ang = -math.pi / 2 + (step - i) * (math.tau / frames)
            dx = cx + math.cos(ang) * orbit
            dy = cy + math.sin(ang) * orbit
            x0 = max(0, int(dx - dot_r - 2))
            x1 = min(w, int(dx + dot_r + 3))
            y0 = max(0, int(dy - dot_r - 2))
            y1 = min(h, int(dy + dot_r + 3))
            for y in range(y0, y1):
                for x in range(x0, x1):
                    dist = math.hypot(x + 0.5 - dx, y + 0.5 - dy) - dot_r
                    cover = _coverage(dist) * weight
                    if cover <= 0:
                        continue
                    index = y * w + x
                    ink[index] = max(ink[index], round(cover * 255))
        out.append(ink)
    return out


def _compose(plate, ink, color):
    w = 150
    buf = bytearray(len(plate) * 4)
    cr, cg, cb, ca = color
    for i, (cover, mark) in enumerate(zip(plate, ink)):
        if cover == 0 and mark == 0:
            continue
        o = i * 4
        src_a = ca / 255 * cover / 255
        buf[o] = cr
        buf[o + 1] = cg
        buf[o + 2] = cb
        buf[o + 3] = round(src_a * 255)
        if mark == 0:
            continue
        ink_a = mark / 255
        out_a = ink_a + src_a * (1 - ink_a)
        if out_a <= 0:
            continue
        for channel in range(3):
            src = 255 * ink_a
            dst = buf[o + channel] * src_a * (1 - ink_a)
            buf[o + channel] = max(0, min(255, round((src + dst) / out_a)))
        buf[o + 3] = round(out_a * 255)
    return bytes(buf)


# Plate alpha sits a little under the old solid fill.
IDLE_BG = (50, 56, 64, 210)
HOVER_BG = (84, 94, 108, 220)
HOT_BG = (176, 48, 52, 214)


class Button:
    def __init__(self, seq, handle):
        self.seq = seq
        self.handle = handle
        self.hovered = False
        self.face = "idle"
        self.offset = None
        self.origin = None
        self.smooth_at = None
        self.parent = 0
        self.placed = None


class Panel:
    def __init__(self):
        self.vr, self.fns, self.glue = load_openvr()
        f = self.fns
        u64 = ctypes.c_uint64
        self.find = fn(f, "find", ctypes.c_int, ctypes.c_char_p, ctypes.POINTER(u64))
        self.create = fn(f, "create", ctypes.c_int, ctypes.c_char_p, ctypes.c_char_p, ctypes.POINTER(u64))
        self.destroy = fn(f, "destroy", ctypes.c_int, u64)
        self.set_flag = fn(f, "set_flag", ctypes.c_int, u64, ctypes.c_int, ctypes.c_bool)
        self.set_color = fn(f, "set_color", ctypes.c_int, u64, ctypes.c_float, ctypes.c_float, ctypes.c_float)
        self.set_alpha = fn(f, "set_alpha", ctypes.c_int, u64, ctypes.c_float)
        self.set_sort = fn(f, "set_sort", ctypes.c_int, u64, ctypes.c_uint32)
        self.set_width = fn(f, "set_width", ctypes.c_int, u64, ctypes.c_float)
        self.get_width = fn(f, "get_width", ctypes.c_int, u64, ctypes.POINTER(ctypes.c_float))
        self.set_absolute = fn(f, "set_absolute", ctypes.c_int, u64, ctypes.c_int, ctypes.POINTER(Mat))
        self.show = fn(f, "show", ctypes.c_int, u64)
        self.hide = fn(f, "hide", ctypes.c_int, u64)
        self.visible = fn(f, "visible", ctypes.c_bool, u64)
        self.wait_frame = fn(f, "wait_frame", ctypes.c_int, ctypes.c_uint32)
        self.poll = fn(f, "poll", ctypes.c_bool, u64, ctypes.c_void_p, ctypes.c_uint32)
        self.set_input = fn(f, "set_input", ctypes.c_int, u64, ctypes.c_int)
        self.set_mouse_scale = fn(f, "set_mouse_scale", ctypes.c_int, u64, ctypes.POINTER(Vec2))
        self.set_raw = fn(f, "set_raw", ctypes.c_int, u64, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32)
        self.tex_w, self.tex_h, plate, ink = _masks()
        self.idle_px = _compose(plate, ink, IDLE_BG)
        self.hover_px = _compose(plate, ink, HOVER_BG)
        circle = _circle_ink(self.tex_w, self.tex_h, 16)
        self.record_px = _compose(plate, circle, HOT_BG)
        spins = _spinner_inks(self.tex_w, self.tex_h)
        self.spin_px = [_compose(plate, frame, IDLE_BG) for frame in spins]
        self.spin_hover_px = [_compose(plate, frame, HOVER_BG) for frame in spins]
        # idle, record, or busy. The daemon publishes this for every take,
        # including the headset Select button.
        self.phase = "idle"
        self.phase_at = time.monotonic()
        self.pending_reply = False
        self.reply = None
        self.buttons = {}
        self.scales = WindowScale()
        self.holding = None
        self.latched = False
        self.pressed_at = 0.0
        self.sock = None
        self.buf = b""
        self.event = ctypes.create_string_buffer(EVENT_SIZE)
        self.odd_event = False
        self.get_mouse_scale = fn(
            f, "get_mouse_scale", ctypes.c_int, u64, ctypes.POINTER(Vec2),
        )

    def shutdown(self):
        for button in list(self.buttons.values()):
            self.destroy(button.handle)
        self.buttons.clear()
        if self.sock is not None:
            self.sock.close()
            self.sock = None
        self.vr.VR_ShutdownInternal()

    def find_key(self, key):
        handle = ctypes.c_uint64(0)
        rc = self.find(key.encode(), ctypes.byref(handle))
        if rc != 0:
            return 0
        return handle.value

    def scan_windows(self):
        found = []
        for seq in range(1, SCAN_MAX + 1):
            handle = self.find_key(f"gamescope.gamescope-0.window.{seq}")
            if handle and self.visible(handle):
                found.append((seq, handle))
        return found

    def ensure_button(self, seq):
        button = self.buttons.get(seq)
        if button is not None:
            return button
        handle = ctypes.c_uint64(0)
        key = f"whisper-frame.mic.{seq}"
        rc = self.create(key.encode(), b"Dictate", ctypes.byref(handle))
        if rc == 17:
            existing = self.find_key(key)
            if not existing:
                log(f"mic overlay {key} in use and not found ({rc})")
                return None
            handle = ctypes.c_uint64(existing)
        elif rc != 0:
            log(f"create mic overlay {key} failed ({rc})")
            return None
        h = handle.value
        self.set_width(h, BUTTON_METERS)
        self.set_alpha(h, 0.88)
        self.set_sort(h, 80)
        self.set_input(h, MOUSE_INPUT)
        scale = Vec2(self.tex_w, self.tex_h)
        self.set_mouse_scale(h, ctypes.byref(scale))
        self.set_flag(h, VISIBLE_IN_DASHBOARD, True)
        self.set_flag(h, SORT_WITH_NON_SCENE, True)
        self.paint(h, self.idle_px)
        self.show(h)
        button = Button(seq, h)
        self.buttons[seq] = button
        log(f"mic on gamescope window {seq}")
        return button

    def paint(self, handle, pixels):
        raw = ctypes.create_string_buffer(pixels)
        self.set_raw(handle, raw, self.tex_w, self.tex_h, 4)

    def refresh(self, button):
        if self.phase == "record":
            face, pixels = "record", self.record_px
        elif self.phase == "busy":
            frame = int((time.monotonic() - self.phase_at) / 0.08) % len(self.spin_px)
            if button.hovered:
                face = f"busy-h-{frame}"
                pixels = self.spin_hover_px[frame]
            else:
                face = f"busy-{frame}"
                pixels = self.spin_px[frame]
        elif button.hovered:
            face, pixels = "hover", self.hover_px
        else:
            face, pixels = "idle", self.idle_px
        if button.face == face:
            return
        button.face = face
        self.paint(button.handle, pixels)

    def point(self, parent, x, y):
        mat = Mat()
        rc = self.glue.whisper_overlay_point(
            self.fns[FN["coords"]], parent, STANDING, x, y, ctypes.byref(mat),
        )
        if rc != 0:
            return None
        return mat

    def place(self, button, parent, now):
        pose = self.scales.pose(button.seq)
        if pose is not None:
            return self.place_on_corner(button, pose, now)
        mouse = Vec2()
        if self.get_mouse_scale(parent, ctypes.byref(mouse)) != 0 or mouse.x < 2:
            return False
        lower_left = self.point(parent, 0, 0)
        lower_right = self.point(parent, mouse.x, 0)
        upper_left = self.point(parent, 0, mouse.y)
        if lower_left is None or lower_right is None or upper_left is None:
            return False

        def translation(spot):
            return (spot.m[3], spot.m[7], spot.m[11])

        left = translation(lower_left)
        right_edge = translation(lower_right)
        top = translation(upper_left)
        right_vec = [right_edge[i] - left[i] for i in range(3)]
        up_vec = [top[i] - left[i] for i in range(3)]
        width = math.sqrt(sum(c * c for c in right_vec))
        height = math.sqrt(sum(c * c for c in up_vec))
        if width < 0.05 or height < 0.05:
            return False
        right_u = [c / width for c in right_vec]
        up_u = [c / height for c in up_vec]
        # Right × up is the overlay Z axis. On these windows it points toward the viewer.
        facing = (
            right_u[1] * up_u[2] - right_u[2] * up_u[1],
            right_u[2] * up_u[0] - right_u[0] * up_u[2],
            right_u[0] * up_u[1] - right_u[1] * up_u[0],
        )
        fx, fy = self.scales.factor(button.seq)
        inset = min(RIGHT_INSET, width * fx * 0.35)
        center = [left[i] + right_vec[i] / 2 + up_vec[i] / 2 for i in range(3)]
        spot = lower_right
        target = [
            center[row]
            + right_u[row] * (width * fx / 2 - inset)
            - up_u[row] * (height * fy / 2 + BELOW)
            + facing[row] * TOWARD_VIEWER
            for row in range(3)
        ]
        shown = self.smooth_point(button, center, target, now)
        for row in range(3):
            spot.m[3 + 4 * row] = shown[row]
            spot.m[row * 4 + 0] = right_u[row]
            spot.m[row * 4 + 1] = up_u[row]
            spot.m[row * 4 + 2] = facing[row]
        signature = (round(width * fx, 3), round(height * fy, 3))
        if button.placed != signature:
            log(
                f"window {button.seq} {width * fx:.2f}x{height * fy:.2f}m, "
                f"mic {inset:.2f}m in from the right edge"
            )
            button.placed = signature
        rc = self.set_absolute(button.handle, STANDING, ctypes.byref(spot))
        return rc == 0

    def smooth_point(self, button, center, target, now):
        """Ease the corner offset and the window center.

        Resize changes the offset. A drag moves the center. A gap past
        0.35 m snaps, so a window that teleports does not crawl.
        """
        if center is None:
            return target
        center = tuple(center)
        offset = tuple(target[i] - center[i] for i in range(3))
        if button.offset is None or button.origin is None or button.smooth_at is None:
            button.offset = offset
            button.origin = center
            button.smooth_at = now
            return target
        dt = min(0.05, max(0.0, now - button.smooth_at))
        button.smooth_at = now
        button.offset = ease(button.offset, offset, dt, SMOOTH_S)
        button.origin = ease(button.origin, center, dt, DRAG_S)
        return tuple(button.origin[i] + button.offset[i] for i in range(3))

    def place_on_corner(self, button, pose, now):
        tx, ty, tz, qx, qy, qz, qw = pose
        right, up, facing = quat_basis(qx, qy, qz, qw)
        target = tuple(
            (tx, ty, tz)[row]
            - right[row] * RIGHT_INSET
            - up[row] * BELOW
            + facing[row] * TOWARD_VIEWER
            for row in range(3)
        )
        shown = self.smooth_point(button, self.scales.center(button.seq), target, now)
        spot = Mat()
        for row in range(3):
            spot.m[row * 4 + 0] = right[row]
            spot.m[row * 4 + 1] = up[row]
            spot.m[row * 4 + 2] = facing[row]
            spot.m[3 + 4 * row] = shown[row]
        if button.placed != "corner":
            log(f"window {button.seq} mic on the window corner")
            button.placed = "corner"
        rc = self.set_absolute(button.handle, STANDING, ctypes.byref(spot))
        return rc == 0

    def connect(self):
        if self.sock is not None:
            return True
        path = control_path()
        if not path.exists():
            return False
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        try:
            sock.connect(str(path))
        except OSError:
            sock.close()
            return False
        sock.setblocking(False)
        self.sock = sock
        self.buf = b""
        log("connected to whisper-ptt")
        return True

    def drain(self):
        """Read daemon lines. State lines update every mic."""
        if self.sock is None:
            self.connect()
        if self.sock is None:
            return
        while True:
            while b"\n" in self.buf:
                raw, _, rest = self.buf.partition(b"\n")
                self.buf = rest
                self._take(raw.decode("ascii", "replace").strip())
            try:
                data = self.sock.recv(512)
            except BlockingIOError:
                return
            except OSError:
                data = b""
            if not data:
                self.sock.close()
                self.sock = None
                self.buf = b""
                return
            self.buf += data

    def _take(self, line):
        if line.startswith("state "):
            phase = line[6:].strip()
            if phase in ("idle", "record", "busy") and phase != self.phase:
                self.phase = phase
                self.phase_at = time.monotonic()
                log(f"mic {phase}")
            return
        if self.pending_reply and self.reply is None and line in ("ok", "busy", "no"):
            self.reply = line

    def send(self, line):
        if not self.connect():
            return None
        try:
            self.sock.sendall((line + "\n").encode())
        except OSError:
            self.sock.close()
            self.sock = None
            return None
        self.pending_reply = True
        self.reply = None
        deadline = time.monotonic() + 0.4
        while time.monotonic() < deadline:
            self.drain()
            if self.sock is None:
                self.pending_reply = False
                return None
            if self.reply is not None:
                reply = self.reply
                self.reply = None
                self.pending_reply = False
                return reply
            time.sleep(0.01)
        self.pending_reply = False
        return None

    def pointer(self, button):
        while self.poll(button.handle, self.event, EVENT_SIZE):
            event_type = struct.unpack_from("<I", self.event, 0)[0]
            if event_type in (MOUSE_MOVE, FOCUS_ENTER):
                button.hovered = True
                self.refresh(button)
                continue
            if event_type == FOCUS_LEAVE:
                button.hovered = False
                self.refresh(button)
                continue
            if event_type not in (MOUSE_DOWN, MOUSE_UP):
                if event_type and not self.odd_event:
                    log(f"overlay event {event_type}")
                    self.odd_event = True
                continue
            button_bits = struct.unpack_from("<I", self.event, 20)[0]
            if button_bits and not (button_bits & LEFT_BUTTON):
                continue
            if event_type == MOUSE_DOWN and self.latched:
                previous = self.holding
                self.send("release")
                self.holding = None
                self.latched = False
                if previous is not None:
                    self.refresh(previous)
                if previous is not button:
                    self.refresh(button)
                log("click off")
                continue
            if event_type == MOUSE_DOWN and self.holding is None:
                reply = self.send(f"hold {button.seq}")
                if reply == "ok":
                    self.holding = button
                    self.pressed_at = time.monotonic()
                    self.refresh(button)
                    log("hold")
                else:
                    log(f"hold {reply or 'no daemon'}")
            elif event_type == MOUSE_UP and self.holding is button and not self.latched:
                if time.monotonic() - self.pressed_at < CLICK_S:
                    self.latched = True
                    log("click on")
                else:
                    self.send("release")
                    self.holding = None
                    self.refresh(button)
                    log("release")

    def retire(self, live):
        for seq in list(self.buttons):
            if seq in live:
                continue
            button = self.buttons.pop(seq)
            if self.holding is button:
                self.send("release")
                self.holding = None
                self.latched = False
            self.hide(button.handle)
            self.destroy(button.handle)
            log(f"mic off window {seq}")

    def run(self):
        height_m = BUTTON_METERS * self.tex_h / self.tex_w
        log(
            f"whisper panel ready, mic {BUTTON_METERS:.3f}x{height_m:.3f}m, "
            f"{RIGHT_INSET:.2f}m in from the right, {BELOW:.2f}m below"
        )
        runtime = steamvr_ipc()
        next_scan = 0.0
        windows = []
        while True:
            self.wait_frame(30)
            now = time.monotonic()
            self.drain()
            if now >= next_scan:
                current = steamvr_ipc()
                if current and runtime and current != runtime:
                    log("SteamVR restarted, reconnecting")
                    os._exit(1)
                if current and not runtime:
                    runtime = current
                windows = self.scan_windows()
                next_scan = now + 1.5
                self.retire({seq for seq, _ in windows})
                self.scales.watch(seq for seq, _ in windows)
            for seq, parent in windows:
                button = self.ensure_button(seq)
                if button is None:
                    continue
                button.parent = parent
                self.refresh(button)
                if not self.place(button, parent, now):
                    self.hide(button.handle)
                    continue
                self.show(button.handle)
                self.pointer(button)


def main():
    panel = Panel()
    try:
        panel.run()
    finally:
        panel.shutdown()


if __name__ == "__main__":
    main()
