diff --git a/apply-theme.sh b/apply-theme.sh index 78af6ae..e633657 100755 --- a/apply-theme.sh +++ b/apply-theme.sh @@ -41,8 +41,6 @@ USER_FILES=( "desktopenvs/hyprland/walker/themes/cyberqueer.css|$HOME/.config/walker/themes/cyberqueer.css" "desktopenvs/hyprland/nwg-panel/menu-start.css|$HOME/.config/nwg-panel/menu-start.css" "desktopenvs/hyprlua/astal-menu/style/_colors.css|$HOME/.config/astal-menu/style/_colors.css" - "desktopenvs/hyprlua/orbit-menu/style/_colors.css|$HOME/.config/orbit-menu/style/_colors.css" - "desktopenvs/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/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" diff --git a/desktopenvs/hyprlua/config-updater/updater.conf b/desktopenvs/hyprlua/config-updater/updater.conf index e50a7a4..09cfae6 100644 --- a/desktopenvs/hyprlua/config-updater/updater.conf +++ b/desktopenvs/hyprlua/config-updater/updater.conf @@ -19,8 +19,6 @@ config hypr except usr config kitty config mimeapps.list config nwg-panel -config orbit-menu -config horizon-dock config scripts config ulauncher config vicinae diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.scss b/desktopenvs/hyprlua/eww-nobattery/eww.scss index a56f92e..dc6ce50 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.scss +++ b/desktopenvs/hyprlua/eww-nobattery/eww.scss @@ -117,24 +117,3 @@ menuitem:hover { .menu-launcher:hover { color: #5018dd; } - -// orbit-menu launcher — round glowing button, opens the radial power/tools/ -// scripts/management/windows menu. Smooth glow + colour-swap on hover. -.orbit-launcher { - border: 2px solid alpha(#E40046, 0.5); - background-image: radial-gradient(circle at 35% 30%, alpha(#5018dd, 0.35), #1a1a1a 75%); - color: #E40046; - font-size: 15pt; - min-width: 26px; - min-height: 26px; - margin: 1px 4px; - border-radius: 9999px; - transition: color 200ms ease, border-color 200ms ease, - background-image 200ms ease, box-shadow 220ms ease; -} -.orbit-launcher:hover { - color: #ffffff; - border-color: #E40046; - background-image: radial-gradient(circle at 35% 30%, alpha(#E40046, 0.65), #1a1a1a 75%); - box-shadow: 0 0 14px 2px alpha(#E40046, 0.65); -} diff --git a/desktopenvs/hyprlua/eww-nobattery/eww.yuck b/desktopenvs/hyprlua/eww-nobattery/eww.yuck index 501b18e..a4dd111 100644 --- a/desktopenvs/hyprlua/eww-nobattery/eww.yuck +++ b/desktopenvs/hyprlua/eww-nobattery/eww.yuck @@ -22,7 +22,6 @@ (box :orientation "h" :space-evenly false :halign "start" ; astal-menu launcher — opens the popup control centre / app drawer (button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""}) - (button :class "orbit-launcher" :onclick "~/.config/scripts/orbit-menu.sh menu" {"󰀘"}) (workspaceWidget :monitor monitor) (button :onclick "~/.config/scripts/menu-toggle.sh toggle top" :class "music" {" ${activewindow}"}) ) diff --git a/desktopenvs/hyprlua/eww-touch/eww.scss b/desktopenvs/hyprlua/eww-touch/eww.scss index f0118dd..88fc01f 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.scss +++ b/desktopenvs/hyprlua/eww-touch/eww.scss @@ -98,27 +98,6 @@ tooltip { color: #5018dd; } -// orbit-menu launcher — round glowing button (touch-sized like .icon-btn), -// opens the radial power/tools/scripts/management/windows menu. -.orbit-launcher { - border: 2px solid alpha(#E40046, 0.5); - background-image: radial-gradient(circle at 35% 30%, alpha(#5018dd, 0.35), #1a1a1a 75%); - color: #E40046; - font-size: 18pt; - min-width: 40px; - min-height: 40px; - margin: 2px 4px; - border-radius: 50%; - transition: color 200ms ease, border-color 200ms ease, - background-image 200ms ease, box-shadow 220ms ease; -} -.orbit-launcher:hover { - color: #ffffff; - border-color: #E40046; - background-image: radial-gradient(circle at 35% 30%, alpha(#E40046, 0.65), #1a1a1a 75%); - box-shadow: 0 0 16px 3px alpha(#E40046, 0.65); -} - menuitem { border: solid; border-width: 3px; diff --git a/desktopenvs/hyprlua/eww-touch/eww.yuck b/desktopenvs/hyprlua/eww-touch/eww.yuck index 1ac3b52..792f99e 100644 --- a/desktopenvs/hyprlua/eww-touch/eww.yuck +++ b/desktopenvs/hyprlua/eww-touch/eww.yuck @@ -27,7 +27,6 @@ (osk) (box :class "music" {"${battery}"}) (button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""}) - (button :class "orbit-launcher" :onclick "~/.config/scripts/orbit-menu.sh menu" {"󰀘"}) (metric :label "󰓃 " :value volume :onchange "pactl set-sink-volume @DEFAULT_SINK@ {}%" diff --git a/desktopenvs/hyprlua/eww/eww.scss b/desktopenvs/hyprlua/eww/eww.scss index b829b3b..5d381da 100644 --- a/desktopenvs/hyprlua/eww/eww.scss +++ b/desktopenvs/hyprlua/eww/eww.scss @@ -117,24 +117,3 @@ menuitem:hover { .menu-launcher:hover { color: #5018dd; } - -// orbit-menu launcher — round glowing button, opens the radial power/tools/ -// scripts/management/windows menu. Smooth glow + colour-swap on hover. -.orbit-launcher { - border: 2px solid alpha(#E40046, 0.5); - background-image: radial-gradient(circle at 35% 30%, alpha(#5018dd, 0.35), #1a1a1a 75%); - color: #E40046; - font-size: 15pt; - min-width: 26px; - min-height: 26px; - margin: 1px 4px; - border-radius: 9999px; - transition: color 200ms ease, border-color 200ms ease, - background-image 200ms ease, box-shadow 220ms ease; -} -.orbit-launcher:hover { - color: #ffffff; - border-color: #E40046; - background-image: radial-gradient(circle at 35% 30%, alpha(#E40046, 0.65), #1a1a1a 75%); - box-shadow: 0 0 14px 2px alpha(#E40046, 0.65); -} diff --git a/desktopenvs/hyprlua/eww/eww.yuck b/desktopenvs/hyprlua/eww/eww.yuck index e0150a5..00323d7 100644 --- a/desktopenvs/hyprlua/eww/eww.yuck +++ b/desktopenvs/hyprlua/eww/eww.yuck @@ -56,8 +56,6 @@ (box :orientation "h" :space-evenly false :halign "start" ; astal-menu launcher — opens the popup control centre / app drawer (button :class "music menu-launcher" :onclick "~/.config/scripts/menu-toggle.sh toggle top" {""}) - ; orbit-menu launcher — opens the radial Power/Tools/Scripts/Management/Windows menu - (button :class "orbit-launcher" :onclick "~/.config/scripts/orbit-menu.sh menu" {"󰀘"}) ; Battery percentage badge — styled as a pill with class "music" (box :class "music" {"${battery}"}) ; Workspace dots — one button per active workspace on this monitor diff --git a/desktopenvs/hyprlua/horizon-dock/.gitignore b/desktopenvs/hyprlua/horizon-dock/.gitignore deleted file mode 100644 index 7a60b85..0000000 --- a/desktopenvs/hyprlua/horizon-dock/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -__pycache__/ -*.pyc diff --git a/desktopenvs/hyprlua/horizon-dock/apps.py b/desktopenvs/hyprlua/horizon-dock/apps.py deleted file mode 100644 index 3476802..0000000 --- a/desktopenvs/hyprlua/horizon-dock/apps.py +++ /dev/null @@ -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") diff --git a/desktopenvs/hyprlua/horizon-dock/config.py b/desktopenvs/hyprlua/horizon-dock/config.py deleted file mode 100644 index 0c5afb7..0000000 --- a/desktopenvs/hyprlua/horizon-dock/config.py +++ /dev/null @@ -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)) diff --git a/desktopenvs/hyprlua/horizon-dock/dock.py b/desktopenvs/hyprlua/horizon-dock/dock.py deleted file mode 100644 index aab5c42..0000000 --- a/desktopenvs/hyprlua/horizon-dock/dock.py +++ /dev/null @@ -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() diff --git a/desktopenvs/hyprlua/horizon-dock/main.py b/desktopenvs/hyprlua/horizon-dock/main.py deleted file mode 100644 index 2fa8ecf..0000000 --- a/desktopenvs/hyprlua/horizon-dock/main.py +++ /dev/null @@ -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()) diff --git a/desktopenvs/hyprlua/horizon-dock/paths.py b/desktopenvs/hyprlua/horizon-dock/paths.py deleted file mode 100644 index 405f142..0000000 --- a/desktopenvs/hyprlua/horizon-dock/paths.py +++ /dev/null @@ -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) diff --git a/desktopenvs/hyprlua/horizon-dock/style/_colors.css b/desktopenvs/hyprlua/horizon-dock/style/_colors.css deleted file mode 100644 index e226c9e..0000000 --- a/desktopenvs/hyprlua/horizon-dock/style/_colors.css +++ /dev/null @@ -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; diff --git a/desktopenvs/hyprlua/horizon-dock/style/style.css b/desktopenvs/hyprlua/horizon-dock/style/style.css deleted file mode 100644 index 559b3a5..0000000 --- a/desktopenvs/hyprlua/horizon-dock/style/style.css +++ /dev/null @@ -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; -} diff --git a/desktopenvs/hyprlua/horizon-dock/theme.py b/desktopenvs/hyprlua/horizon-dock/theme.py deleted file mode 100644 index 433c8c1..0000000 --- a/desktopenvs/hyprlua/horizon-dock/theme.py +++ /dev/null @@ -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 - ) diff --git a/desktopenvs/hyprlua/horizon-dock/tray.py b/desktopenvs/hyprlua/horizon-dock/tray.py deleted file mode 100644 index 354e7d3..0000000 --- a/desktopenvs/hyprlua/horizon-dock/tray.py +++ /dev/null @@ -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) diff --git a/desktopenvs/hyprlua/horizon-dock/windows.py b/desktopenvs/hyprlua/horizon-dock/windows.py deleted file mode 100644 index 9c0662c..0000000 --- a/desktopenvs/hyprlua/horizon-dock/windows.py +++ /dev/null @@ -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) diff --git a/desktopenvs/hyprlua/hypr/usr/autostart.lua b/desktopenvs/hyprlua/hypr/usr/autostart.lua index af7ca75..bee738b 100644 --- a/desktopenvs/hyprlua/hypr/usr/autostart.lua +++ b/desktopenvs/hyprlua/hypr/usr/autostart.lua @@ -16,8 +16,6 @@ hl.on("hyprland.start", function() hl.exec_cmd("hyprctl setcursor Nordzy-cursors-lefthand 50") hl.exec_cmd("hyprpaper") hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/astal-menu-start.sh") - hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/orbit-menu-start.sh") - hl.exec_cmd("bash ~/Dotfiles/desktopenvs/hyprlua/scripts/horizon-dock-start.sh") hl.exec_cmd("blueman-applet") hl.exec_cmd("blueman-tray") hl.exec_cmd("hypridle") diff --git a/desktopenvs/hyprlua/hypr/usr/binds.lua b/desktopenvs/hyprlua/hypr/usr/binds.lua index 76b8c20..f62eefa 100644 --- a/desktopenvs/hyprlua/hypr/usr/binds.lua +++ b/desktopenvs/hyprlua/hypr/usr/binds.lua @@ -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 -- Hyprland and runs its --post-cmd, instead of yanking the session out from under them. --- orbit-menu wraps hyprshutdown behind the radial menu: Shift+O opens straight into --- the Power ring (sleep/reboot/poweroff/soft-reboot); Ctrl+O opens the full 5-category --- menu (Power/Tools/Scripts/Management/Windows). Re-press to close. hl.bind(mainMod .. " + O", hl.dsp.exec_cmd("hyprlock")) -hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("~/.config/scripts/orbit-menu.sh power")) -hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd("~/.config/scripts/orbit-menu.sh menu")) +hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("hyprshutdown")) -- graceful logout +hl.bind(mainMod .. " + CTRL + O", hl.dsp.exec_cmd('hyprshutdown -p "systemctl poweroff"')) -- graceful power off hl.bind(mainMod .. " + CTRL + SHIFT + O",hl.dsp.exec_cmd('hyprshutdown -p "systemctl reboot"')) -- graceful reboot hl.bind(mainMod .. " + ALT + O", hl.dsp.exec_cmd("~/.config/scripts/pwr-dmenu.sh")) hl.bind(mainMod .. " + ALT + CTRL + SHIFT + END", hl.dsp.exit()) diff --git a/desktopenvs/hyprlua/orbit-menu/.gitignore b/desktopenvs/hyprlua/orbit-menu/.gitignore deleted file mode 100644 index 7a60b85..0000000 --- a/desktopenvs/hyprlua/orbit-menu/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -__pycache__/ -*.pyc diff --git a/desktopenvs/hyprlua/orbit-menu/actions.py b/desktopenvs/hyprlua/orbit-menu/actions.py deleted file mode 100644 index e0838a1..0000000 --- a/desktopenvs/hyprlua/orbit-menu/actions.py +++ /dev/null @@ -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 `, which work regardless of the Lua - layer and don't require guessing whether hl.dsp.window exposes a close-by- - address form. -""" - -from __future__ import annotations - -from lib.proc import fire - - -def launch(cmd: str) -> None: - """Run cmd via Hyprland's exec dispatcher — cmd may carry a `[tag ...]` / - `[workspace ...]` window-rule-on-launch prefix, same as binds.lua.""" - fire(["hyprctl", "dispatch", "exec", cmd]) - - -def hyprshutdown(post_cmd: str | None = None) -> None: - if post_cmd: - fire(["hyprshutdown", "-p", post_cmd]) - else: - fire(["hyprshutdown"]) - - -def _dispatch_lua(lua: str) -> None: - fire(["hyprctl", "dispatch", lua]) - - -def focus_window(address: str) -> None: - _dispatch_lua(f'hl.dsp.focus({{ window = "address:{address}" }})') - - -def bring_here(address: str, active_ws: int) -> None: - _dispatch_lua(f'hl.dsp.window.move({{ window = "address:{address}", ' - f'workspace = {active_ws} }})') - - -def send_to_workspace(address: str, workspace_id: int) -> None: - _dispatch_lua(f'hl.dsp.window.move({{ window = "address:{address}", ' - f'workspace = {workspace_id}, silent = true }})') - - -def close_window(address: str) -> None: - fire(["hyprctl", "dispatch", "closewindow", f"address:{address}"]) - - -def force_kill(pid: int) -> None: - fire(["kill", "-9", str(pid)]) - - -def report_pid(pid: int, title: str) -> None: - fire(["wl-copy", str(pid)]) - fire(["notify-send", "-a", "orbit-menu", f"PID {pid}", - f"{title} — copied to clipboard"]) diff --git a/desktopenvs/hyprlua/orbit-menu/config.py b/desktopenvs/hyprlua/orbit-menu/config.py deleted file mode 100644 index ff66ace..0000000 --- a/desktopenvs/hyprlua/orbit-menu/config.py +++ /dev/null @@ -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)) diff --git a/desktopenvs/hyprlua/orbit-menu/lib/proc.py b/desktopenvs/hyprlua/orbit-menu/lib/proc.py deleted file mode 100644 index 8c5c197..0000000 --- a/desktopenvs/hyprlua/orbit-menu/lib/proc.py +++ /dev/null @@ -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) diff --git a/desktopenvs/hyprlua/orbit-menu/main.py b/desktopenvs/hyprlua/orbit-menu/main.py deleted file mode 100644 index d717c31..0000000 --- a/desktopenvs/hyprlua/orbit-menu/main.py +++ /dev/null @@ -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()) diff --git a/desktopenvs/hyprlua/orbit-menu/menu_tree.py b/desktopenvs/hyprlua/orbit-menu/menu_tree.py deleted file mode 100644 index b1b0206..0000000 --- a/desktopenvs/hyprlua/orbit-menu/menu_tree.py +++ /dev/null @@ -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() diff --git a/desktopenvs/hyprlua/orbit-menu/orbit_menu.py b/desktopenvs/hyprlua/orbit-menu/orbit_menu.py deleted file mode 100644 index 57719cd..0000000 --- a/desktopenvs/hyprlua/orbit-menu/orbit_menu.py +++ /dev/null @@ -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. "-< >-". 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) diff --git a/desktopenvs/hyprlua/orbit-menu/paths.py b/desktopenvs/hyprlua/orbit-menu/paths.py deleted file mode 100644 index e642425..0000000 --- a/desktopenvs/hyprlua/orbit-menu/paths.py +++ /dev/null @@ -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) diff --git a/desktopenvs/hyprlua/orbit-menu/style/_colors.css b/desktopenvs/hyprlua/orbit-menu/style/_colors.css deleted file mode 100644 index e226c9e..0000000 --- a/desktopenvs/hyprlua/orbit-menu/style/_colors.css +++ /dev/null @@ -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; diff --git a/desktopenvs/hyprlua/orbit-menu/style/style.css b/desktopenvs/hyprlua/orbit-menu/style/style.css deleted file mode 100644 index 95f2213..0000000 --- a/desktopenvs/hyprlua/orbit-menu/style/style.css +++ /dev/null @@ -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); -} diff --git a/desktopenvs/hyprlua/orbit-menu/theme.py b/desktopenvs/hyprlua/orbit-menu/theme.py deleted file mode 100644 index a36995b..0000000 --- a/desktopenvs/hyprlua/orbit-menu/theme.py +++ /dev/null @@ -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 - ) diff --git a/desktopenvs/hyprlua/scripts/horizon-dock-start.sh b/desktopenvs/hyprlua/scripts/horizon-dock-start.sh deleted file mode 100755 index 066b44b..0000000 --- a/desktopenvs/hyprlua/scripts/horizon-dock-start.sh +++ /dev/null @@ -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" "$@" diff --git a/desktopenvs/hyprlua/scripts/horizon-dock.sh b/desktopenvs/hyprlua/scripts/horizon-dock.sh deleted file mode 100755 index 2bcafa2..0000000 --- a/desktopenvs/hyprlua/scripts/horizon-dock.sh +++ /dev/null @@ -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" diff --git a/desktopenvs/hyprlua/scripts/orbit-menu-start.sh b/desktopenvs/hyprlua/scripts/orbit-menu-start.sh deleted file mode 100755 index 0abbb53..0000000 --- a/desktopenvs/hyprlua/scripts/orbit-menu-start.sh +++ /dev/null @@ -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" "$@" diff --git a/desktopenvs/hyprlua/scripts/orbit-menu.sh b/desktopenvs/hyprlua/scripts/orbit-menu.sh deleted file mode 100755 index b6660f1..0000000 --- a/desktopenvs/hyprlua/scripts/orbit-menu.sh +++ /dev/null @@ -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"