HyprChromeShell owns what the rail's surfaces have to agree on: the target screen, the density and the shortcut that toggles it, and the bar/backdrop layer PAIR. That pair is why the wrapper exists — the backdrop has to sit exactly one layer below the bar in both densities, and split across two files the two assignments drifted apart and put the scrim over the bar. HyprChromeBar is now just its own surface; ChromeBackdrop takes its layer. The backdrop renders in both densities instead of fading out: full-screen while expanded, scoped to the band the rail occupies while collapsed, with a proportional tail fading off the bottom of that band. Both gradients end on their solid color when there is no fade — with a zero-length ramp the start and end stops coincide, Qt sorts stops unstably, and the transparent one winning turned "no fade" into a ramp across the whole scrim. WorkspacesPanel, between host and vitals, shows the active workspace per monitor: one accent box per output collapsed, the full strip with the shown one filled expanded, lined up in a column behind a fixed-width name cell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U19X5LGTxtq4pb4jdNVivv
413 lines
16 KiB
QML
413 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 + 6
|
|
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 }
|
|
}
|
|
|
|
// 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 + 6
|
|
|
|
// 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: 11
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|