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 <verb> <side>` or just `menu-toggle.sh <side>`). 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP
feat/astal-menu
Amir Alexander Abdelbaki 2026-07-06 09:26:37 +02:00
parent e63c99f299
commit 57dede403e
6 changed files with 238 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 -> --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[@]}"