Compare commits

..

No commits in common. "779e365cb858b22ca8b29a8b6647ff986aa37e38" and "2fd7b510ef117244bfb1f4097054952035a1c150" have entirely different histories.

26 changed files with 12 additions and 1320 deletions

View File

@ -41,7 +41,6 @@ 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"

View File

@ -19,7 +19,6 @@ 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

View File

@ -117,24 +117,3 @@ 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);
}

View File

@ -22,7 +22,6 @@
(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}"})
) )

View File

@ -98,27 +98,6 @@ 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;

View File

@ -27,7 +27,6 @@
(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@ {}%"

View File

@ -117,24 +117,3 @@ 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);
}

View File

@ -56,8 +56,6 @@
(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

View File

@ -38,19 +38,21 @@ 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 30s of inactivity. # 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.
listener { listener {
timeout = 30 # 30s — lock screen timeout = 150 # 2.5 min — 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: sleep after 1 minute of inactivity. suspend-then-hibernate # Second idle listener: suspend (then hibernate) after 10 minutes of inactivity.
# suspends to RAM immediately (fast resume); HibernateDelaySec — set system-wide by # This fires only if the lock screen has already been active for ~7.5 minutes,
# hypr/systemd-sleep.conf, deployed to /etc/systemd/sleep.conf.d/ by sysupdate.sh — # meaning the user is confirmed away. suspend-then-hibernate first suspends to RAM
# then hibernates (writes RAM to disk, fully powers it down) 79 minutes later, so # for fast resume; after the hibernate delay (configured in systemd-sleep.conf)
# hibernate lands ~80 minutes after the machine first went idle (1min + 79min). # it writes the RAM image to disk for power-off safety.
listener { listener {
timeout = 60 # 1 min — sleep timeout = 600 # 10 min — suspend
on-timeout = systemctl suspend-then-hibernate on-timeout = systemctl suspend-then-hibernate
} }

View File

@ -16,7 +16,6 @@ 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")

View File

@ -90,12 +90,9 @@ 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("~/.config/scripts/orbit-menu.sh power")) hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("hyprshutdown")) -- graceful logout
hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd("~/.config/scripts/orbit-menu.sh menu")) hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd('hyprshutdown -p "systemctl poweroff"')) -- graceful power off
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())

View File

@ -1,2 +0,0 @@
__pycache__/
*.pyc

View File

@ -1,63 +0,0 @@
"""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"])

View File

@ -1,30 +0,0 @@
"""Tiny user-editable config file: ~/.local/state/orbit-menu/config.json.
Currently a single toggle. Read once at startup (main.py); the satellites flag
is passed into OrbitMenu's constructor rather than polled, so a change takes
effect on the next orbit-menu-start.sh restart, not live.
"""
from __future__ import annotations
import json
from paths import CONFIG_FILE, ensure_dirs
_DEFAULTS = {"satellites": True}
def _load() -> dict:
try:
data = json.loads(CONFIG_FILE.read_text())
except (FileNotFoundError, json.JSONDecodeError):
data = {}
merged = {**_DEFAULTS, **data}
if not CONFIG_FILE.exists():
ensure_dirs()
CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n")
return merged
def satellites_enabled() -> bool:
return bool(_load().get("satellites", True))

View File

@ -1,63 +0,0 @@
"""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)

View File

@ -1,114 +0,0 @@
#!/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 config # 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,
satellites=config.satellites_enabled())
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())

View File

