From 8ba33e5d36c9888490be95c235bcf46d3404e3a4 Mon Sep 17 00:00:00 2001 From: The_miro Date: Fri, 24 Jul 2026 14:57:23 +0200 Subject: [PATCH] pulled in the current working state of cosmonaut shell components --- astro-menu/.gitignore | 2 + astro-menu/README.md | 60 + astro-menu/appservices.py | 35 + astro-menu/backend/geolocate.py | 124 ++ astro-menu/backend/network.sh | 66 ++ astro-menu/backend/nm.py | 212 ++++ astro-menu/backend/staticmap.py | 108 ++ astro-menu/backend/sysmon.sh | 101 ++ astro-menu/backend/weather.sh | 18 + astro-menu/config.py | 31 + astro-menu/lib/ansi.py | 138 +++ astro-menu/lib/border.py | 100 ++ astro-menu/lib/hologram.py | 266 +++++ astro-menu/lib/proc.py | 100 ++ astro-menu/main.py | 138 +++ astro-menu/module_base.py | 73 ++ astro-menu/modules/bluetooth.py | 292 +++++ astro-menu/modules/location.py | 185 +++ astro-menu/modules/network.py | 529 +++++++++ astro-menu/modules/sysmon.py | 195 +++ astro-menu/modules/weather.py | 78 ++ astro-menu/paths.py | 28 + astro-menu/registry.py | 29 + astro-menu/services/location.py | 55 + astro-menu/settings.py | 83 ++ astro-menu/style/_colors.css | 8 + astro-menu/style/style.css | 368 ++++++ astro-menu/theme.py | 35 + astro-menu/ui/appdrawer.py | 175 +++ astro-menu/ui/favorites.py | 106 ++ astro-menu/ui/quadcard.py | 158 +++ astro-menu/ui/quadgrid.py | 191 +++ astro-menu/ui/statsbar.py | 133 +++ astro-menu/ui/taskbar.py | 477 ++++++++ astro-menu/window.py | 243 ++++ beacon/config.py | 55 + beacon/controls.py | 81 ++ .../org.freedesktop.Notifications.service | 12 + beacon/history.py | 109 ++ beacon/lib/hologram.py | 278 +++++ beacon/main.py | 92 ++ beacon/notification.py | 304 +++++ beacon/paths.py | 41 + beacon/server.py | 370 ++++++ beacon/style/_colors.css | 8 + beacon/style/style.css | 152 +++ beacon/theme.py | 30 + beacon/window.py | 138 +++ horizon-dock/.gitignore | 2 + horizon-dock/apps.py | 77 ++ horizon-dock/config.py | 37 + horizon-dock/dock.py | 660 +++++++++++ horizon-dock/lib/hologram.py | 265 +++++ horizon-dock/main.py | 95 ++ horizon-dock/paths.py | 23 + horizon-dock/style/_colors.css | 8 + horizon-dock/style/style.css | 90 ++ horizon-dock/theme.py | 30 + horizon-dock/tray.py | 140 +++ horizon-dock/windows.py | 27 + orbit-menu/.gitignore | 2 + orbit-menu/actions.py | 69 ++ orbit-menu/config.py | 34 + orbit-menu/lib/proc.py | 63 + orbit-menu/main.py | 163 +++ orbit-menu/menu_tree.py | 182 +++ orbit-menu/orbit_menu.py | 1050 +++++++++++++++++ orbit-menu/paths.py | 23 + orbit-menu/style/_colors.css | 8 + orbit-menu/style/style.css | 121 ++ orbit-menu/theme.py | 30 + pull-from-dots.sh | 8 + station-bar/.gitignore | 2 + station-bar/bar.py | 588 +++++++++ station-bar/battery.py | 54 + station-bar/config.py | 31 + station-bar/hypr_ipc.py | 144 +++ station-bar/lib/hologram.py | 295 +++++ station-bar/main.py | 145 +++ station-bar/paths.py | 22 + station-bar/sni_watcher.py | 147 +++ station-bar/style/_colors.css | 8 + station-bar/style/style.css | 164 +++ station-bar/theme.py | 31 + station-bar/tray.py | 251 ++++ station-bar/volume.py | 49 + transmitter-panel/.gitignore | 2 + transmitter-panel/config.py | 30 + transmitter-panel/history_client.py | 79 ++ transmitter-panel/lib/hologram.py | 279 +++++ transmitter-panel/main.py | 92 ++ transmitter-panel/paths.py | 22 + transmitter-panel/style/_colors.css | 8 + transmitter-panel/style/style.css | 133 +++ transmitter-panel/theme.py | 31 + transmitter-panel/window.py | 266 +++++ 96 files changed, 12690 insertions(+) create mode 100644 astro-menu/.gitignore create mode 100644 astro-menu/README.md create mode 100644 astro-menu/appservices.py create mode 100755 astro-menu/backend/geolocate.py create mode 100755 astro-menu/backend/network.sh create mode 100755 astro-menu/backend/nm.py create mode 100755 astro-menu/backend/staticmap.py create mode 100755 astro-menu/backend/sysmon.sh create mode 100755 astro-menu/backend/weather.sh create mode 100644 astro-menu/config.py create mode 100644 astro-menu/lib/ansi.py create mode 100644 astro-menu/lib/border.py create mode 100644 astro-menu/lib/hologram.py create mode 100644 astro-menu/lib/proc.py create mode 100755 astro-menu/main.py create mode 100644 astro-menu/module_base.py create mode 100644 astro-menu/modules/bluetooth.py create mode 100644 astro-menu/modules/location.py create mode 100644 astro-menu/modules/network.py create mode 100644 astro-menu/modules/sysmon.py create mode 100644 astro-menu/modules/weather.py create mode 100644 astro-menu/paths.py create mode 100644 astro-menu/registry.py create mode 100644 astro-menu/services/location.py create mode 100644 astro-menu/settings.py create mode 100644 astro-menu/style/_colors.css create mode 100644 astro-menu/style/style.css create mode 100644 astro-menu/theme.py create mode 100644 astro-menu/ui/appdrawer.py create mode 100644 astro-menu/ui/favorites.py create mode 100644 astro-menu/ui/quadcard.py create mode 100644 astro-menu/ui/quadgrid.py create mode 100644 astro-menu/ui/statsbar.py create mode 100644 astro-menu/ui/taskbar.py create mode 100644 astro-menu/window.py create mode 100644 beacon/config.py create mode 100644 beacon/controls.py create mode 100644 beacon/dbus/org.freedesktop.Notifications.service create mode 100644 beacon/history.py create mode 100644 beacon/lib/hologram.py create mode 100644 beacon/main.py create mode 100644 beacon/notification.py create mode 100644 beacon/paths.py create mode 100644 beacon/server.py create mode 100644 beacon/style/_colors.css create mode 100644 beacon/style/style.css create mode 100644 beacon/theme.py create mode 100644 beacon/window.py create mode 100644 horizon-dock/.gitignore create mode 100644 horizon-dock/apps.py create mode 100644 horizon-dock/config.py create mode 100644 horizon-dock/dock.py create mode 100644 horizon-dock/lib/hologram.py create mode 100644 horizon-dock/main.py create mode 100644 horizon-dock/paths.py create mode 100644 horizon-dock/style/_colors.css create mode 100644 horizon-dock/style/style.css create mode 100644 horizon-dock/theme.py create mode 100644 horizon-dock/tray.py create mode 100644 horizon-dock/windows.py create mode 100644 orbit-menu/.gitignore create mode 100644 orbit-menu/actions.py create mode 100644 orbit-menu/config.py create mode 100644 orbit-menu/lib/proc.py create mode 100644 orbit-menu/main.py create mode 100644 orbit-menu/menu_tree.py create mode 100644 orbit-menu/orbit_menu.py create mode 100644 orbit-menu/paths.py create mode 100644 orbit-menu/style/_colors.css create mode 100644 orbit-menu/style/style.css create mode 100644 orbit-menu/theme.py create mode 100755 pull-from-dots.sh create mode 100644 station-bar/.gitignore create mode 100644 station-bar/bar.py create mode 100644 station-bar/battery.py create mode 100644 station-bar/config.py create mode 100644 station-bar/hypr_ipc.py create mode 100644 station-bar/lib/hologram.py create mode 100644 station-bar/main.py create mode 100644 station-bar/paths.py create mode 100644 station-bar/sni_watcher.py create mode 100644 station-bar/style/_colors.css create mode 100644 station-bar/style/style.css create mode 100644 station-bar/theme.py create mode 100644 station-bar/tray.py create mode 100644 station-bar/volume.py create mode 100644 transmitter-panel/.gitignore create mode 100644 transmitter-panel/config.py create mode 100644 transmitter-panel/history_client.py create mode 100644 transmitter-panel/lib/hologram.py create mode 100644 transmitter-panel/main.py create mode 100644 transmitter-panel/paths.py create mode 100644 transmitter-panel/style/_colors.css create mode 100644 transmitter-panel/style/style.css create mode 100644 transmitter-panel/theme.py create mode 100644 transmitter-panel/window.py diff --git a/astro-menu/.gitignore b/astro-menu/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/astro-menu/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/astro-menu/README.md b/astro-menu/README.md new file mode 100644 index 0000000..37a2e6e --- /dev/null +++ b/astro-menu/README.md @@ -0,0 +1,60 @@ +# astro-menu + +A touch-friendly GTK4 popup control centre for the hyprlua desktop, replacing +`nwg-dock` and `nwg-drawer`. Triggered from the EWW top bar (or `Super+D`). + +Layout: a **2×2 quad grid** of feature modules over a full-width **application +drawer**. Any quad can expand to overlay the other three; the drawer can expand to +the bottom of the screen. A single margined root box letterboxes the menu identically +in every state. + +## Stack + +- **Frontend:** Python + PyGObject + **GTK4**, as a `wlr-layer-shell` surface + (`gtk4-layer-shell`). Chosen over Lua because lgi/Astal-Lua only support GTK3. +- **Location map:** a static OSM image stitched from tiles (`backend/staticmap.py`, + Pillow). libshumate does not paint tiles in this environment (the official + `shumate-demo` shows the same blank map), though tile downloads work — hence the + static fallback. +- **Services:** Astal GObject libraries via introspection — `AstalNetwork`, + `AstalBluetooth`, `AstalApps` — plus our own IP-geolocation singleton. +- **Backends:** Python/Bash scripts in `backend/` (JSON on stdout), run async via + `Gio.Subprocess` so nothing blocks the UI. +- **Theme:** `style/_colors.css` (`@define-color`, generated from + `~/Dotfiles/colors.conf` by `apply-theme.sh`) + `style/style.css`. + +## Running + +`main.py` is single-instance. The autostart launches a hidden resident daemon; +verbs are forwarded over D-Bus: + + scripts/astro-menu-start.sh # resident daemon (hidden); sets LD_PRELOAD + scripts/menu-toggle.sh # --toggle + scripts/menu-toggle.sh appdrawer # open with the app drawer expanded + +`astro-menu-start.sh` must `LD_PRELOAD` libgtk4-layer-shell (it loads after +libwayland under PyGObject otherwise). + +## Adding a module + +1. Create `modules/.py` exposing a top-level `SPEC = ModuleSpec(...)` whose + `build(ctx)` returns a `ModuleInstance(compact=…, expanded=…, …)`. + - `compact` shows in the 2×2 cell; a distinct `expanded` widget enables the + expand button (they must be separate instances — GTK widgets have one parent). + - Declare per-feature toggles via `features=[Feature("id", "Label", default)]`; + read them with `ctx.feature("id")`. A disabled quad never calls `build()`. + - Shared state (network, bluetooth, location) is on `ctx.services`. +2. Append its `SPEC` to `ALL_SPECS` in `registry.py`. Nothing else changes. + +## Files + + main.py app + single-instance IPC window.py layer-shell + letterbox + registry.py module list (extension seam) settings.py JSON toggles/order + module_base.py ModuleSpec / ModuleInstance / ModuleContext + appservices.py shared Astal services + location + ui/ quadcard, quadgrid, appdrawer + modules/ location, weather, bluetooth, network + services/ location (geolocation singleton) + lib/ ansi (SGR→TextTag), proc (async subprocess) + backend/ geolocate.py, weather.sh, network.sh + style/ _colors.css, style.css diff --git a/astro-menu/appservices.py b/astro-menu/appservices.py new file mode 100644 index 0000000..a9797bf --- /dev/null +++ b/astro-menu/appservices.py @@ -0,0 +1,35 @@ +"""Lazily-built shared services handed to every module via ModuleContext. + +Astal's GObject service libraries (AstalNetwork, AstalBluetooth) are consumed here +through GObject-introspection; modules connect to their `notify::` signals instead +of polling. The location service is our own singleton. +""" + +from __future__ import annotations + +import gi + +gi.require_version("AstalNetwork", "0.1") +gi.require_version("AstalBluetooth", "0.1") +from gi.repository import AstalBluetooth, AstalNetwork # noqa: E402 + +from services.location import get_location_service + + +class Services: + def __init__(self) -> None: + self._network = None + self._bluetooth = None + self.location = get_location_service() + + @property + def network(self): + if self._network is None: + self._network = AstalNetwork.Network.get_default() + return self._network + + @property + def bluetooth(self): + if self._bluetooth is None: + self._bluetooth = AstalBluetooth.Bluetooth.get_default() + return self._bluetooth diff --git a/astro-menu/backend/geolocate.py b/astro-menu/backend/geolocate.py new file mode 100755 index 0000000..fbc5987 --- /dev/null +++ b/astro-menu/backend/geolocate.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""IP geolocation, cached with a TTL. + +Discovers the public IP by tracerouting to 1.1.1.1 and taking the first +globally-routable hop (the ISP egress nearest the user), then resolves that IP to a +location through a public geolocation API. Falls back to locating this host's own +public IP when traceroute is unavailable or yields no public hop. + +Prints JSON: {lat, lon, city, country, ip, source}. Diagnostics go to stderr, +non-zero exit on total failure. Used by services/location.py (and runnable +standalone for testing). +""" + +from __future__ import annotations + +import ipaddress +import json +import os +import re +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" / "location.json" +TTL = 1800 # seconds + +TARGET = "1.1.1.1" +_IPV4 = re.compile(r"\b(\d{1,3}(?:\.\d{1,3}){3})\b") + +# Geolocation providers. Each builds a URL for a given IP; an empty IP asks the +# provider to resolve the caller's own public address (the self-IP fallback). +PROVIDERS = [ + ("ip-api", + lambda ip: f"http://ip-api.com/json/{ip}?fields=lat,lon,city,country,query", + lambda d: {"lat": d["lat"], "lon": d["lon"], "city": d.get("city"), + "country": d.get("country"), "ip": d.get("query")}), + ("ipapi.co", + lambda ip: f"https://ipapi.co/{ip}/json/" if ip else "https://ipapi.co/json/", + lambda d: {"lat": d["latitude"], "lon": d["longitude"], "city": d.get("city"), + "country": d.get("country_name"), "ip": d.get("ip")}), + ("ipinfo", + lambda ip: f"https://ipinfo.io/{ip}/json" if ip else "https://ipinfo.io/json", + lambda d: {"lat": float(d["loc"].split(",")[0]), "lon": float(d["loc"].split(",")[1]), + "city": d.get("city"), "country": d.get("country"), "ip": d.get("ip")}), +] + + +def _cached() -> dict | None: + try: + blob = json.loads(CACHE.read_text()) + if time.time() - blob.get("_ts", 0) < TTL: + return blob["data"] + except (FileNotFoundError, json.JSONDecodeError, KeyError): + pass + return None + + +def _store(data: dict) -> None: + CACHE.parent.mkdir(parents=True, exist_ok=True) + CACHE.write_text(json.dumps({"_ts": time.time(), "data": data})) + + +def _public_hop_ip() -> str | None: + """First globally-routable hop on the path to 1.1.1.1 — i.e. the ISP egress + closest to the user. The private/CGNAT hops before it and the anycast target + itself (Cloudflare, useless for locating the user) are skipped.""" + try: + proc = subprocess.run( + ["traceroute", "-n", "-q", "1", "-w", "2", "-m", "12", TARGET], + capture_output=True, text=True, timeout=40, + ) + except (FileNotFoundError, subprocess.SubprocessError) as exc: + print(f"traceroute: {exc}", file=sys.stderr) + return None + for line in proc.stdout.splitlines(): + if line.lower().startswith("traceroute to"): + continue # header line names the target; not a hop + for ip in _IPV4.findall(line): + if ip == TARGET: + continue + try: + if ipaddress.ip_address(ip).is_global: + return ip + except ValueError: + continue + return None + + +def _fetch(url: str) -> dict: + req = urllib.request.Request(url, headers={"User-Agent": "astro-menu/1.0"}) + with urllib.request.urlopen(req, timeout=8) as resp: + return json.loads(resp.read().decode()) + + +def main() -> int: + if "--no-cache" not in sys.argv: + hit = _cached() + if hit: + print(json.dumps(hit)) + return 0 + + hop = _public_hop_ip() + # Try the traced public hop first, then fall back to our own public IP. + candidates = ([hop] if hop else []) + [""] + for ip in candidates: + for name, url_of, parse in PROVIDERS: + try: + data = parse(_fetch(url_of(ip))) + if data.get("lat") is None or data.get("lon") is None: + raise ValueError("no coordinates") + data["source"] = f"{name} via {ip}" if ip else name + _store(data) + print(json.dumps(data)) + return 0 + except Exception as exc: # noqa: BLE001 — try the next provider/candidate + print(f"{name}({ip or 'self'}): {exc}", file=sys.stderr) + print("all geolocation attempts failed", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/astro-menu/backend/network.sh b/astro-menu/backend/network.sh new file mode 100755 index 0000000..6d70fc3 --- /dev/null +++ b/astro-menu/backend/network.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Networking backend for the astro-menu Network quad. +# Each subcommand prints JSON (or plain text for pubip) to stdout. Wi-Fi listing and +# state come from AstalNetwork in the UI; this covers the rest (ip/routes/ports, +# public IP, wifi connect via nmcli, and the manual/DHCP switch). +set -euo pipefail + +active_conn() { + # First active connection on a real device (wifi/ethernet). + nmcli -t -g NAME,TYPE,DEVICE connection show --active 2>/dev/null \ + | awk -F: '$2 ~ /wireless|ethernet/ {print $1; exit}' +} + +cmd="${1:-}"; shift || true +case "$cmd" in + ip) + ip -j addr show 2>/dev/null || echo '[]' + ;; + routes) + ip -j route show 2>/dev/null || echo '[]' + ;; + ports) + # Listening TCP/UDP sockets -> JSON array. + ss -tulnH 2>/dev/null | awk ' + BEGIN { print "["; sep="" } + { printf "%s{\"proto\":\"%s\",\"local\":\"%s\"}", sep, $1, $5; sep="," } + END { print "]" }' + ;; + pubip) + curl -sf --max-time 8 https://ifconfig.co 2>/dev/null \ + || curl -sf --max-time 8 https://api.ipify.org 2>/dev/null \ + || echo "unavailable" + ;; + wifi-connect) + ssid="${1:-}"; pass="${2:-}" + if [[ -n "$pass" ]]; then + nmcli dev wifi connect "$ssid" password "$pass" + else + nmcli dev wifi connect "$ssid" + fi + ;; + wifi-disconnect) + con="$(active_conn)" + [[ -n "$con" ]] && nmcli connection down "$con" + ;; + set-manual) + con="${4:-$(active_conn)}"; addr="${1:-}"; gw="${2:-}"; dns="${3:-}" + [[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; } + nmcli connection modify "$con" ipv4.method manual \ + ipv4.addresses "$addr" ipv4.gateway "$gw" ipv4.dns "$dns" + nmcli connection up "$con" + ;; + set-dhcp) + con="${1:-$(active_conn)}" + [[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; } + nmcli connection modify "$con" ipv4.method auto + nmcli connection up "$con" + ;; + active-conn) + active_conn + ;; + *) + echo "usage: network.sh {ip|routes|ports|pubip|wifi-connect|wifi-disconnect|set-manual|set-dhcp|active-conn}" >&2 + exit 2 + ;; +esac diff --git a/astro-menu/backend/nm.py b/astro-menu/backend/nm.py new file mode 100755 index 0000000..337ec13 --- /dev/null +++ b/astro-menu/backend/nm.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""NetworkManager backend for the Network quad's expanded settings. + +Every subcommand prints JSON to stdout ({"ok": bool, ...} for mutations) and +diagnostics to stderr. Reads use `nmcli -g` (raw, unescaped single fields) and +`ip -j`; writes go through `nmcli connection modify/up`, so persistence and polkit +auth are handled by NetworkManager. Kept as one script so the UI has a single, +stable contract (see modules/network.py). + + adapters list manageable devices + their connection + ipconfig ipv4/ipv6 method/addresses/gateway/dns + set-ip ipv4|ipv6 [addr_cidr] [gateway] [dns_space_sep] + routes kernel routing table (read-only) + vlans configured VLAN connections + vlan-add [name] create + bring up a VLAN + vlan-del delete a VLAN connection + vlan-up activate a VLAN connection + dns effective DNS servers per device + set-dns ipv4|ipv6 +""" + +from __future__ import annotations + +import json +import subprocess +import sys + + +def _run(argv: list[str], timeout: int = 20) -> tuple[bool, str, str]: + try: + p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout) + except (FileNotFoundError, subprocess.SubprocessError) as exc: + return False, "", str(exc) + return p.returncode == 0, p.stdout, p.stderr + + +def _get(field: str, con: str) -> str: + ok, out, _ = _run(["nmcli", "-g", field, "connection", "show", con]) + return out.strip() if ok else "" + + +def _list(field: str, con: str) -> list[str]: + raw = _get(field, con) + return [v.strip() for v in raw.replace(",", " ").split() if v.strip()] + + +def _emit(obj) -> int: + print(json.dumps(obj)) + return 0 + + +def _ok(ok: bool, err: str = "") -> int: + print(json.dumps({"ok": bool(ok), "error": err.strip()})) + return 0 if ok else 1 + + +# -- reads ----------------------------------------------------------------- +def adapters() -> int: + ok, out, _ = _run(["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"]) + rows = [] + if ok: + for line in out.splitlines(): + # DEVICE may contain escaped colons (bt MACs); split from the right on + # the three trailing fields we know are colon-free-ish. + parts = line.split(":") + if len(parts) < 4: + continue + connection = parts[-1] + state = parts[-2] + typ = parts[-3] + device = ":".join(parts[:-3]).replace("\\:", ":") + if typ not in ("ethernet", "wifi"): + continue + rows.append({"device": device, "type": typ, "state": state, + "connection": connection.replace("\\:", ":")}) + return _emit(rows) + + +def ipconfig(con: str) -> int: + def family(fam: str) -> dict: + return { + "method": _get(f"{fam}.method", con) or "auto", + "addresses": _list(f"{fam}.addresses", con), + "gateway": _get(f"{fam}.gateway", con), + "dns": _list(f"{fam}.dns", con), + "ignore_auto_dns": _get(f"{fam}.ignore-auto-dns", con) == "yes", + } + return _emit({"connection": con, "ipv4": family("ipv4"), "ipv6": family("ipv6")}) + + +def routes() -> int: + ok, out, err = _run(["ip", "-j", "route", "show"]) + if not ok: + return _emit([]) + try: + return _emit(json.loads(out)) + except json.JSONDecodeError: + return _emit([]) + + +def vlans() -> int: + ok, out, _ = _run(["nmcli", "-t", "-f", "NAME,TYPE,DEVICE,ACTIVE", "connection", "show"]) + rows = [] + if ok: + for line in out.splitlines(): + parts = line.rsplit(":", 3) + if len(parts) < 4 or parts[1] != "vlan": + continue + name, _typ, device, active = parts + rows.append({"name": name, "device": device, "active": active == "yes"}) + return _emit(rows) + + +def dns() -> int: + """Effective DNS servers, per device, from `nmcli device show`.""" + ok, out, _ = _run(["nmcli", "-t", "-f", + "GENERAL.DEVICE,IP4.DNS,IP6.DNS", "device", "show"]) + devices: dict[str, dict] = {} + cur = None + if ok: + for line in out.splitlines(): + if ":" not in line: + continue + key, _, val = line.partition(":") + val = val.strip() + if key == "GENERAL.DEVICE": + cur = val + devices.setdefault(cur, {"device": cur, "servers": []}) + elif cur and key.startswith(("IP4.DNS", "IP6.DNS")) and val: + devices[cur]["servers"].append(val) + return _emit([d for d in devices.values() if d["servers"]]) + + +# -- writes ---------------------------------------------------------------- +def set_ip(con: str, fam: str, method: str, addr: str = "", + gateway: str = "", dns: str = "") -> int: + if fam not in ("ipv4", "ipv6"): + return _ok(False, "family must be ipv4 or ipv6") + args = ["nmcli", "connection", "modify", con, f"{fam}.method", method] + if method == "manual": + args += [f"{fam}.addresses", addr or ""] + args += [f"{fam}.gateway", gateway or ""] + if dns: + args += [f"{fam}.dns", dns] + else: # auto: clear any manual leftovers so DHCP is clean + args += [f"{fam}.addresses", "", f"{fam}.gateway", ""] + ok, _, err = _run(args) + if ok: + ok, _, err = _run(["nmcli", "connection", "up", con]) + return _ok(ok, err) + + +def set_dns(con: str, fam: str, servers: str, ignore_auto: str) -> int: + if fam not in ("ipv4", "ipv6"): + return _ok(False, "family must be ipv4 or ipv6") + ok, _, err = _run(["nmcli", "connection", "modify", con, + f"{fam}.dns", servers, + f"{fam}.ignore-auto-dns", "yes" if ignore_auto == "yes" else "no"]) + if ok: + ok, _, err = _run(["nmcli", "connection", "up", con]) + return _ok(ok, err) + + +def vlan_add(parent: str, vid: str, name: str = "") -> int: + name = name or f"vlan{vid}" + ok, _, err = _run(["nmcli", "connection", "add", "type", "vlan", + "con-name", name, "dev", parent, "id", str(vid)]) + if ok: + ok, _, err = _run(["nmcli", "connection", "up", name]) + return _ok(ok, err) + + +def vlan_del(name: str) -> int: + ok, _, err = _run(["nmcli", "connection", "delete", name]) + return _ok(ok, err) + + +def vlan_up(name: str) -> int: + ok, _, err = _run(["nmcli", "connection", "up", name]) + return _ok(ok, err) + + +def main(argv: list[str]) -> int: + if not argv: + print("no subcommand", file=sys.stderr) + return 2 + cmd, args = argv[0], argv[1:] + table = { + "adapters": lambda: adapters(), + "ipconfig": lambda: ipconfig(args[0]), + "set-ip": lambda: set_ip(*args), + "routes": lambda: routes(), + "vlans": lambda: vlans(), + "vlan-add": lambda: vlan_add(*args), + "vlan-del": lambda: vlan_del(args[0]), + "vlan-up": lambda: vlan_up(args[0]), + "dns": lambda: dns(), + "set-dns": lambda: set_dns(*args), + } + fn = table.get(cmd) + if fn is None: + print(f"unknown subcommand: {cmd}", file=sys.stderr) + return 2 + try: + return fn() + except TypeError as exc: + print(f"bad arguments for {cmd}: {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/astro-menu/backend/staticmap.py b/astro-menu/backend/staticmap.py new file mode 100755 index 0000000..fdcc993 --- /dev/null +++ b/astro-menu/backend/staticmap.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Render a static slippy-map image centred on a coordinate, with a marker. + +Used by the Location quad because libshumate does not render tiles in this +environment (the official shumate-demo shows the same blank map — a library/GTK +render bug), while plain tile downloads work fine. Stitches raster tiles into one +PNG with Pillow and caches them. The quad re-renders this at a new centre to pan. + + staticmap.py \ + [style] [marker_lat] [marker_lon] + + style: dark (default, CartoDB dark_matter) | satellite (Esri) | standard (OSM) + marker_*: where to draw the location pin; defaults to the centre. When the centre + is panned away, the pin clamps to the nearest edge so it stays visible. +""" + +from __future__ import annotations + +import io +import math +import os +import sys +import urllib.request +from pathlib import Path + +from PIL import Image, ImageDraw + +TILE = 256 +CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" / "tiles" +UA = "astro-menu/1.0 (personal dotfiles)" +BG = (26, 26, 26) +ACCENT = (228, 0, 70) + +STYLES = { + "standard": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "dark": "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + "satellite": "https://server.arcgisonline.com/ArcGIS/rest/services/" + "World_Imagery/MapServer/tile/{z}/{y}/{x}", +} + + +def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]: + n = 2 ** zoom + lat_r = math.radians(lat) + x = (lon + 180.0) / 360.0 * n + y = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n + return x * TILE, y * TILE + + +def _tile(z: int, x: int, y: int, style: str) -> Image.Image | None: + CACHE.mkdir(parents=True, exist_ok=True) + fp = CACHE / f"{style}_{z}_{x}_{y}.png" + if fp.exists(): + try: + return Image.open(fp).convert("RGB") + except Exception: + pass + url = STYLES[style].format(z=z, x=x, y=y) + try: + req = urllib.request.Request(url, headers={"User-Agent": UA}) + data = urllib.request.urlopen(req, timeout=8).read() + except Exception as exc: + print(f"tile {style} {z}/{x}/{y}: {exc}", file=sys.stderr) + return None + img = Image.open(io.BytesIO(data)).convert("RGB") + img.save(fp) # normalise to PNG in the cache (satellite is served as JPEG) + return img + + +def render(lat: float, lon: float, zoom: int, w: int, h: int, out: str, + style: str = "dark", marker: tuple[float, float] | None = None) -> None: + if style not in STYLES: + style = "dark" + n = 2 ** zoom + cx, cy = _center_px(lat, lon, zoom) + left, top = cx - w / 2, cy - h / 2 + img = Image.new("RGB", (w, h), BG) + x0, x1 = int(left // TILE), int((left + w) // TILE) + y0, y1 = int(top // TILE), int((top + h) // TILE) + for tx in range(x0, x1 + 1): + for ty in range(y0, y1 + 1): + if ty < 0 or ty >= n: + continue + tile = _tile(zoom, tx % n, ty, style) + if tile is None: + continue + img.paste(tile, (int(tx * TILE - left), int(ty * TILE - top))) + + # marker at the real location (defaults to centre); clamp to the edge so it stays + # on-screen when the view is panned away from it. + mlat, mlon = marker if marker else (lat, lon) + mwx, mwy = _center_px(mlat, mlon, zoom) + r = 8 + mx = max(r, min(w - r, mwx - left)) + my = max(r, min(h - r, mwy - top)) + d = ImageDraw.Draw(img) + d.ellipse([mx - r, my - r, mx + r, my + r], fill=ACCENT, outline=(255, 255, 255), width=2) + + Path(out).parent.mkdir(parents=True, exist_ok=True) + img.save(out) + + +if __name__ == "__main__": + a = sys.argv + style = a[7] if len(a) > 7 else "dark" + marker = (float(a[8]), float(a[9])) if len(a) > 9 else None + render(float(a[1]), float(a[2]), int(a[3]), int(a[4]), int(a[5]), a[6], style, marker) + print(a[6]) diff --git a/astro-menu/backend/sysmon.sh b/astro-menu/backend/sysmon.sh new file mode 100755 index 0000000..8bfdabd --- /dev/null +++ b/astro-menu/backend/sysmon.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# astro-menu system-monitor backend — the ship's "Systems Diagnostic". +# Emits one TAB-separated record per subsystem: +# +# CATEGORY NAME USAGE_PCT TEMP_C DETAIL +# +# CATEGORY is cpu|gpu|ram|disk (drives the icon). USAGE_PCT is 0..100. TEMP_C is +# an integer °C or empty when no sensor exists. One `disk` row per mounted real +# filesystem. Reads everything from /proc + /sys (no extra deps). +set -uo pipefail + +milli_to_c() { awk '{printf "%d", ($1 + 500) / 1000}'; } + +hwmon_temp() { # $1 = chip-name substring; first temp input of that chip, in °C + local h n t + for h in /sys/class/hwmon/hwmon*; do + n=$(cat "$h/name" 2>/dev/null) || continue + [[ "$n" == *"$1"* ]] || continue + for t in "$h"/temp*_input; do + [[ -r "$t" ]] && { milli_to_c < "$t"; return 0; } + done + done + return 1 +} + +hwmon_dir_temp() { # $1 = hwmon dir, $2 = preferred label substring + local d="$1" pref="$2" lf lbl base + for lf in "$d"/temp*_label; do + [[ -r "$lf" ]] || continue + [[ "$(cat "$lf" 2>/dev/null)" == *"$pref"* ]] || continue + base="${lf%_label}_input" + [[ -r "$base" ]] && { milli_to_c < "$base"; return 0; } + done + for base in "$d"/temp*_input; do + [[ -r "$base" ]] && { milli_to_c < "$base"; return 0; } + done + return 1 +} + +nvme_temp() { # $1 = disk (e.g. nvme0n1); temp of that specific controller, in °C + local ctrl h t + ctrl=$(basename "$(readlink -f "/sys/block/$1/device" 2>/dev/null)") + for h in /sys/class/hwmon/hwmon*; do + [[ "$(cat "$h/name" 2>/dev/null)" == "nvme" ]] || continue + [[ "$(basename "$(readlink -f "$h/device" 2>/dev/null)")" == "$ctrl" ]] || continue + for t in "$h"/temp*_input; do + [[ -r "$t" ]] && { milli_to_c < "$t"; return 0; } + done + done + return 1 +} + +# --- CPU: Thrusters --------------------------------------------------------- +cpu_snapshot() { awk '/^cpu /{t=0; for (i=2; i<=NF; i++) t+=$i; print t, $5}' /proc/stat; } +read t1 idle1 < <(cpu_snapshot); sleep 0.2; read t2 idle2 < <(cpu_snapshot) +dtotal=$(( t2 - t1 )); didle=$(( idle2 - idle1 )); cpu_usage=0 +(( dtotal > 0 )) && cpu_usage=$(( ((dtotal - didle) * 100 + dtotal / 2) / dtotal )) +cpu_temp=$(hwmon_temp k10temp) || cpu_temp=$(hwmon_temp coretemp) || cpu_temp="" +printf 'cpu\tThrusters\t%s\t%s\t%s cores online\n' "$cpu_usage" "$cpu_temp" "$(nproc 2>/dev/null || echo '?')" + +# --- GPU: Hyperdrive (discrete = the card with the most VRAM) --------------- +gpu_dev=""; best_vram=-1 +for d in /sys/class/drm/card[0-9]*/device; do + [[ -r "$d/gpu_busy_percent" ]] || continue + v=$(cat "$d/mem_info_vram_total" 2>/dev/null || echo 0) + (( v > best_vram )) && { best_vram=$v; gpu_dev=$d; } +done +gpu_usage=0; gpu_temp=""; gpu_detail="offline" +if [[ -n "$gpu_dev" ]]; then + gpu_usage=$(cat "$gpu_dev/gpu_busy_percent" 2>/dev/null || echo 0) + for hd in "$gpu_dev"/hwmon/hwmon*; do + [[ -d "$hd" ]] && { gpu_temp=$(hwmon_dir_temp "$hd" junction) || gpu_temp=""; break; } + done + (( best_vram > 0 )) && gpu_detail=$(awk -v b="$best_vram" 'BEGIN{printf "%.0f GiB core", b/1073741824}') +fi +printf 'gpu\tHyperdrive\t%s\t%s\t%s\n' "$gpu_usage" "$gpu_temp" "$gpu_detail" + +# --- RAM: Life Support Systems --------------------------------------------- +read ram_used ram_total < <(free -b | awk '/^Mem:/{print $3, $2}') +ram_usage=0; (( ram_total > 0 )) && ram_usage=$(( (ram_used * 100 + ram_total / 2) / ram_total )) +ram_temp=$(hwmon_temp spd5118) || ram_temp=$(hwmon_temp jc42) || ram_temp="" +ram_detail=$(awk -v u="$ram_used" -v t="$ram_total" 'BEGIN{printf "%.1f/%.1f GiB", u/1073741824, t/1073741824}') +printf 'ram\tLife Support Systems\t%s\t%s\t%s\n' "$ram_usage" "$ram_temp" "$ram_detail" + +# --- Storage: Cargo Hold (one row per real filesystem, deduped by device) --- +declare -A seen +while read -r src used size; do + [[ "$src" == /dev/* ]] || continue + [[ -n "${seen[$src]:-}" ]] && continue + seen[$src]=1 + dev=$(basename "$src") # e.g. sda1 / nvme0n1p2 + disk=$(lsblk -no pkname "$src" 2>/dev/null | head -1) # parent disk, if partitioned + [[ -z "$disk" ]] && disk="$dev" + (( size > 0 )) || continue + usage=$(( (used * 100 + size / 2) / size )) + temp=""; [[ "$disk" == nvme* ]] && { temp=$(nvme_temp "$disk") || temp=""; } + [[ -z "$temp" ]] && temp=$(hwmon_temp drivetemp 2>/dev/null) || true + detail=$(awk -v d="$dev" -v u="$used" -v t="$size" \ + 'BEGIN{printf "%-10s %.0f/%.0f GiB", d, u/1073741824, t/1073741824}') + printf 'disk\tCargo Hold\t%s\t%s\t%s\n' "$usage" "$temp" "$detail" +done < <(df -B1 --output=source,used,size -x tmpfs -x devtmpfs -x overlay -x squashfs 2>/dev/null | tail -n +2) diff --git a/astro-menu/backend/weather.sh b/astro-menu/backend/weather.sh new file mode 100755 index 0000000..9426c89 --- /dev/null +++ b/astro-menu/backend/weather.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Weather backend: wttr.in with its native ANSI art (curl UA gets terminal colours). +# Args: $1 = location (may be empty -> wttr.in geolocates the caller), $2 = query opts. +# Prints the wttr.in body (ANSI or plain, per opts) to stdout. +set -uo pipefail + +loc="${1:-}" +# Note: ${2-0}, not ${2:-0} — the expanded view passes an *empty* opts to request +# wttr.in's default 3-day forecast, and :- would wrongly rewrite that empty string +# to "0" (current conditions only). Default to "0" only when no opts arg is given. +opts="${2-0}" + +# URL-encode spaces in a city name. +loc="${loc// /+}" + +url="https://wttr.in/${loc}?${opts}" +# -A curl makes wttr.in return terminal (ANSI) output regardless of the real UA. +curl -sf -A curl --max-time 10 "$url" diff --git a/astro-menu/config.py b/astro-menu/config.py new file mode 100644 index 0000000..96f59b3 --- /dev/null +++ b/astro-menu/config.py @@ -0,0 +1,31 @@ +"""Tiny user-editable config file: ~/.local/state/astro-menu/config.json. + +Same pattern as orbit-menu/config.py and horizon-dock/config.py. Deliberately +separate from settings.py's Settings class: that one persists quad enable/ +feature toggles and favorites (module-scoped, read via ctx.feature()), while +this is a single whole-window flag read once at startup. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = {"hologram": True} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) diff --git a/astro-menu/lib/ansi.py b/astro-menu/lib/ansi.py new file mode 100644 index 0000000..febb91f --- /dev/null +++ b/astro-menu/lib/ansi.py @@ -0,0 +1,138 @@ +"""Render ANSI/SGR-coloured terminal text (e.g. wttr.in) into a Gtk.TextView. + +wttr.in returns real terminal escape sequences when curled. We parse the SGR +subset it uses (basic 8/16 colours, xterm-256, truecolor, bold, reset) and emit +Gtk.TextTags so the CLI art keeps its colours and block-glyph alignment. This is +deliberately reusable by any future "show me some CLI art" widget. +""" + +from __future__ import annotations + +import re + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +_SGR = re.compile(r"\x1b\[([0-9;]*)m") + +# xterm 16-colour base palette (approx, tuned to the CyberQueer dark background). +_BASE16 = [ + (0x00, 0x00, 0x00), (0xCC, 0x24, 0x24), (0x33, 0xCC, 0x33), (0xCC, 0xCC, 0x33), + (0x33, 0x66, 0xCC), (0xCC, 0x33, 0xCC), (0x33, 0xCC, 0xCC), (0xD6, 0xAB, 0xAB), + (0x66, 0x66, 0x66), (0xF5, 0x05, 0x05), (0x55, 0xFF, 0x55), (0xFF, 0xFF, 0x55), + (0x55, 0x88, 0xFF), (0xE4, 0x00, 0x46), (0x55, 0xFF, 0xFF), (0xFF, 0xFF, 0xFF), +] + + +def _xterm256(n: int) -> tuple[int, int, int]: + if n < 16: + return _BASE16[n] + if n < 232: # 6x6x6 colour cube + n -= 16 + r, g, b = n // 36, (n // 6) % 6, n % 6 + conv = lambda c: 55 + c * 40 if c else 0 + return conv(r), conv(g), conv(b) + v = 8 + (n - 232) * 10 # grayscale ramp + return v, v, v + + +def _rgba(rgb: tuple[int, int, int]) -> Gdk.RGBA: + c = Gdk.RGBA() + c.red, c.green, c.blue, c.alpha = rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1.0 + return c + + +class AnsiRenderer: + """Owns a TextView and repaints it from ANSI text. Tags are cached by state.""" + + def __init__(self) -> None: + self.view = Gtk.TextView( + editable=False, cursor_visible=False, monospace=True, + wrap_mode=Gtk.WrapMode.NONE, + ) + self.view.add_css_class("ansi-view") + self.buffer = self.view.get_buffer() + self._tag_cache: dict[tuple, Gtk.TextTag] = {} + + def _tag(self, fg, bg, bold) -> Gtk.TextTag | None: + if fg is None and bg is None and not bold: + return None + key = (fg, bg, bold) + tag = self._tag_cache.get(key) + if tag is None: + tag = self.buffer.create_tag() + if fg is not None: + tag.set_property("foreground-rgba", _rgba(fg)) + if bg is not None: + tag.set_property("background-rgba", _rgba(bg)) + if bold: + tag.set_property("weight", 700) + self._tag_cache[key] = tag + return tag + + def set_text(self, text: str) -> None: + self.buffer.set_text("", 0) + fg = bg = None + bold = False + pos = 0 + for m in _SGR.finditer(text): + chunk = text[pos:m.start()] + if chunk: + self._insert(chunk, fg, bg, bold) + fg, bg, bold = self._apply(m.group(1), fg, bg, bold) + pos = m.end() + tail = text[pos:] + if tail: + self._insert(tail, fg, bg, bold) + + def _insert(self, chunk, fg, bg, bold) -> None: + end = self.buffer.get_end_iter() + tag = self._tag(fg, bg, bold) + if tag is None: + self.buffer.insert(end, chunk) + else: + self.buffer.insert_with_tags(end, chunk, tag) + + @staticmethod + def _apply(params: str, fg, bg, bold): + codes = [int(x) if x else 0 for x in params.split(";")] if params else [0] + i = 0 + while i < len(codes): + c = codes[i] + if c == 0: + fg = bg = None + bold = False + elif c == 1: + bold = True + elif c == 22: + bold = False + elif c == 39: + fg = None + elif c == 49: + bg = None + elif 30 <= c <= 37: + fg = _BASE16[c - 30] + elif 90 <= c <= 97: + fg = _BASE16[c - 90 + 8] + elif 40 <= c <= 47: + bg = _BASE16[c - 40] + elif 100 <= c <= 107: + bg = _BASE16[c - 100 + 8] + elif c in (38, 48): + target = "fg" if c == 38 else "bg" + if i + 1 < len(codes) and codes[i + 1] == 5: + val = _xterm256(codes[i + 2]) if i + 2 < len(codes) else None + i += 2 + elif i + 1 < len(codes) and codes[i + 1] == 2: + val = tuple(codes[i + 2:i + 5]) if i + 4 < len(codes) else None + i += 4 + else: + val = None + if target == "fg": + fg = val + else: + bg = val + i += 1 + return fg, bg, bold diff --git a/astro-menu/lib/border.py b/astro-menu/lib/border.py new file mode 100644 index 0000000..ab9d2ed --- /dev/null +++ b/astro-menu/lib/border.py @@ -0,0 +1,100 @@ +"""Draw a rounded border around any widget with a Cairo DrawingArea overlay. + +This GTK build's renderer does not paint CSS border/background nodes on plain +container widgets (Box/Frame) — only on buttons/entries — but it renders Cairo +draw funcs and textures fine (the map image proves it). So module 'borders' are +drawn explicitly here instead of via CSS. +""" + +from __future__ import annotations + +import math + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +# CyberQueer accent/violet (matches @accent/@violet / COLOR_HIGHLIGHT/COLOR_DARK +# in colors.conf). +ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) +VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +# Module fills are translucent violet rather than solid gray/black — lets the +# hologram overlay's scanlines/noise (drawn above, on window.py's overlay) and +# the desktop behind the panel both read through, so the whole thing looks +# like projected light/glass instead of an opaque card. +FILL_COLOR = VIOLET +FILL_ALPHA = 0.28 + + +def _rounded_rect(cr, x, y, w, h, r) -> None: + r = min(r, w / 2, h / 2) + cr.new_sub_path() + cr.arc(x + w - r, y + r, r, -math.pi / 2, 0) + cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2) + cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi) + cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2) + cr.close_path() + + +# Soft outer glow around the border ring: Cairo has no native blur, so this is +# the usual poor-man's bloom — the same crisp stroke redrawn a few times at +# increasing width and decreasing alpha, underneath the final crisp line. Wider +# and stronger than before so the thin border reads as glowing, not just drawn. +_GLOW_LAYERS = [(22, 0.05), (15, 0.08), (9, 0.13), (4, 0.22)] + + +def _make_draw(border: int, radius: int, color, fill_bg: bool, glow: bool): + def draw(_area, cr, w, h, *_a) -> None: + inset = border / 2 + r, g, b = color + if glow: + # The bloom strokes are up to 24px wide, so half of each spills OUTWARD + # past the ring. GTK clips a DrawingArea to its own (square) allocation, + # which along the straight edges trims that spill to nothing but inside + # each corner leaves the whole square patch outside the arc painted — + # solid red corners poking out from behind the rounded border. Clip the + # glow to the ring's own outer silhouette (the widget rect rounded by + # radius + inset, i.e. exactly where the crisp stroke's outer edge runs) + # so the bloom only ever reads inward and the card keeps a clean rounded + # outline. + cr.save() + _rounded_rect(cr, 0, 0, w, h, radius + inset) + cr.clip() + for extra_w, alpha in _GLOW_LAYERS: + _rounded_rect(cr, inset, inset, w - border, h - border, radius) + cr.set_source_rgba(r, g, b, alpha) + cr.set_line_width(border + extra_w) + cr.stroke() + cr.restore() + _rounded_rect(cr, inset, inset, w - border, h - border, radius) + if fill_bg: + cr.set_source_rgba(*FILL_COLOR, FILL_ALPHA) + cr.fill_preserve() + cr.set_source_rgb(*color) + cr.set_line_width(border) + cr.stroke() + return draw + + +def bordered(child: Gtk.Widget, border: int = 2, radius: int = 16, + color=ACCENT, fill_bg: bool = False, glow: bool = True) -> Gtk.Overlay: + """Wrap child in an overlay whose background is a drawn rounded border — + with a soft holographic glow around it by default, matching orbit-menu/ + horizon-dock's aesthetic. + + The DrawingArea is the overlay's main child (drawn first, behind); the content + is an overlay child on top and drives the size. A small margin keeps the content + clear of the drawn border ring. + """ + overlay = Gtk.Overlay() + area = Gtk.DrawingArea() + area.set_draw_func(_make_draw(border, radius, color, fill_bg, glow)) + overlay.set_child(area) # background: the border ring + child.set_margin_start(border + 4) + child.set_margin_end(border + 4) + child.set_margin_top(border + 4) + child.set_margin_bottom(border + 4) + overlay.add_overlay(child) # content on top + overlay.set_measure_overlay(child, True) # size the overlay to the content + return overlay diff --git a/astro-menu/lib/hologram.py b/astro-menu/lib/hologram.py new file mode 100644 index 0000000..6c65a22 --- /dev/null +++ b/astro-menu/lib/hologram.py @@ -0,0 +1,266 @@ +"""Holographic scanline/sweep/noise overlay — the same visual treatment as +orbit-menu's hologram effect, adapted for astro-menu's rectangular panel +instead of orbit-menu's circular canvas: no radial vignette mask here, since +the panel's own Cairo-drawn module borders (lib/border.py) already bound it — +covering the full rectangle reads as one continuous "HUD screen" rather than +tinting past its own edges. + +Owns its own animation clock (advanced by window.py's tick callback via +.tick(dt)) and a persistent particle list for the noise specs, exactly like +orbit-menu's _update_hologram_particles: each spec keeps its position/color +for a real randomized lifetime (fade in, hold, fade out) instead of every +spec teleporting to a new position every frame. +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as orbit-menu's hologram overlay. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture + SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 90.0 + NOISE_COUNT = 95 # specs alive at once (each with its own lifetime) + NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field + EDGE_FADE_Y = 40.0 # smooth vertical fade-out + INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in + INTRO_STATIC = 1200 # static specks at the very start of the intro + OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close + + def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, + intro_duration: float | None = None) -> None: + self.enabled = enabled + self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape + # widget whose opacity is ramped 0->1 during the intro so the UI genuinely + # fades in (a gradual reveal), rather than a solid haze block popping on + self._fade_widget = fade_widget + if intro_duration is not None: + self.INTRO_DURATION = intro_duration # per-instance override of the class default + self._sat_time = 0.0 + self._particles: list[dict] = [] + self._intro_t: float | None = None # >=0 while the materialise intro plays + self._outro_t: float | None = None # >=0 while the closing dissolve plays + self._outro_done = None # callback fired when the dissolve finishes + self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from content underneath + self.widget.add_css_class("astro-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + if self._intro_t is not None: + self._intro_t += dt + if self._intro_t >= self.INTRO_DURATION: + self._intro_t = None + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0) + elif self._fade_widget is not None: + p = self._intro_t / self.INTRO_DURATION + self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1 + if self._outro_t is not None: + self._outro_t += dt + po = min(1.0, self._outro_t / self.OUTRO_DURATION) + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0 + if self._outro_t >= self.OUTRO_DURATION: + done = self._outro_done + self._outro_t = None + self._outro_done = None + if done is not None: + done() + self.widget.queue_draw() + + def start_intro(self) -> None: + """Kick off the 'hologram materialising out of static' opening effect.""" + if self.enabled: + self._outro_t = None # cancel any in-flight closing dissolve + self._outro_done = None + self._intro_t = 0.0 + if self._fade_widget is not None: + self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up + + def start_outro(self, on_done) -> None: + """Play a quick reverse of the intro (content dissolving back into static), + then call on_done to actually hide. If disabled, hide immediately.""" + if not self.enabled: + on_done() + return + self._intro_t = None # cancel any in-flight opening intro + self._outro_t = 0.0 + self._outro_done = on_done + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + if self._clip_func is not None: + cr.save() + self._clip_func(cr, width, height) # clip the scanlines to the UI shape + cr.clip() + # Render the holo field into a group, then composite it back through a + # soft edge-fade mask so the scanlines dissolve at the borders (reads far + # more like a projected hologram than a hard-edged rectangle). + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + # No solid veil block: the content itself fades in (see tick's fade_widget + # ramp). Here we only lay churning static over it — dense at first, thinning + # to nothing — so the UI resolves out of noise as it fades up. + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height # a bright scan wiping down as it resolves + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + # Reverse of the intro: the content is already fading back out (tick's + # fade_widget ramp 1->0); here the static thickens from nothing as it goes, + # so the panel dissolves into noise just before it vanishes. + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height # scan wiping back up as it dissolves + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.07) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/astro-menu/lib/proc.py b/astro-menu/lib/proc.py new file mode 100644 index 0000000..34a8924 --- /dev/null +++ b/astro-menu/lib/proc.py @@ -0,0 +1,100 @@ +"""Subprocess helpers built on Gio so nothing blocks the GTK main loop. + +The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command +prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on +failure. These helpers run such commands asynchronously and hand the parsed result +back on the main thread. +""" + +from __future__ import annotations + +import json +import shlex +from typing import Callable, Sequence + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + + +def _as_argv(cmd: Sequence[str] | str) -> list[str]: + return shlex.split(cmd) if isinstance(cmd, str) else list(cmd) + + +def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None: + """Run cmd, call cb(ok, stdout, stderr) on the main thread when done.""" + try: + proc = Gio.Subprocess.new( + _as_argv(cmd), + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, + ) + except GLib.Error as err: + GLib.idle_add(cb, False, "", str(err)) + return + + def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None: + try: + _, out, errout = p.communicate_utf8_finish(res) + except GLib.Error as err: + cb(False, "", str(err)) + return + cb(p.get_successful(), out or "", errout or "") + + proc.communicate_utf8_async(None, None, _done) + + +def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None: + """Run cmd expecting JSON on stdout; call cb(ok, data).""" + + def _text(ok: bool, out: str, err: str) -> None: + if not ok or not out.strip(): + cb(False, err.strip() or "no output") + return + try: + cb(True, json.loads(out)) + except json.JSONDecodeError as exc: + cb(False, f"bad json: {exc}") + + run_text(cmd, _text) + + +class Poller: + """Repeatedly run a command (or callable) on an interval, main-thread callback. + + Used for lightweight state that has no change signal (open ports, public IP, + weather refresh). Modules that back onto an Astal GObject service should prefer + connecting to that service's `notify::` signals instead of polling. + """ + + def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False): + self.interval_s = interval_s + self.cmd = cmd + self.cb = cb + self.json_mode = json_mode + self._source_id: int | None = None + self._stopped = False + + def start(self) -> "Poller": + self._stopped = False + self._tick() + self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick) + return self + + def _tick(self) -> bool: + if self._stopped: + return GLib.SOURCE_REMOVE + if self.json_mode: + run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data)) + else: + run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err)) + return GLib.SOURCE_CONTINUE + + def refresh_now(self) -> None: + self._tick() + + def stop(self) -> None: + self._stopped = True + if self._source_id is not None: + GLib.source_remove(self._source_id) + self._source_id = None diff --git a/astro-menu/main.py b/astro-menu/main.py new file mode 100755 index 0000000..5674087 --- /dev/null +++ b/astro-menu/main.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""astro-menu — a touch-friendly GTK4 popup control centre (Location, Weather, +Bluetooth, Network) plus an application drawer, replacing nwg-dock/nwg-drawer. + +Single-instance: the first launch builds the (hidden) window and holds. Later +invocations forward their arguments to it over D-Bus, so `main.py --toggle` +toggles the running instance without spawning a new process. + + main.py run the resident instance (stays hidden until toggled) + main.py --toggle toggle visibility + main.py --show show | --hide hide | --appdrawer show with drawer open +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# astro-menu-start.sh LD_PRELOADs libgtk4-layer-shell so it loads before +# libwayland-client (a load-ordering requirement of the layer-shell library). That +# only matters at *this* process's exec: the library is already resident now, so the +# variable is never read again. Drop it here so the GUI apps we launch via +# AstalApps.launch() (and the backend subprocesses) don't inherit it — Firefox, for +# one, aborts at startup with libgtk4-layer-shell preloaded. +os.environ.pop("LD_PRELOAD", None) + +# Make sibling modules importable no matter the CWD. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import theme # noqa: E402 +from appservices import Services # noqa: E402 +from paths import APP_ID, ensure_dirs # noqa: E402 +from settings import Settings # noqa: E402 +from window import MenuWindow # noqa: E402 + + +class AstalMenuApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: MenuWindow | None = None + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + ensure_dirs() + theme.load_css() + settings = Settings() + services = Services() + self.window = MenuWindow(self, settings, services) + self._register_actions() + self.hold() # stay alive with no visible window + + def _register_actions(self) -> None: + # Mirrors the --show/--hide/--toggle/--appdrawer verbs from do_command_line, but + # reachable via org.gtk.Actions.Activate. menu-toggle.sh calls these directly with + # `gdbus call` for already-running instances, which skips spawning a whole second + # python3 + GTK process (~0.5s) just to forward one verb over D-Bus. + def add(name: str, callback, has_side: bool) -> None: + action = Gio.SimpleAction.new(name, GLib.VariantType.new("s") if has_side else None) + action.connect("activate", callback) + self.add_action(action) + + def side_of(param: GLib.Variant | None) -> str: + return param.get_string() if param is not None else "" + + def on_show(_action, param) -> None: + assert self.window is not None + if side_of(param): + self.window.set_side(side_of(param)) + self.window.show_menu() + + def on_hide(_action, _param) -> None: + assert self.window is not None + self.window.hide_menu() + + def on_toggle(_action, param) -> None: + assert self.window is not None + if side_of(param): + self.window.set_side(side_of(param)) + self.window.toggle() + + def on_appdrawer(_action, param) -> None: + assert self.window is not None + if side_of(param): + self.window.set_side(side_of(param)) + self.window.show_menu(focus_appdrawer=True) + + add("show", on_show, has_side=True) + add("hide", on_hide, has_side=False) + add("toggle", on_toggle, has_side=True) + add("appdrawer", on_appdrawer, has_side=True) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + # Optional "--side top|bottom|left|right": the menu slides in from and pins to + # that monitor edge. Applied before the verb so the surface maps animated. + side = None + if "--side" in args: + i = args.index("--side") + if i + 1 < len(args): + side = args[i + 1] + del args[i:i + 2] + # No args = start (or keep) the resident instance hidden. Only explicit + # verbs change visibility, so the autostart launch never pops the menu. + action = args[0] if args else "--daemon" + if self.window is None: + return 0 + if side: + self.window.set_side(side) + if action == "--show": + self.window.show_menu() + elif action == "--hide": + self.window.hide_menu() + elif action == "--appdrawer": + self.window.show_menu(focus_appdrawer=True) + elif action == "--toggle": + self.window.toggle() + # --daemon and anything else: no-op (stay as-is) + return 0 + + def do_activate(self) -> None: + # Resident instance: nothing to do on plain activate. + pass + + +def main() -> int: + GLib.set_prgname("astro-menu") + return AstalMenuApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/astro-menu/module_base.py b/astro-menu/module_base.py new file mode 100644 index 0000000..e50c1b8 --- /dev/null +++ b/astro-menu/module_base.py @@ -0,0 +1,73 @@ +"""The contract every quad module implements. + +Adding a module = drop a file in modules/ that defines a top-level `SPEC` +(a ModuleSpec), then append its import to registry.py. Nothing else needs to +change: enable/disable, feature toggles, the card chrome, and the expand/collapse +plumbing are all provided generically. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +@dataclass +class Feature: + """A per-quad on/off switch surfaced in the card's settings popover.""" + id: str + label: str + default: bool = True + + +@dataclass +class ModuleInstance: + """What a module's build() returns.""" + compact: Gtk.Widget # shown in the 2x2 cell + expanded: Optional[Gtk.Widget] = None # shown when the quad is expanded + scroll_expanded: bool = True # wrap the expanded view in a scroller + on_show: Optional[Callable[[], None]] = None + on_hide: Optional[Callable[[], None]] = None + destroy: Optional[Callable[[], None]] = None + + +@dataclass +class ModuleSpec: + id: str + title: str + icon: str # nerd-font glyph + build: Callable[["ModuleContext"], ModuleInstance] + default_enabled: bool = True + features: list[Feature] = field(default_factory=list) + + +class ModuleContext: + """Handed to a module's build(). Scopes settings to the module and exposes the + expand/collapse requests so a module can drive the layout without knowing it.""" + + def __init__(self, spec: ModuleSpec, settings, services, + request_expand: Callable[[str], None], + request_collapse: Callable[[], None]): + self.spec = spec + self.settings = settings + self.services = services + self._request_expand = request_expand + self._request_collapse = request_collapse + + def expand(self) -> None: + self._request_expand(self.spec.id) + + def collapse(self) -> None: + self._request_collapse() + + # feature toggles, scoped to this module + def feature(self, feature_id: str, default: bool = True) -> bool: + return self.settings.feature(self.spec.id, feature_id, default) + + def on_settings_changed(self, cb: Callable[[], None]) -> None: + self.settings.subscribe(cb) diff --git a/astro-menu/modules/bluetooth.py b/astro-menu/modules/bluetooth.py new file mode 100644 index 0000000..38d816f --- /dev/null +++ b/astro-menu/modules/bluetooth.py @@ -0,0 +1,292 @@ +"""Bluetooth quad: powered by AstalBluetooth (bluez wrapper). + +Discovery, connect, disconnect and a local connection history (bluez keeps none, so +we record successful connects in ~/.cache/astro-menu/bt-history.json). 'discovery' +and 'history' are per-quad feature toggles. +""" + +from __future__ import annotations + +import json +import time + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +from lib.proc import run_text +from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec +from paths import CACHE_DIR + +_HISTORY = CACHE_DIR / "bt-history.json" + + +def _load_history() -> list[dict]: + try: + return json.loads(_HISTORY.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return [] + + +def _record_history(address: str, name: str) -> None: + hist = [h for h in _load_history() if h.get("address") != address] + hist.insert(0, {"address": address, "name": name, "ts": int(time.time())}) + CACHE_DIR.mkdir(parents=True, exist_ok=True) + _HISTORY.write_text(json.dumps(hist[:50], indent=2)) + + +class _BluetoothView(Gtk.Box): + def __init__(self, ctx: ModuleContext, full: bool): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add_css_class("bt-view") + self.ctx = ctx + self.full = full + self.bt = ctx.services.bluetooth + self._recorded: set[str] = set() + self._hooked: set[str] = set() # devices whose state signals we've wired + self._pending: set[str] = set() # addresses with an in-flight connect + self._failed: set[str] = set() # addresses whose last connect failed + + self.append(self._build_header()) + + self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self._list.add_css_class("bt-list") + if full: + scroller = Gtk.ScrolledWindow(vexpand=True, + hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(self._list) + self.append(scroller) + else: + self.append(self._list) + + if self.bt is not None: + self.bt.connect("notify::devices", lambda *_: self._refresh()) + self.bt.connect("notify::is-powered", lambda *_: self._refresh()) + self._refresh() + + def _adapter(self): + return self.bt.get_adapter() if self.bt else None + + def _build_header(self) -> Gtk.Widget: + header = Gtk.CenterBox() + header.add_css_class("bt-header") + left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + left.append(Gtk.Label(label="Power")) + self._power = Gtk.Switch(active=bool(self.bt and self.bt.get_is_powered()), + valign=Gtk.Align.CENTER) + self._power.connect("state-set", self._on_power) + left.append(self._power) + header.set_start_widget(left) + + if self.full and self.ctx.feature("discovery", True): + self._scan = Gtk.ToggleButton(label=" Scan") + self._scan.add_css_class("quad-action") + self._scan.connect("toggled", self._on_scan) + header.set_end_widget(self._scan) + return header + + def _on_power(self, _sw, value: bool) -> bool: + ad = self._adapter() + if value: + # The adapter is frequently rfkill soft-blocked at boot; while blocked, + # AstalBluetooth.set_powered(True) is a silent no-op. Unblock first, then + # power on (bluez usually auto-powers on unblock, so set_powered is a + # belt-and-suspenders follow-up). + run_text(["rfkill", "unblock", "bluetooth"], + lambda *_a: ad.set_powered(True) if ad else None) + elif ad: + ad.set_powered(False) + return False + + def _on_scan(self, btn: Gtk.ToggleButton) -> None: + ad = self._adapter() + if not ad: + return + if btn.get_active(): + ad.start_discovery() + else: + ad.stop_discovery() + + # -- device list ------------------------------------------------------- + def _refresh(self) -> None: + child = self._list.get_first_child() + while child: + self._list.remove(child) + child = self._list.get_first_child() + if not self.bt: + self._list.append(Gtk.Label(label="No System Radio hardware")) + return + + devices = list(self.bt.get_devices()) + devices.sort(key=lambda d: (not d.get_connected(), not d.get_paired(), + (d.get_name() or d.get_address() or "").lower())) + if not self.full: + devices = [d for d in devices if d.get_connected() or d.get_paired()][:4] + for dev in devices: + self._hook_device(dev) + self._list.append(self._device_row(dev)) + + if self.full and self.ctx.feature("history", True): + self._list.append(self._history_section()) + + def _hook_device(self, dev) -> None: + # React to a device's own state changes (adapter-level notify::devices doesn't + # fire for per-device connect/disconnect). Hook once each. + key = dev.get_address() or "" + if key in self._hooked: + return + self._hooked.add(key) + for sig in ("notify::connected", "notify::connecting", "notify::paired"): + dev.connect(sig, lambda *_a, d=dev: self._on_device_state(d)) + + def _on_device_state(self, dev) -> None: + addr = dev.get_address() or "" + if dev.get_connected(): + self._pending.discard(addr) + self._failed.discard(addr) + if self.ctx.feature("history", True): + self._maybe_record(dev, force=True) + elif addr in self._pending and not dev.get_connecting(): + # bluez stopped trying without establishing a link → the connect failed + self._pending.discard(addr) + self._failed.add(addr) + self._refresh() + + def _device_row(self, dev) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + row.add_css_class("bt-row") + addr = dev.get_address() or "" + icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic") + row.append(icon) + name = dev.get_name() or addr or "Unknown" + + connected = dev.get_connected() + connecting = dev.get_connecting() or addr in self._pending + failed = addr in self._failed + + lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True) + row.append(lbl) + + status = ("connected" if connected else "connecting…" if connecting else + "failed" if failed else "paired" if dev.get_paired() else "") + if status: + tag = Gtk.Label(label=status) + tag.add_css_class("bt-status") + if failed: + tag.add_css_class("bt-failed") + row.append(tag) + + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + if connecting: + spinner = Gtk.Spinner(valign=Gtk.Align.CENTER) + spinner.start() + actions.append(spinner) + actions.append(self._act("Cancel", lambda: self._cancel(dev))) + else: + if dev.get_paired(): + actions.append(self._act("Forget", lambda: self._forget(dev))) + if connected: + actions.append(self._act("Disconnect", + lambda: dev.disconnect_device(None, self._noop))) + else: + actions.append(self._act("Retry" if failed else "Connect", + lambda: self._start_connect(dev))) + row.append(actions) + + if connected and self.ctx.feature("history", True): + self._maybe_record(dev) + return row + + @staticmethod + def _act(label: str, cb) -> Gtk.Button: + b = Gtk.Button(label=label, valign=Gtk.Align.CENTER) + b.add_css_class("quad-action") + b.connect("clicked", lambda *_a: cb()) + return b + + # -- connect / cancel / forget ---------------------------------------- + def _start_connect(self, dev) -> None: + addr = dev.get_address() or "" + self._failed.discard(addr) + self._pending.add(addr) + + def done(d, res): + try: + d.connect_device_finish(res) + except Exception: + a = d.get_address() or "" + if not d.get_connected(): + self._pending.discard(a) + self._failed.add(a) + self._refresh() + dev.connect_device(None, done) + # Backstop: some failures never resolve the async call, so time out. + GLib.timeout_add_seconds(25, lambda: self._connect_timeout(addr)) + self._refresh() + + def _connect_timeout(self, addr: str) -> bool: + if addr in self._pending: + self._pending.discard(addr) + self._failed.add(addr) + self._refresh() + return GLib.SOURCE_REMOVE + + def _cancel(self, dev) -> None: + addr = dev.get_address() or "" + self._pending.discard(addr) + dev.disconnect_device(None, self._noop) # abort the in-flight attempt + self._refresh() + + def _forget(self, dev) -> None: + ad = self._adapter() + addr = dev.get_address() or "" + self._pending.discard(addr) + self._failed.discard(addr) + self._hooked.discard(addr) + if ad: + ad.remove_device(dev) # fires notify::devices → refresh + self._refresh() + + def _maybe_record(self, dev, force: bool = False) -> None: + addr = dev.get_address() or "" + if not addr or (addr in self._recorded and not force): + return + self._recorded.add(addr) + _record_history(addr, dev.get_name() or addr) + + @staticmethod + def _noop(obj, res) -> None: + try: + obj.disconnect_device_finish(res) + except Exception: + pass + + def _history_section(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + box.add_css_class("bt-history") + box.append(Gtk.Separator()) + box.append(Gtk.Label(label="History", xalign=0.0)) + for h in _load_history()[:8]: + when = time.strftime("%d.%m %H:%M", time.localtime(h.get("ts", 0))) + box.append(Gtk.Label(label=f"{h.get('name', '?')} · {when}", xalign=0.0)) + return box + + +def build(ctx: ModuleContext) -> ModuleInstance: + # Feature toggles (discovery/history) rebuild the whole card via QuadCard, so + # both views are recreated with the current features — no partial refresh here. + compact = _BluetoothView(ctx, full=False) + expanded = _BluetoothView(ctx, full=True) + return ModuleInstance(compact=compact, expanded=expanded) + + +SPEC = ModuleSpec( + id="bluetooth", + title="System Radio", + icon="", # nf-fa-bluetooth + build=build, + default_enabled=True, + features=[Feature("discovery", "Device discovery", True), + Feature("history", "Connection history", True)], +) diff --git a/astro-menu/modules/location.py b/astro-menu/modules/location.py new file mode 100644 index 0000000..9b4236f --- /dev/null +++ b/astro-menu/modules/location.py @@ -0,0 +1,185 @@ +"""Location quad: a slippy-map image centred on the device's IP-geolocated position, +with a marker. Drag to pan, scroll to zoom, double-click to recentre. + +The position comes from the shared LocationService (backend/geolocate.py), which +traceroutes to 1.1.1.1, takes the first public hop (the ISP egress) and resolves it +through a public geolocation API. The "Locate via IP" feature toggle gates that +lookup entirely; when off, this quad shows a placeholder instead. "Satellite view" +switches the tiles between the dark night map and Esri satellite imagery. + +We render a static map (backend/staticmap.py) rather than an interactive libshumate +map: Shumate does not paint tiles in this environment (the official shumate-demo +shows the same blank map), while tile downloads themselves work fine — so panning is +done by re-rendering at a new centre. The Weather quad consumes the same service. +""" + +from __future__ import annotations + +import math +import sys + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, GLib, Gtk # noqa: E402 + +from lib.proc import run_text +from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec +from paths import BACKEND_DIR, CACHE_DIR + +_SCRIPT = str(BACKEND_DIR / "staticmap.py") +_TILE = 256 + + +def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]: + n = 2 ** zoom + y = (1.0 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2.0 * n + x = (lon + 180.0) / 360.0 * n + return x * _TILE, y * _TILE + + +def _px_latlon(px: float, py: float, zoom: int) -> tuple[float, float]: + n = 2 ** zoom + lon = px / (_TILE * n) * 360.0 - 180.0 + lat = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * py / (_TILE * n))))) + return lat, lon + + +class _MapView(Gtk.Box): + def __init__(self, ctx: ModuleContext, zoom: int, size: tuple[int, int], + tag: str, show_info: bool): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("map-view") + # clip the map texture to the rounded .map-view corners (the Picture is a + # texture, so without this its square corners poke out of the card) + self.set_overflow(Gtk.Overflow.HIDDEN) + self.ctx = ctx + self.zoom = zoom + self.size = size + self._out = str(CACHE_DIR / f"map_{tag}.png") + self._loc: tuple[float, float] | None = None # geolocated position (marker) + self._view: tuple[float, float] | None = None # current view centre + self._drag_from: tuple[float, float] | None = None + self._render_pending = False + + self.picture = Gtk.Picture(content_fit=Gtk.ContentFit.COVER, vexpand=True) + self.picture.add_css_class("map-picture") + self.append(self.picture) + + self._info = None + if show_info: + self._info = Gtk.Label(label="Locating…", xalign=0.0) + self._info.add_css_class("map-info") + self.append(self._info) + + self._wire_gestures() + ctx.services.location.subscribe(self._on_location) + + def _style(self) -> str: + return "satellite" if self.ctx.feature("satellite", False) else "dark" + + # -- data / render ----------------------------------------------------- + def _on_location(self, data: dict) -> None: + self._loc = (data["lat"], data["lon"]) + self._view = (data["lat"], data["lon"]) # recentre on a fresh fix + self._render() + if self._info is not None: + city = data.get("city") or "Unknown" + country = data.get("country") or "" + self._info.set_text(f"{city}, {country} · {data['lat']:.3f}, {data['lon']:.3f}" + f" · drag to pan · scroll to zoom · double-click to recentre") + + def _render(self) -> None: + if self._view is None: + return + vlat, vlon = self._view + mlat, mlon = self._loc or self._view + w, h = self.size + run_text([sys.executable, _SCRIPT, str(vlat), str(vlon), str(self.zoom), + str(w), str(h), self._out, self._style(), str(mlat), str(mlon)], + self._on_rendered) + + def _schedule_render(self) -> None: + # coalesce the flood of drag/scroll updates into ~11 renders/sec + if self._render_pending: + return + self._render_pending = True + + def go() -> bool: + self._render_pending = False + self._render() + return GLib.SOURCE_REMOVE + GLib.timeout_add(90, go) + + def _on_rendered(self, ok: bool, out: str, err: str) -> None: + if not ok: + return + # load a fresh texture; set_filename would ignore an unchanged path on re-pan + try: + self.picture.set_paintable(Gdk.Texture.new_from_filename(self._out)) + except GLib.Error: + self.picture.set_filename(self._out) + + # -- gestures: drag to pan, scroll to zoom, double-click to recentre ---- + def _wire_gestures(self) -> None: + drag = Gtk.GestureDrag() + drag.connect("drag-begin", lambda *_a: setattr(self, "_drag_from", self._view)) + drag.connect("drag-update", self._on_drag) + self.picture.add_controller(drag) + + scroll = Gtk.EventControllerScroll( + flags=Gtk.EventControllerScrollFlags.VERTICAL) + scroll.connect("scroll", self._on_scroll) + self.picture.add_controller(scroll) + + click = Gtk.GestureClick() + click.connect("pressed", self._on_click) + self.picture.add_controller(click) + + def _on_drag(self, _gesture, ox: float, oy: float) -> None: + if self._drag_from is None: + return + cx, cy = _center_px(self._drag_from[0], self._drag_from[1], self.zoom) + # drag right → the view centre moves left (map content follows the cursor) + self._view = _px_latlon(cx - ox, cy - oy, self.zoom) + self._schedule_render() + + def _on_scroll(self, _controller, _dx: float, dy: float) -> bool: + self.zoom = max(3, min(18, self.zoom + (1 if dy < 0 else -1))) + self._schedule_render() + return True + + def _on_click(self, _gesture, n_press: int, _x: float, _y: float) -> None: + if n_press >= 2 and self._loc is not None: + self._view = self._loc + self._schedule_render() + + +def _placeholder() -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8, + valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER) + box.add_css_class("map-view") + box.append(Gtk.Label(label="IP location is off")) + return box + + +def build(ctx: ModuleContext) -> ModuleInstance: + # "Locate via IP" gates geolocation entirely: when off we neither traceroute nor + # hit any geolocation API, and just show a placeholder. Toggling it rebuilds the + # card (via QuadCard), so flipping it back on re-triggers the lookup. + if not ctx.feature("ip_locate", True): + return ModuleInstance(compact=_placeholder(), expanded=None) + ctx.services.location.get() # kick off geolocation (traceroute → API) if not started + compact = _MapView(ctx, zoom=12, size=(620, 150), tag="compact", show_info=False) + expanded = _MapView(ctx, zoom=13, size=(1100, 620), tag="expanded", show_info=True) + return ModuleInstance(compact=compact, expanded=expanded, scroll_expanded=False) + + +SPEC = ModuleSpec( + id="location", + title="Location", + icon="", # nf-fa-map_marker + build=build, + features=[Feature("ip_locate", "Locate via IP", True), + Feature("satellite", "Satellite view", False)], +) diff --git a/astro-menu/modules/network.py b/astro-menu/modules/network.py new file mode 100644 index 0000000..10eb53a --- /dev/null +++ b/astro-menu/modules/network.py @@ -0,0 +1,529 @@ +"""Network quad. + +Compact: Wi-Fi enable + primary connection/IP (AstalNetwork + backend/network.sh). +Expanded: a small NetworkManager control panel over backend/nm.py, as four tabs — + Adapters : each device foldout with editable IPv4/IPv6 (DHCP toggle, address, + subnet-mask <-> CIDR, gateway); applied via `nmcli connection modify/up` + Routes : the kernel routing table (read-only; editing kernel routes needs root) + VLAN : list / create / activate / delete VLAN connections + DNS : effective servers per device + a per-connection override table +Every tab is an independently toggle-able feature. +""" + +from __future__ import annotations + +import ipaddress +import sys + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from lib.proc import run_json +from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec +from paths import BACKEND_DIR + +_NET = str(BACKEND_DIR / "network.sh") +_NM = str(BACKEND_DIR / "nm.py") + + +def _nm(args: list[str], cb) -> None: + """Run a backend/nm.py subcommand expecting JSON on stdout.""" + run_json([sys.executable, _NM, *args], cb) + + +def _primary_ip(data) -> str: + if not isinstance(data, list): + return "—" + for iface in data: + if iface.get("ifname") == "lo": + continue + for a in iface.get("addr_info", []): + if a.get("family") == "inet": + return f"{a['local']}/{a.get('prefixlen', '')} ({iface.get('ifname')})" + return "—" + + +# -- subnet mask <-> CIDR prefix ------------------------------------------ +def _mask_to_prefix(mask: str) -> int | None: + try: + return ipaddress.IPv4Network(f"0.0.0.0/{mask.strip()}").prefixlen + except (ipaddress.NetmaskValueError, ValueError): + return None + + +def _prefix_to_mask(prefix: int) -> str: + return str(ipaddress.IPv4Network(f"0.0.0.0/{prefix}").netmask) + + +class _Compact(Gtk.Box): + def __init__(self, ctx: ModuleContext): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add_css_class("net-view") + self.ctx = ctx + self.net = ctx.services.network + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self._primary = Gtk.Label(label="…", xalign=0.0, hexpand=True) + row.append(Gtk.Label(label="")) # nf wifi + row.append(self._primary) + wifi = self.net.get_wifi() if self.net else None + self._wifi_switch = Gtk.Switch(active=bool(wifi and wifi.get_enabled()), + valign=Gtk.Align.CENTER) + self._wifi_switch.set_tooltip_text("Wi-Fi") + self._wifi_switch.connect("state-set", self._on_wifi_toggle) + row.append(self._wifi_switch) + self.append(row) + + self._ip = Gtk.Label(label="", xalign=0.0) + self._ip.add_css_class("net-ip") + self.append(self._ip) + + if self.net: + self.net.connect("notify::primary", lambda *_: self.refresh()) + if wifi: + wifi.connect("notify::ssid", lambda *_: self.refresh()) + self.refresh() + + def _on_wifi_toggle(self, _sw, value: bool) -> bool: + wifi = self.net.get_wifi() if self.net else None + if wifi: + wifi.set_enabled(value) + return False + + def refresh(self) -> None: + wifi = self.net.get_wifi() if self.net else None + if wifi and wifi.get_active_access_point(): + self._primary.set_text(f"{wifi.get_ssid() or '?'} · {wifi.get_strength()}%") + elif self.net and self.net.get_wired() and self.net.get_wired().get_internet(): + self._primary.set_text("Wired") + else: + self._primary.set_text("Disconnected") + run_json([_NET, "ip"], lambda ok, d: self._ip.set_text(_primary_ip(d) if ok else "—")) + + +# -- shared little widgets ------------------------------------------------- +def _clear(box: Gtk.Box) -> None: + child = box.get_first_child() + while child: + box.remove(child) + child = box.get_first_child() + + +def _field(placeholder: str, text: str = "") -> Gtk.Entry: + e = Gtk.Entry(placeholder_text=placeholder, text=text, hexpand=True) + e.add_css_class("net-entry") + return e + + +def _labeled(label: str, widget: Gtk.Widget) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + lbl = Gtk.Label(label=label, xalign=0.0) + lbl.set_size_request(90, -1) + row.append(lbl) + row.append(widget) + return row + + +def _pill(label: str, cb) -> Gtk.Button: + b = Gtk.Button(label=label) + b.add_css_class("quad-action") + b.connect("clicked", lambda *_: cb()) + return b + + +# -- IPv4 / IPv6 editor for one connection -------------------------------- +class _IPSection(Gtk.Box): + """One address family's editor. IPv4 exposes a linked subnet-mask/CIDR pair; + IPv6 exposes a prefix-length field. Fields are only sensitive in Manual mode.""" + + def __init__(self, con: str, family: str, fam_data: dict, on_result): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.con = con + self.family = family + self._on_result = on_result + self.add_css_class("net-view") + + title = "IPv4" if family == "ipv4" else "IPv6" + head = Gtk.CenterBox() + head.set_start_widget(Gtk.Label(label=title, xalign=0.0)) + auto = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + auto.append(Gtk.Label(label="Automatic")) + self._dhcp = Gtk.Switch(active=(fam_data.get("method", "auto") != "manual"), + valign=Gtk.Align.CENTER) + self._dhcp.connect("state-set", self._on_mode) + auto.append(self._dhcp) + head.set_end_widget(auto) + self.append(head) + + addr, prefix = self._split(fam_data.get("addresses", [])) + self._addr = _field("Address", addr) + self._gw = _field("Gateway", fam_data.get("gateway", "")) + + self._fields = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self._fields.append(_labeled("Address", self._addr)) + if family == "ipv4": + self._guard = False + self._mask = _field("255.255.255.0", _prefix_to_mask(prefix) if prefix else "") + self._cidr = _field("/24", f"/{prefix}" if prefix else "") + self._cidr.set_size_request(70, -1) + self._mask.connect("changed", self._sync_from_mask) + self._cidr.connect("changed", self._sync_from_cidr) + mask_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + mask_row.append(self._mask) + mask_row.append(self._cidr) + self._fields.append(_labeled("Netmask", mask_row)) + else: + self._prefix = _field("64", str(prefix) if prefix else "") + self._fields.append(_labeled("Prefix", self._prefix)) + self._fields.append(_labeled("Gateway", self._gw)) + apply = Gtk.Button(label=f"Apply {title}") + apply.add_css_class("enable-btn") + apply.connect("clicked", lambda *_: self._apply()) + self._fields.append(apply) + self.append(self._fields) + + self._set_manual_enabled(not self._dhcp.get_active()) + + def _set_manual_enabled(self, on: bool) -> None: + # The reset GTK theme doesn't dim insensitive widgets, so fade the manual + # fields explicitly when DHCP/Automatic is active. + self._fields.set_sensitive(on) + self._fields.set_opacity(1.0 if on else 0.45) + + @staticmethod + def _split(addresses: list[str]) -> tuple[str, int | None]: + if not addresses: + return "", None + addr, _, prefix = addresses[0].partition("/") + try: + return addr, int(prefix) + except ValueError: + return addr, None + + def _on_mode(self, _sw, auto: bool) -> bool: + self._set_manual_enabled(not auto) + return False + + # keep the dotted mask and /CIDR fields in lock-step (IPv4 only) + def _sync_from_mask(self, _e) -> None: + if self._guard: + return + p = _mask_to_prefix(self._mask.get_text()) + if p is not None: + self._guard = True + self._cidr.set_text(f"/{p}") + self._guard = False + + def _sync_from_cidr(self, _e) -> None: + if self._guard: + return + raw = self._cidr.get_text().lstrip("/").strip() + if raw.isdigit() and 0 <= int(raw) <= 32: + self._guard = True + self._mask.set_text(_prefix_to_mask(int(raw))) + self._guard = False + + def _prefixlen(self) -> str: + if self.family == "ipv4": + raw = self._cidr.get_text().lstrip("/").strip() + if raw.isdigit(): + return raw + p = _mask_to_prefix(self._mask.get_text()) + return str(p) if p is not None else "" + return self._prefix.get_text().strip() + + def _apply(self) -> None: + if self._dhcp.get_active(): + args = ["set-ip", self.con, self.family, "auto"] + else: + addr, plen = self._addr.get_text().strip(), self._prefixlen() + cidr = f"{addr}/{plen}" if addr and plen else addr + args = ["set-ip", self.con, self.family, "manual", + cidr, self._gw.get_text().strip(), ""] + _nm(args, lambda ok, d: self._on_result( + ok and isinstance(d, dict) and d.get("ok"), + (d.get("error") if isinstance(d, dict) else "") or "")) + + +class _AdaptersPage(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self._status = Gtk.Label(xalign=0.0) + self._status.add_css_class("net-ip") + self._status.set_visible(False) + self.append(self._status) + self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(self._list) + self.append(scroller) + self._reload() + + def _reload(self) -> None: + _nm(["adapters"], self._populate) + + def _populate(self, ok: bool, adapters) -> None: + _clear(self._list) + if not ok or not isinstance(adapters, list) or not adapters: + self._list.append(Gtk.Label(label="No manageable adapters", xalign=0.0)) + return + for a in adapters: + self._list.append(self._adapter_row(a)) + + def _adapter_row(self, a: dict) -> Gtk.Widget: + icon = "" if a.get("type") == "wifi" else "" # nf wifi / ethernet + exp = Gtk.Expander( + label=f"{icon} {a['device']} · {a.get('connection') or '—'} [{a.get('state')}]") + exp.add_css_class("net-adapter") + body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + body.set_margin_top(8) + con = a.get("connection") + if not con: + body.append(Gtk.Label(label="No connection profile", xalign=0.0)) + else: + loading = Gtk.Label(label="Loading…", xalign=0.0) + body.append(loading) + _nm(["ipconfig", con], lambda ok, d, b=body, l=loading, c=con: + self._fill(ok, d, b, l, c)) + exp.set_child(body) + return exp + + def _fill(self, ok, data, body, loading, con) -> None: + body.remove(loading) + if not ok or not isinstance(data, dict): + body.append(Gtk.Label(label="Could not read config", xalign=0.0)) + return + result = Gtk.Label(xalign=0.0) + result.set_visible(False) + + def on_result(good: bool, err: str) -> None: + result.set_text("Applied" if good else f"Failed: {err or 'error'}") + result.set_visible(True) + + body.append(_IPSection(con, "ipv4", data.get("ipv4", {}), on_result)) + body.append(Gtk.Separator()) + body.append(_IPSection(con, "ipv6", data.get("ipv6", {}), on_result)) + body.append(result) + + +class _RoutesPage(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=4) + header = Gtk.CenterBox() + header.set_start_widget(Gtk.Label(label="Kernel routing table", xalign=0.0)) + header.set_end_widget(_pill(" Refresh", self._reload)) + self.append(header) + self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(self._list) + self.append(scroller) + self._reload() + + def _reload(self) -> None: + _nm(["routes"], self._populate) + + def _populate(self, ok: bool, routes) -> None: + _clear(self._list) + if not ok or not isinstance(routes, list) or not routes: + self._list.append(Gtk.Label(label="No routes", xalign=0.0)) + return + for r in routes: + self._list.append(Gtk.Label(label=self._fmt(r), xalign=0.0, + selectable=True, wrap=True)) + + @staticmethod + def _fmt(r: dict) -> str: + dst = r.get("dst", "?") + via = f" via {r['gateway']}" if r.get("gateway") else "" + dev = f" dev {r['dev']}" if r.get("dev") else "" + metric = f" metric {r['metric']}" if r.get("metric") is not None else "" + proto = f" ({r['proto']})" if r.get("proto") else "" + return f"{dst}{via}{dev}{metric}{proto}" + + +class _VlanPage(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self._adapters: list[dict] = [] + + add = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._parent = Gtk.DropDown.new_from_strings(["…"]) + self._vid = _field("VLAN ID") + self._vid.set_size_request(90, -1) + add.append(self._parent) + add.append(self._vid) + add.append(_pill("Add", self._add)) + self.append(_labeled("New VLAN", add)) + + self._status = Gtk.Label(xalign=0.0) + self._status.add_css_class("net-ip") + self._status.set_visible(False) + self.append(self._status) + + self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(self._list) + self.append(scroller) + + _nm(["adapters"], self._set_adapters) + self._reload() + + def _set_adapters(self, ok, adapters) -> None: + self._adapters = adapters if ok and isinstance(adapters, list) else [] + names = [a["device"] for a in self._adapters] or ["(no devices)"] + self._parent.set_model(Gtk.StringList.new(names)) + + def _reload(self) -> None: + _nm(["vlans"], self._populate) + + def _populate(self, ok, vlans) -> None: + _clear(self._list) + if not ok or not isinstance(vlans, list) or not vlans: + self._list.append(Gtk.Label(label="No VLANs configured", xalign=0.0)) + return + for v in vlans: + self._list.append(self._vlan_row(v)) + + def _vlan_row(self, v: dict) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.add_css_class("net-row") + state = "active" if v.get("active") else "inactive" + row.append(Gtk.Label(label=f"{v['name']} · {v.get('device') or '—'} [{state}]", + xalign=0.0, hexpand=True)) + if not v.get("active"): + row.append(_pill("Up", lambda n=v["name"]: self._act("vlan-up", n))) + row.append(_pill("Delete", lambda n=v["name"]: self._act("vlan-del", n))) + return row + + def _add(self) -> None: + idx = self._parent.get_selected() + vid = self._vid.get_text().strip() + if idx < 0 or idx >= len(self._adapters) or not vid.isdigit(): + self._flash("Pick a parent device and numeric VLAN ID") + return + parent = self._adapters[idx]["device"] + _nm(["vlan-add", parent, vid], self._after_write) + + def _act(self, cmd: str, name: str) -> None: + _nm([cmd, name], self._after_write) + + def _after_write(self, ok, d) -> None: + good = ok and isinstance(d, dict) and d.get("ok") + self._flash("Done" if good else + f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}") + self._reload() + + def _flash(self, msg: str) -> None: + self._status.set_text(msg) + self._status.set_visible(True) + + +class _DnsPage(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self._adapters: list[dict] = [] + + self.append(Gtk.Label(label="Effective servers", xalign=0.0)) + self._effective = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + self.append(self._effective) + + self.append(Gtk.Separator()) + self.append(Gtk.Label(label="Override", xalign=0.0)) + self._conn = Gtk.DropDown.new_from_strings(["…"]) + self.append(_labeled("Connection", self._conn)) + self._servers = _field("1.1.1.1 8.8.8.8") + self.append(_labeled("Servers", self._servers)) + ignore = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + ignore.append(Gtk.Label(label="Ignore automatic", hexpand=True, xalign=0.0)) + self._ignore = Gtk.Switch(active=True, valign=Gtk.Align.CENTER) + ignore.append(self._ignore) + self.append(ignore) + self.append(_pill("Apply DNS", self._apply)) + self._status = Gtk.Label(xalign=0.0) + self._status.add_css_class("net-ip") + self._status.set_visible(False) + self.append(self._status) + + _nm(["adapters"], self._set_adapters) + _nm(["dns"], self._show_effective) + + def _set_adapters(self, ok, adapters) -> None: + self._adapters = [a for a in (adapters or []) if a.get("connection")] \ + if ok and isinstance(adapters, list) else [] + names = [f"{a['connection']} ({a['device']})" for a in self._adapters] or ["(none)"] + self._conn.set_model(Gtk.StringList.new(names)) + + def _show_effective(self, ok, data) -> None: + _clear(self._effective) + if not ok or not isinstance(data, list) or not data: + self._effective.append(Gtk.Label(label="—", xalign=0.0)) + return + for d in data: + self._effective.append(Gtk.Label( + label=f"{d['device']}: {', '.join(d.get('servers', []))}", + xalign=0.0, selectable=True)) + + def _apply(self) -> None: + idx = self._conn.get_selected() + if idx < 0 or idx >= len(self._adapters): + self._flash("Pick a connection") + return + con = self._adapters[idx]["connection"] + servers = self._servers.get_text().strip() + ign = "yes" if self._ignore.get_active() else "no" + _nm(["set-dns", con, "ipv4", servers, ign], self._after) + + def _after(self, ok, d) -> None: + good = ok and isinstance(d, dict) and d.get("ok") + self._flash("Applied" if good else + f"Failed: {(d.get('error') if isinstance(d, dict) else '') or 'error'}") + _nm(["dns"], self._show_effective) + + def _flash(self, msg: str) -> None: + self._status.set_text(msg) + self._status.set_visible(True) + + +class _Expanded(Gtk.Box): + def __init__(self, ctx: ModuleContext): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add_css_class("net-view") + self.ctx = ctx + + self.stack = Gtk.Stack(vexpand=True) + switcher = Gtk.StackSwitcher(stack=self.stack) + switcher.add_css_class("net-switcher") + self.append(switcher) + self.append(self.stack) + + f = ctx.feature + if f("adapters", True): + self.stack.add_titled(_AdaptersPage(), "adapters", "Adapters") + if f("routes", True): + self.stack.add_titled(_RoutesPage(), "routes", "Routes") + if f("vlan", True): + self.stack.add_titled(_VlanPage(), "vlan", "VLAN") + if f("dns", True): + self.stack.add_titled(_DnsPage(), "dns", "DNS") + + +def build(ctx: ModuleContext) -> ModuleInstance: + # Feature toggles rebuild the whole card via QuadCard, so _Expanded is recreated + # with the enabled tabs — no in-place page rebuild needed here. + compact = _Compact(ctx) + expanded = _Expanded(ctx) + return ModuleInstance(compact=compact, expanded=expanded) + + +SPEC = ModuleSpec( + id="network", + title="Laser Antenna Uplink", + icon="", # nf-md-lan + build=build, + default_enabled=True, + features=[Feature("adapters", "Adapters", True), + Feature("routes", "Routes", True), + Feature("vlan", "VLAN", True), + Feature("dns", "DNS", True)], +) diff --git a/astro-menu/modules/sysmon.py b/astro-menu/modules/sysmon.py new file mode 100644 index 0000000..573d8e0 --- /dev/null +++ b/astro-menu/modules/sysmon.py @@ -0,0 +1,195 @@ +"""System-monitor quad ("Ship Systems"): the machine's vitals reskinned as a +starship diagnostic panel. A small backend script (backend/sysmon.sh) reports +usage + temperature for each subsystem, renamed in the ship's language: + + CPU -> Thrusters + GPU -> Hyperdrive + RAM -> Life Support Systems + Storage -> Cargo Hold (one entry per mounted drive) + +Each is drawn as a bracketed usage meter (-<████░░░░>-) with a temperature +readout (-( 54°C )-). Rows are laid out in a Gtk.Grid so every column lines up +regardless of the (variable-width) nerd-font icons. The compact cell shows the +four headline subsystems (first drive only); the expanded view lists every drive +with its capacity. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +from lib.proc import run_text +from module_base import ModuleContext, ModuleInstance, ModuleSpec +from paths import BACKEND_DIR + +_SCRIPT = str(BACKEND_DIR / "sysmon.sh") +_MONO = "Agave Nerd Font Mono" + +# palette (kept in sync by eye with style/_colors.css / the hologram violets) +_MAGENTA = "#EB00A6" +_ACCENT = "#E40046" +_DIM_EMPTY = "#3B2A6E" # unfilled meter cells +_DIM_TEXT = "#9385C9" # detail line +_COOL = "#22D3EE" +_WARM = "#F5A623" +_HOT = "#E40046" +_GOOD = "#2BE08A" + +# category -> nerd-font glyph (verified to render in Agave Nerd Font) +_ICONS = { + "cpu": chr(0xf135), # nf-fa-rocket + "gpu": chr(0xf0e7), # nf-fa-bolt + "ram": chr(0xf004), # nf-fa-heart + "disk": chr(0xf0a0), # nf-fa-hdd_o +} + + +def _usage_color(pct: int) -> str: + if pct >= 85: + return _HOT + if pct >= 60: + return _WARM + return _GOOD + + +def _temp_color(temp: int) -> str: + if temp >= 85: + return _HOT + if temp >= 65: + return _WARM + return _COOL + + +def _esc(text: str) -> str: + return GLib.markup_escape_text(text) + + +class _SystemMonitor(Gtk.Box): + REFRESH_MS = 2500 + + def __init__(self, ctx: ModuleContext, compact: bool): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.ctx = ctx + self.compact = compact + self.add_css_class("sysmon-view") + self.set_valign(Gtk.Align.CENTER) + self._size = 10 if compact else 12 + self._bar_w = 9 if compact else 16 + self._timer: int | None = None + + self._grid = Gtk.Grid() + self._grid.set_row_spacing(3 if compact else 7) + self._grid.set_column_spacing(12) + self.append(self._grid) + + # -- lifecycle --------------------------------------------------------- + def start(self) -> None: + self.refresh() + if self._timer is None: + self._timer = GLib.timeout_add(self.REFRESH_MS, self._tick) + + def stop(self) -> None: + if self._timer is not None: + GLib.source_remove(self._timer) + self._timer = None + + def _tick(self) -> bool: + self.refresh() + return True + + def refresh(self) -> None: + run_text([_SCRIPT], self._on_result) + + def _on_result(self, ok: bool, out: str, _err: str) -> None: + if not ok: + return + rows = [] + for line in out.splitlines(): + parts = line.split("\t") + if len(parts) < 5: + continue + rows.append(dict(zip(("cat", "name", "usage", "temp", "detail"), parts))) + if self.compact: + # headline subsystems + only the first (root) drive + trimmed, seen_disk = [], False + for r in rows: + if r["cat"] == "disk": + if seen_disk: + continue + seen_disk = True + trimmed.append(r) + rows = trimmed + self._render(rows) + + # -- rendering --------------------------------------------------------- + def _clear(self) -> None: + child = self._grid.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + self._grid.remove(child) + child = nxt + + def _span(self, markup: str, xalign: float = 0.0) -> Gtk.Label: + lbl = Gtk.Label(xalign=xalign) + lbl.add_css_class("sysmon-row") + lbl.set_markup(f"{markup}") + return lbl + + def _render(self, rows: list[dict]) -> None: + self._clear() + for i, r in enumerate(rows): + try: + usage = int(r["usage"]) + except ValueError: + usage = 0 + fill_col = _usage_color(usage) + filled = max(0, min(self._bar_w, round(usage / 100 * self._bar_w))) + bar = (f"{'█' * filled}" + f"{'░' * (self._bar_w - filled)}") + meter = (f"-<{bar}" + f">-") + try: + temp = int(r["temp"]) + temp_txt, temp_col = f"{temp}°C", _temp_color(temp) + except ValueError: + temp_txt, temp_col = "--°C", _DIM_TEXT + temp_field = (f"-( " + f"{temp_txt:>5} " + f")-") + + icon = _ICONS.get(r["cat"], "") + self._grid.attach(self._span(f"{icon}"), 0, i, 1, 1) + self._grid.attach(self._span(f"{_esc(r['name'])}"), 1, i, 1, 1) + self._grid.attach(self._span(meter), 2, i, 1, 1) + self._grid.attach(self._span(f"{usage:>3}%", 1.0), 3, i, 1, 1) + self._grid.attach(self._span(temp_field), 4, i, 1, 1) + if not self.compact: + self._grid.attach( + self._span(f"{_esc(r['detail'])}"), 5, i, 1, 1) + + +def build(ctx: ModuleContext) -> ModuleInstance: + compact = _SystemMonitor(ctx, compact=True) + expanded = _SystemMonitor(ctx, compact=False) + + def on_show() -> None: + compact.start() + expanded.start() + + def on_hide() -> None: + compact.stop() + expanded.stop() + + return ModuleInstance(compact=compact, expanded=expanded, + scroll_expanded=True, on_show=on_show, on_hide=on_hide) + + +SPEC = ModuleSpec( + id="sysmon", + title="Ship Systems", + icon=chr(0xf085), # nf-fa-cogs + build=build, +) diff --git a/astro-menu/modules/weather.py b/astro-menu/modules/weather.py new file mode 100644 index 0000000..9862a7d --- /dev/null +++ b/astro-menu/modules/weather.py @@ -0,0 +1,78 @@ +"""Weather quad: wttr.in rendered with its original ANSI/CLI art (via AnsiRenderer). + +Reuses the shared LocationService for the city; if none is known yet, wttr.in +geolocates the caller's IP itself, so the widget still works standalone. The +'ascii_art' feature toggle swaps the art for a compact one-line text summary. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Pango", "1.0") +from gi.repository import Gtk, Pango # noqa: E402 + +from lib.ansi import AnsiRenderer +from lib.proc import run_text +from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec +from paths import BACKEND_DIR + +_SCRIPT = str(BACKEND_DIR / "weather.sh") + + +class _WeatherView(Gtk.Box): + def __init__(self, ctx: ModuleContext, opts: str): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("weather-view") + self.ctx = ctx + self.opts = opts + self._loc = "" + + headline = Gtk.Label(label="Planetary Environment Report", xalign=0.0) + headline.add_css_class("weather-headline") + headline.set_ellipsize(Pango.EllipsizeMode.END) + self.append(headline) + + self.renderer = AnsiRenderer() + self.append(self.renderer.view) + self._status = Gtk.Label(label="Loading weather…") + self._status.add_css_class("weather-status") + self.append(self._status) + + ctx.services.location.subscribe(self._on_location) + self.refresh() + + def _on_location(self, data: dict) -> None: + self._loc = data.get("city") or "" + self.refresh() + + def refresh(self) -> None: + art = self.ctx.feature("ascii_art", True) + opts = self.opts if art else "format=%l:+%c+%t,+%w" + run_text([_SCRIPT, self._loc, opts], self._on_result) + + def _on_result(self, ok: bool, out: str, err: str) -> None: + if ok and out.strip() and " ModuleInstance: + # The ascii_art toggle rebuilds the whole card via QuadCard, so both views are + # recreated and re-fetch with the current format — no partial refresh here. + compact = _WeatherView(ctx, opts="0") # current conditions only + expanded = _WeatherView(ctx, opts="") # full 3-day forecast + return ModuleInstance(compact=compact, expanded=expanded) + + +SPEC = ModuleSpec( + id="weather", + title="Weather", + icon="", # nf-weather + build=build, + features=[Feature("ascii_art", "CLI art", True)], +) diff --git a/astro-menu/paths.py b/astro-menu/paths.py new file mode 100644 index 0000000..fdce17d --- /dev/null +++ b/astro-menu/paths.py @@ -0,0 +1,28 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +BACKEND_DIR = BASE_DIR / "backend" +STYLE_DIR = BASE_DIR / "style" + +CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "astro-menu" +CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astro-menu" +# User settings live under XDG_STATE_HOME, NOT CONFIG_DIR: the config-updater does +# `rm -rf ~/.config/astro-menu` on every deploy, which would wipe pinned favourites +# and quad toggles. The state dir is never touched by config sync. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "astro-menu" + +SETTINGS_FILE = STATE_DIR / "settings.json" +CONFIG_FILE = STATE_DIR / "config.json" + +APP_ID = "eu.abdelbaki.astromenu" + + +def ensure_dirs() -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CACHE_DIR.mkdir(parents=True, exist_ok=True) + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/astro-menu/registry.py b/astro-menu/registry.py new file mode 100644 index 0000000..51ac023 --- /dev/null +++ b/astro-menu/registry.py @@ -0,0 +1,29 @@ +"""The module registry — the single place that lists available quads in order. + +To add a new module: create modules/.py exposing a top-level `SPEC` +(ModuleSpec), then import it and append its SPEC here. The first four *enabled* +specs fill the 2x2 grid; extra specs are kept for future paginated layouts. +""" + +from __future__ import annotations + +from modules import bluetooth, network, sysmon, weather +from module_base import ModuleSpec + +ALL_SPECS: list[ModuleSpec] = [ + sysmon.SPEC, + weather.SPEC, + bluetooth.SPEC, + network.SPEC, +] + + +def ordered_specs(settings) -> list[ModuleSpec]: + """Return specs in the user's saved order, unknown/new ones appended.""" + order = settings.order() + if not order: + return list(ALL_SPECS) + by_id = {s.id: s for s in ALL_SPECS} + result = [by_id[i] for i in order if i in by_id] + result += [s for s in ALL_SPECS if s.id not in order] + return result diff --git a/astro-menu/services/location.py b/astro-menu/services/location.py new file mode 100644 index 0000000..88735df --- /dev/null +++ b/astro-menu/services/location.py @@ -0,0 +1,55 @@ +"""Shared geolocation singleton. + +Runs backend/geolocate.py (IP geolocation, cached) exactly once and notifies +subscribers. Both the Location map and the Weather widget subscribe, so there is a +single source of truth for "where am I" and only one network lookup. +""" + +from __future__ import annotations + +import sys +from typing import Callable, Optional + +from lib.proc import run_json +from paths import BACKEND_DIR + + +class LocationService: + def __init__(self) -> None: + self.data: Optional[dict] = None + self._subs: list[Callable[[dict], None]] = [] + self._inflight = False + + def subscribe(self, cb: Callable[[dict], None]) -> None: + self._subs.append(cb) + if self.data is not None: + cb(self.data) + + def get(self) -> Optional[dict]: + if self.data is None and not self._inflight: + self.refresh() + return self.data + + def refresh(self) -> None: + if self._inflight: + return + self._inflight = True + argv = [sys.executable, str(BACKEND_DIR / "geolocate.py")] + run_json(argv, self._on_result) + + def _on_result(self, ok: bool, data) -> None: + self._inflight = False + if ok and isinstance(data, dict) and "lat" in data: + self.data = data + for cb in list(self._subs): + cb(data) + + +_instance: LocationService | None = None + + +def get_location_service() -> LocationService: + global _instance + if _instance is None: + _instance = LocationService() + return _instance diff --git a/astro-menu/settings.py b/astro-menu/settings.py new file mode 100644 index 0000000..ff564c8 --- /dev/null +++ b/astro-menu/settings.py @@ -0,0 +1,83 @@ +"""Persisted user settings: which quads are enabled, per-module feature toggles, +and quad ordering. Single JSON file, read at startup, written on every change. + +Modules never import this directly for their own feature flags; they receive a +scoped view via ctx.feature(...) so the persistence format stays centralised. +""" + +from __future__ import annotations + +import json +from typing import Callable + +from paths import CONFIG_DIR, SETTINGS_FILE, ensure_dirs + + +class Settings: + def __init__(self) -> None: + self._data: dict = {"quads": {}, "features": {}, "order": None, "favorites": []} + self._listeners: list[Callable[[], None]] = [] + self.load() + + # -- persistence ------------------------------------------------------- + def load(self) -> None: + path = SETTINGS_FILE + if not path.exists(): + # one-time migration from the old CONFIG_DIR location (pre state-dir) + legacy = CONFIG_DIR / "settings.json" + if legacy.exists(): + path = legacy + try: + self._data.update(json.loads(path.read_text())) + except (FileNotFoundError, json.JSONDecodeError): + pass + + def save(self) -> None: + ensure_dirs() + SETTINGS_FILE.write_text(json.dumps(self._data, indent=2)) + for cb in list(self._listeners): + cb() + + def subscribe(self, cb: Callable[[], None]) -> None: + self._listeners.append(cb) + + # -- quad enable/disable ---------------------------------------------- + def quad_enabled(self, module_id: str, default: bool = True) -> bool: + return bool(self._data["quads"].get(module_id, default)) + + def set_quad_enabled(self, module_id: str, value: bool) -> None: + self._data["quads"][module_id] = bool(value) + self.save() + + # -- per-module feature toggles --------------------------------------- + def feature(self, module_id: str, feature_id: str, default: bool = True) -> bool: + return bool(self._data["features"].get(module_id, {}).get(feature_id, default)) + + def set_feature(self, module_id: str, feature_id: str, value: bool) -> None: + self._data["features"].setdefault(module_id, {})[feature_id] = bool(value) + self.save() + + # -- favorites --------------------------------------------------------- + def favorites(self) -> list[str]: + return list(self._data.get("favorites", [])) + + def is_favorite(self, entry: str) -> bool: + return entry in self._data.get("favorites", []) + + def toggle_favorite(self, entry: str) -> None: + if not entry: + return + favs = self._data.setdefault("favorites", []) + if entry in favs: + favs.remove(entry) + else: + favs.append(entry) + self.save() + + # -- ordering ---------------------------------------------------------- + def order(self) -> list[str] | None: + return self._data.get("order") + + def set_order(self, order: list[str]) -> None: + self._data["order"] = order + self.save() diff --git a/astro-menu/style/_colors.css b/astro-menu/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/astro-menu/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/astro-menu/style/style.css b/astro-menu/style/style.css new file mode 100644 index 0000000..aea11e4 --- /dev/null +++ b/astro-menu/style/style.css @@ -0,0 +1,368 @@ +/* astro-menu — "hologram info display" theme. Colours come from _colors.css + * (@text/@bg/@accent/@violet/@danger). Mirrors the existing bar idiom: Agave + * Nerd Font Mono, 3px borders, ~25px pill radii. Touch-friendly hit targets + * throughout. Module card borders get a Cairo-drawn glow (lib/border.py); + * this file adds matching CSS box-shadow glow + smooth transitions on every + * interactive element, and a scanline/sweep/noise hologram overlay (see + * .astro-hologram, painted by window.py) so the whole panel reads as one + * continuous piece with orbit-menu and horizon-dock. */ + +/* Text colour override: bright magenta reads far better than the default dusty + * @text (#D6ABAB) now that the panel is transparent + blurred glass. @accent + * (red) stays the accent/alt colour. This @define-color is added after + * _colors.css by theme.py (same USER+1 priority, later wins) and only affects + * this process's widgets, so other Cosmonaut Shell apps keep their @text. */ +@define-color text #EB00A6; + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 13pt; +} + +/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }`, which + * otherwise paints EVERY node (every Gtk.Box, label, centerbox, …) an opaque + * dark slab behind the floating modules. Enumerating node types by name is + * fragile (it missed plain `box`), so blank them all here — this provider sits + * at USER+1, above the theme. Interactive elements re-assert their own fills + * via the higher-specificity .class rules below; module cards are Cairo-drawn + * (lib/border.py, fill_bg=True), not CSS, so they are unaffected. */ +* { + background-color: transparent; +} + +/* ---- window / letterbox --------------------------------------------- */ +/* The CyberQueer GTK theme paints `* { background-color: #1a1a1a }` on EVERY node, + * which fills the gaps between modules with a solid slab. Each module carries its + * own drawn (Cairo) card background via bordered(fill_bg=True), so blank the + * structural container nodes here to let the desktop show between the floating + * modules. `drawingarea` is the bordered() ring canvas — transparent so only its + * Cairo-drawn rounded fill shows, not a full-bleed square. */ +window, +window.background, +.menu-window, +#menu-window, +.panel, +#panel-root, +overlay, +revealer, +grid, +scrolledwindow, +viewport, +drawingarea { + background: transparent; + background-color: transparent; +} + +#backdrop { + background: alpha(black, 0.25); +} + +/* Letterbox margins are set in code (window.py) as a fraction of the monitor so + * the inset scales per display; this box just carries the panel background. */ + +/* ---- quad grid + cards ---------------------------------------------- */ +.quad-grid.dimmed { + opacity: 0.15; +} + +/* bouncy pop when a quad expands (added in ui/quadgrid.py on expand) */ +@keyframes quad-pop { + 0% { transform: scale(0.82); opacity: 0.4; } + 55% { transform: scale(1.05); opacity: 1; } + 78% { transform: scale(0.985); } + 100% { transform: scale(1.0); } +} +.quad-pop { animation: quad-pop 300ms ease-out; } + +/* bouncy pop-expand for the app drawer (added in ui/appdrawer.py on expand) */ +@keyframes drawer-pop { + 0% { transform: scale(0.90); opacity: 0.45; } + 60% { transform: scale(1.025); opacity: 1; } + 82% { transform: scale(0.995); } + 100% { transform: scale(1.0); } +} +.drawer-pop { animation: drawer-pop 280ms ease-out; } + +.quad-card { min-height: 100px; } +.section-title { color: @text; font-weight: bold; opacity: 0.85; } + +/* The single module border + background is drawn with Cairo by bordered(fill_bg=True) + * (see lib/border.py); the CSS border/background here would render a second, + * concentric ring now that the app stylesheet sits above the theme, so only keep + * the inner content padding. */ +.quad-card, +.quad-expanded, +.appdrawer, +.taskbar, +.favorites { + padding: 8px 12px; +} + +.quad-header, +.appdrawer-header, +.expanded-header { + margin-bottom: 8px; +} + +.quad-title { color: @text; font-weight: bold; } +.quad-icon { color: @accent; font-size: 15pt; } + +/* minimalist system-stats line (top of the panel) */ +.statsbar { padding: 19px 8px; } +.statsbar .stat-icon { color: @accent; font-size: 11pt; } +.statsbar .stat-value { color: @text; font-size: 10pt; } + +.quad-body { color: @text; } + +/* weather "Planetary Environment Report" headline */ +.weather-headline { + color: @text; + font-weight: 700; + font-size: 12pt; + letter-spacing: 1px; + margin: 0 0 6px 2px; +} + +/* Ship Systems (system monitor): the row labels carry their own inline Pango + * markup (monospace, colour-coded meters), so this only spaces them out. */ +.sysmon-view { margin: 4px 6px; } +.sysmon-row { margin: 1px 0; } + +/* pill action buttons */ +.quad-action, +.enable-btn { + color: @text; + background: alpha(@violet, 0.4); + border: 3px solid @violet; + border-radius: 25px; + padding: 1px 14px; /* tight vertically so pills don't waste vertical space */ + min-height: 22px; + min-width: 22px; + transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.quad-action:hover, +.enable-btn:hover { + border-color: @accent; + color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.55); +} +.quad-action:active, +button:checked.quad-action { background: @accent; color: @bg; border-color: @accent; } + +/* Columns count stepper value (between the − / + pills) */ +.stepper-value { + min-width: 34px; + font-weight: bold; + color: @accent; + font-feature-settings: "tnum"; +} + +/* A MenuButton (the settings cog) wraps an inner `button` node that would render a + * second ring inside the outer .quad-action pill — flatten it so only one border + * shows. */ +.quad-action > button { + border: none; + background: transparent; + padding: 0; + min-width: 0; + min-height: 0; +} + +.quad-disabled { color: @text; opacity: 0.7; } +.enable-btn { border-color: @accent; } + +/* settings popover */ +.quad-settings { background: alpha(@violet, 0.4); border: 3px solid @violet; border-radius: 16px; padding: 10px; } +.switch-row { min-height: 36px; } + +.expanded-header .quad-title { font-size: 14pt; } + +/* ---- appdrawer ------------------------------------------------------ */ +.appdrawer-search { + border: 3px solid @violet; + border-radius: 25px; + padding: 8px 14px; + margin-bottom: 8px; + color: @text; + background: alpha(@violet, 0.4); + transition: border-color 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.appdrawer-search:focus-within { + border-color: @accent; + box-shadow: 0 0 14px 1px alpha(@accent, 0.45); +} + +.app-tile { + background: transparent; + border: 2px solid transparent; + border-radius: 16px; + padding: 10px 6px; + min-width: 92px; + transition: border-color 180ms ease, background 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.app-tile:hover { + border-color: @accent; + background: alpha(@violet, 0.18); + box-shadow: 0 0 14px 1px alpha(@accent, 0.4); +} +.app-tile label { color: @text; font-size: 10pt; } +.fav-star { color: @accent; font-size: 13pt; margin: 2px 4px; } + +/* ---- favourites row (top of drawer) --------------------------------- */ +.favorites-row { padding: 4px 0; } +.fav-tile { + background: alpha(@violet, 0.4); + border: 2px solid @violet; + border-radius: 16px; + padding: 6px 12px; + min-height: 40px; + transition: border-color 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@violet, 0); +} +.fav-tile:hover { + border-color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.5); +} +.fav-tile label { color: @text; font-size: 11pt; } + +/* ---- taskbar (open windows) ----------------------------------------- */ +.taskbar-row { padding: 4px 0; } +.task-tile { + background: transparent; + border: 2px solid transparent; + border-radius: 14px; + padding: 6px 10px; + min-height: 44px; + min-width: 44px; + transition: border-color 180ms ease, background 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.task-tile:hover { + border-color: @accent; + background: alpha(@violet, 0.18); + box-shadow: 0 0 14px 1px alpha(@accent, 0.4); +} +.task-badge { + color: @bg; background: @accent; + border-radius: 10px; padding: 0 6px; font-size: 9pt; +} +.task-popover { background: alpha(@violet, 0.4); border: 3px solid @violet; border-radius: 14px; padding: 6px; } +.task-window { color: @text; background: transparent; border-radius: 10px; padding: 6px 12px; } +.task-window:hover { color: @accent; } + +/* pop-open panel: layout controls + per-window rows */ +.task-panel { padding: 4px 2px; } +.layout-page { padding: 6px 4px 2px 4px; } +.task-row { padding: 2px 4px; border-radius: 10px; min-height: 30px; } +.task-row:hover { background: alpha(@violet, 0.12); } +.task-name { background: transparent; border: none; padding: 2px 6px; min-height: 26px; } +.task-name:hover { color: @accent; } +.task-name label { color: @text; } + +/* ---- location map --------------------------------------------------- */ +.map-view { border-radius: 14px; } +.map-info { color: @text; padding: 6px 2px 0 2px; font-size: 11pt; } +.map-marker { color: @accent; } + +/* ---- weather -------------------------------------------------------- */ +/* Transparent, not a translucent slab: its own alpha(@violet) fill stacked on + * top of the module's Cairo card fill, reading as a brighter square with hard + * corners. Let the card fill show through instead. */ +.ansi-view, +.ansi-view text { + background: transparent; + background-color: transparent; + color: @text; + font-family: "Agave Nerd Font Mono", monospace; + font-size: 11pt; + padding: 4px; +} +.weather-status { color: @text; opacity: 0.7; } + +/* ---- bluetooth / network shared rows -------------------------------- */ +.bt-row, +.net-row { + padding: 4px 6px; + border-radius: 12px; + min-height: 30px; + transition: background 180ms ease; +} +.bt-row:hover, +.net-row:hover { background: alpha(@violet, 0.15); } +.bt-status { color: @accent; font-size: 10pt; } +.bt-status.bt-failed { color: @danger; } +.bt-history label, +.net-ip { color: @text; opacity: 0.85; font-size: 11pt; } + +.net-switcher { margin-bottom: 8px; } +.net-adapter { padding: 4px 0; } +.net-adapter > box { padding-left: 6px; } +.net-entry { + border: 2px solid @violet; + border-radius: 12px; + padding: 6px 10px; + color: @text; + background: alpha(@violet, 0.4); + transition: border-color 180ms ease, box-shadow 220ms ease; + box-shadow: 0 0 0 0 alpha(@accent, 0); +} +.net-entry:focus-within { + border-color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.45); +} +.pubip { color: @accent; font-size: 15pt; } + +/* switches: fixed pill with a bordered round knob (the reset theme leaves them + * shapeless otherwise) */ +switch { + min-width: 48px; + min-height: 26px; + border: 2px solid @violet; + border-radius: 15px; + background: alpha(@violet, 0.4); + padding: 0; + transition: background 180ms ease, border-color 180ms ease, box-shadow 220ms ease; +} +switch:checked { + background: @accent; + border-color: @accent; + box-shadow: 0 0 10px 1px alpha(@accent, 0.5); +} +switch > slider { + min-width: 18px; + min-height: 18px; + margin: 2px; + border-radius: 50%; + border: 1px solid @violet; + background: @text; +} +switch:checked > slider { border-color: @accent; } +scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; } +scrollbar slider:hover { background: @accent; } + +/* module frames — Gtk.Frame paints borders reliably where boxes do not */ +.module-frame { border: 3px solid @accent; border-radius: 16px; background: alpha(@violet, 0.4); } +.module-frame > box { padding: 10px 12px; } + +/* floating panel + close button */ +.panel { padding: 2px; } +.close-btn { + color: @text; background: alpha(@violet, 0.4); + border: none; border-radius: 20px; + min-width: 34px; min-height: 34px; + margin: 16px 20px; /* keep it clear of the module's drawn border */ + transition: background 180ms ease, color 180ms ease, box-shadow 220ms ease; +} +.close-btn:hover { + background: @accent; + color: @bg; + box-shadow: 0 0 14px 2px alpha(@accent, 0.55); +} + +/* ---- holographic overlay (see window.py's Gtk.Overlay + _draw_hologram) --- */ +.astro-hologram { + background: transparent; +} diff --git a/astro-menu/theme.py b/astro-menu/theme.py new file mode 100644 index 0000000..6b732a5 --- /dev/null +++ b/astro-menu/theme.py @@ -0,0 +1,35 @@ +"""Load the two stylesheets as ordered CSS providers. + +_colors.css (generated from ~/Dotfiles/colors.conf by apply-theme.sh) defines the +five CyberQueer @define-color names; style.css consumes them. They are loaded as +two separate providers rather than via @import, because GTK4 resolves @import +paths unreliably. + +Priority is USER+1, not APPLICATION: the CyberQueer GTK theme is installed as +~/.config/gtk-4.0/gtk.css (a symlink), which GTK4 loads at PRIORITY_USER (800) — +above APPLICATION (600). Its aggressive `* { background-color: #1a1a1a }` would +otherwise beat our rules (e.g. the transparent structural containers that let the +modules float), so we must sit just above the user-level theme. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/astro-menu/ui/appdrawer.py b/astro-menu/ui/appdrawer.py new file mode 100644 index 0000000..b23b0fb --- /dev/null +++ b/astro-menu/ui/appdrawer.py @@ -0,0 +1,175 @@ +"""Section 5: the full-width application drawer that replaces nwg-drawer. + +Top to bottom: a favourites row (full-width module), then search, then a FlowBox of +all apps. Collapsed it is a bottom strip; expanded it fills down to the bottom. +Right-click / long-press an app tile to pin or unpin it from favourites. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("AstalApps", "0.1") +from gi.repository import AstalApps, GLib, Gtk # noqa: E402 + +from ui.favorites import Favorites, add_pin_gestures + +_STRIP_HEIGHT = 150 # collapsed grid height (logical px) + + +class AppDrawer(Gtk.Box): + def __init__(self, settings, on_launch, on_toggle_expand): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.add_css_class("appdrawer") + self.settings = settings + self._on_launch = on_launch + self._on_toggle_expand = on_toggle_expand + self._apps = AstalApps.Apps() + self._expanded = False + + self.favorites = Favorites(settings, on_launch) + self.append(self.favorites) + self.append(Gtk.Separator()) + + self.append(self._build_header()) + + self._search = Gtk.SearchEntry(placeholder_text="Search applications") + self._search.add_css_class("appdrawer-search") + self._search.connect("search-changed", self._on_search) + self.append(self._search) + + self._flow = Gtk.FlowBox( + selection_mode=Gtk.SelectionMode.NONE, homogeneous=True, + min_children_per_line=4, max_children_per_line=12, + row_spacing=8, column_spacing=8, + valign=Gtk.Align.START) # keep rows their natural height (no stretch) + self._flow.add_css_class("appdrawer-flow") + self._flow.connect("child-activated", self._on_child_activated) + + self._scroll = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) + self._scroll.set_propagate_natural_height(False) + self._scroll.set_child(self._flow) + self.append(self._scroll) + + self._apply_mode() + self._populate(self._sorted_all()) + # re-render tiles when favourites change so the pinned star stays in sync + settings.subscribe(self._on_settings_changed) + + def _on_settings_changed(self) -> None: + # Deferred so we never rebuild the FlowBox from inside a tile's own gesture. + GLib.idle_add(lambda: (self._on_search(self._search), False)[1]) + + def _build_header(self) -> Gtk.Widget: + header = Gtk.CenterBox() + header.add_css_class("appdrawer-header") + title = Gtk.Label(label="Applications", xalign=0.0) + title.add_css_class("section-title") + header.set_start_widget(title) + self._expand_btn = Gtk.Button(label="") # nf-fa-expand (uniform with quad expand) + self._expand_btn.add_css_class("quad-action") + self._expand_btn.set_tooltip_text("Expand") + self._expand_btn.connect("clicked", lambda *_: self._toggle_expand()) + header.set_end_widget(self._expand_btn) + return header + + # -- expansion --------------------------------------------------------- + def _apply_mode(self) -> None: + if self._expanded: + self.set_vexpand(True) + self._scroll.set_min_content_height(_STRIP_HEIGHT) + self._scroll.set_max_content_height(100000) + self._expand_btn.set_label("") # nf-fa-compress + else: + self.set_vexpand(False) + self._scroll.set_min_content_height(_STRIP_HEIGHT) + self._scroll.set_max_content_height(_STRIP_HEIGHT) + self._expand_btn.set_label("") # nf-fa-expand + + def _pop(self) -> None: + # bouncy scale pop on the app grid when the drawer expands; cleared after the + # animation so a re-layout (tiles loading) can't restart it into a loop. + self._scroll.add_css_class("drawer-pop") + GLib.timeout_add(320, self._clear_pop) + + def _clear_pop(self) -> bool: + self._scroll.remove_css_class("drawer-pop") + return GLib.SOURCE_REMOVE + + def _toggle_expand(self) -> None: + self._expanded = not self._expanded + self._apply_mode() + if self._expanded: + self._pop() + self._on_toggle_expand(self._expanded) + + def set_expanded(self, value: bool) -> None: + if value != self._expanded: + self._toggle_expand() + + # -- data -------------------------------------------------------------- + def _sorted_all(self) -> list: + apps = list(self._apps.get_list()) + apps.sort(key=lambda a: (-a.get_frequency(), a.get_name().lower())) + return apps + + def _on_search(self, entry: Gtk.SearchEntry) -> None: + text = entry.get_text().strip() + results = self._apps.fuzzy_query(text) if text else self._sorted_all() + self._populate(results) + + def _populate(self, apps: list) -> None: + child = self._flow.get_first_child() + while child: + self._flow.remove(child) + child = self._flow.get_first_child() + for app in apps: + self._flow.append(self._app_button(app)) + + def _app_button(self, app) -> Gtk.Widget: + btn = Gtk.Button(valign=Gtk.Align.START) + btn.add_css_class("app-tile") + btn.app = app + overlay = Gtk.Overlay() + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable") + icon.set_pixel_size(48) + label = Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=12, + justify=Gtk.Justification.CENTER) + content.append(icon) + content.append(label) + overlay.set_child(content) + # a star marks pinned apps (visible only when favourited) + star = Gtk.Label(label="★") + star.add_css_class("fav-star") + star.set_halign(Gtk.Align.END) + star.set_valign(Gtk.Align.START) + star.set_visible(self.settings.is_favorite(app.get_entry())) + overlay.add_overlay(star) + btn.set_child(overlay) + btn.connect("clicked", lambda *_: self._launch(app)) + add_pin_gestures(btn, lambda: self._toggle_fav(app)) + return btn + + def _toggle_fav(self, app) -> None: + self.settings.toggle_favorite(app.get_entry()) + + def _on_child_activated(self, _flow, child) -> None: + btn = child.get_child() + if btn and getattr(btn, "app", None): + self._launch(btn.app) + + def _launch(self, app) -> None: + try: + app.launch() + except Exception: + pass + self._on_launch() + + def on_show(self) -> None: + self._search.set_text("") + self._populate(self._sorted_all()) + self.favorites.refresh() + self._search.grab_focus() diff --git a/astro-menu/ui/favorites.py b/astro-menu/ui/favorites.py new file mode 100644 index 0000000..d303adc --- /dev/null +++ b/astro-menu/ui/favorites.py @@ -0,0 +1,106 @@ +"""Full-width favourites row at the top of the app drawer. + +Shows pinned apps (settings["favorites"], a list of .desktop entry ids). If nothing +is pinned yet it falls back to the most-frequently-launched apps. Pin/unpin from the +drawer grid via right-click / long-press. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("AstalApps", "0.1") +from gi.repository import AstalApps, Gtk # noqa: E402 + + +def add_pin_gestures(widget: Gtk.Widget, on_toggle) -> None: + """Wire right-click and long-press on `widget` to pin/unpin. + + Uses the CAPTURE phase and claims the sequence so the gesture fires reliably on a + Gtk.Button (whose own primary-click gesture would otherwise swallow it) and does + not also trigger the button's launch action.""" + def fire(gesture, *_a) -> None: + on_toggle() + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + + rclick = Gtk.GestureClick(button=3) + rclick.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + rclick.connect("pressed", fire) + widget.add_controller(rclick) + + longpress = Gtk.GestureLongPress() + longpress.set_touch_only(False) + longpress.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + longpress.connect("pressed", fire) + widget.add_controller(longpress) + + +class Favorites(Gtk.Box): + def __init__(self, settings, on_launch): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("favorites") + self.settings = settings + self._on_launch = on_launch + self._apps = AstalApps.Apps() + + title = Gtk.Label(label="Favorites", xalign=0.0) + title.add_css_class("section-title") + self.append(title) + + self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self._row.add_css_class("favorites-row") + scroller = Gtk.ScrolledWindow( + vscrollbar_policy=Gtk.PolicyType.NEVER, + hscrollbar_policy=Gtk.PolicyType.AUTOMATIC) + scroller.set_child(self._row) + self.append(scroller) + + settings.subscribe(self.refresh) + self.refresh() + + def _by_entry(self) -> dict: + return {a.get_entry(): a for a in self._apps.get_list() if a.get_entry()} + + def _resolve(self) -> list: + by_entry = self._by_entry() + entries = self.settings.favorites() + if entries: + return [by_entry[e] for e in entries if e in by_entry] + # fallback: most-used apps + apps = sorted(self._apps.get_list(), key=lambda a: -a.get_frequency()) + return [a for a in apps if a.get_frequency() > 0][:8] + + def refresh(self) -> None: + child = self._row.get_first_child() + while child: + self._row.remove(child) + child = self._row.get_first_child() + apps = self._resolve() + if not apps: + self._row.append(Gtk.Label(label="Right-click an app below to pin it", + xalign=0.0)) + return + for app in apps: + self._row.append(self._tile(app)) + + def _tile(self, app) -> Gtk.Widget: + btn = Gtk.Button() + btn.add_css_class("fav-tile") + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable") + icon.set_pixel_size(28) + content.append(icon) + content.append(Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=14)) + btn.set_child(content) + btn.connect("clicked", lambda *_a: self._launch(app)) + # right-click / long-press unpins + add_pin_gestures(btn, lambda: self.settings.toggle_favorite(app.get_entry())) + return btn + + def _launch(self, app) -> None: + try: + app.launch() + except Exception: + pass + self._on_launch() diff --git a/astro-menu/ui/quadcard.py b/astro-menu/ui/quadcard.py new file mode 100644 index 0000000..93b1eb8 --- /dev/null +++ b/astro-menu/ui/quadcard.py @@ -0,0 +1,158 @@ +"""Generic chrome around any module: a header (icon, title, expand, settings) and +a body that is either the module's compact widget or an 'enable me' placeholder. + +A disabled quad never calls the module's build(), so a disabled Bluetooth/Network +quad spawns no backend work at all. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +from module_base import ModuleContext, ModuleInstance, ModuleSpec + + +class QuadCard(Gtk.Box): + def __init__(self, spec: ModuleSpec, settings, services, + request_expand, request_collapse): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.add_css_class("quad-card") + self.spec = spec + self.settings = settings + self.services = services + self.ctx = ModuleContext(spec, settings, services, request_expand, request_collapse) + self.instance: ModuleInstance | None = None + + self._header = self._build_header() + self.append(self._header) + + self._body_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._body_holder.add_css_class("quad-body") + self._body_holder.set_vexpand(True) + self.append(self._body_holder) + + self._features: dict[str, bool] = {} + self._rebuild_body() + + # -- header ------------------------------------------------------------ + def _build_header(self) -> Gtk.Widget: + header = Gtk.CenterBox() + header.add_css_class("quad-header") + + title = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + icon = Gtk.Label(label=self.spec.icon) + icon.add_css_class("quad-icon") + name = Gtk.Label(label=self.spec.title, xalign=0.0) + name.add_css_class("quad-title") + title.append(icon) + title.append(name) + header.set_start_widget(title) + + actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + self._expand_btn = Gtk.Button(label="") # nf-fa-expand + self._expand_btn.add_css_class("quad-action") + self._expand_btn.set_tooltip_text("Expand") + self._expand_btn.connect("clicked", lambda *_: self.ctx.expand()) + actions.append(self._build_settings_button()) + actions.append(self._expand_btn) + header.set_end_widget(actions) + return header + + def _build_settings_button(self) -> Gtk.Widget: + btn = Gtk.MenuButton(label="") # nf-fa-cog + btn.add_css_class("quad-action") + btn.set_tooltip_text("Settings") + pop = Gtk.Popover() + pop.add_css_class("quad-settings") + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + + box.append(self._switch_row("Enabled", self.settings.quad_enabled( + self.spec.id, self.spec.default_enabled), + lambda v: self.settings.set_quad_enabled(self.spec.id, v))) + + for feat in self.spec.features: + box.append(Gtk.Separator()) + box.append(self._switch_row( + feat.label, self.settings.feature(self.spec.id, feat.id, feat.default), + lambda v, fid=feat.id: self.settings.set_feature(self.spec.id, fid, v))) + + pop.set_child(box) + btn.set_popover(pop) + return btn + + @staticmethod + def _switch_row(label: str, value: bool, on_change) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("switch-row") + lbl = Gtk.Label(label=label, xalign=0.0, hexpand=True) + sw = Gtk.Switch(active=value, valign=Gtk.Align.CENTER) + sw.connect("state-set", lambda _sw, v: (on_change(v), False)[1]) + row.append(lbl) + row.append(sw) + return row + + # -- body -------------------------------------------------------------- + def _clear_body(self) -> None: + if self.instance and self.instance.destroy: + self.instance.destroy() + self.instance = None + child = self._body_holder.get_first_child() + while child: + self._body_holder.remove(child) + child = self._body_holder.get_first_child() + + def _rebuild_body(self) -> None: + self._clear_body() + self._features = self._feature_snapshot() + enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled) + if enabled: + inst = self.spec.build(self.ctx) + self.instance = inst + self._body_holder.append(inst.compact) + self._expand_btn.set_sensitive(inst.expanded is not None) + else: + self._expand_btn.set_sensitive(False) + self._body_holder.append(self._disabled_placeholder()) + + def _feature_snapshot(self) -> dict[str, bool]: + return {f.id: self.settings.feature(self.spec.id, f.id, f.default) + for f in self.spec.features} + + def _disabled_placeholder(self) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10, + valign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER) + box.add_css_class("quad-disabled") + box.append(Gtk.Label(label=f"{self.spec.title} is off")) + btn = Gtk.Button(label="Enable") + btn.add_css_class("enable-btn") + btn.connect("clicked", + lambda *_: self.settings.set_quad_enabled(self.spec.id, True)) + box.append(btn) + return box + + # -- lifecycle --------------------------------------------------------- + def on_settings_changed(self) -> None: + """Called by the grid when settings.json changes; rebuild if this quad's + enablement flipped or any of its feature toggles changed, so every switch in + the settings popover takes effect live (the popover is only reachable while + the grid is collapsed, so no expanded view is ever reparented mid-rebuild).""" + enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled) + has_module = self.instance is not None + features_changed = enabled and self._feature_snapshot() != self._features + if enabled != has_module or features_changed: + self._rebuild_body() + + @property + def expanded_widget(self) -> Gtk.Widget | None: + return self.instance.expanded if self.instance else None + + def on_show(self) -> None: + if self.instance and self.instance.on_show: + self.instance.on_show() + + def on_hide(self) -> None: + if self.instance and self.instance.on_hide: + self.instance.on_hide() diff --git a/astro-menu/ui/quadgrid.py b/astro-menu/ui/quadgrid.py new file mode 100644 index 0000000..90b707b --- /dev/null +++ b/astro-menu/ui/quadgrid.py @@ -0,0 +1,191 @@ +"""The 2x2 quad region and its expand-over-the-others behaviour. + +A Gtk.Overlay stacks two things in the same space: + * base : a 2x2 Gtk.Grid of QuadCards + * overlay: a Revealer that, when a quad expands, fills the whole region (the full + content width, covering all four cells) with that module's expanded + view wrapped in a small header carrying a collapse button. + +Because the overlay fills the region exactly, the expanded quad is as wide as the +appdrawer below it, and the outer letterbox margins (applied further up the tree) +are untouched — so letterboxing stays identical in every state. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +from lib.border import bordered +from module_base import ModuleSpec +from ui.quadcard import QuadCard + + +class QuadGrid(Gtk.Overlay): + def __init__(self, specs: list[ModuleSpec], settings, services): + super().__init__() + self.add_css_class("quad-region") + self.set_vexpand(True) + self.settings = settings + self._expanded_id: str | None = None + + self.grid = Gtk.Grid(column_homogeneous=True, row_homogeneous=True, + column_spacing=10, row_spacing=10) + self.grid.add_css_class("quad-grid") + self.set_child(self.grid) + + self.cards: dict[str, QuadCard] = {} + for index, spec in enumerate(specs[:4]): + card = QuadCard(spec, settings, services, + self.request_expand, self.request_collapse) + self.cards[spec.id] = card + self.grid.attach(bordered(card, radius=16, fill_bg=True), + index % 2, index // 2, 1, 1) + + # overlay used for the expanded quad + self._expand_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.CROSSFADE, + transition_duration=180, reveal_child=False) + self._expand_reveal.set_halign(Gtk.Align.FILL) + self._expand_reveal.set_valign(Gtk.Align.FILL) + self._expand_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._expand_holder.add_css_class("quad-expanded") + self._expand_card = bordered(self._expand_holder, radius=16, fill_bg=True) + self._expand_reveal.set_child(self._expand_card) + self.add_overlay(self._expand_reveal) + # The overlay's holder is opaque; keep the whole overlay hidden unless a quad + # is actually expanded, otherwise it paints over the 2x2 grid. + self._expand_reveal.set_visible(False) + + # A second overlay: an external "takeover" widget (the taskbar's workspace/ + # window panel) that, when shown, covers the whole quad region. + self._takeover_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.CROSSFADE, + transition_duration=180, reveal_child=False) + self._takeover_reveal.set_halign(Gtk.Align.FILL) + self._takeover_reveal.set_valign(Gtk.Align.FILL) + self._takeover_holder = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._takeover_holder.add_css_class("quad-expanded") + self._takeover_card = bordered(self._takeover_holder, radius=16, fill_bg=True) + self._takeover_reveal.set_child(self._takeover_card) + self.add_overlay(self._takeover_reveal) + self._takeover_reveal.set_visible(False) + + settings.subscribe(self._on_settings_changed) + + # -- external takeover (taskbar panel) --------------------------------- + def set_takeover_widget(self, widget: Gtk.Widget, on_back) -> None: + """Mount an external widget (once) that will cover the quad region when shown. + A Back row is prepended so the covered quads can be restored.""" + header = Gtk.CenterBox() + header.add_css_class("expanded-header") + back = Gtk.Button(label="") # nf-fa-compress (unfullscreen) + back.add_css_class("quad-action") + back.set_tooltip_text("Collapse") + back.connect("clicked", lambda *_: on_back()) + header.set_start_widget(back) + self._takeover_holder.append(header) + widget.set_vexpand(True) + self._takeover_holder.append(widget) + + def show_takeover(self) -> None: + self._takeover_reveal.set_visible(True) + self._takeover_card.add_css_class("quad-pop") # same bouncy scale as a quad + GLib.timeout_add(340, lambda: (self._takeover_card.remove_css_class("quad-pop"), + GLib.SOURCE_REMOVE)[1]) + self._takeover_reveal.set_reveal_child(True) + self.grid.add_css_class("dimmed") + + def hide_takeover(self) -> None: + self._takeover_reveal.set_reveal_child(False) + self._takeover_reveal.set_visible(False) + self.grid.remove_css_class("dimmed") + + # -- expansion --------------------------------------------------------- + def request_expand(self, module_id: str) -> None: + card = self.cards.get(module_id) + if not card or card.instance is None or card.instance.expanded is None: + return + # A module opts into expansion by supplying a distinct expanded widget + # (a separate instance), so no reparenting of the compact cell is needed. + content = card.instance.expanded + # reparent content into the expanded holder + self._clear_expand_holder() + self._expand_reveal.set_visible(True) + self._expand_holder.append(self._expanded_header(card.spec.title)) + if card.instance.scroll_expanded: + wrap = Gtk.ScrolledWindow(vexpand=True, + hscrollbar_policy=Gtk.PolicyType.NEVER) + wrap.set_child(content) + self._expand_holder.append(wrap) + else: + content.set_vexpand(True) + self._expand_holder.append(content) + self._expanded_id = module_id + self._expand_reveal.set_reveal_child(True) + # bouncy scale pop (CSS @keyframes). Keep the class only for the animation's + # duration, then drop it: while it's applied, any re-layout of the card (module + # content streaming in, the grid updating underneath) restarts the transform + # animation, which made the pop loop forever. + self._expand_card.add_css_class("quad-pop") + GLib.timeout_add(340, self._clear_pop) + self.grid.add_css_class("dimmed") + card.on_show() + + def _clear_pop(self) -> bool: + self._expand_card.remove_css_class("quad-pop") + return GLib.SOURCE_REMOVE + + def request_collapse(self) -> None: + self._expand_reveal.set_reveal_child(False) + self._expand_reveal.set_visible(False) + self._expand_card.remove_css_class("quad-pop") # reset so the pop replays + self._expanded_id = None + self.grid.remove_css_class("dimmed") + # Drop the reference to the reparented widget so the card can reuse it. + self._clear_expand_holder() + + def _clear_expand_holder(self) -> None: + child = self._expand_holder.get_first_child() + while child: + # detach any ScrolledWindow's child so it survives for the card + if isinstance(child, Gtk.ScrolledWindow): + inner = child.get_child() + if inner: + child.set_child(None) + self._expand_holder.remove(child) + child = self._expand_holder.get_first_child() + + def _expanded_header(self, title: str) -> Gtk.Widget: + header = Gtk.CenterBox() + header.add_css_class("expanded-header") + back = Gtk.Button(label="") # nf-fa-compress (unfullscreen) + back.add_css_class("quad-action") + back.set_tooltip_text("Collapse") + back.connect("clicked", lambda *_: self.request_collapse()) + header.set_start_widget(back) + lbl = Gtk.Label(label=title) + lbl.add_css_class("quad-title") + header.set_center_widget(lbl) + return header + + @property + def is_expanded(self) -> bool: + return self._expanded_id is not None + + # -- lifecycle --------------------------------------------------------- + def _on_settings_changed(self) -> None: + for card in self.cards.values(): + card.on_settings_changed() + + def on_show(self) -> None: + for card in self.cards.values(): + card.on_show() + + def on_hide(self) -> None: + if self.is_expanded: + self.request_collapse() + for card in self.cards.values(): + card.on_hide() diff --git a/astro-menu/ui/statsbar.py b/astro-menu/ui/statsbar.py new file mode 100644 index 0000000..b9f6e15 --- /dev/null +++ b/astro-menu/ui/statsbar.py @@ -0,0 +1,133 @@ +"""A minimalist system-stats line for the top of the panel. + +CPU / RAM / GPU / disk utilisation plus network up/down rate, refreshed on a timer. +Everything is read straight from /proc and /sys on the main thread — these are local +file reads that take microseconds, so no subprocess or worker thread is needed. +""" + +from __future__ import annotations + +import glob +import os +import time + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +_GPU_FILES = glob.glob("/sys/class/drm/card*/device/gpu_busy_percent") +_NET_SKIP = ("lo", "docker", "veth", "br-", "virbr", "tun", "tap") + + +def _human(rate: float) -> str: + for unit in ("B", "K", "M", "G"): + if rate < 1024 or unit == "G": + return f"{rate:4.1f}{unit}" + rate /= 1024 + return f"{rate:4.1f}G" + + +class Statsbar(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=18, + halign=Gtk.Align.CENTER) + self.add_css_class("statsbar") + + # short text labels rather than nerd glyphs (some don't exist in the font and + # render as tofu); keeps the line legible on any theme. + self._cpu = self._cell("CPU") + self._ram = self._cell("MEM") + self._gpu = self._cell("GPU") + self._disk = self._cell("DISK") + self._down = self._cell("↓") + self._up = self._cell("↑") + + self._prev_cpu: tuple[int, int] | None = None + self._prev_net: tuple[int, int] | None = None + self._prev_t: float | None = None + + self._refresh() + self._source = GLib.timeout_add_seconds(2, self._tick) + + def _cell(self, icon: str) -> Gtk.Label: + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + ic = Gtk.Label(label=icon) + ic.add_css_class("stat-icon") + val = Gtk.Label(label="…") + val.add_css_class("stat-value") + box.append(ic) + box.append(val) + self.append(box) + return val + + def _tick(self) -> bool: + self._refresh() + return GLib.SOURCE_CONTINUE + + # -- readers ----------------------------------------------------------- + @staticmethod + def _cpu_pct(prev) -> tuple[float, tuple[int, int]]: + parts = [int(x) for x in open("/proc/stat").readline().split()[1:]] + idle = parts[3] + parts[4] # idle + iowait + total = sum(parts) + if prev is None: + return 0.0, (total, idle) + dt, di = total - prev[0], idle - prev[1] + pct = (1 - di / dt) * 100 if dt > 0 else 0.0 + return max(0.0, min(100.0, pct)), (total, idle) + + @staticmethod + def _ram_pct() -> float: + mem = {} + for line in open("/proc/meminfo"): + k, _, v = line.partition(":") + mem[k] = int(v.split()[0]) + if len(mem) > 4 and "MemAvailable" in mem: + break + total, avail = mem.get("MemTotal", 1), mem.get("MemAvailable", 0) + return (1 - avail / total) * 100 + + @staticmethod + def _gpu_pct() -> float: + best = 0 + for f in _GPU_FILES: + try: + best = max(best, int(open(f).read().strip())) + except (OSError, ValueError): + pass + return float(best) + + @staticmethod + def _disk_pct() -> float: + s = os.statvfs("/") + return (1 - s.f_bfree / s.f_blocks) * 100 if s.f_blocks else 0.0 + + @staticmethod + def _net_bytes() -> tuple[int, int]: + rx = tx = 0 + with open("/proc/net/dev") as fh: + for line in fh.readlines()[2:]: + name, _, rest = line.partition(":") + name = name.strip() + if name.startswith(_NET_SKIP): + continue + cols = rest.split() + rx += int(cols[0]) + tx += int(cols[8]) + return rx, tx + + def _refresh(self) -> None: + cpu, self._prev_cpu = self._cpu_pct(self._prev_cpu) + self._cpu.set_text(f"{cpu:2.0f}%") + self._ram.set_text(f"{self._ram_pct():2.0f}%") + self._gpu.set_text(f"{self._gpu_pct():2.0f}%") + self._disk.set_text(f"{self._disk_pct():2.0f}%") + + now = time.monotonic() + rx, tx = self._net_bytes() + if self._prev_net is not None and self._prev_t is not None and now > self._prev_t: + dt = now - self._prev_t + self._down.set_text(_human((rx - self._prev_net[0]) / dt) + "/s") + self._up.set_text(_human((tx - self._prev_net[1]) / dt) + "/s") + self._prev_net, self._prev_t = (rx, tx), now diff --git a/astro-menu/ui/taskbar.py b/astro-menu/ui/taskbar.py new file mode 100644 index 0000000..f6abbaa --- /dev/null +++ b/astro-menu/ui/taskbar.py @@ -0,0 +1,477 @@ +"""Full-width taskbar strip: jump to any open window, plus an expand-over panel. + +Compact: app-grouped icons; click focuses the window (single) or opens a pop-out of +instances (grouped). The ⤢ toggle collapses this compact strip and hands its panel +(exposed as `panel_widget`) to the menu window, which mounts it over the quad region +so it *covers* the 2x2 quads rather than pushing anything down. The panel holds: + * workspace/layout controls — pick the current workspace's layout (scrolling / + dwindle / master / monocle, enumerated from ~/.cache/astro-menu/layouts.json, + written by hypr/layouts) and, for directional layouts, its direction. Applied + live via `hyprctl eval 'layouts.set(ws, name, dir)'`. + * a per-window row list: [icon + title → focus/jump] [⇤ pull to this workspace]. +""" + +from __future__ import annotations + +import json + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("AstalApps", "0.1") +from gi.repository import AstalApps, Gtk # noqa: E402 + +from lib.proc import run_json, run_text +from paths import CACHE_DIR + +_LAYOUTS_MANIFEST = CACHE_DIR / "layouts.json" +_COLUMNS_STATE = CACHE_DIR / "columns-state.json" +_LAYOUTS_STATE = CACHE_DIR / "layouts-state.json" + + +def _eval(lua: str) -> None: + run_text(["hyprctl", "eval", lua], lambda *_a: None) + + +def _dispatch(lua: str) -> None: + # In hyprlua, `hyprctl dispatch` evaluates its argument as Lua (the hl.dsp.* API). + run_text(["hyprctl", "dispatch", lua], lambda *_a: None) + + +class Taskbar(Gtk.Box): + def __init__(self, on_activate, on_toggle_panel=None): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6) + self.add_css_class("taskbar") + self._on_activate = on_activate + self._on_toggle_panel = on_toggle_panel # window mounts the panel over the quads + self._apps = AstalApps.Apps() + self._wm_index = self._build_wm_index() + self._layouts = self._load_layouts() + self._active_ws = None + self._clients: list = [] + self._dir_dds = {} + self._fit_sws = {} + + # The workspace/window panel. It is NOT appended here: the menu window mounts + # `panel_widget` into the quad region so that expanding COLLAPSES this strip's + # body (below) and the panel COVERS the 2x2 quads instead of pushing anything. + self._panel = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self._panel.add_css_class("task-panel") + self._panel_scroll = Gtk.ScrolledWindow( + hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) + self._panel_scroll.set_child(self._panel) + + header = Gtk.CenterBox() + title = Gtk.Label(label="Open windows", xalign=0.0) + title.add_css_class("section-title") + header.set_start_widget(title) + self._expand_btn = Gtk.ToggleButton(label="") # nf-fa-expand + self._expand_btn.add_css_class("quad-action") + self._expand_btn.set_tooltip_text("Workspace & window controls") + self._expand_btn.connect("toggled", lambda b: self._toggle_panel(b.get_active())) + header.set_end_widget(self._expand_btn) + self.append(header) + + # compact icon strip (hidden while the panel is expanded) + self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self._row.add_css_class("taskbar-row") + self._strip = Gtk.ScrolledWindow( + vscrollbar_policy=Gtk.PolicyType.NEVER, + hscrollbar_policy=Gtk.PolicyType.AUTOMATIC) + self._strip.set_child(self._row) + self.append(self._strip) + + # -- expand-over-the-quads panel -------------------------------------- + @property + def panel_widget(self) -> Gtk.Widget: + """The workspace/window panel body; the window mounts this over the quads.""" + return self._panel_scroll + + def _toggle_panel(self, active: bool) -> None: + # Collapse this strip's compact body; the window reveals/hides the panel that + # it has mounted over the quad region (via on_toggle_panel). + self._strip.set_visible(not active) + if self._on_toggle_panel: + self._on_toggle_panel(active) + if active: + self.refresh() + + def collapse_panel(self) -> None: + """Return to the compact strip (called on menu hide / from the panel's Back).""" + if self._expand_btn.get_active(): + self._expand_btn.set_active(False) # fires toggled -> _toggle_panel(False) + else: + self._strip.set_visible(True) + + # -- setup helpers ----------------------------------------------------- + def _build_wm_index(self) -> dict: + idx = {} + for app in self._apps.get_list(): + for key in (app.get_wm_class(), app.get_executable(), app.get_name()): + if key: + idx.setdefault(key.lower(), app) + return idx + + def _icon_for(self, cls: str) -> str: + app = self._wm_index.get((cls or "").lower()) + if app and app.get_icon_name(): + return app.get_icon_name() + return (cls or "application-x-executable").lower() + + @staticmethod + def _load_layouts() -> list: + try: + return json.loads(_LAYOUTS_MANIFEST.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + # fallback if hypr/layouts hasn't written the manifest yet + return [{"name": "scrolling", "label": "Scrolling", "directional": True, + "dirs": ["down", "up", "right", "left"], "default_dir": "down", + "fit_method": True}, + {"name": "columns", "label": "Columns", "directional": True, + "dirs": ["right", "down"], "dir_labels": ["Left / Right", "Up / Down"], + "default_dir": "right", "fit_method": True}, + {"name": "dwindle", "label": "Dwindle", "directional": False, "dirs": []}, + {"name": "master", "label": "Master", "directional": False, "dirs": []}, + {"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}] + + # -- populate ---------------------------------------------------------- + def refresh(self) -> None: + run_json(["hyprctl", "clients", "-j"], self._on_clients) + run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws) + + def _on_ws(self, ok: bool, data) -> None: + if ok and isinstance(data, dict): + self._active_ws = data.get("id") + self._rebuild_panel() + + def _on_clients(self, ok: bool, data) -> None: + self._clients = data if ok and isinstance(data, list) else [] + self._rebuild_strip() + self._rebuild_panel() + + # -- compact strip ----------------------------------------------------- + def _rebuild_strip(self) -> None: + self._clear(self._row) + groups: dict[str, list] = {} + for w in self._clients: + if not w.get("mapped", True) or not w.get("class"): + continue + groups.setdefault(w["class"], []).append(w) + if not groups: + self._row.append(Gtk.Label(label="No open windows")) + return + for cls, wins in sorted(groups.items()): + self._row.append(self._group_button(cls, wins)) + + def _group_button(self, cls: str, wins: list) -> Gtk.Widget: + icon = Gtk.Image.new_from_icon_name(self._icon_for(cls)) + icon.set_pixel_size(32) + if len(wins) == 1: + btn = Gtk.Button() + btn.add_css_class("task-tile") + btn.set_child(icon) + btn.set_tooltip_text(wins[0].get("title") or cls) + btn.connect("clicked", lambda *_a, w=wins[0]: self._focus(w)) + return btn + btn = Gtk.MenuButton() + btn.add_css_class("task-tile") + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.append(icon) + badge = Gtk.Label(label=str(len(wins))) + badge.add_css_class("task-badge") + box.append(badge) + btn.set_child(box) + btn.set_tooltip_text(f"{cls} ({len(wins)})") + pop = Gtk.Popover() + pop.add_css_class("task-popover") + plist = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + for w in wins: + item = Gtk.Button(label=w.get("title") or cls) + item.add_css_class("task-window") + item.connect("clicked", lambda *_a, ww=w: (pop.popdown(), self._focus(ww))) + plist.append(item) + pop.set_child(plist) + btn.set_popover(pop) + return btn + + # -- pop-open panel: layout controls + window list --------------------- + def _rebuild_panel(self) -> None: + self._clear(self._panel) + + # tabbed layout selector for the active workspace: a tab per layout, and a + # per-layout options page underneath that switches with the tab. Selecting a + # tab applies that layout to the currently focused workspace. + ws = self._active_ws + hdr = Gtk.Label(label=f"Workspace {ws if ws is not None else '?'} layout", xalign=0.0) + hdr.add_css_class("section-title") + self._panel.append(hdr) + + self._dir_dds = {} # layout name -> (Gtk.DropDown, [dir values]) + self._fit_sws = {} # layout name -> Gtk.Switch + self._layout_stack = Gtk.Stack() + for ly in self._layouts: + self._layout_stack.add_titled(self._layout_page(ly), ly["name"], ly["label"]) + switcher = Gtk.StackSwitcher(stack=self._layout_stack) + switcher.add_css_class("net-switcher") # reuse the tab pill styling + self._panel.append(switcher) + self._panel.append(self._layout_stack) + self._layout_stack.connect("notify::visible-child-name", self._on_tab_switch) + self._sync_layout_controls() + + self._panel.append(Gtk.Separator()) + + # per-window rows: [focus/jump] [pull here] + wins = [w for w in self._clients if w.get("mapped", True) and w.get("class")] + wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0), + (w.get("title") or w.get("class") or "").lower())) + if not wins: + self._panel.append(Gtk.Label(label="No open windows", xalign=0.0)) + return + listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER) + scroller.set_child(listing) + for w in wins: + listing.append(self._window_row(w)) + self._panel.append(scroller) + + def _window_row(self, w) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.add_css_class("task-row") + icon = Gtk.Image.new_from_icon_name(self._icon_for(w.get("class", ""))) + icon.set_pixel_size(22) + wsid = w.get("workspace", {}).get("id") + title = (w.get("title") or w.get("class") or "?") + name = Gtk.Button(hexpand=True) + name.add_css_class("task-name") + lbl = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + lbl.append(icon) + wl = Gtk.Label(label=f"{title}", xalign=0.0, hexpand=True, ellipsize=3, max_width_chars=32) + lbl.append(wl) + lbl.append(Gtk.Label(label=f"ws {wsid}", xalign=1.0)) + name.set_child(lbl) + name.set_tooltip_text("Jump to window") + name.connect("clicked", lambda *_a: self._focus(w)) + row.append(name) + + pull = Gtk.Button(label="⇤ here") + pull.add_css_class("quad-action") + pull.set_tooltip_text("Pull this window to the current workspace") + pull.connect("clicked", lambda *_a: self._pull(w)) + row.append(pull) + return row + + # -- per-layout options page ------------------------------------------- + def _layout_page(self, ly: dict) -> Gtk.Widget: + page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + page.add_css_class("layout-page") + if ly.get("dirs"): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.append(Gtk.Label(label="direction", xalign=0.0, hexpand=True)) + # dir_labels (optional) are friendly labels shown in place of the raw + # scrolling:direction values (e.g. "Left / Right" for "right"). + labels = ly.get("dir_labels") or ly["dirs"] + dd = Gtk.DropDown.new_from_strings(labels) + dd.connect("notify::selected", self._on_opt_change) + row.append(dd) + page.append(row) + self._dir_dds[ly["name"]] = (dd, list(ly["dirs"])) + if ly.get("fit_method"): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.append(Gtk.Label(label="center-focused", xalign=0.0, hexpand=True)) + sw = Gtk.Switch(valign=Gtk.Align.CENTER) + if ly["name"] == "columns": + sw.set_tooltip_text("Keep the focused window centred in its column as " + "it scrolls, instead of the minimal-movement default") + else: + sw.set_tooltip_text("Keep the focused column centred so the prev/next " + "columns stay on-screen and tappable") + sw.connect("state-set", lambda s, state, name=ly["name"]: self._on_fit_toggle(s, state, name)) + row.append(sw) + page.append(row) + self._fit_sws[ly["name"]] = sw + if ly.get("stepper"): # Columns: (-)[N](+) + page.append(self._cols_stepper()) + if not ly.get("dirs") and not ly.get("fit_method") and not ly.get("stepper"): + page.append(Gtk.Label(label="No adjustable options", xalign=0.0, + css_classes=["net-ip"])) + return page + + # -- columns count stepper -------------------------------------------- + def _cols_stepper(self) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.append(Gtk.Label(label="Columns", xalign=0.0, hexpand=True)) + minus = Gtk.Button(label="−") + minus.add_css_class("quad-action") + self._cols_lbl = Gtk.Label(label=str(self._read_cols())) + self._cols_lbl.add_css_class("stepper-value") + plus = Gtk.Button(label="+") + plus.add_css_class("quad-action") + minus.connect("clicked", lambda *_a: self._step_cols(-1)) + plus.connect("clicked", lambda *_a: self._step_cols(1)) + row.append(minus) + row.append(self._cols_lbl) + row.append(plus) + return row + + def _read_cols(self) -> int: + try: + data = json.loads(_COLUMNS_STATE.read_text()) + return int(data.get(str(self._active_ws), {}).get("cols", 2)) + except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError): + return 2 + + def _step_cols(self, delta: int) -> None: + try: + cur = int(self._cols_lbl.get_label()) + except ValueError: + cur = self._read_cols() + new = max(1, cur + delta) + self._cols_lbl.set_label(str(new)) + _eval(f'hl.dispatch(hl.dsp.layout("cols {"+1" if delta > 0 else "-1"}"))') + + def _read_center(self) -> bool: + # columns.lua's own "center-focused" switch — global, published in the same + # cache file as the columns stepper's count (see columns.lua's publish()). + try: + data = json.loads(_COLUMNS_STATE.read_text()) + return bool(data.get("_center", False)) + except (FileNotFoundError, json.JSONDecodeError, ValueError, TypeError): + return False + + # -- sync + handlers --------------------------------------------------- + def _sync_layout_controls(self) -> None: + # Reflect the current per-ws layout / direction / focus_fit in the tabs+options. + # + # This fires 3 concurrent hyprctl subprocess calls with no guaranteed resolution + # order, and refresh() can trigger _rebuild_panel() (hence this) twice in a row + # (once from the clients query, once from the activeworkspace one) — each + # rebuilding a fresh _layout_stack/_dir_dds/_fit_sws. Two failure modes existed: + # 1. The 3 callbacks used to each toggle the shared `self._syncing` flag + # independently (True at start, False at end). Whichever resolved *first* + # dropped the guard while the others — in particular the tab-selection one, + # which programmatically flips the visible stack child — were still in + # flight, letting `_on_tab_switch`/`_on_opt_change` misfire as if the user + # had clicked and re-apply stale data via `layouts.set(...)`. + # 2. A second `_sync_layout_controls()` call (from the redundant rebuild) could + # land while the first's callbacks were still pending, so a stale callback + # from sync #1 could decrement sync #2's pending-count or apply sync #1's + # (possibly wrong-workspace) data into sync #2's freshly built widgets. + # A generation token invalidates any sync superseded by a newer one outright — + # if the user switches workspaces (or a redundant rebuild fires) mid-flight, only + # the latest call's callbacks are allowed to touch anything, and self._syncing + # only lifts once *that* generation's 3 calls have all resolved. + self._sync_generation = getattr(self, "_sync_generation", 0) + 1 + gen = self._sync_generation + ws = self._active_ws + self._syncing = True + pending = [3] + + def _done() -> None: + if gen != self._sync_generation: + return # superseded — a newer sync owns self._syncing now + pending[0] -= 1 + if pending[0] <= 0: + self._syncing = False + + def _tab_cb(ok, data) -> None: + if gen == self._sync_generation: + self._apply_tab_sel(ws, ok, data) + _done() + + def _dir_cb(ok, data) -> None: + if gen == self._sync_generation: + self._apply_dir_sel(ok, data) + _done() + + def _fit_cb(ok, data) -> None: + if gen == self._sync_generation: + self._apply_fit_sel(ok, data) + _done() + + run_json(["hyprctl", "getoption", "general:layout", "-j"], _tab_cb) + run_json(["hyprctl", "getoption", "scrolling:direction", "-j"], _dir_cb) + run_json(["hyprctl", "getoption", "scrolling:focus_fit_method", "-j"], _fit_cb) + # columns' own center-focused state lives in columns-state.json, not a hyprctl + # option — read it synchronously rather than round-tripping a subprocess. + columns_sw = self._fit_sws.get("columns") + if columns_sw is not None: + columns_sw.set_active(self._read_center()) + + def _apply_tab_sel(self, ws, ok, data) -> None: + cur = data.get("str") if ok and isinstance(data, dict) else None + # prefer the per-workspace layout recorded by hypr/layouts (layouts.set) + try: + state = json.loads(_LAYOUTS_STATE.read_text()) + cur = state.get(str(ws), cur) + except (FileNotFoundError, json.JSONDecodeError): + pass + if cur and any(ly["name"] == cur for ly in self._layouts): + self._layout_stack.set_visible_child_name(cur) + + def _apply_dir_sel(self, ok, data) -> None: + # scrolling:direction is global; reflect it in every directional layout's + # dropdown whose value set contains it. + cur = data.get("str") if ok and isinstance(data, dict) else None + for dd, values in self._dir_dds.values(): + if cur in values: + dd.set_selected(values.index(cur)) + + def _apply_fit_sel(self, ok, data) -> None: + # scrolling:focus_fit_method only describes the scrolling layout's own switch; + # columns' switch is synced separately from columns-state.json (see + # _sync_layout_controls) since it isn't backed by a hyprctl option at all. + val = data.get("int") if ok and isinstance(data, dict) else 0 + sw = self._fit_sws.get("scrolling") + if sw is not None: + sw.set_active(val == 1) + + def _cur_dir(self) -> str: + entry = self._dir_dds.get(self._layout_stack.get_visible_child_name()) + if not entry: + return "" + dd, values = entry + i = dd.get_selected() + return values[i] if 0 <= i < len(values) else "" + + def _on_tab_switch(self, *_a) -> None: + if getattr(self, "_syncing", False) or self._active_ws is None: + return + name = self._layout_stack.get_visible_child_name() + if name: + _eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")') + + def _on_opt_change(self, *_a) -> None: + if getattr(self, "_syncing", False) or self._active_ws is None: + return + name = self._layout_stack.get_visible_child_name() + if name: + _eval(f'layouts.set("{self._active_ws}", "{name}", "{self._cur_dir()}")') + + def _on_fit_toggle(self, _sw, state, name) -> bool: + if not getattr(self, "_syncing", False): + if name == "columns": + _eval(f'hl.dispatch(hl.dsp.layout("center {"on" if state else "off"}"))') + else: + _eval(f"layouts.set_fit({1 if state else 0})") + return False + + # -- window actions ---------------------------------------------------- + def _focus(self, w) -> None: + addr = w.get("address") + if addr: + _dispatch(f'hl.dsp.focus({{ window = "address:{addr}" }})') + self._on_activate() + + def _pull(self, w) -> None: + addr = w.get("address") + if addr and self._active_ws is not None: + _dispatch(f'hl.dsp.window.move({{ window = "address:{addr}", ' + f'workspace = "{self._active_ws}" }})') + run_json(["hyprctl", "clients", "-j"], self._on_clients) # reflect the move + + @staticmethod + def _clear(box: Gtk.Box) -> None: + child = box.get_first_child() + while child: + box.remove(child) + child = box.get_first_child() diff --git a/astro-menu/window.py b/astro-menu/window.py new file mode 100644 index 0000000..4f4ca3b --- /dev/null +++ b/astro-menu/window.py @@ -0,0 +1,243 @@ +"""The popup: a content-sized floating layer-shell panel anchored top-centre. + +Earlier this was a full-monitor overlay with a dim backdrop, but that blocks the +whole screen — the invisible full-screen surface intercepts every click. Instead the +window now sizes to its own content and only occupies that area, leaving the rest of +the screen usable. It is dismissed with the launcher toggle, Esc, or the ✕ button +(there is no click-outside-to-close, since that would require a blocking full-screen +surface). + + Gtk.Window (layer TOP, anchored TOP → horizontally centred, height = content) + └ Gtk.Overlay + main : #panel-root (Taskbar / QuadGrid / AppDrawer, each drawn-bordered) + over : ✕ close button (top-right) + +Expanding the app drawer additionally anchors the BOTTOM edge so the panel stretches +down and the drawer fills to the bottom; collapsing removes that anchor. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, GLib, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +import config +from appservices import Services +from lib.border import bordered +from lib.hologram import HologramOverlay +from registry import ordered_specs +from ui.appdrawer import AppDrawer +from ui.quadgrid import QuadGrid +from ui.statsbar import Statsbar +from ui.taskbar import Taskbar + +PANEL_WIDTH_FRACTION = 0.5 # of the monitor width... +MAX_PANEL_WIDTH = 1100 # ...clamped to this +EDGE_MARGIN = 28 # gap from the anchored edge +BOTTOM_MARGIN = 34 # gap from the far edge when the drawer is expanded +SIDES = ("top", "bottom", "left", "right") + + +class MenuWindow(Gtk.ApplicationWindow): + def __init__(self, app, settings, services: Services): + super().__init__(application=app) + self.set_name("menu-window") + self.add_css_class("menu-window") + self.settings = settings + self.services = services + self._drawer_expanded = False + self._side = "top" + + self._init_layer_shell() + + self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.root.set_name("panel-root") + self.root.add_css_class("panel") + + # minimalist system-stats line (full-width, very top) + self.statsbar = Statsbar() + self.statsbar_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, + transition_duration=160, reveal_child=True) + self.statsbar_reveal.set_child(bordered(self.statsbar, border=2, radius=12, fill_bg=True)) + self.root.append(self.statsbar_reveal) + + # taskbar (full-width, top) + self.taskbar = Taskbar(on_activate=self.hide_menu, + on_toggle_panel=self._on_taskbar_panel) + self.taskbar_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, + transition_duration=160, reveal_child=True) + self.taskbar_reveal.set_child(bordered(self.taskbar, fill_bg=True)) + self.root.append(self.taskbar_reveal) + + # quads + specs = list(ordered_specs(settings)) + self.grid = QuadGrid(specs, settings, services) + # The taskbar's workspace/window panel is mounted over the quad region so that + # expanding it collapses the taskbar strip and covers the 2x2 quads. + self.grid.set_takeover_widget(self.taskbar.panel_widget, + on_back=self.taskbar.collapse_panel) + self.quad_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.SLIDE_UP, + transition_duration=200, reveal_child=True) + self.quad_reveal.set_child(self.grid) + self.root.append(self.quad_reveal) + + # appdrawer + self.appdrawer = AppDrawer(settings, on_launch=self.hide_menu, + on_toggle_expand=self._on_appdrawer_expand) + self.appdrawer_wrap = bordered(self.appdrawer, fill_bg=True) + self.appdrawer_wrap.set_valign(Gtk.Align.FILL) + self.root.append(self.appdrawer_wrap) + + overlay = Gtk.Overlay() + overlay.set_child(self.root) + + self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root) + overlay.add_overlay(self._hologram.widget) + + close = Gtk.Button(label="✕") + close.add_css_class("close-btn") + close.set_halign(Gtk.Align.END) + close.set_valign(Gtk.Align.START) + close.connect("clicked", lambda *_: self.hide_menu()) + overlay.add_overlay(close) + self.set_child(overlay) + + key = Gtk.EventControllerKey() + key.connect("key-pressed", self._on_key) + self.add_controller(key) + + self._last_tick: float | None = None + self._tick_id: int | None = None + + self.connect("map", lambda *_: self._apply_size()) + self.set_visible(False) + + # -- layer shell / size ----------------------------------------------- + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.TOP) + LayerShell.set_namespace(self, "astro-menu") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND) + self._apply_anchor() + + def set_side(self, side: str) -> None: + """Anchor the panel to the given monitor edge ('top'|'bottom'|'left'|'right'). + The compositor's layer 'slide' animation then slides it in from that edge, and + the panel sits flush against it. Set before showing so the map animates.""" + if side in SIDES: + self._side = side + self._apply_anchor() + + def _apply_anchor(self) -> None: + """Pin the panel to its side edge; while the drawer is expanded, also pin the + perpendicular edges so it fills the screen height.""" + E = LayerShell.Edge + for edge in (E.TOP, E.BOTTOM, E.LEFT, E.RIGHT): + LayerShell.set_anchor(self, edge, False) + LayerShell.set_margin(self, edge, 0) + primary = {"top": E.TOP, "bottom": E.BOTTOM, "left": E.LEFT, "right": E.RIGHT}[self._side] + LayerShell.set_anchor(self, primary, True) + LayerShell.set_margin(self, primary, EDGE_MARGIN) + if self._drawer_expanded: + # fill vertically so the expanded drawer reaches top and bottom + for edge in (E.TOP, E.BOTTOM): + LayerShell.set_anchor(self, edge, True) + if edge != primary: + LayerShell.set_margin(self, edge, BOTTOM_MARGIN) + + def _monitor_width(self) -> int: + display = Gdk.Display.get_default() + surface = self.get_surface() + mon = display.get_monitor_at_surface(surface) if surface is not None else None + if mon is None: + monitors = display.get_monitors() + mon = monitors.get_item(0) if monitors.get_n_items() else None + return mon.get_geometry().width if mon is not None else 1920 + + def _apply_size(self) -> None: + width = min(int(self._monitor_width() * PANEL_WIDTH_FRACTION), MAX_PANEL_WIDTH) + self.root.set_size_request(width, -1) + + # -- taskbar panel expansion ------------------------------------------ + def _on_taskbar_panel(self, expanded: bool) -> None: + # Cover the quads with the taskbar's workspace/window panel. Fully collapse the + # taskbar strip (header included) so the expanded panel reads as ONE module + # rather than a thin strip on top plus a detached card. Collapse any quad first. + if expanded and self.grid.is_expanded: + self.grid.request_collapse() + self.taskbar_reveal.set_reveal_child(not expanded) + self.taskbar_reveal.set_visible(not expanded) + self.grid.show_takeover() if expanded else self.grid.hide_takeover() + + # -- appdrawer expansion ---------------------------------------------- + def _on_appdrawer_expand(self, expanded: bool) -> None: + self._drawer_expanded = expanded + self._apply_anchor() + # Hide (not just un-reveal) the stats/taskbar/quads so they reserve zero space + # and the drawer fills the whole panel. + for rev in (self.statsbar_reveal, self.quad_reveal, self.taskbar_reveal): + rev.set_reveal_child(not expanded) + rev.set_visible(not expanded) + self.appdrawer_wrap.set_vexpand(expanded) + + # -- visibility -------------------------------------------------------- + def show_menu(self, focus_appdrawer: bool = False) -> None: + self.appdrawer.set_expanded(False) + self.taskbar.refresh() + self.grid.on_show() + self.appdrawer.on_show() + self.set_visible(True) + self.present() + GLib.timeout_add(30, lambda: (self._apply_size(), False)[1]) + if focus_appdrawer: + self.appdrawer.set_expanded(True) + if self._hologram.enabled and self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + self._hologram.start_intro() + + def hide_menu(self) -> None: + self.grid.on_hide() + self.taskbar.collapse_panel() + self.grid.hide_takeover() + self.appdrawer.set_expanded(False) + # Play the closing dissolve first (content fades back into static), then + # actually hide via _finish_hide. If the hologram is off, hide at once. + if self._hologram.enabled and self._tick_id is not None: + self._hologram.start_outro(self._finish_hide) + else: + self._finish_hide() + + def _finish_hide(self) -> None: + self.set_visible(False) + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + self._last_tick = None + + def toggle(self, focus_appdrawer: bool = False) -> None: + if self.get_visible(): + self.hide_menu() + else: + self.show_menu(focus_appdrawer) + + # -- hologram animation loop -------------------------------------------- + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + self._hologram.tick(dt) + return True # keep ticking every frame while the menu is visible + + def _on_key(self, _c, keyval, _kc, _state) -> bool: + if keyval == Gdk.KEY_Escape: + self.hide_menu() + return True + return False diff --git a/beacon/config.py b/beacon/config.py new file mode 100644 index 0000000..e2af71e --- /dev/null +++ b/beacon/config.py @@ -0,0 +1,55 @@ +"""Tiny user-editable config file: ~/.local/state/beacon/config.json. + +Read once at startup (main.py, via server.py); a change takes effect on the +next beacon-start.sh restart, not live. Same pattern as orbit-menu/horizon- +dock/astro-menu/station-bar's config.py. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = { + "history_enabled": True, + "history_length": 100, + "history_persist": True, + "hologram": True, + "squiggle": True, +} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def history_enabled() -> bool: + return bool(_load().get("history_enabled", True)) + + +def history_length() -> int: + try: + return int(_load().get("history_length", 100)) + except (TypeError, ValueError): + return 100 + + +def history_persist() -> bool: + return bool(_load().get("history_persist", True)) + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) + + +def squiggle_enabled() -> bool: + return bool(_load().get("squiggle", True)) diff --git a/beacon/controls.py b/beacon/controls.py new file mode 100644 index 0000000..87ce057 --- /dev/null +++ b/beacon/controls.py @@ -0,0 +1,81 @@ +"""Parsing/validation for the x-beacon-controls custom notification hint. + +Sending apps opt into rich, embedded controls (beyond the spec's plain +fire-and-dismiss `actions` array) via a hint whose value is a JSON string — +chosen over a nested D-Bus variant struct so any app, in any language, can +produce it without touching GVariant construction. Advertised in +GetCapabilities as "x-beacon-controls" so senders can detect support and +fall back to plain `actions` against any other freedesktop-compliant daemon. + +Shape: + [ + {"type": "button", "id": "reply", "label": "Reply"}, + {"type": "toggle", "id": "mute", "label": "Mute", "value": false}, + {"type": "slider", "id": "volume", "label": "Volume", "value": 40, "min": 0, "max": 100}, + {"type": "entry", "id": "msg", "label": "Message", "placeholder": "Type a reply..."} + ] + +Unknown "type"s and unrecognised extra keys are silently dropped rather than +erroring — forward-compatible with control types added later, and never lets +a malformed/hostile payload (any local session-bus process can call Notify) +crash the daemon. +""" + +from __future__ import annotations + +import json +from typing import Optional + +_TYPES = {"button", "toggle", "slider", "entry"} +# a button auto-dismisses like a classic action by default; the stateful +# controls default to staying on-screen so you can keep adjusting them. +_DEFAULT_DISMISS = {"button": True, "toggle": False, "slider": False, "entry": False} + + +def parse_controls(raw: Optional[str]) -> list[dict]: + if not raw: + return [] + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return [] + if not isinstance(data, list): + return [] + + out: list[dict] = [] + for item in data: + if not isinstance(item, dict): + continue + ctype = item.get("type") + cid = item.get("id") + if ctype not in _TYPES or not isinstance(cid, str) or not cid: + continue + label = item.get("label") + label = label if isinstance(label, str) else cid + dismiss = item.get("dismiss") + dismiss = bool(dismiss) if isinstance(dismiss, bool) else _DEFAULT_DISMISS[ctype] + + control = {"type": ctype, "id": cid, "label": label, "dismiss": dismiss} + + if ctype == "toggle": + control["value"] = bool(item.get("value", False)) + elif ctype == "slider": + control["value"] = _as_float(item.get("value"), 0.0) + control["min"] = _as_float(item.get("min"), 0.0) + control["max"] = _as_float(item.get("max"), 100.0) + control["step"] = _as_float(item.get("step"), 1.0) + if control["max"] <= control["min"]: + control["max"] = control["min"] + 1.0 + elif ctype == "entry": + placeholder = item.get("placeholder") + control["placeholder"] = placeholder if isinstance(placeholder, str) else "" + + out.append(control) + return out + + +def _as_float(value, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default diff --git a/beacon/dbus/org.freedesktop.Notifications.service b/beacon/dbus/org.freedesktop.Notifications.service new file mode 100644 index 0000000..af715fc --- /dev/null +++ b/beacon/dbus/org.freedesktop.Notifications.service @@ -0,0 +1,12 @@ +[D-BUS Service] +# Route D-Bus activation of the freedesktop notification name to beacon instead +# of dunst. The dunst package ships /usr/share/dbus-1/services/org.knopwob.dunst.service +# which also claims Name=org.freedesktop.Notifications, so without this file D-Bus +# auto-activates dunst the moment any app posts a notification and dunst grabs the +# name before beacon can (beacon then exits on name-lost — see beacon/main.py). +# A service file in ~/.local/share/dbus-1/services/ takes precedence over /usr/share, +# so this wins the name resolution for org.freedesktop.Notifications. +# +# Deployed to ~/.local/share/dbus-1/services/ at install time (see updater.conf). +Name=org.freedesktop.Notifications +Exec=/home/themiro/Dotfiles/desktopenvs/hyprdrive/scripts/beacon-start.sh diff --git a/beacon/history.py b/beacon/history.py new file mode 100644 index 0000000..483d57b --- /dev/null +++ b/beacon/history.py @@ -0,0 +1,109 @@ +"""Bounded, disk-persisted notification history. + +Pure data structure — no D-Bus/GLib knowledge, mirrors the window/server +separation used elsewhere in beacon (window.py knows nothing about D-Bus +either). server.py owns turning these entries into GLib.Variant dicts and +emitting HistoryAdded/HistoryRemoved/HistoryCleared. + +Entry schema (plain dict, JSON-safe, deliberately open so new keys can be +added later without breaking old readers): + id (int), app_name (str), summary (str), body (str), icon (str), + urgency (int), timestamp (float, unix seconds), reason (int), + actions (list[str], flat key/label pairs), controls (str | None — the raw + x-beacon-controls JSON hint, re-parsed via controls.parse_controls on Pop). + +image_data (raw pixel bytes) is deliberately never stored here — it would +bloat history.json and doesn't round-trip through JSON cleanly; a +popped-from-history card just falls back to its `icon` string. +""" + +from __future__ import annotations + +import json +import os +import tempfile +from typing import Optional + +from paths import CACHE_DIR, HISTORY_FILE, ensure_dirs + + +class HistoryStore: + def __init__(self, maxlen: int, persist: bool) -> None: + self._maxlen = max(1, maxlen) + self._persist = persist + self._entries: list[dict] = self._load() if persist else [] # newest first + + # -- mutation --------------------------------------------------------------- + def record(self, entry: dict) -> Optional[dict]: + """Insert newest-first; returns the evicted entry if capacity was hit.""" + self._entries.insert(0, entry) + evicted = None + while len(self._entries) > self._maxlen: + evicted = self._entries.pop() + self._save() + return evicted + + def pop(self, nid: int) -> Optional[dict]: + """Remove and return a specific entry (for History1.Pop).""" + for i, e in enumerate(self._entries): + if e["id"] == nid: + entry = self._entries.pop(i) + self._save() + return entry + return None + + def pop_latest(self) -> Optional[dict]: + """Remove and return the most recently recorded entry (LIFO, like + dunstctl history-pop).""" + if not self._entries: + return None + entry = self._entries.pop(0) + self._save() + return entry + + def remove(self, nid: int) -> bool: + for i, e in enumerate(self._entries): + if e["id"] == nid: + del self._entries[i] + self._save() + return True + return False + + def clear(self) -> list[int]: + ids = [e["id"] for e in self._entries] + self._entries = [] + self._save() + return ids + + # -- read --------------------------------------------------------------------- + def list_all(self) -> list[dict]: + return list(self._entries) + + def get(self, nid: int) -> Optional[dict]: + for e in self._entries: + if e["id"] == nid: + return e + return None + + # -- persistence ------------------------------------------------------------ + def _load(self) -> list[dict]: + try: + data = json.loads(HISTORY_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return [] + return data if isinstance(data, list) else [] + + def _save(self) -> None: + if not self._persist: + return + ensure_dirs() + fd, tmp = tempfile.mkstemp(dir=str(CACHE_DIR), prefix=".history-", suffix=".json") + try: + with os.fdopen(fd, "w") as f: + json.dump(self._entries, f) + os.replace(tmp, HISTORY_FILE) + except OSError: + try: + os.unlink(tmp) + except OSError: + pass diff --git a/beacon/lib/hologram.py b/beacon/lib/hologram.py new file mode 100644 index 0000000..6885b55 --- /dev/null +++ b/beacon/lib/hologram.py @@ -0,0 +1,278 @@ +"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as +the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu / +horizon-dock lib/hologram.py), reused here so notification cards read as one more +orbit of the same look: scanline grid + a slow vertical sweep + drifting noise +specks, and a 'materialise out of static' intro when a card first appears. + +Each notification card owns its own overlay (see notification.py); the stack +window (window.py) runs a single frame-clock tick and feeds every visible card's +overlay via .tick(dt). +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.18 # a touch stronger than the bar's — cards are the focus + SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 34.0 # card-sized panel, taller than the thin bar strip + NOISE_COUNT = 26 + NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + EDGE_FADE_X = 40.0 # smooth horizontal fade-out of the scanline field + EDGE_FADE_Y = 34.0 # smooth vertical fade-out + INTRO_DURATION = 0.9 # brisk 'materialise out of static' when a card pops in + INTRO_STATIC = 900 # static specks at the very start of the intro + OUTRO_DURATION = 0.2 # snappy reverse dissolve back into static on dismiss + + def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, + intro_duration: float | None = None) -> None: + self.enabled = enabled + self._clip_func = clip_func # optional path-setter to clip the holo to the card shape + # widget whose opacity is ramped 0->1 during the intro so the card content + # genuinely fades in, rather than a solid haze block popping on + self._fade_widget = fade_widget + if intro_duration is not None: + self.INTRO_DURATION = intro_duration + self._sat_time = 0.0 + self._particles: list[dict] = [] + self._intro_t: float | None = None + self._outro_t: float | None = None + self._outro_done = None + # Wall-clock safety net: the frame-clock tick only advances while the + # compositor sends frame callbacks; if those stall the intro could freeze + # with content stuck at opacity 0. This timeout force-resolves it anyway. + self._intro_deadline_id: int | None = None + self._mask_cache: tuple | None = None + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from the card underneath + self.widget.add_css_class("beacon-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + if self._intro_t is not None: + self._intro_t += dt + if self._intro_t >= self.INTRO_DURATION: + self._finish_intro() + elif self._fade_widget is not None: + p = self._intro_t / self.INTRO_DURATION + self._fade_widget.set_opacity(p * p * (3 - 2 * p)) + if self._outro_t is not None: + self._outro_t += dt + po = min(1.0, self._outro_t / self.OUTRO_DURATION) + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) + if self._outro_t >= self.OUTRO_DURATION: + done = self._outro_done + self._outro_t = None + self._outro_done = None + if done is not None: + done() + self.widget.queue_draw() + + def _finish_intro(self) -> None: + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0) + self.widget.queue_draw() + + def start_intro(self) -> None: + """Kick off the 'card materialising out of static' opening effect.""" + if self.enabled: + self._outro_t = None + self._outro_done = None + self._intro_t = 0.0 + if self._fade_widget is not None: + self._fade_widget.set_opacity(0.0) + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = GLib.timeout_add( + int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline) + + def _on_intro_deadline(self) -> bool: + self._intro_deadline_id = None + if self._intro_t is not None: + self._finish_intro() + return False # one-shot + + def start_outro(self, on_done) -> None: + """Reverse of the intro (card dissolving back into static), then on_done.""" + if not self.enabled: + on_done() + return + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + self._outro_t = 0.0 + self._outro_done = on_done + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + if self._clip_func is not None: + cr.save() + self._clip_func(cr, width, height) # clip the scanlines to the card's rounded shape + cr.clip() + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.09) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/beacon/main.py b/beacon/main.py new file mode 100644 index 0000000..fb237b5 --- /dev/null +++ b/beacon/main.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""beacon — the Cosmonaut Shell notification daemon for hyprdrive. + +Replaces dunst: owns org.freedesktop.Notifications and renders each notification +as a holographic card (scanline/sweep/noise overlay, emitted-magenta text, violet +holo-glass, radio-squiggle divider) in a top-centre layer-shell stack, so +notifications read as one more orbit of the astro-menu / orbit-menu / station-bar +look instead of a plain popup. + + main.py run the resident daemon (owns the notification bus name, stays hidden + until a notification arrives) +""" + +from __future__ import annotations + +import os +import signal +import sys +from pathlib import Path + +# beacon-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement ahead +# of libwayland-client). Drop it once resident so it isn't inherited by anything +# this process launches (same rationale as the rest of the suite's main.py). +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import theme # noqa: E402 +from paths import APP_ID, FDN_NAME # noqa: E402 +from server import NotificationServer # noqa: E402 +from window import BeaconWindow # noqa: E402 + + +class BeaconApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.DEFAULT_FLAGS) + self.window: BeaconWindow | None = None + self._server: NotificationServer | None = None + self._name_id = 0 + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = BeaconWindow(self, on_closed=self._on_closed) + # Own the freedesktop notification name; the server is created once the bus + # connection is in hand. + self._name_id = Gio.bus_own_name( + Gio.BusType.SESSION, FDN_NAME, Gio.BusNameOwnerFlags.NONE, + self._on_bus_acquired, None, self._on_name_lost) + # SIGUSR1 = close every visible card (the `Super+Ctrl+C` keybind, which + # used to run `dunstctl close-all`). + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGUSR1, + self._on_sigusr1) + self.hold() # stay alive with no visible window + + def _on_sigusr1(self) -> bool: + if self.window is not None: + self.window.close_all() + return GLib.SOURCE_CONTINUE + + def _on_bus_acquired(self, connection: Gio.DBusConnection, _name: str) -> None: + assert self.window is not None + self._server = NotificationServer(connection, self.window) + + def _on_closed(self, nid: int, reason: int) -> None: + if self._server is not None: + self._server.emit_closed(nid, reason) + + def _on_name_lost(self, _connection, _name: str) -> None: + # Another notification daemon (e.g. a still-running dunst) already owns it. + sys.stderr.write( + "beacon: could not acquire org.freedesktop.Notifications " + "(another notification daemon is running); exiting.\n") + self.quit() + + def do_activate(self) -> None: + pass # resident daemon: nothing to do on activate + + +def main() -> int: + GLib.set_prgname("beacon") + return BeaconApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/beacon/notification.py b/beacon/notification.py new file mode 100644 index 0000000..3bdd27b --- /dev/null +++ b/beacon/notification.py @@ -0,0 +1,304 @@ +"""A single holographic notification card. + +Layout: [app icon] [ summary (emitted-magenta, letter-spaced) / radio-squiggle +divider / body ] with an optional row of action-pill buttons, all under a +scanline/sweep/noise HologramOverlay (lib/hologram.py) clipped to the card's +rounded rectangle. The card materialises out of static (holo intro) when it +appears and dissolves back into static when dismissed. +""" + +from __future__ import annotations + +import math +from typing import Callable, Optional + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk # noqa: E402 + +import config +from lib.hologram import HologramOverlay + +_CARD_RADIUS = 16.0 + +# Divider "radio wave" colour (Cairo, not CSS): a magenta wave line on a +# transparent background. Critical keeps an accent wave to match its frame. +_MAGENTA = (0xEB / 255, 0x00 / 255, 0xA6 / 255) # foreground wave (normal) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) # foreground wave (critical) +_SQUIGGLE_H = 14 # divider row height +_SQUIGGLE_AMP = 3.0 # wave amplitude (px) +_SQUIGGLE_CYCLES = 0.045 # cycles per px of width +_SQUIGGLE_SPEED = 2.4 # phase advance (rad/s) — a slowly travelling signal + + +def _rounded_path(cr, w: float, h: float, r: float = _CARD_RADIUS) -> None: + r = min(r, w / 2, h / 2) + cr.new_sub_path() + cr.arc(w - r, r, r, -math.pi / 2, 0) + cr.arc(w - r, h - r, r, 0, math.pi / 2) + cr.arc(r, h - r, r, math.pi / 2, math.pi) + cr.arc(r, r, r, math.pi, 3 * math.pi / 2) + cr.close_path() + + +class NotificationCard: + def __init__(self, nid: int, summary: str, body: str, urgency: int, + icon: str, image_data, actions: list[str], controls: list[dict], + on_action: Callable[[int, str], None], + on_control: Callable[[int, str, object], None], + on_dismiss: Callable[[int], None]) -> None: + self.nid = nid + self._on_action = on_action + self._on_control = on_control + self._on_dismiss = on_dismiss + self._dismissing = False + critical = urgency >= 2 + + # -- content ----------------------------------------------------------- + content = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + content.add_css_class("beacon-content") + + img = self._build_icon(icon, image_data) + if img is not None: + content.append(img) + + textcol = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + textcol.set_hexpand(True) + textcol.set_valign(Gtk.Align.CENTER) + + if summary: + lbl = Gtk.Label(label=summary, xalign=0.0) + lbl.add_css_class("beacon-summary") + lbl.set_wrap(True) + lbl.set_wrap_mode(2) # WORD_CHAR + lbl.set_max_width_chars(30) + textcol.append(lbl) + + self._squiggle: Optional[Gtk.DrawingArea] = None + if config.squiggle_enabled(): + self._squiggle_color = _ACCENT if critical else _MAGENTA + self._squiggle_phase = 0.0 + self._squiggle = Gtk.DrawingArea() + self._squiggle.add_css_class("beacon-squiggle") + self._squiggle.set_content_height(_SQUIGGLE_H) + self._squiggle.set_hexpand(True) + self._squiggle.set_draw_func(self._draw_squiggle) + textcol.append(self._squiggle) + + if body: + blbl = Gtk.Label(xalign=0.0) + blbl.add_css_class("beacon-body") + blbl.set_wrap(True) + blbl.set_wrap_mode(2) + blbl.set_max_width_chars(34) + # bodies may carry a small Pango-markup subset (///…); + # fall back to plain text if the sender's markup won't parse. + try: + blbl.set_markup(body) + except GLib.GError: + blbl.set_text(body) + textcol.append(blbl) + + content.append(textcol) + + if actions: + row = self._build_actions(actions) + if row is not None: + textcol.append(row) + + if controls: + crows = self._build_controls(controls) + for crow in crows: + textcol.append(crow) + + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + card.add_css_class("beacon-card") + if critical: + card.add_css_class("critical") + card.append(content) + card.set_size_request(340, -1) + + # -- hologram overlay -------------------------------------------------- + self._holo = HologramOverlay(enabled=config.hologram_enabled(), + clip_func=_rounded_path, fade_widget=content) + + overlay = Gtk.Overlay() + overlay.set_child(card) + overlay.add_overlay(self._holo.widget) + overlay.set_measure_overlay(self._holo.widget, False) + + # left-click anywhere on the card = invoke default action if present, else + # dismiss; right-click always dismisses. + self._default_action = "default" if "default" in actions else None + left = Gtk.GestureClick(button=1) + left.connect("released", self._on_left_click) + overlay.add_controller(left) + right = Gtk.GestureClick(button=3) + right.connect("released", lambda *_a: self.dismiss()) + overlay.add_controller(right) + + self.widget = overlay + self._holo.start_intro() + + # -- public --------------------------------------------------------------- + def tick(self, dt: float) -> None: + self._holo.tick(dt) + if self._squiggle is not None: + self._squiggle_phase += dt * _SQUIGGLE_SPEED + self._squiggle.queue_draw() # travel the wave along like a live signal + + def _draw_squiggle(self, _area, cr, width: int, height: int) -> None: + if width <= 0: + return + # transparent background — just the magenta (accent for critical) radio wave + mid = height / 2.0 + amp = min(_SQUIGGLE_AMP, mid - 2.0) + k = _SQUIGGLE_CYCLES * 2.0 * math.pi # angular freq per px + steps = max(2, int(width)) + cr.set_line_cap(cairo.LINE_CAP_ROUND) + cr.set_line_join(cairo.LINE_JOIN_ROUND) + # two passes: a soft wide glow, then a bright thin core — reads as emitted + # light over the purple bar, matching the card's holographic frame. + for line_w, alpha in ((3.0, 0.30), (1.4, 1.0)): + cr.set_line_width(line_w) + cr.set_source_rgba(*self._squiggle_color, alpha) + for i in range(steps + 1): + x = width * i / steps + y = mid + amp * math.sin(x * k + self._squiggle_phase) + cr.line_to(x, y) if i else cr.move_to(x, y) + cr.stroke() + + def dismiss(self) -> None: + """Play the dissolve, then hand the id back to the window for removal.""" + if self._dismissing: + return + self._dismissing = True + self._holo.start_outro(lambda: self._on_dismiss(self.nid)) + + # -- internals ------------------------------------------------------------ + def _on_left_click(self, *_a) -> None: + if self._default_action is not None: + self._on_action(self.nid, self._default_action) + else: + self.dismiss() + + def _build_actions(self, actions: list[str]) -> Optional[Gtk.Box]: + # actions is a flat [key, label, key, label, …] list; "default" is the + # implicit click action and isn't shown as a button. + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + row.add_css_class("beacon-actions") + row.set_margin_top(6) + shown = 0 + for i in range(0, len(actions) - 1, 2): + key, label = actions[i], actions[i + 1] + if key == "default": + continue + btn = Gtk.Button(label=label or key) + btn.add_css_class("beacon-action") + btn.connect("clicked", lambda _b, k=key: self._on_action(self.nid, k)) + row.append(btn) + shown += 1 + return row if shown else None + + def _build_controls(self, controls: list[dict]) -> list[Gtk.Widget]: + # button-type controls share one pill row (same idiom as plain actions); + # each stateful control (toggle/slider/entry) gets its own full-width row, + # since a slider/entry can't sensibly squeeze into a compact pill. + rows: list[Gtk.Widget] = [] + btn_row: Optional[Gtk.Box] = None + for c in controls: + if c["type"] == "button": + if btn_row is None: + btn_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + btn_row.add_css_class("beacon-actions") + btn_row.add_css_class("beacon-control-row") + btn_row.set_margin_top(6) + rows.append(btn_row) + btn = Gtk.Button(label=c["label"]) + btn.add_css_class("beacon-action") + btn.connect("clicked", lambda _b, c=c: self._fire_control(c, True)) + btn_row.append(btn) + continue + + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.add_css_class("beacon-control-row") + row.set_margin_top(6) + lbl = Gtk.Label(label=c["label"], xalign=0.0) + lbl.add_css_class("beacon-control-label") + row.append(lbl) + + if c["type"] == "toggle": + sw = Gtk.Switch() + sw.add_css_class("beacon-toggle") + sw.set_active(bool(c["value"])) + sw.set_valign(Gtk.Align.CENTER) + sw.set_hexpand(True) + sw.set_halign(Gtk.Align.END) + sw.connect("state-set", lambda _s, state, c=c: self._on_toggle(c, state)) + row.append(sw) + elif c["type"] == "slider": + adj = Gtk.Adjustment(value=c["value"], lower=c["min"], upper=c["max"], + step_increment=c["step"]) + scale = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL, adjustment=adj) + scale.add_css_class("beacon-slider") + scale.set_hexpand(True) + scale.set_draw_value(False) + scale.connect("value-changed", + lambda s, c=c: self._fire_control(c, s.get_value())) + row.append(scale) + elif c["type"] == "entry": + entry = Gtk.Entry() + entry.add_css_class("beacon-entry") + entry.set_hexpand(True) + if c.get("placeholder"): + entry.set_placeholder_text(c["placeholder"]) + entry.connect("activate", + lambda e, c=c: self._fire_control(c, e.get_text())) + row.append(entry) + + rows.append(row) + return rows + + def _on_toggle(self, control: dict, state: bool) -> bool: + self._fire_control(control, state) + return False # False = let Gtk.Switch apply the requested visual state itself + + def _fire_control(self, control: dict, value) -> None: + self._on_control(self.nid, control["id"], value) + if control.get("dismiss"): + self.dismiss() + + def _build_icon(self, icon: str, image_data) -> Optional[Gtk.Image]: + img: Optional[Gtk.Image] = None + if image_data is not None: + pb = self._pixbuf_from_hint(image_data) + if pb is not None: + img = Gtk.Image.new_from_paintable(Gdk.Texture.new_for_pixbuf(pb)) + if img is None and icon: + if icon.startswith("file://"): + icon = icon[len("file://"):] + if icon.startswith("/"): + img = Gtk.Image.new_from_file(icon) + else: + img = Gtk.Image.new_from_icon_name(icon) + if img is None: + return None + img.add_css_class("beacon-icon") + img.set_pixel_size(44) + img.set_valign(Gtk.Align.START) + return img + + @staticmethod + def _pixbuf_from_hint(data) -> Optional[GdkPixbuf.Pixbuf]: + # Spec "image-data": (width, height, rowstride, has_alpha, bits, channels, bytes) + try: + w, h, rowstride, has_alpha, bits, channels, raw = data + return GdkPixbuf.Pixbuf.new_from_bytes( + GLib.Bytes.new(bytes(raw)), GdkPixbuf.Colorspace.RGB, + has_alpha, bits, w, h, rowstride) + except (ValueError, TypeError): + return None diff --git a/beacon/paths.py b/beacon/paths.py new file mode 100644 index 0000000..d98ad92 --- /dev/null +++ b/beacon/paths.py @@ -0,0 +1,41 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# GApplication single-instance id (our own process). NOT the freedesktop +# notification name — that well-known name (org.freedesktop.Notifications) is +# owned separately in main.py, the same one dunst used to hold. +APP_ID = "eu.abdelbaki.beacon" + +FDN_NAME = "org.freedesktop.Notifications" +FDN_PATH = "/org/freedesktop/Notifications" +FDN_IFACE = "org.freedesktop.Notifications" + +# Custom interfaces colocated at FDN_PATH, same well-known bus name — mirrors +# dunst parking org.dunstproject.cmd0 alongside its own freedesktop interface +# rather than using a bespoke object path. +HISTORY_IFACE = "eu.abdelbaki.beacon.History1" +CONTROLS_IFACE = "eu.abdelbaki.beacon.Controls1" + +# Notification history persists under XDG_CACHE_HOME, like the layouts +# registry's ~/.cache/astro-menu/layouts.json — NOT ~/.config/beacon, which +# config-updater wipes and re-copies on every dotfiles sync. +CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "beacon" +HISTORY_FILE = CACHE_DIR / "history.json" + +# User settings live under XDG_STATE_HOME, same rationale/convention as +# station-bar/paths.py (config-updater's rm -rf would wipe a hand-edited +# ~/.config/beacon/config.json otherwise). +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "beacon" +CONFIG_FILE = STATE_DIR / "config.json" + + +def ensure_dirs() -> None: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/beacon/server.py b/beacon/server.py new file mode 100644 index 0000000..cd3ce54 --- /dev/null +++ b/beacon/server.py @@ -0,0 +1,370 @@ +"""org.freedesktop.Notifications D-Bus service — the well-known name dunst used +to own. Parses each Notify call and hands it to the stack window (window.py) to +render as a holographic card; owns per-notification expiry timers and emits the +spec's NotificationClosed / ActionInvoked signals. + +Also owns two custom interfaces colocated at the same object path (mirrors +dunst parking org.dunstproject.cmd0 alongside its own freedesktop interface): + + eu.abdelbaki.beacon.History1 disk-persisted notification history — + List/Get/Pop/PopLatest/Remove/Clear/ + InvokeAction + HistoryAdded/Removed/Cleared + signals. See history.py for the storage side. + + eu.abdelbaki.beacon.Controls1 push-only: ControlChanged(id, control_id, + value) for the rich embedded controls apps + can opt into via the x-beacon-controls hint + (see controls.py). + +Deliberately small: the visible/interactive spec subset (body markup, actions, +icons, urgency, replaces_id, timeouts, persistence) — no sound/markup-hint +gymnastics. +""" + +from __future__ import annotations + +import time + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +import config +from controls import parse_controls +from history import HistoryStore +from paths import CONTROLS_IFACE, FDN_IFACE, FDN_PATH, HISTORY_IFACE + +# reasons per the spec: 1 expired, 2 dismissed by user, 3 closed by call, 4 undefined +_INTROSPECTION_XML = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +# a{sv} per entry (not a fixed tuple shape) so new fields can be added later +# without breaking existing clients — the same shape dunst's own +# NotificationListHistory uses. +_HISTORY_INTROSPECTION_XML = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + +_CONTROLS_INTROSPECTION_XML = """ + + + + + + + + + +""" + +# server default timeouts (ms) by urgency when the client passes -1 +_DEFAULT_TIMEOUT = {0: 5000, 1: 8000, 2: 0} # low / normal / critical(never) + + +class NotificationServer: + def __init__(self, connection: Gio.DBusConnection, window) -> None: + self._conn = connection + self._window = window + self._next_id = 1 + self._timers: dict[int, int] = {} + # metadata for still-visible notifications, keyed by id — snapshotted at + # Notify() time (before render) so emit_closed() can hand a full record + # to history without window.py/notification.py needing to know about it. + self._live_meta: dict[int, dict] = {} + + self._history_enabled = config.history_enabled() + self._history = HistoryStore(maxlen=config.history_length(), + persist=config.history_persist()) + + node = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML) + self._iface = node.interfaces[0] + connection.register_object( + FDN_PATH, self._iface, self._on_method_call, None, None) + + hnode = Gio.DBusNodeInfo.new_for_xml(_HISTORY_INTROSPECTION_XML) + connection.register_object( + FDN_PATH, hnode.interfaces[0], self._on_history_method_call, None, None) + + cnode = Gio.DBusNodeInfo.new_for_xml(_CONTROLS_INTROSPECTION_XML) + connection.register_object( + FDN_PATH, cnode.interfaces[0], self._on_controls_method_call, None, None) + + # -- D-Bus dispatch: org.freedesktop.Notifications ------------------------- + def _on_method_call(self, _conn, _sender, _path, _iface, method, params, invocation): + if method == "Notify": + invocation.return_value(GLib.Variant("(u)", (self._notify(params),))) + elif method == "CloseNotification": + (nid,) = params.unpack() + self._window.close_notification(int(nid), reason=3) + self._cancel_timer(int(nid)) + invocation.return_value(None) + elif method == "GetCapabilities": + invocation.return_value(GLib.Variant("(as)", ( + ["body", "body-markup", "icon-static", "actions", "persistence", + "x-beacon-controls"],))) + elif method == "GetServerInformation": + invocation.return_value(GLib.Variant("(ssss)", ( + "beacon", "abdelbaki.eu", "1.0", "1.2"))) + else: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method) + + def _notify(self, params) -> int: + (app_name, replaces_id, app_icon, summary, body, + actions, hints, expire_timeout) = params.unpack() + + nid = int(replaces_id) if replaces_id else self._next_id + if not replaces_id: + self._next_id += 1 + + urgency = int(hints.get("urgency", 1)) + image_data = (hints.get("image-data") or hints.get("image_data") + or hints.get("icon_data")) + image_path = hints.get("image-path") or hints.get("image_path") + icon = image_path or app_icon or "" + controls_raw = hints.get("x-beacon-controls") + controls = parse_controls(controls_raw) + + self._live_meta[nid] = { + "id": nid, "app_name": app_name or "", "summary": summary, + "body": body, "urgency": urgency, "icon": icon, + "actions": list(actions), "controls": controls_raw, + "timestamp": time.time(), + } + + self._window.show_notification( + nid, summary, body, urgency, icon, image_data, list(actions), + controls, self._emit_action, self._emit_control) + + self._arm_timer(nid, int(expire_timeout), urgency) + return nid + + # -- expiry timers -------------------------------------------------------- + def _arm_timer(self, nid: int, expire_timeout: int, urgency: int) -> None: + self._cancel_timer(nid) + if expire_timeout < 0: + ms = _DEFAULT_TIMEOUT.get(urgency, 8000) + else: + ms = expire_timeout + if ms <= 0: + return # 0 = never expire (or critical default) + self._timers[nid] = GLib.timeout_add(ms, self._on_expire, nid) + + def _on_expire(self, nid: int) -> bool: + self._timers.pop(nid, None) + self._window.close_notification(nid, reason=1) # 1 = expired + return False + + def _cancel_timer(self, nid: int) -> None: + tid = self._timers.pop(nid, None) + if tid is not None: + GLib.source_remove(tid) + + # -- signals (also the window's on_closed / on_action / on_control callbacks) + def emit_closed(self, nid: int, reason: int) -> None: + self._cancel_timer(nid) + self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "NotificationClosed", + GLib.Variant("(uu)", (nid, reason))) + meta = self._live_meta.pop(nid, None) + if meta is not None and self._history_enabled: + entry = {**meta, "reason": reason} + evicted = self._history.record(entry) + self._emit_history_signal("HistoryAdded", GLib.Variant( + "(a{sv})", (_entry_dict(entry),))) + if evicted is not None: + self._emit_history_signal("HistoryRemoved", GLib.Variant( + "(u)", (int(evicted["id"]),))) + + def _emit_action(self, nid: int, key: str) -> None: + self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked", + GLib.Variant("(us)", (nid, key))) + # dismiss the card once its action fired, matching typical daemon behaviour + self._window.close_notification(nid, reason=2) + + def _emit_control(self, nid: int, control_id: str, value) -> None: + self._conn.emit_signal(None, FDN_PATH, CONTROLS_IFACE, "ControlChanged", + GLib.Variant("(usv)", (nid, control_id, _variant_for(value)))) + + def _emit_history_signal(self, name: str, payload: GLib.Variant) -> None: + self._conn.emit_signal(None, FDN_PATH, HISTORY_IFACE, name, payload) + + # -- D-Bus dispatch: eu.abdelbaki.beacon.History1 -------------------------- + def _on_history_method_call(self, _conn, _sender, _path, _iface, method, params, invocation): + if method == "List": + entries = [_entry_dict(e) for e in self._history.list_all()] + invocation.return_value(GLib.Variant("(aa{sv})", (entries,))) + elif method == "Get": + (nid,) = params.unpack() + entry = self._history.get(int(nid)) + if entry is None: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, + f"no history entry with id {nid}") + else: + invocation.return_value(GLib.Variant("(a{sv})", (_entry_dict(entry),))) + elif method == "Pop": + (nid,) = params.unpack() + entry = self._history.pop(int(nid)) + if entry is None: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, + f"no history entry with id {nid}") + else: + self._emit_history_signal("HistoryRemoved", GLib.Variant("(u)", (int(nid),))) + invocation.return_value(GLib.Variant("(u)", (self._redisplay(entry),))) + elif method == "PopLatest": + entry = self._history.pop_latest() + if entry is None: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, "history is empty") + else: + self._emit_history_signal( + "HistoryRemoved", GLib.Variant("(u)", (int(entry["id"]),))) + invocation.return_value(GLib.Variant("(u)", (self._redisplay(entry),))) + elif method == "Remove": + (nid,) = params.unpack() + if self._history.remove(int(nid)): + self._emit_history_signal("HistoryRemoved", GLib.Variant("(u)", (int(nid),))) + invocation.return_value(None) + elif method == "Clear": + removed = self._history.clear() + self._emit_history_signal("HistoryCleared", GLib.Variant("(u)", (len(removed),))) + invocation.return_value(None) + elif method == "InvokeAction": + (nid, key) = params.unpack() + entry = self._history.get(int(nid)) + if entry is None: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, + f"no history entry with id {nid}") + else: + # Re-emit ActionInvoked for a *historical* entry without bringing the + # card back on-screen — beacon (unlike dunst) doesn't void actions once + # a notification leaves the queue, since invoking one has always just + # meant "emit the signal," independent of whether a card widget exists. + self._conn.emit_signal(None, FDN_PATH, FDN_IFACE, "ActionInvoked", + GLib.Variant("(us)", (int(nid), key))) + invocation.return_value(None) + else: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method) + + def _redisplay(self, entry: dict) -> int: + """Re-render a history entry as a live card (History1.Pop/PopLatest).""" + new_nid = self._next_id + self._next_id += 1 + controls = parse_controls(entry.get("controls")) + self._window.show_notification( + new_nid, entry.get("summary", ""), entry.get("body", ""), + int(entry.get("urgency", 1)), entry.get("icon", ""), None, + list(entry.get("actions") or []), controls, + self._emit_action, self._emit_control) + self._live_meta[new_nid] = { + "id": new_nid, "app_name": entry.get("app_name", ""), + "summary": entry.get("summary", ""), "body": entry.get("body", ""), + "urgency": int(entry.get("urgency", 1)), "icon": entry.get("icon", ""), + "actions": list(entry.get("actions") or []), + "controls": entry.get("controls"), "timestamp": time.time(), + } + self._arm_timer(new_nid, -1, int(entry.get("urgency", 1))) + return new_nid + + # -- D-Bus dispatch: eu.abdelbaki.beacon.Controls1 (signal-only, no methods) + def _on_controls_method_call(self, _conn, _sender, _path, _iface, method, _params, invocation): + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD, method) + + +def _entry_dict(entry: dict) -> dict: + """A plain dict with GLib.Variant leaf values, ready to embed in an a{sv}.""" + return { + "id": GLib.Variant("u", int(entry["id"])), + "app_name": GLib.Variant("s", entry.get("app_name") or ""), + "summary": GLib.Variant("s", entry.get("summary") or ""), + "body": GLib.Variant("s", entry.get("body") or ""), + "icon": GLib.Variant("s", entry.get("icon") or ""), + "urgency": GLib.Variant("i", int(entry.get("urgency", 1))), + "timestamp": GLib.Variant("d", float(entry.get("timestamp", 0.0))), + "reason": GLib.Variant("i", int(entry.get("reason", 0))), + "actions": GLib.Variant("as", list(entry.get("actions") or [])), + "controls": GLib.Variant("s", entry.get("controls") or ""), + } + + +def _variant_for(value) -> GLib.Variant: + if isinstance(value, bool): + return GLib.Variant("b", value) + if isinstance(value, (int, float)): + return GLib.Variant("d", float(value)) + return GLib.Variant("s", str(value)) diff --git a/beacon/style/_colors.css b/beacon/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/beacon/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/beacon/style/style.css b/beacon/style/style.css new file mode 100644 index 0000000..749e1c3 --- /dev/null +++ b/beacon/style/style.css @@ -0,0 +1,152 @@ +/* beacon — holographic notification cards. Matches the astro-menu idiom + * (astro-menu/style/style.css): emitted-magenta text, violet holo-glass fills, + * glow-violet / accent frames, Agave Nerd Font Mono, rounded cards. The compositor + * blurs behind the translucent card fills (see the `beacon` layer-rule in + * hypr/usr/windowrules.lua); the scanline/sweep/noise depth is Cairo-drawn on top + * by lib/hologram.py. */ + +/* Emitted magenta reads as projected light on the blurred glass, exactly as the + * astro panel overrides its @text. */ +@define-color text #EB00A6; + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 12pt; +} + +/* The CyberQueer GTK theme paints `* { background-color:#1a1a1a }` on every node, + * which would fill the surface and the gaps between cards with an opaque slab. + * Blank the structural nodes; the card asserts its own glass fill below. */ +window, +window.background, +.beacon-window, +.beacon-stack, +.beacon-content, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +/* the holo-glass card */ +.beacon-card { + background-color: alpha(@violet, 0.40); + border: 2px solid #8A5CFF; /* glow_violet — emitted-light frame */ + border-radius: 16px; + padding: 12px 14px; + box-shadow: 0 0 16px 1px alpha(#8A5CFF, 0.35); +} +.beacon-card.critical { + border-color: @accent; + background-color: alpha(@accent, 0.14); + box-shadow: 0 0 18px 1px alpha(@accent, 0.40); +} + +/* summary = emitted magenta, letter-spaced like the astro HUD headlines */ +.beacon-summary { + color: @text; + font-weight: bold; + font-size: 12pt; + letter-spacing: 1px; +} +.beacon-card.critical .beacon-summary { color: @accent; } + +/* radio-wave divider — a Cairo sine path drawn in notification.py (colour handled + * there, per urgency); this only spaces it from the title/body. The explicit + * transparent bg beats the CyberQueer theme's `* { background-color:#1a1a1a }` + * (a class selector out-specifies its universal one), so no dark pill shows. */ +.beacon-squiggle { + margin: 2px 0; + background: none; + background-color: transparent; + border: none; + box-shadow: none; +} + +.beacon-body { + color: @text; + font-size: 11pt; + opacity: 0.95; +} + +.beacon-icon { margin-right: 2px; } + +/* action pills — astro-menu's .quad-action idiom */ +.beacon-action { + color: @text; + background-color: alpha(@violet, 0.4); + border: 2px solid #8A5CFF; + border-radius: 20px; + padding: 2px 12px; + min-height: 22px; + transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease; +} +.beacon-action:hover { + border-color: @accent; + color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.55); +} + +/* embedded controls (x-beacon-controls hint) — same violet-glass/magenta/ + * accent palette as .beacon-action, just in the shapes GTK ships (switch/ + * scale/entry) instead of another Cairo-drawn widget. */ +.beacon-control-row { background: transparent; } + +.beacon-control-label { + color: @text; + font-size: 11pt; + opacity: 0.95; +} + +.beacon-toggle { + background-color: alpha(@violet, 0.4); + border: 2px solid #8A5CFF; + border-radius: 20px; + min-width: 40px; + min-height: 22px; +} +.beacon-toggle:checked { + background-color: alpha(@accent, 0.5); + border-color: @accent; + box-shadow: 0 0 10px 1px alpha(@accent, 0.45); +} +.beacon-toggle slider { + background-color: @text; + border-radius: 50%; +} + +.beacon-slider trough { + background-color: alpha(@violet, 0.4); + border: 2px solid #8A5CFF; + border-radius: 20px; + min-height: 8px; +} +.beacon-slider highlight { + background-color: @accent; + border-radius: 20px; +} +.beacon-slider slider { + background-color: @text; + border: 2px solid #8A5CFF; + border-radius: 50%; + min-width: 14px; + min-height: 14px; +} + +.beacon-entry { + color: @text; + background-color: alpha(@violet, 0.4); + border: 2px solid #8A5CFF; + border-radius: 12px; + padding: 2px 10px; + min-height: 22px; +} +.beacon-entry:focus-within { + border-color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.55); +} + +.beacon-hologram { background: transparent; } diff --git a/beacon/theme.py b/beacon/theme.py new file mode 100644 index 0000000..82dae80 --- /dev/null +++ b/beacon/theme.py @@ -0,0 +1,30 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as the rest +of the Cosmonaut Shell suite (orbit-menu, horizon-dock, astro-menu, station-bar). +_colors.css defines the CyberQueer @define-color names; style.css consumes them. + +Priority is USER+1 for the same reason as the others: the CyberQueer GTK theme +at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION +(600), and would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/beacon/window.py b/beacon/window.py new file mode 100644 index 0000000..d71103a --- /dev/null +++ b/beacon/window.py @@ -0,0 +1,138 @@ +"""The notification stack: a top-centre layer-shell surface that holds the live +notification cards and drives one frame-clock tick for all their holograms. + +Sized to its content (anchored TOP only, so it floats centred like dunst did), +on the OVERLAY layer above normal windows. Newest card on top. The surface is +hidden whenever no cards are showing, so it never eats clicks on an empty screen. +""" + +from __future__ import annotations + +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +from notification import NotificationCard + +MARGIN_TOP = 46 +MAX_VISIBLE = 6 + + +class BeaconWindow(Gtk.ApplicationWindow): + def __init__(self, app: Gtk.Application, + on_closed: Callable[[int, int], None]) -> None: + super().__init__(application=app) + self._on_closed = on_closed # (id, reason) -> emit NotificationClosed + self._cards: dict[int, NotificationCard] = {} + self._order: list[int] = [] # newest first + self._tick_id: Optional[int] = None + self._last_tick: Optional[float] = None + + self.set_decorated(False) + self.add_css_class("beacon-window") + + self._stack = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self._stack.add_css_class("beacon-stack") + self._stack.set_halign(Gtk.Align.CENTER) + self._stack.set_valign(Gtk.Align.START) + self.set_child(self._stack) + + self._init_layer_shell() + self.set_visible(False) + + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.OVERLAY) + LayerShell.set_namespace(self, "beacon") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE) + LayerShell.set_anchor(self, LayerShell.Edge.TOP, True) + LayerShell.set_margin(self, LayerShell.Edge.TOP, MARGIN_TOP) + LayerShell.set_exclusive_zone(self, 0) + + # -- public: driven by the D-Bus server ----------------------------------- + def show_notification(self, nid: int, summary: str, body: str, urgency: int, + icon: str, image_data, actions: list[str], + controls: list[dict], + on_action: Callable[[int, str], None], + on_control: Callable[[int, str, object], None]) -> None: + if nid in self._cards: # replaces_id: swap in place + self._drop_card(nid) + + card = NotificationCard(nid, summary, body, urgency, icon, image_data, + actions, controls, on_action, on_control, + self._card_dismissed) + self._cards[nid] = card + self._order.insert(0, nid) + self._stack.prepend(card.widget) + + # cap the stack: quietly expire the oldest beyond the limit + while len(self._order) > MAX_VISIBLE: + old = self._order[-1] + self._card_dismissed(old, reason=4) # 4 = undefined/expired-by-limit + + self.set_visible(True) + self._ensure_tick() + + def close_notification(self, nid: int, reason: int = 3) -> bool: + """Server-/user-requested close. reason 3 = closed by CloseNotification.""" + if nid not in self._cards: + return False + card = self._cards[nid] + # play the dissolve; removal + the NotificationClosed signal follow + card.dismiss() + self._pending_reason[nid] = reason + return True + + def close_all(self, reason: int = 2) -> None: + """Dismiss every visible card (the old `dunstctl close-all` keybind). + reason 2 = dismissed by user. Each card plays its dissolve; removal and + the NotificationClosed signals follow as the outros finish.""" + for nid in list(self._order): + self.close_notification(nid, reason=reason) + + # -- card lifecycle ------------------------------------------------------- + _pending_reason: dict[int, int] = {} + + def _card_dismissed(self, nid: int, reason: int = 2) -> None: + """Called after a card's dissolve finishes (reason 2 = dismissed by user), + or directly for cap-expiry. Removes it and signals the closure.""" + if nid not in self._cards: + return + reason = self._pending_reason.pop(nid, reason) + self._drop_card(nid) + self._on_closed(nid, reason) + if not self._cards: + self.set_visible(False) + self._stop_tick() + + def _drop_card(self, nid: int) -> None: + card = self._cards.pop(nid, None) + if card is not None: + self._stack.remove(card.widget) + if nid in self._order: + self._order.remove(nid) + + # -- animation tick ------------------------------------------------------- + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + for card in list(self._cards.values()): + card.tick(dt) + return True + + def _ensure_tick(self) -> None: + if self._tick_id is None: + self._last_tick = None + self._tick_id = self.add_tick_callback(self._on_tick) + + def _stop_tick(self) -> None: + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + self._last_tick = None diff --git a/horizon-dock/.gitignore b/horizon-dock/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/horizon-dock/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/horizon-dock/apps.py b/horizon-dock/apps.py new file mode 100644 index 0000000..3476802 --- /dev/null +++ b/horizon-dock/apps.py @@ -0,0 +1,77 @@ +"""All-apps data source (AstalApps, same library astal-menu's appdrawer uses) plus +favorite-app persistence — a plain JSON list of desktop-entry IDs under +~/.local/state/horizon-dock/favorites.json (see paths.py for why STATE_DIR, not +CONFIG_DIR).""" + +from __future__ import annotations + +import json + +import gi + +gi.require_version("AstalApps", "0.1") +from gi.repository import AstalApps # noqa: E402 + +from paths import FAVORITES_FILE, ensure_dirs + + +class AppSource: + def __init__(self) -> None: + self._apps = AstalApps.Apps() + self._favorites: list[str] = self._load_favorites() + self._wm_index: dict[str, str] = {} + + # -- listing ------------------------------------------------------------- + def all_apps(self) -> list: + """Every installed app, alphabetical by name.""" + apps = list(self._apps.get_list()) + apps.sort(key=lambda a: (a.get_name() or "").lower()) + return apps + + def icon_for_window(self, window: dict) -> str: + """Map an open window's wm class to its installed app's icon — same + wm-class/executable/name index astal-menu/ui/taskbar.py builds for its + own taskbar, rebuilt lazily here on first use.""" + if not self._wm_index: + for app in self._apps.get_list(): + for key in (app.get_wm_class(), app.get_executable(), app.get_name()): + if key: + self._wm_index.setdefault(key.lower(), app.get_icon_name()) + cls = (window.get("class") or "").lower() + return self._wm_index.get(cls) or cls or "application-x-executable" + + def favorite_apps(self) -> list: + """Favorited apps, in the order they were pinned. Entries whose app has + since been uninstalled are silently dropped (not un-favorited — the + desktop file may just be temporarily missing, e.g. mid-update).""" + by_entry = {a.get_entry(): a for a in self._apps.get_list()} + return [by_entry[e] for e in self._favorites if e in by_entry] + + @staticmethod + def launch(app) -> None: + app.launch() + + # -- favorites persistence ------------------------------------------------ + def is_favorite(self, app) -> bool: + return app.get_entry() in self._favorites + + def toggle_favorite(self, app) -> None: + entry = app.get_entry() + if not entry: + return + if entry in self._favorites: + self._favorites.remove(entry) + else: + self._favorites.append(entry) + self._save_favorites() + + def _load_favorites(self) -> list[str]: + try: + data = json.loads(FAVORITES_FILE.read_text()) + return [e for e in data if isinstance(e, str)] + except (FileNotFoundError, json.JSONDecodeError): + return [] + + def _save_favorites(self) -> None: + ensure_dirs() + FAVORITES_FILE.write_text(json.dumps(self._favorites, indent=2) + "\n") diff --git a/horizon-dock/config.py b/horizon-dock/config.py new file mode 100644 index 0000000..9f43f0e --- /dev/null +++ b/horizon-dock/config.py @@ -0,0 +1,37 @@ +"""Tiny user-editable config file: ~/.local/state/horizon-dock/config.json. + +Read once at startup (main.py); flags are passed into HorizonDock's constructor +rather than polled, so a change takes effect on the next horizon-dock-start.sh +restart, not live. Same pattern as orbit-menu/config.py. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +# "tray": still undecided whether the full StatusNotifierItem satellite earns its +# keep vs a simpler dock — default on, but easy to switch off without touching code. +# "hologram": same toggle orbit-menu/astro-menu expose for their own overlay. +_DEFAULTS = {"tray": True, "hologram": True} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def tray_enabled() -> bool: + return bool(_load().get("tray", True)) + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) diff --git a/horizon-dock/dock.py b/horizon-dock/dock.py new file mode 100644 index 0000000..54fbd02 --- /dev/null +++ b/horizon-dock/dock.py @@ -0,0 +1,660 @@ +"""horizon-dock — a hover-scrollable orbital dock, matching orbit-menu's visual +language (same CyberQueer glow/hover "planet" node styling). + +Anchored full-width to the bottom of the screen. Toggled show/hide (not an +always-resident hover-reveal dock); every time it's shown it fades+slides up +into place from below the screen edge, and hiding reverses that — driven by the +same per-frame tick-callback+easing pattern orbit-menu uses for its own +animations. Three rows of circular "planet" icon buttons — Open Windows, +Favorites, All Apps — each laid out evenly-spaced along a shared giant-circle +arc (the "horizon" curve: a shallow dip toward the screen edges, radius derived +from the monitor width so the sag reads consistently at any resolution). + +Hovering a row's Y-band selects it as the active scroll target — mouse-wheel +scrolling only ever moves the currently-hovered row (Gtk.EventControllerScroll), +independent of the other two. The tray satellite sits at the fixed right edge of +the Favorites row: its position is never touched by that row's scroll +repositioning, and it's added to the canvas last so it paints on top — meaning +overflowing favorites scrolling under it are simply covered, reading as "going +behind" the satellite. +""" + +from __future__ import annotations + +import json +import math +import random +import subprocess +import time +from typing import Callable, Optional + +import cairo + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, GLib, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +import apps as apps_source +import windows as windows_source +from lib.hologram import HologramOverlay +from tray import TrayHost + +# CyberQueer accent/violet — hardcoded here as orbit-menu's own orbit_menu.py +# does for its Cairo drawing, since Cairo paints directly and doesn't see GTK +# CSS @define-color names. Kept in sync by eye with style/_colors.css. +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) + + +class HorizonDock(Gtk.Window): + # The dock is one big "orbit node" (a glowing violet planet) whose centre sits + # below the screen, so only its top cap rises above the bottom edge. The three + # item rows are concentric rings on that cap and their icons ride it like + # satellites orbiting the node — the same visual language as orbit-menu. + # DOCK_HEIGHT is derived per-monitor from the two shape constraints below. + # A horizon-perspective dock: the orbits are foreshortened ellipse arcs that + # all converge to a HORIZON_Y line near the top, so they read as concentric + # rings lying on a plane receding to the horizon (à la a low-angle view of a + # ringed planet). Icons ride the front (near) edge of each ring. Short. + DOCK_HEIGHT = 210 # placeholder; recomputed per width (see _dock_height) + PLANET_SIZE = 52 + ITEM_SPACING = 66.0 # center-to-center distance between planets in a row + BASE_MARGIN = 16.0 # the horizon line sits this far above the bottom edge + PERSPECTIVE_SQUASH = 0.19 # vertical foreshortening: low = flat, far-away horizon + + ROW_ORDER = ["windows", "favorites", "apps"] # far -> near, top(horizon) -> bottom(front) + # each ring's horizontal radius as a fraction of the screen width; nearer rings + # (apps) are wider, farther rings (windows) narrower — that's the perspective. + ROW_HALFWIDTH = {"windows": 0.17, "favorites": 0.245, "apps": 0.32} + ROW_TITLE = {"windows": "Open Windows", "favorites": "Favorites", "apps": "All Apps"} + HOVER_BAND = 46.0 # how near the cursor must be to a ring to select it + + GLYPH_FONT = "Agave Nerd Font Mono" + # decorative glyphs sprinkled along the orbits between icons (nerd-font) + ORBIT_GLYPHS = ["\uf444", "\uf10c", "\U000f0471", "\U000f1383", "\uf005"] + # Two ornamental satellite rings framing the (innermost) windows orbit \u2014 one + # just inside it, one just outside \u2014 as fractions of the windows ring radius. + SAT_RING_FACTORS = (0.60, 1.20) + + TRAY_SIZE = 40 + TRAY_MARGIN = 30.0 + SCROLL_SENSITIVITY = 0.9 + + def __init__(self, on_close: Optional[Callable[[], None]] = None, tray_enabled: bool = True, + hologram_enabled: bool = True): + super().__init__() + self.add_css_class("horizon-dock-window") + self._on_close = on_close + self._tray_enabled = False # tray removed — the dock is a clean orbit node now + + self._apps = apps_source.AppSource() + self._tray = None + + self._width = self._monitor_width() + self.DOCK_HEIGHT = self._dock_height() # per-monitor, shadows the class default + self._scroll_offset = {row: 0.0 for row in self.ROW_ORDER} + self._items: dict[str, list] = {row: [] for row in self.ROW_ORDER} # source objects + self._widgets: dict[str, list[Gtk.Widget]] = {row: [] for row in self.ROW_ORDER} + self._hovered_row: Optional[str] = None + + self._sat_time = 0.0 + self._last_tick: Optional[float] = None + self._tick_id: Optional[int] = None + + # cursor tracking for the mouse-reactive ornamental satellites + self._mouse_x = self._width / 2 + self._mouse_y = 0.0 + self._mouse_sx = self._mouse_x # smoothed (eased) toward the raw position + self._mouse_sy = self._mouse_y + self._satellites = self._make_satellites() + + self._init_layer_shell() + + self._bg = Gtk.DrawingArea() + self._bg.set_size_request(int(self._width), self.DOCK_HEIGHT) + self._bg.set_draw_func(self._draw_background) + self._bg.add_css_class("horizon-canvas") + + self._content = Gtk.Fixed() + self._content.set_size_request(int(self._width), self.DOCK_HEIGHT) + self._content.put(self._bg, 0, 0) + + self._viewport = Gtk.Fixed() + self._viewport.set_size_request(int(self._width), self.DOCK_HEIGHT) + self._viewport.put(self._content, 0, 0) + + # A Gtk.Fixed grows to the bounding box of ALL its children, and the dock + # places every app button at an absolute position (many far off-screen, + # each with an arc "dip" that grows unbounded with distance from centre). + # So the viewport's natural size balloons to thousands of px in both axes, + # and the layer-shell surface adopts that size — covering the whole screen + # with an (input-grabbing) surface. Clip it: a base Overlay sized ONLY to a + # DOCK_HEIGHT sizer, with the viewport added as a non-measured overlay + # child and overflow hidden, pins the window to exactly (width x + # DOCK_HEIGHT) no matter how large the Fixed inside gets. + self._sizer = Gtk.DrawingArea() + self._sizer.set_size_request(int(self._width), self.DOCK_HEIGHT) + clip = Gtk.Overlay() + clip.set_overflow(Gtk.Overflow.HIDDEN) + clip.set_child(self._sizer) + clip.add_overlay(self._viewport) + clip.set_measure_overlay(self._viewport, False) + + self._hologram = HologramOverlay(enabled=hologram_enabled, clip_func=self._holo_clip, + fade_widget=self._content, intro_duration=2.4) + overlay = Gtk.Overlay() + overlay.set_child(clip) + overlay.add_overlay(self._hologram.widget) + self.set_child(overlay) + + motion = Gtk.EventControllerMotion() + motion.connect("motion", self._on_motion) + motion.connect("leave", self._on_motion_leave) + self.add_controller(motion) + + scroll = Gtk.EventControllerScroll() + scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) + scroll.connect("scroll", self._on_scroll) + self.add_controller(scroll) + + self._tray_satellite: Optional[Gtk.Widget] = None + self._tray_popover: Optional[Gtk.Popover] = None + + self.set_visible(False) + self._rebuild_all() + + # -- layer shell ------------------------------------------------------ + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.TOP) + LayerShell.set_namespace(self, "horizon-dock") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE) + LayerShell.set_exclusive_zone(self, 0) + for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.BOTTOM): + LayerShell.set_anchor(self, edge, True) + LayerShell.set_margin(self, edge, 0) + + def _focused_gdk_monitor(self): + """The Gdk monitor for Hyprland's currently-focused output, so the dock + opens on (and is sized to) whichever monitor you're on — not always + monitor 0. Falls back to monitor 0 if the lookup fails.""" + display = Gdk.Display.get_default() + if display is None: + return None + monitors = display.get_monitors() + n = monitors.get_n_items() if monitors is not None else 0 + name = None + try: + out = subprocess.run(["hyprctl", "-j", "monitors"], + capture_output=True, text=True, timeout=1).stdout + for m in json.loads(out): + if m.get("focused"): + name = m.get("name") + break + except Exception: + name = None + if name is not None: + for i in range(n): + mon = monitors.get_item(i) + if mon is not None and mon.get_connector() == name: + return mon + return monitors.get_item(0) if n else None + + def _monitor_width(self) -> int: + mon = self._focused_gdk_monitor() + # Gdk logical width (already divided by the monitor's scale), which is the + # coordinate space layer-shell / GTK lay out in — not hyprctl's raw px. + return mon.get_geometry().width if mon is not None else 1920 + + # -- layout math (horizon perspective) ---------------------------------- + def _ring_rx(self, row: str) -> float: + return self.ROW_HALFWIDTH[row] * self._width + + def _ring_ry(self, row: str) -> float: + return self._ring_rx(row) * self.PERSPECTIVE_SQUASH + + def _dock_height(self) -> int: + ry_max = max(self._ring_ry(r) for r in self.ROW_ORDER) + return int(self.BASE_MARGIN + ry_max + self.PLANET_SIZE / 2 + 14) + + def _base_y(self) -> float: + """The horizon line: near the BOTTOM of the dock. Orbits arch UP from it.""" + return self.DOCK_HEIGHT - self.BASE_MARGIN + + def _row_y_at(self, row: str, x: float) -> float: + """y on the near edge of a ring's foreshortened ellipse — highest at the + centre, curving back down to the horizon line toward the sides.""" + cx = self._width / 2 + rx, ry = self._ring_rx(row), self._ring_ry(row) + dx = x - cx + if abs(dx) >= rx: + return self._base_y() + return self._base_y() - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0)) + + def _center_offset(self, row: str) -> float: + """The scroll offset that centres a row's items on the arc: the middle item + lands at the centre, so a short row is a centred cluster and a long row + fills the arc symmetrically (overflowing/​fading equally on both sides).""" + return max(0.0, (len(self._items[row]) - 1) / 2.0) + + def _item_position(self, row: str, index: int) -> tuple[float, float]: + slot = index - self._scroll_offset[row] + x = self._width / 2 + slot * self.ITEM_SPACING + return x, self._row_y_at(row, x) + + def _clamp_scroll(self, row: str) -> None: + count = len(self._items[row]) + max_offset = max(0.0, count - 1) + self._scroll_offset[row] = min(max(self._scroll_offset[row], 0.0), max_offset) + + # -- planet buttons ------------------------------------------------------- + def _make_planet(self, icon_name: str, tooltip: str, size: int = PLANET_SIZE) -> Gtk.Button: + btn = Gtk.Button() + btn.set_has_frame(False) + btn.add_css_class("horizon-planet") + btn.set_size_request(size, size) + btn.set_tooltip_text(tooltip) + image = Gtk.Image.new_from_icon_name(icon_name or "application-x-executable") + image.set_pixel_size(int(size * 0.55)) + btn.set_child(image) + return btn + + def _rebuild_row(self, row: str) -> None: + for w in self._widgets[row]: + if w.get_parent() is not None: + self._content.remove(w) + self._widgets[row] = [] + + if row == "windows": + self._items[row] = windows_source.open_windows() + elif row == "favorites": + self._items[row] = self._apps.favorite_apps() + else: + self._items[row] = self._apps.all_apps() + + # start each orbit centred on the arc (re-centred whenever it's rebuilt) + self._scroll_offset[row] = self._center_offset(row) + self._clamp_scroll(row) + + for i, item in enumerate(self._items[row]): + btn = self._build_item_button(row, item) + x, y = self._item_position(row, i) + self._place_item(row, btn, x, y, put=True) + self._widgets[row].append(btn) + + self._bg.queue_draw() + + def _build_item_button(self, row: str, item) -> Gtk.Button: + if row == "windows": + icon = self._apps.icon_for_window(item) + title = item.get("title") or item.get("class") or "?" + btn = self._make_planet(icon, title) + addr = item.get("address") + btn.connect("clicked", lambda *_a, a=addr: windows_source.focus_window(a)) + else: + btn = self._make_planet(item.get_icon_name(), item.get_name() or "") + btn.connect("clicked", lambda *_a, a=item: self._apps.launch(a)) + if row == "apps": + right_click = Gtk.GestureClick(button=3) + right_click.connect("pressed", lambda *_a, a=item: self._toggle_favorite(a)) + btn.add_controller(right_click) + return btn + + def _toggle_favorite(self, item) -> None: + self._apps.toggle_favorite(item) + self._rebuild_row("favorites") + + def _edge_fade(self, row: str, x: float) -> float: + """1.0 in the middle of an orbit, smoothly fading to 0 as an icon nears the + ring's horizon extremity — so overflowing icons dissolve into the horizon + at the sides instead of piling up / being hard-clipped off the edge.""" + rx = self._ring_rx(row) + dx = abs(x - self._width / 2) + start = rx * 0.68 + if dx <= start: + return 1.0 + if dx >= rx: + return 0.0 + t = (dx - start) / (rx - start) + return 1.0 - t * t * (3 - 2 * t) # smoothstep down + + def _place_item(self, row: str, w: Gtk.Widget, x: float, y: float, put: bool) -> None: + fx, fy = x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2 + if put: + self._content.put(w, fx, fy) + else: + self._content.move(w, fx, fy) + fade = self._edge_fade(row, x) + w.set_opacity(fade) + w.set_can_target(fade > 0.05) # faded-out icons don't grab clicks + w.set_sensitive(fade > 0.05) + + def _reflow_row(self, row: str) -> None: + for i, w in enumerate(self._widgets[row]): + x, y = self._item_position(row, i) + self._place_item(row, w, x, y, put=False) + + def _rebuild_all(self) -> None: + for row in self.ROW_ORDER: + self._rebuild_row(row) + + # -- hover / scroll routing ------------------------------------------------- + def _row_at(self, x: float, y: float) -> Optional[str]: + """The ring nearest the cursor (by vertical distance to its arc at x), + within HOVER_BAND — so scrolling targets whichever orbit you're over.""" + best, best_d = None, self.HOVER_BAND + for row in self.ROW_ORDER: + d = abs(y - self._row_y_at(row, x)) + if d < best_d: + best, best_d = row, d + return best + + def _on_motion(self, _ctrl, x: float, y: float) -> None: + self._mouse_x, self._mouse_y = x, y # drives the ornamental satellites + row = self._row_at(x, y) + if row != self._hovered_row: + self._hovered_row = row + self._bg.queue_draw() + + def _on_motion_leave(self, _ctrl) -> None: + if self._hovered_row is not None: + self._hovered_row = None + self._bg.queue_draw() + + def _on_scroll(self, _ctrl, _dx: float, dy: float) -> bool: + row = self._hovered_row + if row is None: + return False + self._scroll_offset[row] += dy * self.SCROLL_SENSITIVITY + self._clamp_scroll(row) + self._reflow_row(row) + return True + + # -- background: the giant orbit node + its rings ----------------------- + def _draw_background(self, _area, cr, width: float, height: float) -> None: + self._draw_node(cr) + for row in self.ROW_ORDER: + self._draw_ring(cr, row, hovered=(row == self._hovered_row)) + self._draw_satellites(cr) + self._draw_center_sphere(cr) + + def _front_arc_path(self, cr, rx: float, ry: float, close_on_horizon: bool) -> None: + """Trace the near edge of a ring's foreshortened ellipse (arching UP) from + the left horizon point across to the right one; optionally close it back + along the horizon line to make a fillable semi-ellipse dome.""" + cx = self._width / 2 + base = self._base_y() + steps = 72 + cr.move_to(cx - rx, base) + for s in range(1, steps + 1): + x = cx - rx + 2 * rx * s / steps + dx = x - cx + y = base - ry * math.sqrt(max(1.0 - (dx / rx) ** 2, 0.0)) + cr.line_to(x, y) + if close_on_horizon: + cr.close_path() + + def _draw_node(self, cr) -> None: + """The 'planet' the orbits sit on: the widest ring's foreshortened dome + filled with a vertical violet gradient (brighter at the arching near rim), + with a soft glowing edge — a lit surface curving up from the horizon.""" + rx = self._ring_rx("apps") * 1.05 + ry = self._ring_ry("apps") * 1.05 + base = self._base_y() + cr.save() + self._front_arc_path(cr, rx, ry, close_on_horizon=True) + grad = cairo.LinearGradient(0, base - ry, 0, base) + grad.add_color_stop_rgba(0.0, *_VIOLET, 0.30) + grad.add_color_stop_rgba(1.0, *_VIOLET, 0.06) + cr.set_source(grad) + cr.fill() + for lw, a in ((12.0, 0.05), (6.0, 0.10), (2.2, 0.5)): + self._front_arc_path(cr, rx, ry, close_on_horizon=False) + cr.set_source_rgba(*_ACCENT, a) + cr.set_line_width(lw) + cr.stroke() + cr.restore() + + def _draw_ring(self, cr, row: str, hovered: bool) -> None: + """A faint guide arc along a row's foreshortened orbit; the hovered ring + brightens to accent so you can see which orbit the scroll will move.""" + cr.save() + if hovered: + cr.set_source_rgba(*_ACCENT, 0.4) + cr.set_line_width(2.0) + else: + cr.set_source_rgba(*_VIOLET, 0.22) + cr.set_line_width(1.2) + self._front_arc_path(cr, self._ring_rx(row), self._ring_ry(row), close_on_horizon=False) + cr.stroke() + cr.restore() + + def _holo_clip(self, cr, width: float, height: float) -> None: + """Path-setter handed to the hologram overlay: trace the node dome (widest + ring, expanded to cover the icon tops) so the scanlines are clipped to the + UI shape instead of painting a full-height rectangle above it.""" + pad = self.PLANET_SIZE / 2 + 12 + self._front_arc_path(cr, self._ring_rx("apps") + pad, + self._ring_ry("apps") + pad, close_on_horizon=True) + + def _draw_center_sphere(self, cr) -> None: + """The 'planet': a big holographic world rising from the horizon. Its top + fills the clear central band (icon buttons float in front of it), while its + bottom quarter sinks below the screen edge and is clipped away by the dock's + overflow — so it reads as a huge planet, not a small bead. Translucent body + (the desktop/scanlines show through) with a strong outer glow + rim.""" + cx = self._width / 2 + base = self._base_y() + # Clear band below the lowest icon arch (windows); the readout lives here. + band_top = base - self._ring_ry("windows") + self.PLANET_SIZE / 2 + bottom_edge = float(self.DOCK_HEIGHT) # dock surface bottom = screen bottom + vis_h = bottom_edge - band_top # visible vertical extent of the planet + # top anchored at band_top; radius sized so ~1/4 of the disc falls past the + # bottom edge (top 3/4 == the visible band): vis_h = 1.5 * rad. + rad = max(48.0, vis_h / 1.5) + cy = band_top + rad + cr.save() + # gentle breathing pulse so the planet reads as a live, radiant body + pulse = 0.82 + 0.18 * math.sin(self._sat_time * 2.2) + # wide outer bloom halo — the planet glows well beyond its own disc + halo_r = rad * 1.9 + halo = cairo.RadialGradient(cx, cy, rad * 0.5, cx, cy, halo_r) + halo.add_color_stop_rgba(0.0, *_ACCENT, 0.40 * pulse) + halo.add_color_stop_rgba(0.45, *_VIOLET, 0.18 * pulse) + halo.add_color_stop_rgba(1.0, *_VIOLET, 0.0) + cr.arc(cx, cy, halo_r, 0, 2 * math.pi) + cr.set_source(halo) + cr.fill() + # translucent holographic body: a soft see-through core fading to a nearly + # transparent rim, so the blur/scanlines/desktop read through it + grad = cairo.RadialGradient(cx - rad * 0.3, cy - rad * 0.35, rad * 0.05, cx, cy, rad) + grad.add_color_stop_rgba(0.0, 1.0, 0.82, 0.98, 0.48) + grad.add_color_stop_rgba(0.4, *_VIOLET, 0.36) + grad.add_color_stop_rgba(1.0, *_VIOLET, 0.10) + cr.arc(cx, cy, rad, 0, 2 * math.pi) + cr.set_source(grad) + cr.fill() + # faint specular sheen (top-left) for a glassy 3D read + spec = cairo.RadialGradient(cx - rad * 0.34, cy - rad * 0.42, 0, + cx - rad * 0.34, cy - rad * 0.42, rad * 0.5) + spec.add_color_stop_rgba(0.0, 1, 1, 1, 0.38 * pulse) + spec.add_color_stop_rgba(1.0, 1, 1, 1, 0.0) + cr.arc(cx, cy, rad, 0, 2 * math.pi) + cr.set_source(spec) + cr.fill() + # strong, wide rim glow rings, capped by a crisp bright edge + for lw, a in ((24.0, 0.06 * pulse), (14.0, 0.12 * pulse), + (7.0, 0.22 * pulse), (2.6, 0.85)): + cr.arc(cx, cy, rad, 0, 2 * math.pi) + cr.set_source_rgba(*_ACCENT, a) + cr.set_line_width(lw) + cr.stroke() + # date + time readout, centred in the VISIBLE band (not at cy, which is low) + vis_cy = (band_top + bottom_edge) / 2 + cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) + clock = time.strftime("%H:%M") + cr.set_font_size(vis_h * 0.40) + ext = cr.text_extents(clock) + cr.move_to(cx - ext.width / 2 - ext.x_bearing, + vis_cy - ext.height / 2 - ext.y_bearing - vis_h * 0.05) + cr.set_source_rgba(0.98, 0.92, 0.99, 0.98) + cr.show_text(clock) + date = time.strftime("%a %d %b") + cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + cr.set_font_size(vis_h * 0.20) + ext2 = cr.text_extents(date) + cr.move_to(cx - ext2.width / 2 - ext2.x_bearing, vis_cy + vis_h * 0.30) + cr.set_source_rgba(*_ACCENT, 0.95) + cr.show_text(date) + cr.restore() + + def _make_satellites(self) -> list[dict]: + """A handful of ornamental glyphs, split across the two satellite rings that + frame the windows orbit (SAT_RING_FACTORS). Each ring gets a random 3–7 of + them at fixed base angles, and each satellite's *motion source* is picked at + random — it either leans TOWARD the cursor, or is driven by the cursor's X, + or by the cursor's Y — so the pair of rings reads as a lively little swarm.""" + sats: list[dict] = [] + for fac in self.SAT_RING_FACTORS: + for _ in range(random.randint(3, 7)): + sats.append({ + "fac": fac, + # base position on the visible (upper) half of the ring ellipse + "angle": random.uniform(0.16 * math.pi, 0.84 * math.pi), + "glyph": random.choice(self.ORBIT_GLYPHS), + "size": random.uniform(11.0, 15.0), + "source": random.choice(("toward", "mouseX", "mouseY")), + "amp": random.uniform(16.0, 32.0), + "wob_amp": random.uniform(1.5, 3.5), + "wob_speed": random.uniform(1.0, 2.0), + "phase": random.uniform(0.0, 2.0 * math.pi), + }) + return sats + + def _draw_satellites(self, cr) -> None: + """Draw the two satellite rings (faint guide arcs) and their mouse-reactive + glyphs. Drawn on the background, so they sit behind the icons and sphere.""" + cx = self._width / 2 + base = self._base_y() + win_rx = self._ring_rx("windows") + mx, my = self._mouse_sx, self._mouse_sy + half_w = max(1.0, self._width / 2) + # faint guide arcs for the two rings + cr.save() + for fac in self.SAT_RING_FACTORS: + self._front_arc_path(cr, win_rx * fac, win_rx * fac * self.PERSPECTIVE_SQUASH, + close_on_horizon=False) + cr.set_source_rgba(*_VIOLET, 0.14) + cr.set_line_width(1.0) + cr.stroke() + cr.select_font_face(self.GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + for s in self._satellites: + rx = win_rx * s["fac"] + ry = rx * self.PERSPECTIVE_SQUASH + bx = cx + rx * math.cos(s["angle"]) + by = base - ry * math.sin(s["angle"]) + wob = s["wob_amp"] * math.sin(self._sat_time * s["wob_speed"] + s["phase"]) + ox = oy = 0.0 + if s["source"] == "toward": + ddx, ddy = mx - bx, my - by + d = math.hypot(ddx, ddy) or 1.0 + ox, oy = ddx / d * s["amp"], ddy / d * s["amp"] # lean toward the cursor + elif s["source"] == "mouseX": + ox = (mx - cx) / half_w * s["amp"] * 2.0 # driven by cursor X + else: # mouseY + oy = (my - base) / half_w * s["amp"] * 2.0 # driven by cursor Y + x = bx + ox + wob + y = by + oy + wob * 0.5 + border_w = min(1.0, x / 44.0, (self._width - x) / 44.0) + if border_w <= 0.0: + continue + glyph = s["glyph"] + cr.set_font_size(s["size"]) + ext = cr.text_extents(glyph) + cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing) + cr.set_source_rgba(*_VIOLET, 0.7 * border_w) + cr.show_text(glyph) + cr.restore() + + # -- animation loop ------------------------------------------------------- + # No slide/fade reveal: the dock simply appears and the hologram's + # "materialise from static" intro (start_intro, below) is the whole opening + # effect — matching every other Cosmonaut Shell surface. The tick only drives + # the hologram animation while the dock is shown. + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + self._sat_time += dt + # ease the smoothed cursor toward the raw position (frame-rate independent) + e = 1.0 - math.exp(-dt * 6.0) + self._mouse_sx += (self._mouse_x - self._mouse_sx) * e + self._mouse_sy += (self._mouse_y - self._mouse_sy) * e + self._hologram.tick(dt) + self._bg.queue_draw() # animate the planet's glow + the satellites + return True + + def _ensure_tick(self) -> None: + if self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + + def _stop_tick(self) -> None: + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + # Reset the frame-time baseline so the next show's first tick has dt=0. + # Otherwise a warm reopen sees dt = (time the dock was hidden), which would + # blow past the whole intro in one frame and skip the materialise animation. + self._last_tick = None + + # -- external control ---------------------------------------------------- + def _apply_width(self, width: int) -> None: + self._width = width + self.DOCK_HEIGHT = self._dock_height() + for w in (self._bg, self._content, self._viewport, self._sizer): + w.set_size_request(int(width), self.DOCK_HEIGHT) + + def show_dock(self) -> None: + mon = self._focused_gdk_monitor() + if mon is not None: + LayerShell.set_monitor(self, mon) + self._apply_width(mon.get_geometry().width) + else: + self._apply_width(self._monitor_width()) + self._rebuild_all() + self.set_visible(True) + self.present() + self._ensure_tick() + self._hologram.start_intro() + if getattr(self, "_clock_timer_id", None) is None: + self._clock_timer_id = GLib.timeout_add_seconds(15, self._refresh_clock) + + def _refresh_clock(self) -> bool: + if self.get_visible(): + self._bg.queue_draw() # repaint the planet's date/time + return True + self._clock_timer_id = None + return False + + def hide_dock(self) -> None: + # Dissolve into static first, then hide via _finish_hide. + if self._hologram.enabled and self._tick_id is not None: + self._hologram.start_outro(self._finish_hide) + else: + self._finish_hide() + + def _finish_hide(self) -> None: + self.set_visible(False) + self._stop_tick() + if self._on_close: + self._on_close() + + def toggle(self) -> None: + if self.get_visible(): + self.hide_dock() + else: + self.show_dock() diff --git a/horizon-dock/lib/hologram.py b/horizon-dock/lib/hologram.py new file mode 100644 index 0000000..d0d613b --- /dev/null +++ b/horizon-dock/lib/hologram.py @@ -0,0 +1,265 @@ +"""Holographic scanline/sweep/noise overlay — same treatment and tuning as +astro-menu's lib/hologram.py (itself matching orbit-menu's), reused here so +horizon-dock reads as one more orbit of the same Cosmonaut Shell suite. No +radial vignette mask: the dock's own module borders/canvas already bound it, +so covering the full rectangle reads as one continuous "HUD screen". + +Owns its own animation clock (advanced by dock.py's tick callback via +.tick(dt)) and a persistent particle list for the noise specs, exactly like +astro-menu/orbit-menu's hologram: each spec keeps its position/color for a +real randomized lifetime (fade in, hold, fade out) instead of every spec +teleporting to a new position every frame. +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as orbit-menu's/astro-menu's hologram. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture + SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 90.0 + NOISE_COUNT = 95 # specs alive at once (each with its own lifetime) + NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field + EDGE_FADE_Y = 40.0 # smooth vertical fade-out + INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in + INTRO_STATIC = 1200 # static specks at the very start of the intro + OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close + + def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, + intro_duration: float | None = None) -> None: + self.enabled = enabled + self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape + # widget whose opacity is ramped 0->1 during the intro so the UI genuinely + # fades in (a gradual reveal), rather than a solid haze block popping on + self._fade_widget = fade_widget + if intro_duration is not None: + self.INTRO_DURATION = intro_duration # per-instance override of the class default + self._sat_time = 0.0 + self._particles: list[dict] = [] + self._intro_t: float | None = None # >=0 while the materialise intro plays + self._outro_t: float | None = None # >=0 while the closing dissolve plays + self._outro_done = None # callback fired when the dissolve finishes + self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from content underneath + self.widget.add_css_class("horizon-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + if self._intro_t is not None: + self._intro_t += dt + if self._intro_t >= self.INTRO_DURATION: + self._intro_t = None + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0) + elif self._fade_widget is not None: + p = self._intro_t / self.INTRO_DURATION + self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1 + if self._outro_t is not None: + self._outro_t += dt + po = min(1.0, self._outro_t / self.OUTRO_DURATION) + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0 + if self._outro_t >= self.OUTRO_DURATION: + done = self._outro_done + self._outro_t = None + self._outro_done = None + if done is not None: + done() + self.widget.queue_draw() + + def start_intro(self) -> None: + """Kick off the 'hologram materialising out of static' opening effect.""" + if self.enabled: + self._outro_t = None # cancel any in-flight closing dissolve + self._outro_done = None + self._intro_t = 0.0 + if self._fade_widget is not None: + self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up + + def start_outro(self, on_done) -> None: + """Play a quick reverse of the intro (content dissolving back into static), + then call on_done to actually hide. If disabled, hide immediately.""" + if not self.enabled: + on_done() + return + self._intro_t = None # cancel any in-flight opening intro + self._outro_t = 0.0 + self._outro_done = on_done + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + if self._clip_func is not None: + cr.save() + self._clip_func(cr, width, height) # clip the scanlines to the UI shape + cr.clip() + # Render the holo field into a group, then composite it back through a + # soft edge-fade mask so the scanlines dissolve at the borders (reads far + # more like a projected hologram than a hard-edged rectangle). + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + # No solid veil block: the content itself fades in (see tick's fade_widget + # ramp). Here we only lay churning static over it — dense at first, thinning + # to nothing — so the UI resolves out of noise as it fades up. + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height # a bright scan wiping down as it resolves + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + # Reverse of the intro: the content is already fading back out (tick's + # fade_widget ramp 1->0); here the static thickens from nothing as it goes, + # so the panel dissolves into noise just before it vanishes. + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height # scan wiping back up as it dissolves + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.07) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/horizon-dock/main.py b/horizon-dock/main.py new file mode 100644 index 0000000..0a1e3a7 --- /dev/null +++ b/horizon-dock/main.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""horizon-dock — a hover-scrollable orbital dock for hyprdrive, matching +orbit-menu's visual language. + +Single-instance, same pattern as astal-menu/orbit-menu's main.py: the first +launch builds the (hidden) window and holds; later invocations forward their +verb over D-Bus via scripts/horizon-dock.sh instead of spawning a second +python3+GTK4 process. + + main.py run the resident instance (stays hidden until toggled) + main.py --show slide/fade in + main.py --hide slide/fade out + main.py --toggle whichever of the above applies +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# horizon-dock-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement +# ahead of libwayland-client). Drop it once resident so it isn't inherited by +# anything this process launches (see astal-menu/orbit-menu's main.py for the same +# rationale — some GTK4 apps abort at startup with the layer-shell lib preloaded). +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import config # noqa: E402 +import theme # noqa: E402 +from dock import HorizonDock # noqa: E402 +from paths import APP_ID # noqa: E402 + + +class HorizonDockApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: HorizonDock | None = None + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = HorizonDock(tray_enabled=config.tray_enabled(), + hologram_enabled=config.hologram_enabled()) + self._register_actions() + self.hold() # stay alive with no visible window + + def _register_actions(self) -> None: + def add(name: str, callback) -> None: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def guarded(fn): + def wrapper(*_a) -> None: + assert self.window is not None + fn(self.window) + return wrapper + + add("show", guarded(lambda w: w.show_dock())) + add("hide", guarded(lambda w: w.hide_dock())) + add("toggle", guarded(lambda w: w.toggle())) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + if self.window is None: + return 0 + if verb == "--show": + self.window.show_dock() + elif verb == "--hide": + self.window.hide_dock() + elif verb == "--toggle": + self.window.toggle() + # --daemon and anything else: no-op (stay resident, hidden) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +def main() -> int: + GLib.set_prgname("horizon-dock") + return HorizonDockApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/horizon-dock/paths.py b/horizon-dock/paths.py new file mode 100644 index 0000000..405f142 --- /dev/null +++ b/horizon-dock/paths.py @@ -0,0 +1,23 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# User settings/favorites live under XDG_STATE_HOME, NOT ~/.config — config-updater +# does `rm -rf ~/.config/horizon-dock` on every dotfiles sync (see orbit-menu/ +# paths.py and astal-menu/paths.py for the same rationale), which would wipe a +# hand-edited config or pinned favorites on the spot. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "horizon-dock" +CONFIG_FILE = STATE_DIR / "config.json" +FAVORITES_FILE = STATE_DIR / "favorites.json" + +APP_ID = "eu.abdelbaki.horizondock" + + +def ensure_dirs() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/horizon-dock/style/_colors.css b/horizon-dock/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/horizon-dock/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/horizon-dock/style/style.css b/horizon-dock/style/style.css new file mode 100644 index 0000000..326078b --- /dev/null +++ b/horizon-dock/style/style.css @@ -0,0 +1,90 @@ +/* horizon-dock — CyberQueer orbital dock. Colors come from _colors.css + * (@text/@bg/@accent/@violet/@danger), loaded first by theme.py. Every icon is + * a "planet" node styled identically to orbit-menu's .orbit-node/.orbit-center + * (same glow/hover treatment, same radial-gradient formula) so the two + * components read as one continuous aesthetic. + */ + +window.horizon-dock-window { + background: transparent; +} + +/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }`, which + * otherwise paints EVERY node (the Overlay, the Fixed viewport/content, every + * Box) an opaque dark slab. Blanking by node name is fragile — it missed + * `overlay`/`fixed` here — so blank them all (this provider sits at USER+1, + * above the theme). Planets / tray satellite / popover re-assert their fills via + * the higher-specificity .class rules below. */ +* { + background-color: transparent; +} + +/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea + * nodes specifically (see orbit-menu/style/style.css's .orbit-hologram fix for + * the same discovery) — the background arc canvas is a DrawingArea sitting + * behind everything, so without this it paints an opaque rectangle that blots + * out the transparent window, which is what reads as "the system GTK theme". */ +.horizon-canvas, +.horizon-hologram { + background: transparent; + background-color: transparent; + background-image: none; +} + +/* -- planet nodes: identical language to orbit-menu's .orbit-node ------------ */ + +.horizon-planet { + border-radius: 9999px; + border: 1.5px solid alpha(@violet, 0.5); + color: @text; + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.18), alpha(@violet, 0.32) 80%); + transition: background 200ms ease, border-color 200ms ease, + box-shadow 220ms cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 0 0 0 alpha(@violet, 0); +} + +.horizon-planet * { + background: transparent; + background-color: transparent; + background-image: none; +} + +.horizon-planet:hover { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.55), alpha(@violet, 0.32) 80%); + border-color: @violet; + box-shadow: 0 0 16px 1px alpha(@violet, 0.6); +} + +.horizon-planet:active { + box-shadow: 0 0 22px 3px alpha(@violet, 0.85); +} + +/* the tray satellite reads as "special" — accent-tinted instead of violet, and + * an OPAQUE background (not the translucent radial-gradient the other planets + * use) so overflowing favorites scrolling underneath it visibly disappear + * behind it rather than showing through. Opaque, but still violet-tinted + * rather than flat gray (a literal near-black-violet, not @bg/@violet, since + * this one spot genuinely needs full opacity for the occlusion to work). */ +.horizon-tray-satellite { + border-color: alpha(@accent, 0.55); + background-image: none; + background-color: #140b28; +} +.horizon-tray-satellite:hover { + border-color: @accent; + box-shadow: 0 0 16px 1px alpha(@accent, 0.6); +} + +.horizon-tray-popover { + background-color: #140b28; +} + +.horizon-tray-box { + padding: 8px; +} + +.horizon-tray-item { + border-radius: 9999px; + min-width: 28px; + min-height: 28px; +} diff --git a/horizon-dock/theme.py b/horizon-dock/theme.py new file mode 100644 index 0000000..433c8c1 --- /dev/null +++ b/horizon-dock/theme.py @@ -0,0 +1,30 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as orbit-menu's +and astal-menu's theme.py. _colors.css (generated from ~/Dotfiles/colors.conf by +apply-theme.sh) defines the CyberQueer @define-color names; style.css consumes them. + +Priority is USER+1 for the same reason as the other two apps: the CyberQueer GTK +theme at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION +(600), and would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/horizon-dock/tray.py b/horizon-dock/tray.py new file mode 100644 index 0000000..354e7d3 --- /dev/null +++ b/horizon-dock/tray.py @@ -0,0 +1,140 @@ +"""Minimal StatusNotifierItem (SNI) HOST — connects to whichever +org.kde.StatusNotifierWatcher is already running (the existing eww bars' own +systray widget registers one) and renders the currently registered tray items +as clickable planet buttons. The SNI spec allows multiple hosts, so registering +alongside eww's own host is expected and fine, not a conflict. + +Rebuilt fresh each time the tray popover box is built (dock.py calls +build_into() on every dock reveal, since favorites — and therefore the tray +satellite — rebuild then) rather than live-subscribed to Registered/ +Unregistered signals — simple and correct for a popover that's only open +briefly; if no watcher is running at all, build_into() shows a small +placeholder instead of raising. +""" + +from __future__ import annotations + +from typing import Optional + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +_WATCHER_BUS = "org.kde.StatusNotifierWatcher" +_WATCHER_PATH = "/StatusNotifierWatcher" +_WATCHER_IFACE = "org.kde.StatusNotifierWatcher" +_ITEM_IFACE = "org.kde.StatusNotifierItem" +_HOST_NAME = "org.kde.StatusNotifierHost-horizon-dock" + + +class TrayHost: + def __init__(self) -> None: + self._watcher: Optional[Gio.DBusProxy] = None + self._registered_host = False + self._connect() + + def _connect(self) -> None: + try: + proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + _WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None, + ) + # A proxy can be constructed even for a name with no owner yet — only + # a real name-owner query tells us whether a watcher is actually up. + self._watcher = proxy if proxy.get_name_owner() is not None else None + except GLib.Error: + self._watcher = None + + def _ensure_host_registered(self) -> None: + if self._registered_host or self._watcher is None: + return + try: + self._watcher.call_sync( + "RegisterStatusNotifierHost", GLib.Variant("(s)", (_HOST_NAME,)), + Gio.DBusCallFlags.NONE, 500, None, + ) + except GLib.Error: + pass # some watchers don't require/support this explicitly — fine either way + self._registered_host = True + + def _registered_items(self) -> list[str]: + if self._watcher is None: + return [] + value = self._watcher.get_cached_property("RegisteredStatusNotifierItems") + if value is None: + return [] + return list(value.unpack()) + + @staticmethod + def _parse_item(entry: str) -> tuple[str, str]: + """Registration strings are either just a bus name (path defaults to + /StatusNotifierItem) or "busname/ObjectPath" — implementations disagree, + so accept both.""" + if "/" in entry: + bus, _, path = entry.partition("/") + return bus, "/" + path + return entry, "/StatusNotifierItem" + + # -- public: build the popover contents ----------------------------------- + def build_into(self, box: Gtk.Box) -> None: + """Populate `box` with one planet button per currently registered tray + item. Safe to call repeatedly (dock.py does, on every dock reveal).""" + if self._watcher is None: + box.append(Gtk.Label(label="No tray watcher running")) + return + self._ensure_host_registered() + + entries = self._registered_items() + if not entries: + box.append(Gtk.Label(label="(empty)")) + return + + for entry in entries: + bus, path = self._parse_item(entry) + btn = self._build_item_button(bus, path) + if btn is not None: + box.append(btn) + + def _build_item_button(self, bus: str, path: str) -> Optional[Gtk.Button]: + try: + proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + bus, path, _ITEM_IFACE, None, + ) + except GLib.Error: + return None + + icon_name = self._prop_str(proxy, "IconName") or "application-x-executable" + title = self._prop_str(proxy, "Title") or self._prop_str(proxy, "IconName") or bus + + btn = Gtk.Button() + btn.set_has_frame(False) + btn.add_css_class("horizon-planet") + btn.add_css_class("horizon-tray-item") + btn.set_tooltip_text(title) + image = Gtk.Image.new_from_icon_name(icon_name) + image.set_pixel_size(20) + btn.set_child(image) + btn.connect("clicked", lambda *_a, p=proxy: self._activate(p)) + + right_click = Gtk.GestureClick(button=3) + right_click.connect("pressed", lambda *_a, p=proxy: self._context_menu(p)) + btn.add_controller(right_click) + return btn + + @staticmethod + def _prop_str(proxy: Gio.DBusProxy, name: str) -> str: + value = proxy.get_cached_property(name) + return value.unpack() if value is not None else "" + + @staticmethod + def _activate(proxy: Gio.DBusProxy) -> None: + proxy.call("Activate", GLib.Variant("(ii)", (0, 0)), + Gio.DBusCallFlags.NONE, -1, None, None, None) + + @staticmethod + def _context_menu(proxy: Gio.DBusProxy) -> None: + proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)), + Gio.DBusCallFlags.NONE, -1, None, None, None) diff --git a/horizon-dock/windows.py b/horizon-dock/windows.py new file mode 100644 index 0000000..9c0662c --- /dev/null +++ b/horizon-dock/windows.py @@ -0,0 +1,27 @@ +"""Open-window data source — synchronous `hyprctl clients -j`, same pattern as +orbit-menu/menu_tree.py's _hyprctl_json (a local unix-socket round trip, cheap +enough to call straight from the UI thread; refreshed each time the dock is +revealed, not polled continuously).""" + +from __future__ import annotations + +import json +import subprocess + + +def open_windows() -> list[dict]: + try: + out = subprocess.run(["hyprctl", "clients", "-j"], capture_output=True, + text=True, timeout=1.5, check=True).stdout + clients = json.loads(out) + except (subprocess.SubprocessError, json.JSONDecodeError, OSError): + return [] + wins = [w for w in clients if w.get("mapped", True) and w.get("class")] + wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0), + (w.get("title") or w.get("class") or "").lower())) + return wins + + +def focus_window(address: str) -> None: + subprocess.Popen(["hyprctl", "dispatch", f'hl.dsp.focus({{ window = "address:{address}" }})'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) diff --git a/orbit-menu/.gitignore b/orbit-menu/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/orbit-menu/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/orbit-menu/actions.py b/orbit-menu/actions.py new file mode 100644 index 0000000..86fe63c --- /dev/null +++ b/orbit-menu/actions.py @@ -0,0 +1,69 @@ +"""Side-effecting helpers shared by menu_tree.py's leaf actions. + +Everything here fires-and-forgets via Gio.Subprocess (lib.proc.fire) so a click +never blocks the GTK main loop or the menu's close animation. + +Per-window ops use two different mechanisms on purpose: + * focus / bring-here go through `hyprctl dispatch "hl.dsp...."` — hyprlua's own + Lua-eval dispatch layer, the same one astal-menu/ui/taskbar.py already uses in + production for exactly this (see its _focus/_pull). + * close / force-kill go through plain native Hyprland dispatchers (`closewindow + address:...`) and a bare `kill -9 `, which work regardless of the Lua + layer and don't require guessing whether hl.dsp.window exposes a close-by- + address form. +""" + +from __future__ import annotations + +from lib.proc import fire + + +def launch(cmd: str) -> None: + """Run cmd via Hyprland's exec dispatcher — cmd may carry a `[tag ...]` / + `[workspace ...]` window-rule-on-launch prefix, same as binds.lua. + + NB: `hyprctl dispatch` evaluates its argument as Lua here (hyprlua), so this + must go through `hl.dsp.exec_cmd(...)` — NOT the native `dispatch exec ` + form, which Lua parses as a syntax error and silently drops (see binds.lua's + `hl.dsp.exec_cmd("[tag +mixer] ...")`).""" + esc = cmd.replace("\\", "\\\\").replace('"', '\\"') + fire(["hyprctl", "dispatch", f'hl.dsp.exec_cmd("{esc}")']) + + +def hyprshutdown(post_cmd: str | None = None) -> None: + if post_cmd: + fire(["hyprshutdown", "-p", post_cmd]) + else: + fire(["hyprshutdown"]) + + +def _dispatch_lua(lua: str) -> None: + fire(["hyprctl", "dispatch", lua]) + + +def focus_window(address: str) -> None: + _dispatch_lua(f'hl.dsp.focus({{ window = "address:{address}" }})') + + +def bring_here(address: str, active_ws: int) -> None: + _dispatch_lua(f'hl.dsp.window.move({{ window = "address:{address}", ' + f'workspace = {active_ws} }})') + + +def send_to_workspace(address: str, workspace_id: int) -> None: + _dispatch_lua(f'hl.dsp.window.move({{ window = "address:{address}", ' + f'workspace = {workspace_id}, silent = true }})') + + +def close_window(address: str) -> None: + fire(["hyprctl", "dispatch", "closewindow", f"address:{address}"]) + + +def force_kill(pid: int) -> None: + fire(["kill", "-9", str(pid)]) + + +def report_pid(pid: int, title: str) -> None: + fire(["wl-copy", str(pid)]) + fire(["notify-send", "-a", "orbit-menu", f"PID {pid}", + f"{title} — copied to clipboard"]) diff --git a/orbit-menu/config.py b/orbit-menu/config.py new file mode 100644 index 0000000..ff66ace --- /dev/null +++ b/orbit-menu/config.py @@ -0,0 +1,34 @@ +"""Tiny user-editable config file: ~/.local/state/orbit-menu/config.json. + +Read once at startup (main.py); flags are passed into OrbitMenu's constructor +rather than polled, so a change takes effect on the next orbit-menu-start.sh +restart, not live. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = {"satellites": True, "hologram": True} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def satellites_enabled() -> bool: + return bool(_load().get("satellites", True)) + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) diff --git a/orbit-menu/lib/proc.py b/orbit-menu/lib/proc.py new file mode 100644 index 0000000..8c5c197 --- /dev/null +++ b/orbit-menu/lib/proc.py @@ -0,0 +1,63 @@ +"""Subprocess helpers built on Gio so nothing blocks the GTK main loop. + +Trimmed copy of astal-menu/lib/proc.py's contract: a command prints JSON (or plain +text) to stdout, diagnostics to stderr, non-zero exit on failure. These helpers run +such commands asynchronously and hand the parsed result back on the main thread. +""" + +from __future__ import annotations + +import json +import shlex +from typing import Callable, Sequence + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + + +def _as_argv(cmd: Sequence[str] | str) -> list[str]: + return shlex.split(cmd) if isinstance(cmd, str) else list(cmd) + + +def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None: + """Run cmd, call cb(ok, stdout, stderr) on the main thread when done.""" + try: + proc = Gio.Subprocess.new( + _as_argv(cmd), + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, + ) + except GLib.Error as err: + GLib.idle_add(cb, False, "", str(err)) + return + + def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None: + try: + _, out, errout = p.communicate_utf8_finish(res) + except GLib.Error as err: + cb(False, "", str(err)) + return + cb(p.get_successful(), out or "", errout or "") + + proc.communicate_utf8_async(None, None, _done) + + +def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None: + """Run cmd expecting JSON on stdout; call cb(ok, data).""" + + def _text(ok: bool, out: str, err: str) -> None: + if not ok or not out.strip(): + cb(False, err.strip() or "no output") + return + try: + cb(True, json.loads(out)) + except json.JSONDecodeError as exc: + cb(False, f"bad json: {exc}") + + run_text(cmd, _text) + + +def fire(cmd: Sequence[str] | str) -> None: + """Run cmd and discard the result — for launch/dispatch calls no one awaits.""" + run_text(cmd, lambda *_a: None) diff --git a/orbit-menu/main.py b/orbit-menu/main.py new file mode 100644 index 0000000..83c1a33 --- /dev/null +++ b/orbit-menu/main.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""orbit-menu — a radial power/utility menu for hyprdrive. + +Two entry points into the same tree: + Super+Shift+O -> --power opens straight into the Power ring (sleep / reboot / + power off / soft reboot, via hyprshutdown) + Super+Ctrl+O -> --menu opens the 5-category root (Power, Tools, Scripts, + Management, Windows) + +Single-instance, same pattern as astal-menu/main.py: the first launch builds the +(hidden) window and holds; later invocations forward their verb over D-Bus via +scripts/orbit-menu.sh instead of spawning a second python3+GTK4 process. + + main.py run the resident instance (stays hidden until toggled) + main.py --power show, rooted at Power + main.py --menu show, rooted at the full category menu + main.py --hide hide +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# orbit-menu-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement +# ahead of libwayland-client). Drop it once resident so it isn't inherited by +# anything this process launches (see astal-menu/main.py for the same rationale — +# some GTK4 apps abort at startup with the layer-shell lib preloaded). +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import config # noqa: E402 +import menu_tree # noqa: E402 +import theme # noqa: E402 +from orbit_menu import MenuItem, OrbitMenu # noqa: E402 +from paths import APP_ID # noqa: E402 + + +class OrbitMenuApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: OrbitMenu | None = None + self._current_mode: str | None = None # "power" | "menu" + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = OrbitMenu(menu_tree.full_menu_root(), on_close=self._on_closed, + satellites=config.satellites_enabled(), + hologram=config.hologram_enabled()) + self._current_mode = "menu" + self._register_actions() + self.hold() # stay alive with no visible window + + def _on_closed(self) -> None: + pass # nothing to do; the window just hides itself + + def _open(self, mode: str) -> None: + assert self.window is not None + if self.window.get_visible() and self._current_mode == mode: + self.window.set_visible(False) + return + if self._current_mode != mode: + root = menu_tree.power_only_root() if mode == "power" else menu_tree.full_menu_root() + self.window.set_root(root) + self._current_mode = mode + self.window.open_at_root() + + def _register_actions(self) -> None: + def add(name: str, callback) -> None: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def on_hide(*_a) -> None: + assert self.window is not None + self.window.set_visible(False) + + add("power", lambda *_a: self._open("power")) + add("menu", lambda *_a: self._open("menu")) + add("hide", on_hide) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + if self.window is None: + return 0 + if verb == "--power": + self._open("power") + elif verb == "--menu": + self._open("menu") + elif verb == "--hide": + self.window.set_visible(False) + # --daemon and anything else: no-op (stay resident, hidden) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +class DmenuApp(Gtk.Application): + """Standalone 'classic dmenu' mode: read newline-delimited items from stdin, + show them as ring nodes, print the chosen one to stdout and exit 0 (exit 1 on + cancel, printing nothing). NON_UNIQUE so it never routes to the resident daemon + (which couldn't write to this caller's stdout) — it's its own throwaway process.""" + + def __init__(self, items: list[str]) -> None: + super().__init__(application_id=APP_ID + ".dmenu", + flags=Gio.ApplicationFlags.NON_UNIQUE) + self._items = items + self.result: str | None = None + self.window: OrbitMenu | None = None + + def do_activate(self) -> None: + if self.window is not None: # do_activate can fire more than once + return + theme.load_css() + children = [MenuItem(label=line, action=(lambda ln=line: self._pick(ln))) + for line in self._items] + root = MenuItem(label="", children=children) + self.window = OrbitMenu(root, on_close=self._on_closed, + satellites=config.satellites_enabled(), + hologram=config.hologram_enabled()) + self.window.open_at_root() + self.hold() # a hidden layer-shell window wouldn't keep the app alive alone + + def _pick(self, line: str) -> None: + self.result = line # the leaf's action; _close() then runs the outro + on_close + + def _on_closed(self) -> None: + if self.result is not None: + sys.stdout.write(self.result + "\n") + sys.stdout.flush() + self.quit() + + +def _run_dmenu() -> int: + items = [ln.rstrip("\n") for ln in sys.stdin.readlines()] + items = [ln for ln in items if ln != ""] + if not items: + return 1 + app = DmenuApp(items) + app.run([sys.argv[0]]) # don't forward --dmenu into GApplication arg handling + return 0 if app.result is not None else 1 + + +def main() -> int: + GLib.set_prgname("orbit-menu") + if "--dmenu" in sys.argv[1:]: + return _run_dmenu() + return OrbitMenuApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/orbit-menu/menu_tree.py b/orbit-menu/menu_tree.py new file mode 100644 index 0000000..8861691 --- /dev/null +++ b/orbit-menu/menu_tree.py @@ -0,0 +1,182 @@ +"""Builds the 5-category MenuItem tree: Power, Tools, Scripts, Management, Windows. + +Power/Tools/Management are static lists. Scripts and Windows are `dynamic=True` — +orbit_menu.py never caches a dynamic node's page, so both are rebuilt fresh (current +~/Documents/Scripts listing, current `hyprctl clients -j`) every time they're +entered; that dynamism cascades to their children too (see OrbitMenu._cache_key), +so a window's own action ring and its "send to workspace" submenu are always live. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import actions +from orbit_menu import MenuItem +from paths import SCRIPTS_DIR + + +def _hyprctl_json(*args: str): + try: + out = subprocess.run(["hyprctl", *args, "-j"], capture_output=True, + text=True, timeout=1.5, check=True).stdout + return json.loads(out) + except (subprocess.SubprocessError, json.JSONDecodeError, OSError): + return None + + +# -- Power ------------------------------------------------------------------ + +def power_root() -> MenuItem: + return MenuItem(label="Power", icon="⏻", children=[ + MenuItem(label="Sleep", icon="☾", danger=True, + action=lambda: actions.hyprshutdown("systemctl suspend")), + MenuItem(label="Reboot", icon="⟳", danger=True, + action=lambda: actions.hyprshutdown("systemctl reboot")), + MenuItem(label="Power Off", icon="⏻", danger=True, + action=lambda: actions.hyprshutdown("systemctl poweroff")), + MenuItem(label="Soft Reboot", icon="⟲", danger=True, + action=lambda: actions.hyprshutdown("systemctl soft-reboot")), + ]) + + +# -- Tools -------------------------------------------------------------------- +# The little utilities also reachable via their own keybinds (see binds.lua) — +# gathered here as one launcher so they don't all need to be memorised. + +def tools_root() -> MenuItem: + def kitty(tag: str, cmd: str) -> str: + return f"[tag {tag}] kitty {cmd}" + + return MenuItem(label="Tools", icon="⚙", children=[ + MenuItem(label="Timer", + action=lambda: actions.launch( + kitty("+centered-S", "bash ~/.config/scripts/timer-pick"))), + MenuItem(label="SSH Manager", + action=lambda: actions.launch( + kitty("+centered-L", "-e ~/.config/scripts/amssh"))), + MenuItem(label="System Monitor", + action=lambda: actions.launch(kitty("+centered-L", "btop"))), + MenuItem(label="Screen Recorder", + action=lambda: actions.launch("~/.config/scripts/screenrec.sh")), + MenuItem(label="Color Picker", + action=lambda: actions.launch("hyprpicker | wl-copy")), + MenuItem(label="Caffeine", + action=lambda: actions.launch("~/.config/scripts/caffeine.sh")), + MenuItem(label="On-Screen Kbd", + action=lambda: actions.launch("~/.config/scripts/onscreenkb.sh")), + MenuItem(label="Calculator", + action=lambda: actions.launch("qalculate-gtk")), + MenuItem(label="Keybind Help", + action=lambda: actions.launch( + kitty("+centered", "~/.config/scripts/helpmenu.sh"))), + ]) + + +# -- Scripts (dynamic) --------------------------------------------------------- + +def _script_children() -> list[MenuItem]: + if not SCRIPTS_DIR.is_dir(): + return [MenuItem(label=f"(create {SCRIPTS_DIR})")] + entries = sorted(p for p in SCRIPTS_DIR.iterdir() if p.is_file()) + if not entries: + return [MenuItem(label="(no scripts found)")] + + items = [] + for p in entries: + if os.access(p, os.X_OK): + cmd = f"[tag +centered-L] kitty {p}" + elif p.suffix == ".py": + cmd = f"[tag +centered-L] kitty python3 {p}" + elif p.suffix in (".sh", ".bash"): + cmd = f"[tag +centered-L] kitty bash {p}" + else: + continue + items.append(MenuItem(label=p.stem, action=lambda c=cmd: actions.launch(c))) + return items or [MenuItem(label="(no runnable scripts)")] + + +def scripts_root() -> MenuItem: + return MenuItem(label="Scripts", icon="❯", dynamic=True, children=_script_children) + + +# -- Management ----------------------------------------------------------------- + +def management_root() -> MenuItem: + return MenuItem(label="Management", icon="⛶", children=[ + MenuItem(label="Wallpaper Picker", + action=lambda: actions.launch( + "[tag +centered-L] kitty -e ~/.config/scripts/wallpaper-picker ~/Pictures")), + MenuItem(label="Monitor Manager", + action=lambda: actions.launch( + "[tag +centered-L] kitty -e ~/.config/scripts/monitor-manager")), + ]) + + +# -- Windows (dynamic) ----------------------------------------------------------- + +def _window_label(w: dict) -> str: + return (w.get("title") or w.get("class") or "?")[:28] + + +def _workspace_children(address: str) -> list[MenuItem]: + workspaces = _hyprctl_json("workspaces") or [] + if not workspaces: + return [MenuItem(label="(no workspaces)")] + items = [] + for ws in sorted(workspaces, key=lambda w: w.get("id", 0)): + wsid = ws.get("id") + name = ws.get("name") or str(wsid) + items.append(MenuItem(label=name, icon="⇒", + action=lambda w=wsid: actions.send_to_workspace(address, w))) + return items + + +def _window_actions(w: dict) -> list[MenuItem]: + address = w["address"] + pid = w.get("pid", -1) + active = _hyprctl_json("activeworkspace") or {} + active_ws = active.get("id", w.get("workspace", {}).get("id", 0)) + title = _window_label(w) + return [ + MenuItem(label="Go To Window", icon="➤", + action=lambda: actions.focus_window(address)), + MenuItem(label="Bring Here", icon="⇤", + action=lambda: actions.bring_here(address, active_ws)), + MenuItem(label="Close Window", icon="✕", + action=lambda: actions.close_window(address)), + MenuItem(label="Force Kill", icon="⊘", danger=True, + action=lambda: actions.force_kill(pid)), + MenuItem(label="Get PID", icon="#", + action=lambda: actions.report_pid(pid, title)), + MenuItem(label="Send To Workspace", icon="⇒", + dynamic=True, children=lambda: _workspace_children(address)), + ] + + +def _window_children() -> list[MenuItem]: + clients = _hyprctl_json("clients") or [] + wins = [w for w in clients if w.get("mapped", True) and w.get("class")] + if not wins: + return [MenuItem(label="(no open windows)")] + wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0), _window_label(w).lower())) + return [MenuItem(label=_window_label(w), dynamic=True, + children=lambda w=w: _window_actions(w)) for w in wins] + + +def windows_root() -> MenuItem: + return MenuItem(label="Windows", icon="▣", dynamic=True, children=_window_children) + + +# -- Roots ------------------------------------------------------------------ + +def full_menu_root() -> MenuItem: + return MenuItem(label="Menu", children=[ + power_root(), tools_root(), scripts_root(), management_root(), windows_root(), + ]) + + +def power_only_root() -> MenuItem: + return power_root() diff --git a/orbit-menu/orbit_menu.py b/orbit-menu/orbit_menu.py new file mode 100644 index 0000000..3cdfb84 --- /dev/null +++ b/orbit-menu/orbit_menu.py @@ -0,0 +1,1050 @@ +"""Orbit menu — the base element. + +A single "orbit" is one central node surrounded by a ring of child nodes, laid out +on a circle like moons around a planet. Clicking a child that has its own children +re-centers the view on that child and draws a fresh ring for *its* children — the +same base element recursively applied, so orbits nest arbitrarily deep. Clicking the +center (or Backspace) steps back out one level; Escape closes the menu. + +Each level is rendered once into its own Gtk.Fixed page and cached in a plain dict +keyed by the path of indices taken to reach it (e.g. (2, 0)). Navigating between +levels plays a sci-fi-orbital-map zoom (see _start_transition/_update_transition): +the clicked node's on-screen position becomes a focal point — the parent page +zooms *into* that fixed point (as if diving toward it, everything else rushing off +past the edges) while the child page slides in from that exact spot and grows from +a small dot up to the full view, becoming the new center, like a moon becoming the +planet you now orbit. Going back plays the same motion in reverse around the same +point (each depth's focal point is remembered in _stack_origins). Driven by the +same per-frame tick callback that animates the satellites, using GTK4's +Gsk.Transform (via Gtk.Fixed.set_child_transform) to scale each page around an +arbitrary pivot. A category with a dynamic child list (open windows, user scripts) +passes `dynamic=True` so its page is rebuilt — never cached — on every visit. + +Ported from the ~/code.dev/test-astralmenu prototype onto raw Gtk4LayerShell (no +Astal.Window dependency) to match astal-menu/window.py's layer-shell approach. +""" + +from __future__ import annotations + +import math +import random +from dataclasses import dataclass, field +from typing import Callable, Optional + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Gsk", "4.0") +gi.require_version("Graphene", "1.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, Graphene, Gsk, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +# CyberQueer accent/violet, matching style/_colors.css — hardcoded here (as +# lib/border.py does for its own Cairo drawing) since Cairo paints directly and +# doesn't see GTK CSS @define-color names. _MAGENTA has no CSS counterpart — +# it's only used for the hologram noise specs (see _draw_hologram_noise). +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) + + +@dataclass +class _Satellite: + glyph: str + height: float # fixed radial distance from its node — never changes at runtime + wobble_amp: float # extra angle (radians) layered on top of the mouse-facing direction + wobble_speed: float + phase: float + color: tuple[float, float, float] + + +@dataclass +class _Assignment: + """One satellite bound to one node — decided once per page build (see + OrbitMenu._pick_assignments), not re-rolled every frame. A node may end up with + two assignments; since each _Satellite has its own fixed `height`, the two + already ride different-radius orbits, and _pick_assignments additionally + widens the gap between them (see effective_height) so they never crowd.""" + sat: _Satellite + node: tuple[float, float] + bracket: Optional[tuple[str, str]] = None # flanking glyphs, tangent to the orbit + line_style: str = "solid" # "solid" | "dashed" | "dotted" + axis: Optional[str] = None # None -> faces the cursor; "x"/"y" -> see below + effective_height: float = 0.0 # sat.height, widened if this node is doubled up + mouse_offset: float = 0.0 # per-assignment phase added to the axis angle + mult_x: float = 1.0 # independent per-assignment response strength + mult_y: float = 1.0 # to horizontal vs vertical mouse movement + + +@dataclass +class MenuItem: + label: str + children: list["MenuItem"] | Callable[[], list["MenuItem"]] = field(default_factory=list) + action: Optional[Callable[[], None]] = None + danger: bool = False # red/danger tint (power actions) + dynamic: bool = False # rebuild this node's page every visit, never cache + icon: str = "" # optional leading nerd-font glyph, shown above label + + def resolved_children(self) -> list["MenuItem"]: + return self.children() if callable(self.children) else self.children + + +class OrbitMenu(Gtk.Window): + """A layer-shell window that renders a MenuItem tree as nested, cached orbits.""" + + CENTER_SIZE = 108 + NODE_SIZE = 66 # prototype was 84; shrunk >=1/5 (66/84 ≈ 0.79) per node + RADIUS = 168 + CANVAS_SIZE = 480 + + # Cosmetic satellites: glyphs that each orbit ONE ring node at a fixed radial + # "height" from it (set once here, never changed at runtime) — only their angle + # around that node moves, easing to face the cursor plus a small idle wobble so + # they're alive even when the mouse is still. Which nodes get one, how many, and + # which glyph goes where is re-rolled once per page build (_pick_assignments) — + # varied each time you land on a category, stable while that page is showing. + _GLYPH_FONT = "Agave Nerd Font Mono" + _SATELLITES = [ + _Satellite(glyph="\uef5f", height=42, wobble_amp=0.30, wobble_speed=0.6, phase=0.0, color=_ACCENT), + _Satellite(glyph="\U000f0471", height=50, wobble_amp=0.22, wobble_speed=0.45, phase=1.4, color=_VIOLET), + _Satellite(glyph="\uf197", height=46, wobble_amp=0.35, wobble_speed=0.7, phase=2.6, color=_VIOLET), + _Satellite(glyph="\U000f1383", height=54, wobble_amp=0.25, wobble_speed=0.5, phase=4.0, color=_ACCENT), + ] + + # Flanking glyphs drawn tangent to the orbit around some (not all) satellites — + # e.g. "-< >-". Rotated to the local tangent direction in _draw_glyph. + _BRACKETS = [("-<", ">-"), ("-[", "]-"), ("-(", ")-"), ("-{", "}-"), ("-|", "|-")] + _BRACKET_CHANCE = 0.45 + + def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None, + satellites: bool = True, hologram: bool = True): + super().__init__() + self.add_css_class("orbit-menu-window") + self._on_close = on_close + self._satellites_enabled = satellites + self._hologram_enabled = hologram + + self._root = root + self._stack_path: list[int] = [] # indices taken from root + self._stack_origins: list[tuple[float, float]] = [] # focal point per depth, for _go_back + self._pages: dict[tuple, Gtk.Fixed] = {} # cached pages, keyed by path + self._page_rings: dict[Gtk.Fixed, Gtk.DrawingArea] = {} + self._page_buttons: dict[Gtk.Fixed, list[tuple[Gtk.Widget, float, float]]] = {} + self._current_ring: Gtk.DrawingArea | None = None + self._current_page: Gtk.Fixed | None = None + self._transition: dict | None = None # active zoom/fade nav transition, if any + self._intro: dict | None = None # active opening node-condense animation, if any + self._holo_intro_t: float | None = None # >=0 while the "materialize out of static" intro plays + self._holo_outro_t: float | None = None # >=0 while the closing dissolve plays + self._holo_particles: list[dict] = [] # persistent hologram noise specs, see _update_hologram_particles + # Where the hologram glow mask is centred + how big, so it follows the + # zoom into/out of a submenu instead of staying pinned at canvas centre. + c0 = self.CANVAS_SIZE / 2 + self._holo_cx = c0 + self._holo_cy = c0 + self._holo_scale = 1.0 + + # satellite/mouse-parallax state (unused entirely if satellites=False) + cx = cy = self.CANVAS_SIZE / 2 + self._mouse_target = (cx, cy) + self._mouse_smooth = (cx, cy) + self._sat_time = 0.0 + self._last_tick: float | None = None + self._tick_id: int | None = None + + self._init_layer_shell() + + self._viewport = Gtk.Fixed() + self._viewport.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + + # Hologram overlay: a single DrawingArea painted on top of EVERYTHING + # (buttons included), covering the whole menu regardless of which page is + # showing. set_can_target(False) so it never steals clicks meant for the + # buttons underneath. + self._hologram_area = Gtk.DrawingArea() + self._hologram_area.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + self._hologram_area.set_can_target(False) + self._hologram_area.set_draw_func(self._draw_hologram_frame) + self._hologram_area.add_css_class("orbit-hologram") + + root_overlay = Gtk.Overlay() + root_overlay.add_css_class("orbit-root-overlay") + root_overlay.set_child(self._viewport) + root_overlay.add_overlay(self._hologram_area) + self.set_child(root_overlay) + + keys = Gtk.EventControllerKey() + keys.connect("key-pressed", self._on_key) + self.add_controller(keys) + + if self._satellites_enabled: + motion = Gtk.EventControllerMotion() + motion.connect("motion", self._on_motion) + motion.connect("leave", self._on_motion_leave) + self.add_controller(motion) + + self._show_path((), animate=False) + + # -- layer shell ---------------------------------------------------- + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.OVERLAY) + LayerShell.set_namespace(self, "orbit-menu") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.EXCLUSIVE) + # No anchors set => gtk-layer-shell centers the surface on its output. + + # -- item lookup / path helpers -------------------------------------- + def _item_at(self, path: tuple) -> MenuItem: + item = self._root + for i in path: + item = item.resolved_children()[i] + return item + + def _cache_key(self, path: tuple) -> tuple | None: + """None => never cache (path passes through a dynamic node).""" + item = self._root + for i in path: + if item.dynamic: + return None + item = item.resolved_children()[i] + return None if item.dynamic else path + + # -- page construction ------------------------------------------------ + @classmethod + def _ring_position(cls, count: int, idx: int) -> tuple[float, float]: + """Where ring node `idx` (of `count`) sits — same formula every page + uses, so a click handler can compute a node's position without needing + the built page widget (used as the zoom's focal point, see _on_node_clicked).""" + cx = cy = cls.CANVAS_SIZE / 2 + angle = -math.pi / 2 + idx * (2 * math.pi / max(count, 1)) + return cx + cls.RADIUS * math.cos(angle), cy + cls.RADIUS * math.sin(angle) + + def _build_page(self, path: tuple) -> tuple[Gtk.Fixed, Gtk.DrawingArea]: + current = self._item_at(path) + fixed = Gtk.Fixed() + fixed.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + cx = cy = self.CANVAS_SIZE / 2 + + ring = Gtk.DrawingArea() + ring.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + children = current.resolved_children() + fixed.put(ring, 0, 0) + + # (widget, x, y) for every button on this page, center first — used only by + # the opening "materialize from noise" intro (see _start_intro), which needs + # each button's position to fade it in and spawn a noise burst there. + buttons: list[tuple[Gtk.Widget, float, float]] = [] + + center_btn = Gtk.Button() + center_btn.set_has_frame(False) + center_btn.add_css_class("orbit-center") + if len(path) == 0: + center_btn.add_css_class("orbit-root") + center_btn.set_child(self._node_label(current.icon, current.label)) + center_btn.set_size_request(self.CENTER_SIZE, self.CENTER_SIZE) + center_btn.connect("clicked", lambda *_a: self._go_back()) + fixed.put(center_btn, cx - self.CENTER_SIZE / 2, cy - self.CENTER_SIZE / 2) + buttons.append((center_btn, cx, cy)) + + count = len(children) + node_positions: list[tuple[float, float]] = [] + for i, item in enumerate(children): + nx, ny = self._ring_position(count, i) + node_positions.append((nx, ny)) + + node_btn = Gtk.Button() + node_btn.set_has_frame(False) + node_btn.add_css_class("orbit-node") + if item.danger: + node_btn.add_css_class("orbit-danger") + node_btn.set_child(self._node_label(item.icon, item.label)) + node_btn.set_size_request(self.NODE_SIZE, self.NODE_SIZE) + node_btn.set_tooltip_text(item.label) + node_btn.connect("clicked", lambda *_a, idx=i: self._on_node_clicked(idx)) + fixed.put(node_btn, nx - self.NODE_SIZE / 2, ny - self.NODE_SIZE / 2) + buttons.append((node_btn, nx, ny)) + + self._page_buttons[fixed] = buttons + + assignments = self._pick_assignments(node_positions, center=(cx, cy)) \ + if self._satellites_enabled else [] + ring.set_draw_func(self._make_page_draw(has_ring=bool(children), assignments=assignments)) + return fixed, ring + + _MAX_PER_NODE = 2 + _MIN_SPREAD_NODES = 3 # every satellite spawned is spread across >= this + # many distinct nodes, node count permitting + _DOUBLED_MIN_GAP = 22 # minimum radius gap between 2 satellites sharing a node + _CENTER_HEIGHT_BUMP = 30 # extra clearance so satellites orbit outside the + # (much bigger) center button, not underneath it + _LINE_STYLES = ["solid", "dashed", "dotted"] + _COUNT_BASE = 2 + _COUNT_RANDOM_MOD = 3 # random upper bound = (_COUNT_RANDOM_MOD % node-amount) + _COUNT_RANDOM_ADD + _COUNT_RANDOM_ADD = 2 + + def _pick_assignments(self, node_positions: list[tuple[float, float]], + center: Optional[tuple[float, float]] = None) -> list[_Assignment]: + """Randomize which nodes get a satellite, which glyph goes where, and which + (if any) get a tangential bracket — decided once per page build. Total count + is always _COUNT_BASE + random(1, (_COUNT_RANDOM_MOD % node-amount) + + _COUNT_RANDOM_ADD) — scales down on pages with few nodes — clamped to + however many actually fit given _MAX_PER_NODE; since there are only + len(_SATELLITES) distinct glyphs, counts above that cycle through them again + rather than repeating the same one back-to-back. Placement first seeds + _MIN_SPREAD_NODES distinct nodes one each, then drops the rest onto random + nodes with room — so on any page with enough nodes, they land on at least 3 + of them, never crammed onto 1-2. `center` (the center/back button's + position) is just one more candidate node — it can get satellites too, with + extra height so they clear it.""" + all_positions = list(node_positions) + if center is not None: + all_positions.append(center) + n_nodes = len(all_positions) + if n_nodes == 0: + return [] + + random_max = (self._COUNT_RANDOM_MOD % n_nodes) + self._COUNT_RANDOM_ADD + count = self._COUNT_BASE + random.randint(1, random_max) + count = min(count, n_nodes * self._MAX_PER_NODE) + sats: list[_Satellite] = [] + while len(sats) < count: + cycle = list(self._SATELLITES) + random.shuffle(cycle) + sats.extend(cycle) + sats = sats[:count] + random.shuffle(sats) + + node_order = list(range(n_nodes)) + random.shuffle(node_order) + seed_count = min(self._MIN_SPREAD_NODES, n_nodes) + + per_node = [0] * n_nodes + placements: list[tuple[_Satellite, int]] = [] + for idx in node_order[:seed_count]: + if not sats: + break + per_node[idx] += 1 + placements.append((sats.pop(), idx)) + for sat in sats: + candidates = [i for i, c in enumerate(per_node) if c < self._MAX_PER_NODE] + if not candidates: + break # every node already at cap (only possible when n_nodes is tiny) + idx = random.choice(candidates) + per_node[idx] += 1 + placements.append((sat, idx)) + + assignments = [ + _Assignment( + sat=sat, node=all_positions[idx], + effective_height=sat.height + (self._CENTER_HEIGHT_BUMP if all_positions[idx] == center else 0), + bracket=random.choice(self._BRACKETS) if random.random() < self._BRACKET_CHANCE else None, + line_style=random.choice(self._LINE_STYLES), + # Independent per-satellite response strength to horizontal vs. + # vertical mouse movement — drawn separately, so a given satellite + # often ends up noticeably more reactive in one direction than + # the other instead of moving toward the cursor symmetrically. + mult_x=random.uniform(0.5, 1.8), + mult_y=random.uniform(0.5, 1.8), + ) + for sat, idx in placements + ] + + # A node with 2 satellites: split their motion instead of both facing the + # cursor identically (one tracks the mouse's X only, the other its Y — see + # _draw_satellites), widen the gap between their orbit radii so the higher + # one reads as clearly further out, and give each its own random phase + # offset — so when a page has several doubled nodes, they drift out of + # sync with each other instead of all sweeping in lockstep. + by_node: dict[tuple[float, float], list[_Assignment]] = {} + for a in assignments: + by_node.setdefault(a.node, []).append(a) + for group in by_node.values(): + if len(group) == self._MAX_PER_NODE: + random.shuffle(group) + group[0].axis = "x" + group[1].axis = "y" + for g in group: + g.mouse_offset = random.uniform(0.0, 2 * math.pi) + lo, hi = sorted(group, key=lambda a: a.effective_height) + if hi.effective_height - lo.effective_height < self._DOUBLED_MIN_GAP: + hi.effective_height = lo.effective_height + self._DOUBLED_MIN_GAP + + return assignments + + @staticmethod + def _node_label(icon: str, label: str) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, + halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) + if icon: + ic = Gtk.Label(label=icon) + ic.add_css_class("orbit-node-icon") + box.append(ic) + lb = Gtk.Label(label=label, wrap=True, justify=Gtk.Justification.CENTER, + max_width_chars=10, lines=2, ellipsize=3) + lb.add_css_class("orbit-node-text") + box.append(lb) + return box + + def _make_page_draw(self, has_ring: bool, assignments: list[_Assignment]): + def draw(_area, cr, width, height): + if has_ring: + cx, cy = width / 2, height / 2 + cr.set_source_rgba(1, 1, 1, 0.12) + cr.set_line_width(1.5) + cr.arc(cx, cy, self.RADIUS, 0, 2 * math.pi) + cr.stroke() + if self._satellites_enabled: + self._draw_satellites(cr, assignments) + return draw + + def _draw_satellites(self, cr, assignments: list[_Assignment]) -> None: + """Each assigned glyph orbits its node at a FIXED radial height (set once in + _SATELLITES, never changed here) — only the angle around the node moves: it + eases to face the cursor (see _on_tick's mouse smoothing) plus a small idle + wobble so it's alive even when the mouse sits still. Purely cosmetic, never + hit-testable. Orbit lines are drawn first, in their own pass, so no glyph's + glow ends up half-covered by a line belonging to a different satellite.""" + for a in assignments: + nx, ny = a.node + self._draw_orbit_line(cr, nx, ny, a.effective_height, a.sat.color, a.line_style) + + mx, my = self._mouse_smooth + for a in assignments: + nx, ny = a.node + if a.axis == "x": + # tracks only the cursor's X position — a full sweep left-to-right + # across the canvas takes it all the way around its node. Each + # doubled node's own random offset (see _pick_assignments) keeps + # multiple doubled nodes from all sweeping in lockstep. + face_angle = (mx / self.CANVAS_SIZE) * 2 * math.pi * a.mult_x + a.mouse_offset + elif a.axis == "y": + face_angle = (my / self.CANVAS_SIZE) * 2 * math.pi * a.mult_y + a.mouse_offset + else: + # mult_x/mult_y independently scale each axis before the direction + # is computed, so this satellite can be visibly more reactive to + # horizontal cursor movement than vertical, or vice versa. + dx, dy = (mx - nx) * a.mult_x, (my - ny) * a.mult_y + face_angle = math.atan2(dy, dx) if (dx or dy) else 0.0 + angle = face_angle + a.sat.wobble_amp * math.sin(self._sat_time * a.sat.wobble_speed + a.sat.phase) + px = nx + a.effective_height * math.cos(angle) + py = ny + a.effective_height * math.sin(angle) + self._draw_glyph(cr, px, py, a.sat.glyph, a.sat.color, orbit_angle=angle, bracket=a.bracket) + + def _draw_orbit_line(self, cr, nx: float, ny: float, radius: float, + color: tuple[float, float, float], style: str) -> None: + """A circle tracing the path a satellite's angle sweeps around its node — + solid/dashed/dotted per-assignment (see _pick_assignments), fixed for as + long as that page's assignments are, so it never flickers style. Kept + visibly faint against the dark theme, but not so faint it disappears.""" + r, g, b = color + cr.save() + if style == "dashed": + cr.set_dash([7.0, 5.0]) + elif style == "dotted": + cr.set_dash([2.0, 3.5]) + cr.set_source_rgba(r, g, b, 0.38) + cr.set_line_width(1.4) + cr.arc(nx, ny, radius, 0, 2 * math.pi) + cr.stroke() + cr.restore() + + def _draw_glyph(self, cr, x: float, y: float, glyph: str, color: tuple[float, float, float], + orbit_angle: float = 0.0, bracket: Optional[tuple[str, str]] = None) -> None: + r, g, b = color + + glow = cairo.RadialGradient(x, y, 0, x, y, 16) + glow.add_color_stop_rgba(0.0, r, g, b, 0.45) + glow.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(glow) + cr.arc(x, y, 16, 0, 2 * math.pi) + cr.fill() + + cr.select_font_face(self._GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + cr.set_font_size(15) + ext = cr.text_extents(glyph) + cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing) + cr.set_source_rgba(r, g, b, 0.95) + cr.show_text(glyph) + + if bracket is not None: + self._draw_bracket(cr, x, y, orbit_angle, bracket, color) + + def _draw_bracket(self, cr, x: float, y: float, orbit_angle: float, + bracket: tuple[str, str], color: tuple[float, float, float]) -> None: + """Flank the glyph at (x, y) with two small strings along the orbit's + TANGENT at this point (perpendicular to the radial orbit_angle), e.g. + "-< ->-" either side — like brackets riding along the direction of travel + rather than pointing at the node.""" + r, g, b = color + tangent = orbit_angle + math.pi / 2 + # keep the text roughly upright instead of upside-down on the far side + if math.cos(tangent) < 0: + tangent += math.pi + + cr.select_font_face(self._GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + cr.set_font_size(11) + offset = 15.0 + for text, sign in ((bracket[0], -1), (bracket[1], 1)): + ext = cr.text_extents(text) + bx = x + sign * offset * math.cos(tangent) + by = y + sign * offset * math.sin(tangent) + cr.save() + cr.translate(bx, by) + cr.rotate(tangent) + cr.move_to(-ext.width / 2 - ext.x_bearing, -ext.height / 2 - ext.y_bearing) + cr.set_source_rgba(r, g, b, 0.7) + cr.show_text(text) + cr.restore() + + # -- holographic overlay -------------------------------------------------- + # Config-toggleable (~/.local/state/orbit-menu/config.json's "hologram" key, + # see config.py); painted once, on top of the whole menu, independent of + # which page is showing — see the Gtk.Overlay setup in __init__. + _HOLO_TINT = _VIOLET # scanlines/sweep/flicker: violet + _HOLO_SCANLINE_GAP = 4.0 + _HOLO_SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass + _HOLO_SWEEP_HALF_HEIGHT = 90.0 + _HOLO_NOISE_COUNT = 370 # specs alive at once (each with its own lifetime, not per-frame) + _HOLO_NOISE_COLORS = [_MAGENTA, _ACCENT] # magenta + red only + _HOLO_NOISE_LIFETIME = (0.6, 1.7) # seconds a given spec persists before being replaced + _HOLO_NOISE_FADE_IN = 0.2 # fraction of lifetime spent fading in + _HOLO_NOISE_FADE_OUT = 0.5 # fraction of lifetime spent fading out (at the end) + _HOLO_MASK_INNER = RADIUS + 8 # full-strength out to just past the ring nodes + # faded out completely by here — capped so it fully resolves to 0 before the + # canvas edge (CANVAS_SIZE/2), not just before the corners + _HOLO_MASK_OUTER = min(RADIUS + NODE_SIZE * 1.4, CANVAS_SIZE / 2 - 10) + # "materialize out of static" opening intro — same treatment as the other + # Cosmonaut Shell surfaces' shared hologram lib (haze + burst of static specks + # that thins out, plus a bright scan band wiping through as it resolves). + _HOLO_INTRO_DURATION = 2.4 # long, noisy fade-in + _HOLO_INTRO_STATIC = 1300 # specks at the very start, thinning to 0 + _HOLO_OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close + + def _draw_hologram_frame(self, _area, cr, width: float, height: float) -> None: + if self._hologram_enabled: + self._draw_hologram(cr, width, height) + + def _draw_hologram(self, cr, width: float, height: float) -> None: + """Draws the whole effect into an offscreen group, then composites it back + through a radial-gradient alpha mask centered on the canvas — full strength + around the ring, soft-fading to nothing by _HOLO_MASK_OUTER, so it reads as + one big glow over the menu instead of tinting the whole square canvas.""" + cx, cy = self._holo_cx, self._holo_cy + cr.push_group() + self._draw_hologram_content(cr, width, height) + pattern = cr.pop_group() + + inner = self._HOLO_MASK_INNER * self._holo_scale + outer = self._HOLO_MASK_OUTER * self._holo_scale + mask = cairo.RadialGradient(cx, cy, inner, cx, cy, outer) + mask.add_color_stop_rgba(0.0, 1, 1, 1, 1) + mask.add_color_stop_rgba(1.0, 1, 1, 1, 0) + cr.set_source(pattern) + cr.mask(mask) + + if self._holo_intro_t is not None: + self._draw_holo_intro(cr, cx, cy, outer) + elif self._holo_outro_t is not None: + self._draw_holo_outro(cr, cx, cy, outer) + + def _draw_holo_intro(self, cr, cx: float, cy: float, outer: float) -> None: + """The opening burst: a violet haze thick with static specks that thins as + the menu resolves, plus a bright scan band sweeping through — confined to + the same radial footprint as the ambient hologram so the menu looks like it + condenses out of the noise (not a full-screen static wash over the desktop).""" + p = min(1.0, max(0.0, (self._holo_intro_t or 0.0) / self._HOLO_INTRO_DURATION)) + strength = 1.0 - p + # a slightly wider footprint than the ambient glow so the static reads as a + # cloud the menu emerges from, faded out by its edge with a radial mask + reach = outer * 1.15 + cr.push_group() + # No solid veil block: the ring itself fades in (viewport opacity ramp in + # _on_tick). Here we only lay churning static over it — dense at first, + # thinning to nothing — so the menu resolves out of noise as it fades up. + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self._HOLO_INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none + for _ in range(count): + x = cx + random.uniform(-reach, reach) + y = cy + random.uniform(-reach, reach) + cr.set_source_rgba(*random.choice(colors), + random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + # bright scan band wiping down through the footprint as it resolves + band_y = cy - reach + p * (2 * reach) + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(cx - reach, band_y - 1.5, 2 * reach, 3.0) + cr.fill() + pat = cr.pop_group() + imask = cairo.RadialGradient(cx, cy, 0, cx, cy, reach) + imask.add_color_stop_rgba(0.0, 1, 1, 1, 1) + imask.add_color_stop_rgba(0.82, 1, 1, 1, 1) + imask.add_color_stop_rgba(1.0, 1, 1, 1, 0) + cr.set_source(pat) + cr.mask(imask) + + def _draw_holo_outro(self, cr, cx: float, cy: float, outer: float) -> None: + """Reverse of the intro: the ring is already fading back out (viewport + opacity ramp in _on_tick); here the static thickens from nothing as it goes, + so the menu dissolves into noise just before it vanishes.""" + po = min(1.0, max(0.0, (self._holo_outro_t or 0.0) / self._HOLO_OUTRO_DURATION)) + reach = outer * 1.15 + cr.push_group() + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self._HOLO_INTRO_STATIC * (po ** 0.5)) # static grows as it dissolves + for _ in range(count): + x = cx + random.uniform(-reach, reach) + y = cy + random.uniform(-reach, reach) + cr.set_source_rgba(*random.choice(colors), + random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band_y = cy + reach - po * (2 * reach) # scan wiping back up as it dissolves + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(cx - reach, band_y - 1.5, 2 * reach, 3.0) + cr.fill() + pat = cr.pop_group() + imask = cairo.RadialGradient(cx, cy, 0, cx, cy, reach) + imask.add_color_stop_rgba(0.0, 1, 1, 1, 1) + imask.add_color_stop_rgba(0.82, 1, 1, 1, 1) + imask.add_color_stop_rgba(1.0, 1, 1, 1, 0) + cr.set_source(pat) + cr.mask(imask) + + def _draw_hologram_content(self, cr, width: float, height: float) -> None: + r, g, b = self._HOLO_TINT + + # faint horizontal scanlines + cr.save() + cr.set_source_rgba(r, g, b, 0.05) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self._HOLO_SCANLINE_GAP + cr.stroke() + cr.restore() + + # a bright band sweeping top -> bottom on a loop, like a hologram scan pass + phase = (self._sat_time % self._HOLO_SWEEP_PERIOD) / self._HOLO_SWEEP_PERIOD + sweep_y = phase * height + hh = self._HOLO_SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.10) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + # subtle overall flicker so it doesn't look perfectly static + flicker = 0.018 + 0.012 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_hologram_noise(cr, width, height) + + def _update_hologram_particles(self, width: float, height: float) -> None: + """Age out expired specs and top back up to _HOLO_NOISE_COUNT — each spec + keeps its position/color for its own randomized lifetime (_HOLO_NOISE_ + LIFETIME) instead of every spec being replaced every single frame.""" + now = self._sat_time + self._holo_particles = [p for p in self._holo_particles if now - p["birth"] < p["life"]] + while len(self._holo_particles) < self._HOLO_NOISE_COUNT: + self._holo_particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self._HOLO_NOISE_COLORS), + "peak_alpha": random.uniform(0.08, 0.30), + "birth": now, + "life": random.uniform(*self._HOLO_NOISE_LIFETIME), + }) + + def _draw_hologram_noise(self, cr, width: float, height: float) -> None: + """Colored specs that persist for a real lifetime, fading in then out — + reads as signal static/interference rather than the smooth violet wash the + scanlines/sweep alone would give, without the single-frame teleport flicker + redrawing everything from scratch every tick would cause.""" + self._update_hologram_particles(width, height) + now = self._sat_time + for p in self._holo_particles: + t = (now - p["birth"]) / p["life"] # 0 (birth) .. 1 (death) + if t < self._HOLO_NOISE_FADE_IN: + envelope = t / self._HOLO_NOISE_FADE_IN + elif t > 1.0 - self._HOLO_NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self._HOLO_NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() + + # -- mouse parallax / animation loop ------------------------------------- + def _on_motion(self, _ctrl, x: float, y: float) -> None: + self._mouse_target = (x, y) + + def _on_motion_leave(self, _ctrl) -> None: + self._mouse_target = (self.CANVAS_SIZE / 2, self.CANVAS_SIZE / 2) + + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 # -> seconds + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + + self._sat_time += dt + + mx, my = self._mouse_target + sx, sy = self._mouse_smooth + ease = 1 - math.exp(-dt * 6.0) # frame-rate independent lerp toward the cursor + self._mouse_smooth = (sx + (mx - sx) * ease, sy + (my - sy) * ease) + + tr = self._transition + if tr is not None: + self._update_transition() + if tr.get("old_ring") is not None: + tr["old_ring"].queue_draw() + if tr.get("new_ring") is not None: + tr["new_ring"].queue_draw() + elif self._satellites_enabled and self._current_ring is not None: + self._current_ring.queue_draw() + + if self._intro is not None: + self._update_intro() + + if self._holo_intro_t is not None: + self._holo_intro_t += dt + if self._holo_intro_t >= self._HOLO_INTRO_DURATION: + self._holo_intro_t = None + self._viewport.set_opacity(1.0) + else: + q = self._holo_intro_t / self._HOLO_INTRO_DURATION + self._viewport.set_opacity(q * q * (3 - 2 * q)) # smooth fade in of the ring + + if self._holo_outro_t is not None: + self._holo_outro_t += dt + po = min(1.0, self._holo_outro_t / self._HOLO_OUTRO_DURATION) + self._viewport.set_opacity(1.0 - po * po * (3 - 2 * po)) # fade out the ring + if self._holo_outro_t >= self._HOLO_OUTRO_DURATION: + self._finish_close() + return True # tick already removed by _finish_close + + if self._hologram_enabled: + self._hologram_area.queue_draw() + return True # keep ticking every frame while the window is mapped + + # -- zoom/fade navigation transition -------------------------------------- + # Sci-fi-orbital-map feel: the clicked node IS the focal point. Going in, the + # parent page zooms in on that point (as if diving toward it, everything else + # rushing off past the edges) while the child page slides in from that same + # screen position and grows from a small dot up to full size, becoming the new + # center — like a moon becoming the planet you now orbit. Going back reverses + # the exact same motion around the exact same point. + _ZOOM_DURATION = 0.34 # seconds — a bit more cinematic than a plain fade + _ZOOM_CHILD_START = 0.4 # child page starts at this scale, sitting at the focal point + _ZOOM_PARENT_END = 2.4 # parent page zooms into the focal point up to this scale + + def _focal_zoom_transform(self, fx: float, fy: float, scale: float) -> Gsk.Transform: + """Scale a page around an arbitrary point (fx, fy) instead of its own + center — used for the page that stays put but zooms into/out of the + focal point (translate(focal) . scale . translate(-focal)).""" + t = Gsk.Transform.new() + t = t.translate(Graphene.Point().init(fx, fy)) + t = t.scale(scale, scale) + t = t.translate(Graphene.Point().init(-fx, -fy)) + return t + + def _slide_zoom_transform(self, target_x: float, target_y: float, scale: float) -> Gsk.Transform: + """Map a page's own center to on-screen position (target_x, target_y) at + the given scale — used for the page that slides between the focal point + and the canvas center while growing/shrinking to become the full view.""" + c = self.CANVAS_SIZE / 2 + t = Gsk.Transform.new() + t = t.translate(Graphene.Point().init(target_x, target_y)) + t = t.scale(scale, scale) + t = t.translate(Graphene.Point().init(-c, -c)) + return t + + def _start_transition(self, new_page: Gtk.Fixed, direction: str, animate: bool, + focal: Optional[tuple[float, float]] = None) -> None: + old_page = self._current_page + if old_page is new_page: + return + if self._transition is not None: + self._finalize_transition() # snap any in-flight transition instantly first + + if new_page.get_parent() is None: + self._viewport.put(new_page, 0, 0) + + if not animate or old_page is None: + new_page.set_opacity(1.0) + self._viewport.set_child_transform(new_page, None) + if old_page is not None and old_page.get_parent() is not None: + self._viewport.set_child_transform(old_page, None) + self._viewport.remove(old_page) + self._transition = None + return + + c = self.CANVAS_SIZE / 2 + fx, fy = focal if focal is not None else (c, c) + new_page.set_opacity(0.0) + self._transition = { + "old": old_page, "new": new_page, + "old_ring": self._page_rings.get(old_page), + "new_ring": self._page_rings.get(new_page), + "start": None, # set on the first tick, in _sat_time units + "forward": direction == "in", + "focal": (fx, fy), + } + + def _update_transition(self) -> None: + tr = self._transition + if tr is None: + return + if tr["start"] is None: + tr["start"] = self._sat_time + progress = (self._sat_time - tr["start"]) / self._ZOOM_DURATION + progress = min(1.0, max(0.0, progress)) + eased = progress * progress * (3 - 2 * progress) # smoothstep + + c = self.CANVAS_SIZE / 2 + fx, fy = tr["focal"] + forward = tr["forward"] + + # The "slide" page is whichever one occupies the canvas-center full view at + # one end of the animation and the small focal-point dot at the other: the + # child when going in (0 -> 1 = focal -> center), the child when going back + # too but reversed (1 -> 0 = center -> focal) since it's the outgoing side. + if forward: + slide_page, zoom_page = tr["new"], tr["old"] + slide_t0, slide_t1 = (fx, fy), (c, c) + slide_s0, slide_s1 = self._ZOOM_CHILD_START, 1.0 + zoom_s0, zoom_s1 = 1.0, self._ZOOM_PARENT_END + else: + slide_page, zoom_page = tr["old"], tr["new"] + slide_t0, slide_t1 = (c, c), (fx, fy) + slide_s0, slide_s1 = 1.0, self._ZOOM_CHILD_START + zoom_s0, zoom_s1 = self._ZOOM_PARENT_END, 1.0 + + tx = slide_t0[0] + (slide_t1[0] - slide_t0[0]) * eased + ty = slide_t0[1] + (slide_t1[1] - slide_t0[1]) * eased + slide_scale = slide_s0 + (slide_s1 - slide_s0) * eased + self._viewport.set_child_transform(slide_page, self._slide_zoom_transform(tx, ty, slide_scale)) + + # Make the hologram glow mask ride the slide page (the ring that becomes / + # leaves the full view), so the scanline vignette dives into the focal + # point with the submenu instead of staying pinned at canvas centre. + self._holo_cx, self._holo_cy, self._holo_scale = tx, ty, slide_scale + + if zoom_page is not None: + zoom_scale = zoom_s0 + (zoom_s1 - zoom_s0) * eased + self._viewport.set_child_transform(zoom_page, self._focal_zoom_transform(fx, fy, zoom_scale)) + + tr["new"].set_opacity(eased) + if tr["old"] is not None: + tr["old"].set_opacity(1.0 - eased) + + if progress >= 1.0: + self._finalize_transition() + + def _finalize_transition(self) -> None: + """Snap the active transition to its end state — called both when it + completes naturally and to interrupt one cleanly if navigation happens + again before the previous transition finished.""" + tr = self._transition + if tr is None: + return + self._viewport.set_child_transform(tr["new"], None) + tr["new"].set_opacity(1.0) + if tr["old"] is not None: + self._viewport.set_child_transform(tr["old"], None) + if tr["old"].get_parent() is not None: + self._viewport.remove(tr["old"]) + self._transition = None + # settled back on the canvas-centred full view — reset the glow mask + c = self.CANVAS_SIZE / 2 + self._holo_cx, self._holo_cy, self._holo_scale = c, c, 1.0 + + # -- opening intro: nodes materialize from holo-noise ---------------------- + # Only plays when the menu transitions closed -> open (see open_at_root), never + # between nodes — that's the separate zoom transition above. Each button starts + # invisible and fades in on its own staggered delay (center first, then ring + # nodes in order around the circle); the instant a button starts fading in, a + # tight burst of extra hologram noise flashes at its position and burns off, + # like the node is condensing out of the static. + _INTRO_CENTER_DELAY = 0.0 + _INTRO_RING_START = 0.10 # ring nodes begin after the center starts + _INTRO_RING_STAGGER = 0.055 # gap between consecutive ring nodes' start + _INTRO_FADE_DURATION = 0.30 + _INTRO_BURST_COUNT = 10 + _INTRO_BURST_RADIUS = 26.0 + _INTRO_BURST_LIFETIME = (0.35, 0.7) + + def _start_intro(self, page: Gtk.Fixed) -> None: + buttons = self._page_buttons.get(page) + if not buttons: + self._intro = None + return + entries = [] + for i, (widget, x, y) in enumerate(buttons): + widget.set_opacity(0.0) + delay = self._INTRO_CENTER_DELAY if i == 0 else \ + self._INTRO_RING_START + (i - 1) * self._INTRO_RING_STAGGER + entries.append({"widget": widget, "x": x, "y": y, "delay": delay, "burst_spawned": False}) + self._intro = {"start": self._sat_time, "entries": entries} + + def _spawn_holo_burst(self, x: float, y: float) -> None: + colors = [self._HOLO_TINT, self._HOLO_TINT, _MAGENTA, _ACCENT] # mostly violet + for _ in range(self._INTRO_BURST_COUNT): + angle = random.uniform(0, 2 * math.pi) + radius = random.uniform(0, self._INTRO_BURST_RADIUS) + self._holo_particles.append({ + "x": x + radius * math.cos(angle), "y": y + radius * math.sin(angle), + "w": random.uniform(1.0, 2.8), "h": random.uniform(1.0, 2.2), + "color": random.choice(colors), + "peak_alpha": random.uniform(0.20, 0.45), # brighter than ambient noise + "birth": self._sat_time, + "life": random.uniform(*self._INTRO_BURST_LIFETIME), + }) + + def _update_intro(self) -> None: + intro = self._intro + if intro is None: + return + now = self._sat_time - intro["start"] + done = True + for e in intro["entries"]: + t = now - e["delay"] + if t < 0: + done = False + continue + if not e["burst_spawned"]: + if self._hologram_enabled: + self._spawn_holo_burst(e["x"], e["y"]) + e["burst_spawned"] = True + progress = min(1.0, t / self._INTRO_FADE_DURATION) + eased = progress * progress * (3 - 2 * progress) + e["widget"].set_opacity(eased) + if progress < 1.0: + done = False + if done: + self._intro = None + + # -- navigation --------------------------------------------------------- + def _show_path(self, path: tuple, direction: str = "in", animate: bool = True, + focal: Optional[tuple[float, float]] = None) -> None: + key = self._cache_key(path) + if key is not None and key in self._pages: + page = self._pages[key] + ring = self._page_rings[page] + else: + page, ring = self._build_page(path) + self._page_rings[page] = ring + if key is not None: + self._pages[key] = page + + self._start_transition(page, direction=direction, animate=animate, focal=focal) + self._stack_path = list(path) + self._current_ring = ring + self._current_page = page + + def _on_node_clicked(self, idx: int) -> None: + path = tuple(self._stack_path) + children = self._item_at(path).resolved_children() + item = children[idx] + if item.resolved_children(): + focal = self._ring_position(len(children), idx) + self._stack_origins.append(focal) + self._show_path(path + (idx,), direction="in", focal=focal) + else: + if item.action: + item.action() + self._close() + + def _go_back(self) -> None: + if self._stack_path: + focal = self._stack_origins.pop() if self._stack_origins else None + self._show_path(tuple(self._stack_path[:-1]), direction="out", focal=focal) + else: + self._close() + + def _close(self) -> None: + # Play the closing dissolve (ring fades back into static), then hide via + # _finish_close. If already dissolving, or the hologram is off / not + # ticking, just hide immediately. + if (self._hologram_enabled and self._tick_id is not None + and self._holo_outro_t is None): + self._holo_intro_t = None # cancel any in-flight opening intro + self._holo_outro_t = 0.0 + else: + self._finish_close() + + def _finish_close(self) -> None: + self.set_visible(False) + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + self._holo_outro_t = None + self._viewport.set_opacity(1.0) # reset for the next open + if self._on_close: + self._on_close() + + def _on_key(self, _ctrl, keyval, _keycode, _state) -> bool: + if keyval == Gdk.KEY_Escape: + self._close() + return True + if keyval == Gdk.KEY_BackSpace: + self._go_back() + return True + return False + + # -- external control ---------------------------------------------------- + def set_root(self, root: MenuItem) -> None: + """Swap the whole tree (e.g. power-only <-> full category menu). Any + cached pages belonged to the old tree's indices, so they're dropped.""" + self._transition = None + child = self._viewport.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + self._viewport.set_child_transform(child, None) + self._viewport.remove(child) + child = nxt + self._pages.clear() + self._page_rings.clear() + self._page_buttons.clear() + self._current_ring = None + self._current_page = None + self._stack_origins.clear() + self._intro = None + self._holo_intro_t = None + self._holo_outro_t = None + self._root = root + + def open_at_root(self) -> None: + """Reset to the top of the tree, instantly (no zoom on open) — dynamic + pages along the way get rebuilt the next time they're actually visited, + not eagerly here. Plays the "materialize from noise" intro (see + _start_intro) every time, since this only runs on closed -> open, never + on in-menu navigation.""" + self._show_path((), animate=False) + self._start_intro(self._current_page) + if self._hologram_enabled: + self._holo_outro_t = None # cancel any in-flight closing dissolve + self._holo_intro_t = 0.0 # play the "materialize out of static" burst + self._viewport.set_opacity(0.0) # start hidden; _on_tick ramps it up + self.set_visible(True) + self.present() + # Reset the frame-time baseline so the first tick's dt is 0. Otherwise a + # WARM reopen would see dt = (time the menu was closed), advancing the intro + # past its whole duration in one frame — the intro would silently not play. + self._last_tick = None + if self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) diff --git a/orbit-menu/paths.py b/orbit-menu/paths.py new file mode 100644 index 0000000..e642425 --- /dev/null +++ b/orbit-menu/paths.py @@ -0,0 +1,23 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +SCRIPTS_DIR = Path.home() / "Documents" / "Scripts" + +# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does +# `rm -rf ~/.config/orbit-menu` on every dotfiles sync (see astal-menu/paths.py +# for the same rationale), which would wipe a hand-edited config on the spot. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "orbit-menu" +CONFIG_FILE = STATE_DIR / "config.json" + +APP_ID = "eu.abdelbaki.orbitmenu" + + +def ensure_dirs() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/orbit-menu/style/_colors.css b/orbit-menu/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/orbit-menu/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/orbit-menu/style/style.css b/orbit-menu/style/style.css new file mode 100644 index 0000000..bd66b3f --- /dev/null +++ b/orbit-menu/style/style.css @@ -0,0 +1,121 @@ +/* orbit-menu — CyberQueer radial menu. + * Colors come from _colors.css (@text/@bg/@accent/@violet/@danger), loaded first + * by theme.py. Nested-level navigation is a manual zoom (see orbit_menu.py's + * _update_transition); this file only owns per-node hover/glow. + */ + +window.orbit-menu-window { + background: transparent; +} + +/* Neutralise the CyberQueer theme's `* { background-color:#1a1a1a }` on EVERY + * node — the per-level Gtk.Fixed pages and the _viewport Fixed have no css class, + * so blanking by name (below) misses them and they paint an opaque dark slab over + * the whole menu. This provider is at USER+1; the node gradients survive because + * their .orbit-* rules out-specify `*`. */ +* { + background-color: transparent; +} + +/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea + * nodes too (unlike plain Box/Frame containers, which this GTK build's renderer + * leaves alone — see lib/border.py in astal-menu for the same observation). The + * hologram overlay sits on TOP of the entire menu via Gtk.Overlay, so if it kept + * that background it would hide everything underneath it — force both it and its + * Gtk.Overlay parent transparent. */ +.orbit-root-overlay, +.orbit-hologram { + background: transparent; + background-color: transparent; + background-image: none; +} + +.orbit-center, +.orbit-node { + border-radius: 9999px; + border: 1.5px solid alpha(@text, 0.15); + color: @text; + transition: background 200ms ease, border-color 200ms ease, + box-shadow 220ms cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 0 0 0 alpha(@accent, 0); +} + +/* The cyberqueer GTK theme (~/.config/gtk-4.0/gtk.css) paints every widget node + * with `* { background-color: #1a1a1a }`, including the label/box that carries + * each node's icon+text — leaving a dark rectangle behind the glyphs instead of + * showing the round gradient button underneath. Force those descendants clear. */ +.orbit-center *, +.orbit-node * { + background: transparent; + background-color: transparent; + background-image: none; +} + +.orbit-node-icon { + font-size: 15px; + color: @text; +} + +.orbit-node-text { + font-size: 10px; + font-weight: 600; +} + +/* -- center node ------------------------------------------------------- */ + +.orbit-center { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.9), alpha(@violet, 0.32) 75%); + border-color: alpha(@accent, 0.45); + font-size: 13px; + font-weight: 700; +} +.orbit-center .orbit-node-text { + font-size: 12px; +} +.orbit-center:hover { + border-color: @accent; + box-shadow: 0 0 18px 2px alpha(@accent, 0.55); +} + +.orbit-root { + border-color: alpha(@text, 0.25); +} + +/* -- ring nodes ---------------------------------------------------------- */ + +.orbit-node { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.18), alpha(@violet, 0.32) 80%); + border-color: alpha(@violet, 0.5); +} + +.orbit-node:hover { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.55), alpha(@violet, 0.32) 80%); + border-color: @violet; + box-shadow: 0 0 16px 1px alpha(@violet, 0.6); +} + +.orbit-node:active { + box-shadow: 0 0 22px 3px alpha(@violet, 0.85); +} + +/* -- danger nodes (power actions, force kill) ---------------------------- */ + +.orbit-danger { + border-color: alpha(@danger, 0.55); + background-image: radial-gradient(circle at 35% 30%, alpha(@danger, 0.22), alpha(@violet, 0.32) 80%); +} + +.orbit-danger .orbit-node-icon, +.orbit-danger .orbit-node-text { + color: alpha(@text, 0.95); +} + +.orbit-danger:hover { + border-color: @danger; + background-image: radial-gradient(circle at 35% 30%, alpha(@danger, 0.5), alpha(@violet, 0.32) 80%); + box-shadow: 0 0 18px 2px alpha(@danger, 0.7); +} + +.orbit-danger:active { + box-shadow: 0 0 26px 4px alpha(@danger, 0.9); +} diff --git a/orbit-menu/theme.py b/orbit-menu/theme.py new file mode 100644 index 0000000..a36995b --- /dev/null +++ b/orbit-menu/theme.py @@ -0,0 +1,30 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as astal-menu's +theme.py. _colors.css (generated from ~/Dotfiles/colors.conf by apply-theme.sh) +defines the CyberQueer @define-color names; style.css consumes them. + +Priority is USER+1 for the same reason as astal-menu: the CyberQueer GTK theme at +~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION (600), and +would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/pull-from-dots.sh b/pull-from-dots.sh new file mode 100755 index 0000000..6a5ea3a --- /dev/null +++ b/pull-from-dots.sh @@ -0,0 +1,8 @@ +#!/bin/bash +cp -r ~/Dotfiles/desktopenvs/hyprdrive/astro-menu . +cp -r ~/Dotfiles/desktopenvs/hyprdrive/station-bar . +cp -r ~/Dotfiles/desktopenvs/hyprdrive/beacon . +cp -r ~/Dotfiles/desktopenvs/hyprdrive/transmitter-panel . +cp -r ~/Dotfiles/desktopenvs/hyprdrive/horizon-dock . +cp -r ~/Dotfiles/desktopenvs/hyprdrive/orbit-menu . + diff --git a/station-bar/.gitignore b/station-bar/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/station-bar/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/station-bar/bar.py b/station-bar/bar.py new file mode 100644 index 0000000..4a0b017 --- /dev/null +++ b/station-bar/bar.py @@ -0,0 +1,588 @@ +"""station-bar (codename: Voidstation Status Bar) — the EWW top bar's +replacement, styled as an extra orbit of Horizon Dock: a thin, full-width +layer-shell panel anchored to the top edge with a shallow "horizon" curve +drawn behind its content (same arc math as horizon-dock's row arcs, just one +row, much shallower — a narrow curved space rather than a deep dip). + +Three zones, left/center/right (Gtk.CenterBox over a Cairo-drawn curve): + left battery (hidden if none) · Orbit / Astro launcher buttons · + workspace "stations" (the focused one drawn as the "spaceship") + center the focused window's title + right tray (drawn inside a small space-station pictogram) · volume · clock + +Unlike orbit-menu/horizon-dock this is a real reserved-space bar (an exclusive +layer-shell zone), not a floating popover — but it's still toggleable exactly +like them (D-Bus show/hide/toggle via main.py), releasing its exclusive zone +on hide so windows reflow to use the freed space. +""" + +from __future__ import annotations + +import math +import os +import subprocess +import time +from typing import Optional + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Pango", "1.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +import battery +import hypr_ipc +import volume as volume_source +from lib.hologram import HologramOverlay +from tray import TrayHost + +# CyberQueer accent/violet — hardcoded for Cairo the same way orbit-menu and +# horizon-dock do, since Cairo paints directly and doesn't see GTK's +# @define-color names. Kept in sync by eye with style/_colors.css. +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) + +# Icons are Nerd Font (Symbols Nerd Font / Agave Nerd Font Mono) codepoints, +# each verified against the actually-installed font's cmap+glyph names before +# use (see /tmp/icon-check during development) rather than guessed blind: +# md-orbit 0xf0018 — same glyph eww.yuck's orbit-launcher button used +# md-satellite_variant 0xf0471 — astro-menu launcher (satellite/info theme) +# md-space_station 0xf1383 — unselected workspace ("station") — this is the +# exact codepoint the user's own spec used +# fa-rocket 0xf135 — focused workspace ("your spaceship") +# md-dock_window 0xf10ac — focused window title prefix +# md-speaker 0xf04c3 — volume, same glyph eww.yuck's volume metric used +ICON_ORBIT = chr(0xf0018) +ICON_ASTRO = chr(0xf0471) +# md-bell 0xf009a — transmitter-panel launcher (notification history); +# verified against the actually-installed Agave Nerd Font Mono's cmap, +# same technique/rationale as the rest of these codepoints. +ICON_HISTORY = chr(0xf009a) +ICON_STATION = chr(0xf1383) +ICON_SPACESHIP = chr(0xf135) +ICON_WINDOW = chr(0xf10ac) +ICON_VOLUME = chr(0xf04c3) +ICON_FUEL = chr(0xf07c5) # md-fuel — the "battery" reads as a fuel tank +# md-coffee 0xf0176 — a manual caffeine session is holding the idle lock +# md-eye 0xf0208 — presence-detect sees you, so idle is inhibited for you +# md-sleep 0xf04b2 — nothing inhibiting idle; the machine may sleep +# (codepoints verified against Agave Nerd Font's cmap; the ☕ emoji hyprlua's +# eww widget used has no glyph in this font, so md-coffee stands in for it.) +ICON_COFFEE = chr(0xf0176) +ICON_EYE = chr(0xf0208) +ICON_SLEEP = chr(0xf04b2) + +# Shared idle-lock protocol, identical to scripts/caffeine.sh and the status +# scripts it ships alongside — we read the same files rather than shelling out to +# caffeine-manual-status.sh / presence-status.sh on every poll: +# PID alive → the idle inhibitor is held (by anyone) +# PID alive AND no OWNED flag → a MANUAL caffeine toggle holds it +# PRESENCE flag present → the webcam presence daemon currently sees you +_CAFFEINE_PID_FILE = "/tmp/caffeine-inhibit.pid" +_PRESENCE_OWNED_FLAG = "/tmp/presence-inhibit-owned" +_PRESENCE_FLAG = "/tmp/presence-detected" + + +class StationBar(Gtk.Window): + BAR_HEIGHT = 44 # a touch larger — the elements are bare glowing glyphs now + ARC_SAG = 6.0 # shallow — "narrow curved space", not horizon-dock's deep dip + + BATTERY_POLL_S = 20 + CLOCK_POLL_S = 1 + VOLUME_POLL_S = 3 # cheap fallback so hardware volume keys still reflect promptly + CAFFEINE_POLL_S = 2 # matches hyprlua's eww caffeine poll; catches external toggles + + def __init__(self, hologram_enabled: bool = True, monitor=None) -> None: + super().__init__() + self.add_css_class("station-bar-window") + + self._monitor = monitor + self._mon_name = self._monitor_connector() + self._width = self._monitor_width() + self._workspaces: list[dict] = [] + self._active_ws: Optional[int] = None + self._last_tick: Optional[float] = None + self._tick_id: Optional[int] = None + + self._init_layer_shell() + + # Width-adaptive: no hardcoded pixel width. The layer surface is stretched + # to the monitor by the LEFT+RIGHT anchors, and _bg/content fill it via + # hexpand, so the bar adapts to any resolution (and to hotplug) with no + # captured width. Only the height is fixed. + self._bg = Gtk.DrawingArea() + self._bg.set_size_request(-1, self.BAR_HEIGHT) + self._bg.set_hexpand(True) + self._bg.set_draw_func(self._draw_background) + self._bg.add_css_class("station-canvas") + + self._left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + self._left.set_halign(Gtk.Align.START) + self._left.set_valign(Gtk.Align.CENTER) + self._left.add_css_class("station-zone") + + self._center_label = Gtk.Label(label=ICON_WINDOW) + self._center_label.add_css_class("station-title") + self._center_label.set_ellipsize(Pango.EllipsizeMode.END) + self._center_label.set_max_width_chars(60) + + self._right = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + self._right.set_halign(Gtk.Align.END) + self._right.set_valign(Gtk.Align.CENTER) + self._right.add_css_class("station-zone") + + content = Gtk.CenterBox(orientation=Gtk.Orientation.HORIZONTAL) + content.add_css_class("station-content") + content.set_hexpand(True) + content.set_valign(Gtk.Align.CENTER) + content.set_start_widget(self._left) + content.set_center_widget(self._build_center()) + content.set_end_widget(self._right) + + self._hologram = HologramOverlay(enabled=hologram_enabled, fade_widget=content) + + # The bar's height must stay BAR_HEIGHT: nerd-font button labels have tall + # line metrics, so the CenterBox's natural height would otherwise balloon + # the layer surface to ~70px while the exclusive zone still only reserves + # BAR_HEIGHT — the overflow then overlaps windows below. Pin the surface to + # _bg's height (BAR_HEIGHT), don't let the overlay children grow it, and + # clip: content is vertically centred within the thin strip. + overlay = Gtk.Overlay() + overlay.set_overflow(Gtk.Overflow.HIDDEN) + overlay.set_child(self._bg) + overlay.add_overlay(content) + overlay.set_measure_overlay(content, False) + overlay.add_overlay(self._hologram.widget) + overlay.set_measure_overlay(self._hologram.widget, False) + self.set_child(overlay) + + self._build_left() + self._build_right() + + self._tray = TrayHost(on_change=self._refresh_tray) + self._refresh_tray() + + self._ipc = hypr_ipc.HyprIPC( + on_workspaces_changed=self._refresh_workspaces, + on_active_workspace=self._on_active_workspace, + on_active_window=self._on_active_window, + ) + self._active_ws = self._derive_active_ws() + self._refresh_workspaces() + self._on_active_window(hypr_ipc.initial_active_window_title()) + + GLib.timeout_add_seconds(self.BATTERY_POLL_S, self._refresh_battery) + GLib.timeout_add_seconds(self.CLOCK_POLL_S, self._refresh_clock) + GLib.timeout_add_seconds(self.VOLUME_POLL_S, self._refresh_volume) + GLib.timeout_add_seconds(self.CAFFEINE_POLL_S, self._refresh_caffeine) + self._refresh_battery() + self._refresh_clock() + self._refresh_volume() + self._refresh_caffeine() + + self.set_visible(True) + self._ensure_tick() + self._hologram.start_intro() # materialise on first appearance too + + # -- hologram animation loop ----------------------------------------------- + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + self._hologram.tick(dt) + return True # keep ticking for the bar's whole lifetime while shown + + def _ensure_tick(self) -> None: + if self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + + def _stop_tick(self) -> None: + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + self._last_tick = None + + # -- layer shell ---------------------------------------------------------- + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.TOP) + LayerShell.set_namespace(self, "station-bar") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE) + if self._monitor is not None: + LayerShell.set_monitor(self, self._monitor) + for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.TOP): + LayerShell.set_anchor(self, edge, True) + LayerShell.set_margin(self, edge, 0) + LayerShell.set_exclusive_zone(self, self.BAR_HEIGHT) + + def _monitor_connector(self) -> Optional[str]: + """The Gdk connector name (e.g. "DP-1"), which equals the Hyprland + monitor `name` — so it keys this bar's workspaces/active out of the + global lists. None (unknown monitor) falls back to showing everything.""" + mon = self._monitor + if mon is None: + return None + try: + return mon.get_connector() + except Exception: + return None + + def _monitor_width(self) -> int: + mon = self._monitor + if mon is None: + display = Gdk.Display.get_default() + monitors = display.get_monitors() if display is not None else None + mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None + # Gdk logical width (already scaled), the coordinate space layer-shell + # lays out in. + return mon.get_geometry().width if mon is not None else 1920 + + # -- background curve ------------------------------------------------------- + def _arc_radius(self, width: float) -> float: + half_w = width / 2 + sag = self.ARC_SAG + return (sag * sag + half_w * half_w) / (2 * sag) + + def _curve_dip_at(self, x: float, width: float) -> float: + """0 at the bar's center, rising to ARC_SAG at its edges — same shape as + horizon-dock's row arcs, so the bar reads as one shallow shared curve. + Uses the live allocated width so it adapts to any monitor.""" + dx = x - width / 2 + r = self._arc_radius(width) + return r - math.sqrt(max(r * r - dx * dx, 0.0)) + + def _draw_background(self, _area, cr, width: float, height: float) -> None: + base_y = height * 0.62 + cr.save() + cr.set_source_rgba(*_VIOLET, 0.22) + cr.set_line_width(1.2) + cr.move_to(0, base_y + self._curve_dip_at(0, width)) + steps = 64 + for s in range(1, steps + 1): + x = width * s / steps + cr.line_to(x, base_y + self._curve_dip_at(x, width)) + cr.stroke() + cr.restore() + + # -- left zone: battery, launchers, workspace stations ----------------------- + def _build_left(self) -> None: + self._battery_widget = Gtk.Label() + self._battery_widget.add_css_class("station-badge") + self._battery_widget.add_css_class("station-battery") + self._battery_widget.set_visible(False) + self._left.append(self._battery_widget) + + orbit_btn = self._make_launcher(ICON_ORBIT, "Orbit Menu", "station-orbit", + ["bash", "-c", "$HOME/.config/scripts/orbit-menu.sh menu"]) + astro_btn = self._make_launcher(ICON_ASTRO, "Astro Menu", "station-astro", + ["bash", "-c", "$HOME/.config/scripts/astro-menu.sh toggle top"]) + history_btn = self._make_launcher(ICON_HISTORY, "Notification History", "station-history", + ["bash", "-c", "$HOME/.config/scripts/transmitter-panel.sh"]) + self._left.append(orbit_btn) + self._left.append(astro_btn) + self._left.append(history_btn) + + self._ws_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._ws_row.add_css_class("station-ws-row") + self._left.append(self._ws_row) + + def _make_launcher(self, icon: str, tooltip: str, css_class: str, argv: list[str]) -> Gtk.Button: + btn = Gtk.Button(label=icon) + btn.add_css_class("station-launcher") + btn.add_css_class(css_class) + btn.set_has_frame(False) + btn.set_tooltip_text(tooltip) + btn.connect("clicked", lambda *_a: subprocess.Popen( + argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + return btn + + def _refresh_workspaces(self) -> None: + self._workspaces = hypr_ipc.initial_workspaces() + self._active_ws = self._derive_active_ws() + self._rebuild_workspace_row() + + def _derive_active_ws(self) -> Optional[int]: + """This monitor's own active workspace (from `hyprctl monitors`), not the + globally-focused one — so each bar highlights the workspace its monitor is + actually showing, even while another monitor holds keyboard focus.""" + if self._mon_name is None: + return hypr_ipc.initial_active_workspace_id() + for mon in hypr_ipc.initial_monitors(): + if mon.get("name") == self._mon_name: + return (mon.get("activeWorkspace") or {}).get("id") + return None + + def _rebuild_workspace_row(self) -> None: + child = self._ws_row.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + self._ws_row.remove(child) + child = nxt + + for ws in sorted(self._workspaces, key=lambda w: w.get("id", 0)): + wid = ws.get("id") + if not isinstance(wid, int) or wid < 0: + continue # special:* workspaces aren't shown as numbered stations + if self._mon_name is not None and ws.get("monitor") != self._mon_name: + continue # this bar only shows its own monitor's workspaces + selected = wid == self._active_ws + has_windows = ws.get("windows", 0) > 0 + # A populated workspace is a "station" (its glyph); an empty one shows + # just the bare spaceship + number instead — never hidden. The focused + # workspace is framed by the reticle, with the rocket prepended when + # there's a station to dock at (skipped when empty, so no double ship). + base = ICON_STATION if has_windows else ICON_SPACESHIP + if selected: + prefix = ICON_SPACESHIP if has_windows else "" + label = f"-< {prefix}{base}{wid} >-" + else: + label = f"{base}{wid}" + btn = Gtk.Button(label=label) + btn.add_css_class("station-node") + btn.set_has_frame(False) + if selected: + btn.add_css_class("station-node-active") + btn.connect("clicked", lambda *_a, w=wid: hypr_ipc.focus_workspace(w)) + self._ws_row.append(btn) + + def _on_active_workspace(self, _ws_id: int) -> None: + # the `workspace` event only names the focused monitor's new workspace; + # re-derive from `monitors` so this bar reflects its own monitor's active. + self._active_ws = self._derive_active_ws() + self._rebuild_workspace_row() + + # -- center: focused window title in a clickable trapezoid ------------------ + def _build_center(self) -> Gtk.Widget: + """The window title inside a faint elongated trapezoid (wider at the top, + which is left un-bordered) that opens the Astro Menu when clicked — a + little 'viewport screen' framing the title, like a HUD readout panel.""" + trap = Gtk.DrawingArea() + trap.set_draw_func(self._draw_center_trap) + trap.set_can_target(False) + + self._center_label.set_margin_start(30) + self._center_label.set_margin_end(30) + self._center_label.set_margin_top(3) + self._center_label.set_margin_bottom(4) + + overlay = Gtk.Overlay() + overlay.add_css_class("station-center") + # hug the title: size to the label and stay centred (no hexpand, or the + # CenterBox would stretch the trapezoid to fill the whole middle zone). + overlay.set_halign(Gtk.Align.CENTER) + overlay.set_child(trap) + overlay.add_overlay(self._center_label) + overlay.set_measure_overlay(self._center_label, True) + + click = Gtk.GestureClick() + click.connect("released", lambda *_a: subprocess.Popen( + ["bash", "-c", "$HOME/.config/scripts/astro-menu.sh toggle top"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + overlay.add_controller(click) + cursor = Gdk.Cursor.new_from_name("pointer", None) + if cursor is not None: + overlay.set_cursor(cursor) + overlay.set_tooltip_text("Astro Menu") + return overlay + + def _draw_center_trap(self, _area, cr, width: float, height: float) -> None: + if width <= 0 or height <= 0: + return + inset = min(height * 0.85, width * 0.10) # bottom is the shorter side + # faint translucent fill (top edge included, just not stroked) + cr.move_to(0, 0) + cr.line_to(width, 0) + cr.line_to(width - inset, height) + cr.line_to(inset, height) + cr.close_path() + cr.set_source_rgba(*_VIOLET, 0.14) + cr.fill() + # borders on left / bottom / right only — the (longer) top edge stays open + for lw, a in ((5.0, 0.05), (1.6, 0.34)): + cr.move_to(0, 0) + cr.line_to(inset, height) + cr.line_to(width - inset, height) + cr.line_to(width, 0) + cr.set_source_rgba(*_ACCENT, a) + cr.set_line_width(lw) + cr.stroke() + + # -- center: focused window title ------------------------------------------- + def _on_active_window(self, title: str) -> None: + text = title.strip() + self._center_label.set_label(f"{ICON_WINDOW} {text}" if text else ICON_WINDOW) + + # -- right zone: tray, volume, clock ----------------------------------------- + def _build_right(self) -> None: + pod = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + pod.add_css_class("station-tray-pod") + + badge = Gtk.DrawingArea() + badge.set_size_request(20, 14) + badge.set_draw_func(self._draw_station_pictogram) + pod.append(badge) + + self._tray_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2) + pod.append(self._tray_box) + self._right.append(pod) + + self._volume_btn = Gtk.Button(label=f"{ICON_VOLUME} --%") + self._volume_btn.add_css_class("station-badge") + self._volume_btn.add_css_class("station-volume") + self._volume_btn.set_has_frame(False) + self._volume_btn.connect("clicked", lambda *_a: (volume_source.toggle_mute(), self._refresh_volume())) + scroll = Gtk.EventControllerScroll() + scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) + scroll.connect("scroll", self._on_volume_scroll) + self._volume_btn.add_controller(scroll) + self._right.append(self._volume_btn) + + # Caffeine — clicking toggles the shared idle-inhibit lock via the same + # scripts/caffeine.sh hyprlua's eww bar drives. Glyph and colour track + # the same two inputs as that widget (manual inhibit + camera presence). + self._caffeine_btn = Gtk.Button(label=ICON_SLEEP) + self._caffeine_btn.add_css_class("station-badge") + self._caffeine_btn.add_css_class("station-caffeine") + self._caffeine_btn.set_has_frame(False) + self._caffeine_btn.connect("clicked", self._on_caffeine_clicked) + self._right.append(self._caffeine_btn) + + self._clock_label = Gtk.Label() + self._clock_label.add_css_class("station-badge") + self._clock_label.add_css_class("station-clock") + self._right.append(self._clock_label) + + def _draw_station_pictogram(self, _, cr, w: float, h: float) -> None: + """A minimal space-station glyph: a hub circle with two docking-arm + ticks, drawn in Cairo rather than guessing at an unverified icon-font + codepoint for "satellite".""" + cy = h / 2 + cx = w / 2 + cr.save() + cr.set_source_rgba(*_ACCENT, 0.9) + cr.set_line_width(1.4) + cr.arc(cx, cy, h * 0.28, 0, 2 * math.pi) + cr.stroke() + cr.move_to(0, cy) + cr.line_to(cx - h * 0.28, cy) + cr.move_to(cx + h * 0.28, cy) + cr.line_to(w, cy) + cr.stroke() + cr.restore() + + def _refresh_tray(self) -> None: + self._tray.build_into(self._tray_box) + + def _refresh_battery(self) -> bool: + state = battery.read() + if state is None: + self._battery_widget.set_visible(False) + else: + # the ship runs on "fuel", not a battery — a fixed fuel-tank glyph in + # place of the charge-level battery icon (only shown when a battery + # actually exists, e.g. on a laptop). + self._battery_widget.set_visible(True) + self._battery_widget.set_label(f"{ICON_FUEL} {state.percent}%") + return True + + def _refresh_volume(self) -> bool: + if volume_source.is_muted(): + self._volume_btn.set_label(f"{ICON_VOLUME} muted") + else: + self._volume_btn.set_label(f"{ICON_VOLUME} {volume_source.get_percent()}%") + return True + + def _on_volume_scroll(self, _ctrl, _dx: float, dy: float) -> bool: + volume_source.adjust(-1 if dy > 0 else 1) + GLib.timeout_add(80, self._refresh_volume_once) + return True + + def _refresh_volume_once(self) -> bool: + self._refresh_volume() + return False + + # -- caffeine (shared idle-inhibit lock) ------------------------------------- + @staticmethod + def _pid_alive(pid_file: str) -> bool: + """True if pid_file holds the PID of a still-running process (kill -0).""" + try: + with open(pid_file) as fh: + pid = int(fh.read().strip()) + except (OSError, ValueError): + return False + try: + os.kill(pid, 0) # signal 0 = liveness probe, sends nothing + except OSError: + return False + return True + + def _caffeine_state(self) -> tuple[bool, bool]: + """(manual, presence) — same two inputs as hyprlua's eww caffeine widget: + a manual toggle holding the lock, and the webcam presence flag.""" + manual = self._pid_alive(_CAFFEINE_PID_FILE) and not os.path.exists(_PRESENCE_OWNED_FLAG) + presence = os.path.exists(_PRESENCE_FLAG) + return manual, presence + + def _refresh_caffeine(self) -> bool: + manual, presence = self._caffeine_state() + if manual: + icon = ICON_COFFEE + tip = "Caffeine: ON (manual + presence)" if presence else "Caffeine: ON (manual)" + elif presence: + icon = ICON_EYE + tip = "Awake: presence detected" + else: + icon = ICON_SLEEP + tip = "Idle: no inhibit" + self._caffeine_btn.set_label(icon) + self._caffeine_btn.set_tooltip_text(tip) + # Colour tracks presence (magenta), exactly like eww's .caffeine-presence; + # the glyph carries the manual/presence/idle distinction on its own. + if presence: + self._caffeine_btn.add_css_class("station-caffeine-active") + else: + self._caffeine_btn.remove_css_class("station-caffeine-active") + return True + + def _on_caffeine_clicked(self, *_a) -> None: + subprocess.Popen(["bash", "-c", "$HOME/.config/scripts/caffeine.sh"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + # caffeine.sh flips the lock synchronously; give it a beat, then reflect it. + GLib.timeout_add(250, self._refresh_caffeine_once) + + def _refresh_caffeine_once(self) -> bool: + self._refresh_caffeine() + return False + + def _refresh_clock(self) -> bool: + self._clock_label.set_label(time.strftime("%H:%M")) + return True + + # -- external control (D-Bus show/hide/toggle, see main.py) ------------------- + def show_bar(self) -> None: + LayerShell.set_exclusive_zone(self, self.BAR_HEIGHT) + self.set_visible(True) + self.present() + self._ensure_tick() + self._hologram.start_intro() + + def hide_bar(self) -> None: + # Dissolve into static first, then release the exclusive zone + hide. + if self._hologram.enabled and self._tick_id is not None: + self._hologram.start_outro(self._finish_hide) + else: + self._finish_hide() + + def _finish_hide(self) -> None: + LayerShell.set_exclusive_zone(self, 0) + self.set_visible(False) + self._stop_tick() + + def toggle(self) -> None: + if self.get_visible(): + self.hide_bar() + else: + self.show_bar() diff --git a/station-bar/battery.py b/station-bar/battery.py new file mode 100644 index 0000000..316ae65 --- /dev/null +++ b/station-bar/battery.py @@ -0,0 +1,54 @@ +"""Battery presence + percentage via sysfs — no upower dependency (that daemon +isn't guaranteed running; sysfs always is on real hardware). Mirrors the icon +tiers already established in scripts/batteryperc (the eww bar's battery poll) +so a laptop that switches between hyprlua and hyprdrive sees the same glyphs. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import NamedTuple, Optional + +_POWER_SUPPLY = Path("/sys/class/power_supply") + +# (floor percentage, glyph) — first match wins, checked high to low. +_DISCHARGING_TIERS = [ + (95, "󰁹"), (90, "󰂂"), (80, "󰂁"), (70, "󰂀"), (60, "󰁿"), + (50, "󰁾"), (40, "󰁽"), (30, "󰁼"), (20, "󰁻"), (10, "󰁺"), (0, "󰂎"), +] +_CHARGING_ICON = "󰂄" + + +class BatteryState(NamedTuple): + percent: int + charging: bool + icon: str + + +def _find_battery() -> Optional[Path]: + if not _POWER_SUPPLY.is_dir(): + return None + for entry in sorted(_POWER_SUPPLY.iterdir()): + if entry.name.startswith("BAT"): + return entry + return None + + +def read() -> Optional[BatteryState]: + """Returns None when no battery is present (desktop machine) — callers + should hide the widget entirely in that case, per spec.""" + bat = _find_battery() + if bat is None: + return None + try: + percent = int((bat / "capacity").read_text().strip()) + status = (bat / "status").read_text().strip().lower() + except (OSError, ValueError): + return None + + charging = status == "charging" + if charging: + icon = _CHARGING_ICON + else: + icon = next(g for floor, g in _DISCHARGING_TIERS if percent >= floor) + return BatteryState(percent=percent, charging=charging, icon=icon) diff --git a/station-bar/config.py b/station-bar/config.py new file mode 100644 index 0000000..b2c4e0e --- /dev/null +++ b/station-bar/config.py @@ -0,0 +1,31 @@ +"""Tiny user-editable config file: ~/.local/state/station-bar/config.json. + +Read once at startup (main.py); the flag is passed into StationBar's +constructor rather than polled, so a change takes effect on the next +station-bar-start.sh restart, not live. Same pattern as orbit-menu/horizon- +dock/astro-menu's config.py. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = {"hologram": True} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) diff --git a/station-bar/hypr_ipc.py b/station-bar/hypr_ipc.py new file mode 100644 index 0000000..da47823 --- /dev/null +++ b/station-bar/hypr_ipc.py @@ -0,0 +1,144 @@ +"""Event-driven Hyprland state tracking via the IPC event socket (.socket2.sock). + +A status bar is always visible, so polling hyprctl on a timer (like horizon-dock's +"refresh when revealed" windows.py) would waste cycles or lag behind real events. +Instead this opens one persistent connection and streams parsed events as they +arrive — the same source `scripts/workspace` (legacy eww fallback) reads via +`socat`, but consumed natively through GLib/Gio so no subprocess is needed. + +Initial state (on startup, before any event has fired) comes from a one-off +`hyprctl -j` round trip, same pattern as horizon-dock's windows.py. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Callable, Optional + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + + +def _socket_path() -> Optional[Path]: + sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE") + if not sig: + return None + runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") + modern = Path(runtime) / "hypr" / sig / ".socket2.sock" + if modern.exists(): + return modern + legacy = Path("/tmp/hypr") / sig / ".socket2.sock" # older Hyprland versions + return legacy if legacy.exists() else None + + +def _hyprctl_json(*args: str) -> object: + try: + out = subprocess.run(["hyprctl", "-j", *args], capture_output=True, + text=True, timeout=1.5, check=True).stdout + return json.loads(out) + except (subprocess.SubprocessError, json.JSONDecodeError, OSError): + return None + + +def initial_workspaces() -> list[dict]: + data = _hyprctl_json("workspaces") + return data if isinstance(data, list) else [] + + +def initial_active_workspace_id() -> Optional[int]: + data = _hyprctl_json("activeworkspace") + return data.get("id") if isinstance(data, dict) else None + + +def initial_monitors() -> list[dict]: + """Each monitor dict carries `name` (the connector, e.g. "DP-1", matching a + Gdk.Monitor's connector) and `activeWorkspace: {id, name}` — the workspace + that monitor is currently showing, independent of which monitor has focus. + Used to give each per-monitor bar its own workspace list and active-highlight.""" + data = _hyprctl_json("monitors") + return data if isinstance(data, list) else [] + + +def initial_active_window_title() -> str: + data = _hyprctl_json("activewindow") + return (data.get("title") or "") if isinstance(data, dict) else "" + + +def focus_workspace(ws_id: int) -> None: + subprocess.Popen( + ["hyprctl", "dispatch", f"hl.dsp.focus({{ workspace = {ws_id} }})"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + + +class HyprIPC: + """Connects once to .socket2.sock and dispatches parsed events. + + Callbacks: + on_workspaces_changed() a workspace was created/destroyed/moved — re-fetch + the list via initial_workspaces() + on_active_workspace(id) focus moved to workspace `id` + on_active_window(title) the focused window's title changed (empty string + when focus moved to no window) + """ + + def __init__(self, on_workspaces_changed: Callable[[], None], + on_active_workspace: Callable[[int], None], + on_active_window: Callable[[str], None]) -> None: + self._on_workspaces_changed = on_workspaces_changed + self._on_active_workspace = on_active_workspace + self._on_active_window = on_active_window + self._stream: Optional[Gio.DataInputStream] = None + self._connect() + + def _connect(self) -> None: + path = _socket_path() + if path is None: + return + try: + conn = Gio.SocketClient().connect(Gio.UnixSocketAddress.new(str(path)), None) + except GLib.Error: + return + self._stream = Gio.DataInputStream.new(conn.get_input_stream()) + self._read_next() + + def _read_next(self) -> None: + if self._stream is not None: + self._stream.read_line_async(GLib.PRIORITY_DEFAULT, None, self._on_line) + + def _on_line(self, stream: Gio.DataInputStream, result: Gio.AsyncResult) -> None: + try: + line, _len = stream.read_line_finish_utf8(result) + except GLib.Error: + line = None + if line is None: + self._stream = None # socket closed — stop rather than spin + return + self._dispatch(line) + self._read_next() + + def _dispatch(self, line: str) -> None: + if ">>" not in line: + return + event, _, payload = line.partition(">>") + if event == "workspace": + try: + self._on_active_workspace(int(payload)) + except ValueError: + pass # special:* workspaces aren't shown as numbered stations + elif event in ("createworkspace", "destroyworkspace", "moveworkspace", + "openwindow", "closewindow", "movewindow", "movewindowv2", + "focusedmon"): + # any of these can change a workspace's window count OR which monitor + # a workspace lives on (moveworkspace) OR a monitor's active workspace + # (focusedmon) — all of which per-monitor bars (bar.py) filter on, so + # re-fetch the list + re-derive each bar's active from `monitors`. + self._on_workspaces_changed() + elif event == "activewindow": + _cls, _, title = payload.partition(",") + self._on_active_window(title) diff --git a/station-bar/lib/hologram.py b/station-bar/lib/hologram.py new file mode 100644 index 0000000..8b62084 --- /dev/null +++ b/station-bar/lib/hologram.py @@ -0,0 +1,295 @@ +"""Holographic scanline/sweep/noise overlay — same treatment and tuning as +astro-menu's/horizon-dock's lib/hologram.py (itself matching orbit-menu's), +reused here so station-bar reads as one more orbit of the same Cosmonaut +Shell suite. No radial vignette mask: covering the full bar rectangle reads +as one continuous "HUD screen" strip. + +Unlike the other three components, station-bar has no reveal/show-hide +animation loop to piggyback a tick callback on — it's a persistent bar, not a +popup — so bar.py starts/stops its own frame-clock tick callback directly in +show_bar()/hide_bar() and feeds it into .tick(dt) here. +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.16 # fixed grid — kept clearly visible, not just a faint texture + SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 24.0 # much shorter than the popups' — the bar itself is only ~30px tall + NOISE_COUNT = 40 # thinner strip than a popup panel, so fewer specs at once + NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + EDGE_FADE_X = 64.0 # smooth horizontal fade-out of the scanline field + EDGE_FADE_Y = 40.0 # smooth vertical fade-out + INTRO_DURATION = 1.5 # long, noisy 'materialise out of static' fade-in + INTRO_STATIC = 1200 # static specks at the very start of the intro + OUTRO_DURATION = 0.45 # quick reverse dissolve back into static on close + + def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, + intro_duration: float | None = None) -> None: + self.enabled = enabled + self._clip_func = clip_func # optional path-setter to clip the holo to the UI shape + # widget whose opacity is ramped 0->1 during the intro so the UI genuinely + # fades in (a gradual reveal), rather than a solid haze block popping on + self._fade_widget = fade_widget + if intro_duration is not None: + self.INTRO_DURATION = intro_duration # per-instance override of the class default + self._sat_time = 0.0 + self._particles: list[dict] = [] + self._intro_t: float | None = None # >=0 while the materialise intro plays + self._outro_t: float | None = None # >=0 while the closing dissolve plays + self._outro_done = None # callback fired when the dissolve finishes + # Wall-clock safety net for the intro: the frame-clock tick that normally + # advances the intro only fires while the compositor sends frame callbacks. + # When the bar is (re)started into an otherwise-idle compositor those can + # stall, freezing the intro at t=0 — i.e. the bar sits in permanent static + # with content stuck at opacity 0. This timeout force-resolves the intro on + # real time regardless, so content always appears. + self._intro_deadline_id: int | None = None + self._mask_cache: tuple | None = None # (w, h, pattern) edge-fade mask + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from content underneath + self.widget.add_css_class("station-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + if self._intro_t is not None: + self._intro_t += dt + if self._intro_t >= self.INTRO_DURATION: + self._finish_intro() + elif self._fade_widget is not None: + p = self._intro_t / self.INTRO_DURATION + self._fade_widget.set_opacity(p * p * (3 - 2 * p)) # smooth ramp 0->1 + if self._outro_t is not None: + self._outro_t += dt + po = min(1.0, self._outro_t / self.OUTRO_DURATION) + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) # ramp 1->0 + if self._outro_t >= self.OUTRO_DURATION: + done = self._outro_done + self._outro_t = None + self._outro_done = None + if done is not None: + done() + self.widget.queue_draw() + + def _finish_intro(self) -> None: + """End the intro: clear its state, cancel the safety timeout, and make the + content fully opaque. Safe to call from either the tick or the timeout.""" + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0) + self.widget.queue_draw() # in case the frame clock has stalled + + def start_intro(self) -> None: + """Kick off the 'hologram materialising out of static' opening effect.""" + if self.enabled: + self._outro_t = None # cancel any in-flight closing dissolve + self._outro_done = None + self._intro_t = 0.0 + if self._fade_widget is not None: + self._fade_widget.set_opacity(0.0) # start hidden; tick() ramps it up + # Wall-clock backstop: if frame callbacks stall, force the intro done a + # little past its nominal length so content can never get stuck hidden. + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = GLib.timeout_add( + int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline) + + def _on_intro_deadline(self) -> bool: + self._intro_deadline_id = None + if self._intro_t is not None: + self._finish_intro() + return False # one-shot + + def start_outro(self, on_done) -> None: + """Play a quick reverse of the intro (content dissolving back into static), + then call on_done to actually hide. If disabled, hide immediately.""" + if not self.enabled: + on_done() + return + self._intro_t = None # cancel any in-flight opening intro + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + self._outro_t = 0.0 + self._outro_done = on_done + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + if self._clip_func is not None: + cr.save() + self._clip_func(cr, width, height) # clip the scanlines to the UI shape + cr.clip() + # Render the holo field into a group, then composite it back through a + # soft edge-fade mask so the scanlines dissolve at the borders (reads far + # more like a projected hologram than a hard-edged rectangle). + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) # subtract edge gradients from the solid + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + # No solid veil block: the content itself fades in (see tick's fade_widget + # ramp). Here we only lay churning static over it — dense at first, thinning + # to nothing — so the UI resolves out of noise as it fades up. + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) # dense, thinning to none + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height # a bright scan wiping down as it resolves + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + # Reverse of the intro: the content is already fading back out (tick's + # fade_widget ramp 1->0); here the static thickens from nothing as it goes, + # so the panel dissolves into noise just before it vanishes. + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height # scan wiping back up as it dissolves + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.07) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/station-bar/main.py b/station-bar/main.py new file mode 100644 index 0000000..50c6e39 --- /dev/null +++ b/station-bar/main.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""station-bar — the EWW top bar's replacement for hyprdrive (codename: +Voidstation Status Bar). Single-instance, same pattern as the rest of the +Cosmonaut Shell suite: the first launch builds the (visible, reserved-space) +bar and holds; later invocations forward their verb over D-Bus via +scripts/station-bar.sh instead of spawning a second python3+GTK4 process. + + main.py run the resident instance (bar shown by default) + main.py --show show the bar (restores its exclusive zone) + main.py --hide hide the bar (releases its exclusive zone) + main.py --toggle whichever of the above applies +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# station-bar-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering +# requirement ahead of libwayland-client). Drop it once resident so it isn't +# inherited by anything this process launches (see orbit-menu/horizon-dock/ +# astro-menu's main.py for the same rationale). +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +from gi.repository import Gdk, Gio, GLib, Gtk # noqa: E402 + +import config # noqa: E402 +import theme # noqa: E402 +from bar import StationBar # noqa: E402 +from paths import APP_ID # noqa: E402 +from sni_watcher import SniWatcher # noqa: E402 + + +class StationBarApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + # One bar per monitor, keyed by connector name so hotplugged monitors can + # be matched on the "items-changed" signal. + self.bars: dict[str, StationBar] = {} + self._hologram = True + self._visible = True + self._sni_watcher: SniWatcher | None = None + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + # Provide the StatusNotifierWatcher ourselves (no DE does it here), so tray + # apps have something to register with and the bars' tray fills up. + self._sni_watcher = SniWatcher() + self._hologram = config.hologram_enabled() + display = Gdk.Display.get_default() + if display is not None: + monitors = display.get_monitors() + monitors.connect("items-changed", lambda *_a: self._sync_bars()) + self._sync_bars() + self._register_actions() + self.hold() # stay alive even while the bars are briefly hidden + + def _sync_bars(self) -> None: + """Build a bar for every current monitor (and drop bars for monitors that + went away) — a single layer-shell window only ever lands on one output, so + a per-monitor bar is the only way to get one on every screen.""" + display = Gdk.Display.get_default() + if display is None: + return + monitors = display.get_monitors() + seen: set[str] = set() + for i in range(monitors.get_n_items()): + mon = monitors.get_item(i) + conn = (mon.get_connector() if mon is not None else None) or f"mon{i}" + seen.add(conn) + if conn not in self.bars: + bar = StationBar(hologram_enabled=self._hologram, monitor=mon) + if not self._visible: + bar.hide_bar() + self.bars[conn] = bar + for conn in list(self.bars): + if conn not in seen: + self.bars.pop(conn).destroy() + + def _apply(self, verb: str, target: str = "") -> None: + """target="" acts on every monitor's bar (global); a connector name acts + only on that monitor's bar (Super+Z toggles the bar you're looking at).""" + if target: + bars = [self.bars[target]] if target in self.bars else [] + else: + bars = list(self.bars.values()) + if verb == "show": + self._visible = True + elif verb == "hide": + self._visible = False + elif verb == "toggle": + self._visible = not self._visible + for bar in bars: + if verb == "show": + bar.show_bar() + elif verb == "hide": + bar.hide_bar() + elif verb == "toggle": + # per-monitor toggle keys off that bar's own state; the global + # toggle drives every bar from the shared _visible flag. + bar.toggle() if target else (bar.show_bar() if self._visible else bar.hide_bar()) + + def _register_actions(self) -> None: + stype = GLib.VariantType.new("s") + for name in ("show", "hide", "toggle"): + action = Gio.SimpleAction.new(name, stype) + action.connect( + "activate", + lambda _a, p, n=name: self._apply(n, p.get_string() if p is not None else ""), + ) + self.add_action(action) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + target = args[1] if len(args) > 1 else "" + if verb == "--show": + self._apply("show", target) + elif verb == "--hide": + self._apply("hide", target) + elif verb == "--toggle": + self._apply("toggle", target) + # --daemon and anything else: no-op (bars already shown by do_startup) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +def main() -> int: + GLib.set_prgname("station-bar") + return StationBarApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/station-bar/paths.py b/station-bar/paths.py new file mode 100644 index 0000000..312a191 --- /dev/null +++ b/station-bar/paths.py @@ -0,0 +1,22 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does +# `rm -rf ~/.config/station-bar` on every dotfiles sync (see orbit-menu/ +# horizon-dock/astro-menu's paths.py for the same rationale), which would wipe +# a hand-edited config on the spot. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "station-bar" +CONFIG_FILE = STATE_DIR / "config.json" + +APP_ID = "eu.abdelbaki.stationbar" + + +def ensure_dirs() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/station-bar/sni_watcher.py b/station-bar/sni_watcher.py new file mode 100644 index 0000000..205cfb5 --- /dev/null +++ b/station-bar/sni_watcher.py @@ -0,0 +1,147 @@ +"""A minimal StatusNotifierWatcher (org.kde.StatusNotifierWatcher). + +Under a full desktop this bus name is provided by the panel/DE; hyprdrive has no +such component (we replaced waybar/eww), so tray apps have nowhere to register +and every SNI host — including our own bar's tray.py — sees an empty tray. This +owns the name, tracks registered items/hosts, and emits the registration signals ++ PropertiesChanged so hosts stay live as apps come and go. + +Kept deliberately small: it does not proxy the items themselves (the host talks +to each StatusNotifierItem directly), it just is the registry the spec requires. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + +_NAME = "org.kde.StatusNotifierWatcher" +_PATH = "/StatusNotifierWatcher" +_IFACE = "org.kde.StatusNotifierWatcher" + +_XML = f""" + + + + + + + + + + + + + + + + + +""" + + +class SniWatcher: + def __init__(self) -> None: + self._conn: Gio.DBusConnection | None = None + self._items: dict[str, str] = {} # "service/path" -> owner unique name + self._hosts: set[str] = set() + self._reg_id = 0 + # NAME_OWNER flags let a real DE watcher take over cleanly if one appears. + self._owner_id = Gio.bus_own_name( + Gio.BusType.SESSION, _NAME, + Gio.BusNameOwnerFlags.ALLOW_REPLACEMENT, + self._on_bus_acquired, None, self._on_name_lost, + ) + + # -- bus lifecycle ----------------------------------------------------- + def _on_bus_acquired(self, conn: Gio.DBusConnection, _name: str) -> None: + self._conn = conn + info = Gio.DBusNodeInfo.new_for_xml(_XML).interfaces[0] + try: + self._reg_id = conn.register_object( + _PATH, info, self._on_method, self._on_get_property, None) + except GLib.Error: + return + # drop items whose owner leaves the bus + conn.signal_subscribe( + "org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged", + "/org/freedesktop/DBus", None, Gio.DBusSignalFlags.NONE, + self._on_name_owner_changed) + + def _on_name_lost(self, _conn, _name) -> None: + # another watcher grabbed the name — step aside quietly + self._conn = None + + # -- method / property handlers --------------------------------------- + def _on_method(self, conn, sender, _path, _iface, method, params, invocation) -> None: + if method == "RegisterStatusNotifierItem": + self._register_item(sender, params.unpack()[0]) + invocation.return_value(None) + elif method == "RegisterStatusNotifierHost": + self._hosts.add(params.unpack()[0]) + conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierHostRegistered", None) + self._emit_props({"IsStatusNotifierHostRegistered": GLib.Variant("b", True)}) + invocation.return_value(None) + else: + invocation.return_value(None) + + def _on_get_property(self, _conn, _sender, _path, _iface, prop): + if prop == "RegisteredStatusNotifierItems": + return GLib.Variant("as", list(self._items.keys())) + if prop == "IsStatusNotifierHostRegistered": + return GLib.Variant("b", bool(self._hosts)) + if prop == "ProtocolVersion": + return GLib.Variant("i", 0) + return None + + # -- registry bookkeeping --------------------------------------------- + @staticmethod + def _entry_for(sender: str, service: str) -> str: + # apps register either an object path (use the caller as the service), + # a full "busname/path", or just a bus name (default item path). + if service.startswith("/"): + return sender + service + if "/" in service: + return service + return service + "/StatusNotifierItem" + + def _register_item(self, sender: str, service: str) -> None: + if self._conn is None: + return + entry = self._entry_for(sender, service) + if entry in self._items: + return + self._items[entry] = sender + self._conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierItemRegistered", + GLib.Variant("(s)", (entry,))) + self._emit_items_changed() + + def _on_name_owner_changed(self, _conn, _sender, _path, _iface, _signal, params) -> None: + name, _old, new_owner = params.unpack() + if new_owner: + return # a name was acquired, not lost + gone = [e for e, owner in self._items.items() + if owner == name or e.split("/", 1)[0] == name] + for e in gone: + del self._items[e] + if self._conn is not None: + self._conn.emit_signal(None, _PATH, _IFACE, "StatusNotifierItemUnregistered", + GLib.Variant("(s)", (e,))) + if name in self._hosts: + self._hosts.discard(name) + if gone: + self._emit_items_changed() + + # -- signalling -------------------------------------------------------- + def _emit_items_changed(self) -> None: + self._emit_props({"RegisteredStatusNotifierItems": + GLib.Variant("as", list(self._items.keys()))}) + + def _emit_props(self, changed: dict) -> None: + if self._conn is None: + return + self._conn.emit_signal( + None, _PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged", + GLib.Variant("(sa{sv}as)", (_IFACE, changed, []))) diff --git a/station-bar/style/_colors.css b/station-bar/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/station-bar/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/station-bar/style/style.css b/station-bar/style/style.css new file mode 100644 index 0000000..0c331ca --- /dev/null +++ b/station-bar/style/style.css @@ -0,0 +1,164 @@ +/* station-bar — "Voidstation Status Bar" theme. Colours come from _colors.css + * (@text/@bg/@accent/@violet/@danger). Mirrors orbit-menu/horizon-dock/ + * astro-menu: Agave Nerd Font Mono, glow-on-hover, CyberQueer palette — this + * bar is meant to read as one more orbit of the same suite, not a separate + * look. Thin (30px) and semi-transparent violet rather than solid, with the + * shallow Cairo-drawn curve (bar.py's _draw_background) and the hologram + * overlay's scanlines/noise (lib/hologram.py) doing the visual work instead + * of a solid strip. + */ + +/* Glow palette — bright, saturated variants that read as emitted light over the + * dark/blurred strip (the raw @violet is too dark for glowing text). Added in + * this process only, so the rest of the suite keeps its palette. */ +@define-color glow_violet #8A5CFF; +@define-color glow_magenta #EB00A6; +@define-color glow_cyan #22D3EE; +@define-color glow_green #2BE08A; +@define-color glow_amber #F5A623; + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 13pt; +} + +window, +window.background, +.station-bar-window, +.station-content, +.station-zone, +overlay, +drawingarea { + background: transparent; + background-color: transparent; +} + +.station-hologram { + background: transparent; +} + +/* Neutralise the CyberQueer GTK theme's `* { background-color:#1a1a1a }` on + * every node — enumerating node names above misses some (labels, the CenterBox + * boxes). This provider is at USER+1, above the theme; interactive elements + * re-assert their own fills via the higher-specificity .class rules below. */ +* { + background-color: transparent; +} + +/* Everything on the bar is now a bare glowing glyph — no pills, borders, fills + * or box-shadows. Each element type is a distinct colour and glows via + * text-shadow, like the satellites orbiting an orbit-menu node. */ +.station-launcher, +.station-node, +button.station-badge, +.station-badge { + background: none; + background-color: transparent; + border: none; + box-shadow: none; + min-width: 0; + min-height: 0; + padding: 0 5px; +} + +.station-title { + color: @text; + opacity: 0.95; + font-size: 12pt; + text-shadow: 0 0 7px alpha(@glow_violet, 0.7); +} + +/* readouts — battery / volume / clock, each its own colour */ +.station-badge { color: @text; transition: text-shadow 160ms ease; } +.station-battery { color: @glow_green; text-shadow: 0 0 7px alpha(@glow_green, 0.85); } +.station-volume { color: @glow_cyan; text-shadow: 0 0 7px alpha(@glow_cyan, 0.85); } +.station-clock { color: @glow_amber; text-shadow: 0 0 7px alpha(@glow_amber, 0.85); } +.station-volume:hover { text-shadow: 0 0 13px @glow_cyan; } + +/* caffeine — dim violet at rest (idle/manual), magenta glow while the webcam + * presence daemon is keeping the screen awake, mirroring eww's .caffeine-presence */ +.station-caffeine { color: alpha(@glow_violet, 0.8); text-shadow: 0 0 7px alpha(@glow_violet, 0.55); } +.station-caffeine:hover { color: @glow_violet; text-shadow: 0 0 13px @glow_violet; } +.station-caffeine-active { color: @glow_magenta; text-shadow: 0 0 8px alpha(@glow_magenta, 0.9); } +.station-caffeine-active:hover { text-shadow: 0 0 14px @glow_magenta, 0 0 4px @glow_magenta; } + +/* Orbit / Astro launchers — larger glowing icon glyphs */ +.station-launcher { + font-size: 17pt; + padding: 0 7px; + transition: text-shadow 160ms ease, color 160ms ease; +} +.station-orbit { color: @glow_violet; text-shadow: 0 0 9px alpha(@glow_violet, 0.9); } +.station-astro { color: @accent; text-shadow: 0 0 9px alpha(@accent, 0.9); } +.station-history { color: @glow_magenta; text-shadow: 0 0 9px alpha(@glow_magenta, 0.9); } +.station-orbit:hover { text-shadow: 0 0 16px @glow_violet, 0 0 5px @glow_violet; } +.station-astro:hover { text-shadow: 0 0 16px @accent, 0 0 5px @accent; } +.station-history:hover { text-shadow: 0 0 16px @glow_magenta, 0 0 5px @glow_magenta; } + +/* workspace "stations" row */ +.station-ws-row { padding: 0 4px; } +.station-node { + color: alpha(@glow_violet, 0.9); + font-size: 13pt; + text-shadow: 0 0 6px alpha(@glow_violet, 0.6); + transition: color 160ms ease, text-shadow 160ms ease; +} +.station-node:hover { + color: @glow_violet; + text-shadow: 0 0 11px @glow_violet; +} +/* the focused workspace — brightest, magenta, reticle-framed in bar.py */ +.station-node-active { + color: @glow_magenta; + font-weight: 700; + font-size: 14pt; + text-shadow: 0 0 13px @glow_magenta, 0 0 4px @glow_magenta; +} + +/* tray pod — the drawn space-station pictogram + its icon row */ +.station-tray-pod { + padding: 0 4px; +} +.station-tray-item { + background: transparent; + border: none; + padding: 0 2px; + min-width: 18px; + min-height: 18px; + transition: opacity 160ms ease; + opacity: 0.85; +} +.station-tray-item:hover { opacity: 1; } + +/* Right-click context menu, rendered by us from the item's com.canonical.dbusmenu + * (tray.py). Themed to match the suite: translucent violet-charcoal glass, violet + * frame, accent on the hovered/active row. */ +.station-tray-menu > contents, +.station-tray-menu > arrow { + background-color: alpha(@bg, 0.92); + border: 1px solid alpha(@violet, 0.85); + border-radius: 12px; + box-shadow: 0 0 18px alpha(@violet, 0.35); +} +.station-tray-menu modelbutton { + border-radius: 8px; + padding: 4px 10px; + color: @text; + min-height: 22px; +} +.station-tray-menu modelbutton:hover { + background-color: alpha(@violet, 0.35); + color: #F0E6EA; +} +.station-tray-menu modelbutton:active, +.station-tray-menu modelbutton:checked { + background-color: alpha(@accent, 0.40); +} +.station-tray-menu separator { + background-color: alpha(@violet, 0.35); + min-height: 1px; + margin: 3px 6px; +} +.station-tray-menu modelbutton:disabled { + color: alpha(@text, 0.45); +} diff --git a/station-bar/theme.py b/station-bar/theme.py new file mode 100644 index 0000000..d531be1 --- /dev/null +++ b/station-bar/theme.py @@ -0,0 +1,31 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as the rest +of the Cosmonaut Shell suite (orbit-menu, horizon-dock, astro-menu). _colors.css +(generated from ~/Dotfiles/colors.conf by apply-theme.sh) defines the CyberQueer +@define-color names; style.css consumes them. + +Priority is USER+1 for the same reason as the others: the CyberQueer GTK theme +at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION +(600), and would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/station-bar/tray.py b/station-bar/tray.py new file mode 100644 index 0000000..a14dcd4 --- /dev/null +++ b/station-bar/tray.py @@ -0,0 +1,251 @@ +"""Minimal StatusNotifierItem (SNI) HOST — same protocol/approach as horizon- +dock's tray.py, but rendered inline directly on the always-visible bar instead +of inside a popover. Since there's no "rebuild on reveal" moment to hook, this +live-subscribes to the watcher's RegisteredStatusNotifierItems property so the +row updates itself as apps come and go, rather than rebuilding on a timer. +""" + +from __future__ import annotations + +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +_WATCHER_BUS = "org.kde.StatusNotifierWatcher" +_WATCHER_PATH = "/StatusNotifierWatcher" +_WATCHER_IFACE = "org.kde.StatusNotifierWatcher" +_ITEM_IFACE = "org.kde.StatusNotifierItem" +_HOST_NAME = "org.kde.StatusNotifierHost-station-bar" + + +class TrayHost: + def __init__(self, on_change: Callable[[], None]) -> None: + self._on_change = on_change + self._watcher: Optional[Gio.DBusProxy] = None + self._registered_host = False + # Watch the name rather than connecting once: our own SniWatcher may not + # own it yet at construction, and this also recovers if the watcher (ours + # or a real DE's) restarts. + self._watch_id = Gio.bus_watch_name( + Gio.BusType.SESSION, _WATCHER_BUS, Gio.BusNameWatcherFlags.NONE, + self._on_watcher_appeared, self._on_watcher_vanished) + + def _on_watcher_appeared(self, _conn, _name, _owner) -> None: + # MUST be async: this bar hosts the StatusNotifierWatcher itself (see + # sni_watcher.py), so a *_sync proxy here would block the main loop + # waiting on a reply only that same loop can produce — a self-deadlock + # that freezes the whole bar (its clock, its holo intro — everything) + # until the 25s D-Bus timeout finally lets it go. + Gio.DBusProxy.new_for_bus( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + _WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None, + self._on_watcher_ready, None, + ) + + def _on_watcher_ready(self, _source, result, _user_data) -> None: + try: + self._watcher = Gio.DBusProxy.new_for_bus_finish(result) + except GLib.Error: + self._watcher = None + return + self._watcher.connect("g-properties-changed", lambda *_a: self._on_change()) + self._registered_host = False + self._ensure_host_registered() + self._on_change() + + def _on_watcher_vanished(self, _conn, _name) -> None: + self._watcher = None + self._registered_host = False + self._on_change() + + def _ensure_host_registered(self) -> None: + if self._registered_host or self._watcher is None: + return + # Async for the same self-deadlock reason as _on_watcher_appeared: this + # call targets our own in-process watcher. Fire-and-forget. + self._registered_host = True + self._watcher.call( + "RegisterStatusNotifierHost", GLib.Variant("(s)", (_HOST_NAME,)), + Gio.DBusCallFlags.NONE, 2000, None, None, + ) + + def available(self) -> bool: + return self._watcher is not None + + def registered_items(self) -> list[str]: + if self._watcher is None: + return [] + value = self._watcher.get_cached_property("RegisteredStatusNotifierItems") + return list(value.unpack()) if value is not None else [] + + @staticmethod + def _parse_item(entry: str) -> tuple[str, str]: + if "/" in entry: + bus, _, path = entry.partition("/") + return bus, "/" + path + return entry, "/StatusNotifierItem" + + def build_into(self, box: Gtk.Box) -> None: + """(Re)populate `box` with one small icon button per registered tray item.""" + child = box.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + box.remove(child) + child = nxt + + for entry in self.registered_items(): + bus, path = self._parse_item(entry) + btn = self._build_item_button(bus, path) + if btn is not None: + box.append(btn) + + def _build_item_button(self, bus: str, path: str) -> Optional[Gtk.Button]: + try: + proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + bus, path, _ITEM_IFACE, None, + ) + except GLib.Error: + return None + + icon_name = self._prop_str(proxy, "IconName") or "application-x-executable" + title = self._prop_str(proxy, "Title") or icon_name or bus + + btn = Gtk.Button() + btn.set_has_frame(False) + btn.add_css_class("station-tray-item") + btn.set_tooltip_text(title) + image = Gtk.Image.new_from_icon_name(icon_name) + image.set_pixel_size(14) + btn.set_child(image) + btn.connect("clicked", lambda *_a, p=proxy: self._activate(p)) + + right_click = Gtk.GestureClick(button=3) + right_click.connect("pressed", + lambda *_a, p=proxy, b=btn: self._context_menu(p, b)) + btn.add_controller(right_click) + return btn + + @staticmethod + def _prop_str(proxy: Gio.DBusProxy, name: str) -> str: + value = proxy.get_cached_property(name) + return value.unpack() if value is not None else "" + + @staticmethod + def _activate(proxy: Gio.DBusProxy) -> None: + proxy.call("Activate", GLib.Variant("(ii)", (0, 0)), + Gio.DBusCallFlags.NONE, -1, None, None, None) + + # -- context menu (com.canonical.dbusmenu) -------------------------------- + # + # SNI items don't hand us a positioned menu — most expose a dbusmenu object + # (the `Menu` property) that the HOST is expected to render itself. The old + # code just called the item's ContextMenu(0,0); apps that implement it (e.g. + # Discord) popped their menu at screen (0,0) — top-left — and apps that only + # do dbusmenu (nm-applet, blueman) got nothing at all. So instead we fetch + # the dbusmenu layout and render it in a GTK popover anchored to the icon. + + def _context_menu(self, proxy: Gio.DBusProxy, button: Gtk.Button) -> None: + menu_path = self._prop_str(proxy, "Menu") + if not menu_path: + # No dbusmenu — last-ditch: ask the item to show its own context menu. + proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)), + Gio.DBusCallFlags.NONE, -1, None, None, None) + return + try: + dm = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + proxy.get_name(), menu_path, "com.canonical.dbusmenu", None, + ) + except GLib.Error: + return + + # Best-effort: let the app refresh its menu before we read it. + try: + dm.call_sync("AboutToShow", GLib.Variant("(i)", (0,)), + Gio.DBusCallFlags.NONE, 800, None) + except GLib.Error: + pass + try: + res = dm.call_sync("GetLayout", GLib.Variant("(iias)", (0, -1, [])), + Gio.DBusCallFlags.NONE, 1500, None) + except GLib.Error: + return + _revision, layout = res.unpack() + + actions = Gio.SimpleActionGroup() + model = self._build_model(dm, layout, actions) + + popover = Gtk.PopoverMenu.new_from_model(model) + popover.add_css_class("station-tray-menu") + popover.set_parent(button) + popover.set_position(Gtk.PositionType.BOTTOM) + popover.insert_action_group("traymenu", actions) + # Keep a ref while shown, and tear the surface down cleanly on close. + self._open_popover = popover + popover.connect("closed", self._on_popover_closed) + popover.popup() + + def _on_popover_closed(self, popover: Gtk.Popover) -> None: + popover.unparent() + if getattr(self, "_open_popover", None) is popover: + self._open_popover = None + + def _build_model(self, dm: Gio.DBusProxy, node, actions: Gio.SimpleActionGroup) -> Gio.Menu: + """Turn a dbusmenu (id, props, children) node into a Gio.Menu. Separators + split the run into sections (PopoverMenu draws a divider between them).""" + menu = Gio.Menu() + section = Gio.Menu() + + def flush() -> None: + nonlocal section + if section.get_n_items() > 0: + menu.append_section(None, section) + section = Gio.Menu() + + for child in node[2]: + cid, props, _kids = child + if not props.get("visible", True): + continue + if props.get("type") == "separator": + flush() + continue + + label = props.get("label", "") or "" + if props.get("children-display") == "submenu": + item = Gio.MenuItem.new(label, None) + item.set_submenu(self._build_model(dm, child, actions)) + section.append_item(item) + continue + + aname = f"i{cid}" + if props.get("toggle-type") in ("checkmark", "radio"): + state = GLib.Variant("b", bool(props.get("toggle-state", 0))) + act = Gio.SimpleAction.new_stateful(aname, None, state) + else: + act = Gio.SimpleAction.new(aname, None) + act.set_enabled(bool(props.get("enabled", True))) + act.connect("activate", lambda _a, _p, i=cid: self._menu_event(dm, i)) + actions.add_action(act) + + item = Gio.MenuItem.new(label, f"traymenu.{aname}") + icon_name = props.get("icon-name") + if icon_name: + try: + item.set_icon(Gio.ThemedIcon.new(icon_name)) + except GLib.Error: + pass + section.append_item(item) + + flush() + return menu + + @staticmethod + def _menu_event(dm: Gio.DBusProxy, item_id: int) -> None: + dm.call("Event", + GLib.Variant("(isvu)", (item_id, "clicked", GLib.Variant("i", 0), 0)), + Gio.DBusCallFlags.NONE, -1, None, None, None) diff --git a/station-bar/volume.py b/station-bar/volume.py new file mode 100644 index 0000000..3cee6c2 --- /dev/null +++ b/station-bar/volume.py @@ -0,0 +1,49 @@ +"""Default-sink volume via pactl — same tool scripts/getvol already shells out +to. Simple synchronous subprocess calls on click/scroll, no continuous polling: +the bar only needs to reflect what IT changed, and hardware volume keys are rare +enough on a device that also runs Hyprland that a poll loop isn't worth the cost. +""" + +from __future__ import annotations + +import re +import subprocess + +_STEP = 5 # percent per scroll tick, matches typical bar/WM conventions + +_VOL_RE = re.compile(r"(\d+)%") + + +def get_percent() -> int: + try: + out = subprocess.run(["pactl", "get-sink-volume", "@DEFAULT_SINK@"], + capture_output=True, text=True, timeout=1.0, check=True).stdout + m = _VOL_RE.search(out) + return int(m.group(1)) if m else 0 + except (subprocess.SubprocessError, OSError): + return 0 + + +def is_muted() -> bool: + try: + out = subprocess.run(["pactl", "get-sink-mute", "@DEFAULT_SINK@"], + capture_output=True, text=True, timeout=1.0, check=True).stdout + return "yes" in out.lower() + except (subprocess.SubprocessError, OSError): + return False + + +def _set(percent: int) -> None: + percent = max(0, min(100, percent)) + subprocess.Popen(["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{percent}%"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def adjust(delta_ticks: int) -> None: + """delta_ticks > 0 raises, < 0 lowers, in _STEP-sized increments.""" + _set(get_percent() + delta_ticks * _STEP) + + +def toggle_mute() -> None: + subprocess.Popen(["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) diff --git a/transmitter-panel/.gitignore b/transmitter-panel/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/transmitter-panel/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/transmitter-panel/config.py b/transmitter-panel/config.py new file mode 100644 index 0000000..cfa698a --- /dev/null +++ b/transmitter-panel/config.py @@ -0,0 +1,30 @@ +"""Tiny user-editable config file: ~/.local/state/transmitter-panel/config.json. + +Read once at startup (main.py); a change takes effect on the next +transmitter-panel-start.sh restart, not live. Same pattern as orbit-menu/ +horizon-dock/astro-menu/station-bar/beacon's config.py. +""" + +from __future__ import annotations + +import json + +from paths import CONFIG_FILE, ensure_dirs + +_DEFAULTS = {"hologram": True} + + +def _load() -> dict: + try: + data = json.loads(CONFIG_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + data = {} + merged = {**_DEFAULTS, **data} + if not CONFIG_FILE.exists(): + ensure_dirs() + CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n") + return merged + + +def hologram_enabled() -> bool: + return bool(_load().get("hologram", True)) diff --git a/transmitter-panel/history_client.py b/transmitter-panel/history_client.py new file mode 100644 index 0000000..4448f8a --- /dev/null +++ b/transmitter-panel/history_client.py @@ -0,0 +1,79 @@ +"""Async client for beacon's eu.abdelbaki.beacon.History1 D-Bus interface. + +Runs on the GTK main loop: every call is async (Gio.DBusProxy.call, never +call_sync) so a slow or hung beacon never freezes the panel. If beacon isn't +running yet (or drops off the bus), calls fail — callers get an empty list +or a silent no-op rather than a crash, the same defensive stance beacon's +own controls.py/history.py took. +""" + +from __future__ import annotations + +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib # noqa: E402 + +# Same well-known name/path/interface beacon's own paths.py defines — +# duplicated here as plain strings rather than a shared import, matching how +# screenrec.sh independently hardcodes them too (each deployed component is +# its own ~/.config/ tree with no shared Python path). +FDN_NAME = "org.freedesktop.Notifications" +FDN_PATH = "/org/freedesktop/Notifications" +HISTORY_IFACE = "eu.abdelbaki.beacon.History1" + + +class HistoryClient: + def __init__(self, + on_added: Optional[Callable[[dict], None]] = None, + on_removed: Optional[Callable[[int], None]] = None, + on_cleared: Optional[Callable[[], None]] = None) -> None: + self._on_added = on_added + self._on_removed = on_removed + self._on_cleared = on_cleared + # Constructing a proxy doesn't require the peer to be present yet — + # only individual calls fail while beacon isn't up. + self._proxy = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + FDN_NAME, FDN_PATH, HISTORY_IFACE, None) + self._proxy.get_connection().signal_subscribe( + FDN_NAME, HISTORY_IFACE, None, FDN_PATH, None, + Gio.DBusSignalFlags.NONE, self._on_signal) + + def _on_signal(self, _conn, _sender, _path, _iface, signal, params) -> None: + if signal == "HistoryAdded" and self._on_added is not None: + (entry,) = params.unpack() + self._on_added(entry) + elif signal == "HistoryRemoved" and self._on_removed is not None: + (nid,) = params.unpack() + self._on_removed(int(nid)) + elif signal == "HistoryCleared" and self._on_cleared is not None: + self._on_cleared() + + # -- calls -------------------------------------------------------------- + def list_async(self, callback: Callable[[list[dict]], None]) -> None: + def done(proxy, result, _data=None) -> None: + try: + (entries,) = proxy.call_finish(result).unpack() + except GLib.GError: + entries = [] + callback(entries) + self._proxy.call("List", None, Gio.DBusCallFlags.NONE, -1, None, done, None) + + def remove_async(self, nid: int) -> None: + self._proxy.call( + "Remove", GLib.Variant("(u)", (nid,)), Gio.DBusCallFlags.NONE, -1, None, + self._ignore_result, None) + + def clear_async(self) -> None: + self._proxy.call( + "Clear", None, Gio.DBusCallFlags.NONE, -1, None, self._ignore_result, None) + + @staticmethod + def _ignore_result(proxy, result, _data=None) -> None: + try: + proxy.call_finish(result) + except GLib.GError: + pass # beacon not running / already gone — nothing to do diff --git a/transmitter-panel/lib/hologram.py b/transmitter-panel/lib/hologram.py new file mode 100644 index 0000000..62b1856 --- /dev/null +++ b/transmitter-panel/lib/hologram.py @@ -0,0 +1,279 @@ +"""Holographic scanline/sweep/noise overlay — the same treatment and tuning as +the rest of the Cosmonaut Shell suite (astro-menu / station-bar / orbit-menu / +horizon-dock / beacon lib/hologram.py), reused here so the transmitter-panel +popup reads as one more orbit of the same look: scanline grid + a slow +vertical sweep + drifting noise specks, and a 'materialise out of static' +intro when the panel opens. + +One overlay covers the whole panel (same usage as astro-menu's window.py, +`fade_widget=self.root`), not a per-row overlay — window.py runs a single +frame-clock tick and feeds it via .tick(dt). +""" + +from __future__ import annotations + +import math +import random + +import cairo +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +# Same CyberQueer violet/magenta/red combo as the rest of the suite's hologram. +_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) +_MAGENTA = (0.92, 0.0, 0.65) +_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255) + + +class HologramOverlay: + SCANLINE_GAP = 4.0 + SCANLINE_ALPHA = 0.18 + SWEEP_PERIOD = 3.2 # seconds for one top-to-bottom pass + SWEEP_HALF_HEIGHT = 34.0 + NOISE_COUNT = 26 + NOISE_COLORS = [_MAGENTA, _MAGENTA, _ACCENT] # magenta-biased specks + NOISE_LIFETIME = (0.5, 1.4) + NOISE_FADE_IN = 0.2 + NOISE_FADE_OUT = 0.35 + NOISE_ALPHA_RANGE = (0.10, 0.34) + EDGE_FADE_X = 40.0 # smooth horizontal fade-out of the scanline field + EDGE_FADE_Y = 34.0 # smooth vertical fade-out + INTRO_DURATION = 0.9 # brisk 'materialise out of static' when the panel opens + INTRO_STATIC = 900 # static specks at the very start of the intro + OUTRO_DURATION = 0.2 # snappy reverse dissolve back into static on close + + def __init__(self, enabled: bool = True, clip_func=None, fade_widget=None, + intro_duration: float | None = None) -> None: + self.enabled = enabled + self._clip_func = clip_func # optional path-setter to clip the holo to a shape + # widget whose opacity is ramped 0->1 during the intro so the content + # genuinely fades in, rather than a solid haze block popping on + self._fade_widget = fade_widget + if intro_duration is not None: + self.INTRO_DURATION = intro_duration + self._sat_time = 0.0 + self._particles: list[dict] = [] + self._intro_t: float | None = None + self._outro_t: float | None = None + self._outro_done = None + # Wall-clock safety net: the frame-clock tick only advances while the + # compositor sends frame callbacks; if those stall the intro could freeze + # with content stuck at opacity 0. This timeout force-resolves it anyway. + self._intro_deadline_id: int | None = None + self._mask_cache: tuple | None = None + + self.widget = Gtk.DrawingArea() + self.widget.set_can_target(False) # never steals clicks from the panel underneath + self.widget.add_css_class("tx-hologram") + self.widget.set_hexpand(True) + self.widget.set_vexpand(True) + self.widget.set_halign(Gtk.Align.FILL) + self.widget.set_valign(Gtk.Align.FILL) + self.widget.set_draw_func(self._draw_frame) + + def tick(self, dt: float) -> None: + if not self.enabled: + return + self._sat_time += dt + if self._intro_t is not None: + self._intro_t += dt + if self._intro_t >= self.INTRO_DURATION: + self._finish_intro() + elif self._fade_widget is not None: + p = self._intro_t / self.INTRO_DURATION + self._fade_widget.set_opacity(p * p * (3 - 2 * p)) + if self._outro_t is not None: + self._outro_t += dt + po = min(1.0, self._outro_t / self.OUTRO_DURATION) + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0 - po * po * (3 - 2 * po)) + if self._outro_t >= self.OUTRO_DURATION: + done = self._outro_done + self._outro_t = None + self._outro_done = None + if done is not None: + done() + self.widget.queue_draw() + + def _finish_intro(self) -> None: + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + if self._fade_widget is not None: + self._fade_widget.set_opacity(1.0) + self.widget.queue_draw() + + def start_intro(self) -> None: + """Kick off the 'materialising out of static' opening effect.""" + if self.enabled: + self._outro_t = None + self._outro_done = None + self._intro_t = 0.0 + if self._fade_widget is not None: + self._fade_widget.set_opacity(0.0) + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = GLib.timeout_add( + int(self.INTRO_DURATION * 1000) + 150, self._on_intro_deadline) + + def _on_intro_deadline(self) -> bool: + self._intro_deadline_id = None + if self._intro_t is not None: + self._finish_intro() + return False # one-shot + + def start_outro(self, on_done) -> None: + """Reverse of the intro (dissolving back into static), then on_done.""" + if not self.enabled: + on_done() + return + self._intro_t = None + if self._intro_deadline_id is not None: + GLib.source_remove(self._intro_deadline_id) + self._intro_deadline_id = None + self._outro_t = 0.0 + self._outro_done = on_done + + # -- drawing -------------------------------------------------------------- + def _draw_frame(self, _area, cr, width: float, height: float) -> None: + if not self.enabled or width <= 0 or height <= 0: + return + if self._clip_func is not None: + cr.save() + self._clip_func(cr, width, height) + cr.clip() + cr.push_group() + self._draw_content(cr, width, height) + cr.pop_group_to_source() + cr.mask(self._edge_fade_mask(width, height)) + if self._intro_t is not None: + self._draw_intro(cr, width, height) + elif self._outro_t is not None: + self._draw_outro(cr, width, height) + if self._clip_func is not None: + cr.restore() + + def _edge_fade_mask(self, width: float, height: float): + key = (int(width), int(height)) + if self._mask_cache is not None and self._mask_cache[0] == key: + return self._mask_cache[1] + w, h = max(1, key[0]), max(1, key[1]) + surf = cairo.ImageSurface(cairo.FORMAT_A8, w, h) + m = cairo.Context(surf) + m.set_source_rgba(0, 0, 0, 1) + m.paint() + m.set_operator(cairo.OPERATOR_DEST_OUT) + fx = min(self.EDGE_FADE_X, w / 2) + fy = min(self.EDGE_FADE_Y, h / 2) + + def band(x0, y0, x1, y1, rx, ry, rw, rh): + gr = cairo.LinearGradient(x0, y0, x1, y1) + gr.add_color_stop_rgba(0.0, 0, 0, 0, 1) + gr.add_color_stop_rgba(1.0, 0, 0, 0, 0) + m.set_source(gr) + m.rectangle(rx, ry, rw, rh) + m.fill() + band(0, 0, fx, 0, 0, 0, fx, h) # left + band(w, 0, w - fx, 0, w - fx, 0, fx, h) # right + band(0, 0, 0, fy, 0, 0, w, fy) # top + band(0, h, 0, h - fy, 0, h - fy, w, fy) # bottom + pattern = cairo.SurfacePattern(surf) + self._mask_cache = (key, pattern) + return pattern + + def _draw_intro(self, cr, width: float, height: float) -> None: + p = min(1.0, max(0.0, (self._intro_t or 0.0) / self.INTRO_DURATION)) + strength = 1.0 - p + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (strength ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * strength)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = p * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * p - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_outro(self, cr, width: float, height: float) -> None: + po = min(1.0, max(0.0, (self._outro_t or 0.0) / self.OUTRO_DURATION)) + colors = [_MAGENTA, _MAGENTA, _ACCENT, (0.85, 0.85, 0.95)] + count = int(self.INTRO_STATIC * (po ** 0.5)) + for _ in range(count): + x = random.uniform(0, width) + y = random.uniform(0, height) + col = random.choice(colors) + cr.set_source_rgba(*col, random.uniform(0.25, 0.85) * (0.35 + 0.65 * po)) + sz = random.uniform(1.0, 2.8) + cr.rectangle(x, y, sz, sz) + cr.fill() + band = (1.0 - po) * height + cr.set_source_rgba(*_ACCENT, 0.5 * (1.0 - abs(2 * po - 1))) + cr.rectangle(0, band - 2.0, width, 4.0) + cr.fill() + + def _draw_content(self, cr, width: float, height: float) -> None: + r, g, b = _VIOLET + + cr.save() + cr.set_source_rgba(r, g, b, self.SCANLINE_ALPHA) + cr.set_line_width(1.0) + y = 0.0 + while y < height: + cr.move_to(0, y) + cr.line_to(width, y) + y += self.SCANLINE_GAP + cr.stroke() + cr.restore() + + phase = (self._sat_time % self.SWEEP_PERIOD) / self.SWEEP_PERIOD + sweep_y = phase * height + hh = self.SWEEP_HALF_HEIGHT + grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh) + grad.add_color_stop_rgba(0.0, r, g, b, 0.0) + grad.add_color_stop_rgba(0.5, r, g, b, 0.09) + grad.add_color_stop_rgba(1.0, r, g, b, 0.0) + cr.set_source(grad) + cr.rectangle(0, sweep_y - hh, width, hh * 2) + cr.fill() + + flicker = 0.012 + 0.007 * math.sin(self._sat_time * 11.0) + cr.set_source_rgba(r, g, b, max(0.0, flicker)) + cr.paint() + + self._draw_noise(cr, width, height) + + def _draw_noise(self, cr, width: float, height: float) -> None: + now = self._sat_time + self._particles = [p for p in self._particles if now - p["birth"] < p["life"]] + while len(self._particles) < self.NOISE_COUNT: + self._particles.append({ + "x": random.uniform(0, width), + "y": random.uniform(0, height), + "w": random.uniform(1.0, 2.6), + "h": random.uniform(1.0, 2.0), + "color": random.choice(self.NOISE_COLORS), + "peak_alpha": random.uniform(*self.NOISE_ALPHA_RANGE), + "birth": now, + "life": random.uniform(*self.NOISE_LIFETIME), + }) + + for p in self._particles: + t = (now - p["birth"]) / p["life"] + if t < self.NOISE_FADE_IN: + envelope = t / self.NOISE_FADE_IN + elif t > 1.0 - self.NOISE_FADE_OUT: + envelope = max(0.0, (1.0 - t) / self.NOISE_FADE_OUT) + else: + envelope = 1.0 + r, g, b = p["color"] + cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope) + cr.rectangle(p["x"], p["y"], p["w"], p["h"]) + cr.fill() diff --git a/transmitter-panel/main.py b/transmitter-panel/main.py new file mode 100644 index 0000000..3fd2197 --- /dev/null +++ b/transmitter-panel/main.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""transmitter-panel — beacon's notification-history viewer for hyprdrive. + +Single-instance, same pattern as horizon-dock/astro-menu's main.py: the first +launch builds the (hidden) window and holds; later invocations forward their +verb over D-Bus via scripts/transmitter-panel.sh instead of spawning a second +python3+GTK4 process. + + main.py run the resident instance (stays hidden until toggled) + main.py --show show + main.py --hide hide + main.py --toggle whichever of the above applies +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# transmitter-panel-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering +# requirement ahead of libwayland-client). Drop it once resident so it isn't +# inherited by anything this process launches — same rationale as the rest of +# the suite's main.py. +os.environ.pop("LD_PRELOAD", None) + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import gi # noqa: E402 + +gi.require_version("Gtk", "4.0") +from gi.repository import Gio, GLib, Gtk # noqa: E402 + +import theme # noqa: E402 +from paths import APP_ID # noqa: E402 +from window import TransmitterWindow # noqa: E402 + + +class TransmitterPanelApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: TransmitterWindow | None = None + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = TransmitterWindow(self) + self._register_actions() + self.hold() # stay alive with no visible window + + def _register_actions(self) -> None: + def add(name: str, callback) -> None: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def guarded(fn): + def wrapper(*_a) -> None: + assert self.window is not None + fn(self.window) + return wrapper + + add("show", guarded(lambda w: w.show_panel())) + add("hide", guarded(lambda w: w.hide_panel())) + add("toggle", guarded(lambda w: w.toggle())) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + if self.window is None: + return 0 + if verb == "--show": + self.window.show_panel() + elif verb == "--hide": + self.window.hide_panel() + elif verb == "--toggle": + self.window.toggle() + # --daemon and anything else: no-op (stay resident, hidden) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +def main() -> int: + GLib.set_prgname("transmitter-panel") + return TransmitterPanelApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/transmitter-panel/paths.py b/transmitter-panel/paths.py new file mode 100644 index 0000000..f5b6f67 --- /dev/null +++ b/transmitter-panel/paths.py @@ -0,0 +1,22 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does +# `rm -rf ~/.config/transmitter-panel` on every dotfiles sync (see beacon/ +# station-bar/astro-menu's own paths.py for the same rationale), which would +# wipe a hand-edited config on the spot. +STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "transmitter-panel" +CONFIG_FILE = STATE_DIR / "config.json" + +APP_ID = "eu.abdelbaki.transmitterpanel" + + +def ensure_dirs() -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/transmitter-panel/style/_colors.css b/transmitter-panel/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/transmitter-panel/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/transmitter-panel/style/style.css b/transmitter-panel/style/style.css new file mode 100644 index 0000000..665bead --- /dev/null +++ b/transmitter-panel/style/style.css @@ -0,0 +1,133 @@ +/* transmitter-panel — beacon's notification-history viewer. Matches the + * astro-menu/beacon idiom: emitted-magenta text, violet holo-glass fills, + * glow-violet/accent frames, Agave Nerd Font Mono, rounded panels. The + * compositor blurs behind the translucent fill (see the `transmitter-panel` + * layer-rule in hypr/usr/windowrules.lua); the scanline/sweep/noise depth is + * Cairo-drawn on top by lib/hologram.py. */ + +@define-color text #EB00A6; /* emitted magenta, same override astro-menu/beacon use */ + +* { + font-family: "Agave Nerd Font Mono", monospace; + font-size: 12pt; +} + +/* Blank the structural nodes so the CyberQueer theme's `* { background-color: + * #1a1a1a }` doesn't fill the surface/gaps with an opaque slab — the panel + * asserts its own glass fill below (same trick as beacon/style/style.css). */ +window, +window.background, +.tx-window, +.tx-list, +box, +overlay, +label, +image, +drawingarea { + background: transparent; + background-color: transparent; +} + +/* the holo-glass panel */ +.tx-panel { + background-color: alpha(@violet, 0.40); + border: 2px solid #8A5CFF; + border-radius: 16px; + padding: 14px 16px; + box-shadow: 0 0 16px 1px alpha(#8A5CFF, 0.35); +} + +/* right margin keeps "Clear All" clear of the overlaid panel-level ✕ button + * (min-width 34px + its own 20px right margin, see .close-btn below — a + * ~54px footprint from the right edge), since both sit in the top-right + * corner otherwise. */ +.tx-header { margin: 0 64px 8px 0; } +.tx-title { + color: @text; + font-weight: bold; + font-size: 13pt; + letter-spacing: 1px; +} + +.tx-clear-all { + color: @text; + background-color: alpha(@violet, 0.4); + border: 2px solid #8A5CFF; + border-radius: 20px; + padding: 4px 14px; + min-height: 26px; + transition: border-color 180ms ease, color 180ms ease, box-shadow 220ms ease, background 180ms ease; +} +.tx-clear-all:hover { + border-color: @accent; + color: @accent; + box-shadow: 0 0 12px 1px alpha(@accent, 0.55); +} + +.tx-empty { + color: @text; + opacity: 0.6; + padding: 18px 4px; +} + +/* per-entry rows */ +.tx-row { + padding: 8px 10px; + border-radius: 12px; + min-height: 34px; + transition: background 180ms ease; +} +.tx-row:hover { background: alpha(@violet, 0.16); } +.tx-row-icon { margin-right: 2px; } +.tx-row-app { + color: @text; + opacity: 0.65; + font-size: 9pt; + letter-spacing: 0.5px; +} +.tx-row-summary { + color: @text; + font-weight: bold; + font-size: 11.5pt; +} +.tx-row-body { + color: @text; + font-size: 10.5pt; + opacity: 0.9; +} + +/* small per-row dismiss ✕ — a scaled-down .close-btn */ +.tx-row-close { + color: @text; + background: alpha(@violet, 0.4); + border: none; + border-radius: 14px; + min-width: 24px; + min-height: 24px; + transition: background 180ms ease, color 180ms ease, box-shadow 220ms ease; +} +.tx-row-close:hover { + background: @accent; + color: @bg; + box-shadow: 0 0 10px 1px alpha(@accent, 0.5); +} + +/* floating panel-level close button (top-right, closes the whole popup) — + * copied verbatim from astro-menu/style/style.css's .close-btn idiom. */ +.close-btn { + color: @text; background: alpha(@violet, 0.4); + border: none; border-radius: 20px; + min-width: 34px; min-height: 34px; + margin: 16px 20px; + transition: background 180ms ease, color 180ms ease, box-shadow 220ms ease; +} +.close-btn:hover { + background: @accent; + color: @bg; + box-shadow: 0 0 14px 2px alpha(@accent, 0.55); +} + +scrollbar slider { background: @violet; border-radius: 8px; min-width: 6px; } +scrollbar slider:hover { background: @accent; } + +.tx-hologram { background: transparent; } diff --git a/transmitter-panel/theme.py b/transmitter-panel/theme.py new file mode 100644 index 0000000..650e12f --- /dev/null +++ b/transmitter-panel/theme.py @@ -0,0 +1,31 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as the rest +of the Cosmonaut Shell suite (orbit-menu, horizon-dock, astro-menu, station-bar, +beacon). _colors.css defines the CyberQueer @define-color names; style.css +consumes them. + +Priority is USER+1 for the same reason as the others: the CyberQueer GTK theme +at ~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION +(600), and would beat our transparent structural containers otherwise. +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from paths import STYLE_DIR + + +def load_css() -> None: + display = Gdk.Display.get_default() + for name in ("_colors.css", "style.css"): + path = STYLE_DIR / name + if not path.exists(): + continue + provider = Gtk.CssProvider() + provider.load_from_path(str(path)) + Gtk.StyleContext.add_provider_for_display( + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 + ) diff --git a/transmitter-panel/window.py b/transmitter-panel/window.py new file mode 100644 index 0000000..c71cead --- /dev/null +++ b/transmitter-panel/window.py @@ -0,0 +1,266 @@ +"""The popup: a content-sized floating layer-shell panel anchored top-centre, +listing beacon's notification history (via history_client.HistoryClient). + + Gtk.Window (layer TOP, anchored TOP -> horizontally centred, height = content) + └ Gtk.Overlay + main : .tx-panel (header w/ Clear All + scrollable row list) + over : ✕ close button (top-right, closes the whole panel) + +Dismissed with the launcher toggle, Esc, or the ✕ button — no click-outside- +to-close (would need a blocking full-screen surface), same tradeoff astro- +menu's window.py documents. Each row has its own ✕ that removes just that +entry from beacon's history (independent of closing the panel itself). +""" + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Gdk", "4.0") +gi.require_version("Gtk4LayerShell", "1.0") +from gi.repository import Gdk, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + +import config +from history_client import HistoryClient +from lib.hologram import HologramOverlay + +PANEL_WIDTH = 380 +EDGE_MARGIN = 28 +MAX_LIST_HEIGHT = 420 + + +class TransmitterWindow(Gtk.ApplicationWindow): + def __init__(self, app: Gtk.Application) -> None: + super().__init__(application=app) + self.set_name("transmitter-window") + self.add_css_class("tx-window") + self.set_decorated(False) + + self._rows: dict[int, Gtk.Widget] = {} + self._order: list[int] = [] # newest-first ids, mirrors History1.List order + + self._init_layer_shell() + + self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + self.root.set_name("panel-root") + self.root.add_css_class("tx-panel") + self.root.set_size_request(PANEL_WIDTH, -1) + + header = Gtk.CenterBox() + header.add_css_class("tx-header") + title = Gtk.Label(label="Transmissions", xalign=0.0) + title.add_css_class("tx-title") + header.set_start_widget(title) + clear_btn = Gtk.Button(label="Clear All") + clear_btn.add_css_class("tx-clear-all") + clear_btn.connect("clicked", lambda *_a: self._client.clear_async()) + header.set_end_widget(clear_btn) + self.root.append(header) + + self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + self._list.add_css_class("tx-list") + self._scroller = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER) + self._scroller.set_max_content_height(MAX_LIST_HEIGHT) + self._scroller.set_propagate_natural_height(True) + self._scroller.set_child(self._list) + self.root.append(self._scroller) + + self._empty_label = Gtk.Label(label="No transmissions") + self._empty_label.add_css_class("tx-empty") + + overlay = Gtk.Overlay() + overlay.set_child(self.root) + + self._hologram = HologramOverlay(enabled=config.hologram_enabled(), fade_widget=self.root) + overlay.add_overlay(self._hologram.widget) + + close = Gtk.Button(label="✕") + close.add_css_class("close-btn") + close.set_halign(Gtk.Align.END) + close.set_valign(Gtk.Align.START) + close.connect("clicked", lambda *_a: self.hide_panel()) + overlay.add_overlay(close) + self.set_child(overlay) + + key = Gtk.EventControllerKey() + key.connect("key-pressed", self._on_key) + self.add_controller(key) + + self._last_tick: float | None = None + self._tick_id: int | None = None + + self._client = HistoryClient(on_added=self._on_history_added, + on_removed=self._on_history_removed, + on_cleared=self._on_history_cleared) + self._rebuild_empty_state() + self.set_visible(False) + + # -- layer shell ------------------------------------------------------------ + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.TOP) + LayerShell.set_namespace(self, "transmitter-panel") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND) + LayerShell.set_anchor(self, LayerShell.Edge.TOP, True) + LayerShell.set_margin(self, LayerShell.Edge.TOP, EDGE_MARGIN) + + # -- visibility --------------------------------------------------------------- + def show_panel(self) -> None: + self._client.list_async(self._on_list_result) + self.set_visible(True) + self.present() + if self._hologram.enabled and self._tick_id is None: + self._tick_id = self.add_tick_callback(self._on_tick) + self._hologram.start_intro() + + def hide_panel(self) -> None: + if self._hologram.enabled and self._tick_id is not None: + self._hologram.start_outro(self._finish_hide) + else: + self._finish_hide() + + def _finish_hide(self) -> None: + self.set_visible(False) + if self._tick_id is not None: + self.remove_tick_callback(self._tick_id) + self._tick_id = None + self._last_tick = None + + def toggle(self) -> None: + if self.get_visible(): + self.hide_panel() + else: + self.show_panel() + + # -- hologram tick -------------------------------------------------------------- + def _on_tick(self, _widget, frame_clock) -> bool: + now = frame_clock.get_frame_time() / 1_000_000 + dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick) + self._last_tick = now + self._hologram.tick(dt) + return True + + def _on_key(self, _c, keyval, _kc, _state) -> bool: + if keyval == Gdk.KEY_Escape: + self.hide_panel() + return True + return False + + # -- history model ---------------------------------------------------------- + def _on_list_result(self, entries: list[dict]) -> None: + self._clear_list() + self._order = [] + for entry in entries: # already newest-first from History1.List + self._add_entry(entry, prepend=False) + self._rebuild_empty_state() + + def _on_history_added(self, entry: dict) -> None: + self._add_entry(entry, prepend=True) + + def _on_history_removed(self, nid: int) -> None: + self._drop_entry(nid) + + def _on_history_cleared(self) -> None: + self._clear_list() + self._order = [] + self._rebuild_empty_state() + + def _add_entry(self, entry: dict, prepend: bool) -> None: + nid = int(entry["id"]) + if nid in self._rows: # replace in place (shouldn't normally happen) + self._drop_entry(nid) + row = self._build_row(entry) + self._rows[nid] = row + if prepend: + self._order.insert(0, nid) + self._list.prepend(row) + else: + self._order.append(nid) + self._list.append(row) + self._rebuild_empty_state() + + def _drop_entry(self, nid: int) -> None: + row = self._rows.pop(nid, None) + if row is not None: + self._list.remove(row) + if nid in self._order: + self._order.remove(nid) + self._rebuild_empty_state() + + def _clear_list(self) -> None: + child = self._list.get_first_child() + while child: + nxt = child.get_next_sibling() + self._list.remove(child) + child = nxt + self._rows = {} + + def _rebuild_empty_state(self) -> None: + empty = not self._order + if empty and self._empty_label.get_parent() is None: + self._list.append(self._empty_label) + elif not empty and self._empty_label.get_parent() is not None: + self._list.remove(self._empty_label) + + # -- row building ------------------------------------------------------------- + def _build_row(self, entry: dict) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + row.add_css_class("tx-row") + + img = self._build_icon(entry.get("icon") or "") + if img is not None: + row.append(img) + + textcol = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + textcol.set_hexpand(True) + textcol.set_valign(Gtk.Align.CENTER) + + app_name = entry.get("app_name") or "" + if app_name: + app_lbl = Gtk.Label(label=app_name.upper(), xalign=0.0) + app_lbl.add_css_class("tx-row-app") + textcol.append(app_lbl) + + summary = entry.get("summary") or "" + if summary: + sum_lbl = Gtk.Label(label=summary, xalign=0.0) + sum_lbl.add_css_class("tx-row-summary") + sum_lbl.set_wrap(True) + sum_lbl.set_wrap_mode(2) # WORD_CHAR, same convention as beacon/notification.py + sum_lbl.set_max_width_chars(28) + textcol.append(sum_lbl) + + body = entry.get("body") or "" + if body: + body_lbl = Gtk.Label(label=body, xalign=0.0, ellipsize=3, lines=3) + body_lbl.add_css_class("tx-row-body") + body_lbl.set_wrap(True) + body_lbl.set_wrap_mode(2) + body_lbl.set_max_width_chars(32) + textcol.append(body_lbl) + + row.append(textcol) + + nid = int(entry["id"]) + close = Gtk.Button(label="✕") + close.add_css_class("tx-row-close") + close.set_valign(Gtk.Align.START) + close.set_tooltip_text("Remove from history") + close.connect("clicked", lambda *_a, i=nid: self._client.remove_async(i)) + row.append(close) + + return row + + def _build_icon(self, icon: str) -> Gtk.Image | None: + if not icon: + return None + if icon.startswith("file://"): + icon = icon[len("file://"):] + img = Gtk.Image.new_from_file(icon) if icon.startswith("/") \ + else Gtk.Image.new_from_icon_name(icon) + img.add_css_class("tx-row-icon") + img.set_pixel_size(32) + img.set_valign(Gtk.Align.START) + return img