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: false 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 } } } 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 } } } } }