diff --git a/bin/omarchy-toggle-nightlight b/bin/omarchy-toggle-nightlight index 3db4e41f..d879463f 100755 --- a/bin/omarchy-toggle-nightlight +++ b/bin/omarchy-toggle-nightlight @@ -49,4 +49,4 @@ else hyprctl hyprsunset temperature $OFF_TEMP fi -omarchy-shell -q omarchy.indicators refresh +omarchy-shell -q nightlight refresh diff --git a/shell/plugins/README.md b/shell/plugins/README.md index c452b037..479148db 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -32,6 +32,7 @@ User-installed plugins live alongside these conceptually but on disk under | Media | `omarchy.media` | `service`, `bar-widget` | `services/media/Service.qml`, `services/media/BarWidget.qml` | | Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` | | Idle | `omarchy.idle` | `service` | `services/idle/Service.qml` | +| Night light | `omarchy.nightlight` | `service` | `services/nightlight/Service.qml` | | Lock screen | `omarchy.lock` | `service` | `lock/Service.qml` | | OSD | `omarchy.osd` | `panel` | `osd/Osd.qml` | | Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` | diff --git a/shell/plugins/bar/indicators/NightLight.qml b/shell/plugins/bar/indicators/NightLight.qml index 8388a71a..b15744c7 100644 --- a/shell/plugins/bar/indicators/NightLight.qml +++ b/shell/plugins/bar/indicators/NightLight.qml @@ -1,57 +1,19 @@ import QtQuick -import Quickshell.Io import qs.Ui BarIndicator { id: root - property bool nightlight: false + readonly property var nightlightService: bar?.shell?.firstPartyServiceFor("omarchy.nightlight") - active: nightlight + active: nightlightService ? nightlightService.enabled : false activeText: "󰔎" inactiveText: "󰔎" activeTooltipText: "Day Light" inactiveTooltipText: "Night Light" - function refresh() { - if (!statusProc.running) statusProc.running = true - } - - function update(raw) { - var data = extractData(raw) - nightlight = data && data.enabled === true - } - function toggle() { - nightlight = !nightlight - if (root.bar) root.bar.run("omarchy-toggle-nightlight") - refreshTimer.restart() - } - - Component.onCompleted: refresh() - - Connections { - target: root.indicatorHost - ignoreUnknownSignals: true - function onRefreshRequested() { root.refresh() } - } - - Process { - id: statusProc - command: ["omarchy-toggle-nightlight", "--status"] - stdout: StdioCollector { - waitForEnd: true - onStreamFinished: root.update(text) - } - onExited: function(exitCode) { - if (exitCode !== 0) root.nightlight = false - } - } - - Timer { - id: refreshTimer - interval: 1500 - onTriggered: root.refresh() + if (root.nightlightService) root.nightlightService.setNightlight(!root.active) } onPressed: function() { root.toggle() } diff --git a/shell/plugins/services/nightlight/NightlightModel.js b/shell/plugins/services/nightlight/NightlightModel.js new file mode 100644 index 00000000..7db5bdcd --- /dev/null +++ b/shell/plugins/services/nightlight/NightlightModel.js @@ -0,0 +1,20 @@ +// Temperatures below the identity point count as night light. Keep in sync +// with bin/omarchy-toggle-nightlight, which applies the same threshold. +var IDENTITY_TEMPERATURE = 6000 + +function temperatureFromOutput(output) { + var match = String(output === undefined || output === null ? "" : output).match(/[0-9]+/) + return match ? Number(match[0]) : null +} + +function isNightlight(temperature) { + return temperature !== null && temperature !== undefined && temperature < IDENTITY_TEMPERATURE +} + +if (typeof module !== "undefined") { + module.exports = { + IDENTITY_TEMPERATURE: IDENTITY_TEMPERATURE, + temperatureFromOutput: temperatureFromOutput, + isNightlight: isNightlight + } +} diff --git a/shell/plugins/services/nightlight/Service.qml b/shell/plugins/services/nightlight/Service.qml new file mode 100644 index 00000000..388b7798 --- /dev/null +++ b/shell/plugins/services/nightlight/Service.qml @@ -0,0 +1,115 @@ +import QtQuick +import Quickshell.Io +import "NightlightModel.js" as NightlightModel + +Item { + id: root + + // Injected by omarchy-shell (the first-party service loader). + property var shell: null + + // Keep in sync with bin/omarchy-toggle-nightlight, which sets the same + // temperatures for callers outside the shell (keybindings, menu, ssh). + readonly property int nightTemperature: 4000 + readonly property int dayTemperature: 6500 + + property bool stateLoaded: false + property var temperature: null + readonly property bool enabled: stateLoaded && NightlightModel.isNightlight(temperature) + + property bool hasPendingTemperature: false + property int pendingTemperature: 0 + + function refresh() { + if (!statusProbe.running) statusProbe.running = true + } + + function setNightlight(value) { + applyTemperature(value ? nightTemperature : dayTemperature) + } + + function toggle() { + setNightlight(!enabled) + } + + function applyTemperature(temp) { + root.temperature = temp + root.stateLoaded = true + + if (applyProcess.running) { + root.pendingTemperature = temp + root.hasPendingTemperature = true + return + } + + runApply(temp) + } + + function runApply(temp) { + applyProcess.command = ["bash", "-lc", + "pgrep -x hyprsunset >/dev/null || { setsid uwsm-app -- hyprsunset >/dev/null 2>&1 & sleep 1; }; " + + "hyprctl hyprsunset temperature " + Number(temp)] + applyProcess.running = true + } + + Process { + id: statusProbe + command: ["hyprctl", "hyprsunset", "temperature"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + root.temperature = NightlightModel.temperatureFromOutput(text) + root.stateLoaded = true + } + } + onExited: function(exitCode) { + if (exitCode !== 0) { + root.temperature = null + root.stateLoaded = true + } + } + } + + Process { + id: applyProcess + onExited: function() { + if (root.hasPendingTemperature) { + root.hasPendingTemperature = false + root.runApply(root.pendingTemperature) + return + } + + root.refresh() + } + } + + Component.onCompleted: refresh() + + IpcHandler { + target: "nightlight" + + function status(): string { + return JSON.stringify({ enabled: root.enabled, temperature: root.temperature }) + } + + function refresh(): void { + root.refresh() + } + + function enable(): string { + root.setNightlight(true) + return "enabled" + } + + function disable(): string { + root.setNightlight(false) + return "disabled" + } + + function toggle(): string { + var enabling = !root.enabled + root.setNightlight(enabling) + return enabling ? "enabled" : "disabled" + } + } +} diff --git a/shell/plugins/services/nightlight/manifest.json b/shell/plugins/services/nightlight/manifest.json new file mode 100644 index 00000000..0b1a4da5 --- /dev/null +++ b/shell/plugins/services/nightlight/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omarchy.nightlight", + "name": "Night Light", + "version": "1.0.0", + "author": "Omarchy", + "description": "Owns the hyprsunset night light temperature for the bar indicator and CLI.", + "kinds": [ + "service" + ], + "entryPoints": { + "service": "Service.qml" + } +} diff --git a/test/shell.d/fixtures/indicator-contract/shell.qml b/test/shell.d/fixtures/indicator-contract/shell.qml index 77899318..0adc6ade 100644 --- a/test/shell.d/fixtures/indicator-contract/shell.qml +++ b/test/shell.d/fixtures/indicator-contract/shell.qml @@ -33,11 +33,20 @@ ShellRoot { } } + QtObject { + id: nightlightService + property bool enabled: false + function setNightlight(value) { + enabled = !!value + } + } + QtObject { id: mockShell function firstPartyServiceFor(id) { if (id === "omarchy.notifications") return notificationService if (id === "omarchy.idle") return idleService + if (id === "omarchy.nightlight") return nightlightService return null } } @@ -175,7 +184,7 @@ ShellRoot { nightLight.moduleName = "NightLight" root.injectBar(nightLight) nightLight.triggerPress(Qt.LeftButton) - root.assertTrue(root.commandCount("omarchy-toggle-nightlight") === 1, "Night Light left click runs toggle command") + root.assertTrue(nightlightService.enabled === true, "Night Light left click toggles the nightlight service") } var screenRecording = root.createIndicator("ScreenRecording") diff --git a/test/shell.d/nightlight-test.sh b/test/shell.d/nightlight-test.sh index a075d5b7..43f3ef4f 100644 --- a/test/shell.d/nightlight-test.sh +++ b/test/shell.d/nightlight-test.sh @@ -4,26 +4,61 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +run_node_test <<'JS' +const nightlight = requireFromRoot('shell/plugins/services/nightlight/NightlightModel.js') + +assertEqual(nightlight.temperatureFromOutput('4000\n'), 4000, 'nightlight parses probe temperature') +assertEqual(nightlight.temperatureFromOutput("Couldn't connect to hyprsunset"), null, 'nightlight treats unreachable hyprsunset as unknown') +assertEqual(nightlight.isNightlight(4000), true, 'nightlight reports warm temperatures as enabled') +assertEqual(nightlight.isNightlight(5999), true, 'nightlight reports warmer-than-identity values as enabled') +assertEqual(nightlight.isNightlight(6000), false, 'nightlight reports identity temperature as disabled') +assertEqual(nightlight.isNightlight(null), false, 'nightlight reports unknown temperature as disabled') +JS + TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT mkdir -p "$TMPDIR/bin" +STATE="$TMPDIR/hyprsunset-temp" +SHELL_LOG="$TMPDIR/omarchy-shell-log" cat >"$TMPDIR/bin/hyprctl" <<'SH' #!/bin/bash if [[ ${1:-} == "hyprsunset" && ${2:-} == "temperature" ]]; then - printf '%s\n' "${HYPRSUNSET_TEMP:-6500}" + if [[ -n ${3:-} ]]; then + printf '%s\n' "$3" >"$HYPRSUNSET_STATE" + else + cat "$HYPRSUNSET_STATE" 2>/dev/null || exit 1 + fi exit 0 fi exit 1 SH -chmod +x "$TMPDIR/bin/hyprctl" +cat >"$TMPDIR/bin/pgrep" <<'SH' +#!/bin/bash +exit 0 +SH + +cat >"$TMPDIR/bin/omarchy-shell" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$OMARCHY_SHELL_LOG" +SH + +chmod +x "$TMPDIR/bin/hyprctl" "$TMPDIR/bin/pgrep" "$TMPDIR/bin/omarchy-shell" + +nightlight_cli() { + PATH="$TMPDIR/bin:$PATH" \ + HYPRSUNSET_STATE="$STATE" \ + OMARCHY_SHELL_LOG="$SHELL_LOG" \ + "$ROOT/bin/omarchy-toggle-nightlight" "$@" +} nightlight_status() { - HYPRSUNSET_TEMP="$1" PATH="$TMPDIR/bin:$PATH" "$ROOT/bin/omarchy-toggle-nightlight" --status + printf '%s\n' "$1" >"$STATE" + nightlight_cli --status } [[ $(nightlight_status 4000 | jq -r .enabled) == "true" ]] || fail "nightlight status reports 4000K as enabled" @@ -37,3 +72,21 @@ pass "nightlight status reports identity temperature as disabled" [[ $(nightlight_status 6500 | jq -r .enabled) == "false" ]] || fail "nightlight status reports daylight temperature as disabled" pass "nightlight status reports daylight temperature as disabled" + +printf '6500\n' >"$STATE" +: >"$SHELL_LOG" +nightlight_cli >/dev/null +[[ $(<"$STATE") == 4000 ]] || fail "nightlight toggle warms the screen from daylight" +pass "nightlight toggle warms the screen from daylight" + +grep -Fqx -- '-q nightlight refresh' "$SHELL_LOG" || fail "nightlight toggle nudges the shell nightlight service" +pass "nightlight toggle nudges the shell nightlight service" + +nightlight_cli >/dev/null +[[ $(<"$STATE") == 6500 ]] || fail "nightlight toggle restores daylight from night light" +pass "nightlight toggle restores daylight from night light" + +if rg -q 'omarchy.indicators' "$ROOT/bin/omarchy-toggle-nightlight"; then + fail "nightlight toggle leaves indicator refresh to the nightlight service" +fi +pass "nightlight toggle leaves indicator refresh to the nightlight service"