Files
homelab/dotfiles/quickshell/hyprchrome/widgets/panels/BarPanel.qml
T
darmanandClaude Opus 5 bbe35dd72e feat(quickshell): join adjacent panels into a continuous rail
At spacing 0 a row of panels read as separate tiles, each closing its own
silhouette. BarPanel can now drop either cut corner — rightChamfer (top-right)
and leftChamfer (bottom-left), the only two the shape cuts — so an edge that a
neighbour butts against runs square into it. The bar turns off the host's right
and the vitals' left; the tray keeps both, since the spacer between them is not
a panel and that edge is free.

A side with its chamfer off also draws a connector: the shared edge restroked
in accent at outlineWidth + 2, so the join reads as a deliberate seam rather
than two outlines that happen to touch. The outline's own width becomes a
property so the connector can be defined against it instead of as a second
literal.

The lower-right accent strip takes accentLineWidth, the same slug-derived width
the upper-left one already used, instead of its own Math.min(49, width / 3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
2026-08-29 04:13:43 +02:00

431 lines
16 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
// Rounded: bodyBottom and bodyEndPadding are animated reals, so the sum
// spends the tail of every transition on a fraction. A layout rounds that
// UP, then drops a pixel the moment the animation lands on its exact
// value — a 1px hop after the motion has visibly finished.
implicitHeight: Math.round(Math.max(panel.minimumHeight, panel.bodyBottom + panel.bodyEndPadding))
// Where the panel settles in each state, skipping the values the transition
// passes through: a host sizes its surface and its exclusive zone from these
// rather than from the animated height, so the desktop is relaid out once per
// toggle instead of once per animation frame.
// Height of the expanded body regardless of the current state — what a
// host needs to size a surface that must not resize when panels collapse.
readonly property real expandedHeight: Math.round(Math.max(panel.minimumHeight,
detail.y + detail.height + panel.padding))
readonly property real targetHeight: panel.expanded
? panel.expandedHeight
: Math.round(Math.max(panel.minimumHeight, 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))
// The silhouette has exactly two cut corners: top-right and bottom-left.
// Turn one off where another panel butts against that side, so a row laid
// out with no spacing reads as one continuous strip instead of a line of
// separate tiles. A cut corner costs its side nothing when disabled — the
// edge simply runs square into the neighbour.
property bool rightChamfer: true // top-right cut
property bool leftChamfer: true // bottom-left cut
readonly property real rightCut: panel.rightChamfer ? panel.activeChamfer : 0
readonly property real leftCut: panel.leftChamfer ? panel.activeChamfer : 0
// The seam where two panels meet is drawn a step heavier and in accent, so
// a chamfer-less join reads as a deliberate connector rather than as two
// outlines that happen to touch.
property int outlineWidth: 1
readonly property int connectorWidth: panel.outlineWidth + 2
Shape {
id: panelShape
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
// Main panel shape
ShapePath {
fillColor: Theme.surface
strokeColor: Theme.hair
strokeWidth: panel.outlineWidth
startX: 0; startY: panel.offsetY
PathLine { x: panelShape.width - panel.rightCut; y: panel.offsetY }
PathLine { x: panelShape.width; y: panel.rightChamfer ? panel.activeChamfer : panel.offsetY }
PathLine { x: panelShape.width; y: panelShape.height }
PathLine { x: panel.leftCut; y: panelShape.height }
PathLine { x: 0; y: panelShape.height - panel.leftCut }
PathLine { x: 0; y: panel.offsetY }
}
// Connecting edges — drawn only on a side whose chamfer is off, which is
// exactly where a neighbour butts against this panel.
ShapePath {
fillColor: "transparent"
strokeColor: Theme.accent
strokeWidth: panel.rightChamfer ? 0 : panel.connectorWidth
startX: panelShape.width; startY: panel.offsetY
PathLine { x: panelShape.width; y: panelShape.height }
}
ShapePath {
fillColor: "transparent"
strokeColor: Theme.accent
strokeWidth: panel.leftChamfer ? 0 : panel.connectorWidth
startX: 0; startY: panel.offsetY
PathLine { x: 0; y: panelShape.height }
}
// 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 - panel.accentLineWidth; y: panelShape.height }
PathLine { x: panelShape.width - panel.accentLineWidth; 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
}
}
}
}
]
}