From 57dede403e89973444429333cf21c088e6314d2f Mon Sep 17 00:00:00 2001 From: The_miro Date: Mon, 6 Jul 2026 09:26:37 +0200 Subject: [PATCH] feat(astal-menu): stats line, side-slide flag, drawer pop, tighter buttons - Add a minimalist system-stats line at the top of the panel: CPU / MEM / GPU / disk utilisation + network down/up rate, read straight from /proc and /sys on a 2s timer (no subprocess). ui/statsbar.py. - Opening side is selectable: `main.py --side top|bottom|left|right` (also via `menu-toggle.sh ` or just `menu-toggle.sh `). The panel anchors flush to that edge, and the compositor's layer slide brings it in from there. Unified all layer anchoring into MenuWindow._apply_anchor. - App drawer now does a bouncy pop-expand (CSS scale, play-once guard like the quads). - Tighten the action-pill buttons vertically (padding 6->1px, min-height 32->22) so they stop wasting vertical space. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP --- desktopenvs/hyprlua/astal-menu/main.py | 12 +- .../hyprlua/astal-menu/style/style.css | 20 ++- .../hyprlua/astal-menu/ui/appdrawer.py | 12 ++ desktopenvs/hyprlua/astal-menu/ui/statsbar.py | 133 ++++++++++++++++++ desktopenvs/hyprlua/astal-menu/window.py | 60 ++++++-- desktopenvs/hyprlua/scripts/menu-toggle.sh | 24 +++- 6 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 desktopenvs/hyprlua/astal-menu/ui/statsbar.py diff --git a/desktopenvs/hyprlua/astal-menu/main.py b/desktopenvs/hyprlua/astal-menu/main.py index c561aed..3784951 100755 --- a/desktopenvs/hyprlua/astal-menu/main.py +++ b/desktopenvs/hyprlua/astal-menu/main.py @@ -56,12 +56,22 @@ class AstalMenuApp(Gtk.Application): self.hold() # stay alive with no visible window def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: - args = cmdline.get_arguments()[1:] + args = list(cmdline.get_arguments()[1:]) + # Optional "--side top|bottom|left|right": the menu slides in from and pins to + # that monitor edge. Applied before the verb so the surface maps animated. + side = None + if "--side" in args: + i = args.index("--side") + if i + 1 < len(args): + side = args[i + 1] + del args[i:i + 2] # No args = start (or keep) the resident instance hidden. Only explicit # verbs change visibility, so the autostart launch never pops the menu. action = args[0] if args else "--daemon" if self.window is None: return 0 + if side: + self.window.set_side(side) if action == "--show": self.window.show_menu() elif action == "--hide": diff --git a/desktopenvs/hyprlua/astal-menu/style/style.css b/desktopenvs/hyprlua/astal-menu/style/style.css index da655d8..d2f16ee 100644 --- a/desktopenvs/hyprlua/astal-menu/style/style.css +++ b/desktopenvs/hyprlua/astal-menu/style/style.css @@ -51,6 +51,15 @@ drawingarea { } .quad-pop { animation: quad-pop 300ms ease-out; } +/* bouncy pop-expand for the app drawer (added in ui/appdrawer.py on expand) */ +@keyframes drawer-pop { + 0% { transform: scale(0.90); opacity: 0.45; } + 60% { transform: scale(1.025); opacity: 1; } + 82% { transform: scale(0.995); } + 100% { transform: scale(1.0); } +} +.drawer-pop { animation: drawer-pop 280ms ease-out; } + .quad-card { min-height: 100px; } .section-title { color: @text; font-weight: bold; opacity: 0.85; } @@ -75,6 +84,11 @@ drawingarea { .quad-title { color: @text; font-weight: bold; } .quad-icon { color: @accent; font-size: 15pt; } +/* minimalist system-stats line (top of the panel) */ +.statsbar { padding: 1px 8px; } +.statsbar .stat-icon { color: @accent; font-size: 11pt; } +.statsbar .stat-value { color: @text; font-size: 10pt; } + .quad-body { color: @text; } /* pill action buttons */ @@ -84,9 +98,9 @@ drawingarea { background: @bg; border: 3px solid @violet; border-radius: 25px; - padding: 6px 14px; - min-height: 32px; - min-width: 32px; + padding: 1px 14px; /* tight vertically so pills don't waste vertical space */ + min-height: 22px; + min-width: 22px; } .quad-action:hover, .enable-btn:hover { border-color: @accent; color: @accent; } diff --git a/desktopenvs/hyprlua/astal-menu/ui/appdrawer.py b/desktopenvs/hyprlua/astal-menu/ui/appdrawer.py index f7efe52..b23b0fb 100644 --- a/desktopenvs/hyprlua/astal-menu/ui/appdrawer.py +++ b/desktopenvs/hyprlua/astal-menu/ui/appdrawer.py @@ -88,9 +88,21 @@ class AppDrawer(Gtk.Box): self._scroll.set_max_content_height(_STRIP_HEIGHT) self._expand_btn.set_label("") # nf-fa-expand + def _pop(self) -> None: + # bouncy scale pop on the app grid when the drawer expands; cleared after the + # animation so a re-layout (tiles loading) can't restart it into a loop. + self._scroll.add_css_class("drawer-pop") + GLib.timeout_add(320, self._clear_pop) + + def _clear_pop(self) -> bool: + self._scroll.remove_css_class("drawer-pop") + return GLib.SOURCE_REMOVE + def _toggle_expand(self) -> None: self._expanded = not self._expanded self._apply_mode() + if self._expanded: + self._pop() self._on_toggle_expand(self._expanded) def set_expanded(self, value: bool) -> None: diff --git a/desktopenvs/hyprlua/astal-menu/ui/statsbar.py b/desktopenvs/hyprlua/astal-menu/ui/statsbar.py new file mode 100644 index 0000000..b9f6e15 --- /dev/null +++ b/desktopenvs/hyprlua/astal-menu/ui/statsbar.py @@ -0,0 +1,133 @@ +"""A minimalist system-stats line for the top of the panel. + +CPU / RAM / GPU / disk utilisation plus network up/down rate, refreshed on a timer. +Everything is read straight from /proc and /sys on the main thread — these are local +file reads that take microseconds, so no subprocess or worker thread is needed. +""" + +from __future__ import annotations + +import glob +import os +import time + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import GLib, Gtk # noqa: E402 + +_GPU_FILES = glob.glob("/sys/class/drm/card*/device/gpu_busy_percent") +_NET_SKIP = ("lo", "docker", "veth", "br-", "virbr", "tun", "tap") + + +def _human(rate: float) -> str: + for unit in ("B", "K", "M", "G"): + if rate < 1024 or unit == "G": + return f"{rate:4.1f}{unit}" + rate /= 1024 + return f"{rate:4.1f}G" + + +class Statsbar(Gtk.Box): + def __init__(self): + super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=18, + halign=Gtk.Align.CENTER) + self.add_css_class("statsbar") + + # short text labels rather than nerd glyphs (some don't exist in the font and + # render as tofu); keeps the line legible on any theme. + self._cpu = self._cell("CPU") + self._ram = self._cell("MEM") + self._gpu = self._cell("GPU") + self._disk = self._cell("DISK") + self._down = self._cell("↓") + self._up = self._cell("↑") + + self._prev_cpu: tuple[int, int] | None = None + self._prev_net: tuple[int, int] | None = None + self._prev_t: float | None = None + + self._refresh() + self._source = GLib.timeout_add_seconds(2, self._tick) + + def _cell(self, icon: str) -> Gtk.Label: + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + ic = Gtk.Label(label=icon) + ic.add_css_class("stat-icon") + val = Gtk.Label(label="…") + val.add_css_class("stat-value") + box.append(ic) + box.append(val) + self.append(box) + return val + + def _tick(self) -> bool: + self._refresh() + return GLib.SOURCE_CONTINUE + + # -- readers ----------------------------------------------------------- + @staticmethod + def _cpu_pct(prev) -> tuple[float, tuple[int, int]]: + parts = [int(x) for x in open("/proc/stat").readline().split()[1:]] + idle = parts[3] + parts[4] # idle + iowait + total = sum(parts) + if prev is None: + return 0.0, (total, idle) + dt, di = total - prev[0], idle - prev[1] + pct = (1 - di / dt) * 100 if dt > 0 else 0.0 + return max(0.0, min(100.0, pct)), (total, idle) + + @staticmethod + def _ram_pct() -> float: + mem = {} + for line in open("/proc/meminfo"): + k, _, v = line.partition(":") + mem[k] = int(v.split()[0]) + if len(mem) > 4 and "MemAvailable" in mem: + break + total, avail = mem.get("MemTotal", 1), mem.get("MemAvailable", 0) + return (1 - avail / total) * 100 + + @staticmethod + def _gpu_pct() -> float: + best = 0 + for f in _GPU_FILES: + try: + best = max(best, int(open(f).read().strip())) + except (OSError, ValueError): + pass + return float(best) + + @staticmethod + def _disk_pct() -> float: + s = os.statvfs("/") + return (1 - s.f_bfree / s.f_blocks) * 100 if s.f_blocks else 0.0 + + @staticmethod + def _net_bytes() -> tuple[int, int]: + rx = tx = 0 + with open("/proc/net/dev") as fh: + for line in fh.readlines()[2:]: + name, _, rest = line.partition(":") + name = name.strip() + if name.startswith(_NET_SKIP): + continue + cols = rest.split() + rx += int(cols[0]) + tx += int(cols[8]) + return rx, tx + + def _refresh(self) -> None: + cpu, self._prev_cpu = self._cpu_pct(self._prev_cpu) + self._cpu.set_text(f"{cpu:2.0f}%") + self._ram.set_text(f"{self._ram_pct():2.0f}%") + self._gpu.set_text(f"{self._gpu_pct():2.0f}%") + self._disk.set_text(f"{self._disk_pct():2.0f}%") + + now = time.monotonic() + rx, tx = self._net_bytes() + if self._prev_net is not None and self._prev_t is not None and now > self._prev_t: + dt = now - self._prev_t + self._down.set_text(_human((rx - self._prev_net[0]) / dt) + "/s") + self._up.set_text(_human((tx - self._prev_net[1]) / dt) + "/s") + self._prev_net, self._prev_t = (rx, tx), now diff --git a/desktopenvs/hyprlua/astal-menu/window.py b/desktopenvs/hyprlua/astal-menu/window.py index 125a0c3..63cd441 100644 --- a/desktopenvs/hyprlua/astal-menu/window.py +++ b/desktopenvs/hyprlua/astal-menu/window.py @@ -31,12 +31,14 @@ from lib.border import bordered from registry import ordered_specs from ui.appdrawer import AppDrawer from ui.quadgrid import QuadGrid +from ui.statsbar import Statsbar from ui.taskbar import Taskbar PANEL_WIDTH_FRACTION = 0.5 # of the monitor width... MAX_PANEL_WIDTH = 1100 # ...clamped to this -TOP_MARGIN = 28 # gap below the top bar -BOTTOM_MARGIN = 34 # gap from the screen bottom when expanded +EDGE_MARGIN = 28 # gap from the anchored edge +BOTTOM_MARGIN = 34 # gap from the far edge when the drawer is expanded +SIDES = ("top", "bottom", "left", "right") class MenuWindow(Gtk.ApplicationWindow): @@ -47,6 +49,7 @@ class MenuWindow(Gtk.ApplicationWindow): self.settings = settings self.services = services self._drawer_expanded = False + self._side = "top" self._init_layer_shell() @@ -54,6 +57,14 @@ class MenuWindow(Gtk.ApplicationWindow): self.root.set_name("panel-root") self.root.add_css_class("panel") + # minimalist system-stats line (full-width, very top) + self.statsbar = Statsbar() + self.statsbar_reveal = Gtk.Revealer( + transition_type=Gtk.RevealerTransitionType.SLIDE_DOWN, + transition_duration=160, reveal_child=True) + self.statsbar_reveal.set_child(bordered(self.statsbar, border=2, radius=12, fill_bg=True)) + self.root.append(self.statsbar_reveal) + # taskbar (full-width, top) self.taskbar = Taskbar(on_activate=self.hide_menu) self.taskbar_reveal = Gtk.Revealer( @@ -100,11 +111,33 @@ class MenuWindow(Gtk.ApplicationWindow): 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) + self._apply_anchor() + + def set_side(self, side: str) -> None: + """Anchor the panel to the given monitor edge ('top'|'bottom'|'left'|'right'). + The compositor's layer 'slide' animation then slides it in from that edge, and + the panel sits flush against it. Set before showing so the map animates.""" + if side in SIDES: + self._side = side + self._apply_anchor() + + def _apply_anchor(self) -> None: + """Pin the panel to its side edge; while the drawer is expanded, also pin the + perpendicular edges so it fills the screen height.""" + E = LayerShell.Edge + for edge in (E.TOP, E.BOTTOM, E.LEFT, E.RIGHT): + LayerShell.set_anchor(self, edge, False) + LayerShell.set_margin(self, edge, 0) + primary = {"top": E.TOP, "bottom": E.BOTTOM, "left": E.LEFT, "right": E.RIGHT}[self._side] + LayerShell.set_anchor(self, primary, True) + LayerShell.set_margin(self, primary, EDGE_MARGIN) + if self._drawer_expanded: + # fill vertically so the expanded drawer reaches top and bottom + for edge in (E.TOP, E.BOTTOM): + LayerShell.set_anchor(self, edge, True) + if edge != primary: + LayerShell.set_margin(self, edge, BOTTOM_MARGIN) def _monitor_width(self) -> int: display = Gdk.Display.get_default() @@ -122,15 +155,12 @@ class MenuWindow(Gtk.ApplicationWindow): # -- 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._apply_anchor() + # Hide (not just un-reveal) the stats/taskbar/quads so they reserve zero space + # and the drawer fills the whole panel. + for rev in (self.statsbar_reveal, self.quad_reveal, self.taskbar_reveal): + rev.set_reveal_child(not expanded) + rev.set_visible(not expanded) self.appdrawer_wrap.set_vexpand(expanded) # -- visibility -------------------------------------------------------- diff --git a/desktopenvs/hyprlua/scripts/menu-toggle.sh b/desktopenvs/hyprlua/scripts/menu-toggle.sh index c3ddd1f..c49f58f 100755 --- a/desktopenvs/hyprlua/scripts/menu-toggle.sh +++ b/desktopenvs/hyprlua/scripts/menu-toggle.sh @@ -4,19 +4,35 @@ # (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 +# menu-toggle.sh -> --toggle (top, the default) +# menu-toggle.sh appdrawer -> open with the app drawer expanded +# menu-toggle.sh left -> toggle, sliding in from / pinned to the left +# menu-toggle.sh toggle right -> same, explicit verb +# menu-toggle.sh appdrawer bottom +# +# Verbs: (toggle) | show | hide | appdrawer. Sides: top | bottom | left | right. BUS="eu.abdelbaki.astalmenu" APP="${HOME}/.config/astal-menu/main.py" -case "${1:-}" in +verb="${1:-}"; side="${2:-}" +# allow the side to be given as the sole argument (verb defaults to toggle) +case "$verb" in + top|bottom|left|right) side="$verb"; verb="" ;; +esac + +case "$verb" in appdrawer) VERB="--appdrawer" ;; show) VERB="--show" ;; hide) VERB="--hide" ;; *) VERB="--toggle" ;; esac +case "$side" in + top|bottom|left|right) SIDE=(--side "$side") ;; + *) SIDE=() ;; +esac + registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; } if ! registered; then @@ -29,4 +45,4 @@ 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" +exec python3 "$APP" "$VERB" "${SIDE[@]}"