revert(hyprlua): remove Cosmonaut Shell components, restore pre-Friday state

orbit-menu and horizon-dock (and their eww-bar launcher buttons, autostart
hooks, and O-bind wrapping) were built inside hyprlua before hyprdrive
existed as a separate DE. hyprdrive is now the home for the full Cosmonaut
Shell suite, so hyprlua drops back to eww bars + vicinae launcher + the
astal-menu pop-open menu, unchanged, matching last Thursday's state.
hyprdrive itself is untouched.
main
Amir Alexander Abdelbaki 2026-07-20 08:37:12 +02:00
parent 74fc9aefaf
commit 9d59565469
36 changed files with 2 additions and 2612 deletions

View File

@ -41,8 +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/hyprlua/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css"
"desktopenvs/hyprdrive/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css" "desktopenvs/hyprdrive/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css"
"desktopenvs/hyprdrive/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css" "desktopenvs/hyprdrive/horizon-dock/style/_colors.css|$HOME/.config/horizon-dock/style/_colors.css"
"desktopenvs/hyprdrive/astro-menu/style/_colors.css|$HOME/.config/astro-menu/style/_colors.css" "desktopenvs/hyprdrive/astro-menu/style/_colors.css|$HOME/.config/astro-menu/style/_colors.css"

View File

@ -19,8 +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 horizon-dock
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

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

View File

@ -1,77 +0,0 @@
"""All-apps data source (AstalApps, same library astal-menu's appdrawer uses) plus
favorite-app persistence a plain JSON list of desktop-entry IDs under
~/.local/state/horizon-dock/favorites.json (see paths.py for why STATE_DIR, not
CONFIG_DIR)."""
from __future__ import annotations
import json
import gi
gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps # noqa: E402
from paths import FAVORITES_FILE, ensure_dirs
class AppSource:
def __init__(self) -> None:
self._apps = AstalApps.Apps()
self._favorites: list[str] = self._load_favorites()
self._wm_index: dict[str, str] = {}
# -- listing -------------------------------------------------------------
def all_apps(self) -> list:
"""Every installed app, alphabetical by name."""
apps = list(self._apps.get_list())
apps.sort(key=lambda a: (a.get_name() or "").lower())
return apps
def icon_for_window(self, window: dict) -> str:
"""Map an open window's wm class to its installed app's icon — same
wm-class/executable/name index astal-menu/ui/taskbar.py builds for its
own taskbar, rebuilt lazily here on first use."""
if not self._wm_index:
for app in self._apps.get_list():
for key in (app.get_wm_class(), app.get_executable(), app.get_name()):
if key:
self._wm_index.setdefault(key.lower(), app.get_icon_name())
cls = (window.get("class") or "").lower()
return self._wm_index.get(cls) or cls or "application-x-executable"
def favorite_apps(self) -> list:
"""Favorited apps, in the order they were pinned. Entries whose app has
since been uninstalled are silently dropped (not un-favorited the
desktop file may just be temporarily missing, e.g. mid-update)."""
by_entry = {a.get_entry(): a for a in self._apps.get_list()}
return [by_entry[e] for e in self._favorites if e in by_entry]
@staticmethod
def launch(app) -> None:
app.launch()
# -- favorites persistence ------------------------------------------------
def is_favorite(self, app) -> bool:
return app.get_entry() in self._favorites
def toggle_favorite(self, app) -> None:
entry = app.get_entry()
if not entry:
return
if entry in self._favorites:
self._favorites.remove(entry)
else:
self._favorites.append(entry)
self._save_favorites()
def _load_favorites(self) -> list[str]:
try:
data = json.loads(FAVORITES_FILE.read_text())
return [e for e in data if isinstance(e, str)]
except (FileNotFoundError, json.JSONDecodeError):
return []
def _save_favorites(self) -> None:
ensure_dirs()
FAVORITES_FILE.write_text(json.dumps(self._favorites, indent=2) + "\n")

View File

