diff --git a/desktopenvs/hyprlua/orbit-menu/config.py b/desktopenvs/hyprlua/orbit-menu/config.py index 910444c..ff66ace 100644 --- a/desktopenvs/hyprlua/orbit-menu/config.py +++ b/desktopenvs/hyprlua/orbit-menu/config.py @@ -1,8 +1,8 @@ """Tiny user-editable config file: ~/.local/state/orbit-menu/config.json. -Currently a single toggle. Read once at startup (main.py); the satellites flag -is passed into OrbitMenu's constructor rather than polled, so a change takes -effect on the next orbit-menu-start.sh restart, not live. +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 @@ -11,7 +11,7 @@ import json from paths import CONFIG_FILE, ensure_dirs -_DEFAULTS = {"satellites": True} +_DEFAULTS = {"satellites": True, "hologram": True} def _load() -> dict: @@ -28,3 +28,7 @@ def _load() -> dict: 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/main.py b/desktopenvs/hyprlua/orbit-menu/main.py index 395e085..d717c31 100644 --- a/desktopenvs/hyprlua/orbit-menu/main.py +++ b/desktopenvs/hyprlua/orbit-menu/main.py @@ -54,7 +54,8 @@ class OrbitMenuApp(Gtk.Application): 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()) + satellites=config.satellites_enabled(), + hologram=config.hologram_enabled()) self._current_mode = "menu" self._register_actions() self.hold() # stay alive with no visible window diff --git a/desktopenvs/hyprlua/orbit-menu/orbit_menu.py b/desktopenvs/hyprlua/orbit-menu/orbit_menu.py index 2fe9f24..57719cd 100644 --- a/desktopenvs/hyprlua/orbit-menu/orbit_menu.py +++ b/desktopenvs/hyprlua/orbit-menu/orbit_menu.py @@ -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 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. +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. @@ -29,15 +36,19 @@ 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, Gtk # noqa: E402 +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. +# 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 @@ -109,17 +120,24 @@ class OrbitMenu(Gtk.Window): _BRACKET_CHANCE = 0.45 def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None, - satellites: bool = True): + 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[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_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 @@ -131,12 +149,24 @@ class OrbitMenu(Gtk.Window): 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) + 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) @@ -148,7 +178,7 @@ class OrbitMenu(Gtk.Window): motion.connect("leave", self._on_motion_leave) self.add_controller(motion) - self._show_path(()) + self._show_path((), animate=False) # -- layer shell ---------------------------------------------------- def _init_layer_shell(self) -> None: @@ -175,6 +205,15 @@ class OrbitMenu(Gtk.Window): 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() @@ -186,6 +225,11 @@ class OrbitMenu(Gtk.Window): 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") @@ -195,13 +239,12 @@ class OrbitMenu(Gtk.Window): 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): - 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) + nx, ny = self._ring_position(count, i) node_positions.append((nx, ny)) node_btn = Gtk.Button() @@ -214,6 +257,9 @@ class OrbitMenu(Gtk.Window): 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 [] @@ -445,6 +491,116 @@ class OrbitMenu(Gtk.Window): 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) @@ -464,38 +620,238 @@ class OrbitMenu(Gtk.Window): 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) - 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() + + 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) -> 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) - 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: + 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) - 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) - 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._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 = self._page_rings.get(name) + self._current_ring = ring + self._current_page = page def _on_node_clicked(self, idx: int) -> None: 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(): - 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: if item.action: item.action() @@ -503,7 +859,8 @@ class OrbitMenu(Gtk.Window): def _go_back(self) -> None: 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: self._close() @@ -528,19 +885,31 @@ class OrbitMenu(Gtk.Window): 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._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 (dynamic pages along the way get rebuilt - the next time they're actually visited, not eagerly here).""" - self._show_path(()) + """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._satellites_enabled and self._tick_id is None: + if self._tick_id is None: self._tick_id = self.add_tick_callback(self._on_tick) diff --git a/desktopenvs/hyprlua/orbit-menu/style/style.css b/desktopenvs/hyprlua/orbit-menu/style/style.css index 92e6d48..95f2213 100644 --- a/desktopenvs/hyprlua/orbit-menu/style/style.css +++ b/desktopenvs/hyprlua/orbit-menu/style/style.css @@ -1,13 +1,26 @@ /* orbit-menu — CyberQueer radial menu. * 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 - * CROSSFADE (see orbit_menu.py); this file only owns per-node hover/glow. + * 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;