Files
niritings/nirimod/pages/animations.py
2026-05-29 00:41:12 +00:00

1223 lines
46 KiB
Python

"""Animations page with bezier curve editor and Nirimation preset browser."""
from __future__ import annotations
import json
import math
from pathlib import Path
import threading
import urllib.error
import urllib.request
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk
from nirimod.kdl_parser import KdlNode, find_or_create, parse_kdl, set_child_arg, set_node_flag
from nirimod.pages.base import BasePage
_NIRIMATION_API = (
"https://api.github.com/repos/XansiVA/nirimation/contents/animations"
)
_NIRIMATION_RAW = (
"https://raw.githubusercontent.com/XansiVA/nirimation/main/animations/{name}"
)
_NIRIMATION_HTML = (
"https://github.com/XansiVA/nirimation/blob/main/animations/{name}"
)
_JGARZA_API = (
"https://api.github.com/repos/jgarza9788/niri-animation-collection/contents/animations"
)
_JGARZA_RAW = (
"https://raw.githubusercontent.com/jgarza9788/niri-animation-collection/main/animations/{name}"
)
_JGARZA_HTML = (
"https://github.com/jgarza9788/niri-animation-collection/blob/main/animations/{name}"
)
# In-memory cache: None = not fetched, list = fetched entries, Exception = error
_nirimation_cache: list[dict] | Exception | None = None
_jgarza_cache: list[dict] | Exception | None = None
# Local presets directory
_LOCAL_PRESETS_DIR = Path("~/.config/nirimod/presets").expanduser()
# Slug used as subdirectory name for each source
_SOURCE_SLUGS = {
"XansiVA/nirimation": "nirimation",
"jgarza9788/niri-animation-collection": "niri-animation-collection",
}
ANIM_GROUPS = [
("Window Management", [
("window-open", "Window Open", "window-new-symbolic"),
("window-close", "Window Close", "window-close-symbolic"),
("window-movement", "Window Movement", "transform-move-symbolic"),
("window-resize", "Window Resize", "view-fullscreen-symbolic"),
]),
("Workspace", [
("workspace-switch", "Workspace Switch", "video-display-symbolic"),
("horizontal-view-movement", "Horizontal View Movement", "pan-end-symbolic"),
]),
("Interface", [
("overview-open-close", "Overview Open/Close", "view-app-grid-symbolic"),
("overview-screenshot", "Overview Screenshot", "camera-photo-symbolic"),
("screenshot-ui-open", "Screenshot UI Open", "camera-photo-symbolic"),
("config-notification-open-close", "Config Notification", "preferences-system-symbolic"),
])
]
PRESET_CURVES = {
"ease": (0.25, 0.1, 0.25, 1.0),
"ease-in": (0.42, 0.0, 1.0, 1.0),
"ease-out": (0.0, 0.0, 0.58, 1.0),
"ease-in-out": (0.42, 0.0, 0.58, 1.0),
"linear": (0.0, 0.0, 1.0, 1.0),
"spring": (0.17, 0.67, 0.83, 0.67),
}
class BezierEditor(Gtk.DrawingArea):
"""Interactive cubic Bézier curve editor with animated preview ball."""
def __init__(self, on_changed=None):
super().__init__()
self._cp = [0.25, 0.1, 0.25, 1.0] # x1,y1,x2,y2
self._on_changed = on_changed
self._dragging: int | None = None # 0=p1, 1=p2
self._ball_t = 0.0
self._ball_dir = 1
self._anim_id: int | None = None
self.set_content_width(220)
self.set_content_height(180)
self.set_draw_func(self._draw)
motion = Gtk.EventControllerMotion()
motion.connect("motion", self._on_motion)
self.add_controller(motion)
click = Gtk.GestureClick()
click.connect("pressed", self._on_press)
click.connect("released", self._on_release)
self.add_controller(click)
self.add_tick_callback(self._on_tick)
def set_curve(self, x1, y1, x2, y2):
self._cp = [x1, y1, x2, y2]
self.queue_draw()
def get_curve(self):
return tuple(self._cp)
def _on_tick(self, widget, frame_clock):
current_time = frame_clock.get_frame_time()
if not hasattr(self, "_last_time"):
self._last_time = current_time
return True
dt = (current_time - self._last_time) / 1_000_000.0
self._last_time = current_time
# Move at a constant speed of ~0.75 units per second
speed = 0.75
self._ball_t += (dt * speed) * self._ball_dir
if self._ball_t >= 1.0:
self._ball_t = 1.0
self._ball_dir = -1
elif self._ball_t <= 0.0:
self._ball_t = 0.0
self._ball_dir = 1
self.queue_draw()
return True
def _bezier_pt(self, t):
x1, y1, x2, y2 = self._cp
# Cubic bezier from (0,0) to (1,1) with controls (x1,y1), (x2,y2)
mt = 1 - t
bx = 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t
by = 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t
return bx, by
def _canvas_to_cp(self, cx, cy, W, H, pad=20):
"""Convert canvas coords to bezier control point (0-1 range)."""
x = (cx - pad) / (W - 2 * pad)
y = 1.0 - (cy - pad) / (H - 2 * pad)
return max(0.0, min(1.0, x)), max(-0.5, min(1.5, y))
def _cp_to_canvas(self, x, y, W, H, pad=20):
cx = pad + x * (W - 2 * pad)
cy = pad + (1.0 - y) * (H - 2 * pad)
return cx, cy
def _draw(self, area, cr, W, H):
pad = 20
cr.set_source_rgba(0.08, 0.08, 0.08, 1.0)
cr.rectangle(0, 0, W, H)
cr.fill()
cr.set_source_rgba(0.2, 0.2, 0.22, 0.4)
cr.set_line_width(0.5)
for i in range(5):
gx = pad + i * (W - 2 * pad) / 4
gy = pad + i * (H - 2 * pad) / 4
cr.move_to(gx, pad)
cr.line_to(gx, H - pad)
cr.stroke()
cr.move_to(pad, gy)
cr.line_to(W - pad, gy)
cr.stroke()
x1, y1, x2, y2 = self._cp
px1, py1 = self._cp_to_canvas(x1, y1, W, H, pad)
px2, py2 = self._cp_to_canvas(x2, y2, W, H, pad)
start = self._cp_to_canvas(0, 0, W, H, pad)
end = self._cp_to_canvas(1, 1, W, H, pad)
cr.set_source_rgba(0.2, 0.2, 0.25, 0.4)
cr.set_line_width(1.0)
cr.move_to(*start)
cr.line_to(px1, py1)
cr.stroke()
cr.move_to(*end)
cr.line_to(px2, py2)
cr.stroke()
# Bezier path
cr.set_source_rgba(0.3, 0.7, 1.0, 0.9)
cr.set_line_width(2.5)
cr.move_to(*start)
cr.curve_to(px1, py1, px2, py2, *end)
cr.stroke()
bx_01, by_01 = self._bezier_pt(self._ball_t)
bx_c, by_c = self._cp_to_canvas(bx_01, by_01, W, H, pad)
cr.set_source_rgba(1.0, 0.6, 0.2, 0.95)
cr.arc(bx_c, by_c, 5, 0, 2 * math.pi)
cr.fill()
for px, py, color in [
(px1, py1, (0.4, 1.0, 0.5, 1.0)),
(px2, py2, (1.0, 0.4, 0.5, 1.0)),
]:
cr.set_source_rgba(*color)
cr.arc(px, py, 6, 0, 2 * math.pi)
cr.fill()
cr.set_source_rgba(1, 1, 1, 0.5)
cr.set_line_width(1.5)
cr.arc(px, py, 6, 0, 2 * math.pi)
cr.stroke()
def _hit_cp(self, cx, cy, W, H, pad=20):
x1, y1, x2, y2 = self._cp
px1, py1 = self._cp_to_canvas(x1, y1, W, H, pad)
px2, py2 = self._cp_to_canvas(x2, y2, W, H, pad)
if math.hypot(cx - px1, cy - py1) < 12:
return 0
if math.hypot(cx - px2, cy - py2) < 12:
return 1
return None
def _on_press(self, gesture, _n, x, y):
W = self.get_width()
H = self.get_height()
self._dragging = self._hit_cp(x, y, W, H)
def _on_release(self, gesture, _n, x, y):
self._dragging = None
def _on_motion(self, controller, x, y):
if self._dragging is None:
return
W = self.get_width()
H = self.get_height()
cpx, cpy = self._canvas_to_cp(x, y, W, H)
if self._dragging == 0:
self._cp[0] = cpx
self._cp[1] = cpy
else:
self._cp[2] = cpx
self._cp[3] = cpy
self.queue_draw()
if self._on_changed:
self._on_changed(*self._cp)
def _fetch_presets_from_github(api_url, raw_tmpl, html_tmpl, cache_attr, callback):
"""Generic preset fetcher for any GitHub contents API endpoint."""
import sys
mod = sys.modules[__name__]
cached = getattr(mod, cache_attr)
if cached is not None:
GLib.idle_add(callback, cached)
return
def _worker():
try:
req = urllib.request.Request(
api_url,
headers={"User-Agent": "nirimod/1.0"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
entries = []
for item in data:
if item.get("type") != "file":
continue
n = item["name"]
if not n.endswith(".kdl"):
continue
stem = n[:-4] # strip .kdl
display = stem.replace("-", " ").replace("_", " ").title()
entries.append(
{
"name": n,
"display_name": display,
"download_url": item.get(
"download_url",
raw_tmpl.format(name=n),
),
"html_url": item.get(
"html_url",
html_tmpl.format(name=n),
),
}
)
entries.sort(key=lambda e: e["display_name"])
setattr(mod, cache_attr, entries)
GLib.idle_add(callback, entries)
except Exception as exc:
setattr(mod, cache_attr, exc)
GLib.idle_add(callback, exc)
threading.Thread(target=_worker, daemon=True).start()
def _fetch_nirimation_presets(callback):
"""Fetch preset list from XansiVA/nirimation in a background thread."""
_fetch_presets_from_github(
_NIRIMATION_API, _NIRIMATION_RAW, _NIRIMATION_HTML,
"_nirimation_cache", callback,
)
def _fetch_jgarza_presets(callback):
"""Fetch preset list from jgarza9788/niri-animation-collection in a background thread."""
_fetch_presets_from_github(
_JGARZA_API, _JGARZA_RAW, _JGARZA_HTML,
"_jgarza_cache", callback,
)
class AnimationsPage(BasePage):
def __init__(self, window):
super().__init__(window)
self._prev_anim_snapshot = None
self._active_preset_name = None
self._state_file = Path("~/.config/nirimod/animations.json").expanduser()
self._load_state()
def _load_state(self):
try:
if self._state_file.exists():
with open(self._state_file, "r", encoding="utf-8") as f:
data = json.load(f)
self._prev_anim_snapshot = data.get("prev_anim_snapshot")
self._active_preset_name = data.get("active_preset_name")
except Exception as e:
print(f"Failed to load animations state: {e}")
def _save_state(self):
try:
self._state_file.parent.mkdir(parents=True, exist_ok=True)
with open(self._state_file, "w", encoding="utf-8") as f:
json.dump({
"prev_anim_snapshot": self._prev_anim_snapshot,
"active_preset_name": self._active_preset_name
}, f)
except Exception as e:
print(f"Failed to save animations state: {e}")
def build(self) -> Gtk.Widget:
tb, header, _, _ = self._make_toolbar_page("")
header.set_title_widget(Gtk.Box()) # hide the default title
# Custom Header (matches Workspace View / Keybindings aesthetic)
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
header_box.set_margin_start(24)
header_box.set_margin_end(24)
header_box.set_margin_top(20)
header_box.set_margin_bottom(12)
# Title/Subtitle Group
title_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
title_vbox.set_hexpand(True)
self._main_title = Gtk.Label(label="Animations")
self._main_title.set_xalign(0.0)
self._main_title.add_css_class("title-1")
title_vbox.append(self._main_title)
self._active_preset_lbl = Gtk.Label(label="Using custom animations")
self._active_preset_lbl.set_xalign(0.0)
self._active_preset_lbl.add_css_class("dim-label")
self._active_preset_lbl.add_css_class("caption")
title_vbox.append(self._active_preset_lbl)
header_box.append(title_vbox)
# View Switcher (Styled as Custom/Presets buttons)
self._view_stack = Adw.ViewStack()
switcher_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
switcher_box.add_css_class("linked")
switcher_box.set_valign(Gtk.Align.START)
self._btn_custom = Gtk.ToggleButton(label="Custom")
self._btn_presets = Gtk.ToggleButton(label="Presets")
self._btn_presets.set_group(self._btn_custom)
self._btn_custom.connect("toggled", self._on_view_toggle)
self._btn_presets.connect("toggled", self._on_view_toggle)
switcher_box.append(self._btn_custom)
switcher_box.append(self._btn_presets)
header_box.append(switcher_box)
# Custom Header (matches Workspace View / Keybindings aesthetic)
self._view_stack = Adw.ViewStack()
self._view_stack.set_vexpand(True)
# Tabs
custom_widget = self._build_custom_tab()
self._view_stack.add_named(custom_widget, "custom")
presets_widget = self._build_presets_tab()
self._view_stack.add_named(presets_widget, "presets")
# Default to Custom
self._view_stack.set_visible_child_name("custom")
self._btn_custom.set_active(True)
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
main_box.append(header_box)
main_box.append(self._view_stack)
tb.set_content(main_box)
self._update_header()
return tb
def _on_view_toggle(self, btn):
if not btn.get_active():
return
is_custom = btn == self._btn_custom
self._view_stack.set_visible_child_name("custom" if is_custom else "presets")
def _update_header(self):
if self._active_preset_name:
self._active_preset_lbl.set_label(f"✨ Active preset: <b>{GLib.markup_escape_text(self._active_preset_name)}</b>")
self._active_preset_lbl.set_use_markup(True)
else:
self._active_preset_lbl.set_label("Using custom animations")
self._active_preset_lbl.set_use_markup(False)
if hasattr(self, "_custom_switch_grp"):
self._custom_switch_grp.set_visible(self._prev_anim_snapshot is not None)
def _build_custom_tab(self) -> Gtk.Widget:
"""Return the custom animations tab (global toggles, bezier editor, and categories)."""
if not hasattr(self, "_custom_scroll"):
self._custom_scroll = Gtk.ScrolledWindow()
self._custom_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
self._custom_scroll.set_vexpand(True)
self._custom_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
self._custom_content.set_hexpand(True)
self._custom_content.set_margin_start(24)
self._custom_content.set_margin_end(24)
self._custom_content.set_margin_top(24)
self._custom_content.set_margin_bottom(24)
self._custom_scroll.set_child(self._custom_content)
else:
while child := self._custom_content.get_first_child():
self._custom_content.remove(child)
content = self._custom_content
anim_node = find_or_create(self._nodes, "animations")
# ── Switch to Custom Banner ──────────────────────────────────────────
self._custom_switch_grp = Adw.PreferencesGroup()
self._custom_switch_grp.set_hexpand(True)
self._custom_switch_row = Adw.ActionRow(
title="Community Preset Active",
subtitle="You are currently using a preset. Switch back to use your custom animation settings."
)
self._custom_switch_row.add_css_class("property")
self._custom_switch_row.set_icon_name("emblem-important-symbolic")
switch_btn = Gtk.Button(label="Switch to Custom")
switch_btn.add_css_class("suggested-action")
switch_btn.add_css_class("pill")
switch_btn.set_valign(Gtk.Align.CENTER)
switch_btn.set_margin_top(8)
switch_btn.set_margin_bottom(8)
switch_btn.connect("clicked", self._on_restore_previous)
self._custom_switch_row.add_suffix(switch_btn)
self._custom_switch_grp.add(self._custom_switch_row)
self._custom_switch_grp.set_visible(self._prev_anim_snapshot is not None)
content.append(self._custom_switch_grp)
# ── Global Settings ──────────────────────────────────────────────────
off_grp = Adw.PreferencesGroup(
title="Global Settings",
description="These apply to all animations universally."
)
off_grp.set_hexpand(True)
off_row = Adw.SwitchRow(title="Enable Animations", subtitle="Toggle all desktop animations on or off")
off_row.set_icon_name("media-playback-start-symbolic")
off_row.set_active(anim_node.get_child("off") is None)
off_row.connect(
"notify::active", lambda r, _: self._toggle_all(not r.get_active())
)
off_grp.add(off_row)
slowdown_val = float(anim_node.child_arg("slowdown") or 1.0)
slowdown_adj = Gtk.Adjustment(
value=slowdown_val, lower=0.1, upper=10.0, step_increment=0.1
)
slowdown_row = Adw.SpinRow(
title="Global Slowdown Factor",
subtitle="Multiply all animation durations by this factor",
adjustment=slowdown_adj, digits=1
)
slowdown_row.set_icon_name("preferences-system-time-symbolic")
slowdown_row._last_val = slowdown_val
def _on_slowdown_changed(r, _):
new_val = float(r.get_value())
if abs(new_val - getattr(r, "_last_val", 0.0)) > 0.01:
r._last_val = new_val
self._set_anim("slowdown", new_val)
slowdown_row.connect("notify::value", _on_slowdown_changed)
off_grp.add(slowdown_row)
content.append(off_grp)
# ── Easing Curve Editor ──────────────────────────────────────────────
bezier_grp = Adw.PreferencesGroup(
title="Easing Curve Editor",
description="Design a custom easing curve to apply to any animation below."
)
bezier_grp.set_hexpand(True)
editor_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=48)
editor_row.set_margin_start(12)
editor_row.set_margin_end(12)
editor_row.set_margin_top(20)
editor_row.set_margin_bottom(20)
editor_row.set_halign(Gtk.Align.CENTER)
# Left: bezier canvas
edit_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
edit_vbox.set_valign(Gtk.Align.CENTER)
self._bezier_editor = BezierEditor(on_changed=self._on_bezier_changed)
self._bezier_editor.set_halign(Gtk.Align.CENTER)
edit_vbox.append(self._bezier_editor)
coords_lbl = Gtk.Label(label="0.25, 0.1, 0.25, 1.0")
coords_lbl.add_css_class("monospace")
coords_lbl.add_css_class("dim-label")
coords_lbl.set_selectable(True)
coords_lbl.set_halign(Gtk.Align.CENTER)
self._coords_lbl = coords_lbl
edit_vbox.append(coords_lbl)
editor_row.append(edit_vbox)
# Right: quick presets
presets_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
presets_vbox.set_valign(Gtk.Align.CENTER)
preset_title = Gtk.Label(label="Quick Presets", xalign=0)
preset_title.add_css_class("heading")
presets_vbox.append(preset_title)
flow = Gtk.FlowBox()
flow.set_selection_mode(Gtk.SelectionMode.NONE)
flow.set_max_children_per_line(2)
flow.set_min_children_per_line(2)
flow.set_valign(Gtk.Align.START)
flow.set_column_spacing(6)
flow.set_row_spacing(6)
for name, curve in PRESET_CURVES.items():
btn = Gtk.Button(label=name)
btn.connect("clicked", lambda b, c=curve, n=name: self._apply_preset(c, n))
flow.append(btn)
presets_vbox.append(flow)
editor_row.append(presets_vbox)
bezier_grp.add(editor_row)
content.append(bezier_grp)
# ── Per-animation groups ─────────────────────────────────────────────
for group_title, anims in ANIM_GROUPS:
grp = Adw.PreferencesGroup(title=group_title)
grp.set_hexpand(True)
for anim_key, anim_label, icon_name in anims:
row = self._build_anim_row(anim_key, anim_label, icon_name, anim_node)
grp.add(row)
content.append(grp)
return self._custom_scroll
def _build_presets_tab(self) -> Gtk.Widget:
"""Return the community presets tab (downloaded + online sources)."""
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroll.set_vexpand(True)
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
content.set_hexpand(True)
content.set_margin_start(24)
content.set_margin_end(24)
content.set_margin_top(24)
content.set_margin_bottom(24)
scroll.set_child(content)
# Downloaded / offline section — always shown first
self._presets_content = content
self._local_presets_grp: Adw.PreferencesGroup | None = None
self._refresh_local_presets_group()
nim_grp = self._build_nirimation_group()
nim_grp.set_hexpand(True)
content.append(nim_grp)
jgarza_grp = self._build_jgarza_group()
jgarza_grp.set_hexpand(True)
content.append(jgarza_grp)
return scroll
# ------------------------------------------------------------------ local
def _local_preset_dir(self, source_label: str) -> Path:
slug = _SOURCE_SLUGS.get(source_label, source_label.replace("/", "-"))
return _LOCAL_PRESETS_DIR / slug
def _list_local_presets(self) -> list[dict]:
"""Return all downloaded presets sorted by display name."""
entries: list[dict] = []
if not _LOCAL_PRESETS_DIR.exists():
return entries
for source_dir in sorted(_LOCAL_PRESETS_DIR.iterdir()):
if not source_dir.is_dir():
continue
# Reverse-map slug → label
slug_to_label = {v: k for k, v in _SOURCE_SLUGS.items()}
source_label = slug_to_label.get(source_dir.name, source_dir.name)
for kdl_file in sorted(source_dir.glob("*.kdl")):
stem = kdl_file.stem
display = stem.replace("-", " ").replace("_", " ").title()
entries.append(
{
"name": kdl_file.name,
"display_name": display,
"source_label": source_label,
"local_path": kdl_file,
}
)
return entries
def _refresh_local_presets_group(self):
"""Rebuild the Downloaded Presets group from the filesystem."""
# Remove the old group widget from the content box if present
if self._local_presets_grp is not None and hasattr(self, "_presets_content"):
try:
self._presets_content.remove(self._local_presets_grp)
except Exception:
pass
entries = self._list_local_presets()
grp = Adw.PreferencesGroup(
title="Downloaded Presets",
description="Locally saved presets — apply these without an internet connection.",
)
grp.set_hexpand(True)
grp.set_header_suffix(self._make_open_folder_btn())
self._local_presets_grp = grp
if not entries:
empty_row = Adw.ActionRow(
title="No presets downloaded yet",
subtitle="Use the download button (\u2193) next to any online preset below.",
)
empty_row.add_prefix(Gtk.Image.new_from_icon_name("folder-download-symbolic"))
grp.add(empty_row)
else:
for entry in entries:
row = self._make_local_preset_row(entry)
grp.add(row)
# Prepend at the top of the presets content box
if hasattr(self, "_presets_content"):
# Insert before the first child (nirimation group)
first = self._presets_content.get_first_child()
if first:
self._presets_content.insert_child_after(grp, None) # prepend
else:
self._presets_content.append(grp)
def _make_open_folder_btn(self) -> Gtk.Button:
btn = Gtk.Button(icon_name="folder-open-symbolic")
btn.set_tooltip_text("Open presets folder")
btn.add_css_class("flat")
btn.add_css_class("circular")
btn.connect(
"clicked",
lambda _b: Gtk.show_uri(None, _LOCAL_PRESETS_DIR.as_uri(), 0),
)
return btn
def _make_local_preset_row(self, entry: dict) -> Adw.ActionRow:
"""Row for a locally-downloaded preset (Apply + Delete)."""
row = Adw.ActionRow(
title=entry["display_name"],
subtitle=f"{entry['source_label']} · {entry['name']}",
)
row.add_prefix(Gtk.Image.new_from_icon_name("drive-harddisk-symbolic"))
# Delete button
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
del_btn.set_tooltip_text("Delete local copy")
del_btn.add_css_class("flat")
del_btn.add_css_class("circular")
del_btn.set_valign(Gtk.Align.CENTER)
del_btn.connect(
"clicked",
lambda _b, e=entry: self._delete_local_preset(e),
)
row.add_suffix(del_btn)
# Apply button
apply_btn = Gtk.Button(label="Apply")
apply_btn.add_css_class("suggested-action")
apply_btn.add_css_class("pill")
apply_btn.set_valign(Gtk.Align.CENTER)
apply_btn.connect(
"clicked",
lambda _b, e=entry, r=row: self._confirm_apply_local_preset(e, r),
)
row.add_suffix(apply_btn)
return row
def _confirm_apply_local_preset(self, entry: dict, row: Adw.ActionRow):
try:
dialog = Adw.AlertDialog(
heading=f"Apply \"{entry['display_name']}\"?",
body=(
"This will fully replace your current animations block with the locally saved "
f"\"{entry['display_name']}\" preset.\n\n"
"Your existing bezier curves and per-animation settings will be overwritten. "
"You can undo this with Ctrl+Z."
),
)
dialog.add_response("cancel", "Cancel")
dialog.add_response("apply", "Apply Preset")
dialog.set_response_appearance("apply", Adw.ResponseAppearance.SUGGESTED)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
def _on_response(d, resp):
if resp == "apply":
self._apply_local_preset(entry, row)
dialog.connect("response", _on_response)
dialog.present(self._win)
except AttributeError:
self._apply_local_preset(entry, row)
def _apply_local_preset(self, entry: dict, row: Adw.ActionRow):
"""Apply a locally-saved .kdl file (no network required)."""
try:
kdl_text = entry["local_path"].read_text(encoding="utf-8")
self._do_apply_kdl_preset(kdl_text, entry["display_name"], row)
except Exception as exc:
self.show_toast(f"Failed to read local preset: {exc}")
def _delete_local_preset(self, entry: dict):
try:
entry["local_path"].unlink(missing_ok=True)
# Clean up empty source dir
parent = entry["local_path"].parent
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
self.show_toast(f"🗑 {entry['display_name']} deleted")
self._refresh_local_presets_group()
except Exception as exc:
self.show_toast(f"Delete failed: {exc}")
def _on_restore_previous(self, _btn):
"""Restore the animations block that was saved before the last preset apply."""
if self._prev_anim_snapshot is None:
return
try:
snap_nodes = parse_kdl(self._prev_anim_snapshot)
snap_anim = next((n for n in snap_nodes if n.name == "animations"), None)
user_nodes = self._nodes
user_anim = next((n for n in reversed(user_nodes) if n.name == "animations"), None)
if user_anim is None:
user_anim = KdlNode(name="animations")
user_anim.leading_trivia = "\n"
user_nodes.append(user_anim)
if snap_anim is not None:
user_anim.children = list(snap_anim.children)
user_anim.args = list(snap_anim.args)
user_anim.props = dict(snap_anim.props)
else:
user_anim.children = []
user_anim.args = []
user_anim.props = {}
self._prev_anim_snapshot = None
self._active_preset_name = None
self._save_state()
self._commit("restore previous animations")
self.show_toast("↩ Previous animations restored")
self._update_header()
self._build_custom_tab() # Refresh UI components
except Exception as exc:
self.show_toast(f"Restore failed: {exc}")
def _build_preset_group(
self,
title: str,
description: str,
fetch_fn,
bust_cache_attr: str,
rows_attr: str,
source_label: str,
repo_url: str,
) -> Adw.PreferencesGroup:
"""Generic builder for a community-preset PreferencesGroup."""
import sys
mod = sys.modules[__name__]
header_btns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
repo_btn = Gtk.Button(icon_name="web-browser-symbolic")
repo_btn.set_tooltip_text("View repository on GitHub")
repo_btn.add_css_class("flat")
repo_btn.add_css_class("circular")
repo_btn.connect("clicked", lambda _b: Gtk.show_uri(None, repo_url, 0))
header_btns.append(repo_btn)
refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic")
refresh_btn.set_tooltip_text("Refresh preset list from GitHub")
refresh_btn.add_css_class("flat")
refresh_btn.add_css_class("circular")
header_btns.append(refresh_btn)
grp = Adw.PreferencesGroup(title=title, description=description)
grp.set_header_suffix(header_btns)
spinner = Gtk.Spinner()
spinner.start()
spinner.set_margin_top(8)
spinner.set_margin_bottom(8)
spinner_row = Adw.ActionRow(title="Fetching presets…")
spinner_row.add_prefix(spinner)
grp.add(spinner_row)
rows: list[Adw.ActionRow] = []
setattr(self, rows_attr, rows)
def _on_result(result):
grp.remove(spinner_row)
spinner.stop()
if isinstance(result, Exception):
err_row = Adw.ActionRow(
title="Unable to fetch presets",
subtitle=str(result),
)
err_row.add_prefix(Gtk.Image.new_from_icon_name("network-error-symbolic"))
grp.add(err_row)
rows.append(err_row)
return
for entry in result:
row = self._make_preset_row(entry, source_label)
grp.add(row)
rows.append(row)
def _on_refresh_clicked(_btn):
setattr(mod, bust_cache_attr, None)
for row in list(rows):
grp.remove(row)
rows.clear()
sp2 = Gtk.Spinner()
sp2.start()
sp2.set_margin_top(8)
sp2.set_margin_bottom(8)
wait_row = Adw.ActionRow(title="Fetching presets…")
wait_row.add_prefix(sp2)
grp.add(wait_row)
def _on_result2(result):
grp.remove(wait_row)
sp2.stop()
if isinstance(result, Exception):
err_row = Adw.ActionRow(
title="Unable to fetch presets",
subtitle=str(result),
)
err_row.add_prefix(Gtk.Image.new_from_icon_name("network-error-symbolic"))
grp.add(err_row)
rows.append(err_row)
return
for entry in result:
row = self._make_preset_row(entry, source_label)
grp.add(row)
rows.append(row)
fetch_fn(_on_result2)
refresh_btn.connect("clicked", _on_refresh_clicked)
fetch_fn(_on_result)
return grp
def _build_nirimation_group(self) -> Adw.PreferencesGroup:
"""Build the XansiVA/nirimation presets section."""
return self._build_preset_group(
title="Nirimation Community Presets",
description="GLSL shader animations from XansiVA/nirimation — replaces your current animations block.",
fetch_fn=_fetch_nirimation_presets,
bust_cache_attr="_nirimation_cache",
rows_attr="_nirimation_rows",
source_label="XansiVA/nirimation",
repo_url="https://github.com/XansiVA/nirimation",
)
def _build_jgarza_group(self) -> Adw.PreferencesGroup:
"""Build the jgarza9788/niri-animation-collection presets section."""
return self._build_preset_group(
title="Niri Animation Collection",
description="Community GLSL shader presets from jgarza9788/niri-animation-collection — replaces your current animations block.",
fetch_fn=_fetch_jgarza_presets,
bust_cache_attr="_jgarza_cache",
rows_attr="_jgarza_rows",
source_label="jgarza9788/niri-animation-collection",
repo_url="https://github.com/jgarza9788/niri-animation-collection",
)
def _make_preset_row(self, entry: dict, source_label: str) -> Adw.ActionRow:
"""Create a single preset row for any community-preset group."""
row = Adw.ActionRow(
title=entry["display_name"],
subtitle=entry["name"],
)
# Download-to-disk button
dl_btn = Gtk.Button(icon_name="folder-download-symbolic")
dl_btn.set_tooltip_text("Download preset for offline use")
dl_btn.add_css_class("flat")
dl_btn.add_css_class("circular")
dl_btn.set_valign(Gtk.Align.CENTER)
dl_btn.connect(
"clicked",
lambda _b, e=entry, r=row, sl=source_label, b=dl_btn: self._download_preset_locally(e, r, sl, b),
)
row.add_suffix(dl_btn)
# Apply button
apply_btn = Gtk.Button(label="Apply")
apply_btn.add_css_class("suggested-action")
apply_btn.add_css_class("pill")
apply_btn.set_valign(Gtk.Align.CENTER)
apply_btn.connect(
"clicked",
lambda _b, e=entry, r=row, sl=source_label: self._confirm_apply_preset(e, r, sl),
)
row.add_suffix(apply_btn)
return row
def _download_preset_locally(self, entry, row, source_label, dl_btn):
# download the preset KDL to disk
dest_dir = self._local_preset_dir(source_label)
dest_file = dest_dir / entry["name"]
dl_btn.set_sensitive(False)
self.show_toast(f"Downloading {entry['display_name']}", timeout=5)
def _worker():
try:
req = urllib.request.Request(
entry["download_url"],
headers={"User-Agent": "nirimod/1.0"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
kdl_bytes = resp.read()
GLib.idle_add(_on_done, kdl_bytes, None)
except Exception as exc:
GLib.idle_add(_on_done, None, exc)
def _on_done(kdl_bytes, error):
dl_btn.set_sensitive(True)
if error:
self.show_toast(f"Download failed: {error}")
return
try:
dest_dir.mkdir(parents=True, exist_ok=True)
dest_file.write_bytes(kdl_bytes)
self.show_toast(f"{entry['display_name']} saved locally")
# Update the download button to show it's already saved
dl_btn.set_icon_name("emblem-ok-symbolic")
dl_btn.set_tooltip_text("Already downloaded")
dl_btn.set_sensitive(False)
self._refresh_local_presets_group()
except Exception as exc:
self.show_toast(f"Save failed: {exc}")
threading.Thread(target=_worker, daemon=True).start()
def _confirm_apply_preset(self, entry, row, source_label="community"):
try:
dialog = Adw.AlertDialog(
heading=f"Apply \"{entry['display_name']}\"?",
body=(
"This will fully replace your current animations block with the "
f"\"{entry['display_name']}\" preset from {source_label}.\n\n"
"Your existing bezier curves and per-animation settings will be overwritten. "
"You can undo this with Ctrl+Z."
),
)
dialog.add_response("cancel", "Cancel")
dialog.add_response("apply", "Apply Preset")
dialog.set_response_appearance("apply", Adw.ResponseAppearance.SUGGESTED)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
def _on_response(d, resp):
if resp == "apply":
self._apply_nirimation_preset(entry, row)
dialog.connect("response", _on_response)
dialog.present(self._win)
except AttributeError:
self._apply_nirimation_preset(entry, row)
def _apply_nirimation_preset(self, entry, row):
row.set_sensitive(False)
self.show_toast(f"Downloading {entry['display_name']}...", timeout=5)
def _worker():
try:
req = urllib.request.Request(
entry["download_url"],
headers={"User-Agent": "nirimod/1.0"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
kdl_text = resp.read().decode()
GLib.idle_add(_on_downloaded, kdl_text, None)
except Exception as exc:
GLib.idle_add(_on_downloaded, None, exc)
def _on_downloaded(kdl_text, error):
row.set_sensitive(True)
if error:
self.show_toast(f"Failed to download preset: {error}")
return
self._do_apply_kdl_preset(kdl_text, entry["display_name"], row)
threading.Thread(target=_worker, daemon=True).start()
def _do_apply_kdl_preset(self, kdl_text, display_name, row):
try:
preset_nodes = parse_kdl(kdl_text)
preset_anim = next(
(n for n in preset_nodes if n.name == "animations"), None
)
if preset_anim is None:
self.show_toast("Preset has no animations block — nothing applied.")
return
user_nodes = self._nodes
user_anim = next(
(n for n in reversed(user_nodes) if n.name == "animations"), None
)
if self._prev_anim_snapshot is None:
from nirimod.kdl_parser import write_kdl
if user_anim is not None:
snap_node = KdlNode(name="animations")
snap_node.children = list(user_anim.children)
snap_node.args = list(user_anim.args)
snap_node.props = dict(user_anim.props)
self._prev_anim_snapshot = write_kdl([snap_node])
else:
self._prev_anim_snapshot = write_kdl([])
if user_anim is None:
user_anim = KdlNode(name="animations")
user_anim.leading_trivia = "\n"
user_nodes.append(user_anim)
user_anim.children = list(preset_anim.children)
user_anim.args = list(preset_anim.args)
user_anim.props = dict(preset_anim.props)
self._active_preset_name = display_name
self._save_state()
self._commit(f"preset: {display_name}")
self._update_header()
self.show_toast(f"\u2728 {display_name} preset applied!")
except Exception as exc:
self.show_toast(f"Error applying preset: {exc}")
def _apply_preset(self, curve: tuple, name: str):
self._bezier_editor.set_curve(*curve)
self._update_coords_label()
def _on_bezier_changed(self, x1, y1, x2, y2):
self._update_coords_label()
def _update_coords_label(self):
x1, y1, x2, y2 = self._bezier_editor.get_curve()
self._coords_lbl.set_label(f"{x1:.3f}, {y1:.3f}, {x2:.3f}, {y2:.3f}")
def _build_anim_row(
self, key: str, label: str, icon_name: str, anim_node: KdlNode
) -> Adw.ExpanderRow:
grp = Adw.ExpanderRow(title=label)
grp.set_icon_name(icon_name)
grp.add_css_class("nm-expander")
an = anim_node.get_child(key)
enabled_row = Adw.SwitchRow(title="Enabled")
enabled_row.set_active(an is not None and an.get_child("off") is None)
enabled_row.connect(
"notify::active",
lambda r, _, k=key: self._set_anim_enabled(k, r.get_active()),
)
grp.add_row(enabled_row)
duration = an.child_arg("duration-ms") if an else 250
dur_val = int(duration) if duration else 250
dur_adj = Gtk.Adjustment(value=dur_val, lower=10, upper=2000, step_increment=10)
dur_row = Adw.SpinRow(title="Duration (ms)", adjustment=dur_adj, digits=0)
dur_row._last_val = dur_val
def _on_dur_changed(r, _, k=key):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_anim_prop(k, "duration-ms", new_val)
dur_row.connect("notify::value", _on_dur_changed)
grp.add_row(dur_row)
# Apply bezier button
apply_btn = Gtk.Button(label="Apply Editor Curve")
apply_btn.add_css_class("flat")
apply_btn.set_valign(Gtk.Align.CENTER)
# Determine current curve for subtitle
curve_node = an.get_child("curve") if an else None
easing = an.get_child("easing") if an else None
current_curve = ""
if curve_node and len(curve_node.args) >= 5:
vals = " ".join(str(v) for v in curve_node.args[1:])
current_curve = f"cubic-bezier {vals}"
elif easing and easing.child_arg("bezier"):
current_curve = f"bezier {easing.child_arg('bezier')}"
elif easing and easing.args:
current_curve = str(easing.args[0])
apply_row = Adw.ActionRow(title="Easing Curve", subtitle=current_curve if current_curve else "Default")
apply_btn.connect("clicked", lambda *_, k=key, ar=apply_row: self._apply_bezier_to_anim(k, ar))
apply_row.add_suffix(apply_btn)
grp.add_row(apply_row)
return grp
def _toggle_all(self, off: bool):
anim = find_or_create(self._nodes, "animations")
set_node_flag(anim, "off", off)
self._commit("animations off")
def _set_anim(self, key: str, value):
anim = find_or_create(self._nodes, "animations")
set_child_arg(anim, key, value)
self._commit(f"animations {key}")
def _set_anim_enabled(self, anim_key: str, enabled: bool):
anim = find_or_create(self._nodes, "animations")
an = anim.get_child(anim_key)
if not enabled:
if an is None:
an = KdlNode(anim_key)
anim.children.append(an)
set_node_flag(an, "off", True)
else:
if an:
from nirimod.kdl_parser import remove_child
remove_child(an, "off")
self._commit(f"animation {anim_key} enabled")
def _set_anim_prop(self, anim_key: str, prop: str, value):
anim = find_or_create(self._nodes, "animations")
an = anim.get_child(anim_key)
if an is None:
an = KdlNode(anim_key)
anim.children.append(an)
if prop == "duration-ms":
from nirimod.kdl_parser import remove_child
remove_child(an, "spring")
set_child_arg(an, prop, value)
self._commit(f"animation {anim_key} {prop}")
def _apply_bezier_to_anim(self, anim_key: str, apply_row: Adw.ActionRow = None):
x1, y1, x2, y2 = self._bezier_editor.get_curve()
anim = find_or_create(self._nodes, "animations")
an = anim.get_child(anim_key)
if an is None:
an = KdlNode(anim_key)
anim.children.append(an)
# Remove legacy easing block if present
old_easing = an.get_child("easing")
if old_easing is not None:
an.children.remove(old_easing)
from nirimod.kdl_parser import remove_child
remove_child(an, "spring")
curve_node = an.get_child("curve")
if curve_node is None:
curve_node = KdlNode("curve")
an.children.append(curve_node)
curve_node.args = ["cubic-bezier", round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3)]
curve_str = f"{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f}"
self._commit(f"animation {anim_key} bezier")
self.show_toast(f"Bezier applied to {anim_key}")
if apply_row:
apply_row.set_subtitle(f"cubic-bezier {curve_str}")