#!/usr/bin/env python3
"""midiviz — the MIDI stream as a picture, not a transcript.

`midimon.py` prints one line per event ("14:0  Control change  ch0, controller
29, value 15"). That is the right shape for a debugger and the wrong shape for a
performer: during a set nobody reads sentences, and a fader sweep emits ~400
events a second, so the words scroll past faster than an eye can land on them.
PLN, 2026-08-28: "midiwatch still naively verbose ... no more console, tiny
borderless window ... no word like 'control change' pure viz".

So this is a *lens*, not a log. Zero English words render: an event's identity is
its POSITION, its type is a GLYPH CLASS, its value is a BAR plus two hex digits,
and its recency is BRIGHTNESS. The layout is the authored surface itself —
`lcxl_grid` rows A..F down, columns 1..8 across, and column N *is* orbit N — so a
glance answers "which orbit did I just touch" without decoding anything. Only
what the grid cannot place (foreign CCs, notes, bend, program) falls as rain in
the right-hand gutter, which is itself the signal "that came from somewhere
else".

    python3 midiviz.py                  # auto-resolve the surface, watch it
    python3 midiviz.py -p 28:0          # pin a source port
    python3 midiviz.py -l               # list ports, exit
    python3 midiviz.py --selftest       # 3 s of synthetic events, headless

Keys: q / Esc quit · p pause · c clear · +/- resize. Drag anywhere to move.

Design notes worth keeping
--------------------------
* **Mapped-but-idle must not be black.** Every one of the 48 grid cells is
  painted with a floor tint even when nothing has ever arrived on it, because
  "dark = not mapped" is the reading a cockpit trains you into, and a dark cell
  that actually *is* mapped reads as a hardware fault (the LED work settled
  this).
* **Two frame rates, not a busy loop.** This machine performs live audio. The
  timer runs at ~30 fps only while events are arriving; 2 s after the last one it
  drops to 5 fps and the picture becomes a slow dim pulse. Colours are a
  precomputed LUT and paint uses the int overloads, so a frame allocates
  essentially nothing.
* **The reader is a thread over `aseqdump`, reusing `midimon.parse_line` +
  `enrich`.** One parsing source of truth for the CLI, the web SSE stream and
  this window. rtmidi cannot subscribe to an arbitrary ALSA source port; the
  subprocess can.
* Port choice reuses `surface.resolve_port` (lowest port number on a matching
  client — the LCXL's second port is the HUI one and carries nothing) tried
  against `WATCH_PREFERENCE` in order.
"""
from __future__ import annotations

import argparse
import math
import os
import random
import signal
import subprocess
import sys
import threading
import time
from collections import deque
from pathlib import Path

HERE = Path(__file__).resolve().parent
TOOLS = HERE.parent
for _p in (str(HERE), str(TOOLS)):
    if _p not in sys.path:
        sys.path.insert(0, _p)

import lcxl_grid as grid          # noqa: E402  the authored control grid
import midimon                    # noqa: E402  parse_line / enrich / resolve_port
import midistream                 # noqa: E402  parse_ports

# ── which port to watch ────────────────────────────────────────────────────
# `midimon.resolve_port()` OWNS this decision and this file delegates to it. Two
# things about it are load-bearing and were nearly re-derived wrong here:
#
#   * the preference ORDER is authored (translated corpus stream first, raw v3
#     DAW port second, raw custom mode third, Midi Through last as a catch-all);
#   * it matches the whole `aseqdump -l` ROW, not just the CLIENT column —
#     python-rtmidi registers the lcxl3 driver's client as 'RtMidiOut Client'
#     and puts 'ParVagues LCXL3' on the PORT, so a client-name match (which is
#     what `surface.resolve_port` does, correctly, for its own purpose) misses
#     exactly the stream we most want to watch.
#
# The fallback below exists only for a tree whose midimon predates that helper;
# it is a faithful copy, so the two can never give DIFFERENT answers to "which
# port", which is the failure mode worth engineering against.
WATCH_PREFERENCE: tuple[str, ...] = (
    "ParVagues LCXL3", "LCXL3 1 DAW", "LCXL3", "Launch Control XL", "Midi Through",
)


def list_ports() -> list[dict]:
    """[{addr, client, port}] from `aseqdump -l`, or [] if aseqdump/ALSA is absent."""
    try:
        r = subprocess.run(["aseqdump", "-l"], capture_output=True, text=True, timeout=5)
    except (OSError, subprocess.SubprocessError):
        return []
    return midistream.parse_ports(r.stdout)


