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,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
|
||||
Reference in New Issue
Block a user