Files
DankMaterialShell/quickshell/Modules/Settings/BatteryTab.qml
T
Mario Mohar 1b34aa0ce1 fix(battery): report when no charge threshold exists instead of faking success (#3385)
* fix(battery): report when no charge threshold exists instead of faking success

* fix(battery): hide the charge limit controls when there is no battery

Follow-up to the review on #3385: the new explanation showed on
desktops and VMs with no battery at all, where it reads as a fault
report about hardware the machine was never going to have.

Both the explanation and the apply button now require
BatteryService.batteryAvailable. Hiding the button there is the same
point the pull request makes elsewhere: offering a control that cannot
work is what produced the false success in the first place. A machine
without a battery has no charge threshold to write to, so the limit was
never applicable.

BatteryService.batteryAvailable is already the gate used twice in this
file for the same reason.

(cherry picked from commit 6b50696b8a)
2026-09-15 16:46:47 -04:00

441 lines
19 KiB
QML

import QtQuick
import Quickshell.Io
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
Item {
id: root
readonly property color batteryStatusColor: {
if (BatteryService.isLowBattery && !BatteryService.isCharging)
return Theme.error;
if (BatteryService.isCharging || BatteryService.isPluggedIn)
return Theme.primary;
return Theme.surfaceText;
}
// The sysfs names DMS can write, in the order they are tried. Hardware that
// exposes none of them, a Lenovo IdeaPad on ideapad_laptop for instance,
// used to run the apply script to completion without writing anything, and
// sh exiting 0 was indistinguishable from a successful write.
readonly property string thresholdFileList: "charge_control_limit_max charge_stop_threshold charge_control_end_threshold"
readonly property int noThresholdExitCode: 2
property bool chargeLimitSupported: true
Process {
id: thresholdProbe
running: Qt.platform.os === "linux"
command: ["sh", "-c", "for bat in /sys/class/power_supply/BAT*; do for file in " + root.thresholdFileList + "; do [ -f \"$bat/$file\" ] && exit 0; done; done; exit 1"]
onExited: exitCode => root.chargeLimitSupported = exitCode === 0
}
Process {
id: applyLimitProcess
command: ["pkexec", "sh", "-c", "
found=0
for bat in /sys/class/power_supply/BAT*; do
for file in " + root.thresholdFileList + "; do
if [ -f \"$bat/$file\" ]; then
found=1
echo " + SettingsData.batteryChargeLimit + " > \"$bat/$file\" || exit 1
break
fi
done
done
[ \"$found\" = 1 ] || exit " + root.noThresholdExitCode + "
"]
running: false
onExited: exitCode => {
if (exitCode === root.noThresholdExitCode) {
root.chargeLimitSupported = false;
ToastService.showError(I18n.tr("Charge limit not supported on this hardware", "battery settings: toast title when sysfs has no charge threshold"), I18n.tr("No writable charge threshold file was found under /sys/class/power_supply.", "battery settings: why the charge limit could not be applied"));
} else if (exitCode !== 0) {
ToastService.showError(I18n.tr("Failed to apply charge limit to system"), I18n.tr("Process exited with code %1").arg(exitCode));
} else {
ToastService.showInfo(I18n.tr("Charge limit applied successfully"), I18n.tr("Limit set to %1%").arg(SettingsData.batteryChargeLimit));
}
}
}
DankFlickable {
anchors.fill: parent
clip: true
contentHeight: mainColumn.height + Theme.spacingXL
contentWidth: width
Column {
id: mainColumn
topPadding: 4
width: Math.min(550, parent.width - Theme.spacingL * 2)
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
// 1. Information Card
SettingsCard {
width: parent.width
title: I18n.tr("Status")
settingKey: "batteryStatusCard"
tags: ["battery", "status", "charge", "health"]
Column {
width: parent.width - Theme.spacingM * 2
x: Theme.spacingM
spacing: Theme.spacingM
Row {
width: parent.width
spacing: Theme.spacingM
DankIcon {
name: BatteryService.getBatteryIcon()
size: Theme.iconSizeLarge
color: root.batteryStatusColor
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: Theme.spacingXS
width: parent.width - Theme.iconSizeLarge - Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
Row {
spacing: Theme.spacingS
width: parent.width
StyledText {
text: BatteryService.batteryAvailable ? `${BatteryService.batteryLevel}%` : ""
font.pixelSize: Theme.fontSizeXLarge
font.weight: Font.Bold
color: root.batteryStatusColor
}
StyledText {
text: BatteryService.batteryStatus
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
elide: Text.ElideRight
width: Math.max(0, parent.width - 100)
}
}
StyledText {
text: BatteryService.isPluggedIn ? I18n.tr("AC Adapter (Plugged In)") : I18n.tr("Battery Power")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
width: parent.width
elide: Text.ElideRight
}
}
}
Item {
width: parent.width
height: 4
Rectangle {
anchors.fill: parent
radius: height / 2
color: Theme.withAlpha(Theme.primary, 0.16)
}
Rectangle {
anchors.left: parent.left
width: parent.width * Math.max(0, Math.min(1, BatteryService.batteryLevel / 100))
height: parent.height
radius: height / 2
color: root.batteryStatusColor
visible: BatteryService.batteryAvailable
}
}
Row {
width: parent.width
spacing: Theme.spacingM
Item {
width: (parent.width - Theme.spacingM) / 2
height: timeColumn.implicitHeight
Column {
id: timeColumn
width: parent.width
spacing: Theme.spacingXXS
StyledText {
text: I18n.tr("Estimated Time")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
}
StyledText {
text: {
const remaining = BatteryService.formatTimeRemaining();
const estimated = BatteryService.formatEstimatedTime();
return estimated ? `${remaining} (${estimated})` : remaining;
}
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
elide: Text.ElideRight
}
}
}
Item {
width: (parent.width - Theme.spacingM) / 2
height: healthColumn.implicitHeight
Column {
id: healthColumn
width: parent.width
spacing: Theme.spacingXXS
StyledText {
text: I18n.tr("Battery Health")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceTextMedium
}
StyledText {
text: BatteryService.batteryHealth
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
width: parent.width
elide: Text.ElideRight
}
}
}
}
}
}
// 2. Threshold & Limits Card
SettingsCard {
width: parent.width
iconName: "tune"
title: I18n.tr("Protection")
settingKey: "batteryProtection"
tags: ["battery", "protection", "charge", "limit"]
SettingsSliderRow {
settingKey: "batteryChargeLimit"
text: I18n.tr("Battery Charge Limit")
description: I18n.tr("Limit the maximum battery charge level to extend lifespan.")
value: SettingsData.batteryChargeLimit
minimum: 50
maximum: 100
defaultValue: 100
onSliderValueChanged: newValue => SettingsData.set("batteryChargeLimit", newValue)
}
StyledText {
width: parent.width
// A machine without a battery has no charge threshold to
// write to either, but saying so there would be noise: the
// limit was never applicable in the first place.
visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && !root.chargeLimitSupported
text: I18n.tr("No writable charge threshold file was found under /sys/class/power_supply.", "battery settings: why the charge limit could not be applied")
wrapMode: Text.WordWrap
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeMedium
}
Row {
// charge_control_* live in Linux sysfs; no BSD equivalent.
// Without a battery the apply button cannot do anything,
// and offering it anyway is what produced the false
// success this change is about.
visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && root.chargeLimitSupported
width: parent.width
height: applyButton.height
layoutDirection: I18n.isRtl ? Qt.LeftToRight : Qt.RightToLeft
Item {
width: Theme.spacingM
height: 1
}
DankButton {
id: applyButton
text: I18n.tr("Apply to Hardware")
iconName: "lock"
backgroundColor: Theme.primary
textColor: Theme.onPrimary
onClicked: {
applyLimitProcess.running = true;
}
}
}
SettingsToggleRow {
settingKey: "batteryNotifyChargeLimit"
text: I18n.tr("Notify when limit is reached")
description: I18n.tr("Show a notification when battery reaches the charge limit.")
checked: SettingsData.batteryNotifyChargeLimit
onToggled: checked => SettingsData.set("batteryNotifyChargeLimit", checked)
}
SettingsButtonGroupRow {
settingKey: "batteryChargeLimitNotificationType"
text: I18n.tr("Notification Type")
description: I18n.tr("Choose how to be notified when charge limit is reached.")
model: [I18n.tr("Toast"), I18n.tr("Notification")]
visible: SettingsData.batteryNotifyChargeLimit
currentIndex: SettingsData.batteryChargeLimitNotificationType
onSelectionChanged: (index, selected) => {
if (selected) {
SettingsData.set("batteryChargeLimitNotificationType", index);
}
}
}
}
// 3. Battery Alerts Card
SettingsCard {
width: parent.width
iconName: "notifications"
title: I18n.tr("Alerts")
settingKey: "batteryAlerts"
tags: ["battery", "alerts", "low", "warning"]
SettingsSliderRow {
settingKey: "batteryLowThreshold"
text: I18n.tr("Low Battery Threshold")
description: I18n.tr("Set the percentage at which the battery is considered low.")
value: SettingsData.batteryLowThreshold
minimum: 5
maximum: 40
defaultValue: 20
onSliderValueChanged: newValue => SettingsData.set("batteryLowThreshold", newValue)
}
SettingsToggleRow {
settingKey: "batteryNotifyLow"
text: I18n.tr("Low Battery Notifications")
description: I18n.tr("Show a warning popup when battery is running low.")
checked: SettingsData.batteryNotifyLow
onToggled: checked => SettingsData.set("batteryNotifyLow", checked)
}
SettingsButtonGroupRow {
settingKey: "batteryLowNotificationType"
text: I18n.tr("Notification Type")
description: I18n.tr("Choose how to be notified about low battery alerts.")
model: [I18n.tr("Toast"), I18n.tr("Notification")]
visible: SettingsData.batteryNotifyLow
currentIndex: SettingsData.batteryLowNotificationType
onSelectionChanged: (index, selected) => {
if (selected) {
SettingsData.set("batteryLowNotificationType", index);
}
}
}
SettingsDivider {}
StyledText {
text: I18n.tr("Critical Battery Alert")
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.DemiBold
color: Theme.surfaceText
x: Theme.spacingM
width: parent.width - Theme.spacingM * 2
topPadding: Theme.spacingS
}
SettingsSliderRow {
settingKey: "batteryCriticalThreshold"
text: I18n.tr("Critical Threshold")
description: I18n.tr("Battery percentage to trigger a critical alert.")
value: SettingsData.batteryCriticalThreshold
minimum: 1
maximum: 30
defaultValue: 10
onSliderValueChanged: newValue => SettingsData.set("batteryCriticalThreshold", newValue)
}
SettingsToggleRow {
settingKey: "batteryNotifyCritical"
text: I18n.tr("Critical Battery Notifications")
description: I18n.tr("Show an urgent alert when battery reaches critical level.")
checked: SettingsData.batteryNotifyCritical
onToggled: checked => SettingsData.set("batteryNotifyCritical", checked)
}
SettingsButtonGroupRow {
settingKey: "batteryCriticalNotificationType"
text: I18n.tr("Notification Type")
description: I18n.tr("Choose how to be notified about critical battery alerts.")
model: [I18n.tr("Toast"), I18n.tr("Notification")]
visible: SettingsData.batteryNotifyCritical
currentIndex: SettingsData.batteryCriticalNotificationType
onSelectionChanged: (index, selected) => {
if (selected) {
SettingsData.set("batteryCriticalNotificationType", index);
}
}
}
}
// 4. Power Profiles & Saving Card
SettingsCard {
width: parent.width
iconName: "power"
title: I18n.tr("Power Profiles & Saving")
settingKey: "powerProfilesSaving"
SettingsToggleRow {
settingKey: "batteryAutoPowerSaver"
text: I18n.tr("Auto Power Saver")
description: I18n.tr("Automatically turn on Power Saver profile when battery is low.")
checked: SettingsData.batteryAutoPowerSaver
onToggled: checked => SettingsData.set("batteryAutoPowerSaver", checked)
}
SettingsDivider {}
SettingsDropdownRow {
settingKey: "acProfileName"
text: I18n.tr("Profile when Plugged In (AC)")
options: [I18n.tr("Don't Change"), Theme.getPowerProfileLabel(0), Theme.getPowerProfileLabel(1), Theme.getPowerProfileLabel(2)]
currentValue: {
const val = SettingsData.acProfileName;
const idx = ["", "0", "1", "2"].indexOf(val);
return idx >= 0 ? options[idx] : options[0];
}
onValueChanged: value => {
const idx = options.indexOf(value);
if (idx >= 0) {
SettingsData.set("acProfileName", ["", "0", "1", "2"][idx]);
}
}
}
SettingsDropdownRow {
settingKey: "batteryProfileName"
text: I18n.tr("Profile when on Battery")
options: [I18n.tr("Don't Change"), Theme.getPowerProfileLabel(0), Theme.getPowerProfileLabel(1), Theme.getPowerProfileLabel(2)]
currentValue: {
const val = SettingsData.batteryProfileName;
const idx = ["", "0", "1", "2"].indexOf(val);
return idx >= 0 ? options[idx] : options[0];
}
onValueChanged: value => {
const idx = options.indexOf(value);
if (idx >= 0) {
SettingsData.set("batteryProfileName", ["", "0", "1", "2"][idx]);
}
}
}
}
}
}
}