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.main
parent
2fd7b510ef
commit
2fa3414e31
|
|
@ -41,6 +41,7 @@ USER_FILES=(
|
||||||
"desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.css"
|
"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/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/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/vicinae/cyberqueer.toml|$HOME/.config/vicinae/cyberqueer.toml"
|
||||||
"desktopenvs/hyprland/scripts/onscreenkb.sh|$HOME/.config/scripts/onscreenkb.sh"
|
"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"
|
"desktopenvs/hyprland/spicetify/Themes/cli-cyberqueer/color.ini|$HOME/.config/spicetify/Themes/cli-cyberqueer/color.ini"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ config hypr except usr
|
||||||
config kitty
|
config kitty
|
||||||
config mimeapps.list
|
config mimeapps.list
|
||||||
config nwg-panel
|
config nwg-panel
|
||||||
|
config orbit-menu
|
||||||
config scripts
|
config scripts
|
||||||
config ulauncher
|
config ulauncher
|
||||||
config vicinae
|
config vicinae
|
||||||
|
|
|
||||||
|
|
@ -117,3 +117,24 @@ menuitem:hover {
|
||||||
.menu-launcher:hover {
|
.menu-launcher:hover {
|
||||||
color: #5018dd;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
(box :orientation "h" :space-evenly false :halign "start"
|
(box :orientation "h" :space-evenly false :halign "start"
|
||||||
; astal-menu launcher — opens the popup control centre / app drawer
|
; 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 "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)
|
(workspaceWidget :monitor monitor)
|
||||||
(button :onclick "~/.config/scripts/menu-toggle.sh toggle top" :class "music" {" ${activewindow}"})
|
(button :onclick "~/.config/scripts/menu-toggle.sh toggle top" :class "music" {" ${activewindow}"})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,27 @@ tooltip {
|
||||||
color: #5018dd;
|
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 {
|
menuitem {
|
||||||
border: solid;
|
border: solid;
|
||||||
border-width: 3px;
|
border-width: 3px;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
(osk)
|
(osk)
|
||||||
(box :class "music" {"${battery}"})
|
(box :class "music" {"${battery}"})
|
||||||
(button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""})
|
(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 " "
|
(metric :label " "
|
||||||
:value volume
|
:value volume
|
||||||
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
:onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%"
|
||||||
|
|
|
||||||
|
|
@ -117,3 +117,24 @@ menuitem:hover {
|
||||||
.menu-launcher:hover {
|
.menu-launcher:hover {
|
||||||
color: #5018dd;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@
|
||||||
(box :orientation "h" :space-evenly false :halign "start"
|
(box :orientation "h" :space-evenly false :halign "start"
|
||||||
; astal-menu launcher — opens the popup control centre / app drawer
|
; 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 "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"
|
; Battery percentage badge — styled as a pill with class "music"
|
||||||
(box :class "music" {"${battery}"})
|
(box :class "music" {"${battery}"})
|
||||||
; Workspace dots — one button per active workspace on this monitor
|
; Workspace dots — one button per active workspace on this monitor
|
||||||
|
|
|
||||||
|
|
@ -38,21 +38,19 @@ general {
|
||||||
# while motion is detected, so these timeouts only run when you've actually
|
# while motion is detected, so these timeouts only run when you've actually
|
||||||
# stepped away.
|
# stepped away.
|
||||||
|
|
||||||
# First idle listener: lock the screen after 2.5 minutes of inactivity.
|
# First idle listener: lock the screen after 30s 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.
|
|
||||||
listener {
|
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.
|
# loginctl lock-session triggers the session lock signal; hyprlock picks it up.
|
||||||
on-timeout = loginctl lock-session
|
on-timeout = loginctl lock-session
|
||||||
}
|
}
|
||||||
|
|
||||||
# Second idle listener: suspend (then hibernate) after 10 minutes of inactivity.
|
# Second idle listener: sleep after 1 minute of inactivity. suspend-then-hibernate
|
||||||
# This fires only if the lock screen has already been active for ~7.5 minutes,
|
# suspends to RAM immediately (fast resume); HibernateDelaySec — set system-wide by
|
||||||
# meaning the user is confirmed away. suspend-then-hibernate first suspends to RAM
|
# hypr/systemd-sleep.conf, deployed to /etc/systemd/sleep.conf.d/ by sysupdate.sh —
|
||||||
# for fast resume; after the hibernate delay (configured in systemd-sleep.conf)
|
# then hibernates (writes RAM to disk, fully powers it down) 79 minutes later, so
|
||||||
# it writes the RAM image to disk for power-off safety.
|
# hibernate lands ~80 minutes after the machine first went idle (1min + 79min).
|
||||||
listener {
|
listener {
|
||||||
timeout = 600 # 10 min — suspend
|
timeout = 60 # 1 min — sleep
|
||||||
on-timeout = systemctl suspend-then-hibernate
|
on-timeout = systemctl suspend-then-hibernate
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ hl.on("hyprland.start", function()
|
||||||
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
|
hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50")
|
||||||
hl.exec_cmd("hyprpaper")
|
hl.exec_cmd("hyprpaper")
|
||||||
hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/astal-menu-start.sh")
|
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-applet")
|
||||||
hl.exec_cmd("blueman-tray")
|
hl.exec_cmd("blueman-tray")
|
||||||
hl.exec_cmd("hypridle")
|
hl.exec_cmd("hypridle")
|
||||||
|
|
|
||||||
|
|
@ -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
|
-- 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.
|
-- 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 .. " + O", hl.dsp.exec_cmd("hyprlock"))
|
||||||
hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("hyprshutdown")) -- graceful logout
|
hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("~/.config/scripts/orbit-menu.sh power"))
|
||||||
hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd('hyprshutdown -p "systemctl poweroff"')) -- graceful power off
|
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 .. " + 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 + O", hl.dsp.exec_cmd("~/.config/scripts/pwr-dmenu.sh"))
|
||||||
hl.bind(mainMod .. " + ALT + CTRL + SHIFT + END", hl.dsp.exit())
|
hl.bind(mainMod .. " + ALT + CTRL + SHIFT + END", hl.dsp.exit())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
@ -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 <pid>`, 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"])
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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" "$@"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
15
sysupdate.sh
15
sysupdate.sh
|
|
@ -741,6 +741,20 @@ _ensure_astal_menu_deps() {
|
||||||
# Ensure hyprshutdown is installed. hypr/usr/binds.lua's power binds
|
# Ensure hyprshutdown is installed. hypr/usr/binds.lua's power binds
|
||||||
# (Super+Shift/Ctrl/Ctrl+Shift+O) call it directly; without it those 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.
|
# 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() {
|
_ensure_hyprshutdown() {
|
||||||
local de_dir; de_dir=$(_de_source_dir)
|
local de_dir; de_dir=$(_de_source_dir)
|
||||||
[[ "${de_dir:-}" == */hyprlua ]] || return 0
|
[[ "${de_dir:-}" == */hyprlua ]] || return 0
|
||||||
|
|
@ -771,6 +785,7 @@ sync_configs() {
|
||||||
_ensure_plymouth_regreet
|
_ensure_plymouth_regreet
|
||||||
_ensure_astal_menu_deps
|
_ensure_astal_menu_deps
|
||||||
_ensure_hyprshutdown
|
_ensure_hyprshutdown
|
||||||
|
_deploy_systemd_sleep
|
||||||
|
|
||||||
# ── Preferred: use the installed update-configs.sh ───────────────────────
|
# ── Preferred: use the installed update-configs.sh ───────────────────────
|
||||||
local cfg_script
|
local cfg_script
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue