Commit 794b6313 by PLN (Algolia)

bridge midiviz: sync the DevCon skin from the standalone repo

parent 1294c6d1
#!/usr/bin/env python3 #!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""midiviz — the MIDI stream as a picture, not a transcript. """midiviz — the MIDI stream as a picture, not a transcript.
`midimon.py` prints one line per event ("14:0 Control change ch0, controller `midimon.py` prints one line per event ("14:0 Control change ch0, controller
...@@ -108,6 +109,30 @@ import midimon # noqa: E402 parse_line / enrich / resolve_po ...@@ -108,6 +109,30 @@ import midimon # noqa: E402 parse_line / enrich / resolve_po
import midistream # noqa: E402 parse_ports import midistream # noqa: E402 parse_ports
import oscfeed # noqa: E402 the Tidal HL feed (SuperDirt mirror) import oscfeed # noqa: E402 the Tidal HL feed (SuperDirt mirror)
# ── brand — whose lens this is ─────────────────────────────────────────────
# midiviz was born inside ParVagues (a livecoding rig) and ships wearing it;
# nothing here NEEDS that name. Override the three at launch to wear your own:
#
# MIDIVIZ_BRAND="Coolnaut" MIDIVIZ_WORDMARK="COOLNAUT" \
# MIDIVIZ_BRAND_SLUG="coolnaut" python3 midiviz.py
#
# * BRAND is the spoken name (menu labels, tray presence);
# * WORDMARK is the block lettering drawn over row D (upper-case on purpose —
# that is what the vendored display face is for, see the SYNE_PATH note);
# * BRAND_SLUG is the directory under $XDG_CONFIG_HOME and $XDG_RUNTIME_DIR
# where THIS instance keeps its memory and latches — one per brand, so two
# brands never fight over one saved theme.
# The watermarked wave swaps the same way: MIDIVIZ_WAVE=/path/to/logo.png
# (any transparent PNG; it is painted faint enough to never compete with a
# digit — see the WAVE_PATH note below).
# What branding does NOT touch: the wire paths midiviz READS
# (`~/.cache/parvagues/surface-state.json`, `eval-events.jsonl`) are contracts
# with the producers that write them — a driver, an editor — not decoration.
# Where those live is documented in the README's "wire" section.
BRAND = os.environ.get("MIDIVIZ_BRAND", "ParVagues")
WORDMARK = os.environ.get("MIDIVIZ_WORDMARK", "PARVAGUES")
BRAND_SLUG = os.environ.get("MIDIVIZ_BRAND_SLUG", "parvagues")
# ── which port to watch ──────────────────────────────────────────────────── # ── which port to watch ────────────────────────────────────────────────────
# `midimon.resolve_port()` OWNS this decision and this file delegates to it. Two # `midimon.resolve_port()` OWNS this decision and this file delegates to it. Two
# things about it are load-bearing and were nearly re-derived wrong here: # things about it are load-bearing and were nearly re-derived wrong here:
...@@ -349,6 +374,26 @@ class Theme(NamedTuple): ...@@ -349,6 +374,26 @@ class Theme(NamedTuple):
scan: "tuple[tuple[int, int, int, int], tuple[int, int, int, int]] | None" 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 bar_frac: float # value-bar height as a fraction of the cell
bold: bool # a heavier face, for a screen in daylight bold: bool # a heavier face, for a screen in daylight
# The face every digit and label is set in: "mono" (the cockpit's
# monospace stack) or "jersey" (the vendored pixel face, see JERSEY_PATH).
ui_font: str
# The eight family hues, in FAM_* order. Every theme but devcon keeps the
# ParVagues violet arc (FAM_HUE); a family still MEANS the same role
# whatever hue it wears, so a theme may re-dress it but never reorder it.
fam_hue: tuple[float, ...]
# How a cell body is made. False: `tint` is an HSV ramp, as always. True:
# the family's vivid colour laid OVER the page, `tint`'s value read as how
# much of it -- a warm hue darkened toward black is brown, the same hue
# thinned over navy stays itself (how the DevCon site layers its colour).
tint_over_page: bool
# The texture's pitch, in base px: 0 = the CRT (fine lines, a vertical
# every fourth), anything else = a square grid at that pitch -- the
# DevCon artwork's graph paper.
scan_grid: float
# The PARVAGUES wordmark (`l`) as the site's hero headline instead of the
# neon: Jersey 15, hero tracking, navy on a baked gradient plate. The
# theme decides, not --word-face, so no other page ever wears the plate.
word_plate: bool
# The dark ink ramp, sampled at levels 1..4, IS the dark surface ramp -- which # The dark ink ramp, sampled at levels 1..4, IS the dark surface ramp -- which
...@@ -366,7 +411,9 @@ THEMES: dict[str, Theme] = { ...@@ -366,7 +411,9 @@ THEMES: dict[str, Theme] = {
spec=SpecRamp(0.68, -0.055, 0.80, 0.30, 0.70, 12, 84), spec=SpecRamp(0.68, -0.055, 0.80, 0.30, 0.70, 12, 84),
spec_peak=(0xc8, 0xa8, 0xff, 90), spec_peak=(0xc8, 0xa8, 0xff, 90),
scan=((0xa0, 0x60, 0xff, 16), (0xa0, 0x60, 0xff, 9)), scan=((0xa0, 0x60, 0xff, 16), (0xa0, 0x60, 0xff, 9)),
bar_frac=0.22, bold=False, bar_frac=0.22, bold=False, ui_font="mono",
fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0,
word_plate=False,
), ),
# Indoors on a pale desktop. Still a lens, still the violet arc, still a # 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. # decay gradient the eye can follow -- the ink simply runs the other way.
...@@ -385,7 +432,47 @@ THEMES: dict[str, Theme] = { ...@@ -385,7 +432,47 @@ THEMES: dict[str, Theme] = {
spec=SpecRamp(0.62, -0.05, 0.55, 0.62, -0.28, 34, 120), spec=SpecRamp(0.62, -0.05, 0.55, 0.62, -0.28, 34, 120),
spec_peak=(0x4a, 0x2a, 0x78, 110), spec_peak=(0x4a, 0x2a, 0x78, 110),
scan=((0x50, 0x3a, 0x78, 10), (0x50, 0x3a, 0x78, 6)), scan=((0x50, 0x3a, 0x78, 10), (0x50, 0x3a, 0x78, 6)),
bar_frac=0.22, bold=False, bar_frac=0.22, bold=False, ui_font="mono",
fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0,
word_plate=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.
# The DevCon26 stage skin, v2 (PLN, 2026-09-28: "not white theme"): the
# event's OWN tokens, pulled from algolia.com/devcon's CSS -- navy page
# #0d1833, electric blue #003dff / #457aff, orange #ff7700, mint
# #07ffda (gradient #adf6ff->#46dbee->#07ffda). The artwork's corail
# (measured #fd956e, 47% of the intro loop) walks the spec wash: one
# linear hue drift, corail 21 deg down through magenta 296 to cyan --
# wrapped, see _build_palette. Family hues stay the orbit identity.
"devcon": Theme(
name="devcon", bg=(0x0d, 0x18, 0x33),
# Vivid, not grey (PLN, 2026-09-28: "a bit grey, make this pop"): the
# ink keeps its saturation to the top instead of paling out, and the
# cell bodies carry the hue at every heat step. The grey was low
# saturation at low value -- a family hue at s=0.30 on navy is mud.
ink=Ramp(0.14, 0.86, 1.10, 1.00, -0.22, 2.00),
tint=Ramp(0.10, 0.42, 1.00, 0.85, 0.00, 1.00), # page->hue mix, see tint_over_page
tint_t=_TINT_T_LINEAR,
chrome=Ramp(0.70, -0.40, 0.80, 0.25, 0.30, 1.00),
chrome_hue=216.0, # #457aff, the site's electric blue
spec=SpecRamp(0.058, -0.45, 0.85, 0.75, -0.30, 40, 140),
spec_peak=(0x07, 0xff, 0xda, 160), # the site's mint
# Pale cyan #a2edf2 hairlines, the artwork's grid on its gradient.
scan=((0xa2, 0xed, 0xf2, 13), (0xa2, 0xed, 0xf2, 13)),
# The site sets everything that is not body copy in Jersey 15, so
# every digit here is too.
bar_frac=0.34, bold=True, ui_font="jersey",
# The families in the event's colours (PLN, 2026-09-28: "DevCon
# palette"), from the site's CSS and the artwork's measured mass:
# level = electric blue #457aff, fx = corail #fd956e, fx2 = magenta
# #df96ef, gate = mint #07ffda, gate2 = cyan #46dbee, family = orange
# #ff7700, other = the chrome's blue, note = the artwork's rose.
fam_hue=(223, 16, 290, 171, 191, 28, 216, 350),
tint_over_page=True, scan_grid=14.0, word_plate=True,
), ),
# Direct sunlight, and built for nothing else. Near-white page, heavy ink # 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 # at every decay level (a "dim" state outdoors is a blank state), thicker
...@@ -411,12 +498,14 @@ THEMES: dict[str, Theme] = { ...@@ -411,12 +498,14 @@ THEMES: dict[str, Theme] = {
spec=SpecRamp(0.60, -0.04, 0.72, 0.48, -0.26, 48, 150), spec=SpecRamp(0.60, -0.04, 0.72, 0.48, -0.26, 48, 150),
spec_peak=(0x18, 0x0c, 0x38, 190), spec_peak=(0x18, 0x0c, 0x38, 190),
scan=None, scan=None,
bar_frac=0.34, bold=True, bar_frac=0.34, bold=True, ui_font="mono",
fam_hue=FAM_HUE, tint_over_page=False, scan_grid=0.0,
word_plate=False,
), ),
} }
# Cycle order for the `d` key and the menu: darkest to brightest page, so the # 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". # key has a direction and "one more press" means "one step sunnier".
THEME_ORDER: tuple[str, ...] = ("dark", "light", "sun") THEME_ORDER: tuple[str, ...] = ("dark", "light", "devcon", "sun")
DEFAULT_THEME = "dark" DEFAULT_THEME = "dark"
# What each page is FOR, for the menu only. `dark`/`light`/`sun` stay the # 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 # vocabulary of the config file, the `--theme` flag and every test; a menu row
...@@ -425,6 +514,7 @@ THEME_MENU: dict[str, str] = { ...@@ -425,6 +514,7 @@ THEME_MENU: dict[str, str] = {
"dark": "Dark · the cockpit, indoors", "dark": "Dark · the cockpit, indoors",
"light": "Light · a pale desktop", "light": "Light · a pale desktop",
"sun": "Sun · daylight, maximum ink", "sun": "Sun · daylight, maximum ink",
"devcon": "DevCon · the Algolia stage skin",
} }
TAU = 0.85 # seconds; brightness e-folding time TAU = 0.85 # seconds; brightness e-folding time
...@@ -475,7 +565,8 @@ BASE_W, BASE_H = 420, 260 ...@@ -475,7 +565,8 @@ BASE_W, BASE_H = 420, 260
# (Perso/www/public/images/parvagues/logo_transparent.png, downscaled to # (Perso/www/public/images/parvagues/logo_transparent.png, downscaled to
# 640px) because midiviz must run standalone from a checkout with no www # 640px) because midiviz must run standalone from a checkout with no www
# tree on the box at all. # tree on the box at all.
WAVE_PATH = Path(__file__).resolve().parent / "ui" / "parvagues_wave.png" WAVE_PATH = (Path(os.environ["MIDIVIZ_WAVE"]) if os.environ.get("MIDIVIZ_WAVE")
else Path(__file__).resolve().parent / "ui" / "parvagues_wave.png")
WAVE_ALPHA = 0.12 WAVE_ALPHA = 0.12
# …and its wordmark, over D1-D4 (PLN, 2026-09-24: "PARVAGUES in nice our # …and its wordmark, over D1-D4 (PLN, 2026-09-24: "PARVAGUES in nice our
# usual site font bloc letters over D1/2/3/4 alongside the wave same # usual site font bloc letters over D1/2/3/4 alongside the wave same
...@@ -484,7 +575,27 @@ WAVE_ALPHA = 0.12 ...@@ -484,7 +575,27 @@ WAVE_ALPHA = 0.12
# recipe) and the pink is --neon-high. The variable font's latin subset is # recipe) and the pink is --neon-high. The variable font's latin subset is
# vendored out of the built site for the same standalone reason as the wave. # vendored out of the built site for the same standalone reason as the wave.
SYNE_PATH = Path(__file__).resolve().parent / "ui" / "syne-extrabold.ttf" SYNE_PATH = Path(__file__).resolve().parent / "ui" / "syne-extrabold.ttf"
# Jersey 15 (Soft Type Project, OFL, vendored from google/fonts) — the face of
# the Algolia DevCon26 stage kit, measured, not eyed: algolia.com/devcon's CSS
# sets every headline in 'Jersey 15' (Jersey 25 was a guess, and wrong). Same
# standalone reason as Syne; `--word-face jersey` puts the wordmark in it,
# default stays the brand's Syne.
JERSEY_PATH = Path(__file__).resolve().parent / "ui" / "Jersey15-Regular.ttf"
WORD_FACES = ("syne", "jersey")
JERSEY_UI_SIZE = 1.3 # px multiplier when Jersey sets the digits
WORD_ALPHA = 0.8 WORD_ALPHA = 0.8
# The DevCon hero, read off algolia.com/devcon (.devcon-hero__headline): Jersey
# 15 at weight 400, letter-spacing -2.72px on 136px (-2%), --devcon-navy. The
# plate is the artwork's own gradient (corail -> magenta -> cyan, measured on
# the intro loop and the header) with its pale grid, because navy type needs
# the bright page the site sets it on.
PLATE_INK = "#14283f"
PLATE_STOPS = ((0.0, "#fd956e"), (0.55, "#df96ef"), (1.0, "#46dbee"))
PLATE_GRID = (0xff, 0xff, 0xff, 46)
# Only the gradient is translucent; the letters stay solid. PLN, 2026-09-28:
# "ensure that we still see the D1-3 under the lettering" -- those three cells
# are wired controls, and a sticker that hid them would read as unwired.
PLATE_ALPHA = 0.5
# ── the HL feed ──────────────────────────────────────────────────────────── # ── the HL feed ────────────────────────────────────────────────────────────
# PLN, 2026-09-24: "please do indeed sub from the events, to indeed feed # PLN, 2026-09-24: "please do indeed sub from the events, to indeed feed
...@@ -784,7 +895,7 @@ USER_CLOSE_EXIT = 78 ...@@ -784,7 +895,7 @@ USER_CLOSE_EXIT = 78
# #
# Writes go through a temp file and `os.replace`, so a crash mid-save leaves # 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. # the previous choice rather than the corrupt file this paragraph is about.
CONFIG_DIR = "parvagues" CONFIG_DIR = BRAND_SLUG # one config dir per brand (see the brand block)
CONFIG_NAME = "midiviz.json" CONFIG_NAME = "midiviz.json"
...@@ -843,7 +954,7 @@ def save_config(theme: str, scale: float) -> bool: ...@@ -843,7 +954,7 @@ def save_config(theme: str, scale: float) -> bool:
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, CONFIG_DIR, f"{UNIT_NAME}.closed")
def _latch_close(): def _latch_close():
...@@ -1113,7 +1224,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1113,7 +1224,8 @@ 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, theme: str = DEFAULT_THEME, spectro: bool = False, theme: str = DEFAULT_THEME,
persist: bool = False, tray: bool = False, persist: bool = False, tray: bool = False,
hl: "oscfeed.HLFeed | None" = None): hl: "oscfeed.HLFeed | None" = None,
word_face: str = "syne"):
QtCore, QtGui, QtWidgets = _qt() QtCore, QtGui, QtWidgets = _qt()
Qt = QtCore.Qt Qt = QtCore.Qt
...@@ -1200,6 +1312,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1200,6 +1312,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
str(SYNE_PATH)) str(SYNE_PATH))
except Exception: except Exception:
self._syne_id = -1 self._syne_id = -1
# Same deal, same non-crash rule: a checkout without Jersey loses
# only the alternate wordmark face, never the lens.
try:
self._jersey_id = QtGui.QFontDatabase.addApplicationFont(
str(JERSEY_PATH))
except Exception:
self._jersey_id = -1
self._word_face = word_face if word_face in WORD_FACES else "syne"
self._word_font = None self._word_font = None
self._word_font_key = None self._word_font_key = None
self._word_pm = None self._word_pm = None
...@@ -1226,7 +1346,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1226,7 +1346,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._ref_w = max(1, round(BASE_W * self.scale)) self._ref_w = max(1, round(BASE_W * self.scale))
self._ref_h = max(1, round(BASE_H * self.scale)) self._ref_h = max(1, round(BASE_H * self.scale))
self.dens = self.scale self.dens = self.scale
self._metrics_key = None # (main px, micro px, bold) last measured self._metrics_key = None # (main px, micro px, bold, face) last measured
self.setMinimumSize(MIN_W, MIN_H) self.setMinimumSize(MIN_W, MIN_H)
self.resize(self._ref_w, self._ref_h) self.resize(self._ref_w, self._ref_h)
# …then read it BACK off the widget. If a minimumSize ever clamped # …then read it BACK off the widget. If a minimumSize ever clamped
...@@ -1344,7 +1464,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1344,7 +1464,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# LUT[family][level] — every INK colour this window can ever paint, # LUT[family][level] — every INK colour this window can ever paint,
# so a frame constructs no QColor at all. # so a frame constructs no QColor at all.
self.lut = [] self.lut = []
for hue in FAM_HUE: for hue in th.fam_hue:
col = [] col = []
for lv in range(LEVELS): for lv in range(LEVELS):
val, sat = th.ink.at(lv / (LEVELS - 1)) val, sat = th.ink.at(lv / (LEVELS - 1))
...@@ -1357,10 +1477,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1357,10 +1477,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# separate, shallower ramp, because a panel must stay near the page # separate, shallower ramp, because a panel must stay near the page
# while the ink on it walks away from it. # while the ink on it walks away from it.
self.tint = [] self.tint = []
for hue in FAM_HUE: for hue in th.fam_hue:
row = [] row = []
for t in th.tint_t: for t in th.tint_t:
val, sat = th.tint.at(t) val, sat = th.tint.at(t)
if th.tint_over_page:
c = QtGui.QColor.fromHsvF(hue / 360.0, sat, 1.0)
row.append(QtGui.QColor(
*(round(b + (f - b) * val) for b, f in
zip(th.bg, (c.red(), c.green(), c.blue())))))
else:
row.append(QtGui.QColor.fromHsvF(hue / 360.0, sat, val)) row.append(QtGui.QColor.fromHsvF(hue / 360.0, sat, val))
self.tint.append(row) self.tint.append(row)
# Chrome (border, row letters, CC reference digits, the two header # Chrome (border, row letters, CC reference digits, the two header
...@@ -1383,7 +1509,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1383,7 +1509,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# else, so a spectrum frame constructs no QColor. # else, so a spectrum frame constructs no QColor.
sp = th.spec sp = th.spec
self.spec_lut = [ self.spec_lut = [
QtGui.QColor.fromHsvF(sp.h0 + sp.hspan * (lv / (SPEC_LEVELS - 1)), # The hue drift wraps (`% 1.0`): a theme may walk the wheel
# through 0 — DevCon26's does, corail 21 deg descending to
# cyan past magenta — and fromHsvF rejects a negative hue.
# Identity on every theme that never crosses zero.
QtGui.QColor.fromHsvF((sp.h0 + sp.hspan * (lv / (SPEC_LEVELS - 1))) % 1.0,
sp.sat, sp.v0 + sp.vspan * (lv / (SPEC_LEVELS - 1)), sp.sat, sp.v0 + sp.vspan * (lv / (SPEC_LEVELS - 1)),
(sp.a0 + sp.aspan * (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)
...@@ -1401,10 +1531,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1401,10 +1531,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
th = THEMES.get(name) th = THEMES.get(name)
if th is None or th is self.theme: if th is None or th is self.theme:
return return
was_bold = self.theme.bold was_face = (self.theme.bold, self.theme.ui_font)
self.theme = th self.theme = th
self._build_palette() self._build_palette()
if th.bold != was_bold: if (th.bold, th.ui_font) != was_face:
self._apply_scale() # new weight, new metrics, new layout self._apply_scale() # new weight, new metrics, new layout
if getattr(self, "tray", None) is not None: if getattr(self, "tray", None) is not None:
# The tray icon is painted from the palette, so it goes stale # The tray icon is painted from the palette, so it goes stale
...@@ -1429,6 +1559,21 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1429,6 +1559,21 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
save_config(self.theme.name, self.scale) save_config(self.theme.name, self.scale)
def _font(self, px: int): def _font(self, px: int):
if self.theme.ui_font == "jersey" and self._jersey_id >= 0:
fams = QtGui.QFontDatabase.applicationFontFamilies(self._jersey_id)
if fams:
f = QtGui.QFont(fams[0])
# Jersey's caps stand shorter in their em than the mono's,
# so the same pixel size reads a size smaller.
f.setPixelSize(max(5, round(px * JERSEY_UI_SIZE)))
# Tabular figures, so every digit shares one advance. A-F
# still do not (see `_paint_stream`). And no bold: Jersey
# ships a Regular only, and a synthesized bold smears the
# pixel grid that is the whole point of the face.
f.setFeature(QtGui.QFont.Tag("tnum"), 1)
return f
# No Jersey on this checkout (id -1): the mono stack, silently,
# the same non-crash rule as the wordmark.
f = QtGui.QFont() f = QtGui.QFont()
f.setFamilies(["Geist Mono", "JetBrains Mono", "Fira Code", f.setFamilies(["Geist Mono", "JetBrains Mono", "Fira Code",
"DejaVu Sans Mono", "monospace"]) "DejaVu Sans Mono", "monospace"])
...@@ -1470,7 +1615,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1470,7 +1615,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
""" """
self.dens = d = self._density() self.dens = d = self._density()
px, upx = max(5, round(11 * d)), max(5, round(8 * d)) px, upx = max(5, round(11 * d)), max(5, round(8 * d))
key = (px, upx, self.theme.bold) key = (px, upx, self.theme.bold, self.theme.ui_font)
if key != self._metrics_key: if key != self._metrics_key:
# Re-measured only when the FACE actually changed. The weight # Re-measured only when the FACE actually changed. The weight
# belongs in that key because `set_theme` calls this precisely # belongs in that key because `set_theme` calls this precisely
...@@ -1481,7 +1626,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1481,7 +1626,11 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self.f_micro = self._font(upx) self.f_micro = self._font(upx)
fm = QtGui.QFontMetricsF(self.f_main) fm = QtGui.QFontMetricsF(self.f_main)
fmm = QtGui.QFontMetricsF(self.f_micro) fmm = QtGui.QFontMetricsF(self.f_micro)
self.mw, self.mh = fm.horizontalAdvance("0"), fm.height() # The WIDEST hex glyph is the ribbon's grid step; on a
# monospaced face they are all the same and this is "0".
self.mw = max(fm.horizontalAdvance(c) for c in HEX)
self.mh = fm.height()
self._hex_mono = len({fm.horizontalAdvance(c) for c in HEX}) == 1
self.uw, self.uh = fmm.horizontalAdvance("0"), fmm.height() self.uw, self.uh = fmm.horizontalAdvance("0"), fmm.height()
self.m_asc, self.u_asc = fm.ascent(), fmm.ascent() self.m_asc, self.u_asc = fm.ascent(), fmm.ascent()
self._relayout() self._relayout()
...@@ -1538,13 +1687,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1538,13 +1687,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
pm = QtGui.QPixmap(w, h) pm = QtGui.QPixmap(w, h)
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.dens)) grid = self.theme.scan_grid
step = max(2, round((grid or 3) * self.dens))
hz, vt = self.theme.scan hz, vt = self.theme.scan
p.setPen(QtGui.QColor(*hz)) 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(*vt)) p.setPen(QtGui.QColor(*vt))
for x in range(0, w, step * 4): for x in range(0, w, step if grid else step * 4):
p.drawLine(x, 0, x, h) p.drawLine(x, 0, x, h)
p.end() p.end()
self._scan = pm self._scan = pm
...@@ -1729,17 +1879,20 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1729,17 +1879,20 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
spacing and kerned sub-advances disagreed, and that arithmetic is spacing and kerned sub-advances disagreed, and that arithmetic is
exactly how "PARVAGU" lost its tail. The bake measures its own exactly how "PARVAGU" lost its tail. The bake measures its own
ink instead (see _word_pixmap).""" ink instead (see _word_pixmap)."""
key = (round(target_w), round(target_h)) plate = self.theme.word_plate
face = "jersey" if plate else self._word_face
key = (face, plate, round(target_w), round(target_h))
if self._word_font_key == key: if self._word_font_key == key:
return self._word_font return self._word_font
fam_id = self._syne_id fam_id = self._jersey_id if face == "jersey" else self._syne_id
if fam_id < 0: if fam_id < 0:
return None return None
fam = QtGui.QFontDatabase.applicationFontFamilies(fam_id)[0] fam = QtGui.QFontDatabase.applicationFontFamilies(fam_id)[0]
f = QtGui.QFont(fam) f = QtGui.QFont(fam)
f.setPixelSize(max(6, target_h * 0.92)) f.setPixelSize(max(6, target_h * 0.92))
f.setLetterSpacing(QtGui.QFont.SpacingType.PercentageSpacing, f.setLetterSpacing(QtGui.QFont.SpacingType.PercentageSpacing,
106.0) # +0.06em, as the hero tracks 98.0 if plate # -2%, the DevCon hero
else 106.0) # +0.06em, as the hero tracks
self._word_font, self._word_font_key = f, key self._word_font, self._word_font_key = f, key
return f return f
...@@ -1747,7 +1900,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1747,7 +1900,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
"""One pass of the word: glyphs drawn one by one with the tracked """One pass of the word: glyphs drawn one by one with the tracked
advance, so the letter spacing is OURS and identical under the advance, so the letter spacing is OURS and identical under the
glow rings and the core.""" glow rings and the core."""
word = "PARVAGUES" word = WORDMARK # the brand's block lettering, one glyph at a time
qp.setPen(pen) qp.setPen(pen)
x = float(pad) x = float(pad)
for c in word: for c in word:
...@@ -1782,14 +1935,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1782,14 +1935,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
PARVAGU"), and it must FIT the D1 cell at the cell's margin — PARVAGU"), and it must FIT the D1 cell at the cell's margin —
which means the TYPE shrinks to the width, honestly, rather than which means the TYPE shrinks to the width, honestly, rather than
a squash that would make the letters strangers to themselves.""" a squash that would make the letters strangers to themselves."""
key = (round(span_w), round(span_h)) plate = self.theme.word_plate
key = (round(span_w), round(span_h), plate)
if self._word_pm_key == key: if self._word_pm_key == key:
return self._word_pm return self._word_pm
f = self._wave_font(span_w, span_h) f = self._wave_font(span_w, span_h)
if f is None: if f is None:
return None return None
ink_col = QtGui.QColor(PLATE_INK if plate else "#d900ff")
def bake(px, canvas_w, glow=True): def bake(px, canvas_w, glow=not plate):
fb = QtGui.QFont(f) fb = QtGui.QFont(f)
fb.setPixelSize(max(4, px)) fb.setPixelSize(max(4, px))
fmb = QtGui.QFontMetricsF(fb) fmb = QtGui.QFontMetricsF(fb)
...@@ -1815,9 +1970,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1815,9 +1970,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
qp.translate(math.cos(a) * rad, math.sin(a) * rad) qp.translate(math.cos(a) * rad, math.sin(a) * rad)
self._draw_word_layer(qp, base, pad_b, fmb, glow_pen) self._draw_word_layer(qp, base, pad_b, fmb, glow_pen)
qp.restore() qp.restore()
# the core: --neon-high itself, the site's own pink # the core: --neon-high itself, the site's own pink (or,
# on the plate, the DevCon navy -- pure, no glow)
self._draw_word_layer(qp, base, pad_b, fmb, self._draw_word_layer(qp, base, pad_b, fmb,
QtGui.QPen(QtGui.QColor("#d900ff"))) QtGui.QPen(ink_col))
finally: finally:
qp.end() qp.end()
return img, pad_b return img, pad_b
...@@ -1837,7 +1993,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1837,7 +1993,14 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
px = max(4.0, px * fit / word_w) px = max(4.0, px * fit / word_w)
img, pad = bake(px, span_w + int(px * 2)) img, pad = bake(px, span_w + int(px * 2))
else: else:
img, pad = bake(px, word_w + pad * 2) # pad1, not pad: pad is not bound on this path. It went unseen
# behind PARVAGUES (long enough to always take the fit branch
# at every geometry the selftest tries) — a SHORTER wordmark,
# i.e. exactly what the brand override invites, lands here.
# pad1 came from the probe bake at the same px, and bake's
# margin is a pure function of px, so it is the same number
# this bake is about to use.
img, pad = bake(px, word_w + pad1 * 2)
# crop to the true ink so the margins are the cell's, no more # crop to the true ink so the margins are the cell's, no more
left = 0 left = 0
w_ = img.width() w_ = img.width()
...@@ -1850,9 +2013,38 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1850,9 +2013,38 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
min(w_, right - left + 1 + pad), min(w_, right - left + 1 + pad),
img.height()) img.height())
pm = QtGui.QPixmap.fromImage(cropped) pm = QtGui.QPixmap.fromImage(cropped)
if plate:
pm = self._plate_pixmap(span_w, span_h, pm)
self._word_pm, self._word_pm_key = pm, key self._word_pm, self._word_pm_key = pm, key
return pm return pm
def _plate_pixmap(self, span_w, span_h, word_pm):
"""The DevCon hero as one baked picture: gradient, grid, word.
Built once per geometry like the word itself, so a frame is still
one blit and constructs no gradient."""
pw, ph = max(1, round(span_w)), max(1, round(span_h))
pm = QtGui.QPixmap(pw, ph)
pm.fill(QtGui.QColor(0, 0, 0, 0))
qp = QtGui.QPainter(pm)
try:
g = QtGui.QLinearGradient(0, 0, pw, ph)
for at, col in PLATE_STOPS:
g.setColorAt(at, QtGui.QColor(col))
qp.setOpacity(PLATE_ALPHA)
qp.fillRect(0, 0, pw, ph, QtGui.QBrush(g))
qp.setPen(QtGui.QColor(*PLATE_GRID))
step = max(4, round(ph / 5))
for x in range(step, pw, step):
qp.drawLine(x, 0, x, ph)
for y in range(step, ph, step):
qp.drawLine(0, y, pw, y)
qp.setOpacity(1.0)
qp.drawPixmap(round((pw - word_pm.width()) / 2.0),
round((ph - word_pm.height()) / 2.0), word_pm)
finally:
qp.end()
return pm
def _col_has_ink(self, img, x): def _col_has_ink(self, img, x):
return any(img.pixelColor(x, y).alpha() > 8 return any(img.pixelColor(x, y).alpha() > 8
for y in range(img.height())) for y in range(img.height()))
...@@ -1868,7 +2060,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -1868,7 +2060,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
if pm is None: if pm is None:
return return
row_y = self.my + 3 * self.ch # PHYSICAL_ORDER index of D row_y = self.my + 3 * self.ch # PHYSICAL_ORDER index of D
p.setOpacity(WORD_ALPHA) # the plate carries its own alpha (see PLATE_ALPHA)
p.setOpacity(1.0 if self.theme.word_plate else WORD_ALPHA)
try: try:
# clamped to the span's outer rect: at the smallest windows # clamped to the span's outer rect: at the smallest windows
# the glow ring can reach 1px past the inner margin, and a # the glow ring can reach 1px past the inner margin, and a
...@@ -2545,9 +2738,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2545,9 +2738,8 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
One drawText per glyph was 2.1 ms of an 8.9 ms frame. Consecutive One drawText per glyph was 2.1 ms of an 8.9 ms frame. Consecutive
entries sharing a colour AND a decay level are concatenated into one entries sharing a colour AND a decay level are concatenated into one
call — which is legitimate only for hex glyphs, because those are call — which is legitimate only for hex glyphs, and only on a
guaranteed to come from the monospaced face at exactly `self.mw` monospaced face, where they all advance exactly `self.mw`. A class glyph (◆ ≈ ≡ …) may be served by a fallback face
advance. A class glyph (◆ ≈ ≡ …) may be served by a fallback face
with a different advance, so those still draw one at a time or the with a different advance, so those still draw one at a time or the
ribbon would drift out of its grid. ribbon would drift out of its grid.
""" """
...@@ -2561,7 +2753,9 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2561,7 +2753,9 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
run_x, run_key = x, (0, 0) run_x, run_key = x, (0, 0)
for g, fam, t in self.stream: for g, fam, t in self.stream:
lv = self._lv(now - t, floor=2) lv = self._lv(now - t, floor=2)
key = (fam, lv) if g in HEX else None # Runs need every hex glyph at exactly `mw`; a proportional
# face (Jersey's A-F) gets one call per glyph instead.
key = (fam, lv) if g in HEX and self._hex_mono else None
if run and key == run_key: if run and key == run_key:
run.append(g) run.append(g)
else: else:
...@@ -2738,7 +2932,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2738,7 +2932,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
# on black read as the two different pages they are. # on black read as the two different pages they are.
for i, fam in enumerate((FAM_LEVEL, FAM_FX, FAM_NOTE)): for i, fam in enumerate((FAM_LEVEL, FAM_FX, FAM_NOTE)):
val, sat = th.ink.at(1.0 - i * 0.30) val, sat = th.ink.at(1.0 - i * 0.30)
p.setBrush(QtGui.QColor.fromHsvF(FAM_HUE[fam] / 360.0, sat, val)) p.setBrush(QtGui.QColor.fromHsvF(th.fam_hue[fam] / 360.0, sat, val))
p.drawRect(6, 5 + i * 9, n - 13 - i * 4, 6) p.drawRect(6, 5 + i * 9, n - 13 - i * 4, 6)
edge = th.chrome or th.ink edge = th.chrome or th.ink
val, sat = edge.at(0.55) val, sat = edge.at(0.55)
...@@ -2788,7 +2982,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2788,7 +2982,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
grp.addAction(a) grp.addAction(a)
m.addAction(a) m.addAction(a)
self._theme_acts[name] = a self._theme_acts[name] = a
self._act_cycle = QtGui.QAction("Next page\td", self) self._act_cycle = QtGui.QAction("Next theme\td", self)
self._act_cycle.triggered.connect(lambda _c=False: self.cycle_theme()) self._act_cycle.triggered.connect(lambda _c=False: self.cycle_theme())
m.addAction(self._act_cycle) m.addAction(self._act_cycle)
...@@ -2831,15 +3025,15 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0, ...@@ -2831,15 +3025,15 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0,
self._act_spec.setCheckable(True) self._act_spec.setCheckable(True)
self._act_spec.triggered.connect(lambda _c=False: self.toggle_spectro()) self._act_spec.triggered.connect(lambda _c=False: self.toggle_spectro())
m.addAction(self._act_spec) m.addAction(self._act_spec)
self._act_hl = QtGui.QAction("Tidal HL feed\th", self) self._act_hl = QtGui.QAction("Tidal orbit feed\th", self)
self._act_hl.setCheckable(True) self._act_hl.setCheckable(True)
self._act_hl.triggered.connect(lambda _c=False: self.toggle_hl()) self._act_hl.triggered.connect(lambda _c=False: self.toggle_hl())
m.addAction(self._act_hl) m.addAction(self._act_hl)
self._act_wave = QtGui.QAction("ParVagues wave\tw", self) self._act_wave = QtGui.QAction(f"{BRAND} wave\tw", self)
self._act_wave.setCheckable(True) self._act_wave.setCheckable(True)
self._act_wave.triggered.connect(lambda _c=False: self.toggle_wave()) self._act_wave.triggered.connect(lambda _c=False: self.toggle_wave())
m.addAction(self._act_wave) m.addAction(self._act_wave)
self._act_word = QtGui.QAction("PARVAGUES lettering\tl", self) self._act_word = QtGui.QAction(f"{WORDMARK} lettering\tl", self)
self._act_word.setCheckable(True) self._act_word.setCheckable(True)
self._act_word.triggered.connect(lambda _c=False: self.toggle_word()) self._act_word.triggered.connect(lambda _c=False: self.toggle_word())
m.addAction(self._act_word) m.addAction(self._act_word)
...@@ -3443,7 +3637,9 @@ def selftest_config() -> tuple[bool, str]: ...@@ -3443,7 +3637,9 @@ def selftest_config() -> tuple[bool, str]:
def selftest(seconds: float = 3.0, show: bool = False, def selftest(seconds: float = 3.0, show: bool = False,
theme: str = DEFAULT_THEME) -> int: theme: str = DEFAULT_THEME, shot_dir: str | None = None) -> int:
if shot_dir:
os.makedirs(shot_dir, exist_ok=True) # img.save() no-ops on a missing dir
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")
...@@ -3516,7 +3712,30 @@ def selftest(seconds: float = 3.0, show: bool = False, ...@@ -3516,7 +3712,30 @@ def selftest(seconds: float = 3.0, show: bool = False,
w.resize(sw, sh) w.resize(sw, sh)
app.processEvents() app.processEvents()
w.tick() w.tick()
# Re-feed before every grab: the decay is seconds-long and the loop
# above already spent most of them, so a picture taken now would show
# the resting state rather than the lens working.
for ev in parsed[:24]:
w.ingest(ev)
app.processEvents()
img = w.grab().toImage() img = w.grab().toImage()
if shot_dir and (sw, sh) == (1600, 1000):
img.save(os.path.join(shot_dir, "hero-%s.png" % w.theme.name))
# The brand layer is OFF by default (it is atmosphere, and the
# default lens must be pure measurement), so a screenshot set that
# never turns it on would fail to document it. Enable once here;
# every theme grab after this carries the watermark too.
if not w.wave:
w.toggle_wave()
if not w.word:
w.toggle_word()
app.processEvents()
for ev in parsed[:24]:
w.ingest(ev)
w.tick()
app.processEvents()
w.grab().toImage().save(
os.path.join(shot_dir, "hero-branded-%s.png" % w.theme.name))
shapes["%dx%d" % (sw, sh)] = round(w.dens, 2) shapes["%dx%d" % (sw, sh)] = round(w.dens, 2)
if img.width() != sw or img.height() != sh: if img.width() != sw or img.height() != sh:
shapes_ok = False shapes_ok = False
...@@ -3540,8 +3759,12 @@ def selftest(seconds: float = 3.0, show: bool = False, ...@@ -3540,8 +3759,12 @@ def selftest(seconds: float = 3.0, show: bool = False,
QtCore.Qt.Key.Key_D, QtCore.Qt.Key.Key_D,
QtCore.Qt.KeyboardModifier.NoModifier)) QtCore.Qt.KeyboardModifier.NoModifier))
w.tick() w.tick()
for ev in parsed[:24]:
w.ingest(ev)
app.processEvents() app.processEvents()
img = w.grab().toImage() img = w.grab().toImage()
if shot_dir:
img.save(os.path.join(shot_dir, "theme-%s.png" % w.theme.name))
sx, sy = max(1, img.width() // 12), max(1, img.height() // 12) sx, sy = max(1, img.width() // 12), max(1, img.height() // 12)
seen = {img.pixel(xx, yy) seen = {img.pixel(xx, yy)
for yy in range(0, img.height(), sy) for yy in range(0, img.height(), sy)
...@@ -3592,9 +3815,12 @@ def main(argv=None) -> int: ...@@ -3592,9 +3815,12 @@ def main(argv=None) -> int:
help="initial scale, 0.6-2.4 (also +/- at runtime; " help="initial scale, 0.6-2.4 (also +/- at runtime; "
"remembered between runs)") "remembered between runs)")
ap.add_argument("--theme", choices=sorted(THEMES), default=None, ap.add_argument("--theme", choices=sorted(THEMES), default=None,
help="palette: dark (the cockpit), light (pale desktop), " help="theme: dark (the cockpit), light (pale desktop), "
"sun (maximum contrast, for playing outdoors); " "sun (maximum contrast, for playing outdoors); "
"'d' cycles at runtime and the choice is remembered") "'d' cycles at runtime and the choice is remembered")
ap.add_argument("--word-face", choices=WORD_FACES, default="syne",
help="face of the PARVAGUES wordmark; jersey is the "
"DevCon26 stage face")
ap.add_argument("--no-tray", action="store_true", ap.add_argument("--no-tray", action="store_true",
help="skip the system-tray presence even where one exists") help="skip the system-tray presence even where one exists")
ap.add_argument("--spectro", action="store_true", ap.add_argument("--spectro", action="store_true",
...@@ -3603,11 +3829,14 @@ def main(argv=None) -> int: ...@@ -3603,11 +3829,14 @@ def main(argv=None) -> int:
ap.add_argument("--spectro-target", ap.add_argument("--spectro-target",
help="capture this node instead of the default sink") help="capture this node instead of the default sink")
ap.add_argument("--no-hl", action="store_true", ap.add_argument("--no-hl", action="store_true",
help="do not subscribe to the Tidal HL feed (SuperDirt " help="do not subscribe to the Tidal orbit feed (SuperDirt "
"mirror on port %d; 'h' toggles it at runtime)" "mirror on port %d; 'h' toggles it at runtime)"
% oscfeed.VIZ_PORT) % oscfeed.VIZ_PORT)
ap.add_argument("--selftest", action="store_true", ap.add_argument("--selftest", action="store_true",
help="render synthetic events for --seconds, then exit") help="render synthetic events for --seconds, then exit")
ap.add_argument("--screenshot", metavar="DIR",
help="with --selftest: save rendered frames (one per "
"theme, plus a large hero shot) as PNGs into DIR")
ap.add_argument("--seconds", type=float, default=3.0) ap.add_argument("--seconds", type=float, default=3.0)
ap.add_argument("--show", action="store_true", ap.add_argument("--show", action="store_true",
help="run the selftest on the real display, not offscreen") help="run the selftest on the real display, not offscreen")
...@@ -3619,7 +3848,8 @@ def main(argv=None) -> int: ...@@ -3619,7 +3848,8 @@ 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, a.theme or DEFAULT_THEME) return selftest(a.seconds, a.show, a.theme or DEFAULT_THEME,
shot_dir=a.screenshot)
# PLN, 2026-09-24: "do ensure never more than one instance, midiviz # PLN, 2026-09-24: "do ensure never more than one instance, midiviz
# should check unique else restart not duplicate". flock on a runtime # should check unique else restart not duplicate". flock on a runtime
...@@ -3628,14 +3858,14 @@ def main(argv=None) -> int: ...@@ -3628,14 +3858,14 @@ def main(argv=None) -> int:
# because Restart=always plus exit 0 would be a restart storm against # because Restart=always plus exit 0 would be a restart storm against
# the live instance. # the live instance.
rt = os.environ.get("XDG_RUNTIME_DIR") or "/run/user/%d" % os.getuid() rt = os.environ.get("XDG_RUNTIME_DIR") or "/run/user/%d" % os.getuid()
lock_path = Path(rt) / "parvagues" / "midiviz.instance" lock_path = Path(rt) / CONFIG_DIR / "midiviz.instance"
try: try:
lock_path.parent.mkdir(parents=True, exist_ok=True) lock_path.parent.mkdir(parents=True, exist_ok=True)
fh = open(lock_path, "w") fh = open(lock_path, "w")
try: try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError: except OSError:
print("midiviz: already running — refusing to duplicate " print("midiviz: already running, refusing to duplicate "
"(focus the existing window)", file=sys.stderr) "(focus the existing window)", file=sys.stderr)
try: try:
import launchers import launchers
...@@ -3665,7 +3895,7 @@ def main(argv=None) -> int: ...@@ -3665,7 +3895,7 @@ def main(argv=None) -> int:
# The theme joins it for the same reason: a `--theme` that lost to a saved # The theme joins it for the same reason: a `--theme` that lost to a saved
# one, or a saved one that lost to a corrupt file, is otherwise a silent # 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. # 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" 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) % (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
...@@ -3692,7 +3922,8 @@ def main(argv=None) -> int: ...@@ -3692,7 +3922,8 @@ def main(argv=None) -> int:
app.setQuitOnLastWindowClosed(False) app.setQuitOnLastWindowClosed(False)
w = build_widget(port or "--", reader, scale=scale, w = build_widget(port or "--", reader, scale=scale,
pinned_port=pinned, watch=True, theme=theme, pinned_port=pinned, watch=True, theme=theme,
persist=True, tray=not a.no_tray, hl=hl) persist=True, tray=not a.no_tray, hl=hl,
word_face=a.word_face)
if a.spectro: if a.spectro:
src = None src = None
if a.spectro_target: if a.spectro_target:
......
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