From 74b41758762776b0af73453ac10fbfbb4f3d88fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hu=E1=BB=B3nh=20Thi=E1=BB=87n=20L=E1=BB=99c?= Date: Thu, 29 Oct 2026 21:22:04 +0700 Subject: [PATCH] feat(battery): scale battery level display (#4012) port 2.0 (cherry picked from commit d254f40acd37bf204d52acc97c0abaa930e82aaf) --- quickshell/Common/SettingsData.qml | 2 + quickshell/Common/settings/SettingsSpec.js | 6 ++ .../ControlCenter/Widgets/BatteryTile.qml | 8 +- .../DBar/Popouts/BatteryPopoutContent.qml | 4 +- quickshell/Modules/DBar/Widgets/Battery.qml | 6 +- .../DBar/Widgets/ControlCenterButton.qml | 4 +- .../Modules/DDash/Overview/BatteryCard.qml | 2 +- .../Modules/DIsland/IslandSystemSource.qml | 2 +- quickshell/Modules/Lock/LockStatusRow.qml | 2 +- quickshell/Modules/Settings/BatteryTab.qml | 21 +++- quickshell/Services/BatteryService.qml | 101 ++++++++++++------ quickshell/Widgets/BatteryMeter.qml | 4 +- .../translations/settings_search_index.json | 53 +++++++++ 13 files changed, 167 insertions(+), 48 deletions(-) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 1b30f35d4..0f6dca6fc 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -736,6 +736,8 @@ Singleton { property string batteryProfileName: Spec.SPEC.batteryProfileName.def property int batteryPostLockMonitorTimeout: Spec.SPEC.batteryPostLockMonitorTimeout.def property int batteryChargeLimit: Spec.SPEC.batteryChargeLimit.def + property bool batteryScaleToChargeLimit: Spec.SPEC.batteryScaleToChargeLimit.def + property bool batteryScaleAlertThresholds: Spec.SPEC.batteryScaleAlertThresholds.def property bool batteryNotifyChargeLimit: Spec.SPEC.batteryNotifyChargeLimit.def property int batteryCriticalThreshold: Spec.SPEC.batteryCriticalThreshold.def property bool batteryCriticalAnimation: Spec.SPEC.batteryCriticalAnimation.def diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 0b9132836..1e7c6e43f 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -1081,6 +1081,12 @@ var LOCAL_SPEC = { batteryChargeLimit: { def: 100 }, + batteryScaleToChargeLimit: { + def: false + }, + batteryScaleAlertThresholds: { + def: false + }, batteryNotifyChargeLimit: { def: false }, diff --git a/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml b/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml index 19ced3603..f6ee35bd3 100644 --- a/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml +++ b/quickshell/Modules/ControlCenter/Widgets/BatteryTile.qml @@ -28,12 +28,12 @@ CcTile { if (!available) return I18n.tr("Not available"); if (BatteryService.isCharging) - return `${BatteryService.batteryLevel}% • ` + I18n.tr("Charging"); + return `${BatteryService.displayBatteryLevelText}% • ` + I18n.tr("Charging"); if (BatteryService.isPluggedIn) - return `${BatteryService.batteryLevel}% • ` + I18n.tr("Plugged in"); - return `${BatteryService.batteryLevel}%`; + return `${BatteryService.displayBatteryLevelText}% • ` + I18n.tr("Plugged in"); + return `${BatteryService.displayBatteryLevelText}%`; } - denseTitle: available && !showsMeter ? I18n.tr("Battery") + ` • ${BatteryService.batteryLevel}%` : "" + denseTitle: available && !showsMeter ? I18n.tr("Battery") + ` • ${BatteryService.displayBatteryLevelText}%` : "" function syncBatteryRef(wanted) { if (wanted === batteryRefHeld) diff --git a/quickshell/Modules/DBar/Popouts/BatteryPopoutContent.qml b/quickshell/Modules/DBar/Popouts/BatteryPopoutContent.qml index 41f167835..e12d06b86 100644 --- a/quickshell/Modules/DBar/Popouts/BatteryPopoutContent.qml +++ b/quickshell/Modules/DBar/Popouts/BatteryPopoutContent.qml @@ -204,7 +204,7 @@ Item { width: Theme.iconButtonSize + Theme.spacingM height: width anchors.verticalCenter: parent.verticalCenter - value: BatteryService.hasBatteryReading ? BatteryService.batteryLevel / 100 : -1 + value: BatteryService.hasBatteryReading ? BatteryService.displayBatteryLevel / 100 : -1 trackGap: Theme.spacingXXS ringColor: root.levelLow ? Theme.error : Theme.primary animated: root.active @@ -224,7 +224,7 @@ Item { StyledText { width: parent.width - text: root.formatPercent(BatteryService.batteryLevel) + text: root.formatPercent(BatteryService.displayBatteryLevelText) visible: BatteryService.hasBatteryReading font.pixelSize: Theme.fontSizeXXLarge color: root.levelColor diff --git a/quickshell/Modules/DBar/Widgets/Battery.qml b/quickshell/Modules/DBar/Widgets/Battery.qml index ce3e6c5dd..1ee20f86d 100644 --- a/quickshell/Modules/DBar/Widgets/Battery.qml +++ b/quickshell/Modules/DBar/Widgets/Battery.qml @@ -96,7 +96,7 @@ BasePill { readonly property string horizontalDisplayText: { const parts = []; if (showPercent) { - parts.push(`${BatteryService.batteryLevel}%`); + parts.push(`${BatteryService.displayBatteryLevelText}%`); } if (showTime && batteryTimeText) { parts.push(showPercent ? `(${batteryTimeText})` : batteryTimeText); @@ -114,7 +114,7 @@ BasePill { } const parts = []; if (showPercent && batteryStyle === "duo") { - parts.push(`${BatteryService.batteryLevel}%`); + parts.push(`${BatteryService.displayBatteryLevelText}%`); } if (showTime && batteryTimeText) { parts.push(batteryTimeText); @@ -128,7 +128,7 @@ BasePill { readonly property string verticalDisplayText: { const lines = []; if (showPercent) { - lines.push(BatteryService.batteryLevel.toString()); + lines.push(BatteryService.displayBatteryLevelText); } if (showTime && batteryTimeText) { lines.push(verticalBatteryTimeText); diff --git a/quickshell/Modules/DBar/Widgets/ControlCenterButton.qml b/quickshell/Modules/DBar/Widgets/ControlCenterButton.qml index 9ea155ab8..6bdf7ba83 100644 --- a/quickshell/Modules/DBar/Widgets/ControlCenterButton.qml +++ b/quickshell/Modules/DBar/Widgets/ControlCenterButton.qml @@ -502,7 +502,7 @@ BasePill { case "bluetooth": return BluetoothService.connected ? "bluetooth_connected" : "bluetooth"; case "battery": - return Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable); + return Theme.getBatteryIcon(BatteryService.displayBatteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable); case "printer": return "print"; case "idleInhibitor": @@ -687,7 +687,7 @@ BasePill { case "bluetooth": return BluetoothService.connected ? "bluetooth_connected" : "bluetooth"; case "battery": - return Theme.getBatteryIcon(BatteryService.batteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable); + return Theme.getBatteryIcon(BatteryService.displayBatteryLevel, BatteryService.isCharging, BatteryService.batteryAvailable); case "printer": return "print"; case "idleInhibitor": diff --git a/quickshell/Modules/DDash/Overview/BatteryCard.qml b/quickshell/Modules/DDash/Overview/BatteryCard.qml index c77ff92be..1dbb3ccd0 100644 --- a/quickshell/Modules/DDash/Overview/BatteryCard.qml +++ b/quickshell/Modules/DDash/Overview/BatteryCard.qml @@ -13,7 +13,7 @@ Card { readonly property bool wide: width >= DashMetrics.gridRowUnit * 2 readonly property bool tall: height >= DashMetrics.gridRowUnit * 2 - readonly property string levelText: Math.round(BatteryService.batteryLevel) + "%" + readonly property string levelText: Math.round(BatteryService.displayBatteryLevel) + "%" readonly property string meterStyle: options.style ?? "solid" readonly property bool levelColors: options.color !== "theme" readonly property string healthText: options.health === true && BatteryService.batteryHealth.endsWith("%") ? I18n.tr("Health %1", "battery health, %1 is a percentage").arg(BatteryService.batteryHealth) : "" diff --git a/quickshell/Modules/DIsland/IslandSystemSource.qml b/quickshell/Modules/DIsland/IslandSystemSource.qml index 6bb801215..e084fb8e1 100644 --- a/quickshell/Modules/DIsland/IslandSystemSource.qml +++ b/quickshell/Modules/DIsland/IslandSystemSource.qml @@ -112,7 +112,7 @@ QtObject { } function chargingValue() { - const level = Math.round(BatteryService.batteryLevel) + "%"; + const level = Math.round(BatteryService.displayBatteryLevel) + "%"; const seconds = BatteryService.estimatedSeconds(); if (!seconds) return level; diff --git a/quickshell/Modules/Lock/LockStatusRow.qml b/quickshell/Modules/Lock/LockStatusRow.qml index 8c246c463..dbc830df6 100644 --- a/quickshell/Modules/Lock/LockStatusRow.qml +++ b/quickshell/Modules/Lock/LockStatusRow.qml @@ -376,7 +376,7 @@ Row { } StyledText { - text: BatteryService.batteryLevel + "%" + text: BatteryService.displayBatteryLevelText + "%" font.pixelSize: Theme.fontSizeLarge font.weight: Theme.fontWeight color: root.contentColor diff --git a/quickshell/Modules/Settings/BatteryTab.qml b/quickshell/Modules/Settings/BatteryTab.qml index 5c312ebe1..905714c81 100644 --- a/quickshell/Modules/Settings/BatteryTab.qml +++ b/quickshell/Modules/Settings/BatteryTab.qml @@ -106,7 +106,7 @@ done width: parent.width StyledText { - text: BatteryService.batteryAvailable ? `${BatteryService.batteryLevel}%` : "" + text: BatteryService.batteryAvailable ? `${BatteryService.displayBatteryLevelText}%` : "" font.pixelSize: Theme.fontSizeXLarge font.weight: Theme.fontWeightMedium color: root.batteryStatusColor @@ -145,7 +145,7 @@ done Rectangle { anchors.left: parent.left - width: parent.width * Math.max(0, Math.min(1, BatteryService.batteryLevel / 100)) + width: parent.width * Math.max(0, Math.min(1, BatteryService.displayBatteryLevel / 100)) height: parent.height radius: Theme.fullRadius(width, height) color: root.batteryStatusColor @@ -252,6 +252,23 @@ done onSliderValueChanged: newValue => SettingsData.set("batteryChargeLimit", newValue) } + SettingsToggleRow { + settingKey: "batteryScaleToChargeLimit" + text: I18n.tr("Scale battery level to charge limit", "battery settings: toggle to scale the displayed percentage so the charge limit represents 100%") + description: I18n.tr("Scale the displayed battery level to the configured charge limit", "battery settings: explains that the displayed battery percentage is scaled relative to the charge limit") + checked: SettingsData.batteryScaleToChargeLimit + onToggled: checked => SettingsData.set("batteryScaleToChargeLimit", checked) + } + + SettingsToggleRow { + settingKey: "batteryScaleAlertThresholds" + text: I18n.tr("Scale low and critical alert thresholds", "battery settings: toggle to apply low and critical alert thresholds to the charge-limit-scaled battery percentage") + description: I18n.tr("Use the scaled battery level for alert thresholds and percentages", "battery settings: explains that low and critical alerts trigger and report percentages on the scaled battery level") + checked: SettingsData.batteryScaleAlertThresholds + visible: SettingsData.batteryScaleToChargeLimit + onToggled: checked => SettingsData.set("batteryScaleAlertThresholds", checked) + } + SettingsRow { visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && !root.chargeLimitSupported body: StyledText { diff --git a/quickshell/Services/BatteryService.qml b/quickshell/Services/BatteryService.qml index 09b1e58e0..bc296414c 100644 --- a/quickshell/Services/BatteryService.qml +++ b/quickshell/Services/BatteryService.qml @@ -353,6 +353,9 @@ Singleton { const live = _liveChargePercent(); return live > 0 ? live : _lastBatteryLevel; } + readonly property real displayBatteryLevel: displayLevelForChargeLimit(batteryLevel, SettingsData.batteryChargeLimit, SettingsData.batteryScaleToChargeLimit) + readonly property string displayBatteryLevelText: SettingsData.batteryScaleToChargeLimit ? Math.round(displayBatteryLevel).toString() : displayBatteryLevel.toString() + readonly property real batteryAlertLevel: batteryAlertLevelForDisplay(batteryLevel, displayBatteryLevel, SettingsData.batteryScaleToChargeLimit, SettingsData.batteryScaleAlertThresholds) readonly property bool isCharging: (isBSD && freebsdBatteryAvailable) ? freebsdIsCharging : (_hasKnownChargingState ? _currentIsCharging : _lastIsCharging) @@ -360,12 +363,28 @@ Singleton { readonly property bool isPluggedIn: (isBSD && freebsdBatteryAvailable) ? freebsdPluggedIn : !UPower.onBattery readonly property bool hasBatteryReading: batteryAvailable && batteryLevel > 0 - readonly property bool isLowBattery: hasBatteryReading && batteryLevel <= SettingsData.batteryLowThreshold - readonly property bool isCriticalBattery: hasBatteryReading && batteryLevel <= SettingsData.batteryCriticalThreshold + readonly property bool isLowBattery: hasBatteryReading && isBatteryLevelAtOrBelow(batteryLevel, SettingsData.batteryLowThreshold) + readonly property bool isCriticalBattery: hasBatteryReading && isBatteryLevelAtOrBelow(batteryLevel, SettingsData.batteryCriticalThreshold) + readonly property bool isLowBatteryAlert: hasBatteryReading && isBatteryLevelAtOrBelow(batteryAlertLevel, SettingsData.batteryLowThreshold) + readonly property bool isCriticalBatteryAlert: hasBatteryReading && isBatteryLevelAtOrBelow(batteryAlertLevel, SettingsData.batteryCriticalThreshold) readonly property color levelCautionColor: "#FFC107" readonly property color levelCriticalColor: "#F44336" + function displayLevelForChargeLimit(level, chargeLimit, enabled) { + if (!enabled || chargeLimit <= 0 || chargeLimit >= 100) + return level; + return Math.max(0, Math.min(100, level / chargeLimit * 100)); + } + + function isBatteryLevelAtOrBelow(level, threshold) { + return level > 0 && level <= threshold; + } + + function batteryAlertLevelForDisplay(actualLevel, scaledLevel, scaleDisplayEnabled, scaleAlertsEnabled) { + return scaleDisplayEnabled && scaleAlertsEnabled ? scaledLevel : actualLevel; + } + readonly property color levelColor: { if (isCharging) return Theme.success; @@ -393,6 +412,23 @@ Singleton { on_HasKnownChargingStateChanged: _syncLastIsCharging() on_CurrentIsChargingChanged: _syncLastIsCharging() + onBatteryAlertLevelChanged: updateBatteryWarnings() + + Connections { + target: SettingsData + function onBatteryLowThresholdChanged() { + root.updateBatteryWarnings(); + } + function onBatteryCriticalThresholdChanged() { + root.updateBatteryWarnings(); + } + function onBatteryScaleToChargeLimitChanged() { + root.updateBatteryWarnings(); + } + function onBatteryScaleAlertThresholdsChanged() { + root.updateBatteryWarnings(); + } + } Component.onCompleted: { Qt.callLater(root.updateFreeBsdBatteryFallback); @@ -436,7 +472,10 @@ Singleton { } else if (!isCharging || batteryLevel < SettingsData.batteryChargeLimit - 2) { _hasNotifiedChargeLimit = false; } + updateBatteryWarnings(); + } + function updateBatteryWarnings() { if (isCharging) { _hasNotifiedLowBattery = false; _hasNotifiedCriticalBattery = false; @@ -444,34 +483,35 @@ Singleton { } // Critical battery check (higher priority) - if (isCriticalBattery) { + if (isCriticalBatteryAlert) { if (!_hasNotifiedCriticalBattery && SettingsData.batteryNotifyCritical) { _hasNotifiedCriticalBattery = true; - sendAlert(I18n.tr("Critical Battery"), I18n.tr("Battery is at %1% - Connect charger immediately!", "critical battery notification body, %1 is the battery percentage").arg(batteryLevel), "critical", "material:battery_alert", SettingsData.batteryCriticalNotificationType); + sendAlert(I18n.tr("Critical Battery"), I18n.tr("Battery is at %1% - Connect charger immediately!", "critical battery notification body, %1 is the battery percentage").arg(Math.round(batteryAlertLevel)), "critical", "material:battery_alert", SettingsData.batteryCriticalNotificationType); } return; } - if (batteryLevel > SettingsData.batteryCriticalThreshold) + if (batteryAlertLevel > SettingsData.batteryCriticalThreshold) _hasNotifiedCriticalBattery = false; // Low battery check - if (isLowBattery) { + if (isLowBatteryAlert) { if (!_hasNotifiedLowBattery && SettingsData.batteryNotifyLow) { _hasNotifiedLowBattery = true; - sendAlert(I18n.tr("Low Battery"), I18n.tr("Battery is at %1% - Consider charging soon", "low battery notification body, %1 is the battery percentage").arg(batteryLevel), "warning", "material:battery_0_bar", SettingsData.batteryLowNotificationType); + sendAlert(I18n.tr("Low Battery"), I18n.tr("Battery is at %1% - Consider charging soon", "low battery notification body, %1 is the battery percentage").arg(Math.round(batteryAlertLevel)), "warning", "material:battery_0_bar", SettingsData.batteryLowNotificationType); } - if (SettingsData.batteryAutoPowerSaver && PowerProfileWatcher.available) { - if (PowerProfileWatcher.currentProfile !== PowerProfile.PowerSaver) - PowerProfileWatcher.applyProfile(PowerProfile.PowerSaver); - } } - if (batteryLevel > SettingsData.batteryLowThreshold) + if (isLowBattery && SettingsData.batteryAutoPowerSaver && PowerProfileWatcher.available) { + if (PowerProfileWatcher.currentProfile !== PowerProfile.PowerSaver) + PowerProfileWatcher.applyProfile(PowerProfile.PowerSaver); + } + + if (batteryAlertLevel > SettingsData.batteryLowThreshold) _hasNotifiedLowBattery = false; } @@ -486,6 +526,7 @@ Singleton { _hasNotifiedCriticalBattery = false; } else { _hasNotifiedChargeLimit = false; + updateBatteryWarnings(); } } @@ -827,50 +868,50 @@ Singleton { return "power"; if (isCharging) { - if (batteryLevel >= 90) + if (displayBatteryLevel >= 90) return "battery_charging_full"; - if (batteryLevel >= 80) + if (displayBatteryLevel >= 80) return "battery_charging_90"; - if (batteryLevel >= 60) + if (displayBatteryLevel >= 60) return "battery_charging_80"; - if (batteryLevel >= 50) + if (displayBatteryLevel >= 50) return "battery_charging_60"; - if (batteryLevel >= 30) + if (displayBatteryLevel >= 30) return "battery_charging_50"; - if (batteryLevel >= 20) + if (displayBatteryLevel >= 20) return "battery_charging_30"; return "battery_charging_20"; } if (isPluggedIn) { - if (batteryLevel >= 90) + if (displayBatteryLevel >= 90) return "battery_charging_full"; - if (batteryLevel >= 80) + if (displayBatteryLevel >= 80) return "battery_charging_90"; - if (batteryLevel >= 60) + if (displayBatteryLevel >= 60) return "battery_charging_80"; - if (batteryLevel >= 50) + if (displayBatteryLevel >= 50) return "battery_charging_60"; - if (batteryLevel >= 30) + if (displayBatteryLevel >= 30) return "battery_charging_50"; - if (batteryLevel >= 20) + if (displayBatteryLevel >= 20) return "battery_charging_30"; return "battery_charging_20"; } - if (batteryLevel >= 95) + if (displayBatteryLevel >= 95) return "battery_full"; - if (batteryLevel >= 85) + if (displayBatteryLevel >= 85) return "battery_6_bar"; - if (batteryLevel >= 70) + if (displayBatteryLevel >= 70) return "battery_5_bar"; - if (batteryLevel >= 55) + if (displayBatteryLevel >= 55) return "battery_4_bar"; - if (batteryLevel >= 40) + if (displayBatteryLevel >= 40) return "battery_3_bar"; - if (batteryLevel >= 25) + if (displayBatteryLevel >= 25) return "battery_2_bar"; return "battery_1_bar"; diff --git a/quickshell/Widgets/BatteryMeter.qml b/quickshell/Widgets/BatteryMeter.qml index 119876b27..d295221b7 100644 --- a/quickshell/Widgets/BatteryMeter.qml +++ b/quickshell/Widgets/BatteryMeter.qml @@ -29,9 +29,9 @@ Item { readonly property bool circular: root.ring || root.duo readonly property real arcStart: root.duo ? 150 : -90 readonly property real arcSpan: root.duo ? 240 : 360 - readonly property real chargeTarget: Math.max(root.level, Math.min(100, SettingsData.batteryChargeLimit)) + readonly property real chargeTarget: SettingsData.batteryScaleToChargeLimit ? 100 : Math.max(root.level, Math.min(100, SettingsData.batteryChargeLimit)) readonly property real unit: root.thickness / 14 - readonly property real level: Math.max(0, Math.min(100, BatteryService.batteryLevel)) + readonly property real level: Math.max(0, Math.min(100, BatteryService.displayBatteryLevel)) readonly property bool charging: BatteryService.batteryAvailable && BatteryService.isCharging readonly property bool lowState: BatteryService.batteryAvailable && BatteryService.isLowBattery && !BatteryService.isCharging readonly property color fillColor: { diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index ae4a25152..7bede4694 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -14342,6 +14342,59 @@ ], "parentLabel": "Power & battery" }, + { + "section": "batteryScaleToChargeLimit", + "label": "Scale battery level to charge limit", + "tabIndex": 42, + "category": "Battery", + "keywords": [ + "battery", + "charge", + "charging", + "configured", + "displayed", + "hibernate", + "level", + "limit", + "power", + "reboot", + "restart", + "scale", + "shutdown", + "sleep", + "suspend" + ], + "parentLabel": "Power & battery", + "description": "Scale the displayed battery level to the configured charge limit" + }, + { + "section": "batteryScaleAlertThresholds", + "label": "Scale low and critical alert thresholds", + "tabIndex": 42, + "category": "Battery", + "keywords": [ + "alert", + "battery", + "charge", + "charging", + "critical", + "hibernate", + "level", + "low", + "percentages", + "power", + "reboot", + "restart", + "scale", + "scaled", + "shutdown", + "sleep", + "suspend", + "thresholds" + ], + "parentLabel": "Power & battery", + "description": "Use the scaled battery level for alert thresholds and percentages" + }, { "section": "batteryStatusCard", "label": "Status",