Add niri-animations plugin (#223)

Animation preset picker for niri: choose a .kdl preset, set the global
slowdown factor, or turn animations off. Ships a floating and a
bar-attached panel plus a control-center tile.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ImJustDoingMyPart
2026-08-02 23:09:10 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 9d40f236bf
commit 5dc0808725
7 changed files with 487 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
# Niri Animations
Pick a [niri](https://github.com/YaLTeR/niri) animation preset, tune the global animation
speed, or switch animations off — from a Noctalia panel, instead of editing config by hand
and reloading the compositor yourself.
## Plugin
| Field | Value |
| --- | --- |
| ID | `imjustdoingmypart/niri-animations` |
| Entries | Panels: `picker` (floating), `docked` (attached to the bar); shortcut: `toggle` |
## Requirements
- The `niri` command on `PATH` — used to reload the compositor config after a change.
- A niri config that `include`s the plugin's target file **after** your base animations.
The plugin owns that file; point it at one dedicated to this, not at your `config.kdl`:
```kdl
include "./cfg/animation.kdl" // your base / fallback animations
// ...
include "./animations.kdl" // managed by this plugin, included last
```
- A folder of `.kdl` animation presets. Any file niri can `include` works; collections such
as [nirimation](https://github.com/XansiVA/nirimation) drop straight in.
## Usage
Open the picker floating, or attached to the bar:
```sh
noctalia msg panel-toggle imjustdoingmypart/niri-animations:picker # floating, centered
noctalia msg panel-toggle imjustdoingmypart/niri-animations:docked # attached to the bar
```
Bind one of those to a key in your compositor. The **Animations** tile, added from
Settings → Control Center → Shortcuts, opens the attached variant and stays highlighted
while animations are enabled.
Both panel ids run the same script. They exist as separate entries because `placement` and
`position` are host-owned — the host reads them from the manifest at load time, so a plugin
cannot change its own placement at runtime and there is no user-facing key to override a
plugin panel's placement. Shipping both variants is the only way to offer the choice.
In the panel:
- **Preset** — a dropdown of every `.kdl` in the presets directory, plus *No preset (base
pack)*, which drops the `include` and falls back to your base animations.
- **Animations toggle** — writes `animations { off }`.
- **Speed** — the global `slowdown` factor, 0.25×–3.00×. Above 1 is slower.
- **Random** — picks a random preset.
Every change is written and applied immediately; there is no Apply button.
Presets are listed by **filename**, deliberately. Preset headers carry a `Desc:` field, but
it is free-form text written by each preset author — often just "imported from https://…" —
and it gets worse with presets a user imported themselves. Surfacing it makes the plugin
look broken when the problem is somebody else's metadata.
## Settings
| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `presets_dir` | `string` | `~/.config/niri/animations` | Folder scanned for `.kdl` presets. Non-`.kdl` files are ignored. |
| `target_file` | `string` | `~/.config/niri/animations.kdl` | File rewritten with the selection. **Rewritten whole** — treat it as owned by the plugin. |
| `include_prefix` | `string` | `./animations` | Path prefix written in the `include` line, relative to `target_file`. |
| `reload_command` | `string` | `niri msg action load-config-file` | Run after each write so the compositor picks up the change. |
## Notes
**What the plugin writes.** `target_file` gets an `include` line for the chosen preset and an
`animations` block carrying the speed:
```kdl
include "./animations/prism_fold.kdl"
animations {
slowdown 1.50
}
```
`off` and `slowdown` are direct fields of `animations`, not subsections, which is why the
speed applies on top of a preset that sets its own `slowdown`. niri's includes are positional
and merge field by field, so a target file included last wins over the base animations.
See [Configuration: Animations](https://github.com/YaLTeR/niri/wiki/Configuration:-Animations)
and [Configuration: Include](https://github.com/YaLTeR/niri/wiki/Configuration:-Include).
**Side effects.**
| | |
| --- | --- |
| Network | None. |
| Files read | `presets_dir` (directory listing) and `target_file` (to restore panel state on open). |
| Files written | `target_file` only. Nothing else on disk is modified. |
| Processes spawned | `reload_command` after each write, and `noctalia msg panel-toggle …` when the control-center tile is clicked. |
**Other compositors.** The logic is compositor-agnostic — it writes an `include` line and runs
a reload command — but the generated `animations { }` block is niri syntax, so only niri is
supported today.
## License
MIT
+203
View File
@@ -0,0 +1,203 @@
--!nonstrict
-- Animation preset picker for niri.
--
-- Rewrites the target file (default ~/.config/niri/animations.kdl) with two things: an
-- `include` for the chosen preset and an `animations` block carrying the global speed.
-- Then it asks niri to reload its config.
--
-- Why rewriting it whole is safe: that file exists only for this. Your niri config.kdl is
-- expected to include it LAST, after the base animations — niri's includes are positional
-- and merge field by field, so whatever lands here wins.
--
-- `off` and `slowdown` are direct fields of `animations`, not subsections, which is why the
-- speed override applies on top of a preset that sets its own slowdown:
-- https://github.com/YaLTeR/niri/wiki/Configuration:-Animations
--
-- Presets are listed by FILENAME on purpose. Their headers carry a `Desc:` field, but it is
-- free-form text written by each preset author (often just "imported from https://…"), and
-- it gets worse with presets the user imported themselves. Surfacing it makes the plugin
-- look broken when the problem is somebody else's metadata. A filename always matches what
-- the user sees in the folder.
local PLUGIN_ID = "imjustdoingmypart/niri-animations"
local function cfg(key: string, fallback: string): string
local v = noctalia.getConfig(key)
if v == nil or v == "" then return fallback end
return v
end
local PRESETS_DIR = cfg("presets_dir", "~/.config/niri/animations")
local TARGET = cfg("target_file", "~/.config/niri/animations.kdl")
local PREFIX = cfg("include_prefix", "./animations")
local RELOAD = cfg("reload_command", "niri msg action load-config-file")
local presets: { any } = {} -- { { file = "bloom.kdl", name = "bloom" } }
local activeFile: string? = nil
local slowdown = 1.0
local animationsOff = false
local status = ""
local function loadPresets()
presets = {}
local entries, err = noctalia.listDir(PRESETS_DIR)
if entries == nil then
status = `{noctalia.tr("status_read_error")} {PRESETS_DIR}: {err or "?"}`
return
end
table.sort(entries)
for _, name in ipairs(entries) do
if string.sub(name, -4) == ".kdl" then
table.insert(presets, { file = name, name = string.sub(name, 1, #name - 4) })
end
end
end
-- Re-read state from the target file so the panel always opens reflecting reality, even if
-- the file was edited by hand or changed from somewhere else.
local function loadCurrent()
activeFile = nil
slowdown = 1.0
animationsOff = false
local text = noctalia.readFile(TARGET)
if text == nil then return end
activeFile = string.match(text, 'include%s+"[^"]*[/\\]([^"/\\]+%.kdl)"')
or string.match(text, 'include%s+"([^"/\\]+%.kdl)"')
slowdown = tonumber(string.match(text, "slowdown%s+([%d%.]+)") or "") or 1.0
animationsOff = string.match(text, "\n%s*off%s*[\n\r]") ~= nil
end
local function apply()
local out = {
`// Generated by the Noctalia plugin "{PLUGIN_ID}". Do not edit by hand: this file is`,
"// rewritten whole whenever a preset is picked or the speed changes.",
"// Your base/fallback animations belong in a file included BEFORE this one.",
"",
}
if activeFile ~= nil and not animationsOff then
table.insert(out, `include "{PREFIX}/{activeFile}"`)
table.insert(out, "")
end
table.insert(out, "animations {")
if animationsOff then
table.insert(out, " off")
else
table.insert(out, ` slowdown {string.format("%.2f", slowdown)}`)
end
table.insert(out, "}")
table.insert(out, "")
local _, err = noctalia.writeFile(TARGET, table.concat(out, "\n"))
if err ~= nil then
status = `{noctalia.tr("status_write_error")} {TARGET}: {err}`
noctalia.notify(noctalia.tr("title"), status)
return
end
noctalia.runAsync(RELOAD)
local speed = `{string.format("%.2f", slowdown)}×`
if animationsOff then
status = noctalia.tr("status_off")
elseif activeFile == nil then
status = `{noctalia.tr("status_base_pack")} — {speed}`
else
status = `{string.sub(activeFile, 1, #activeFile - 4)} — {speed}`
end
end
-- Index 0 of the dropdown is "no preset"; real presets start at 1.
local function options(): { string }
local opts = { noctalia.tr("no_preset") }
for _, p in ipairs(presets) do
table.insert(opts, p.name)
end
return opts
end
local function selectedIndex(): number
if activeFile == nil then return 0 end
for i, p in ipairs(presets) do
if p.file == activeFile then return i end
end
return 0
end
local function render()
panel.render(ui.column({ flexGrow = 1, gap = 16, align = "stretch" }, {
-- No close button on purpose: on a keyboard-driven WM the panel is dismissed with Esc
-- or by pressing the shortcut again, so the button is dead weight.
ui.label({ text = noctalia.tr("title"), fontSize = 16, fontWeight = "bold", color = "on_surface" }),
ui.row({ gap = 12, align = "center" }, {
ui.toggle({ checked = not animationsOff, onChange = "onToggleAnimations" }),
ui.label({
text = animationsOff and noctalia.tr("animations_off") or noctalia.tr("animations_on"),
flexGrow = 1,
}),
ui.button({ text = noctalia.tr("random"), onClick = "onRandom" }),
}),
ui.column({ gap = 6, align = "stretch" }, {
ui.label({ text = noctalia.tr("preset"), color = "on_surface_variant" }),
ui.select({ options = options(), selectedIndex = selectedIndex(), onChange = "onPreset" }),
}),
ui.column({ gap = 6, align = "stretch" }, {
ui.label({
text = `{noctalia.tr("speed")} — {string.format("%.2f", slowdown)}× ({noctalia.tr("speed_hint")})`,
color = "on_surface_variant",
}),
ui.slider({ min = 0.25, max = 3.0, step = 0.05, value = slowdown, onChange = "onSlowdown" }),
}),
ui.label({ text = status, color = "primary" }),
}))
end
function onOpen(_context)
loadPresets()
loadCurrent()
if status == "" then
status = `{#presets} {noctalia.tr("status_presets_found")} {PRESETS_DIR}`
end
render()
end
function onPreset(index, _text)
local i = tonumber(index) or 0
if i <= 0 then
activeFile = nil
else
local p = presets[i]
activeFile = p and p.file or nil
end
animationsOff = false
apply()
render()
end
function onToggleAnimations(value)
animationsOff = not (value == "true")
apply()
render()
end
function onSlowdown(value)
slowdown = tonumber(value) or 1.0
apply()
render()
end
function onRandom()
if #presets == 0 then return end
activeFile = presets[math.random(1, #presets)].file
animationsOff = false
apply()
render()
end
+75
View File
@@ -0,0 +1,75 @@
# Animation preset picker for niri, with a global speed control.
#
# Writes one file (an `include` line plus an `animations` block) and reloads niri's config.
# It never touches config.kdl — that file is expected to include the target file last.
id = "imjustdoingmypart/niri-animations"
name = "Niri Animations"
version = "0.2.0"
plugin_api = 9
author = "ImJustDoingMyPart"
license = "MIT"
dependencies = ["niri"]
tags = ["niri", "animation", "panel", "shortcut"]
icon = "movie"
description = "Pick a niri animation preset, tune global speed, or turn animations off."
# Root-level settings: these seed EVERY entry (panels and shortcut alike). They live here
# rather than under [[panel.setting]] because [[shortcut]] entries do not receive entry-level
# settings, and the tile needs target_file to know whether animations are currently on.
[[setting]]
key = "presets_dir"
type = "string"
label_key = "settings.presets_dir.label"
description_key = "settings.presets_dir.description"
default = "~/.config/niri/animations"
[[setting]]
key = "target_file"
type = "string"
label_key = "settings.target_file.label"
description_key = "settings.target_file.description"
default = "~/.config/niri/animations.kdl"
[[setting]]
key = "include_prefix"
type = "string"
label_key = "settings.include_prefix.label"
description_key = "settings.include_prefix.description"
default = "./animations"
[[setting]]
key = "reload_command"
type = "string"
label_key = "settings.reload_command.label"
description_key = "settings.reload_command.description"
default = "niri msg action load-config-file"
# Two entries, same script, different anchoring. placement/position are host-owned: the host
# reads them from this manifest at load time, so a plugin cannot change its own placement at
# runtime and there is no user-facing key to override a plugin panel's placement. Shipping
# both variants is the only way to offer the choice.
#
# noctalia msg panel-toggle imjustdoingmypart/niri-animations:picker (floating, centered)
# noctalia msg panel-toggle imjustdoingmypart/niri-animations:docked (attached to the bar)
[[panel]]
id = "picker"
entry = "panel.luau"
width = 420
height = 300
placement = "floating"
position = "center"
[[panel]]
id = "docked"
entry = "panel.luau"
width = 420
height = 300
placement = "attached"
# Control-center tile; opens the attached variant.
[[shortcut]]
id = "toggle"
entry = "shortcut.luau"
+36
View File
@@ -0,0 +1,36 @@
--!nonstrict
-- Control-center tile: opens the picker panel, and lights up while animations are enabled.
--
-- shortcut.setLabel(text)
-- shortcut.setIcon(on [, off])
-- shortcut.setActive(bool)
-- shortcut.setEnabled(bool)
-- target_file is declared as a root-level [[setting]], which seeds every entry. Shortcuts do
-- NOT receive entry-level ([[panel.setting]]) values, so keeping the fallback here is what
-- makes this file survive someone moving that setting back under an entry.
local TARGET = noctalia.getConfig("target_file") or "~/.config/niri/animations.kdl"
local function animationsOn(): boolean
local text = noctalia.readFile(TARGET)
if text == nil then return true end
return string.match(text, "\n%s*off%s*[\n\r]") == nil
end
local function render()
local on = animationsOn()
shortcut.setLabel(noctalia.tr("shortcut_label"))
shortcut.setIcon("movie")
shortcut.setActive(on)
shortcut.setEnabled(true)
end
render()
-- The tile opens the ATTACHED variant: it is pressed from the control center, which already
-- sits against the bar, so a panel floating in the middle of the screen would feel detached.
-- Bind the `picker` entry to a key for the floating one.
function onClick()
noctalia.runAsync("noctalia msg panel-toggle imjustdoingmypart/niri-animations:docked")
render()
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+34
View File
@@ -0,0 +1,34 @@
{
"title": "Niri Animations",
"preset": "Preset",
"no_preset": "No preset (base pack)",
"animations_on": "Animations on",
"animations_off": "Animations off",
"random": "Random",
"speed": "Speed",
"speed_hint": "above 1 is slower",
"shortcut_label": "Animations",
"status_presets_found": "presets in",
"status_base_pack": "Base pack",
"status_off": "Animations disabled.",
"status_read_error": "Could not read",
"status_write_error": "Could not write",
"settings": {
"presets_dir": {
"label": "Presets directory",
"description": "Folder scanned for .kdl animation presets"
},
"target_file": {
"label": "Target file",
"description": "File rewritten with the chosen preset and speed. Your niri config must include it after the base animations."
},
"include_prefix": {
"label": "Include prefix",
"description": "Path prefix written in the include line, relative to the target file"
},
"reload_command": {
"label": "Reload command",
"description": "Run after writing, so the compositor picks up the change"
}
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"title": "Niri Animations",
"preset": "Preset",
"no_preset": "Sin preset (pack base)",
"animations_on": "Animaciones activas",
"animations_off": "Animaciones apagadas",
"random": "Al azar",
"speed": "Velocidad",
"speed_hint": "arriba de 1 es más lento",
"shortcut_label": "Animaciones",
"status_presets_found": "presets en",
"status_base_pack": "Pack base",
"status_off": "Animaciones desactivadas.",
"status_read_error": "No pude leer",
"status_write_error": "No pude escribir",
"settings": {
"presets_dir": {
"label": "Carpeta de presets",
"description": "Carpeta donde se buscan los presets .kdl de animación"
},
"target_file": {
"label": "Archivo destino",
"description": "Archivo que se reescribe con el preset y la velocidad elegidos. Tu config de niri tiene que incluirlo después de las animaciones base."
},
"include_prefix": {
"label": "Prefijo del include",
"description": "Prefijo de ruta que se escribe en la línea include, relativo al archivo destino"
},
"reload_command": {
"label": "Comando de recarga",
"description": "Se ejecuta después de escribir, para que el compositor tome el cambio"
}
}
}