590 lines
24 KiB
QML
590 lines
24 KiB
QML
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import Quickshell.Services.UPower
|
|
|
|
import "../../config" as Cfg
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
property real panelRadius: Cfg.Config.radius / 2
|
|
property int borderWidth: Cfg.Config.panelBorderWidth
|
|
|
|
property bool isTop: true
|
|
property var targetScreen: null
|
|
property int barHeight: Cfg.Config.barHeight
|
|
property int barMargin: Cfg.Config.margin
|
|
|
|
readonly property var t: Cfg.Config.theme
|
|
|
|
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 } }
|
|
}
|
|
}
|
|
|
|
PanelWindow {
|
|
id: batteryPopup
|
|
screen: root.targetScreen
|
|
visible: false
|
|
color: "transparent"
|
|
|
|
property bool popupOpen: false
|
|
|
|
function open() { hideTimer.stop(); visible = true; popupOpen = true }
|
|
function close() { popupOpen = false; hideTimer.restart() }
|
|
function toggle() { popupOpen ? close() : open() }
|
|
|
|
Timer {
|
|
id: hideTimer
|
|
interval: Cfg.Config.popupAnimDuration + 70
|
|
onTriggered: batteryPopup.visible = false
|
|
}
|
|
|
|
anchors { top: true; bottom: true; left: true; right: true }
|
|
WlrLayershell.layer: WlrLayer.Top
|
|
WlrLayershell.namespace: "main-shell-battery"
|
|
WlrLayershell.exclusiveZone: -1
|
|
mask: popupOpen ? null : _noInput
|
|
Region { id: _noInput }
|
|
|
|
TapHandler { onTapped: batteryPopup.close() }
|
|
|
|
Item {
|
|
id: popupClipContainer
|
|
readonly property int cardW: 260
|
|
readonly property int screenPad: root.barMargin + 10
|
|
|
|
x: {
|
|
let centerX = root.mapToItem(null, 0, 0).x + root.width / 2
|
|
let desiredX = centerX - cardW / 2
|
|
return Math.max(screenPad,
|
|
Math.min(desiredX, parent.width - cardW - screenPad))
|
|
}
|
|
width: cardW
|
|
|
|
anchors.top: root.isTop ? parent.top : undefined
|
|
anchors.bottom: root.isTop ? undefined : parent.bottom
|
|
anchors.topMargin: root.isTop ? (root.barHeight + 10) : 0
|
|
anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + 10)
|
|
height: parent.height - root.barHeight - 10
|
|
|
|
clip: true
|
|
|
|
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 } }
|
|
}
|
|
|
|
Rectangle {
|
|
id: card
|
|
width: parent.width
|
|
height: cardCol.implicitHeight + 28
|
|
|
|
anchors.top: root.isTop ? parent.top : undefined
|
|
anchors.bottom: root.isTop ? undefined : parent.bottom
|
|
|
|
transform: Translate {
|
|
y: batteryPopup.popupOpen ? 0
|
|
: (root.isTop ? -card.height : card.height)
|
|
Behavior on y {
|
|
NumberAnimation {
|
|
duration: Cfg.Config.popupAnimDuration
|
|
easing.type: Easing.OutExpo
|
|
}
|
|
enabled: batteryPopup.visible
|
|
}
|
|
}
|
|
|
|
opacity: batteryPopup.popupOpen ? 1.0 : 0.0
|
|
Behavior on opacity { NumberAnimation { duration: 220 } }
|
|
|
|
color: root.t.batPopupBg
|
|
radius: Cfg.Config.popupRadius
|
|
border.color: root.t.batPopupBorder
|
|
border.width: Cfg.Config.popupBorderWidth
|
|
layer.enabled: true
|
|
|
|
MouseArea { anchors.fill: parent; propagateComposedEvents: false }
|
|
|
|
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 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|