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)
......
......@@ -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