pulled in the current working state of cosmonaut shell components
commit
8ba33e5d36
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -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/<name>.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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 <con> ipv4/ipv6 method/addresses/gateway/dns
|
||||||
|
set-ip <con> ipv4|ipv6 <method> [addr_cidr] [gateway] [dns_space_sep]
|
||||||
|
routes kernel routing table (read-only)
|
||||||
|
vlans configured VLAN connections
|
||||||
|
vlan-add <parent_dev> <id> [name] create + bring up a VLAN
|
||||||
|
vlan-del <name> delete a VLAN connection
|
||||||
|
vlan-up <name> activate a VLAN connection
|
||||||
|
dns effective DNS servers per device
|
||||||
|
set-dns <con> ipv4|ipv6 <servers_space_sep> <yes|no ignore-auto>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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:]))
|
||||||
|
|
@ -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 <center_lat> <center_lon> <zoom> <w> <h> <out> \
|
||||||
|
[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])
|
||||||
|
|
@ -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 <TAB> NAME <TAB> USAGE_PCT <TAB> TEMP_C <TAB> 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)
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)],
|
||||||
|
)
|
||||||
|
|
@ -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)],
|
||||||
|
)
|
||||||
|
|
@ -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)],
|
||||||
|
)
|
||||||
|
|
@ -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"<span font_family='{_MONO}' size='{int(self._size * 1024)}'>{markup}</span>")
|
||||||
|
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"<span foreground='{fill_col}'>{'█' * filled}</span>"
|
||||||
|
f"<span foreground='{_DIM_EMPTY}'>{'░' * (self._bar_w - filled)}</span>")
|
||||||
|
meter = (f"<span foreground='{_ACCENT}'>-<</span>{bar}"
|
||||||
|
f"<span foreground='{_ACCENT}'>>-</span>")
|
||||||
|
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"<span foreground='{_ACCENT}'>-(</span> "
|
||||||
|
f"<span foreground='{temp_col}'>{temp_txt:>5}</span> "
|
||||||
|
f"<span foreground='{_ACCENT}'>)-</span>")
|
||||||
|
|
||||||
|
icon = _ICONS.get(r["cat"], "")
|
||||||
|
self._grid.attach(self._span(f"<span foreground='{_MAGENTA}'>{icon}</span>"), 0, i, 1, 1)
|
||||||
|
self._grid.attach(self._span(f"<span foreground='{_MAGENTA}'>{_esc(r['name'])}</span>"), 1, i, 1, 1)
|
||||||
|
self._grid.attach(self._span(meter), 2, i, 1, 1)
|
||||||
|
self._grid.attach(self._span(f"<span foreground='{fill_col}'>{usage:>3}%</span>", 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"<span foreground='{_DIM_TEXT}'>{_esc(r['detail'])}</span>"), 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,
|
||||||
|
)
|
||||||
|
|
@ -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 "<html" not in out.lower():
|
||||||
|
self.renderer.set_text(out.rstrip("\n"))
|
||||||
|
self._status.set_visible(False)
|
||||||
|
else:
|
||||||
|
self._status.set_text("Weather unavailable")
|
||||||
|
self._status.set_visible(True)
|
||||||
|
|
||||||
|
|
||||||
|
def build(ctx: ModuleContext) -> 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)],
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""The module registry — the single place that lists available quads in order.
|
||||||
|
|
||||||
|
To add a new module: create modules/<name>.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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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 (<b>/<i>/<u>/<a>…);
|
||||||
|
# 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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 = """
|
||||||
|
<node>
|
||||||
|
<interface name="org.freedesktop.Notifications">
|
||||||
|
<method name="GetCapabilities">
|
||||||
|
<arg type="as" name="capabilities" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<method name="Notify">
|
||||||
|
<arg type="s" name="app_name" direction="in"/>
|
||||||
|
<arg type="u" name="replaces_id" direction="in"/>
|
||||||
|
<arg type="s" name="app_icon" direction="in"/>
|
||||||
|
<arg type="s" name="summary" direction="in"/>
|
||||||
|
<arg type="s" name="body" direction="in"/>
|
||||||
|
<arg type="as" name="actions" direction="in"/>
|
||||||
|
<arg type="a{sv}" name="hints" direction="in"/>
|
||||||
|
<arg type="i" name="expire_timeout" direction="in"/>
|
||||||
|
<arg type="u" name="id" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<method name="CloseNotification">
|
||||||
|
<arg type="u" name="id" direction="in"/>
|
||||||
|
</method>
|
||||||
|
<method name="GetServerInformation">
|
||||||
|
<arg type="s" name="name" direction="out"/>
|
||||||
|
<arg type="s" name="vendor" direction="out"/>
|
||||||
|
<arg type="s" name="version" direction="out"/>
|
||||||
|
<arg type="s" name="spec_version" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<signal name="NotificationClosed">
|
||||||
|
<arg type="u" name="id"/>
|
||||||
|
<arg type="u" name="reason"/>
|
||||||
|
</signal>
|
||||||
|
<signal name="ActionInvoked">
|
||||||
|
<arg type="u" name="id"/>
|
||||||
|
<arg type="s" name="action_key"/>
|
||||||
|
</signal>
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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 = """
|
||||||
|
<node>
|
||||||
|
<interface name="eu.abdelbaki.beacon.History1">
|
||||||
|
<method name="List">
|
||||||
|
<arg type="aa{sv}" name="entries" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<method name="Get">
|
||||||
|
<arg type="u" name="id" direction="in"/>
|
||||||
|
<arg type="a{sv}" name="entry" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<method name="Pop">
|
||||||
|
<arg type="u" name="id" direction="in"/>
|
||||||
|
<arg type="u" name="new_id" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<method name="PopLatest">
|
||||||
|
<arg type="u" name="new_id" direction="out"/>
|
||||||
|
</method>
|
||||||
|
<method name="Remove">
|
||||||
|
<arg type="u" name="id" direction="in"/>
|
||||||
|
</method>
|
||||||
|
<method name="Clear">
|
||||||
|
</method>
|
||||||
|
<method name="InvokeAction">
|
||||||
|
<arg type="u" name="id" direction="in"/>
|
||||||
|
<arg type="s" name="action_key" direction="in"/>
|
||||||
|
</method>
|
||||||
|
<signal name="HistoryAdded">
|
||||||
|
<arg type="a{sv}" name="entry"/>
|
||||||
|
</signal>
|
||||||
|
<signal name="HistoryRemoved">
|
||||||
|
<arg type="u" name="id"/>
|
||||||
|
</signal>
|
||||||
|
<signal name="HistoryCleared">
|
||||||
|
<arg type="u" name="count"/>
|
||||||
|
</signal>
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CONTROLS_INTROSPECTION_XML = """
|
||||||
|
<node>
|
||||||
|
<interface name="eu.abdelbaki.beacon.Controls1">
|
||||||
|
<signal name="ControlChanged">
|
||||||
|
<arg type="u" name="id"/>
|
||||||
|
<arg type="s" name="control_id"/>
|
||||||
|
<arg type="v" name="value"/>
|
||||||
|
</signal>
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 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))
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -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 <pid>`, 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 <cmd>`
|
||||||
|
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"])
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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()
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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 .
|
||||||
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"""
|
||||||
|
<node>
|
||||||
|
<interface name="{_IFACE}">
|
||||||
|
<method name="RegisterStatusNotifierItem">
|
||||||
|
<arg name="service" type="s" direction="in"/>
|
||||||
|
</method>
|
||||||
|
<method name="RegisterStatusNotifierHost">
|
||||||
|
<arg name="service" type="s" direction="in"/>
|
||||||
|
</method>
|
||||||
|
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
|
||||||
|
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
|
||||||
|
<property name="ProtocolVersion" type="i" access="read"/>
|
||||||
|
<signal name="StatusNotifierItemRegistered"><arg name="service" type="s"/></signal>
|
||||||
|
<signal name="StatusNotifierItemUnregistered"><arg name="service" type="s"/></signal>
|
||||||
|
<signal name="StatusNotifierHostRegistered"/>
|
||||||
|
<signal name="StatusNotifierHostUnregistered"/>
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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, [])))
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -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))
|
||||||
|
|
@ -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/<name> 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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue