diff --git a/bin/omarchy-battery-low b/bin/omarchy-battery-low index d107d4f8dc4..55050dbd93b 100755 --- a/bin/omarchy-battery-low +++ b/bin/omarchy-battery-low @@ -13,5 +13,8 @@ fi level=$1 +# The shell's battery service dismisses this toast by summary once the charger +# is back, so keep the headline in sync with lowBatterySummary in +# shell/plugins/services/battery/Service.qml. omarchy-notification-send -g 󱐋 -u critical "Time to recharge!" "Battery is down to ${level}%" -i battery-caution -t 30000 omarchy-hook battery-low "$level" diff --git a/shell/plugins/services/battery/BatteryModel.js b/shell/plugins/services/battery/BatteryModel.js index 2aca5dbd92b..0797bca2898 100644 --- a/shell/plugins/services/battery/BatteryModel.js +++ b/shell/plugins/services/battery/BatteryModel.js @@ -7,14 +7,17 @@ function isDischarging(device, onBattery, dischargingState) { return !!(device && device.isPresent && onBattery && device.state === dischargingState) } -function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified) { +function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified, firstCheck) { var level = batteryPercentage(device) - if (level < 0) return { level: level, notify: false, notifiedLowBattery: false } + var low = level >= 0 && isDischarging(device, onBattery, dischargingState) && level <= threshold - var low = isDischarging(device, onBattery, dischargingState) && level <= threshold return { level: level, notify: low && !alreadyNotified, + // A full shell restart forgets alreadyNotified but restores the critical + // toast from disk, so the first check clears too rather than trusting the + // flag alone. + clear: !low && (!!alreadyNotified || !!firstCheck), notifiedLowBattery: low } } diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index a1b01cfff60..c7e07d399b2 100644 --- a/shell/plugins/services/battery/Service.qml +++ b/shell/plugins/services/battery/Service.qml @@ -11,6 +11,9 @@ Item { property string omarchyPath: Quickshell.env("OMARCHY_PATH") readonly property int batteryThreshold: 10 + readonly property string lowBatterySummary: "Time to recharge!" + property bool checkedBattery: false + property bool pendingLowBatteryClear: false property string pendingPowerSource: "" property string activePowerProfile: "" readonly property bool powerSaverOnBattery: UPower.onBattery && activePowerProfile === "power-saver" @@ -30,9 +33,11 @@ Item { } function checkBattery() { - var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery) + var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery, !checkedBattery) + checkedBattery = true persisted.notifiedLowBattery = state.notifiedLowBattery if (state.notify) sendLowBatteryWarning(state.level) + else if (state.clear) clearLowBatteryWarning() } function sendLowBatteryWarning(level) { @@ -44,6 +49,26 @@ Item { warningProcess.running = true } + // The low-battery toast is critical urgency, so the shell never expires it on + // its own. Take it down once the charger is back rather than leaving a stale + // warning on screen; it stays in notification history either way. + // + // Wait for a warning still being posted: dismissing by summary only matches + // toasts the server already has, and checkBattery has cleared the notified + // flag by now, so a dismissal that runs too early would never be retried and + // the toast would linger for good. Defer rather than drop, the way + // pendingPowerSource does for the power-profile process. + function clearLowBatteryWarning() { + pendingLowBatteryClear = true + if (!warningProcess.running && !dismissProcess.running) runPendingLowBatteryClear() + } + + function runPendingLowBatteryClear() { + dismissProcess.command = ["omarchy-notification-dismiss", lowBatterySummary] + pendingLowBatteryClear = false + dismissProcess.running = true + } + function applyPowerProfile() { pendingPowerSource = UPower.onBattery ? "battery" : "ac" if (!powerProfileProcess.running) runPendingPowerProfile() @@ -59,7 +84,15 @@ Item { if (!powerProfileReadProcess.running) powerProfileReadProcess.running = true } - Process { id: warningProcess } + Process { + id: warningProcess + onExited: if (root.pendingLowBatteryClear && !dismissProcess.running) root.runPendingLowBatteryClear() + } + + Process { + id: dismissProcess + onExited: if (root.pendingLowBatteryClear) root.runPendingLowBatteryClear() + } Process { id: powerProfileProcess diff --git a/test/shell.d/battery-test.sh b/test/shell.d/battery-test.sh index 0447779a96f..afadbcc41d4 100644 --- a/test/shell.d/battery-test.sh +++ b/test/shell.d/battery-test.sh @@ -15,17 +15,45 @@ assert(!battery.isDischarging({ isPresent: true, state: discharging }, false, di assertDeepEqual( battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, false), - { level: 8, notify: true, notifiedLowBattery: true }, + { level: 8, notify: true, clear: false, notifiedLowBattery: true }, 'battery warns once under threshold' ) assertDeepEqual( battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, true), - { level: 8, notify: false, notifiedLowBattery: true }, + { level: 8, notify: false, clear: false, notifiedLowBattery: true }, 'battery keeps low-battery notified state' ) assertDeepEqual( battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.4, state: discharging }, true, discharging, 10, true), - { level: 40, notify: false, notifiedLowBattery: false }, + { level: 40, notify: false, clear: true, notifiedLowBattery: false }, 'battery clears notified state after recovery' ) +assertDeepEqual( + battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, false, discharging, 10, true), + { level: 8, notify: false, clear: true, notifiedLowBattery: false }, + 'battery clears the warning once the charger is plugged in' +) +assertDeepEqual( + battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.4, state: discharging }, true, discharging, 10, false), + { level: 40, notify: false, clear: false, notifiedLowBattery: false }, + 'battery leaves notifications alone when it never warned' +) +assertDeepEqual( + battery.shouldWarnLowBattery({ isPresent: false, percentage: 0.08, state: discharging }, true, discharging, 10, true), + { level: -1, notify: false, clear: true, notifiedLowBattery: false }, + 'battery clears the warning when the battery goes away' +) + +// A shell restart forgets the notified flag but restores the critical toast, +// so the first check after startup clears on its own. +assertDeepEqual( + battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.4, state: discharging }, true, discharging, 10, false, true), + { level: 40, notify: false, clear: true, notifiedLowBattery: false }, + 'battery clears a restored warning on the first check after a restart' +) +assertDeepEqual( + battery.shouldWarnLowBattery({ isPresent: true, percentage: 0.08, state: discharging }, true, discharging, 10, false, true), + { level: 8, notify: true, clear: false, notifiedLowBattery: true }, + 'battery still warns on the first check when it starts up low' +) JS