Building HyDE Display Settings in one long feedback loop
I wanted display settings on my HyDE laptop to feel like settings, not like a collection of configuration files I happened to remember. Monitor layout, brightness, night light, idle actions, and screen time were all available, but through different commands and different files. The first question was whether there was already a HyDE or Hyprland package that joined them together. The answer led to a small native application and, over the course of one day, a surprisingly useful lesson in testing software against the actual desktop it is meant to control.
The result is HyDE Display Settings, a GTK 4 and libadwaita application for HyDE and Hyprland. It controls brightness, opens a visual monitor editor, writes idle and night-light configuration, keeps private local screen-time totals, and exposes today's total through Waybar.
This note records the implementation and the mistakes, including the changes that looked correct in code and were wrong on the screen.
Starting with the boundaries
The application was initially for my own laptop, but the intention quickly became to release it as open source. That changed what “working” meant. A local script can assume a directory layout, overwrite a file, and depend on the author remembering how to undo it. A public desktop utility cannot.
The first boundary was ownership. HyDE owns files under
~/.local/share/waybar; user customization belongs under
~/.config/waybar. The installer therefore creates a user module at
~/.config/waybar/modules/custom-display-settings.jsonc and a persistent
layout at ~/.config/waybar/layouts/display-settings.jsonc. It does not patch
the distributed HyDE files.
The same principle applies to Hyprland configuration. The app does not replace
hypridle.conf or hyprsunset.conf. It inserts a block between explicit
BEGIN HYDE DISPLAY SETTINGS and END HYDE DISPLAY SETTINGS markers,
preserves unrelated content, and writes a uniquely timestamped backup before a
change. Applying several settings is treated as one operation. If a later
write fails, earlier writes are rolled back.
Those choices added more code than simply rendering a configuration template, but they made uninstalling and experimentation safer. The uninstaller removes the application, service, launcher, and Waybar integration while deliberately leaving preferences, backups, and screen-time history for the user to inspect or remove.
The native control surface
The UI has three views: Display, Idle, and Screen Time.
Display reads connected monitors through hyprctl, adjusts brightness through
brightnessctl, launches nwg-displays for visual layout editing, and writes
a scheduled hyprsunset profile. Idle exposes dim, lock, display-off, and
suspend timeouts. Screen Time shows the day's total and the most-used
application classes.
I chose libadwaita because this is a system utility, not a branded web surface.
The controls should inherit the active desktop theme and use familiar settings
patterns. Timeout rows use native Adw.SpinRow inputs, so their minute values
can be typed directly or adjusted with the native stepper buttons. Changes are
draft values until Save & Apply writes the configuration and reloads the
relevant helpers. Brightness is the exception because immediate feedback is
the useful behavior for a slider.
The distinction between draft and applied state later caused confusion. I changed a display-off control, closed the application, and saw the old value when I returned. That was the specified behavior, but it was not sufficiently visible behavior. The apply action became Save & Apply, and the Idle page now states that closing without applying discards its draft. A technically consistent interaction can still be unclear.
Screen time without surveillance
Screen-time tracking is opt-in. A user-level service samples the active Hyprland window every five seconds and stores only three things in SQLite: the local date, the application's Wayland class, and accumulated active seconds. It does not store window titles, keyboard input, screenshots, file names, browsing history, or window contents.
The tracker does not count time while the session reports itself locked or
idle, or while every monitor has DPMS disabled. An individual sample is capped
so a delayed process cannot attribute a large pause to one application. The
state directory is mode 0700; the database and its WAL files are 0600.
The systemd service has no network access and uses NoNewPrivileges, a strict
filesystem view, an empty capability set, private temporary storage, and
kernel protection directives. systemd-analyze security rated the installed
unit at exposure level 3.8 OK. This score is not proof of security, but it is
a useful check that the declared sandbox matches the intended local-only data
model.
Waybar runs a separate small command that reads the database and emits JSON.
The bar shows a compact total such as 3h 3m; its tooltip gives a per-app
breakdown, and clicking it opens the settings application. Pango markup is
escaped before application classes enter the tooltip.
Auditing before calling the tests real
The initial review asked a necessary question: were the test files actual tests, and what vulnerabilities or bugs remained?
The test suite uses Python's unittest, temporary directories, and process
mocks. It covers managed-block replacement, preservation of unrelated config,
backup creation, rollback on partial failure, default idle ordering, disabled
actions, night-light schedules, tracking opt-in, private database permissions,
locked and DPMS-off detection, Waybar insertion and removal, idempotency,
layout variants, tooltip escaping, and launcher behavior through an installed
symlink.
The hardening pass made several implementation details explicit:
- configuration is written through a temporary file and atomic rename;
- state and configuration permissions are owner-only;
- backups include microseconds so rapid consecutive writes cannot collide;
- app-class output is escaped before entering Waybar markup;
- Waybar edits are constrained to user-owned configuration;
- the tracker service is denied IP networking;
- installation and removal preserve user data by default.
The release checks grew beyond unit tests. Python compilation, warnings as errors, shell syntax, desktop-entry validation, wheel construction, diff whitespace, live tracker status, permissions, systemd hardening, and a GTK launch smoke test all became part of the verification routine. The suite reached 25 tests after the final UI reversals. Earlier intermediate versions reached 29, but four tests belonged to custom controls that were correctly removed with those controls. A smaller suite can represent a better product when it no longer tests the wrong design.
The display-off failure was a stale process
The most important bug did not appear in the isolated tests. The generated
hypridle.conf contained the correct listener:
listener {
timeout = 900
on-timeout = hyprctl dispatch dpms off
on-resume = hyprctl dispatch dpms on
}
Yet changing the setting appeared to do nothing. The live machine showed why.
hypridle was running as a process launched directly by HyDE, while its
optional user systemd unit was inactive. The application tried
systemctl --user try-restart hypridle.service. For an inactive unit,
try-restart returned success without starting anything. Because the return
code looked successful, the fallback never killed or replaced the unmanaged
HyDE process. The process had been alive since August 8 and continued reading
the old configuration.
The fix stops the existing process, asks systemd to start a fresh instance,
and falls back to hyprctl dispatch exec when the user service cannot start.
Tests now cover both the unmanaged-process replacement and the failed-systemd
fallback.
The end-to-end test briefly dispatched dpms off and then dpms on. The
screen actually turned off. That established two things at once: the Hyprland
command was valid on the real monitor, and my intended meaning of “display
off” needed to be precise. I wanted the panel off while builds, downloads, and
other processes continued. DPMS does that. Suspend does not. The saved setup
therefore keeps display-off enabled and suspend disabled. The UI copy now says
that display-off leaves apps and background work running, while suspend pauses
the computer and its processes.
A theme bug, followed by several design mistakes
The most visible failures came from trying to correct a theme interaction too broadly.
On my active theme, the selected tab's text and background were difficult to
distinguish. The first attempted fix forced libadwaita's semantic accent
background and foreground onto the selected Adw.ViewSwitcher button. That
looked defensible in CSS and bad in the actual theme. It made the active tab
muddy and dim.
The next attempt removed the fill and used bold text with an accent underline. It solved the contrast problem by inventing a different tab design. It was still wrong because the original native tab treatment was preferable. The correct response was to remove the application-level tab styling entirely and return control to the GTK theme.
The idle steppers went through a similar cycle. Their plus and minus images appeared absent, so I first tried a foreground-color override. When that did not work, I replaced the native spin rows with a custom linked group containing minus, a minute label, and plus. The buttons worked, but the result looked like a generic generated control and removed direct numeric editing. It solved the symptom by discarding the better component.
I reverted the custom controls and restored Adw.SpinRow. Inspecting the live
widget tree showed that libadwaita had created the correct native buttons and
assigned value-decrease-symbolic and value-increase-symbolic. Inspecting the
resolved assets then found the concrete issue: the Tela Circle Dracula minus
SVG hard-coded #565656 instead of inheriting the button foreground. On the
dark surface it nearly disappeared.
The final repair keeps the native spin row, its editable input, its spacing,
and its button behavior. It replaces only the two broken image children inside
the native buttons with foreground-inheriting − and + labels. A live widget
inspection verified that the down button contained −, the up button
contained +, and the editable GtkSpinButton remained intact.
The sequence left a useful rule: when a native component is almost right, inspect the smallest failing layer before replacing the component. A broken theme asset did not justify a new control system. A poor selected color did not justify a new navigation style.
Making it public
The repository was initialized with small development commits rather than one large final dump: the native app, Waybar integration, privacy hardening, documentation, UI corrections, daemon reload fix, reversals, and final native symbol repair each have their own history.
Before publishing, I compared the README structure with active HyDE projects. The resulting documentation covers requirements, install and update commands, the backend behind every control, privacy boundaries, managed paths, backups, uninstallation, development commands, compatibility, contribution expectations, and the fact that this is an independent community project, not an official HyDE component.
The public repository was created with GitHub CLI, the full history was pushed
to main, discovery topics were added, and the generic checkout instructions
were replaced with the canonical clone URL. The installed copy on my laptop is
the same code recorded at commit c2f7712.
One Waybar edge remains. The installer creates the user module and a persistent
display-settings layout, then asks HyDE to regenerate its includes. On this
machine, a reinstall could regenerate the previously selected layout into the
active config, so I explicitly selected display-settings again with HyDE's
waybar.py --set command after each reinstall. The layout survives and is
available to HyDE, but selecting it automatically without overriding a user's
intent still needs a cleaner installer rule.
What is verified, and what is not
On the target laptop, the following paths were exercised against the live desktop:
- monitor discovery through
hyprctland launch ofnwg-displays; - brightness read and write through
brightnessctl; - DPMS off and on through Hyprland;
- fresh
hypridleandhyprsunsetprocesses after apply; - managed configuration and backup creation;
- screen-time collection, permissions, and Waybar JSON;
- user service activation and security analysis;
- GTK launch under the active HyDE theme;
- installation, repeated installation, and explicit Waybar layout selection.
Lock and suspend were not triggered end to end because doing so would disrupt
the active session. Their generated commands, ordering, inclusion, omission,
and apply behavior are tested in isolation. Night-light schedule generation
and daemon configuration loading were tested, but waiting through a real
scheduled transition was not. Multi-monitor behavior is implemented through
hyprctl and nwg-displays, but the development machine had one internal
eDP-1 panel during verification.
Those limits matter. “All tests pass” means the claims covered by those tests
pass. The stale hypridle process and the invisible theme asset both survived
the earlier automated suite because each depended on the actual session. For
desktop software, the live environment is not merely a place to demonstrate
the result. It is another test harness, and sometimes the only one capable of
showing the bug.