@ -1,32 +0,0 @@
"""Tiny user-editable config file: ~/.local/state/horizon-dock/config.json.
Read once at startup (main.py); flags are passed into HorizonDock's constructor
rather than polled, so a change takes effect on the next horizon-dock-start.sh
restart, not live. Same pattern as orbit-menu/config.py.
"""
from __future__ import annotations
import json
from paths import CONFIG_FILE, ensure_dirs
# "tray": still undecided whether the full StatusNotifierItem satellite earns its
# keep vs a simpler dock — default on, but easy to switch off without touching code.
_DEFAULTS = {"tray": 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 tray_enabled() -> bool:
return bool(_load().get("tray", True))

View File

@ -1,372 +0,0 @@
"""horizon-dock — a hover-scrollable orbital dock, matching orbit-menu's visual
language (same CyberQueer glow/hover "planet" node styling).
Anchored full-width to the bottom of the screen. Toggled show/hide (not an
always-resident hover-reveal dock); every time it's shown it fades+slides up
into place from below the screen edge, and hiding reverses that driven by the
same per-frame tick-callback+easing pattern orbit-menu uses for its own
animations. Three rows of circular "planet" icon buttons Open Windows,
Favorites, All Apps each laid out evenly-spaced along a shared giant-circle
arc (the "horizon" curve: a shallow dip toward the screen edges, radius derived
from the monitor width so the sag reads consistently at any resolution).
Hovering a row's Y-band selects it as the active scroll target — mouse-wheel
scrolling only ever moves the currently-hovered row (Gtk.EventControllerScroll),
independent of the other two. The tray satellite sits at the fixed right edge of
the Favorites row: its position is never touched by that row's scroll
repositioning, and it's added to the canvas last so it paints on top — meaning
overflowing favorites scrolling under it are simply covered, reading as "going
behind" the satellite.
"""
from __future__ import annotations
import math
from typing import Callable, Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Gsk", "4.0")
gi.require_version("Graphene", "1.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gdk, Graphene, Gsk, Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
import apps as apps_source
import windows as windows_source
from tray import TrayHost
# CyberQueer accent/violet — hardcoded here as orbit-menu's own orbit_menu.py
# does for its Cairo drawing, since Cairo paints directly and doesn't see GTK
# CSS @define-color names. Kept in sync by eye with style/_colors.css.
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
class HorizonDock(Gtk.Window):
DOCK_HEIGHT = 214
PLANET_SIZE = 52
ITEM_SPACING = 66.0 # center-to-center distance between planets in a row
ARC_SAG = 34.0 # how far a row dips toward the screen edges vs. its center
ROW_ORDER = ["windows", "favorites", "apps"] # back -> front, top -> bottom
ROW_Y = {"windows": 46.0, "favorites": 112.0, "apps": 176.0}
ROW_BAND_TOP = {"windows": 0.0, "favorites": 79.0, "apps": 144.0}
ROW_TITLE = {"windows": "Open Windows", "favorites": "Favorites", "apps": "All Apps"}
TRAY_SIZE = 40
TRAY_MARGIN = 30.0
SCROLL_SENSITIVITY = 0.9
REVEAL_DURATION = 0.28
def __init__(self, on_close: Optional[Callable[[], None]] = None, tray_enabled: bool = True):
super().__init__()
self.add_css_class("horizon-dock-window")
self._on_close = on_close
self._tray_enabled = tray_enabled
self._apps = apps_source.AppSource()
self._tray = TrayHost() if tray_enabled else None
self._width = self._monitor_width()
self._scroll_offset = {row: 0.0 for row in self.ROW_ORDER}
self._items: dict[str, list] = {row: [] for row in self.ROW_ORDER} # source objects
self._widgets: dict[str, list[Gtk.Widget]] = {row: [] for row in self.ROW_ORDER}
self._hovered_row: Optional[str] = None
self._reveal_progress = 0.0
self._reveal_target = 0.0
self._sat_time = 0.0
self._last_tick: Optional[float] = None
self._tick_id: Optional[int] = None
self._init_layer_shell()
self._bg = Gtk.DrawingArea()
self._bg.set_size_request(int(self._width), self.DOCK_HEIGHT)
self._bg.set_draw_func(self._draw_background)
self._bg.add_css_class("horizon-canvas")
self._content = Gtk.Fixed()
self._content.set_size_request(int(self._width), self.DOCK_HEIGHT)
self._content.put(self._bg, 0, 0)
self._viewport = Gtk.Fixed()
self._viewport.set_size_request(int(self._width), self.DOCK_HEIGHT)
self._viewport.put(self._content, 0, 0)
self.set_child(self._viewport)
motion = Gtk.EventControllerMotion()
motion.connect("motion", self._on_motion)
motion.connect("leave", self._on_motion_leave)
self.add_controller(motion)
scroll = Gtk.EventControllerScroll()
scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
scroll.connect("scroll", self._on_scroll)
self.add_controller(scroll)
self._tray_satellite: Optional[Gtk.Widget] = None
self._tray_popover: Optional[Gtk.Popover] = None
self.set_visible(False)
self._rebuild_all()
# -- layer shell ------------------------------------------------------
def _init_layer_shell(self) -> None:
LayerShell.init_for_window(self)
LayerShell.set_layer(self, LayerShell.Layer.TOP)
LayerShell.set_namespace(self, "horizon-dock")
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.NONE)
LayerShell.set_exclusive_zone(self, 0)
for edge in (LayerShell.Edge.LEFT, LayerShell.Edge.RIGHT, LayerShell.Edge.BOTTOM):
LayerShell.set_anchor(self, edge, True)
LayerShell.set_margin(self, edge, 0)
def _monitor_width(self) -> int:
display = Gdk.Display.get_default()
monitors = display.get_monitors() if display is not None else None
mon = monitors.get_item(0) if monitors is not None and monitors.get_n_items() else None
return mon.get_geometry().width if mon is not None else 1920
# -- layout math --------------------------------------------------------
def _arc_radius(self) -> float:
half_w = self._width / 2
sag = self.ARC_SAG
return (sag * sag + half_w * half_w) / (2 * sag)
def _row_y_at(self, row: str, x: float) -> float:
row_y = self.ROW_Y[row]
dx = x - self._width / 2
r = self._arc_radius()
dip = r - math.sqrt(max(r * r - dx * dx, 0.0))
return row_y + dip
def _item_position(self, row: str, index: int) -> tuple[float, float]:
slot = index - self._scroll_offset[row]
x = self._width / 2 + slot * self.ITEM_SPACING
return x, self._row_y_at(row, x)
def _clamp_scroll(self, row: str) -> None:
count = len(self._items[row])
max_offset = max(0.0, count - 1)
self._scroll_offset[row] = min(max(self._scroll_offset[row], 0.0), max_offset)
# -- planet buttons -------------------------------------------------------
def _make_planet(self, icon_name: str, tooltip: str, size: int = PLANET_SIZE) -> Gtk.Button:
btn = Gtk.Button()
btn.set_has_frame(False)
btn.add_css_class("horizon-planet")
btn.set_size_request(size, size)
btn.set_tooltip_text(tooltip)
image = Gtk.Image.new_from_icon_name(icon_name or "application-x-executable")
image.set_pixel_size(int(size * 0.55))
btn.set_child(image)
return btn
def _rebuild_row(self, row: str) -> None:
for w in self._widgets[row]:
if w.get_parent() is not None:
self._content.remove(w)
self._widgets[row] = []
if row == "windows":
self._items[row] = windows_source.open_windows()
elif row == "favorites":
self._items[row] = self._apps.favorite_apps()
else:
self._items[row] = self._apps.all_apps()
self._clamp_scroll(row)
for i, item in enumerate(self._items[row]):
btn = self._build_item_button(row, item)
x, y = self._item_position(row, i)
self._content.put(btn, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
self._widgets[row].append(btn)
if row == "favorites":
self._place_tray_satellite()
self._bg.queue_draw()
def _build_item_button(self, row: str, item) -> Gtk.Button:
if row == "windows":
icon = self._apps.icon_for_window(item)
title = item.get("title") or item.get("class") or "?"
btn = self._make_planet(icon, title)
addr = item.get("address")
btn.connect("clicked", lambda *_a, a=addr: windows_source.focus_window(a))
else:
btn = self._make_planet(item.get_icon_name(), item.get_name() or "")
btn.connect("clicked", lambda *_a, a=item: self._apps.launch(a))
if row == "apps":
right_click = Gtk.GestureClick(button=3)
right_click.connect("pressed", lambda *_a, a=item: self._toggle_favorite(a))
btn.add_controller(right_click)
return btn
def _toggle_favorite(self, item) -> None:
self._apps.toggle_favorite(item)
self._rebuild_row("favorites")
def _reflow_row(self, row: str) -> None:
for i, w in enumerate(self._widgets[row]):
x, y = self._item_position(row, i)
self._content.move(w, x - self.PLANET_SIZE / 2, y - self.PLANET_SIZE / 2)
def _rebuild_all(self) -> None:
for row in self.ROW_ORDER:
self._rebuild_row(row)
# -- tray satellite -------------------------------------------------------
def _place_tray_satellite(self) -> None:
if not self._tray_enabled:
return
if self._tray_satellite is not None and self._tray_satellite.get_parent() is not None:
self._content.remove(self._tray_satellite)
x = self._width - self.TRAY_MARGIN
y = self.ROW_Y["favorites"]
btn = Gtk.MenuButton()
btn.add_css_class("horizon-planet")
btn.add_css_class("horizon-tray-satellite")
btn.set_size_request(self.TRAY_SIZE, self.TRAY_SIZE)
btn.set_tooltip_text("Tray")
icon = Gtk.Image.new_from_icon_name("view-list-symbolic")
icon.set_pixel_size(int(self.TRAY_SIZE * 0.5))
btn.set_child(icon)
popover = Gtk.Popover()
popover.add_css_class("horizon-tray-popover")
popover.set_child(self._build_tray_box())
btn.set_popover(popover)
self._tray_popover = popover
self._content.put(btn, x - self.TRAY_SIZE / 2, y - self.TRAY_SIZE / 2)
self._tray_satellite = btn
def _build_tray_box(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
box.add_css_class("horizon-tray-box")
if self._tray is None:
box.append(Gtk.Label(label="Tray disabled"))
return box
self._tray.build_into(box)
return box
# -- hover / scroll routing -------------------------------------------------
def _row_at_y(self, y: float) -> Optional[str]:
for row in reversed(self.ROW_ORDER):
if y >= self.ROW_BAND_TOP[row]:
return row
return None
def _on_motion(self, _ctrl, _x: float, y: float) -> None:
row = self._row_at_y(y)
if row != self._hovered_row:
self._hovered_row = row
self._bg.queue_draw()
def _on_motion_leave(self, _ctrl) -> None:
if self._hovered_row is not None:
self._hovered_row = None
self._bg.queue_draw()
def _on_scroll(self, _ctrl, _dx: float, dy: float) -> bool:
row = self._hovered_row
if row is None:
return False
self._scroll_offset[row] += dy * self.SCROLL_SENSITIVITY
self._clamp_scroll(row)
self._reflow_row(row)
return True
# -- background: the horizon arcs + hover band --------------------------
def _draw_background(self, _area, cr, width: float, height: float) -> None:
for row in self.ROW_ORDER:
self._draw_row_arc(cr, row, width)
if self._hovered_row is not None:
self._draw_hover_band(cr, self._hovered_row, width)
def _draw_row_arc(self, cr, row: str, width: float) -> None:
cr.save()
cr.set_source_rgba(*_VIOLET, 0.16)
cr.set_line_width(1.2)
cr.move_to(0, self._row_y_at(row, 0))
steps = 48
for s in range(1, steps + 1):
x = width * s / steps
cr.line_to(x, self._row_y_at(row, x))
cr.stroke()
cr.restore()
def _draw_hover_band(self, cr, row: str, width: float) -> None:
top = self.ROW_BAND_TOP[row]
bottom_candidates = [self.ROW_BAND_TOP[r] for r in self.ROW_ORDER if self.ROW_BAND_TOP[r] > top]
bottom = min(bottom_candidates) if bottom_candidates else self.DOCK_HEIGHT
cr.save()
cr.set_source_rgba(*_ACCENT, 0.05)
cr.rectangle(0, top, width, bottom - top)
cr.fill()
cr.restore()
# -- reveal animation -----------------------------------------------------
def _reveal_transform(self, progress: float) -> Gsk.Transform:
offset = (1.0 - progress) * self.DOCK_HEIGHT
t = Gsk.Transform.new()
t = t.translate(Graphene.Point().init(0, offset))
return t
def _on_tick(self, _widget, frame_clock) -> bool:
now = frame_clock.get_frame_time() / 1_000_000
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
ease = 1 - math.exp(-dt * (1.0 / self.REVEAL_DURATION) * 3.2)
self._reveal_progress += (self._reveal_target - self._reveal_progress) * ease
if abs(self._reveal_target - self._reveal_progress) < 0.002:
self._reveal_progress = self._reveal_target
self._viewport.set_child_transform(self._content, self._reveal_transform(self._reveal_progress))
self._content.set_opacity(max(0.0, min(1.0, self._reveal_progress)))
if self._reveal_progress == self._reveal_target and self._reveal_target == 0.0:
self.set_visible(False)
self._tick_id = None # GTK drops the callback itself on a False return
if self._on_close:
self._on_close()
return False
return True
def _ensure_tick(self) -> None:
if self._tick_id is None:
self._tick_id = self.add_tick_callback(self._on_tick)
def _stop_tick(self) -> None:
if self._tick_id is not None:
self.remove_tick_callback(self._tick_id)
self._tick_id = None
# -- external control ----------------------------------------------------
def show_dock(self) -> None:
self._width = self._monitor_width()
self._rebuild_all()
self._reveal_target = 1.0
self.set_visible(True)
self.present()
self._ensure_tick()
def hide_dock(self) -> None:
self._reveal_target = 0.0
self._ensure_tick()
def toggle(self) -> None:
if self.get_visible() and self._reveal_target == 1.0:
self.hide_dock()
else:
self.show_dock()

View File

@ -1,94 +0,0 @@
#!/usr/bin/env python3
"""horizon-dock — a hover-scrollable orbital dock for hyprlua, matching
orbit-menu's visual language.
Single-instance, same pattern as astal-menu/orbit-menu's main.py: the first
launch builds the (hidden) window and holds; later invocations forward their
verb over D-Bus via scripts/horizon-dock.sh instead of spawning a second
python3+GTK4 process.
main.py run the resident instance (stays hidden until toggled)
main.py --show slide/fade in
main.py --hide slide/fade out
main.py --toggle whichever of the above applies
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# horizon-dock-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/orbit-menu's 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 theme # noqa: E402
from dock import HorizonDock # noqa: E402
from paths import APP_ID # noqa: E402
class HorizonDockApp(Gtk.Application):
def __init__(self) -> None:
super().__init__(application_id=APP_ID,
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
self.window: HorizonDock | None = None
def do_startup(self) -> None:
Gtk.Application.do_startup(self)
theme.load_css()
self.window = HorizonDock(tray_enabled=config.tray_enabled())
self._register_actions()
self.hold() # stay alive with no visible window
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 guarded(fn):
def wrapper(*_a) -> None:
assert self.window is not None
fn(self.window)
return wrapper
add("show", guarded(lambda w: w.show_dock()))
add("hide", guarded(lambda w: w.hide_dock()))
add("toggle", guarded(lambda w: w.toggle()))
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 == "--show":
self.window.show_dock()
elif verb == "--hide":
self.window.hide_dock()
elif verb == "--toggle":
self.window.toggle()
# --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("horizon-dock")
return HorizonDockApp().run(sys.argv)
if __name__ == "__main__":
raise SystemExit(main())

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"
# User settings/favorites live under XDG_STATE_HOME, NOT ~/.config — config-updater
# does `rm -rf ~/.config/horizon-dock` on every dotfiles sync (see orbit-menu/
# paths.py and astal-menu/paths.py for the same rationale), which would wipe a
# hand-edited config or pinned favorites on the spot.
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "horizon-dock"
CONFIG_FILE = STATE_DIR / "config.json"
FAVORITES_FILE = STATE_DIR / "favorites.json"
APP_ID = "eu.abdelbaki.horizondock"
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,77 +0,0 @@
/* horizon-dock CyberQueer orbital dock. Colors come from _colors.css
* (@text/@bg/@accent/@violet/@danger), loaded first by theme.py. Every icon is
* a "planet" node styled identically to orbit-menu's .orbit-node/.orbit-center
* (same glow/hover treatment, same radial-gradient formula) so the two
* components read as one continuous aesthetic.
*/
window.horizon-dock-window {
background: transparent;
}
/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea
* nodes specifically (see orbit-menu/style/style.css's .orbit-hologram fix for
* the same discovery) the background arc canvas is a DrawingArea sitting
* behind everything, so without this it paints an opaque rectangle that blots
* out the transparent window, which is what reads as "the system GTK theme". */
.horizon-canvas {
background: transparent;
background-color: transparent;
background-image: none;
}
/* -- planet nodes: identical language to orbit-menu's .orbit-node ------------ */
.horizon-planet {
border-radius: 9999px;
border: 1.5px solid alpha(@violet, 0.5);
color: @text;
background-image: radial-gradient(circle at 35% 30%, alpha(@bg, 0.4), @bg 80%);
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(@violet, 0);
}
.horizon-planet * {
background: transparent;
background-color: transparent;
background-image: none;
}
.horizon-planet: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);
}
.horizon-planet:active {
box-shadow: 0 0 22px 3px alpha(@violet, 0.85);
}
/* the tray satellite reads as "special" accent-tinted instead of violet, and
* an OPAQUE background (not the translucent radial-gradient the other planets
* use) so overflowing favorites scrolling underneath it visibly disappear
* behind it rather than showing through. */
.horizon-tray-satellite {
border-color: alpha(@accent, 0.55);
background-image: none;
background-color: @bg;
}
.horizon-tray-satellite:hover {
border-color: @accent;
box-shadow: 0 0 16px 1px alpha(@accent, 0.6);
}
.horizon-tray-popover {
background-color: @bg;
}
.horizon-tray-box {
padding: 8px;
}
.horizon-tray-item {
border-radius: 9999px;
min-width: 28px;
min-height: 28px;
}

View File

@ -1,30 +0,0 @@
"""Load the two stylesheets as ordered CSS providers — same scheme as orbit-menu's
and 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 the other two apps: 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,140 +0,0 @@
"""Minimal StatusNotifierItem (SNI) HOST — connects to whichever
org.kde.StatusNotifierWatcher is already running (the existing eww bars' own
systray widget registers one) and renders the currently registered tray items
as clickable planet buttons. The SNI spec allows multiple hosts, so registering
alongside eww's own host is expected and fine, not a conflict.
Rebuilt fresh each time the tray popover box is built (dock.py calls
build_into() on every dock reveal, since favorites and therefore the tray
satellite rebuild then) rather than live-subscribed to Registered/
Unregistered signals simple and correct for a popover that's only open
briefly; if no watcher is running at all, build_into() shows a small
placeholder instead of raising.
"""
from __future__ import annotations
from typing import Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib, Gtk # noqa: E402
_WATCHER_BUS = "org.kde.StatusNotifierWatcher"
_WATCHER_PATH = "/StatusNotifierWatcher"
_WATCHER_IFACE = "org.kde.StatusNotifierWatcher"
_ITEM_IFACE = "org.kde.StatusNotifierItem"
_HOST_NAME = "org.kde.StatusNotifierHost-horizon-dock"
class TrayHost:
def __init__(self) -> None:
self._watcher: Optional[Gio.DBusProxy] = None
self._registered_host = False
self._connect()
def _connect(self) -> None:
try:
proxy = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
_WATCHER_BUS, _WATCHER_PATH, _WATCHER_IFACE, None,
)
# A proxy can be constructed even for a name with no owner yet — only
# a real name-owner query tells us whether a watcher is actually up.
self._watcher = proxy if proxy.get_name_owner() is not None else None
except GLib.Error:
self._watcher = None
def _ensure_host_registered(self) -> None:
if self._registered_host or self._watcher is None:
return
try:
self._watcher.call_sync(
"RegisterStatusNotifierHost", GLib.Variant("(s)", (_HOST_NAME,)),
Gio.DBusCallFlags.NONE, 500, None,
)
except GLib.Error:
pass # some watchers don't require/support this explicitly — fine either way
self._registered_host = True
def _registered_items(self) -> list[str]:
if self._watcher is None:
return []
value = self._watcher.get_cached_property("RegisteredStatusNotifierItems")
if value is None:
return []
return list(value.unpack())
@staticmethod
def _parse_item(entry: str) -> tuple[str, str]:
"""Registration strings are either just a bus name (path defaults to
/StatusNotifierItem) or "busname/ObjectPath" implementations disagree,
so accept both."""
if "/" in entry:
bus, _, path = entry.partition("/")
return bus, "/" + path
return entry, "/StatusNotifierItem"
# -- public: build the popover contents -----------------------------------
def build_into(self, box: Gtk.Box) -> None:
"""Populate `box` with one planet button per currently registered tray
item. Safe to call repeatedly (dock.py does, on every dock reveal)."""
if self._watcher is None:
box.append(Gtk.Label(label="No tray watcher running"))
return
self._ensure_host_registered()
entries = self._registered_items()
if not entries:
box.append(Gtk.Label(label="(empty)"))
return
for entry in entries:
bus, path = self._parse_item(entry)
btn = self._build_item_button(bus, path)
if btn is not None:
box.append(btn)
def _build_item_button(self, bus: str, path: str) -> Optional[Gtk.Button]:
try:
proxy = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None,
bus, path, _ITEM_IFACE, None,
)
except GLib.Error:
return None
icon_name = self._prop_str(proxy, "IconName") or "application-x-executable"
title = self._prop_str(proxy, "Title") or self._prop_str(proxy, "IconName") or bus
btn = Gtk.Button()
btn.set_has_frame(False)
btn.add_css_class("horizon-planet")
btn.add_css_class("horizon-tray-item")
btn.set_tooltip_text(title)
image = Gtk.Image.new_from_icon_name(icon_name)
image.set_pixel_size(20)
btn.set_child(image)
btn.connect("clicked", lambda *_a, p=proxy: self._activate(p))
right_click = Gtk.GestureClick(button=3)
right_click.connect("pressed", lambda *_a, p=proxy: self._context_menu(p))
btn.add_controller(right_click)
return btn
@staticmethod
def _prop_str(proxy: Gio.DBusProxy, name: str) -> str:
value = proxy.get_cached_property(name)
return value.unpack() if value is not None else ""
@staticmethod
def _activate(proxy: Gio.DBusProxy) -> None:
proxy.call("Activate", GLib.Variant("(ii)", (0, 0)),
Gio.DBusCallFlags.NONE, -1, None, None, None)
@staticmethod
def _context_menu(proxy: Gio.DBusProxy) -> None:
proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)),
Gio.DBusCallFlags.NONE, -1, None, None, None)

