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:
2026-08-29 02:38:00 +02:00
co-authored by Claude Opus 5
parent 6406e06330
commit e38a8403ac
15 changed files with 1444 additions and 2 deletions
@@ -0,0 +1,217 @@
pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
import qs.hyprchrome.theme
import qs.hyprchrome.widgets.panels
// Host identity in the hyprchrome panel chrome: the machine's name set large,
// with its timezone and the current date and time.
//
// Expanded: name on the left, clock stack on the right.
// Collapsed: name, time and zone abbreviation on the header line.
//
// The name and the zone come from one shell call at startup — neither changes
// while the shell runs, so there is nothing to poll. The clock is a plain
// Timer; `now` is the single source both densities read, so they always agree
// down to the second.
BarPanel {
id: panel
panelId: "HST"
title: "HOST"
meta: panel.zoneAbbrev
property string hostName: "LOCAL"
property string zoneName: "" // IANA, e.g. EUROPE/BERLIN
property string userName: "" // whoever is logged in, e.g. DARMAN
property date now: new Date()
// Qt resolves the abbreviation ("CEST") against the same zone the offset
// comes from, so the two can never disagree.
readonly property string zoneAbbrev: Qt.formatDateTime(panel.now, "t")
readonly property string utcOffset: {
const hours = -panel.now.getTimezoneOffset() / 60;
return "UTC" + (hours >= 0 ? "+" : "") + (Number.isInteger(hours) ? hours : hours.toFixed(1));
}
function two(value) {
return value < 10 ? "0" + value : String(value);
}
function timeText(value) {
return panel.two(value.getHours()) + ":" + panel.two(value.getMinutes()) + ":" + panel.two(value.getSeconds());
}
function dateText(value) {
const days = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
return days[value.getDay()] + " // " + panel.two(value.getDate()) + " " + months[value.getMonth()] + " " + value.getFullYear();
}
Timer {
interval: 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: panel.now = new Date()
}
Process {
running: true
// /etc/localtime is a symlink into the zoneinfo tree; its tail is the
// IANA name, which no environment variable reliably carries.
command: ["sh", "-c", "cat /proc/sys/kernel/hostname; readlink -f /etc/localtime; id -un"]
stdout: StdioCollector {
onStreamFinished: {
const lines = this.text.trim().split("\n");
if (lines.length > 0 && lines[0].trim().length > 0)
panel.hostName = lines[0].trim().toUpperCase();
if (lines.length > 1) {
const zone = lines[1].match(/zoneinfo\/(.+)$/);
if (zone)
panel.zoneName = zone[1].toUpperCase();
}
if (lines.length > 2 && lines[2].trim().length > 0)
panel.userName = lines[2].trim().toUpperCase();
}
}
}
// Collapsed: who and when, nothing else.
summary: Row {
spacing: 10
Text {
id: hostLabel
text: panel.hostName
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 13
font.bold: true
font.letterSpacing: 1.2
}
// The pieces are set at two sizes. A Row positions its children at the
// top and has no item alignment of its own (that is Grid), and a
// vertical anchor inside a positioner is ignored — so the smaller
// pieces take the tallest one's height and centre their text in it.
Text {
text: panel.timeText(panel.now)
color: Theme.accent
font.family: Theme.displayFont
font.pixelSize: 13
font.bold: true
height: hostLabel.implicitHeight
verticalAlignment: Text.AlignVCenter
}
Text {
text: panel.dateText(panel.now)
color: Theme.text
font.family: Theme.microFont
font.pixelSize: 11
height: hostLabel.implicitHeight
verticalAlignment: Text.AlignVCenter
}
Text {
text: panel.zoneAbbrev
color: Theme.muted
font.family: Theme.microFont
font.pixelSize: 11
height: hostLabel.implicitHeight
verticalAlignment: Text.AlignVCenter
}
}
// Expanded: name left, clock stack right.
RowLayout {
width: parent.width
height: 48
spacing: 16
Column {
Layout.fillWidth: true
Layout.fillHeight: true
Text {
id: hostText
text: panel.hostName
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 25
font.bold: true
font.letterSpacing: 2
elide: Text.ElideRight
}
Text {
y: 32
text: (panel.userName || "NODE") + " // " + (panel.zoneName || "LOCAL")
color: Theme.accent
font.family: Theme.microFont
font.pixelSize: 9
font.letterSpacing: 1.4
elide: Text.ElideRight
}
}
// Rule between identity and clock, the same hairline the dense bar
// puts between its host name and node readout.
Rectangle {
Layout.alignment: Qt.AlignCenter
y: 2
width: 2
height: parent.height - 8
color: Theme.hair
}
Column {
id: clock
Layout.fillWidth: true
Layout.fillHeight: true
Layout.alignment: Qt.AlignRight
width: 210
spacing: 2
Text {
width: parent.width
text: panel.timeText(panel.now)
horizontalAlignment: Text.AlignRight
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 18
font.bold: true
font.letterSpacing: 1
}
Text {
width: parent.width
text: panel.dateText(panel.now)
horizontalAlignment: Text.AlignRight
color: Theme.accent
font.family: Theme.microFont
font.pixelSize: 11
}
Text {
width: parent.width
text: panel.zoneAbbrev + " // " + panel.utcOffset
horizontalAlignment: Text.AlignRight
color: Theme.muted
font.family: Theme.microFont
font.pixelSize: 9
font.letterSpacing: 0.7
}
}
}
}