feat(hyprlua/orbit-menu): cosmetic mouse-reactive satellites around ring nodes
Adds a decorative layer to the radial menu: small nerd-font glyphs orbit the
ring nodes (and occasionally the center) at a fixed radial "height" per
satellite, only their angle changes — easing toward the cursor plus a slight
idle wobble. Faint dashed/dotted/solid orbit-path circles trace each one.
Per page build (not per frame, so it never flickers):
- total count is 2 + random(1, (3 % node-amount) + 2), clamped to node
capacity, spread across at least 3 distinct nodes when there are that many
- a node can get up to 2 satellites; since each has its own fixed height they
never overlap, and the gap between a doubled pair is widened further
- a doubled pair splits its motion — one tracks only the cursor's X, the
other only Y, each with its own random phase offset so multiple doubled
nodes on one page don't move in lockstep
- every satellite gets independent horizontal/vertical response multipliers,
so it can read as more reactive to one direction than the other
- some satellites get tangential bracket flourishes (-<, -[, -(, -{, -|)
rotated to the orbit's local tangent
Toggleable via ~/.local/state/orbit-menu/config.json ({"satellites": false})
since config-updater wipes ~/.config/orbit-menu on every sync.
main
parent
2fa3414e31
commit
779e365cb8
|
|
@ -0,0 +1,30 @@
|
|||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from paths import CONFIG_FILE, ensure_dirs
|
||||
|
||||
_DEFAULTS = {"satellites": True}
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
data = json.loads(CONFIG_FILE.read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = {}
|
||||
merged = {**_DEFAULTS, **data}
|
||||
if not CONFIG_FILE.exists():
|
||||
ensure_dirs()
|
||||
CONFIG_FILE.write_text(json.dumps(merged, indent=2) + "\n")
|
||||
return merged
|
||||
|
||||
|
||||
def satellites_enabled() -> bool:
|
||||
return bool(_load().get("satellites", True))
|
||||
|
|
@ -36,6 +36,7 @@ import gi # noqa: E402
|
|||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gio, GLib, Gtk # noqa: E402
|
||||
|
||||
import config # noqa: E402
|
||||
import menu_tree # noqa: E402
|
||||
import theme # noqa: E402
|
||||
from orbit_menu import OrbitMenu # noqa: E402
|
||||
|
|
@ -52,7 +53,8 @@ class OrbitMenuApp(Gtk.Application):
|
|||
def do_startup(self) -> None:
|
||||
Gtk.Application.do_startup(self)
|
||||
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())
|
||||
self._current_mode = "menu"
|
||||
self._register_actions()
|
||||
self.hold() # stay alive with no visible window
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ Astal.Window dependency) to match astal-menu/window.py's layer-shell approach.
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional
|
||||
|
||||
import cairo
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
|
|
@ -31,6 +33,40 @@ gi.require_version("Gtk4LayerShell", "1.0")
|
|||
from gi.repository import Gdk, 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.
|
||||
_ACCENT = (0xE4 / 255, 0x00 / 255, 0x46 / 255)
|
||||
_VIOLET = (0x50 / 255, 0x18 / 255, 0xDD / 255)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Satellite:
|
||||
glyph: str
|
||||
height: float # fixed radial distance from its node — never changes at runtime
|
||||
wobble_amp: float # extra angle (radians) layered on top of the mouse-facing direction
|
||||
wobble_speed: float
|
||||
phase: float
|
||||
color: tuple[float, float, float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Assignment:
|
||||
"""One satellite bound to one node — decided once per page build (see
|
||||
OrbitMenu._pick_assignments), not re-rolled every frame. A node may end up with
|
||||
two assignments; since each _Satellite has its own fixed `height`, the two
|
||||
already ride different-radius orbits, and _pick_assignments additionally
|
||||
widens the gap between them (see effective_height) so they never crowd."""
|
||||
sat: _Satellite
|
||||
node: tuple[float, float]
|
||||
bracket: Optional[tuple[str, str]] = None # flanking glyphs, tangent to the orbit
|
||||
line_style: str = "solid" # "solid" | "dashed" | "dotted"
|
||||
axis: Optional[str] = None # None -> faces the cursor; "x"/"y" -> see below
|
||||
effective_height: float = 0.0 # sat.height, widened if this node is doubled up
|
||||
mouse_offset: float = 0.0 # per-assignment phase added to the axis angle
|
||||
mult_x: float = 1.0 # independent per-assignment response strength
|
||||
mult_y: float = 1.0 # to horizontal vs vertical mouse movement
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuItem:
|
||||
|
|
@ -53,14 +89,45 @@ class OrbitMenu(Gtk.Window):
|
|||
RADIUS = 168
|
||||
CANVAS_SIZE = 480
|
||||
|
||||
def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None):
|
||||
# Cosmetic satellites: glyphs that each orbit ONE ring node at a fixed radial
|
||||
# "height" from it (set once here, never changed at runtime) — only their angle
|
||||
# around that node moves, easing to face the cursor plus a small idle wobble so
|
||||
# they're alive even when the mouse is still. Which nodes get one, how many, and
|
||||
# which glyph goes where is re-rolled once per page build (_pick_assignments) —
|
||||
# varied each time you land on a category, stable while that page is showing.
|
||||
_GLYPH_FONT = "Agave Nerd Font Mono"
|
||||
_SATELLITES = [
|
||||
_Satellite(glyph="\uef5f", height=42, wobble_amp=0.30, wobble_speed=0.6, phase=0.0, color=_ACCENT),
|
||||
_Satellite(glyph="\U000f0471", height=50, wobble_amp=0.22, wobble_speed=0.45, phase=1.4, color=_VIOLET),
|
||||
_Satellite(glyph="\uf197", height=46, wobble_amp=0.35, wobble_speed=0.7, phase=2.6, color=_VIOLET),
|
||||
_Satellite(glyph="\U000f1383", height=54, wobble_amp=0.25, wobble_speed=0.5, phase=4.0, color=_ACCENT),
|
||||
]
|
||||
|
||||
# Flanking glyphs drawn tangent to the orbit around some (not all) satellites —
|
||||
# e.g. "-< <glyph> >-". Rotated to the local tangent direction in _draw_glyph.
|
||||
_BRACKETS = [("-<", ">-"), ("-[", "]-"), ("-(", ")-"), ("-{", "}-"), ("-|", "|-")]
|
||||
_BRACKET_CHANCE = 0.45
|
||||
|
||||
def __init__(self, root: MenuItem, on_close: Optional[Callable[[], None]] = None,
|
||||
satellites: bool = True):
|
||||
super().__init__()
|
||||
self.add_css_class("orbit-menu-window")
|
||||
self._on_close = on_close
|
||||
self._satellites_enabled = satellites
|
||||
|
||||
self._root = root
|
||||
self._stack_path: list[int] = [] # indices taken from root
|
||||
self._pages: dict[tuple, Gtk.Fixed] = {} # cached pages, keyed by path
|
||||
self._page_rings: dict[str, Gtk.DrawingArea] = {}
|
||||
self._current_ring: Gtk.DrawingArea | None = None
|
||||
|
||||
# satellite/mouse-parallax state (unused entirely if satellites=False)
|
||||
cx = cy = self.CANVAS_SIZE / 2
|
||||
self._mouse_target = (cx, cy)
|
||||
self._mouse_smooth = (cx, cy)
|
||||
self._sat_time = 0.0
|
||||
self._last_tick: float | None = None
|
||||
self._tick_id: int | None = None
|
||||
|
||||
self._init_layer_shell()
|
||||
|
||||
|
|
@ -75,6 +142,12 @@ class OrbitMenu(Gtk.Window):
|
|||
keys.connect("key-pressed", self._on_key)
|
||||
self.add_controller(keys)
|
||||
|
||||
if self._satellites_enabled:
|
||||
motion = Gtk.EventControllerMotion()
|
||||
motion.connect("motion", self._on_motion)
|
||||
motion.connect("leave", self._on_motion_leave)
|
||||
self.add_controller(motion)
|
||||
|
||||
self._show_path(())
|
||||
|
||||
# -- layer shell ----------------------------------------------------
|
||||
|
|
@ -102,7 +175,7 @@ class OrbitMenu(Gtk.Window):
|
|||
return None if item.dynamic else path
|
||||
|
||||
# -- page construction ------------------------------------------------
|
||||
def _build_page(self, path: tuple) -> Gtk.Fixed:
|
||||
def _build_page(self, path: tuple) -> tuple[Gtk.Fixed, Gtk.DrawingArea]:
|
||||
current = self._item_at(path)
|
||||
fixed = Gtk.Fixed()
|
||||
fixed.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
|
||||
|
|
@ -111,8 +184,6 @@ class OrbitMenu(Gtk.Window):
|
|||
ring = Gtk.DrawingArea()
|
||||
ring.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
|
||||
children = current.resolved_children()
|
||||
if children:
|
||||
ring.set_draw_func(self._make_ring_draw())
|
||||
fixed.put(ring, 0, 0)
|
||||
|
||||
center_btn = Gtk.Button()
|
||||
|
|
@ -126,10 +197,12 @@ class OrbitMenu(Gtk.Window):
|
|||
fixed.put(center_btn, cx - self.CENTER_SIZE / 2, cy - self.CENTER_SIZE / 2)
|
||||
|
||||
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)
|
||||
node_positions.append((nx, ny))
|
||||
|
||||
node_btn = Gtk.Button()
|
||||
node_btn.set_has_frame(False)
|
||||
|
|
@ -142,7 +215,110 @@ class OrbitMenu(Gtk.Window):
|
|||
node_btn.connect("clicked", lambda *_a, idx=i: self._on_node_clicked(idx))
|
||||
fixed.put(node_btn, nx - self.NODE_SIZE / 2, ny - self.NODE_SIZE / 2)
|
||||
|
||||
return fixed
|
||||
assignments = self._pick_assignments(node_positions, center=(cx, cy)) \
|
||||
if self._satellites_enabled else []
|
||||
ring.set_draw_func(self._make_page_draw(has_ring=bool(children), assignments=assignments))
|
||||
return fixed, ring
|
||||
|
||||
_MAX_PER_NODE = 2
|
||||
_MIN_SPREAD_NODES = 3 # every satellite spawned is spread across >= this
|
||||
# many distinct nodes, node count permitting
|
||||
_DOUBLED_MIN_GAP = 22 # minimum radius gap between 2 satellites sharing a node
|
||||
_CENTER_HEIGHT_BUMP = 30 # extra clearance so satellites orbit outside the
|
||||
# (much bigger) center button, not underneath it
|
||||
_LINE_STYLES = ["solid", "dashed", "dotted"]
|
||||
_COUNT_BASE = 2
|
||||
_COUNT_RANDOM_MOD = 3 # random upper bound = (_COUNT_RANDOM_MOD % node-amount) + _COUNT_RANDOM_ADD
|
||||
_COUNT_RANDOM_ADD = 2
|
||||
|
||||
def _pick_assignments(self, node_positions: list[tuple[float, float]],
|
||||
center: Optional[tuple[float, float]] = None) -> list[_Assignment]:
|
||||
"""Randomize which nodes get a satellite, which glyph goes where, and which
|
||||
(if any) get a tangential bracket — decided once per page build. Total count
|
||||
is always _COUNT_BASE + random(1, (_COUNT_RANDOM_MOD % node-amount) +
|
||||
_COUNT_RANDOM_ADD) — scales down on pages with few nodes — clamped to
|
||||
however many actually fit given _MAX_PER_NODE; since there are only
|
||||
len(_SATELLITES) distinct glyphs, counts above that cycle through them again
|
||||
rather than repeating the same one back-to-back. Placement first seeds
|
||||
_MIN_SPREAD_NODES distinct nodes one each, then drops the rest onto random
|
||||
nodes with room — so on any page with enough nodes, they land on at least 3
|
||||
of them, never crammed onto 1-2. `center` (the center/back button's
|
||||
position) is just one more candidate node — it can get satellites too, with
|
||||
extra height so they clear it."""
|
||||
all_positions = list(node_positions)
|
||||
if center is not None:
|
||||
all_positions.append(center)
|
||||
n_nodes = len(all_positions)
|
||||
if n_nodes == 0:
|
||||
return []
|
||||
|
||||
random_max = (self._COUNT_RANDOM_MOD % n_nodes) + self._COUNT_RANDOM_ADD
|
||||
count = self._COUNT_BASE + random.randint(1, random_max)
|
||||
count = min(count, n_nodes * self._MAX_PER_NODE)
|
||||
sats: list[_Satellite] = []
|
||||
while len(sats) < count:
|
||||
cycle = list(self._SATELLITES)
|
||||
random.shuffle(cycle)
|
||||
sats.extend(cycle)
|
||||
sats = sats[:count]
|
||||
random.shuffle(sats)
|
||||
|
||||
node_order = list(range(n_nodes))
|
||||
random.shuffle(node_order)
|
||||
seed_count = min(self._MIN_SPREAD_NODES, n_nodes)
|
||||
|
||||
per_node = [0] * n_nodes
|
||||
placements: list[tuple[_Satellite, int]] = []
|
||||
for idx in node_order[:seed_count]:
|
||||
if not sats:
|
||||
break
|
||||
per_node[idx] += 1
|
||||
placements.append((sats.pop(), idx))
|
||||
for sat in sats:
|
||||
candidates = [i for i, c in enumerate(per_node) if c < self._MAX_PER_NODE]
|
||||
if not candidates:
|
||||
break # every node already at cap (only possible when n_nodes is tiny)
|
||||
idx = random.choice(candidates)
|
||||
per_node[idx] += 1
|
||||
placements.append((sat, idx))
|
||||
|
||||
assignments = [
|
||||
_Assignment(
|
||||
sat=sat, node=all_positions[idx],
|
||||
effective_height=sat.height + (self._CENTER_HEIGHT_BUMP if all_positions[idx] == center else 0),
|
||||
bracket=random.choice(self._BRACKETS) if random.random() < self._BRACKET_CHANCE else None,
|
||||
line_style=random.choice(self._LINE_STYLES),
|
||||
# Independent per-satellite response strength to horizontal vs.
|
||||
# vertical mouse movement — drawn separately, so a given satellite
|
||||
# often ends up noticeably more reactive in one direction than
|
||||
# the other instead of moving toward the cursor symmetrically.
|
||||
mult_x=random.uniform(0.5, 1.8),
|
||||
mult_y=random.uniform(0.5, 1.8),
|
||||
)
|
||||
for sat, idx in placements
|
||||
]
|
||||
|
||||
# A node with 2 satellites: split their motion instead of both facing the
|
||||
# cursor identically (one tracks the mouse's X only, the other its Y — see
|
||||
# _draw_satellites), widen the gap between their orbit radii so the higher
|
||||
# one reads as clearly further out, and give each its own random phase
|
||||
# offset — so when a page has several doubled nodes, they drift out of
|
||||
# sync with each other instead of all sweeping in lockstep.
|
||||
by_node: dict[tuple[float, float], list[_Assignment]] = {}
|
||||
for a in assignments:
|
||||
by_node.setdefault(a.node, []).append(a)
|
||||
for group in by_node.values():
|
||||
if len(group) == self._MAX_PER_NODE:
|
||||
random.shuffle(group)
|
||||
group[0].axis = "x"
|
||||
group[1].axis = "y"
|
||||
for g in group:
|
||||
g.mouse_offset = random.uniform(0.0, 2 * math.pi)
|
||||
lo, hi = sorted(group, key=lambda a: a.effective_height)
|
||||
if hi.effective_height - lo.effective_height < self._DOUBLED_MIN_GAP:
|
||||
hi.effective_height = lo.effective_height + self._DOUBLED_MIN_GAP
|
||||
|
||||
return assignments
|
||||
|
||||
@staticmethod
|
||||
def _node_label(icon: str, label: str) -> Gtk.Widget:
|
||||
|
|
@ -158,15 +334,140 @@ class OrbitMenu(Gtk.Window):
|
|||
box.append(lb)
|
||||
return box
|
||||
|
||||
def _make_ring_draw(self):
|
||||
def _make_page_draw(self, has_ring: bool, assignments: list[_Assignment]):
|
||||
def draw(_area, cr, width, height):
|
||||
cx, cy = width / 2, height / 2
|
||||
cr.set_source_rgba(1, 1, 1, 0.12)
|
||||
cr.set_line_width(1.5)
|
||||
cr.arc(cx, cy, self.RADIUS, 0, 2 * math.pi)
|
||||
cr.stroke()
|
||||
if has_ring:
|
||||
cx, cy = width / 2, height / 2
|
||||
cr.set_source_rgba(1, 1, 1, 0.12)
|
||||
cr.set_line_width(1.5)
|
||||
cr.arc(cx, cy, self.RADIUS, 0, 2 * math.pi)
|
||||
cr.stroke()
|
||||
if self._satellites_enabled:
|
||||
self._draw_satellites(cr, assignments)
|
||||
return draw
|
||||
|
||||
def _draw_satellites(self, cr, assignments: list[_Assignment]) -> None:
|
||||
"""Each assigned glyph orbits its node at a FIXED radial height (set once in
|
||||
_SATELLITES, never changed here) — only the angle around the node moves: it
|
||||
eases to face the cursor (see _on_tick's mouse smoothing) plus a small idle
|
||||
wobble so it's alive even when the mouse sits still. Purely cosmetic, never
|
||||
hit-testable. Orbit lines are drawn first, in their own pass, so no glyph's
|
||||
glow ends up half-covered by a line belonging to a different satellite."""
|
||||
for a in assignments:
|
||||
nx, ny = a.node
|
||||
self._draw_orbit_line(cr, nx, ny, a.effective_height, a.sat.color, a.line_style)
|
||||
|
||||
mx, my = self._mouse_smooth
|
||||
for a in assignments:
|
||||
nx, ny = a.node
|
||||
if a.axis == "x":
|
||||
# tracks only the cursor's X position — a full sweep left-to-right
|
||||
# across the canvas takes it all the way around its node. Each
|
||||
# doubled node's own random offset (see _pick_assignments) keeps
|
||||
# multiple doubled nodes from all sweeping in lockstep.
|
||||
face_angle = (mx / self.CANVAS_SIZE) * 2 * math.pi * a.mult_x + a.mouse_offset
|
||||
elif a.axis == "y":
|
||||
face_angle = (my / self.CANVAS_SIZE) * 2 * math.pi * a.mult_y + a.mouse_offset
|
||||
else:
|
||||
# mult_x/mult_y independently scale each axis before the direction
|
||||
# is computed, so this satellite can be visibly more reactive to
|
||||
# horizontal cursor movement than vertical, or vice versa.
|
||||
dx, dy = (mx - nx) * a.mult_x, (my - ny) * a.mult_y
|
||||
face_angle = math.atan2(dy, dx) if (dx or dy) else 0.0
|
||||
angle = face_angle + a.sat.wobble_amp * math.sin(self._sat_time * a.sat.wobble_speed + a.sat.phase)
|
||||
px = nx + a.effective_height * math.cos(angle)
|
||||
py = ny + a.effective_height * math.sin(angle)
|
||||
self._draw_glyph(cr, px, py, a.sat.glyph, a.sat.color, orbit_angle=angle, bracket=a.bracket)
|
||||
|
||||
def _draw_orbit_line(self, cr, nx: float, ny: float, radius: float,
|
||||
color: tuple[float, float, float], style: str) -> None:
|
||||
"""A circle tracing the path a satellite's angle sweeps around its node —
|
||||
solid/dashed/dotted per-assignment (see _pick_assignments), fixed for as
|
||||
long as that page's assignments are, so it never flickers style. Kept
|
||||
visibly faint against the dark theme, but not so faint it disappears."""
|
||||
r, g, b = color
|
||||
cr.save()
|
||||
if style == "dashed":
|
||||
cr.set_dash([7.0, 5.0])
|
||||
elif style == "dotted":
|
||||
cr.set_dash([2.0, 3.5])
|
||||
cr.set_source_rgba(r, g, b, 0.38)
|
||||
cr.set_line_width(1.4)
|
||||
cr.arc(nx, ny, radius, 0, 2 * math.pi)
|
||||
cr.stroke()
|
||||
cr.restore()
|
||||
|
||||
def _draw_glyph(self, cr, x: float, y: float, glyph: str, color: tuple[float, float, float],
|
||||
orbit_angle: float = 0.0, bracket: Optional[tuple[str, str]] = None) -> None:
|
||||
r, g, b = color
|
||||
|
||||
glow = cairo.RadialGradient(x, y, 0, x, y, 16)
|
||||
glow.add_color_stop_rgba(0.0, r, g, b, 0.45)
|
||||
glow.add_color_stop_rgba(1.0, r, g, b, 0.0)
|
||||
cr.set_source(glow)
|
||||
cr.arc(x, y, 16, 0, 2 * math.pi)
|
||||
cr.fill()
|
||||
|
||||
cr.select_font_face(self._GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
||||
cr.set_font_size(15)
|
||||
ext = cr.text_extents(glyph)
|
||||
cr.move_to(x - ext.width / 2 - ext.x_bearing, y - ext.height / 2 - ext.y_bearing)
|
||||
cr.set_source_rgba(r, g, b, 0.95)
|
||||
cr.show_text(glyph)
|
||||
|
||||
if bracket is not None:
|
||||
self._draw_bracket(cr, x, y, orbit_angle, bracket, color)
|
||||
|
||||
def _draw_bracket(self, cr, x: float, y: float, orbit_angle: float,
|
||||
bracket: tuple[str, str], color: tuple[float, float, float]) -> None:
|
||||
"""Flank the glyph at (x, y) with two small strings along the orbit's
|
||||
TANGENT at this point (perpendicular to the radial orbit_angle), e.g.
|
||||
"-< ->-" either side — like brackets riding along the direction of travel
|
||||
rather than pointing at the node."""
|
||||
r, g, b = color
|
||||
tangent = orbit_angle + math.pi / 2
|
||||
# keep the text roughly upright instead of upside-down on the far side
|
||||
if math.cos(tangent) < 0:
|
||||
tangent += math.pi
|
||||
|
||||
cr.select_font_face(self._GLYPH_FONT, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
||||
cr.set_font_size(11)
|
||||
offset = 15.0
|
||||
for text, sign in ((bracket[0], -1), (bracket[1], 1)):
|
||||
ext = cr.text_extents(text)
|
||||
bx = x + sign * offset * math.cos(tangent)
|
||||
by = y + sign * offset * math.sin(tangent)
|
||||
cr.save()
|
||||
cr.translate(bx, by)
|
||||
cr.rotate(tangent)
|
||||
cr.move_to(-ext.width / 2 - ext.x_bearing, -ext.height / 2 - ext.y_bearing)
|
||||
cr.set_source_rgba(r, g, b, 0.7)
|
||||
cr.show_text(text)
|
||||
cr.restore()
|
||||
|
||||
# -- mouse parallax / animation loop -------------------------------------
|
||||
def _on_motion(self, _ctrl, x: float, y: float) -> None:
|
||||
self._mouse_target = (x, y)
|
||||
|
||||
def _on_motion_leave(self, _ctrl) -> None:
|
||||
self._mouse_target = (self.CANVAS_SIZE / 2, self.CANVAS_SIZE / 2)
|
||||
|
||||
def _on_tick(self, _widget, frame_clock) -> bool:
|
||||
now = frame_clock.get_frame_time() / 1_000_000 # -> seconds
|
||||
dt = 0.0 if self._last_tick is None else max(0.0, now - self._last_tick)
|
||||
self._last_tick = now
|
||||
|
||||
self._sat_time += dt
|
||||
|
||||
mx, my = self._mouse_target
|
||||
sx, sy = self._mouse_smooth
|
||||
ease = 1 - math.exp(-dt * 6.0) # frame-rate independent lerp toward the cursor
|
||||
self._mouse_smooth = (sx + (mx - sx) * ease, sy + (my - sy) * ease)
|
||||
|
||||
if self._current_ring is not None:
|
||||
self._current_ring.queue_draw()
|
||||
return True # keep ticking every frame while the window is mapped
|
||||
|
||||
# -- navigation ---------------------------------------------------------
|
||||
def _show_path(self, path: tuple) -> None:
|
||||
key = self._cache_key(path)
|
||||
|
|
@ -174,18 +475,21 @@ class OrbitMenu(Gtk.Window):
|
|||
# a dynamic path (or one passing through a dynamic ancestor) is rebuilt fresh
|
||||
# and swapped in under a throwaway name so the stack still crossfades to it.
|
||||
if key is None:
|
||||
page = self._build_page(path)
|
||||
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 = self._build_page(path)
|
||||
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._current_ring = self._page_rings.get(name)
|
||||
|
||||
def _on_node_clicked(self, idx: int) -> None:
|
||||
path = tuple(self._stack_path)
|
||||
|
|
@ -205,6 +509,9 @@ class OrbitMenu(Gtk.Window):
|
|||
|
||||
def _close(self) -> None:
|
||||
self.set_visible(False)
|
||||
if self._tick_id is not None:
|
||||
self.remove_tick_callback(self._tick_id)
|
||||
self._tick_id = None
|
||||
if self._on_close:
|
||||
self._on_close()
|
||||
|
||||
|
|
@ -225,6 +532,8 @@ class OrbitMenu(Gtk.Window):
|
|||
for child in [pages.get_item(i).get_child() for i in range(pages.get_n_items())]:
|
||||
self._gtkstack.remove(child)
|
||||
self._pages.clear()
|
||||
self._page_rings.clear()
|
||||
self._current_ring = None
|
||||
self._root = root
|
||||
|
||||
def open_at_root(self) -> None:
|
||||
|
|
@ -233,3 +542,5 @@ class OrbitMenu(Gtk.Window):
|
|||
self._show_path(())
|
||||
self.set_visible(True)
|
||||
self.present()
|
||||
if self._satellites_enabled and self._tick_id is None:
|
||||
self._tick_id = self.add_tick_callback(self._on_tick)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
|
@ -9,4 +10,14 @@ STYLE_DIR = BASE_DIR / "style"
|
|||
|
||||
SCRIPTS_DIR = Path.home() / "Documents" / "Scripts"
|
||||
|
||||
# User settings live under XDG_STATE_HOME, NOT ~/.config — config-updater does
|
||||
# `rm -rf ~/.config/orbit-menu` on every dotfiles sync (see astal-menu/paths.py
|
||||
# for the same rationale), which would wipe a hand-edited config on the spot.
|
||||
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "orbit-menu"
|
||||
CONFIG_FILE = STATE_DIR / "config.json"
|
||||
|
||||
APP_ID = "eu.abdelbaki.orbitmenu"
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
|
|
|||
Loading…
Reference in New Issue