From 2fa3414e3135d404518462ddf1804665b1753f04 Mon Sep 17 00:00:00 2001 From: The_miro Date: Fri, 17 Jul 2026 09:49:03 +0200 Subject: [PATCH] feat(hyprlua): add orbit-menu radial power/utility menu New GTK4 layer-shell radial menu (desktopenvs/hyprlua/orbit-menu/), following astal-menu's single-instance D-Bus daemon conventions. Super+Shift+O opens straight into a Power ring (sleep/reboot/poweroff/soft-reboot via hyprshutdown); Super+Ctrl+O opens the full 5-category root (Power, Tools, Scripts sourced live from ~/Documents/Scripts, Management, and a live open-Windows list with go-to/bring-here/close/force-kill/get-pid/send-to-workspace actions). Adds a round launcher button to all 3 eww bar variants with matching glow/hover CSS. Also retunes hypridle (lock 30s, sleep 1min) and adds a HibernateDelaySec systemd-sleep.conf.d fragment (deployed by sysupdate.sh) so the machine hibernates ~80 minutes after going idle instead of drifting to sleep indefinitely. --- apply-theme.sh | 1 + .../hyprlua/config-updater/updater.conf | 1 + desktopenvs/hyprlua/eww-nobattery/eww.scss | 21 ++ desktopenvs/hyprlua/eww-nobattery/eww.yuck | 1 + desktopenvs/hyprlua/eww-touch/eww.scss | 21 ++ desktopenvs/hyprlua/eww-touch/eww.yuck | 1 + desktopenvs/hyprlua/eww/eww.scss | 21 ++ desktopenvs/hyprlua/eww/eww.yuck | 2 + desktopenvs/hyprlua/hypr/hypridle.conf | 18 +- desktopenvs/hyprlua/hypr/usr/autostart.lua | 1 + desktopenvs/hyprlua/hypr/usr/binds.lua | 7 +- desktopenvs/hyprlua/orbit-menu/.gitignore | 2 + desktopenvs/hyprlua/orbit-menu/actions.py | 63 +++++ desktopenvs/hyprlua/orbit-menu/lib/proc.py | 63 +++++ desktopenvs/hyprlua/orbit-menu/main.py | 112 +++++++++ desktopenvs/hyprlua/orbit-menu/menu_tree.py | 182 ++++++++++++++ desktopenvs/hyprlua/orbit-menu/orbit_menu.py | 235 ++++++++++++++++++ desktopenvs/hyprlua/orbit-menu/paths.py | 12 + .../hyprlua/orbit-menu/style/_colors.css | 8 + .../hyprlua/orbit-menu/style/style.css | 99 ++++++++ desktopenvs/hyprlua/orbit-menu/theme.py | 30 +++ .../hyprlua/scripts/orbit-menu-start.sh | 12 + desktopenvs/hyprlua/scripts/orbit-menu.sh | 41 +++ .../90-hyprlua-hibernate-delay.conf | 9 + sysupdate.sh | 15 ++ 25 files changed, 966 insertions(+), 12 deletions(-) create mode 100644 desktopenvs/hyprlua/orbit-menu/.gitignore create mode 100644 desktopenvs/hyprlua/orbit-menu/actions.py create mode 100644 desktopenvs/hyprlua/orbit-menu/lib/proc.py create mode 100644 desktopenvs/hyprlua/orbit-menu/main.py create mode 100644 desktopenvs/hyprlua/orbit-menu/menu_tree.py create mode 100644 desktopenvs/hyprlua/orbit-menu/orbit_menu.py create mode 100644 desktopenvs/hyprlua/orbit-menu/paths.py create mode 100644 desktopenvs/hyprlua/orbit-menu/style/_colors.css create mode 100644 desktopenvs/hyprlua/orbit-menu/style/style.css create mode 100644 desktopenvs/hyprlua/orbit-menu/theme.py create mode 100755 desktopenvs/hyprlua/scripts/orbit-menu-start.sh create mode 100755 desktopenvs/hyprlua/scripts/orbit-menu.sh create mode 100644 etc-systemd-sleep/90-hyprlua-hibernate-delay.conf diff --git a/apply-theme.sh b/apply-theme.sh index 0b6d94b..0dd5333 100755 --- a/apply-theme.sh +++ b/apply-theme.sh @@ -41,6 +41,7 @@ USER_FILES=( "desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.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/hyprlua/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css" "desktopenvs/hyprland/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml" "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" diff --git a/desktopenvs/hyprlua/config-updater/updater.conf b/desktopenvs/hyprlua/config-updater/updater.conf index 09cfae6..e797d64 100644 --- a/desktopenvs/hyprlua/config-updater/updater.conf +++ b/desktopenvs/hyprlua/config-updater/updater.conf @@ -19,6 +19,7 @@ config hypr except usr config kitty config mimeapps.list config nwg-panel +config orbit-menu config scripts config ulauncher config vicinae diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.scss b/desktopenvs/hyprlua/eww-nobattery/eww.scss index dc6ce50..a56f92e 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.scss +++ b/desktopenvs/hyprlua/eww-nobattery/eww.scss @@ -117,3 +117,24 @@ menuitem:hover { .menu-launcher:hover { color: #5018dd; } + +// orbit-menu launcher — round glowing button, opens the radial power/tools/ +// scripts/management/windows menu. Smooth glow + colour-swap on hover. +.orbit-launcher { + border: 2px solid alpha(#E40046, 0.5); + background-image: radial-gradient(circle at 35% 30%, alpha(#5018dd, 0.35), #1a1a1a 75%); + color: #E40046; + font-size: 15pt; + min-width: 26px; + min-height: 26px; + margin: 1px 4px; + border-radius: 9999px; + transition: color 200ms ease, border-color 200ms ease, + background-image 200ms ease, box-shadow 220ms ease; +} +.orbit-launcher:hover { + color: #ffffff; + border-color: #E40046; + background-image: radial-gradient(circle at 35% 30%, alpha(#E40046, 0.65), #1a1a1a 75%); + box-shadow: 0 0 14px 2px alpha(#E40046, 0.65); +} diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.yuck b/desktopenvs/hyprlua/eww-nobattery/eww.yuck index a4dd111..501b18e 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.yuck +++ b/desktopenvs/hyprlua/eww-nobattery/eww.yuck @@ -22,6 +22,7 @@ (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 toggle top" {""}) + (button :class "orbit-launcher" :onclick "~/.config/scripts/orbit-menu.sh menu" {"󰀘"}) (workspaceWidget :monitor monitor) (button :onclick "~/.config/scripts/menu-toggle.sh toggle top" :class "music" {" ${activewindow}"}) ) diff --git a/desktopenvs/hyprlua/eww-touch/eww.scss b/desktopenvs/hyprlua/eww-touch/eww.scss index 88fc01f..f0118dd 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.scss +++ b/desktopenvs/hyprlua/eww-touch/eww.scss @@ -98,6 +98,27 @@ tooltip { color: #5018dd; } +// orbit-menu launcher — round glowing button (touch-sized like .icon-btn), +// opens the radial power/tools/scripts/management/windows menu. +.orbit-launcher { + border: 2px solid alpha(#E40046, 0.5); + background-image: radial-gradient(circle at 35% 30%, alpha(#5018dd, 0.35), #1a1a1a 75%); + color: #E40046; + font-size: 18pt; + min-width: 40px; + min-height: 40px; + margin: 2px 4px; + border-radius: 50%; + transition: color 200ms ease, border-color 200ms ease, + background-image 200ms ease, box-shadow 220ms ease; +} +.orbit-launcher:hover { + color: #ffffff; + border-color: #E40046; + background-image: radial-gradient(circle at 35% 30%, alpha(#E40046, 0.65), #1a1a1a 75%); + box-shadow: 0 0 16px 3px alpha(#E40046, 0.65); +} + menuitem { border: solid; border-width: 3px; diff --git a/desktopenvs/hyprlua/eww-touch/eww.yuck b/desktopenvs/hyprlua/eww-touch/eww.yuck index 792f99e..1ac3b52 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.yuck +++ b/desktopenvs/hyprlua/eww-touch/eww.yuck @@ -27,6 +27,7 @@ (osk) (box :class "music" {"${battery}"}) (button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""}) + (button :class "orbit-launcher" :onclick "~/.config/scripts/orbit-menu.sh menu" {"󰀘"}) (metric :label "󰓃 " :value volume :onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%" diff --git a/desktopenvs/hyprlua/eww/eww.scss b/desktopenvs/hyprlua/eww/eww.scss index 5d381da..b829b3b 100644 --- a/desktopenvs/hyprlua/eww/eww.scss +++ b/desktopenvs/hyprlua/eww/eww.scss @@ -117,3 +117,24 @@ menuitem:hover { .menu-launcher:hover { color: #5018dd; } + +// orbit-menu launcher — round glowing button, opens the radial power/tools/ +// scripts/management/windows menu. Smooth glow + colour-swap on hover. +.orbit-launcher { + border: 2px solid alpha(#E40046, 0.5); + background-image: radial-gradient(circle at 35% 30%, alpha(#5018dd, 0.35), #1a1a1a 75%); + color: #E40046; + font-size: 15pt; + min-width: 26px; + min-height: 26px; + margin: 1px 4px; + border-radius: 9999px; + transition: color 200ms ease, border-color 200ms ease, + background-image 200ms ease, box-shadow 220ms ease; +} +.orbit-launcher:hover { + color: #ffffff; + border-color: #E40046; + background-image: radial-gradient(circle at 35% 30%, alpha(#E40046, 0.65), #1a1a1a 75%); + box-shadow: 0 0 14px 2px alpha(#E40046, 0.65); +} diff --git a/desktopenvs/hyprlua/eww/eww.yuck b/desktopenvs/hyprlua/eww/eww.yuck index 00323d7..e0150a5 100644 --- a/desktopenvs/hyprlua/eww/eww.yuck +++ b/desktopenvs/hyprlua/eww/eww.yuck @@ -56,6 +56,8 @@ (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 toggle top" {""}) + ; orbit-menu launcher — opens the radial Power/Tools/Scripts/Management/Windows menu + (button :class "orbit-launcher" :onclick "~/.config/scripts/orbit-menu.sh menu" {"󰀘"}) ; Battery percentage badge — styled as a pill with class "music" (box :class "music" {"${battery}"}) ; Workspace dots — one button per active workspace on this monitor diff --git a/desktopenvs/hyprlua/hypr/hypridle.conf b/desktopenvs/hyprlua/hypr/hypridle.conf index 56dd19c..27b7f92 100644 --- a/desktopenvs/hyprlua/hypr/hypridle.conf +++ b/desktopenvs/hyprlua/hypr/hypridle.conf @@ -38,21 +38,19 @@ general { # while motion is detected, so these timeouts only run when you've actually # stepped away. -# First idle listener: lock the screen after 2.5 minutes of inactivity. -# 150 seconds is long enough to avoid triggering during short pauses (reading, -# thinking) but short enough to protect the session if you leave unexpectedly. +# First idle listener: lock the screen after 30s of inactivity. listener { - timeout = 150 # 2.5 min — lock screen + timeout = 30 # 30s — lock screen # loginctl lock-session triggers the session lock signal; hyprlock picks it up. on-timeout = loginctl lock-session } -# Second idle listener: suspend (then hibernate) after 10 minutes of inactivity. -# This fires only if the lock screen has already been active for ~7.5 minutes, -# meaning the user is confirmed away. suspend-then-hibernate first suspends to RAM -# for fast resume; after the hibernate delay (configured in systemd-sleep.conf) -# it writes the RAM image to disk for power-off safety. +# Second idle listener: sleep after 1 minute of inactivity. suspend-then-hibernate +# suspends to RAM immediately (fast resume); HibernateDelaySec — set system-wide by +# hypr/systemd-sleep.conf, deployed to /etc/systemd/sleep.conf.d/ by sysupdate.sh — +# then hibernates (writes RAM to disk, fully powers it down) 79 minutes later, so +# hibernate lands ~80 minutes after the machine first went idle (1min + 79min). listener { - timeout = 600 # 10 min — suspend + timeout = 60 # 1 min — sleep on-timeout = systemctl suspend-then-hibernate } diff --git a/desktopenvs/hyprlua/hypr/usr/autostart.lua b/desktopenvs/hyprlua/hypr/usr/autostart.lua index bee738b..d58b112 100644 --- a/desktopenvs/hyprlua/hypr/usr/autostart.lua +++ b/desktopenvs/hyprlua/hypr/usr/autostart.lua @@ -16,6 +16,7 @@ hl.on("hyprland.start", function() hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50") hl.exec_cmd("hyprpaper") hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/astal-menu-start.sh") + hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/orbit-menu-start.sh") hl.exec_cmd("blueman-applet") hl.exec_cmd("blueman-tray") hl.exec_cmd("hypridle") diff --git a/desktopenvs/hyprlua/hypr/usr/binds.lua b/desktopenvs/hyprlua/hypr/usr/binds.lua index f62eefa..76b8c20 100644 --- a/desktopenvs/hyprlua/hypr/usr/binds.lua +++ b/desktopenvs/hyprlua/hypr/usr/binds.lua @@ -90,9 +90,12 @@ hl.bind(mainMod .. " + CTRL + M", hl.dsp.exec_cmd("~/.config/scripts/togg -- lock/exit — hyprshutdown gracefully closes every app (with a UI) before it exits -- Hyprland and runs its --post-cmd, instead of yanking the session out from under them. +-- orbit-menu wraps hyprshutdown behind the radial menu: Shift+O opens straight into +-- the Power ring (sleep/reboot/poweroff/soft-reboot); Ctrl+O opens the full 5-category +-- menu (Power/Tools/Scripts/Management/Windows). Re-press to close. hl.bind(mainMod .. " + O", hl.dsp.exec_cmd("hyprlock")) -hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("hyprshutdown")) -- graceful logout -hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd('hyprshutdown -p "systemctl poweroff"')) -- graceful power off +hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("~/.config/scripts/orbit-menu.sh power")) +hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd("~/.config/scripts/orbit-menu.sh menu")) hl.bind(mainMod .. " + CTRL + SHIFT + O",hl.dsp.exec_cmd('hyprshutdown -p "systemctl reboot"')) -- graceful reboot hl.bind(mainMod .. " + ALT + O", hl.dsp.exec_cmd("~/.config/scripts/pwr-dmenu.sh")) hl.bind(mainMod .. " + ALT + CTRL + SHIFT + END", hl.dsp.exit()) diff --git a/desktopenvs/hyprlua/orbit-menu/.gitignore b/desktopenvs/hyprlua/orbit-menu/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/desktopenvs/hyprlua/orbit-menu/actions.py b/desktopenvs/hyprlua/orbit-menu/actions.py new file mode 100644 index 0000000..e0838a1 --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/actions.py @@ -0,0 +1,63 @@ +"""Side-effecting helpers shared by menu_tree.py's leaf actions. + +Everything here fires-and-forgets via Gio.Subprocess (lib.proc.fire) so a click +never blocks the GTK main loop or the menu's close animation. + +Per-window ops use two different mechanisms on purpose: + * focus / bring-here go through `hyprctl dispatch "hl.dsp...."` — hyprlua's own + Lua-eval dispatch layer, the same one astal-menu/ui/taskbar.py already uses in + production for exactly this (see its _focus/_pull). + * close / force-kill go through plain native Hyprland dispatchers (`closewindow + address:...`) and a bare `kill -9 `, which work regardless of the Lua + layer and don't require guessing whether hl.dsp.window exposes a close-by- + address form. +""" + +from __future__ import annotations + +from lib.proc import fire + + +def launch(cmd: str) -> None: + """Run cmd via Hyprland's exec dispatcher — cmd may carry a `[tag ...]` / + `[workspace ...]` window-rule-on-launch prefix, same as binds.lua.""" + fire(["hyprctl", "dispatch", "exec", cmd]) + + +def hyprshutdown(post_cmd: str | None = None) -> None: + if post_cmd: + fire(["hyprshutdown", "-p", post_cmd]) + else: + fire(["hyprshutdown"]) + + +def _dispatch_lua(lua: str) -> None: + fire(["hyprctl", "dispatch", lua]) + + +def focus_window(address: str) -> None: + _dispatch_lua(f'hl.dsp.focus({{ window = "address:{address}" }})') + + +def bring_here(address: str, active_ws: int) -> None: + _dispatch_lua(f'hl.dsp.window.move({{ window = "address:{address}", ' + f'workspace = {active_ws} }})') + + +def send_to_workspace(address: str, workspace_id: int) -> None: + _dispatch_lua(f'hl.dsp.window.move({{ window = "address:{address}", ' + f'workspace = {workspace_id}, silent = true }})') + + +def close_window(address: str) -> None: + fire(["hyprctl", "dispatch", "closewindow", f"address:{address}"]) + + +def force_kill(pid: int) -> None: + fire(["kill", "-9", str(pid)]) + + +def report_pid(pid: int, title: str) -> None: + fire(["wl-copy", str(pid)]) + fire(["notify-send", "-a", "orbit-menu", f"PID {pid}", + f"{title} — copied to clipboard"]) diff --git a/desktopenvs/hyprlua/orbit-menu/lib/proc.py b/desktopenvs/hyprlua/orbit-menu/lib/proc.py new file mode 100644 index 0000000..8c5c197 --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/lib/proc.py @@ -0,0 +1,63 @@ +"""Subprocess helpers built on Gio so nothing blocks the GTK main loop. + +Trimmed copy of astal-menu/lib/proc.py's contract: 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) + + +def fire(cmd: Sequence[str] | str) -> None: + """Run cmd and discard the result — for launch/dispatch calls no one awaits.""" + run_text(cmd, lambda *_a: None) diff --git a/desktopenvs/hyprlua/orbit-menu/main.py b/desktopenvs/hyprlua/orbit-menu/main.py new file mode 100644 index 0000000..0942838 --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/main.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""orbit-menu — a radial power/utility menu for hyprlua. + +Two entry points into the same tree: + Super+Shift+O -> --power opens straight into the Power ring (sleep / reboot / + power off / soft reboot, via hyprshutdown) + Super+Ctrl+O -> --menu opens the 5-category root (Power, Tools, Scripts, + Management, Windows) + +Single-instance, same pattern as astal-menu/main.py: the first launch builds the +(hidden) window and holds; later invocations forward their verb over D-Bus via +scripts/orbit-menu.sh instead of spawning a second python3+GTK4 process. + + main.py run the resident instance (stays hidden until toggled) + main.py --power show, rooted at Power + main.py --menu show, rooted at the full category menu + main.py --hide hide +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +# orbit-menu-start.sh LD_PRELOADs libgtk4-layer-shell (load-ordering requirement +# ahead of libwayland-client). Drop it once resident so it isn't inherited by +# anything this process launches (see astal-menu/main.py for the same rationale — +# some GTK4 apps abort at startup with the layer-shell lib preloaded). +os.environ.pop("LD_PRELOAD", None) + +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 menu_tree # noqa: E402 +import theme # noqa: E402 +from orbit_menu import OrbitMenu # noqa: E402 +from paths import APP_ID # noqa: E402 + + +class OrbitMenuApp(Gtk.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID, + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.window: OrbitMenu | None = None + self._current_mode: str | None = None # "power" | "menu" + + def do_startup(self) -> None: + Gtk.Application.do_startup(self) + theme.load_css() + self.window = OrbitMenu(menu_tree.full_menu_root(), on_close=self._on_closed) + self._current_mode = "menu" + self._register_actions() + self.hold() # stay alive with no visible window + + def _on_closed(self) -> None: + pass # nothing to do; the window just hides itself + + def _open(self, mode: str) -> None: + assert self.window is not None + if self.window.get_visible() and self._current_mode == mode: + self.window.set_visible(False) + return + if self._current_mode != mode: + root = menu_tree.power_only_root() if mode == "power" else menu_tree.full_menu_root() + self.window.set_root(root) + self._current_mode = mode + self.window.open_at_root() + + def _register_actions(self) -> None: + def add(name: str, callback) -> None: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.add_action(action) + + def on_hide(*_a) -> None: + assert self.window is not None + self.window.set_visible(False) + + add("power", lambda *_a: self._open("power")) + add("menu", lambda *_a: self._open("menu")) + add("hide", on_hide) + + def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int: + args = list(cmdline.get_arguments()[1:]) + verb = args[0] if args else "--daemon" + if self.window is None: + return 0 + if verb == "--power": + self._open("power") + elif verb == "--menu": + self._open("menu") + elif verb == "--hide": + self.window.set_visible(False) + # --daemon and anything else: no-op (stay resident, hidden) + return 0 + + def do_activate(self) -> None: + pass # resident instance: nothing to do on plain activate + + +def main() -> int: + GLib.set_prgname("orbit-menu") + return OrbitMenuApp().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktopenvs/hyprlua/orbit-menu/menu_tree.py b/desktopenvs/hyprlua/orbit-menu/menu_tree.py new file mode 100644 index 0000000..b1b0206 --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/menu_tree.py @@ -0,0 +1,182 @@ +"""Builds the 5-category MenuItem tree: Power, Tools, Scripts, Management, Windows. + +Power/Tools/Management are static lists. Scripts and Windows are `dynamic=True` — +orbit_menu.py never caches a dynamic node's page, so both are rebuilt fresh (current +~/Documents/Scripts listing, current `hyprctl clients -j`) every time they're +entered; that dynamism cascades to their children too (see OrbitMenu._cache_key), +so a window's own action ring and its "send to workspace" submenu are always live. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import actions +from orbit_menu import MenuItem +from paths import SCRIPTS_DIR + + +def _hyprctl_json(*args: str): + try: + out = subprocess.run(["hyprctl", *args, "-j"], capture_output=True, + text=True, timeout=1.5, check=True).stdout + return json.loads(out) + except (subprocess.SubprocessError, json.JSONDecodeError, OSError): + return None + + +# -- Power ------------------------------------------------------------------ + +def power_root() -> MenuItem: + return MenuItem(label="Power", icon="⏻", children=[ + MenuItem(label="Sleep", icon="☾", danger=True, + action=lambda: actions.hyprshutdown("systemctl suspend")), + MenuItem(label="Reboot", icon="⟳", danger=True, + action=lambda: actions.hyprshutdown("systemctl reboot")), + MenuItem(label="Power Off", icon="⏻", danger=True, + action=lambda: actions.hyprshutdown("systemctl poweroff")), + MenuItem(label="Soft Reboot", icon="⟲", danger=True, + action=lambda: actions.hyprshutdown("systemctl soft-reboot")), + ]) + + +# -- Tools -------------------------------------------------------------------- +# The little utilities also reachable via their own keybinds (see binds.lua) — +# gathered here as one launcher so they don't all need to be memorised. + +def tools_root() -> MenuItem: + def kitty(tag: str, cmd: str) -> str: + return f"[tag {tag}] kitty {cmd}" + + return MenuItem(label="Tools", icon="⚙", children=[ + MenuItem(label="Timer", + action=lambda: actions.launch( + kitty("+centered-S", "bash ~/.config/scripts/timer-pick"))), + MenuItem(label="SSH Manager", + action=lambda: actions.launch( + kitty("+centered-L", "-e ~/.config/scripts/amssh"))), + MenuItem(label="System Monitor", + action=lambda: actions.launch(kitty("+centered-L", "btop"))), + MenuItem(label="Screen Recorder", + action=lambda: actions.launch("~/.config/scripts/screenrec.sh")), + MenuItem(label="Color Picker", + action=lambda: actions.launch("hyprpicker | wl-copy")), + MenuItem(label="Caffeine", + action=lambda: actions.launch("~/.config/scripts/caffeine.sh")), + MenuItem(label="On-Screen Kbd", + action=lambda: actions.launch("~/.config/scripts/onscreenkb.sh")), + MenuItem(label="Calculator", + action=lambda: actions.launch("wofi-calc")), + MenuItem(label="Keybind Help", + action=lambda: actions.launch( + kitty("+centered", "~/.config/scripts/helpmenu.sh"))), + ]) + + +# -- Scripts (dynamic) --------------------------------------------------------- + +def _script_children() -> list[MenuItem]: + if not SCRIPTS_DIR.is_dir(): + return [MenuItem(label=f"(create {SCRIPTS_DIR})")] + entries = sorted(p for p in SCRIPTS_DIR.iterdir() if p.is_file()) + if not entries: + return [MenuItem(label="(no scripts found)")] + + items = [] + for p in entries: + if os.access(p, os.X_OK): + cmd = f"[tag +centered-L] kitty {p}" + elif p.suffix == ".py": + cmd = f"[tag +centered-L] kitty python3 {p}" + elif p.suffix in (".sh", ".bash"): + cmd = f"[tag +centered-L] kitty bash {p}" + else: + continue + items.append(MenuItem(label=p.stem, action=lambda c=cmd: actions.launch(c))) + return items or [MenuItem(label="(no runnable scripts)")] + + +def scripts_root() -> MenuItem: + return MenuItem(label="Scripts", icon="❯", dynamic=True, children=_script_children) + + +# -- Management ----------------------------------------------------------------- + +def management_root() -> MenuItem: + return MenuItem(label="Management", icon="⛶", children=[ + MenuItem(label="Wallpaper Picker", + action=lambda: actions.launch( + "[tag +centered-L] kitty -e ~/.config/scripts/wallpaper-picker ~/Pictures")), + MenuItem(label="Monitor Manager", + action=lambda: actions.launch( + "[tag +centered-L] kitty -e ~/.config/scripts/monitor-manager")), + ]) + + +# -- Windows (dynamic) ----------------------------------------------------------- + +def _window_label(w: dict) -> str: + return (w.get("title") or w.get("class") or "?")[:28] + + +def _workspace_children(address: str) -> list[MenuItem]: + workspaces = _hyprctl_json("workspaces") or [] + if not workspaces: + return [MenuItem(label="(no workspaces)")] + items = [] + for ws in sorted(workspaces, key=lambda w: w.get("id", 0)): + wsid = ws.get("id") + name = ws.get("name") or str(wsid) + items.append(MenuItem(label=name, icon="⇒", + action=lambda w=wsid: actions.send_to_workspace(address, w))) + return items + + +def _window_actions(w: dict) -> list[MenuItem]: + address = w["address"] + pid = w.get("pid", -1) + active = _hyprctl_json("activeworkspace") or {} + active_ws = active.get("id", w.get("workspace", {}).get("id", 0)) + title = _window_label(w) + return [ + MenuItem(label="Go To Window", icon="➤", + action=lambda: actions.focus_window(address)), + MenuItem(label="Bring Here", icon="⇤", + action=lambda: actions.bring_here(address, active_ws)), + MenuItem(label="Close Window", icon="✕", + action=lambda: actions.close_window(address)), + MenuItem(label="Force Kill", icon="⊘", danger=True, + action=lambda: actions.force_kill(pid)), + MenuItem(label="Get PID", icon="#", + action=lambda: actions.report_pid(pid, title)), + MenuItem(label="Send To Workspace", icon="⇒", + dynamic=True, children=lambda: _workspace_children(address)), + ] + + +def _window_children() -> list[MenuItem]: + clients = _hyprctl_json("clients") or [] + wins = [w for w in clients if w.get("mapped", True) and w.get("class")] + if not wins: + return [MenuItem(label="(no open windows)")] + wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0), _window_label(w).lower())) + return [MenuItem(label=_window_label(w), dynamic=True, + children=lambda w=w: _window_actions(w)) for w in wins] + + +def windows_root() -> MenuItem: + return MenuItem(label="Windows", icon="▣", dynamic=True, children=_window_children) + + +# -- Roots ------------------------------------------------------------------ + +def full_menu_root() -> MenuItem: + return MenuItem(label="Menu", children=[ + power_root(), tools_root(), scripts_root(), management_root(), windows_root(), + ]) + + +def power_only_root() -> MenuItem: + return power_root() diff --git a/desktopenvs/hyprlua/orbit-menu/orbit_menu.py b/desktopenvs/hyprlua/orbit-menu/orbit_menu.py new file mode 100644 index 0000000..331195c --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/orbit_menu.py @@ -0,0 +1,235 @@ +"""Orbit menu — the base element. + +A single "orbit" is one central node surrounded by a ring of child nodes, laid out +on a circle like moons around a planet. Clicking a child that has its own children +re-centers the view on that child and draws a fresh ring for *its* children — the +same base element recursively applied, so orbits nest arbitrarily deep. Clicking the +center (or Backspace) steps back out one level; Escape closes the menu. + +Each level is rendered once into its own Gtk.Fixed page and cached in a Gtk.Stack +keyed by the path of indices taken to reach it (e.g. (2, 0)); navigating in either +direction just swaps the stack's visible child, and the Stack's own CROSSFADE +transition gives the nested navigation a smooth fade instead of a hard cut. A +category with a dynamic child list (open windows, user scripts) passes +`dynamic=True` so its page is rebuilt — never cached — on every visit. + +Ported from the ~/code.dev/test-astralmenu prototype onto raw Gtk4LayerShell (no +Astal.Window dependency) to match astal-menu/window.py's layer-shell approach. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Callable, Optional + +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, Gtk # noqa: E402 +from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 + + +@dataclass +class MenuItem: + label: str + children: list["MenuItem"] | Callable[[], list["MenuItem"]] = field(default_factory=list) + action: Optional[Callable[[], None]] = None + danger: bool = False # red/danger tint (power actions) + dynamic: bool = False # rebuild this node's page every visit, never cache + icon: str = "" # optional leading nerd-font glyph, shown above label + + def resolved_children(self) -> list["MenuItem"]: + return self.children() if callable(self.children) else self.children + + +class OrbitMenu(Gtk.Window): + """A layer-shell window that renders a MenuItem tree as nested, cached orbits.""" + + CENTER_SIZE = 108 + NODE_SIZE = 66 # prototype was 84; shrunk >=1/5 (66/84 ≈ 0.79) per node + RADIUS = 168 + CANVAS_SIZE = 480 + + def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None): + super().__init__() + self.add_css_class("orbit-menu-window") + self._on_close = on_close + + self._root = root + self._stack_path: list[int] = [] # indices taken from root + self._pages: dict[tuple, Gtk.Fixed] = {} # cached pages, keyed by path + + self._init_layer_shell() + + self._gtkstack = Gtk.Stack( + transition_type=Gtk.StackTransitionType.CROSSFADE, + transition_duration=220, + ) + self._gtkstack.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + self.set_child(self._gtkstack) + + keys = Gtk.EventControllerKey() + keys.connect("key-pressed", self._on_key) + self.add_controller(keys) + + self._show_path(()) + + # -- layer shell ---------------------------------------------------- + def _init_layer_shell(self) -> None: + LayerShell.init_for_window(self) + LayerShell.set_layer(self, LayerShell.Layer.OVERLAY) + LayerShell.set_namespace(self, "orbit-menu") + LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.EXCLUSIVE) + # No anchors set => gtk-layer-shell centers the surface on its output. + + # -- item lookup / path helpers -------------------------------------- + def _item_at(self, path: tuple) -> MenuItem: + item = self._root + for i in path: + item = item.resolved_children()[i] + return item + + def _cache_key(self, path: tuple) -> tuple | None: + """None => never cache (path passes through a dynamic node).""" + item = self._root + for i in path: + if item.dynamic: + return None + item = item.resolved_children()[i] + return None if item.dynamic else path + + # -- page construction ------------------------------------------------ + def _build_page(self, path: tuple) -> Gtk.Fixed: + current = self._item_at(path) + fixed = Gtk.Fixed() + fixed.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + cx = cy = self.CANVAS_SIZE / 2 + + ring = Gtk.DrawingArea() + ring.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) + children = current.resolved_children() + if children: + ring.set_draw_func(self._make_ring_draw()) + fixed.put(ring, 0, 0) + + center_btn = Gtk.Button() + center_btn.set_has_frame(False) + center_btn.add_css_class("orbit-center") + if len(path) == 0: + center_btn.add_css_class("orbit-root") + center_btn.set_child(self._node_label(current.icon, current.label)) + center_btn.set_size_request(self.CENTER_SIZE, self.CENTER_SIZE) + center_btn.connect("clicked", lambda *_a: self._go_back()) + fixed.put(center_btn, cx - self.CENTER_SIZE / 2, cy - self.CENTER_SIZE / 2) + + count = len(children) + for i, item in enumerate(children): + angle = -math.pi / 2 + i * (2 * math.pi / max(count, 1)) + nx = cx + self.RADIUS * math.cos(angle) + ny = cy + self.RADIUS * math.sin(angle) + + node_btn = Gtk.Button() + node_btn.set_has_frame(False) + node_btn.add_css_class("orbit-node") + if item.danger: + node_btn.add_css_class("orbit-danger") + node_btn.set_child(self._node_label(item.icon, item.label)) + node_btn.set_size_request(self.NODE_SIZE, self.NODE_SIZE) + node_btn.set_tooltip_text(item.label) + node_btn.connect("clicked", lambda *_a, idx=i: self._on_node_clicked(idx)) + fixed.put(node_btn, nx - self.NODE_SIZE / 2, ny - self.NODE_SIZE / 2) + + return fixed + + @staticmethod + def _node_label(icon: str, label: str) -> Gtk.Widget: + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, + halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) + if icon: + ic = Gtk.Label(label=icon) + ic.add_css_class("orbit-node-icon") + box.append(ic) + lb = Gtk.Label(label=label, wrap=True, justify=Gtk.Justification.CENTER, + max_width_chars=10, lines=2, ellipsize=3) + lb.add_css_class("orbit-node-text") + box.append(lb) + return box + + def _make_ring_draw(self): + def draw(_area, cr, width, height): + cx, cy = width / 2, height / 2 + cr.set_source_rgba(1, 1, 1, 0.12) + cr.set_line_width(1.5) + cr.arc(cx, cy, self.RADIUS, 0, 2 * math.pi) + cr.stroke() + return draw + + # -- navigation --------------------------------------------------------- + def _show_path(self, path: tuple) -> None: + key = self._cache_key(path) + name = "/".join(str(i) for i in path) or "root" + # a dynamic path (or one passing through a dynamic ancestor) is rebuilt fresh + # and swapped in under a throwaway name so the stack still crossfades to it. + if key is None: + page = self._build_page(path) + name = f"{name}#live" + old = self._gtkstack.get_child_by_name(name) + if old is not None: + self._gtkstack.remove(old) + self._gtkstack.add_named(page, name) + elif key not in self._pages: + page = self._build_page(path) + self._pages[key] = page + self._gtkstack.add_named(page, name) + self._gtkstack.set_visible_child_name(name) + self._stack_path = list(path) + + def _on_node_clicked(self, idx: int) -> None: + path = tuple(self._stack_path) + item = self._item_at(path).resolved_children()[idx] + if item.resolved_children(): + self._show_path(path + (idx,)) + else: + if item.action: + item.action() + self._close() + + def _go_back(self) -> None: + if self._stack_path: + self._show_path(tuple(self._stack_path[:-1])) + else: + self._close() + + def _close(self) -> None: + self.set_visible(False) + if self._on_close: + self._on_close() + + def _on_key(self, _ctrl, keyval, _keycode, _state) -> bool: + if keyval == Gdk.KEY_Escape: + self._close() + return True + if keyval == Gdk.KEY_BackSpace: + self._go_back() + return True + return False + + # -- external control ---------------------------------------------------- + def set_root(self, root: MenuItem) -> None: + """Swap the whole tree (e.g. power-only <-> full category menu). Any + cached pages belonged to the old tree's indices, so they're dropped.""" + pages = self._gtkstack.get_pages() + for child in [pages.get_item(i).get_child() for i in range(pages.get_n_items())]: + self._gtkstack.remove(child) + self._pages.clear() + self._root = root + + def open_at_root(self) -> None: + """Reset to the top of the tree (dynamic pages along the way get rebuilt + the next time they're actually visited, not eagerly here).""" + self._show_path(()) + self.set_visible(True) + self.present() diff --git a/desktopenvs/hyprlua/orbit-menu/paths.py b/desktopenvs/hyprlua/orbit-menu/paths.py new file mode 100644 index 0000000..058028b --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/paths.py @@ -0,0 +1,12 @@ +"""Shared filesystem locations. Works whether run from the repo or ~/.config.""" + +from __future__ import annotations + +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +STYLE_DIR = BASE_DIR / "style" + +SCRIPTS_DIR = Path.home() / "Documents" / "Scripts" + +APP_ID = "eu.abdelbaki.orbitmenu" diff --git a/desktopenvs/hyprlua/orbit-menu/style/_colors.css b/desktopenvs/hyprlua/orbit-menu/style/_colors.css new file mode 100644 index 0000000..e226c9e --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/style/_colors.css @@ -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; diff --git a/desktopenvs/hyprlua/orbit-menu/style/style.css b/desktopenvs/hyprlua/orbit-menu/style/style.css new file mode 100644 index 0000000..92e6d48 --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/style/style.css @@ -0,0 +1,99 @@ +/* orbit-menu — CyberQueer radial menu. + * Colors come from _colors.css (@text/@bg/@accent/@violet/@danger), loaded first + * by theme.py. Nested-level transitions are handled by the Gtk.Stack's own + * CROSSFADE (see orbit_menu.py); this file only owns per-node hover/glow. + */ + +window.orbit-menu-window { + background: transparent; +} + +.orbit-center, +.orbit-node { + border-radius: 9999px; + border: 1.5px solid alpha(@text, 0.15); + color: @text; + transition: background 200ms ease, border-color 200ms ease, + box-shadow 220ms cubic-bezier(0.34, 1.56, 0.64, 1); + box-shadow: 0 0 0 0 alpha(@accent, 0); +} + +/* The cyberqueer GTK theme (~/.config/gtk-4.0/gtk.css) paints every widget node + * with `* { background-color: #1a1a1a }`, including the label/box that carries + * each node's icon+text — leaving a dark rectangle behind the glyphs instead of + * showing the round gradient button underneath. Force those descendants clear. */ +.orbit-center *, +.orbit-node * { + background: transparent; + background-color: transparent; + background-image: none; +} + +.orbit-node-icon { + font-size: 15px; + color: @text; +} + +.orbit-node-text { + font-size: 10px; + font-weight: 600; +} + +/* -- center node ------------------------------------------------------- */ + +.orbit-center { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.9), @bg 75%); + border-color: alpha(@accent, 0.45); + font-size: 13px; + font-weight: 700; +} +.orbit-center .orbit-node-text { + font-size: 12px; +} +.orbit-center:hover { + border-color: @accent; + box-shadow: 0 0 18px 2px alpha(@accent, 0.55); +} + +.orbit-root { + border-color: alpha(@text, 0.25); +} + +/* -- ring nodes ---------------------------------------------------------- */ + +.orbit-node { + background-image: radial-gradient(circle at 35% 30%, alpha(@bg, 0.4), @bg 80%); + border-color: alpha(@violet, 0.5); +} + +.orbit-node:hover { + background-image: radial-gradient(circle at 35% 30%, alpha(@violet, 0.55), @bg 80%); + border-color: @violet; + box-shadow: 0 0 16px 1px alpha(@violet, 0.6); +} + +.orbit-node:active { + box-shadow: 0 0 22px 3px alpha(@violet, 0.85); +} + +/* -- danger nodes (power actions, force kill) ---------------------------- */ + +.orbit-danger { + border-color: alpha(@danger, 0.55); + background-image: radial-gradient(circle at 35% 30%, alpha(@danger, 0.22), @bg 80%); +} + +.orbit-danger .orbit-node-icon, +.orbit-danger .orbit-node-text { + color: alpha(@text, 0.95); +} + +.orbit-danger:hover { + border-color: @danger; + background-image: radial-gradient(circle at 35% 30%, alpha(@danger, 0.5), @bg 80%); + box-shadow: 0 0 18px 2px alpha(@danger, 0.7); +} + +.orbit-danger:active { + box-shadow: 0 0 26px 4px alpha(@danger, 0.9); +} diff --git a/desktopenvs/hyprlua/orbit-menu/theme.py b/desktopenvs/hyprlua/orbit-menu/theme.py new file mode 100644 index 0000000..a36995b --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/theme.py @@ -0,0 +1,30 @@ +"""Load the two stylesheets as ordered CSS providers — same scheme as astal-menu's +theme.py. _colors.css (generated from ~/Dotfiles/colors.conf by apply-theme.sh) +defines the CyberQueer @define-color names; style.css consumes them. + +Priority is USER+1 for the same reason as astal-menu: the CyberQueer GTK theme at +~/.config/gtk-4.0/gtk.css loads at PRIORITY_USER (800), above APPLICATION (600), and +would beat our transparent structural containers otherwise. +""" + +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_USER + 1 + ) diff --git a/desktopenvs/hyprlua/scripts/orbit-menu-start.sh b/desktopenvs/hyprlua/scripts/orbit-menu-start.sh new file mode 100755 index 0000000..0abbb53 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/orbit-menu-start.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Resident launcher for the orbit-menu GTK4 radial power/utility menu. +# Same LD_PRELOAD requirement and rationale as astal-menu-start.sh: gtk4-layer-shell +# must load before libwayland-client, which isn't guaranteed under PyGObject. + +APP="${HOME}/.config/orbit-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" "$@" diff --git a/desktopenvs/hyprlua/scripts/orbit-menu.sh b/desktopenvs/hyprlua/scripts/orbit-menu.sh new file mode 100755 index 0000000..b6660f1 --- /dev/null +++ b/desktopenvs/hyprlua/scripts/orbit-menu.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Toggle the orbit-menu radial popup. Forwards a verb to the resident daemon over +# D-Bus; if the daemon isn't running yet, starts it first. Mirrors menu-toggle.sh. +# +# orbit-menu.sh power -> open straight into the Power ring (sleep/reboot/ +# poweroff/soft-reboot); re-press to close +# orbit-menu.sh menu -> open the 5-category root; re-press to close +# +# (No `set -e`: a non-zero `busctl` in the wait loop is expected and must not +# abort the script before it forwards the verb.) + +BUS="eu.abdelbaki.orbitmenu" +OBJ="/eu/abdelbaki/orbitmenu" +APP="${HOME}/.config/orbit-menu/main.py" + +case "${1:-menu}" in + power) VERB="--power"; ACTION="power" ;; + *) VERB="--menu"; ACTION="menu" ;; +esac + +registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; } + +if registered; then + # Fast path: activate directly over D-Bus instead of cold-starting a second + # python3 + PyGObject + GTK4 process just to forward one verb. + exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \ + --method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null +fi + +# Not registered yet: start the daemon and wait for it to come up, then retry. +"${HOME}/.config/scripts/orbit-menu-start.sh" >/dev/null 2>&1 & +for _ in $(seq 1 25); do + if registered; then + exec "$0" "$@" + fi + sleep 0.2 +done + +# Still not registered after 5s: fall back to becoming the primary instance +# ourselves (Gio single-instance handles the routing either way). +exec python3 "$APP" "$VERB" diff --git a/etc-systemd-sleep/90-hyprlua-hibernate-delay.conf b/etc-systemd-sleep/90-hyprlua-hibernate-delay.conf new file mode 100644 index 0000000..5e9227c --- /dev/null +++ b/etc-systemd-sleep/90-hyprlua-hibernate-delay.conf @@ -0,0 +1,9 @@ +# Deployed to /etc/systemd/sleep.conf.d/90-hyprlua-hibernate-delay.conf by +# sysupdate.sh's _deploy_systemd_sleep(). Paired with hypr/hypridle.conf's idle +# listeners: lock at 30s, `systemctl suspend-then-hibernate` at 1 minute. Without +# HibernateDelaySec set, systemd estimates the suspend-to-hibernate delay from +# battery drain (or a multi-hour fallback on AC-only machines) — not the fixed +# schedule we want. 4740s (79 min) here + the 1 min idle-to-sleep listener lands +# hibernate ~80 minutes after the machine first went idle. +[Sleep] +HibernateDelaySec=4740 diff --git a/sysupdate.sh b/sysupdate.sh index e1e73e9..b84df0e 100755 --- a/sysupdate.sh +++ b/sysupdate.sh @@ -741,6 +741,20 @@ _ensure_astal_menu_deps() { # Ensure hyprshutdown is installed. hypr/usr/binds.lua's power binds # (Super+Shift/Ctrl/Ctrl+Shift+O) call it directly; without it those binds # silently no-op. It's in the official extra repo, not AUR. +_deploy_systemd_sleep() { + local de_dir; de_dir=$(_de_source_dir) + [[ "${de_dir:-}" == */hyprlua ]] || return 0 + + local src="$DOTFILES/etc-systemd-sleep" + [[ -d "$src" ]] || return 0 + local f + for f in "$src"/*.conf; do + [[ -f "$f" ]] || continue + sudo install -Dm644 "$f" "/etc/systemd/sleep.conf.d/$(basename "$f")" + done && ok "systemd sleep.conf.d deployed (hibernate delay)" \ + || warn "systemd sleep.conf.d deploy had errors" +} + _ensure_hyprshutdown() { local de_dir; de_dir=$(_de_source_dir) [[ "${de_dir:-}" == */hyprlua ]] || return 0 @@ -771,6 +785,7 @@ sync_configs() { _ensure_plymouth_regreet _ensure_astal_menu_deps _ensure_hyprshutdown + _deploy_systemd_sleep # ── Preferred: use the installed update-configs.sh ─────────────────────── local cfg_script