pulled in the current working state of cosmonaut shell components

main
Amir Alexander Abdelbaki 2026-07-24 14:57:23 +02:00
commit 8ba33e5d36
96 changed files with 12690 additions and 0 deletions

2
astro-menu/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

60
astro-menu/README.md Normal file
View File

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

35
astro-menu/appservices.py Normal file
View File

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

124
astro-menu/backend/geolocate.py Executable file
View File

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

66
astro-menu/backend/network.sh Executable file
View File

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

212
astro-menu/backend/nm.py Executable file
View File

@ -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:]))

108
astro-menu/backend/staticmap.py Executable file
View File

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

101
astro-menu/backend/sysmon.sh Executable file
View File

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

18
astro-menu/backend/weather.sh Executable file
View File

@ -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"

31
astro-menu/config.py Normal file
View File

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

138
astro-menu/lib/ansi.py Normal file
View File

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

100
astro-menu/lib/border.py Normal file
View File

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

266
astro-menu/lib/hologram.py Normal file
View File

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

100
astro-menu/lib/proc.py Normal file
View File

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

138
astro-menu/main.py Executable file
View File

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

73
astro-menu/module_base.py Normal file
View File

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

View File

@ -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)],
)

View File

@ -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)],
)

View File

@ -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)],
)

View File

@ -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}'>-&lt;</span>{bar}"
f"<span foreground='{_ACCENT}'>&gt;-</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,
)

View File

@ -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)],
)

28
astro-menu/paths.py Normal file
View File

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

29
astro-menu/registry.py Normal file
View File

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

View File

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

83
astro-menu/settings.py Normal file
View File

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

View File

@ -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;

368
astro-menu/style/style.css Normal file
View File

@ -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;
}

35
astro-menu/theme.py Normal file
View File

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

175
astro-menu/ui/appdrawer.py Normal file
View File

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

106
astro-menu/ui/favorites.py Normal file
View File

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

158
astro-menu/ui/quadcard.py Normal file
View File

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

191
astro-menu/ui/quadgrid.py Normal file
View File

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

133
astro-menu/ui/statsbar.py Normal file
View File

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

477
astro-menu/ui/taskbar.py Normal file
View File

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

243
astro-menu/window.py Normal file
View File

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

55
beacon/config.py Normal file
View File

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

81
beacon/controls.py Normal file
View File

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

View File

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

109
beacon/history.py Normal file
View File

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

278
beacon/lib/hologram.py Normal file
View File

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

92
beacon/main.py Normal file
View File

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

304
beacon/notification.py Normal file
View File

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

41
beacon/paths.py Normal file
View File

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

370
beacon/server.py Normal file
View File

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

8
beacon/style/_colors.css Normal file
View File

@ -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;

152
beacon/style/style.css Normal file
View File

@ -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; }

30
beacon/theme.py Normal file
View File

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

138
beacon/window.py Normal file
View File

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

2
horizon-dock/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

77
horizon-dock/apps.py Normal file
View File

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

37
horizon-dock/config.py Normal file
View File

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

660
horizon-dock/dock.py Normal file
View File

@ -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 37 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()

View File

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

95
horizon-dock/main.py Normal file
View File

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

23
horizon-dock/paths.py Normal file
View File

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

View File

@ -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;

View File

@ -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;
}

30
horizon-dock/theme.py Normal file
View File

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

140
horizon-dock/tray.py Normal file
View File

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

27
horizon-dock/windows.py Normal file
View File

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

2
orbit-menu/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

69
orbit-menu/actions.py Normal file
View File

@ -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"])

34
orbit-menu/config.py Normal file
View File

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

63
orbit-menu/lib/proc.py Normal file
View File

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

163
orbit-menu/main.py Normal file
View File

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

182
orbit-menu/menu_tree.py Normal file
View File

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

1050
orbit-menu/orbit_menu.py Normal file

File diff suppressed because it is too large Load Diff

23
orbit-menu/paths.py Normal file
View File

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

View File

@ -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;

121
orbit-menu/style/style.css Normal file
View File

@ -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);
}

30
orbit-menu/theme.py Normal file
View File

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

8
pull-from-dots.sh Executable file
View File

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

2
station-bar/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

588
station-bar/bar.py Normal file
View File

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

54
station-bar/battery.py Normal file
View File

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

31
station-bar/config.py Normal file
View File

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

144
station-bar/hypr_ipc.py Normal file
View File

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

295
station-bar/lib/hologram.py Normal file
View File

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

145
station-bar/main.py Normal file
View File

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

22
station-bar/paths.py Normal file
View File

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

147
station-bar/sni_watcher.py Normal file
View File

@ -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, [])))

View File

@ -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;

164
station-bar/style/style.css Normal file
View File

@ -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);
}

31
station-bar/theme.py Normal file
View File

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

251
station-bar/tray.py Normal file
View File

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

49
station-bar/volume.py Normal file
View File

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

2
transmitter-panel/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

View File

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

View File

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

View File

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

92
transmitter-panel/main.py Normal file
View File

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

View File

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

View File

@ -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;

View File

@ -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; }

View File

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

266
transmitter-panel/window.py Normal file
View File

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