Commit 6fb0f98d by PLN (Algolia)

feat(tray): Bridge-styled menu, de-dashed copy, keyboard mnemonics

The double click was the appindicator extension's, not ours: it reserves
double-click for SNI Activate (instant menu) and delays single-click by
400 ms, so a single click feels dead. Nothing to fix tray-side; documented
in the changelog.

- BRIDGE_QSS restyles the Qt-drawn popup on dark desktops: tonal surfaces,
  hairline borders, hover pill, magenta on the engaged radio; circles for
  radio groups, squares for boolean toggles. Contrast measured: body 14.7:1,
  disabled 6.4:1, radio ring 3.3:1 (bumped from 2.3:1 in review).
- stat rows and the thermal tooltip read in tabular digits.
- every user-visible string loses its em dash (colons, parens, middots);
  same pass on gearbox.py labels (shared with the web Bridge) and the
  launcher blurbs; unknown gearbox placeholder is now n/a, not a bare dash.
- mnemonics on the action rows (&Open Bridge, &GIG UP, &Restart SuperDirt,
  R&ig, &Start at login, Cut G&PU, &Quit); verified over DBusMenu that Qt
  exports them as underscores, so the shell-drawn twin gets them too.
  Literal ampersand does not survive the export, so the copy avoids it.
parent 4ea24cb0
......@@ -4,6 +4,35 @@ Sprint entries, newest first. Player-facing: what changed about *playing*, not
about the code. Task IDs reference the L'Armada board; `n/a` where the work was
unplanned (which, on a gig night, is most of it).
## Sprint 11 — 2026-09-17 · the tray learns which click is which
### Fixed
- **Why the tray wanted a double click.** Not perf-tray's code: Qt advertises
the SNI `Activate` method, the appindicator extension reserves double-click
for it (instant menu) and delays single-click by 400 ms before opening the
shell-drawn one, so a single click feels dead and fingers learn to click
twice. Nothing to fix in the tray; the delay is the extension's, and there is
no setting for it. Bonus discovery: the icon has been serving TWO menus with
two skins all along (instant Qt-drawn on double click, GNOME-styled on
single/right click).
### Changed
- **The Qt-drawn menu got the Ship's Bridge look** (`armada/DESIGN.md`): tonal
dark surfaces, hairline borders, hover pill, brand magenta on the engaged
radio, tabular digits on the stat rows and the thermal tooltip. Only applies
when the desktop asks for dark; the shell-drawn menus keep the desktop skin,
because a StatusNotifierItem lets us style only what Qt renders itself. All
contrast pairs measured (body 14.7:1, disabled 6.4:1, radio ring 3.3:1 after
a bump from 2.3:1 caught in review). Rendered offscreen before shipping.
- **Menu copy de-dashed and mnemonics added.** Every user-visible string now
uses colons, parentheses and middots instead of em dashes (statuses in
parentheses, armed warnings after a colon, descriptions after one). Action
rows carry keyboard mnemonics (&Open Bridge, &GIG UP, &Restart SuperDirt,
R&ig, &Start at login, Cut G&PU, &Quit): with the menu open, the letter
fires the row. The gearbox client's labels (`tools/bridge/gearbox.py`,
shared with the web Bridge) got the same punctuation pass, and its unknown
placeholder is now "n/a" instead of a bare dash.
## Sprint 10 — 2026-09-11 (midday) · the second audio server, again
A launch started by hand ("I opened Ardour and Pulsar and ran sclang myself")
......
#!/usr/bin/env python3
"""
perf-tray — system-tray face of the thermal GEARBOX (SRE/thermal/GEARBOX.md).
perf-tray: system-tray face of the thermal GEARBOX (SRE/thermal/GEARBOX.md).
Shows live thermals in the tray (a heat-coloured badge with a real temp sparkline)
and lets you move the gear lever without leaving your livecoding session.
This is THE gear UI. PLN, 2026-08-21: "i dont use the webui most of the time,
tray indicator is the only ui i use for parvagues gear bro" — hands on keyboard
tray indicator is the only ui i use for parvagues gear bro", hands on keyboard
and LCXL, eyes on Pulsar and the room, so a browser tab is a context switch that
does not happen mid-set. Everything the gearbox can do has to be reachable here.
......@@ -15,7 +15,7 @@ Design notes
* TWO AXES, never one list of modes: GEAR (off/standard/aggressive) is how hard
the machine may work, TRIM (quiet/auto/cool) is how the heat gets paid for, and
trim applies in every gear. Collapsing them back into five flat "modes" is the
exact mistake GEARBOX.md exists to undo — the old menu here did that, offering
exact mistake GEARBOX.md exists to undo; the old menu here did that, offering
Silent/Cool/Standard/Extreme/Normal as if quiet and cool were the same wish.
* The gearbox ENGINE is shell in the SRE repo; `tools/bridge/gearbox.py` is the
shared client both this tray and the web Bridge speak, so the two faces cannot
......@@ -28,7 +28,7 @@ Design notes
tray that reached around the arbiter would rebuild the mess in one click.
* hwmon device numbers are not stable across boots → chips resolved by name
(in perf.py).
* PyQt5 because it ships in the Arch repos (python-pyqt5) — no extra deps.
* PyQt5 because it ships in the Arch repos (python-pyqt5), no extra deps.
"""
import json
......@@ -69,7 +69,7 @@ SC_READY_S = 30 # measured 2026-08-14: active→responder armed
def sc_state():
"""(active, seconds_up) for the SuperDirt unit. Never raises — the tray is
"""(active, seconds_up) for the SuperDirt unit. Never raises: the tray is
non-essential and a status label must not be able to take the menu down."""
try:
r = subprocess.run(
......@@ -167,12 +167,12 @@ def temp_color(c):
# Widths are DELIBERATELY heavy. The first version used 2 px and 5 px on the 64 px
# pixmap, which looked right in a mockup and vanished on the panel: Qt renders the
# icon at ~22 px, so a 2 px stroke becomes 0.7 px of grey mush. Verified by
# rendering every combination at 20/24/32 px before believing it — a tray cue that
# rendering every combination at 20/24/32 px before believing it; a tray cue that
# only works at 64 px is a cue that does not work.
GEAR_RING_PX = {"off": 0, "standard": 6, "aggressive": 11}
TRIM_INK = {"quiet": "#9fa8da", # indigo — the fans are being held back
"auto": "#eceff1", # bone — platform's choice
"cool": "#4dd0e1"} # cyan — the fans are allowed to work
TRIM_INK = {"quiet": "#9fa8da", # indigo: the fans are being held back
"auto": "#eceff1", # bone: the platform's choice
"cool": "#4dd0e1"} # cyan: the fans are allowed to work
def make_icon(temp_c, hist=None, gear=None, trim=None):
......@@ -212,7 +212,7 @@ def make_icon(temp_c, hist=None, gear=None, trim=None):
p.setPen(pen)
p.drawPath(path)
# temp number in the upper area, clear of the sparkline — and clear of the
# temp number in the upper area, clear of the sparkline, and clear of the
# gear ring, which is why the size depends on the gear: an 11 px ring drawn
# over a 30 px numeral clips the digits at panel size, and aggressive is
# exactly the gear in which the temperature matters most.
......@@ -251,18 +251,21 @@ class PerfTray:
self.tray.setToolTip("perf-tray")
self.menu = QMenu()
# BRIDGE_QSS rounds the popup's corners; without translucency the window
# behind those corners is opaque and you get square black ears.
self.menu.setAttribute(Qt.WA_TranslucentBackground)
self._build_menu()
self.tray.setContextMenu(self.menu)
# A LEFT-click had no handler at all, so the icon looked dead. GNOME's
# AppIndicator extension turns button-1 into a StatusNotifierItem
# Activate() call and Qt re-emits it as activated(Trigger) — with nothing
# Activate() call and Qt re-emits it as activated(Trigger); with nothing
# connected, the most obvious gesture on the rig's only UI did nothing.
# Only button-3 opened anything, and that menu is drawn by the shell from
# the exported DBusMenu, not by us. PLN, 2026-09-06: "i see the parvagues
# tray icon, but doesnt react when i click or right click?"
# Qt5 hardcodes ItemIsMenu=false, so the host will never open the menu for
# us on a left click; popping it at the cursor is the only way. Middle
# click too — same menu, since a hidden third behaviour mid-set is worse
# click too: same menu, since a hidden third behaviour mid-set is worse
# than a redundant one.
self.tray.activated.connect(self.on_tray_activated)
self.tray.show()
......@@ -299,13 +302,13 @@ class PerfTray:
self.gear_group.setExclusive(True)
self.gear_actions = {}
for key, (label, blurb) in GB.GEARS.items():
act = QAction(f"{label} — {blurb}", self.menu, checkable=True)
act = QAction(f"{label}: {blurb}", self.menu, checkable=True)
act.triggered.connect(lambda _c, k=key: self.set_gear(k))
self.gear_group.addAction(act)
self.menu.addAction(act)
self.gear_actions[key] = act
self.trim_header = QAction("🌡 trim — applies in every gear", self.menu)
self.trim_header = QAction("🌡 trim (applies in every gear)", self.menu)
self.trim_header.setEnabled(False)
self.menu.addAction(self.trim_header)
......@@ -313,7 +316,7 @@ class PerfTray:
self.trim_group.setExclusive(True)
self.trim_actions = {}
for key, (label, blurb) in GB.TRIMS.items():
act = QAction(f"{label} — {blurb}", self.menu, checkable=True)
act = QAction(f"{label}: {blurb}", self.menu, checkable=True)
act.triggered.connect(lambda _c, k=key: self.set_trim(k))
self.trim_group.addAction(act)
self.menu.addAction(act)
......@@ -321,22 +324,22 @@ class PerfTray:
if not GB.installed():
# A sibling repo that was never installed here is a legitimate state,
# not an error — but a live-looking radio button that silently cannot
# not an error; but a live-looking radio button that silently cannot
# move is worse than a greyed one, so say it and grey them.
self.gear_header.setText("⚙ gear — gearbox NOT INSTALLED")
self.gear_header.setText("⚙ gear: gearbox NOT INSTALLED")
for act in (*self.gear_actions.values(), *self.trim_actions.values()):
act.setEnabled(False)
self.menu.addSeparator()
bridge_act = QAction("⚓ Open Bridge", self.menu)
bridge_act = QAction("⚓ &Open Bridge", self.menu)
bridge_act.triggered.connect(lambda: self._open_url(BRIDGE_URL))
self.menu.addAction(bridge_act)
# PLN, 2026-08-03: "below open bridge, i want a 'Launch Gig' button that
# opens and focuses auto the parvagues".
#
# Pulsar was already reachable, but four clicks down (Gear ▸ Pulsar) and
# — worse — clicking it while Pulsar was ALREADY UP did nothing but
# Pulsar was already reachable, but four clicks down (Gear ▸ Pulsar) and,
# worse, clicking it while Pulsar was ALREADY UP did nothing but
# print "already-running", because launchers.py assumed Wayland could
# not focus a window. It can, through KWin's scripting bus; see
# launchers.focus_window. So this is a top-level entry AND a real focus.
......@@ -344,7 +347,7 @@ class PerfTray:
# PLN, 2026-09-05: "click launch gig started pulsar, not ardour??", then
# "is our launch gig now autodoing?".
#
# It opened Pulsar and nothing else, and that was deliberate — #116 says
# It opened Pulsar and nothing else, and that was deliberate; #116 says
# the hot path must not be able to start the sound by accident. But the
# name promised a gig, so the button read as broken rather than as
# careful, and the rig's real launcher (the Bridge's RIG UP, which
......@@ -353,7 +356,7 @@ class PerfTray:
# nothing; it just moves the launch to a hand-typed command.
#
# So this now drives the SAME reconciler as the Bridge, and #116 is
# honoured by ARMING rather than by crippling — the idiom already proven
# honoured by ARMING rather than by crippling, the idiom already proven
# two entries below on Restart SuperDirt. With SuperDirt already up the
# press only converges and focuses, so it is one click. With it DOWN the
# press would start the sound, so the first click arms and says so and a
......@@ -366,7 +369,7 @@ class PerfTray:
# PLN, 2026-08-14: "add a restart SC command in parvagues tray gui menu".
#
# This is the only entry in the menu that can STOP THE SOUND, and it sits
# two pixels under "Launch Gig" — so it is deliberately not a one-click
# two pixels under "Launch Gig", so it is deliberately not a one-click
# action while the rig is up. First click ARMS it and says what it costs;
# a second click, which means opening the menu again, actually restarts.
# The arming lapses after ten seconds. That is the mirror of #116 ("the
......@@ -379,9 +382,9 @@ class PerfTray:
#
# Why it is wanted at all: the SuperCollider boot is the only way to arm
# the sample-watcher responder, and to pick up sample packs dropped since
# boot — a rig running headless under systemd has no post window and no
# boot: a rig running headless under systemd has no post window and no
# eval-over-OSC endpoint, so a restart is the ONLY way in.
self.sc_act = QAction("↻ Restart SuperDirt", self.menu)
self.sc_act = QAction("↻ &Restart SuperDirt", self.menu)
self.sc_act.triggered.connect(self.on_sc_clicked)
self.menu.addAction(self.sc_act)
self._sc_armed = False
......@@ -389,14 +392,15 @@ class PerfTray:
self._sc_restart_at = None # monotonic time of the last restart we fired
self.menu.aboutToShow.connect(self.refresh_sc)
# #69 — the menu offered to LAUNCH things without ever saying what was
# #69: the menu offered to LAUNCH things without ever saying what was
# already up. The state was there all along (launchers.is_running), but
# _build_menu ran once at startup and read only `available`, so a running
# Ardour looked identical to a stopped one — and the safe action ("do
# Ardour looked identical to a stopped one, and the safe action ("do
# nothing, it's up") looked identical to the dangerous one.
# Named "Rig ▸", not "Gear ▸": "gear" is now the thermal lever above, and
# the web panel already calls this collection of processes the rig (RIG UP).
self.rig_menu = self.menu.addMenu("Rig ▸")
self.rig_menu = self.menu.addMenu("R&ig ▸")
self.rig_menu.setAttribute(Qt.WA_TranslucentBackground)
self.rig_actions = {}
snap = LA.snapshot()
for item in (*snap["web"], *snap["apps"]):
......@@ -413,6 +417,7 @@ class PerfTray:
# disabled) the rest of the time. PLN, 2026-09-05: "i want the gui/tray
# to allow run and or close".
self.close_menu = self.rig_menu.addMenu("Close ▸")
self.close_menu.setAttribute(Qt.WA_TranslucentBackground)
self._rebuild_close_menu(snap)
self._apply_gear(snap)
# Gear state is refreshed on menu-open ONLY, never on the 2s timer: reading
......@@ -421,26 +426,32 @@ class PerfTray:
self.menu.aboutToShow.connect(self.refresh_gear)
self.menu.addSeparator()
self.autostart_action = QAction("Start at login", self.menu, checkable=True)
self.autostart_action = QAction("&Start at login", self.menu, checkable=True)
self.autostart_action.toggled.connect(self.on_autostart_toggled)
self.menu.addAction(self.autostart_action)
self.cutgpu_action = QAction("Cut GPU at gig launch", self.menu, checkable=True)
self.cutgpu_action = QAction("Cut G&PU at gig launch", self.menu, checkable=True)
self.cutgpu_action.toggled.connect(self.on_cutgpu_toggled)
self.menu.addAction(self.cutgpu_action)
self.menu.addSeparator()
# Live stats block — refreshed whenever the menu is about to show.
# Live stats block, refreshed whenever the menu is about to show.
# Readouts, not actions: a typeface with tabular digits, so a changing
# temp or fan RPM does not visibly reflow its row.
self.stat_actions = []
for _ in range(4):
a = QAction("", self.menu)
a.setEnabled(False)
f = a.font()
f.setStyleHint(QFont.TypeWriter)
f.setFamily("monospace")
a.setFont(f)
self.menu.addAction(a)
self.stat_actions.append(a)
self.menu.aboutToShow.connect(self.refresh)
self.menu.addSeparator()
quit_act = QAction("Quit perf-tray", self.menu)
quit_act = QAction("&Quit perf-tray", self.menu)
quit_act.triggered.connect(self.app.quit)
self.menu.addAction(quit_act)
......@@ -457,7 +468,7 @@ class PerfTray:
QProcess and not subprocess.run: the helper walks both RAPL zones, pushes
a firmware thermal profile and (in the loud gears) re-priorities the audio
chain, which takes about a second — long enough that a synchronous call
chain, which takes about a second, long enough that a synchronous call
would visibly freeze the menu on every click.
No desired-state file is written here. The gearbox persists the gear
......@@ -557,28 +568,28 @@ class PerfTray:
up += 1
else:
state = "stopped"
suffix = {"running": " — running", "stopped": "", "missing": " — not installed"}
suffix = {"running": " (running)", "stopped": "", "missing": " (not installed)"}
act.setText(f"{self.GEAR_GLYPH[state]} {item['name']}{suffix[state]}")
# A running NATIVE app used to be greyed out, because launch() could
# only refuse: there was no window focus on Wayland, so the only
# honest thing a click could do was print "already-running". Now that
# launchers.focus_window drives KWin's scripting bus, clicking a
# running app RAISES it — which is the thing you actually want mid-set
# — so the row stays live. Only a missing binary is unclickable.
# running app RAISES it, which is the thing you actually want mid-set
# so the row stays live. Only a missing binary is unclickable.
act.setEnabled(item["available"])
self.rig_menu.setTitle(f"Rig ▸ {up}/{total} up")
self.rig_menu.setTitle(f"R&ig ▸ {up}/{total} up")
if getattr(self, "gig_act", None) is not None:
# Three states, because the click means three different things and the
# dangerous one has to announce itself BEFORE it is pressed.
if self._gig_armed:
self.gig_act.setText("⚠ GIG UP — click again to START THE RIG")
self.gig_act.setText("⚠ &GIG UP: click again to START THE RIG")
else:
try:
sc_up = bool(sc_state()[0])
except Exception:
sc_up = False
self.gig_act.setText("🌊 GIG UP — converge & focus" if sc_up
else "🌊 GIG UP — boot the rig")
self.gig_act.setText("🌊 &GIG UP: converge and focus" if sc_up
else "🌊 &GIG UP: boot the rig")
# ------------------------------------------------------------ SuperDirt
#
......@@ -590,20 +601,20 @@ class PerfTray:
try:
active, up = sc_state()
if self._sc_armed:
self.sc_act.setText("⚠ Restart SuperDirt — click again to CUT THE SOUND")
self.sc_act.setText("⚠ &Restart SuperDirt: click again to CUT THE SOUND")
return
if active is None:
self.sc_act.setText("↻ Restart SuperDirt — state unknown")
self.sc_act.setText("↻ &Restart SuperDirt (state unknown)")
elif not active:
self.sc_act.setText("○ Start SuperDirt — down")
self.sc_act.setText("○ &Start SuperDirt (down)")
elif up is not None and up < SC_READY_S:
# `active` fires at exec; the banks then register alphabetically
# and the rig is not playable until the end of that. Say which of
# the two "up"s this is, so a silent rig is not a mystery.
self.sc_act.setText(
f"◐ SuperDirt booting — {fmt_dur(up)} of ~{SC_READY_S}s")
f"◐ SuperDirt booting ({fmt_dur(up)} of ~{SC_READY_S}s)")
else:
self.sc_act.setText(f"● Restart SuperDirt — up {fmt_dur(up)}")
self.sc_act.setText(f"● &Restart SuperDirt (up {fmt_dur(up)})")
except Exception:
pass
......@@ -658,15 +669,15 @@ class PerfTray:
"""Ask the Bridge to converge the rig, then open it.
POSTed from a worker thread and never waited on: a cold converge takes
~100 s, and the reason PLN launched Ardour by hand mid-evening — believing
RIG UP had failed — was a face that showed a spinner and no window. The
~100 s, and the reason PLN launched Ardour by hand mid-evening, believing
RIG UP had failed, was a face that showed a spinner and no window. The
Bridge answers the POST immediately and runs the job in the background,
so the only thing that must not block is this thread. Nothing in the
worker touches Qt, which is not thread-safe.
"""
self.tray.showMessage(
"perf-tray: GIG UP",
"Converging the rig — the Bridge is opening.",
"Converging the rig; the Bridge is opening.",
QSystemTrayIcon.Information, 4000,
)
......@@ -704,7 +715,7 @@ class PerfTray:
self.tray.showMessage(
"perf-tray: SuperDirt restarting",
f"{'Sound stopped. ' if was_up else ''}Banks register "
f"alphabetically — playable in ~{SC_READY_S}s.\n"
f"alphabetically; playable in ~{SC_READY_S}s.\n"
"Any sample packs added since the last boot come up with it.",
QSystemTrayIcon.Information, 6000,
)
......@@ -755,7 +766,7 @@ class PerfTray:
# Check the gear the FILE says, never one inferred from sysfs. The old
# menu inferred it (perf.detect_mode reads governor/EPP/max_perf_pct), and
# inference cannot tell a deliberate gear from power-profiles-daemon
# having quietly moved the same knobs — which it does, by design, since
# having quietly moved the same knobs, which it does, by design, since
# the gearbox cooperates with ppd instead of masking it.
for key, act in self.gear_actions.items():
act.blockSignals(True)
......@@ -767,10 +778,10 @@ class PerfTray:
act.blockSignals(False)
if GB.installed():
self.gear_header.setText(
"⚙ gear — " + (GB.label("gear", gb["gear"]) if gb["gear"]
"⚙ gear: " + (GB.label("gear", gb["gear"]) if gb["gear"]
else "none engaged (ppd owns the knobs)"))
self.trim_header.setText(
"🌡 trim — " + GB.label("trim", gb["trim"] or GB.DEFAULT_TRIM)
"🌡 trim: " + GB.label("trim", gb["trim"] or GB.DEFAULT_TRIM)
+ ("" if gb["trim"] else " (default)"))
# Reflect autostart state without re-triggering the toggle handler.
......@@ -789,7 +800,7 @@ class PerfTray:
w = power_caps_w()
g = dgpu_state()
gm = gpu_manager_mode()
cap = f"{w['pl1']}/{w['pl2']}W" if w.get("pl1") is not None else "–"
cap = f"{w['pl1']}/{w['pl2']}W" if w.get("pl1") is not None else "n/a"
if not g.get("present"):
gpu = "none"
elif gm == "integrated":
......@@ -803,7 +814,7 @@ class PerfTray:
label = GB.summary()
tip = (
f"perf-tray — gear: {label} · cap {cap}\n"
f"perf-tray · gear {label} · cap {cap}\n"
f"package {pkg}°C (hottest core {hottest}°C)\n"
f"freq {favg}/{fmax} MHz avg/max · fans {' / '.join(str(f) for f in fans) or 'n/a'} RPM\n"
f"gpu {gpu} · thermald {td} · throttle Δ {t.throttle_delta()}"
......@@ -820,6 +831,84 @@ class PerfTray:
a.setText(txt)
# The Qt-drawn popup gets the Ship's Bridge look (armada/DESIGN.md): tonal dark
# surfaces, hairline borders, a hover pill, and the brand magenta marking the
# engaged radio. This styles ONLY the menu Qt draws itself: the double-click /
# left-click popup. The single-click and right-click menus are rendered by the
# GNOME shell from the exported DBusMenu and keep the desktop's own skin; one
# surface, two renderers, is the price of a StatusNotifierItem.
#
# Only applied when the desktop asks for dark: on a light desktop a #171717
# menu would be a fad hoverboard on a wooden deck. The rig runs dark rooms,
# which is when this palette was drawn for anyway.
BRIDGE_QSS = """
QMenu {
background-color: #171717;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 6px 0px;
}
QMenu::item {
background: transparent;
color: #e8e8ea;
padding: 5px 24px 5px 8px;
margin: 1px 7px;
border-radius: 6px;
}
QMenu::item:selected {
background-color: #262626;
color: #ffffff;
}
QMenu::item:disabled {
color: #9a9aa0;
}
QMenu::indicator:exclusive {
width: 10px;
height: 10px;
margin-left: 6px;
}
QMenu::indicator:exclusive:checked {
background-color: #d900ff;
border: 2px solid #d900ff;
border-radius: 7px;
}
QMenu::indicator:exclusive:unchecked {
background-color: transparent;
border: 2px solid #6a6a70;
border-radius: 7px;
}
QMenu::indicator:exclusive:unchecked:hover {
border-color: #9a9aa0;
}
QMenu::indicator:non-exclusive {
width: 10px;
height: 10px;
margin-left: 6px;
background-color: transparent;
border: 2px solid #6a6a70;
border-radius: 3px;
}
QMenu::indicator:non-exclusive:checked {
background-color: #d900ff;
border-color: #d900ff;
}
QMenu::indicator:non-exclusive:unchecked:hover {
border-color: #9a9aa0;
}
QMenu::separator {
height: 1px;
background: rgba(255, 255, 255, 0.12);
margin: 5px 12px;
}
QToolTip {
background-color: #171717;
color: #e8e8ea;
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 4px 8px;
}
"""
def prefers_dark() -> bool:
"""Does the desktop ask for a dark UI?
......@@ -845,10 +934,24 @@ def prefers_dark() -> bool:
def apply_style(app) -> None:
"""Fusion + an explicit palette, so the popup looks like the desktop."""
"""Fusion + an explicit palette, so the popup looks like the desktop.
Dark additionally gets the Ship's Bridge QSS (BRIDGE_QSS above): the Fusion
palette alone is what rendered the 2009 grey box, because Fusion keeps its
own hard-coded item metrics no matter what the palette says.
"""
app.setStyle("Fusion")
if not prefers_dark():
return
app.setStyleSheet(BRIDGE_QSS)
# The tooltip is the dense thermal readout; proportional digits reflow
# line to line, so it reads in the same tabular face as the stat rows.
from PyQt5.QtGui import QFont
f = QFont()
f.setStyleHint(QFont.TypeWriter)
f.setFamily("monospace")
from PyQt5.QtWidgets import QToolTip
QToolTip.setFont(f)
from PyQt5.QtGui import QPalette
p = QPalette()
ink, ground, raised = QColor("#f6f5f4"), QColor("#353535"), QColor("#2b2b2b")
......@@ -874,14 +977,14 @@ def main():
# WAIT for the tray host instead of dying on it. On GNOME the tray is not a
# session service but an EXTENSION (appindicatorsupport), and it registers
# its StatusNotifierWatcher a few seconds after login — so at 20:44:10 this
# its StatusNotifierWatcher a few seconds after login, so at 20:44:10 this
# process printed "No system tray available", exited 1, and only systemd's
# Restart= saved it three seconds later. That is a race won by luck, and a
# slower login loses it: Restart=on-failure with a StartLimit would leave
# the rig's only UI permanently absent.
#
# Availability arrives over D-Bus, so it needs the event loop spinning to be
# noticed — hence a QTimer rather than a sleep().
# noticed; hence a QTimer rather than a sleep().
deadline = time.monotonic() + TRAY_WAIT_S
state = {"tray": None}
......
"""
gearbox — Python client for the thermal gearbox (SRE/thermal, GEARBOX.md).
gearbox: Python client for the thermal gearbox (SRE/thermal, GEARBOX.md).
ONE source of the gearbox vocabulary for every face: the Qt tray (perf-tray.py,
the face PLN actually uses mid-set), the web Bridge, and anything else that grows
later. The engine itself is shell and lives in the SRE repo — this module never
later. The engine itself is shell and lives in the SRE repo; this module never
duplicates its POLICY, it only names the gears and shells out.
The model, in one paragraph (the long version is SRE/thermal/GEARBOX.md):
Two ORTHOGONAL axes, not one list of modes.
GEAR — how hard the machine may work: off / standard / aggressive
TRIM — how the heat gets paid for: quiet / auto / cool
GEAR (how hard the machine may work): off / standard / aggressive
TRIM (how the heat gets paid for): quiet / auto / cool
Trim applies in EVERY gear. Quiet and cool are in tension: you cannot be fast
AND quiet AND cool, and the whole point of splitting the axes is that the UI
stops pretending you can.
Reading is unprivileged (a plain file); only SWITCHING needs root, via
`sudo -n` against the root-owned, sudoers-whitelisted helper — the same contract
`sudo -n` against the root-owned, sudoers-whitelisted helper, the same contract
perf.py uses for perf-audio. `-n` matters: the tray has no tty, so an
interactive sudo would hang a menu click forever instead of failing loudly.
"""
......@@ -27,14 +27,14 @@ from pathlib import Path
# Root-owned helper installed by SRE/thermal/install.sh. Every one of the argv
# forms built below is an exact-args NOPASSWD rule in /etc/sudoers.d/thermal-mode
# — that exactness is the security model, so DO NOT interpolate anything into
# that exactness is the security model, so DO NOT interpolate anything into
# these commands beyond a key validated against the tables below.
HELPER = os.environ.get("THERMAL_MODE_HELPER", "/usr/local/sbin/thermal-mode-apply")
CONF = Path(os.environ.get("THERMAL_MODE_CONF", "/etc/thermal-mode.conf"))
# gear key -> (label, blurb). Order = the lever's travel, idle → hard.
GEARS = {
"off": ("Off", "stop steering — ppd gets the knobs back"),
"off": ("Off", "stop steering; ppd gets the knobs back"),
"standard": ("Standard", "the everyday livecoding regime"),
"aggressive": ("Aggressive", "full watts the source can give, turbo, RT audio"),
}
......@@ -76,7 +76,7 @@ def read_conf(conf=None):
def state(conf=None):
"""Where the lever is, read unprivileged.
`gear`/`trim` are None when never set — that is NOT the same as "off" (a
`gear`/`trim` are None when never set; that is NOT the same as "off" (a
deliberate off) and the distinction is the whole reason `off` had to become
an explicit gear rather than the absence of one.
"""
......@@ -95,7 +95,7 @@ def state(conf=None):
def gear_argv(key):
"""argv to engage a gear. Raises ValueError on anything not in the table —
"""argv to engage a gear. Raises ValueError on anything not in the table:
the sudoers rules are exact-args, so an unvalidated key can only produce a
confusing sudo refusal instead of an honest error."""
if key not in GEARS:
......@@ -114,7 +114,7 @@ def trim_argv(key):
def apply(kind, key, timeout=25):
"""Blocking switch, for the web face and tests. Returns (ok, message).
The Qt tray does NOT use this — it drives the same argv through QProcess so a
The Qt tray does NOT use this: it drives the same argv through QProcess so a
menu click cannot freeze the tray while RAPL zones and fan profiles settle
(measured: an aggressive→standard switch takes about a second, and the RT
ladder walk can add more).
......@@ -134,12 +134,12 @@ def apply(kind, key, timeout=25):
def label(kind, key):
"""Human label for a key, or the key itself when it is unknown/None. Faces
show what the file SAYS even if this module has never heard of it — a
show what the file SAYS even if this module has never heard of it; a
gearbox newer than its UI must not render a blank."""
table = GEARS if kind == "gear" else TRIMS
if key in table:
return table[key][0]
return key or "—"
return key or "n/a"
def summary(conf=None):
......
"""launchers — the fleet's app launchers, shared by the web Bridge and the tray.
"""launchers: the fleet's app launchers, shared by the web Bridge and the tray.
An allow-listed registry (never arbitrary commands): each entry knows how to be
found, whether it's running, and how to spawn detached. Both faces — the web hub
(`/api/launch`) and the perf-tray menu — drive the same `launch(key)`.
found, whether it's running, and how to spawn detached. Both faces, the web hub
(`/api/launch`) and the perf-tray menu, drive the same `launch(key)`.
"Open if running, else launch": on Wayland there's no portable window-focus, so
a running app reports `already-running` (the user can click again knowingly)
......@@ -24,14 +24,14 @@ from pathlib import Path
HERE = Path(__file__).resolve().parent
TIDAL = Path.home() / "Work" / "Sound" / "Tidal"
# "Tidal Live" is THE performing session (records every orbit as a stem). "Tidal
# Multi" is the older ARCHIVE session holding the historical per-orbit recordings —
# Multi" is the older ARCHIVE session holding the historical per-orbit recordings;
# it was wired in here, so the tray's Ardour button opened the archive instead of the
# rig, and any faders touched there would have been the wrong ones. Auditing that
# same archive as if it were live already produced a confidently wrong fader report
# on 2026-07-28; this is the same mixup, one layer down.
ARDOUR_SESSION = Path.home() / "Work" / "Sound" / "Ardour" / "Tidal Live" / "Tidal Live.ardour"
MIDIMON = HERE / "midimon.py" # the terminal monitor, kept as the fallback
MIDIVIZ = HERE / "midiviz.py" # the frameless viz — what the button opens
MIDIVIZ = HERE / "midiviz.py" # the frameless viz, what the button opens
# Terminal emulators we know how to wrap, best first.
TERMINALS = ("konsole", "kitty", "alacritty", "gnome-terminal", "x-terminal-emulator")
......@@ -61,13 +61,13 @@ def _term_argv(inner):
# key -> spec. `candidates` = binaries (first found wins); `args` appended;
# `pgrep` = running-check pattern; `terminal` = run inside a terminal emulator.
LAUNCHERS = [
{"key": "pulsar", "name": "Pulsar", "blurb": "editor — open the Tidal folder",
{"key": "pulsar", "name": "Pulsar", "blurb": "editor: open the Tidal folder",
"candidates": ["pulsar"], "args": [str(TIDAL)], "exe": r"pulsar$"},
# `ardour9` was missing from the candidates, so this entry could never actually
# start the installed Ardour (9.2.0) — it reported "unavailable" while Ardour was
# start the installed Ardour (9.2.0); it reported "unavailable" while Ardour was
# running. Newest first, and the version-suffixed real binary (`ardour-9.7.0` as of 2026-09-05) is
# what the exe check has to match.
{"key": "ardour", "name": "Ardour", "blurb": "DAW — the Tidal Live session (records the stems)",
{"key": "ardour", "name": "Ardour", "blurb": "DAW: the Tidal Live session (records the stems)",
"candidates": ["ardour9", "ardour8", "ardour7", "ardour6", "ardour"],
"args": ([str(ARDOUR_SESSION)] if ARDOUR_SESSION.exists() else []),
"exe": r"[Aa]rdour[-\d.]*$"},
......@@ -84,7 +84,7 @@ LAUNCHERS = [
# workspace.windowList(); before midiviz.py set desktopFileName it was the
# even less useful "python3"). Matching "midimon" could never hit, so a
# click on an already-running lens silently did nothing.
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched",
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain, the surface watched",
"candidates": ["python3"], "args": [str(MIDIVIZ)], "pgrep": "midiviz.py",
"focus": "midiviz",
# Launching it from here is an explicit "I want this open", so it
......@@ -107,7 +107,7 @@ WEB = [
{"key": "foundry", "name": "The Foundry", "blurb": "URL → audio → stems → loops",
"url": "http://127.0.0.1:8765/", "port": 8765,
"cmd": ["python3", str(TIDAL / "tools/foundry/server.py"), "--port", "8765"]},
# Serve tide-table (the catalog viz lives there) and open triangle.html —
# Serve tide-table (the catalog viz lives there) and open triangle.html.
# exactly what `tide.py serve` does. Serving armada/ root = a dir listing.
{"key": "armada", "name": "L'Armada", "blurb": "catalog · triangle viz · gigs",
"url": "http://127.0.0.1:8731/triangle.html", "port": 8731,
......@@ -129,15 +129,15 @@ def _port_open(port, host="127.0.0.1"):
#
# The docstring above used to say "on Wayland there's no portable window-focus",
# and `launch()` therefore answered a click on a running app with a text status.
# That is true of the X11 toolbox — xdotool, wmctrl and kdotool are all absent
# here and the first two could not work under Wayland anyway — but it is not
# That is true of the X11 toolbox: xdotool, wmctrl and kdotool are all absent
# here and the first two could not work under Wayland anyway; but it is not
# true of THIS desktop. Plasma exposes KWin's scripting engine on the session
# bus, and a KWin script runs inside the compositor, where activating a window
# is simply allowed.
#
# So: register a tiny script, run it, unregister it. The script matches on
# resourceClass (the app id), which for Pulsar is stable across projects and
# window titles — matching on the title would break the moment PLN opens a file
# window titles; matching on the title would break the moment PLN opens a file
# whose name does not contain "Tidal".
#
# Everything here is best-effort: if qdbus is missing, if KWin refuses, if the
......@@ -191,7 +191,7 @@ def focus_window(pattern):
return False
# loadScript returns the script id; the object path is derived from it.
# A script that is already registered returns its EXISTING id, which is
# why the name carries our pid — two tray clicks must not collide.
# why the name carries our pid: two tray clicks must not collide.
ok = subprocess.run([qd, "org.kde.KWin", "/Scripting/Script%s" % sid, "run"],
capture_output=True, text=True, timeout=5).returncode == 0
subprocess.run([qd, "org.kde.KWin", "/Scripting", "unloadScript", name],
......@@ -223,7 +223,7 @@ def _cmdlines():
"""[(argv0 basename, full command line)] for every process, with NO forks.
This used to be one `pgrep -f` per launcher, so a single snapshot() forked N
processes — and snapshot() is called by the web Bridge's poll AND (now) by the
processes, and snapshot() is called by the web Bridge's poll AND (now) by the
tray menu. A fork per item per refresh is the shape of bug that made the LED
daemon lag; on an audio rig it is not worth paying for a boolean.
"""
......@@ -366,7 +366,7 @@ def is_running(spec, lines=None):
Matches the EXECUTABLE NAME (`exe`) rather than the whole command line wherever
possible, because the whole line is full of false positives: `pgrep -f ardour`
matched the `tidal-ardour-autoroute.sh` helper AND any shell whose command line
merely mentioned the word — so the tray could report Ardour up when it was down,
merely mentioned the word, so the tray could report Ardour up when it was down,
which is worse than reporting nothing. Full-line matching (`pgrep`) is kept only
for interpreted tools, where argv[0] is `python3` and the real identity is the
script path.
......@@ -398,10 +398,10 @@ def _argv(spec):
# ---------------------------------------------------------------------------
# Spawn guard
#
# 2026-09-05: RIG UP answered a press with "ardour: launched — launched Ardour"
# 2026-09-05: RIG UP answered a press with "ardour: launched, launched Ardour"
# while Ardour was already up. Two instances then collided over the session lock
# and BOTH exited, taking all twelve orbit links with them. The obvious suspect
# was is_running()'s `exe` regex, so it was parked as the blocker — but measured
# was is_running()'s `exe` regex, so it was parked as the blocker; but measured
# cold with Ardour running it returns True (argv0 is `ardour-9.7.0`, and
# `[Aa]rdour[-\d.]*$` matches it). The false negative is NOT reproducible, and
# there was no log of the decision, so the cause cannot be recovered.
......@@ -415,7 +415,7 @@ def _argv(spec):
# faces are two PROCESSES: the web Bridge and the perf-tray both call launch(),
# so an in-memory note in one is invisible to the other. It blocks while the pid
# we spawned is alive, plus a short window after the spawn to cover the gap
# between Popen returning and exec landing the new argv0 in /proc — the one
# between Popen returning and exec landing the new argv0 in /proc: the one
# interval in which is_running() is legitimately blind.
_RUNDIR = Path(os.environ.get("XDG_RUNTIME_DIR") or "/tmp") / "parvagues" / "launch"
SPAWN_GRACE = 20.0 # seconds; covers fork→exec→/proc visibility
......@@ -436,7 +436,7 @@ def _pid_alive(pid, spec):
The identity re-check matters: pids are recycled, and a stale pidfile whose
number now belongs to an unrelated process would refuse to ever launch again
— a guard that fails closed forever is its own outage.
(a guard that fails closed forever is its own outage).
"""
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
......@@ -487,14 +487,14 @@ def _note_spawn(spec, pid):
def _sweep_stale_pending(spec):
"""Move a SUPERSEDED Ardour crash-recovery file aside so no modal appears.
No Ardour flag suppresses the "Recover from crash?" dialog — only the absence
No Ardour flag suppresses the "Recover from crash?" dialog; only the absence
of the `.pending` file does (a clean quit removes it). That dialog is exactly
the kind of focus-stealing modal the hot path must not contain (#136).
But a `.pending` is sometimes real recovery data, so this deliberately does
NOT delete unconditionally: it moves one aside only when the session was
SAVED AFTER the pending was written, which means a later save already
superseded it and the dialog can only offer staler state. A `.pending` newer
than the save holds unsaved captures — that one deserves a human, so the
than the save holds unsaved captures; that one deserves a human, so the
dialog is left to appear.
Moved, never deleted, and into the session's own `dead/`: this runs
......@@ -511,7 +511,7 @@ def _sweep_stale_pending(spec):
for pend in sorted(session.parent.glob("*.pending")):
try:
if pend.stat().st_mtime >= saved:
_log(f"keeping {pend.name}: newer than the last save — a human should decide")
_log(f"keeping {pend.name}: newer than the last save; a human should decide")
continue
dead.mkdir(exist_ok=True)
dest = dead / f"{pend.name}.superseded-{int(time.time())}"
......@@ -555,7 +555,7 @@ def launch(key):
"msg": f"{spec['name']} not installed ({'/'.join(spec['candidates'])})"}
if not spec.get("terminal") and is_running(spec):
# Already up: FOCUS it rather than reporting at it. Spawning a duplicate
# would be worse than useless during a set — a second Pulsar means a
# would be worse than useless during a set: a second Pulsar means a
# second GHCi and a fight over port 6010, and a second Ardour kills BOTH
# over the session lock.
_log(f"{key}: running → focus")
......@@ -566,16 +566,16 @@ def launch(key):
"msg": f"{spec['name']} is already running (could not focus)"}
# is_running() said no. Before believing it, ask whether WE started this thing
# moments ago — the one question a /proc snapshot cannot answer, and the one
# moments ago: the one question a /proc snapshot cannot answer, and the one
# that would have prevented the 2026-09-05 double-Ardour whatever went wrong
# upstream of it.
recent = _recent_spawn(spec)
if recent:
pid, age = recent
_log(f"{key}: NOT spawning — we started pid {pid} {age:.1f}s ago")
_log(f"{key}: NOT spawning, we started pid {pid} {age:.1f}s ago")
if focus_window(spec.get("focus") or spec["key"]):
return {"ok": True, "status": "focused",
"msg": f"{spec['name']} was already started ({age:.0f}s ago) — brought to front"}
"msg": f"{spec['name']} was already started ({age:.0f}s ago), brought to front"}
return {"ok": True, "status": "already-running",
"msg": f"{spec['name']} was already started {age:.0f}s ago (pid {pid})"}
......@@ -589,10 +589,10 @@ def launch(key):
proc = subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e:
_log(f"{key}: spawn failed — {e}")
_log(f"{key}: spawn failed: {e}")
return {"ok": False, "status": "error", "msg": str(e)}
_note_spawn(spec, proc.pid)
_log(f"{key}: SPAWNED pid {proc.pid} — {' '.join(argv)}")
_log(f"{key}: SPAWNED pid {proc.pid}: {' '.join(argv)}")
return {"ok": True, "status": "launched", "msg": f"launched {spec['name']}"}
......
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