diff --git a/apply-theme.sh b/apply-theme.sh index 3013776..0b6d94b 100755 --- a/apply-theme.sh +++ b/apply-theme.sh @@ -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" diff --git a/desktopenvs/hyprlua/astal-menu/.gitignore b/desktopenvs/hyprlua/astal-menu/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/desktopenvs/hyprlua/astal-menu/README.md b/desktopenvs/hyprlua/astal-menu/README.md new file mode 100644 index 0000000..b66d536 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/README.md @@ -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/.py` exposing a top-level `SPEC = ModuleSpec(...)` whose + `build(ctx)` returns a `ModuleInstance(compact=…, expanded=…, …)`. + - `compact` shows in the 2×2 cell; a distinct `expanded` widget enables the + expand button (they must be separate instances — GTK widgets have one parent). + - Declare per-feature toggles via `features=[Feature("id", "Label", default)]`; + read them with `ctx.feature("id")`. A disabled quad never calls `build()`. + - Shared state (network, bluetooth, location) is on `ctx.services`. +2. Append its `SPEC` to `ALL_SPECS` in `registry.py`. Nothing else changes. + +## Files + + main.py app + single-instance IPC window.py layer-shell + letterbox + registry.py module list (extension seam) settings.py JSON toggles/order + module_base.py ModuleSpec / ModuleInstance / ModuleContext + appservices.py shared Astal services + location + ui/ quadcard, quadgrid, appdrawer + modules/ location, weather, bluetooth, network + services/ location (geolocation singleton) + lib/ ansi (SGR→TextTag), proc (async subprocess) + backend/ geolocate.py, weather.sh, network.sh + style/ _colors.css, style.css diff --git a/desktopenvs/hyprlua/astal-menu/appservices.py b/desktopenvs/hyprlua/astal-menu/appservices.py new file mode 100644 index 0000000..a9797bf --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/appservices.py @@ -0,0 +1,35 @@ +"""Lazily-built shared services handed to every module via ModuleContext. + +Astal's GObject service libraries (AstalNetwork, AstalBluetooth) are consumed here +through GObject-introspection; modules connect to their `notify::` signals instead +of polling. The location service is our own singleton. +""" + +from __future__ import annotations + +import gi + +gi.require_version("AstalNetwork", "0.1") +gi.require_version("AstalBluetooth", "0.1") +from gi.repository import AstalBluetooth, AstalNetwork # noqa: E402 + +from services.location import get_location_service + + +class Services: + def __init__(self) -> None: + self._network = None + self._bluetooth = None + self.location = get_location_service() + + @property + def network(self): + if self._network is None: + self._network = AstalNetwork.Network.get_default() + return self._network + + @property + def bluetooth(self): + if self._bluetooth is None: + self._bluetooth = AstalBluetooth.Bluetooth.get_default() + return self._bluetooth diff --git a/desktopenvs/hyprlua/astal-menu/backend/geolocate.py b/desktopenvs/hyprlua/astal-menu/backend/geolocate.py new file mode 100755 index 0000000..0b4cf15 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/backend/geolocate.py @@ -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()) diff --git a/desktopenvs/hyprlua/astal-menu/backend/network.sh b/desktopenvs/hyprlua/astal-menu/backend/network.sh new file mode 100755 index 0000000..5c997ac --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/backend/network.sh @@ -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 diff --git a/desktopenvs/hyprlua/astal-menu/backend/staticmap.py b/desktopenvs/hyprlua/astal-menu/backend/staticmap.py new file mode 100755 index 0000000..988ada5 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/backend/staticmap.py @@ -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 +""" + +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]) diff --git a/desktopenvs/hyprlua/astal-menu/backend/weather.sh b/desktopenvs/hyprlua/astal-menu/backend/weather.sh new file mode 100755 index 0000000..9991f61 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/backend/weather.sh @@ -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" diff --git a/desktopenvs/hyprlua/astal-menu/lib/ansi.py b/desktopenvs/hyprlua/astal-menu/lib/ansi.py new file mode 100644 index 0000000..febb91f --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/lib/ansi.py @@ -0,0 +1,138 @@ +"""Render ANSI/SGR-coloured terminal text (e.g. wttr.in) into a Gtk.TextView. + +wttr.in returns real terminal escape sequences when curled. We parse the SGR +subset it uses (basic 8/16 colours, xterm-256, truecolor, bold, reset) and emit +Gtk.TextTags so the CLI art keeps its colours and block-glyph alignment. This is +deliberately reusable by any future "show me some CLI art" widget. +""" + +from __future__ import annotations + +import re + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +_SGR = re.compile(r"\x1b\[([0-9;]*)m") + +# xterm 16-colour base palette (approx, tuned to the CyberQueer dark background). +_BASE16 = [ + (0x00, 0x00, 0x00), (0xCC, 0x24, 0x24), (0x33, 0xCC, 0x33), (0xCC, 0xCC, 0x33), + (0x33, 0x66, 0xCC), (0xCC, 0x33, 0xCC), (0x33, 0xCC, 0xCC), (0xD6, 0xAB, 0xAB), + (0x66, 0x66, 0x66), (0xF5, 0x05, 0x05), (0x55, 0xFF, 0x55), (0xFF, 0xFF, 0x55), + (0x55, 0x88, 0xFF), (0xE4, 0x00, 0x46), (0x55, 0xFF, 0xFF), (0xFF, 0xFF, 0xFF), +] + + +def _xterm256(n: int) -> tuple[int, int, int]: + if n < 16: + return _BASE16[n] + if n < 232: # 6x6x6 colour cube + n -= 16 + r, g, b = n // 36, (n // 6) % 6, n % 6 + conv = lambda c: 55 + c * 40 if c else 0 + return conv(r), conv(g), conv(b) + v = 8 + (n - 232) * 10 # grayscale ramp + return v, v, v + + +def _rgba(rgb: tuple[int, int, int]) -> Gdk.RGBA: + c = Gdk.RGBA() + c.red, c.green, c.blue, c.alpha = rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1.0 + return c + + +class AnsiRenderer: + """Owns a TextView and repaints it from ANSI text. Tags are cached by state.""" + + def __init__(self) -> None: + self.view = Gtk.TextView( + editable=False, cursor_visible=False, monospace=True, + wrap_mode=Gtk.WrapMode.NONE, + ) + self.view.add_css_class("ansi-view") + self.buffer = self.view.get_buffer() + self._tag_cache: dict[tuple, Gtk.TextTag] = {} + + def _tag(self, fg, bg, bold) -> Gtk.TextTag | None: + if fg is None and bg is None and not bold: + return None + key = (fg, bg, bold) + tag = self._tag_cache.get(key) + if tag is None: + tag = self.buffer.create_tag() + if fg is not None: + tag.set_property("foreground-rgba", _rgba(fg)) + if bg is not None: + tag.set_property("background-rgba", _rgba(bg)) + if bold: + tag.set_property("weight", 700) + self._tag_cache[key] = tag + return tag + + def set_text(self, text: str) -> None: + self.buffer.set_text("", 0) + fg = bg = None + bold = False + pos = 0 + for m in _SGR.finditer(text): + chunk = text[pos:m.start()] + if chunk: + self._insert(chunk, fg, bg, bold) + fg, bg, bold = self._apply(m.group(1), fg, bg, bold) + pos = m.end() + tail = text[pos:] + if tail: + self._insert(tail, fg, bg, bold) + + def _insert(self, chunk, fg, bg, bold) -> None: + end = self.buffer.get_end_iter() + tag = self._tag(fg, bg, bold) + if tag is None: + self.buffer.insert(end, chunk) + else: + self.buffer.insert_with_tags(end, chunk, tag) + + @staticmethod + def _apply(params: str, fg, bg, bold): + codes = [int(x) if x else 0 for x in params.split(";")] if params else [0] + i = 0 + while i < len(codes): + c = codes[i] + if c == 0: + fg = bg = None + bold = False + elif c == 1: + bold = True + elif c == 22: + bold = False + elif c == 39: + fg = None + elif c == 49: + bg = None + elif 30 <= c <= 37: + fg = _BASE16[c - 30] + elif 90 <= c <= 97: + fg = _BASE16[c - 90 + 8] + elif 40 <= c <= 47: + bg = _BASE16[c - 40] + elif 100 <= c <= 107: + bg = _BASE16[c - 100 + 8] + elif c in (38, 48): + target = "fg" if c == 38 else "bg" + if i + 1 < len(codes) and codes[i + 1] == 5: + val = _xterm256(codes[i + 2]) if i + 2 < len(codes) else None + i += 2 + elif i + 1 < len(codes) and codes[i + 1] == 2: + val = tuple(codes[i + 2:i + 5]) if i + 4 < len(codes) else None + i += 4 + else: + val = None + if target == "fg": + fg = val + else: + bg = val + i += 1 + return fg, bg, bold diff --git a/desktopenvs/hyprlua/astal-menu/lib/border.py b/desktopenvs/hyprlua/astal-menu/lib/border.py new file mode 100644 index 0000000..16b1b3f --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/lib/border.py @@ -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 diff --git a/desktopenvs/hyprlua/astal-menu/lib/proc.py b/desktopenvs/hyprlua/astal-menu/lib/proc.py new file mode 100644 index 0000000..34a8924 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/lib/proc.py @@ -0,0 +1,100 @@ +"""Subprocess helpers built on Gio so nothing blocks the GTK main loop. + +The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command +prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on +failure. These helpers run such commands asynchronously and hand the parsed result +back on the main thread. +""" + +from __future__ import annotations + +import json +import shlex +from typing import Callable, Sequence + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + + +def _as_argv(cmd: Sequence[str] | str) -> list[str]: + return shlex.split(cmd) if isinstance(cmd, str) else list(cmd) + + +def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None: + """Run cmd, call cb(ok, stdout, stderr) on the main thread when done.""" + try: + proc = Gio.Subprocess.new( + _as_argv(cmd), + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, + ) + except GLib.Error as err: + GLib.idle_add(cb, False, "", str(err)) + return + + def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None: + try: + _, out, errout = p.communicate_utf8_finish(res) + except GLib.Error as err: + cb(False, "", str(err)) + return + cb(p.get_successful(), out or "", errout or "") + + proc.communicate_utf8_async(None, None, _done) + + +def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None: + """Run cmd expecting JSON on stdout; call cb(ok, data).""" + + def _text(ok: bool, out: str, err: str) -> None: + if not ok or not out.strip(): + cb(False, err.strip() or "no output") + return + try: + cb(True, json.loads(out)) + except json.JSONDecodeError as exc: + cb(False, f"bad json: {exc}") + + run_text(cmd, _text) + + +class Poller: + """Repeatedly run a command (or callable) on an interval, main-thread callback. + + Used for lightweight state that has no change signal (open ports, public IP, + weather refresh). Modules that back onto an Astal GObject service should prefer + connecting to that service's `notify::` signals instead of polling. + """ + + def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False): + self.interval_s = interval_s + self.cmd = cmd + self.cb = cb + self.json_mode = json_mode + self._source_id: int | None = None + self._stopped = False + + def start(self) -> "Poller": + self._stopped = False + self._tick() + self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick) + return self + + def _tick(self) -> bool: + if self._stopped: + return GLib.SOURCE_REMOVE + if self.json_mode: + run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data)) + else: + run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err)) + return GLib.SOURCE_CONTINUE + + def refresh_now(self) -> None: + self._tick() + + def stop(self) -> None: + self._stopped = True + if self._source_id is not None: + GLib.source_remove(self._source_id) + self._source_id = None diff --git a/desktopenvs/hyprlua/astal-menu/main.py b/desktopenvs/hyprlua/astal-menu/main.py new file mode 100755 index 0000000..3ca517c --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/main.py @@ -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()) diff --git a/desktopenvs/hyprlua/astal-menu/module_base.py b/desktopenvs/hyprlua/astal-menu/module_base.py new file mode 100644 index 0000000..e50c1b8 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/module_base.py @@ -0,0 +1,73 @@ +"""The contract every quad module implements. + +Adding a module = drop a file in modules/ that defines a top-level `SPEC` +(a ModuleSpec), then append its import to registry.py. Nothing else needs to +change: enable/disable, feature toggles, the card chrome, and the expand/collapse +plumbing are all provided generically. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Optional + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk # noqa: E402 + + +@dataclass +class Feature: + """A per-quad on/off switch surfaced in the card's settings popover.""" + id: str + label: str + default: bool = True + + +@dataclass +class ModuleInstance: + """What a module's build() returns.""" + compact: Gtk.Widget # shown in the 2x2 cell + expanded: Optional[Gtk.Widget] = None # shown when the quad is expanded + scroll_expanded: bool = True # wrap the expanded view in a scroller + on_show: Optional[Callable[[], None]] = None + on_hide: Optional[Callable[[], None]] = None + destroy: Optional[Callable[[], None]] = None + + +@dataclass +class ModuleSpec: + id: str + title: str + icon: str # nerd-font glyph + build: Callable[["ModuleContext"], ModuleInstance] + default_enabled: bool = True + features: list[Feature] = field(default_factory=list) + + +class ModuleContext: + """Handed to a module's build(). Scopes settings to the module and exposes the + expand/collapse requests so a module can drive the layout without knowing it.""" + + def __init__(self, spec: ModuleSpec, settings, services, + request_expand: Callable[[str], None], + request_collapse: Callable[[], None]): + self.spec = spec + self.settings = settings + self.services = services + self._request_expand = request_expand + self._request_collapse = request_collapse + + def expand(self) -> None: + self._request_expand(self.spec.id) + + def collapse(self) -> None: + self._request_collapse() + + # feature toggles, scoped to this module + def feature(self, feature_id: str, default: bool = True) -> bool: + return self.settings.feature(self.spec.id, feature_id, default) + + def on_settings_changed(self, cb: Callable[[], None]) -> None: + self.settings.subscribe(cb) diff --git a/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py b/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py new file mode 100644 index 0000000..ece351a --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py @@ -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)], +) diff --git a/desktopenvs/hyprlua/astal-menu/modules/location.py b/desktopenvs/hyprlua/astal-menu/modules/location.py new file mode 100644 index 0000000..2da6686 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/modules/location.py @@ -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)], +) diff --git a/desktopenvs/hyprlua/astal-menu/modules/network.py b/desktopenvs/hyprlua/astal-menu/modules/network.py new file mode 100644 index 0000000..a9983bd --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/modules/network.py @@ -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)], +) diff --git a/desktopenvs/hyprlua/astal-menu/modules/weather.py b/desktopenvs/hyprlua/astal-menu/modules/weather.py new file mode 100644 index 0000000..4851202 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/modules/weather.py @@ -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 " 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)], +) diff --git a/desktopenvs/hyprlua/astal-menu/paths.py b/desktopenvs/hyprlua/astal-menu/paths.py new file mode 100644 index 0000000..f24cebd --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/paths.py @@ -0,0 +1,22 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +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) diff --git a/desktopenvs/hyprlua/astal-menu/registry.py b/desktopenvs/hyprlua/astal-menu/registry.py new file mode 100644 index 0000000..77e5a4e --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/registry.py @@ -0,0 +1,29 @@ +"""The module registry — the single place that lists available quads in order. + +To add a new module: create modules/.py exposing a top-level `SPEC` +(ModuleSpec), then import it and append its SPEC here. The first four *enabled* +specs fill the 2x2 grid; extra specs are kept for future paginated layouts. +""" + +from __future__ import annotations + +from modules import bluetooth, 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 diff --git a/desktopenvs/hyprlua/astal-menu/services/location.py b/desktopenvs/hyprlua/astal-menu/services/location.py new file mode 100644 index 0000000..88735df --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/services/location.py @@ -0,0 +1,55 @@ +"""Shared geolocation singleton. + +Runs backend/geolocate.py (IP geolocation, cached) exactly once and notifies +subscribers. Both the Location map and the Weather widget subscribe, so there is a +single source of truth for "where am I" and only one network lookup. +""" + +from __future__ import annotations + +import sys +from typing import Callable, Optional + +from lib.proc import run_json +from paths import BACKEND_DIR + + +class LocationService: + def __init__(self) -> None: + self.data: Optional[dict] = None + self._subs: list[Callable[[dict], None]] = [] + self._inflight = False + + def subscribe(self, cb: Callable[[dict], None]) -> None: + self._subs.append(cb) + if self.data is not None: + cb(self.data) + + def get(self) -> Optional[dict]: + if self.data is None and not self._inflight: + self.refresh() + return self.data + + def refresh(self) -> None: + if self._inflight: + return + self._inflight = True + argv = [sys.executable, str(BACKEND_DIR / "geolocate.py")] + run_json(argv, self._on_result) + + def _on_result(self, ok: bool, data) -> None: + self._inflight = False + if ok and isinstance(data, dict) and "lat" in data: + self.data = data + for cb in list(self._subs): + cb(data) + + +_instance: LocationService | None = None + + +def get_location_service() -> LocationService: + global _instance + if _instance is None: + _instance = LocationService() + return _instance diff --git a/desktopenvs/hyprlua/astal-menu/settings.py b/desktopenvs/hyprlua/astal-menu/settings.py new file mode 100644 index 0000000..d4ce39d --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/settings.py @@ -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() diff --git a/desktopenvs/hyprlua/astal-menu/style/_colors.css b/desktopenvs/hyprlua/astal-menu/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/style/_colors.css @@ -0,0 +1,8 @@ +/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh — do not hand-edit the + * hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the + * sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */ +@define-color text #D6ABAB; +@define-color bg #1A1A1A; +@define-color accent #E40046; +@define-color violet #5018DD; +@define-color danger #F50505; diff --git a/desktopenvs/hyprlua/astal-menu/style/style.css b/desktopenvs/hyprlua/astal-menu/style/style.css new file mode 100644 index 0000000..d86b6a1 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/style/style.css @@ -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; } diff --git a/desktopenvs/hyprlua/astal-menu/theme.py b/desktopenvs/hyprlua/astal-menu/theme.py new file mode 100644 index 0000000..41cce8d --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/theme.py @@ -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 + ) diff --git a/desktopenvs/hyprlua/astal-menu/ui/appdrawer.py b/desktopenvs/hyprlua/astal-menu/ui/appdrawer.py new file mode 100644 index 0000000..a7cbbb8 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/ui/appdrawer.py @@ -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() diff --git a/desktopenvs/hyprlua/astal-menu/ui/favorites.py b/desktopenvs/hyprlua/astal-menu/ui/favorites.py new file mode 100644 index 0000000..995d5ba --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/ui/favorites.py @@ -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() diff --git a/desktopenvs/hyprlua/astal-menu/ui/quadcard.py b/desktopenvs/hyprlua/astal-menu/ui/quadcard.py new file mode 100644 index 0000000..5ceb0bd --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/ui/quadcard.py @@ -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() diff --git a/desktopenvs/hyprlua/astal-menu/ui/quadgrid.py b/desktopenvs/hyprlua/astal-menu/ui/quadgrid.py new file mode 100644 index 0000000..0af6b75 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/ui/quadgrid.py @@ -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() diff --git a/desktopenvs/hyprlua/astal-menu/ui/taskbar.py b/desktopenvs/hyprlua/astal-menu/ui/taskbar.py new file mode 100644 index 0000000..84b00df --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/ui/taskbar.py @@ -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() diff --git a/desktopenvs/hyprlua/astal-menu/window.py b/desktopenvs/hyprlua/astal-menu/window.py new file mode 100644 index 0000000..58c1cc9 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/window.py @@ -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 diff --git a/desktopenvs/hyprlua/config-updater/updater.conf b/desktopenvs/hyprlua/config-updater/updater.conf index db58e9e..09cfae6 100644 --- a/desktopenvs/hyprlua/config-updater/updater.conf +++ b/desktopenvs/hyprlua/config-updater/updater.conf @@ -11,14 +11,13 @@ SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprlua # ── deployed as ~/.config/ ───────────────────────────────────────────── 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 diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.scss b/desktopenvs/hyprlua/eww-nobattery/eww.scss index da979e2..3e0afad 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.scss +++ b/desktopenvs/hyprlua/eww-nobattery/eww.scss @@ -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; +} diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.yuck b/desktopenvs/hyprlua/eww-nobattery/eww.yuck index b41fd2d..2008b49 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.yuck +++ b/desktopenvs/hyprlua/eww-nobattery/eww.yuck @@ -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}"}) ) ) diff --git a/desktopenvs/hyprlua/eww-touch/eww.yuck b/desktopenvs/hyprlua/eww-touch/eww.yuck index ad2d16c..5865e28 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.yuck +++ b/desktopenvs/hyprlua/eww-touch/eww.yuck @@ -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@ {}%" diff --git a/desktopenvs/hyprlua/eww/eww.scss b/desktopenvs/hyprlua/eww/eww.scss index 588671d..50959a2 100644 --- a/desktopenvs/hyprlua/eww/eww.scss +++ b/desktopenvs/hyprlua/eww/eww.scss @@ -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; +} diff --git a/desktopenvs/hyprlua/eww/eww.yuck b/desktopenvs/hyprlua/eww/eww.yuck index a078c62..7895881 100644 --- a/desktopenvs/hyprlua/eww/eww.yuck +++ b/desktopenvs/hyprlua/eww/eww.yuck @@ -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}"}) ) ) diff --git a/desktopenvs/hyprlua/hypr/usr/autostart.lua b/desktopenvs/hyprlua/hypr/usr/autostart.lua index ee3c85d..8c65043 100644 --- a/desktopenvs/hyprlua/hypr/usr/autostart.lua +++ b/desktopenvs/hyprlua/hypr/usr/autostart.lua @@ -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") diff --git a/desktopenvs/hyprlua/hypr/usr/binds.lua b/desktopenvs/hyprlua/hypr/usr/binds.lua index c769f14..3582906 100644 --- a/desktopenvs/hyprlua/hypr/usr/binds.lua +++ b/desktopenvs/hyprlua/hypr/usr/binds.lua @@ -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 ---- diff --git a/desktopenvs/hyprlua/scripts/astal-menu-start.sh b/desktopenvs/hyprlua/scripts/astal-menu-start.sh new file mode 100755 index 0000000..0add692 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/astal-menu-start.sh @@ -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" "$@" diff --git a/desktopenvs/hyprlua/scripts/drawer.sh b/desktopenvs/hyprlua/scripts/deprecated/drawer.sh similarity index 100% rename from desktopenvs/hyprlua/scripts/drawer.sh rename to desktopenvs/hyprlua/scripts/deprecated/drawer.sh diff --git a/desktopenvs/hyprlua/scripts/menu-toggle.sh b/desktopenvs/hyprlua/scripts/menu-toggle.sh new file mode 100755 index 0000000..c3ddd1f --- /dev/null +++ b/desktopenvs/hyprlua/scripts/menu-toggle.sh @@ -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" diff --git a/setup/modules/Desktop-Environments/hyprlua.sh b/setup/modules/Desktop-Environments/hyprlua.sh index cd1ad44..b285c4d 100755 --- a/setup/modules/Desktop-Environments/hyprlua.sh +++ b/setup/modules/Desktop-Environments/hyprlua.sh @@ -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/