View File

@ -1,27 +0,0 @@
"""Open-window data source — synchronous `hyprctl clients -j`, same pattern as
orbit-menu/menu_tree.py's _hyprctl_json (a local unix-socket round trip, cheap
enough to call straight from the UI thread; refreshed each time the dock is
revealed, not polled continuously)."""
from __future__ import annotations
import json
import subprocess
def open_windows() -> list[dict]:
try:
out = subprocess.run(["hyprctl", "clients", "-j"], capture_output=True,
text=True, timeout=1.5, check=True).stdout
clients = json.loads(out)
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
return []
wins = [w for w in clients if w.get("mapped", True) and w.get("class")]
wins.sort(key=lambda w: (w.get("workspace", {}).get("id", 0),
(w.get("title") or w.get("class") or "").lower()))
return wins
def focus_window(address: str) -> None:
subprocess.Popen(["hyprctl", "dispatch", f'hl.dsp.focus({{ window = "address:{address}" }})'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

View File

@ -16,8 +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("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/horizon-dock-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,34 +0,0 @@
"""Tiny user-editable config file: ~/.local/state/orbit-menu/config.json.
Read once at startup (main.py); flags are 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, "hologram": 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))
def hologram_enabled() -> bool:
return bool(_load().get("hologram", 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,115 +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(),
hologram=config.hologram_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,915 +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 plain dict
keyed by the path of indices taken to reach it (e.g. (2, 0)). Navigating between
levels plays a sci-fi-orbital-map zoom (see _start_transition/_update_transition):
the clicked node's on-screen position becomes a focal point — the parent page
zooms *into* that fixed point (as if diving toward it, everything else rushing off
past the edges) while the child page slides in from that exact spot and grows from
a small dot up to the full view, becoming the new center, like a moon becoming the
planet you now orbit. Going back plays the same motion in reverse around the same
point (each depth's focal point is remembered in _stack_origins). Driven by the
same per-frame tick callback that animates the satellites, using GTK4's
Gsk.Transform (via Gtk.Fixed.set_child_transform) to scale each page around an
arbitrary pivot. 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("Gsk", "4.0")
gi.require_version("Graphene", "1.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gdk, Graphene, Gsk, 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. _MAGENTA has no CSS counterpart —
# it's only used for the hologram noise specs (see _draw_hologram_noise).
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
_MAGENTA = (0.92, 0.0, 0.65)
@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, hologram: bool = True):
super().__init__()
self.add_css_class("orbit-menu-window")
self._on_close = on_close
self._satellites_enabled = satellites
self._hologram_enabled = hologram
self._root = root
self._stack_path: list[int] = [] # indices taken from root
self._stack_origins: list[tuple[float, float]] = [] # focal point per depth, for _go_back
self._pages: dict[tuple, Gtk.Fixed] = {} # cached pages, keyed by path
self._page_rings: dict[Gtk.Fixed, Gtk.DrawingArea] = {}
self._page_buttons: dict[Gtk.Fixed, list[tuple[Gtk.Widget, float, float]]] = {}
self._current_ring: Gtk.DrawingArea | None = None
self._current_page: Gtk.Fixed | None = None
self._transition: dict | None = None # active zoom/fade nav transition, if any
self._intro: dict | None = None # active opening "materialize" animation, if any
self._holo_particles: list[dict] = [] # persistent hologram noise specs, see _update_hologram_particles
# 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._viewport = Gtk.Fixed()
self._viewport.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
# Hologram overlay: a single DrawingArea painted on top of EVERYTHING
# (buttons included), covering the whole menu regardless of which page is
# showing. set_can_target(False) so it never steals clicks meant for the
# buttons underneath.
self._hologram_area = Gtk.DrawingArea()
self._hologram_area.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
self._hologram_area.set_can_target(False)
self._hologram_area.set_draw_func(self._draw_hologram_frame)
self._hologram_area.add_css_class("orbit-hologram")
root_overlay = Gtk.Overlay()
root_overlay.add_css_class("orbit-root-overlay")
root_overlay.set_child(self._viewport)
root_overlay.add_overlay(self._hologram_area)
self.set_child(root_overlay)
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((), animate=False)
# -- 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 ------------------------------------------------
@classmethod
def _ring_position(cls, count: int, idx: int) -> tuple[float, float]:
"""Where ring node `idx` (of `count`) sits — same formula every page
uses, so a click handler can compute a node's position without needing
the built page widget (used as the zoom's focal point, see _on_node_clicked)."""
cx = cy = cls.CANVAS_SIZE / 2
angle = -math.pi / 2 + idx * (2 * math.pi / max(count, 1))
return cx + cls.RADIUS * math.cos(angle), cy + cls.RADIUS * math.sin(angle)
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)
# (widget, x, y) for every button on this page, center first — used only by
# the opening "materialize from noise" intro (see _start_intro), which needs
# each button's position to fade it in and spawn a noise burst there.
buttons: list[tuple[Gtk.Widget, float, float]] = []
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)
buttons.append((center_btn, cx, cy))
count = len(children)
node_positions: list[tuple[float, float]] = []
for i, item in enumerate(children):
nx, ny = self._ring_position(count, i)
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)
buttons.append((node_btn, nx, ny))
self._page_buttons[fixed] = buttons
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()
# -- holographic overlay --------------------------------------------------
# Config-toggleable (~/.local/state/orbit-menu/config.json's "hologram" key,
# see config.py); painted once, on top of the whole menu, independent of
# which page is showing — see the Gtk.Overlay setup in __init__.
_HOLO_TINT = _VIOLET # scanlines/sweep/flicker: violet
_HOLO_SCANLINE_GAP = 4.0
_HOLO_SWEEP_PERIOD = 3.4 # seconds for one top-to-bottom pass
_HOLO_SWEEP_HALF_HEIGHT = 90.0
_HOLO_NOISE_COUNT = 370 # specs alive at once (each with its own lifetime, not per-frame)
_HOLO_NOISE_COLORS = [_MAGENTA, _ACCENT] # magenta + red only
_HOLO_NOISE_LIFETIME = (0.6, 1.7) # seconds a given spec persists before being replaced
_HOLO_NOISE_FADE_IN = 0.2 # fraction of lifetime spent fading in
_HOLO_NOISE_FADE_OUT = 0.5 # fraction of lifetime spent fading out (at the end)
_HOLO_MASK_INNER = RADIUS + 8 # full-strength out to just past the ring nodes
# faded out completely by here — capped so it fully resolves to 0 before the
# canvas edge (CANVAS_SIZE/2), not just before the corners
_HOLO_MASK_OUTER = min(RADIUS + NODE_SIZE * 1.4, CANVAS_SIZE / 2 - 10)
def _draw_hologram_frame(self, _area, cr, width: float, height: float) -> None:
if self._hologram_enabled:
self._draw_hologram(cr, width, height)
def _draw_hologram(self, cr, width: float, height: float) -> None:
"""Draws the whole effect into an offscreen group, then composites it back
through a radial-gradient alpha mask centered on the canvas full strength
around the ring, soft-fading to nothing by _HOLO_MASK_OUTER, so it reads as
one big glow over the menu instead of tinting the whole square canvas."""
cx, cy = width / 2, height / 2
cr.push_group()
self._draw_hologram_content(cr, width, height)
pattern = cr.pop_group()
mask = cairo.RadialGradient(cx, cy, self._HOLO_MASK_INNER, cx, cy, self._HOLO_MASK_OUTER)
mask.add_color_stop_rgba(0.0, 1, 1, 1, 1)
mask.add_color_stop_rgba(1.0, 1, 1, 1, 0)
cr.set_source(pattern)
cr.mask(mask)
def _draw_hologram_content(self, cr, width: float, height: float) -> None:
r, g, b = self._HOLO_TINT
# faint horizontal scanlines
cr.save()
cr.set_source_rgba(r, g, b, 0.05)
cr.set_line_width(1.0)
y = 0.0
while y < height:
cr.move_to(0, y)
cr.line_to(width, y)
y += self._HOLO_SCANLINE_GAP
cr.stroke()
cr.restore()
# a bright band sweeping top -> bottom on a loop, like a hologram scan pass
phase = (self._sat_time % self._HOLO_SWEEP_PERIOD) / self._HOLO_SWEEP_PERIOD
sweep_y = phase * height
hh = self._HOLO_SWEEP_HALF_HEIGHT
grad = cairo.LinearGradient(0, sweep_y - hh, 0, sweep_y + hh)
grad.add_color_stop_rgba(0.0, r, g, b, 0.0)
grad.add_color_stop_rgba(0.5, r, g, b, 0.10)
grad.add_color_stop_rgba(1.0, r, g, b, 0.0)
cr.set_source(grad)
cr.rectangle(0, sweep_y - hh, width, hh * 2)
cr.fill()
# subtle overall flicker so it doesn't look perfectly static
flicker = 0.018 + 0.012 * math.sin(self._sat_time * 11.0)
cr.set_source_rgba(r, g, b, max(0.0, flicker))
cr.paint()
self._draw_hologram_noise(cr, width, height)
def _update_hologram_particles(self, width: float, height: float) -> None:
"""Age out expired specs and top back up to _HOLO_NOISE_COUNT — each spec
keeps its position/color for its own randomized lifetime (_HOLO_NOISE_
LIFETIME) instead of every spec being replaced every single frame."""
now = self._sat_time
self._holo_particles = [p for p in self._holo_particles if now - p["birth"] < p["life"]]
while len(self._holo_particles) < self._HOLO_NOISE_COUNT:
self._holo_particles.append({
"x": random.uniform(0, width),
"y": random.uniform(0, height),
"w": random.uniform(1.0, 2.6),
"h": random.uniform(1.0, 2.0),
"color": random.choice(self._HOLO_NOISE_COLORS),
"peak_alpha": random.uniform(0.08, 0.30),
"birth": now,
"life": random.uniform(*self._HOLO_NOISE_LIFETIME),
})
def _draw_hologram_noise(self, cr, width: float, height: float) -> None:
"""Colored specs that persist for a real lifetime, fading in then out —
reads as signal static/interference rather than the smooth violet wash the
scanlines/sweep alone would give, without the single-frame teleport flicker
redrawing everything from scratch every tick would cause."""
self._update_hologram_particles(width, height)
now = self._sat_time
for p in self._holo_particles:
t = (now - p["birth"]) / p["life"] # 0 (birth) .. 1 (death)
if t < self._HOLO_NOISE_FADE_IN:
envelope = t / self._HOLO_NOISE_FADE_IN
elif t > 1.0 - self._HOLO_NOISE_FADE_OUT:
envelope = max(0.0, (1.0 - t) / self._HOLO_NOISE_FADE_OUT)
else:
envelope = 1.0
r, g, b = p["color"]
cr.set_source_rgba(r, g, b, p["peak_alpha"] * envelope)
cr.rectangle(p["x"], p["y"], p["w"], p["h"])
cr.fill()
# -- 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)
tr = self._transition
if tr is not None:
self._update_transition()
if tr.get("old_ring") is not None:
tr["old_ring"].queue_draw()
if tr.get("new_ring") is not None:
tr["new_ring"].queue_draw()
elif self._satellites_enabled and self._current_ring is not None:
self._current_ring.queue_draw()
if self._intro is not None:
self._update_intro()
if self._hologram_enabled:
self._hologram_area.queue_draw()
return True # keep ticking every frame while the window is mapped
# -- zoom/fade navigation transition --------------------------------------
# Sci-fi-orbital-map feel: the clicked node IS the focal point. Going in, the
# parent page zooms in on that point (as if diving toward it, everything else
# rushing off past the edges) while the child page slides in from that same
# screen position and grows from a small dot up to full size, becoming the new
# center — like a moon becoming the planet you now orbit. Going back reverses
# the exact same motion around the exact same point.
_ZOOM_DURATION = 0.34 # seconds — a bit more cinematic than a plain fade
_ZOOM_CHILD_START = 0.4 # child page starts at this scale, sitting at the focal point
_ZOOM_PARENT_END = 2.4 # parent page zooms into the focal point up to this scale
def _focal_zoom_transform(self, fx: float, fy: float, scale: float) -> Gsk.Transform:
"""Scale a page around an arbitrary point (fx, fy) instead of its own
center used for the page that stays put but zooms into/out of the
focal point (translate(focal) . scale . translate(-focal))."""
t = Gsk.Transform.new()
t = t.translate(Graphene.Point().init(fx, fy))
t = t.scale(scale, scale)
t = t.translate(Graphene.Point().init(-fx, -fy))
return t
def _slide_zoom_transform(self, target_x: float, target_y: float, scale: float) -> Gsk.Transform:
"""Map a page's own center to on-screen position (target_x, target_y) at
the given scale used for the page that slides between the focal point
and the canvas center while growing/shrinking to become the full view."""
c = self.CANVAS_SIZE / 2
t = Gsk.Transform.new()
t = t.translate(Graphene.Point().init(target_x, target_y))
t = t.scale(scale, scale)
t = t.translate(Graphene.Point().init(-c, -c))
return t
def _start_transition(self, new_page: Gtk.Fixed, direction: str, animate: bool,
focal: Optional[tuple[float, float]] = None) -> None:
old_page = self._current_page
if old_page is new_page:
return
if self._transition is not None:
self._finalize_transition() # snap any in-flight transition instantly first
if new_page.get_parent() is None:
self._viewport.put(new_page, 0, 0)
if not animate or old_page is None:
new_page.set_opacity(1.0)
self._viewport.set_child_transform(new_page, None)
if old_page is not None and old_page.get_parent() is not None:
self._viewport.set_child_transform(old_page, None)
self._viewport.remove(old_page)
self._transition = None
return
c = self.CANVAS_SIZE / 2
fx, fy = focal if focal is not None else (c, c)
new_page.set_opacity(0.0)
self._transition = {
"old": old_page, "new": new_page,
"old_ring": self._page_rings.get(old_page),
"new_ring": self._page_rings.get(new_page),
"start": None, # set on the first tick, in _sat_time units
"forward": direction == "in",
"focal": (fx, fy),
}
def _update_transition(self) -> None:
tr = self._transition
if tr is None:
return
if tr["start"] is None:
tr["start"] = self._sat_time
progress = (self._sat_time - tr["start"]) / self._ZOOM_DURATION
progress = min(1.0, max(0.0, progress))
eased = progress * progress * (3 - 2 * progress) # smoothstep
c = self.CANVAS_SIZE / 2
fx, fy = tr["focal"]
forward = tr["forward"]
# The "slide" page is whichever one occupies the canvas-center full view at
# one end of the animation and the small focal-point dot at the other: the
# child when going in (0 -> 1 = focal -> center), the child when going back
# too but reversed (1 -> 0 = center -> focal) since it's the outgoing side.
if forward:
slide_page, zoom_page = tr["new"], tr["old"]
slide_t0, slide_t1 = (fx, fy), (c, c)
slide_s0, slide_s1 = self._ZOOM_CHILD_START, 1.0
zoom_s0, zoom_s1 = 1.0, self._ZOOM_PARENT_END
else:
slide_page, zoom_page = tr["old"], tr["new"]
slide_t0, slide_t1 = (c, c), (fx, fy)
slide_s0, slide_s1 = 1.0, self._ZOOM_CHILD_START
zoom_s0, zoom_s1 = self._ZOOM_PARENT_END, 1.0
tx = slide_t0[0] + (slide_t1[0] - slide_t0[0]) * eased
ty = slide_t0[1] + (slide_t1[1] - slide_t0[1]) * eased
slide_scale = slide_s0 + (slide_s1 - slide_s0) * eased
self._viewport.set_child_transform(slide_page, self._slide_zoom_transform(tx, ty, slide_scale))
if zoom_page is not None:
zoom_scale = zoom_s0 + (zoom_s1 - zoom_s0) * eased
self._viewport.set_child_transform(zoom_page, self._focal_zoom_transform(fx, fy, zoom_scale))
tr["new"].set_opacity(eased)
if tr["old"] is not None:
tr["old"].set_opacity(1.0 - eased)
if progress >= 1.0:
self._finalize_transition()
def _finalize_transition(self) -> None:
"""Snap the active transition to its end state — called both when it
completes naturally and to interrupt one cleanly if navigation happens
again before the previous transition finished."""
tr = self._transition
if tr is None:
return
self._viewport.set_child_transform(tr["new"], None)
tr["new"].set_opacity(1.0)
if tr["old"] is not None:
self._viewport.set_child_transform(tr["old"], None)
if tr["old"].get_parent() is not None:
self._viewport.remove(tr["old"])
self._transition = None
# -- opening intro: nodes materialize from holo-noise ----------------------
# Only plays when the menu transitions closed -> open (see open_at_root), never
# between nodes — that's the separate zoom transition above. Each button starts
# invisible and fades in on its own staggered delay (center first, then ring
# nodes in order around the circle); the instant a button starts fading in, a
# tight burst of extra hologram noise flashes at its position and burns off,
# like the node is condensing out of the static.
_INTRO_CENTER_DELAY = 0.0
_INTRO_RING_START = 0.10 # ring nodes begin after the center starts
_INTRO_RING_STAGGER = 0.055 # gap between consecutive ring nodes' start
_INTRO_FADE_DURATION = 0.30
_INTRO_BURST_COUNT = 10
_INTRO_BURST_RADIUS = 26.0
_INTRO_BURST_LIFETIME = (0.35, 0.7)
def _start_intro(self, page: Gtk.Fixed) -> None:
buttons = self._page_buttons.get(page)
if not buttons:
self._intro = None
return
entries = []
for i, (widget, x, y) in enumerate(buttons):
widget.set_opacity(0.0)
delay = self._INTRO_CENTER_DELAY if i == 0 else \
self._INTRO_RING_START + (i - 1) * self._INTRO_RING_STAGGER
entries.append({"widget": widget, "x": x, "y": y, "delay": delay, "burst_spawned": False})
self._intro = {"start": self._sat_time, "entries": entries}
def _spawn_holo_burst(self, x: float, y: float) -> None:
colors = [self._HOLO_TINT, self._HOLO_TINT, _MAGENTA, _ACCENT] # mostly violet
for _ in range(self._INTRO_BURST_COUNT):
angle = random.uniform(0, 2 * math.pi)
radius = random.uniform(0, self._INTRO_BURST_RADIUS)
self._holo_particles.append({
"x": x + radius * math.cos(angle), "y": y + radius * math.sin(angle),
"w": random.uniform(1.0, 2.8), "h": random.uniform(1.0, 2.2),
"color": random.choice(colors),
"peak_alpha": random.uniform(0.20, 0.45), # brighter than ambient noise
"birth": self._sat_time,
"life": random.uniform(*self._INTRO_BURST_LIFETIME),
})
def _update_intro(self) -> None:
intro = self._intro
if intro is None:
return
now = self._sat_time - intro["start"]
done = True
for e in intro["entries"]:
t = now - e["delay"]
if t < 0:
done = False
continue
if not e["burst_spawned"]:
if self._hologram_enabled:
self._spawn_holo_burst(e["x"], e["y"])
e["burst_spawned"] = True
progress = min(1.0, t / self._INTRO_FADE_DURATION)
eased = progress * progress * (3 - 2 * progress)
e["widget"].set_opacity(eased)
if progress < 1.0:
done = False
if done:
self._intro = None
# -- navigation ---------------------------------------------------------
def _show_path(self, path: tuple, direction: str = "in", animate: bool = True,
focal: Optional[tuple[float, float]] = None) -> None:
key = self._cache_key(path)
if key is not None and key in self._pages:
page = self._pages[key]
ring = self._page_rings[page]
else:
page, ring = self._build_page(path)
self._page_rings[page] = ring
if key is not None:
self._pages[key] = page
self._start_transition(page, direction=direction, animate=animate, focal=focal)
self._stack_path = list(path)
self._current_ring = ring
self._current_page = page
def _on_node_clicked(self, idx: int) -> None:
path = tuple(self._stack_path)
children = self._item_at(path).resolved_children()
item = children[idx]
if item.resolved_children():
focal = self._ring_position(len(children), idx)
self._stack_origins.append(focal)
self._show_path(path + (idx,), direction="in", focal=focal)
else:
if item.action:
item.action()
self._close()
def _go_back(self) -> None:
if self._stack_path:
focal = self._stack_origins.pop() if self._stack_origins else None
self._show_path(tuple(self._stack_path[:-1]), direction="out", focal=focal)
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."""
self._transition = None
child = self._viewport.get_first_child()
while child is not None:
nxt = child.get_next_sibling()
self._viewport.set_child_transform(child, None)
self._viewport.remove(child)
child = nxt
self._pages.clear()
self._page_rings.clear()
self._page_buttons.clear()
self._current_ring = None
self._current_page = None
self._stack_origins.clear()
self._intro = None
self._root = root
def open_at_root(self) -> None:
"""Reset to the top of the tree, instantly (no zoom on open) — dynamic
pages along the way get rebuilt the next time they're actually visited,
not eagerly here. Plays the "materialize from noise" intro (see
_start_intro) every time, since this only runs on closed -> open, never
on in-menu navigation."""
self._show_path((), animate=False)
self._start_intro(self._current_page)
self.set_visible(True)
self.present()
if 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,112 +0,0 @@
/* orbit-menu CyberQueer radial menu.
* Colors come from _colors.css (@text/@bg/@accent/@violet/@danger), loaded first
* by theme.py. Nested-level navigation is a manual zoom (see orbit_menu.py's
* _update_transition); this file only owns per-node hover/glow.
*/
window.orbit-menu-window {
background: transparent;
}
/* The cyberqueer theme's `* { background-color: #1a1a1a }` paints DrawingArea
* nodes too (unlike plain Box/Frame containers, which this GTK build's renderer
* leaves alone see lib/border.py in astal-menu for the same observation). The
* hologram overlay sits on TOP of the entire menu via Gtk.Overlay, so if it kept
* that background it would hide everything underneath it force both it and its
* Gtk.Overlay parent transparent. */
.orbit-root-overlay,
.orbit-hologram {
background: transparent;
background-color: transparent;
background-image: none;
}
.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,13 +0,0 @@
#!/usr/bin/env bash
# Resident launcher for the horizon-dock GTK4 orbital dock.
# Same LD_PRELOAD requirement and rationale as astal-menu-start.sh/orbit-menu-
# start.sh: gtk4-layer-shell must load before libwayland-client, which isn't
# guaranteed under PyGObject.
APP="${HOME}/.config/horizon-dock/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,39 +0,0 @@
#!/usr/bin/env bash
# Toggle the horizon-dock. Forwards a verb to the resident daemon over D-Bus; if
# the daemon isn't running yet, starts it first. Mirrors orbit-menu.sh/menu-
# toggle.sh. Not bound to a key by default — invoke manually or wire it into a
# bar button / gesture yourself.
#
# horizon-dock.sh -> --toggle (default)
# horizon-dock.sh show -> --show
# horizon-dock.sh hide -> --hide
#
# (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.horizondock"
OBJ="/eu/abdelbaki/horizondock"
APP="${HOME}/.config/horizon-dock/main.py"
case "${1:-toggle}" in
show) VERB="--show"; ACTION="show" ;;
hide) VERB="--hide"; ACTION="hide" ;;
*) VERB="--toggle"; ACTION="toggle" ;;
esac
registered() { busctl --user list 2>/dev/null | grep -q "$BUS"; }
if registered; then
exec gdbus call --session --dest "$BUS" --object-path "$OBJ" \
--method org.gtk.Actions.Activate "$ACTION" "[]" "{}" >/dev/null
fi
"${HOME}/.config/scripts/horizon-dock-start.sh" >/dev/null 2>&1 &
for _ in $(seq 1 25); do
if registered; then
exec "$0" "$@"
fi
sleep 0.2
done
exec python3 "$APP" "$VERB"

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"