feat(hyprlua): add astal-menu popup, replacing nwg-dock + nwg-drawer

A touch-friendly GTK4 popup control centre (Python + PyGObject, wlr-layer-shell)
launched from the EWW bar or Super+D. Replaces nwg-dock and nwg-drawer.

Layout: a floating, drawn-bordered panel (anchored top-centre so it never blocks
the rest of the screen) with a 2x2 quad grid over a full-width app drawer, plus a
top taskbar. Any quad expands over the others; the drawer expands to the bottom.

Modules:
- Location  — static OSM map (backend/staticmap.py) on IP geolocation
              (libshumate won't paint tiles in this env; shumate-demo is blank too)
- Weather   — wttr.in ANSI art rendered via an SGR parser into a TextView
- Bluetooth — AstalBluetooth: discovery/connect/disconnect + local history
- Network   — AstalNetwork wifi + nmcli/ip/ss for IP, DHCP/manual, routes, ports,
              public IP; per-feature toggles
- Taskbar   — open windows from hyprctl, grouped by app with pop-out per instance
- Favorites — pinned apps (right-click/long-press to pin) atop the app drawer

Notes:
- Frontend is Python/GTK4, not Lua: lgi/Astal-Lua are GTK3-only.
- Module borders are drawn with a Cairo overlay (lib/border.py); this GTK build's
  renderer skips CSS border/background on plain container widgets.
- Consumes Astal GObject libs (io/apps/network/bluetooth) via introspection.

Wiring: autostart + Super+D/Super+Shift+A binds, EWW launcher button in all three
bar variants, apply-theme.sh (_colors.css), installer packages, config-updater.
drawer.sh moved to scripts/deprecated/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/astal-menu
Amir Alexander Abdelbaki 2026-07-05 00:11:39 +02:00
parent 654606df86
commit 3d75182b54
42 changed files with 2757 additions and 20 deletions

View File

@ -39,9 +39,8 @@ USER_FILES=(
"desktopenvs/hyprland/waybar/style.css|$HOME/.config/waybar/style.css" "desktopenvs/hyprland/waybar/style.css|$HOME/.config/waybar/style.css"
"desktopenvs/hyprland/wofi/style.css|$HOME/.config/wofi/style.css" "desktopenvs/hyprland/wofi/style.css|$HOME/.config/wofi/style.css"
"desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.css" "desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.css"
"desktopenvs/hyprland/nwg-dock-hyprland/style.css|$HOME/.config/nwg-dock-hyprland/style.css"
"desktopenvs/hyprland/nwg-drawer/drawer.css|$HOME/.config/nwg-drawer/drawer.css"
"desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css" "desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css"
"desktopenvs/hyprlua/astal-menu/style/_colors.css|$HOME/.config/astal-menu/style/_colors.css"
"desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml" "desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml"
"desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh" "desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh"
"desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini" "desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini"

View File

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

View File

@ -0,0 +1,60 @@
# astal-menu
A touch-friendly GTK4 popup control centre for the hyprlua desktop, replacing
`nwg-dock` and `nwg-drawer`. Triggered from the EWW top bar (or `Super+D`).
Layout: a **2×2 quad grid** of feature modules over a full-width **application
drawer**. Any quad can expand to overlay the other three; the drawer can expand to
the bottom of the screen. A single margined root box letterboxes the menu identically
in every state.
## Stack
- **Frontend:** Python + PyGObject + **GTK4**, as a `wlr-layer-shell` surface
(`gtk4-layer-shell`). Chosen over Lua because lgi/Astal-Lua only support GTK3.
- **Location map:** a static OSM image stitched from tiles (`backend/staticmap.py`,
Pillow). libshumate does not paint tiles in this environment (the official
`shumate-demo` shows the same blank map), though tile downloads work — hence the
static fallback.
- **Services:** Astal GObject libraries via introspection — `AstalNetwork`,
`AstalBluetooth`, `AstalApps` — plus our own IP-geolocation singleton.
- **Backends:** Python/Bash scripts in `backend/` (JSON on stdout), run async via
`Gio.Subprocess` so nothing blocks the UI.
- **Theme:** `style/_colors.css` (`@define-color`, generated from
`~/Dotfiles/colors.conf` by `apply-theme.sh`) + `style/style.css`.
## Running
`main.py` is single-instance. The autostart launches a hidden resident daemon;
verbs are forwarded over D-Bus:
scripts/astal-menu-start.sh # resident daemon (hidden); sets LD_PRELOAD
scripts/menu-toggle.sh # --toggle
scripts/menu-toggle.sh appdrawer # open with the app drawer expanded
`astal-menu-start.sh` must `LD_PRELOAD` libgtk4-layer-shell (it loads after
libwayland under PyGObject otherwise).
## Adding a module
1. Create `modules/<name>.py` exposing a top-level `SPEC = ModuleSpec(...)` whose
`build(ctx)` returns a `ModuleInstance(compact=…, expanded=…, …)`.
- `compact` shows in the 2×2 cell; a distinct `expanded` widget enables the
expand button (they must be separate instances — GTK widgets have one parent).
- Declare per-feature toggles via `features=[Feature("id", "Label", default)]`;
read them with `ctx.feature("id")`. A disabled quad never calls `build()`.
- Shared state (network, bluetooth, location) is on `ctx.services`.
2. Append its `SPEC` to `ALL_SPECS` in `registry.py`. Nothing else changes.
## Files
main.py app + single-instance IPC window.py layer-shell + letterbox
registry.py module list (extension seam) settings.py JSON toggles/order
module_base.py ModuleSpec / ModuleInstance / ModuleContext
appservices.py shared Astal services + location
ui/ quadcard, quadgrid, appdrawer
modules/ location, weather, bluetooth, network
services/ location (geolocation singleton)
lib/ ansi (SGR→TextTag), proc (async subprocess)
backend/ geolocate.py, weather.sh, network.sh
style/ _colors.css, style.css

View File

@ -0,0 +1,35 @@
"""Lazily-built shared services handed to every module via ModuleContext.
Astal's GObject service libraries (AstalNetwork, AstalBluetooth) are consumed here
through GObject-introspection; modules connect to their `notify::` signals instead
of polling. The location service is our own singleton.
"""
from __future__ import annotations
import gi
gi.require_version("AstalNetwork", "0.1")
gi.require_version("AstalBluetooth", "0.1")
from gi.repository import AstalBluetooth, AstalNetwork # noqa: E402
from services.location import get_location_service
class Services:
def __init__(self) -> None:
self._network = None
self._bluetooth = None
self.location = get_location_service()
@property
def network(self):
if self._network is None:
self._network = AstalNetwork.Network.get_default()
return self._network
@property
def bluetooth(self):
if self._bluetooth is None:
self._bluetooth = AstalBluetooth.Bluetooth.get_default()
return self._bluetooth

View File

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

View File

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

View File

@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Render a static OpenStreetMap image centred on a coordinate, with a marker.
Used by the Location quad because libshumate does not render tiles in this
environment (the official shumate-demo shows the same blank map a library/GTK
render bug), while plain tile downloads work fine. Stitches OSM raster tiles into
one PNG with Pillow and caches them.
staticmap.py <lat> <lon> <zoom> <width_px> <height_px> <out_path>
"""
from __future__ import annotations
import io
import math
import os
import sys
import urllib.request
from pathlib import Path
from PIL import Image, ImageDraw
TILE = 256
CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astal-menu" / "tiles"
UA = "astal-menu/1.0 (personal dotfiles)"
BG = (26, 26, 26)
ACCENT = (228, 0, 70)
def _center_px(lat: float, lon: float, zoom: int) -> tuple[float, float]:
n = 2 ** zoom
lat_r = math.radians(lat)
x = (lon + 180.0) / 360.0 * n
y = (1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n
return x * TILE, y * TILE
def _tile(z: int, x: int, y: int) -> Image.Image | None:
CACHE.mkdir(parents=True, exist_ok=True)
fp = CACHE / f"{z}_{x}_{y}.png"
if fp.exists():
try:
return Image.open(fp).convert("RGB")
except Exception:
pass
url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
data = urllib.request.urlopen(req, timeout=8).read()
except Exception as exc:
print(f"tile {z}/{x}/{y}: {exc}", file=sys.stderr)
return None
fp.write_bytes(data)
return Image.open(io.BytesIO(data)).convert("RGB")
def render(lat: float, lon: float, zoom: int, w: int, h: int, out: str) -> None:
n = 2 ** zoom
cx, cy = _center_px(lat, lon, zoom)
left, top = cx - w / 2, cy - h / 2
img = Image.new("RGB", (w, h), BG)
x0, x1 = int(left // TILE), int((left + w) // TILE)
y0, y1 = int(top // TILE), int((top + h) // TILE)
for tx in range(x0, x1 + 1):
for ty in range(y0, y1 + 1):
if ty < 0 or ty >= n:
continue
tile = _tile(zoom, tx % n, ty)
if tile is None:
continue
img.paste(tile, (int(tx * TILE - left), int(ty * TILE - top)))
d = ImageDraw.Draw(img)
mx, my, r = w // 2, h // 2, 8
d.ellipse([mx - r, my - r, mx + r, my + r], fill=ACCENT, outline=(255, 255, 255), width=2)
Path(out).parent.mkdir(parents=True, exist_ok=True)
img.save(out)
if __name__ == "__main__":
a = sys.argv
render(float(a[1]), float(a[2]), int(a[3]), int(a[4]), int(a[5]), a[6])
print(a[6])

View File

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

View File

@ -0,0 +1,138 @@
"""Render ANSI/SGR-coloured terminal text (e.g. wttr.in) into a Gtk.TextView.
wttr.in returns real terminal escape sequences when curled. We parse the SGR
subset it uses (basic 8/16 colours, xterm-256, truecolor, bold, reset) and emit
Gtk.TextTags so the CLI art keeps its colours and block-glyph alignment. This is
deliberately reusable by any future "show me some CLI art" widget.
"""
from __future__ import annotations
import re
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gdk, Gtk # noqa: E402
_SGR = re.compile(r"\x1b\[([0-9;]*)m")
# xterm 16-colour base palette (approx, tuned to the CyberQueer dark background).
_BASE16 = [
(0x00, 0x00, 0x00), (0xCC, 0x24, 0x24), (0x33, 0xCC, 0x33), (0xCC, 0xCC, 0x33),
(0x33, 0x66, 0xCC), (0xCC, 0x33, 0xCC), (0x33, 0xCC, 0xCC), (0xD6, 0xAB, 0xAB),
(0x66, 0x66, 0x66), (0xF5, 0x05, 0x05), (0x55, 0xFF, 0x55), (0xFF, 0xFF, 0x55),
(0x55, 0x88, 0xFF), (0xE4, 0x00, 0x46), (0x55, 0xFF, 0xFF), (0xFF, 0xFF, 0xFF),
]
def _xterm256(n: int) -> tuple[int, int, int]:
if n < 16:
return _BASE16[n]
if n < 232: # 6x6x6 colour cube
n -= 16
r, g, b = n // 36, (n // 6) % 6, n % 6
conv = lambda c: 55 + c * 40 if c else 0
return conv(r), conv(g), conv(b)
v = 8 + (n - 232) * 10 # grayscale ramp
return v, v, v
def _rgba(rgb: tuple[int, int, int]) -> Gdk.RGBA:
c = Gdk.RGBA()
c.red, c.green, c.blue, c.alpha = rgb[0] / 255, rgb[1] / 255, rgb[2] / 255, 1.0
return c
class AnsiRenderer:
"""Owns a TextView and repaints it from ANSI text. Tags are cached by state."""
def __init__(self) -> None:
self.view = Gtk.TextView(
editable=False, cursor_visible=False, monospace=True,
wrap_mode=Gtk.WrapMode.NONE,
)
self.view.add_css_class("ansi-view")
self.buffer = self.view.get_buffer()
self._tag_cache: dict[tuple, Gtk.TextTag] = {}
def _tag(self, fg, bg, bold) -> Gtk.TextTag | None:
if fg is None and bg is None and not bold:
return None
key = (fg, bg, bold)
tag = self._tag_cache.get(key)
if tag is None:
tag = self.buffer.create_tag()
if fg is not None:
tag.set_property("foreground-rgba", _rgba(fg))
if bg is not None:
tag.set_property("background-rgba", _rgba(bg))
if bold:
tag.set_property("weight", 700)
self._tag_cache[key] = tag
return tag
def set_text(self, text: str) -> None:
self.buffer.set_text("", 0)
fg = bg = None
bold = False
pos = 0
for m in _SGR.finditer(text):
chunk = text[pos:m.start()]
if chunk:
self._insert(chunk, fg, bg, bold)
fg, bg, bold = self._apply(m.group(1), fg, bg, bold)
pos = m.end()
tail = text[pos:]
if tail:
self._insert(tail, fg, bg, bold)
def _insert(self, chunk, fg, bg, bold) -> None:
end = self.buffer.get_end_iter()
tag = self._tag(fg, bg, bold)
if tag is None:
self.buffer.insert(end, chunk)
else:
self.buffer.insert_with_tags(end, chunk, tag)
@staticmethod
def _apply(params: str, fg, bg, bold):
codes = [int(x) if x else 0 for x in params.split(";")] if params else [0]
i = 0
while i < len(codes):
c = codes[i]
if c == 0:
fg = bg = None
bold = False
elif c == 1:
bold = True
elif c == 22:
bold = False
elif c == 39:
fg = None
elif c == 49:
bg = None
elif 30 <= c <= 37:
fg = _BASE16[c - 30]
elif 90 <= c <= 97:
fg = _BASE16[c - 90 + 8]
elif 40 <= c <= 47:
bg = _BASE16[c - 40]
elif 100 <= c <= 107:
bg = _BASE16[c - 100 + 8]
elif c in (38, 48):
target = "fg" if c == 38 else "bg"
if i + 1 < len(codes) and codes[i + 1] == 5:
val = _xterm256(codes[i + 2]) if i + 2 < len(codes) else None
i += 2
elif i + 1 < len(codes) and codes[i + 1] == 2:
val = tuple(codes[i + 2:i + 5]) if i + 4 < len(codes) else None
i += 4
else:
val = None
if target == "fg":
fg = val
else:
bg = val
i += 1
return fg, bg, bold

View File

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

View File

@ -0,0 +1,100 @@
"""Subprocess helpers built on Gio so nothing blocks the GTK main loop.
The whole backend contract (see backend/*.sh, backend/geolocate.py) is: a command
prints JSON (or plain text) to stdout, diagnostics to stderr, non-zero exit on
failure. These helpers run such commands asynchronously and hand the parsed result
back on the main thread.
"""
from __future__ import annotations
import json
import shlex
from typing import Callable, Sequence
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib # noqa: E402
def _as_argv(cmd: Sequence[str] | str) -> list[str]:
return shlex.split(cmd) if isinstance(cmd, str) else list(cmd)
def run_text(cmd: Sequence[str] | str, cb: Callable[[bool, str, str], None]) -> None:
"""Run cmd, call cb(ok, stdout, stderr) on the main thread when done."""
try:
proc = Gio.Subprocess.new(
_as_argv(cmd),
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
)
except GLib.Error as err:
GLib.idle_add(cb, False, "", str(err))
return
def _done(p: Gio.Subprocess, res: Gio.AsyncResult) -> None:
try:
_, out, errout = p.communicate_utf8_finish(res)
except GLib.Error as err:
cb(False, "", str(err))
return
cb(p.get_successful(), out or "", errout or "")
proc.communicate_utf8_async(None, None, _done)
def run_json(cmd: Sequence[str] | str, cb: Callable[[bool, object], None]) -> None:
"""Run cmd expecting JSON on stdout; call cb(ok, data)."""
def _text(ok: bool, out: str, err: str) -> None:
if not ok or not out.strip():
cb(False, err.strip() or "no output")
return
try:
cb(True, json.loads(out))
except json.JSONDecodeError as exc:
cb(False, f"bad json: {exc}")
run_text(cmd, _text)
class Poller:
"""Repeatedly run a command (or callable) on an interval, main-thread callback.
Used for lightweight state that has no change signal (open ports, public IP,
weather refresh). Modules that back onto an Astal GObject service should prefer
connecting to that service's `notify::` signals instead of polling.
"""
def __init__(self, interval_s: float, cmd: Sequence[str] | str, cb, json_mode: bool = False):
self.interval_s = interval_s
self.cmd = cmd
self.cb = cb
self.json_mode = json_mode
self._source_id: int | None = None
self._stopped = False
def start(self) -> "Poller":
self._stopped = False
self._tick()
self._source_id = GLib.timeout_add_seconds(int(self.interval_s), self._tick)
return self
def _tick(self) -> bool:
if self._stopped:
return GLib.SOURCE_REMOVE
if self.json_mode:
run_json(self.cmd, lambda ok, data: None if self._stopped else self.cb(ok, data))
else:
run_text(self.cmd, lambda ok, out, err: None if self._stopped else self.cb(ok, out, err))
return GLib.SOURCE_CONTINUE
def refresh_now(self) -> None:
self._tick()
def stop(self) -> None:
self._stopped = True
if self._source_id is not None:
GLib.source_remove(self._source_id)
self._source_id = None

View File

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

View File

@ -0,0 +1,73 @@
"""The contract every quad module implements.
Adding a module = drop a file in modules/ that defines a top-level `SPEC`
(a ModuleSpec), then append its import to registry.py. Nothing else needs to
change: enable/disable, feature toggles, the card chrome, and the expand/collapse
plumbing are all provided generically.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable, Optional
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
@dataclass
class Feature:
"""A per-quad on/off switch surfaced in the card's settings popover."""
id: str
label: str
default: bool = True
@dataclass
class ModuleInstance:
"""What a module's build() returns."""
compact: Gtk.Widget # shown in the 2x2 cell
expanded: Optional[Gtk.Widget] = None # shown when the quad is expanded
scroll_expanded: bool = True # wrap the expanded view in a scroller
on_show: Optional[Callable[[], None]] = None
on_hide: Optional[Callable[[], None]] = None
destroy: Optional[Callable[[], None]] = None
@dataclass
class ModuleSpec:
id: str
title: str
icon: str # nerd-font glyph
build: Callable[["ModuleContext"], ModuleInstance]
default_enabled: bool = True
features: list[Feature] = field(default_factory=list)
class ModuleContext:
"""Handed to a module's build(). Scopes settings to the module and exposes the
expand/collapse requests so a module can drive the layout without knowing it."""
def __init__(self, spec: ModuleSpec, settings, services,
request_expand: Callable[[str], None],
request_collapse: Callable[[], None]):
self.spec = spec
self.settings = settings
self.services = services
self._request_expand = request_expand
self._request_collapse = request_collapse
def expand(self) -> None:
self._request_expand(self.spec.id)
def collapse(self) -> None:
self._request_collapse()
# feature toggles, scoped to this module
def feature(self, feature_id: str, default: bool = True) -> bool:
return self.settings.feature(self.spec.id, feature_id, default)
def on_settings_changed(self, cb: Callable[[], None]) -> None:
self.settings.subscribe(cb)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,71 @@
"""Weather quad: wttr.in rendered with its original ANSI/CLI art (via AnsiRenderer).
Reuses the shared LocationService for the city; if none is known yet, wttr.in
geolocates the caller's IP itself, so the widget still works standalone. The
'ascii_art' feature toggle swaps the art for a compact one-line text summary.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from lib.ansi import AnsiRenderer
from lib.proc import run_text
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
from paths import BACKEND_DIR
_SCRIPT = str(BACKEND_DIR / "weather.sh")
class _WeatherView(Gtk.Box):
def __init__(self, ctx: ModuleContext, opts: str):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
self.add_css_class("weather-view")
self.ctx = ctx
self.opts = opts
self._loc = ""
self.renderer = AnsiRenderer()
self.append(self.renderer.view)
self._status = Gtk.Label(label="Loading weather…")
self._status.add_css_class("weather-status")
self.append(self._status)
ctx.services.location.subscribe(self._on_location)
self.refresh()
def _on_location(self, data: dict) -> None:
self._loc = data.get("city") or ""
self.refresh()
def refresh(self) -> None:
art = self.ctx.feature("ascii_art", True)
opts = self.opts if art else "format=%l:+%c+%t,+%w"
run_text([_SCRIPT, self._loc, opts], self._on_result)
def _on_result(self, ok: bool, out: str, err: str) -> None:
if ok and out.strip() and "<html" not in out.lower():
self.renderer.set_text(out.rstrip("\n"))
self._status.set_visible(False)
else:
self._status.set_text("Weather unavailable")
self._status.set_visible(True)
def build(ctx: ModuleContext) -> ModuleInstance:
compact = _WeatherView(ctx, opts="0") # current conditions only
expanded = _WeatherView(ctx, opts="") # full 3-day forecast
ctx.on_settings_changed(lambda: (compact.refresh(), expanded.refresh()))
return ModuleInstance(compact=compact, expanded=expanded)
SPEC = ModuleSpec(
id="weather",
title="Weather",
icon="", # nf-weather
build=build,
features=[Feature("ascii_art", "CLI art", True)],
)

View File

@ -0,0 +1,22 @@
"""Shared filesystem locations. Works whether run from the repo or ~/.config."""
from __future__ import annotations
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
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)

View File

@ -0,0 +1,29 @@
"""The module registry — the single place that lists available quads in order.
To add a new module: create modules/<name>.py exposing a top-level `SPEC`
(ModuleSpec), then import it and append its SPEC here. The first four *enabled*
specs fill the 2x2 grid; extra specs are kept for future paginated layouts.
"""
from __future__ import annotations
from modules import bluetooth, 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

View File

@ -0,0 +1,55 @@
"""Shared geolocation singleton.
Runs backend/geolocate.py (IP geolocation, cached) exactly once and notifies
subscribers. Both the Location map and the Weather widget subscribe, so there is a
single source of truth for "where am I" and only one network lookup.
"""
from __future__ import annotations
import sys
from typing import Callable, Optional
from lib.proc import run_json
from paths import BACKEND_DIR
class LocationService:
def __init__(self) -> None:
self.data: Optional[dict] = None
self._subs: list[Callable[[dict], None]] = []
self._inflight = False
def subscribe(self, cb: Callable[[dict], None]) -> None:
self._subs.append(cb)
if self.data is not None:
cb(self.data)
def get(self) -> Optional[dict]:
if self.data is None and not self._inflight:
self.refresh()
return self.data
def refresh(self) -> None:
if self._inflight:
return
self._inflight = True
argv = [sys.executable, str(BACKEND_DIR / "geolocate.py")]
run_json(argv, self._on_result)
def _on_result(self, ok: bool, data) -> None:
self._inflight = False
if ok and isinstance(data, dict) and "lat" in data:
self.data = data
for cb in list(self._subs):
cb(data)
_instance: LocationService | None = None
def get_location_service() -> LocationService:
global _instance
if _instance is None:
_instance = LocationService()
return _instance

View File

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

View File

@ -0,0 +1,8 @@
/* Generated from ~/Dotfiles/colors.conf by apply-theme.sh do not hand-edit the
* hex values; edit colors.conf and re-run apply-theme.sh. (Kept in sync via the
* sed pipeline in USER_FILES; these defaults are the CyberQueer palette.) */
@define-color text #D6ABAB;
@define-color bg #1A1A1A;
@define-color accent #E40046;
@define-color violet #5018DD;
@define-color danger #F50505;

View File

@ -0,0 +1,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; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,14 +11,13 @@ SOURCE_BASE = ~/Dotfiles/desktopenvs/hyprlua
# ── deployed as ~/.config/<name> ───────────────────────────────────────────── # ── deployed as ~/.config/<name> ─────────────────────────────────────────────
config alacritty config alacritty
config astal-menu
config btop config btop
config dunst config dunst
config gtk-3.0 config gtk-3.0
config hypr except usr config hypr except usr
config kitty config kitty
config mimeapps.list config mimeapps.list
config nwg-dock-hyprland
config nwg-drawer
config nwg-panel config nwg-panel
config scripts config scripts
config ulauncher config ulauncher

View File

@ -102,3 +102,12 @@ menuitem {
menuitem:hover { menuitem:hover {
color: #E40046; color: #E40046;
} }
// astal-menu launcher button accent-coloured icon, highlight on hover
.menu-launcher {
color: #E40046;
font-size: 15pt;
}
.menu-launcher:hover {
color: #5018dd;
}

View File

@ -20,8 +20,10 @@
(defwidget winsworks [monitor] (defwidget winsworks [monitor]
(box :orientation "h" :space-evenly false :halign "start" (box :orientation "h" :space-evenly false :halign "start"
; astal-menu launcher — opens the popup control centre / app drawer
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh" {""})
(workspaceWidget :monitor monitor) (workspaceWidget :monitor monitor)
(button :onclick "~/Dotfiles/desktopenvs/hyprland/scripts/drawer.sh" :class "music" {" ${activewindow}"}) (button :onclick "~/.config/scripts/menu-toggle.sh" :class "music" {" ${activewindow}"})
) )
) )

View File

@ -26,7 +26,7 @@
(box :orientation "h" :space-evenly false :halign "start" (box :orientation "h" :space-evenly false :halign "start"
(osk) (osk)
(box :class "music" {"${battery}"}) (box :class "music" {"${battery}"})
(button :onclick "~/.config/scripts/drawer.sh" :class "icon-btn" :valign "center" :width 26 :height 26 {""}) (button :onclick "~/.config/scripts/menu-toggle.sh" :class "icon-btn" :valign "center" :width 26 :height 26 {""})
(metric :label "󰓃 " (metric :label "󰓃 "
:value volume :value volume
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%" :onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"

View File

@ -102,3 +102,12 @@ menuitem {
menuitem:hover { menuitem:hover {
color: #E40046; color: #E40046;
} }
// astal-menu launcher button accent-coloured icon, highlight on hover
.menu-launcher {
color: #E40046;
font-size: 15pt;
}
.menu-launcher:hover {
color: #5018dd;
}

View File

@ -54,12 +54,14 @@
; :halign "start" — left-aligns the content within the centerbox left cell. ; :halign "start" — left-aligns the content within the centerbox left cell.
(defwidget winsworks [monitor] (defwidget winsworks [monitor]
(box :orientation "h" :space-evenly false :halign "start" (box :orientation "h" :space-evenly false :halign "start"
; astal-menu launcher — opens the popup control centre / app drawer
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh" {""})
; Battery percentage badge — styled as a pill with class "music" ; Battery percentage badge — styled as a pill with class "music"
(box :class "music" {"${battery}"}) (box :class "music" {"${battery}"})
; Workspace dots — one button per active workspace on this monitor ; Workspace dots — one button per active workspace on this monitor
(workspaceWidget :monitor monitor) (workspaceWidget :monitor monitor)
; Active window title — clicking opens the application drawer ; Active window title — clicking opens the application drawer
(button :onclick "~/Dotfiles/desktopenvs/hyprland/scripts/drawer.sh" :class "music" {" ${activewindow}"}) (button :onclick "~/.config/scripts/menu-toggle.sh" :class "music" {" ${activewindow}"})
) )
) )

View File

@ -16,7 +16,7 @@ hl.on("hyprland.start", function()
hl.exec_cmd("[workspace special:magic silent] kitty") hl.exec_cmd("[workspace special:magic silent] kitty")
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50") hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
hl.exec_cmd("hyprpaper") hl.exec_cmd("hyprpaper")
hl.exec_cmd("nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p right") hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/astal-menu-start.sh")
hl.exec_cmd("blueman-applet") hl.exec_cmd("blueman-applet")
hl.exec_cmd("blueman-tray") hl.exec_cmd("blueman-tray")
hl.exec_cmd("hypridle") hl.exec_cmd("hypridle")

View File

@ -209,15 +209,11 @@ hl.bind(mainMod .. " + SHIFT + ALT + k", hl.dsp.group.move_window("u"))
hl.bind(mainMod .. " + SHIFT + ALT + j", hl.dsp.group.move_window("d")) hl.bind(mainMod .. " + SHIFT + ALT + j", hl.dsp.group.move_window("d"))
-------------------- --------------------
---- NWG-DOCK ------ ---- ASTAL-MENU ----
-------------------- --------------------
hl.bind(mainMod .. " + SHIFT + W", hl.dsp.exec_cmd("killall nwg-dock-hyprland; nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p left")) hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("~/.config/scripts/menu-toggle.sh"), { release = true })
hl.bind(mainMod .. " + SHIFT + E", hl.dsp.exec_cmd("killall nwg-dock-hyprland; nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p right")) hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/menu-toggle.sh appdrawer"))
hl.bind(mainMod .. " + SHIFT + S", hl.dsp.exec_cmd("killall nwg-dock-hyprland; nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p top"))
hl.bind(mainMod .. " + SHIFT + D", hl.dsp.exec_cmd("killall nwg-dock-hyprland; nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p bottom"))
hl.bind(mainMod .. " + D", hl.dsp.exec_cmd("killall nwg-dock-hyprland || nwg-dock-hyprland -c ~/.config/scripts/drawer.sh -mt 50 -i 25 -r -s style.css -p bottom"), { release = true })
hl.bind(mainMod .. " + SHIFT + A", hl.dsp.exec_cmd("~/.config/scripts/drawer.sh"))
-------------------- --------------------
---- SCREENSHOT ---- ---- SCREENSHOT ----

View File

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

View File

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

View File

@ -71,11 +71,16 @@ HYPRLUA_PACKAGES=(
kitty # GPU-accelerated terminal (default in this setup) kitty # GPU-accelerated terminal (default in this setup)
dunst # lightweight, scriptable notification daemon dunst # lightweight, scriptable notification daemon
nwg-dock-hyprland # dock/taskbar with Hyprland workspace awareness
nwg-drawer # grid application drawer / launcher
nwg-menu # GTK application menu for the panel button
nwg-look # GTK/cursor/icon theme picker for wlroots sessions nwg-look # GTK/cursor/icon theme picker for wlroots sessions
# astal-menu popup control centre (replaces nwg-dock + nwg-drawer)
python-gobject # PyGObject: GTK4 + Astal libs from Python
gtk4 # GTK4 toolkit (the menu's frontend)
gtk4-layer-shell # wlr-layer-shell for GTK4 (the popup surface)
libshumate # GTK4 OpenStreetMap widget (Location quad)
bluez-utils # bluetoothctl + bluez CLI (Bluetooth quad fallbacks)
iproute2 curl jq # ip/ss, HTTP fetches, JSON (Network/Weather backends)
# Build toolchain required for EWW (Rust) and AUR package compilation # Build toolchain required for EWW (Rust) and AUR package compilation
python cmake meson cpio pkgconf ruby-pkg-config python cmake meson cpio pkgconf ruby-pkg-config
@ -194,10 +199,13 @@ rustup default stable
# wofi-calc — inline calculator inside wofi # wofi-calc — inline calculator inside wofi
# bri — brightness control helper for bar widgets # bri — brightness control helper for bar widgets
# chamel — colour-palette / theme switcher # chamel — colour-palette / theme switcher
# libastal-*-git — Astal GObject service libraries consumed by astal-menu
# (io, apps, network, bluetooth) via PyGObject
yay -Syu --answerdiff None --answerclean All --noconfirm --needed \ yay -Syu --answerdiff None --answerclean All --noconfirm --needed \
hyprland-workspaces vicinae-bin bluetuith wvkbd iwmenu pinta \ hyprland-workspaces vicinae-bin bluetuith wvkbd iwmenu pinta \
walker-bin ulauncher bzmenu udiskie \ walker-bin ulauncher bzmenu udiskie \
wofi-calc bri chamel wofi-calc bri chamel \
libastal-io-git libastal-apps-git libastal-network-git libastal-bluetooth-git
# hyprmoncfg if custom script no worky # hyprmoncfg if custom script no worky
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -335,7 +343,7 @@ log "Copying configs..."
# Deploy each config directory from the hyprlua Dotfiles source. # Deploy each config directory from the hyprlua Dotfiles source.
# The wipe-then-copy pattern ensures no stale files from older installs remain. # The wipe-then-copy pattern ensures no stale files from older installs remain.
CONFIGS=(kitty mimeapps.list vicinae walker ulauncher hypr xfce4 wofi dunst alacritty nwg-dock-hyprland nwg-drawer nwg-panel scripts btop gtk-3.0) CONFIGS=(kitty mimeapps.list vicinae walker ulauncher hypr xfce4 wofi dunst alacritty astal-menu nwg-panel scripts btop gtk-3.0)
for cfg in "${CONFIGS[@]}"; do for cfg in "${CONFIGS[@]}"; do
rm -rf ~/.config/"$cfg" rm -rf ~/.config/"$cfg"
cp -r ~/Dotfiles/desktopenvs/hyprlua/"$cfg" ~/.config/ cp -r ~/Dotfiles/desktopenvs/hyprlua/"$cfg" ~/.config/