diff --git a/niri-animations/README.md b/niri-animations/README.md new file mode 100644 index 0000000..003b210 --- /dev/null +++ b/niri-animations/README.md @@ -0,0 +1,105 @@ +# Niri Animations + +Pick a [niri](https://github.com/YaLTeR/niri) animation preset, tune the global animation +speed, or switch animations off — from a Noctalia panel, instead of editing config by hand +and reloading the compositor yourself. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `imjustdoingmypart/niri-animations` | +| Entries | Panels: `picker` (floating), `docked` (attached to the bar); shortcut: `toggle` | + +## Requirements + +- The `niri` command on `PATH` — used to reload the compositor config after a change. +- A niri config that `include`s the plugin's target file **after** your base animations. + The plugin owns that file; point it at one dedicated to this, not at your `config.kdl`: + + ```kdl + include "./cfg/animation.kdl" // your base / fallback animations + // ... + include "./animations.kdl" // managed by this plugin, included last + ``` + +- A folder of `.kdl` animation presets. Any file niri can `include` works; collections such + as [nirimation](https://github.com/XansiVA/nirimation) drop straight in. + +## Usage + +Open the picker floating, or attached to the bar: + +```sh +noctalia msg panel-toggle imjustdoingmypart/niri-animations:picker # floating, centered +noctalia msg panel-toggle imjustdoingmypart/niri-animations:docked # attached to the bar +``` + +Bind one of those to a key in your compositor. The **Animations** tile, added from +Settings → Control Center → Shortcuts, opens the attached variant and stays highlighted +while animations are enabled. + +Both panel ids run the same script. They exist as separate entries because `placement` and +`position` are host-owned — the host reads them from the manifest at load time, so a plugin +cannot change its own placement at runtime and there is no user-facing key to override a +plugin panel's placement. Shipping both variants is the only way to offer the choice. + +In the panel: + +- **Preset** — a dropdown of every `.kdl` in the presets directory, plus *No preset (base + pack)*, which drops the `include` and falls back to your base animations. +- **Animations toggle** — writes `animations { off }`. +- **Speed** — the global `slowdown` factor, 0.25×–3.00×. Above 1 is slower. +- **Random** — picks a random preset. + +Every change is written and applied immediately; there is no Apply button. + +Presets are listed by **filename**, deliberately. Preset 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 a user imported themselves. Surfacing it makes the plugin +look broken when the problem is somebody else's metadata. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `presets_dir` | `string` | `~/.config/niri/animations` | Folder scanned for `.kdl` presets. Non-`.kdl` files are ignored. | +| `target_file` | `string` | `~/.config/niri/animations.kdl` | File rewritten with the selection. **Rewritten whole** — treat it as owned by the plugin. | +| `include_prefix` | `string` | `./animations` | Path prefix written in the `include` line, relative to `target_file`. | +| `reload_command` | `string` | `niri msg action load-config-file` | Run after each write so the compositor picks up the change. | + +## Notes + +**What the plugin writes.** `target_file` gets an `include` line for the chosen preset and an +`animations` block carrying the speed: + +```kdl +include "./animations/prism_fold.kdl" + +animations { + slowdown 1.50 +} +``` + +`off` and `slowdown` are direct fields of `animations`, not subsections, which is why the +speed applies on top of a preset that sets its own `slowdown`. niri's includes are positional +and merge field by field, so a target file included last wins over the base animations. +See [Configuration: Animations](https://github.com/YaLTeR/niri/wiki/Configuration:-Animations) +and [Configuration: Include](https://github.com/YaLTeR/niri/wiki/Configuration:-Include). + +**Side effects.** + +| | | +| --- | --- | +| Network | None. | +| Files read | `presets_dir` (directory listing) and `target_file` (to restore panel state on open). | +| Files written | `target_file` only. Nothing else on disk is modified. | +| Processes spawned | `reload_command` after each write, and `noctalia msg panel-toggle …` when the control-center tile is clicked. | + +**Other compositors.** The logic is compositor-agnostic — it writes an `include` line and runs +a reload command — but the generated `animations { }` block is niri syntax, so only niri is +supported today. + +## License + +MIT diff --git a/niri-animations/panel.luau b/niri-animations/panel.luau new file mode 100644 index 0000000..275c6b7 --- /dev/null +++ b/niri-animations/panel.luau @@ -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 diff --git a/niri-animations/plugin.toml b/niri-animations/plugin.toml new file mode 100644 index 0000000..8df5b34 --- /dev/null +++ b/niri-animations/plugin.toml @@ -0,0 +1,75 @@ +# Animation preset picker for niri, with a global speed control. +# +# Writes one file (an `include` line plus an `animations` block) and reloads niri's config. +# It never touches config.kdl — that file is expected to include the target file last. + +id = "imjustdoingmypart/niri-animations" +name = "Niri Animations" +version = "0.2.0" +plugin_api = 9 +author = "ImJustDoingMyPart" +license = "MIT" +dependencies = ["niri"] +tags = ["niri", "animation", "panel", "shortcut"] +icon = "movie" +description = "Pick a niri animation preset, tune global speed, or turn animations off." + +# Root-level settings: these seed EVERY entry (panels and shortcut alike). They live here +# rather than under [[panel.setting]] because [[shortcut]] entries do not receive entry-level +# settings, and the tile needs target_file to know whether animations are currently on. + +[[setting]] +key = "presets_dir" +type = "string" +label_key = "settings.presets_dir.label" +description_key = "settings.presets_dir.description" +default = "~/.config/niri/animations" + +[[setting]] +key = "target_file" +type = "string" +label_key = "settings.target_file.label" +description_key = "settings.target_file.description" +default = "~/.config/niri/animations.kdl" + +[[setting]] +key = "include_prefix" +type = "string" +label_key = "settings.include_prefix.label" +description_key = "settings.include_prefix.description" +default = "./animations" + +[[setting]] +key = "reload_command" +type = "string" +label_key = "settings.reload_command.label" +description_key = "settings.reload_command.description" +default = "niri msg action load-config-file" + +# Two entries, same script, different anchoring. placement/position are host-owned: the host +# reads them from this manifest at load time, so a plugin cannot change its own placement at +# runtime and there is no user-facing key to override a plugin panel's placement. Shipping +# both variants is the only way to offer the choice. +# +# noctalia msg panel-toggle imjustdoingmypart/niri-animations:picker (floating, centered) +# noctalia msg panel-toggle imjustdoingmypart/niri-animations:docked (attached to the bar) + +[[panel]] +id = "picker" +entry = "panel.luau" +width = 420 +height = 300 +placement = "floating" +position = "center" + +[[panel]] +id = "docked" +entry = "panel.luau" +width = 420 +height = 300 +placement = "attached" + +# Control-center tile; opens the attached variant. +[[shortcut]] +id = "toggle" +entry = "shortcut.luau" diff --git a/niri-animations/shortcut.luau b/niri-animations/shortcut.luau new file mode 100644 index 0000000..a792086 --- /dev/null +++ b/niri-animations/shortcut.luau @@ -0,0 +1,36 @@ +--!nonstrict +-- Control-center tile: opens the picker panel, and lights up while animations are enabled. +-- +-- shortcut.setLabel(text) +-- shortcut.setIcon(on [, off]) +-- shortcut.setActive(bool) +-- shortcut.setEnabled(bool) + +-- target_file is declared as a root-level [[setting]], which seeds every entry. Shortcuts do +-- NOT receive entry-level ([[panel.setting]]) values, so keeping the fallback here is what +-- makes this file survive someone moving that setting back under an entry. +local TARGET = noctalia.getConfig("target_file") or "~/.config/niri/animations.kdl" + +local function animationsOn(): boolean + local text = noctalia.readFile(TARGET) + if text == nil then return true end + return string.match(text, "\n%s*off%s*[\n\r]") == nil +end + +local function render() + local on = animationsOn() + shortcut.setLabel(noctalia.tr("shortcut_label")) + shortcut.setIcon("movie") + shortcut.setActive(on) + shortcut.setEnabled(true) +end + +render() + +-- The tile opens the ATTACHED variant: it is pressed from the control center, which already +-- sits against the bar, so a panel floating in the middle of the screen would feel detached. +-- Bind the `picker` entry to a key for the floating one. +function onClick() + noctalia.runAsync("noctalia msg panel-toggle imjustdoingmypart/niri-animations:docked") + render() +end diff --git a/niri-animations/thumbnail.webp b/niri-animations/thumbnail.webp new file mode 100644 index 0000000..5923ce1 Binary files /dev/null and b/niri-animations/thumbnail.webp differ diff --git a/niri-animations/translations/en.json b/niri-animations/translations/en.json new file mode 100644 index 0000000..868e042 --- /dev/null +++ b/niri-animations/translations/en.json @@ -0,0 +1,34 @@ +{ + "title": "Niri Animations", + "preset": "Preset", + "no_preset": "No preset (base pack)", + "animations_on": "Animations on", + "animations_off": "Animations off", + "random": "Random", + "speed": "Speed", + "speed_hint": "above 1 is slower", + "shortcut_label": "Animations", + "status_presets_found": "presets in", + "status_base_pack": "Base pack", + "status_off": "Animations disabled.", + "status_read_error": "Could not read", + "status_write_error": "Could not write", + "settings": { + "presets_dir": { + "label": "Presets directory", + "description": "Folder scanned for .kdl animation presets" + }, + "target_file": { + "label": "Target file", + "description": "File rewritten with the chosen preset and speed. Your niri config must include it after the base animations." + }, + "include_prefix": { + "label": "Include prefix", + "description": "Path prefix written in the include line, relative to the target file" + }, + "reload_command": { + "label": "Reload command", + "description": "Run after writing, so the compositor picks up the change" + } + } +} diff --git a/niri-animations/translations/es.json b/niri-animations/translations/es.json new file mode 100644 index 0000000..790ce6d --- /dev/null +++ b/niri-animations/translations/es.json @@ -0,0 +1,34 @@ +{ + "title": "Niri Animations", + "preset": "Preset", + "no_preset": "Sin preset (pack base)", + "animations_on": "Animaciones activas", + "animations_off": "Animaciones apagadas", + "random": "Al azar", + "speed": "Velocidad", + "speed_hint": "arriba de 1 es más lento", + "shortcut_label": "Animaciones", + "status_presets_found": "presets en", + "status_base_pack": "Pack base", + "status_off": "Animaciones desactivadas.", + "status_read_error": "No pude leer", + "status_write_error": "No pude escribir", + "settings": { + "presets_dir": { + "label": "Carpeta de presets", + "description": "Carpeta donde se buscan los presets .kdl de animación" + }, + "target_file": { + "label": "Archivo destino", + "description": "Archivo que se reescribe con el preset y la velocidad elegidos. Tu config de niri tiene que incluirlo después de las animaciones base." + }, + "include_prefix": { + "label": "Prefijo del include", + "description": "Prefijo de ruta que se escribe en la línea include, relativo al archivo destino" + }, + "reload_command": { + "label": "Comando de recarga", + "description": "Se ejecuta después de escribir, para que el compositor tome el cambio" + } + } +}