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 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 18:33:48 +02:00
parent f8a4536a02
commit eecf1a1b6a
4 changed files with 411 additions and 53 deletions

View File

@@ -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 <cpu%> <mem%> <name>". 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"];

79
services/topproc.sh Executable file
View File

@@ -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 <cpu%> <mem%> <name>" 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/<pid>/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 "<cpu> <pid> <rss>" 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