import QtQuick import Quickshell.Io import qs.Common import qs.Services import qs.DCommon.Widgets import qs.Modules.Settings.Widgets import qs.Modules.DBar.Popouts 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: Qt.platform.os === "linux" Component.onCompleted: BatteryService.addRef() Component.onDestruction: BatteryService.removeRef() 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", "charge limit error toast detail, %1 is the exit code").arg(exitCode)); } else { ToastService.showInfo(I18n.tr("Charge limit applied successfully"), I18n.tr("Limit set to %1%", "charge limit toast detail, %1 is a percentage number").arg(SettingsData.batteryChargeLimit)); } } } BatteryHistory { id: history readonly property var device: BatteryService.usePreferred ? BatteryService.preferredDevice : BatteryService.device active: root.visible && BatteryService.batteryAvailable nativePath: device?.nativePath ?? "" } SettingsPage { id: mainColumn SettingsCard { width: parent.width title: I18n.tr("Status") settingKey: "batteryStatusCard" tags: ["battery", "status", "charge", "health"] SettingsRow { body: Column { width: parent.width spacing: Theme.spacingM Row { width: parent.width spacing: Theme.spacingM DIcon { 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: Theme.fontWeightMedium color: root.batteryStatusColor } StyledText { text: BatteryService.batteryStatus font.pixelSize: Theme.fontSizeLarge font.weight: Theme.fontWeightMedium 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: Theme.spacingXS Rectangle { anchors.fill: parent radius: Theme.fullRadius(width, height) color: Theme.withAlpha(root.batteryStatusColor, Theme.tonalTintAlpha) } Rectangle { anchors.left: parent.left width: parent.width * Math.max(0, Math.min(1, BatteryService.batteryLevel / 100)) height: parent.height radius: Theme.fullRadius(width, height) 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: Theme.fontWeightMedium 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("Health", "noun, battery health percentage label") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceTextMedium } StyledText { text: BatteryService.batteryHealth font.pixelSize: Theme.fontSizeMedium font.weight: Theme.fontWeightMedium color: Theme.surfaceText width: parent.width elide: Text.ElideRight } } } } } } } SettingsCard { width: parent.width iconName: "show_chart" title: I18n.tr("History") settingKey: "batteryHistory" tags: ["battery", "history", "graph", "charge"] visible: BatteryService.batteryAvailable && history.samples.filter(sample => sample[2] !== 0).length > 1 BatteryHistoryChart { height: implicitHeight color: "transparent" pad: 0 samples: history.samples rangeStart: history.rangeStart rangeEnd: history.rangeEnd deviceName: !BatteryService.usePreferred && BatteryService.batteries.length > 1 ? history.device?.model || history.device?.nativePath || "" : "" } } SettingsCard { width: parent.width iconName: "tune" title: I18n.tr("Protection", "battery settings card title for charge limit") settingKey: "batteryProtection" tags: ["battery", "protection", "charge", "limit"] SettingsSliderRow { settingKey: "batteryChargeLimit" text: I18n.tr("Charge limit") value: SettingsData.batteryChargeLimit minimum: 50 maximum: 100 onSliderValueChanged: newValue => SettingsData.set("batteryChargeLimit", newValue) } SettingsRow { visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && !root.chargeLimitSupported body: StyledText { width: parent.width 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 } } SettingsRow { visible: Qt.platform.os === "linux" && BatteryService.batteryAvailable && root.chargeLimitSupported body: Row { // charge_control_* live in Linux sysfs; no BSD equivalent width: parent.width height: applyButton.height layoutDirection: I18n.isRtl ? Qt.LeftToRight : Qt.RightToLeft Item { width: Theme.spacingM height: 1 } DButton { 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") checked: SettingsData.batteryNotifyChargeLimit onToggled: checked => SettingsData.set("batteryNotifyChargeLimit", checked) } SettingsButtonGroupRow { settingKey: "batteryChargeLimitNotificationType" text: I18n.tr("Alert style") model: [I18n.tr("Toast", "noun, battery alert style option, small popup message"), I18n.tr("Notification")] visible: SettingsData.batteryNotifyChargeLimit currentIndex: SettingsData.batteryChargeLimitNotificationType onSelectionChanged: (index, selected) => { if (selected) { SettingsData.set("batteryChargeLimitNotificationType", index); } } } } SettingsCard { width: parent.width iconName: "notifications" title: I18n.tr("Alerts", "noun, battery settings card title for low battery alerts") settingKey: "batteryAlerts" tags: ["battery", "alerts", "low", "warning"] SettingsSliderRow { settingKey: "batteryLowThreshold" text: I18n.tr("Low threshold") value: SettingsData.batteryLowThreshold minimum: 5 maximum: 40 onSliderValueChanged: newValue => SettingsData.set("batteryLowThreshold", newValue) } SettingsToggleRow { settingKey: "batteryNotifyLow" text: I18n.tr("Low notification") checked: SettingsData.batteryNotifyLow onToggled: checked => SettingsData.set("batteryNotifyLow", checked) } SettingsButtonGroupRow { settingKey: "batteryLowNotificationType" text: I18n.tr("Alert style") model: [I18n.tr("Toast"), I18n.tr("Notification")] enabled: SettingsData.batteryNotifyLow currentIndex: SettingsData.batteryLowNotificationType onSelectionChanged: (index, selected) => { if (selected) { SettingsData.set("batteryLowNotificationType", index); } } } } SettingsCard { width: parent.width iconName: "battery_alert" title: I18n.tr("Critical", "adjective, card title for critical battery level alerts") settingKey: "batteryCriticalAlerts" tags: ["battery", "critical", "alerts"] SettingsSliderRow { settingKey: "batteryCriticalThreshold" text: I18n.tr("Critical threshold") value: SettingsData.batteryCriticalThreshold minimum: 1 maximum: 30 onSliderValueChanged: newValue => SettingsData.set("batteryCriticalThreshold", newValue) } SettingsToggleRow { settingKey: "batteryCriticalAnimation" tags: ["battery", "critical", "indicator", "animation", "pulse", "blink"] text: I18n.tr("Pulse animation", "critical battery indicator animation toggle") checked: SettingsData.batteryCriticalAnimation onToggled: checked => SettingsData.set("batteryCriticalAnimation", checked) } SettingsToggleRow { settingKey: "batteryNotifyCritical" text: I18n.tr("Critical notification") checked: SettingsData.batteryNotifyCritical onToggled: checked => SettingsData.set("batteryNotifyCritical", checked) } SettingsButtonGroupRow { settingKey: "batteryCriticalNotificationType" text: I18n.tr("Alert style") model: [I18n.tr("Toast"), I18n.tr("Notification")] enabled: SettingsData.batteryNotifyCritical currentIndex: SettingsData.batteryCriticalNotificationType onSelectionChanged: (index, selected) => { if (selected) { SettingsData.set("batteryCriticalNotificationType", index); } } } } SettingsCard { width: parent.width iconName: "power" title: I18n.tr("Power profiles") settingKey: "powerProfilesSaving" SettingsToggleRow { settingKey: "batteryAutoPowerSaver" text: I18n.tr("Auto power saver") description: I18n.tr("Switches to power saver at the low battery threshold", "battery auto power saver toggle description") checked: SettingsData.batteryAutoPowerSaver onToggled: checked => SettingsData.set("batteryAutoPowerSaver", checked) } SettingsToggleRow { settingKey: "lowerDisplayRefreshRateOnBattery" tags: ["power", "battery", "display", "refresh", "rate", "60hz", "hz", "vrr"] text: I18n.tr("Lower display refresh rate on battery") visible: BatteryService.batteryAvailable checked: SettingsData.lowerDisplayRefreshRateOnBattery onToggled: checked => SettingsData.set("lowerDisplayRefreshRateOnBattery", checked) } SettingsDropdownRow { settingKey: "acProfileName" text: I18n.tr("Profile on 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 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]); } } } } } }