Commit 949b9926 by PLN (Algolia)

feat(midiviz): a sun theme, a menu, and a tray it can hide into

PLN: "midimon unusable in the sun in light mode :') can we have a basic menu
for midimon, maybe a tray presence when runnig, with scaling, theme, etc
options ? to help sets in all context, robust, antifragile".

Colours leave the paint loop and become data: three themes resolved once per
change, so a frame still constructs no QColor and the 30/5 fps split is intact.
The cut that made it work is INK versus SURFACE — recency (digits, bars, pens)
against cell bodies, column glow and gutter — because the docstring's own
invariant is that a mapped-but-idle cell must never read as a hardware fault.
On dark that is a glow above black; on light and sun it is a wash below the
page. A test asserts the floor tint differs from the page on all three.

`sun` needed two passes and only a screenshot said so: the first render climbed
to a saturated mid-tone panel and the digits lost the fight against it — light
with fatter bars, not a sun theme. Flattened tint, near-black ink at every
decay level, and a bold face, which outdoors is the cheapest contrast there is.
The decayed state was shot on purpose, since "no subtle dim states" is exactly
about the moment after a movement ends.

Menu and tray are one QMenu, and every entry calls the same method the key
does — no second code path, and no setShortcut, which would have taken the keys
off keyPressEvent and out of the selftest's reach. Tray guarded on
isSystemTrayAvailable and silently absent otherwise; a hidden window drops to
idle fps and X/q/Quit still really exit.

Config at $XDG_CONFIG_HOME/parvagues/midiviz.json, temp-file plus os.replace,
and load cannot raise: missing, corrupt, not-a-dict, unknown-theme, NaN scale
and unwritable are each exercised. A monitor that dies on a bad config file is
worse than one with no config, and this one is meant for the middle of a set.

Verified: bridge tests 105 -> 121 passing (the single failure is the inherited
test_gearbox dash case, identical before and after); selftest PASS on all three
themes; and dark proven pixel-identical to HEAD at four scales by rendering
both versions side by side.

