diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 10e4203c..ea4cd638 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -598,6 +598,11 @@ Singleton { property string batteryProfileName: "" property int batteryPostLockMonitorTimeout: 0 property int batteryChargeLimit: 100 + property bool batteryNotifyChargeLimit: false + property int batteryLowThreshold: 20 + property bool batteryNotifyLow: false + property int batteryNotificationType: 0 + property bool batteryAutoPowerSaver: false property bool lockBeforeSuspend: false property bool loginctlLockIntegration: true property bool fadeToLockEnabled: true diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index fc845fd1..0f23f6cd 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -305,6 +305,11 @@ var SPEC = { batteryProfileName: { def: "" }, batteryPostLockMonitorTimeout: { def: 0 }, batteryChargeLimit: { def: 100 }, + batteryNotifyChargeLimit: { def: false }, + batteryLowThreshold: { def: 20 }, + batteryNotifyLow: { def: false }, + batteryNotificationType: { def: 0 }, + batteryAutoPowerSaver: { def: false }, lockBeforeSuspend: { def: false }, loginctlLockIntegration: { def: true }, fadeToLockEnabled: { def: true }, diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 2cd4df76..d8737ab4 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -686,5 +686,20 @@ FocusScope { Qt.callLater(() => item.forceActiveFocus()); } } + + Loader { + id: batteryLoader + anchors.fill: parent + active: root.currentIndex === 37 + visible: active + focus: active + + sourceComponent: BatteryTab {} + + onActiveChanged: { + if (active && item) + Qt.callLater(() => item.forceActiveFocus()); + } + } } } diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 70502ad7..e3c6e9c8 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -377,6 +377,12 @@ Rectangle { "text": I18n.tr("Power & Sleep"), "icon": "power_settings_new", "tabIndex": 21 + }, + { + "id": "battery", + "text": I18n.tr("Battery"), + "icon": "battery_charging_full", + "tabIndex": 37 } ] }, diff --git a/quickshell/Modules/DankBar/Widgets/Battery.qml b/quickshell/Modules/DankBar/Widgets/Battery.qml index 1f440b60..43a7cd39 100644 --- a/quickshell/Modules/DankBar/Widgets/Battery.qml +++ b/quickshell/Modules/DankBar/Widgets/Battery.qml @@ -118,10 +118,18 @@ BasePill { width: battery.width + battery.leftMargin + battery.rightMargin height: battery.height + battery.topMargin + battery.bottomMargin cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton + acceptedButtons: Qt.LeftButton | Qt.RightButton onPressed: mouse => { battery.triggerRipple(this, mouse.x, mouse.y); - toggleBatteryPopup(); + if (mouse.button === Qt.LeftButton) { + toggleBatteryPopup(); + } else if (mouse.button === Qt.RightButton) { + if (PowerProfileWatcher.available) { + PowerProfileWatcher.cycleProfile(); + } else { + ToastService.showError(I18n.tr("power-profiles-daemon not available")); + } + } } onWheel: wheel => { var delta = wheel.angleDelta.y; @@ -131,33 +139,20 @@ BasePill { // Check if this is a touchpad if (delta !== 120 && delta !== -120) { touchpadAccumulator += delta; - log.info("Acc: " + touchpadAccumulator); if (Math.abs(touchpadAccumulator) < 500) return; delta = touchpadAccumulator; touchpadAccumulator = 0; } - log.info("Trigger! Delta: " + delta); - // This is after the other delta checks so it only shows on valid Y scroll - if (!PowerProfileWatcher.available) { - ToastService.showError(I18n.tr("power-profiles-daemon not available")); + if (!DisplayService.brightnessAvailable) { return; } - const profiles = PowerProfileWatcher.availableProfiles; - var index = profiles.findIndex(profile => PowerProfiles.profile === profile); - - if (delta > 0) - index += 1; - else - index -= 1; - - if (index < 0 || index >= profiles.length) - return; - - if (!PowerProfileWatcher.applyProfile(profiles[index])) - ToastService.showError(I18n.tr("Failed to set power profile")); + const step = 5; + const change = delta > 0 ? step : -step; + const newBrightness = Math.max(0, Math.min(100, DisplayService.brightnessLevel + change)); + DisplayService.setBrightness(newBrightness, "", false); } } } diff --git a/quickshell/Modules/Settings/BatteryTab.qml b/quickshell/Modules/Settings/BatteryTab.qml new file mode 100644 index 00000000..fc8367ec --- /dev/null +++ b/quickshell/Modules/Settings/BatteryTab.qml @@ -0,0 +1,310 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets + +Item { + id: root + + Process { + id: applyLimitProcess + command: ["pkexec", "sh", "-c", " +for bat in /sys/class/power_supply/BAT*; do + if [ -f \"$bat/charge_control_limit_max\" ]; then + echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_limit_max\" + elif [ -f \"$bat/charge_stop_threshold\" ]; then + echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_stop_threshold\" + elif [ -f \"$bat/charge_control_end_threshold\" ]; then + echo " + SettingsData.batteryChargeLimit + " > \"$bat/charge_control_end_threshold\" + fi +done +"] + running: false + onExited: exitCode => { + 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 + iconName: "battery_charging_full" + title: I18n.tr("Battery Status") + settingKey: "batteryStatusCard" + + Column { + width: parent.width + spacing: Theme.spacingM + + Row { + width: parent.width + StyledText { + text: I18n.tr("Power Source") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + width: parent.width / 2 + } + StyledText { + text: BatteryService.isPluggedIn ? I18n.tr("AC Adapter (Plugged In)") : I18n.tr("Battery Power") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width / 2 + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.1 + } + + Row { + width: parent.width + StyledText { + text: I18n.tr("Charge Level") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + width: parent.width / 2 + } + StyledText { + text: `${BatteryService.batteryLevel}%` + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width / 2 + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.1 + } + + Row { + width: parent.width + StyledText { + text: I18n.tr("Status") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + width: parent.width / 2 + } + StyledText { + text: BatteryService.batteryStatus + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width / 2 + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.1 + } + + Row { + width: parent.width + StyledText { + text: I18n.tr("Estimated Time") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + width: parent.width / 2 + } + StyledText { + text: BatteryService.formatTimeRemaining() + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width / 2 + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.1 + } + + Row { + width: parent.width + StyledText { + text: I18n.tr("Battery Health") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceVariantText + width: parent.width / 2 + } + StyledText { + text: BatteryService.batteryHealth + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width / 2 + } + } + } + } + + // 2. Threshold & Limits Card + SettingsCard { + width: parent.width + iconName: "tune" + title: I18n.tr("Battery Protection & Charging") + settingKey: "batteryProtection" + + 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) + } + + Row { + width: parent.width + height: applyButton.height + layoutDirection: Qt.RightToLeft + + 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) + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.15 + } + + 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: "batteryNotificationType" + text: I18n.tr("Notification Type") + description: I18n.tr("Choose how to be notified about battery alerts.") + model: [I18n.tr("Toast Overlay"), I18n.tr("System Notification")] + currentIndex: SettingsData.batteryNotificationType + onSelectionChanged: (index, selected) => { + if (selected) { + SettingsData.set("batteryNotificationType", index); + } + } + } + + 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) + } + } + + // 3. Power Profiles Card + SettingsCard { + width: parent.width + iconName: "power" + title: I18n.tr("Power Profiles Auto-Switching") + settingKey: "powerProfilesAuto" + + SettingsDropdownRow { + settingKey: "acProfileName" + text: I18n.tr("Profile when Plugged In (AC)") + description: I18n.tr("Power profile to use when AC power is connected.") + 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") + description: I18n.tr("Power profile to use when running on battery power.") + 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]); + } + } + } + } + } + } +} diff --git a/quickshell/Services/BatteryService.qml b/quickshell/Services/BatteryService.qml index f191221f..173e70ec 100644 --- a/quickshell/Services/BatteryService.qml +++ b/quickshell/Services/BatteryService.qml @@ -102,7 +102,59 @@ Singleton { // Is the system plugged in (Is not running on battery) readonly property bool isPluggedIn: !UPower.onBattery - readonly property bool isLowBattery: batteryAvailable && batteryLevel <= 20 + readonly property bool isLowBattery: batteryAvailable && batteryLevel <= SettingsData.batteryLowThreshold + + property bool _hasNotifiedLowBattery: false + property bool _hasNotifiedChargeLimit: false + + function sendAlert(title, message, isWarning, category) { + if (SettingsData.batteryNotificationType === 1) { + Quickshell.execDetached(["notify-send", "-u", isWarning ? "critical" : "normal", "-a", "DMS", "-i", isWarning ? "battery-caution" : "battery-charging", title, message]); + } else { + if (isWarning) { + ToastService.showWarning(title, message, "", category); + } else { + ToastService.showInfo(title, message, "", category); + } + } + } + + onBatteryLevelChanged: { + if (isCharging && batteryLevel >= SettingsData.batteryChargeLimit) { + if (!_hasNotifiedChargeLimit && SettingsData.batteryNotifyChargeLimit) { + _hasNotifiedChargeLimit = true; + sendAlert(I18n.tr("Charge Limit Reached"), I18n.tr("Battery has charged to your set limit of %1%").arg(SettingsData.batteryChargeLimit), false, "battery-charge-limit"); + } + } else if (!isCharging || batteryLevel < SettingsData.batteryChargeLimit - 2) { + _hasNotifiedChargeLimit = false; + } + + if (isCharging || !isLowBattery) { + _hasNotifiedLowBattery = false; + return; + } + + if (!isCharging && isLowBattery) { + if (!_hasNotifiedLowBattery && SettingsData.batteryNotifyLow) { + _hasNotifiedLowBattery = true; + sendAlert(I18n.tr("Low Battery"), I18n.tr("Battery is at %1%").arg(batteryLevel), true, "battery-low"); + } + + if (SettingsData.batteryAutoPowerSaver && PowerProfileWatcher.available) { + if (PowerProfileWatcher.currentProfile !== PowerProfile.PowerSaver) { + PowerProfileWatcher.applyProfile(PowerProfile.PowerSaver); + } + } + } + } + + onIsChargingChanged: { + if (isCharging) { + _hasNotifiedLowBattery = false; + } else { + _hasNotifiedChargeLimit = false; + } + } onIsPluggedInChanged: { if (suppressSound || !batteryAvailable) {