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
388 lines
14 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|