From fc930af43c23bab224758b5e0a63ca83b7e92cdf Mon Sep 17 00:00:00 2001 From: Damocles Date: Fri, 17 Apr 2026 09:59:34 +0200 Subject: [PATCH] feat(battery): add 24h history sparkline and hover panel with rate/health --- modules/Battery.qml | 338 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 331 insertions(+), 7 deletions(-) diff --git a/modules/Battery.qml b/modules/Battery.qml index 6cfb19b..ad72374 100644 --- a/modules/Battery.qml +++ b/modules/Battery.qml @@ -8,13 +8,7 @@ M.BarSection { spacing: M.Theme.moduleSpacing opacity: M.Modules.battery.enable && (UPower.displayDevice?.isLaptopBattery ?? false) ? 1 : 0 visible: opacity > 0 - tooltip: { - const state = root.charging ? "Charging" : "Discharging"; - const t = root.charging ? root.dev?.timeToFull : root.dev?.timeToEmpty; - const mins = t ? Math.round(t / 60) : 0; - const timeStr = mins > 0 ? "\n" + Math.floor(mins / 60) + "h " + (mins % 60) + "m " + (root.charging ? "until full" : "remaining") : ""; - return state + " \u2014 " + Math.round(root.pct) + "%" + timeStr; - } + tooltip: "" readonly property var dev: UPower.displayDevice readonly property real pct: (dev?.percentage ?? 0) * 100 @@ -46,6 +40,7 @@ M.BarSection { root._blinkOpacity = 1 } + // ── Notifications ──────────────────────────────────────────────────── property bool _warnSent: false property bool _critSent: false @@ -72,6 +67,47 @@ M.BarSection { 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) @@ -83,6 +119,10 @@ M.BarSection { 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) + "%" @@ -90,5 +130,289 @@ M.BarSection { 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 + } } }