def resolve_watch_port() -> tuple[str | None, str]:
    """(port_id, human label) of the most interesting source present."""
    delegate = getattr(midimon, "resolve_port", None)
    if callable(delegate):
        try:
            return delegate()
        except TypeError:            # a different signature is a different concept
            pass
    try:
        out = subprocess.run(["aseqdump", "-l"], capture_output=True,
                             text=True, timeout=3).stdout
    except (OSError, subprocess.SubprocessError):
        return None, "aseqdump unavailable"
    rows = []
    for line in out.splitlines():
        tok = line.split()
        if tok and ":" in tok[0] and tok[0].split(":")[0].isdigit():
            rows.append((tok[0], line))
    for want in WATCH_PREFERENCE:
        for pid, line in rows:
            if want in line:
                return pid, "%s · %s" % (pid, want)
    return None, "no MIDI source found"


# ── event classes → glyphs (no words, ever) ────────────────────────────────
# Restricted to glyphs with reliable coverage in DejaVu Sans Mono, the
# guaranteed fallback face on this box.
HEX = "0123456789ABCDEF"
CLASS_GLYPH: tuple[tuple[str, str], ...] = (
    ("note on", "◆"),               # ◆ solid  = a note exists
    ("note off", "◇"),              # ◇ hollow = it stopped
    ("control", "▪"),               # ▪ (only reached for un-gridded CCs)
    ("pitch", "≈"),                 # ≈
    ("channel aftertouch", "▴"),    # ▴
    ("poly aftertouch", "▴"),
    ("aftertouch", "▴"),
    ("program", "≡"),               # ≡
    ("sysex", "◈"),                 # ◈
    ("system exclusive", "◈"),
    # NOTE: the four below are currently UNREACHABLE, and that is a midimon bug,
    # not a midiviz one. `parse_line`'s row regex is `addr EVENT <2+ spaces> DATA`,
    # and aseqdump prints Start/Stop/Continue/Clock with no data column at all, so
    # those lines match nothing and are dropped before they ever get here. Kept
    # (and pinned by test_dataless_transport_messages_do_not_parse_at_all) so the
    # day the shared parser learns them, transport lights up for free.
    ("clock", "·"),                 # ·
    ("start", "▶"),                 # ▶
    ("continue", "▷"),              # ▷
    ("stop", "■"),                  # ■
    ("song", "·"),
    ("active", "·"),
    ("reset", "■"),
)
OTHER_GLYPH = "▫"                   # ▫

# Colour families. Every hue stays inside the violet→magenta arc PLN asked for;
# only the hue *within* that arc separates roles, so the window reads as one
# colour while eight things stay distinguishable.
FAM_LEVEL, FAM_FX, FAM_FX2, FAM_GATE, FAM_GATE2, FAM_FAMILY, FAM_OTHER, FAM_NOTE = range(8)
FAM_HUE = (275, 305, 258, 288, 320, 334, 246, 292)
ROLE_FAM = {"level": FAM_LEVEL, "fx": FAM_FX, "fx2": FAM_FX2, "gate": FAM_GATE,
            "gate2": FAM_GATE2, "family_filter": FAM_FAMILY, "family_mute": FAM_FAMILY}
LEVELS = 16                         # decay resolution of the colour LUT

BG = (0x0a, 0x06, 0x12)
TAU = 0.85                          # seconds; brightness e-folding time
IDLE_AFTER = 2.0                    # seconds of silence before the slow pulse
# 40 ms = 25 fps. Profiling a frame (see MAX_DROPS) put the cost squarely in the
# number of drawText calls, and at this size 25 fps is indistinguishable from 33
# while costing a fifth less of the core PLN needs for audio.
FPS_ACTIVE, FPS_IDLE = 40, 200      # timer intervals, ms
# Rain is decoration, and it was the single most expensive thing on screen: a
# 96-drop cap with a trail glyph each meant up to 192 drawText per frame, 3.1 ms
# of an 8.9 ms frame. 32 heads with a trail only on the brightest few costs ~0.6.
MAX_DROPS = 32
BASE_W, BASE_H = 420, 260


def _glyph_for(event: str) -> tuple[str, int]:
    """Event name → (glyph, colour family). The only place a name is inspected."""
    e = (event or "").lower()
    for kw, g in CLASS_GLYPH:
        if e.startswith(kw):
            return g, (FAM_NOTE if kw.startswith("note") else FAM_OTHER)
    return OTHER_GLYPH, FAM_OTHER


def _norm_value(ev: dict) -> int:
    """Any event → 0..127, so one bar/brightness scale serves every type."""
    for key in ("value", "velocity", "program"):
        if key in ev:
            v = ev[key]
            if key == "value" and (v < 0 or v > 127):     # pitch bend, ±8192
                return max(0, min(127, int((v + 8192) * 127 / 16383)))
            return max(0, min(127, int(v)))
    return 64


