From 1dc2f1ec79c195f65288cdb371864e91439bd487 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 18 May 2026 20:47:37 +0200 Subject: [PATCH] Introduce power panel --- shell/plugins/bar/Bar.qml | 1 + shell/plugins/bar/README.md | 3 +- shell/plugins/bar/widgets/powerPanel.qml | 214 +++++++++++++++++++++++ shell/shell-defaults.json | 2 +- 4 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 shell/plugins/bar/widgets/powerPanel.qml diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index c6ce3630..4b85fc52 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -266,6 +266,7 @@ Item { "audioPanel": { displayName: "Audio", description: "Volume slider, output picker, per-app mixer", category: "Audio", allowMultiple: false }, "monitorPanel": { displayName: "Display", description: "Brightness slider and laptop display controls", category: "System", allowMultiple: false }, "networkPanel": { displayName: "Network", description: "Wi-Fi list and connection state", category: "Network", allowMultiple: false }, + "powerPanel": { displayName: "Power", description: "Battery, power profile, and system stats", category: "System", allowMultiple: false }, "bluetoothPanel": { displayName: "Bluetooth", description: "Bluetooth device list with connect/disconnect", category: "Network", allowMultiple: false }, "calendar": { displayName: "Calendar", description: "Clock with month-grid popup", category: "Time", allowMultiple: false, settingsForm: "calendarSettings" }, "notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND", category: "Status", allowMultiple: false }, diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index 3bc72c1d..74906b6c 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -39,7 +39,7 @@ Example `shell.json` (bar subtree only shown): ], "right": [ { "id": "audioPanel" }, - { "id": "battery" } + { "id": "powerPanel" } ] } } @@ -57,6 +57,7 @@ Example `shell.json` (bar subtree only shown): | `media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup | | `audioPanel` | Volume icon + popup with master slider, output-device picker, per-app mixer | left = popup · right = mute · middle = audio TUI · scroll = volume | | `networkPanel` | Wi-Fi/Ethernet icon + popup with Wi-Fi scan, signal, connect, DNS provider selection | left = popup · right = nmtui | +| `powerPanel` | Battery/AC icon + popup with battery stats, power profiles, and system info | left = popup | | `bluetoothPanel` | Bluetooth icon + popup with device list, connect/disconnect, battery | left = popup · right = toggle radio · middle = bluetoothctl TUI | | `calendar` | Clock + popup with month-grid calendar | left = popup · right = tz selector | | `notificationCenter` | Bell with badge + popup with recent notifications, DND toggle | left = popup · right = toggle DND | diff --git a/shell/plugins/bar/widgets/powerPanel.qml b/shell/plugins/bar/widgets/powerPanel.qml new file mode 100644 index 00000000..aa4a7128 --- /dev/null +++ b/shell/plugins/bar/widgets/powerPanel.qml @@ -0,0 +1,214 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.UPower +import qs.Ui + +Item { + id: root + + property QtObject bar: null + property string moduleName: "powerPanel" + property var settings: ({}) + + property bool popupOpen: false + property var batteryInfo: ({}) + property var systemInfo: ({}) + property var profiles: [] + property string activeProfile: "" + + function closePopout() { popupOpen = false } + + function batteryIcon() { + var device = UPower.displayDevice + if (!device || !device.isPresent) return "" + + var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"] + var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"] + var index = Math.max(0, Math.min(9, Math.floor(device.percentage * 10))) + + if (device.state === UPowerDeviceState.FullyCharged) return "󰂅" + if (!UPower.onBattery && device.state !== UPowerDeviceState.Charging) return "" + if (device.state === UPowerDeviceState.Charging) return chargingIcons[index] + return defaultIcons[index] + } + + function modeLabel() { return UPower.onBattery ? "Battery" : "AC" } + + function refresh() { + if (!batteryProc.running) batteryProc.running = true + if (!profilesProc.running) profilesProc.running = true + if (!systemProc.running) systemProc.running = true + } + + function updateKeyValue(raw, targetName) { + var next = {} + var lines = String(raw || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var idx = lines[i].indexOf("\t") + if (idx <= 0) continue + next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim() + } + if (targetName === "battery") batteryInfo = next + else systemInfo = next + } + + function updateProfiles(raw) { + var lines = String(raw || "").split("\n") + var list = [] + var active = "" + for (var i = 0; i < lines.length; i++) { + var line = lines[i].trim() + if (!line) continue + var parts = line.split("\t") + list.push(parts[0]) + if (parts[1] === "1") active = parts[0] + } + profiles = list + activeProfile = active + } + + function setProfile(profile) { + if (!profile || actionProc.running) return + actionProc.command = ["powerprofilesctl", "set", profile] + actionProc.running = true + } + + Component.onCompleted: refresh() + + implicitWidth: button.implicitWidth + implicitHeight: button.implicitHeight + + IpcHandler { + target: "powerPanel" + function toggle(): void { root.popupOpen = !root.popupOpen } + function show(): void { root.popupOpen = true } + function hide(): void { root.closePopout() } + } + + Process { + id: batteryProc + command: ["bash", "-lc", ` +bat=$(upower -e 2>/dev/null | grep BAT | head -n1) +[[ -z $bat ]] && exit 0 +info=$(upower -i "$bat") +printf 'percentage\t%s\n' "$(awk '/percentage/ { print int($2) "%"; exit }' <<<"$info")" +printf 'state\t%s\n' "$(awk '/state/ { print $2; exit }' <<<"$info")" +printf 'rate\t%s\n' "$(awk '/energy-rate/ { v=sprintf("%.1f", $2); sub(/\.0$/, "", v); print v "W"; exit }' <<<"$info")" +printf 'size\t%s\n' "$(awk '/energy-full:/ { printf "%dWh", $2; exit }' <<<"$info")" +printf 'time\t%s\n' "$($OMARCHY_PATH/bin/omarchy-battery-remaining-time 2>/dev/null)" +`] + stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "battery") } + } + + Process { + id: profilesProc + command: ["bash", "-lc", "powerprofilesctl list 2>/dev/null | awk '/^\\s*[* ]\\s*[a-zA-Z0-9-]+:$/ { active=($1==\"*\"); gsub(/^[*[:space:]]+|:$/,\"\"); print $0 \"\\t\" (active ? 1 : 0) }'"] + stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateProfiles(text) } + } + + Process { + id: systemProc + command: ["bash", "-lc", "cpu=$(top -bn1 | awk '/^%?Cpu/ { gsub(/,/, \"\"); for (i=1; i<=NF; i++) if ($(i+1) == \"id\") { printf \"%.0f%%\", 100 - $i; exit } }'); awk -v cpu=\"$cpu\" '/^MemTotal:/ { total=$2 } /^MemAvailable:/ { avail=$2 } END { used=total-avail; printf \"cpu\\t%s\\n\", cpu; printf \"memory\\t%.1fGB / %.0fGB\\n\", used/1024/1024, total/1024/1024 }' /proc/meminfo"] + stdout: StdioCollector { waitForEnd: true; onStreamFinished: root.updateKeyValue(text, "system") } + } + + Process { + id: actionProc + onExited: root.refresh() + } + + Timer { interval: 5000; running: true; repeat: true; triggeredOnStart: true; onTriggered: root.refresh() } + + WidgetButton { + id: button + anchors.fill: parent + bar: root.bar + text: root.batteryIcon() + horizontalMargin: 8.5 + active: UPower.displayDevice && UPower.displayDevice.percentage <= 0.2 && UPower.onBattery + tooltipText: "" + onPressed: function(b) { root.popupOpen = !root.popupOpen; if (root.popupOpen) root.refresh() } + } + + KeyboardPanel { + id: panel + anchorItem: button + owner: root + bar: root.bar + open: root.popupOpen + contentWidth: 340 + contentHeight: column.implicitHeight + 28 + + Column { + id: column + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + spacing: 12 + + Row { + spacing: 10 + Text { text: root.batteryIcon(); color: root.bar.foreground; font.family: root.bar.fontFamily; font.pixelSize: 22 } + Text { text: root.modeLabel(); color: root.bar.foreground; font.family: root.bar.fontFamily; font.pixelSize: 13; font.bold: true } + } + + Grid { + visible: root.batteryInfo.percentage !== undefined + width: parent.width + columns: 2 + columnSpacing: 14 + rowSpacing: 4 + InfoLabel { text: "Percentage" } InfoValue { text: root.batteryInfo.percentage || "" } + InfoLabel { text: UPower.onBattery ? "Time left" : "Time to full" } InfoValue { text: root.batteryInfo.time || "—" } + InfoLabel { text: "Battery size" } InfoValue { text: root.batteryInfo.size || "" } + InfoLabel { text: UPower.onBattery ? "Draw" : "Charge rate" } InfoValue { text: root.batteryInfo.rate || "" } + } + + PanelSeparator { foreground: root.bar.foreground } + PanelSectionHeader { text: "Power profile"; foreground: root.bar.foreground; fontFamily: root.bar.fontFamily } + Row { + width: parent.width + spacing: 6 + Repeater { + model: root.profiles + CursorPill { + required property var modelData + text: String(modelData).charAt(0).toUpperCase() + String(modelData).slice(1) + foreground: root.bar.foreground + tooltipBackground: root.bar.background + tooltipForeground: root.bar.foreground + fontFamily: root.bar.fontFamily + horizontalPadding: 10 + verticalPadding: 6 + active: root.activeProfile === modelData + onClicked: root.setProfile(modelData) + } + } + } + + PanelSeparator { foreground: root.bar.foreground } + PanelSectionHeader { text: "System"; foreground: root.bar.foreground; fontFamily: root.bar.fontFamily } + Grid { + width: parent.width + columns: 2 + columnSpacing: 14 + rowSpacing: 4 + InfoLabel { text: "CPU" } InfoValue { text: root.systemInfo.cpu || "—" } + InfoLabel { text: "Memory" } InfoValue { text: root.systemInfo.memory || "—" } + } + } + } + + component InfoLabel: Text { + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: 11 + } + + component InfoValue: Text { + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: 11 + } +} diff --git a/shell/shell-defaults.json b/shell/shell-defaults.json index f3280488..c64f6f34 100644 --- a/shell/shell-defaults.json +++ b/shell/shell-defaults.json @@ -56,7 +56,7 @@ "id": "monitorPanel" }, { - "id": "battery" + "id": "powerPanel" } ] }