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/wofi/style.css|$HOME/.config/wofi/style.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/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/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh"
|
||||
"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> ─────────────────────────────────────────────
|
||||
config alacritty
|
||||
config astal-menu
|
||||
config btop
|
||||
config dunst
|
||||
config gtk-3.0
|
||||
config hypr except usr
|
||||
config kitty
|
||||
config mimeapps.list
|
||||
config nwg-dock-hyprland
|
||||
config nwg-drawer
|
||||
config nwg-panel
|
||||
config scripts
|
||||
config ulauncher
|
||||
|
|
|
|||
|
|
@ -102,3 +102,12 @@ menuitem {
|
|||
menuitem:hover {
|
||||
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]
|
||||
(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)
|
||||
(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"
|
||||
(osk)
|
||||
(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 " "
|
||||
:value volume
|
||||
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
||||
|
|
|
|||
|
|
@ -102,3 +102,12 @@ menuitem {
|
|||
menuitem:hover {
|
||||
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.
|
||||
(defwidget winsworks [monitor]
|
||||
(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"
|
||||
(box :class "music" {"${battery}"})
|
||||
; Workspace dots — one button per active workspace on this monitor
|
||||
(workspaceWidget :monitor monitor)
|
||||
; 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("hyprctl setcursor Nordzy-cursors-lefthand 50")
|
||||
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-tray")
|
||||
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"))
|
||||
|
||||
--------------------
|
||||
---- 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 .. " + 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 + 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"))
|
||||
hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/menu-toggle.sh"), { release = true })
|
||||
hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/menu-toggle.sh appdrawer"))
|
||||
|
||||
--------------------
|
||||
---- 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)
|
||||
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
|
||||
|
||||
# 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
|
||||
python cmake meson cpio pkgconf ruby-pkg-config
|
||||
|
||||
|
|
@ -194,10 +199,13 @@ rustup default stable
|
|||
# wofi-calc — inline calculator inside wofi
|
||||
# bri — brightness control helper for bar widgets
|
||||
# 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 \
|
||||
hyprland-workspaces vicinae-bin bluetuith wvkbd iwmenu pinta \
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -335,7 +343,7 @@ log "Copying configs..."
|
|||
|
||||
# Deploy each config directory from the hyprlua Dotfiles source.
|
||||
# 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
|
||||
rm -rf ~/.config/"$cfg"
|
||||
cp -r ~/Dotfiles/desktopenvs/hyprlua/"$cfg" ~/.config/
|
||||
|
|
|
|||
Loading…
Reference in New Issue