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
parent
74fc9aefaf
commit
9d59565469
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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}"})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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@ {}%"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
@ -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"])
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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())
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
@ -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" "$@"
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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" "$@"
|
|
||||||
|
|
@ -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"
|
|
||||||
Loading…
Reference in New Issue