add BatteryService, refactor BatteryModule to use it

This commit is contained in:
Damocles 2026-04-22 21:13:29 +02:00
parent d814ee041f
commit 57d42d7ac3
3 changed files with 111 additions and 90 deletions

View file

@ -1,28 +1,19 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import "." as M import "." as M
import "../services" as S import "../services" as S
M.BarSection { M.BarSection {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
opacity: S.Modules.battery.enable && (UPower.displayDevice?.isLaptopBattery ?? false) ? 1 : 0 opacity: S.Modules.battery.enable && S.BatteryService.available ? 1 : 0
visible: opacity > 0 visible: opacity > 0
tooltip: "" tooltip: ""
readonly property var dev: UPower.displayDevice
readonly property real pct: (dev?.percentage ?? 0) * 100
readonly property bool charging: dev?.state === UPowerDeviceState.Charging
readonly property int _critThresh: S.Modules.battery.critical || 15
readonly property int _warnThresh: S.Modules.battery.warning || 25
readonly property bool _critical: pct < _critThresh && !charging
property color _stateColor: charging ? S.Theme.base0B : _critical ? S.Theme.base09 : pct < _warnThresh ? S.Theme.base0A : root.accentColor
property real _blinkOpacity: 1 property real _blinkOpacity: 1
SequentialAnimation { SequentialAnimation {
running: root._critical running: S.BatteryService.critical
loops: Animation.Infinite loops: Animation.Infinite
NumberAnimation { NumberAnimation {
target: root target: root
@ -42,48 +33,7 @@ M.BarSection {
root._blinkOpacity = 1 root._blinkOpacity = 1
} }
// Notifications // Panel state
property bool _warnSent: false
property bool _critSent: false
onChargingChanged: {
_warnSent = false;
_critSent = false;
}
onPctChanged: {
if (charging)
return;
if (pct < _critThresh && !_critSent) {
_critSent = true;
_warnSent = true;
_notif.command = ["notify-send", "--urgency=critical", "--icon=battery-low", "--category=device", "Very Low Battery", "Connect to power now!"];
_notif.running = true;
} else if (pct < _warnThresh && !_warnSent) {
_warnSent = true;
_notif.command = ["notify-send", "--icon=battery-caution", "--category=device", "Low Battery"];
_notif.running = true;
}
}
Process {
id: _notif
}
// History (always-running, 1440 samples @ 60s = 24h)
property var _history: []
Timer {
interval: 60000
running: root.visible
repeat: true
triggeredOnStart: true
onTriggered: {
const h = root._history.concat([root.pct]);
root._history = h.length > 1440 ? h.slice(h.length - 1440) : h;
}
}
// Panel state
property bool _pinned: false property bool _pinned: false
readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered
readonly property bool _showPanel: _anyHover || _pinned readonly property bool _showPanel: _anyHover || _pinned
@ -101,23 +51,15 @@ M.BarSection {
onTriggered: root._pinned = false onTriggered: root._pinned = false
} }
function _fmtTime(secs) { // Bar widgets
if (!secs || secs <= 0)
return "";
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return h > 0 ? h + "h " + m + "m" : m + "m";
}
// Bar widgets
M.BarIcon { M.BarIcon {
icon: { icon: {
if (root.charging) if (S.BatteryService.charging)
return "\uDB80\uDC84"; return "\uDB80\uDC84";
const icons = ["\uDB80\uDC8E", "\uDB80\uDC7A", "\uDB80\uDC7B", "\uDB80\uDC7C", "\uDB80\uDC7D", "\uDB80\uDC7E", "\uDB80\uDC7F", "\uDB80\uDC80", "\uDB80\uDC81", "\uDB80\uDC82", "\uDB85\uDFE2"]; const icons = ["\uDB80\uDC8E", "\uDB80\uDC7A", "\uDB80\uDC7B", "\uDB80\uDC7C", "\uDB80\uDC7D", "\uDB80\uDC7E", "\uDB80\uDC7F", "\uDB80\uDC80", "\uDB80\uDC81", "\uDB80\uDC82", "\uDB85\uDFE2"];
return icons[Math.min(10, Math.floor(root.pct / 10))]; return icons[Math.min(10, Math.floor(S.BatteryService.pct / 10))];
} }
color: root._stateColor color: S.BatteryService.stateColor
opacity: root._blinkOpacity opacity: root._blinkOpacity
font.pixelSize: S.Theme.fontSize + 2 font.pixelSize: S.Theme.fontSize + 2
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@ -126,9 +68,9 @@ M.BarSection {
} }
} }
M.BarLabel { M.BarLabel {
label: Math.round(root.pct) + "%" label: Math.round(S.BatteryService.pct) + "%"
minText: "100%" minText: "100%"
color: root._stateColor color: S.BatteryService.stateColor
opacity: root._blinkOpacity opacity: root._blinkOpacity
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler { TapHandler {
@ -136,7 +78,7 @@ M.BarSection {
} }
} }
// Hover panel // Hover panel
M.HoverPanel { M.HoverPanel {
id: hoverPanel id: hoverPanel
showPanel: root._showPanel showPanel: root._showPanel
@ -147,7 +89,7 @@ M.BarSection {
panelTitle: "Battery" panelTitle: "Battery"
contentWidth: 240 contentWidth: 240
// Header pct + time // Header - pct + time
Item { Item {
width: parent.width width: parent.width
height: 28 height: 28
@ -157,11 +99,11 @@ M.BarSection {
anchors.rightMargin: 12 anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: { text: {
const t = root.charging ? root.dev?.timeToFull : root.dev?.timeToEmpty; const t = S.BatteryService.charging ? S.BatteryService.timeToFull : S.BatteryService.timeToEmpty;
const ts = root._fmtTime(t); const ts = S.BatteryService.fmtTime(t);
return Math.round(root.pct) + "%" + (ts ? " " + ts : ""); return Math.round(S.BatteryService.pct) + "%" + (ts ? " " + ts : "");
} }
color: root._stateColor color: S.BatteryService.stateColor
font.pixelSize: S.Theme.fontSize font.pixelSize: S.Theme.fontSize
font.family: S.Theme.fontFamily font.family: S.Theme.fontFamily
font.bold: true font.bold: true
@ -188,9 +130,9 @@ M.BarSection {
} }
Rectangle { Rectangle {
width: parent.width * Math.min(1, root.pct / 100) width: parent.width * Math.min(1, S.BatteryService.pct / 100)
height: parent.height height: parent.height
color: root._stateColor color: S.BatteryService.stateColor
radius: 3 radius: 3
Behavior on width { Behavior on width {
enabled: root._showPanel enabled: root._showPanel
@ -203,7 +145,7 @@ M.BarSection {
// Warning threshold marker // Warning threshold marker
Rectangle { Rectangle {
x: parent.width * (root._warnThresh / 100) - 1 x: parent.width * (S.BatteryService.warnThresh / 100) - 1
width: 1 width: 1
height: parent.height + 4 height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@ -213,7 +155,7 @@ M.BarSection {
// Critical threshold marker // Critical threshold marker
Rectangle { Rectangle {
x: parent.width * (root._critThresh / 100) - 1 x: parent.width * (S.BatteryService.critThresh / 100) - 1
width: 1 width: 1
height: parent.height + 4 height: parent.height + 4
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@ -232,8 +174,8 @@ M.BarSection {
anchors.rightMargin: 12 anchors.rightMargin: 12
height: 44 height: 44
property var _hist: root._history property var _hist: S.BatteryService.history
property color _col: root._stateColor property color _col: S.BatteryService.stateColor
on_HistChanged: if (root._showPanel) on_HistChanged: if (root._showPanel)
requestPaint() requestPaint()
@ -266,7 +208,7 @@ M.BarSection {
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
// Warning threshold line // Warning threshold line
const warnY = height - height * (root._warnThresh / 100); const warnY = height - height * (S.BatteryService.warnThresh / 100);
ctx.strokeStyle = S.Theme.base0A.toString(); ctx.strokeStyle = S.Theme.base0A.toString();
ctx.globalAlpha = 0.3; ctx.globalAlpha = 0.3;
ctx.lineWidth = 1; ctx.lineWidth = 1;
@ -277,7 +219,7 @@ M.BarSection {
ctx.stroke(); ctx.stroke();
// Critical threshold line // Critical threshold line
const critY = height - height * (root._critThresh / 100); const critY = height - height * (S.BatteryService.critThresh / 100);
ctx.strokeStyle = S.Theme.base08.toString(); ctx.strokeStyle = S.Theme.base08.toString();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, critY); ctx.moveTo(0, critY);
@ -325,7 +267,7 @@ M.BarSection {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 12 anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: "warn " + root._warnThresh + "% crit " + root._critThresh + "%" text: "warn " + S.BatteryService.warnThresh + "% crit " + S.BatteryService.critThresh + "%"
color: S.Theme.base03 color: S.Theme.base03
font.pixelSize: S.Theme.fontSize - 3 font.pixelSize: S.Theme.fontSize - 3
font.family: S.Theme.fontFamily font.family: S.Theme.fontFamily
@ -351,17 +293,17 @@ M.BarSection {
color: S.Theme.base03 color: S.Theme.base03
} }
// Rate + health rows // Rate row
Item { Item {
width: parent.width width: parent.width
height: 20 height: 20
visible: (root.dev?.changeRate ?? 0) !== 0 visible: S.BatteryService.changeRate !== 0
Text { Text {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 12 anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: root.charging ? "Charging" : "Discharging" text: S.BatteryService.charging ? "Charging" : "Discharging"
color: S.Theme.base04 color: S.Theme.base04
font.pixelSize: S.Theme.fontSize - 2 font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily font.family: S.Theme.fontFamily
@ -372,19 +314,20 @@ M.BarSection {
anchors.rightMargin: 12 anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: { text: {
const r = Math.abs(root.dev?.changeRate ?? 0); const r = Math.abs(S.BatteryService.changeRate);
return r > 0 ? r.toFixed(1) + " W" : ""; return r > 0 ? r.toFixed(1) + " W" : "";
} }
color: root._stateColor color: S.BatteryService.stateColor
font.pixelSize: S.Theme.fontSize - 2 font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily font.family: S.Theme.fontFamily
} }
} }
// Health row
Item { Item {
width: parent.width width: parent.width
height: 20 height: 20
visible: root.dev?.healthSupported ?? false visible: S.BatteryService.healthSupported
Text { Text {
anchors.left: parent.left anchors.left: parent.left
@ -400,9 +343,9 @@ M.BarSection {
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 12 anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: Math.round((root.dev?.healthPercentage ?? 0) * 100) + "%" text: Math.round(S.BatteryService.healthPct) + "%"
color: { color: {
const h = (root.dev?.healthPercentage ?? 1) * 100; const h = S.BatteryService.healthPct;
return h < 50 ? S.Theme.base08 : h < 75 ? S.Theme.base0A : S.Theme.base0B; return h < 50 ? S.Theme.base08 : h < 75 ? S.Theme.base0A : S.Theme.base0B;
} }
font.pixelSize: S.Theme.fontSize - 2 font.pixelSize: S.Theme.fontSize - 2

View file

@ -0,0 +1,77 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import "." as S
QtObject {
id: root
readonly property var dev: UPower.displayDevice
readonly property bool available: dev?.isLaptopBattery ?? false
readonly property real pct: (dev?.percentage ?? 0) * 100
readonly property bool charging: dev?.state === UPowerDeviceState.Charging
readonly property real changeRate: dev?.changeRate ?? 0
readonly property int timeToFull: dev?.timeToFull ?? 0
readonly property int timeToEmpty: dev?.timeToEmpty ?? 0
readonly property bool healthSupported: dev?.healthSupported ?? false
readonly property real healthPct: (dev?.healthPercentage ?? 1) * 100
readonly property int critThresh: S.Modules.battery.critical || 15
readonly property int warnThresh: S.Modules.battery.warning || 25
readonly property bool critical: pct < critThresh && !charging
readonly property color stateColor: charging ? S.Theme.base0B : critical ? S.Theme.base09 : pct < warnThresh ? S.Theme.base0A : S.Theme.base05
// 24h history (1440 samples @ 60s)
property var history: []
property var _histTimer: Timer {
interval: 60000
running: root.available
repeat: true
triggeredOnStart: true
onTriggered: {
const h = root.history.concat([root.pct]);
root.history = h.length > 1440 ? h.slice(h.length - 1440) : h;
}
}
// Low battery notifications
property bool _warnSent: false
property bool _critSent: false
property var _chargingWatcher: Connections {
target: root
function onChargingChanged() {
root._warnSent = false;
root._critSent = false;
}
function onPctChanged() {
if (root.charging)
return;
if (root.pct < root.critThresh && !root._critSent) {
root._critSent = true;
root._warnSent = true;
_notifProc.command = ["notify-send", "--urgency=critical", "--icon=battery-low", "--category=device", "Very Low Battery", "Connect to power now!"];
_notifProc.running = true;
} else if (root.pct < root.warnThresh && !root._warnSent) {
root._warnSent = true;
_notifProc.command = ["notify-send", "--icon=battery-caution", "--category=device", "Low Battery"];
_notifProc.running = true;
}
}
}
property var _notifProc: Process {}
function fmtTime(secs) {
if (!secs || secs <= 0)
return "";
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
return h > 0 ? h + "h " + m + "m" : m + "m";
}
}

View file

@ -2,6 +2,7 @@ module services
# keep-sorted start # keep-sorted start
NotifItem 1.0 NotifItem.qml NotifItem 1.0 NotifItem.qml
singleton BacklightService 1.0 BacklightService.qml singleton BacklightService 1.0 BacklightService.qml
singleton BatteryService 1.0 BatteryService.qml
singleton BluetoothService 1.0 BluetoothService.qml singleton BluetoothService 1.0 BluetoothService.qml
singleton IdleInhibitService 1.0 IdleInhibitService.qml singleton IdleInhibitService 1.0 IdleInhibitService.qml
singleton LockService 1.0 LockService.qml singleton LockService 1.0 LockService.qml