- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| bin | ||
| config | ||
| docs/superpowers | ||
| tests | ||
| .gitignore | ||
| README.md | ||
niri-screensaver
An idle screensaver for this user's Niri/Wayland session, styled after Omarchy's. After 150 seconds without
input, Noctalia runs the controller. The controller opens one fullscreen Ghostty window on each active output, and
each window runs ttfx effects over the supplied ASCII artwork at 60 FPS on solid black. Any key, click or wheel
event dismisses every window (see "Dismissal"). Noctalia's existing lock (600 s), display-off (660 s) and lock-and-suspend
(900 s) behaviors are not changed.
Status: installed and enabled (idle trigger at 150 s). The interactive checks are recorded under "Test results"; the few that were not exercised live are listed there too.
Dependencies and provenance
| Dependency | Version | Source |
|---|---|---|
| Niri | 26.04 (8ed0da4) | system package |
| Noctalia | v5.2.1 (pacman noctalia 5.2.1-1.1, CachyOS) |
system package; owns idle and locking |
| Ghostty | system package | the user's normal config is not used or changed |
| Python | 3.14 (stdlib only) | system package |
ttfx |
ttfx 0.5.0 |
see below |
ttfx was built by the user from the upstream repository https://github.com/omacom/ttfx at checkout
v0.5.0-11-g921bd55. It reports ttfx 0.5.0 and is installed at ~/.local/bin/ttfx with sha256
a8d386b42e5419727ad6a5f84b72865c20b2576ddc429cb386602cd6852ccc37. Upstream publishes no release assets or
checksums, so that hash only identifies this local build. No remote installer script was run.
Commands
~/.local/bin/niri-screensaver start # launch on every active output (no-op if already running)
~/.local/bin/niri-screensaver stop # stop and restore the previously focused window when appropriate
~/.local/bin/niri-screensaver stop --locked # stop without touching focus (used by the lock hook)
~/.local/bin/niri-screensaver status # prints running/starting/stopped; exit 0 only while running/starting
Runtime state lives in $XDG_RUNTIME_DIR/niri-screensaver/ (control.sock, lock, serve.log).
Layout
| Repository file | Installed as |
|---|---|
bin/niri-screensaver |
~/.local/bin/niri-screensaver (0755) |
config/artwork.txt |
~/.config/niri-screensaver/artwork.txt |
config/ghostty.conf |
~/.config/niri-screensaver/ghostty.conf (dedicated; only passed with --config-file) |
config/niri.kdl |
appended to ~/.config/niri/cfg/rules.kdl |
config/noctalia.toml |
~/.config/noctalia/niri-screensaver.toml (idle behavior plus session_locked hook) |
Install
cd ~/Projects/niri-screensaver
timeout 300 python3 -m unittest discover -s tests
# 1. Back up first (private directory, mode 700).
B=~/.local/state/niri-screensaver/backups/$(date -u +%Y%m%dT%H%M%SZ)
(umask 077; mkdir -p "$B/files/.config/niri/cfg" "$B/files/.config/noctalia" "$B/files/.local/state/noctalia")
cp -p ~/.config/niri/cfg/rules.kdl "$B/files/.config/niri/cfg/"
cp -p ~/.config/noctalia/*.toml "$B/files/.config/noctalia/"
cp -p ~/.local/state/noctalia/settings.toml "$B/files/.local/state/noctalia/"
noctalia config export full > "$B/noctalia-export-full.toml"
# 2. Controller and assets.
install -Dm755 bin/niri-screensaver ~/.local/bin/niri-screensaver
install -d ~/.config/niri-screensaver
install -m644 config/artwork.txt config/ghostty.conf ~/.config/niri-screensaver/
# 3. Niri window rule (idempotent append), then validate.
grep -qF 'match app-id=r#"^local\.niri\.screensaver$"#' ~/.config/niri/cfg/rules.kdl \
|| { printf '\n'; cat config/niri.kdl; } >> ~/.config/niri/cfg/rules.kdl
niri validate
# 4. Noctalia: install the snippet (idle behavior + lock hook), then validate.
# First make sure the GUI overrides do not already define a screensaver (must print nothing; see "Caveat").
grep -n 'screensaver' ~/.local/state/noctalia/settings.toml
install -m644 config/noctalia.toml ~/.config/noctalia/niri-screensaver.toml
noctalia config validate
noctalia config export full | grep -A7 'behavior.screensaver' # enabled = true, timeout = 150.0, locked_timeout = 4294967.0
Noctalia loads every *.toml in ~/.config/noctalia/ in alphabetical order, then applies the GUI overrides in
~/.local/state/noctalia/settings.toml. It hot-reloads both, so no restart is needed. Niri also reloads its config
automatically.
Dismissal
- A manually started session ends on any key press, mouse button press or wheel event in a screensaver window, and that dismisses every window. Pointer motion does not dismiss it: the renderer enables only xterm button reporting (1000 with SGR encoding 1006), never motion tracking, and also ignores any SGR motion report that arrives anyway. A pointer resting over a freshly mapped window therefore cannot end the session at once.
- An idle-started session is additionally stopped by Noctalia's
resume_command(a normalstop) on any real user activity, including pointer movement. That is Noctalia's behavior, not the renderer's. - A lone Escape press dismisses after at most 0.2 s (the wait for a possible split mouse report).
Backups
The first install made ~/.local/state/niri-screensaver/backups/20261005T235450Z/ (mode 700). It contains:
files/.config/niri/cfg/rules.kdl: the only desktop file that was modifiedfiles/.config/niri/config.kdl,files/.config/noctalia/config.toml,files/.local/state/noctalia/settings.toml: reference copies; these files were not modifiednoctalia-export-full.toml,noctalia-export-merged.toml,noctalia-config-dir-listing.txt,SHA256SUMSidle-baseline.txt: the effective idle behaviors before installationniri-screensaver.toml.pre-enable: the earlier hook-only version of the Noctalia snippet, saved before idle was enabledexport-pre-enable.toml,export-post-enable.toml:noctalia config export fullbefore and after enabling idle
Rollback
Rollback does not restore any whole file, so unrelated edits made since the install (including Noctalia GUI changes
in settings.toml) are kept. Run it from the repository checkout.
B=~/.local/state/niri-screensaver/backups/20261005T235450Z
cd ~/Projects/niri-screensaver
~/.local/bin/niri-screensaver stop
rm -f ~/.config/noctalia/niri-screensaver.toml # removes the idle behavior and the lock hook
# Remove only the appended window rule (config/niri.kdl, plus the blank line before it) from rules.kdl.
python3 - <<'EOF'
import os
p = os.path.expanduser("~/.config/niri/cfg/rules.kdl")
blk = open("config/niri.kdl").read()
s = open(p).read()
if "\n" + blk in s:
open(p, "w").write(s.replace("\n" + blk, "", 1))
else:
print("rule block not found (already removed or edited); edit rules.kdl by hand")
EOF
diff "$B/files/.config/niri/cfg/rules.kdl" ~/.config/niri/cfg/rules.kdl && echo "rules.kdl identical to backup" # differences = your later edits, expected
rm -f ~/.local/bin/niri-screensaver
rm -rf ~/.config/niri-screensaver
niri validate && noctalia config validate
# Compare against the pre-install export. Differences confined to settings you changed since then are expected.
noctalia config export full | diff "$B/noctalia-export-full.toml" -
# Optional: remove the user's ttfx build as well: rm -f ~/.local/bin/ttfx
~/.local/state/noctalia/settings.toml is never touched by install or rollback. This also leaves alone the
"Screensaver" session-menu action (running niri-screensaver start) that the user added separately in Noctalia
Settings. It is not part of this install; after rollback that entry points at a missing command and can be removed
in Settings.
Behavior notes
- Effective start is about 152 s, not 150 s. Noctalia's default
pre_action_fade_seconds = 2.0applies to every idle behavior, including this one. At 150 s idle a dim overlay fades in for 2 s, thenstartruns at about 152 s. Input during the fade cancels it, and the screensaver does not start. - No start while locked.
locked_timeout = 4294967makes Noctalia use a timeout of about 49.7 days (the uint32-millisecond maximum) while the session is locked. In practice the screensaver never launches unseen under the lock screen. - Idle inhibitors are honored (verified live with Noctalia's caffeine inhibitor). With caffeine on, Noctalia logged
idle behavior 'screensaver' suppressed (screensaver inhibit locks=1)at 08:04:55, 08:07:25 and 08:10:11, and nothing started. Noctalia usesext_idle_notifier_v1.get_idle_notificationand also suppresses behaviors while D-Bus screensaver inhibit locks are held. Media inhibition was not tested. - Optional manual launcher: the user also has a Noctalia session-menu action "Screensaver" that runs
niri-screensaver start(their own Settings change, not part of this install). - The lock hook
stop --lockedis started detached by Noctalia (double fork plussetsid, via/bin/sh -lc) only after the compositor confirms the lock, so it cannot delay locking.
Known limitation: lock while the screensaver is running
When the session locks while an idle-started screensaver is running, Noctalia re-arms its idle behaviors. That
runs this behavior's resume_command (a normal stop) before the session_locked hook (stop --locked).
The normal stop may therefore return focus to the previously focused window behind the lock screen. Nothing is
unlocked, no display is woken, and the lock is not delayed. Only the focus target under the lock changes. This
was accepted as-is. In the live lock test below the screensaver was gone about 1.2 s after the lock.
Known limitation: blurred lock background
The lockscreen has blurred_desktop = true, which captures the screen at lock time. Locking over a running
screensaver therefore shows a blurred still frame of the animation as the lock background until unlock. Noctalia
5.2.1 has no pre-lock hook to stop the screensaver first. This was accepted and the lock behavior (600 s idle) was
kept. To avoid it, set the lockscreen's blurred_desktop = false.
Caveat: Noctalia Settings window can override or drop the integration
~/.local/state/noctalia/settings.toml (written by the Settings window) takes precedence over
~/.config/noctalia/niri-screensaver.toml.
- Editing idle behaviors in Settings writes the whole behavior list, including
screensaver, intosettings.toml. After that, changes to this file's screensaver entry are shadowed. - The hooks editor shows only the first
session_lockedcommand. Saving that field replaces the whole list with a single string. If you save anything else there, the screensaver lock cleanup is silently removed.
After any idle or hook change in Settings, check the effective configuration:
noctalia config export full | grep -E -A7 '\[idle\.behavior\.screensaver\]' # enabled = true, timeout = 150.0, locked_timeout = 4294967.0
noctalia config export full | grep -E '^session_locked' # must contain .../niri-screensaver stop --locked
noctalia config export full | grep -E '^behavior_order' # lock, screen-off, lock-and-suspend kept
Test results
Automated checks (2026-10-06):
timeout 180 python3 -m unittest discover -s tests -v:Ran 50 tests in 31.555s,OK. This includestest_integration_config(Noctalia snippet: 150 s,action = "command", absolute start/stop commands,locked_timeout = 4294967,stop --lockedhook, no redefinition of existing behaviors) andtest_niri_rule_is_scoped.noctalia config validate config/noctalia.toml: valid, 0 warnings. Unknown keys would produce warnings.niri validateagainst a temporary copy with the rule appended, then against the live config after install: valid. A misspelled property was rejected, confirming the validator checks the rule.noctalia config validateon the live config after install and again after enabling idle: valid.- Export diff before vs after enabling idle (
export-pre-enable.tomlvsexport-post-enable.toml): onlybehavior_ordergains"screensaver"and the new[idle.behavior.screensaver](150 s,locked_timeout4294967) appears. The 600/660/900 s behaviors are unchanged and thesession_lockedhook is present. - Real
ttfx --helpmatches the constructed arguments; a PTY smoke run of the exact arguments exited 0;ghostty +validate-configonconfig/ghostty.confexited 0.
Interactive checks on the live session (2026-10-06):
- Passed (user-observed): manual preview. The user ran
niri-screensaver start: both monitors showed a fullscreen, centered, solid black, animating window. Afterwardsstatusreturned 1 with no owned processes, windows or control socket. - Passed: automatic idle path. Inhibition was honored while caffeine was on (see "Behavior notes"). After caffeine was disabled (released 08:11:26), the fade began 08:14:27.9, Noctalia triggered the behavior at 08:14:30.2 and the controller was running at 08:14:33. A key press made Noctalia log "resumed" at 08:18:31.2 and the screensaver was stopped at 08:18:32, with no leftovers.
- Passed: lock cleanup (done with the user's consent). Manual start at 08:19:15.9 (2 windows), then
noctalia msg session lockat 08:19:21.1. Noctalia logged "session is locked" at 08:19:21.122 and ran thesession_lockedhook at 08:19:21.133. The screensaver was stopped by 08:19:22.3 with 0 processes and 0 windows. Locking was not delayed.
Not tested live:
- A resting pointer being ignored over a freshly mapped window (covered only by the renderer's unit tests).
- Click or wheel dismissal of a manually started session on the live desktop.
- The 600 s lock and 660 s screen-off behaviors firing while an idle-started screensaver is up.
- Media-player idle inhibition.