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)) 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 } } } } ] }