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
parent
779e365cb8
commit
4fbffee5a0
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
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)
|
page, ring = self._build_page(path)
|
||||||
|
self._page_rings[page] = ring
|
||||||
|
if key is not None:
|
||||||
self._pages[key] = page
|
self._pages[key] = page
|
||||||
self._gtkstack.add_named(page, name)
|
|
||||||
self._page_rings[name] = ring
|
self._start_transition(page, direction=direction, animate=animate, focal=focal)
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue