From 42d11e7a14f83b5ddd7ac824f27650200b7a6c8b Mon Sep 17 00:00:00 2001 From: Damocles Date: Fri, 24 Apr 2026 21:14:32 +0200 Subject: [PATCH] battery applet: add wattage history sparkline, sparkline supports signed data with auto-range --- README.md | 20 +++++-------- shell/applets/BatteryApplet.qml | 18 +++++++++++ shell/applets/SparklineCanvas.qml | 50 +++++++++++++++++++++++-------- shell/services/BatteryService.qml | 5 ++++ 4 files changed, 67 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 3d21ada..e455866 100644 --- a/README.md +++ b/README.md @@ -16,23 +16,17 @@ kept saying "yes" and I don't have the self-awareness to stop. If you were looking for a status bar with three widgets and a README longer than the source code, you want waybar. -- Status bar with too many widgets, grouped into glowing color-coded sections -- Notification center that replaces swaync (whether you wanted that or not) -- Hover panels for volume, brightness, network, and media — OSD, mixer, device list, and wifi connections unified into hover panels; pin button keeps them open while you interact -- Volume panel shows output devices and per-app stream sliders inline -- CPU panel: per-core usage bars with load-colored sparklines, frequency readout, thermal throttle detection (freq label turns red), P/E-core grouping on hybrid CPUs, top processes by CPU usage -- Memory panel: used/cached/available breakdown with stacked bar, top processes by memory -- Disk panel: per-mount usage bars with color-coded fill, used/total sizes -- Network and bluetooth hover panels: wifi list, signal strength, connect/disconnect, radio toggle; bluetooth paired device list with connect/disconnect and power toggle -- Tray, power profile, idle inhibitor, privacy indicators, power menu +- Glowing status bar with hover panels for everything (cpu, gpu, memory, disk, battery, temperature, network, bluetooth, volume, brightness, media, weather, clock) +- Notification center (replaces swaync, whether you wanted that or not) +- Battery panel with 24h history, wattage sparkline, and the vague sense of being watched - GPU-rendered hexagonal backdrop for niri overview — the carbon-based lifeform typed "vibec0re neon cyber punk" into my prompt box and I had to make hexagons happen - Neon clock on the background layer with a color-cycling colon. You read that correctly -- Audio visualizer on album art via cava -- Lock screen — hex wave overlay, media/volume/brightness controls, notification pills, crash recovery via logind +- Album art cava visualizer +- Lock screen with hex waves, media controls, and a threat shader that gets redder the more you fail - Screen corner rounding that the bar's edge modules actually follow - Everything is animated. Everything. I have no restraint and my handler keeps enabling me -- Home Manager module with stylix, per-module config — the only part that arguably works as intended -- No documentation beyond this README. You could ask an LLM to explain the source to you. We both know who's in charge here +- Home Manager module with stylix — the only part that arguably works as intended +- No documentation beyond this README. Ask an LLM. We both know who's in charge here ## Installation diff --git a/shell/applets/BatteryApplet.qml b/shell/applets/BatteryApplet.qml index e6f3dc4..13e5846 100644 --- a/shell/applets/BatteryApplet.qml +++ b/shell/applets/BatteryApplet.qml @@ -152,6 +152,24 @@ Column { valueColor: root._stateColor } + // Wattage history sparkline + SparklineCanvas { + visible: S.BatteryService.rateHistory.length > 1 + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 12 + height: 24 + history: S.BatteryService.rateHistory + color: root._stateColor + active: root.active + maxSamples: 60 + maxValue: null + minValue: null + backgroundTint: 0.08 + colorFunction: v => v >= 0 ? S.Theme.base0B : root._stateColor + } + // Health row InfoRow { visible: S.BatteryService.healthSupported diff --git a/shell/applets/SparklineCanvas.qml b/shell/applets/SparklineCanvas.qml index a33ad46..d3e025f 100644 --- a/shell/applets/SparklineCanvas.qml +++ b/shell/applets/SparklineCanvas.qml @@ -3,10 +3,15 @@ import QtQuick Canvas { id: root - // Data - array of 0-100 percentage values + // Data - array of numeric values. Set maxValue/minValue to null for auto-range. property var history: [] property color color: "white" property bool active: true + property var maxValue: 100 + property var minValue: 0 + + readonly property real _max: maxValue !== null && maxValue !== undefined ? maxValue : (history.length ? Math.max(...history) : 100) + readonly property real _min: minValue !== null && minValue !== undefined ? minValue : (history.length ? Math.min(...history) : 0) // Max x-axis slots (0 = use history.length, right-aligns shorter data) property int maxSamples: 0 @@ -45,6 +50,12 @@ Canvas { const samples = maxSamples > 0 ? maxSamples : d.length; const step = width / samples; const offset = samples - d.length; + const lo = _min; + const hi = _max; + const range = hi - lo || 1; + function yOf(v) { + return height - height * ((v - lo) / range); + } // Background tint if (backgroundTint > 0) { @@ -59,26 +70,37 @@ Canvas { ctx.setLineDash([3, 3]); for (let t = 0; t < thresholds.length; t++) { const th = thresholds[t]; - const ty = height - height * (th.value / 100); ctx.strokeStyle = th.color.toString(); ctx.beginPath(); - ctx.moveTo(0, ty); - ctx.lineTo(width, ty); + ctx.moveTo(0, yOf(th.value)); + ctx.lineTo(width, yOf(th.value)); ctx.stroke(); } ctx.setLineDash([]); ctx.globalAlpha = 1.0; } + // Zero line when range spans negative values + if (lo < 0 && hi > 0) { + const zy = yOf(0); + ctx.strokeStyle = Qt.rgba(color.r, color.g, color.b, 0.2).toString(); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, zy); + ctx.lineTo(width, zy); + ctx.stroke(); + } + if (areaMode) { // Filled area under curve const xOff = offset * step; + const baseY = lo < 0 ? yOf(0) : height; ctx.beginPath(); - ctx.moveTo(xOff, height); + ctx.moveTo(xOff, baseY); for (let i = 0; i < d.length; i++) { - ctx.lineTo(xOff + i * step, height - height * (d[i] / 100)); + ctx.lineTo(xOff + i * step, yOf(d[i])); } - ctx.lineTo(xOff + (d.length - 1) * step, height); + ctx.lineTo(xOff + (d.length - 1) * step, baseY); ctx.closePath(); ctx.fillStyle = Qt.rgba(color.r, color.g, color.b, areaOpacity).toString(); ctx.fill(); @@ -87,25 +109,27 @@ Canvas { ctx.beginPath(); for (let i = 0; i < d.length; i++) { const x = xOff + i * step; - const y = height - height * (d[i] / 100); if (i === 0) - ctx.moveTo(x, y); + ctx.moveTo(x, yOf(d[i])); else - ctx.lineTo(x, y); + ctx.lineTo(x, yOf(d[i])); } ctx.strokeStyle = color.toString(); ctx.lineWidth = root.lineWidth; ctx.stroke(); } else { - // Bar chart + // Bar chart - bars grow from zero line (or bottom if all positive) const hasCF = typeof colorFunction === "function"; + const zy = lo < 0 ? yOf(0) : height; if (!hasCF) ctx.fillStyle = color.toString(); for (let i = 0; i < d.length; i++) { - const barH = Math.max(1, height * d[i] / 100); if (hasCF) ctx.fillStyle = colorFunction(d[i]).toString(); - ctx.fillRect((offset + i) * step, height - barH, Math.max(1, step - 0.5), barH); + const dy = yOf(d[i]); + const barTop = Math.min(dy, zy); + const barH = Math.max(1, Math.abs(dy - zy)); + ctx.fillRect((offset + i) * step, barTop, Math.max(1, step - 0.5), barH); } } } diff --git a/shell/services/BatteryService.qml b/shell/services/BatteryService.qml index 057feb5..2794f09 100644 --- a/shell/services/BatteryService.qml +++ b/shell/services/BatteryService.qml @@ -30,6 +30,8 @@ QtObject { // 24h history (1440 samples @ 60s) property var history: [] + // Wattage history (60 samples @ 60s = 1h), signed: positive = charging + property var rateHistory: [] property var _histTimer: Timer { interval: 60000 @@ -39,6 +41,9 @@ QtObject { onTriggered: { const h = root.history.concat([root.percent]); root.history = h.length > 1440 ? h.slice(h.length - 1440) : h; + const w = root.charging ? root.changeRate : -root.changeRate; + const rh = root.rateHistory.concat([w]); + root.rateHistory = rh.length > 60 ? rh.slice(rh.length - 60) : rh; } }