ADR 0009

The screensaver is a picture, and the lock is somewhere else

accepted View source

Context

Alpymist had no idle behaviour at all. No tier's configuration started swayidle, though the Wayland tiers installed it; nothing turned a screen off; nothing locked one. A laptop left alone on the login desk kept its backlight on until the battery was flat. Super+L ran swaylock, and that was the whole of it.

Wayland has no screensaver framework to reach for. The X server had one — the screen saver extension, xset s, a fullscreen override-redirect window — and nothing replaced it, because the job turned out to be three unrelated ones:

  1. Noticing that a seat has gone still. ext-idle-notify-v1, which Hyprland and labwc both implement and swayidle speaks.
  2. Covering the screen. Either an ordinary client on the overlay layer of wlr-layer-shell — which is what the menu and every Alpymist popup already are — or an ext-session-lock-v1 client, which the compositor keeps on screen even if the client dies.
  3. Turning the panel off. wlr-output-power-management, spoken by wlopm.

Only the second of these has anything to do with a picture, and the third is the one that actually saves a battery. The phosphor a screensaver was invented to save has not existed on this hardware for twenty years.

Decision

1. The screensaver is an overlay surface, and it is not a lock

alpymist-screensaver is a layer surface on the overlay layer, drawn with Denise like every other Alpymist window, that goes away at the first key or movement. It is the same alpymist-widget host the Wi-Fi and power popups use, with a Placement::FullScreen added to it.

It is deliberately not an ext-session-lock-v1 client, and it deliberately never asks for anything. Locking stays swaylock, run separately at its own timeout and by Super+L, as before.

That division is worth more than the code it saves. A full-screen surface that holds the keyboard and asks for a password is exactly what a lookalike prompt would be, and ADR 0003's answer to lookalikes — Ctrl+Alt+Delete, which asks Hyprland who drew what is on screen — works because Alpymist's real password prompts are few and known. A screensaver that sometimes asked for a password would make "the screensaver is asking for your password" a normal thing to see. It is not one: if the mountains ask you for anything, something is wrong.

The cost is honest and should be written down: an overlay surface is not a guard. A compositor crash, or the process being killed, uncovers the screen. It is a picture in front of an unlocked session, which is what screensaver.lock and swaylock behind it are for.

2. swayidle decides when, and alpymist-screensaver idle decides what

Rather than a timeout written into each tier's compositor configuration, the watch is one command in both, and it reads the account's ~/.config/alpymist/screensaver.toml:

timeout 300 alpymist-screensaver resume 'alpymist-screensaver stop'
timeout 600 "wlopm --off '*'" resume "wlopm --on '*'"

alpymist-screensaver idle builds that list and keeps a swayidle running with it. Running it a second time — which is what Settings does after a change — does not start a second watch: it reaches the first over a socket in XDG_RUNTIME_DIR and has it start over with the new file. So every screensaver setting is Applies::Now (ADR 0007) rather than next-login, which for a timeout is the difference between a setting and a note to self.

swayidle rather than speaking ext-idle-notify-v1 ourselves: it is packaged, it is already installed on both Wayland tiers, and what it adds over the protocol — before-sleep, and a process per command — is what we would have written. If it ever needs replacing, idle::arguments is the only thing that knows its spelling.

wlopm rather than hyprctl dispatch dpms: it speaks the wlroots protocol, so one command covers Hyprland and labwc instead of one each.

3. The picture is the wallpaper's, drawn small

The scene is alpymist_ui::backdrop at the same seed as the wallpaper, the splash and the installer, composed at a fraction of the screen's resolution and blown up in square blocks. The pixelation is not an effect applied to a picture; it is the picture, drawn at 1/6 the size. A 1920×1080 screen is 320×180 pixels of scene, and the expansion is one memory copy per row.

What moves is the mist, and only the mist: sky and ridges are painted once per screen size and never again, and each frame copies that and lays patchy, sideways-drifting bands over it. At twelve frames a second this is a few hundred thousand pixels of work for a screen of two million.

That mattered enough to design around. The machines Alpymist exists for are the reason the Potato tier exists, and a screensaver is by definition the only thing running: one that keeps a core busy drains the battery it was there to idle through. The setting that turns the screen off after ten minutes saves far more power than any amount of care here, which is why it is on by default and why the picture is an overture to it rather than an alternative.

