feat(quickshell): add hyprchrome bar with collapsible panels
A second shell chrome under dotfiles/quickshell/hyprchrome, built around a BarPanel that carries TWO renderings of its data: the default children are the expanded detail view, `summary` the terse one shown while collapsed. Both stay bound to the same sources, so the densities cannot disagree, and the panel cross-fades between them while its height animates. Panels: HostPanel (hostname, user, timezone, clock), VitalsPanel (CPU load and temperature, memory, GPU load and temperature, all metered), TrayPanel (system tray, self-sizing). HyprChromeBar pins them to DP-2 and owns `expanded` for the whole rail — SUPER A, via GlobalShortcut "chrome". GPU busy comes off sysfs rather than the node_exporter scrape VitalsData already does: the hwmon collector carries the card's temps, power and clocks but not its utilisation. The bar's height binds to each panel's `targetHeight` — where it will settle, not where the animation currently is — because the exclusive zone is window-sized by default, and binding to the animated height relayouts every tiled window on the output twelve times per toggle. The zone follows the target immediately so the desktop reflows once, at the start; the surface itself shrinks only after the panels finish, or it would clip them mid-animation. DebugWindow stages a widget in the middle of the secondary monitor (SUPER CTRL D), masked so only the staged widget takes pointer input. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
// amdgpu utilisation, straight off sysfs.
|
||||
//
|
||||
// node_exporter's hwmon collector carries the card's temperatures, power and
|
||||
// clocks — which is where VitalsData gets them — but not its busy percentage,
|
||||
// so this is the one vital that cannot come from the same scrape.
|
||||
//
|
||||
// The card number is globbed rather than pinned: it is card1 on terra today,
|
||||
// but it depends on probe order and moves when a GPU is added or removed.
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
// Poll only while something is showing it, like VitalsData.
|
||||
property bool active: false
|
||||
property int interval: 2000
|
||||
|
||||
property real value: 0 // 0..1 busy
|
||||
property bool ready: false
|
||||
|
||||
onActiveChanged: {
|
||||
if (!root.active)
|
||||
root.ready = false;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: probe
|
||||
|
||||
command: ["sh", "-c", "cat /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | head -n1"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const busy = parseInt(this.text.trim(), 10);
|
||||
if (isFinite(busy)) {
|
||||
root.value = Math.max(0, Math.min(1, busy / 100));
|
||||
root.ready = true;
|
||||
} else {
|
||||
// No amdgpu (or no permission) — leave the meter blank
|
||||
// rather than pinning it at zero, which would read as idle.
|
||||
root.ready = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: root.interval
|
||||
running: root.active
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
if (!probe.running)
|
||||
probe.running = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.hyprchrome.theme
|
||||
|
||||
// Segmented horizontal meter: a row of cells lit up to `value`.
|
||||
//
|
||||
// A copy of the dense bar's meter rather than a reuse of it — that one is an
|
||||
// inline component inside DenseBarContent.qml and so is not visible from any
|
||||
// other file (the same reason BarPanel inlines its own MicroText).
|
||||
Row {
|
||||
id: meter
|
||||
|
||||
property int segments: 16
|
||||
property real value: 0 // 0..1
|
||||
property bool ready: false
|
||||
property real warn: 0.85 // fraction at which the lit cells go hot
|
||||
|
||||
readonly property real fraction: Math.max(0, Math.min(1, meter.value))
|
||||
readonly property bool hot: meter.ready && meter.fraction >= meter.warn
|
||||
readonly property color litColor: meter.hot ? Theme.hot : Theme.accent
|
||||
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: meter.segments
|
||||
|
||||
Rectangle {
|
||||
required property int index
|
||||
|
||||
readonly property bool lit: meter.ready && index < Math.round(meter.fraction * meter.segments)
|
||||
|
||||
width: Math.max(2, (meter.width - (meter.segments - 1) * meter.spacing) / meter.segments)
|
||||
height: meter.height
|
||||
color: lit ? meter.litColor : Theme.textAlpha(0.06)
|
||||
border.width: 1
|
||||
border.color: lit ? meter.litColor : Theme.textAlpha(0.18)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import qs.hyprchrome.theme
|
||||
|
||||
// One metric of the expanded vitals panel: label, meter, readout on a line.
|
||||
//
|
||||
// The label and readout columns are fixed so the meters of stacked rows line
|
||||
// up on both edges regardless of how long any one readout gets.
|
||||
Item {
|
||||
id: row
|
||||
|
||||
property string label: ""
|
||||
property real value: 0 // 0..1
|
||||
property string readout: "--"
|
||||
property bool ready: false
|
||||
property real warn: 0.85
|
||||
|
||||
property int labelWidth: 52
|
||||
property int readoutWidth: 46
|
||||
|
||||
readonly property bool hot: row.ready && row.value >= row.warn
|
||||
|
||||
implicitHeight: 11
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: row.labelWidth
|
||||
text: row.label
|
||||
color: Theme.muted
|
||||
font.family: Theme.microFont
|
||||
font.pixelSize: 10
|
||||
font.letterSpacing: 0.7
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
SegmentMeter {
|
||||
x: row.labelWidth
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(0, row.width - row.labelWidth - row.readoutWidth)
|
||||
height: 9
|
||||
value: row.value
|
||||
ready: row.ready
|
||||
warn: row.warn
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: row.readoutWidth
|
||||
text: row.readout
|
||||
color: row.hot ? Theme.hot : Theme.text
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 10
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
import qs.hyprchrome.theme
|
||||
import qs.hyprchrome.widgets.panels
|
||||
import qs.widgets.vitals
|
||||
|
||||
// Host vitals in the hyprchrome panel chrome, in both of BarPanel's densities.
|
||||
//
|
||||
// Expanded: CPU load and temperature, memory usage, GPU load and temperature,
|
||||
// each as a segmented meter with its readout.
|
||||
// Collapsed: the same five numbers as percentages behind Nerd Font glyphs.
|
||||
//
|
||||
// Both bodies read the same properties below, so the two densities cannot
|
||||
// disagree — they are one set of numbers rendered twice.
|
||||
//
|
||||
// The scrape comes from the shared VitalsData (node_exporter over loopback);
|
||||
// GPU busy is the one reading that scrape does not carry, so it comes off
|
||||
// sysfs through GpuBusy.
|
||||
BarPanel {
|
||||
id: panel
|
||||
|
||||
panelId: "MON"
|
||||
title: "RESOURCE MONITOR"
|
||||
meta: vitals.failed ? "OFFLINE" : vitals.ready ? "REALTIME" : "PRIMING"
|
||||
|
||||
// Poll only while the panel exists on screen; both sources idle otherwise.
|
||||
property bool polling: true
|
||||
|
||||
// Temperatures are metered against a 0–100 °C span so a bar means the same
|
||||
// thing on every row.
|
||||
readonly property real tempCeiling: 100
|
||||
|
||||
readonly property real memoryFraction: vitals.memTotal > 0 ? vitals.memUsed / vitals.memTotal : 0
|
||||
readonly property bool cpuTempReady: isFinite(vitals.cpuTemp)
|
||||
readonly property bool gpuTempReady: isFinite(vitals.gpuTemp)
|
||||
|
||||
function pct(value, ready) {
|
||||
return ready ? Math.round(Math.max(0, Math.min(1, value)) * 100) + "%" : "--";
|
||||
}
|
||||
|
||||
function tempFraction(celsius) {
|
||||
return isFinite(celsius) ? Math.max(0, Math.min(1, celsius / panel.tempCeiling)) : 0;
|
||||
}
|
||||
|
||||
VitalsData {
|
||||
id: vitals
|
||||
active: panel.polling
|
||||
}
|
||||
|
||||
GpuBusy {
|
||||
id: gpu
|
||||
active: panel.polling
|
||||
}
|
||||
|
||||
// Collapsed: glyph + percentage, in the same order as the rows below.
|
||||
// Codepoints are Nerd Fonts v3 — oct-cpu, fa-thermometer-half,
|
||||
// md-memory, md-expansion-card-variant — all present in DepartureMono.
|
||||
summary: RowLayout {
|
||||
spacing: 12
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
Readout { icon: "CPU:"; value: panel.pct(vitals.cpu, vitals.ratesReady) }
|
||||
Readout { icon: "CPU Temp:"; value: vitals.fmtTemp(vitals.cpuTemp) }
|
||||
Readout { icon: "Mem:"; value: panel.pct(panel.memoryFraction, vitals.ready) }
|
||||
Readout { icon: "GPU:"; value: panel.pct(gpu.value, gpu.ready) }
|
||||
Readout { icon: "GPU Temp:"; value: vitals.fmtTemp(vitals.gpuTemp) }
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
// Expanded: the same five, metered.
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 5
|
||||
|
||||
VitalRow {
|
||||
width: parent.width
|
||||
label: "CPU"
|
||||
value: vitals.cpu
|
||||
ready: vitals.ratesReady && !vitals.failed
|
||||
readout: panel.pct(vitals.cpu, vitals.ratesReady)
|
||||
}
|
||||
|
||||
VitalRow {
|
||||
width: parent.width
|
||||
label: "CPU TMP"
|
||||
value: panel.tempFraction(vitals.cpuTemp)
|
||||
ready: panel.cpuTempReady
|
||||
readout: vitals.fmtTemp(vitals.cpuTemp)
|
||||
warn: 0.85
|
||||
}
|
||||
|
||||
VitalRow {
|
||||
width: parent.width
|
||||
label: "MEM"
|
||||
value: panel.memoryFraction
|
||||
ready: vitals.ready && !vitals.failed
|
||||
readout: panel.pct(panel.memoryFraction, vitals.ready)
|
||||
}
|
||||
|
||||
VitalRow {
|
||||
width: parent.width
|
||||
label: "GPU"
|
||||
value: gpu.value
|
||||
ready: gpu.ready
|
||||
readout: panel.pct(gpu.value, gpu.ready)
|
||||
}
|
||||
|
||||
VitalRow {
|
||||
width: parent.width
|
||||
label: "GPU TMP"
|
||||
value: panel.tempFraction(vitals.gpuTemp)
|
||||
ready: panel.gpuTempReady
|
||||
readout: vitals.fmtTemp(vitals.gpuTemp)
|
||||
warn: 0.85
|
||||
}
|
||||
}
|
||||
|
||||
component Readout: Row {
|
||||
property string icon: ""
|
||||
property string value: "--"
|
||||
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
text: parent.icon
|
||||
color: Theme.accent
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
Text {
|
||||
text: parent.value
|
||||
color: Theme.text
|
||||
font.family: Theme.displayFont
|
||||
font.pixelSize: 12
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user