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>
204 lines
6.5 KiB
Luau
204 lines
6.5 KiB
Luau
--!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
|