Consequences

  • The Wayland tiers gain an idle policy they did not have: mountains at five minutes, screen off at ten, and a lock only if asked for. The Legacy tier gains nothing — X11 has xautolock and i3lock and none of this — and is left as it was.
  • alpymist-screensaver brings swayidle and wlopm; swayidle comes off alpymist-desktop-wayland's list, where it was installed and unused.
  • alpymist-widget's Widget gains frame_interval, so an animation can ask for fewer frames than a spinner wants, and Placement::FullScreen.
  • Locking by default was considered and declined. A machine that starts asking for a password because it was upgraded is a machine people turn the feature off on, and the switch is one line in Settings for anyone who wants it.
  • If a lock screen of Alpymist's own is ever wanted, the drawing here is what it would show, and ext-session-lock-v1 is where it would go. Nothing here forecloses that; it is a separate decision, and this one does not pretend to have made it.

Addendum, 2026-09-18 — a screensaver is a program, not a picture in ours

Status: accepted · amends §3 above and the shape of §1.

What changed

The decision above described one screensaver, built into alpymist-screensaver, with the account's chunkiness in the same file as the idle policy. Asked for more screensavers, the obvious next step was a second module and an enum variant. That was declined, and deliberately:

I think of screensavers as individual programs, so then they should also provide a settings page that individually can be configured. The settings page could be definitions that each screensaver defines that makes the settings page render it from that definition.

So a screensaver is now a program plus a file that describes it:

/usr/bin/alpymist-saver-mountains
/usr/share/alpymist/screensavers/mountains.toml

The file says what it is called, what to run, and every setting it takes — each with a kind, a range, a default and a sentence of its own. Settings reads that directory, renders a page per screensaver from the declarations, and writes the values to ~/.config/alpymist/screensavers/<id>.toml. The program reads that same pair. Nothing in Alpymist knows what a "mountain" is. Installing a package that ships those two files adds a page and an entry in the Screensaver list; removing it takes both away.

alpymist-screensaver keeps the idle watch and becomes a launcher: it resolves show — a name, or random — against what is installed and execs it, so what swayidle started and what alpymist-screensaver stop takes away are one process.

What this costs, and what was kept

The obvious cost of separate programs is that each opens its own Wayland surface and does its own scaling, and the care in §3 above would have to be repeated in every one. That is not how this is built. alpymist-screensaver is a library as well as a launcher: a screensaver implements Painting — draw a small picture, say how small — and paint::start provides the layer surface, the block magnification, the frame pacing, the input that dismisses it and the socket stop reaches. A whole screensaver is a main of a dozen lines and its picture. The 8% of a core measured above is a property of the shared host, so every screensaver gets it.

What it buys is that a third party can add one without Alpymist being rebuilt, and that a screensaver's settings live with the screensaver rather than in a registry somebody has to remember to extend.

Two things that follow

  • Kind::Action. Settings gained a control that is a thing to do rather than a thing to be: a button, whose set runs it. Every screensaver's page gets one — See it now — whether or not it declares anything else, because looking at a screensaver is the one thing everybody wants from its page and a timeout of five minutes is a poor way to do it. alpymist set screensaver-mountains.preview does the same from a terminal.
  • Settings' registry is no longer entirely static. An Area and a Setting hold &'static str because every one of them used to be written in the source. The screensavers' are read from files, once per process, and kept for as long as it runs. That is a deliberate small leak, bounded by the number of screensavers installed, and it is the reason Settings::areas() can still hand back &'static [Area] to everything that already expected one.

What this does not reach

The menu's settings fragment is generated when alpymist-settings is packaged (alpymist menu-fragment, ADR 0007 §6), in a container where no screensaver is installed — so the menu offers the Screensaver page and its four policy settings, and not the per-screensaver pages. Nothing wrong is written; the list is simply the static half. Settings itself reads the directory every time it starts and is complete. Closing the gap would mean either generating the fragment on install, which a screensaver added later would still miss, or having every screensaver ship a menu fragment as well as a definition — a second file saying what the first already says. Neither is worth it for a search result, and this is written down so the next person does not assume it was overlooked.

What 0.0.7 shipped, and what happens to it

