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'