Files
Panama/config/dot/quickshell/services/NightLight.qml
T
Gabriel Brown 00a81edadd Make Panama settings one shared source of truth
Panama had grown into three configuration surfaces that only agreed because
they had been typed to agree: looks.lua hardcoded values, DesktopPreferences
independently defaulted the same values, and SystemSettings replayed them at
startup. Nothing kept them in sync, and the Lua side read no shared state at
all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md.

Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl
keyword refuses the write, prints the refusal to stdout, and still exits 0, so
the HDR, VRR, and direct-scanout toggles persisted their value and reported
success while the compositor never changed. Writes now go through hyprctl eval,
which has the same hazard on syntax and runtime errors, so success is defined
as reading the value back and finding it equal. The existing contract passed
throughout the outage because it re-applied the values already in place; the
new one flips each value to something it does not hold.

Derive preferences from a schema. Every setting used to be restated four times
-- a property alias, a JSON adapter property, a change handler, and a line in
reset -- where omitting any one failed silently. PreferenceSchema.qml is now
the single source, and persistence, validation, reset, and the Hyprland mapping
all derive from it. Unknown keys on disk survive a write so a rollback does not
discard a newer build's settings, and a corrupt file falls back to shipped
defaults. The store moved to ~/.config/panama/settings.json, migrating from the
old state directory without deleting it.

Share that file with Hyprland. prefs.lua reads it at config time with every
shipped literal kept as the fallback, so the config still stands alone. The Lua
is the default, the JSON is the truth, and Settings is the editor. The
compositor-adjustable surface goes from 3 keys to 23.

Also fixes two test-hygiene bugs found by running the suite end to end for the
first time: settings-pages-contract could see the window settings-window-contract
leaves behind, and the new write contract was persisting its deliberately-wrong
values into the user's real store.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-17 23:26:56 -04:00

107 lines
4.3 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)
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)
}