From 9e532023bd4ef302d788b16be5c1b9516b432a24 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 14:06:21 +0100 Subject: [PATCH] Fix: Corrected battery behavior --- .../modules/bar/BatteryIndicator.qml | 221 ++++++++++++++---- 1 file changed, 172 insertions(+), 49 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml index 82a966b..ca6d3e7 100644 --- a/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Hyprland +import Quickshell.Io import Quickshell.Services.UPower import "../../config" @@ -14,7 +15,7 @@ import "../../config" // Nerd Font ones. The charge-threshold ("Holding"/limited-charge) concept from // the original is hardware-specific (vendor charge-limit sysfs knobs) and has // no equivalent in Quickshell's UPower binding, so it's dropped rather than -// guessed at; "Battery health" (device.healthPercentage) is shown in its +// guessed at; "Battery health" (computed from sysfs energy_full/design) is shown in its // place, using data UPower actually reports. Item { id: root @@ -51,10 +52,100 @@ Item { readonly property real fraction: root.batteryPresent ? Math.max(0, Math.min(1, root.device.percentage)) : 0 readonly property bool low: root.batteryPresent && root.fraction <= 0.15 && !root.charging + // Quickshell's UPower binding has no charge-cycle property (see the + // file-header comment on why the old charge-until threshold was + // dropped) — it comes straight from sysfs instead. + // + // device.nativePath turned out to be empty on real hardware (tested + // live), so rather than build a path from it, a shell glob finds + // whichever BAT* directory actually exists — same shell-out-to-a-CLI + // convention TailscaleIndicator.qml uses for its own live stats. + // This barely changes during a session, so one read at startup is + // enough; no polling timer. + property int cycleCount: -1 + + Process { + id: cycleCountProc + command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/cycle_count\" 2>/dev/null"] + running: root.batteryPresent + stdout: StdioCollector { + id: cycleCountCollector + onStreamFinished: { + const n = parseInt(cycleCountCollector.text.trim(), 10) + root.cycleCount = (isNaN(n) || n <= 0) ? -1 : n + } + } + } + + // device.healthPercentage/healthSupported come from upowerd over D-Bus, + // and upowerd has been observed reporting energy-full as a flat 0 (a + // live daemon glitch, `upower -i` shows it too) while sysfs still has + // the real numbers underneath — same class of unreliability that put + // cycle_count on sysfs above instead of trusting upowerd's own + // charge-cycles field. Health is computed the same way UPower computes + // it (current full-charge capacity over the battery's original design + // capacity), just read directly so a daemon hiccup doesn't blank it. + property real energyFullWh: -1 + property real energyFullDesignWh: -1 + readonly property real healthPercent: (root.energyFullWh > 0 && root.energyFullDesignWh > 0) + ? Math.min(100, (root.energyFullWh / root.energyFullDesignWh) * 100) : -1 + + Process { + id: energyFullProc + command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full\" 2>/dev/null"] + running: root.batteryPresent + stdout: StdioCollector { + id: energyFullCollector + onStreamFinished: { + const n = parseFloat(energyFullCollector.text.trim()) + root.energyFullWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000) + } + } + } + + Process { + id: energyFullDesignProc + command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full_design\" 2>/dev/null"] + running: root.batteryPresent + stdout: StdioCollector { + id: energyFullDesignCollector + onStreamFinished: { + const n = parseFloat(energyFullDesignCollector.text.trim()) + root.energyFullDesignWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000) + } + } + } + + // Raw UPower state string (Charging/Discharging/PendingCharge/…) — more + // granular than modeLabel below, which collapses those down to the + // three phrases the hero text and STATE row show. + readonly property string rawStateLabel: root.batteryPresent + ? UPowerDeviceState.toString(root.deviceState) : "—" + + // Material Symbols' charging glyphs aren't an evenly-spaced "_bar" + // scale like the non-charging ones below — they're fixed at these + // seven breakpoints (confirmed against the installed font's ligature + // table), so this picks the highest one at or below the current + // percentage rather than rounding to an index. + function chargingIcon(fraction) { + const percent = fraction * 100 + const thresholds = [20, 30, 50, 60, 80, 90, 100] + const icons = [ + "battery_charging_20", "battery_charging_30", "battery_charging_50", "battery_charging_60", + "battery_charging_80", "battery_charging_90", "battery_charging_full" + ] + for (let i = thresholds.length - 1; i >= 0; i--) { + if (percent >= thresholds[i]) return icons[i] + } + // Below 20% there's no lower charging glyph — it's still the + // truthful floor of what's available while current is flowing in. + return icons[0] + } + function batteryIcon() { if (!root.batteryPresent) return "battery_unknown" if (root.fullyCharged) return "battery_full" - if (root.charging) return "battery_charging_full" + if (root.charging) return root.chargingIcon(root.fraction) if (root.fraction <= 0.1) return "battery_alert" const bars = ["battery_0_bar", "battery_1_bar", "battery_2_bar", "battery_3_bar", "battery_4_bar", "battery_5_bar", "battery_full"] return bars[Math.max(0, Math.min(bars.length - 1, Math.round(root.fraction * (bars.length - 1))))] @@ -74,6 +165,34 @@ Item { return root.onBattery ? "On battery" : "Charging" } + // Info grid rows — same shape as NetworkIndicator.qml's statRows/ + // statCells: a flat label/value list, flattened again into individual + // grid cells (label, value, label, value, …) so a 4-column GridLayout + // lays them out as two label/value pairs per row. + readonly property var statRows: [ + { + label: "HEALTH", + value: root.healthPercent > 0 ? Math.round(root.healthPercent) + "%" : "—" + }, + { + label: root.onBattery ? "TIME LEFT" : "TIME TO FULL", + value: root.batteryPresent + ? root.formatDuration(root.onBattery ? root.device.timeToEmpty : root.device.timeToFull) + : "—" + }, + { label: "STATUS", value: root.rawStateLabel }, + { label: "CYCLES", value: root.cycleCount > 0 ? String(root.cycleCount) : "—" } + ] + + readonly property var statCells: { + const cells = [] + for (let i = 0; i < root.statRows.length; i++) { + cells.push({ text: root.statRows[i].label, isLabel: true }) + cells.push({ text: root.statRows[i].value, isLabel: false }) + } + return cells + } + // Cute rotating status phrases while current is actually flowing — // ported as-is from omarchy's power panel, which pairs the same phrase // sets with the same charging/on-battery states. @@ -142,6 +261,34 @@ Item { return entries } + // --- Automatic power profile -------------------------------------- + // Battery-state-driven, on top of the manual picker above: below 20% + // → Power Saver (protect what's left), plugged in → Performance, + // unplugged and healthy → Balanced. Low battery wins even if a weak + // charger is still plugged in — running low is the more urgent signal. + // Performance only gets requested when the daemon actually reports it + // as available, falling back to Balanced instead of erroring on + // hardware without a performance profile (e.g. the ERROR this exact + // widget hit before power-profiles-daemon was installed). + // + // This only ever writes when the *target* changes (crossing the 20% + // line, plugging/unplugging) rather than on every poll, so a manual + // pick from the buttons above still sticks until the next real + // transition instead of being fought immediately. + readonly property int desiredProfile: { + if (root.fraction <= 0.20) return PowerProfile.PowerSaver + if (!root.onBattery) return PowerProfiles.hasPerformanceProfile ? PowerProfile.Performance : PowerProfile.Balanced + return PowerProfile.Balanced + } + + function applyDesiredProfile() { + if (!root.batteryPresent) return + if (PowerProfiles.profile !== root.desiredProfile) PowerProfiles.profile = root.desiredProfile + } + + onDesiredProfileChanged: root.applyDesiredProfile() + Component.onCompleted: Qt.callLater(root.applyDesiredProfile) + Text { id: label anchors.centerIn: parent @@ -217,33 +364,16 @@ Item { } } - component StatRow: RowLayout { - id: statRow - width: parent.width - property string label: "" - property string value: "" - Text { - Layout.fillWidth: true - text: statRow.label - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.muted - } - Text { - text: statRow.value - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.muted - } - } - // --- Panel ----------------------------------------------------------- PopupWindow { id: panel visible: false color: "transparent" - readonly property int contentWidth: 244 + // Wider than before (244) to give the 2-column info grid's + // label/value pairs enough room per half without eliding — + // same reasoning as NetworkIndicator.qml's own wider panel. + readonly property int contentWidth: 280 readonly property int contentPadding: 16 implicitWidth: contentWidth + contentPadding * 2 implicitHeight: mainColumn.implicitHeight + contentPadding * 2 @@ -350,34 +480,27 @@ Item { } } - // Stats - RowLayout { + // Stats — a 2-column, 2-row info grid, same layout + // NetworkIndicator.qml uses for its own live stats: a + // 4-column GridLayout fed a flattened label/value/label/value + // list, so each row holds two label/value pairs side by side + // with the values right-aligned against their own half. + GridLayout { width: parent.width - spacing: 16 + columns: 4 + columnSpacing: 12 + rowSpacing: 5 - Column { - Layout.fillWidth: true - spacing: 4 - StatRow { label: "STATE"; value: root.modeLabel } - StatRow { - label: "HEALTH" - value: (root.batteryPresent && root.device.healthSupported) - ? Math.round(root.device.healthPercentage) + "%" : "—" - } - } - - Column { - Layout.fillWidth: true - spacing: 4 - StatRow { - label: root.onBattery ? "TIME LEFT" : "TIME TO FULL" - value: root.batteryPresent - ? root.formatDuration(root.onBattery ? root.device.timeToEmpty : root.device.timeToFull) - : "—" - } - StatRow { - label: "RATE" - value: root.batteryPresent ? Math.abs(root.device.changeRate).toFixed(1) + " W" : "—" + Repeater { + model: root.statCells + Text { + required property var modelData + Layout.fillWidth: !modelData.isLabel + horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight + text: modelData.text + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: modelData.isLabel ? Colors.muted : Colors.foreground } } }