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

@@ -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
}
}