Files
community-plugins/niri-animations/panel.luau
T
5dc0808725 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>
2026-08-02 23:09:10 -04:00

204 lines
6.5 KiB
Luau
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
--!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