From bc6d63b70f80adf28ebb4022875e2dc02c9a87c6 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 21 Aug 2026 22:18:29 -0400 Subject: [PATCH] Let the idle timings know whether you are plugged in An idle screen costs a screen on wall power and the rest of your afternoon on battery, so they should not be the same number. hypridle has no concept of a power source -- one config, one set of timeouts -- so rather than maintaining two configs and swapping them, panama-idle builds the single config from whichever key set applies, and IdleLock rebuilds it when the charger comes or goes. That runs through the same 400ms debounce a settings change uses, so a loose charger cannot restart hypridle in a loop. The battery keys fall back to their AC counterparts rather than to the schema defaults. Without that, unplugging would silently override a deliberately long timing with a shipped short one, which is the kind of thing you would notice only by losing work. A machine with no battery reads none of it and generates exactly what it generated before. The contract pins that alongside the two obvious directions, and was checked by sabotaging the detection to confirm it fails rather than passing vacuously. --- .../quickshell/config/PreferenceSchema.qml | 27 +++++++ .../quickshell/modules/settings/PowerPage.qml | 24 +++++- config/dot/quickshell/scripts/panama-idle | 39 +++++++++- config/dot/quickshell/services/IdleLock.qml | 12 +++ .../quickshell/services/SettingsSearch.qml | 1 + .../share/vicinae/scripts/settings-power | 2 +- docs/settings.md | 12 ++- tests/hypr/idle-config-contract | 78 +++++++++++++++++++ 8 files changed, 187 insertions(+), 8 deletions(-) diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index fe7df75..18b809e 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -1003,6 +1003,33 @@ Singleton { detail: "Requires your password when the machine wakes" }, + // The same three timings again, for when the machine is running on + // its own battery. hypridle has no concept of a power source, so + // there is one generated config and panama-idle rebuilds it from + // whichever set applies when the charger comes or goes. + // + // Shorter defaults, because the cost of an idle screen differs: on + // wall power it is a screen, on battery it is the rest of your + // afternoon. A machine with no battery never reads these at all. + { + key: "screenBlankMinutesBattery", type: "int", def: 2, min: 0, max: 120, step: 1, + unit: "min", group: "idleBattery", + label: "Turn the screen off after", + detail: "On battery. Blanks the display; nothing is locked yet" + }, + { + key: "lockMinutesBattery", type: "int", def: 5, min: 0, max: 240, step: 1, + unit: "min", group: "idleBattery", + label: "Lock after", + detail: "On battery. Requires your password to get back in" + }, + { + key: "suspendMinutesBattery", type: "int", def: 20, min: 0, max: 480, step: 5, + unit: "min", group: "idleBattery", + label: "Suspend after", + detail: "On battery, sleeping is what makes the charge last" + }, + // ── Night light schedule ──────────────────────────────────────────── // Hours as decimals, so 17.5 is half past five. Wrapping past midnight // is normal here and is what the shipped values do: on at 17:00, off at diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index 95be88e..5320687 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -90,9 +90,13 @@ SettingsPage { } SettingsCard { - title: "Idle behavior" + // Named for the power source only on a machine that has two of them. + // On a desktop this is simply "Idle behavior", as it always was. + title: Battery.available ? "Idle behavior on wall power" : "Idle behavior" subtitle: IdleLock.managed - ? "Idle timings are managed here. Changes take effect immediately." + ? (Battery.available && !Battery.acOnline + ? "Managed here. These apply when the charger is connected; the battery timings below are what is in effect right now." + : "Idle timings are managed here. Changes take effect immediately.") : "hypridle is running its shipped configuration. Turn on management below to make these adjustable." SliderRow { setting: "screenBlankMinutes"; zeroLabel: "Never" } @@ -101,6 +105,22 @@ SettingsPage { ToggleRow { setting: "lockOnSleep"; divider: false } } + // Absent on a desktop. hypridle holds one set of timeouts at a time, so + // these do not layer on top of the card above -- they replace it whenever + // the charger is unplugged, and panama-idle rebuilds the config at that + // moment. + SettingsCard { + visible: Battery.available + title: "Idle behavior on battery" + subtitle: Battery.acOnline + ? "What will apply once the charger is unplugged." + : "In effect right now." + + SliderRow { setting: "screenBlankMinutesBattery"; zeroLabel: "Never" } + SliderRow { setting: "lockMinutesBattery"; zeroLabel: "Never" } + SliderRow { setting: "suspendMinutesBattery"; zeroLabel: "Never"; divider: false } + } + // Only shown when the numbers are actually contradictory, rather than as a // permanent warning nobody reads. SettingsCard { diff --git a/config/dot/quickshell/scripts/panama-idle b/config/dot/quickshell/scripts/panama-idle index cc69614..38d1c54 100755 --- a/config/dot/quickshell/scripts/panama-idle +++ b/config/dot/quickshell/scripts/panama-idle @@ -49,10 +49,38 @@ clamp_int() { printf '%s' "$value" } +# hypridle has no concept of a power source: one config, one set of timeouts. +# So rather than maintaining two configs and swapping them, the single config +# is regenerated whenever the machine moves between wall power and battery, and +# this decides which set of keys it is built from. IdleLock watches +# Battery.acChanged and calls `apply` for exactly this reason. +# +# A machine with no battery never consults the battery keys at all, which is +# what keeps a desktop's generated config byte-for-byte what it was before any +# of this existed. +on_battery() { + local hw="${PANAMA_PATH:-$HOME/.local/share/Panama}/bin/panama-hw" + [[ -x "$hw" ]] || return 1 + "$hw" battery || return 1 + ! "$hw" ac +} + load() { - blank_min="$(clamp_int "$(read_setting screenBlankMinutes 5)" 0 120 5)" - lock_min="$(clamp_int "$(read_setting lockMinutes 10)" 0 240 10)" - suspend_min="$(clamp_int "$(read_setting suspendMinutes 0)" 0 480 0)" + local suffix="" + if on_battery; then + suffix="Battery" + fi + + # The battery variants fall back to their AC counterparts rather than to a + # constant, so a machine whose battery keys were never written still gets + # coherent behavior instead of the schema's shipped shorter timings + # overriding a deliberately long AC setting. + blank_min="$(clamp_int "$(read_setting "screenBlankMinutes$suffix" \ + "$(read_setting screenBlankMinutes 5)")" 0 120 5)" + lock_min="$(clamp_int "$(read_setting "lockMinutes$suffix" \ + "$(read_setting lockMinutes 10)")" 0 240 10)" + suspend_min="$(clamp_int "$(read_setting "suspendMinutes$suffix" \ + "$(read_setting suspendMinutes 0)")" 0 480 0)" lock_on_sleep="$(read_setting lockOnSleep true)" [[ "$lock_on_sleep" == "true" || "$lock_on_sleep" == "false" ]] || lock_on_sleep=true } @@ -67,6 +95,8 @@ generate() { { printf '# Generated by panama-idle from %s\n' "$settings" + printf '# Power source at generation: %s\n' \ + "$(on_battery && echo battery || echo 'wall power')" printf '# Do not edit: it is rewritten whenever the idle settings change.\n' printf '# The shipped defaults live in the Panama repo at config/dot/hypr/hypridle.conf.\n\n' @@ -143,9 +173,10 @@ case "${1:-apply}" in load managed=false [[ -f "$dropin" ]] && managed=true - printf '{"managed":%s,"active":"%s","blankMinutes":%s,"lockMinutes":%s,"suspendMinutes":%s,"lockOnSleep":%s,"generated":"%s"}\n' \ + printf '{"managed":%s,"active":"%s","powerSource":"%s","blankMinutes":%s,"lockMinutes":%s,"suspendMinutes":%s,"lockOnSleep":%s,"generated":"%s"}\n' \ "$managed" \ "$(systemctl --user is-active hypridle.service 2>/dev/null || printf unknown)" \ + "$(on_battery && printf battery || printf ac)" \ "$blank_min" "$lock_min" "$suspend_min" "$lock_on_sleep" "$generated" ;; *) diff --git a/config/dot/quickshell/services/IdleLock.qml b/config/dot/quickshell/services/IdleLock.qml index f761d92..f725add 100644 --- a/config/dot/quickshell/services/IdleLock.qml +++ b/config/dot/quickshell/services/IdleLock.qml @@ -107,6 +107,18 @@ Singleton { } } + // The charger came or went. hypridle cannot express two sets of timeouts, + // so the config is rebuilt from the other set and the daemon restarted -- + // the same path a settings change takes, through the same debounce, so a + // flapping charger cannot restart hypridle in a loop. + Connections { + target: Battery + function onAcChanged(online: bool): void { + if (root.managed) + regenerate.restart(); + } + } + Timer { id: regenerate interval: 400 diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index dcb7add..b9991e4 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -30,6 +30,7 @@ Singleton { "clock": "appearance", "vitals": "appearance", "battery": "power", + "idleBattery": "power", "typography": "appearance", "themes": "appearance", "titlebar": "appearance", diff --git a/config/local/share/vicinae/scripts/settings-power b/config/local/share/vicinae/scripts/settings-power index e933cae..8391654 100755 --- a/config/local/share/vicinae/scripts/settings-power +++ b/config/local/share/vicinae/scripts/settings-power @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Power & Lock in Settings. -# @vicinae.keywords ["settings", "warn at", "urgent at", "stop charging at", "turn the screen off after", "lock the screen after", "suspend after", "lock before sleeping"] +# @vicinae.keywords ["settings", "warn at", "urgent at", "stop charging at", "turn the screen off after", "lock the screen after", "suspend after", "lock before sleeping", "lock after"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page power diff --git a/docs/settings.md b/docs/settings.md index a7ad4c1..a2dd6a5 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` after changing the schema; a contract fails when this copy is stale. -143 settings across 28 groups. 70 of them are applied to the compositor and confirmed by reading the value back. +146 settings across 29 groups. 70 of them are applied to the compositor and confirmed by reading the value back. ## accessibility @@ -143,6 +143,16 @@ Found on **Power & Lock**. | **Suspend after**
`suspendMinutes` | 0 min | This is a desktop, so Panama ships with automatic suspend off. Range 0–480. | | **Lock before sleeping**
`lockOnSleep` | true | Requires your password when the machine wakes | +## idleBattery + +Found on **Power & Lock**. + +| Setting | Default | What it does | +|---|---|---| +| **Turn the screen off after**
`screenBlankMinutesBattery` | 2 min | On battery. Blanks the display; nothing is locked yet. Range 0–120. | +| **Lock after**
`lockMinutesBattery` | 5 min | On battery. Requires your password to get back in. Range 0–240. | +| **Suspend after**
`suspendMinutesBattery` | 20 min | On battery, sleeping is what makes the charge last. Range 0–480. | + ## input Found on **Keyboard**. diff --git a/tests/hypr/idle-config-contract b/tests/hypr/idle-config-contract index 415b31c..17b4699 100755 --- a/tests/hypr/idle-config-contract +++ b/tests/hypr/idle-config-contract @@ -77,6 +77,84 @@ status="$(XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" jq -e '.managed == false and .blankMinutes == 5 and .lockMinutes == 10' <<<"$status" >/dev/null \ || fail "status did not report the generated values: $status" +# ── Power source ───────────────────────────────────────────────────────────── +# +# hypridle has no concept of AC versus battery, so there is one config and it +# is rebuilt from the other set of keys when the charger comes or goes. Three +# things must hold, and the third is the one that protects every existing +# desktop: +# +# 1. On battery, the battery keys win. +# 2. A battery key that was never written falls back to its AC counterpart, +# not to the schema's shorter shipped default -- otherwise unplugging +# would silently override a deliberately long setting. +# 3. A machine with no battery reads none of them, and generates exactly +# what it generated before any of this existed. +# +# panama-hw is stubbed rather than the sysfs tree, because this is testing +# which keys panama-idle chooses, not how the hardware is detected. That is +# the hardware predicates contract's job. + +stub_hw() { + mkdir -p "$work/fake/bin" + cat >"$work/fake/bin/panama-hw" <"$work/config/panama/settings.json" + XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \ + PANAMA_PATH="$work/fake" "$helper" apply +} + +settings='{"screenBlankMinutes":30,"lockMinutes":45,"suspendMinutes":0, + "screenBlankMinutesBattery":2,"lockMinutesBattery":5,"suspendMinutesBattery":20}' + +# On battery: battery keys. +stub_hw 0 1 +run_powered "$settings" +grep -q 'timeout = 120' "$generated" || fail 'on battery, the battery blank timing was not used' +grep -q 'timeout = 300' "$generated" || fail 'on battery, the battery lock timing was not used' +grep -q 'systemctl suspend' "$generated" || fail 'on battery, the battery suspend listener is missing' +grep -q 'Power source at generation: battery' "$generated" \ + || fail 'the generated config does not record that it was built for battery' + +# Plugged in: AC keys, and no suspend, even though the battery set has one. +stub_hw 0 0 +run_powered "$settings" +grep -q 'timeout = 1800' "$generated" || fail 'on wall power, the AC blank timing was not used' +grep -q 'timeout = 2700' "$generated" || fail 'on wall power, the AC lock timing was not used' +grep -q 'systemctl suspend' "$generated" && fail 'on wall power, a battery-only suspend listener was written' + +# A battery key that was never written falls back to its AC counterpart. +stub_hw 0 1 +run_powered '{"screenBlankMinutes":30,"lockMinutes":45}' +grep -q 'timeout = 1800' "$generated" \ + || fail 'an unset battery blank did not fall back to the AC value, so unplugging would override a deliberate setting' +grep -q 'timeout = 2700' "$generated" \ + || fail 'an unset battery lock did not fall back to the AC value' + +# No battery at all: the battery keys are never consulted, even when present. +stub_hw 1 0 +run_powered "$settings" +grep -q 'timeout = 1800' "$generated" || fail 'a machine with no battery did not use the AC blank timing' +grep -q 'timeout = 120' "$generated" && fail 'a machine with no battery read the battery keys' +grep -q 'Power source at generation: wall power' "$generated" \ + || fail 'a machine with no battery did not record wall power' + +status="$(XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \ + PANAMA_PATH="$work/fake" "$helper" status)" +jq -e '.powerSource == "ac"' <<<"$status" >/dev/null \ + || fail "status did not report the power source: $status" + trap - EXIT cleanup printf 'idle config contract: PASS\n'