Dotfiles/desktopenvs/hyprlua/orbit-menu/orbit_menu.py

236 lines
9.2 KiB
Python

"""Orbit menu — the base element.
A single "orbit" is one central node surrounded by a ring of child nodes, laid out
on a circle like moons around a planet. Clicking a child that has its own children
re-centers the view on that child and draws a fresh ring for *its* children — the
same base element recursively applied, so orbits nest arbitrarily deep. Clicking the
center (or Backspace) steps back out one level; Escape closes the menu.
Each level is rendered once into its own Gtk.Fixed page and cached in a Gtk.Stack
keyed by the path of indices taken to reach it (e.g. (2, 0)); navigating in either
direction just swaps the stack's visible child, and the Stack's own CROSSFADE
transition gives the nested navigation a smooth fade instead of a hard cut. A
category with a dynamic child list (open windows, user scripts) passes
`dynamic=True` so its page is rebuilt — never cached — on every visit.
Ported from the ~/code.dev/test-astralmenu prototype onto raw Gtk4LayerShell (no
Astal.Window dependency) to match astal-menu/window.py's layer-shell approach.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Callable, Optional
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gdk, Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
@dataclass
class MenuItem:
label: str
children: list["MenuItem"] | Callable[[], list["MenuItem"]] = field(default_factory=list)
action: Optional[Callable[[], None]] = None
danger: bool = False # red/danger tint (power actions)
dynamic: bool = False # rebuild this node's page every visit, never cache
icon: str = "" # optional leading nerd-font glyph, shown above label
def resolved_children(self) -> list["MenuItem"]:
return self.children() if callable(self.children) else self.children
class OrbitMenu(Gtk.Window):
"""A layer-shell window that renders a MenuItem tree as nested, cached orbits."""
CENTER_SIZE = 108
NODE_SIZE = 66 # prototype was 84; shrunk >=1/5 (66/84 ≈ 0.79) per node
RADIUS = 168
CANVAS_SIZE = 480
def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None):
super().__init__()
self.add_css_class("orbit-menu-window")
self._on_close = on_close
self._root = root
self._stack_path: list[int] = [] # indices taken from root
self._pages: dict[tuple, Gtk.Fixed] = {} # cached pages, keyed by path
self._init_layer_shell()
self._gtkstack = Gtk.Stack(
transition_type=Gtk.StackTransitionType.CROSSFADE,
transition_duration=220,
)
self._gtkstack.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
self.set_child(self._gtkstack)
keys = Gtk.EventControllerKey()
keys.connect("key-pressed", self._on_key)
self.add_controller(keys)
self._show_path(())
# -- layer shell ----------------------------------------------------
def _init_layer_shell(self) -> None:
LayerShell.init_for_window(self)
LayerShell.set_layer(self, LayerShell.Layer.OVERLAY)
LayerShell.set_namespace(self, "orbit-menu")
LayerShell.set_keyboard_mode(self, LayerShell.KeyboardMode.EXCLUSIVE)
# No anchors set => gtk-layer-shell centers the surface on its output.
# -- item lookup / path helpers --------------------------------------
def _item_at(self, path: tuple) -> MenuItem:
item = self._root
for i in path:
item = item.resolved_children()[i]
return item
def _cache_key(self, path: tuple) -> tuple | None:
"""None => never cache (path passes through a dynamic node)."""
item = self._root
for i in path:
if item.dynamic:
return None
item = item.resolved_children()[i]
return None if item.dynamic else path
# -- page construction ------------------------------------------------
def _build_page(self, path: tuple) -> Gtk.Fixed:
current = self._item_at(path)
fixed = Gtk.Fixed()
fixed.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
cx = cy = self.CANVAS_SIZE / 2
ring = Gtk.DrawingArea()
ring.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
children = current.resolved_children()
if children:
ring.set_draw_func(self._make_ring_draw())
fixed.put(ring, 0, 0)
center_btn = Gtk.Button()
center_btn.set_has_frame(False)
center_btn.add_css_class("orbit-center")
if len(path) == 0:
center_btn.add_css_class("orbit-root")
center_btn.set_child(self._node_label(current.icon, current.label))
center_btn.set_size_request(self.CENTER_SIZE, self.CENTER_SIZE)
center_btn.connect("clicked", lambda *_a: self._go_back())
fixed.put(center_btn, cx - self.CENTER_SIZE / 2, cy - self.CENTER_SIZE / 2)
count = len(children)
for i, item in enumerate(children):
angle = -math.pi / 2 + i * (2 * math.pi / max(count, 1))
nx = cx + self.RADIUS * math.cos(angle)
ny = cy + self.RADIUS * math.sin(angle)
node_btn = Gtk.Button()
node_btn.set_has_frame(False)
node_btn.add_css_class("orbit-node")
if item.danger:
node_btn.add_css_class("orbit-danger")
node_btn.set_child(self._node_label(item.icon, item.label))
node_btn.set_size_request(self.NODE_SIZE, self.NODE_SIZE)
node_btn.set_tooltip_text(item.label)
node_btn.connect("clicked", lambda *_a, idx=i: self._on_node_clicked(idx))
fixed.put(node_btn, nx - self.NODE_SIZE / 2, ny - self.NODE_SIZE / 2)
return fixed
@staticmethod
def _node_label(icon: str, label: str) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2,
halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
if icon:
ic = Gtk.Label(label=icon)
ic.add_css_class("orbit-node-icon")
box.append(ic)
lb = Gtk.Label(label=label, wrap=True, justify=Gtk.Justification.CENTER,
max_width_chars=10, lines=2, ellipsize=3)
lb.add_css_class("orbit-node-text")
box.append(lb)
return box
def _make_ring_draw(self):
def draw(_area, cr, width, height):
cx, cy = width / 2, height / 2
cr.set_source_rgba(1, 1, 1, 0.12)
cr.set_line_width(1.5)
cr.arc(cx, cy, self.RADIUS, 0, 2 * math.pi)
cr.stroke()
return draw
# -- navigation ---------------------------------------------------------
def _show_path(self, path: tuple) -> None:
key = self._cache_key(path)
name = "/".join(str(i) for i in path) or "root"
# a dynamic path (or one passing through a dynamic ancestor) is rebuilt fresh
# and swapped in under a throwaway name so the stack still crossfades to it.
if key is None:
page = self._build_page(path)
name = f"{name}#live"
old = self._gtkstack.get_child_by_name(name)
if old is not None:
self._gtkstack.remove(old)
self._gtkstack.add_named(page, name)
elif key not in self._pages:
page = self._build_page(path)
self._pages[key] = page
self._gtkstack.add_named(page, name)
self._gtkstack.set_visible_child_name(name)
self._stack_path = list(path)
def _on_node_clicked(self, idx: int) -> None:
path = tuple(self._stack_path)
item = self._item_at(path).resolved_children()[idx]
if item.resolved_children():
self._show_path(path + (idx,))
else:
if item.action:
item.action()
self._close()
def _go_back(self) -> None:
if self._stack_path:
self._show_path(tuple(self._stack_path[:-1]))
else:
self._close()
def _close(self) -> None:
self.set_visible(False)
if self._on_close:
self._on_close()
def _on_key(self, _ctrl, keyval, _keycode, _state) -> bool:
if keyval == Gdk.KEY_Escape:
self._close()
return True
if keyval == Gdk.KEY_BackSpace:
self._go_back()
return True
return False
# -- external control ----------------------------------------------------
def set_root(self, root: MenuItem) -> None:
"""Swap the whole tree (e.g. power-only <-> full category menu). Any
cached pages belonged to the old tree's indices, so they're dropped."""
pages = self._gtkstack.get_pages()
for child in [pages.get_item(i).get_child() for i in range(pages.get_n_items())]:
self._gtkstack.remove(child)
self._pages.clear()
self._root = root
def open_at_root(self) -> None:
"""Reset to the top of the tree (dynamic pages along the way get rebuilt
the next time they're actually visited, not eagerly here)."""
self._show_path(())
self.set_visible(True)
self.present()