# ── the reader ─────────────────────────────────────────────────────────────
class Reader:
    """`aseqdump` in a daemon thread; a bounded deque the GUI drains each frame.

    Bounded, because a live monitor wants the PRESENT: the same lesson
    `midistream.DEPTH` records — a deep buffer that fills stays full, and then
    every frame you draw is a fixed lag behind the surface, forever.
    """

    DEPTH = 512

    def __init__(self, port: str | None):
        self.port = port
        self.error: str | None = None
        self.total = 0
        self._q: deque[dict] = deque(maxlen=self.DEPTH)
        self._lock = threading.Lock()
        self._proc: subprocess.Popen | None = None
        self._stop = threading.Event()

    def start(self) -> "Reader":
        threading.Thread(target=self._run, daemon=True).start()
        return self

    def _run(self) -> None:
        cmd = ["aseqdump"] + (["-p", self.port] if self.port else [])
        try:
            self._proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                                          stderr=subprocess.DEVNULL,
                                          text=True, bufsize=1)
        except OSError as exc:
            self.error = type(exc).__name__
            return
        try:
            for line in self._proc.stdout or []:
                if self._stop.is_set():
                    break
                ev = midimon.parse_line(line)
                if ev is None:
                    continue
                with self._lock:
                    self._q.append(midimon.enrich(ev))
                    self.total += 1
        except (OSError, ValueError):
            self.error = "eof"

    def drain(self) -> list[dict]:
        with self._lock:
            if not self._q:
                return []
            out = list(self._q)
            self._q.clear()
        return out

    def close(self) -> None:
        self._stop.set()
        if self._proc:
            try:
                self._proc.terminate()
            except OSError:
                pass
            self._proc = None


# ── Qt ─────────────────────────────────────────────────────────────────────
def _qt():
    from PySide6 import QtCore, QtGui, QtWidgets
    return QtCore, QtGui, QtWidgets


_QUIT = threading.Event()


def _install_signals() -> None:
    """Ctrl-C must close the window, but a Python handler only runs between
    bytecodes — Qt's event loop would swallow it. The always-running frame timer
    polls this flag, which is why there is no separate wakeup pipe."""
    def bye(_sig, _frm):
        _QUIT.set()
    for s in (signal.SIGINT, signal.SIGTERM):
        try:
            signal.signal(s, bye)
        except (OSError, ValueError):
            pass