0.0.7 wrote block into screensaver.toml. That key now belongs to the mountains, in their own file. The policy file still accepts block and ignores it: deny_unknown_fields would otherwise refuse the whole file, and a refused file means a laptop whose screen stops turning off — over a key that no longer matters. The default is the same either way, so nobody who never changed it can tell.


Addendum, 2026-09-18 (later) — a screensaver's settings are not a page

Status: accepted · amends the addendum above.

What changed

The addendum above gave every screensaver an Area of its own, and Settings listed those areas down its side like any other. With one screensaver installed that read as a second Screensaver entry; the objection was that it should not be there at all:

I would like the screensaver settings to only be available from the screensaver menu item only, and not a separate setting. If we install a new screensaver and then select that screensaver, we should have a button that would open a dialog where we can set those settings for the selected screensaver.

So the areas stay and the pages go. Settings::areas() is still every area including the screensavers', because that is what resolves alpymist set screensaver-mountains.block and what alpymist list prints; Settings::pages() is the side list, and it is exactly the areas written in the source. The Screensaver page gains one button under its four policy settings — Mountains settings…, named after whichever is chosen — and the button opens those settings in a modal dialog over the page, built from the same build_row as every other setting so a declared switch, number or choice looks the same wherever it is drawn.

Three consequences worth writing down:

  • random has no one screensaver, so its dialog has all of them, a heading each. A different one each time means every installed screensaver is in the rotation, so every installed screensaver's settings are what that choice is made of. There is no third state where the button does nothing.
  • alpymist-settings screensaver-mountains.block still lands on that control. It opens the Screensaver page with that screensaver's dialog over it. An id that names nothing installed is still "no setting or area", as it was.
  • Search still finds them, and edits them in place among the results, the same as every other setting. The dialog is where they are kept, not a lock on them: a search that found a setting it then refused to change would be worse than either.

The cost is that the dialog is a scene over the page and its rows live in the same list as the page's, so rebuilding the page — a resize, a search — means taking the dialog down and putting it back. That is done in build, in one place, rather than left for each caller to remember.

The second screensaver, and what it needed

alpymist-saver-starfield ships alongside the mountains: the view from a ship under way, with an asteroid field every minute or so and a hole through it that the ship steers for. It is the proof the first addendum wanted — nothing in alpymist-screensaver or alpymist-settings names it, and installing or removing the package is the whole of adding or taking away the screensaver.

It needed one thing the shared host did not have. §3 above fixed the frame at eight a second, which is right for mist and wrong for anything travelling in a straight line: at eight frames a star crossing the screen is a dotted line, and the judder is what you see. So Painting gained interval_ms, defaulting to the same eight a second, and the host re-reads it between frames. The ceiling is thirty: past that the magnification alone — a screen's worth of memory copied per frame — is most of a core on the machines this exists for.

Rather than choose for everybody, the starfield declares it as a setting and says in the description what it costs, so the person paying for the frames in battery is the one deciding how many there are. The rocks go the other way: they are drawn from a grid of square cells blown up whole, and they are not turned to follow the ship's roll, because a chunky grid rotated off the screen's grid stops looking like a sprite and starts looking like a mistake. Smooth movement and coarse rocks are two settings, not one.


Addendum, 2026-09-20 — the lock is Alpymist's now

Status: accepted · amends §1 and §2 above.

§1 said "Locking stays swaylock", and the consequences said a lock screen of Alpymist's own would be a separate decision. That decision is ADR 0010, and it was made: the lock is alpymist-lock, an ext-session-lock-v1 client showing the login screen.

What changes here is one constant. idle::LOCK is alpymist-lock -f instead of swaylock -f -c 0b121e, at the blank timeout and before sleeping, and -f means the same thing it did — return once the screen is covered.

What does not change is the division §1 was actually about. The screensaver is still an overlay surface that never asks for anything, and the lock is still somewhere else: a different program, a different protocol, and a different surface, started by the same watch. "If the mountains ask you for anything, something is wrong" is as true as it was — and now there is a real lock screen to compare them with, which is what makes it checkable.


Addendum, 2026-09-25 — the default wallpaper is a photograph

Status: accepted · amends §3 above.