NOT verified, and not to be claimed: the tray on a real desktop. Headless has
no status area, so only the degrade path was proven. One known follow-up — a
window hidden to the tray is still alive, so the hub's focus_window does
nothing for it and the tray is the only recall path.
parent 3827b629
...@@ -20,11 +20,12 @@ else". ...@@ -20,11 +20,12 @@ else".
python3 midiviz.py # auto-resolve the surface, watch it python3 midiviz.py # auto-resolve the surface, watch it
python3 midiviz.py -p 28:0 # pin a source port python3 midiviz.py -p 28:0 # pin a source port
python3 midiviz.py -l # list ports, exit python3 midiviz.py -l # list ports, exit
python3 midiviz.py --theme sun # near-white, heavy ink, for playing outdoors
python3 midiviz.py --selftest # 3 s of synthetic events, headless python3 midiviz.py --selftest # 3 s of synthetic events, headless
python3 midiviz.py --spectro # + the master-bus spectrum, behind python3 midiviz.py --spectro # + the master-bus spectrum, behind
Keys: q / Esc quit · p pause · c clear · s spectrum · t pin · +/- resize. Keys: q / Esc quit · p pause · c clear · s spectrum · t pin · d theme · +/- resize.
Drag anywhere to move. Drag anywhere to move; RIGHT-CLICK for the menu (also in the system tray).
Design notes worth keeping Design notes worth keeping
-------------------------- --------------------------
...@@ -32,7 +33,30 @@ Design notes worth keeping ...@@ -32,7 +33,30 @@ Design notes worth keeping
painted with a floor tint even when nothing has ever arrived on it, because 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 "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 that actually *is* mapped reads as a hardware fault (the LED work settled
this). this). On a pale theme the same rule reads **must not be invisible**: the
floor becomes a wash *below* the page white rather than a glow above the
black, and `test_mapped_but_idle_is_visible_on_every_theme` holds every
theme to it.
* **Three themes, and the colours are data.** `dark` is the cockpit and is
bit-for-bit what it was before themes existed; `light` reads on a pale
desktop; `sun` is near-white with near-black ink at every decay level, thick
bars, a bold face and no CRT texture, for a laptop on a table at noon. The
split that makes this work is INK (recency: digits, bars, pens) versus
SURFACE (the cell bodies) — see the THEMES block. Everything resolves into
the same precomputed LUTs, once per theme change; a frame still constructs no
QColor.
* **One menu, three ways to reach it, one implementation.** Right-click the
window, or use the tray icon: same QMenu, and every entry calls the method
the keyboard already called. The menu is discoverability for what exists, so
no entry gets its own copy of an action and the keys stay the tested path
(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).
* **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
you do not have, and a window that will not start because that file is
half-written is worse than one with no memory at all.
* **Two frame rates, not a busy loop.** This machine performs live audio. The * **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 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 drops to 5 fps and the picture becomes a slow dim pulse. Colours are a
...@@ -49,6 +73,7 @@ Design notes worth keeping ...@@ -49,6 +73,7 @@ Design notes worth keeping
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import math import math
import os import os
import random import random
...@@ -60,6 +85,7 @@ import threading ...@@ -60,6 +85,7 @@ import threading
import time import time
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
from typing import NamedTuple
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
TOOLS = HERE.parent TOOLS = HERE.parent
...@@ -233,6 +259,155 @@ ROLE_FAM = {"level": FAM_LEVEL, "fx": FAM_FX, "fx2": FAM_FX2, "gate": FAM_GATE, ...@@ -233,6 +259,155 @@ ROLE_FAM = {"level": FAM_LEVEL, "fx": FAM_FX, "fx2": FAM_FX2, "gate": FAM_GATE,
LEVELS = 16 # decay resolution of the colour LUT LEVELS = 16 # decay resolution of the colour LUT
BG = (0x0a, 0x06, 0x12) BG = (0x0a, 0x06, 0x12)
# ── themes ─────────────────────────────────────────────────────────────────
# PLN, 2026-09-22: "midimon unusable in the sun in light mode :')".
#
# The window was drawn once, for a dark room, and it is beautiful there. On a
# terrace at 14:00, or on a pale desktop, a cockpit is a black mirror. So the
# colours leave the paint loop and become DATA: three themes, resolved into the
# same precomputed LUTs `_build_palette` always built, exactly once per theme
# change. A frame still constructs no QColor -- that budget is what lets this
# thing run beside live audio and it is not up for renegotiation over legibility.
#
# Two ramps, and the split is the whole idea:
#
# * **INK** (`self.lut`, `self.chrome`) is everything the eye reads: digits,
# bars, pens, rain. Its axis is RECENCY, so on a dark theme it climbs from
# near-black to bright, and on a light one it DARKENS from pale to heavy --
# "hot" means "further from the page" either way.
# * **SURFACE** (`self.tint`) is the four cell-body shades. It exists because
# of the design invariant below, and because sampling the ink ramp for a
# panel is exactly what makes a light theme unreadable: a panel wants to
# stay near the page while the ink on it walks away from it.
#
# **Mapped-but-idle must not be black** generalises to **must not be invisible**.
# `tint[fam][0]` is the floor every one of the 48 mapped cells gets before
# anything has ever arrived; on `dark` it is a faint glow above the background,
# on `light`/`sun` a pale wash BELOW the page white. Either way it is a panel you
# can see, because a cell you cannot see reads as a control that is not wired.
#
# `dark` is bit-for-bit the pre-theme look. Its ramps are the literal
# expressions that used to be inlined in `_build_palette`, in that exact form,
# and `test_dark_theme_is_bit_identical_to_the_pre_theme_look` pins it against
# `git show HEAD~`-era values. This is the palette PLN has trained his eye on
# over a dozen sets; a rounding difference in it is a regression nobody can put
# into words and everybody would feel.
class Ramp(NamedTuple):
"""One (value, saturation) curve in HSV over a 0..1 axis.
`v0 + vspan * t**vg`, which is the shape every colour in the file already
had. A span is signed on purpose: a light theme's value ramp runs downhill.
"""
v0: float
vspan: float
vg: float
s0: float
sspan: float
sg: float
def at(self, t: float) -> tuple[float, float]:
return (self.v0 + self.vspan * (t ** self.vg),
self.s0 + self.sspan * (t ** self.sg))
class SpecRamp(NamedTuple):
"""The spectrum wash: hue drift, one saturation, a value ramp and an alpha
ramp. Deliberately not one of the eight families -- see `spec_lut`."""
h0: float
hspan: float
sat: float
v0: float
vspan: float
a0: float
aspan: float
class Theme(NamedTuple):
name: str
bg: tuple[int, int, int]
ink: Ramp # LEVELS shades per family hue
tint: Ramp # the cell-body surface ramp
tint_t: tuple[float, ...] # where on `tint`'s axis the 4 shades sit
chrome: "Ramp | None" # None = chrome IS the FAM_OTHER ink ramp
chrome_hue: float
spec: SpecRamp
spec_peak: tuple[int, int, int, int]
# (horizontal, vertical) scanline colours, or None for no CRT texture at
# all: on near-white a violet haze is dirt on the screen, not atmosphere.
scan: "tuple[tuple[int, int, int, int], tuple[int, int, int, int]] | None"
bar_frac: float # value-bar height as a fraction of the cell
bold: bool # a heavier face, for a screen in daylight
# The dark ink ramp, sampled at levels 1..4, IS the dark surface ramp -- which
# is how `tint[fam][i] == lut[fam][1 + i]` comes out bit-identical instead of
# merely close.
_INK_DARK = Ramp(0.055, 0.945, 1.25, 0.96, -0.50, 1.6)
_TINT_T_DARK = tuple(lv / (LEVELS - 1) for lv in (1, 2, 3, 4))
_TINT_T_LINEAR = (0.0, 1 / 3, 2 / 3, 1.0)
THEMES: dict[str, Theme] = {
"dark": Theme(
name="dark", bg=BG,
ink=_INK_DARK, tint=_INK_DARK, tint_t=_TINT_T_DARK,
chrome=None, chrome_hue=FAM_HUE[FAM_OTHER],
spec=SpecRamp(0.68, -0.055, 0.80, 0.30, 0.70, 12, 84),
spec_peak=(0xc8, 0xa8, 0xff, 90),
scan=((0xa0, 0x60, 0xff, 16), (0xa0, 0x60, 0xff, 9)),
bar_frac=0.22, bold=False,
),
# Indoors on a pale desktop. Still a lens, still the violet arc, still a
# decay gradient the eye can follow -- the ink simply runs the other way.
"light": Theme(
name="light", bg=(0xf2, 0xf0, 0xf6),
# `vg` below 1 puts the floor of the decay ramp lower than a linear
# one would: a cell nobody has touched for six seconds still has to be
# readable on a pale desktop, and the first pass let it drift into a
# mid-tone that was fine on the fresh screenshot and thin on the
# decayed one. Recency reads as 0.54 -> 0.24, which is plenty.
ink=Ramp(0.64, -0.40, 0.70, 0.50, 0.45, 0.80),
tint=Ramp(0.90, -0.14, 1.00, 0.14, 0.34, 1.00),
tint_t=_TINT_T_LINEAR,
chrome=Ramp(0.66, -0.50, 0.80, 0.08, 0.30, 1.00),
chrome_hue=258.0,
spec=SpecRamp(0.62, -0.05, 0.55, 0.62, -0.28, 34, 120),
spec_peak=(0x4a, 0x2a, 0x78, 110),
scan=((0x50, 0x3a, 0x78, 10), (0x50, 0x3a, 0x78, 6)),
bar_frac=0.22, bold=False,
),
# Direct sunlight, and built for nothing else. Near-white page, heavy ink
# at every decay level (a "dim" state outdoors is a blank state), thicker
# value bars, and no scanline texture to eat the little contrast there is.
# It is not meant to be pretty; it is meant to be the one that still works
# when the laptop is the brightest thing on a table at noon.
"sun": Theme(
name="sun", bg=(0xfc, 0xfc, 0xfa),
# Ink is near-black at EVERY decay level (`vg` below 1 spends the whole
# ramp in the first tenth of it), because outdoors a dim state is not a
# faint state, it is a blank one. Recency still reads -- 0.46 down to
# 0.18 is a real 2.5x -- but nothing on this page is ever pale.
ink=Ramp(0.46, -0.28, 0.50, 0.86, 0.12, 1.00),
# And the panels stay near the page, deliberately flatter than
# `light`'s. The first version let the heat fill climb to a saturated
# mid-tone and the digits printed on it lost the fight: a cell body is
# a surface for ink, and in sunlight that is all it gets to be. The
# value still reads, twice -- the bar and the digits.
tint=Ramp(0.92, -0.10, 1.00, 0.14, 0.26, 1.00),
tint_t=_TINT_T_LINEAR,
chrome=Ramp(0.40, -0.32, 0.50, 0.05, 0.18, 1.00),
chrome_hue=258.0,
spec=SpecRamp(0.60, -0.04, 0.72, 0.48, -0.26, 48, 150),
spec_peak=(0x18, 0x0c, 0x38, 190),
scan=None,
bar_frac=0.34, bold=True,
),
}
# Cycle order for the `d` key and the menu: darkest to brightest page, so the
# key has a direction and "one more press" means "one step sunnier".
THEME_ORDER: tuple[str, ...] = ("dark", "light", "sun")
DEFAULT_THEME = "dark"
TAU = 0.85 # seconds; brightness e-folding time TAU = 0.85 # seconds; brightness e-folding time
IDLE_AFTER = 2.0 # seconds of silence before the slow pulse 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 # 40 ms = 25 fps. Profiling a frame (see MAX_DROPS) put the cost squarely in the
...@@ -390,6 +565,82 @@ UNIT_NAME = "midiviz" ...@@ -390,6 +565,82 @@ UNIT_NAME = "midiviz"
USER_CLOSE_EXIT = 78 USER_CLOSE_EXIT = 78
# ── what survives a restart ────────────────────────────────────────────────
# A theme you have to re-pick every launch is a theme you do not have: PLN is
# in the sun, he reaches for the menu, the set ends, and tomorrow at the same
# terrace the window is black again. So theme and scale go to one small JSON
# file and come back.
#
# Every read and every write here is wrapped, and that is the whole feature.
# This file is on the critical path of a window PLN opens two minutes before a
# set: a monitor that refuses to start because a config file is half-written,
# or owned by root, or names a theme that was renamed, is strictly worse than a
# monitor with no memory at all. So `load_config` cannot raise and cannot
# return anything the rest of the file has to re-check -- it hands back a theme
# that IS in THEMES or None, and a scale that is a finite float in range or
# None. Notably a NaN survives `float()` and `min`/`max` untouched and only
# detonates later, inside `round()` in `_apply_scale`, where the traceback
# points at the font code and not at the config.
#
# Writes go through a temp file and `os.replace`, so a crash mid-save leaves
# the previous choice rather than the corrupt file this paragraph is about.
CONFIG_DIR = "parvagues"
CONFIG_NAME = "midiviz.json"
def config_path() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
return Path(base) / CONFIG_DIR / CONFIG_NAME
def _clean_scale(raw) -> "float | None":
try:
s = float(raw)
except (TypeError, ValueError):
return None
if not math.isfinite(s):
return None
return max(0.6, min(2.4, round(s * 100) / 100))
def load_config() -> dict:
"""{'theme': name-in-THEMES or None, 'scale': float or None}. Never raises."""
out: dict = {"theme": None, "scale": None}
try:
with open(config_path(), "r") as fh:
raw = json.load(fh)
except (OSError, ValueError, UnicodeDecodeError):
return out
if not isinstance(raw, dict): # a list, a string, a bare number
return out
theme = raw.get("theme")
if isinstance(theme, str) and theme in THEMES:
out["theme"] = theme
out["scale"] = _clean_scale(raw.get("scale"))
return out
def save_config(theme: str, scale: float) -> bool:
"""Best effort; False (never an exception) when the disk says no."""
path = config_path()
tmp = None
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.new")
with open(tmp, "w") as fh:
json.dump({"theme": theme, "scale": scale}, fh)
fh.write("\n")
os.replace(tmp, path)
return True
except (OSError, ValueError, TypeError):
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
return False
def latch_close_path(): def latch_close_path():
rt = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" rt = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
return os.path.join(rt, "parvagues", f"{UNIT_NAME}.closed") return os.path.join(rt, "parvagues", f"{UNIT_NAME}.closed")
...@@ -660,7 +911,8 @@ class SpectrumSource: ...@@ -660,7 +911,8 @@ class SpectrumSource:
def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pinned_port: str | None = None, watch: bool = False, pinned_port: str | None = None, watch: bool = False,
spectro: bool = False): spectro: bool = False, theme: str = DEFAULT_THEME,
persist: bool = False, tray: bool = False):
QtCore, QtGui, QtWidgets = _qt() QtCore, QtGui, QtWidgets = _qt()
Qt = QtCore.Qt Qt = QtCore.Qt
...@@ -689,6 +941,12 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -689,6 +941,12 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._pinned = pinned_port # explicit -p, or None for auto-resolve self._pinned = pinned_port # explicit -p, or None for auto-resolve
self.last_cc = None # (cc, value, channel, t) -- header readout self.last_cc = None # (cc, value, channel, t) -- header readout
self.scale = scale self.scale = scale
self.theme = THEMES.get(theme) or THEMES[DEFAULT_THEME]
# OFF unless main() asks. The suite and --selftest build widgets by
# the dozen; none of them gets to rewrite PLN's saved theme, and
# none of them gets to read one either -- a test whose result
# depends on the developer's config file is not a test.
self._persist = bool(persist)
self.paused = False self.paused = False
self.t0 = time.monotonic() self.t0 = time.monotonic()
self.last_event_t = 0.0 self.last_event_t = 0.0
...@@ -718,6 +976,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -718,6 +976,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._build_palette() self._build_palette()
self._apply_scale() self._apply_scale()
# The menu exists even when nothing will ever open it (headless,
# no tray): it is the single definition of "what this window can
# do", and building it unconditionally is what lets --selftest and
# the suite construct it on every run instead of never.
self._build_menu()
self.tray = None
if tray:
self._build_tray()
self.timer = QtCore.QTimer(self) self.timer = QtCore.QTimer(self)
self.timer.setTimerType(Qt.TimerType.CoarseTimer) self.timer.setTimerType(Qt.TimerType.CoarseTimer)
...@@ -781,32 +1047,96 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -781,32 +1047,96 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# ── palette / fonts / geometry ───────────────────────────────────── # ── palette / fonts / geometry ─────────────────────────────────────
def _build_palette(self): def _build_palette(self):
self.bg = QtGui.QColor(*BG) """Resolve the whole theme into LUTs. Once per theme change, never
# LUT[family][level] — every colour this window can ever paint, so a per frame -- the frame budget is why this method exists at all."""
# frame constructs no QColor at all. th = self.theme
self.bg = QtGui.QColor(*th.bg)
# LUT[family][level] — every INK colour this window can ever paint,
# so a frame constructs no QColor at all.
self.lut = [] self.lut = []
for hue in FAM_HUE: for hue in FAM_HUE:
col = [] col = []
for lv in range(LEVELS): for lv in range(LEVELS):
t = lv / (LEVELS - 1) val, sat = th.ink.at(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)) col.append(QtGui.QColor.fromHsvF(hue / 360.0, sat, val))
self.lut.append(col) self.lut.append(col)
self.chrome = self.lut[FAM_OTHER] # tint[family][0..3] — the cell-body SURFACES, and [0] is the floor
# every mapped-but-idle cell gets. See the THEMES note: on dark
# these are exactly ink levels 1..4 (that is what `tint_t` says),
# so nothing about the old look moved; on a light page they are a
# separate, shallower ramp, because a panel must stay near the page
# while the ink on it walks away from it.
self.tint = []
for hue in FAM_HUE:
row = []
for t in th.tint_t:
val, sat = th.tint.at(t)
row.append(QtGui.QColor.fromHsvF(hue / 360.0, sat, val))
self.tint.append(row)
# Chrome (border, row letters, CC reference digits, the two header
# controls) is the FAM_OTHER ink ramp on dark — one fewer ramp to
# keep honest — but needs to be its own near-neutral ink on a light
# page, where a violet at 6/15 is a smudge rather than a hairline.
if th.chrome is None:
self.chrome = self.lut[FAM_OTHER]
else:
self.chrome = [
QtGui.QColor.fromHsvF(th.chrome_hue / 360.0, sat, val)
for val, sat in (th.chrome.at(lv / (LEVELS - 1))
for lv in range(LEVELS))
]
# The backdrop's own ramp, and it is deliberately NOT one of the # The backdrop's own ramp, and it is deliberately NOT one of the
# families: the eight families MEAN role, and a wash that borrowed # families: the eight families MEAN role, and a wash that borrowed
# one would read as "orbit N did something". A cold blue-violet # one would read as "orbit N did something". A cold blue-violet
# that no control ever uses, at alphas low enough that the dimmest # that no control ever uses, at alphas low enough that the dimmest
# glyph still wins the foreground. Precomputed like everything # glyph still wins the foreground. Precomputed like everything
# else, so a spectrum frame constructs no QColor. # else, so a spectrum frame constructs no QColor.
sp = th.spec
self.spec_lut = [ self.spec_lut = [
QtGui.QColor.fromHsvF(0.68 - 0.055 * (lv / (SPEC_LEVELS - 1)), QtGui.QColor.fromHsvF(sp.h0 + sp.hspan * (lv / (SPEC_LEVELS - 1)),
0.80, 0.30 + 0.70 * (lv / (SPEC_LEVELS - 1)), sp.sat, sp.v0 + sp.vspan * (lv / (SPEC_LEVELS - 1)),
(12 + 84 * (lv / (SPEC_LEVELS - 1))) / 255.0) (sp.a0 + sp.aspan * (lv / (SPEC_LEVELS - 1))) / 255.0)
for lv in range(SPEC_LEVELS) for lv in range(SPEC_LEVELS)
] ]
self.spec_peak_col = QtGui.QColor(0xc8, 0xa8, 0xff, 90) self.spec_peak_col = QtGui.QColor(*th.spec_peak)
# The scanline texture is a CACHED PIXMAP in this theme's colours.
# Forgetting this line is the whole bug: the picture would repaint
# correctly and a violet CRT haze from the previous theme would stay
# blitted over a white page, once per frame, forever.
self._scan = None
# ── the theme axis ─────────────────────────────────────────────────
def set_theme(self, name: str, save: bool = True):
"""Switch palettes. Idempotent, and safe with an unknown name."""
th = THEMES.get(name)
if th is None or th is self.theme:
return
was_bold = self.theme.bold
self.theme = th
self._build_palette()
if th.bold != was_bold:
self._apply_scale() # new weight, new metrics, new layout
if getattr(self, "tray", None) is not None:
# The tray icon is painted from the palette, so it goes stale
# the moment the palette does -- a dark badge on a light panel
# is the same complaint one size down.
self.tray.setIcon(self._tray_icon())
self._sync_menu()
if save:
self._save()
self.update()
def cycle_theme(self):
names = THEME_ORDER
try:
i = names.index(self.theme.name)
except ValueError: # a theme outside the cycle: rejoin it
i = -1
self.set_theme(names[(i + 1) % len(names)])
def _save(self):
if self._persist:
save_config(self.theme.name, self.scale)
def _font(self, px: int): def _font(self, px: int):
f = QtGui.QFont() f = QtGui.QFont()
...@@ -814,6 +1144,12 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -814,6 +1144,12 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
"DejaVu Sans Mono", "monospace"]) "DejaVu Sans Mono", "monospace"])
f.setStyleHint(QtGui.QFont.StyleHint.Monospace) f.setStyleHint(QtGui.QFont.StyleHint.Monospace)
f.setPixelSize(max(5, px)) f.setPixelSize(max(5, px))
# Weight is a theme property, and in daylight it is the single
# cheapest contrast there is: one more stroke pixel beats any
# amount of hue. Metrics are re-measured from the resulting font in
# `_apply_scale`, so the ribbon's run concatenation -- which
# assumes a known advance -- stays correct at either weight.
f.setBold(self.theme.bold)
return f return f
def _apply_scale(self): def _apply_scale(self):
...@@ -868,10 +1204,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -868,10 +1204,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pm.fill(QtGui.QColor(0, 0, 0, 0)) pm.fill(QtGui.QColor(0, 0, 0, 0))
p = QtGui.QPainter(pm) p = QtGui.QPainter(pm)
step = max(2, round(3 * self.scale)) step = max(2, round(3 * self.scale))
p.setPen(QtGui.QColor(0xa0, 0x60, 0xff, 16)) hz, vt = self.theme.scan
p.setPen(QtGui.QColor(*hz))
for y in range(0, h, step): for y in range(0, h, step):
p.drawLine(0, y, w, y) p.drawLine(0, y, w, y)
p.setPen(QtGui.QColor(0xa0, 0x60, 0xff, 9)) p.setPen(QtGui.QColor(*vt))
for x in range(0, w, step * 4): for x in range(0, w, step * 4):
p.drawLine(x, 0, x, h) p.drawLine(x, 0, x, h)
p.end() p.end()
...@@ -879,6 +1216,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -879,6 +1216,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
return pm return pm
def _scanlines_blit(self, p): def _scanlines_blit(self, p):
# A theme may have no texture at all (`sun`): outdoors every point
# of contrast is spent on the reading, not on the atmosphere.
if self.theme.scan is None:
return
p.drawPixmap(0, 0, self._scanlines()) p.drawPixmap(0, 0, self._scanlines())
def resizeEvent(self, _e): def resizeEvent(self, _e):
...@@ -964,6 +1305,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -964,6 +1305,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._spec_tick(now) self._spec_tick(now)
idle = (now - self.last_event_t) > IDLE_AFTER and not self.drops idle = (now - self.last_event_t) > IDLE_AFTER and not self.drops
# Hidden to the tray is the deepest idle there is: nothing is on
# screen to be behind, so the two-frame-rate rule says take the
# slow one and give the core back to the audio.
if not self.isVisible():
idle = True
want = FPS_IDLE if idle else FPS_ACTIVE want = FPS_IDLE if idle else FPS_ACTIVE
# A third rate, and only when earned. The two-rate rule exists so a # A third rate, and only when earned. The two-rate rule exists so a
# silent window is not a busy loop; a MOVING backdrop at 5 fps is # silent window is not a busy loop; a MOVING backdrop at 5 fps is
...@@ -1224,9 +1570,12 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1224,9 +1570,12 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
continue continue
lv = self._lv(now - self.col_t[col]) lv = self._lv(now - self.col_t[col])
if lv > 1: if lv > 1:
# A SURFACE, not ink: this is the slab a live column stands
# on. Sampled from the ink ramp it would paint a dark bar
# over a white page and hide the very column it advertises.
p.fillRect(round(mx + (col - 1) * cw), round(my), p.fillRect(round(mx + (col - 1) * cw), round(my),
max(1, round(cw) - self.inset), round(self.mh_px), max(1, round(cw) - self.inset), round(self.mh_px),
self.lut[FAM_LEVEL][min(3, 1 + lv // 6)]) self.tint[FAM_LEVEL][min(2, lv // 6)])
p.setFont(self.f_micro) p.setFont(self.f_micro)
# column digits = orbit numbers, brightening with that orbit's activity # column digits = orbit numbers, brightening with that orbit's activity
...@@ -1281,17 +1630,19 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1281,17 +1630,19 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
fam = self._fam_for_cc(cc) fam = self._fam_for_cc(cc)
st = self.cc.get(cc) st = self.cc.get(cc)
if st is None: if st is None:
# FLOOR TINT, never black: a mapped control that simply # FLOOR TINT, never black and never invisible: a
# has not moved must not read as unmapped. # mapped control that simply has not moved must not
p.fillRect(x, y, cwi, chi, self.lut[fam][1]) # read as unmapped. On a light page that means a wash
# BELOW the page white, which is what tint[..][0] is.
p.fillRect(x, y, cwi, chi, self.tint[fam][0])
p.setPen(self.chrome[3]) p.setPen(self.chrome[3])
p.drawText(x + 2, y + chi - 3, "··" if two else "·") p.drawText(x + 2, y + chi - 3, "··" if two else "·")
continue continue
v, t, chn = st v, t, chn = st
lv = self._lv(now - t, floor=2) lv = self._lv(now - t, floor=2)
# heat: the value tints the cell body even when cold # heat: the value tints the cell body even when cold
p.fillRect(x, y, cwi, chi, self.lut[fam][1 + int(3 * v / 127)]) p.fillRect(x, y, cwi, chi, self.tint[fam][int(3 * v / 127)])
bar_h = max(2, round(chi * 0.22)) bar_h = max(2, round(chi * self.theme.bar_frac))
if cc in grid.BUTTON_CCS: if cc in grid.BUTTON_CCS:
# buttons are latches: filled above half, hollow below # buttons are latches: filled above half, hollow below
if v >= 64: if v >= 64:
...@@ -1312,7 +1663,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1312,7 +1663,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
def _paint_gutter(self, p, now, pulse): def _paint_gutter(self, p, now, pulse):
gx, gw = self.gut_x, self.gut_w 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.fillRect(gx, round(self.my), gw, round(self.mh_px), self.tint[FAM_OTHER][0])
p.setPen(self.chrome[3 + int(3 * pulse)]) p.setPen(self.chrome[3 + int(3 * pulse)])
p.drawLine(gx, round(self.my), gx, round(self.my + self.mh_px)) p.drawLine(gx, round(self.my), gx, round(self.my + self.mh_px))
if not self.drops: if not self.drops:
...@@ -1374,26 +1725,51 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1374,26 +1725,51 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
p.drawText(round(run_x), y, "".join(run)) p.drawText(round(run_x), y, "".join(run))
# ── input ────────────────────────────────────────────────────────── # ── input ──────────────────────────────────────────────────────────
# ── actions ────────────────────────────────────────────────────────
# One method per thing the window can do, because there are now THREE
# ways to ask for each: the key, the window's context menu and the tray.
# The menu is discoverability for what the keyboard already did -- if it
# were a second implementation, the two would drift, and the one that
# drifted would be the one PLN reaches for mid-set (the keys are the
# ones exercised by --selftest; the menu is the one nothing watches).
def quit_now(self):
self._user_closed = True
self.close()
def toggle_pause(self):
self.paused = not self.paused
self._sync_menu()
self.update()
def clear(self):
self.cc.clear()
self.stream.clear()
self.drops.clear()
self.col_t = [0.0] * 9
self.hist = deque([0] * 64, maxlen=64)
self.update()
def toggle_spectro(self):
# The runtime half of "off by default": `s` both acquires the
# tap and gives it back, so nothing about the audio graph is
# permanent and nothing needs a restart to undo.
self.set_spectro(not self.spectro)
self._sync_menu()
def keyPressEvent(self, e): def keyPressEvent(self, e):
k = e.key() k = e.key()
if k in (Qt.Key.Key_Q, Qt.Key.Key_Escape): if k in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
self._user_closed = True self.quit_now()
self.close()
elif k == Qt.Key.Key_T: elif k == Qt.Key.Key_T:
self._set_on_top(not self.on_top) self._set_on_top(not self.on_top)
elif k == Qt.Key.Key_P: elif k == Qt.Key.Key_P:
self.paused = not self.paused self.toggle_pause()
elif k == Qt.Key.Key_S: elif k == Qt.Key.Key_S:
# The runtime half of "off by default": `s` both acquires the self.toggle_spectro()
# tap and gives it back, so nothing about the audio graph is elif k == Qt.Key.Key_D:
# permanent and nothing needs a restart to undo. self.cycle_theme()
self.set_spectro(not self.spectro)
elif k == Qt.Key.Key_C: elif k == Qt.Key.Key_C:
self.cc.clear() self.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): elif k in (Qt.Key.Key_Plus, Qt.Key.Key_Equal):
self._rescale(+0.15) self._rescale(+0.15)
elif k in (Qt.Key.Key_Minus, Qt.Key.Key_Underscore): elif k in (Qt.Key.Key_Minus, Qt.Key.Key_Underscore):
...@@ -1406,15 +1782,262 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1406,15 +1782,262 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if s != self.scale: if s != self.scale:
self.scale = s self.scale = s
self._apply_scale() self._apply_scale()
self._sync_menu()
self._save()
self.update() self.update()
def _set_scale(self, s):
self._rescale(s - self.scale)
# ── the menu, on one surface, owned by two faces ───────────────────
# PLN, 2026-09-22: "can we have a basic menu for midimon, maybe a tray
# presence when runnig, with scaling, theme, etc options ?"
#
# ONE QMenu object. It is the window's right-click menu (the window is
# frameless and the whole surface is a drag handle, so right-click is
# the only gesture left) and it is the tray's context menu, and those
# are the same menu because there is only one set of things to do.
#
# Labels carry their key after a tab, which is the column QMenu renders
# shortcuts in. Real `setShortcut`s were deliberately NOT used: Qt's
# 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 _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
# on screen is exactly the sort of thing a compositor gets to have
# an opinion about. The actions are parented to the widget, and
# `self.menu` is what keeps the menu itself alive.
self.menu = QtWidgets.QMenu()
self._theme_acts = {}
m = self.menu
th = m.addMenu("Theme")
grp = QtGui.QActionGroup(self)
grp.setExclusive(True)
for name in THEME_ORDER:
a = QtGui.QAction(name, self)
a.setCheckable(True)
a.triggered.connect(lambda _c=False, n=name: self.set_theme(n))
grp.addAction(a)
th.addAction(a)
self._theme_acts[name] = a
th.addSeparator()
self._act_cycle = QtGui.QAction("Cycle\td", self)
self._act_cycle.triggered.connect(lambda _c=False: self.cycle_theme())
th.addAction(self._act_cycle)
sc = m.addMenu("Scale")
self._scale_acts = {}
sgrp = QtGui.QActionGroup(self)
sgrp.setExclusive(True)
for s in (0.6, 0.8, 1.0, 1.2, 1.5, 2.0, 2.4):
a = QtGui.QAction("%.1f×" % s, self)
a.setCheckable(True)
a.triggered.connect(lambda _c=False, v=s: self._set_scale(v))
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)
m.addSeparator()
self._act_pause = QtGui.QAction("Pause\tp", self)
self._act_pause.setCheckable(True)
self._act_pause.triggered.connect(lambda _c=False: self.toggle_pause())
m.addAction(self._act_pause)
a = QtGui.QAction("Clear\tc", self)
a.triggered.connect(lambda _c=False: self.clear())
m.addAction(a)
self._act_spec = QtGui.QAction("Spectrum backdrop\ts", self)
self._act_spec.setCheckable(True)
self._act_spec.triggered.connect(lambda _c=False: self.toggle_spectro())
m.addAction(self._act_spec)
self._act_pin = QtGui.QAction("Always on top\tt", self)
self._act_pin.setCheckable(True)
self._act_pin.triggered.connect(
lambda _c=False: self._set_on_top(not self.on_top))
m.addAction(self._act_pin)
m.addSeparator()
self._port_menu = m.addMenu("Source")
self._act_show = QtGui.QAction("Show window", self)
self._act_show.setCheckable(True)
self._act_show.triggered.connect(
lambda _c=False: self.set_window_shown(not self.isVisible()))
m.addAction(self._act_show)
m.addSeparator()
a = QtGui.QAction("Quit\tq", self)
a.triggered.connect(lambda _c=False: self.quit_now())
m.addAction(a)
m.aboutToShow.connect(self._menu_about_to_show)
self._sync_menu()
def _menu_about_to_show(self):
self._sync_ports()
self._sync_menu()
def _sync_menu(self):
"""Make the checkmarks tell the truth.
Called on every state change as well as on aboutToShow, because a
tray menu is often rendered by the DESKTOP from an exported
DBusMenu (perf-tray.py records this: "one surface, two renderers")
and aboutToShow may never fire for it. Syncing eagerly costs a
handful of setChecked calls and removes a whole class of "the menu
says dark and the window is white".
"""
m = getattr(self, "menu", None)
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)
for s, act in self._scale_acts.items():
act.setChecked(abs(s - self.scale) < 1e-9)
self._act_pause.setChecked(self.paused)
self._act_spec.setChecked(self.spectro)
self._act_pin.setChecked(self.on_top)
self._act_show.setChecked(self.isVisible())
def _sync_ports(self):
"""Rebuild the source submenu from what ALSA has RIGHT NOW.
Only on aboutToShow: this shells out to `aseqdump -l`, and the one
thing the rebind tick has taught this file is that a port list is
true for about two seconds. Read-only, like everything here -- the
window subscribes to a source and never opens an output.
"""
pm = getattr(self, "_port_menu", None)
if pm is None:
return
# Every QAction here is parented to the SUBMENU, not to the
# widget: `clear()` deletes its children, and a right-click that
# left ten dead QActions attached to the window would accumulate
# them for the length of a two-hour set.
pm.clear()
a = QtGui.QAction("Auto (re-resolve)", pm)
a.setCheckable(True)
a.setChecked(self._pinned is None)
a.triggered.connect(lambda _c=False: self._pin_port(None))
pm.addAction(a)
pm.addSeparator()
try:
ports = list_ports()
except Exception: # ALSA absent is not a menu failure
ports = []
for pt in ports:
label = "%s %s │ %s" % (pt["addr"], pt["client"], pt["port"])
a = QtGui.QAction(label, pm)
a.setCheckable(True)
a.setChecked(self._pinned == pt["addr"])
a.triggered.connect(
lambda _c=False, pid=pt["addr"]: self._pin_port(pid))
pm.addAction(a)
if not ports:
a = QtGui.QAction("no MIDI sources", pm)
a.setEnabled(False)
pm.addAction(a)
def _pin_port(self, pid):
"""Pin a source, or None to go back to auto, then re-resolve now.
Reuses `_rebind_tick`, which is the only code that knows how to
swap a Reader without dropping the window -- picking a port from a
menu must not become a second, less-tested way to do that.
"""
self._pinned = pid
self._rebind_tick()
self.update()
def contextMenuEvent(self, e):
self._menu_about_to_show()
self.menu.exec(e.globalPos())
e.accept()
# ── the tray ───────────────────────────────────────────────────────
# PLN: "maybe a tray presence when runnig". The point is recall: the
# lens can be dismissed for a track and brought back for the next one
# without killing the process, which is what `close` has always meant
# here ("if i close i wanna close it" -- that stays true, and the X and
# `q` still really quit; only Show/Hide hides).
#
# Guarded on `isSystemTrayAvailable()` and silent when there is none: a
# monitor that refuses to start because the desktop has no status area
# would be a new failure mode traded for a convenience.
def _build_tray(self):
self.tray = None
try:
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
return
# Held on self: an unreferenced QSystemTrayIcon is garbage
# collected and the icon simply disappears from the panel.
self.tray = QtWidgets.QSystemTrayIcon(self)
self.tray.setIcon(self._tray_icon())
self.tray.setToolTip("midiviz")
self.tray.setContextMenu(self.menu)
self.tray.activated.connect(self._tray_activated)
self.tray.show()
except Exception as exc: # no status area, no D-Bus, no matter
self.tray = None
print("midiviz: no tray presence (%s)" % (exc,), file=sys.stderr)
def _tray_icon(self):
"""A 64px grid in the current theme's own colours, painted not
loaded: the lens has no icon file and should not acquire one."""
pm = QtGui.QPixmap(64, 64)
pm.fill(QtGui.QColor(0, 0, 0, 0))
p = QtGui.QPainter(pm)
try:
p.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
p.setBrush(self.bg)
p.setPen(self.chrome[8])
p.drawRoundedRect(2, 2, 60, 60, 12, 12)
for r in range(3):
for c in range(4):
fam = (r * 4 + c) % 8
p.setBrush(self.lut[fam][11 - 2 * r])
p.setPen(Qt.PenStyle.NoPen)
p.drawRect(10 + c * 12, 12 + r * 14, 8, 9)
finally:
p.end()
return QtGui.QIcon(pm)
def _tray_activated(self, reason):
if reason in (QtWidgets.QSystemTrayIcon.ActivationReason.Trigger,
QtWidgets.QSystemTrayIcon.ActivationReason.DoubleClick):
self.set_window_shown(not self.isVisible())
def set_window_shown(self, on: bool):
"""Hide/show, never close. `close()` stops the timers and hands the
source port back -- that is quitting, and it is a different verb."""
if on:
self.show()
self.raise_()
self.activateWindow()
else:
self.hide()
self._sync_menu()
def _set_on_top(self, on: bool): def _set_on_top(self, on: bool):
self.on_top = on self.on_top = on
self._sync_menu()
# Qt requires the window be re-shown after a flag change for the # Qt requires the window be re-shown after a flag change for the
# compositor to be told. It does not touch the KWin all-desktops # compositor to be told. It does not touch the KWin all-desktops
# rule, which is keyed on the app id and stays in force. # rule, which is keyed on the app id and stays in force.
self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, on) self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, on)
self.show() # Only re-show a window that was already on screen. Qt needs the
# re-show to tell the compositor about a flag change on a VISIBLE
# window; a hidden one picks the flag up on its next show() -- and
# toggling the pin from the tray must not be a way to un-hide the
# lens PLN just dismissed.
if self.isVisible():
self.show()
self.update() self.update()
def mousePressEvent(self, e): def mousePressEvent(self, e):
...@@ -1482,6 +2105,17 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1482,6 +2105,17 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# logout must not be mistaken for an opinion. # logout must not be mistaken for an opinion.
if self._user_closed: if self._user_closed:
_latch_close() _latch_close()
if self.tray is not None:
self.tray.hide()
# With a tray in the panel, `main()` turns quitOnLastWindowClosed
# OFF -- that setting is what makes "hide to tray" possible at all,
# since Qt otherwise treats the last window going away as the end
# of the program. The price is that closing must now say so out
# loud, or the X would dismiss the window and leave the process
# running with nothing left to show it.
app = QtWidgets.QApplication.instance()
if app is not None and not app.quitOnLastWindowClosed():
app.quit()
e.accept() e.accept()
return MidiViz() return MidiViz()
...@@ -1582,19 +2216,93 @@ def selftest_spectrum(bands: int = SPEC_BANDS) -> tuple[bool, str]: ...@@ -1582,19 +2216,93 @@ def selftest_spectrum(bands: int = SPEC_BANDS) -> tuple[bool, str]:
return ok, " ".join(notes) return ok, " ".join(notes)
def selftest(seconds: float = 3.0, show: bool = False) -> int: def selftest_config() -> tuple[bool, str]:
"""The persistence layer, against every file a disk can actually hand back.
Runs entirely inside a throwaway XDG_CONFIG_HOME, so the selftest cannot
read or overwrite PLN's real choice. The cases are not hypothetical: a
truncated write, a config from a build where a theme had a different name,
and a hand-edited scale are all things that happen, and every one of them
must come back as "use the default", never as a traceback in front of an
audience.
"""
import tempfile
notes, ok = [], True
prev = os.environ.get("XDG_CONFIG_HOME")
with tempfile.TemporaryDirectory(prefix="midiviz-cfg-") as tmp:
os.environ["XDG_CONFIG_HOME"] = tmp
try:
cases = [
("missing", None, (None, None)),
("roundtrip", '{"theme": "sun", "scale": 1.5}', ("sun", 1.5)),
("corrupt", '{"theme": "sun", "sca', (None, None)),
("not-a-dict", '["sun", 1.5]', (None, None)),
("unknown-theme", '{"theme": "neon", "scale": 0.8}', (None, 0.8)),
("scale-text", '{"theme": "light", "scale": "abc"}', ("light", None)),
("scale-nan", '{"theme": "light", "scale": NaN}', ("light", None)),
("scale-huge", '{"theme": "light", "scale": 99}', ("light", 2.4)),
("empty", "", (None, None)),
]
for name, body, want in cases:
path = config_path()
try:
if body is None:
if path.exists():
path.unlink()
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body)
got = load_config()
good = (got["theme"], got["scale"]) == want
except Exception as exc: # noqa: BLE001
got, good = repr(exc), False
ok = ok and good
notes.append("%s:%s" % (name, "ok" if good else "BAD(%r)" % (got,)))
# A write that cannot land must report False, not raise: the
# config directory's parent is a FILE here, so mkdir fails.
wall = Path(tmp) / "wall"
wall.write_text("not a directory\n")
os.environ["XDG_CONFIG_HOME"] = str(wall)
try:
wrote = save_config("sun", 1.2)
good = wrote is False and load_config()["theme"] is None
except Exception as exc: # noqa: BLE001
good = False
notes.append("unwritable:RAISED(%r)" % (exc,))
else:
notes.append("unwritable:%s" % ("ok" if good else "BAD"))
ok = ok and good
# And the happy path really does come back after a real save.
os.environ["XDG_CONFIG_HOME"] = tmp
good = save_config("light", 1.35) and load_config() == {
"theme": "light", "scale": 1.35}
notes.append("save->load:%s" % ("ok" if good else "BAD"))
ok = ok and good
finally:
if prev is None:
os.environ.pop("XDG_CONFIG_HOME", None)
else:
os.environ["XDG_CONFIG_HOME"] = prev
return ok, " ".join(notes)
def selftest(seconds: float = 3.0, show: bool = False,
theme: str = DEFAULT_THEME) -> int:
if not show: if not show:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.*=false") os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.*=false")
QtCore, QtGui, QtWidgets = _qt() QtCore, QtGui, QtWidgets = _qt()
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
w = build_widget("00:0", None, scale=1.0) # persist=False: the selftest presses `+`, `-` and cycles every theme, and
# none of that is allowed to become PLN's saved preference.
w = build_widget("00:0", None, scale=1.0, theme=theme, persist=False)
w.show() w.show()
app.processEvents() app.processEvents()
# The backdrop, fed by hand. `target` is a name no node can have, so even # The backdrop, fed by hand. `target` is a name no node can have, so even
# if `start()` were called nothing would be captured -- but it is not # if `start()` were called nothing would be captured -- but it is not
# called: `push()` is the seam, and the selftest is the other caller of it. # called: `push()` is the seam, and the selftest is the other caller of it.
cfg_ok, cfg_notes = selftest_config()
spec_ok, spec_notes = selftest_spectrum() spec_ok, spec_notes = selftest_spectrum()
spec_src = SpectrumSource(target="__never__") spec_src = SpectrumSource(target="__never__")
w.set_spectro(True, spec_src) w.set_spectro(True, spec_src)
...@@ -1637,11 +2345,32 @@ def selftest(seconds: float = 3.0, show: bool = False) -> int: ...@@ -1637,11 +2345,32 @@ def selftest(seconds: float = 3.0, show: bool = False) -> int:
w.tick() w.tick()
app.processEvents() app.processEvents()
w.grab() w.grab()
# 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
# the cheapest possible proof that no palette has a colour key the paint
# loop reads and the theme forgot to define.
theme_paints = {}
for name in THEME_ORDER:
w.keyPressEvent(QtGui.QKeyEvent(QtCore.QEvent.Type.KeyPress,
QtCore.Qt.Key.Key_D,
QtCore.Qt.KeyboardModifier.NoModifier))
w.tick()
app.processEvents()
img = w.grab().toImage()
sx, sy = max(1, img.width() // 12), max(1, img.height() // 12)
seen = {img.pixel(xx, yy)
for yy in range(0, img.height(), sy)
for xx in range(0, img.width(), sx)}
theme_paints[w.theme.name] = len(seen)
w.set_theme(theme, save=False)
themes_ok = (len(theme_paints) == len(THEME_ORDER)
and min(theme_paints.values()) >= 8)
painted = w.painted painted = w.painted
# Prove the LAYER, not just the maths: count the bars the painter actually # Prove the LAYER, not just the maths: count the bars the painter actually
# emitted on a real frame, then prove `s` gives the tap back. # emitted on a real frame, then prove `s` gives the tap back.
_pm = QtGui.QPixmap(w.width(), w.height()) _pm = QtGui.QPixmap(w.width(), w.height())
_pm.fill(QtGui.QColor(*BG)) _pm.fill(w.bg)
_pp = QtGui.QPainter(_pm) _pp = QtGui.QPainter(_pm)
try: try:
bars = w._paint_spectrum(_pp, w.width(), w.height()) bars = w._paint_spectrum(_pp, w.width(), w.height())
...@@ -1654,12 +2383,14 @@ def selftest(seconds: float = 3.0, show: bool = False) -> int: ...@@ -1654,12 +2383,14 @@ def selftest(seconds: float = 3.0, show: bool = False) -> int:
ok = (grabs >= 30 and len(colours) >= 8 and painted >= grabs ok = (grabs >= 30 and len(colours) >= 8 and painted >= grabs
and spec_ok and bars >= SPEC_BANDS // 2 and spec_frames >= 20 and spec_ok and bars >= SPEC_BANDS // 2 and spec_frames >= 20
and released) and released and themes_ok and cfg_ok)
print("midiviz selftest: parsed=%d ingested=%d frames=%d paints=%d grabs=%d " print("midiviz selftest: theme=%s parsed=%d ingested=%d frames=%d paints=%d "
"distinct_sampled_colours=%d spectro[frames=%d bars=%d released=%s %s] " "grabs=%d distinct_sampled_colours=%d themes[%s] config[%s] "
"platform=%s -> %s" "spectro[frames=%d bars=%d released=%s %s] platform=%s -> %s"
% (len(parsed), w.ingested, w.frames, painted, grabs, len(colours), % (theme, len(parsed), w.ingested, w.frames, painted, grabs,
spec_frames, bars, released, spec_notes, len(colours),
" ".join("%s:%d" % kv for kv in sorted(theme_paints.items())),
cfg_notes, spec_frames, bars, released, spec_notes,
os.environ.get("QT_QPA_PLATFORM", "native"), "PASS" if ok else "FAIL")) os.environ.get("QT_QPA_PLATFORM", "native"), "PASS" if ok else "FAIL"))
return 0 if ok else 1 return 0 if ok else 1
...@@ -1668,8 +2399,19 @@ def main(argv=None) -> int: ...@@ -1668,8 +2399,19 @@ def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="midiviz - MIDI stream as a picture") 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("-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("-l", "--list", action="store_true", help="list ports and exit")
ap.add_argument("-s", "--scale", type=float, default=1.0, # default None, not 1.0: the three sources of a scale are the flag, the
help="initial scale, 0.6-2.4 (also +/- at runtime)") # saved config and the built-in 1.0, in that order, and a flag whose
# default equals the built-in cannot be told apart from an absent one --
# which would mean the saved scale never won and the memory was decoration.
ap.add_argument("-s", "--scale", type=float, default=None,
help="initial scale, 0.6-2.4 (also +/- at runtime; "
"remembered between runs)")
ap.add_argument("--theme", choices=sorted(THEMES), default=None,
help="palette: dark (the cockpit), light (pale desktop), "
"sun (maximum contrast, for playing outdoors); "
"'d' cycles at runtime and the choice is remembered")
ap.add_argument("--no-tray", action="store_true",
help="skip the system-tray presence even where one exists")
ap.add_argument("--spectro", action="store_true", ap.add_argument("--spectro", action="store_true",
help="spectrum backdrop from the default sink's monitor " help="spectrum backdrop from the default sink's monitor "
"(OFF by default; 's' toggles it at runtime)") "(OFF by default; 's' toggles it at runtime)")
...@@ -1688,17 +2430,26 @@ def main(argv=None) -> int: ...@@ -1688,17 +2430,26 @@ def main(argv=None) -> int:
print("-> %s" % (resolve_watch_port()[1],)) print("-> %s" % (resolve_watch_port()[1],))
return 0 return 0
if a.selftest: if a.selftest:
return selftest(a.seconds, a.show) return selftest(a.seconds, a.show, a.theme or DEFAULT_THEME)
_install_signals() _install_signals()
# cli beats the saved choice beats the built-in. A bad config file loses
# here silently and on purpose: see load_config.
cfg = load_config()
theme = a.theme or cfg["theme"] or DEFAULT_THEME
scale = a.scale if a.scale is not None else cfg["scale"]
scale = 1.0 if scale is None else max(0.6, min(2.4, scale))
pinned = a.port # explicit -p pin, or None for auto-resolve pinned = a.port # explicit -p pin, or None for auto-resolve
port, label = pinned, "" port, label = pinned, ""
if port is None: if port is None:
port, label = resolve_watch_port() port, label = resolve_watch_port()
# The window itself stays wordless; where it is listening goes to stderr, so # 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. # `-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), # The theme joins it for the same reason: a `--theme` that lost to a saved
file=sys.stderr) # one, or a saved one that lost to a corrupt file, is otherwise a silent
# surprise, and the window has no words to explain itself with.
print("⚓ midiviz — %s · theme %s · %.2f× · q/Esc/Ctrl-C to quit · right-click for the menu"
% (label or "port %s" % port, theme, scale), file=sys.stderr)
# A blind `aseqdump` (no -p) subscribes to NOTHING -- see the # A blind `aseqdump` (no -p) subscribes to NOTHING -- see the
# WATCH_PREFERENCE comment above -- so start a Reader only once a real # WATCH_PREFERENCE comment above -- so start a Reader only once a real
# port resolved. Otherwise the window opens idle and the RECONNECT_MS # port resolved. Otherwise the window opens idle and the RECONNECT_MS
...@@ -1715,8 +2466,13 @@ def main(argv=None) -> int: ...@@ -1715,8 +2466,13 @@ def main(argv=None) -> int:
# worse, match EVERY python3 GUI on the box. Give the window its own # worse, match EVERY python3 GUI on the box. Give the window its own
# identity so the compositor can be told about this window and no other. # identity so the compositor can be told about this window and no other.
app.setDesktopFileName("midiviz") app.setDesktopFileName("midiviz")
w = build_widget(port or "--", reader, scale=max(0.6, min(2.4, a.scale)), # quitOnLastWindowClosed OFF is what makes "hide to the tray" survivable
pinned_port=pinned, watch=True) # -- otherwise Qt ends the program the moment the window goes away, and a
# dismissed lens could never be recalled. closeEvent quits explicitly.
app.setQuitOnLastWindowClosed(False)
w = build_widget(port or "--", reader, scale=scale,
pinned_port=pinned, watch=True, theme=theme,
persist=True, tray=not a.no_tray)
if a.spectro: if a.spectro:
src = None src = None
if a.spectro_target: if a.spectro_target:
......
...@@ -535,3 +535,382 @@ def test_a_short_pipe_read_is_not_mistaken_for_end_of_stream(): ...@@ -535,3 +535,382 @@ def test_a_short_pipe_read_is_not_mistaken_for_end_of_stream():
assert mv._read_exact(Dribble(b""), 16) is None assert mv._read_exact(Dribble(b""), 16) is None
assert mv._read_exact(Dribble(payload[:100]), len(payload)) is None, \ assert mv._read_exact(Dribble(payload[:100]), len(payload)) is None, \
"a stream that ends mid-block must read as EOF, not a partial frame" "a stream that ends mid-block must read as EOF, not a partial frame"
# ── themes ─────────────────────────────────────────────────────────────────
#
# The paint loop indexes its palette blind: `self.lut[fam][lv]`,
# `self.tint[fam][0]`, `self.chrome[5]`, `self.spec_lut[lv]`. A theme that
# forgot one of those does not render wrong, it raises KeyError or IndexError
# inside `paintEvent`, at 25 fps, mid-set. That is what these pin: not how the
# themes look (screenshots did that, and the selftest paints every one), but
# that each of them answers every question the painter asks.
def _themed(theme):
"""A built widget on `theme`, or (None, None) where Qt is unavailable."""
import importlib.util
import os
import sys as _sys
from pathlib import Path as _Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
root = _Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location("_mv_theme",
root / "tools/bridge/midiviz.py")
mv = importlib.util.module_from_spec(spec)
_sys.modules["_mv_theme"] = mv
spec.loader.exec_module(mv)
try:
_QtCore, _QtGui, QtWidgets = mv._qt()
except Exception:
return None, None
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
w = mv.build_widget("--", None, scale=1.0, theme=theme, persist=False)
w.show()
app.processEvents()
return mv, w
def test_every_theme_answers_every_colour_the_painter_asks_for():
import pytest
mv, _ = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
for name in mv.THEMES:
_, w = _themed(name)
assert len(w.lut) == 8, f"{name}: a family lost its ink ramp"
for fam in range(8):
assert len(w.lut[fam]) == mv.LEVELS, \
f"{name}: lut[{fam}] is not {mv.LEVELS} deep — _lv() indexes it blind"
assert len(w.tint[fam]) == 4, \
f"{name}: tint[{fam}] must hold 4 surfaces (idle + 3 heat steps)"
assert len(w.chrome) == mv.LEVELS, \
f"{name}: chrome is read at index 12 on hover; a short ramp raises there"
assert len(w.spec_lut) == mv.SPEC_LEVELS, f"{name}: spectrum ramp is short"
for probe in (w.bg, w.spec_peak_col, w.chrome[12], w.tint[0][0]):
assert probe.isValid(), f"{name}: an invalid QColor reached the palette"
def test_no_theme_leaves_a_field_unset():
"""A Theme built by copying another one and editing is how a None gets in;
`scan` and `chrome` are the only two fields allowed to be one."""
import pytest
if not hasattr(V, "THEMES"):
pytest.skip("no themes in this tree")
optional = {"chrome", "scan"}
for name, th in V.THEMES.items():
assert th.name == name, f"{name}: the key and the theme's own name differ"
for field in th._fields:
if field in optional:
continue
assert getattr(th, field) is not None, f"{name}.{field} is None"
assert set(V.THEME_ORDER) <= set(V.THEMES), \
"the cycle order names a theme that does not exist"
assert V.DEFAULT_THEME in V.THEMES
def test_mapped_but_idle_is_visible_on_every_theme():
"""The design invariant, generalised.
On dark it reads "must not be black"; on a light page the same rule is
"must not be invisible against the page". Either way the floor tint of a
mapped cell has to differ from the background by enough that an eye lands
on it — a cell you cannot see reads as a control that is not wired, which
is the hardware-fault misreading the LED work already paid for once.
"""
import pytest
mv, _ = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
for name in mv.THEMES:
_, w = _themed(name)
bg = w.bg
for fam in range(8):
floor = w.tint[fam][0]
dist = (abs(floor.red() - bg.red()) + abs(floor.green() - bg.green())
+ abs(floor.blue() - bg.blue()))
assert dist >= 9, (
f"{name}: the idle floor tint of family {fam} is "
f"{floor.name()} against a {bg.name()} page — invisible, so "
f"48 wired controls would read as unwired")
# and it must never be the page itself
assert floor.rgba() != bg.rgba(), f"{name}: floor tint IS the background"
def test_dark_stays_the_look_pln_trained_his_eye_on():
"""A ratchet, not a design statement.
`dark` is the palette a dozen sets were played on. It was proven
pixel-identical to the pre-theme render when themes landed; these few rgba
values keep it that way, so a tweak aimed at `light` cannot quietly warm
the cockpit by one step.
"""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
assert w.bg.rgba() == 0xff0a0612
assert w.lut[0][0].rgba() == 0xff08010e
assert w.lut[0][8].rgba() == 0xff541c7c
assert w.lut[0][15].rgba() == 0xffce8aff
assert w.chrome[6].rgba() == w.lut[mv.FAM_OTHER][6].rgba(), \
"on dark, chrome IS the FAM_OTHER ink ramp"
for fam in range(8):
for i in range(4):
assert w.tint[fam][i].rgba() == w.lut[fam][1 + i].rgba(), (
"on dark the surface ramp is the ink ramp at levels 1..4; that "
"identity is what made the theme split invisible")
def test_switching_theme_drops_the_cached_scanline_pixmap():
"""The bug this prevents renders perfectly and looks broken anyway.
The CRT texture is one cached pixmap, drawn in the theme's own colours and
blitted whole every frame. Keep it across a theme change and a violet haze
from the cockpit stays painted over a white page for the rest of the set.
"""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
w._scanlines()
assert w._scan is not None, "the texture did not cache at all"
w.set_theme("light", save=False)
assert w._scan is None, "a stale scanline pixmap survived the theme change"
w.set_theme("sun", save=False)
assert w.theme.scan is None and w._scanlines_blit is not None
# sun has no texture: the blit must be a no-op, not a crash
_QtCore, QtGui, _QtWidgets = mv._qt()
pm = QtGui.QPixmap(w.width(), w.height())
pm.fill(w.bg)
p = QtGui.QPainter(pm)
try:
w._scanlines_blit(p)
finally:
p.end()
def test_the_menu_only_mirrors_actions_the_keyboard_already_had():
"""The menu is discoverability, not a second code path.
Both faces call the same method, so this drives the KEYS and asserts the
menu's checkmarks followed. If the menu ever grows its own copy of an
action, this is where the two stop agreeing.
"""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
_QtCore, QtGui, _QtWidgets = mv._qt()
def press(key):
w.keyPressEvent(QtGui.QKeyEvent(_QtCore.QEvent.Type.KeyPress,
getattr(_QtCore.Qt.Key, key),
_QtCore.Qt.KeyboardModifier.NoModifier))
press("Key_P")
assert w.paused and w._act_pause.isChecked(), "p paused but the menu says running"
press("Key_P")
assert not w.paused and not w._act_pause.isChecked()
press("Key_T")
assert w.on_top is False and w._act_pin.isChecked() is False
press("Key_T")
assert w.on_top is True and w._act_pin.isChecked() is True
before = w.theme.name
press("Key_D")
assert w.theme.name != before, "d did not cycle the theme"
assert w._theme_acts[w.theme.name].isChecked(), \
"the theme cycled and the menu's radio did not follow"
w.cc[13] = [64, 0.0, 0]
press("Key_C")
assert not w.cc, "c did not clear"
s0 = w.scale
press("Key_Plus")
assert w.scale > s0, "+ did not grow the window"
press("Key_Minus")
assert abs(w.scale - s0) < 1e-9, "+ then - did not return to the same scale"
assert w._scale_acts[s0].isChecked(), \
"the scale came back to 1.0 and the menu's radio did not"
# The pin from the tray must not un-hide a window that was dismissed.
w.set_window_shown(False)
assert not w.isVisible()
w._set_on_top(not w.on_top)
assert not w.isVisible(), \
"toggling always-on-top brought back a window hidden to the tray"
w.set_window_shown(True)
# every menu entry that names a key must have a handler wired
labels = [a.text() for a in w.menu.actions() if a.text()]
assert any("Quit" in t for t in labels)
assert any("Pause" in t for t in labels)
assert any("Clear" in t for t in labels)
assert any("Spectrum" in t for t in labels)
assert any("Always on top" in t for t in labels)
def test_quit_from_the_menu_records_a_deliberate_close():
"""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."""
import pytest
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
quit_act = [a for a in w.menu.actions() if a.text().startswith("Quit")]
assert quit_act, "the menu has no Quit"
assert not w._user_closed
quit_act[0].trigger()
assert w._user_closed, \
"menu Quit closed the window without recording that a human asked"
def test_the_tray_degrades_silently_where_there_is_no_status_area():
"""Headless is the guard's own test case: no tray, and the window still
builds and paints. Never failing to start is the point."""
import pytest
import importlib.util
import os
import sys as _sys
from pathlib import Path as _Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
root = _Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location("_mv_tray",
root / "tools/bridge/midiviz.py")
mv = importlib.util.module_from_spec(spec)
_sys.modules["_mv_tray"] = mv
spec.loader.exec_module(mv)
try:
_QtCore, _QtGui, QtWidgets = mv._qt()
except Exception:
pytest.skip("no Qt available")
app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
if QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
pytest.skip("this session HAS a tray; the degrade path is not reachable")
w = mv.build_widget("--", None, scale=1.0, tray=True, persist=False)
w.show()
app.processEvents()
assert w.tray is None, "a tray was constructed where the platform has none"
assert w.grab().width() > 0, "the window did not paint without a tray"
# hide/show is still available: it is the window's own state, not the tray's
w.set_window_shown(False)
assert not w.isVisible()
w.set_window_shown(True)
assert w.isVisible()
# ── the remembered choice ──────────────────────────────────────────────────
#
# This is the part most likely to be done carelessly, so it gets the most
# cases. A monitor that will not start because its config file is half-written
# is strictly worse than one with no memory at all — every one of these inputs
# must come back as "use the default", never as a traceback two minutes before
# a set.
def test_config_path_follows_xdg_then_falls_back_to_dot_config(monkeypatch, tmp_path):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
assert V.config_path() == tmp_path / "xdg" / "parvagues" / "midiviz.json"
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
assert V.config_path() == tmp_path / "home" / ".config" / "parvagues" / "midiviz.json"
def test_config_round_trips(monkeypatch, tmp_path):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
assert V.save_config("sun", 1.45) is True
assert V.load_config() == {"theme": "sun", "scale": 1.45}
def test_a_missing_config_is_not_an_error(monkeypatch, tmp_path):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "nope"))
assert V.load_config() == {"theme": None, "scale": None}
def test_every_shape_of_bad_config_reads_as_no_config(monkeypatch, tmp_path):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
path = V.config_path()
path.parent.mkdir(parents=True, exist_ok=True)
cases = {
'{"theme": "sun", "sca': (None, None), # truncated write
"": (None, None), # zero-length file
"not json at all": (None, None),
'["sun", 1.5]': (None, None), # a list, not an object
"42": (None, None), # a bare number
'{"theme": "neon", "scale": 1.0}': (None, 1.0), # a theme that was renamed
'{"theme": 7, "scale": 1.0}': (None, 1.0), # a theme that is not a name
'{"theme": "light", "scale": "abc"}': ("light", None),
'{"theme": "light", "scale": null}': ("light", None),
'{"theme": "light", "scale": NaN}': ("light", None),
'{"theme": "light", "scale": Infinity}': ("light", None),
'{"theme": "light", "scale": 99}': ("light", 2.4), # clamped, not refused
'{"theme": "light", "scale": -3}': ("light", 0.6),
"{}": (None, None),
}
for body, want in cases.items():
path.write_text(body)
got = V.load_config()
assert (got["theme"], got["scale"]) == want, \
f"{body!r} read back as {got}, not {want}"
def test_an_unreadable_config_reads_as_no_config(monkeypatch, tmp_path):
import os
import pytest
if os.getuid() == 0:
pytest.skip("root can read anything; the chmod guard proves nothing")
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
path = V.config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('{"theme": "sun", "scale": 1.2}')
path.chmod(0o000)
try:
assert V.load_config() == {"theme": None, "scale": None}
finally:
path.chmod(0o600)
def test_a_config_directory_that_cannot_exist_fails_without_raising(monkeypatch,
tmp_path):
"""XDG_CONFIG_HOME pointing at a FILE. mkdir cannot succeed, and a window
must not die over being unable to remember a colour."""
wall = tmp_path / "wall"
wall.write_text("not a directory\n")
monkeypatch.setenv("XDG_CONFIG_HOME", str(wall))
assert V.save_config("sun", 1.0) is False
assert V.load_config() == {"theme": None, "scale": None}
def test_a_nan_scale_never_reaches_the_font_code(monkeypatch, tmp_path):
"""The one bad value that survives float(), min() and max() untouched and
only detonates later, inside round() in `_apply_scale`, where the
traceback points at the fonts and not at the config file."""
assert V._clean_scale(float("nan")) is None
assert V._clean_scale(float("inf")) is None
assert V._clean_scale("1.2") == 1.2
assert V._clean_scale(None) is None
assert V._clean_scale([]) is None
def test_a_saved_theme_and_scale_come_back_on_the_next_window(monkeypatch,
tmp_path):
"""The whole point: the second time PLN is in the sun, it is already sunny."""
import pytest
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
mv, w = _themed("dark")
if mv is None:
pytest.skip("no Qt available")
w._persist = True
w.set_theme("sun")
w._rescale(+0.15)
cfg = mv.load_config()
assert cfg["theme"] == "sun", "the theme change was not written"
assert cfg["scale"] == w.scale, "the scale change was not written"
# and a widget that was NOT asked to persist must not write at all
(tmp_path / "parvagues" / "midiviz.json").unlink()
_, w2 = _themed("dark")
w2.set_theme("light")
w2._rescale(-0.15)
assert mv.load_config() == {"theme": None, "scale": None}, \
"a widget built with persist=False rewrote the human's saved choice"
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