def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
    QtCore, QtGui, QtWidgets = _qt()
    Qt = QtCore.Qt

    class MidiViz(QtWidgets.QWidget):
        def __init__(self):
            super().__init__(None)
            self.setWindowTitle("midiviz")
            self.setWindowFlags(Qt.WindowType.Window
                                | Qt.WindowType.FramelessWindowHint
                                | Qt.WindowType.WindowStaysOnTopHint)
            self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
            self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
            self.setCursor(Qt.CursorShape.SizeAllCursor)

            self.reader = reader
            self.port_label = port_label
            self.scale = scale
            self.paused = False
            self.t0 = time.monotonic()
            self.last_event_t = 0.0
            self.frames = 0
            self.painted = 0
            self.ingested = 0
            self._interval = FPS_ACTIVE
            self._rng = random.Random(0xC0FFEE)
            self._drag_from = None

            self.cc: dict[int, list] = {}        # cc -> [value, t_last, channel]
            self.col_t = [0.0] * 9               # column 1..8 last-touch, for the glow
            self.stream: deque = deque(maxlen=256)   # bottom ribbon, newest right
            self.drops: list[list] = []          # gutter rain
            self.hist: deque = deque([0] * 64, maxlen=64)   # events per 100 ms
            self._bucket_t = self.t0
            self._bucket_n = 0

            self._build_palette()
            self._apply_scale()

            self.timer = QtCore.QTimer(self)
            self.timer.setTimerType(Qt.TimerType.CoarseTimer)
            self.timer.timeout.connect(self.tick)
            self.timer.start(self._interval)

        # ── palette / fonts / geometry ─────────────────────────────────────
        def _build_palette(self):
            self.bg = QtGui.QColor(*BG)
            # LUT[family][level] — every colour this window can ever paint, so a
            # frame constructs no QColor at all.
            self.lut = []
            for hue in FAM_HUE:
                col = []
                for lv in range(LEVELS):
                    t = lv / (LEVELS - 1)
                    val = 0.055 + 0.945 * (t ** 1.25)
                    sat = 0.96 - 0.50 * (t ** 1.6)
                    col.append(QtGui.QColor.fromHsvF(hue / 360.0, sat, val))
                self.lut.append(col)
            self.chrome = self.lut[FAM_OTHER]

        def _font(self, px: int):
            f = QtGui.QFont()
            f.setFamilies(["Geist Mono", "JetBrains Mono", "Fira Code",
                           "DejaVu Sans Mono", "monospace"])
            f.setStyleHint(QtGui.QFont.StyleHint.Monospace)
            f.setPixelSize(max(5, px))
            return f

        def _apply_scale(self):
            s = self.scale
            self.f_main = self._font(round(11 * s))
            self.f_micro = self._font(round(8 * s))
            fm = QtGui.QFontMetricsF(self.f_main)
            fmm = QtGui.QFontMetricsF(self.f_micro)
            self.mw, self.mh = fm.horizontalAdvance("0"), fm.height()
            self.uw, self.uh = fmm.horizontalAdvance("0"), fmm.height()
            self.m_asc, self.u_asc = fm.ascent(), fmm.ascent()
            self.resize(round(BASE_W * s), round(BASE_H * s))
            self._relayout()

        def _relayout(self):
            w, h = max(80, self.width()), max(60, self.height())
            s = self.scale
            self.pad = max(3, round(5 * s))
            gap = max(2, round(4 * s))
            self.hdr_y = self.pad
            self.hdr_h = round(self.uh + 2)
            self.foot_h = round(self.mh + 2)
            self.foot_y = h - self.pad - self.foot_h
            self.gut_w = max(round(18 * s), round(self.uw * 5))
            self.gut_x = w - self.pad - self.gut_w
            self.lab_w = round(self.uw * 1.7)
            self.mx = self.pad + self.lab_w
            # A band of its own for the column digits. Column N *is* orbit N, so
            # that digit is the single most load-bearing glyph in the window;
            # squeezing it into the header (where the sparkline lives) lost it.
            self.col_y = self.hdr_y + self.hdr_h + gap
            self.col_h = round(self.uh)
            self.my = self.col_y + self.col_h
            self.mw_px = max(8, self.gut_x - gap - self.mx)
            self.mh_px = max(8, self.foot_y - gap - self.my)
            self.cw = self.mw_px / 8.0
            self.ch = self.mh_px / 6.0
            self.inset = max(1, round(1.6 * s))  # keeps neighbouring cells distinct
            self.gut_rows = max(1, int(self.mh_px / max(1.0, self.uh)))
            self.stream_n = max(4, int((w - 2 * self.pad) / max(1.0, self.mw)))
            # Bound the ribbon to exactly what fits, so paint iterates the deque
            # in place instead of copying and slicing a 256-list every frame.
            self.stream = deque(self.stream, maxlen=self.stream_n)
            self._scan = None                    # rebuilt lazily at paint time

        def _scanlines(self):
            """One cached pixmap for the CRT texture: a whole frame of it is one blit."""
            if self._scan is not None:
                return self._scan
            w, h = max(1, self.width()), max(1, self.height())
            pm = QtGui.QPixmap(w, h)
            pm.fill(QtGui.QColor(0, 0, 0, 0))
            p = QtGui.QPainter(pm)
            step = max(2, round(3 * self.scale))
            p.setPen(QtGui.QColor(0xa0, 0x60, 0xff, 16))
            for y in range(0, h, step):
                p.drawLine(0, y, w, y)
            p.setPen(QtGui.QColor(0xa0, 0x60, 0xff, 9))
            for x in range(0, w, step * 4):
                p.drawLine(x, 0, x, h)
            p.end()
            self._scan = pm
            return pm

        def _scanlines_blit(self, p):
            p.drawPixmap(0, 0, self._scanlines())

        def resizeEvent(self, _e):
            self._relayout()

        # ── ingest ─────────────────────────────────────────────────────────
        def ingest(self, ev: dict) -> None:
            """One parsed event → visual state. The whole render path hangs off
            this, which is what lets --selftest exercise the drawing for real."""
            now = time.monotonic()
            self.last_event_t = now
            self.ingested += 1
            self._bucket_n += 1
            e = (ev.get("event") or "").lower()
            chn = ev.get("ch") or 0
            v = _norm_value(ev)

            if e.startswith("control") and ev.get("controller") is not None:
                cc = int(ev["controller"])
                cell = grid.CC_TO_CELL.get(cc)
                if cell is not None:
                    st = self.cc.get(cc)
                    if st is None:
                        self.cc[cc] = [v, now, chn]
                    else:
                        st[0], st[1], st[2] = v, now, chn
                    self.col_t[cell[1]] = now
                    self.stream.append((HEX[v >> 3], self._fam_for_cc(cc), now))
                    return
                # a CC the authored grid does not own: rain, not a cell.
                self.stream.append((HEX[v >> 3], FAM_OTHER, now))
                self._spawn_drop(HEX[cc & 0xF], FAM_OTHER)
                return

            g, fam = _glyph_for(ev.get("event") or "")
            self.stream.append((g, fam, now))
            self._spawn_drop(g, fam)

        def _fam_for_cc(self, cc: int) -> int:
            role = grid.CC_ROLE.get(cc)
            return ROLE_FAM.get(role[0], FAM_OTHER) if role else FAM_OTHER

        def _spawn_drop(self, glyph: str, fam: int) -> None:
            if len(self.drops) >= MAX_DROPS:      # hard cap: rain is decoration
                del self.drops[:len(self.drops) - MAX_DROPS + 1]
            self.drops.append([self._rng.randrange(0, 3), 0.0,
                               time.monotonic(), glyph, fam])

        # ── frame ──────────────────────────────────────────────────────────
        def tick(self):
            if _QUIT.is_set():
                self.close()
                return
            now = time.monotonic()
            if self.reader is not None:
                evs = self.reader.drain()
                if evs and not self.paused:
                    for ev in evs:
                        self.ingest(ev)
                elif evs:
                    self.last_event_t = now       # stay awake while paused but fed

            if now - self._bucket_t >= 0.1:
                self.hist.append(self._bucket_n)
                self._bucket_n = 0
                self._bucket_t = now

            if self.drops:                        # rain falls; dead drops go
                dt = self._interval / 1000.0
                rows = self.gut_rows
                self.drops = [d for d in self.drops
                              if (d.__setitem__(1, d[1] + dt * 9.0) or True)
                              and d[1] < rows + 2 and now - d[2] < 4.0]

            idle = (now - self.last_event_t) > IDLE_AFTER and not self.drops
            want = FPS_IDLE if idle else FPS_ACTIVE
            if want != self._interval:
                self._interval = want
                self.timer.setInterval(want)
            self.frames += 1
            self.update()

        def _lv(self, age: float, floor: int = 0) -> int:
            """Age in seconds → LUT level. The only decay curve in the file."""
            if age < 0:
                return LEVELS - 1
            lv = int((LEVELS - 1) * math.exp(-age / TAU))
            return max(floor, min(LEVELS - 1, lv))

        # ── paint ──────────────────────────────────────────────────────────
        def paintEvent(self, _e):
            p = QtGui.QPainter(self)
            try:
                p.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, False)
                p.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing, True)
                w, h = self.width(), self.height()
                p.fillRect(0, 0, w, h, self.bg)
                now = time.monotonic()
                # the slow dim pulse: the only thing that moves when it is silent
                pulse = 0.5 + 0.5 * math.sin((now - self.t0) * 0.55)
                self._paint_chrome(p, w, h, pulse)
                self._paint_header(p, now, pulse)
                self._paint_matrix(p, now, pulse)
                self._paint_gutter(p, now, pulse)
                self._paint_stream(p, now)
                self._scanlines_blit(p)
                self.painted += 1
            finally:
                p.end()

        def _paint_chrome(self, p, w, h, pulse):
            p.setPen(self.chrome[2 + int(3 * pulse)])
            p.drawRect(0, 0, w - 1, h - 1)
            p.setPen(self.chrome[6 + int(5 * pulse)])
            n = max(4, round(8 * self.scale))
            for cx, cy, dx, dy in ((0, 0, 1, 1), (w - 1, 0, -1, 1),
                                   (0, h - 1, 1, -1), (w - 1, h - 1, -1, -1)):
                p.drawLine(cx, cy, cx + dx * n, cy)
                p.drawLine(cx, cy, cx, cy + dy * n)

        def _paint_header(self, p, now, pulse):
            p.setFont(self.f_micro)
            lv = 5 + int(4 * pulse) if now - self.last_event_t > IDLE_AFTER else 12
            p.setPen(self.chrome[lv])
            total = self.reader.total if self.reader else self.ingested
            # port address digits + a hex event counter. No words.
            head = "%s│%06X" % (self.port_label, total & 0xFFFFFF)
            y = self.hdr_y + self.u_asc
            p.drawText(self.pad, round(y), head)

            # sparkline of events/100 ms, most recent at the right
            hx = self.pad + round(self.uw * (len(head) + 1))
            bw = max(1, round(self.scale))
            avail = max(4, int((self.gut_x - hx) / (bw + 1)))
            bars = list(self.hist)[-avail:]
            top = self.hdr_y
            bot = self.hdr_y + self.hdr_h - 1
            hh = max(2, bot - top)
            peak = max(4, max(bars) if bars else 4)
            x = hx
            for n in bars:
                if n:
                    bh = max(1, round(hh * min(1.0, n / peak)))
                    p.fillRect(x, bot - bh, bw, bh, self.lut[FAM_FX][6 + min(9, n)])
                x += bw + 1

            # state pips instead of words: paused, stream broken
            pw = max(2, round(2 * self.scale))
            if self.paused:
                p.fillRect(self.gut_x, top + 1, pw, hh - 1, self.lut[FAM_GATE2][14])
            if self.reader is not None and self.reader.error:
                p.fillRect(self.gut_x + pw * 2, top + 1, pw, hh - 1,
                           self.lut[FAM_FAMILY][13])

        def _paint_matrix(self, p, now, pulse):
            mx, my, cw, ch = self.mx, self.my, self.cw, self.ch
            # per-orbit charge glow behind each column: "this orbit is live"
            for col in range(1, 9):
                if not self.col_t[col]:
                    continue
                lv = self._lv(now - self.col_t[col])
                if lv > 1:
                    p.fillRect(round(mx + (col - 1) * cw), round(my),
                               max(1, round(cw) - self.inset), round(self.mh_px),
                               self.lut[FAM_LEVEL][min(3, 1 + lv // 6)])

            p.setFont(self.f_micro)
            # column digits = orbit numbers, brightening with that orbit's activity
            col_base = self.col_y + self.u_asc
            for col in range(1, 9):
                lv = self._lv(now - self.col_t[col], floor=4) if self.col_t[col] \
                    else 4 + int(2 * pulse)
                p.setPen(self.lut[FAM_LEVEL][min(LEVELS - 1, lv)])
                p.drawText(round(mx + (col - 1) * cw + (cw - self.uw) / 2),
                           round(col_base), HEX[col])
            # row letters: the grid's own single-glyph names, on the digits' baseline
            p.setPen(self.chrome[4 + int(3 * pulse)])
            for r, row in enumerate(grid.PHYSICAL_ORDER):
                p.drawText(self.pad, round(my + r * ch + self.u_asc + 1), row)

            two = self.cw > self.mw * 2.4
            p.setFont(self.f_main)
            for r, row in enumerate(grid.PHYSICAL_ORDER):
                for col in range(1, 9):
                    cc = grid.CELL_TO_CC.get((row, col))
                    if cc is None:
                        continue
                    x = round(mx + (col - 1) * cw)
                    y = round(my + r * ch)
                    cwi = max(2, round(cw) - self.inset)
                    chi = max(2, round(ch) - self.inset)
                    fam = self._fam_for_cc(cc)
                    st = self.cc.get(cc)
                    if st is None:
                        # FLOOR TINT, never black: a mapped control that simply
                        # has not moved must not read as unmapped.
                        p.fillRect(x, y, cwi, chi, self.lut[fam][1])
                        p.setPen(self.chrome[3])
                        p.drawText(x + 2, y + chi - 3, "··" if two else "·")
                        continue
                    v, t, chn = st
                    lv = self._lv(now - t, floor=2)
                    # heat: the value tints the cell body even when cold
                    p.fillRect(x, y, cwi, chi, self.lut[fam][1 + int(3 * v / 127)])
                    bar_h = max(2, round(chi * 0.22))
                    if cc in grid.BUTTON_CCS:
                        # buttons are latches: filled above half, hollow below
                        if v >= 64:
                            p.fillRect(x + 1, y + chi - bar_h - 1, cwi - 2, bar_h,
                                       self.lut[fam][lv])
                        else:
                            p.setPen(self.lut[fam][max(3, lv - 5)])
                            p.drawRect(x + 1, y + chi - bar_h - 1,
                                       max(1, cwi - 3), max(1, bar_h - 1))
                    else:
                        p.fillRect(x + 1, y + chi - bar_h - 1,
                                   max(1, round((cwi - 2) * v / 127)), bar_h,
                                   self.lut[fam][lv])
                    # the channel shifts the hue of the digits, not of the cell
                    p.setPen(self.lut[(fam + chn) % 8][lv])
                    p.drawText(x + 2, y + self.m_asc,
                               ("%02X" % v) if two else HEX[v >> 3])

        def _paint_gutter(self, p, now, pulse):
            gx, gw = self.gut_x, self.gut_w
            p.fillRect(gx, round(self.my), gw, round(self.mh_px), self.lut[FAM_OTHER][1])
            p.setPen(self.chrome[3 + int(3 * pulse)])
            p.drawLine(gx, round(self.my), gx, round(self.my + self.mh_px))
            if not self.drops:
                return
            p.setFont(self.f_micro)
            sub_w = gw / 3.0
            limit = self.my + self.mh_px
            for sub, ypos, born, g, fam in self.drops:
                lv = self._lv(now - born)
                if lv < 2:
                    continue
                y = round(self.my + ypos * self.uh + self.u_asc)
                if y > limit:
                    continue
                x = round(gx + 2 + sub * sub_w)
                p.setPen(self.lut[fam][lv])
                p.drawText(x, y, g)
                if lv > 11:                      # one trailing glyph sells the fall
                    p.setPen(self.lut[fam][lv - 7])
                    p.drawText(x, round(y - self.uh), g)

        def _paint_stream(self, p, now):
            """The ribbon, drawn in RUNS rather than one call per glyph.

            One drawText per glyph was 2.1 ms of an 8.9 ms frame. Consecutive
            entries sharing a colour AND a decay level are concatenated into one
            call — which is legitimate only for hex glyphs, because those are
            guaranteed to come from the monospaced face at exactly `self.mw`
            advance. A class glyph (◆ ≈ ≡ …) may be served by a fallback face
            with a different advance, so those still draw one at a time or the
            ribbon would drift out of its grid.
            """
            if not self.stream:
                return
            p.setFont(self.f_main)
            mw = self.mw
            x = float(self.pad)
            y = round(self.foot_y + self.m_asc)
            run: list[str] = []
            run_x, run_key = x, (0, 0)
            for g, fam, t in self.stream:
                lv = self._lv(now - t, floor=2)
                key = (fam, lv) if g in HEX else None
                if run and key == run_key:
                    run.append(g)
                else:
                    if run:
                        p.setPen(self.lut[run_key[0]][run_key[1]])
                        p.drawText(round(run_x), y, "".join(run))
                        run = []
                    if key is None:
                        p.setPen(self.lut[fam][lv])
                        p.drawText(round(x), y, g)
                    else:
                        run, run_x, run_key = [g], x, key
                x += mw
            if run:
                p.setPen(self.lut[run_key[0]][run_key[1]])
                p.drawText(round(run_x), y, "".join(run))

        # ── input ──────────────────────────────────────────────────────────
        def keyPressEvent(self, e):
            k = e.key()
            if k in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
                self.close()
            elif k == Qt.Key.Key_P:
                self.paused = not self.paused
            elif k == Qt.Key.Key_C:
                self.cc.clear()
                self.stream.clear()
                self.drops.clear()
                self.col_t = [0.0] * 9
                self.hist = deque([0] * 64, maxlen=64)
            elif k in (Qt.Key.Key_Plus, Qt.Key.Key_Equal):
                self._rescale(+0.15)
            elif k in (Qt.Key.Key_Minus, Qt.Key.Key_Underscore):
                self._rescale(-0.15)
            else:
                super().keyPressEvent(e)

        def _rescale(self, d):
            s = max(0.6, min(2.4, round((self.scale + d) * 100) / 100))
            if s != self.scale:
                self.scale = s
                self._apply_scale()
                self.update()

        def mousePressEvent(self, e):
            if e.button() == Qt.MouseButton.LeftButton:
                # Wayland forbids a client positioning itself; startSystemMove is
                # the compositor-blessed path. X11 falls back to a manual move.
                wh = self.windowHandle()
                if wh is not None and wh.startSystemMove():
                    self._drag_from = None
                    return
                self._drag_from = e.globalPosition().toPoint() - self.pos()

        def mouseMoveEvent(self, e):
            if self._drag_from is not None:
                self.move(e.globalPosition().toPoint() - self._drag_from)

        def mouseReleaseEvent(self, _e):
            self._drag_from = None

        def closeEvent(self, e):
            self.timer.stop()
            if self.reader:
                self.reader.close()
            e.accept()

    return MidiViz()


# ── entry points ───────────────────────────────────────────────────────────
def _synthetic_lines() -> list[str]:
    """aseqdump-shaped lines covering the whole grid, several channels, and every
    glyph class — fed through the REAL parser, so --selftest exercises
    parse_line + enrich as well as the drawing."""
    out: list[str] = []
    ccs = [cc for row in grid.PHYSICAL_ORDER for cc in grid.ROW_CCS[row]]
    for i, cc in enumerate(ccs):
        out.append(" 28:0   Control change          %d, controller %d, value %d"
                   % (i % 4, cc, (i * 17) % 128))
    for cc in (7, 93, 120, 64):                  # un-gridded CCs → gutter rain
        out.append(" 28:0   Control change          0, controller %d, value 99" % cc)
    for n in (36, 48, 60, 67, 72):
        out.append(" 28:0   Note on                 1, note %d, velocity %d" % (n, 40 + n // 2))
        out.append(" 28:0   Note off                1, note %d, velocity 0" % n)
    out += [
        " 28:0   Pitch bend              2, value 4096",
        " 28:0   Pitch bend              2, value -6000",
        " 28:0   Program change          3, program 7",
        " 28:0   Channel aftertouch      0, value 64",
        " 28:0   Poly aftertouch         5, note 60, value 31",
        " 28:0   Start",
        " 28:0   Stop",
        " 28:0   System exclusive        f0 00 20 29 f7",
    ]
    return out


def selftest(seconds: float = 3.0, show: bool = False) -> int:
    if not show:
        os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
    os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.*=false")
    QtCore, QtGui, QtWidgets = _qt()
    app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
    w = build_widget("00:0", None, scale=1.0)
    w.show()
    app.processEvents()

    parsed = [midimon.enrich(ev) for ev in
              (midimon.parse_line(ln) for ln in _synthetic_lines()) if ev]
    if not parsed:
        print("selftest FAIL: the parser produced no events", file=sys.stderr)
        return 1

    grabs, i = 0, 0
    colours: set = set()
    t_end = time.monotonic() + seconds
    while time.monotonic() < t_end:
        for _ in range(3):                       # a plausible live event rate
            w.ingest(parsed[i % len(parsed)])
            i += 1
        w.tick()
        app.processEvents()
        pm = w.grab()                            # forces the real paint path
        grabs += 1
        if grabs % 10 == 1:
            img = pm.toImage()
            sx = max(1, img.width() // 16)
            sy = max(1, img.height() // 16)
            for yy in range(0, img.height(), sy):
                for xx in range(0, img.width(), sx):
                    colours.add(img.pixel(xx, yy))
        time.sleep(0.033)

    # exercise the keyboard paths too (pause, unpause, clear, both resizes)
    for key in ("Key_P", "Key_P", "Key_C", "Key_Plus", "Key_Minus"):
        w.keyPressEvent(QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress,
                                        getattr(QtCore.Qt.Key, key),
                                        QtCore.Qt.KeyboardModifier.NoModifier))
    w.ingest(parsed[0])
    w.tick()
    app.processEvents()
    w.grab()
    painted = w.painted
    w.close()

    ok = grabs >= 30 and len(colours) >= 8 and painted >= grabs
    print("midiviz selftest: parsed=%d ingested=%d frames=%d paints=%d grabs=%d "
          "distinct_sampled_colours=%d platform=%s -> %s"
          % (len(parsed), w.ingested, w.frames, painted, grabs, len(colours),
             os.environ.get("QT_QPA_PLATFORM", "native"), "PASS" if ok else "FAIL"))
    return 0 if ok else 1


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(description="midiviz - MIDI stream as a picture")
    ap.add_argument("-p", "--port", help="source port (CLIENT:PORT), e.g. 28:0")
    ap.add_argument("-l", "--list", action="store_true", help="list ports and exit")
    ap.add_argument("-s", "--scale", type=float, default=1.0,
                    help="initial scale, 0.6-2.4 (also +/- at runtime)")
    ap.add_argument("--selftest", action="store_true",
                    help="render synthetic events for --seconds, then exit")
    ap.add_argument("--seconds", type=float, default=3.0)
    ap.add_argument("--show", action="store_true",
                    help="run the selftest on the real display, not offscreen")
    a = ap.parse_args(argv)

    if a.list:
        for pt in list_ports():
            print("%7s  %s  │ %s" % (pt["addr"], pt["client"], pt["port"]))
        print("-> %s" % (resolve_watch_port()[1],))
        return 0
    if a.selftest:
        return selftest(a.seconds, a.show)

    _install_signals()
    port, label = a.port, ""
    if port is None:
        port, label = resolve_watch_port()
    # The window itself stays wordless; where it is listening goes to stderr, so
    # `-l`-free debugging is still possible without putting prose on the canvas.
    print("⚓ midiviz — %s · q/Esc/Ctrl-C to quit" % (label or "port %s" % port),
          file=sys.stderr)
    reader = Reader(port).start()

    _QtCore, _QtGui, QtWidgets = _qt()
    app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
    app.setApplicationName("midiviz")
    w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale)))
    w.show()
    w.raise_()
    w.activateWindow()
    rc = app.exec()
    reader.close()
    return rc


if __name__ == "__main__":
    sys.exit(main())
