"""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()