battery applet: add wattage history sparkline, sparkline supports signed data with auto-range

This commit is contained in:
Damocles 2026-04-24 21:14:32 +02:00
parent b5be146619
commit 42d11e7a14
4 changed files with 67 additions and 26 deletions

View file

@ -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 looking for a status bar with three widgets and a README longer than the
source code, you want waybar. source code, you want waybar.
- Status bar with too many widgets, grouped into glowing color-coded sections - Glowing status bar with hover panels for everything (cpu, gpu, memory, disk, battery, temperature, network, bluetooth, volume, brightness, media, weather, clock)
- Notification center that replaces swaync (whether you wanted that or not) - Notification center (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 - Battery panel with 24h history, wattage sparkline, and the vague sense of being watched
- 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
- 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 - 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 - Neon clock on the background layer with a color-cycling colon. You read that correctly
- Audio visualizer on album art via cava - Album art cava visualizer
- Lock screen — hex wave overlay, media/volume/brightness controls, notification pills, crash recovery via logind - 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 - 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 - 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 - Home Manager module with stylix — 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 - No documentation beyond this README. Ask an LLM. We both know who's in charge here
## Installation ## Installation

View file

@ -152,6 +152,24 @@ Column {
valueColor: root._stateColor 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 // Health row
InfoRow { InfoRow {
visible: S.BatteryService.healthSupported visible: S.BatteryService.healthSupported

View file

@ -3,10 +3,15 @@ import QtQuick
Canvas { Canvas {
id: root 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 var history: []
property color color: "white" property color color: "white"
property bool active: true 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) // Max x-axis slots (0 = use history.length, right-aligns shorter data)
property int maxSamples: 0 property int maxSamples: 0
@ -45,6 +50,12 @@ Canvas {
const samples = maxSamples > 0 ? maxSamples : d.length; const samples = maxSamples > 0 ? maxSamples : d.length;
const step = width / samples; const step = width / samples;
const offset = samples - d.length; 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 // Background tint
if (backgroundTint > 0) { if (backgroundTint > 0) {
@ -59,26 +70,37 @@ Canvas {
ctx.setLineDash([3, 3]); ctx.setLineDash([3, 3]);
for (let t = 0; t < thresholds.length; t++) { for (let t = 0; t < thresholds.length; t++) {
const th = thresholds[t]; const th = thresholds[t];
const ty = height - height * (th.value / 100);
ctx.strokeStyle = th.color.toString(); ctx.strokeStyle = th.color.toString();
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, ty); ctx.moveTo(0, yOf(th.value));
ctx.lineTo(width, ty); ctx.lineTo(width, yOf(th.value));
ctx.stroke(); ctx.stroke();
} }
ctx.setLineDash([]); ctx.setLineDash([]);
ctx.globalAlpha = 1.0; 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) { if (areaMode) {
// Filled area under curve // Filled area under curve
const xOff = offset * step; const xOff = offset * step;
const baseY = lo < 0 ? yOf(0) : height;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(xOff, height); ctx.moveTo(xOff, baseY);
for (let i = 0; i < d.length; i++) { 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.closePath();
ctx.fillStyle = Qt.rgba(color.r, color.g, color.b, areaOpacity).toString(); ctx.fillStyle = Qt.rgba(color.r, color.g, color.b, areaOpacity).toString();
ctx.fill(); ctx.fill();
@ -87,25 +109,27 @@ Canvas {
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i < d.length; i++) { for (let i = 0; i < d.length; i++) {
const x = xOff + i * step; const x = xOff + i * step;
const y = height - height * (d[i] / 100);
if (i === 0) if (i === 0)
ctx.moveTo(x, y); ctx.moveTo(x, yOf(d[i]));
else else
ctx.lineTo(x, y); ctx.lineTo(x, yOf(d[i]));
} }
ctx.strokeStyle = color.toString(); ctx.strokeStyle = color.toString();
ctx.lineWidth = root.lineWidth; ctx.lineWidth = root.lineWidth;
ctx.stroke(); ctx.stroke();
} else { } else {
// Bar chart // Bar chart - bars grow from zero line (or bottom if all positive)
const hasCF = typeof colorFunction === "function"; const hasCF = typeof colorFunction === "function";
const zy = lo < 0 ? yOf(0) : height;
if (!hasCF) if (!hasCF)
ctx.fillStyle = color.toString(); ctx.fillStyle = color.toString();
for (let i = 0; i < d.length; i++) { for (let i = 0; i < d.length; i++) {
const barH = Math.max(1, height * d[i] / 100);
if (hasCF) if (hasCF)
ctx.fillStyle = colorFunction(d[i]).toString(); 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);
} }
} }
} }

View file

@ -30,6 +30,8 @@ QtObject {
// 24h history (1440 samples @ 60s) // 24h history (1440 samples @ 60s)
property var history: [] property var history: []
// Wattage history (60 samples @ 60s = 1h), signed: positive = charging
property var rateHistory: []
property var _histTimer: Timer { property var _histTimer: Timer {
interval: 60000 interval: 60000
@ -39,6 +41,9 @@ QtObject {
onTriggered: { onTriggered: {
const h = root.history.concat([root.percent]); const h = root.history.concat([root.percent]);
root.history = h.length > 1440 ? h.slice(h.length - 1440) : h; 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;
} }
} }