From 5cc71b4da2a35fa7f491b1048bf705ec965ea782 Mon Sep 17 00:00:00 2001 From: art0rz Date: Sat, 22 Nov 2025 13:14:51 +0100 Subject: [PATCH] Add BatteryPanel with charge level, power profile settings, prevent sleep toggle, battery health (if available) --- .gitignore | 1 + Assets/Translations/de.json | 6 + Assets/Translations/en.json | 6 + Assets/Translations/es.json | 6 + Assets/Translations/fr.json | 6 + Assets/Translations/nl.json | 6 + Assets/Translations/pt.json | 6 + Assets/Translations/ru.json | 6 + Assets/Translations/tr.json | 6 + Assets/Translations/uk-UA.json | 6 + Assets/Translations/zh-CN.json | 6 + Modules/Bar/Widgets/Battery.qml | 1 + .../MainScreen/Backgrounds/AllBackgrounds.qml | 7 + Modules/MainScreen/MainScreen.qml | 9 + Modules/Panels/Battery/BatteryPanel.qml | 289 ++++++++++++++++++ 15 files changed, 367 insertions(+) create mode 100644 Modules/Panels/Battery/BatteryPanel.qml diff --git a/.gitignore b/.gitignore index 6c5fb963e..61d3f782e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .qmlls.ini .zed Bin/battery-manager/uninstall-battery-manager.sh +.idea \ No newline at end of file diff --git a/Assets/Translations/de.json b/Assets/Translations/de.json index d8fc09ece..476a0592b 100644 --- a/Assets/Translations/de.json +++ b/Assets/Translations/de.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "Helligkeit", + "charge-level": "Ladestand", "charging": "Wird geladen.", "charging-rate": "Laderate: {rate} W.", "discharging": "Wird entladen.", "discharging-rate": "Entladerate: {rate} W.", "health": "Zustand: {percent}%", + "inhibit-idle-description": "Hält das System wach.", + "inhibit-idle-label": "Ruhezustand verhindern", "idle": "Leerlauf.", "no-battery-detected": "Keine Batterie erkannt.", + "panel-title": "Akku", "plugged-in": "Angeschlossen.", + "power-profile": "Energieprofil", "time-left": "Verbleibende Zeit: {time}.", "time-until-full": "Zeit bis vollständig geladen: {time}." }, diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 9adf305e1..734e3f660 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "Brightness", + "charge-level": "Charge level", "charging": "Charging", "charging-rate": "Charging rate: {rate} W", "discharging": "Discharging", "discharging-rate": "Discharging rate: {rate} W", "health": "Health: {percent}%", + "inhibit-idle-description": "Keeps the system awake.", + "inhibit-idle-label": "Prevent sleep", "idle": "Idle", "no-battery-detected": "No battery detected", + "panel-title": "Battery", "plugged-in": "Plugged in", + "power-profile": "Power profile", "time-left": "Time left: {time}", "time-until-full": "Time until full: {time}" }, diff --git a/Assets/Translations/es.json b/Assets/Translations/es.json index 9eaed6677..971817ce9 100644 --- a/Assets/Translations/es.json +++ b/Assets/Translations/es.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "Brillo", + "charge-level": "Nivel de carga", "charging": "Cargando.", "charging-rate": "Tasa de carga: {rate} W.", "discharging": "Descargando.", "discharging-rate": "Tasa de descarga: {rate} W.", "health": "Salud: {percent}%", + "inhibit-idle-description": "Mantiene el sistema despierto.", + "inhibit-idle-label": "Evitar suspensión", "idle": "Inactivo.", "no-battery-detected": "No se detectó ninguna batería.", + "panel-title": "Batería", "plugged-in": "Conectado.", + "power-profile": "Perfil de energía", "time-left": "Tiempo restante: {time}.", "time-until-full": "Tiempo hasta carga completa: {time}." }, diff --git a/Assets/Translations/fr.json b/Assets/Translations/fr.json index f1c53e48e..fbf447f93 100644 --- a/Assets/Translations/fr.json +++ b/Assets/Translations/fr.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "Luminosité", + "charge-level": "Niveau de charge", "charging": "En charge.", "charging-rate": "Taux de charge : {rate} W.", "discharging": "En décharge.", "discharging-rate": "Taux de décharge : {rate} W.", "health": "État : {percent}%", + "inhibit-idle-description": "Maintient le système éveillé.", + "inhibit-idle-label": "Empêcher la veille", "idle": "Inactif.", "no-battery-detected": "Aucune batterie détectée.", + "panel-title": "Batterie", "plugged-in": "Branché.", + "power-profile": "Profil d’alimentation", "time-left": "Temps restant : {time}.", "time-until-full": "Temps jusqu'à charge complète : {time}." }, diff --git a/Assets/Translations/nl.json b/Assets/Translations/nl.json index dfc093e2d..5fac0f3a9 100644 --- a/Assets/Translations/nl.json +++ b/Assets/Translations/nl.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "Helderheid", + "charge-level": "Laadniveau", "charging": "Opladen.", "charging-rate": "Laadsnelheid: {rate} W.", "discharging": "Ontladen.", "discharging-rate": "Ontlaadsnelheid: {rate} W.", "health": "Gezondheid: {percent}%", + "inhibit-idle-description": "Houdt het systeem wakker.", + "inhibit-idle-label": "Slaapstand voorkomen", "idle": "In rust.", "no-battery-detected": "Geen batterij gedetecteerd.", + "panel-title": "Batterij", "plugged-in": "Op netstroom.", + "power-profile": "Energieprofiel", "time-left": "Resterende tijd: {time}.", "time-until-full": "Tijd tot vol: {time}." }, diff --git a/Assets/Translations/pt.json b/Assets/Translations/pt.json index a42179464..6fce117bd 100644 --- a/Assets/Translations/pt.json +++ b/Assets/Translations/pt.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "Brilho", + "charge-level": "Nível de carga", "charging": "Carregando.", "charging-rate": "Taxa de carregamento: {rate} W.", "discharging": "Descarregando.", "discharging-rate": "Taxa de descarregamento: {rate} W.", "health": "Saúde: {percent}%", + "inhibit-idle-description": "Mantém o sistema acordado.", + "inhibit-idle-label": "Impedir suspensão", "idle": "Ocioso.", "no-battery-detected": "Nenhuma bateria detectada.", + "panel-title": "Bateria", "plugged-in": "Conectado.", + "power-profile": "Perfil de energia", "time-left": "Tempo restante: {time}.", "time-until-full": "Tempo até carga completa: {time}." }, diff --git a/Assets/Translations/ru.json b/Assets/Translations/ru.json index ead37b0e2..97f75d4f6 100644 --- a/Assets/Translations/ru.json +++ b/Assets/Translations/ru.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "Яркость", + "charge-level": "Уровень заряда", "charging": "Зарядка.", "charging-rate": "Скорость зарядки: {rate} Вт.", "discharging": "Разрядка.", "discharging-rate": "Скорость разрядки: {rate} Вт.", "health": "Здоровье: {percent}%", + "inhibit-idle-description": "Не дает системе уснуть.", + "inhibit-idle-label": "Запрет сна", "idle": "Простой.", "no-battery-detected": "Батарея не обнаружена.", + "panel-title": "Батарея", "plugged-in": "Подключено.", + "power-profile": "Профиль питания", "time-left": "Осталось времени: {time}.", "time-until-full": "Время до полной зарядки: {time}." }, diff --git a/Assets/Translations/tr.json b/Assets/Translations/tr.json index f96ecff28..0cd1bbb31 100644 --- a/Assets/Translations/tr.json +++ b/Assets/Translations/tr.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "Parlaklık", + "charge-level": "Şarj seviyesi", "charging": "Şarj oluyor.", "charging-rate": "Şarj oranı: {rate} W.", "discharging": "Deşarj oluyor.", "discharging-rate": "Deşarj oranı: {rate} W.", "health": "Sağlık: {percent}%", + "inhibit-idle-description": "Sistemi uyanık tutar.", + "inhibit-idle-label": "Uykuya engelle", "idle": "Boşta.", "no-battery-detected": "Pil tespit edilmedi.", + "panel-title": "Pil", "plugged-in": "Prize takılı.", + "power-profile": "Güç profili", "time-left": "Kalan süre: {time}.", "time-until-full": "Dolma süresi: {time}." }, diff --git a/Assets/Translations/uk-UA.json b/Assets/Translations/uk-UA.json index 7bfa0d3b0..2b5d3f378 100644 --- a/Assets/Translations/uk-UA.json +++ b/Assets/Translations/uk-UA.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "Яскравість", + "charge-level": "Рівень заряду", "charging": "Зарядка.", "charging-rate": "Швидкість зарядки: {rate} Вт.", "discharging": "Розрядка.", "discharging-rate": "Швидкість розрядки: {rate} Вт.", "health": "Здоров'я: {percent}%", + "inhibit-idle-description": "Підтримує систему активною.", + "inhibit-idle-label": "Запобігати сну", "idle": "Бездіяльність.", "no-battery-detected": "Батарею не виявлено.", + "panel-title": "Батарея", "plugged-in": "Підключено.", + "power-profile": "Профіль живлення", "time-left": "Залишилось часу: {time}.", "time-until-full": "Час до повного заряду: {time}." }, diff --git a/Assets/Translations/zh-CN.json b/Assets/Translations/zh-CN.json index 04f3cd2f8..cedd62e89 100644 --- a/Assets/Translations/zh-CN.json +++ b/Assets/Translations/zh-CN.json @@ -381,14 +381,20 @@ } }, "battery": { + "brightness": "亮度", + "charge-level": "电量", "charging": "正在充电。", "charging-rate": "充电速率:{rate} W。", "discharging": "正在放电。", "discharging-rate": "放电速率:{rate} W。", "health": "健康状况:{percent}%", + "inhibit-idle-description": "保持系统唤醒。", + "inhibit-idle-label": "防止睡眠", "idle": "空闲。", "no-battery-detected": "未检测到电池。", + "panel-title": "电池", "plugged-in": "已接通电源。", + "power-profile": "电源模式", "time-left": "剩余时间:{time}。", "time-until-full": "充满所需时间:{time}。" }, diff --git a/Modules/Bar/Widgets/Battery.qml b/Modules/Bar/Widgets/Battery.qml index a1739cc73..d0c5c4741 100644 --- a/Modules/Bar/Widgets/Battery.qml +++ b/Modules/Bar/Widgets/Battery.qml @@ -168,6 +168,7 @@ Item { } return lines.join("\n"); } + onClicked: PanelService.getPanel("batteryPanel", screen)?.toggle(this) onRightClicked: { var popupMenuWindow = PanelService.getPopupMenuWindow(screen); if (popupMenuWindow) { diff --git a/Modules/MainScreen/Backgrounds/AllBackgrounds.qml b/Modules/MainScreen/Backgrounds/AllBackgrounds.qml index d3339f27c..c43aadef8 100644 --- a/Modules/MainScreen/Backgrounds/AllBackgrounds.qml +++ b/Modules/MainScreen/Backgrounds/AllBackgrounds.qml @@ -69,6 +69,13 @@ Item { backgroundColor: panelBackgroundColor } + // Battery + PanelBackground { + panel: root.windowRoot.batteryPanelPlaceholder + shapeContainer: backgroundsShape + backgroundColor: panelBackgroundColor + } + // Bluetooth PanelBackground { panel: root.windowRoot.bluetoothPanelPlaceholder diff --git a/Modules/MainScreen/MainScreen.qml b/Modules/MainScreen/MainScreen.qml index f9b6d1935..f90a65126 100644 --- a/Modules/MainScreen/MainScreen.qml +++ b/Modules/MainScreen/MainScreen.qml @@ -10,6 +10,7 @@ import qs.Commons import qs.Modules.Bar import qs.Modules.Bar.Extras import qs.Modules.Panels.Audio +import qs.Modules.Panels.Battery import qs.Modules.Panels.Bluetooth import qs.Modules.Panels.Brightness import qs.Modules.Panels.Calendar @@ -33,6 +34,7 @@ PanelWindow { // Expose panels as readonly property aliases readonly property alias audioPanel: audioPanel + readonly property alias batteryPanel: batteryPanel readonly property alias bluetoothPanel: bluetoothPanel readonly property alias brightnessPanel: brightnessPanel readonly property alias calendarPanel: calendarPanel @@ -49,6 +51,7 @@ PanelWindow { // Expose panel placeholders for AllBackgrounds readonly property var audioPanelPlaceholder: audioPanel.panelPlaceholder + readonly property var batteryPanelPlaceholder: batteryPanel.panelPlaceholder readonly property var bluetoothPanelPlaceholder: bluetoothPanel.panelPlaceholder readonly property var brightnessPanelPlaceholder: brightnessPanel.panelPlaceholder readonly property var calendarPanelPlaceholder: calendarPanel.panelPlaceholder @@ -168,6 +171,12 @@ PanelWindow { screen: root.screen } + BatteryPanel { + id: batteryPanel + objectName: "batteryPanel-" + (root.screen?.name || "unknown") + screen: root.screen + } + BluetoothPanel { id: bluetoothPanel objectName: "bluetoothPanel-" + (root.screen?.name || "unknown") diff --git a/Modules/Panels/Battery/BatteryPanel.qml b/Modules/Panels/Battery/BatteryPanel.qml new file mode 100644 index 000000000..cbf654c7c --- /dev/null +++ b/Modules/Panels/Battery/BatteryPanel.qml @@ -0,0 +1,289 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.UPower +import qs.Commons +import qs.Modules.MainScreen +import qs.Services.Hardware +import qs.Services.Power +import qs.Widgets + +SmartPanel { + id: root + + preferredWidth: Math.round(360 * Style.uiScaleRatio) + preferredHeight: Math.round(460 * Style.uiScaleRatio) + + readonly property var battery: UPower.displayDevice + readonly property bool isReady: battery && battery.ready && battery.isLaptopBattery && battery.isPresent + readonly property int percent: isReady ? Math.round(battery.percentage * 100) : -1 + readonly property bool charging: isReady ? battery.state === UPowerDeviceState.Charging : false + readonly property bool healthSupported: isReady && battery.healthSupported + readonly property bool healthAvailable: healthSupported + readonly property int healthPercent: healthAvailable ? Math.round(battery.healthPercentage) : -1 + readonly property string timeText: { + if (!isReady) + return I18n.tr("battery.no-battery-detected"); + if (charging && battery.timeToFull > 0) { + return I18n.tr("battery.time-until-full", { + "time": Time.formatVagueHumanReadableDuration(battery.timeToFull) + }); + } + if (!charging && battery.timeToEmpty > 0) { + return I18n.tr("battery.time-left", { + "time": Time.formatVagueHumanReadableDuration(battery.timeToEmpty) + }); + } + return I18n.tr("battery.idle"); + } + readonly property string iconName: BatteryService.getIcon(percent, charging, isReady) + readonly property bool profilesAvailable: PowerProfileService.available + property int profileIndex: profileToIndex(PowerProfileService.profile) + property bool manualInhibitActive: manualInhibitorEnabled() + + panelContent: Item { + property real contentPreferredHeight: mainLayout.implicitHeight + Style.marginL * 2 + + ColumnLayout { + id: mainLayout + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginM + + // HEADER + NBox { + Layout.fillWidth: true + implicitHeight: headerRow.implicitHeight + (Style.marginM * 2) + + RowLayout { + id: headerRow + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + NIcon { + pointSize: Style.fontSizeXXL + color: Color.mPrimary + icon: iconName + } + + ColumnLayout { + spacing: Style.marginXXS + Layout.fillWidth: true + + NText { + text: I18n.tr("battery.panel-title") + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NText { + text: timeText + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + wrapMode: Text.Wrap + Layout.fillWidth: true + } + } + + NIconButton { + icon: "close" + tooltipText: I18n.tr("tooltips.close") + baseSize: Style.baseWidgetSize * 0.8 + onClicked: root.close() + } + } + } + + // Charge level + health/time + NBox { + Layout.fillWidth: true + implicitHeight: chargeLayout.implicitHeight + Style.marginM * 2 + + ColumnLayout { + id: chargeLayout + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NText { + text: I18n.tr("battery.charge-level") + color: Color.mOnSurfaceVariant + pointSize: Style.fontSizeS + } + Item { + Layout.fillWidth: true + } + NText { + text: percent >= 0 ? `${percent}%` : "--" + color: Color.mOnSurface + pointSize: Style.fontSizeS + font.weight: Style.fontWeightBold + } + } + + Rectangle { + Layout.fillWidth: true + height: Math.round(8 * Style.uiScaleRatio) + radius: height / 2 + color: Color.mSurfaceVariant + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + height: parent.height + radius: parent.radius + width: { + var ratio = Math.max(0, Math.min(1, percent / 100)); + return parent.width * ratio; + } + color: Color.mPrimary + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginL + visible: healthAvailable + + NText { + text: I18n.tr("battery.health", {"percent": healthPercent}) + color: Color.mOnSurface + pointSize: Style.fontSizeS + font.weight: Style.fontWeightMedium + Layout.fillWidth: true + } + } + } + } + + // Power profile and idle inhibit controls + NBox { + Layout.fillWidth: true + implicitHeight: controlsLayout.implicitHeight + Style.marginM * 2 + + ColumnLayout { + id: controlsLayout + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + NIcon { + icon: PowerProfileService.getIcon(); pointSize: Style.fontSizeM; color: Color.mPrimary + } + NText { + text: I18n.tr("battery.power-profile") + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + NText { + text: PowerProfileService.getName() + color: Color.mOnSurfaceVariant + } + } + + NValueSlider { + Layout.fillWidth: true + from: 0 + to: 2 + stepSize: 1 + snapAlways: true + value: profileIndex + enabled: profilesAvailable + onPressedChanged: function (pressed, v) { + if (!pressed) { + setProfileByIndex(Math.round(v)); + } + } + onMoved: function (v) { + profileIndex = Math.round(v); + } + } + + NToggle { + Layout.fillWidth: true + checked: manualInhibitActive + label: I18n.tr("battery.inhibit-idle-label") + description: I18n.tr("battery.inhibit-idle-description") + onToggled: function (checked) { + if (checked) { + IdleInhibitorService.addManualInhibitor(null); + } else { + IdleInhibitorService.removeManualInhibitor(); + } + manualInhibitActive = checked; + } + } + } + } + } + } + + function profileToIndex(p) { + switch (p) { + case PowerProfile.PowerSaver: + return 0; + case PowerProfile.Balanced: + return 1; + case PowerProfile.Performance: + return 2; + default: + return 1; + } + } + + function indexToProfile(idx) { + switch (idx) { + case 0: + return PowerProfile.PowerSaver; + case 2: + return PowerProfile.Performance; + default: + return PowerProfile.Balanced; + } + } + + function setProfileByIndex(idx) { + var prof = indexToProfile(idx); + profileIndex = idx; + PowerProfileService.setProfile(prof); + } + + function manualInhibitorEnabled() { + return IdleInhibitorService.activeInhibitors && IdleInhibitorService.activeInhibitors.indexOf("manual") >= 0; + } + + Connections { + target: IdleInhibitorService + + function onIsInhibitedChanged() { + manualInhibitActive = manualInhibitorEnabled(); + } + } + + Timer { + id: inhibitorPoll + interval: 1000 + repeat: true + running: true + onTriggered: manualInhibitActive = manualInhibitorEnabled(); + } + + Connections { + target: PowerProfileService + + function onProfileChanged() { + profileIndex = profileToIndex(PowerProfileService.profile); + } + } +}