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'