Commit 46709b26 by PLN (Algolia)

feat(midiviz): an X that closes it, a [x] that pins it, and a close that sticks

PLN: "needs basic menu or at least top right X to close, and its too sticky
atm if i close i wanna close it i guess maybe X and a [x] where x goes and
comes and is the 'stick above' feature, but it anyway is all desks always."

No menu -- two glyph targets in the header's right edge, in the same micro
font as everything else: "[x]" toggles always-on-top (the x IS the state) and
"X" closes. Dim by default, brighter under the pointer, which is the only
affordance a frameless window can offer. T toggles the pin from the keyboard;
Q and Escape already closed it and still do.

Controls win over the drag. The whole surface is a drag handle, so without an
explicit hit-test first the X would only ever have moved the window.

Targets are laid out from the right edge with the glyphs centred inside them,
not sized to fit the glyphs. Sizing to the glyph gave an 8px-wide X --
measured -- which is fine to look at and unhittable mid-set, and at 0.6 scale
the two targets then had to either overlap or shrink below usable. Deciding
targets first makes "disjoint and at least 18px" true by construction at every
zoom, asserted across four scales and three widths.

All-desktops is deliberately untouched: it is a KWin rule keyed on the app id,
it is unconditional as PLN said, and toggling the pin must not disturb it.

THE STICKINESS WAS NOT THE WINDOW. A clean exit is not a failure so systemd
never restarted it -- but rig_units.ensure() starts every non-manual unit that
is not active, and both gig-up and the Bridge watcher converge, so the lens
came straight back with nothing in the output admitting why. Marking the unit
"manual" would have answered the complaint by deleting the feature he asked
for last week.

So a deliberate close is recorded in $XDG_RUNTIME_DIR, ensure() honours the
latch and says so, and anything that starts the lens on purpose clears it.
That directory is wiped at logout, which is exactly the right lifetime:
"always open" is about how a session starts, "closed means closed" is about
what happens after he acts, and the two only looked contradictory. Closed for
this session, back at next login, no state to remember to undo. Only a close
the human asked for latches -- a compositor teardown is not an opinion.

The latch path is a contract between three files that deliberately do not
import each other, so a test asserts they agree rather than trusting three
copies of an f-string. That test immediately earned its keep: rig_units.py
used Path without importing it, on a line only reached once a latch existed.

