Commit 05df7fee by PLN (Algolia)

feat(tray): Launch Gig — and Wayland can focus a window after all

PLN: "wheres the 'run parvagues' big HUD menu button? I see perf modes, open
bridge, then the gear 4/6 up, but below open bridge, i want a 'Launch Gig' button
that opens and focuses auto the parvagues".

Two problems behind that one sentence.

DEPTH. Pulsar was reachable only through Gear ▸ Pulsar — the hot path buried
under the tool drawer. It is now a top-level entry directly below Open Bridge,
labelled with what the click will actually do ("focus ParVagues" when it is up,
"open ParVagues" when it is not), because "Focus" vs "Open" is the difference
between a confident click and one that looks like a no-op.

FOCUS DID NOT EXIST. launchers.py's docstring said "on Wayland there's no
portable window-focus", so clicking a running app answered with the string
"already-running" and did nothing. That is true of the X11 toolbox — xdotool,
wmctrl and kdotool are all absent here and the first two cannot work under
Wayland anyway — but it is not true of THIS desktop. Plasma exposes KWin's
scripting engine on the session bus, and a script running inside the compositor
is simply allowed to activate a window.

So focus_window() registers a tiny KWin script, runs it, and unregisters it. It
matches on resourceClass (the app id), not the window title: the title changes
with every file PLN opens, and matching it would break the moment a filename
stopped containing "Tidal". The script name carries our pid so two clicks cannot
collide on an already-registered name. Verified live against the running Pulsar:
focus_window('pulsar') -> True.

Consequence worth noting: running native apps in the Gear menu used to be greyed
out, because the only honest thing a click could do was refuse. They are live
again — clicking a running app now raises it, which is the thing you actually
want mid-set.

Deliberately editor-only. Launch Gig does not start SuperCollider, boot Tidal or
arm Ardour: #116 is the standing rule that the hot path must not be able to start
the sound by accident, and one wrong click before a set is a stuck scsynth rather
than a convenience.

Every failure path falls back to reporting. A focus button that quietly does
nothing is a papercut; one that throws during a gig is not acceptable.

Bridge suite: 43 passed.
parent 21a55777
......@@ -161,6 +161,23 @@ class PerfTray:
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
# 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.
#
# Deliberately editor-only: it does not start SuperCollider, boot Tidal
# or arm Ardour. #116 is the standing rule that the hot path must not be
# able to start the sound by accident — one wrong click before a set is
# a stuck scsynth, not a convenience.
self.gig_act = QAction("🌊 Launch Gig", self.menu)
self.gig_act.triggered.connect(lambda: self._launch("pulsar"))
self.menu.addAction(self.gig_act)
# #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
......@@ -254,12 +271,22 @@ class PerfTray:
state = "stopped"
suffix = {"running": " — running", "stopped": "", "missing": " — not installed"}
act.setText(f"{self.GEAR_GLYPH[state]} {item['name']}{suffix[state]}")
# A running app stays clickable ONLY if clicking it opens something (a web
# tool's URL). For a native app, launch() would refuse anyway, and a
# greyed row says "already up" faster than a notification does.
act.setEnabled(item["available"] and
(item["kind"] == "web" or not item["running"]))
# 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.
act.setEnabled(item["available"])
self.gear_menu.setTitle(f"Gear ▸ {up}/{total} up")
if getattr(self, "gig_act", None) is not None:
pulsar = next((i for i in snap["apps"] if i["key"] == "pulsar"), None)
# Say which of the two things the click will do. "Focus" vs "Open" is
# the difference between a no-op-looking click and a confident one.
self.gig_act.setText("🌊 Launch Gig — focus ParVagues"
if pulsar and pulsar["running"]
else "🌊 Launch Gig — open ParVagues")
self.gig_act.setEnabled(bool(pulsar and pulsar["available"]))
def refresh_gear(self):
try:
......
......@@ -96,6 +96,89 @@ def _port_open(port, host="127.0.0.1"):
return s.connect_ex((host, port)) == 0
# ---------------------------------------------------------------------------
# Window focus on Wayland
#
# 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
# 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
# whose name does not contain "Tidal".
#
# Everything here is best-effort: if qdbus is missing, if KWin refuses, if the
# script registers but matches nothing, we fall back to reporting. A focus
# button that silently does nothing is a papercut; one that throws during a gig
# is not acceptable.
_KWIN_SCRIPT = """
var re = new RegExp(%s, "i");
var wins = (workspace.windowList ? workspace.windowList()
: workspace.clientList());
for (var i = 0; i < wins.length; i++) {
var w = wins[i];
var id = (w.resourceClass || "") + " " + (w.resourceName || "");
if (re.test(id)) {
w.minimized = false;
if (workspace.currentDesktop && w.desktops && w.desktops.length)
workspace.currentDesktop = w.desktops[0];
workspace.activeWindow = w; // Plasma 6
workspace.activeClient = w; // Plasma 5 fallback; harmless if absent
break;
}
}
"""
def _qdbus():
return which_first(("qdbus6", "qdbus", "qdbus-qt6"))
def focus_window(pattern):
"""Raise+activate the first window whose app id matches `pattern` (regex).
Returns True only when the whole round trip succeeded. Never raises.
"""
qd = _qdbus()
if not qd:
return False
tmp = None
try:
import json as _json
import tempfile
body = _KWIN_SCRIPT % _json.dumps(pattern)
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) as f:
f.write(body)
tmp = f.name
name = "pv-focus-%d" % os.getpid()
r = subprocess.run([qd, "org.kde.KWin", "/Scripting", "loadScript", tmp, name],
capture_output=True, text=True, timeout=5)
sid = (r.stdout or "").strip()
if r.returncode != 0 or not sid.lstrip("-").isdigit():
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.
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],
capture_output=True, text=True, timeout=5)
return ok
except Exception:
return False
finally:
if tmp:
try:
os.unlink(tmp)
except OSError:
pass
def binary(spec):
return which_first(spec["candidates"])
......@@ -190,9 +273,14 @@ def launch(key):
return {"ok": False, "status": "unavailable",
"msg": f"{spec['name']} not installed ({'/'.join(spec['candidates'])})"}
if not spec.get("terminal") and is_running(spec):
# Wayland: no portable focus — report rather than duplicate.
# 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
# second GHCi and a fight over port 6010.
if focus_window(spec.get("focus") or spec["key"]):
return {"ok": True, "status": "focused",
"msg": f"{spec['name']} brought to front"}
return {"ok": True, "status": "already-running",
"msg": f"{spec['name']} is already running"}
"msg": f"{spec['name']} is already running (could not focus)"}
argv = _argv(spec)
if not argv:
return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
......
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