@ -1,182 +0,0 @@
"""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()

View File

@ -1,546 +0,0 @@
"""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
import random
from dataclasses import dataclass, field
from typing import Callable, Optional
import cairo
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
# CyberQueer accent/violet, matching style/_colors.css — hardcoded here (as
# lib/border.py does for its own Cairo drawing) since Cairo paints directly and
# doesn't see GTK CSS @define-color names.
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
@dataclass
class _Satellite:
glyph: str
height: float # fixed radial distance from its node — never changes at runtime
wobble_amp: float # extra angle (radians) layered on top of the mouse-facing direction
wobble_speed: float
phase: float
color: tuple[float, float, float]
@dataclass
class _Assignment:
"""One satellite bound to one node — decided once per page build (see
OrbitMenu._pick_assignments), not re-rolled every frame. A node may end up with
two assignments; since each _Satellite has its own fixed `height`, the two
already ride different-radius orbits, and _pick_assignments additionally
widens the gap between them (see effective_height) so they never crowd."""
sat: _Satellite
node: tuple[float, float]
bracket: Optional[tuple[str, str]] = None # flanking glyphs, tangent to the orbit
line_style: str = "solid" # "solid" | "dashed" | "dotted"
axis: Optional[str] = None # None -> faces the cursor; "x"/"y" -> see below
effective_height: float = 0.0 # sat.height, widened if this node is doubled up
mouse_offset: float = 0.0 # per-assignment phase added to the axis angle
mult_x: float = 1.0 # independent per-assignment response strength
mult_y: float = 1.0 # to horizontal vs vertical mouse movement
@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
# Cosmetic satellites: glyphs that each orbit ONE ring node at a fixed radial
# "height" from it (set once here, never changed at runtime) — only their angle
# around that node moves, easing to face the cursor plus a small idle wobble so
# they're alive even when the mouse is still. Which nodes get one, how many, and
# which glyph goes where is re-rolled once per page build (_pick_assignments) —
# varied each time you land on a category, stable while that page is showing.
_GLYPH_FONT = "Agave Nerd Font Mono"
_SATELLITES = [
_Satellite(glyph="\uef5f", height=42, wobble_amp=0.30, wobble_speed=0.6, phase=0.0, color=_ACCENT),
_Satellite(glyph="\U000f0471", height=50, wobble_amp=0.22, wobble_speed=0.45, phase=1.4, color=_VIOLET),
_Satellite(glyph="\uf197", height=46, wobble_amp=0.35, wobble_speed=0.7, phase=2.6, color=_VIOLET),
_Satellite(glyph="\U000f1383", height=54, wobble_amp=0.25, wobble_speed=0.5, phase=4.0, color=_ACCENT),
]
# Flanking glyphs drawn tangent to the orbit around some (not all) satellites —
# e.g. "-< <glyph> >-". Rotated to the local tangent direction in _draw_glyph.
_BRACKETS = [("-<", ">-"), ("-[", "]-"), ("-(", ")-"), ("-{", "}-"), ("-|", "|-")]
_BRACKET_CHANCE = 0.45
def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None,
satellites: bool = True):
super().__init__()
self.add_css_class("orbit-menu-window")
self._on_close = on_close
self._satellites_enabled = satellites
self._root = root
self._stack_path: list[int] = [] # indices taken from root
self._pages: dict[tuple, Gtk.Fixed] = {} # cached pages, keyed by path
self._page_rings: dict[str, Gtk.DrawingArea] = {}
self._current_ring: Gtk.DrawingArea | None = None
# satellite/mouse-parallax state (unused entirely if satellites=False)
cx = cy = self.CANVAS_SIZE / 2
self._mouse_target = (cx, cy)
self._mouse_smooth = (cx, cy)
self._sat_time = 0.0
self._last_tick: float | None = None
self._tick_id: int | None = None
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)
if self._satellites_enabled:
motion = Gtk.EventControllerMotion()
motion.connect("motion", self._on_motion)
motion.connect("leave", self._on_motion_leave)
self.add_controller(motion)
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) -> tuple[Gtk.Fixed, Gtk.DrawingArea]:
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()
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)
node_positions: list[tuple[float, float]] = []
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_positions.append((nx, ny))
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)
assignments = self._pick_assignments(node_positions, center=(cx, cy)) \
if self._satellites_enabled else []
ring.set_draw_func(self._make_page_draw(has_ring=bool(children), assignments=assignments))
return fixed, ring
_MAX_PER_NODE = 2
_MIN_SPREAD_NODES = 3 # every satellite spawned is spread across >= this
# many distinct nodes, node count permitting
_DOUBLED_MIN_GAP = 22 # minimum radius gap between 2 satellites sharing a node
_CENTER_HEIGHT_BUMP = 30 # extra clearance so satellites orbit outside the
# (much bigger) center button, not underneath it
_LINE_STYLES = ["solid", "dashed", "dotted"]
_COUNT_BASE = 2
_COUNT_RANDOM_MOD = 3 # random upper bound = (_COUNT_RANDOM_MOD % node-amount) + _COUNT_RANDOM_ADD
_COUNT_RANDOM_ADD = 2
def _pick_assignments(self, node_positions: list[tuple[float, float]],
center: Optional[tuple[float, float]] = None) -> list[_Assignment]:
"""Randomize which nodes get a satellite, which glyph goes where, and which
(if any) get a tangential bracket decided once per page build. Total count
is always _COUNT_BASE + random(1, (_COUNT_RANDOM_MOD % node-amount) +
_COUNT_RANDOM_ADD) scales down on pages with few nodes clamped to
however many actually fit given _MAX_PER_NODE; since there are only
len(_SATELLITES) distinct glyphs, counts above that cycle through them again
rather than repeating the same one back-to-back. Placement first seeds
_MIN_SPREAD_NODES distinct nodes one each, then drops the rest onto random
nodes with room so on any page with enough nodes, they land on at least 3
of them, never crammed onto 1-2. `center` (the center/back button's
position) is just one more candidate node it can get satellites too, with
extra height so they clear it."""
all_positions = list(node_positions)
if center is not None:
all_positions.append(center)
n_nodes = len(all_positions)
if n_nodes == 0:
return []
random_max = (self._COUNT_RANDOM_MOD % n_nodes) + self._COUNT_RANDOM_ADD
count = self._COUNT_BASE + random.randint(1, random_max)
count = min(count, n_nodes * self._MAX_PER_NODE)
sats: list[_Satellite] = []
while len(sats) < count:
cycle = list(self._SATELLITES)
random.shuffle(cycle)
sats.extend(cycle)
sats = sats[:count]
random.shuffle(sats)
node_order = list(range(n_nodes))
random.shuffle(node_order)
seed_count = min(self._MIN_SPREAD_NODES, n_nodes)
per_node = [0] * n_nodes
placements: list[tuple[_Satellite, int]] = []
for idx in node_order[:seed_count]:
if not sats:
break
per_node[idx] += 1
placements.append((sats.pop(), idx))
for sat in sats:
candidates = [i for i, c in enumerate(per_node) if c < self._MAX_PER_NODE]
if not candidates:
break # every node already at cap (only possible when n_nodes is tiny)
idx = random.choice(candidates)
per_node[idx] += 1
placements.append((sat, idx))
assignments = [
_Assignment(
sat=sat, node=all_positions[idx],
effective_height=sat.height + (self._CENTER_HEIGHT_BUMP if all_positions[idx] == center else 0),
bracket=random.choice(self._BRACKETS) if random.random() < self._BRACKET_CHANCE else None,
line_style=random.choice(self._LINE_STYLES),
# Independent per-satellite response strength to horizontal vs.
# vertical mouse movement — drawn separately, so a given satellite
# often ends up noticeably more reactive in one direction than
# the other instead of moving toward the cursor symmetrically.
mult_x=random.uniform(0.5, 1.8),
mult_y=random.uniform(0.5, 1.8),
)
for sat, idx in placements
]
# A node with 2 satellites: split their motion instead of both facing the
# cursor identically (one tracks the mouse's X only, the other its Y — see
# _draw_satellites), widen the gap between their orbit radii so the higher
# one reads as clearly further out, and give each its own random phase
# offset — so when a page has several doubled nodes, they drift out of
# sync with each other instead of all sweeping in lockstep.
by_node: dict[tuple[float, float], list[_Assignment]] = {}
for a in assignments:
by_node.setdefault(a.node, []).append(a)
for group in by_node.values():
if len(group) == self._MAX_PER_NODE:
random.shuffle(group)
group[0].axis = "x"
group[1].axis = "y"
for g in group:
g.mouse_offset = random.uniform(0.0, 2 * math.pi)
lo, hi = sorted(group, key=lambda a: a.effective_height)
if hi.effective_height - lo.effective_height < self._DOUBLED_MIN_GAP:
hi.effective_height = lo.effective_height + self._DOUBLED_MIN_GAP
return assignments
@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_page_draw(self, has_ring: bool, assignments: list[_Assignment]):
def draw(_area, cr, width, height):
if has_ring:
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()
if self._satellites_enabled:
self._draw_satellites(cr, assignments)
return draw
def _draw_satellites(self, cr, assignments: list[_Assignment]) -> None:
"""Each assigned glyph orbits its node at a FIXED radial height (set once in
_SATELLITES, never changed here) only the angle around the node moves: it
eases to face the cursor (see _on_tick's mouse smoothing) plus a small idle
wobble so it's alive even when the mouse sits still. Purely cosmetic, never
hit-testable. Orbit lines are drawn first, in their own pass, so no glyph's
glow ends up half-covered by a line belonging to a different satellite."""
for a in assignments:
nx, ny = a.node
self._draw_orbit_line(cr, nx, ny, a.effective_height, a.sat.color, a.line_style)
mx, my = self._mouse_smooth
for a in assignments:
nx, ny = a.node
if a.axis == "x":
# tracks only the cursor's X position — a full sweep left-to-right
# across the canvas takes it all the way around its node. Each
# doubled node's own random offset (see _pick_assignments) keeps
# multiple doubled nodes from all sweeping in lockstep.
face_angle = (mx / self.CANVAS_SIZE) * 2 * math.pi * a.mult_x + a.mouse_offset
elif a.axis == "y":
face_angle = (my / self.CANVAS_SIZE) * 2 * math.pi * a.mult_y + a.mouse_offset
else:
# mult_x/mult_y independently scale each axis before the direction
# is computed, so this satellite can be visibly more reactive to
# horizontal cursor movement than vertical, or vice versa.
dx, dy = (mx - nx) * a.mult_x, (my - ny) * a.mult_y
face_angle = math.atan2(dy, dx) if (dx or dy) else 0.0
angle = face_angle + a.sat.wobble_amp * math.sin(self._sat_time * a.sat.wobble_speed + a.sat.phase)
px = nx + a.effective_height * math.cos(angle)
py = ny + a.effective_height * math.sin(angle)
self._draw_glyph(cr, px, py, a.sat.glyph, a.sat.color, orbit_angle=angle, bracket=a.bracket)
def _draw_orbit_line(self, cr, nx: float, ny: float, radius: float,
color: tuple[float, float, float], style: str) -> None:
"""A circle tracing the path a satellite's angle sweeps around its node —
solid/dashed/dotted per-assignment (see _pick_assignments), fixed for as
long as that page's assignments are, so it never flickers style. Kept
visibly faint against the dark theme, but not so faint it disappears."""
r, g, b = color
cr.save()
if style == "dashed":
cr.set_dash([7.0, 5.0])
elif style == "dotted":
cr.set_dash([2.0, 3.5])
cr.set_source_rgba(r, g, b, 0.38)
cr.set_line_width(1.4)
cr.arc(nx, ny, radius, 0, 2 * math.pi)
cr.stroke()
cr.restore()
def _draw_glyph(self, cr, x: float, y: float, glyph: str, color: tuple[float, float, float],
orbit_angle: float = 0.0, bracket: Optional[tuple[str, str]] = None) -> None:
r, g, b = color
glow = cairo.RadialGradient(x, y, 0, x, y, 16)
glow.add_color_stop_rgba(0.0, r, g, b, 0.45)
glow.add_color_stop_rgba(1.0, r, g, b, 0.0)
cr.set_source(glow)
cr.arc(x, y, 16, 0, 2 * math.pi)
cr.fill()
cr.select_font_face(self._GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
cr.set_font_size(15)
ext = cr.text_extents(glyph)
cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing)
cr.set_source_rgba(r, g, b, 0.95)
cr.show_text(glyph)
if bracket is not None:
self._draw_bracket(cr, x, y, orbit_angle, bracket, color)
def _draw_bracket(self, cr, x: float, y: float, orbit_angle: float,
bracket: tuple[str, str], color: tuple[float, float, float]) -> None:
"""Flank the glyph at (x, y) with two small strings along the orbit's
TANGENT at this point (perpendicular to the radial orbit_angle), e.g.
"-< ->-" either side like brackets riding along the direction of travel
rather than pointing at the node."""
r, g, b = color
tangent = orbit_angle + math.pi / 2
# keep the text roughly upright instead of upside-down on the far side
if math.cos(tangent) < 0:
tangent += math.pi
cr.select_font_face(self._GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
cr.set_font_size(11)
offset = 15.0
for text, sign in ((bracket[0], -1), (bracket[1], 1)):
ext = cr.text_extents(text)
bx = x + sign * offset * math.cos(tangent)
by = y + sign * offset * math.sin(tangent)
cr.save()
cr.translate(bx, by)
cr.rotate(tangent)
cr.move_to(-ext.width / 2 - ext.x_bearing, -ext.height / 2 - ext.y_bearing)
cr.set_source_rgba(r, g, b, 0.7)
cr.show_text(text)
cr.restore()
# -- mouse parallax / animation loop -------------------------------------
def _on_motion(self, _ctrl, x: float, y: float) -> None:
self._mouse_target = (x, y)
def _on_motion_leave(self, _ctrl) -> None:
self._mouse_target = (self.CANVAS_SIZE / 2, self.CANVAS_SIZE / 2)
def _on_tick(self, _widget, frame_clock) -> bool:
now = frame_clock.get_frame_time() / 1_000_000 # -> seconds
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
self._last_tick = now
self._sat_time += dt
mx, my = self._mouse_target
sx, sy = self._mouse_smooth
ease = 1 - math.exp(-dt * 6.0) # frame-rate independent lerp toward the cursor
self._mouse_smooth = (sx + (mx - sx) * ease, sy + (my - sy) * ease)
if self._current_ring is not None:
self._current_ring.queue_draw()
return True # keep ticking every frame while the window is mapped
# -- 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, ring = 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)
self._page_rings[name] = ring
elif key not in self._pages:
page, ring = self._build_page(path)
self._pages[key] = page
self._gtkstack.add_named(page, name)
self._page_rings[name] = ring
self._gtkstack.set_visible_child_name(name)
self._stack_path = list(path)
self._current_ring = self._page_rings.get(name)
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._tick_id is not None:
self.remove_tick_callback(self._tick_id)
self._tick_id = None
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._page_rings.clear()
self._current_ring = None
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()
if self._satellites_enabled and self._tick_id is None:
self._tick_id = self.add_tick_callback(self._on_tick)

View File

@ -1,23 +0,0 @@
"""Shared filesystem locations. Works whether run from the repo or ~/.config."""
from __future__ import annotations
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
STYLE_DIR = BASE_DIR / "style"
SCRIPTS_DIR = Path.home() / "Documents" / "Scripts"
# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does
# `rm -rf ~/.config/orbit-menu` on every dotfiles sync (see astal-menu/paths.py
# for the same rationale), which would wipe a hand-edited config on the spot.
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "orbit-menu"
CONFIG_FILE = STATE_DIR / "config.json"
APP_ID = "eu.abdelbaki.orbitmenu"
def ensure_dirs() -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)

View File

@ -1,8 +0,0 @@
/* 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;

View File

@ -1,99 +0,0 @@
/* 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);
}

View File

@ -1,30 +0,0 @@
"""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
)

View File

@ -1,12 +0,0 @@
#!/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" "$@"

View File

@ -1,41 +0,0 @@
#!/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"

View File

@ -1,9 +0,0 @@
# 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

View File

@ -741,20 +741,6 @@ _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
@ -785,7 +771,6 @@ 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