Also fixes a shadow of my own making: the new painter was called
_paint_chrome, which is already a method on this widget, so it silently
replaced the window-chrome painter and the selftest died on the signature.
Renamed _paint_controls.
parent 436d327f
......@@ -83,7 +83,12 @@ LAUNCHERS = [
# click on an already-running lens silently did nothing.
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live MIDI as glyph-rain — the surface, watched",
"candidates": ["python3"], "args": [str(MIDIVIZ)], "pgrep": "midiviz.py",
"focus": "midiviz"},
"focus": "midiviz",
# Launching it from here is an explicit "I want this open", so it
# un-says a previous close (midiviz writes a latch that
# rig_units.ensure() honours until logout). Cleared only when we
# actually SPAWN -- focusing a running window is not a new decision.
"latch": "midiviz"},
]
# Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then
......@@ -264,6 +269,18 @@ def _argv(spec):
return _term_argv(inner) if spec.get("terminal") else inner
def _clear_latch(spec):
"""Drop a unit's user-close latch, because we are starting it on purpose."""
unit = spec.get("latch")
if not unit:
return
rt = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
try:
os.unlink(os.path.join(rt, "parvagues", f"{unit}.closed"))
except (FileNotFoundError, OSError):
pass
def launch(key):
"""Spawn the launcher detached. Returns {ok, status, msg, url?}."""
spec = BY_KEY.get(key)
......@@ -297,6 +314,7 @@ def launch(key):
argv = _argv(spec)
if not argv:
return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
_clear_latch(spec)
try:
subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
......
......@@ -282,6 +282,33 @@ def _install_signals() -> None:
pass
# The latch rig_units.ensure() reads. Duplicated as four lines here rather
# than imported: midiviz must run standalone (`python3 midiviz.py`) from a
# checkout with no rig on the box at all, and importing tools/rig_units.py
# from tools/bridge/ would make the lens depend on the supervisor it is only
# leaving a note for. The path is the contract; rig_units.latch_path() is the
# same expression and the test asserts they agree.
UNIT_NAME = "midiviz"
def latch_close_path():
rt = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
return os.path.join(rt, "parvagues", f"{UNIT_NAME}.closed")
def _latch_close():
"""Record that the human closed this, best-effort and never fatal."""
try:
path = latch_close_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
fh.write(time.strftime("%Y-%m-%dT%H:%M:%S%z") + "\n")
except OSError as e:
print(f"midiviz: could not write the close latch ({e}); "
f"the rig may start me again on its next converge",
file=sys.stderr)
def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
QtCore, QtGui, QtWidgets = _qt()
Qt = QtCore.Qt
......@@ -293,6 +320,15 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self.setWindowFlags(Qt.WindowType.Window
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint)
# On-top starts on (it is a lens you glance at over the editor) but
# is now a RUNTIME choice, because PLN asked for the pin and the
# close to be separate controls. All-desktops is deliberately NOT
# in here: it is a KWin rule keyed on the app id, it is
# unconditional, and toggling the pin must not disturb it.
self.on_top = True
self._user_closed = False
self._chrome_hot = False
self._t_pad = 4 # vertical slack on the hit targets
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent, True)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setCursor(Qt.CursorShape.SizeAllCursor)
......@@ -524,6 +560,7 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
pulse = 0.5 + 0.5 * math.sin((now - self.t0) * 0.55)
self._paint_chrome(p, w, h, pulse)
self._paint_header(p, now, pulse)
self._paint_controls(p)
self._paint_matrix(p, now, pulse)
self._paint_gutter(p, now, pulse)
self._paint_stream(p, now)
......@@ -542,6 +579,56 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
p.drawLine(cx, cy, cx + dx * n, cy)
p.drawLine(cx, cy, cx, cy + dy * n)
# ── the two controls, top right ────────────────────────────────────
#
# PLN: "needs basic menu or at least top right X to close ... maybe X
# and a [x] where x goes and comes and is the 'stick above' feature".
# So: no menu. Two glyph-sized targets in the header's right edge, in
# the same monospace micro font as everything else -- the pin reads as
# a checkbox whose x is literally the state, and the close is an X.
#
# Geometry lives in one place and both the painter and the hit-test
# call it, because a control drawn at one position and clickable at
# another is worse than no control at all.
MIN_TARGET = 18 # px: a trackpad-sized hit target
# Laid out from the RIGHT EDGE inwards, targets first and glyphs
# centred inside them -- not the other way round. Sizing the target to
# the glyph is what produced an 8px-wide X: fine to look at, unhittable
# mid-set, and at 0.6 scale the two targets then had to overlap or
# shrink below usable. Deciding the targets first makes "always
# disjoint and never smaller than MIN_TARGET" true by construction at
# every zoom level, and the text simply follows.
def _ctrl_rects(self):
"""(pin_rect, close_rect), disjoint and hittable at any scale."""
h = max(self.hdr_h + 2 * self._t_pad, self.MIN_TARGET)
y = self.hdr_y + (self.hdr_h - h) // 2
cw = max(self.MIN_TARGET, round(self.uw * 2))
pw = max(self.MIN_TARGET, round(self.uw * 3))
close = QtCore.QRect(self.gut_x - cw, y, cw, h)
pin = QtCore.QRect(close.left() - 2 - pw, y, pw, h)
return pin, close
def _ctrl_left(self):
"""Where the controls begin — the sparkline must stop here."""
return self._ctrl_rects()[0].left()
def _paint_controls(self, p):
p.setFont(self.f_micro)
# Dim like the CC reference layer: findable, never competing with
# the values the eye tracks while playing. Brighter under the
# pointer, which is the only affordance a frameless window has.
p.setPen(self.chrome[12 if self._chrome_hot else 6])
pin, close = self._ctrl_rects()
base = round(self.hdr_y + self.u_asc)
pin_txt = "[x]" if self.on_top else "[ ]"
p.drawText(pin.left() + max(0, (pin.width()
- round(self.uw * 3)) // 2),
base, pin_txt)
p.drawText(close.left() + max(0, (close.width()
- round(self.uw)) // 2),
base, "X")
def _paint_header(self, p, now, pulse):
p.setFont(self.f_micro)
lv = 5 + int(4 * pulse) if now - self.last_event_t > IDLE_AFTER else 12
......@@ -563,7 +650,9 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
# sparkline of events/100 ms, most recent at the right
hx = self.pad + round(self.uw * (len(head) + 1))
bw = max(1, round(self.scale))
avail = max(4, int((self.gut_x - hx) / (bw + 1)))
# stop short of the chrome, or the bars draw under the controls
right = self._ctrl_left() - round(self.uw)
avail = max(4, int(max(0, right - hx) / (bw + 1)))
bars = list(self.hist)[-avail:]
top = self.hdr_y
bot = self.hdr_y + self.hdr_h - 1
......@@ -745,7 +834,10 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
def keyPressEvent(self, e):
k = e.key()
if k in (Qt.Key.Key_Q, Qt.Key.Key_Escape):
self._user_closed = True
self.close()
elif k == Qt.Key.Key_T:
self._set_on_top(not self.on_top)
elif k == Qt.Key.Key_P:
self.paused = not self.paused
elif k == Qt.Key.Key_C:
......@@ -768,8 +860,30 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self._apply_scale()
self.update()
def _set_on_top(self, on: bool):
self.on_top = on
# 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
# rule, which is keyed on the app id and stays in force.
self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, on)
self.show()
self.update()
def mousePressEvent(self, e):
if e.button() == Qt.MouseButton.LeftButton:
# Controls win over the drag. The whole surface is a drag
# handle (frameless window, no titlebar), so without this the
# X would only ever move the window.
pin, close = self._ctrl_rects()
pos = e.position().toPoint()
if close.contains(pos):
self._user_closed = True
self.close()
return
if pin.contains(pos):
self._set_on_top(not self.on_top)
self._drag_from = None
return
# Wayland forbids a client positioning itself; startSystemMove is
# the compositor-blessed path. X11 falls back to a manual move.
wh = self.windowHandle()
......@@ -779,6 +893,16 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self._drag_from = e.globalPosition().toPoint() - self.pos()
def mouseMoveEvent(self, e):
pin, close = self._ctrl_rects()
pos = e.position().toPoint()
hot = pin.contains(pos) or close.contains(pos)
if hot != self._chrome_hot:
self._chrome_hot = hot
# An arrow over the controls, the move cursor everywhere else:
# the cursor is the only hint a frameless window can give.
self.setCursor(Qt.CursorShape.ArrowCursor if hot
else Qt.CursorShape.SizeAllCursor)
self.update()
if self._drag_from is not None:
self.move(e.globalPosition().toPoint() - self._drag_from)
......@@ -789,6 +913,18 @@ def build_widget(port_label: str, reader: "Reader | None", scale: float = 1.0):
self.timer.stop()
if self.reader:
self.reader.close()
# "if i close i wanna close it". A clean exit is not a failure, so
# systemd will not restart us -- but rig_units.ensure() starts
# every non-manual unit that is not active, and gig-up and the
# Bridge watcher both converge, so the window came straight back.
# Leave a latch ensure() honours. $XDG_RUNTIME_DIR means it dies
# at logout, which is the right lifetime: closed for this session,
# present again next login, nothing to remember to undo.
#
# Only for a close the HUMAN asked for. A compositor teardown at
# logout must not be mistaken for an opinion.
if self._user_closed:
_latch_close()
e.accept()
return MidiViz()
......
......@@ -132,3 +132,131 @@ def test_the_real_parser_feeds_the_real_ingest_path():
# every one of the 48 authored grid cells is covered
assert set(V.grid.CC_TO_CELL) <= set(ccs)
assert any(cc not in V.grid.CC_TO_CELL for cc in ccs), "no un-gridded CC to rain"
# ── the close latch ────────────────────────────────────────────────────────
#
# The latch is a PATH agreed on by three files that deliberately do not import
# each other: midiviz writes it, rig_units.ensure() honours it, and
# launchers.launch() clears it. That is the whole contract, and a typo in any
# one of them fails silently in the direction that costs PLN the complaint he
# raised -- the window coming back after he closed it. So assert they agree
# rather than trusting three copies of an f-string.
def test_latch_path_agrees_across_the_three_files(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
import importlib.util
import sys as _sys
from pathlib import Path as _Path
root = _Path(__file__).resolve().parents[3]
def _load(name, rel):
spec = importlib.util.spec_from_file_location(name, root / rel)
mod = importlib.util.module_from_spec(spec)
_sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
rig = _load("_rig_units_latch", "tools/rig_units.py")
mv = _load("_midiviz_latch", "tools/bridge/midiviz.py")
assert str(rig.latch_path("midiviz")) == mv.latch_close_path(), (
"rig_units.ensure() reads a different path than midiviz writes, so a "
"close would be silently undone by the next converge")
assert str(tmp_path) in mv.latch_close_path(), \
"the latch must live under XDG_RUNTIME_DIR so it dies at logout"
def test_a_written_latch_is_seen_and_cleared(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
import importlib.util
import sys as _sys
from pathlib import Path as _Path
root = _Path(__file__).resolve().parents[3]
def _load(name, rel):
spec = importlib.util.spec_from_file_location(name, root / rel)
mod = importlib.util.module_from_spec(spec)
_sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
rig = _load("_rig_units_latch2", "tools/rig_units.py")
mv = _load("_midiviz_latch2", "tools/bridge/midiviz.py")
lau = _load("_launchers_latch2", "tools/bridge/launchers.py")
assert not rig.closed_by_user("midiviz"), "no latch yet"
mv._latch_close()
assert rig.closed_by_user("midiviz"), \
"ensure() cannot see the close midiviz just recorded"
# An explicit launch is a new decision and must un-say the close.
lau._clear_latch({"latch": "midiviz"})
assert not rig.closed_by_user("midiviz"), \
"launching it on purpose left the latch in place, so the next " \
"converge would refuse to start it"
# Clearing a latch that is not there is not an error.
lau._clear_latch({"latch": "midiviz"})
lau._clear_latch({})
# ── the two controls stay hittable ─────────────────────────────────────────
#
# Sizing a hit target to its glyph gave an 8px-wide X: pleasant to look at,
# unhittable during a set, and at 0.6 scale the pin and the X had to either
# overlap or shrink below usable. The layout now derives targets from the
# right edge and centres the glyphs inside them, so this holds by
# construction -- which is worth an assertion at the scales PLN actually
# zooms through, since a regression here is invisible until he reaches for it.
def _widget(scale, width):
import importlib.util
import sys as _sys
from pathlib import Path as _Path
root = _Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location("_mv_ctrl",
root / "tools/bridge/midiviz.py")
mv = importlib.util.module_from_spec(spec)
_sys.modules["_mv_ctrl"] = 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=scale)
w.resize(width, 400)
w.show()
app.processEvents()
return mv, w
def test_controls_are_hittable_and_disjoint_at_every_scale():
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
mv, w0 = _widget(1.0, 900)
if mv is None:
pytest.skip("no Qt available")
for scale in (0.6, 1.0, 1.6, 2.4):
for width in (520, 900, 1920):
_, w = _widget(scale, width)
pin, close = w._ctrl_rects()
assert not pin.intersects(close), (
f"pin and X overlap at scale={scale} width={width}: a click "
f"meant for one would hit the other")
assert close.right() <= w.width(), \
f"the X is off-screen at scale={scale} width={width}"
assert min(pin.width(), pin.height(),
close.width(), close.height()) >= w.MIN_TARGET, (
f"a target fell below MIN_TARGET at scale={scale} "
f"width={width}: pin={pin} close={close}")
# The sparkline must stop before the controls, not run under them.
assert w._ctrl_left() <= pin.left(), \
"the sparkline is allowed to draw over the controls"
w.close()
......@@ -44,8 +44,10 @@ accident. `--status` spells it out rather than printing systemd's word.
"""
from __future__ import annotations
import os
import argparse
import subprocess
from pathlib import Path
import sys
# unit, label, process that proves it really works (None = the unit is the whole
......@@ -165,6 +167,45 @@ Wants=pipewire.service
"""
# ── the user-close latch ───────────────────────────────────────────────────
#
# PLN, 2026-09-05, about midiviz: "its too sticky atm if i close i wanna close
# it". He also asked for the same lens to be permanently present. Both are
# reasonable and they only look contradictory: "always open" is about how a
# SESSION starts, "closed means closed" is about what happens after he acts.
#
# ensure() starts every non-manual unit that is not active, and gig-up and the
# Bridge watcher both call it -- so closing the window put it back within the
# next converge, with nothing in the output admitting why. Making the unit
# "manual" would have answered the complaint by breaking the feature.
#
# So a close is recorded, and it is recorded in $XDG_RUNTIME_DIR: that
# directory is wiped at logout, which is exactly the lifetime this decision
# should have. Closed for the rest of this session; back on at next login,
# with no state to clean up and nothing to forget to undo.
def _latch_dir() -> Path:
rt = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
return Path(rt) / "parvagues"
def latch_path(unit: str) -> Path:
return _latch_dir() / f"{unit}.closed"
def closed_by_user(unit: str) -> bool:
return latch_path(unit).exists()
def clear_latch(unit: str) -> None:
"""Anything that deliberately starts a unit un-says the close."""
try:
latch_path(unit).unlink()
except FileNotFoundError:
pass
except OSError:
pass
def ensure(apply: bool) -> int:
"""Enable per policy AND start. The half gig-up.sh never did."""
st = states()
......@@ -178,6 +219,11 @@ def ensure(apply: bool) -> int:
# lcxl3-driver, so starting it here would KILL the LCXL3 translation
# mid-bring-up while printing nothing but green.
if s["active"] != "active" and BOOT[u] != "manual":
# A unit the human closed on purpose stays closed until logout.
if closed_by_user(u):
print(f"rig: {u:<24} inactive because it was CLOSED by hand "
f"— leaving it alone (latch: {latch_path(u)})")
continue
todo.append(("start", u, s["active"]))
if not todo:
print(f"rig: all {len(UNITS)} units enabled-per-policy and active — nothing to do")
......
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