Files
Panama/config/dot/quickshell/services/NightLight.qml
T
Gabriel Brown 94353aa0bd Close the gaps the cross-UI audit found
Audited bar, dock, quick settings, date menu and Settings for three
things: a setting reachable in one UI but not another, a setting that
exists but is unreachable anywhere, and UI that states something false.

Night Light was fully exposed in Quick Settings and had no control
anywhere in Settings. It now has a card on Displays, where GNOME also
puts it, with on/off, schedule, times and temperature.

Adding those controls would have shipped the exact defect this audit
exists to find. NightLight declared enabled, temperature and automatic
as bindings on the store, but toggle() assigns to them, and an
assignment destroys a QML binding permanently -- so the service wrote to
the store and never read from it again. The Settings controls would have
written values the service ignored, while Quick Settings kept working.
It now follows the store. Every other service was swept for the same
pattern; this was the only one.

The night light schedule was two hardcoded literals, so the hours could
not be changed. They are schema keys now, with a row that renders 17.5
as "5:30 PM" and honours the 24-hour preference rather than showing a
decimal nobody reads as a time.

keyboardLayout was in the schema and read by input.lua but had no
control anywhere: configurable in principle, unreachable in practice. It
is surfaced on Input & Shortcuts as read-only, with the reason, because
it needs a compositor reload and a control implying instant apply would
be a smaller lie but still a lie.

Caffeine was a Quick Settings toggle mentioned only in a subtitle in
Settings. It has a real control now.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 06:25:15 -04:00

135 lines
5.6 KiB
QML

pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Night light — a thin wrapper around hyprsunset (0.4.0).
//
// hyprsunset is a daemon: `hyprsunset -t <kelvin>` grabs wlr-gamma-control and
// holds it until it exits, and `hyprctl hyprsunset <request>` re-tunes the
// running instance over its socket. So the shape here is:
// * `active` drives whether the daemon process runs at all,
// * temperature changes are pushed to the *running* daemon rather than
// restarting it, which would flash the screen back to 6500K.
//
// Gamma is restored by the compositor when the daemon's gamma-control object
// dies, so stopping the Process is a complete "off" — no `-i` pass needed.
//
// Only works under Hyprland; there is no gamma protocol on GNOME, so the
// daemon exits immediately there. That is reported once, not retried.
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
property bool initialized: false
// The manual switch. Ignored while `automatic` is on.
property bool enabled: Settings.nightLightEnabledByDefault
property int temperature: Settings.nightLightTemperature
// Follow Settings.nightLightFrom .. nightLightTo instead of the manual
// switch. Off by default because GNOME's schedule was disabled.
property bool automatic: DesktopPreferences.get("nightLightAutomatic")
// What is actually applied right now.
readonly property bool active: root.automatic ? root.scheduled : root.enabled
// True while the wall clock is inside the scheduled window.
readonly property bool scheduled: root.inWindow(clock.hours + clock.minutes / 60)
// A manual toggle always wins: it drops out of the schedule rather than
// being silently reverted a minute later. Same as GNOME's behaviour when
// you flip night light off during a scheduled evening.
function toggle(): void {
if (root.automatic) {
root.automatic = false;
root.enabled = !root.scheduled;
} else {
root.enabled = !root.enabled;
}
}
// The window wraps midnight (17:00 → 10:00), so the comparison flips when
// `from` is later in the day than `to`.
function inWindow(hour: real): bool {
const from = Settings.nightLightFrom;
const to = Settings.nightLightTo;
return from <= to ? (hour >= from && hour < to) : (hour >= from || hour < to);
}
// Only ticks while the schedule is in charge — no idle work otherwise.
SystemClock {
id: clock
precision: SystemClock.Minutes
enabled: root.automatic
}
Process {
id: daemon
command: ["hyprsunset", "-t", String(root.temperature)]
running: root.active
onExited: (code, status) => {
if (root.active)
console.warn("NightLight: hyprsunset exited with", code, "- is Hyprland running?");
}
}
// Re-tune in place; restarting the daemon would flash the display.
onTemperatureChanged: {
DesktopPreferences.set("nightLightTemperature", root.temperature);
if (daemon.running)
Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", String(root.temperature)]);
}
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic)
// `enabled`, `temperature`, and `automatic` are declared as bindings on the
// store, but a binding is destroyed the moment anything assigns to the
// property -- which toggle() does. Without this, the service would write to
// the store and never read from it again: Quick Settings would keep working
// while the same settings on the Displays page silently did nothing, which
// is worse than not offering them there at all.
//
// No loop: set() is a no-op when the value is unchanged, and assigning a
// property its current value emits nothing, so this converges immediately.
Connections {
target: DesktopPreferences
function onRevisionChanged(): void { root.syncFromStore(); }
}
function syncFromStore(): void {
const storedEnabled = DesktopPreferences.get("nightLightEnabled") === true;
if (root.enabled !== storedEnabled)
root.enabled = storedEnabled;
const storedAutomatic = DesktopPreferences.get("nightLightAutomatic") === true;
if (root.automatic !== storedAutomatic)
root.automatic = storedAutomatic;
const storedTemperature = DesktopPreferences.get("nightLightTemperature");
if (typeof storedTemperature === "number" && root.temperature !== storedTemperature)
root.temperature = storedTemperature;
}
onActiveChanged: {
if (!root.initialized)
return;
StatusEvents.publish({
key: "display-night-light",
glyph: "\u{F0F31}",
title: root.active ? "Night Light on" : "Night Light off",
detail: root.active ? String(root.temperature) + " K" : "Display colors restored",
tone: root.active ? "warn" : "accent",
priority: StatusEvents.ambientPriority
});
}
Component.onCompleted: Qt.callLater(() => root.initialized = true)
}