From eecf1a1b6ab18c35bd952bb8390d690c36db1e3b Mon Sep 17 00:00:00 2001 From: Asmir A Date: Sun, 31 May 2026 18:33:48 +0200 Subject: [PATCH] Add top-processes drawer to the CPU widget Clicking the CPU pill opens a popup listing the highest-CPU processes, refreshed live while open. services/topproc.sh computes top-style per-process CPU% and mem% from two /proc snapshots and prints one ranked frame per run; SysStats streams it only while procPollEnabled (drawer open), so the bar pays nothing at rest. CLAUDE.md documents the architecture and the runtime constraints discovered here (helper scripts must be git-tracked for `nix run`; single-shot + exit to flush stdout; bash+coreutils only; StdioCollector/Timer don't fire in this Quickshell build; fixed-size popup to avoid stale-buffer on resize under Sway). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 45 ++++++- services/SysStats.qml | 63 ++++++++++ services/topproc.sh | 79 ++++++++++++ widgets/CpuGraph.qml | 277 ++++++++++++++++++++++++++++++++++-------- 4 files changed, 411 insertions(+), 53 deletions(-) create mode 100755 services/topproc.sh diff --git a/CLAUDE.md b/CLAUDE.md index eee4dea..fee70f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,10 +49,36 @@ modules (right). Changing module order/presence is purely editing that RowLayout reassigning its `path`, as `_parseNet` does for the active interface), writing a `_parseX()`, and calling `reload()` + `_parseX()` inside `_tickOnce()`. - Deltas (CPU%, net rates) keep a `_prev*` field and diff against the last tick. -- **Two metrics escape the pure-virtual-file rule:** disk usage runs `df` via a - `Process` only every 30 ticks (`_tick % 30`), and a one-shot `Process` runs - `services/discover.sh` at startup. The bar avoids per-tick subprocess spawns - by design — keep new metrics reading virtual files, not shelling out. +- **Three things escape the pure-virtual-file rule:** disk usage runs `df` via a + `Process` only every 30 ticks (`_tick % 30`); a one-shot `Process` runs + `services/discover.sh` at startup; and the top-processes list (`topProcs`) is + fed by `services/topproc.sh`, re-run for each frame only while `procPollEnabled` + (true while the CPU drawer is open) — at rest the bar still spawns nothing. The + bar avoids per-tick subprocess spawns by design — keep new always-on metrics + reading virtual files, not shelling out, and gate anything heavier behind an + opt-in flag like `procPollEnabled`. +- **`topproc.sh` does the work, QML just displays.** Each run takes two `/proc` + snapshots `procInterval` apart, computes top-style per-process CPU% and mem% in + the shell, prints one ranked frame of `P ` lines, and + **exits**. `SysStats` accumulates stdout via a `SplitParser`, parses it in + `_parseProcFrame` on `onExited`, then re-arms (`Qt.callLater(running=true)`) + while the drawer stays open. Hard-won constraints — violate any one and you get + silent empty frames / a stuck "sampling…": + - **The helper script must be tracked by git.** `nix run` builds from the flake + snapshot (`${self}`), which excludes untracked files (`git status` will warn + "tree is dirty") — an untracked script simply isn't found at runtime. For + iterative work prefer `quickshell --path .`, which reads the working dir + (untracked files included) and hot-reloads. + - **Single-shot + exit, not a long-running loop.** A looping script's output + stuck in bash's block buffer (pipe stdout is block-buffered), so the QML side + never saw a frame; exiting per run forces a flush. + - **bash builtins + coreutils only** (`nproc`/`sort`/`head`/`sleep`/`cat`). The + flake wrapper's `runtimeInputs` is just `bash` + `coreutils`, so `awk` and + `getconf` are absent at runtime. + - **`StdioCollector.streamFinished` never fired** in this Quickshell build, and + a `Timer`-driven re-arm didn't repeat — but `Process.onStarted`/`onExited` and + `SplitParser.onRead` all work (`discover.sh` is the reference). Invoke helpers + as `["bash", Quickshell.shellPath("services/x.sh")]`. **`discover.sh` handshake.** At startup it prints `TEMP ` and `BAT ` (hwmon temp sensor + main battery, skipping `scope=Device` peripherals) on stdout; @@ -78,3 +104,14 @@ the QML side parses those lines into `tempPath`/`batPath`, which then feed to pick up layer-surface fixes ahead of release. - Native Quickshell services back some modules: `Quickshell.I3` (workspaces), `Quickshell.Services.SystemTray` (tray), PipeWire (volume). +- **Making a module interactive (popups/drawers):** wrap the module's `Pill` in + an `Item` (forward `implicitWidth`/`implicitHeight` from the pill so the bar's + RowLayout still sizes it), add a `MouseArea`, and open a `Quickshell.PopupWindow`; + `CpuGraph.qml` is the reference for a full drawer. Anchor it with + `anchor.item: ` (**not** `anchor.window` — the module is + nested in `Bar.qml`'s RowLayout, so its coordinates aren't window coordinates, + and a window-relative rect lands in the screen corner). `anchor.item` makes the + rect item-relative and derives the window automatically; position the dropdown + with `anchor.edges`/`anchor.gravity` (`Edges.*`) and let Quickshell slide it + on-screen. Adding a `MouseArea` directly to a `Pill`'s default content would + place it in the layout — wrapping in an `Item` avoids that. diff --git a/services/SysStats.qml b/services/SysStats.qml index 508b41c..147c821 100644 --- a/services/SysStats.qml +++ b/services/SysStats.qml @@ -28,6 +28,15 @@ Singleton { property var coreLoads: [] property int coreCount: 0 + // --- top processes (only streamed while a consumer opts in) --- + // Each entry: { name, cpu (top-style %, 100 = one full core), mem (% of RAM) }. + // Set procPollEnabled true (e.g. when the CPU drawer opens) to start the + // streamer; false when nothing is watching, so the bar pays nothing at rest. + property var topProcs: [] + readonly property int procCount: 6 // how many to show, highest CPU first + property bool procPollEnabled: false + readonly property int procInterval: 2 // seconds between frames + readonly property bool hasBattery: battery >= 0 // poll interval in seconds @@ -214,6 +223,25 @@ Singleton { root._prevIface = ifc; } + // Build topProcs from one run of topproc.sh: lines of + // "P ". The script already did the delta math and + // ranking, so this just maps the lines to objects. + function _parseProcFrame(text) { + if (!text) return; + const lines = text.split("\n"); + const out = []; + for (let i = 0; i < lines.length; i++) { + const p = lines[i].split(" "); + if (p[0] !== "P" || p.length < 4) continue; + out.push({ + cpu: Number(p[1]), + mem: Number(p[2]), + name: p.slice(3).join(" ") + }); + } + root.topProcs = out; + } + function _tickOnce() { statView.reload(); cpuInfoView.reload(); @@ -280,6 +308,41 @@ Singleton { } } + // ---- top processes (opt-in, drawer-driven) ---------------------------- + // services/topproc.sh prints one ranked frame and exits; we re-run it every + // ~procInterval (the Timer re-arms it once it exits, like dfProc) only while + // procPollEnabled (a drawer is open), so the bar pays nothing at rest. The + // script itself sleeps procInterval between its two /proc snapshots, so each + // run takes about that long; the Timer just relaunches promptly after. + + property var _procFrame: [] // stdout lines accumulated for the current run + + // topproc.sh prints one ranked frame and exits; accumulate its lines with a + // SplitParser (StdioCollector.streamFinished doesn't fire in this Quickshell + // build) and parse them on exit, then re-arm for the next frame while the + // drawer stays open. discover.sh is the reference for the SplitParser pattern. + Process { + id: procScan + command: ["bash", Quickshell.shellPath("services/topproc.sh"), + String(root.procInterval), String(root.procCount)] + onStarted: root._procFrame = []; + onExited: { + root._parseProcFrame(root._procFrame.join("\n")); + if (root.procPollEnabled) // re-arm for the next frame + Qt.callLater(function () { procScan.running = true; }); + } + stdout: SplitParser { + splitMarker: "\n" + onRead: line => { if (line) root._procFrame.push(line); } + } + } + + // Kick the loop when the drawer opens; clear rows when it closes. + onProcPollEnabledChanged: { + if (procPollEnabled) procScan.running = true; + else root.topProcs = []; + } + // Human-readable byte rate with kB as the smallest unit, e.g. "1.2M", "8K". function fmtRate(bytes) { const u = ["K", "M", "G"]; diff --git a/services/topproc.sh b/services/topproc.sh new file mode 100755 index 0000000..6b13ec4 --- /dev/null +++ b/services/topproc.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Print one frame of the top CPU-consuming processes, then exit. +# +# Single-shot by design: SysStats re-runs it every procInterval while the CPU +# drawer is open (Process.running re-armed by a Timer, like dfProc). Exiting +# per frame guarantees stdout is flushed — a long-running loop kept its frame +# delimiter stuck in bash's block buffer and the QML side never saw a full +# frame. discover.sh is the reference for the bash-script + parse pattern. +# +# IMPORTANT: the flake wrapper only puts bash + coreutils on PATH, so this uses +# ONLY bash builtins and coreutils (nproc, sort, head, sleep, cat) — no awk, no +# getconf. Keep it that way or add the tool to runtimeInputs in flake.nix. +# +# Output: one "P " line per process, highest CPU first. +# cpu% is top-style: 100 == one fully-used core. + +intv="${1:-2}" +n="${2:-6}" +ncpu="$(nproc 2>/dev/null || echo 1)" +pg_kb=4 # page size is 4096B on all targeted arches (x86_64/aarch64) + +# MemTotal in kB, read without awk. +memkb=0 +while read -r key val _; do + if [ "$key" = "MemTotal:" ]; then memkb="$val"; break; fi +done < /proc/meminfo + +# Aggregate CPU jiffies: sum of the fields on the leading "cpu" line. +cpu_total() { + local line f s=0 + read -r line < /proc/stat + # shellcheck disable=SC2086 + set -- $line # collapses the double space; $1 == "cpu" + shift + for f in "$@"; do s=$(( s + f )); done + echo "$s" +} + +# Read pid -> (utime+stime) jiffies and rss pages into the given assoc arrays. +# /proc//stat is "PID (comm) STATE ...": strip through ") " so comm spaces +# and parens can't shift field positions, then utime=14, stime=15, rss=24. +snapshot() { + local -n _jif="$1" _rss="$2" + local f l pid rest + for f in /proc/[0-9]*/stat; do + # 2>/dev/null first so a process vanishing mid-scan is silent. + IFS= read -r l 2>/dev/null < "$f" || continue + pid="${l%% *}" + rest="${l#*) }" + # shellcheck disable=SC2086 + set -- $rest + _jif["$pid"]=$(( ${12:-0} + ${13:-0} )) + _rss["$pid"]="${22:-0}" + done +} + +declare -A j1 j2 rss +t1="$(cpu_total)" +snapshot j1 rss +sleep "$intv" +t2="$(cpu_total)" +snapshot j2 rss +dt=$(( t2 - t1 )) +[ "$dt" -gt 0 ] || exit 0 + +# Emit " " for each process seen in both snapshots, sort by cpu +# desc, take the top n, then resolve name + mem% for just those. +for pid in "${!j2[@]}"; do + [ -n "${j1[$pid]:-}" ] || continue + d=$(( ${j2[$pid]} - ${j1[$pid]} )) + cpu=$(( 100 * ncpu * d / dt )) + printf '%d %s %s\n' "$cpu" "$pid" "${rss[$pid]:-0}" +done | sort -rn | head -n "$n" | while read -r cpu pid r; do + name="$(cat "/proc/$pid/comm" 2>/dev/null)" + [ -n "$name" ] || name="$pid" + mem=0 + [ "$memkb" -gt 0 ] && mem=$(( r * pg_kb * 100 / memkb )) + printf 'P %s %s %s\n' "$cpu" "$mem" "$name" +done diff --git a/widgets/CpuGraph.qml b/widgets/CpuGraph.qml index 7629a3b..5e4f059 100644 --- a/widgets/CpuGraph.qml +++ b/widgets/CpuGraph.qml @@ -1,66 +1,245 @@ import QtQuick import QtQuick.Layouts +import Quickshell import "../config" import "../services" // CPU usage as a live filled-area graph plus the current percentage. -Pill { +// Clicking the pill toggles a drawer listing the top CPU-consuming processes, +// refreshed live (SysStats only scans /proc while the drawer is open). +Item { id: root - spacing: Theme.spacing - Text { - text: Icons.cpu - font.family: Theme.monoFont - font.pixelSize: Theme.fontSize + 1 - color: Theme.loadColor(SysStats.cpu) - Layout.alignment: Qt.AlignVCenter + implicitWidth: pill.implicitWidth + implicitHeight: pill.implicitHeight + + Pill { + id: pill + anchors.fill: parent + spacing: Theme.spacing + + Text { + text: Icons.cpu + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize + 1 + color: Theme.loadColor(SysStats.cpu) + Layout.alignment: Qt.AlignVCenter + } + + // One vertical bar per core, filled from the bottom by that core's load. + Row { + id: cores + Layout.alignment: Qt.AlignVCenter + spacing: 1 + readonly property int barH: Theme.barHeight - Theme.gap * 2 - 12 + + Repeater { + model: SysStats.coreCount + delegate: Rectangle { + readonly property real load: SysStats.coreLoads[index] || 0 + width: 3 + height: cores.barH + radius: 1 + color: Qt.rgba(1, 1, 1, 0.08) // unfilled track + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + radius: 1 + height: Math.max(1, parent.height * (parent.load / 100)) + color: Theme.loadColor(parent.load) + } + } + } + } + + Text { + text: SysStats.cpu.toFixed(0) + "%" + font.family: Theme.font + font.pixelSize: Theme.fontSize + color: Theme.text + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: 42 // fits "100%" at fontSize 15 + horizontalAlignment: Text.AlignRight + } + + // Max current core frequency, shown in GHz. + Text { + text: (SysStats.cpuFreq / 1000).toFixed(1) + "GHz" + font.family: Theme.font + font.pixelSize: Theme.fontSize + color: Theme.subtext + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: 56 // fits "0.0GHz" at fontSize 15 + horizontalAlignment: Text.AlignRight + } } - // One vertical bar per core, filled from the bottom by that core's load. - Row { - id: cores - Layout.alignment: Qt.AlignVCenter - spacing: 1 - readonly property int barH: Theme.barHeight - Theme.gap * 2 - 12 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: drawer.visible = !drawer.visible + } - Repeater { - model: SysStats.coreCount - delegate: Rectangle { - readonly property real load: SysStats.coreLoads[index] || 0 - width: 3 - height: cores.barH - radius: 1 - color: Qt.rgba(1, 1, 1, 0.08) // unfilled track + // ---- top-processes drawer --------------------------------------------- - Rectangle { - anchors.bottom: parent.bottom - width: parent.width - radius: 1 - height: Math.max(1, parent.height * (parent.load / 100)) - color: Theme.loadColor(parent.load) + PopupWindow { + id: drawer + visible: false + color: "transparent" + + implicitWidth: panel.implicitWidth + implicitHeight: panel.implicitHeight + + // Hang below the pill, top-right corner aligned to the pill's + // bottom-right; Quickshell slides it to stay on-screen if it would + // overflow the right edge. Anchoring to the item (not the window) means + // the rect is in the pill's own coordinates, so it tracks the pill + // wherever the RowLayout places it. + anchor { + item: root + rect.x: 0 + rect.y: root.height + Theme.gap + rect.width: root.width + rect.height: 1 + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + } + + // Only pay for the /proc scan while the drawer is actually showing. + onVisibleChanged: SysStats.procPollEnabled = visible + + Rectangle { + id: panel + readonly property int rowH: 22 + implicitWidth: 320 + // Fixed height (header + procCount rows): the panel must NOT resize + // when rows replace "sampling…", or the popup's layer surface shows a + // stale/blank buffer under Sway on first open (same class of bug as + // Bar.qml's geomKey remap). A constant size sidesteps it entirely. + implicitHeight: col.implicitHeight + Theme.padding * 2 + radius: Theme.radius + color: Theme.barColor + border.color: Theme.surface1 + border.width: 1 + + ColumnLayout { + id: col + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.padding + spacing: 3 + + // header: title + overall CPU + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing + + Text { + text: Icons.cpu + font.family: Theme.monoFont + font.pixelSize: Theme.fontSize + 1 + color: Theme.loadColor(SysStats.cpu) + } + Text { + text: "Top processes" + font.family: Theme.font + font.pixelSize: Theme.fontSize + color: Theme.text + Layout.fillWidth: true + } + Text { + text: SysStats.cpu.toFixed(0) + "% total" + font.family: Theme.font + font.pixelSize: Theme.fontSize - 2 + color: Theme.subtext + } + } + + Rectangle { // divider + Layout.fillWidth: true + Layout.topMargin: 2 + Layout.bottomMargin: 2 + implicitHeight: 1 + color: Theme.surface1 + } + + // Fixed-height body so the panel stays one constant size whether + // it shows the placeholder or the rows (see panel.implicitHeight). + Item { + Layout.fillWidth: true + Layout.topMargin: 2 + implicitHeight: SysStats.procCount * panel.rowH + + Text { + anchors.centerIn: parent + visible: SysStats.topProcs.length === 0 + text: "sampling…" + font.family: Theme.font + font.pixelSize: Theme.fontSize - 2 + color: Theme.subtext + } + + Column { + anchors.fill: parent + + Repeater { + model: SysStats.topProcs + + delegate: Item { + id: rowItem + required property var modelData + width: parent.width + height: panel.rowH + + // load bar behind the text; full width == one busy core. + Rectangle { + readonly property color loadColor: Theme.loadColor(rowItem.modelData.cpu) + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + height: parent.height - 4 + radius: 3 + width: parent.width * Math.min(1, rowItem.modelData.cpu / 100) + color: Qt.rgba(loadColor.r, loadColor.g, loadColor.b, 0.18) + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: Theme.spacing + + Text { + text: rowItem.modelData.name + font.family: Theme.font + font.pixelSize: Theme.fontSize - 1 + color: Theme.text + elide: Text.ElideRight + Layout.fillWidth: true + } + Text { + text: rowItem.modelData.mem.toFixed(0) + "% mem" + font.family: Theme.font + font.pixelSize: Theme.fontSize - 3 + color: Theme.subtext + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 54 + } + Text { + text: rowItem.modelData.cpu.toFixed(0) + "%" + font.family: Theme.font + font.pixelSize: Theme.fontSize - 1 + color: Theme.loadColor(rowItem.modelData.cpu) + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 40 + } + } + } + } + } } } } } - - Text { - text: SysStats.cpu.toFixed(0) + "%" - font.family: Theme.font - font.pixelSize: Theme.fontSize - color: Theme.text - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: 42 // fits "100%" at fontSize 15 - horizontalAlignment: Text.AlignRight - } - - // Max current core frequency, shown in GHz. - Text { - text: (SysStats.cpuFreq / 1000).toFixed(1) + "GHz" - font.family: Theme.font - font.pixelSize: Theme.fontSize - color: Theme.subtext - Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: 56 // fits "0.0GHz" at fontSize 15 - horizontalAlignment: Text.AlignRight - } }