Omarchy-like screensaver utilizing TTFX with custom logos!
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-06 08:22:51 +02:00
bin Add niri screensaver 2026-10-06 08:01:32 +02:00
config Add niri screensaver 2026-10-06 08:01:32 +02:00
docs/superpowers Add niri screensaver 2026-10-06 08:01:32 +02:00
tests Add niri screensaver 2026-10-06 08:01:32 +02:00
.gitignore Add niri screensaver 2026-10-06 08:01:32 +02:00
README.md docs: update README.md 2026-10-06 08:22:51 +02:00

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 normal stop) 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 modified
  • files/.config/niri/config.kdl, files/.config/noctalia/config.toml, files/.local/state/noctalia/settings.toml: reference copies; these files were not modified
  • noctalia-export-full.toml, noctalia-export-merged.toml, noctalia-config-dir-listing.txt, SHA256SUMS
  • idle-baseline.txt: the effective idle behaviors before installation
  • niri-screensaver.toml.pre-enable: the earlier hook-only version of the Noctalia snippet, saved before idle was enabled
  • export-pre-enable.toml, export-post-enable.toml: noctalia config export full before 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.0 applies to every idle behavior, including this one. At 150 s idle a dim overlay fades in for 2 s, then start runs at about 152 s. Input during the fade cancels it, and the screensaver does not start.
  • No start while locked. locked_timeout = 4294967 makes 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 uses ext_idle_notifier_v1.get_idle_notification and 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 --locked is started detached by Noctalia (double fork plus setsid, 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, into settings.toml. After that, changes to this file's screensaver entry are shadowed.
  • The hooks editor shows only the first session_locked command. 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 includes test_integration_config (Noctalia snippet: 150 s, action = "command", absolute start/stop commands, locked_timeout = 4294967, stop --locked hook, no redefinition of existing behaviors) and test_niri_rule_is_scoped.
  • noctalia config validate config/noctalia.toml: valid, 0 warnings. Unknown keys would produce warnings.
  • niri validate against 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 validate on the live config after install and again after enabling idle: valid.
  • Export diff before vs after enabling idle (export-pre-enable.toml vs export-post-enable.toml): only behavior_order gains "screensaver" and the new [idle.behavior.screensaver] (150 s, locked_timeout 4294967) appears. The 600/660/900 s behaviors are unchanged and the session_locked hook is present.
  • Real ttfx --help matches the constructed arguments; a PTY smoke run of the exact arguments exited 0; ghostty +validate-config on config/ghostty.conf exited 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. Afterwards status returned 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 lock at 08:19:21.1. Noctalia logged "session is locked" at 08:19:21.122 and ran the session_locked hook 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.