Commit 3633fa0f by PLN (Algolia)

midiviz: the window resizes, the type follows it, and the theme is two clicks away

Three complaints from the rehearsal, one root cause each.

"unusable in the sun in light mode" — themes are now top-level rows in the
right-click menu with painted swatches and a ● / ○ marking the live one, plus a
`MIDI ▸` submenu in the tray carrying the same three. Sun is listed first,
because the moment you need it is the moment you cannot read the menu to find
it. The swatches paint from the Theme ramps rather than the live LUT, so the
menu shows what you would get, not what you have.

"only drag -> grab atm" — 7px edges and 20px corners hand off to
startSystemResize, which is what a tiling WM wants; a manual setGeometry path
covers compositors that refuse it, keeping the opposite edge pinned so the
window grows where you pull. minimumSize 240x150, corner ticks brighten on
hover, and leaveEvent puts them back (without it they stay lit forever).

"have it squeeze and expand properly as we make it taller or wider" — the old
`_apply_scale` resized the window, so scaling and being resized fought each
other. Now `_fit` = min(w/ref, h/ref) clamped to [0.35, 2.4], multiplied by the
density knob, and `resizeEvent` re-measures rather than rescaling: idempotent,
keyed on the pixel sizes it actually produces, and no resize() inside a resize
handler. The reference is the birth size read BACK off the widget, so fit == 1.0
at birth and the look at 1.0x is byte-for-byte what it was. Key `0` resets.

FIT_MIN went 0.55 -> 0.35 because 0.55 crushed the layout at 2.4x on a 240x150
tile and printed rows on top of each other — found by screenshotting six window
shapes, not by reasoning about it.

Tray theme switching is stop -> forget -> launch, and the forget is load-bearing:
two relaunches inside SPAWN_GRACE would otherwise close the lens and then refuse
to reopen it. It returns early if the stop is not confirmed, so a wedged monitor
is never followed by a second one.

Variants live in BY_KEY only, never in LAUNCHERS or snapshot(), so they cannot
be converged into existence behind your back.