§3's title is no longer true for anyone who installs from now on. The default wallpaper for new accounts is blue-hour.jpg, one of the photographs and paintings in alpymist-wallpapers (see brand/wallpapers/README.md), and the boot splash and boot menus now show milky-way.jpg. The drawn mountains are still what the login screen, the lock and the installer show, and still ship as alpymist.png, which is what every account made before this points at.

So the mountains screensaver no longer opens on the ranges that were on the desktop. It opens on the ranges the lock screen will show when it is dismissed, which is the one screen it sits in front of that matters, and its description now says "the login screen" rather than "the wallpaper". Nothing about how it draws them changed: same seed, same code, still a sixth of the screen blown up in blocks.

What this gives up is one continuous picture from the boot menu to the desktop, drawn by one piece of code with one seed. The boot now shows a photograph until greetd starts, and the login screen draws the mountains; there is a cut between them where there was none, and another at the desktop for anyone who keeps the default. The drawn scene stays where it is part of a program rather than a background: the installer, the login and the lock.


Addendum, 2026-09-25 (later) — the login screen and the lock show it too

Status: accepted · amends the addendum above.

The addendum above kept the drawn mountains on the login screen and the lock, and put the cut between the boot's photograph and them at greetd. That cut is gone: the login screen and the lock now show the same picture as the boot, as /usr/share/alpymist/picture.jpg from alpymist-splash-picture. From the boot menu to the password it is one picture again, a photograph rather than a drawing. The mountains are left to the installer, to alpymist.png for the accounts that still point at it, and to anything that has to show something when the picture is missing.

So the mountains screensaver no longer matches any screen it sits in front of, on a machine that keeps the defaults. It now describes itself as Alpymist's own ranges rather than as some other screen's. How it draws them is unchanged.


Addendum, 2026-09-27 — every screen is covered, and the picture is on one

Status: accepted · closes #23.

With several screens, the screensaver covered only the one with focus. The layer surface was asked for with no output, and a compositor puts one like that on the focused output. The others went on showing the desktop until wlopm --off '*' turned all of them off. That was seen on the dev machine: a ThinkPad X1 Carbon on its dock, with three screens.

What it does now. The picture is on the main screen, and every other screen is covered in black until somebody comes back.

  • The main screen is the first one the compositor numbers: Hyprland's monitor 0, the laptop's own panel while it is on, and the first external screen when the lid is shut. Wayland has no primary display. The order the compositor announces its outputs in is the nearest thing it has, and it is the order Hyprland numbers them in.
  • main-screen in screensaver.toml (Settings › Screensaver › Main screen) names another. A screen named there that is not connected, say on a dock left at the office, leaves the picture on the first.
  • every-screen puts the picture on all of them instead, each drawn at its own screen's size.

Blank the rest was chosen over the picture everywhere as the default, because the picture is for the screen somebody looks at. The rest only need to stop showing the desktop. A black cover is drawn once and never again, so it costs nothing, and on OLED black is as good as off.

How. alpymist_widget::host gained two options: the output to be on, by name, and cover_others, which puts a black layer surface on every other output, including one plugged in while the screensaver is up. paint::start chooses between them, so the screensaver packages did not change. Mountains and starfield cover every screen as they are, and so will any other screensaver.

In every-screen mode the first process becomes a coordinator:

  • It starts one copy of the same program per screen, each told its screen in ALPYMIST_SCREENSAVER_OUTPUT, and each with its own socket, alpymist-screensaver@DP-3.
  • When any copy goes, or stop asks, it takes the rest away.
  • It waits on threads, not a timer, so it adds no wakeups.
  • stop also reaches the copies directly, in case a coordinator died.

A full-screen widget no longer closes when it loses the keyboard. With a copy on each screen, each takes the keyboard from the last as it comes up, and only a key or the pointer should take them away. For a cover, only a real move of the pointer counts: one appearing under a still pointer is told where the pointer is, which is not someone coming back.

Cost, measured on the X1 (i7, three 1920×1080 screens, mountains at the default settings):

Mode CPU
Default: picture on one screen, black on the other two 5% of a core, what one screen alone costs; the covers add nothing after their first frame
every-screen 5% of a core per screen, 15% in all; the coordinator 0%

The cost per screen is §3's, once per screen. That is why the picture on every screen is a choice and not the default.