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