Verified on screen (grabs, scaling, colours) before this landed. 75 tests over
the three modules; suites 510 passed, 2 failed, both pre-existing. `--theme` is
still one-shot, so a converge brings dark back — inherited, not fixed here.
parent e4e421be
......@@ -419,6 +419,54 @@ class PerfTray:
self.close_menu = self.rig_menu.addMenu("Close ▸")
self.close_menu.setAttribute(Qt.WA_TranslucentBackground)
self._rebuild_close_menu(snap)
# ── MIDI ▸ ─────────────────────────────────────────────────────────
# PLN, 2026-09-22: "is it doable quickly in the gear menu a submenu for
# controling midi ?", the sentence straight after "midimon still
# unreadable on the sun".
#
# So this submenu has ONE job: put a READABLE MIDI monitor in front of
# him in two clicks, outdoors, minutes before a set. Rig ▸ already
# opens the lens — but it opens it on whatever page the saved config
# says, and `--theme` is a one-shot flag the window does not persist,
# so the page has to be chosen at OPEN time. Hence three rows rather
# than one.
#
# Rows follow #69 and say what is already up before offering anything.
# With the lens running, "open in sun" could only focus the unreadable
# window it was meant to replace (launch() focuses rather than
# duplicating, correctly), so the row changes VERB and becomes an
# explicit relaunch: stop, then open with the theme. That crosses the
# "closing lives in its own submenu" rule above, knowingly, and for
# this one tool only: the monitor makes no sound, it is already opt-in
# closable, and a relaunch is the only thing that can change its page
# from outside the window.
self.midi_menu = self.menu.addMenu("&MIDI ▸")
self.midi_menu.setAttribute(Qt.WA_TranslucentBackground)
self.midi_header = QAction("⚓ MIDI Monitor", self.midi_menu)
self.midi_header.setEnabled(False)
self.midi_menu.addAction(self.midi_header)
self.midi_theme_actions = {}
# Sun first: it is the one he reaches for, and the one that was missing.
for theme, blurb in (("sun", "daylight, maximum ink"),
("light", "a pale desktop"),
("dark", "the cockpit, indoors")):
act = QAction(theme, self.midi_menu)
act.triggered.connect(lambda _c, t=theme: self.on_midimon_theme(t))
self.midi_menu.addAction(act)
self.midi_theme_actions[theme] = (act, blurb)
self.midi_menu.addSeparator()
term_act = QAction("▸ Terminal monitor · one line per event",
self.midi_menu)
term_act.triggered.connect(lambda _c: self._launch("midimon-term"))
self.midi_menu.addAction(term_act)
# One QAction can live in two menus, so this is literally the SAME row
# as QjackCtl in Rig ▸: it takes its running/missing label from the
# same _apply_gear pass and cannot disagree with itself.
jack = self.rig_actions.get("qjackctl")
if jack is not None:
self.midi_menu.addAction(jack)
self._apply_gear(snap)
# Gear state is refreshed on menu-open ONLY, never on the 2s timer: reading
# it walks /proc, and doing that 30x a minute for a label nobody is looking
......@@ -578,6 +626,22 @@ class PerfTray:
# so the row stays live. Only a missing binary is unclickable.
act.setEnabled(item["available"])
self.rig_menu.setTitle(f"R&ig ▸ {up}/{total} up")
# MIDI ▸, from the SAME snapshot the rows above came from: two reads
# would let Rig ▸ say the lens is running while MIDI ▸ offered to open
# it. The theme rows change verb with that state — see the note in
# _build_menu.
if getattr(self, "midi_theme_actions", None):
viz = next((i for i in (*snap["apps"], *snap["web"])
if i["key"] == "midimon"), None)
viz_up = bool(viz and viz["running"])
self.midi_header.setText(
f"{self.GEAR_GLYPH['running' if viz_up else 'stopped']} MIDI Monitor"
f" — {'running, relaunch to change the page' if viz_up else 'not running'}")
for theme, (act, blurb) in self.midi_theme_actions.items():
verb = "↻ relaunch in" if viz_up else "▸ open in"
act.setText(f"{verb} {theme} · {blurb}")
self.midi_menu.setTitle("&MIDI ▸ "
+ ("● monitor up" if viz_up else "○ monitor down"))
if getattr(self, "gig_act", None) is not None:
# Three states, because the click means three different things and the
# dangerous one has to announce itself BEFORE it is pressed.
......@@ -738,6 +802,43 @@ class PerfTray:
def _open_url(self, url):
QProcess.startDetached("xdg-open", [url])
def on_midimon_theme(self, theme):
"""Open the MIDI lens on `theme`, relaunching it when it is already up.
`--theme` is applied at startup and not persisted, so a running window
cannot be re-themed from out here; the honest action is stop-then-open,
and the label says "relaunch" before the click rather than after it.
stop() CONFIRMS rather than assumes, which matters exactly here: a
hand-started midiviz is not the systemd unit, so `systemctl --user stop
midiviz` can legitimately change nothing. When it reports the window is
still there, show that verbatim and do not spawn a second one — two
lenses on the same port is the mistake, not the unreadable one.
"""
try:
running = LA.is_running(LA.BY_KEY["midimon"])
except Exception: # noqa: BLE001
running = False
if running:
try:
res = LA.stop("midimon")
except Exception as e: # noqa: BLE001
self.tray.showMessage("perf-tray", f"MIDI Monitor: {e}",
QSystemTrayIcon.Warning, 5000)
return
if not res.get("ok"):
self.tray.showMessage("perf-tray",
res.get("msg") or "MIDI Monitor still running",
QSystemTrayIcon.Warning, 6000)
return
# stop() polls until the window is really down, so the spawn guard's
# note about the last themed open is now known-stale. Without this,
# two relaunches inside SPAWN_GRACE would close the lens and then
# refuse to reopen it — see launchers.forget_spawn.
LA.forget_spawn(f"midimon-{theme}")
self._launch(f"midimon-{theme}")
self.refresh_gear()
def _launch(self, key):
r = LA.launch(key)
if r["ok"] and r.get("url"):
......
......@@ -109,6 +109,44 @@ LAUNCHERS = [
"unit": "midiviz"},
]
_VIZ = next(l for l in LAUNCHERS if l["key"] == "midimon")
# ── the same tools, opened a particular way ────────────────────────────────
# PLN, 2026-09-22: "midimon still unreadable on the sun. is it doable quickly
# in the gear menu a submenu for controling midi ?"
#
# midiviz's `--theme` is a one-shot override it does not persist, so the fast
# path to a readable lens outdoors is to OPEN it already in `sun` — which means
# rows that differ from the one above only in an argument.
#
# They are deliberately NOT in LAUNCHERS. `snapshot()` walks that list, so four
# near-copies of the MIDI Monitor would appear as four rows in the tray's Rig ▸
# and in the web hub, every one of them reporting the same single process as
# running. They live here instead, reach `BY_KEY`, and go through the exact same
# `launch()` — a themed open must not become a second spawn path (the
# 2026-09-05 double-Ardour is what a second spawn path costs).
#
# Each row is the MIDI Monitor's own spec with its args replaced, so `pgrep`,
# `focus`, `unit` and `latch` cannot drift from it: `is_running` keeps answering
# about the one window that can exist, and the tray can say so before offering
# to open another (#69).
VARIANTS = [
*({**_VIZ,
"key": "midimon-%s" % t,
"name": "MIDI Monitor (%s)" % t,
"blurb": "live MIDI as glyph-rain, opened on the %s page" % t,
"args": [str(MIDIVIZ), "--theme", t]}
for t in ("dark", "light", "sun")),
# The terminal monitor, which is the OTHER tool and not another view of
# this one: one line per event, the debugger's shape, kept as the fallback
# for "is anything arriving at all". `terminal` is right here and wrong on
# the row above -- midiviz draws its own window, midimon prints.
{"key": "midimon-term", "name": "MIDI Monitor (terminal)",
"blurb": "one line per event, in a terminal — the fallback",
"candidates": ["python3"], "args": [str(MIDIMON)], "pgrep": "midimon.py",
"terminal": True},
]
# Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then
# the UI opens `url`. (Fixes dead hub links to not-yet-started servers.)
WEB = [
......@@ -123,7 +161,7 @@ WEB = [
"--dir", str(TIDAL / "armada/tide-table"), "--port", "8731"]},
]
BY_KEY = {l["key"]: l for l in (*LAUNCHERS, *WEB)}
BY_KEY = {l["key"]: l for l in (*LAUNCHERS, *WEB, *VARIANTS)}
def _port_open(port, host="127.0.0.1"):
......@@ -532,6 +570,35 @@ def _sweep_stale_pending(spec):
except OSError as e:
_log(f"could not sweep {pend.name}: {e}")
def forget_spawn(key) -> bool:
"""Drop our note that WE started `key`. True if there was one.
Only for a caller that has just CONFIRMED the process is gone — `stop()`
returning "stopped" is exactly that proof, since it polls until the window
is really down.
Why it has to exist: the spawn guard blocks for SPAWN_GRACE even on a DEAD
pid, on purpose, because it cannot tell "died" from "exec has not landed in
/proc yet". So a themed relaunch pressed twice inside 20 s would stop the
monitor and then decline to reopen it — the guard recognising its own
ten-second-old note — and PLN would be left with no window at all, which is
a worse state than the unreadable one he was fixing.
Never call this to force a spawn past a LIVE process. That is the
2026-09-05 double-Ardour, and the guard above is the whole reason it cannot
happen twice.
"""
spec = BY_KEY.get(key)
if not spec:
return False
try:
(_RUNDIR / f"{spec['key']}.pid").unlink()
_log(f"{key}: forgot our spawn note (the process was confirmed gone)")
return True
except OSError: # never written, or already swept
return False
def _clear_latch(spec):
"""Drop a unit's user-close latch, because we are starting it on purpose."""
unit = spec.get("latch")
......
......@@ -267,3 +267,65 @@ def test_matching_pids_never_returns_our_own_pid(tmp_path, monkeypatch):
import os as _os
assert _os.getpid() not in la.matching_pids(la.BY_KEY["midimon"]), \
"matching_pids would have SIGTERMed the process doing the killing"
# ── the themed monitor rows ────────────────────────────────────────────────
#
# `--theme` is a one-shot flag midiviz does not persist, so "open it already in
# sun" needs rows that differ from the MIDI Monitor's only in an argument. The
# two things that would rot silently: a row that stops inheriting the monitor's
# own `pgrep`/`unit` (and so reports the wrong process), and a variant leaking
# into LAUNCHERS, where `snapshot()` would turn it into a duplicate rig row.
def test_a_themed_monitor_row_is_the_monitor_row_plus_a_theme():
base = L.BY_KEY["midimon"]
for theme in ("dark", "light", "sun"):
spec = L.BY_KEY["midimon-%s" % theme]
assert spec["args"][0].endswith("midiviz.py")
assert spec["args"][1:] == ["--theme", theme]
for shared in ("candidates", "pgrep", "focus", "unit", "latch"):
assert spec.get(shared) == base.get(shared), (
f"midimon-{theme} drifted from the MIDI Monitor on {shared!r}: "
f"is_running would then answer about a different process")
assert spec.get("terminal") is None, \
"midiviz draws its own window; a terminal would park an empty one behind it"
# One window, so one running state: the tray has to be able to say
# "it is up" before offering to open another (#69).
assert L.is_running(spec, [("python3", "python3 .../midiviz.py")]) is True
def test_the_terminal_monitor_row_is_the_other_tool_not_another_view():
spec = L.BY_KEY["midimon-term"]
assert spec["args"][0].endswith("midimon.py")
assert Path(spec["args"][0]).exists()
assert spec["terminal"] is True, "midimon.py prints; it needs a terminal"
assert spec["pgrep"] == "midimon.py"
def test_no_variant_leaks_into_the_rig_listing():
"""`snapshot()` drives the tray's Rig ▸ and the web hub. Four near-copies
of the MIDI Monitor there would be four rows all claiming the same one
process, which is exactly the state #69 was about."""
assert {l["key"] for l in L.LAUNCHERS} == {"pulsar", "ardour", "qjackctl", "midimon"}
keys = {i["key"] for i in (*L.snapshot()["apps"], *L.snapshot()["web"])}
assert not {k for k in keys if k.startswith("midimon-")}
for v in L.VARIANTS: # …and still launchable by key
assert v["key"] in L.BY_KEY
def test_a_confirmed_stop_can_clear_the_spawn_note(tmp_path, monkeypatch):
"""The spawn guard blocks for SPAWN_GRACE even on a dead pid (it cannot
tell "died" from "exec has not landed yet"). Pressed twice inside those 20
seconds, a themed relaunch would stop the monitor and then decline to
reopen it — no window at all, which is worse than an unreadable one."""
la, _rig = _launchers(tmp_path, monkeypatch)
spec = la.BY_KEY["midimon-sun"]
la._RUNDIR.mkdir(parents=True, exist_ok=True)
dead = 999_999 # a pid that is not alive here
(la._RUNDIR / "midimon-sun.pid").write_text(f"{dead} {la.time.time()}")
assert la._recent_spawn(spec), \
"this test is moot: a fresh note for a dead pid no longer blocks"
assert la.forget_spawn("midimon-sun") is True
assert la._recent_spawn(spec) is None, "the note survived being forgotten"
assert la.forget_spawn("midimon-sun") is False # idempotent
assert la.forget_spawn("nope") is False # and safe on nonsense
"""The tray's MIDI ▸ submenu against the launcher registry it speaks to.
This is a DRIFT test, not a UI test: it reads `perf-tray.py` as text and checks
that every launcher key the tray asks for is a key `launchers.py` actually has.
Nothing here proves the submenu appears in a real status area — only a desktop
session can say that, and this file does not pretend otherwise.
Why it is worth having at all: the tray asks for its launchers BY NAME, at click
time, inside a `try` that is deliberately forgiving (a status label must never
take the menu down). So a renamed or removed launcher row does not fail here, it
fails in a dark room, as a menu entry that does nothing — the exact shape of the
bug #69 in perf-tray.py was filed about.
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "tools" / "bridge"))
import launchers as L # noqa: E402
import midiviz as V # noqa: E402
TRAY = (ROOT / "perf-tray.py").read_text()
def test_every_launcher_key_the_tray_asks_for_exists():
keys = set(re.findall(r'_launch\(\s*[fr]?["\']([a-z0-9-]+)["\']', TRAY))
# the themed rows are built from a theme name: `self._launch(f"midimon-{theme}")`
templates = re.findall(r'_launch\(\s*f["\']([a-z0-9-]*)\{theme\}["\']', TRAY)
for prefix in templates:
keys |= {prefix + t for t in V.THEMES}
assert keys, "no launcher keys found in perf-tray.py — has the idiom changed?"
missing = sorted(k for k in keys if k not in L.BY_KEY)
assert not missing, (
f"perf-tray.py offers launcher rows that launchers.py does not have: "
f"{missing}. A click on one of those is a menu entry that does nothing.")
def test_the_midi_submenu_offers_every_page_the_window_can_be_on():
"""The submenu exists because `--theme` is chosen at OPEN time. A theme
that midiviz grew and the tray never heard about is a page PLN cannot get
to from the one UI he uses mid-set."""
assert "self.midi_menu" in TRAY, "the tray has no MIDI submenu"
assert "on_midimon_theme" in TRAY, "nothing in the tray opens a themed monitor"
rows = re.search(r"for theme, blurb in \((.*?)\):", TRAY, re.S)
assert rows, "could not find the tray's theme rows"
offered = set(re.findall(r'\("(\w+)",', rows.group(1)))
assert offered == set(V.THEMES), \
f"the tray offers {sorted(offered)} and midiviz has {sorted(V.THEMES)}"
def test_the_relaunch_path_stops_before_it_launches():
"""Order matters, and it is asserted on the SOURCE because driving it for
real would stop PLN's running monitor. `stop()` confirms rather than
assumes (a hand-started window is not the systemd unit), so a failed stop
has to return early — two lenses on one port is a worse state than an
unreadable one."""
body = TRAY.split("def on_midimon_theme", 1)[1].split("\n def ", 1)[0]
stop_at, launch_at = body.find("LA.stop("), body.find("self._launch(")
assert 0 <= stop_at < launch_at, \
"the themed relaunch launches before it stops, or does not stop at all"
early = body.find("return", stop_at)
assert 0 <= early < launch_at, \
"a stop that reported failure falls through to a second launch"
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment