import QtQuick import Quickshell.Io import Quickshell.Services.UPower import "." as M M.BarSection { id: root spacing: M.Theme.moduleSpacing opacity: M.Modules.battery.enable && (UPower.displayDevice?.isLaptopBattery ?? false) ? 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: M.Modules.battery.critical || 15 readonly property int _warnThresh: M.Modules.battery.warning || 25 readonly property bool _critical: pct < _critThresh && !charging property color _stateColor: charging ? M.Theme.base0B : _critical ? M.Theme.base09 : pct < _warnThresh ? M.Theme.base0A : root.accentColor property real _blinkOpacity: 1 SequentialAnimation { running: root._critical loops: Animation.Infinite NumberAnimation { target: root property: "_blinkOpacity" to: 0.45 duration: 400 easing.type: Easing.InOutQuad } NumberAnimation { target: root property: "_blinkOpacity" to: 1 duration: 400 easing.type: Easing.InOutQuad } onRunningChanged: if (!running) 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 ────────────────────────────────────────────────────── property bool _pinned: false readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered readonly property bool _showPanel: _anyHover || _pinned on_AnyHoverChanged: { if (_anyHover) _unpinTimer.stop(); else if (_pinned) _unpinTimer.start(); } Timer { id: _unpinTimer interval: 500 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 ────────────────────────────────────────────────────── M.BarIcon { icon: { if (root.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))]; } color: root._stateColor opacity: root._blinkOpacity font.pixelSize: M.Theme.fontSize + 2 anchors.verticalCenter: parent.verticalCenter TapHandler { cursorShape: Qt.PointingHandCursor onTapped: root._pinned = !root._pinned } } M.BarLabel { label: Math.round(root.pct) + "%" minText: "100%" color: root._stateColor opacity: root._blinkOpacity anchors.verticalCenter: parent.verticalCenter TapHandler { cursorShape: Qt.PointingHandCursor onTapped: root._pinned = !root._pinned } } // ── Hover panel ────────────────────────────────────────────────────── M.HoverPanel { id: hoverPanel showPanel: root._showPanel screen: QsWindow.window?.screen ?? null anchorItem: root accentColor: root.accentColor panelNamespace: "nova-battery" panelTitle: "Battery" contentWidth: 240 // Header — pct + time Item { width: parent.width height: 28 Text { anchors.right: parent.right 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 : ""); } color: root._stateColor font.pixelSize: M.Theme.fontSize font.family: M.Theme.fontFamily font.bold: true } } // Progress bar Item { width: parent.width height: 14 Item { anchors.left: parent.left anchors.leftMargin: 12 anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter height: 6 Rectangle { anchors.fill: parent color: M.Theme.base02 radius: 3 } Rectangle { width: parent.width * Math.min(1, root.pct / 100) height: parent.height color: root._stateColor radius: 3 Behavior on width { enabled: root._showPanel NumberAnimation { duration: 300 easing.type: Easing.OutCubic } } } // Warning threshold marker Rectangle { x: parent.width * (root._warnThresh / 100) - 1 width: 1 height: parent.height + 4 anchors.verticalCenter: parent.verticalCenter color: M.Theme.base0A opacity: 0.6 } // Critical threshold marker Rectangle { x: parent.width * (root._critThresh / 100) - 1 width: 1 height: parent.height + 4 anchors.verticalCenter: parent.verticalCenter color: M.Theme.base08 opacity: 0.6 } } } // 24h history sparkline (area chart) Canvas { id: _sparkline anchors.left: parent.left anchors.leftMargin: 12 anchors.right: parent.right anchors.rightMargin: 12 height: 44 property var _hist: root._history property color _col: root._stateColor on_HistChanged: if (root._showPanel) requestPaint() on_ColChanged: if (root._showPanel) requestPaint() Connections { target: root function on_ShowPanelChanged() { if (root._showPanel) _sparkline.requestPaint(); } } onPaint: { const ctx = getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, width, height); const d = _hist; if (d.length < 2) return; const maxSamples = 1440; const xScale = width / maxSamples; const xOffset = (maxSamples - d.length) * xScale; // Background tint ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.07).toString(); ctx.fillRect(0, 0, width, height); // Warning threshold line const warnY = height - height * (root._warnThresh / 100); ctx.strokeStyle = M.Theme.base0A.toString(); ctx.globalAlpha = 0.3; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(0, warnY); ctx.lineTo(width, warnY); ctx.stroke(); // Critical threshold line const critY = height - height * (root._critThresh / 100); ctx.strokeStyle = M.Theme.base08.toString(); ctx.beginPath(); ctx.moveTo(0, critY); ctx.lineTo(width, critY); ctx.stroke(); ctx.setLineDash([]); ctx.globalAlpha = 1.0; // Filled area under the curve ctx.beginPath(); ctx.moveTo(xOffset, height); for (let i = 0; i < d.length; i++) { const x = xOffset + i * xScale; const y = height - height * (d[i] / 100); ctx.lineTo(x, y); } ctx.lineTo(xOffset + (d.length - 1) * xScale, height); ctx.closePath(); ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.18).toString(); ctx.fill(); // Top line ctx.beginPath(); for (let i = 0; i < d.length; i++) { const x = xOffset + i * xScale; const y = height - height * (d[i] / 100); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = _col.toString(); ctx.lineWidth = 1.5; ctx.stroke(); } } // Footer: thresholds + time label Item { width: parent.width height: 16 Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "warn " + root._warnThresh + "% crit " + root._critThresh + "%" color: M.Theme.base03 font.pixelSize: M.Theme.fontSize - 3 font.family: M.Theme.fontFamily font.letterSpacing: 0.5 } Text { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "24h" color: M.Theme.base03 font.pixelSize: M.Theme.fontSize - 3 font.family: M.Theme.fontFamily } } // Separator Rectangle { width: parent.width - 16 height: 1 anchors.horizontalCenter: parent.horizontalCenter color: M.Theme.base03 } // Rate + health rows Item { width: parent.width height: 20 visible: (root.dev?.changeRate ?? 0) !== 0 Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: root.charging ? "Charging" : "Discharging" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily } Text { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: { const r = Math.abs(root.dev?.changeRate ?? 0); return r > 0 ? r.toFixed(1) + " W" : ""; } color: root._stateColor font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily } } Item { width: parent.width height: 20 visible: root.dev?.healthSupported ?? false Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "Health" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily } Text { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: Math.round((root.dev?.healthPercentage ?? 0) * 100) + "%" color: { const h = (root.dev?.healthPercentage ?? 1) * 100; return h < 50 ? M.Theme.base08 : h < 75 ? M.Theme.base0A : M.Theme.base0B; } font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily } } Item { width: 1 height: 4 } } }