feat(hyprlua): add astal-menu popup, replacing nwg-dock + nwg-drawer
A touch-friendly GTK4 popup control centre (Python + PyGObject, wlr-layer-shell)
launched from the EWW bar or Super+D. Replaces nwg-dock and nwg-drawer.
Layout: a floating, drawn-bordered panel (anchored top-centre so it never blocks
the rest of the screen) with a 2x2 quad grid over a full-width app drawer, plus a
top taskbar. Any quad expands over the others; the drawer expands to the bottom.
Modules:
- Location — static OSM map (backend/staticmap.py) on IP geolocation
(libshumate won't paint tiles in this env; shumate-demo is blank too)
- Weather — wttr.in ANSI art rendered via an SGR parser into a TextView
- Bluetooth — AstalBluetooth: discovery/connect/disconnect + local history
- Network — AstalNetwork wifi + nmcli/ip/ss for IP, DHCP/manual, routes, ports,
public IP; per-feature toggles
- Taskbar — open windows from hyprctl, grouped by app with pop-out per instance
- Favorites — pinned apps (right-click/long-press to pin) atop the app drawer
Notes:
- Frontend is Python/GTK4, not Lua: lgi/Astal-Lua are GTK3-only.
- Module borders are drawn with a Cairo overlay (lib/border.py); this GTK build's
renderer skips CSS border/background on plain container widgets.
- Consumes Astal GObject libs (io/apps/network/bluetooth) via introspection.
Wiring: autostart + Super+D/Super+Shift+A binds, EWW launcher button in all three
bar variants, apply-theme.sh (_colors.css), installer packages, config-updater.
drawer.sh moved to scripts/deprecated/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/astal-menu
parent
654606df86
commit
3d75182b54
|
|
@ -39,9 +39,8 @@ USER_FILES=(
|
||||||
"desktopenvs/hyprland/waybar/style.css|$HOME/.config/waybar/style.css"
|
"desktopenvs/hyprland/waybar/style.css|$HOME/.config/waybar/style.css"
|
||||||
"desktopenvs/hyprland/wofi/style.css|$HOME/.config/wofi/style.css"
|
"desktopenvs/hyprland/wofi/style.css|$HOME/.config/wofi/style.css"
|
||||||
"desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.css"
|
"desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.css"
|
||||||
"desktopenvs/hyprland/nwg-dock-hyprland/style.css|$HOME/.config/nwg-dock-hyprland/style.css"
|
|
||||||
"desktopenvs/hyprland/nwg-drawer/drawer.css|$HOME/.config/nwg-drawer/drawer.css"
|
|
||||||
"desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css"
|
"desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css"
|
||||||
|
"desktopenvs/hyprlua/astal-menu/style/_colors.css|$HOME/.config/astal-menu/style/_colors.css"
|
||||||
"desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml"
|
"desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml"
|
||||||
"desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh"
|
"desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh"
|
||||||
"desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini"
|
"desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# astal-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/astal-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
|
||||||
|
|
||||||
|
`astal-menu-start.sh` must `LD_PRELOAD` libgtk4-layer-shell (it loads after
|
||||||
|
libwayland under PyGObject otherwise).
|
||||||
|
|
||||||
|
## Adding a module
|
||||||
|
|
||||||
|
1. Create `modules/<name>.py` exposing a top-level `SPEC = ModuleSpec(...)` whose
|
||||||
|
`build(ctx)` returns a `ModuleInstance(compact=…, expanded=…, …)`.
|
||||||
|
- `compact` shows in the 2×2 cell; a distinct `expanded` widget enables the
|
||||||
|
expand button (they must be separate instances — GTK widgets have one parent).
|
||||||
|
- Declare per-feature toggles via `features=[Feature("id", "Label", default)]`;
|
||||||
|
read them with `ctx.feature("id")`. A disabled quad never calls `build()`.
|
||||||
|
- Shared state (network, bluetooth, location) is on `ctx.services`.
|
||||||
|
2. Append its `SPEC` to `ALL_SPECS` in `registry.py`. Nothing else changes.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
main.py app + single-instance IPC window.py layer-shell + letterbox
|
||||||
|
registry.py module list (extension seam) settings.py JSON toggles/order
|
||||||
|
module_base.py ModuleSpec / ModuleInstance / ModuleContext
|
||||||
|
appservices.py shared Astal services + location
|
||||||
|
ui/ quadcard, quadgrid, appdrawer
|
||||||
|
modules/ location, weather, bluetooth, network
|
||||||
|
services/ location (geolocation singleton)
|
||||||
|
lib/ ansi (SGR→TextTag), proc (async subprocess)
|
||||||
|
backend/ geolocate.py, weather.sh, network.sh
|
||||||
|
style/ _colors.css, style.css
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Lazily-built shared services handed to every module via ModuleContext.
|
||||||
|
|
||||||
|
Astal's GObject service libraries (AstalNetwork, AstalBluetooth) are consumed here
|
||||||
|
through GObject-introspection; modules connect to their `notify::` signals instead
|
||||||
|
of polling. The location service is our own singleton.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("AstalNetwork", "0.1")
|
||||||
|
gi.require_version("AstalBluetooth", "0.1")
|
||||||
|
from gi.repository import AstalBluetooth, AstalNetwork # noqa: E402
|
||||||
|
|
||||||
|
from services.location import get_location_service
|
||||||
|
|
||||||
|
|
||||||
|
class Services:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._network = None
|
||||||
|
self._bluetooth = None
|
||||||
|
self.location = get_location_service()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def network(self):
|
||||||
|
if self._network is None:
|
||||||
|
self._network = AstalNetwork.Network.get_default()
|
||||||
|
return self._network
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bluetooth(self):
|
||||||
|
if self._bluetooth is None:
|
||||||
|
self._bluetooth = AstalBluetooth.Bluetooth.get_default()
|
||||||
|
return self._bluetooth
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""IP geolocation with a provider fallback chain, cached with a TTL.
|
||||||
|
|
||||||
|
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 json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astal-menu" / "location.json"
|
||||||
|
TTL = 1800 # seconds
|
||||||
|
|
||||||
|
PROVIDERS = [
|
||||||
|
("ip-api", "http://ip-api.com/json/?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", "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", "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 _fetch(url: str) -> dict:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "astal-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
|
||||||
|
for name, url, parse in PROVIDERS:
|
||||||
|
try:
|
||||||
|
data = parse(_fetch(url))
|
||||||
|
data["source"] = name
|
||||||
|
if data.get("lat") is None or data.get("lon") is None:
|
||||||
|
raise ValueError("no coordinates")
|
||||||
|
_store(data)
|
||||||
|
print(json.dumps(data))
|
||||||
|
return 0
|
||||||
|
except Exception as exc: # noqa: BLE001 — try the next provider
|
||||||
|
print(f"{name}: {exc}", file=sys.stderr)
|
||||||
|
print("all geolocation providers failed", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Networking backend for the astal-menu Network quad.
|
||||||
|
# Each subcommand prints JSON (or plain text for pubip) to stdout. Wi-Fi listing and
|
||||||
|
# state come from AstalNetwork in the UI; this covers the rest (ip/routes/ports,
|
||||||
|
# public IP, wifi connect via nmcli, and the manual/DHCP switch).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
active_conn() {
|
||||||
|
# First active connection on a real device (wifi/ethernet).
|
||||||
|
nmcli -t -g NAME,TYPE,DEVICE connection show --active 2>/dev/null \
|
||||||
|
| awk -F: '$2 ~ /wireless|ethernet/ {print $1; exit}'
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-}"; shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
ip)
|
||||||
|
ip -j addr show 2>/dev/null || echo '[]'
|
||||||
|
;;
|
||||||
|
routes)
|
||||||
|
ip -j route show 2>/dev/null || echo '[]'
|
||||||
|
;;
|
||||||
|
ports)
|
||||||
|
# Listening TCP/UDP sockets -> JSON array.
|
||||||
|
ss -tulnH 2>/dev/null | awk '
|
||||||
|
BEGIN { print "["; sep="" }
|
||||||
|
{ printf "%s{\"proto\":\"%s\",\"local\":\"%s\"}", sep, $1, $5; sep="," }
|
||||||
|
END { print "]" }'
|
||||||
|
;;
|
||||||
|
pubip)
|
||||||
|
curl -sf --max-time 8 https://ifconfig.co 2>/dev/null \
|
||||||
|
|| curl -sf --max-time 8 https://api.ipify.org 2>/dev/null \
|
||||||
|
|| echo "unavailable"
|
||||||
|
;;
|
||||||
|
wifi-connect)
|
||||||
|
ssid="${1:-}"; pass="${2:-}"
|
||||||
|
if [[ -n "$pass" ]]; then
|
||||||
|
nmcli dev wifi connect "$ssid" password "$pass"
|
||||||
|
else
|
||||||
|
nmcli dev wifi connect "$ssid"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
wifi-disconnect)
|
||||||
|
con="$(active_conn)"
|
||||||
|
[[ -n "$con" ]] && nmcli connection down "$con"
|
||||||
|
;;
|
||||||
|
set-manual)
|
||||||
|
con="${4:-$(active_conn)}"; addr="${1:-}"; gw="${2:-}"; dns="${3:-}"
|
||||||
|
[[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; }
|
||||||
|
nmcli connection modify "$con" ipv4.method manual \
|
||||||
|
ipv4.addresses "$addr" ipv4.gateway "$gw" ipv4.dns "$dns"
|
||||||
|
nmcli connection up "$con"
|
||||||
|
;;
|
||||||
|
set-dhcp)
|
||||||
|
con="${1:-$(active_conn)}"
|
||||||
|
[[ -z "$con" ]] && { echo "no active connection" >&2; exit 1; }
|
||||||
|
nmcli connection modify "$con" ipv4.method auto
|
||||||
|
nmcli connection up "$con"
|
||||||
|
;;
|
||||||
|
active-conn)
|
||||||
|
active_conn
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: network.sh {ip|routes|ports|pubip|wifi-connect|wifi-disconnect|set-manual|set-dhcp|active-conn}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render a static OpenStreetMap 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 OSM raster tiles into
|
||||||
|
one PNG with Pillow and caches them.
|
||||||
|
|
||||||
|
staticmap.py <lat> <lon> <zoom> <width_px> <height_px> <out_path>
|
||||||
|
"""
|
||||||
|
|
||||||
|
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")) / "astal-menu" / "tiles"
|
||||||
|
UA = "astal-menu/1.0 (personal dotfiles)"
|
||||||
|
BG = (26, 26, 26)
|
||||||
|
ACCENT = (228, 0, 70)
|
||||||
|
|
||||||
|
|
||||||
|
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) -> Image.Image | None:
|
||||||
|
CACHE.mkdir(parents=True, exist_ok=True)
|
||||||
|
fp = CACHE / f"{z}_{x}_{y}.png"
|
||||||
|
if fp.exists():
|
||||||
|
try:
|
||||||
|
return Image.open(fp).convert("RGB")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
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 {z}/{x}/{y}: {exc}", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
fp.write_bytes(data)
|
||||||
|
return Image.open(io.BytesIO(data)).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def render(lat: float, lon: float, zoom: int, w: int, h: int, out: str) -> None:
|
||||||
|
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)
|
||||||
|
if tile is None:
|
||||||
|
continue
|
||||||
|
img.paste(tile, (int(tx * TILE - left), int(ty * TILE - top)))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
mx, my, r = w // 2, h // 2, 8
|
||||||
|
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
|
||||||
|
render(float(a[1]), float(a[2]), int(a[3]), int(a[4]), int(a[5]), a[6])
|
||||||
|
print(a[6])
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/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:-}"
|
||||||
|
opts="${2:-0}"
|
||||||
|
|
||||||
|
# URL-encode spaces in a city name.
|
||||||
|
loc="${loc// /+}"
|
||||||
|
|
||||||
|
url="https://wttr.in/${loc}?${opts}"
|
||||||
|
# -A curl makes wttr.in return terminal (ANSI) output regardless of the real UA.
|
||||||
|
curl -sf -A curl --max-time 10 "$url"
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Render ANSI/SGR-coloured terminal text (e.g. wttr.in) into a Gtk.TextView.
|
||||||
|
|
||||||
|
wttr.in returns real terminal escape sequences when curled. We parse the SGR
|
||||||
|
subset it uses (basic 8/16 colours, xterm-256, truecolor, bold, reset) and emit
|
||||||
|
Gtk.TextTags so the CLI art keeps its colours and block-glyph alignment. This is
|
||||||
|
deliberately reusable by any future "show me some CLI art" widget.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gdk, Gtk # noqa: E402
|
||||||
|
|
||||||
|
_SGR = re.compile(r"\x1b\[([0-9;]*)m")
|
||||||
|
|
||||||
|
# xterm 16-colour base palette (approx, tuned to the CyberQueer dark background).
|
||||||
|
_BASE16 = [
|
||||||
|
(0x00, 0x00, 0x00), (0xCC, 0x24, 0x24), (0x33, 0xCC, 0x33), (0xCC, 0xCC, 0x33),
|
||||||
|
(0x33, 0x66, 0xCC), (0xCC, 0x33, 0xCC), (0x33, 0xCC, 0xCC), (0xD6, 0xAB, 0xAB),
|
||||||
|
(0x66, 0x66, 0x66), (0xF5, 0x05, 0x05), (0x55, 0xFF, 0x55), (0xFF, 0xFF, 0x55),
|
||||||
|
(0x55, 0x88, 0xFF), (0xE4, 0x00, 0x46), (0x55, 0xFF, 0xFF), (0xFF, 0xFF, 0xFF),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _xterm256(n: int) -> tuple[int, int, int]:
|
||||||
|
if n < 16:
|
||||||
|
return _BASE16[n]
|
||||||
|
if n < 232: # 6x6x6 colour cube
|
||||||
|
n -= 16
|
||||||
|
r, g, b = n // 36, (n // 6) % 6, n % 6
|
||||||
|
conv = lambda c: 55 + c * 40 if c else 0
|
||||||
|
return conv(r), conv(g), conv(b)
|
||||||
|
v = 8 + (n - 232) * 10 # grayscale ramp
|
||||||
|
return v, v, v
|
||||||
|
|
||||||
|
|
||||||
|
def _rgba(rgb: tuple[int, int, int]) -> Gdk.RGBA:
|
||||||
|
c = Gdk.RGBA()
|
||||||
|
c.red, c.green, c.blue, c.alpha = rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1.0
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
class AnsiRenderer:
|
||||||
|
"""Owns a TextView and repaints it from ANSI text. Tags are cached by state."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.view = Gtk.TextView(
|
||||||
|
editable=False, cursor_visible=False, monospace=True,
|
||||||
|
wrap_mode=Gtk.WrapMode.NONE,
|
||||||
|
)
|
||||||
|
self.view.add_css_class("ansi-view")
|
||||||
|
self.buffer = self.view.get_buffer()
|
||||||
|
self._tag_cache: dict[tuple, Gtk.TextTag] = {}
|
||||||
|
|
||||||
|
def _tag(self, fg, bg, bold) -> Gtk.TextTag | None:
|
||||||
|
if fg is None and bg is None and not bold:
|
||||||
|
return None
|
||||||
|
key = (fg, bg, bold)
|
||||||
|
tag = self._tag_cache.get(key)
|
||||||
|
if tag is None:
|
||||||
|
tag = self.buffer.create_tag()
|
||||||
|
if fg is not None:
|
||||||
|
tag.set_property("foreground-rgba", _rgba(fg))
|
||||||
|
if bg is not None:
|
||||||
|
tag.set_property("background-rgba", _rgba(bg))
|
||||||
|
if bold:
|
||||||
|
tag.set_property("weight", 700)
|
||||||
|
self._tag_cache[key] = tag
|
||||||
|
return tag
|
||||||
|
|
||||||
|
def set_text(self, text: str) -> None:
|
||||||
|
self.buffer.set_text("", 0)
|
||||||
|
fg = bg = None
|
||||||
|
bold = False
|
||||||
|
pos = 0
|
||||||
|
for m in _SGR.finditer(text):
|
||||||
|
chunk = text[pos:m.start()]
|
||||||
|
if chunk:
|
||||||
|
self._insert(chunk, fg, bg, bold)
|
||||||
|
fg, bg, bold = self._apply(m.group(1), fg, bg, bold)
|
||||||
|
pos = m.end()
|
||||||
|
tail = text[pos:]
|
||||||
|
if tail:
|
||||||
|
self._insert(tail, fg, bg, bold)
|
||||||
|
|
||||||
|
def _insert(self, chunk, fg, bg, bold) -> None:
|
||||||
|
end = self.buffer.get_end_iter()
|
||||||
|
tag = self._tag(fg, bg, bold)
|
||||||
|
if tag is None:
|
||||||
|
self.buffer.insert(end, chunk)
|
||||||
|
else:
|
||||||
|
self.buffer.insert_with_tags(end, chunk, tag)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply(params: str, fg, bg, bold):
|
||||||
|
codes = [int(x) if x else 0 for x in params.split(";")] if params else [0]
|
||||||
|
i = 0
|
||||||
|
while i < len(codes):
|
||||||
|
c = codes[i]
|
||||||
|
if c == 0:
|
||||||
|
fg = bg = None
|
||||||
|
bold = False
|
||||||
|
elif c == 1:
|
||||||
|
bold = True
|
||||||
|
elif c == 22:
|
||||||
|
bold = False
|
||||||
|
elif c == 39:
|
||||||
|
fg = None
|
||||||
|
elif c == 49:
|
||||||
|
bg = None
|
||||||
|
elif 30 <= c <= 37:
|
||||||
|
fg = _BASE16[c - 30]
|
||||||
|
elif 90 <= c <= 97:
|
||||||
|
fg = _BASE16[c - 90 + 8]
|
||||||
|
elif 40 <= c <= 47:
|
||||||
|
bg = _BASE16[c - 40]
|
||||||
|
elif 100 <= c <= 107:
|
||||||
|
bg = _BASE16[c - 100 + 8]
|
||||||
|
elif c in (38, 48):
|
||||||
|
target = "fg" if c == 38 else "bg"
|
||||||
|
if i + 1 < len(codes) and codes[i + 1] == 5:
|
||||||
|
val = _xterm256(codes[i + 2]) if i + 2 < len(codes) else None
|
||||||
|
i += 2
|
||||||
|
elif i + 1 < len(codes) and codes[i + 1] == 2:
|
||||||
|
val = tuple(codes[i + 2:i + 5]) if i + 4 < len(codes) else None
|
||||||
|
i += 4
|
||||||
|
else:
|
||||||
|
val = None
|
||||||
|
if target == "fg":
|
||||||
|
fg = val
|
||||||
|
else:
|
||||||
|
bg = val
|
||||||
|
i += 1
|
||||||
|
return fg, bg, bold
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""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 (matches @accent / COLOR_HIGHLIGHT in colors.conf).
|
||||||
|
ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
|
||||||
|
BG = (0x1A / 255, 0x1A / 255, 0x1A / 255)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_draw(border: int, radius: int, color, fill_bg: bool):
|
||||||
|
def draw(_area, cr, w, h, *_a) -> None:
|
||||||
|
inset = border / 2
|
||||||
|
_rounded_rect(cr, inset, inset, w - border, h - border, radius)
|
||||||
|
if fill_bg:
|
||||||
|
cr.set_source_rgb(*BG)
|
||||||
|
cr.fill_preserve()
|
||||||
|
cr.set_source_rgb(*color)
|
||||||
|
cr.set_line_width(border)
|
||||||
|
cr.stroke()
|
||||||
|
return draw
|
||||||
|
|
||||||
|
|
||||||
|
def bordered(child: Gtk.Widget, border: int = 3, radius: int = 16,
|
||||||
|
color=ACCENT, fill_bg: bool = False) -> Gtk.Overlay:
|
||||||
|
"""Wrap child in an overlay whose background is a drawn rounded border.
|
||||||
|
|
||||||
|
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))
|
||||||
|
overlay.set_child(area) # background: the border ring
|
||||||
|
child.set_margin_start(border + 4)
|
||||||
|
child.set_margin_end(border + 4)
|
||||||
|
child.set_margin_top(border + 4)
|
||||||
|
child.set_margin_bottom(border + 4)
|
||||||
|
overlay.add_overlay(child) # content on top
|
||||||
|
overlay.set_measure_overlay(child, True) # size the overlay to the content
|
||||||
|
return overlay
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""Subprocess helpers built on Gio so nothing blocks the GTK main loop.
|
||||||
|
|
||||||
|
The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command
|
||||||
|
prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on
|
||||||
|
failure. These helpers run such commands asynchronously and hand the parsed result
|
||||||
|
back on the main thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shlex
|
||||||
|
from typing import Callable, Sequence
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gio", "2.0")
|
||||||
|
from gi.repository import Gio, GLib # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _as_argv(cmd: Sequence[str] | str) -> list[str]:
|
||||||
|
return shlex.split(cmd) if isinstance(cmd, str) else list(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None:
|
||||||
|
"""Run cmd, call cb(ok, stdout, stderr) on the main thread when done."""
|
||||||
|
try:
|
||||||
|
proc = Gio.Subprocess.new(
|
||||||
|
_as_argv(cmd),
|
||||||
|
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
|
||||||
|
)
|
||||||
|
except GLib.Error as err:
|
||||||
|
GLib.idle_add(cb, False, "", str(err))
|
||||||
|
return
|
||||||
|
|
||||||
|
def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None:
|
||||||
|
try:
|
||||||
|
_, out, errout = p.communicate_utf8_finish(res)
|
||||||
|
except GLib.Error as err:
|
||||||
|
cb(False, "", str(err))
|
||||||
|
return
|
||||||
|
cb(p.get_successful(), out or "", errout or "")
|
||||||
|
|
||||||
|
proc.communicate_utf8_async(None, None, _done)
|
||||||
|
|
||||||
|
|
||||||
|
def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None:
|
||||||
|
"""Run cmd expecting JSON on stdout; call cb(ok, data)."""
|
||||||
|
|
||||||
|
def _text(ok: bool, out: str, err: str) -> None:
|
||||||
|
if not ok or not out.strip():
|
||||||
|
cb(False, err.strip() or "no output")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cb(True, json.loads(out))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
cb(False, f"bad json: {exc}")
|
||||||
|
|
||||||
|
run_text(cmd, _text)
|
||||||
|
|
||||||
|
|
||||||
|
class Poller:
|
||||||
|
"""Repeatedly run a command (or callable) on an interval, main-thread callback.
|
||||||
|
|
||||||
|
Used for lightweight state that has no change signal (open ports, public IP,
|
||||||
|
weather refresh). Modules that back onto an Astal GObject service should prefer
|
||||||
|
connecting to that service's `notify::` signals instead of polling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False):
|
||||||
|
self.interval_s = interval_s
|
||||||
|
self.cmd = cmd
|
||||||
|
self.cb = cb
|
||||||
|
self.json_mode = json_mode
|
||||||
|
self._source_id: int | None = None
|
||||||
|
self._stopped = False
|
||||||
|
|
||||||
|
def start(self) -> "Poller":
|
||||||
|
self._stopped = False
|
||||||
|
self._tick()
|
||||||
|
self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _tick(self) -> bool:
|
||||||
|
if self._stopped:
|
||||||
|
return GLib.SOURCE_REMOVE
|
||||||
|
if self.json_mode:
|
||||||
|
run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data))
|
||||||
|
else:
|
||||||
|
run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err))
|
||||||
|
return GLib.SOURCE_CONTINUE
|
||||||
|
|
||||||
|
def refresh_now(self) -> None:
|
||||||
|
self._tick()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stopped = True
|
||||||
|
if self._source_id is not None:
|
||||||
|
GLib.source_remove(self._source_id)
|
||||||
|
self._source_id = None
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""astal-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 sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 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.hold() # stay alive with no visible window
|
||||||
|
|
||||||
|
def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int:
|
||||||
|
args = cmdline.get_arguments()[1:]
|
||||||
|
# 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 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("astal-menu")
|
||||||
|
return AstalMenuApp().run(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""The contract every quad module implements.
|
||||||
|
|
||||||
|
Adding a module = drop a file in modules/ that defines a top-level `SPEC`
|
||||||
|
(a ModuleSpec), then append its import to registry.py. Nothing else needs to
|
||||||
|
change: enable/disable, feature toggles, the card chrome, and the expand/collapse
|
||||||
|
plumbing are all provided generically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Feature:
|
||||||
|
"""A per-quad on/off switch surfaced in the card's settings popover."""
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
default: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModuleInstance:
|
||||||
|
"""What a module's build() returns."""
|
||||||
|
compact: Gtk.Widget # shown in the 2x2 cell
|
||||||
|
expanded: Optional[Gtk.Widget] = None # shown when the quad is expanded
|
||||||
|
scroll_expanded: bool = True # wrap the expanded view in a scroller
|
||||||
|
on_show: Optional[Callable[[], None]] = None
|
||||||
|
on_hide: Optional[Callable[[], None]] = None
|
||||||
|
destroy: Optional[Callable[[], None]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModuleSpec:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
icon: str # nerd-font glyph
|
||||||
|
build: Callable[["ModuleContext"], ModuleInstance]
|
||||||
|
default_enabled: bool = True
|
||||||
|
features: list[Feature] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleContext:
|
||||||
|
"""Handed to a module's build(). Scopes settings to the module and exposes the
|
||||||
|
expand/collapse requests so a module can drive the layout without knowing it."""
|
||||||
|
|
||||||
|
def __init__(self, spec: ModuleSpec, settings, services,
|
||||||
|
request_expand: Callable[[str], None],
|
||||||
|
request_collapse: Callable[[], None]):
|
||||||
|
self.spec = spec
|
||||||
|
self.settings = settings
|
||||||
|
self.services = services
|
||||||
|
self._request_expand = request_expand
|
||||||
|
self._request_collapse = request_collapse
|
||||||
|
|
||||||
|
def expand(self) -> None:
|
||||||
|
self._request_expand(self.spec.id)
|
||||||
|
|
||||||
|
def collapse(self) -> None:
|
||||||
|
self._request_collapse()
|
||||||
|
|
||||||
|
# feature toggles, scoped to this module
|
||||||
|
def feature(self, feature_id: str, default: bool = True) -> bool:
|
||||||
|
return self.settings.feature(self.spec.id, feature_id, default)
|
||||||
|
|
||||||
|
def on_settings_changed(self, cb: Callable[[], None]) -> None:
|
||||||
|
self.settings.subscribe(cb)
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
"""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/astal-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 Gtk # noqa: E402
|
||||||
|
|
||||||
|
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.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()))
|
||||||
|
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 ad:
|
||||||
|
ad.set_powered(value)
|
||||||
|
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 Bluetooth adapter"))
|
||||||
|
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._list.append(self._device_row(dev))
|
||||||
|
|
||||||
|
if self.full and self.ctx.feature("history", True):
|
||||||
|
self._list.append(self._history_section())
|
||||||
|
|
||||||
|
def _device_row(self, dev) -> Gtk.Widget:
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||||
|
row.add_css_class("bt-row")
|
||||||
|
icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic")
|
||||||
|
row.append(icon)
|
||||||
|
name = dev.get_name() or dev.get_address() or "Unknown"
|
||||||
|
status = "connected" if dev.get_connected() else (
|
||||||
|
"connecting…" if dev.get_connecting() else
|
||||||
|
("paired" if dev.get_paired() else ""))
|
||||||
|
lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True)
|
||||||
|
if status:
|
||||||
|
lbl.set_tooltip_text(status)
|
||||||
|
row.append(lbl)
|
||||||
|
if status:
|
||||||
|
tag = Gtk.Label(label=status)
|
||||||
|
tag.add_css_class("bt-status")
|
||||||
|
row.append(tag)
|
||||||
|
|
||||||
|
btn = Gtk.Button()
|
||||||
|
btn.add_css_class("quad-action")
|
||||||
|
if dev.get_connected():
|
||||||
|
btn.set_label("Disconnect")
|
||||||
|
btn.connect("clicked", lambda *_: dev.disconnect_device(None, self._noop))
|
||||||
|
else:
|
||||||
|
btn.set_label("Connect")
|
||||||
|
btn.connect("clicked", lambda *_, d=dev: self._connect(d))
|
||||||
|
row.append(btn)
|
||||||
|
|
||||||
|
if dev.get_connected() and self.ctx.feature("history", True):
|
||||||
|
self._maybe_record(dev)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _connect(self, dev) -> None:
|
||||||
|
def done(d, res):
|
||||||
|
try:
|
||||||
|
d.connect_device_finish(res)
|
||||||
|
if self.ctx.feature("history", True):
|
||||||
|
self._maybe_record(d, force=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
dev.connect_device(None, done)
|
||||||
|
|
||||||
|
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:
|
||||||
|
compact = _BluetoothView(ctx, full=False)
|
||||||
|
expanded = _BluetoothView(ctx, full=True)
|
||||||
|
ctx.on_settings_changed(lambda: (compact._refresh(), expanded._refresh()))
|
||||||
|
return ModuleInstance(compact=compact, expanded=expanded)
|
||||||
|
|
||||||
|
|
||||||
|
SPEC = ModuleSpec(
|
||||||
|
id="bluetooth",
|
||||||
|
title="Bluetooth",
|
||||||
|
icon="", # nf-fa-bluetooth
|
||||||
|
build=build,
|
||||||
|
default_enabled=True,
|
||||||
|
features=[Feature("discovery", "Device discovery", True),
|
||||||
|
Feature("history", "Connection history", True)],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Location quad: a static OpenStreetMap image centred on the device's IP-geolocated
|
||||||
|
position, with a marker.
|
||||||
|
|
||||||
|
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. Consumes the
|
||||||
|
shared LocationService, which the Weather quad also uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import 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")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
self.ctx = ctx
|
||||||
|
self.zoom = zoom
|
||||||
|
self.size = size
|
||||||
|
self._out = str(CACHE_DIR / f"map_{tag}.png")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
ctx.services.location.subscribe(self._on_location)
|
||||||
|
|
||||||
|
def _on_location(self, data: dict) -> None:
|
||||||
|
lat, lon = data["lat"], data["lon"]
|
||||||
|
w, h = self.size
|
||||||
|
run_text([sys.executable, _SCRIPT, str(lat), str(lon), str(self.zoom),
|
||||||
|
str(w), str(h), self._out], self._on_rendered)
|
||||||
|
if self._info is not None:
|
||||||
|
city = data.get("city") or "Unknown"
|
||||||
|
country = data.get("country") or ""
|
||||||
|
self._info.set_text(f"{city}, {country} · {lat:.3f}, {lon:.3f}")
|
||||||
|
|
||||||
|
def _on_rendered(self, ok: bool, out: str, err: str) -> None:
|
||||||
|
if ok:
|
||||||
|
self.picture.set_filename(self._out)
|
||||||
|
|
||||||
|
|
||||||
|
def build(ctx: ModuleContext) -> ModuleInstance:
|
||||||
|
ctx.services.location.get() # kick off geolocation if not started
|
||||||
|
compact = _MapView(ctx, zoom=12, size=(640, 340), 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)],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
"""Network quad: Wi-Fi (via AstalNetwork) plus ip/routes/ports/public-IP and the
|
||||||
|
manual/DHCP switch (via backend/network.sh). Every section is an independently
|
||||||
|
toggle-able feature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk # noqa: E402
|
||||||
|
|
||||||
|
from lib.proc import run_json, run_text
|
||||||
|
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
|
||||||
|
from paths import BACKEND_DIR
|
||||||
|
|
||||||
|
_NET = str(BACKEND_DIR / "network.sh")
|
||||||
|
|
||||||
|
|
||||||
|
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 "—"
|
||||||
|
|
||||||
|
|
||||||
|
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()))
|
||||||
|
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 "—"))
|
||||||
|
|
||||||
|
|
||||||
|
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.net = ctx.services.network
|
||||||
|
|
||||||
|
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)
|
||||||
|
self._build_pages()
|
||||||
|
|
||||||
|
def _build_pages(self) -> None:
|
||||||
|
child = self.stack.get_first_child()
|
||||||
|
while child:
|
||||||
|
self.stack.remove(child)
|
||||||
|
child = self.stack.get_first_child()
|
||||||
|
f = self.ctx.feature
|
||||||
|
if f("wifi", True):
|
||||||
|
self.stack.add_titled(self._wifi_page(), "wifi", "Wi-Fi")
|
||||||
|
if f("ip_config", True):
|
||||||
|
self.stack.add_titled(self._ip_page(), "ip", "IP")
|
||||||
|
if f("routes", True):
|
||||||
|
self.stack.add_titled(self._list_page("routes", self._fmt_route), "routes", "Routes")
|
||||||
|
if f("ports", True):
|
||||||
|
self.stack.add_titled(self._list_page("ports", self._fmt_port), "ports", "Ports")
|
||||||
|
if f("public_ip", True):
|
||||||
|
self.stack.add_titled(self._pubip_page(), "pub", "Public IP")
|
||||||
|
|
||||||
|
# -- Wi-Fi -------------------------------------------------------------
|
||||||
|
def _wifi_page(self) -> Gtk.Widget:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
header = Gtk.CenterBox()
|
||||||
|
header.set_start_widget(Gtk.Label(label="Networks", xalign=0.0))
|
||||||
|
scan = Gtk.Button(label=" Scan")
|
||||||
|
scan.add_css_class("quad-action")
|
||||||
|
scan.connect("clicked", lambda *_: self._scan())
|
||||||
|
header.set_end_widget(scan)
|
||||||
|
box.append(header)
|
||||||
|
|
||||||
|
self._ap_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
|
scroller.set_child(self._ap_list)
|
||||||
|
box.append(scroller)
|
||||||
|
|
||||||
|
wifi = self.net.get_wifi() if self.net else None
|
||||||
|
if wifi:
|
||||||
|
wifi.connect("notify::access-points", lambda *_: self._refresh_aps())
|
||||||
|
wifi.connect("notify::active-access-point", lambda *_: self._refresh_aps())
|
||||||
|
self._refresh_aps()
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _scan(self) -> None:
|
||||||
|
wifi = self.net.get_wifi() if self.net else None
|
||||||
|
if wifi:
|
||||||
|
wifi.scan()
|
||||||
|
|
||||||
|
def _refresh_aps(self) -> None:
|
||||||
|
child = self._ap_list.get_first_child()
|
||||||
|
while child:
|
||||||
|
self._ap_list.remove(child)
|
||||||
|
child = self._ap_list.get_first_child()
|
||||||
|
wifi = self.net.get_wifi() if self.net else None
|
||||||
|
if not wifi:
|
||||||
|
self._ap_list.append(Gtk.Label(label="No Wi-Fi device"))
|
||||||
|
return
|
||||||
|
active = wifi.get_active_access_point()
|
||||||
|
seen = {}
|
||||||
|
for ap in wifi.get_access_points():
|
||||||
|
ssid = ap.get_ssid()
|
||||||
|
if not ssid:
|
||||||
|
continue
|
||||||
|
if ssid not in seen or ap.get_strength() > seen[ssid].get_strength():
|
||||||
|
seen[ssid] = ap
|
||||||
|
for ap in sorted(seen.values(), key=lambda a: -a.get_strength()):
|
||||||
|
is_active = active is not None and ap.get_ssid() == active.get_ssid()
|
||||||
|
self._ap_list.append(self._ap_row(ap, is_active))
|
||||||
|
|
||||||
|
def _ap_row(self, ap, is_active: bool) -> Gtk.Widget:
|
||||||
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||||
|
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||||
|
row.add_css_class("net-row")
|
||||||
|
row.append(Gtk.Image.new_from_icon_name(ap.get_icon_name() or "network-wireless-symbolic"))
|
||||||
|
lbl = Gtk.Label(label=ap.get_ssid(), xalign=0.0, hexpand=True)
|
||||||
|
row.append(lbl)
|
||||||
|
row.append(Gtk.Label(label=f"{ap.get_strength()}%"))
|
||||||
|
if ap.get_requires_password():
|
||||||
|
row.append(Gtk.Label(label="")) # lock glyph
|
||||||
|
|
||||||
|
btn = Gtk.Button(label="Disconnect" if is_active else "Connect")
|
||||||
|
btn.add_css_class("quad-action")
|
||||||
|
row.append(btn)
|
||||||
|
outer.append(row)
|
||||||
|
|
||||||
|
if is_active:
|
||||||
|
btn.connect("clicked", lambda *_: run_text([_NET, "wifi-disconnect"], self._ignore))
|
||||||
|
elif ap.get_requires_password():
|
||||||
|
reveal = Gtk.Revealer(transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN)
|
||||||
|
pw_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
|
entry = Gtk.PasswordEntry(show_peek_icon=True, hexpand=True)
|
||||||
|
go = Gtk.Button(label="Join")
|
||||||
|
go.add_css_class("quad-action")
|
||||||
|
pw_row.append(entry)
|
||||||
|
pw_row.append(go)
|
||||||
|
reveal.set_child(pw_row)
|
||||||
|
outer.append(reveal)
|
||||||
|
btn.connect("clicked", lambda *_: reveal.set_reveal_child(not reveal.get_reveal_child()))
|
||||||
|
go.connect("clicked", lambda *_, s=ap.get_ssid():
|
||||||
|
run_text([_NET, "wifi-connect", s, entry.get_text()], self._ignore))
|
||||||
|
else:
|
||||||
|
btn.connect("clicked", lambda *_, s=ap.get_ssid():
|
||||||
|
run_text([_NET, "wifi-connect", s], self._ignore))
|
||||||
|
return outer
|
||||||
|
|
||||||
|
# -- IP config ---------------------------------------------------------
|
||||||
|
def _ip_page(self) -> Gtk.Widget:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
|
self._ip_current = Gtk.Label(label="…", xalign=0.0)
|
||||||
|
self._ip_current.add_css_class("net-ip")
|
||||||
|
box.append(self._ip_current)
|
||||||
|
|
||||||
|
mode = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
mode.append(Gtk.Label(label="Manual"))
|
||||||
|
self._manual = Gtk.Switch()
|
||||||
|
self._manual.connect("state-set", self._on_mode)
|
||||||
|
mode.append(self._manual)
|
||||||
|
box.append(mode)
|
||||||
|
|
||||||
|
self._manual_fields = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||||
|
self._addr = self._field("Address/CIDR (e.g. 192.168.1.50/24)")
|
||||||
|
self._gw = self._field("Gateway")
|
||||||
|
self._dns = self._field("DNS")
|
||||||
|
for w in (self._addr, self._gw, self._dns):
|
||||||
|
self._manual_fields.append(w)
|
||||||
|
apply = Gtk.Button(label="Apply")
|
||||||
|
apply.add_css_class("enable-btn")
|
||||||
|
apply.connect("clicked", lambda *_: self._apply_manual())
|
||||||
|
self._manual_fields.append(apply)
|
||||||
|
self._manual_fields.set_sensitive(False)
|
||||||
|
box.append(self._manual_fields)
|
||||||
|
|
||||||
|
run_json([_NET, "ip"], lambda ok, d:
|
||||||
|
self._ip_current.set_text(_primary_ip(d) if ok else "—"))
|
||||||
|
return box
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _field(placeholder: str) -> Gtk.Entry:
|
||||||
|
e = Gtk.Entry(placeholder_text=placeholder)
|
||||||
|
e.add_css_class("net-entry")
|
||||||
|
return e
|
||||||
|
|
||||||
|
def _on_mode(self, _sw, manual: bool) -> bool:
|
||||||
|
self._manual_fields.set_sensitive(manual)
|
||||||
|
if not manual:
|
||||||
|
run_text([_NET, "set-dhcp"], self._ignore)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _apply_manual(self) -> None:
|
||||||
|
run_text([_NET, "set-manual", self._addr.get_text(),
|
||||||
|
self._gw.get_text(), self._dns.get_text()], self._ignore)
|
||||||
|
|
||||||
|
# -- generic list pages ------------------------------------------------
|
||||||
|
def _list_page(self, sub: str, fmt) -> Gtk.Widget:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||||
|
listing = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||||
|
scroller = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||||
|
scroller.set_child(listing)
|
||||||
|
refresh = Gtk.Button(label=" Refresh")
|
||||||
|
refresh.add_css_class("quad-action")
|
||||||
|
refresh.connect("clicked", lambda *_: self._load_list(sub, fmt, listing))
|
||||||
|
box.append(refresh)
|
||||||
|
box.append(scroller)
|
||||||
|
self._load_list(sub, fmt, listing)
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _load_list(self, sub: str, fmt, listing: Gtk.Box) -> None:
|
||||||
|
child = listing.get_first_child()
|
||||||
|
while child:
|
||||||
|
listing.remove(child)
|
||||||
|
child = listing.get_first_child()
|
||||||
|
|
||||||
|
def done(ok, data):
|
||||||
|
if not ok or not isinstance(data, list):
|
||||||
|
listing.append(Gtk.Label(label="unavailable", xalign=0.0))
|
||||||
|
return
|
||||||
|
for item in data:
|
||||||
|
listing.append(Gtk.Label(label=fmt(item), xalign=0.0,
|
||||||
|
selectable=True, wrap=True))
|
||||||
|
run_json([_NET, sub], done)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_route(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 ""
|
||||||
|
return f"{dst}{via}{dev}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt_port(p: dict) -> str:
|
||||||
|
return f"{p.get('proto', '?'):5} {p.get('local', '')}"
|
||||||
|
|
||||||
|
# -- public IP ---------------------------------------------------------
|
||||||
|
def _pubip_page(self) -> Gtk.Widget:
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8,
|
||||||
|
valign=Gtk.Align.CENTER)
|
||||||
|
label = Gtk.Label(label="—", selectable=True)
|
||||||
|
label.add_css_class("pubip")
|
||||||
|
btn = Gtk.Button(label="Query public IP")
|
||||||
|
btn.add_css_class("enable-btn")
|
||||||
|
btn.connect("clicked", lambda *_: run_text(
|
||||||
|
[_NET, "pubip"], lambda ok, out, err: label.set_text(out.strip() if ok else "unavailable")))
|
||||||
|
box.append(label)
|
||||||
|
box.append(btn)
|
||||||
|
return box
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ignore(*_a) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_settings_changed(self) -> None:
|
||||||
|
self._build_pages()
|
||||||
|
|
||||||
|
|
||||||
|
def build(ctx: ModuleContext) -> ModuleInstance:
|
||||||
|
compact = _Compact(ctx)
|
||||||
|
expanded = _Expanded(ctx)
|
||||||
|
ctx.on_settings_changed(expanded.on_settings_changed)
|
||||||
|
return ModuleInstance(compact=compact, expanded=expanded)
|
||||||
|
|
||||||
|
|
||||||
|
SPEC = ModuleSpec(
|
||||||
|
id="network",
|
||||||
|
title="Network",
|
||||||
|
icon="", # nf-md-lan
|
||||||
|
build=build,
|
||||||
|
default_enabled=True,
|
||||||
|
features=[Feature("wifi", "Wi-Fi", True),
|
||||||
|
Feature("ip_config", "IP / DHCP config", True),
|
||||||
|
Feature("routes", "Routes", True),
|
||||||
|
Feature("ports", "Open ports", True),
|
||||||
|
Feature("public_ip", "Public IP query", True)],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""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")
|
||||||
|
from gi.repository import Gtk # 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 = ""
|
||||||
|
|
||||||
|
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:
|
||||||
|
compact = _WeatherView(ctx, opts="0") # current conditions only
|
||||||
|
expanded = _WeatherView(ctx, opts="") # full 3-day forecast
|
||||||
|
ctx.on_settings_changed(lambda: (compact.refresh(), expanded.refresh()))
|
||||||
|
return ModuleInstance(compact=compact, expanded=expanded)
|
||||||
|
|
||||||
|
|
||||||
|
SPEC = ModuleSpec(
|
||||||
|
id="weather",
|
||||||
|
title="Weather",
|
||||||
|
icon="", # nf-weather
|
||||||
|
build=build,
|
||||||
|
features=[Feature("ascii_art", "CLI art", True)],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,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
|
||||||
|
BACKEND_DIR = BASE_DIR / "backend"
|
||||||
|
STYLE_DIR = BASE_DIR / "style"
|
||||||
|
|
||||||
|
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "astal-menu"
|
||||||
|
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astal-menu"
|
||||||
|
|
||||||
|
SETTINGS_FILE = CONFIG_DIR / "settings.json"
|
||||||
|
|
||||||
|
APP_ID = "eu.abdelbaki.astalmenu"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""The module registry — the single place that lists available quads in order.
|
||||||
|
|
||||||
|
To add a new module: create modules/<name>.py exposing a top-level `SPEC`
|
||||||
|
(ModuleSpec), then import it and append its SPEC here. The first four *enabled*
|
||||||
|
specs fill the 2x2 grid; extra specs are kept for future paginated layouts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from modules import bluetooth, location, network, weather
|
||||||
|
from module_base import ModuleSpec
|
||||||
|
|
||||||
|
ALL_SPECS: list[ModuleSpec] = [
|
||||||
|
location.SPEC,
|
||||||
|
weather.SPEC,
|
||||||
|
bluetooth.SPEC,
|
||||||
|
network.SPEC,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_specs(settings) -> list[ModuleSpec]:
|
||||||
|
"""Return specs in the user's saved order, unknown/new ones appended."""
|
||||||
|
order = settings.order()
|
||||||
|
if not order:
|
||||||
|
return list(ALL_SPECS)
|
||||||
|
by_id = {s.id: s for s in ALL_SPECS}
|
||||||
|
result = [by_id[i] for i in order if i in by_id]
|
||||||
|
result += [s for s in ALL_SPECS if s.id not in order]
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""Shared geolocation singleton.
|
||||||
|
|
||||||
|
Runs backend/geolocate.py (IP geolocation, cached) exactly once and notifies
|
||||||
|
subscribers. Both the Location map and the Weather widget subscribe, so there is a
|
||||||
|
single source of truth for "where am I" and only one network lookup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from lib.proc import run_json
|
||||||
|
from paths import BACKEND_DIR
|
||||||
|
|
||||||
|
|
||||||
|
class LocationService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.data: Optional[dict] = None
|
||||||
|
self._subs: list[Callable[[dict], None]] = []
|
||||||
|
self._inflight = False
|
||||||
|
|
||||||
|
def subscribe(self, cb: Callable[[dict], None]) -> None:
|
||||||
|
self._subs.append(cb)
|
||||||
|
if self.data is not None:
|
||||||
|
cb(self.data)
|
||||||
|
|
||||||
|
def get(self) -> Optional[dict]:
|
||||||
|
if self.data is None and not self._inflight:
|
||||||
|
self.refresh()
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
if self._inflight:
|
||||||
|
return
|
||||||
|
self._inflight = True
|
||||||
|
argv = [sys.executable, str(BACKEND_DIR / "geolocate.py")]
|
||||||
|
run_json(argv, self._on_result)
|
||||||
|
|
||||||
|
def _on_result(self, ok: bool, data) -> None:
|
||||||
|
self._inflight = False
|
||||||
|
if ok and isinstance(data, dict) and "lat" in data:
|
||||||
|
self.data = data
|
||||||
|
for cb in list(self._subs):
|
||||||
|
cb(data)
|
||||||
|
|
||||||
|
|
||||||
|
_instance: LocationService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_location_service() -> LocationService:
|
||||||
|
global _instance
|
||||||
|
if _instance is None:
|
||||||
|
_instance = LocationService()
|
||||||
|
return _instance
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""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 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:
|
||||||
|
try:
|
||||||
|
self._data.update(json.loads(SETTINGS_FILE.read_text()))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
ensure_dirs()
|
||||||
|
SETTINGS_FILE.write_text(json.dumps(self._data, indent=2))
|
||||||
|
for cb in list(self._listeners):
|
||||||
|
cb()
|
||||||
|
|
||||||
|
def subscribe(self, cb: Callable[[], None]) -> None:
|
||||||
|
self._listeners.append(cb)
|
||||||
|
|
||||||
|
# -- quad enable/disable ----------------------------------------------
|
||||||
|
def quad_enabled(self, module_id: str, default: bool = True) -> bool:
|
||||||
|
return bool(self._data["quads"].get(module_id, default))
|
||||||
|
|
||||||
|
def set_quad_enabled(self, module_id: str, value: bool) -> None:
|
||||||
|
self._data["quads"][module_id] = bool(value)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# -- per-module feature toggles ---------------------------------------
|
||||||
|
def feature(self, module_id: str, feature_id: str, default: bool = True) -> bool:
|
||||||
|
return bool(self._data["features"].get(module_id, {}).get(feature_id, default))
|
||||||
|
|
||||||
|
def set_feature(self, module_id: str, feature_id: str, value: bool) -> None:
|
||||||
|
self._data["features"].setdefault(module_id, {})[feature_id] = bool(value)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# -- favorites ---------------------------------------------------------
|
||||||
|
def favorites(self) -> list[str]:
|
||||||
|
return list(self._data.get("favorites", []))
|
||||||
|
|
||||||
|
def is_favorite(self, entry: str) -> bool:
|
||||||
|
return entry in self._data.get("favorites", [])
|
||||||
|
|
||||||
|
def toggle_favorite(self, entry: str) -> None:
|
||||||
|
if not entry:
|
||||||
|
return
|
||||||
|
favs = self._data.setdefault("favorites", [])
|
||||||
|
if entry in favs:
|
||||||
|
favs.remove(entry)
|
||||||
|
else:
|
||||||
|
favs.append(entry)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# -- ordering ----------------------------------------------------------
|
||||||
|
def order(self) -> list[str] | None:
|
||||||
|
return self._data.get("order")
|
||||||
|
|
||||||
|
def set_order(self, order: list[str]) -> None:
|
||||||
|
self._data["order"] = order
|
||||||
|
self.save()
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the
|
||||||
|
* hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the
|
||||||
|
* sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */
|
||||||
|
@define-color text #D6ABAB;
|
||||||
|
@define-color bg #1A1A1A;
|
||||||
|
@define-color accent #E40046;
|
||||||
|
@define-color violet #5018DD;
|
||||||
|
@define-color danger #F50505;
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
/* astal-menu — CyberQueer 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. */
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: "Agave Nerd Font Mono", monospace;
|
||||||
|
font-size: 13pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- window / letterbox --------------------------------------------- */
|
||||||
|
.menu-window,
|
||||||
|
#menu-window {
|
||||||
|
background: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quad-card { min-height: 190px; }
|
||||||
|
.section-title { color: @text; font-weight: bold; opacity: 0.85; }
|
||||||
|
|
||||||
|
.quad-card,
|
||||||
|
.quad-expanded,
|
||||||
|
.appdrawer,
|
||||||
|
.taskbar,
|
||||||
|
.favorites {
|
||||||
|
background: @bg;
|
||||||
|
border: 3px solid @accent;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 3px; /* keep the border off the grid/overlay clip edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
.quad-header,
|
||||||
|
.appdrawer-header,
|
||||||
|
.expanded-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quad-title { color: @text; font-weight: bold; }
|
||||||
|
.quad-icon { color: @accent; font-size: 15pt; }
|
||||||
|
|
||||||
|
.quad-body { color: @text; }
|
||||||
|
|
||||||
|
/* pill action buttons */
|
||||||
|
.quad-action,
|
||||||
|
.enable-btn {
|
||||||
|
color: @text;
|
||||||
|
background: @bg;
|
||||||
|
border: 3px solid @violet;
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
min-height: 32px;
|
||||||
|
min-width: 32px;
|
||||||
|
}
|
||||||
|
.quad-action:hover,
|
||||||
|
.enable-btn:hover { border-color: @accent; color: @accent; }
|
||||||
|
.quad-action:active,
|
||||||
|
button:checked.quad-action { background: @accent; color: @bg; border-color: @accent; }
|
||||||
|
|
||||||
|
.quad-disabled { color: @text; opacity: 0.7; }
|
||||||
|
.enable-btn { border-color: @accent; }
|
||||||
|
|
||||||
|
/* settings popover */
|
||||||
|
.quad-settings { background: @bg; 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: @bg;
|
||||||
|
}
|
||||||
|
.appdrawer-search:focus-within { border-color: @accent; }
|
||||||
|
|
||||||
|
.app-tile {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 10px 6px;
|
||||||
|
min-width: 92px;
|
||||||
|
}
|
||||||
|
.app-tile:hover { border-color: @accent; background: alpha(@violet, 0.18); }
|
||||||
|
.app-tile label { color: @text; font-size: 10pt; }
|
||||||
|
|
||||||
|
/* ---- favourites row (top of drawer) --------------------------------- */
|
||||||
|
.favorites-row { padding: 4px 0; }
|
||||||
|
.fav-tile {
|
||||||
|
background: @bg;
|
||||||
|
border: 2px solid @violet;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.fav-tile:hover { border-color: @accent; }
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.task-tile:hover { border-color: @accent; background: alpha(@violet, 0.18); }
|
||||||
|
.task-badge {
|
||||||
|
color: @bg; background: @accent;
|
||||||
|
border-radius: 10px; padding: 0 6px; font-size: 9pt;
|
||||||
|
}
|
||||||
|
.task-popover { background: @bg; 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; }
|
||||||
|
|
||||||
|
/* ---- location map --------------------------------------------------- */
|
||||||
|
.map-view { border-radius: 14px; }
|
||||||
|
.map-info { color: @text; padding: 6px 2px 0 2px; font-size: 11pt; }
|
||||||
|
.map-marker { color: @accent; }
|
||||||
|
|
||||||
|
/* ---- weather -------------------------------------------------------- */
|
||||||
|
.ansi-view,
|
||||||
|
.ansi-view text {
|
||||||
|
background: @bg;
|
||||||
|
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: 8px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
.bt-row:hover,
|
||||||
|
.net-row:hover { background: alpha(@violet, 0.15); }
|
||||||
|
.bt-status { color: @accent; font-size: 10pt; }
|
||||||
|
.bt-history label,
|
||||||
|
.net-ip { color: @text; opacity: 0.85; font-size: 11pt; }
|
||||||
|
|
||||||
|
.net-switcher { margin-bottom: 8px; }
|
||||||
|
.net-entry {
|
||||||
|
border: 2px solid @violet;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
color: @text;
|
||||||
|
background: @bg;
|
||||||
|
}
|
||||||
|
.net-entry:focus-within { border-color: @accent; }
|
||||||
|
.pubip { color: @accent; font-size: 15pt; }
|
||||||
|
|
||||||
|
/* switches / scrollbars pick up the accent */
|
||||||
|
switch:checked { background: @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: @bg; }
|
||||||
|
.module-frame > box { padding: 10px 12px; }
|
||||||
|
|
||||||
|
/* floating panel + close button */
|
||||||
|
.panel { padding: 2px; }
|
||||||
|
.close-btn {
|
||||||
|
color: @text; background: @bg;
|
||||||
|
border: 2px solid @accent; border-radius: 20px;
|
||||||
|
min-width: 34px; min-height: 34px; margin: 2px 4px;
|
||||||
|
}
|
||||||
|
.close-btn:hover { background: @accent; color: @bg; }
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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_APPLICATION
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""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, Gtk # noqa: E402
|
||||||
|
|
||||||
|
from ui.favorites import Favorites
|
||||||
|
|
||||||
|
_STRIP_HEIGHT = 190 # 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())
|
||||||
|
|
||||||
|
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="") # chevron up
|
||||||
|
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("") # chevron down
|
||||||
|
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("") # chevron up
|
||||||
|
|
||||||
|
def _toggle_expand(self) -> None:
|
||||||
|
self._expanded = not self._expanded
|
||||||
|
self._apply_mode()
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
btn.set_child(content)
|
||||||
|
btn.connect("clicked", lambda *_: self._launch(app))
|
||||||
|
# pin / unpin: right-click or long-press
|
||||||
|
rclick = Gtk.GestureClick(button=3)
|
||||||
|
rclick.connect("released", lambda *_a: self._toggle_fav(app))
|
||||||
|
btn.add_controller(rclick)
|
||||||
|
longpress = Gtk.GestureLongPress()
|
||||||
|
longpress.connect("pressed", lambda *_a: self._toggle_fav(app))
|
||||||
|
btn.add_controller(longpress)
|
||||||
|
return btn
|
||||||
|
|
||||||
|
def _toggle_fav(self, app) -> None:
|
||||||
|
self.settings.toggle_favorite(app.get_entry())
|
||||||
|
|
||||||
|
def _on_child_activated(self, _flow, child) -> None:
|
||||||
|
btn = child.get_child()
|
||||||
|
if btn and getattr(btn, "app", None):
|
||||||
|
self._launch(btn.app)
|
||||||
|
|
||||||
|
def _launch(self, app) -> None:
|
||||||
|
try:
|
||||||
|
app.launch()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._on_launch()
|
||||||
|
|
||||||
|
def on_show(self) -> None:
|
||||||
|
self._search.set_text("")
|
||||||
|
self._populate(self._sorted_all())
|
||||||
|
self.favorites.refresh()
|
||||||
|
self._search.grab_focus()
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
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 unpins
|
||||||
|
gesture = Gtk.GestureClick(button=3)
|
||||||
|
gesture.connect("released", lambda *_a: self.settings.toggle_favorite(app.get_entry()))
|
||||||
|
btn.add_controller(gesture)
|
||||||
|
return btn
|
||||||
|
|
||||||
|
def _launch(self, app) -> None:
|
||||||
|
try:
|
||||||
|
app.launch()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._on_launch()
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""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._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)
|
||||||
|
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()
|
||||||
|
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 _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 enablement flipped."""
|
||||||
|
enabled = self.settings.quad_enabled(self.spec.id, self.spec.default_enabled)
|
||||||
|
has_module = self.instance is not None
|
||||||
|
if enabled != has_module:
|
||||||
|
self._rebuild_body()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expanded_widget(self) -> Gtk.Widget | None:
|
||||||
|
return self.instance.expanded if self.instance else None
|
||||||
|
|
||||||
|
def on_show(self) -> None:
|
||||||
|
if self.instance and self.instance.on_show:
|
||||||
|
self.instance.on_show()
|
||||||
|
|
||||||
|
def on_hide(self) -> None:
|
||||||
|
if self.instance and self.instance.on_hide:
|
||||||
|
self.instance.on_hide()
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""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 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=14, row_spacing=14)
|
||||||
|
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), 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_reveal.set_child(bordered(self._expand_holder, radius=16, fill_bg=True))
|
||||||
|
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)
|
||||||
|
|
||||||
|
settings.subscribe(self._on_settings_changed)
|
||||||
|
|
||||||
|
# -- 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)
|
||||||
|
self.grid.add_css_class("dimmed")
|
||||||
|
card.on_show()
|
||||||
|
|
||||||
|
def request_collapse(self) -> None:
|
||||||
|
self._expand_reveal.set_reveal_child(False)
|
||||||
|
self._expand_reveal.set_visible(False)
|
||||||
|
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=" Back") # nf arrow
|
||||||
|
back.add_css_class("quad-action")
|
||||||
|
back.connect("clicked", lambda *_: self.request_collapse())
|
||||||
|
header.set_start_widget(back)
|
||||||
|
lbl = Gtk.Label(label=title)
|
||||||
|
lbl.add_css_class("quad-title")
|
||||||
|
header.set_center_widget(lbl)
|
||||||
|
return header
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expanded(self) -> bool:
|
||||||
|
return self._expanded_id is not None
|
||||||
|
|
||||||
|
# -- lifecycle ---------------------------------------------------------
|
||||||
|
def _on_settings_changed(self) -> None:
|
||||||
|
for card in self.cards.values():
|
||||||
|
card.on_settings_changed()
|
||||||
|
|
||||||
|
def on_show(self) -> None:
|
||||||
|
for card in self.cards.values():
|
||||||
|
card.on_show()
|
||||||
|
|
||||||
|
def on_hide(self) -> None:
|
||||||
|
if self.is_expanded:
|
||||||
|
self.request_collapse()
|
||||||
|
for card in self.cards.values():
|
||||||
|
card.on_hide()
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Full-width taskbar strip: jump to any open window.
|
||||||
|
|
||||||
|
Reads open windows from `hyprctl clients -j`, groups them by app (class). A group
|
||||||
|
with a single window focuses it directly; a group with several windows opens a
|
||||||
|
pop-out list so you can pick a specific instance. The strip scrolls sideways when
|
||||||
|
many apps are open. Focusing a window closes the menu.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
from lib.proc import run_json, run_text
|
||||||
|
|
||||||
|
|
||||||
|
class Taskbar(Gtk.Box):
|
||||||
|
def __init__(self, on_activate):
|
||||||
|
super().__init__(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.add_css_class("taskbar")
|
||||||
|
self._on_activate = on_activate
|
||||||
|
self._apps = AstalApps.Apps()
|
||||||
|
self._wm_index = self._build_wm_index()
|
||||||
|
|
||||||
|
header = Gtk.Label(label="Open windows", xalign=0.0)
|
||||||
|
header.add_css_class("section-title")
|
||||||
|
self.append(header)
|
||||||
|
|
||||||
|
self._row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||||
|
self._row.add_css_class("taskbar-row")
|
||||||
|
scroller = Gtk.ScrolledWindow(
|
||||||
|
vscrollbar_policy=Gtk.PolicyType.NEVER,
|
||||||
|
hscrollbar_policy=Gtk.PolicyType.AUTOMATIC)
|
||||||
|
scroller.set_child(self._row)
|
||||||
|
self.append(scroller)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# -- populate ----------------------------------------------------------
|
||||||
|
def refresh(self) -> None:
|
||||||
|
run_json(["hyprctl", "clients", "-j"], self._on_clients)
|
||||||
|
|
||||||
|
def _on_clients(self, ok: bool, data) -> None:
|
||||||
|
child = self._row.get_first_child()
|
||||||
|
while child:
|
||||||
|
self._row.remove(child)
|
||||||
|
child = self._row.get_first_child()
|
||||||
|
if not ok or not isinstance(data, list):
|
||||||
|
self._row.append(Gtk.Label(label="no window data"))
|
||||||
|
return
|
||||||
|
groups: dict[str, list] = {}
|
||||||
|
for w in data:
|
||||||
|
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
|
||||||
|
# multiple windows -> pop-out list of specific instances
|
||||||
|
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
|
||||||
|
|
||||||
|
def _focus(self, w) -> None:
|
||||||
|
addr = w.get("address")
|
||||||
|
if addr:
|
||||||
|
run_text(["hyprctl", "dispatch", "focuswindow", f"address:{addr}"],
|
||||||
|
lambda *_a: None)
|
||||||
|
self._on_activate()
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
from appservices import Services
|
||||||
|
from lib.border import bordered
|
||||||
|
from registry import ordered_specs
|
||||||
|
from ui.appdrawer import AppDrawer
|
||||||
|
from ui.quadgrid import QuadGrid
|
||||||
|
from ui.taskbar import Taskbar
|
||||||
|
|
||||||
|
PANEL_WIDTH_FRACTION = 0.5 # of the monitor width...
|
||||||
|
MAX_PANEL_WIDTH = 1100 # ...clamped to this
|
||||||
|
TOP_MARGIN = 46 # gap below the top bar
|
||||||
|
BOTTOM_MARGIN = 34 # gap from the screen bottom when expanded
|
||||||
|
|
||||||
|
|
||||||
|
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._init_layer_shell()
|
||||||
|
|
||||||
|
self.root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||||
|
self.root.set_name("panel-root")
|
||||||
|
self.root.add_css_class("panel")
|
||||||
|
|
||||||
|
# taskbar (full-width, top)
|
||||||
|
self.taskbar = Taskbar(on_activate=self.hide_menu)
|
||||||
|
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))
|
||||||
|
self.root.append(self.taskbar_reveal)
|
||||||
|
|
||||||
|
# quads
|
||||||
|
specs = list(ordered_specs(settings))
|
||||||
|
self.grid = QuadGrid(specs, settings, services)
|
||||||
|
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)
|
||||||
|
self.appdrawer_wrap.set_valign(Gtk.Align.FILL)
|
||||||
|
self.root.append(self.appdrawer_wrap)
|
||||||
|
|
||||||
|
overlay = Gtk.Overlay()
|
||||||
|
overlay.set_child(self.root)
|
||||||
|
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.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, "astal-menu")
|
||||||
|
# Anchor the top edge only: the surface is horizontally centred and its
|
||||||
|
# height follows the content (no opposite anchors = no full-screen fill).
|
||||||
|
LayerShell.set_anchor(self, LayerShell.Edge.TOP, True)
|
||||||
|
LayerShell.set_margin(self, LayerShell.Edge.TOP, TOP_MARGIN)
|
||||||
|
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.ON_DEMAND)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# -- appdrawer expansion ----------------------------------------------
|
||||||
|
def _on_appdrawer_expand(self, expanded: bool) -> None:
|
||||||
|
self._drawer_expanded = expanded
|
||||||
|
# Stretch to the bottom only while the drawer is expanded.
|
||||||
|
LayerShell.set_anchor(self, LayerShell.Edge.BOTTOM, expanded)
|
||||||
|
LayerShell.set_margin(self, LayerShell.Edge.BOTTOM, BOTTOM_MARGIN if expanded else 0)
|
||||||
|
# Hide (not just un-reveal) the taskbar + quads so they reserve zero space
|
||||||
|
# and the drawer fills the whole panel from the top.
|
||||||
|
self.quad_reveal.set_reveal_child(not expanded)
|
||||||
|
self.taskbar_reveal.set_reveal_child(not expanded)
|
||||||
|
self.quad_reveal.set_visible(not expanded)
|
||||||
|
self.taskbar_reveal.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)
|
||||||
|
|
||||||
|
def hide_menu(self) -> None:
|
||||||
|
self.grid.on_hide()
|
||||||
|
self.appdrawer.set_expanded(False)
|
||||||
|
self.set_visible(False)
|
||||||
|
|
||||||
|
def toggle(self, focus_appdrawer: bool = False) -> None:
|
||||||
|
if self.get_visible():
|
||||||
|
self.hide_menu()
|
||||||
|
else:
|
||||||
|
self.show_menu(focus_appdrawer)
|
||||||
|
|
||||||
|
def _on_key(self, _c, keyval, _kc, _state) -> bool:
|
||||||
|
if keyval == Gdk.KEY_Escape:
|
||||||
|
self.hide_menu()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
@ -11,14 +11,13 @@ SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprlua
|
||||||
|
|
||||||
# ── deployed as ~/.config/<name> ─────────────────────────────────────────────
|
# ── deployed as ~/.config/<name> ─────────────────────────────────────────────
|
||||||
config alacritty
|
config alacritty
|
||||||
|
config astal-menu
|
||||||
config btop
|
config btop
|
||||||
config dunst
|
config dunst
|
||||||
config gtk-3.0
|
config gtk-3.0
|
||||||
config hypr except usr
|
config hypr except usr
|
||||||
config kitty
|
config kitty
|
||||||
config mimeapps.list
|
config mimeapps.list
|
||||||
config nwg-dock-hyprland
|
|
||||||
config nwg-drawer
|
|
||||||
config nwg-panel
|
config nwg-panel
|
||||||
config scripts
|
config scripts
|
||||||
config ulauncher
|
config ulauncher
|
||||||
|
|
|
||||||
|
|
@ -102,3 +102,12 @@ menuitem {
|
||||||
menuitem:hover {
|
menuitem:hover {
|
||||||
color: #E40046;
|
color: #E40046;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// astal-menu launcher button — accent-coloured icon, highlight on hover
|
||||||
|
.menu-launcher {
|
||||||
|
color: #E40046;
|
||||||
|
font-size: 15pt;
|
||||||
|
}
|
||||||
|
.menu-launcher:hover {
|
||||||
|
color: #5018dd;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,10 @@
|
||||||
|
|
||||||
(defwidget winsworks [monitor]
|
(defwidget winsworks [monitor]
|
||||||
(box :orientation "h" :space-evenly false :halign "start"
|
(box :orientation "h" :space-evenly false :halign "start"
|
||||||
|
; astal-menu launcher — opens the popup control centre / app drawer
|
||||||
|
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh" {""})
|
||||||
(workspaceWidget :monitor monitor)
|
(workspaceWidget :monitor monitor)
|
||||||
(button :onclick "~/Dotfiles/desktopenvs/hyprland/scripts/drawer.sh" :class "music" {" ${activewindow}"})
|
(button :onclick "~/.config/scripts/menu-toggle.sh" :class "music" {" ${activewindow}"})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
(box :orientation "h" :space-evenly false :halign "start"
|
(box :orientation "h" :space-evenly false :halign "start"
|
||||||
(osk)
|
(osk)
|
||||||
(box :class "music" {"${battery}"})
|
(box :class "music" {"${battery}"})
|
||||||
(button :onclick "~/.config/scripts/drawer.sh" :class "icon-btn" :valign "center" :width 26 :height 26 {""})
|
(button :onclick "~/.config/scripts/menu-toggle.sh" :class "icon-btn" :valign "center" :width 26 :height 26 {""})
|
||||||
(metric :label " "
|
(metric :label " "
|
||||||
:value volume
|
:value volume
|
||||||
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
||||||
|
|
|
||||||
|
|
@ -102,3 +102,12 @@ menuitem {
|
||||||
menuitem:hover {
|
menuitem:hover {
|
||||||
color: #E40046;
|
color: #E40046;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// astal-menu launcher button — accent-coloured icon, highlight on hover
|
||||||
|
.menu-launcher {
|
||||||
|
color: #E40046;
|
||||||
|
font-size: 15pt;
|
||||||
|
}
|
||||||
|
.menu-launcher:hover {
|
||||||
|
color: #5018dd;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,14 @@
|
||||||
; :halign "start" — left-aligns the content within the centerbox left cell.
|
; :halign "start" — left-aligns the content within the centerbox left cell.
|
||||||
(defwidget winsworks [monitor]
|
(defwidget winsworks [monitor]
|
||||||
(box :orientation "h" :space-evenly false :halign "start"
|
(box :orientation "h" :space-evenly false :halign "start"
|
||||||
|
; astal-menu launcher — opens the popup control centre / app drawer
|
||||||
|
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh" {""})
|
||||||
; Battery percentage badge — styled as a pill with class "music"
|
; Battery percentage badge — styled as a pill with class "music"
|
||||||
(box :class "music" {"${battery}"})
|
(box :class "music" {"${battery}"})
|
||||||
; Workspace dots — one button per active workspace on this monitor
|
; Workspace dots — one button per active workspace on this monitor
|
||||||
(workspaceWidget :monitor monitor)
|
(workspaceWidget :monitor monitor)
|
||||||
; Active window title — clicking opens the application drawer
|
; Active window title — clicking opens the application drawer
|
||||||
(button :onclick "~/Dotfiles/desktopenvs/hyprland/scripts/drawer.sh" :class "music" {" ${activewindow}"})
|
(button :onclick "~/.config/scripts/menu-toggle.sh" :class "music" {" ${activewindow}"})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ hl.on("hyprland.start", function()
|
||||||
hl.exec_cmd("[workspace special:magic silent] kitty")
|
hl.exec_cmd("[workspace special:magic silent] kitty")
|
||||||
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
|
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
|
||||||
hl.exec_cmd("hyprpaper")
|
hl.exec_cmd("hyprpaper")
|
||||||
hl.exec_cmd("nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p right")
|
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/astal-menu-start.sh")
|
||||||
hl.exec_cmd("blueman-applet")
|
hl.exec_cmd("blueman-applet")
|
||||||
hl.exec_cmd("blueman-tray")
|
hl.exec_cmd("blueman-tray")
|
||||||
hl.exec_cmd("hypridle")
|
hl.exec_cmd("hypridle")
|
||||||
|
|
|
||||||
|
|
@ -209,15 +209,11 @@ hl.bind(mainMod .. " + SHIFT + ALT + k", hl.dsp.group.move_window("u"))
|
||||||
hl.bind(mainMod .. " + SHIFT + ALT + j", hl.dsp.group.move_window("d"))
|
hl.bind(mainMod .. " + SHIFT + ALT + j", hl.dsp.group.move_window("d"))
|
||||||
|
|
||||||
--------------------
|
--------------------
|
||||||
---- NWG-DOCK ------
|
---- ASTAL-MENU ----
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
hl.bind(mainMod .. " + SHIFT + W", hl.dsp.exec_cmd("killall nwg-dock-hyprland; nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p left"))
|
hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/menu-toggle.sh"), { release = true })
|
||||||
hl.bind(mainMod .. " + SHIFT + E", hl.dsp.exec_cmd("killall nwg-dock-hyprland; nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p right"))
|
hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/menu-toggle.sh appdrawer"))
|
||||||
hl.bind(mainMod .. " + SHIFT + S", hl.dsp.exec_cmd("killall nwg-dock-hyprland; nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p top"))
|
|
||||||
hl.bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd("killall nwg-dock-hyprland; nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p bottom"))
|
|
||||||
hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("killall nwg-dock-hyprland || nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p bottom"), { release = true })
|
|
||||||
hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/drawer.sh"))
|
|
||||||
|
|
||||||
--------------------
|
--------------------
|
||||||
---- SCREENSHOT ----
|
---- SCREENSHOT ----
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Resident launcher for the astal-menu GTK4 popup control centre.
|
||||||
|
#
|
||||||
|
# gtk4-layer-shell must be loaded before libwayland-client; when the shell is used
|
||||||
|
# through PyGObject that ordering isn't guaranteed, so we LD_PRELOAD it (the library
|
||||||
|
# itself documents this workaround). Starts the daemon hidden — it shows only when
|
||||||
|
# menu-toggle.sh forwards a --toggle/--show/--appdrawer verb.
|
||||||
|
|
||||||
|
APP="${HOME}/.config/astal-menu/main.py"
|
||||||
|
SO="$(ldconfig -p 2>/dev/null | awk '/libgtk4-layer-shell\.so/ {print $NF; exit}')"
|
||||||
|
if [[ -n "${SO:-}" ]]; then
|
||||||
|
export LD_PRELOAD="${SO}${LD_PRELOAD:+:${LD_PRELOAD}}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec python3 "$APP" "$@"
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Toggle the astal-menu popup (or open it to a section). Forwards a verb to the
|
||||||
|
# resident daemon over D-Bus; if the daemon isn't running yet, starts it first.
|
||||||
|
# (No `set -e`: a non-zero `grep`/`busctl` in the wait loop is expected and must
|
||||||
|
# not abort the script before it forwards the verb.)
|
||||||
|
#
|
||||||
|
# menu-toggle.sh -> --toggle
|
||||||
|
# menu-toggle.sh appdrawer -> open with the app drawer expanded
|
||||||
|
|
||||||
|
BUS="eu.abdelbaki.astalmenu"
|
||||||
|
APP="${HOME}/.config/astal-menu/main.py"
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
appdrawer) VERB="--appdrawer" ;;
|
||||||
|
show) VERB="--show" ;;
|
||||||
|
hide) VERB="--hide" ;;
|
||||||
|
*) VERB="--toggle" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; }
|
||||||
|
|
||||||
|
if ! registered; then
|
||||||
|
"${HOME}/.config/scripts/astal-menu-start.sh" >/dev/null 2>&1 &
|
||||||
|
for _ in $(seq 1 25); do
|
||||||
|
if registered; then break; fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If it still didn't register, this invocation just becomes the primary instance
|
||||||
|
# (Gio single-instance handles the routing either way).
|
||||||
|
exec python3 "$APP" "$VERB"
|
||||||
|
|
@ -71,11 +71,16 @@ HYPRLUA_PACKAGES=(
|
||||||
kitty # GPU-accelerated terminal (default in this setup)
|
kitty # GPU-accelerated terminal (default in this setup)
|
||||||
dunst # lightweight, scriptable notification daemon
|
dunst # lightweight, scriptable notification daemon
|
||||||
|
|
||||||
nwg-dock-hyprland # dock/taskbar with Hyprland workspace awareness
|
|
||||||
nwg-drawer # grid application drawer / launcher
|
|
||||||
nwg-menu # GTK application menu for the panel button
|
|
||||||
nwg-look # GTK/cursor/icon theme picker for wlroots sessions
|
nwg-look # GTK/cursor/icon theme picker for wlroots sessions
|
||||||
|
|
||||||
|
# astal-menu popup control centre (replaces nwg-dock + nwg-drawer)
|
||||||
|
python-gobject # PyGObject: GTK4 + Astal libs from Python
|
||||||
|
gtk4 # GTK4 toolkit (the menu's frontend)
|
||||||
|
gtk4-layer-shell # wlr-layer-shell for GTK4 (the popup surface)
|
||||||
|
libshumate # GTK4 OpenStreetMap widget (Location quad)
|
||||||
|
bluez-utils # bluetoothctl + bluez CLI (Bluetooth quad fallbacks)
|
||||||
|
iproute2 curl jq # ip/ss, HTTP fetches, JSON (Network/Weather backends)
|
||||||
|
|
||||||
# Build toolchain required for EWW (Rust) and AUR package compilation
|
# Build toolchain required for EWW (Rust) and AUR package compilation
|
||||||
python cmake meson cpio pkgconf ruby-pkg-config
|
python cmake meson cpio pkgconf ruby-pkg-config
|
||||||
|
|
||||||
|
|
@ -194,10 +199,13 @@ rustup default stable
|
||||||
# wofi-calc — inline calculator inside wofi
|
# wofi-calc — inline calculator inside wofi
|
||||||
# bri — brightness control helper for bar widgets
|
# bri — brightness control helper for bar widgets
|
||||||
# chamel — colour-palette / theme switcher
|
# chamel — colour-palette / theme switcher
|
||||||
|
# libastal-*-git — Astal GObject service libraries consumed by astal-menu
|
||||||
|
# (io, apps, network, bluetooth) via PyGObject
|
||||||
yay -Syu --answerdiff None --answerclean All --noconfirm --needed \
|
yay -Syu --answerdiff None --answerclean All --noconfirm --needed \
|
||||||
hyprland-workspaces vicinae-bin bluetuith wvkbd iwmenu pinta \
|
hyprland-workspaces vicinae-bin bluetuith wvkbd iwmenu pinta \
|
||||||
walker-bin ulauncher bzmenu udiskie \
|
walker-bin ulauncher bzmenu udiskie \
|
||||||
wofi-calc bri chamel
|
wofi-calc bri chamel \
|
||||||
|
libastal-io-git libastal-apps-git libastal-network-git libastal-bluetooth-git
|
||||||
# hyprmoncfg if custom script no worky
|
# hyprmoncfg if custom script no worky
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -335,7 +343,7 @@ log "Copying configs..."
|
||||||
|
|
||||||
# Deploy each config directory from the hyprlua Dotfiles source.
|
# Deploy each config directory from the hyprlua Dotfiles source.
|
||||||
# The wipe-then-copy pattern ensures no stale files from older installs remain.
|
# The wipe-then-copy pattern ensures no stale files from older installs remain.
|
||||||
CONFIGS=(kitty mimeapps.list vicinae walker ulauncher hypr xfce4 wofi dunst alacritty nwg-dock-hyprland nwg-drawer nwg-panel scripts btop gtk-3.0)
|
CONFIGS=(kitty mimeapps.list vicinae walker ulauncher hypr xfce4 wofi dunst alacritty astal-menu nwg-panel scripts btop gtk-3.0)
|
||||||
for cfg in "${CONFIGS[@]}"; do
|
for cfg in "${CONFIGS[@]}"; do
|
||||||
rm -rf ~/.config/"$cfg"
|
rm -rf ~/.config/"$cfg"
|
||||||
cp -r ~/Dotfiles/desktopenvs/hyprlua/"$cfg" ~/.config/
|
cp -r ~/Dotfiles/desktopenvs/hyprlua/"$cfg" ~/.config/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue