feat(hyprlua/orbit-menu): sci-fi zoom navigation + holographic overlay + opening intro

Replaces the plain Gtk.Stack crossfade with a focal-point zoom (Gtk.Fixed +
Gsk.Transform): the clicked node's screen position becomes a pivot the parent
page zooms into while the child page slides in from that exact spot and grows
to become the new center — like a moon becoming the planet you now orbit.
Going back reverses the same motion around the same remembered point
(_stack_origins tracks it per depth).

Adds a holographic overlay (config.json "hologram" toggle, default on):
violet scanlines + a looping sweep band + magenta/red noise specs with real
per-particle lifetimes (fade in/out, not single-frame flicker), all composited
through a radial mask so it reads as one soft-edged glow over the menu rather
than tinting the whole square canvas.

Adds an opening-only intro (never plays between nodes): each button starts
invisible and fades in on a staggered cascade — center first, then ring nodes
in order — with a bright noise burst flashing at each node's position the
instant it starts appearing, like it's condensing out of the static.

Also fixes a transparency regression: the new hologram DrawingArea sits above
everything via Gtk.Overlay, and the cyberqueer theme's aggressive `*` selector
was painting it an opaque background, hiding the whole menu — forced
transparent via CSS, same fix pattern as the earlier orbit-node label bug.
main
Amir Alexander Abdelbaki 2026-07-17 11:29:51 +02:00
parent 779e365cb8
commit 4fbffee5a0
4 changed files with 443 additions and 56 deletions

View File

@ -1,8 +1,8 @@
"""Tiny user-editable config file: ~/.local/state/orbit-menu/config.json. """Tiny user-editable config file: ~/.local/state/orbit-menu/config.json.
Currently a single toggle. Read once at startup (main.py); the satellites flag Read once at startup (main.py); flags are passed into OrbitMenu's constructor
is passed into OrbitMenu's constructor rather than polled, so a change takes rather than polled, so a change takes effect on the next orbit-menu-start.sh
effect on the next orbit-menu-start.sh restart, not live. restart, not live.
""" """
from __future__ import annotations from __future__ import annotations
@ -11,7 +11,7 @@ import json
from paths import CONFIG_FILE, ensure_dirs from paths import CONFIG_FILE, ensure_dirs
_DEFAULTS = {"satellites": True} _DEFAULTS = {"satellites": True, "hologram": True}
def _load() -> dict: def _load() -> dict:
@ -28,3 +28,7 @@ def _load() -> dict:
def satellites_enabled() -> bool: def satellites_enabled() -> bool:
return bool(_load().get("satellites", True)) return bool(_load().get("satellites", True))
def hologram_enabled() -> bool:
return bool(_load().get("hologram", True))

View File

@ -54,7 +54,8 @@ class OrbitMenuApp(Gtk.Application):
Gtk.Application.do_startup(self) Gtk.Application.do_startup(self)
theme.load_css() theme.load_css()
self.window = OrbitMenu(menu_tree.full_menu_root(), on_close=self._on_closed, self.window = OrbitMenu(menu_tree.full_menu_root(), on_close=self._on_closed,
satellites=config.satellites_enabled()) satellites=config.satellites_enabled(),
hologram=config.hologram_enabled())
self._current_mode = "menu" self._current_mode = "menu"
self._register_actions() self._register_actions()
self.hold() # stay alive with no visible window self.hold() # stay alive with no visible window

View File

@ -6,12 +6,19 @@ re-centers the view on that child and draws a fresh ring for *its* children —
same base element recursively applied, so orbits nest arbitrarily deep. Clicking 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. 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 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 in either keyed by the path of indices taken to reach it (e.g. (2, 0)). Navigating between
direction just swaps the stack's visible child, and the Stack's own CROSSFADE levels plays a sci-fi-orbital-map zoom (see _start_transition/_update_transition):
transition gives the nested navigation a smooth fade instead of a hard cut. A the clicked node's on-screen position becomes a focal point — the parent page
category with a dynamic child list (open windows, user scripts) passes zooms *into* that fixed point (as if diving toward it, everything else rushing off
`dynamic=True` so its page is rebuilt never cached on every visit. 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 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. Astal.Window dependency) to match astal-menu/window.py's layer-shell approach.
@ -29,15 +36,19 @@ import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "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") gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gdk, Gtk # noqa: E402 from gi.repository import Gdk, Graphene, Gsk, Gtk # noqa: E402
from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402 from gi.repository import Gtk4LayerShell as LayerShell # noqa: E402
# CyberQueer accent/violet, matching style/_colors.css — hardcoded here (as # CyberQueer accent/violet, matching style/_colors.css — hardcoded here (as
# lib/border.py does for its own Cairo drawing) since Cairo paints directly and # lib/border.py does for its own Cairo drawing) since Cairo paints directly and
# doesn't see GTK CSS @define-color names. # 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) _ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255) _VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
_MAGENTA = (0.92, 0.0, 0.65)
@dataclass @dataclass
@ -109,17 +120,24 @@ class OrbitMenu(Gtk.Window):
_BRACKET_CHANCE = 0.45 _BRACKET_CHANCE = 0.45
def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None, def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None,
satellites: bool = True): satellites: bool = True, hologram: bool = True):
super().__init__() super().__init__()
self.add_css_class("orbit-menu-window") self.add_css_class("orbit-menu-window")
self._on_close = on_close self._on_close = on_close
self._satellites_enabled = satellites self._satellites_enabled = satellites
self._hologram_enabled = hologram
self._root = root self._root = root
self._stack_path: list[int] = [] # indices taken from 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._pages: dict[tuple, Gtk.Fixed] = {} # cached pages, keyed by path
self._page_rings: dict[str, Gtk.DrawingArea] = {} 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_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) # satellite/mouse-parallax state (unused entirely if satellites=False)
cx = cy = self.CANVAS_SIZE / 2 cx = cy = self.CANVAS_SIZE / 2
@ -131,12 +149,24 @@ class OrbitMenu(Gtk.Window):
self._init_layer_shell() self._init_layer_shell()
self._gtkstack = Gtk.Stack( self._viewport = Gtk.Fixed()
transition_type=Gtk.StackTransitionType.CROSSFADE, self._viewport.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
transition_duration=220,
) # Hologram overlay: a single DrawingArea painted on top of EVERYTHING
self._gtkstack.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) # (buttons included), covering the whole menu regardless of which page is
self.set_child(self._gtkstack) # 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 = Gtk.EventControllerKey()
keys.connect("key-pressed", self._on_key) keys.connect("key-pressed", self._on_key)
@ -148,7 +178,7 @@ class OrbitMenu(Gtk.Window):
motion.connect("leave", self._on_motion_leave) motion.connect("leave", self._on_motion_leave)
self.add_controller(motion) self.add_controller(motion)
self._show_path(()) self._show_path((), animate=False)
# -- layer shell ---------------------------------------------------- # -- layer shell ----------------------------------------------------
def _init_layer_shell(self) -> None: def _init_layer_shell(self) -> None:
@ -175,6 +205,15 @@ class OrbitMenu(Gtk.Window):
return None if item.dynamic else path return None if item.dynamic else path
# -- page construction ------------------------------------------------ # -- 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]: def _build_page(self, path: tuple) -> tuple[Gtk.Fixed, Gtk.DrawingArea]:
current = self._item_at(path) current = self._item_at(path)
fixed = Gtk.Fixed() fixed = Gtk.Fixed()
@ -186,6 +225,11 @@ class OrbitMenu(Gtk.Window):
children = current.resolved_children() children = current.resolved_children()
fixed.put(ring, 0, 0) 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 = Gtk.Button()
center_btn.set_has_frame(False) center_btn.set_has_frame(False)
center_btn.add_css_class("orbit-center") center_btn.add_css_class("orbit-center")
@ -195,13 +239,12 @@ class OrbitMenu(Gtk.Window):
center_btn.set_size_request(self.CENTER_SIZE, self.CENTER_SIZE) center_btn.set_size_request(self.CENTER_SIZE, self.CENTER_SIZE)
center_btn.connect("clicked", lambda *_a: self._go_back()) center_btn.connect("clicked", lambda *_a: self._go_back())
fixed.put(center_btn, cx - self.CENTER_SIZE / 2, cy - self.CENTER_SIZE / 2) fixed.put(center_btn, cx - self.CENTER_SIZE / 2, cy - self.CENTER_SIZE / 2)
buttons.append((center_btn, cx, cy))
count = len(children) count = len(children)
node_positions: list[tuple[float, float]] = [] node_positions: list[tuple[float, float]] = []
for i, item in enumerate(children): for i, item in enumerate(children):
angle = -math.pi / 2 + i * (2 * math.pi / max(count, 1)) nx, ny = self._ring_position(count, i)
nx = cx + self.RADIUS * math.cos(angle)
ny = cy + self.RADIUS * math.sin(angle)
node_positions.append((nx, ny)) node_positions.append((nx, ny))
node_btn = Gtk.Button() node_btn = Gtk.Button()
@ -214,6 +257,9 @@ class OrbitMenu(Gtk.Window):
node_btn.set_tooltip_text(item.label) node_btn.set_tooltip_text(item.label)
node_btn.connect("clicked", lambda *_a, idx=i: self._on_node_clicked(idx)) 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) 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)) \ assignments = self._pick_assignments(node_positions, center=(cx, cy)) \
if self._satellites_enabled else [] if self._satellites_enabled else []
@ -445,6 +491,116 @@ class OrbitMenu(Gtk.Window):
cr.show_text(text) cr.show_text(text)
cr.restore() 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 ------------------------------------- # -- mouse parallax / animation loop -------------------------------------
def _on_motion(self, _ctrl, x: float, y: float) -> None: def _on_motion(self, _ctrl, x: float, y: float) -> None:
self._mouse_target = (x, y) self._mouse_target = (x, y)
@ -464,38 +620,238 @@ class OrbitMenu(Gtk.Window):
ease = 1 - math.exp(-dt * 6.0) # frame-rate independent lerp toward the cursor 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) self._mouse_smooth = (sx + (mx - sx) * ease, sy + (my - sy) * ease)
if self._current_ring is not None: 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() 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 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 --------------------------------------------------------- # -- navigation ---------------------------------------------------------
def _show_path(self, path: tuple) -> None: def _show_path(self, path: tuple, direction: str = "in", animate: bool = True,
focal: Optional[tuple[float, float]] = None) -> None:
key = self._cache_key(path) key = self._cache_key(path)
name = "/".join(str(i) for i in path) or "root" if key is not None and key in self._pages:
# a dynamic path (or one passing through a dynamic ancestor) is rebuilt fresh page = self._pages[key]
# and swapped in under a throwaway name so the stack still crossfades to it. ring = self._page_rings[page]
if key is None: else:
page, ring = self._build_page(path) page, ring = self._build_page(path)
name = f"{name}#live" self._page_rings[page] = ring
old = self._gtkstack.get_child_by_name(name) if key is not None:
if old is not None: self._pages[key] = page
self._gtkstack.remove(old)
self._gtkstack.add_named(page, name) self._start_transition(page, direction=direction, animate=animate, focal=focal)
self._page_rings[name] = ring
elif key not in self._pages:
page, ring = self._build_page(path)
self._pages[key] = page
self._gtkstack.add_named(page, name)
self._page_rings[name] = ring
self._gtkstack.set_visible_child_name(name)
self._stack_path = list(path) self._stack_path = list(path)
self._current_ring = self._page_rings.get(name) self._current_ring = ring
self._current_page = page
def _on_node_clicked(self, idx: int) -> None: def _on_node_clicked(self, idx: int) -> None:
path = tuple(self._stack_path) path = tuple(self._stack_path)
item = self._item_at(path).resolved_children()[idx] children = self._item_at(path).resolved_children()
item = children[idx]
if item.resolved_children(): if item.resolved_children():
self._show_path(path + (idx,)) focal = self._ring_position(len(children), idx)
self._stack_origins.append(focal)
self._show_path(path + (idx,), direction="in", focal=focal)
else: else:
if item.action: if item.action:
item.action() item.action()
@ -503,7 +859,8 @@ class OrbitMenu(Gtk.Window):
def _go_back(self) -> None: def _go_back(self) -> None:
if self._stack_path: if self._stack_path:
self._show_path(tuple(self._stack_path[:-1])) focal = self._stack_origins.pop() if self._stack_origins else None
self._show_path(tuple(self._stack_path[:-1]), direction="out", focal=focal)
else: else:
self._close() self._close()
@ -528,19 +885,31 @@ class OrbitMenu(Gtk.Window):
def set_root(self, root: MenuItem) -> None: def set_root(self, root: MenuItem) -> None:
"""Swap the whole tree (e.g. power-only <-> full category menu). Any """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.""" cached pages belonged to the old tree's indices, so they're dropped."""
pages = self._gtkstack.get_pages() self._transition = None
for child in [pages.get_item(i).get_child() for i in range(pages.get_n_items())]: child = self._viewport.get_first_child()
self._gtkstack.remove(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._pages.clear()
self._page_rings.clear() self._page_rings.clear()
self._page_buttons.clear()
self._current_ring = None self._current_ring = None
self._current_page = None
self._stack_origins.clear()
self._intro = None
self._root = root self._root = root
def open_at_root(self) -> None: def open_at_root(self) -> None:
"""Reset to the top of the tree (dynamic pages along the way get rebuilt """Reset to the top of the tree, instantly (no zoom on open) — dynamic
the next time they're actually visited, not eagerly here).""" pages along the way get rebuilt the next time they're actually visited,
self._show_path(()) 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.set_visible(True)
self.present() self.present()
if self._satellites_enabled and self._tick_id is None: if self._tick_id is None:
self._tick_id = self.add_tick_callback(self._on_tick) self._tick_id = self.add_tick_callback(self._on_tick)

View File

@ -1,13 +1,26 @@
/* orbit-menu CyberQueer radial menu. /* orbit-menu CyberQueer radial menu.
* Colors come from _colors.css (@text/@bg/@accent/@violet/@danger), loaded first * Colors come from _colors.css (@text/@bg/@accent/@violet/@danger), loaded first
* by theme.py. Nested-level transitions are handled by the Gtk.Stack's own * by theme.py. Nested-level navigation is a manual zoom (see orbit_menu.py's
* CROSSFADE (see orbit_menu.py); this file only owns per-node hover/glow. * _update_transition); this file only owns per-node hover/glow.
*/ */
window.orbit-menu-window { window.orbit-menu-window {
background: transparent; 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-center,
.orbit-node { .orbit-node {
border-radius: 9999px; border-radius: 9999px;