Files
quickdots/bar/modules/Battery.qml

520 lines
21 KiB
QML

import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.UPower
import "../../config" as Cfg
import "../../components"
ModuleChip {
id: root
readonly property var bat: UPower.displayDevice
readonly property bool batReady: bat && bat.ready && bat.isLaptopBattery
readonly property bool moduleEnabled: batReady
readonly property real pct: batReady ? bat.percentage * 100 : 72
readonly property int state: batReady ? bat.state : UPowerDeviceState.Unknown
readonly property bool charging: state === UPowerDeviceState.Charging
readonly property bool fullCharge: state === UPowerDeviceState.FullyCharged
readonly property string batIcon: {
if (!batReady) return "󱉞"
if (fullCharge) return "󱟢"
if (charging) {
if (pct >= 90) return "󰂋"
if (pct >= 80) return "󰂊"
if (pct >= 70) return "󰢞"
if (pct >= 60) return "󰂉"
if (pct >= 50) return "󰢝"
if (pct >= 40) return "󰂈"
if (pct >= 30) return "󰂇"
if (pct >= 20) return "󰂆"
if (pct >= 10) return "󰢜"
return "󰢟"
}
if (pct >= 90) return "󰂂"
if (pct >= 80) return "󰂁"
if (pct >= 70) return "󰂀"
if (pct >= 60) return "󰁿"
if (pct >= 50) return "󰁾"
if (pct >= 40) return "󰁽"
if (pct >= 30) return "󰁼"
if (pct >= 20) return "󰁻"
if (pct >= 10) return "󰁺"
return "󰂎"
}
readonly property bool isCritical: batReady && !charging && pct <= 15
readonly property bool isLow: batReady && !charging && pct <= 30
readonly property color iconColor: {
if (fullCharge) return t.batteryFull ?? t.accent
if (charging) return t.batteryCharging ?? t.accent
if (isCritical) return t.batteryCritical ?? "#ff5555"
if (isLow) return t.batteryLow ?? "#ffb86c"
return t.batteryIcon ?? t.clockIcon
}
readonly property var up: UPower
readonly property var pp: PowerProfiles
readonly property string typeText: {
if (!batReady) return "—"
switch (bat.type) {
case UPowerDeviceType.Battery: return "Battery"
case UPowerDeviceType.Ups: return "UPS"
case UPowerDeviceType.Mouse: return "Mouse"
case UPowerDeviceType.Keyboard: return "Keyboard"
case UPowerDeviceType.Phone: return "Phone"
case UPowerDeviceType.Tablet: return "Tablet"
default: return "Other"
}
}
readonly property string degradationText: {
if (pp.degradationReason === PerformanceDegradationReason.None) return ""
if (pp.degradationReason === PerformanceDegradationReason.LapDetected) return "Lap detected"
if (pp.degradationReason === PerformanceDegradationReason.HighTemperature) return "High temp"
return ""
}
readonly property var profileHolds: {
let names = []
for (let i = 0; i < pp.holds.length; i++) {
let h = pp.holds[i]
let label = PowerProfile.toString(h.profile)
if (h.applicationId) label += " (" + h.applicationId + ")"
names.push(label)
}
return names
}
width: batLayout.implicitWidth + 24
height: Cfg.Config.moduleHeight
radius: panelRadius
color: hoverHandler.hovered ? t.batBgHover : t.batBg
border.color: hoverHandler.hovered ? t.batBorderHover : t.batBorder
border.width: borderWidth
Behavior on width { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
HoverHandler { id: hoverHandler; cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: batteryPopup.toggle() }
RowLayout {
id: batLayout
anchors.centerIn: parent
spacing: 0
Text {
text: root.batIcon
color: root.iconColor
font.pixelSize: 16
Layout.alignment: Qt.AlignVCenter
Layout.rightMargin: 7
SequentialAnimation on opacity {
running: root.isCritical
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 700; easing.type: Easing.InOutSine }
NumberAnimation { to: 1.0; duration: 700; easing.type: Easing.InOutSine }
}
opacity: 1.0
}
Text {
text: root.batReady ? Math.round(root.pct) + "%" : "—"
color: root.t.batText
font.pixelSize: 13
font.bold: true
verticalAlignment: Text.AlignVCenter
}
Text {
id: chargingLabel
text: root.charging ? " +" : (root.fullCharge ? " ✓" : "")
color: root.iconColor
font.pixelSize: 13
font.bold: true
verticalAlignment: Text.AlignVCenter
opacity: (hoverHandler.hovered || batteryPopup.popupOpen) ? 1.0 : 0.0
Layout.preferredWidth: (hoverHandler.hovered || batteryPopup.popupOpen)
? implicitWidth : 0
clip: true
Behavior on opacity { NumberAnimation { duration: 200 } }
Behavior on Layout.preferredWidth { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
}
}
PopupPanel {
id: batteryPopup
ns: "battery"
chipRoot: root
cardWidth: 260
Rectangle {
id: cardShadow
anchors.fill: card
anchors.margins: -3
radius: card.radius + 3
color: "transparent"
border.color: Qt.rgba(0, 0, 0, 0.35)
border.width: 6
visible: batteryPopup.popupOpen
opacity: batteryPopup.popupOpen ? 0.6 : 0
Behavior on opacity { NumberAnimation { duration: 200 } }
}
PopupCard {
id: card
popupOpen: batteryPopup.popupOpen
isTop: root.isTop
animEnabled: batteryPopup.visible
height: cardCol.implicitHeight + 28
color: root.t.batPopupBg
radius: Cfg.Config.popupRadius
border.color: root.t.batPopupBorder
border.width: Cfg.Config.popupBorderWidth
layer.enabled: true
ColumnLayout {
id: cardCol
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: 18
topMargin: 20
}
spacing: 12
RowLayout {
Layout.fillWidth: true
spacing: 14
Item {
width: 52
height: 52
Rectangle {
anchors.centerIn: parent
width: 46
height: 46
radius: 23
color: Qt.rgba(
Qt.darker(root.iconColor, 1.0).r,
Qt.darker(root.iconColor, 1.0).g,
Qt.darker(root.iconColor, 1.0).b,
0.15
)
}
Text {
anchors.centerIn: parent
text: root.batIcon
color: root.iconColor
font.pixelSize: 26
SequentialAnimation on opacity {
running: root.isCritical
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 700; easing.type: Easing.InOutSine }
NumberAnimation { to: 1.0; duration: 700; easing.type: Easing.InOutSine }
}
opacity: 1.0
}
}
ColumnLayout {
spacing: 2
Layout.fillWidth: true
Text {
text: root.batReady ? Math.round(root.pct) + "%" : "—"
color: root.t.batPopupHeader
font.pixelSize: 30
font.bold: true
}
Text {
text: {
if (!root.batReady) return "No battery"
if (root.fullCharge) return "Fully charged"
if (root.charging) return "Charging"
if (root.isCritical) return "Critical"
if (root.isLow) return "Low"
return "Discharging"
}
color: root.iconColor
font.pixelSize: 11
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.8
font.bold: true
}
}
}
Item {
Layout.fillWidth: true
height: 10
Rectangle {
anchors.fill: parent
radius: 5
color: root.t.batProgressTrack
}
Rectangle {
width: Math.max(height, parent.width * (root.pct / 100))
height: parent.height
radius: 5
clip: false
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0.0
color: Qt.darker(root.iconColor, 1.25)
}
GradientStop {
position: 1.0
color: root.iconColor
}
}
Behavior on width { NumberAnimation { duration: 450; easing.type: Easing.OutCubic } }
}
Rectangle {
anchors.fill: parent
radius: 5
color: "transparent"
border.color: Qt.rgba(root.iconColor.r,
root.iconColor.g,
root.iconColor.b, 0.2)
border.width: 1
}
}
Rectangle {
Layout.fillWidth: true
height: 1
color: root.t.batPopupSeparator
opacity: 0.6
}
component DetailRow: RowLayout {
required property string label
required property string value
Layout.fillWidth: true
Text {
text: label
color: root.t.batPopupDim
font.pixelSize: 12
Layout.fillWidth: true
}
Text {
text: value
color: root.t.batPopupText
font.pixelSize: 12
font.bold: true
font.family: "monospace"
}
}
DetailRow {
label: root.charging ? "Time to full" : "Time left"
value: {
if (!root.batReady || root.fullCharge) return "—"
let secs = root.charging ? bat.timeToFull : bat.timeToEmpty
if (secs <= 0) return "—"
let h = Math.floor(secs / 3600)
let m = Math.floor((secs % 3600) / 60)
return h > 0 ? h + "h " + m + "m" : m + "m"
}
}
DetailRow {
label: "Rate"
value: {
if (!root.batReady) return "—"
let r = bat.changeRate
return (r >= 0 ? "+" : "") + r.toFixed(1) + " W"
}
}
DetailRow {
label: "Health"
value: {
if (!root.batReady) return "—"
if (!bat.healthSupported) return "Undetected"
return Math.round(bat.healthPercentage) + "%"
}
}
DetailRow {
label: "Energy"
value: {
if (!root.batReady) return "—"
return bat.energy.toFixed(1) + " / "
+ bat.energyCapacity.toFixed(1) + " Wh"
}
}
Item {
Layout.fillWidth: true
height: 16
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 1
color: root.t.batPopupSeparator
opacity: 0.6
}
Rectangle {
anchors.centerIn: parent
width: sectionLabel.implicitWidth + 14
height: sectionLabel.implicitHeight + 4
radius: 4
color: root.t.batPopupBg
Text {
id: sectionLabel
anchors.centerIn: parent
text: "Power Profile"
color: root.t.batPopupDim
font.pixelSize: 10
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.8
}
}
}
component ProfileButton: Rectangle {
required property string text
required property var profileValue
property bool interactive: true
property bool isActive: interactive && (root.pp.profile === profileValue)
height: 38
radius: 10
color: isActive ? root.iconColor
: Qt.rgba(root.iconColor.r, root.iconColor.g, root.iconColor.b,
(profileHover.containsMouse && interactive) ? 0.14 : 0.06)
border.color: isActive
? Qt.lighter(root.iconColor, 1.2)
: Qt.rgba(root.iconColor.r, root.iconColor.g, root.iconColor.b, 0.12)
border.width: 1
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
Text {
anchors.centerIn: parent
text: parent.text
font.pixelSize: 10
font.bold: parent.isActive
font.letterSpacing: 0.4
color: parent.isActive ? root.t.batProfileActiveText : root.t.batPopupText
}
HoverHandler { id: profileHover; enabled: parent.interactive }
MouseArea {
anchors.fill: parent
cursorShape: parent.interactive ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: {
if (parent.interactive) root.pp.profile = parent.profileValue
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: 6
ProfileButton {
text: "Power Saver"
profileValue: PowerProfile.PowerSaver
Layout.fillWidth: true
}
ProfileButton {
text: "Balanced"
profileValue: PowerProfile.Balanced
Layout.fillWidth: true
}
ProfileButton {
text: "Performance"
profileValue: PowerProfile.Performance
interactive: root.pp.hasPerformanceProfile
Layout.fillWidth: true
}
}
DetailRow {
label: "Degradation"
value: root.degradationText || "None"
}
DetailRow {
label: "Holds"
value: root.profileHolds.length > 0 ? root.profileHolds[0] : "—"
visible: root.profileHolds.length > 0
}
Item {
Layout.fillWidth: true
height: 16
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 1
color: root.t.batPopupSeparator
opacity: 0.6
}
Rectangle {
anchors.centerIn: parent
width: devLabel.implicitWidth + 14
height: devLabel.implicitHeight + 4
radius: 4
color: root.t.batPopupBg
Text {
id: devLabel
anchors.centerIn: parent
text: "Device"
color: root.t.batPopupDim
font.pixelSize: 10
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.8
}
}
}
DetailRow { label: "Type"; value: root.typeText }
DetailRow { label: "Model"; value: root.batReady ? (bat.model || "Undetected") : "—" }
DetailRow { label: "Power supply"; value: root.batReady ? (bat.powerSupply ? "Yes" : "No") : "—" }
DetailRow { label: "On battery"; value: root.up.onBattery ? "Yes" : "No" }
DetailRow { label: "Devices"; value: root.up.devices.values.length + " total" }
Item { height: 2 }
}
}
}
}