Two causes, both outside the panel animation itself. bodyBottom and bodyEndPadding are animated reals, so their 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 and targetHeight now round. The rest was the surface. Hyprland animates layer-surface resizes (animations enabled, `layers` left at its default), and the deferred shrink put that resize exactly where the panel motion ended. The bar is now sized once to the expanded rail via the new BarPanel.expandedHeight and never resizes; only exclusiveZone tracks the state, so the desktop still reflows once per toggle, at the start. That retires barHeight, the content-height handler and the 340ms shrink timer. A surface that stays tall would swallow clicks across the screen while the rail is collapsed, so input is masked to the panel row. Instrumenting BarPanel per frame ruled the panels themselves out first: slug and summary hold the same absolute y through an entire collapse. The bar and scrim also get their own layer namespaces. Nothing depends on them yet; they are the handle for a layerrule that would exempt the rail from compositor animations without catching the launchers, which share the default "quickshell" namespace and do want their fade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HEtXvseVb5gwAtKNhFx2PU
396 lines
15 KiB
QML
396 lines
15 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))
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|