423 lines
17 KiB
QML
423 lines
17 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|