Files
quickshell/bar/SystemStats.qml
2026-04-16 09:34:19 +02:00

309 lines
12 KiB
QML

pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
import ".."
// System stats strip: Volume · Network · CPU load · Memory · Temperature · Brightness · Battery
// All stats refreshed every 5 s via a single bash process.
// Requires: pactl, ip, awk, nproc, acpi, coreutils. Battery/backlight items hide when unavailable.
RowLayout {
id: root
spacing: 16 // space between stat groups
// ── State ─────────────────────────────────────────────────────────────────
property int volPct: 0
property bool volMuted: false
property real loadAvg: 0
property int cpuCores: 1
property real memUsedGB: 0
property real memTotalGB: 0
readonly property real memRatio: memTotalGB > 0 ? memUsedGB / memTotalGB : 0
property int tempC: 0
property int brightPct: 100
property int batPct: -1 // -1 means no battery detected
property string batStatus: ""
property string batTime: "" // "H:MM" remaining, empty when unknown
property string netIp: ""
property string powerProfile: "balanced"
readonly property var profileIcons: ({
"power-saver": "󰌪",
"balanced": "󰾅",
"performance": "󱐋"
})
readonly property var profileColors: ({
"power-saver": Colors.accent,
"balanced": Colors.fg,
"performance": Colors.warning
})
// ── Line parser ───────────────────────────────────────────────────────────
function parseLine(line) {
if (line.startsWith("vol:")) {
const p = line.split(":");
root.volPct = parseInt(p[1]) || 0;
root.volMuted = p[2] === "1";
} else if (line.startsWith("cpu:")) {
const p = line.split(":");
root.loadAvg = parseFloat(p[1]) || 0;
root.cpuCores = parseInt(p[2]) || 1;
} else if (line.startsWith("mem:")) {
const p = line.split(":");
root.memUsedGB = parseFloat(p[1]) || 0;
root.memTotalGB = parseFloat(p[2]) || 0;
} else if (line.startsWith("temp:")) {
root.tempC = parseInt(line.slice(5)) || 0;
} else if (line.startsWith("bright:")) {
root.brightPct = parseInt(line.slice(7)) || 100;
} else if (line.startsWith("bat:")) {
const p = line.split(":");
const cap = parseInt(p[1]);
root.batPct = isNaN(cap) ? -1 : cap;
root.batStatus = p[2] || "";
root.batTime = p.slice(3).join(":") || ""; // rejoin H:MM split by delimiter
} else if (line.startsWith("net:")) {
root.netIp = line.slice(4).trim();
}
}
// ── Power profile ─────────────────────────────────────────────────────────
Process {
id: getPowerProc
command: ["powerprofilesctl", "get"]
running: true
stdout: SplitParser {
onRead: data => {
const p = data.trim();
if (p.length > 0) root.powerProfile = p;
}
}
}
Process {
id: setPowerProc
property string target: ""
command: ["powerprofilesctl", "set", setPowerProc.target]
onExited: (code, status) => {
if (code === 0) getPowerProc.running = true;
}
}
function cycleProfile() {
const profiles = ["power-saver", "balanced", "performance"];
const idx = profiles.indexOf(powerProfile);
const next = profiles[(idx + 1) % profiles.length];
powerProfile = next;
setPowerProc.target = next;
setPowerProc.running = true;
}
// ── Process ───────────────────────────────────────────────────────────────
Process {
id: statsProc
command: [
"bash", "-c",
// Volume
"vol=$(pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oP '\\d+(?=%)' | head -1); " +
"muted=$(pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null | grep -q yes && echo 1 || echo 0); " +
// CPU: 1-min load average + core count
"load=$(cut -d' ' -f1 /proc/loadavg); " +
"cores=$(nproc); " +
// Memory: used and total in GB
"mem=$(awk '/MemTotal/{t=$2}/MemAvailable/{a=$2}END{printf \"%.2f:%.2f\",(t-a)/1048576,t/1048576}' /proc/meminfo); " +
// Temperature (highest thermal zone; falls back to hwmon)
"temps=$(cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | sort -n | tail -1); " +
"[ -z \"$temps\" ] && temps=$(cat /sys/class/hwmon/hwmon*/temp1_input 2>/dev/null | sort -n | tail -1); " +
// Brightness
"bright_cur=$(cat /sys/class/backlight/*/brightness 2>/dev/null | head -1); " +
"bright_max=$(cat /sys/class/backlight/*/max_brightness 2>/dev/null | head -1); " +
"if [ -n \"$bright_cur\" ] && [ -n \"$bright_max\" ] && [ \"$bright_max\" -gt 0 ]; " +
"then bright=$((bright_cur*100/bright_max)); else bright=100; fi; " +
// Battery capacity + status + estimated time (via acpi, H:MM only)
"bat=$(cat /sys/class/power_supply/BAT*/capacity 2>/dev/null | head -1); " +
"bat_st=$(cat /sys/class/power_supply/BAT*/status 2>/dev/null | head -1); " +
"bat_time=$(acpi -b 2>/dev/null | grep -oP '\\d+:\\d+:\\d+' | head -1 | cut -d: -f1-2); " +
// Network (outbound IP)
"net=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \\K[\\d.]+' | head -1); " +
// Normalise
"temp=$([ -n \"$temps\" ] && echo $((temps/1000)) || echo 0); " +
"[ -z \"$vol\" ] && vol=0; [ -z \"$bat\" ] && bat=-1; " +
// Output
"printf \"vol:%s:%s\\ncpu:%s:%s\\nmem:%s\\ntemp:%s\\nbright:%s\\nbat:%s:%s:%s\\nnet:%s\\n\" " +
"\"$vol\" \"$muted\" \"$load\" \"$cores\" \"$mem\" \"$temp\" \"$bright\" \"$bat\" \"$bat_st\" \"$bat_time\" \"$net\""
]
stdout: SplitParser {
onRead: function(line) { root.parseLine(line) }
}
}
Timer {
interval: 5000
running: true
repeat: true
triggeredOnStart: true
onTriggered: {
statsProc.running = false
statsProc.running = true
}
}
// ── Volume ────────────────────────────────────────────────────────────────
Item {
implicitWidth: volRow.implicitWidth
implicitHeight: volRow.implicitHeight
Process { id: pavucontrolProc; command: ["pavucontrol"] }
Process { id: volUpProc; command: ["pactl", "set-sink-volume", "@DEFAULT_SINK@", "+5%"] }
Process { id: volDownProc; command: ["pactl", "set-sink-volume", "@DEFAULT_SINK@", "-5%"] }
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
onClicked: if (!pavucontrolProc.running) pavucontrolProc.running = true
onWheel: function(wheel) {
if (wheel.angleDelta.y > 0) {
volUpProc.running = false
volUpProc.running = true
root.volPct = Math.min(100, root.volPct + 5)
} else {
volDownProc.running = false
volDownProc.running = true
root.volPct = Math.max(0, root.volPct - 5)
}
}
}
RowLayout {
id: volRow
anchors.fill: parent
spacing: 4
Text {
text: root.volMuted ? "󰖁" : (root.volPct > 50 ? "󰕾" : root.volPct > 0 ? "󰖀" : "󰕿")
color: root.volMuted ? Colors.fgDim : Colors.fg
font.pixelSize: 15
}
Text {
text: root.volMuted ? "mute" : root.volPct + "%"
color: Colors.fg
font.pixelSize: 13
}
}
}
// ── Network ───────────────────────────────────────────────────────────────
RowLayout {
spacing: 4
Text {
text: root.netIp ? "󰱓" : "󰲛"
color: root.netIp ? Colors.fg : Colors.fgDim
font.pixelSize: 15
}
Text {
visible: root.netIp !== ""
text: root.netIp
color: Colors.fg
font.pixelSize: 13
}
}
// ── CPU load average + power profile ─────────────────────────────────────
Item {
implicitWidth: cpuRow.implicitWidth
implicitHeight: cpuRow.implicitHeight
TapHandler {
onTapped: root.cycleProfile()
}
RowLayout {
id: cpuRow
anchors.fill: parent
spacing: 4
Text {
text: "󰻠"
color: Colors.fg
font.pixelSize: 15
}
Text {
text: root.profileIcons[root.powerProfile] ?? ""
color: root.profileColors[root.powerProfile] ?? Colors.fg
font.pixelSize: 13
Behavior on color { ColorAnimation { duration: 80 } }
}
Text {
text: root.loadAvg.toFixed(2)
color: root.loadAvg > root.cpuCores ? Colors.critical :
root.loadAvg > root.cpuCores * 0.7 ? Colors.warning : Colors.fg
font.pixelSize: 13
}
}
}
// ── Memory ────────────────────────────────────────────────────────────────
RowLayout {
spacing: 4
Text { text: "󰍛"; color: Colors.fg; font.pixelSize: 15 }
Text {
text: root.memUsedGB.toFixed(1) + "/" + root.memTotalGB.toFixed(1) + "G"
color: root.memRatio > 0.8 ? Colors.critical :
root.memRatio > 0.6 ? Colors.warning : Colors.fg
font.pixelSize: 13
}
}
// ── Temperature ───────────────────────────────────────────────────────────
RowLayout {
spacing: 4
Text { text: "󰔐"; color: Colors.fg; font.pixelSize: 15 }
Text {
text: root.tempC + "°"
color: root.tempC > 85 ? Colors.critical : root.tempC > 65 ? Colors.warning : Colors.fg
font.pixelSize: 13
}
}
// ── Brightness ────────────────────────────────────────────────────────────
RowLayout {
spacing: 4
Text { text: "󰃟"; color: Colors.fg; font.pixelSize: 15 }
Text {
text: root.brightPct + "%"
color: Colors.fg
font.pixelSize: 13
}
}
// ── Battery (hidden on desktops without a battery) ────────────────────────
RowLayout {
spacing: 4
visible: root.batPct >= 0
Text {
text: root.batStatus === "Charging" ? "󰂄" :
root.batPct > 80 ? "󰁹" :
root.batPct > 60 ? "󰁾" :
root.batPct > 40 ? "󰁼" :
root.batPct > 20 ? "󰁺" : "󰂃"
color: root.batPct < 20 && root.batStatus !== "Charging" ? Colors.critical : Colors.fg
font.pixelSize: 15
}
Text {
text: root.batTime ? root.batPct + "% · " + root.batTime : root.batPct + "%"
color: root.batPct < 20 && root.batStatus !== "Charging" ? Colors.critical : Colors.fg
font.pixelSize: 13
}
}
}