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