Files
homelab/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml
T
darmanandClaude Opus 5 e38a8403ac 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
2026-08-29 02:38:00 +02:00

388 lines
14 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Shapes
import qs.hyprchrome.theme
// Chamfered panel chrome for the dense status rail: outline, corner accent
// lines, header strip (id chip / title / meta / tick marks) and a collapsing
// body.
//
// The body holds TWO renderings of the same data: the default children are the
// expanded detail view, `summary` the terse one shown while collapsed. Both
// stay instantiated and bound to the same sources — two renderings of one
// truth, not two truths — and the panel cross-fades between them while its
// height animates to whichever is showing.
//
// BarPanel {
// panelId: "02"
// summary: Text { text: "CPU 43%" }
// MetricRow { /* the full view */ }
// }
//
// Expanded, the detail view sits under the header rule. Collapsed, the summary
// moves up ONTO the header line, starting just right of the slug chip and
// centred in the strip, so the whole panel becomes a single line. The slug
// in both modes; title, meta and tick deco fade out with the detail body.
//
// Each slot keeps its own fixed geometry — only the panel's height animates,
// and the body is clipped — so neither rendering reflows while the other one
// is fading. Slot children are measured (`childrenRect`), so they must carry
// their own size and must NOT anchor to the slot.
//
// Colors and fonts both come from the Theme singleton.
Item {
id: panel
property string panelId: ""
property string title: ""
property string meta: ""
property bool expanded: true
property int chamfer: 13
property int offsetY: 2
property int accentLineThickness: 3
readonly property int headerHeight: 28
// Breathing room between the header rule and the detail view. The
// collapsed summary is unaffected — it sits on the header line itself.
property int headerGap: 8
// Left offset of the header row, and the inset the upper-left accent line
// is sized against.
readonly property int headerPadding: 8
// Upper-left accent line, sized to the slug chip it underlines. Lives on
// the panel rather than on the ShapePath: a PathLine does not see its
// ShapePath's own properties by bare name (they resolve through the
// component scope, not the parent object).
// Unclamped: what the header chrome WANTS to span. Everything that has to
// stay independent of the panel's final width reads this one — a width
// that clamps against panel.width cannot also decide it.
readonly property real headerContentWidth: slugChip.width + panel.headerPadding * 2
readonly property real accentLineWidth: Math.min(panel.width, panel.headerContentWidth)
// Narrowest the panel can be before the title runs into the meta text and
// tick deco. A panel that sizes itself to its content (the tray) has to
// take this as a floor; none of it depends on panel.width, so it can.
readonly property real headerMinWidth: panel.headerContentWidth + panelTitle.width
+ headerRow.spacing + headerEnd.width + 12 + panel.headerPadding
property int padding: 12
// Floor for the animated height, so a collapsed strip with a short (or
// empty) summary still reads as a panel rather than a hairline.
property int minimumHeight: 38
// Self-toggling is a convenience for staging a panel on its own. A host
// that drives `expanded` for a whole group turns it off: assigning to a
// bound property from a click would destroy that binding for good.
property bool toggleOnClick: true
default property alias content: detail.data
property alias summary: brief.data
// Where the summary sits when collapsed: just past the slug chip, and
// centred in the strip the panel collapses to, which is sized to the
// summary itself (or the height floor, whichever is taller).
readonly property real briefLeft: panel.headerContentWidth
readonly property real collapsedHeight: Math.max(panel.minimumHeight, brief.height + panel.headerPadding * 2)
readonly property real briefTop: Math.round((panel.collapsedHeight - brief.height) / 2)
// Bottom of the visible body, and the gap kept below it. The states bind
// these to whichever rendering is showing and the transitions animate them,
// so they are plain properties rather than ternaries on implicitHeight —
// an animation cannot drive a binding.
property real bodyBottom: detail.y + detail.height
property real bodyEndPadding: panel.padding
implicitHeight: Math.max(panel.minimumHeight, panel.bodyBottom + panel.bodyEndPadding)
// Where the panel will settle in its current state, skipping the values
// the transition passes through. A window sized to this reconfigures once
// per toggle rather than once per animation frame — which, for a
// layer-shell bar with an automatic exclusive zone, is the difference
// between one relayout of the desktop and a dozen.
readonly property real targetHeight: Math.max(panel.minimumHeight, panel.expanded
? detail.y + detail.height + panel.padding
: panel.collapsedHeight)
// The two chamfer cuts (top-right at y=chamfer, bottom-left at
// height-chamfer) cross once the panel is shorter than twice the chamfer,
// which turns the outline inside out for the last frames of a collapse.
readonly property real activeChamfer: Math.max(2, Math.min(panel.chamfer, panel.height / 2 - 1))
Shape {
id: panelShape
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
// Main panel shape
ShapePath {
fillColor: Theme.surface
strokeColor: Theme.hair
strokeWidth: 1
startX: 0; startY: panel.offsetY
PathLine { x: panelShape.width - panel.activeChamfer; y: panel.offsetY }
PathLine { x: panelShape.width; y: panel.activeChamfer }
PathLine { x: panelShape.width; y: panelShape.height }
PathLine { x: panel.activeChamfer; y: panelShape.height }
PathLine { x: 0; y: panelShape.height - panel.activeChamfer }
PathLine { x: 0; y: panel.offsetY }
}
// Upper left accent line
ShapePath {
fillColor: Theme.accent
strokeWidth: 0
startX: 0; startY: 0
PathLine { x: panel.accentLineWidth; y: 0 }
PathLine { x: panel.accentLineWidth; y: panel.accentLineThickness }
PathLine { x: 0; y: panel.accentLineThickness }
PathLine { x: 0; y: 0 }
}
// Lower right accent line
ShapePath {
fillColor: Theme.accent
strokeWidth: 0
startX: panelShape.width; startY: panelShape.height
PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height }
PathLine { x: panelShape.width - Math.min(49, panelShape.width / 3); y: panelShape.height - panel.accentLineThickness }
PathLine { x: panelShape.width; y: panelShape.height - panel.accentLineThickness }
PathLine { x: panelShape.width; y: panelShape.height }
}
}
// Header
Row {
id: headerRow
x: panel.headerPadding
// Centred in the header band rather than pinned, so the chip keeps
// clear of the rule when the slug font changes size.
y: Math.round((panel.headerHeight - height) / 2)
// Puts the title where the accent line ends: chip + headerPadding on
// both sides of it.
spacing: panel.headerPadding
// Header slug — the one piece that survives a collapse, so the strip
// still says which panel it is.
Rectangle {
id: slugChip
width: panelSlugText.implicitWidth + 6
height: panelSlugText.implicitHeight + 3
color: Theme.accent
Text {
id: panelSlugText
anchors.centerIn: parent
text: panel.panelId
color: Theme.surface
font.family: Theme.microFont
font.pixelSize: 9
font.bold: true
}
}
// Header title
Item {
id: panelTitle
width: panelTitleText.implicitWidth + 6
height: panelTitleText.implicitHeight + 3
Text {
id: panelTitleText
anchors.centerIn: parent
text: panel.title
color: Theme.text
font.family: Theme.displayFont
font.pixelSize: 11
font.bold: true
font.letterSpacing: 1.1
elide: Text.ElideRight
}
}
}
// Everything else in the header fades as one, so a collapse is a single
// coordinated move rather than four independently timed ones.
Item {
id: headerExtras
anchors.fill: parent
// Header separator
Rectangle {
x: 1; y: panel.headerHeight
width: parent.width - 2
height: 1
color: Theme.text
opacity: 0.12
}
// Header end deco
Row {
id: headerEnd
anchors.right: parent.right
anchors.rightMargin: 12
y: 12
spacing: 4
Text {
id: metaText
visible: panel.meta.length > 0
text: panel.meta
color: Theme.muted
font.family: Theme.microFont
font.pixelSize: 8
font.letterSpacing: 0.7
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
}
// Same height as the meta text, so the Row aligning both by their
// tops also aligns them by their bottoms — no wrapper needed.
Row {
spacing: 2
Repeater {
model: 5
Rectangle { required property int index; width: 4; height: metaText.implicitHeight; color: Theme.accent }
}
}
}
}
// Body. Spans the panel and clips, because mid-collapse the panel is
// already shorter than the detail view that is still fading out.
Item {
id: bodyClip
anchors.fill: parent
clip: true
Item {
id: detail
x: panel.padding
y: panel.headerHeight + panel.headerGap
width: Math.max(0, panel.width - panel.padding * 2)
height: childrenRect.height
visible: opacity > 0
}
Item {
id: brief
x: panel.briefLeft
y: panel.briefTop
width: Math.max(0, panel.width - panel.briefLeft - panel.padding)
height: childrenRect.height
opacity: 0
visible: opacity > 0
}
}
// Click anywhere on the panel to switch densities. Sits above the body, so
// interactive content in a slot would need its own handler on top of this.
MouseArea {
anchors.fill: parent
onClicked: {
if (panel.toggleOnClick)
panel.expanded = !panel.expanded;
}
}
states: [
State {
name: "expanded"
when: panel.expanded
PropertyChanges {
target: panel
bodyBottom: detail.y + detail.height
bodyEndPadding: panel.padding
}
PropertyChanges { target: detail; opacity: 1 }
PropertyChanges { target: brief; opacity: 0 }
PropertyChanges { target: panelTitle; opacity: 1 }
PropertyChanges { target: headerExtras; opacity: 1 }
},
State {
name: "collapsed"
when: !panel.expanded
// Symmetric about the slug's midline: the same gap the summary has
// above it is kept below, so the strip reads as one line.
PropertyChanges {
target: panel
bodyBottom: brief.y + brief.height
bodyEndPadding: panel.collapsedHeight - brief.y - brief.height
}
PropertyChanges { target: detail; opacity: 0 }
PropertyChanges { target: brief; opacity: 1 }
PropertyChanges { target: panelTitle; opacity: 0 }
PropertyChanges { target: headerExtras; opacity: 0 }
}
]
// Out fast, resize, in late. Fading both bodies on the same clock would
// show them at half opacity on top of each other in the middle frames.
transitions: [
Transition {
to: "collapsed"
ParallelAnimation {
NumberAnimation {
targets: [detail, panelTitle, headerExtras]
property: "opacity"
duration: 90
easing.type: Easing.OutCubic
}
NumberAnimation {
target: panel
properties: "bodyBottom,bodyEndPadding"
duration: 200
easing.type: Easing.OutCubic
}
SequentialAnimation {
PauseAnimation { duration: 110 }
NumberAnimation {
target: brief
property: "opacity"
duration: 120
easing.type: Easing.OutCubic
}
}
}
},
Transition {
to: "expanded"
ParallelAnimation {
NumberAnimation {
target: brief
property: "opacity"
duration: 90
easing.type: Easing.OutCubic
}
NumberAnimation {
target: panel
properties: "bodyBottom,bodyEndPadding"
duration: 200
easing.type: Easing.OutCubic
}
SequentialAnimation {
PauseAnimation { duration: 110 }
NumberAnimation {
targets: [detail, panelTitle, headerExtras]
property: "opacity"
duration: 120
easing.type: Easing.OutCubic
}
}
}
}
]
}