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
+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