diff --git a/dotfiles/.config/quickshell/modules/bar/Bar.qml b/dotfiles/.config/quickshell/modules/bar/Bar.qml index d04baa7..149caa7 100644 --- a/dotfiles/.config/quickshell/modules/bar/Bar.qml +++ b/dotfiles/.config/quickshell/modules/bar/Bar.qml @@ -52,6 +52,7 @@ PanelWindow { NetworkIndicator { barWindow: bar } BluetoothIndicator { barWindow: bar } Volume { barWindow: bar } + BatteryIndicator { barWindow: bar } PowerButton { barWindow: bar } } } diff --git a/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml new file mode 100644 index 0000000..82a966b --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/BatteryIndicator.qml @@ -0,0 +1,422 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Quickshell.Services.UPower +import "../../config" + +// Battery + power-profile panel, ported from omarchy's power panel +// (github.com/basecamp/omarchy shell/plugins/panels/power/{Model.js,Panel.qml}) +// onto this project's own conventions: Quickshell's native UPower/PowerProfiles +// bindings instead of omarchy's `omarchy-battery-status` / +// `omarchy-powerprofiles-list` / `omarchy-powerprofiles-set` helper scripts +// (which don't exist outside omarchy), and Material Symbols glyphs instead of +// 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 +// place, using data UPower actually reports. +Item { + id: root + + readonly property var device: UPower.displayDevice + // isLaptopBattery (type === Battery && powerSupply === true) is the + // Quickshell-recommended check for "is this actually a battery" — more + // precise than isPresent, which the docs note is only meaningful once + // you already know the device is of Battery type. + readonly property bool batteryPresent: !!(root.device && root.device.ready && root.device.isLaptopBattery) + + // Hidden entirely on a desktop with no battery — RowLayout skips + // invisible children for both sizing and spacing, so this just vanishes + // from the bar rather than leaving a gap. + visible: root.batteryPresent + implicitWidth: root.batteryPresent ? label.implicitWidth : 0 + implicitHeight: Appearance.barHeight + + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow + + readonly property bool onBattery: UPower.onBattery + // Named deviceState, not "state" — Item already has a built-in `state` + // property (the property-based state-machine one) that this would + // otherwise collide with. + readonly property int deviceState: root.batteryPresent ? root.device.state : UPowerDeviceState.Unknown + readonly property bool fullyCharged: root.deviceState === UPowerDeviceState.FullyCharged + readonly property bool charging: root.batteryPresent && !root.onBattery && !root.fullyCharged + // device.percentage is a 0..1 fraction despite the name (same convention + // Volume.qml relies on for Pipewire's audio.volume). + 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 + + function batteryIcon() { + if (!root.batteryPresent) return "battery_unknown" + if (root.fullyCharged) return "battery_full" + if (root.charging) return "battery_charging_full" + 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))))] + } + + function formatDuration(seconds) { + const s = Math.max(0, Math.round(seconds || 0)) + if (s <= 0) return "—" + const h = Math.floor(s / 3600) + const m = Math.round((s % 3600) / 60) + return h > 0 ? (h + "h " + m + "m") : (m + "m") + } + + readonly property string modeLabel: { + if (!root.batteryPresent) return "" + if (root.fullyCharged) return "Fully charged" + return root.onBattery ? "On battery" : "Charging" + } + + // 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. + readonly property var chargingPhrases: [ + "Pumping power", "Injecting electrons", "Pouring juice", "Amassing watts", + "Hoarding joules", "Sucking volts", "Topping reserves", "Soaking amps", "Inhaling kilowatts" + ] + readonly property var onBatteryPhrases: [ + "Slurping power", "Spending joules", "Draining watts", "Burning electrons", + "Sipping juice", "Spending coulombs", "Bleeding amps", "Guzzling volts", "Munching reserves" + ] + readonly property var activePhrases: root.fullyCharged ? [] + : (root.charging ? root.chargingPhrases : (root.onBattery ? root.onBatteryPhrases : [])) + readonly property bool rotatingPhrases: root.activePhrases.length > 0 + property int phraseIndex: 0 + readonly property string heroStatusText: root.rotatingPhrases + ? root.activePhrases[root.phraseIndex % root.activePhrases.length] + : root.modeLabel + + // Only rotates while the panel is open and actually charging/discharging. + Timer { + id: phraseTimer + interval: 2800 + running: panel.visible && root.rotatingPhrases + repeat: true + triggeredOnStart: false + onTriggered: phraseSwap.restart() + } + + SequentialAnimation { + id: phraseSwap + PropertyAnimation { target: heroStatus; property: "opacity"; to: 0.0; duration: 180; easing.type: Easing.OutQuad } + ScriptAction { + script: { + const n = root.activePhrases.length + if (n > 0) root.phraseIndex = (root.phraseIndex + 1) % n + } + } + PropertyAnimation { target: heroStatus; property: "opacity"; to: 1.0; duration: 260; easing.type: Easing.InQuad } + } + + // If we leave a rotating state mid-swap (e.g. unplugged mid-fade), + // snap back to full opacity so the new state reads immediately. + Connections { + target: root + function onRotatingPhrasesChanged() { + if (!root.rotatingPhrases) { + phraseSwap.stop() + heroStatus.opacity = 1.0 + } + } + } + + // --- Power profile list ------------------------------------------------ + // power-profiles-daemon only ever exposes these three fixed profiles; + // Performance is conditional on hasPerformanceProfile (e.g. absent on + // battery-only/thermally-limited hardware) — same gating omarchy's own + // Model.js applies via its profile-list script. + readonly property var profileEntries: { + const entries = [ + { value: PowerProfile.PowerSaver, label: "Saver", icon: "eco" }, + { value: PowerProfile.Balanced, label: "Balanced", icon: "balance" } + ] + if (PowerProfiles.hasPerformanceProfile) + entries.push({ value: PowerProfile.Performance, label: "Performance", icon: "bolt" }) + return entries + } + + Text { + id: label + anchors.centerIn: parent + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 16 + color: root.low ? Colors.urgent : Colors.foreground + text: root.batteryIcon() + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + + // --- Reusable pieces ----------------------------------------------- + + component ProfileButton: Rectangle { + id: profBtn + required property string label + required property string icon + required property bool active + signal activate() + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: 44 + color: mouse.containsMouse ? Colors.primary + : (profBtn.active ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent") + + // Hover-only outline, offset outside the button rather than drawn on + // its own edge — same treatment as this project's other selectable + // rows (e.g. Volume.qml's DeviceRow). + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: mouse.containsMouse ? 1 : 0 + } + + Column { + anchors.centerIn: parent + spacing: 2 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: profBtn.icon + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 18 + color: mouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: profBtn.label + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + font.bold: profBtn.active + color: mouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: profBtn.activate() + } + } + + 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 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + + anchor { + window: root.barWindow + // Unlike anchor.item, anchor.window doesn't default rect to the + // window's own bounds — set it explicitly so edges/gravity below + // resolve against the full bar (and thus the true screen edges) + // instead of a near-zero rect at the window's origin. + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + // Hero: icon · title/status · percentage + RowLayout { + width: parent.width + spacing: 10 + + Text { + text: root.batteryIcon() + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 30 + color: root.low ? Colors.urgent : Colors.foreground + } + + Column { + Layout.fillWidth: true + spacing: 2 + Text { + width: parent.width + text: "Battery" + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + Text { + id: heroStatus + width: parent.width + text: root.heroStatusText + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + } + + Text { + text: Math.round(root.fraction * 100) + "%" + font.family: Appearance.fontFamily + font.pixelSize: 20 + font.bold: true + color: Colors.foreground + } + } + + // Charge level bar + Item { + width: parent.width + height: 4 + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.muted + opacity: 0.35 + } + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + radius: 0 + color: root.low ? Colors.urgent : Colors.primary + width: Math.max(height, parent.width * root.fraction) + Behavior on width { NumberAnimation { duration: 320; easing.type: Easing.OutCubic } } + } + } + + // Stats + RowLayout { + width: parent.width + spacing: 16 + + 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" : "—" + } + } + } + + // Power profile picker + Column { + width: parent.width + spacing: 6 + + Text { + text: "POWER PROFILE" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Row { + id: profileRow + width: parent.width + spacing: 6 + + readonly property real cellWidth: root.profileEntries.length > 0 + ? (width - spacing * (root.profileEntries.length - 1)) / root.profileEntries.length + : 0 + + Repeater { + model: root.profileEntries + ProfileButton { + required property var modelData + width: profileRow.cellWidth + label: modelData.label + icon: modelData.icon + active: PowerProfiles.profile === modelData.value + onActivate: PowerProfiles.profile = modelData.value + } + } + } + } + } + } + } +}