From 779e365cb858b22ca8b29a8b6647ff986aa37e38 Mon Sep 17 00:00:00 2001 From: The_miro Date: Fri, 17 Jul 2026 10:45:24 +0200 Subject: [PATCH] feat(hyprlua/orbit-menu): cosmetic mouse-reactive satellites around ring nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- desktopenvs/hyprlua/orbit-menu/config.py | 30 ++ desktopenvs/hyprlua/orbit-menu/main.py | 4 +- desktopenvs/hyprlua/orbit-menu/orbit_menu.py | 337 ++++++++++++++++++- desktopenvs/hyprlua/orbit-menu/paths.py | 11 + 4 files changed, 368 insertions(+), 14 deletions(-) create mode 100644 desktopenvs/hyprlua/orbit-menu/config.py diff --git a/desktopenvs/hyprlua/orbit-menu/config.py b/desktopenvs/hyprlua/orbit-menu/config.py new file mode 100644 index 0000000..910444c --- /dev/null +++ b/desktopenvs/hyprlua/orbit-menu/config.py @@ -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)) diff --git a/desktopenvs/hyprlua/orbit-menu/main.py b/desktopenvs/hyprlua/orbit-menu/main.py index 0942838..395e085 100644 --- a/desktopenvs/hyprlua/orbit-menu/main.py +++ b/desktopenvs/hyprlua/orbit-menu/main.py @@ -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 diff --git a/desktopenvs/hyprlua/orbit-menu/orbit_menu.py b/desktopenvs/hyprlua/orbit-menu/orbit_menu.py index 331195c..2fe9f24 100644 --- a/desktopenvs/hyprlua/orbit-menu/orbit_menu.py +++ b/desktopenvs/hyprlua/orbit-menu/orbit_menu.py @@ -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. "-< >-". 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) diff --git a/desktopenvs/hyprlua/orbit-menu/paths.py b/desktopenvs/hyprlua/orbit-menu/paths.py index 058028b..e642425 100644 --- a/desktopenvs/hyprlua/orbit-menu/paths.py +++ b/desktopenvs/hyprlua/orbit-menu/paths.py @@ -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)