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
Amir Alexander Abdelbaki 2026-07-17 10:45:24 +02:00
parent 2fa3414e31
commit 779e365cb8
4 changed files with 368 additions and 14 deletions

View File

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

View File

@ -36,6 +36,7 @@ import gi # noqa: E402
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
from gi.repository import Gio, GLib, Gtk # noqa: E402 from gi.repository import Gio, GLib, Gtk # noqa: E402
import config # noqa: E402
import menu_tree # noqa: E402 import menu_tree # noqa: E402
import theme # noqa: E402 import theme # noqa: E402
from orbit_menu import OrbitMenu # noqa: E402 from orbit_menu import OrbitMenu # noqa: E402
@ -52,7 +53,8 @@ class OrbitMenuApp(Gtk.Application):
def do_startup(self) -> None: def do_startup(self) -> None:
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())
self._current_mode = "menu" self._current_mode = "menu"
self._register_actions() self._register_actions()
self.hold() # stay alive with no visible window self.hold() # stay alive with no visible window

View File

@ -20,9 +20,11 @@ Astal.Window dependency) to match astal-menu/window.py's layer-shell approach.
from __future__ import annotations from __future__ import annotations
import math import math
import random
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Callable, Optional from typing import Callable, Optional
import cairo
import gi import gi
gi.require_version("Gtk", "4.0") 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 Gdk, 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
# 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 @dataclass
class MenuItem: class MenuItem:
@ -53,14 +89,45 @@ class OrbitMenu(Gtk.Window):
RADIUS = 168 RADIUS = 168
CANVAS_SIZE = 480 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__() 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._root = root self._root = root
self._stack_path: list[int] = [] # indices taken from root self._stack_path: list[int] = [] # indices taken from root
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._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() self._init_layer_shell()
@ -75,6 +142,12 @@ class OrbitMenu(Gtk.Window):
keys.connect("key-pressed", self._on_key) keys.connect("key-pressed", self._on_key)
self.add_controller(keys) 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(()) self._show_path(())
# -- layer shell ---------------------------------------------------- # -- layer shell ----------------------------------------------------
@ -102,7 +175,7 @@ class OrbitMenu(Gtk.Window):
return None if item.dynamic else path return None if item.dynamic else path
# -- page construction ------------------------------------------------ # -- 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) current = self._item_at(path)
fixed = Gtk.Fixed() fixed = Gtk.Fixed()
fixed.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) fixed.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
@ -111,8 +184,6 @@ class OrbitMenu(Gtk.Window):
ring = Gtk.DrawingArea() ring = Gtk.DrawingArea()
ring.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE) ring.set_size_request(self.CANVAS_SIZE, self.CANVAS_SIZE)
children = current.resolved_children() children = current.resolved_children()
if children:
ring.set_draw_func(self._make_ring_draw())
fixed.put(ring, 0, 0) fixed.put(ring, 0, 0)
center_btn = Gtk.Button() 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) fixed.put(center_btn, cx - self.CENTER_SIZE / 2, cy - self.CENTER_SIZE / 2)
count = len(children) count = len(children)
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)) angle = -math.pi / 2 + i * (2 * math.pi / max(count, 1))
nx = cx + self.RADIUS * math.cos(angle) nx = cx + self.RADIUS * math.cos(angle)
ny = cy + self.RADIUS * math.sin(angle) ny = cy + self.RADIUS * math.sin(angle)
node_positions.append((nx, ny))
node_btn = Gtk.Button() node_btn = Gtk.Button()
node_btn.set_has_frame(False) 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)) 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)
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 @staticmethod
def _node_label(icon: str, label: str) -> Gtk.Widget: def _node_label(icon: str, label: str) -> Gtk.Widget:
@ -158,15 +334,140 @@ class OrbitMenu(Gtk.Window):
box.append(lb) box.append(lb)
return box return box
def _make_ring_draw(self): def _make_page_draw(self, has_ring: bool, assignments: list[_Assignment]):
def draw(_area, cr, width, height): def draw(_area, cr, width, height):
cx, cy = width / 2, height / 2 if has_ring:
cr.set_source_rgba(1, 1, 1, 0.12) cx, cy = width / 2, height / 2
cr.set_line_width(1.5) cr.set_source_rgba(1, 1, 1, 0.12)
cr.arc(cx, cy, self.RADIUS, 0, 2 * math.pi) cr.set_line_width(1.5)
cr.stroke() cr.arc(cx, cy, self.RADIUS, 0, 2 * math.pi)
cr.stroke()
if self._satellites_enabled:
self._draw_satellites(cr, assignments)
return draw 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 --------------------------------------------------------- # -- navigation ---------------------------------------------------------
def _show_path(self, path: tuple) -> None: def _show_path(self, path: tuple) -> None:
key = self._cache_key(path) 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 # 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. # and swapped in under a throwaway name so the stack still crossfades to it.
if key is None: if key is None:
page = self._build_page(path) page, ring = self._build_page(path)
name = f"{name}#live" name = f"{name}#live"
old = self._gtkstack.get_child_by_name(name) old = self._gtkstack.get_child_by_name(name)
if old is not None: if old is not None:
self._gtkstack.remove(old) self._gtkstack.remove(old)
self._gtkstack.add_named(page, name) self._gtkstack.add_named(page, name)
self._page_rings[name] = ring
elif key not in self._pages: elif key not in self._pages:
page = self._build_page(path) page, ring = self._build_page(path)
self._pages[key] = page self._pages[key] = page
self._gtkstack.add_named(page, name) self._gtkstack.add_named(page, name)
self._page_rings[name] = ring
self._gtkstack.set_visible_child_name(name) 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)
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)
@ -205,6 +509,9 @@ class OrbitMenu(Gtk.Window):
def _close(self) -> None: def _close(self) -> None:
self.set_visible(False) 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: if self._on_close:
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())]: for child in [pages.get_item(i).get_child() for i in range(pages.get_n_items())]:
self._gtkstack.remove(child) self._gtkstack.remove(child)
self._pages.clear() self._pages.clear()
self._page_rings.clear()
self._current_ring = None
self._root = root self._root = root
def open_at_root(self) -> None: def open_at_root(self) -> None:
@ -233,3 +542,5 @@ class OrbitMenu(Gtk.Window):
self._show_path(()) self._show_path(())
self.set_visible(True) self.set_visible(True)
self.present() self.present()
if self._satellites_enabled and self._tick_id is None:
self._tick_id = self.add_tick_callback(self._on_tick)

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
@ -9,4 +10,14 @@ STYLE_DIR = BASE_DIR / "style"
SCRIPTS_DIR = Path.home() / "Documents" / "Scripts" 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" APP_ID = "eu.abdelbaki.orbitmenu"
def ensure_dirs() -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)