Compare commits

..

2 Commits

Author SHA1 Message Date
Amir Alexander Abdelbaki 7ebff0d2a4 feat(hyprdrive): configurable/live-polled workspace layout picker in astro-menu
config.json gains a "layouts" key to narrow which hypr/layouts-discovered
layouts astro-menu offers (and reorder them), plus per-layout option
overrides (trim directions, hide fit_method/stepper), without touching the
.lua layout sources.

Also: the taskbar's workspace/layout panel now polls every second while the
menu is open, since switching workspaces doesn't notify (or get blocked by)
the menu and the panel previously only refreshed on open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 15:07:02 +02:00
Amir Alexander Abdelbaki 008e4d980c feat(hyprdrive,hyprlua): notification action buttons for timer/screenrec
timer-run now offers Dismiss/Snooze 2m/Snooze 5m buttons via notify-send -A
(beacon/mnotifd both implement the freedesktop Actions spec) and rings the
alarm on a loop until the notification resolves, instead of playing once and
going silent.

screenrec.sh gets a real "Stop Recording" button in the same vein, replacing
a broken anonymous --action= and a `$nid=` assignment bug that never actually
captured the notification id; also fixes hyprlua's stale `dunstctl close`
call left over from the mnotifd migration (dunst is no longer running).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 15:06:55 +02:00
7 changed files with 184 additions and 55 deletions

View File

@ -4,6 +4,21 @@ Same pattern as orbit-menu/config.py and horizon-dock/config.py. Deliberately
separate from settings.py's Settings class: that one persists quad enable/ separate from settings.py's Settings class: that one persists quad enable/
feature toggles and favorites (module-scoped, read via ctx.feature()), while feature toggles and favorites (module-scoped, read via ctx.feature()), while
this is a single whole-window flag read once at startup. this is a single whole-window flag read once at startup.
"layouts" narrows/customises the workspace-layout picker in the taskbar panel
(ui/taskbar.py) on top of what hypr/layouts auto-discovers and writes to
~/.cache/astro-menu/layouts.json:
"enabled": list of layout names to offer, in that order (tab order follows
it) omit/null to show everything hypr/layouts discovered.
"overrides": {layout_name: {partial layout spec}} shallow-merged onto the
discovered spec for that layout e.g. trim "dirs" to fewer
directions, or set "fit_method"/"stepper" to false to hide
those controls for that layout, without touching its .lua file.
Example:
"layouts": {
"enabled": ["scrolling", "master", "monocle"],
"overrides": {"scrolling": {"dirs": ["down", "right"]}}
}
""" """
from __future__ import annotations from __future__ import annotations
@ -12,7 +27,7 @@ import json
from paths import CONFIG_FILE, ensure_dirs from paths import CONFIG_FILE, ensure_dirs
_DEFAULTS = {"hologram": True} _DEFAULTS = {"hologram": True, "layouts": {"enabled": None, "overrides": {}}}
def _load() -> dict: def _load() -> dict:
@ -29,3 +44,23 @@ def _load() -> dict:
def hologram_enabled() -> bool: def hologram_enabled() -> bool:
return bool(_load().get("hologram", True)) return bool(_load().get("hologram", True))
def apply_layout_config(layouts: list[dict]) -> list[dict]:
"""Filter/reorder/override the layouts hypr/layouts discovered, per the
"layouts" config key. `layouts` is the parsed layouts.json (or its
hardcoded fallback) see Taskbar._load_layouts."""
cfg = _load().get("layouts") or {}
enabled = cfg.get("enabled")
overrides = cfg.get("overrides") or {}
by_name = {ly["name"]: ly for ly in layouts}
names = enabled if enabled else [ly["name"] for ly in layouts]
out = []
for name in names:
ly = by_name.get(name)
if ly is None:
continue # config names a layout hypr/layouts never discovered
out.append({**ly, **overrides.get(name, {})})
return out

View File

