diff --git a/shell/modules/BatteryModule.qml b/shell/modules/BatteryModule.qml index a1c703a..f5b18b0 100644 --- a/shell/modules/BatteryModule.qml +++ b/shell/modules/BatteryModule.qml @@ -1,28 +1,19 @@ import QtQuick import Quickshell -import Quickshell.Io -import Quickshell.Services.UPower import "." as M import "../services" as S M.BarSection { id: root 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 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 SequentialAnimation { - running: root._critical + running: S.BatteryService.critical loops: Animation.Infinite NumberAnimation { target: root @@ -42,48 +33,7 @@ M.BarSection { root._blinkOpacity = 1 } - // ── Notifications ──────────────────────────────────────────────────── - 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 ────────────────────────────────────────────────────── + // Panel state property bool _pinned: false readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered readonly property bool _showPanel: _anyHover || _pinned @@ -101,23 +51,15 @@ M.BarSection { onTriggered: root._pinned = false } - 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"; - } - - // ── Bar widgets ────────────────────────────────────────────────────── + // Bar widgets M.BarIcon { icon: { - if (root.charging) + if (S.BatteryService.charging) 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"]; - 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 font.pixelSize: S.Theme.fontSize + 2 anchors.verticalCenter: parent.verticalCenter @@ -126,9 +68,9 @@ M.BarSection { } } M.BarLabel { - label: Math.round(root.pct) + "%" + label: Math.round(S.BatteryService.pct) + "%" minText: "100%" - color: root._stateColor + color: S.BatteryService.stateColor opacity: root._blinkOpacity anchors.verticalCenter: parent.verticalCenter TapHandler { @@ -136,7 +78,7 @@ M.BarSection { } } - // ── Hover panel ────────────────────────────────────────────────────── + // Hover panel M.HoverPanel { id: hoverPanel showPanel: root._showPanel @@ -147,7 +89,7 @@ M.BarSection { panelTitle: "Battery" contentWidth: 240 - // Header — pct + time + // Header - pct + time Item { width: parent.width height: 28 @@ -157,11 +99,11 @@ M.BarSection { anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: { - const t = root.charging ? root.dev?.timeToFull : root.dev?.timeToEmpty; - const ts = root._fmtTime(t); - return Math.round(root.pct) + "%" + (ts ? " " + ts : ""); + const t = S.BatteryService.charging ? S.BatteryService.timeToFull : S.BatteryService.timeToEmpty; + const ts = S.BatteryService.fmtTime(t); + return Math.round(S.BatteryService.pct) + "%" + (ts ? " " + ts : ""); } - color: root._stateColor + color: S.BatteryService.stateColor font.pixelSize: S.Theme.fontSize font.family: S.Theme.fontFamily font.bold: true @@ -188,9 +130,9 @@ M.BarSection { } Rectangle { - width: parent.width * Math.min(1, root.pct / 100) + width: parent.width * Math.min(1, S.BatteryService.pct / 100) height: parent.height - color: root._stateColor + color: S.BatteryService.stateColor radius: 3 Behavior on width { enabled: root._showPanel @@ -203,7 +145,7 @@ M.BarSection { // Warning threshold marker Rectangle { - x: parent.width * (root._warnThresh / 100) - 1 + x: parent.width * (S.BatteryService.warnThresh / 100) - 1 width: 1 height: parent.height + 4 anchors.verticalCenter: parent.verticalCenter @@ -213,7 +155,7 @@ M.BarSection { // Critical threshold marker Rectangle { - x: parent.width * (root._critThresh / 100) - 1 + x: parent.width * (S.BatteryService.critThresh / 100) - 1 width: 1 height: parent.height + 4 anchors.verticalCenter: parent.verticalCenter @@ -232,8 +174,8 @@ M.BarSection { anchors.rightMargin: 12 height: 44 - property var _hist: root._history - property color _col: root._stateColor + property var _hist: S.BatteryService.history + property color _col: S.BatteryService.stateColor on_HistChanged: if (root._showPanel) requestPaint() @@ -266,7 +208,7 @@ M.BarSection { ctx.fillRect(0, 0, width, height); // 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.globalAlpha = 0.3; ctx.lineWidth = 1; @@ -277,7 +219,7 @@ M.BarSection { ctx.stroke(); // 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.beginPath(); ctx.moveTo(0, critY); @@ -325,7 +267,7 @@ M.BarSection { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: "warn " + root._warnThresh + "% crit " + root._critThresh + "%" + text: "warn " + S.BatteryService.warnThresh + "% crit " + S.BatteryService.critThresh + "%" color: S.Theme.base03 font.pixelSize: S.Theme.fontSize - 3 font.family: S.Theme.fontFamily @@ -351,17 +293,17 @@ M.BarSection { color: S.Theme.base03 } - // Rate + health rows + // Rate row Item { width: parent.width height: 20 - visible: (root.dev?.changeRate ?? 0) !== 0 + visible: S.BatteryService.changeRate !== 0 Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: root.charging ? "Charging" : "Discharging" + text: S.BatteryService.charging ? "Charging" : "Discharging" color: S.Theme.base04 font.pixelSize: S.Theme.fontSize - 2 font.family: S.Theme.fontFamily @@ -372,19 +314,20 @@ M.BarSection { anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: { - const r = Math.abs(root.dev?.changeRate ?? 0); + const r = Math.abs(S.BatteryService.changeRate); return r > 0 ? r.toFixed(1) + " W" : ""; } - color: root._stateColor + color: S.BatteryService.stateColor font.pixelSize: S.Theme.fontSize - 2 font.family: S.Theme.fontFamily } } + // Health row Item { width: parent.width height: 20 - visible: root.dev?.healthSupported ?? false + visible: S.BatteryService.healthSupported Text { anchors.left: parent.left @@ -400,9 +343,9 @@ M.BarSection { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: Math.round((root.dev?.healthPercentage ?? 0) * 100) + "%" + text: Math.round(S.BatteryService.healthPct) + "%" 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; } font.pixelSize: S.Theme.fontSize - 2 diff --git a/shell/services/BatteryService.qml b/shell/services/BatteryService.qml new file mode 100644 index 0000000..dc64642 --- /dev/null +++ b/shell/services/BatteryService.qml @@ -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"; + } +} diff --git a/shell/services/qmldir b/shell/services/qmldir index 1167a3c..339d03e 100644 --- a/shell/services/qmldir +++ b/shell/services/qmldir @@ -2,6 +2,7 @@ module services # keep-sorted start NotifItem 1.0 NotifItem.qml singleton BacklightService 1.0 BacklightService.qml +singleton BatteryService 1.0 BatteryService.qml singleton BluetoothService 1.0 BluetoothService.qml singleton IdleInhibitService 1.0 IdleInhibitService.qml singleton LockService 1.0 LockService.qml