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")
......
......@@ -24,8 +24,10 @@ else".
python3 midiviz.py --selftest # 3 s of synthetic events, headless
python3 midiviz.py --spectro # + the master-bus spectrum, behind
Keys: q / Esc quit · p pause · c clear · s spectrum · t pin · d theme · +/- resize.
Drag anywhere to move; RIGHT-CLICK for the menu (also in the system tray).
Keys: q / Esc quit · p pause · c clear · s spectrum · t pin · d theme ·
+/- type density · 0 back to the birth size.
Drag the body to move, the edges and corners to resize (the type follows the
window); RIGHT-CLICK for the menu (also in the system tray).
Design notes worth keeping
--------------------------
......@@ -52,6 +54,13 @@ Design notes worth keeping
(real `setShortcut`s were left out on purpose — Qt would intercept the key
before `keyPressEvent`, moving every key onto a path `--selftest` does not
drive).
* **The window's SIZE is the window manager's; its DENSITY is the window's.**
The scale knob used to be both, so any geometry a tiling WM handed out was
thrown away by the next theme cycle. Now the type is sized from the real
`width()`/`height()` (a fit factor against the size the window was born at),
the knob multiplies that and resizes nothing, and the four edges and corners
are grab zones. Only startup and "Reset size" ever call `resize()` — see the
FIT_MIN block.
* **Theme and scale survive a restart**, in `$XDG_CONFIG_HOME/parvagues/
midiviz.json`. Every read and write is wrapped and the loader cannot hand
back anything unusable: a choice you have to re-make each launch is a choice
......@@ -407,6 +416,14 @@ THEMES: dict[str, Theme] = {
# key has a direction and "one more press" means "one step sunnier".
THEME_ORDER: tuple[str, ...] = ("dark", "light", "sun")
DEFAULT_THEME = "dark"
# What each page is FOR, for the menu only. `dark`/`light`/`sun` stay the
# vocabulary of the config file, the `--theme` flag and every test; a menu row
# read in a hurry outdoors gets to say the thing the word is short for.
THEME_MENU: dict[str, str] = {
"dark": "Dark · the cockpit, indoors",
"light": "Light · a pale desktop",
"sun": "Sun · daylight, maximum ink",
}
TAU = 0.85 # seconds; brightness e-folding time
IDLE_AFTER = 2.0 # seconds of silence before the slow pulse
......@@ -421,6 +438,49 @@ FPS_SPECTRO = 54 # ms; ~18 fps, the spectrum's own rate
MAX_DROPS = 32
BASE_W, BASE_H = 420, 260
# ── the size belongs to the window manager, the density to the window ──────
# PLN, 2026-09-22: "only drag-> grab atm, can we have corner grabs to make it
# bigger, and have it you know squeeze and expand properly as we make it taller
# or wider, for proper tiling WM integration ?"
#
# `_apply_scale` used to end in `self.resize(BASE_W * scale, BASE_H * scale)`,
# which made the scale knob and the window's SIZE the same number. Under a
# tiling WM that is a fight the compositor loses at the worst moment: it picks
# a geometry, and the next theme cycle snaps the window back to BASE × scale
# (`d` re-measures the fonts whenever the bold weight changes) and throws the
# tile away. Nothing announces it; the window just jumps mid-set.
#
# So the two are separate concerns now:
#
# * the TYPE follows the real window — a fit factor, min(w/ref, h/ref), so a
# taller AND wider window gets proportionally bigger glyphs and a squeezed
# one keeps its layout instead of clipping it. `min` and not an average on
# purpose: the tight axis is the one that clips, so it is the one that
# decides;
# * the SCALE knob is an explicit multiplier on top of that, and resizes
# nothing. Only the startup geometry and "Reset size" ever call resize().
#
# The reference is the size the window was BORN at (BASE × the remembered
# scale), captured once and never moved: at birth the fit is exactly 1.0, so
# the type is pixel-for-pixel what this file has always drawn at that scale,
# and every reading after that is relative to the size PLN himself chose.
# FIT_MIN is deliberately well below the smallest window the knob can ask for
# (MIN_W/MIN_H is 0.57 of BASE, so at 1.0× the floor is never reached). It earns
# its keep at the OTHER end: at 2.4× with the window crushed into a narrow tile,
# a shallower floor is what lets the squeeze keep winning over the knob, instead
# of printing six rows of 15 px digits on top of each other in 150 px of height.
# Measured at 240×150 × 2.4: 1.32× before, 0.84× after, and `-` or `0` was
# always the way back either way.
FIT_MIN, FIT_MAX = 0.35, 2.4 # how far the window's own size may push the type
DENS_MIN, DENS_MAX = 0.5, 3.0 # and the absolute bounds once the knob is in
# A tile can be narrow; it must not be able to crush the lens into nothing.
# Below BASE × 0.6 (the smallest scale the knob offers) so the startup resize
# is never clamped — a clamped startup would make the density reference
# disagree with the window, and the fit would not be 1.0 at birth.
MIN_W, MIN_H = 240, 150
GRAB_PX = 7 # the edge resize band, in pixels
CORNER_PX = 20 # …widened to this at the four corners
def _glyph_for(event: str) -> tuple[str, int]:
"""Event name → (glyph, colour family). The only place a name is inspected."""
......@@ -956,6 +1016,9 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._interval = FPS_ACTIVE
self._rng = random.Random(0xC0FFEE)
self._drag_from = None
self._resize_edges = Qt.Edge(0) # which edges a press grabbed; 0 = body
self._resize_from = None # (press point, geometry) for the manual path
self._grab_hot = Qt.Edge(0) # edges under the pointer, for the ticks
self.cc: dict[int, list] = {} # cc -> [value, t_last, channel]
self.col_t = [0.0] * 9 # column 1..8 last-touch, for the glow
......@@ -974,6 +1037,30 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._spec_peak: list[float] = []
self._spec_hot_t = 0.0 # last time the mix was audibly awake
# The only pixel size this file chooses on its own, and the
# reference every later density reading is measured against. The
# fit being exactly 1.0 at birth is what keeps this whole change
# invisible to an eye trained on the old look.
#
# Set BEFORE the resize, because a resize can deliver its own
# event, and `resizeEvent` re-measures everything: a reference that
# does not exist yet would be an AttributeError at startup on any
# platform that sends that event synchronously.
self._ref_w = max(1, round(BASE_W * self.scale))
self._ref_h = max(1, round(BASE_H * self.scale))
self.dens = self.scale
self._metrics_key = None # (main px, micro px, bold) last measured
self.setMinimumSize(MIN_W, MIN_H)
self.resize(self._ref_w, self._ref_h)
# …then read it BACK off the widget. If a minimumSize ever clamped
# the startup geometry, a computed reference would disagree with
# the real window and the fit would not be 1.0 at birth.
self._ref_w, self._ref_h = max(1, self.width()), max(1, self.height())
# Hover must reach mouseMoveEvent with no button held, or neither
# the resize cursors nor the header controls' own brightening ever
# happen: without this Qt only delivers moves during a drag.
self.setMouseTracking(True)
self._build_palette()
self._apply_scale()
# The menu exists even when nothing will ever open it (headless,
......@@ -1152,21 +1239,66 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
f.setBold(self.theme.bold)
return f
# ── density, which is NOT the window's size ────────────────────────
# See the FIT_MIN block up top for why these are two numbers. Both
# helpers take an explicit (w, h) so the shape of the window can be
# asked about without resizing it — which is what the tests do, and
# what "does the type grow with the window" has to mean to be checkable
# at all.
def _fit(self, w=None, h=None) -> float:
"""How big the window is, relative to the one it was born at."""
w = self.width() if w is None else w
h = self.height() if h is None else h
f = min(max(1, w) / self._ref_w, max(1, h) / self._ref_h)
return max(FIT_MIN, min(FIT_MAX, f))
def _density(self, w=None, h=None) -> float:
"""The type scale: the window's own fit times the explicit knob."""
return max(DENS_MIN, min(DENS_MAX, self._fit(w, h) * self.scale))
def _apply_scale(self):
s = self.scale
self.f_main = self._font(round(11 * s))
self.f_micro = self._font(round(8 * s))
"""Fonts, metrics and layout for the CURRENT size, knob and weight.
Idempotent, and it never calls resize(): `resizeEvent` runs this,
and a resize() from inside a resize handler is how a frameless
window ends up oscillating between two geometries for ever. The
name stays `_apply_scale` because the knob still comes through
here; what left is the geometry.
"""
self.dens = d = self._density()
px, upx = max(5, round(11 * d)), max(5, round(8 * d))
key = (px, upx, self.theme.bold)
if key != self._metrics_key:
# Re-measured only when the FACE actually changed. The weight
# belongs in that key because `set_theme` calls this precisely
# when bold flips at an unchanged pixel size, and the ribbon's
# run concatenation assumes a measured advance (see `_font`).
self._metrics_key = key
self.f_main = self._font(px)
self.f_micro = self._font(upx)
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 reset_size(self):
"""Back to the size the window was born at — BASE × the knob.
The one deliberate pixel-size action in the file, and the only
resize() outside startup. It exists so that a tile experiment, or a
compositor that parked the lens at 3000 px wide, is one menu entry
(or `0`) away from the shape PLN knows, without the scale knob
having to secretly mean "size" again.
"""
self.resize(self._ref_w, self._ref_h)
self._apply_scale()
self.update()
def _relayout(self):
w, h = max(80, self.width()), max(60, self.height())
s = self.scale
s = self.dens
self.pad = max(3, round(5 * s))
gap = max(2, round(4 * s))
self.hdr_y = self.pad
......@@ -1203,7 +1335,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pm = QtGui.QPixmap(w, h)
pm.fill(QtGui.QColor(0, 0, 0, 0))
p = QtGui.QPainter(pm)
step = max(2, round(3 * self.scale))
step = max(2, round(3 * self.dens))
hz, vt = self.theme.scan
p.setPen(QtGui.QColor(*hz))
for y in range(0, h, step):
......@@ -1223,7 +1355,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
p.drawPixmap(0, 0, self._scanlines())
def resizeEvent(self, _e):
self._relayout()
# Density follows the window, so a resize is a full re-measure and
# not just a re-layout -- that is the whole of "squeeze and expand
# properly". `_apply_scale` is idempotent and calls no resize(),
# so re-entering here is not a loop.
self._apply_scale()
# ── ingest ─────────────────────────────────────────────────────────
def ingest(self, ev: dict) -> None:
......@@ -1461,12 +1597,28 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
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))
# The four corner ticks were always here; they are now also the
# resize affordance, because the alternative is new furniture on a
# surface that is looked at for two hours. Quiet at rest, brighter
# and longer while the pointer is in a grab band — an affordance
# that costs nothing when nobody is reaching for it.
hot = bool(self._grab_hot)
p.setPen(self.chrome[13 if hot else 6 + int(5 * pulse)])
n = max(4, round(8 * self.dens))
if hot:
n = round(n * 1.6)
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)
# Three hairlines in the bottom-right, the one corner every desktop
# has trained a hand to reach for. Dim as the CC reference layer:
# findable when looked for, never competing with the values.
p.setPen(self.chrome[11 if hot else 5])
step = max(3, round(3 * self.dens))
for k in range(3):
o = step * (k + 1)
p.drawLine(w - 2 - o, h - 2, w - 2, h - 2 - o)
# ── the two controls, top right ────────────────────────────────────
#
......@@ -1538,7 +1690,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# 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))
bw = max(1, round(self.dens))
# stop short of the chrome, or the bars draw under the controls
right = self._ctrl_left() - round(self.uw)
avail = max(4, int(max(0, right - hx) / (bw + 1)))
......@@ -1555,7 +1707,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
x += bw + 1
# state pips instead of words: paused, stream broken
pw = max(2, round(2 * self.scale))
pw = max(2, round(2 * self.dens))
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:
......@@ -1774,10 +1926,19 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._rescale(+0.15)
elif k in (Qt.Key.Key_Minus, Qt.Key.Key_Underscore):
self._rescale(-0.15)
elif k == Qt.Key.Key_0:
self.reset_size()
else:
super().keyPressEvent(e)
def _rescale(self, d):
"""Move the density knob by `d`, and resize NOTHING.
`+` used to mean "make the window bigger" and therefore also "make
the type bigger", one press for both. It now means only the second
one: the size is the WM's (and the corner grabs'), the type is
this. See the FIT_MIN block.
"""
s = max(0.6, min(2.4, round((self.scale + d) * 100) / 100))
if s != self.scale:
self.scale = s
......@@ -1803,6 +1964,47 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# shortcut machinery intercepts a key before `keyPressEvent` ever sees
# it, so installing them would silently move every key onto a path
# --selftest does not drive. The hint is a hint; the key stays the key.
def _section(self, menu, title):
"""A dim, unclickable header row. Same idiom as perf-tray's gear
headers: the separation IS the design, and a reader who sees one
flat list learns nothing from it twice."""
a = QtGui.QAction(title, menu)
a.setEnabled(False)
menu.addAction(a)
return a
def _swatch(self, th):
"""A chip of one theme's page and ink — painted, never loaded.
From the Theme's own RAMPS and not from `self.lut`: the LUTs only
ever hold the palette that is currently up, so swatches built from
them would have been three identical chips, which is worse than
none (it would say "these look the same" about the one axis PLN
opens this menu for).
"""
n = 36
pm = QtGui.QPixmap(n, n)
pm.fill(QtGui.QColor(*th.bg))
p = QtGui.QPainter(pm)
try:
p.setPen(Qt.PenStyle.NoPen)
# Three of the grid's real family hues, at three decay levels:
# the chip is a miniature of what the page will look like, so
# `sun`'s near-black ink on near-white and `dark`'s violet glow
# on black read as the two different pages they are.
for i, fam in enumerate((FAM_LEVEL, FAM_FX, FAM_NOTE)):
val, sat = th.ink.at(1.0 - i * 0.30)
p.setBrush(QtGui.QColor.fromHsvF(FAM_HUE[fam] / 360.0, sat, val))
p.drawRect(6, 5 + i * 9, n - 13 - i * 4, 6)
edge = th.chrome or th.ink
val, sat = edge.at(0.55)
p.setBrush(Qt.BrushStyle.NoBrush)
p.setPen(QtGui.QColor.fromHsvF(th.chrome_hue / 360.0, sat, val))
p.drawRect(0, 0, n - 1, n - 1)
finally:
p.end()
return QtGui.QIcon(pm)
def _build_menu(self):
# Parentless, like perf-tray's: the tray has to be able to pop this
# up while the window is HIDDEN, and a popup whose parent is not
......@@ -1813,22 +2015,51 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._theme_acts = {}
m = self.menu
th = m.addMenu("Theme")
# PLN, 2026-09-22: "midimon still unreadable on the sun ... right
# click on window -> big modern contextual menu to quickly make it
# color ?"
#
# So the theme is not a submenu any more. It is the FIRST thing in
# the menu, at the top level, one row per page with that page's own
# colours painted into a swatch — because the reason this menu gets
# opened at all, outdoors, with a set starting, is "make it
# readable right now", and a nested Theme ▸ costs a hover, a
# sub-popup and a second aim at a row labelled with a bare word.
# Everything else keeps its place below it.
f = m.font()
f.setPointSizeF(max(10.5, f.pointSizeF() * 1.2))
m.setFont(f)
self._section(m, "◆ PAGE — what it looks like")
grp = QtGui.QActionGroup(self)
grp.setExclusive(True)
for name in THEME_ORDER:
a = QtGui.QAction(name, self)
a = QtGui.QAction(THEME_MENU[name], self)
a.setCheckable(True)
a.setIcon(self._swatch(THEMES[name]))
# Some styles drop an icon on a checkable row and show only the
# tick. The swatch is the point of the row, so ask for it.
a.setIconVisibleInMenu(True)
a.triggered.connect(lambda _c=False, n=name: self.set_theme(n))
grp.addAction(a)
th.addAction(a)
m.addAction(a)
self._theme_acts[name] = a
th.addSeparator()
self._act_cycle = QtGui.QAction("Cycle\td", self)
self._act_cycle = QtGui.QAction("Next page\td", self)
self._act_cycle.triggered.connect(lambda _c=False: self.cycle_theme())
th.addAction(self._act_cycle)
m.addAction(self._act_cycle)
sc = m.addMenu("Scale")
m.addSeparator()
# The size axis, and it says out loud which half of it is which:
# the window's shape is the WM's business, the type's weight is
# this menu's. Bigger/Smaller sit at the top level because they are
# the two rows anybody actually clicks twice in a row.
self._section(m, "◆ TYPE — the window's own size is the WM's:"
" drag an edge or a corner")
for label, delta in (("Bigger type\t+", +0.15), ("Smaller type\t-", -0.15)):
a = QtGui.QAction(label, self)
a.triggered.connect(lambda _c=False, d=delta: self._rescale(d))
m.addAction(a)
sc = m.addMenu("Density multiplier")
self._scale_acts = {}
sgrp = QtGui.QActionGroup(self)
sgrp.setExclusive(True)
......@@ -1839,13 +2070,12 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
sgrp.addAction(a)
sc.addAction(a)
self._scale_acts[s] = a
sc.addSeparator()
for label, delta in (("Bigger\t+", +0.15), ("Smaller\t-", -0.15)):
a = QtGui.QAction(label, self)
a.triggered.connect(lambda _c=False, d=delta: self._rescale(d))
sc.addAction(a)
self._act_reset = QtGui.QAction("Reset size\t0", self)
self._act_reset.triggered.connect(lambda _c=False: self.reset_size())
m.addAction(self._act_reset)
m.addSeparator()
self._section(m, "◆ STREAM")
self._act_pause = QtGui.QAction("Pause\tp", self)
self._act_pause.setCheckable(True)
self._act_pause.triggered.connect(lambda _c=False: self.toggle_pause())
......@@ -1864,7 +2094,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
m.addAction(self._act_pin)
m.addSeparator()
self._port_menu = m.addMenu("Source")
self._section(m, "◆ WINDOW")
self._port_menu = m.addMenu("MIDI source")
self._act_show = QtGui.QAction("Show window", self)
self._act_show.setCheckable(True)
self._act_show.triggered.connect(
......@@ -1897,7 +2128,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if m is None: # during __init__, before the menu exists
return
for name, act in self._theme_acts.items():
act.setChecked(name == self.theme.name)
on = name == self.theme.name
act.setChecked(on)
# A checkable row that also carries an ICON loses its tick
# under most Qt styles: the check indicator and the icon share
# one column, and all that is left is a faint frame around the
# swatch, which is not a thing anybody reads at a glance in a
# dark room. So the state is also a glyph in the label — the
# same reason perf-tray labels its rig rows ● / ○ instead of
# trusting colour it does not control.
act.setText(("● " if on else "○ ") + THEME_MENU[name])
for s, act in self._scale_acts.items():
act.setChecked(abs(s - self.scale) < 1e-9)
self._act_pause.setChecked(self.paused)
......@@ -2040,6 +2280,103 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.show()
self.update()
# ── the grab zones ─────────────────────────────────────────────────
# PLN, 2026-09-22: "only drag-> grab atm, can we have corner grags to
# make it bigger".
#
# A hit test against a margin, NOT four QSizeGrips, and the reasons are
# worth writing down because QSizeGrip is the obvious answer:
#
# * a QSizeGrip is a child WIDGET. This canvas is
# WA_OpaquePaintEvent and paints every pixel itself; a grip would
# draw its style's little triangle on top of the picture, in the
# desktop palette, permanently — furniture on a performance
# surface, and invisible or garish depending on the theme;
# * a grip only ever gives the CORNERS, and "squeeze it narrower"
# means the EDGES. Eight zones out of one margin is the same
# dozen lines as four grips;
# * one hit test is shared by the press, the hover cursor and the
# corner ticks the painter draws, so what is grabbable and what
# LOOKS grabbable cannot drift apart. Two mechanisms drifting is
# exactly the bug `_ctrl_rects` exists to prevent one size up.
EDGE_L, EDGE_R = Qt.Edge.LeftEdge, Qt.Edge.RightEdge
EDGE_T, EDGE_B = Qt.Edge.TopEdge, Qt.Edge.BottomEdge
def _edges_at(self, pos):
"""Which window edges this point grabs. Qt.Edge(0) means the body.
Or-ed flags, so a corner is literally two edges — which is also
what `startSystemResize` wants.
"""
x, y = pos.x(), pos.y()
w, h = self.width(), self.height()
g = GRAB_PX
e = Qt.Edge(0)
if 0 <= x < g:
e |= self.EDGE_L
elif w - g <= x < w:
e |= self.EDGE_R
if 0 <= y < g:
e |= self.EDGE_T
elif h - g <= y < h:
e |= self.EDGE_B
if not e:
return e
# A corner is far easier to aim at than a 7 px strip, and it is the
# gesture that means "make it bigger", so the last CORNER_PX along
# the OTHER axis counts as a corner even where only one band was
# hit. Every desktop does this; a 7×7 px corner would be a joke
# mid-set.
horiz, vert = e & (self.EDGE_L | self.EDGE_R), e & (self.EDGE_T | self.EDGE_B)
if horiz and not vert:
if y < CORNER_PX:
e |= self.EDGE_T
elif y >= h - CORNER_PX:
e |= self.EDGE_B
elif vert and not horiz:
if x < CORNER_PX:
e |= self.EDGE_L
elif x >= w - CORNER_PX:
e |= self.EDGE_R
return e
def _grab_cursor(self, edges):
"""The cursor for a grab zone — the only hover hint a frameless
window gets, and the thing that says "this edge is live"."""
C = Qt.CursorShape
if edges in (self.EDGE_L | self.EDGE_T, self.EDGE_R | self.EDGE_B):
return C.SizeFDiagCursor
if edges in (self.EDGE_R | self.EDGE_T, self.EDGE_L | self.EDGE_B):
return C.SizeBDiagCursor
if edges in (self.EDGE_L, self.EDGE_R):
return C.SizeHorCursor
if edges in (self.EDGE_T, self.EDGE_B):
return C.SizeVerCursor
return C.SizeAllCursor # the body: still drag-to-move
def _resized_rect(self, r0, d):
"""The geometry a manual drag of `self._resize_edges` asks for.
Clamped to minimumSize on the way, and the moving edge is the one
that gives: dragging the LEFT edge right must shrink the window and
leave the right edge where it is, which means the position moves
too. That is why this returns a whole rect and not a size.
"""
x, y, w, h = r0.x(), r0.y(), r0.width(), r0.height()
mw, mh = max(1, self.minimumWidth()), max(1, self.minimumHeight())
e = self._resize_edges
if e & self.EDGE_L:
nw = max(mw, w - d.x())
x, w = x + w - nw, nw
elif e & self.EDGE_R:
w = max(mw, w + d.x())
if e & self.EDGE_T:
nh = max(mh, h - d.y())
y, h = y + h - nh, nh
elif e & self.EDGE_B:
h = max(mh, h + d.y())
return QtCore.QRect(x, y, w, h)
def mousePressEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton:
# Controls win over the drag. The whole surface is a drag
......@@ -2055,6 +2392,29 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._set_on_top(not self.on_top)
self._drag_from = None
return
# Controls first, THEN the edges, then the body. The X's hit
# rect reaches up into the top grab band (its y is the header
# pad minus the slack), and a click meant for the close must
# never turn into a resize.
edges = self._edges_at(pos)
if edges:
self._drag_from = None
self._resize_edges = edges
wh = self.windowHandle()
if wh is not None and wh.startSystemResize(edges):
# The compositor owns the drag from here — same deal as
# startSystemMove below, and the only path Wayland
# allows. Nothing left for us to track.
self._resize_edges = Qt.Edge(0)
self._resize_from = None
return
# Only reached where the compositor said no (offscreen, or
# an X11 server that refuses): do it by hand from the
# press-time geometry. This one moves the window as well as
# sizing it, which a Wayland client may not do — hence the
# order: ask the compositor first, always.
self._resize_from = (e.globalPosition().toPoint(), self.geometry())
return
# Wayland forbids a client positioning itself; startSystemMove is
# the compositor-blessed path. X11 falls back to a manual move.
wh = self.windowHandle()
......@@ -2067,18 +2427,45 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pin, close = self._ctrl_rects()
pos = e.position().toPoint()
hot = pin.contains(pos) or close.contains(pos)
if hot != self._chrome_hot:
self._chrome_hot = hot
# An arrow over the controls, the move cursor everywhere else:
# the cursor is the only hint a frameless window can give.
# The controls sit inside the top band, so they take the point
# before the grab test does — the hover hint has to agree with what
# the press will actually do.
edges = Qt.Edge(0) if hot else self._edges_at(pos)
dragging = self._drag_from is not None or self._resize_from is not None
if not dragging and (hot != self._chrome_hot or edges != self._grab_hot):
self._chrome_hot, self._grab_hot = hot, edges
# An arrow over the controls, a resize arrow in a grab zone,
# the move cursor everywhere else: the cursor is the only hint
# a frameless window can give.
self.setCursor(Qt.CursorShape.ArrowCursor if hot
else Qt.CursorShape.SizeAllCursor)
else self._grab_cursor(edges))
self.update()
if self._resize_from is not None:
g0, r0 = self._resize_from
self.setGeometry(
self._resized_rect(r0, e.globalPosition().toPoint() - g0))
return
if self._drag_from is not None:
self.move(e.globalPosition().toPoint() - self._drag_from)
def mouseReleaseEvent(self, _e):
self._drag_from = None
self._resize_edges = Qt.Edge(0)
self._resize_from = None
def leaveEvent(self, _e):
"""Back to rest when the pointer leaves.
You leave a frameless window by crossing an EDGE, so the last move
this widget ever sees is inside a grab band: without this the
corner ticks stay long and bright and the cursor stays a resize
arrow for the rest of the set. The affordance is only quiet if it
also knows how to stop.
"""
if self._grab_hot or self._chrome_hot:
self._grab_hot, self._chrome_hot = Qt.Edge(0), False
self.setCursor(Qt.CursorShape.SizeAllCursor)
self.update()
def closeEvent(self, e):
self.timer.stop()
......@@ -2336,8 +2723,9 @@ def selftest(seconds: float = 3.0, show: bool = False,
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"):
# exercise the keyboard paths too (pause, unpause, clear, both density
# steps, and back to the birth size)
for key in ("Key_P", "Key_P", "Key_C", "Key_Plus", "Key_Minus", "Key_0"):
w.keyPressEvent(QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress,
getattr(QtCore.Qt.Key, key),
QtCore.Qt.KeyboardModifier.NoModifier))
......@@ -2345,6 +2733,33 @@ def selftest(seconds: float = 3.0, show: bool = False,
w.tick()
app.processEvents()
w.grab()
# ── the tiling-WM shapes, painted for real ────────────────────────────
# The density now comes from the WINDOW, so the shapes a tiling WM hands
# out are a real code path and not a hypothetical: a font that never
# re-measures, a gutter that no longer fits or a layout that clips is a
# paint bug the old fixed-size selftest could not have seen. Base, large,
# tall/narrow, short/wide -- and the type has to actually follow, which is
# what the density comparison at the bottom checks.
shapes: dict[str, float] = {}
shapes_ok = True
for sw, sh in ((900, 560), (1600, 1000), (600, 900), (1400, 420)):
w.resize(sw, sh)
app.processEvents()
w.tick()
img = w.grab().toImage()
shapes["%dx%d" % (sw, sh)] = round(w.dens, 2)
if img.width() != sw or img.height() != sh:
shapes_ok = False
# The column digits live in their own band and the ribbon in the
# footer; both have to still be inside the window at every shape.
if not (0 < w.col_y < w.foot_y < sh and w.mx < w.gut_x <= sw):
shapes_ok = False
shapes_ok = shapes_ok and shapes["1600x1000"] > shapes["600x900"]
w.reset_size()
app.processEvents()
shapes_ok = shapes_ok and (w.width(), w.height()) == (w._ref_w, w._ref_h)
# Every theme, painted for real. `_build_palette` is re-entrant by
# construction and the scanline pixmap is invalidated with it -- both are
# the kind of claim that is true until it is not, and a grab per theme is
......@@ -2383,13 +2798,14 @@ def selftest(seconds: float = 3.0, show: bool = False,
ok = (grabs >= 30 and len(colours) >= 8 and painted >= grabs
and spec_ok and bars >= SPEC_BANDS // 2 and spec_frames >= 20
and released and themes_ok and cfg_ok)
and released and themes_ok and cfg_ok and shapes_ok)
print("midiviz selftest: theme=%s parsed=%d ingested=%d frames=%d paints=%d "
"grabs=%d distinct_sampled_colours=%d themes[%s] config[%s] "
"spectro[frames=%d bars=%d released=%s %s] platform=%s -> %s"
"grabs=%d distinct_sampled_colours=%d themes[%s] shapes[%s ok=%s] "
"config[%s] spectro[frames=%d bars=%d released=%s %s] platform=%s -> %s"
% (theme, len(parsed), w.ingested, w.frames, painted, grabs,
len(colours),
" ".join("%s:%d" % kv for kv in sorted(theme_paints.items())),
" ".join("%s:%.2f×" % kv for kv in shapes.items()), shapes_ok,
cfg_notes, spec_frames, bars, released, spec_notes,
os.environ.get("QT_QPA_PLATFORM", "native"), "PASS" if ok else "FAIL"))
return 0 if ok else 1
......
......@@ -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
......@@ -728,8 +728,11 @@ def test_the_menu_only_mirrors_actions_the_keyboard_already_had():
assert not w.cc, "c did not clear"
s0 = w.scale
size0 = (w.width(), w.height())
press("Key_Plus")
assert w.scale > s0, "+ did not grow the window"
assert w.scale > s0, "+ did not grow the type"
assert (w.width(), w.height()) == size0, \
"+ resized the window: the size belongs to the WM now"
press("Key_Minus")
assert abs(w.scale - s0) < 1e-9, "+ then - did not return to the same scale"
assert w._scale_acts[s0].isChecked(), \
......@@ -752,10 +755,17 @@ def test_the_menu_only_mirrors_actions_the_keyboard_already_had():
assert any("Always on top" in t for t in labels)
def test_quit_from_the_menu_records_a_deliberate_close():
def test_quit_from_the_menu_records_a_deliberate_close(monkeypatch, tmp_path):
"""The tray's Quit must latch exactly like `q` does, or systemd's converge
puts the window straight back and PLN reports "too sticky" a third time."""
puts the window straight back and PLN reports "too sticky" a third time.
XDG_RUNTIME_DIR is redirected because this drives a REAL deliberate close:
without it, every suite run wrote `midiviz.closed` into the live runtime
dir, which is the one file that tells rig_units.ensure() not to bring the
monitor back. A test is not allowed to have an opinion about that.
"""
import pytest
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
......@@ -914,3 +924,279 @@ def test_a_saved_theme_and_scale_come_back_on_the_next_window(monkeypatch,
w2._rescale(-0.15)
assert mv.load_config() == {"theme": None, "scale": None}, \
"a widget built with persist=False rewrote the human's saved choice"
# ── the size is the WM's, the density is the window's ──────────────────────
#
# `_apply_scale` used to end in `resize(BASE_W * scale, BASE_H * scale)`, so
# every theme cycle that flipped the bold weight threw away whatever geometry
# the tiling WM had handed out. These pin the two halves of the fix separately,
# because each half is invisible on its own: type that does not follow the
# window looks like a font bug, and a window that snaps back looks like a WM
# bug, and the same line caused both.
def test_the_type_follows_the_window_and_stops_at_the_clamp():
"""Bigger window, bigger glyphs — and a bound at each end, or a 4K tile
would ask for a 90 px face and a 200 px-tall one for a 3 px face."""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
ref = (w._ref_w, w._ref_h)
assert w._fit(*ref) == 1.0, \
"the window is not 1.0× of the size it was born at — the density " \
"reference disagrees with the window, so nothing below means anything"
small = w._density(round(ref[0] * 0.7), round(ref[1] * 0.7))
base = w._density(*ref)
big = w._density(ref[0] * 2, ref[1] * 2)
assert small < base < big, \
f"the type does not follow the window: {small} / {base} / {big}"
# The tight axis decides: a window stretched on ONE axis only must not
# grow the type, or a short/wide tile clips vertically.
assert w._density(ref[0] * 4, ref[1]) == base, \
"a wider-but-not-taller window grew the type — the tight axis has to win"
assert mv.DENS_MIN <= w._density(1, 1) <= mv.DENS_MAX
assert w._density(20_000, 20_000) <= mv.DENS_MAX, "the clamp does not hold"
assert w._density(1, 1) >= mv.DENS_MIN
# The knob is a multiplier ON TOP of the fit, not a replacement for it.
w.scale = 2.0
assert w._density(*ref) > base
def test_neither_the_knob_nor_the_theme_may_resize_the_window():
"""The tiling-WM bug itself. Cycling the theme re-runs `_apply_scale`
whenever the bold weight changes (`sun` is the bold one), and that is what
used to snap a tiled window back to BASE × scale."""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
w.resize(900, 560)
before = (w.width(), w.height())
px_before = w.f_main.pixelSize()
w._rescale(+0.15)
assert (w.width(), w.height()) == before, "the density knob resized the window"
assert w.f_main.pixelSize() > px_before, "the density knob changed nothing"
for name in mv.THEME_ORDER: # including the bold one
w.set_theme(name, save=False)
assert (w.width(), w.height()) == before, \
f"switching to {name} resized the window"
# …and the one action that IS allowed to, is allowed to.
w.reset_size()
assert (w.width(), w.height()) == (w._ref_w, w._ref_h), \
"Reset size did not return the window to the size it was born at"
def test_the_metrics_are_idempotent_and_re_measured_when_the_weight_flips():
"""`resizeEvent` runs the whole re-measure, so it has to be free of
side effects — and it must NOT skip the one case where the pixel size is
unchanged and the FACE is not: `sun` is bold, and the ribbon's run
concatenation assumes a measured advance."""
import pytest
mv, w = _themed("light")
if mv is None:
pytest.skip("no Qt available")
w.resize(880, 540)
w._apply_scale()
first = (w.mw, w.mh, w.uw, w.uh, w.gut_x, w.cw, w.ch)
w._apply_scale()
assert (w.mw, w.mh, w.uw, w.uh, w.gut_x, w.cw, w.ch) == first, \
"re-applying the same size and scale moved the layout"
bold_before = w.f_main.bold()
w.set_theme("sun", save=False)
assert w.f_main.bold() != bold_before, \
"the bold weight did not reach the font: the metrics key skipped it"
def test_a_grab_zone_is_never_the_body():
"""Corner, edge, body — and a press in the BODY must never resize, or
dragging the lens across a screen would reshape it instead of moving it."""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
QtCore, _QtGui, _QtWidgets = mv._qt()
Qt = QtCore.Qt
w.resize(900, 560)
W, H = w.width(), w.height()
P = QtCore.QPoint
def at(x, y):
return w._edges_at(P(x, y))
L, R = Qt.Edge.LeftEdge, Qt.Edge.RightEdge
T, B = Qt.Edge.TopEdge, Qt.Edge.BottomEdge
assert at(0, 0) == L | T
assert at(W - 1, 0) == R | T
assert at(0, H - 1) == L | B
assert at(W - 1, H - 1) == R | B
# an edge, far from any corner, is exactly one edge
assert at(1, H // 2) == L
assert at(W - 1, H // 2) == R
assert at(W // 2, 0) == T
assert at(W // 2, H - 1) == B
for x, y in ((W // 2, H // 2), (mv.GRAB_PX + 2, H // 2),
(W // 2, mv.GRAB_PX + 3), (W - mv.GRAB_PX - 2, H // 2)):
assert not at(x, y), f"({x},{y}) is the body and it reported a resize edge"
# the body's cursor stays the move cursor; a corner gets a diagonal one
assert w._grab_cursor(at(W // 2, H // 2)) == Qt.CursorShape.SizeAllCursor
assert w._grab_cursor(at(0, 0)) == Qt.CursorShape.SizeFDiagCursor
assert w._grab_cursor(at(W - 1, 0)) == Qt.CursorShape.SizeBDiagCursor
assert w._grab_cursor(at(1, H // 2)) == Qt.CursorShape.SizeHorCursor
assert w._grab_cursor(at(W // 2, H - 1)) == Qt.CursorShape.SizeVerCursor
def test_the_close_control_wins_over_the_top_edge(monkeypatch, tmp_path):
"""At the birth size the X's hit rect starts INSIDE the top grab band, so
the order of the two tests in `mousePressEvent` is load-bearing: a click
meant for the close must close, never begin a resize. The latch goes to a
tmp runtime dir — a test must not tell the rig's converge that a human
closed the monitor."""
import pytest
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
QtCore, QtGui, _QtWidgets = mv._qt()
Qt = QtCore.Qt
pin, close = w._ctrl_rects()
assert close.top() < mv.GRAB_PX, (
"this test is moot at this size: the X no longer reaches into the top "
"grab band, so it proves nothing about the ordering")
# …and both controls stay clear of the two top CORNERS, which are the
# zones a hand aims at to make the window bigger.
assert pin.left() > mv.CORNER_PX and close.right() < w.width() - mv.CORNER_PX
p = close.center()
w.mousePressEvent(QtGui.QMouseEvent(
QtCore.QEvent.Type.MouseButtonPress,
QtCore.QPointF(p.x(), p.y()), QtCore.QPointF(p.x(), p.y()),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier))
assert w._user_closed, "a click on the X did not close the window"
assert not w._resize_edges and w._resize_from is None, \
"a click on the X armed a resize instead"
def test_a_body_drag_moves_and_leaves_the_geometry_alone():
"""A manual resize (the fallback path, when the compositor refuses) only
ever runs for a press that grabbed an edge."""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
QtCore, QtGui, _QtWidgets = mv._qt()
Qt = QtCore.Qt
w.resize(900, 560)
w.show()
def press(x, y):
return QtGui.QMouseEvent(
QtCore.QEvent.Type.MouseButtonPress,
QtCore.QPointF(x, y), QtCore.QPointF(x, y),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier)
w.mousePressEvent(press(w.width() // 2, w.height() // 2))
assert not w._resize_edges and w._resize_from is None, \
"a press in the body armed a resize"
w.mouseReleaseEvent(None)
# and the manual rect respects minimumSize, whichever edge is dragged
w._resize_edges = Qt.Edge.LeftEdge | Qt.Edge.TopEdge
r0 = QtCore.QRect(100, 100, 900, 560)
got = w._resized_rect(r0, QtCore.QPoint(10_000, 10_000))
assert got.width() >= w.minimumWidth() and got.height() >= w.minimumHeight(), \
f"a crushing drag got through the minimum size: {got}"
assert got.right() == r0.right() and got.bottom() == r0.bottom(), \
"dragging the left/top edge moved the opposite edge"
got = w._resized_rect(r0, QtCore.QPoint(-200, -100))
assert got.width() == 1100 and got.height() == 660
w._resize_edges = Qt.Edge.RightEdge | Qt.Edge.BottomEdge
got = w._resized_rect(r0, QtCore.QPoint(60, 40))
assert (got.x(), got.y()) == (100, 100), \
"dragging the right/bottom edge moved the window"
assert (got.width(), got.height()) == (960, 600)
def test_every_theme_gets_a_swatch_in_its_own_colours():
"""The menu's whole point is "make it colour, now", so a swatch that came
out of the CURRENT palette (three identical chips) would be worse than no
swatch at all."""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
pages = {}
for name, th in mv.THEMES.items():
act = w._theme_acts[name]
assert not act.icon().isNull(), f"{name} has no swatch"
img = act.icon().pixmap(36, 36).toImage()
assert img.width() >= 16, f"{name}'s swatch came back empty"
cols = {img.pixel(x, y) for x in range(4, img.width() - 4)
for y in range(4, img.height() - 4)}
assert len(cols) >= 3, \
f"{name}'s swatch is flat ({len(cols)} colours): it says nothing"
pages[name] = img.pixel(img.width() - 3, 3)
assert mv.THEME_MENU[name].split()[0].lower() in act.text().lower()
assert len(set(pages.values())) == len(pages), \
f"two themes painted the same page colour: {pages}"
def test_a_corner_press_really_arms_a_resize():
"""The one call in this file with a binding-type question in it.
`startSystemResize` takes the compositor's word for it and returns a bool;
the manual path only runs when it says no. Nothing else in the suite sends
a press into a grab band, so if PySide6 rejected the flag type, resizing
would be dead on the real desktop with every test still green. Offscreen
there IS no compositor, so the fallback is what gets exercised here —
which is also the X11 path.
"""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
QtCore, QtGui, _QtWidgets = mv._qt()
Qt = QtCore.Qt
w.resize(900, 560)
w.show()
def press(x, y):
w.mousePressEvent(QtGui.QMouseEvent(
QtCore.QEvent.Type.MouseButtonPress,
QtCore.QPointF(x, y), QtCore.QPointF(x, y),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier))
def move(gx, gy):
w.mouseMoveEvent(QtGui.QMouseEvent(
QtCore.QEvent.Type.MouseMove,
QtCore.QPointF(0, 0), QtCore.QPointF(gx, gy),
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier))
for x, y in ((0, 0), (w.width() - 1, w.height() - 1)):
press(x, y)
armed = w._resize_from is not None
assert armed or not w._resize_edges, (
"a corner press neither armed the manual resize nor handed the "
"drag to the compositor — startSystemResize rejected the edges")
w.mouseReleaseEvent(None)
assert w._resize_from is None and not w._resize_edges
# the bottom-right grab, dragged out: bigger, and the top-left corner of
# the window stays exactly where it was
before = w.geometry()
press(w.width() - 1, w.height() - 1)
if w._resize_from is None:
pytest.skip("the platform took the resize; nothing to assert by hand")
g0 = w._resize_from[0]
move(g0.x() + 80, g0.y() + 40)
assert w.width() > before.width() and w.height() > before.height(), \
"dragging the bottom-right corner did not grow the window"
assert (w.x(), w.y()) == (before.x(), before.y()), \
"dragging the bottom-right corner moved the window as well"
# …and the type followed, without anybody calling _apply_scale by hand
assert w.dens == w._density(), "the density did not follow the resize"
w.mouseReleaseEvent(None)
"""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