@ -7,7 +7,9 @@ so it *covers* the 2x2 quads rather than pushing anything down. The panel holds:
* workspace/layout controls pick the current workspace's layout (scrolling / * workspace/layout controls pick the current workspace's layout (scrolling /
dwindle / master / monocle, enumerated from ~/.cache/astro-menu/layouts.json, dwindle / master / monocle, enumerated from ~/.cache/astro-menu/layouts.json,
written by hypr/layouts) and, for directional layouts, its direction. Applied written by hypr/layouts) and, for directional layouts, its direction. Applied
live via `hyprctl eval 'layouts.set(ws, name, dir)'`. live via `hyprctl eval 'layouts.set(ws, name, dir)'`. The set of layouts
offered (and their per-layout options) can be narrowed/customised via
config.py's "layouts" key — see config.apply_layout_config.
* a per-window row list: [icon + title focus/jump] [ pull to this workspace]. * a per-window row list: [icon + title focus/jump] [ pull to this workspace].
""" """
@ -19,8 +21,9 @@ import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("AstalApps", "0.1") gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps, Gtk # noqa: E402 from gi.repository import AstalApps, Gtk, GLib # noqa: E402
import config
from lib.proc import run_json, run_text from lib.proc import run_json, run_text
from paths import CACHE_DIR from paths import CACHE_DIR
@ -51,6 +54,7 @@ class Taskbar(Gtk.Box):
self._clients: list = [] self._clients: list = []
self._dir_dds = {} self._dir_dds = {}
self._fit_sws = {} self._fit_sws = {}
self._poll_id: int | None = None
# The workspace/window panel. It is NOT appended here: the menu window mounts # The workspace/window panel. It is NOT appended here: the menu window mounts
# `panel_widget` into the quad region so that expanding COLLAPSES this strip's # `panel_widget` into the quad region so that expanding COLLAPSES this strip's
@ -121,10 +125,10 @@ class Taskbar(Gtk.Box):
@staticmethod @staticmethod
def _load_layouts() -> list: def _load_layouts() -> list:
try: try:
return json.loads(_LAYOUTS_MANIFEST.read_text()) layouts = json.loads(_LAYOUTS_MANIFEST.read_text())
except (FileNotFoundError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):
# fallback if hypr/layouts hasn't written the manifest yet # fallback if hypr/layouts hasn't written the manifest yet
return [{"name": "scrolling", "label": "Scrolling", "directional": True, layouts = [{"name": "scrolling", "label": "Scrolling", "directional": True,
"dirs": ["down", "up", "right", "left"], "default_dir": "down", "dirs": ["down", "up", "right", "left"], "default_dir": "down",
"fit_method": True}, "fit_method": True},
{"name": "columns", "label": "Columns", "directional": True, {"name": "columns", "label": "Columns", "directional": True,
@ -133,12 +137,32 @@ class Taskbar(Gtk.Box):
{"name": "dwindle", "label": "Dwindle", "directional": False, "dirs": []}, {"name": "dwindle", "label": "Dwindle", "directional": False, "dirs": []},
{"name": "master", "label": "Master", "directional": False, "dirs": []}, {"name": "master", "label": "Master", "directional": False, "dirs": []},
{"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}] {"name": "monocle", "label": "Monocle", "directional": False, "dirs": []}]
return config.apply_layout_config(layouts)
# -- populate ---------------------------------------------------------- # -- populate ----------------------------------------------------------
def refresh(self) -> None: def refresh(self) -> None:
run_json(["hyprctl", "clients", "-j"], self._on_clients) run_json(["hyprctl", "clients", "-j"], self._on_clients)
run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws) run_json(["hyprctl", "activeworkspace", "-j"], self._on_ws)
# -- keep the panel live while the menu is open -------------------------
# Hyprland doesn't push workspace-switch events to us, and switching
# workspaces isn't blocked by the menu being open — so without this the
# "Workspace N layout" panel goes stale the moment you change workspace
# while astro-menu is still up. Poll instead; the window starts/stops
# this alongside show_menu/hide_menu.
def start_polling(self) -> None:
if self._poll_id is None:
self._poll_id = GLib.timeout_add(1000, self._on_poll)
def stop_polling(self) -> None:
if self._poll_id is not None:
GLib.source_remove(self._poll_id)
self._poll_id = None
def _on_poll(self) -> bool:
self.refresh()
return True # keep polling every second while the menu is open
def _on_ws(self, ok: bool, data) -> None: def _on_ws(self, ok: bool, data) -> None:
if ok and isinstance(data, dict): if ok and isinstance(data, dict):
self._active_ws = data.get("id") self._active_ws = data.get("id")

View File

@ -192,6 +192,7 @@ class MenuWindow(Gtk.ApplicationWindow):
def show_menu(self, focus_appdrawer: bool = False) -> None: def show_menu(self, focus_appdrawer: bool = False) -> None:
self.appdrawer.set_expanded(False) self.appdrawer.set_expanded(False)
self.taskbar.refresh() self.taskbar.refresh()
self.taskbar.start_polling()
self.grid.on_show() self.grid.on_show()
self.appdrawer.on_show() self.appdrawer.on_show()
self.set_visible(True) self.set_visible(True)
@ -204,6 +205,7 @@ class MenuWindow(Gtk.ApplicationWindow):
self._hologram.start_intro() self._hologram.start_intro()
def hide_menu(self) -> None: def hide_menu(self) -> None:
self.taskbar.stop_polling()
self.grid.on_hide() self.grid.on_hide()
self.taskbar.collapse_panel() self.taskbar.collapse_panel()
self.grid.hide_takeover() self.grid.hide_takeover()

View File

@ -1,5 +1,9 @@
#!/bin/bash #!/bin/bash
# screenrec.sh — toggle screen recording via wf-recorder.
# Press the bound key once to start (region picked via slurp): a
# "Stop Recording" button appears on the notification and the script blocks
# on it. Either clicking that button, or pressing the same keybind again,
# ends the recording.
endrec () { endrec () {
killall -s SIGINT wf-recorder killall -s SIGINT wf-recorder
@ -14,16 +18,22 @@ endrec () {
mkdir -p ~/Videos mkdir -p ~/Videos
statecon=$( pidof wf-recorder ) statecon=$( pidof wf-recorder )
outfile="$HOME/Videos/$(date +'%Y%m%d%H%M%S').mp4" outfile="$HOME/Videos/$(date +'%Y%m%d%H%M%S').mp4"
#$outfile
nid="" nid=""
if [ "$statecon" == '' ]; then
wf-recorder -g "$(slurp)" -f $outfile & if [ -z "$statecon" ]; then
$nid=$(notify-send -p -u critical -t 0 "recording started" --action="goto endrecplace" wf-recorder -g "$(slurp)" -f "$outfile" &
)
# --id-fd keeps the notification id off stdout so -A's chosen action
# (the only thing we read back) isn't mixed in with it.
nidfile=$(mktemp)
action=$(notify-send -u critical -t 0 --id-fd=3 \
-A "stop=Stop Recording" \
"recording started" "output: $outfile" 3>"$nidfile")
nid=$(<"$nidfile")
rm -f "$nidfile"
[ "$action" == "stop" ] && endrec
else else
endrec endrec
fi fi
pidof wf-recorder && endrec

View File

@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# timer-run <total_seconds> [label] # timer-run <total_seconds> [label]
# Runs entirely in background. No terminal needed after launch. # Runs entirely in background. No terminal needed after launch.
# Sends a dunst notification + audio beep when done. # Sends a desktop notification (with Dismiss / Snooze 2m / Snooze 5m action
# buttons, via beacon) + audio beep when done.
# #
# Install: ~/.config/scripts/timer-run (companion: timer-pick in same dir) # Install: ~/.config/scripts/timer-run (companion: timer-pick in same dir)
@ -60,20 +61,41 @@ if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
done done
fi fi
# ── audio alert (rings on a loop until dismissed/snoozed) ─────────────────────
_ALARM_SOUND="/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga"
play_alarm() {
if command -v paplay &>/dev/null && [[ -f "$_ALARM_SOUND" ]]; then
paplay "$_ALARM_SOUND" 2>/dev/null
elif command -v canberra-gtk-play &>/dev/null; then
canberra-gtk-play --id=alarm-clock-elapsed 2>/dev/null
fi
}
( while true; do play_alarm; sleep 1; done ) &
RING_PID=$!
# ── notification with action buttons ──────────────────────────────────────────
# -A implies --wait: notify-send blocks until the card is closed or an action
# is invoked, printing the chosen action's id to stdout. beacon supports the
# spec's Actions/ActionInvoked, so this works without any custom hint.
ACTION=""
if command -v notify-send &>/dev/null; then if command -v notify-send &>/dev/null; then
notify-send \ ACTION=$(notify-send \
--urgency=critical \ --urgency=critical \
--expire-time=0 \ --expire-time=0 \
--icon=alarm-timer \ --icon=alarm-timer \
-A "dismiss=Dismiss" \
-A "snooze2=Snooze 2m" \
-A "snooze5=Snooze 5m" \
"$NOTIF_SUMMARY" \ "$NOTIF_SUMMARY" \
"$NOTIF_BODY" "$NOTIF_BODY")
fi fi
# ── audio alert ─────────────────────────────────────────────────────────────── kill "$RING_PID" 2>/dev/null
_ALARM_SOUND="/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga" wait "$RING_PID" 2>/dev/null
if command -v paplay &>/dev/null && [[ -f "$_ALARM_SOUND" ]]; then case "$ACTION" in
paplay "$_ALARM_SOUND" 2>/dev/null snooze2) exec bash "$0" 120 "$LABEL" ;;
elif command -v canberra-gtk-play &>/dev/null; then snooze5) exec bash "$0" 300 "$LABEL" ;;
canberra-gtk-play --id=alarm-clock-elapsed 2>/dev/null esac
fi

View File

@ -1,25 +1,39 @@
#!/bin/bash #!/bin/bash
# screenrec.sh — toggle screen recording via wf-recorder.
# Press the bound key once to start (region picked via slurp): a
# "Stop Recording" button appears on the notification and the script blocks
# on it. Either clicking that button, or pressing the same keybind again,
# ends the recording.
endrec () { endrec () {
killall -s SIGINT wf-recorder killall -s SIGINT wf-recorder
dunstctl close $nid # mnotifd (the notif daemon) has no dunstctl; close the "recording started"
# card via the freedesktop CloseNotification method instead.
[ -n "$nid" ] && gdbus call --session --dest org.freedesktop.Notifications \
--object-path /org/freedesktop/Notifications \
--method org.freedesktop.Notifications.CloseNotification "$nid" >/dev/null 2>&1
notify-send "recording ended - output to $outfile" notify-send "recording ended - output to $outfile"
} }
mkdir -p ~/Videos mkdir -p ~/Videos
statecon=$( pidof wf-recorder ) statecon=$( pidof wf-recorder )
outfile="$HOME/Videos/$(date +'%Y%m%d%H%M%S').mp4" outfile="$HOME/Videos/$(date +'%Y%m%d%H%M%S').mp4"
#$outfile
nid="" nid=""
if [ "$statecon" == '' ]; then
wf-recorder -g "$(slurp)" -f $outfile & if [ -z "$statecon" ]; then
$nid=$(notify-send -p -u critical -t 0 "recording started" --action="goto endrecplace" wf-recorder -g "$(slurp)" -f "$outfile" &
)
# --id-fd keeps the notification id off stdout so -A's chosen action
# (the only thing we read back) isn't mixed in with it.
nidfile=$(mktemp)
action=$(notify-send -u critical -t 0 --id-fd=3 \
-A "stop=Stop Recording" \
"recording started" "output: $outfile" 3>"$nidfile")
nid=$(<"$nidfile")
rm -f "$nidfile"
[ "$action" == "stop" ] && endrec
else else
endrec endrec
fi fi
pidof wf-recorder && endrec

View File

@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# timer-run <total_seconds> [label] # timer-run <total_seconds> [label]
# Runs entirely in background. No terminal needed after launch. # Runs entirely in background. No terminal needed after launch.
# Sends a dunst notification + audio beep when done. # Sends a desktop notification (with Dismiss / Snooze 2m / Snooze 5m action
# buttons, via mnotifd) + audio beep when done.
# #
# Install: ~/.config/scripts/timer-run (companion: timer-pick in same dir) # Install: ~/.config/scripts/timer-run (companion: timer-pick in same dir)
@ -42,13 +43,13 @@ DURATION_STR=$(fmt_dur "$TOTAL")
NOTIF_SUMMARY="⏰ Timer done${LABEL:+ — $LABEL}" NOTIF_SUMMARY="⏰ Timer done${LABEL:+ — $LABEL}"
NOTIF_BODY="Set for ${DURATION_STR}. Finished at $(date '+%H:%M:%S')." NOTIF_BODY="Set for ${DURATION_STR}. Finished at $(date '+%H:%M:%S')."
# ── dunst notification ──────────────────────────────────────────────────────── # ── desktop notification ──────────────────────────────────────────────────────
# notify-send needs DBUS_SESSION_BUS_ADDRESS. If not in env, find it via the # notify-send needs DBUS_SESSION_BUS_ADDRESS. If not in env, find it via the
# running dunst process (reliable on single-user Wayland/Hyprland setups). # running mnotifd notif daemon (reliable on single-user Wayland/Hyprland setups).
if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
local_uid=$(id -u) local_uid=$(id -u)
# try to grab it from the environment of any process owned by this user # try to grab it from the environment of any process owned by this user
for pid in $(pgrep -u "$local_uid" dunst 2>/dev/null); do for pid in $(pgrep -u "$local_uid" -f '[m]notifd/main.py' 2>/dev/null); do
addr=$(cat /proc/$pid/environ 2>/dev/null \ addr=$(cat /proc/$pid/environ 2>/dev/null \
| tr '\0' '\n' \ | tr '\0' '\n' \
| grep '^DBUS_SESSION_BUS_ADDRESS=' \ | grep '^DBUS_SESSION_BUS_ADDRESS=' \
@ -60,20 +61,41 @@ if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
done done
fi fi
# ── audio alert (rings on a loop until dismissed/snoozed) ─────────────────────
_ALARM_SOUND="/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga"
play_alarm() {
if command -v paplay &>/dev/null && [[ -f "$_ALARM_SOUND" ]]; then
paplay "$_ALARM_SOUND" 2>/dev/null
elif command -v canberra-gtk-play &>/dev/null; then
canberra-gtk-play --id=alarm-clock-elapsed 2>/dev/null
fi
}
( while true; do play_alarm; sleep 1; done ) &
RING_PID=$!
# ── notification with action buttons ──────────────────────────────────────────
# -A implies --wait: notify-send blocks until the card is closed or an action
# is invoked, printing the chosen action's id to stdout. mnotifd supports the
# spec's Actions/ActionInvoked, so this works without any custom hint.
ACTION=""
if command -v notify-send &>/dev/null; then if command -v notify-send &>/dev/null; then
notify-send \ ACTION=$(notify-send \
--urgency=critical \ --urgency=critical \
--expire-time=0 \ --expire-time=0 \
--icon=alarm-timer \ --icon=alarm-timer \
-A "dismiss=Dismiss" \
-A "snooze2=Snooze 2m" \
-A "snooze5=Snooze 5m" \
"$NOTIF_SUMMARY" \ "$NOTIF_SUMMARY" \
"$NOTIF_BODY" "$NOTIF_BODY")
fi fi
# ── audio alert ─────────────────────────────────────────────────────────────── kill "$RING_PID" 2>/dev/null
_ALARM_SOUND="/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga" wait "$RING_PID" 2>/dev/null
if command -v paplay &>/dev/null && [[ -f "$_ALARM_SOUND" ]]; then case "$ACTION" in
paplay "$_ALARM_SOUND" 2>/dev/null snooze2) exec bash "$0" 120 "$LABEL" ;;
elif command -v canberra-gtk-play &>/dev/null; then snooze5) exec bash "$0" 300 "$LABEL" ;;
canberra-gtk-play --id=alarm-clock-elapsed 2>/dev/null esac
fi