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_01XUWCXM4KhjRkwheaA3X7bPfeat/astal-menu
parent
e63c99f299
commit
57dede403e
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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[@]}"
|
||||
|
|
|
|||
Loading…
Reference in New Issue