# Status Registry Desktop modes as state: do not disturb, presentation mode, and whatever follows. One place that owns them, one way to read them, one way to set them. This is the first of two specs. The notification daemon that replaces dunst is the second, and it consumes the registry's `dnd` mode rather than owning it. The registry comes first because DND belongs to the desktop, not to the notification daemon, and because the notification daemon is the larger and riskier component. ## What a mode is A named boolean plus a set of effects asserted while it is true. Two modes in this project: **`dnd`** has no effects of its own. It is state that the notification daemon reads. On its own it is a flag; the behaviour lives in the consumer. **`presentation`** has three effects: | effect | mechanism | |---|---| | do not disturb | sets the `dnd` mode | | idle inhibited | `IdleInhibitor` on the drawer's keepalive window | | breaktimer paused | `breaktimer.sh pause`, restored with `resume` | Not in this project: any further mode. Gaming, focus, and the rest are one file and one row each when they arrive, which is the point of building the registry rather than two toggles. ## Why a registry rather than two toggles Because the second mode is where the coupling appears. Presentation mode sets DND, so DND has two writers: the user, and presentation mode. Turning presentation mode off must restore DND to what the user had, not unconditionally clear it, or an afternoon of hand-set DND vanishes when a talk ends. That rule has to live somewhere, and a registry is that somewhere. ## State One file per mode in `$XDG_RUNTIME_DIR`, containing `0` or `1`: status.dnd status.presentation Flat, one value per file, matching the convention already on disk: `breaktimer.pid`, `breaktimer.state`, `breaktimer.phase`, `breaktimer.remain` are written exactly this way by the breaktimer daemon. A missing file means the mode is off. This is not a fallback, it is the mechanism: `$XDG_RUNTIME_DIR` is `/run/user/1000`, a tmpfs, so a reboot clears every mode with no cleanup code and no persistence logic. A mode surviving a reboot would need code; a mode resetting is the absence of it. Logout without reboot is less certain and the design does not rely on it. `elogind` runs here and `pam_elogind.so` is in the PAM stack, and its manual says the runtime directory and its contents are removed when a user's last concurrent session ends. The same manual says the module does nothing if the system was not booted with elogind as its init, which on Slackware it is not. Session tracking demonstrably works, so the removal probably happens, but it is not guaranteed by the documentation for this configuration. If it does not, a mode survives a logout, which is the same behaviour as surviving a shell restart, described next. A shell restart is a different event. The files live in the runtime directory, not in the shell, so a `qs` restart, a hot reload failure or a crash leaves modes intact and the registry reads them back at startup. A presentation that outlives a shell crash is the desired behaviour; a presentation that outlives a reboot is not. ### Reading and writing `FileView` covers both halves with no shell-out: - `atomicWrites: true` (the default) writes a temporary file and renames it over the target, so no reader ever sees a half-written value. - `watchChanges: true` with `onFileChanged: reload()` means an external writer changes the file and the drawer repaints. No polling, in either direction. Because atomic writes arrive as a rename rather than a write, any external watcher must watch for `close_write,moved_to`, not `close_write` alone. This is the same trap the mail watcher hit with Xapian, recorded in AGENTS.md: a watch that sees only writes never fires on a file that is replaced. ### Ownership The registry writes `status.*` and nothing else. `breaktimer.state` belongs to the breaktimer daemon and is driven only through `breaktimer.sh pause|resume`. Two writers on one file is a race, and breaktimer's own daemon loop rewrites that file on every phase change. ## Components shared/Status.qml the singleton: modes, effects, files desktop/Status.qml symlink to the above desktop/modules/status/ Module.qml registration Tile.qml active mode count, click opens the page Page.qml one row per mode, a switch each ~/bin/statusctl the CLI `Status.qml` is a `pragma Singleton` rather than a module service because modes outlive any page, and because the notification daemon needs to read `dnd` at startup, before any page is instantiated. A directory import resolves it with no `qmldir`, the same way `Theme.qml` resolves. The symlink rather than a shared import path follows the existing convention: a singleton outside the config directory needs a `qmldir`, which is the friction that keeps `Theme.qml` symlinked into each component. ## The CLI statusctl get prints 0 or 1 statusctl set 0|1 statusctl toggle statusctl watch JSON on change, for waybar `get`, `set` and `toggle` read and write the file directly. They do not go through the shell, so they work when the shell is down, and they cost no process spawn beyond the script itself. `watch` runs `inotifywait -m -e close_write,moved_to` on the file's directory, debounced, and prints a waybar JSON line on each change. One long-lived process, no polling. When the file is absent it prints `class: "down"` rather than `deactivated`, so a dead registry is visibly different from a mode that is off. Setting a mode by file rather than through the shell means the effects do not fire. `statusctl presentation set 1` writes the file; the shell sees the change through its watch and asserts the inhibitor and pauses breaktimer. If the shell is down, the file changes and nothing else happens, which is the correct failure: the state is recorded and reasserted when the shell returns. ## Effects Effects are asserted by the singleton when a mode turns on and released when it turns off. **Idle inhibit** is `IdleInhibitor` bound to the drawer's keepalive `PanelWindow`, which is the one window guaranteed to exist for the shell's lifetime. The property needs a non-null `window` to do anything. The compositor advertises `zwp_idle_inhibit_manager_v1` (version 1), confirmed by `wayland-info`, and waybar's built-in `idle_inhibitor` module already drives it on this machine. So hypridle honours the Wayland protocol here, which is why the inhibitor is asserted that way rather than over D-Bus. `elogind` does run on this system, so a D-Bus inhibit path exists, but the Wayland one is confirmed working and needs no extra service. **Breaktimer** is paused with `breaktimer.sh pause` and restored with `resume`. The verbs exist, are backed by the daemon's own state file, and survive the daemon restarting. The exit status is readable, so a failure to pause is detectable, unlike the inhibitor. **DND from presentation mode** records the user's DND value when presentation mode turns on, sets DND on, and restores the recorded value when presentation mode turns off. The recorded value is held in the singleton, not in a file: it is meaningful only while presentation mode is on, and presentation mode does not survive a reboot. ## Waybar The built-in `idle_inhibitor` module is replaced by a `custom/presentation` module reading the registry. This is a replacement rather than a demotion because the built-in module has no input mode: it owns an inhibitor object, its `activated` state is that object's state, and it cannot display state owned by anything else. Left in place alongside the registry it would assert a second, independent inhibitor, and idle would resume only when both were released. "I turned presentation mode off and the screen still will not lock" is the failure that produces. "custom/presentation": { "exec": "~/bin/statusctl presentation watch", "return-type": "json", "on-click": "~/bin/statusctl presentation toggle", "format": "{icon}", "format-icons": { "activated": "󰅶 ", "deactivated": "󰾪 " } } The glyphs, the slot and the CSS ids carry over from the built-in module, whose own tooltip already reads "Presentation Mode". The name is settled by precedent. A `custom/dnd` module is available the same way but is not part of this project: the notification daemon's own waybar presence is the second spec's business. ## Drawer A Status tile in the grid, showing the number of active modes, and a page with one row per mode and a switch each. Adding a mode is one file and one row. ## Failure The registry cannot lose data: every mode is a boolean that resets at reboot by design. What it can do is misreport. If the shell dies, the `status.*` files remain and waybar keeps showing the last known state while nothing enforces it. The inhibitor is released, because it is a Wayland object owned by the dead process, and breaktimer stays paused, because nothing told it otherwise. So a crash during presentation mode leaves the screen able to lock and breaktimer still quiet, which is the safer half of each pair. `statusctl watch` prints `class: "down"` when a file is missing, which distinguishes a dead registry from a mode that is off, but only for the missing case. A crash leaves the file present and stale. That is the honest limit of a file as a liveness signal, and the alternative, a heartbeat, is more machinery than two booleans justify. ## Verification One runnable check, `test-statusctl.sh`, exercising the file contract without the shell: - `statusctl dnd set 1`, assert the file contains `1`, assert `get` prints `1` - `statusctl dnd toggle`, assert `0` - `statusctl presentation watch` in the background, change the file, assert a JSON line appears, assert it carries `activated` - remove the file, assert the next line carries `down` That covers the parse, the write, the atomic rename and the watch together, and it fails if any of them break. The effects need the shell and are confirmed by hand, once, because each is observable: `hyprctl clients` counts inhibitors, `breaktimer.sh status` reports `paused`, and the notification daemon's own behaviour under DND is the second spec's verification. Per AGENTS.md, anything visual is confirmed by the user rather than screenshotted.