diff --git a/cider/README.md b/cider/README.md index 31b66381f..d5a108d08 100644 --- a/cider/README.md +++ b/cider/README.md @@ -6,28 +6,25 @@ Replaces the sparse stock media OSD **for Cider only**. Other players keep Nocta ## Plugin - -| Field | Value | -| ---------- | ---------------------------------------------------------- | -| ID | `dragged/cider` | -| Entries | Bar widget: `now-playing`; panel: `osd`; service: `bridge` | -| Dependency | `python3`, `gtk3`, `gtk-layer-shell`, `python-gobject` | - +| Field | Value | +| ------------ | ---------------------------------------------------------- | +| ID | `dragged/cider` | +| Entries | Bar widget: `now-playing`; panel: `osd`; service: `bridge` | +| Dependencies | `python3`, `gtk3`, `gtk-layer-shell`, `python-gobject` | ## Requirements -- Noctalia v5.0.0-beta.9+ (`plugin_api` 24 — argv `runAsync`). Tested on Umbriel, Niri, and Hyprland. +- Noctalia v5.0.0-beta.9+ (`plugin_api` 24 — argv `runAsync`). Tested on Umbriel 0.1.0 / Noctalia v5.0.1; Niri and Hyprland keep left-click = lyrics HUD. - Cider with Connectivity / External API enabled - `python3` on `PATH`, with `python-socketio`, `requests`, and `websocket-client` (`pip install -r requirements.txt` from this plugin directory) - Overlay HUD: `gtk3`, `gtk-layer-shell`, and `python-gobject`. Untimed silence gate optionally uses `parec` (PulseAudio / PipeWire). - - +- Umbriel loft (middle-click): `umbriel` on `PATH` (0.1.0+). No-op on other compositors. ## Usage 1. Enable **Cider** in Settings → Plugins (or `noctalia msg plugins enable dragged/cider`). 2. Open **Settings → Plugins → Cider** and set the API token, or leave it empty to reuse `~/.config/cider-kde-notifier/config.json`. -3. Add bar widget `dragged/cider:now-playing`. Click it to toggle the sticky lyrics HUD (gtk-layer-shell overlay). +3. Add bar widget `dragged/cider:now-playing`. **Left-click** toggles the sticky lyrics HUD. **Middle-click** lofts Cider to the Umbriel scratchpad (and restores it). **Right-click** shows the OSD card. All three are remappable in the widget editor (`[widget.actions]`). 4. **Do not put Cider on** `shell.mpris.blacklist` — that also kills Control Center / Media Now Playing. 5. Hide Cider app toasts with a notification filter (not an MPRIS blacklist). Plugin toasts use app name `Now Playing` / desktop entry `noctalia-now-playing`: @@ -49,22 +46,29 @@ Put `cider_app` first in `notification.filter_order`. media = false ``` - - ### Panels ```sh noctalia msg panel-toggle dragged/cider:osd ``` -`osd` is the now-playing card (also opened automatically on track change when **Track alert** is OSD). Lyrics always use the gtk-layer-shell overlay. - -Bar chip: **left-click** toggles the lyrics HUD; **right-click** shows the OSD card. Both are remappable in the widget editor (`[widget.actions]`). +`osd` is the now-playing card (also opened automatically on track change when **Track alert** is OSD). It is a persistent floating panel. Lyrics always use the gtk-layer-shell overlay. On 5.0.1 the host also injects Attached/Floating/Layer/Open Near Click overrides under **Settings → Plugins** (gear on this plugin). Cider’s MPRIS has no synced lyrics. The bridge pulls Apple Music TTML via Cider’s amapi (LRCLIB fallback) for the sticky HUD. -## Settings +### Bar chip clicks +Defaults are remappable in the widget editor. + +| Click | Umbriel | Niri / Hyprland | +| --- | --- | --- | +| Left | Lyrics HUD | Lyrics HUD | +| Middle | Loft that Cider window (scratchpad send/restore) | Lyrics HUD (`chip-left` dispatcher) | +| Right | Track OSD | Track OSD | + +`toggle-loft` is also a bindable IPC event. It is a no-op when Cider is not running or the window id is unknown. Lyrics and OSD IPC stay unaliased. + +## Settings | Setting | Type | Default | Description | | ------------------------- | -------- | ------------------------ | --------------------------------------------------------------------------- | @@ -93,7 +97,6 @@ Cider’s MPRIS has no synced lyrics. The bridge pulls Apple Music TTML via Cide | `cover_size` | `int` | `18` | Bar widget artwork size, 12–32 px. | | `glyph` | `glyph` | `music` | Bar widget fallback icon when artwork is hidden/missing. | - Gap under the bar is shell-global: **Settings → Shell → Panel → floating offset**. ## IPC @@ -104,15 +107,16 @@ noctalia msg plugin dragged/cider:bridge all hide-osd noctalia msg plugin dragged/cider:bridge all toggle-lyrics-hud noctalia msg plugin dragged/cider:bridge all show-lyrics-hud noctalia msg plugin dragged/cider:bridge all hide-lyrics-hud +noctalia msg plugin dragged/cider:bridge all chip-left +noctalia msg plugin dragged/cider:bridge all toggle-loft ``` - - ## Notes - **Network:** the Python bridge talks to Cider’s local Connectivity API (`base_url`). Lyrics use Cider `amapi/run-v3` (Apple Music TTML) with an LRCLIB fallback. -- **Processes:** `scripts/start-bridge.sh` launches `scripts/cider_bridge.py`. The lyrics HUD is `scripts/lyrics_overlay.py`. Disable/uninstall stops them via `onExit`. -- **Filesystem:** runtime JSON, artwork, and the Cider API token file live under `~/.cache/noctalia-cider/`. Durable settings also go to `noctalia.pluginDataDir()`. `ui.image` only loads local cover files after the bridge downloads them. Detached process logs: `/tmp/noctalia-cider-bridge.log`, `/tmp/noctalia-cider-lyrics-overlay.log`. +- **Processes:** `scripts/start-bridge.sh` launches `scripts/cider_bridge.py`. The lyrics HUD is `scripts/lyrics_overlay.py`. Umbriel loft is a one-shot `python3 cider_bridge.py --toggle-loft` (does not restart the bridge). Disable/uninstall stops helpers via `onExit`. +- **Filesystem:** runtime JSON, artwork, loft latch, and the Cider API token file live under `~/.cache/noctalia-cider/`. Durable settings also go to `noctalia.pluginDataDir()`. `ui.image` only loads local cover files after the bridge downloads them. Detached process logs: `/tmp/noctalia-cider-bridge.log`, `/tmp/noctalia-cider-lyrics-overlay.log`. +- **Compositor:** Umbriel loft send/restore uses `umbriel msg` (`window-move-to-scratchpad`, `scratchpad-toggle`, `window-restore-from-scratchpad`). If another client is stored in the same output pad, it can flash for a frame on restore — Umbriel cannot restore a hidden pad member without showing the pad first. - **Panels:** `panel-open` / `panel-close` are used instead of `togglePanel` so a persistent toast is never inverted if it is already open. - Local path source for development: @@ -121,4 +125,4 @@ noctalia msg plugins source add cider-local path /path/to/noctalia-plugin noctalia msg plugins enable dragged/cider noctalia msg config-reload noctalia plugins lint cider -``` \ No newline at end of file +``` diff --git a/cider/plugin.toml b/cider/plugin.toml index 6b83125f1..ec599ab22 100644 --- a/cider/plugin.toml +++ b/cider/plugin.toml @@ -7,7 +7,7 @@ id = "dragged/cider" name = "Cider" -version = "1.9.7" +version = "1.10.0" plugin_api = 24 author = "dragged" license = "MIT" @@ -246,6 +246,7 @@ entry = "widget.luau" # Remappable from the bar widget editor (community pattern: airpods / todo / mimir). [widget.actions] left = "plugin dragged/cider:bridge all toggle-lyrics-hud" + middle = "plugin dragged/cider:bridge all chip-left" right = "plugin dragged/cider:bridge all show-osd" [[widget.setting]] diff --git a/cider/scripts/cider_bridge.py b/cider/scripts/cider_bridge.py index 75266c597..2e72b29cd 100644 --- a/cider/scripts/cider_bridge.py +++ b/cider/scripts/cider_bridge.py @@ -204,21 +204,95 @@ def _empty_window_probe() -> dict[str, Any]: } -def _umbriel_windows_text() -> str | None: +def _loft_path() -> Path: + return _STATE_DIR / "loft.json" + + +def _read_loft() -> dict[str, Any]: + path = _loft_path() + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _write_loft(payload: dict[str, Any]) -> None: + _STATE_DIR.mkdir(parents=True, exist_ok=True) + _atomic_write(_loft_path(), json.dumps(payload, ensure_ascii=False)) + + +def _clear_loft() -> None: + try: + _loft_path().unlink(missing_ok=True) + except OSError: + pass + + +def _umbriel_output_from_workspace(workspace: str | None) -> str: + raw = (workspace or "").strip() + if ":" in raw: + return raw.split(":", 1)[0] + return raw + + +def _umbriel_in_scratchpad(window: dict[str, Any] | None) -> bool: + """Pad members stay in `windows --json` with an empty workspace.""" + if window is None: + return True + return not _umbriel_output_from_workspace(window.get("workspace")) + + +def _umbriel_peer_output(windows: list[dict[str, Any]]) -> str: + focused = next((w for w in windows if w.get("focused") is True), None) + if focused is not None: + output = _umbriel_output_from_workspace(focused.get("workspace")) + if output: + return output + for row in windows: + output = _umbriel_output_from_workspace(row.get("workspace")) + if output: + return output + return "" + + +def _lofted_window_payload(loft: dict[str, Any]) -> dict[str, Any]: + payload = _empty_window_probe() + payload["compositor"] = "umbriel" + payload["present"] = True + payload["focused"] = False + payload["on_screen"] = False + payload["suppress_notify"] = False + wid = str(loft.get("id") or "") + if wid: + payload["id"] = wid + output = str(loft.get("output") or "") + if output: + payload["output"] = output + return payload + + +def _umbriel_windows_json() -> list[dict[str, Any]] | None: + """Listed Umbriel windows, or None when the compositor query failed.""" if not shutil.which("umbriel"): return None try: - return subprocess.check_output( - ["umbriel", "windows"], + raw = subprocess.check_output( + ["umbriel", "windows", "--json"], stderr=subprocess.DEVNULL, timeout=1.5, ).decode("utf-8") + data = json.loads(raw) except Exception as exc: - log.debug("umbriel windows query failed: %s", exc) + log.debug("umbriel windows --json failed: %s", exc) + return None + if not isinstance(data, list): return None + return [row for row in data if isinstance(row, dict)] def _parse_umbriel_windows(text: str) -> list[tuple[bool, str, str]]: + """TSV listing leftover for tests; live probe uses JSON.""" rows: list[tuple[bool, str, str]] = [] for line in text.splitlines(): if not line.strip(): @@ -233,28 +307,147 @@ def _parse_umbriel_windows(text: str) -> list[tuple[bool, str, str]]: return rows -def _probe_umbriel() -> dict[str, Any] | None: - text = _umbriel_windows_text() - if text is None: +def _umbriel_on_screen(cider: dict[str, Any], windows: list[dict[str, Any]]) -> bool: + if cider.get("focused") is True: + return True + cider_ws = str(cider.get("workspace") or "") + focused = next((w for w in windows if w.get("focused") is True), None) + if focused is None: + return bool(cider.get("active") is True) + return cider_ws != "" and cider_ws == str(focused.get("workspace") or "") + + +def apply_umbriel_listing(windows: list[dict[str, Any]] | None) -> dict[str, Any] | None: + """Map an Umbriel window list (or query failure) onto window.json + loft latch.""" + loft = _read_loft() + cached_id = str(loft.get("id") or "") + + if windows is None: + if loft.get("lofted") is True and cached_id: + return _lofted_window_payload(loft) return None - cider_focused = False - cider_present = False - for focused, app_id, title in _parse_umbriel_windows(text): - if not _is_cider_window(app_id, title): - continue - cider_present = True - if focused: - cider_focused = True + + cider = next( + (w for w in windows if _is_cider_window(w.get("app_id"), w.get("title"))), + None, + ) + if cider is not None: + wid = str(cider.get("id") or cached_id) + ws_output = _umbriel_output_from_workspace(cider.get("workspace")) + lofted = _umbriel_in_scratchpad(cider) + output = ws_output or str(loft.get("output") or "") or _umbriel_peer_output(windows) + _write_loft({"id": wid, "output": output, "lofted": lofted}) + if lofted: + return _lofted_window_payload({"id": wid, "output": output, "lofted": True}) + focused = cider.get("focused") is True + on_screen = _umbriel_on_screen(cider, windows) + payload = _empty_window_probe() + payload["compositor"] = "umbriel" + payload["present"] = True + payload["focused"] = focused + payload["on_screen"] = on_screen + payload["suppress_notify"] = focused or on_screen + if wid: + payload["id"] = wid + if output: + payload["output"] = output + return payload + + if cached_id: + output = str(loft.get("output") or "") + _write_loft({"id": cached_id, "output": output, "lofted": True}) + return _lofted_window_payload({"id": cached_id, "output": output, "lofted": True}) + + if not windows: + return None + payload = _empty_window_probe() payload["compositor"] = "umbriel" - payload["present"] = cider_present - payload["focused"] = cider_focused - # Listed windows are mapped on the active layout strip. - payload["on_screen"] = cider_present - payload["suppress_notify"] = cider_focused or cider_present return payload +def _probe_umbriel() -> dict[str, Any] | None: + return apply_umbriel_listing(_umbriel_windows_json()) + + +def _umbriel_msg(action: str) -> bool: + if not action or not shutil.which("umbriel"): + return False + try: + subprocess.check_call( + ["umbriel", "msg", action], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=1.5, + ) + return True + except Exception as exc: + log.debug("umbriel msg %s failed: %s", action, exc) + return False + + +def _umbriel_action(name: str, output: str) -> str: + output = (output or "").strip() + if not output: + return "" + return f"{name}:{output}" + + +def _window_is_focused(windows: list[dict[str, Any]] | None, wid: str) -> bool: + if not windows or not wid: + return False + return any(str(w.get("id") or "") == wid and w.get("focused") is True for w in windows) + + +def toggle_loft( + msg: Callable[[str], bool] | None = None, + listing: Callable[[], list[dict[str, Any]] | None] | None = None, +) -> int: + """Send or restore Cider on Umbriel. Unaddressable window → no-op.""" + send = msg or _umbriel_msg + list_windows = listing or _umbriel_windows_json + windows = list_windows() + payload = apply_umbriel_listing(windows) + if payload is None or payload.get("compositor") != "umbriel": + return 0 + loft = _read_loft() + wid = str(loft.get("id") or payload.get("id") or "") + output = str(loft.get("output") or payload.get("output") or "") + if not wid or not output: + return 0 + was_lofted = loft.get("lofted") is True + cider_now = next( + (w for w in (windows or []) if str(w.get("id") or "") == wid), + None, + ) + pad_visible = ( + was_lofted + and cider_now is not None + and cider_now.get("focused") is True + ) + if was_lofted: + # Hidden pad members ignore window-focus. Restore needs the pad shown first. + # https://docs.noctalia.dev/umbriel/scratchpads/ + if not pad_visible: + show = _umbriel_action("scratchpad-toggle", output) + if show: + send(show) + send(f"window-focus:{wid}") + restore = _umbriel_action("window-restore-from-scratchpad", output) + if restore: + send(restore) + return 0 + if not send(f"window-focus:{wid}"): + return 0 + if _window_is_focused(list_windows(), wid) is False: + return 0 + pad = _umbriel_action("window-move-to-scratchpad", output) + if not pad: + return 0 + send(pad) + return 0 + + def _niri_json(cmd: list[str]) -> Any | None: try: raw = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=1.5) @@ -847,6 +1040,7 @@ def disconnect() -> None: self._track_key = "" self._lyrics_key = "" self._last = {} + _clear_loft() emit(TrackEvent(type="clear")) emit(TrackEvent(type="status", message="disconnected")) @@ -870,20 +1064,11 @@ def stop(self) -> None: def _window_loop(self) -> None: last_body = "" - was_present = False while not self._stop.is_set(): try: payload = probe_cider_window() - present = payload.get("present") is True - # Closing Cider removes its window — clear immediately instead of - # waiting for socket/API death (that lag left the bar chip stuck). - if was_present and not present: - self._track_key = "" - self._lyrics_key = "" - self._last = {} - emit(TrackEvent(type="clear")) - emit(TrackEvent(type="status", message="cider_closed")) - was_present = present + # Unlist while the session is alive is loft (KTD1). Chip hide + # waits for socket/API death, not a missing window row. body = json.dumps(payload, ensure_ascii=False) if body != last_body: _write_window(payload) @@ -1337,12 +1522,20 @@ def main() -> int: ) parser.add_argument("--poll", type=float, default=0.0) parser.add_argument("--log-level", default="WARNING") + parser.add_argument( + "--toggle-loft", + action="store_true", + help="One-shot Umbriel loft send/restore; do not start the bridge.", + ) args = parser.parse_args() logging.basicConfig(level=getattr(logging, args.log_level.upper(), logging.WARNING)) global _STATE_DIR _STATE_DIR = Path(args.state_dir).expanduser() _STATE_DIR.mkdir(parents=True, exist_ok=True) + if args.toggle_loft: + return toggle_loft() + if not args.token: token_file = _STATE_DIR / "apptoken" if token_file.is_file(): diff --git a/cider/scripts/test_write_position.py b/cider/scripts/test_write_position.py index 5e722643e..1b9970ad1 100644 --- a/cider/scripts/test_write_position.py +++ b/cider/scripts/test_write_position.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import sys import tempfile import unittest from pathlib import Path @@ -109,6 +110,42 @@ class UmbrielWindowProbeTests(unittest.TestCase): " zen\tZen Browser\t[tile 1694x1372+3490+51]", ] ) + JSON_SAMPLE = [ + { + "id": "cursor-id", + "app_id": "cursor", + "title": "Cursor Agents", + "focused": True, + "workspace": "DP-1:1", + "active": True, + }, + { + "id": "cider-id", + "app_id": "cider", + "title": "Cider", + "focused": False, + "workspace": "DP-1:1", + "active": False, + "xwayland": True, + }, + { + "id": "zen-id", + "app_id": "zen", + "title": "Zen Browser", + "focused": False, + "workspace": "DP-1:1", + "active": False, + }, + ] + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self._prev_state = cider_bridge._STATE_DIR + cider_bridge._STATE_DIR = Path(self._tmp.name) + + def tearDown(self) -> None: + cider_bridge._STATE_DIR = self._prev_state + self._tmp.cleanup() def test_parse_umbriel_windows(self) -> None: rows = cider_bridge._parse_umbriel_windows(self.SAMPLE) @@ -121,11 +158,8 @@ def test_normalize_xwayland_app_id(self) -> None: self.assertEqual(cider_bridge._normalize_app_id("[Xwayland] cider"), "cider") self.assertTrue(cider_bridge._is_cider_window("[Xwayland] cider", "Cider")) - def test_probe_umbriel_from_sample(self) -> None: - with mock.patch.object( - cider_bridge, "_umbriel_windows_text", return_value=self.SAMPLE - ): - payload = cider_bridge._probe_umbriel() + def test_probe_umbriel_from_json_sample(self) -> None: + payload = cider_bridge.apply_umbriel_listing(self.JSON_SAMPLE) self.assertIsNotNone(payload) assert payload is not None self.assertEqual(payload["compositor"], "umbriel") @@ -133,6 +167,71 @@ def test_probe_umbriel_from_sample(self) -> None: self.assertFalse(payload["focused"]) self.assertTrue(payload["on_screen"]) self.assertTrue(payload["suppress_notify"]) + self.assertEqual(payload["id"], "cider-id") + loft = cider_bridge._read_loft() + self.assertEqual(loft.get("id"), "cider-id") + self.assertFalse(loft.get("lofted")) + + def test_unlist_while_session_alive_is_loft_not_quit(self) -> None: + cider_bridge.apply_umbriel_listing(self.JSON_SAMPLE) + others = [row for row in self.JSON_SAMPLE if row["app_id"] != "cider"] + payload = cider_bridge.apply_umbriel_listing(others) + assert payload is not None + self.assertEqual(payload["compositor"], "umbriel") + self.assertTrue(payload["present"]) + self.assertFalse(payload["on_screen"]) + self.assertFalse(payload["suppress_notify"]) + self.assertEqual(payload["id"], "cider-id") + self.assertTrue(cider_bridge._read_loft().get("lofted")) + lyrics = cider_bridge._STATE_DIR / "lyrics.json" + lyrics.write_text("{}", encoding="utf-8") + self.assertTrue(lyrics.is_file()) + + def test_listed_empty_workspace_is_loft_and_keeps_output(self) -> None: + cider_bridge.apply_umbriel_listing(self.JSON_SAMPLE) + pad = [ + self.JSON_SAMPLE[0], + {**self.JSON_SAMPLE[1], "workspace": "", "floating": True, "focused": False}, + self.JSON_SAMPLE[2], + ] + payload = cider_bridge.apply_umbriel_listing(pad) + assert payload is not None + self.assertTrue(payload["present"]) + self.assertFalse(payload["on_screen"]) + self.assertFalse(payload["suppress_notify"]) + loft = cider_bridge._read_loft() + self.assertTrue(loft.get("lofted")) + self.assertEqual(loft.get("output"), "DP-1") + self.assertEqual(loft.get("id"), "cider-id") + + def test_empty_workspace_infers_output_from_peer(self) -> None: + pad = [ + self.JSON_SAMPLE[0], + {**self.JSON_SAMPLE[1], "workspace": "", "floating": True, "focused": False}, + self.JSON_SAMPLE[2], + ] + payload = cider_bridge.apply_umbriel_listing(pad) + assert payload is not None + loft = cider_bridge._read_loft() + self.assertTrue(loft.get("lofted")) + self.assertEqual(loft.get("output"), "DP-1") + + def test_empty_listing_falls_through_without_latch(self) -> None: + self.assertIsNone(cider_bridge.apply_umbriel_listing([])) + + def test_empty_listing_keeps_umbriel_when_lofted(self) -> None: + cider_bridge.apply_umbriel_listing(self.JSON_SAMPLE) + cider_bridge.apply_umbriel_listing( + [row for row in self.JSON_SAMPLE if row["app_id"] != "cider"] + ) + payload = cider_bridge.apply_umbriel_listing([]) + assert payload is not None + self.assertEqual(payload["compositor"], "umbriel") + self.assertTrue(payload["present"]) + self.assertFalse(payload["on_screen"]) + + def test_query_failure_falls_through_without_latch(self) -> None: + self.assertIsNone(cider_bridge.apply_umbriel_listing(None)) def test_probe_prefers_umbriel_over_niri(self) -> None: with mock.patch.object( @@ -156,6 +255,166 @@ def test_probe_falls_through_when_niri_ipc_dead(self) -> None: payload = cider_bridge.probe_cider_window() self.assertEqual(payload["compositor"], "hyprland") + def test_empty_umbriel_plus_niri_listing_is_niri(self) -> None: + with mock.patch.object( + cider_bridge, "_umbriel_windows_json", return_value=[] + ), mock.patch.object( + cider_bridge, + "_probe_niri", + return_value={"compositor": "niri", "present": True, "on_screen": True}, + ): + payload = cider_bridge.probe_cider_window() + self.assertEqual(payload["compositor"], "niri") + + +class UmbrielLoftActuationTests(UmbrielWindowProbeTests): + def _listing(self, *frames: list) -> mock.Mock: + queued = list(frames) + + def listing() -> list | None: + if queued: + return queued.pop(0) + return frames[-1] + + return listing + + @staticmethod + def _group_toggle(action: str) -> bool: + return action == "scratchpad-toggle" or action.startswith("scratchpad-toggle:") + + def test_send_focuses_cider_id_not_foreign_focus(self) -> None: + listed = self.JSON_SAMPLE + cider_focused = [{**row, "focused": row["id"] == "cider-id"} for row in listed] + calls: list[str] = [] + + def msg(action: str) -> bool: + calls.append(action) + return True + + cider_bridge.toggle_loft(msg=msg, listing=self._listing(listed, cider_focused)) + self.assertEqual(calls[0], "window-focus:cider-id") + self.assertEqual(calls[1], "window-move-to-scratchpad:DP-1") + self.assertFalse(any(self._group_toggle(action) for action in calls)) + + def test_send_succeeds_when_another_output_also_has_focus(self) -> None: + listed = self.JSON_SAMPLE + cider_and_steam = [ + {**row, "focused": row["id"] in {"cider-id", "cursor-id"}} + for row in listed + ] + cider_and_steam[0] = {**listed[0], "focused": True, "workspace": "DP-1:2"} + cider_and_steam[1] = {**listed[1], "focused": True} + calls: list[str] = [] + + def msg(action: str) -> bool: + calls.append(action) + return True + + cider_bridge.toggle_loft(msg=msg, listing=self._listing(listed, cider_and_steam)) + self.assertEqual(calls[1], "window-move-to-scratchpad:DP-1") + + def test_restore_shows_pad_then_restores_window(self) -> None: + cider_bridge.apply_umbriel_listing(self.JSON_SAMPLE) + pad = [ + self.JSON_SAMPLE[0], + {**self.JSON_SAMPLE[1], "workspace": "", "floating": True, "focused": False}, + self.JSON_SAMPLE[2], + ] + calls: list[str] = [] + + def msg(action: str) -> bool: + calls.append(action) + return True + + cider_bridge.toggle_loft(msg=msg, listing=lambda: pad) + self.assertEqual( + calls, + [ + "scratchpad-toggle:DP-1", + "window-focus:cider-id", + "window-restore-from-scratchpad:DP-1", + ], + ) + + def test_visible_pad_restores_without_group_toggle(self) -> None: + pad = [ + {**self.JSON_SAMPLE[0], "focused": False}, + {**self.JSON_SAMPLE[1], "workspace": "", "floating": True, "focused": True}, + {**self.JSON_SAMPLE[2], "focused": False}, + ] + calls: list[str] = [] + + def msg(action: str) -> bool: + calls.append(action) + return True + + cider_bridge.toggle_loft(msg=msg, listing=lambda: pad) + self.assertEqual( + calls, + ["window-focus:cider-id", "window-restore-from-scratchpad:DP-1"], + ) + self.assertFalse(any(self._group_toggle(action) for action in calls)) + + def test_unlisted_restore_shows_then_restores(self) -> None: + cider_bridge.apply_umbriel_listing(self.JSON_SAMPLE) + others = [row for row in self.JSON_SAMPLE if row["app_id"] != "cider"] + cider_bridge.apply_umbriel_listing(others) + calls: list[str] = [] + + def msg(action: str) -> bool: + calls.append(action) + return True + + cider_bridge.toggle_loft(msg=msg, listing=lambda: others) + self.assertEqual( + calls, + [ + "scratchpad-toggle:DP-1", + "window-focus:cider-id", + "window-restore-from-scratchpad:DP-1", + ], + ) + + def test_focus_fail_never_runs_pad_action(self) -> None: + calls: list[str] = [] + + def msg(action: str) -> bool: + calls.append(action) + return False + + cider_bridge.toggle_loft(msg=msg, listing=lambda: self.JSON_SAMPLE) + self.assertEqual(calls, ["window-focus:cider-id"]) + + def test_no_cider_id_is_noop(self) -> None: + calls: list[str] = [] + others = [row for row in self.JSON_SAMPLE if row["app_id"] != "cider"] + cider_bridge.toggle_loft( + msg=lambda action: calls.append(action) or True, + listing=lambda: others, + ) + self.assertEqual(calls, []) + + def test_missing_output_is_noop(self) -> None: + listed = [{**self.JSON_SAMPLE[1], "workspace": "", "focused": True}] + calls: list[str] = [] + cider_bridge.toggle_loft( + msg=lambda action: calls.append(action) or True, + listing=lambda: listed, + ) + self.assertEqual(calls, []) + + def test_cli_does_not_start_bridge(self) -> None: + with mock.patch.object(cider_bridge, "toggle_loft", return_value=0) as loft_mock, mock.patch.object( + cider_bridge, "CiderBridge" + ) as bridge_mock, mock.patch.object( + sys, + "argv", + ["cider_bridge.py", "--toggle-loft", "--state-dir", self._tmp.name], + ): + self.assertEqual(cider_bridge.main(), 0) + loft_mock.assert_called_once() + bridge_mock.assert_not_called() + class OverlayLauncherContractTests(unittest.TestCase): def test_service_does_not_pkill_overlay_by_cmdline_pattern(self) -> None: @@ -176,6 +435,7 @@ def test_service_uses_runasync_argv_for_process_launches(self) -> None: text = service.read_text(encoding="utf-8") self.assertIn("plugin_api = 24", (Path(__file__).resolve().parent.parent / "plugin.toml").read_text(encoding="utf-8")) self.assertIn('runArgv({ "python3", script })', text) + self.assertIn('runArgv({ "python3", script, "--toggle-loft" })', text) self.assertIn('runArgv({ "bash", launcher, baseUrl })', text) self.assertIn("noctaliaMsg(", text) # No shell-string noctalia msg / nohup launches left. @@ -186,6 +446,10 @@ def test_service_uses_runasync_argv_for_process_launches(self) -> None: ) self.assertNotIn('noctalia.runAsync("noctalia msg', code) self.assertNotIn("nohup python3", code) + self.assertIn('event == "chip-left"', text) + self.assertIn('event == "toggle-loft"', text) + self.assertIn('compositor or "") == "umbriel"', text) + self.assertNotIn("umbriel msg", code) def test_service_does_not_push_external_lyrics_plugin(self) -> None: service = Path(__file__).resolve().parent.parent / "service.luau" @@ -315,6 +579,9 @@ def test_widget_hides_when_no_track(self) -> None: self.assertNotIn("function onClick", text) toml = (Path(__file__).resolve().parent.parent / "plugin.toml").read_text(encoding="utf-8") self.assertIn("[widget.actions]", toml) + self.assertIn('left = "plugin dragged/cider:bridge all toggle-lyrics-hud"', toml) + self.assertIn('middle = "plugin dragged/cider:bridge all chip-left"', toml) + self.assertIn('right = "plugin dragged/cider:bridge all show-osd"', toml) self.assertIn("toggle-lyrics-hud", toml) self.assertIn('type = "color"', toml) self.assertIn("advanced = true", toml) @@ -329,11 +596,13 @@ def test_service_gates_plain_lyrics_and_reapplies_on_config(self) -> None: self.assertIn("lastLyricsEvent", text) self.assertIn("applyLocalLyrics(lastLyricsEvent)", text) - def test_cider_window_gone_clears_playback(self) -> None: + def test_unlist_does_not_emit_cider_closed(self) -> None: bridge = Path(__file__).resolve().parent / "cider_bridge.py" text = bridge.read_text(encoding="utf-8") - self.assertIn("was_present and not present", text) - self.assertIn('message="cider_closed"', text) + self.assertNotIn("was_present and not present", text) + self.assertNotIn('message="cider_closed"', text) + self.assertIn("_clear_loft()", text) + self.assertIn('message="disconnected"', text) service = Path(__file__).resolve().parent.parent / "service.luau" svc = service.read_text(encoding="utf-8") self.assertIn("maybeHideWhenCiderClosed", svc) diff --git a/cider/service.luau b/cider/service.luau index 138d37487..0fbc6f263 100644 --- a/cider/service.luau +++ b/cider/service.luau @@ -1017,6 +1017,24 @@ function onConfigChanged() end end +local function runToggleLoft() + local script = pluginRoot() .. "/scripts/cider_bridge.py" + if not noctalia.fileExists(script) then + return + end + -- One-shot helper. start-bridge.sh would wipe playback sidecars. + runArgv({ "python3", script, "--toggle-loft" }) +end + +local function chipLeft() + local probe = readWindowProbe() + if type(probe) == "table" and tostring(probe.compositor or "") == "umbriel" then + runToggleLoft() + return + end + toggleLyricsHud() +end + function onIpc(event, payload) if event == "show-osd" then lastOsdTrackKey = "" @@ -1028,6 +1046,10 @@ function onIpc(event, payload) presentTrack(nowPlaying, key, true) elseif event == "hide-osd" then closeOsd() + elseif event == "chip-left" then + chipLeft() + elseif event == "toggle-loft" then + runToggleLoft() elseif event == "toggle-lyrics-hud" then toggleLyricsHud() elseif event == "show-lyrics-hud" then diff --git a/cider/translations/en.json b/cider/translations/en.json index c6327c76f..3ca2e5667 100644 --- a/cider/translations/en.json +++ b/cider/translations/en.json @@ -1,130 +1,130 @@ { - "lyrics_osd": { - "disabled": "Lyrics OSD disabled in settings", - "idle": "No lyrics" - }, - "osd": { - "not_playing": "Not playing" - }, "settings": { "apptoken": { - "description": "From Cider → Settings → Connectivity → External Application Access (apptoken header). Leave empty to reuse ~/.config/cider-kde-notifier/config.json.", - "label": "Cider API token" + "label": "API token", + "description": "Cider → Settings → Connectivity → External Application Access. Leave empty to reuse ~/.config/cider-kde-notifier/config.json." }, "base_url": { - "description": "Usually http://127.0.0.1:10767", - "label": "Cider API URL" - }, - "cover_size": { - "description": "Artwork edge length on the bar chip, in logical pixels (12–32).", - "label": "Cover size" + "label": "API URL", + "description": "Cider Connectivity base URL. Default http://127.0.0.1:10767 works for local installs." }, "display_mode": { - "description": "Noctalia notification (recommended), rich OSD panel, or silent on track changes.", - "label": "Track alert style", + "label": "Track alert", + "description": "What to show when the track changes: a notification, the rich OSD card, or nothing.", "options": { "notification": "Notification", - "off": "Off", - "osd": "OSD panel" + "osd": "OSD card", + "off": "Off" } }, - "glyph": { - "description": "Icon shown on the bar chip when artwork is hidden or missing.", - "label": "Fallback glyph" + "save_to_history": { + "label": "Keep in notification history", + "description": "Also save track-change notifications to history. Off by default." }, - "lyrics_karaoke_active": { - "description": "Hex for the word currently being sung when Custom. Empty keeps Noctalia primary. Example: #83c2c8", - "label": "Active word color" + "osd_duration_ms": { + "label": "Alert duration", + "description": "How long the notification or OSD card stays visible (milliseconds)." }, - "lyrics_karaoke_style": { - "description": "Theme follows the live Noctalia palette (on_surface + primary). Custom uses the hex fields below.", - "label": "Karaoke colors", + "lyrics_osd_enabled": { + "label": "Lyrics HUD", + "description": "Sticky karaoke overlay. Left-click the bar chip toggles it; on Umbriel, middle-click lofts Cider (remappable in the widget editor)." + }, + "lyrics_osd_position": { + "label": "HUD position", + "description": "Where the overlay sits on screen.", "options": { - "custom": "Custom hex", - "theme": "Noctalia theme" + "bottom_center": "Bottom center", + "bottom_left": "Bottom left", + "bottom_right": "Bottom right", + "top_center": "Top center", + "top_left": "Top left", + "top_right": "Top right", + "center": "Center", + "center_left": "Center left", + "center_right": "Center right" } }, - "lyrics_karaoke_sung": { - "description": "Hex for already-sung words when Karaoke colors is Custom. Empty keeps the theme color. Example: #f2f3f3", - "label": "Sung word color" - }, - "lyrics_karaoke_upcoming": { - "description": "Hex for not-yet-sung words in the current line when Custom. Empty uses sung color at lower opacity.", - "label": "Upcoming word color" + "lyrics_osd_show_next": { + "label": "Show next line", + "description": "Dim preview of the upcoming lyric under the current line." }, "lyrics_osd_animate_cues": { - "description": "Pulse three cue dots through intros, interludes, and song mixes. Off = static dots.", - "label": "Animate cue dots" + "label": "Animate cue dots", + "description": "Pulse the three cue dots through intros and interludes. Off = static dots." }, - "lyrics_osd_enabled": { - "description": "Bar widget click toggles sticky lyrics. Off = click does nothing.", - "label": "Enable lyrics HUD" + "lyrics_osd_karaoke": { + "label": "Word highlight", + "description": "Highlight the sung word when Apple syllable timings exist. Line-timed lyrics light the whole line." }, "lyrics_osd_glow": { - "description": "Offset dark shadow under overlay glyphs so they stay readable on wallpaper.", - "label": "Lyric drop shadow" + "label": "Drop shadow", + "description": "Offset shadow under lyrics so they stay readable on busy wallpapers." }, - "lyrics_osd_karaoke": { - "description": "Highlight the currently sung word when Apple syllable timings exist. Line-only lyrics light the whole active line to match Cider.", - "label": "Word sing-along highlight" - }, - "lyrics_osd_position": { - "description": "Where the overlay sits on screen. Reopen the HUD after changing.", - "label": "Lyrics HUD position", + "lyrics_karaoke_style": { + "label": "Highlight colors", + "description": "Theme follows the live Noctalia palette. Custom uses the color pickers below.", "options": { - "bottom_center": "Bottom center", - "bottom_left": "Bottom left", - "bottom_right": "Bottom right", - "center": "Center", - "center_left": "Center left", - "center_right": "Center right", - "top_center": "Top center", - "top_left": "Top left", - "top_right": "Top right" + "theme": "Noctalia theme", + "custom": "Custom" } }, + "lyrics_karaoke_sung": { + "label": "Sung color", + "description": "Already-sung words when Highlight colors is Custom." + }, + "lyrics_karaoke_active": { + "label": "Active color", + "description": "Word currently being sung when Custom." + }, + "lyrics_karaoke_upcoming": { + "label": "Upcoming color", + "description": "Not-yet-sung words in the current line when Custom." + }, "lyrics_osd_show_idle": { - "description": "When the HUD is open with no lyrics yet, show a muted music/idle message instead of a blank overlay.", - "label": "Show idle placeholder" + "label": "Idle placeholder", + "description": "When the HUD is open with no lyrics yet, show a muted idle message instead of a blank overlay." }, - "lyrics_osd_show_next": { - "description": "Dim preview of the upcoming lyric under the current line.", - "label": "Show next line" + "lyrics_show_untimed": { + "label": "Show untimed lyrics", + "description": "Off by default. Show plain lyrics that have no timestamps. Synced karaoke always shows." }, "lyrics_plain_scroll": { - "description": "Advance plain lyrics over the song length instead of freezing on the first line. Drift vs vocals is expected.", - "label": "Scroll untimed lyrics" + "label": "Scroll untimed lyrics", + "description": "Advance plain lyrics over the song length instead of freezing on the first line. Drift vs vocals is expected." + }, + "lyrics_plain_scroll_speed": { + "label": "Scroll speed", + "description": "Pace relative to song length (25–300%). 100 is default." }, "lyrics_plain_scroll_silence": { - "description": "Hold scroll during quiet system audio (intros/gaps). Soft beds may pause too. Needs parec.", - "label": "Pause when quiet" + "label": "Pause when quiet", + "description": "Hold scroll during quiet system audio (intros/gaps). Soft beds may pause too. Needs parec." }, "lyrics_plain_scroll_silence_level": { - "description": "How loud audio must be before scroll advances (1–40).", - "label": "Quiet threshold" - }, - "lyrics_plain_scroll_speed": { - "description": "Pace relative to song length (25–300%). 100 is default.", - "label": "Scroll speed" - }, - "lyrics_show_untimed": { - "description": "Off by default. Show plain lyrics that have no timestamps. Synced karaoke always shows.", - "label": "Show untimed lyrics" + "label": "Quiet threshold", + "description": "How loud audio must be before scroll advances (1–40)." }, - "osd_duration_ms": { - "description": "How long the notification or now-playing OSD stays visible after a track change.", - "label": "Alert duration (ms)" + "show_cover": { + "label": "Show artwork", + "description": "Show Cider cover art on the bar chip when a local file is available." }, - "save_to_history": { - "description": "When using notifications, also keep them in notification history. Off by default.", - "label": "Save track alerts to history" + "cover_size": { + "label": "Artwork size", + "description": "Cover edge length on the bar chip (12–32 px)." }, - "show_cover": { - "description": "Show the current Cider artwork on the bar chip when a local cover file is available.", - "label": "Show cover" + "glyph": { + "label": "Fallback glyph", + "description": "Icon shown on the bar chip when artwork is hidden or missing." } }, "widget": { "idle": "Cider" + }, + "osd": { + "not_playing": "Not playing" + }, + "lyrics_osd": { + "idle": "No lyrics", + "disabled": "Lyrics OSD disabled in settings" } } diff --git a/cider/widget.luau b/cider/widget.luau index ea5bcf486..4eca63fa8 100644 --- a/cider/widget.luau +++ b/cider/widget.luau @@ -2,7 +2,8 @@ -- Compact Cider now-playing bar widget. -- Only re-render on identity/playing changes — position ticks must not flash opacity. -- Hide entirely when nothing is active (no title/artist). --- Clicks: [widget.actions] in plugin.toml (left = lyrics HUD, right = show OSD). +-- Clicks: [widget.actions] in plugin.toml (left = HUD, Umbriel middle = loft, +-- right = OSD). local showCover = noctalia.getConfig("show_cover") if showCover == nil then showCover = true end