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:
co-authored by
Claude Opus 5
parent
9d40f236bf
commit
5dc0808725
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user