feat(quickshell): cap the rail's open chamfers and trace between them
A chamfer with no neighbour behind it now carries the corner it removed, put back OUTSIDE the panel as a detached accent triangle. capGap is the perpendicular distance from the cut, hence the per-axis shift of capGap over root 2: the cap moves along the cut's normal, not along an axis. The trace joining two caps belongs to the RAIL, not the panel — the run it draws is the gap BETWEEN two panels, which no panel can see. HyprChromeBar filters the row down to panels (a slack Item has no `rightChamfer`, so spacers drop out, and dropping them is exactly what makes a trace span them), then joins each right cap to the next left cap: straight, one 45° step at the midpoint of the gap, straight. It meets the middle of each cap's outward face rather than its tip. TestPanel is a staging slot between two spacers. It counts seconds since load, which is the cheapest proof the panel is live, and standing alone it exercises a cap and a trace at both ends — something a rail of butted panels never does. Restructures the module in the same commit, since the moves and the edits above land in the same files: folders are PascalCase, the bar and its widgets moved under Widgets/Bar, and DebugWindow takes the HyprChrome Theme instead of the legacy one. The two singletons are identical today, so that is not a visual fix — it is a palette edit reaching the debug stage in future. Rename detection needs -M40% to follow HyprChromeBar, which grew past the default similarity threshold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U19X5LGTxtq4pb4jdNVivv
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
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
|
||||
|
||||
// A cut corner has nothing butting against it, so it gets capped: the very
|
||||
// corner the chamfer removed, put back OUTSIDE the panel as a detached
|
||||
// accent triangle. Its hypotenuse faces the cut and its right angle points
|
||||
// away, so the cap and the notch read as two halves of one corner.
|
||||
//
|
||||
// Nothing guards these — a chamfer that is off measures zero, which
|
||||
// collapses its triangle to no area at all, so one piece of geometry covers
|
||||
// both cases.
|
||||
//
|
||||
// capGap is the perpendicular distance from the chamfer, which is why the
|
||||
// per-axis shift is it over root 2 rather than the gap itself: the cap
|
||||
// moves along the cut's normal, not along an axis.
|
||||
property real capGap: 4
|
||||
readonly property real capOffset: panel.capGap / Math.SQRT2
|
||||
|
||||
// Where a trace attaches: the MIDDLE of the cap's outward-facing edge — the
|
||||
// vertical one, since a trace arrives horizontally — rather than the tip,
|
||||
// so the line meets the triangle's face instead of clipping its corner.
|
||||
// That edge runs from the cut's end to the corner, so its midpoint is the
|
||||
// half-way point between them, carried out by the same offset as the cap.
|
||||
//
|
||||
// The RAIL draws those traces, between one panel's right cap and the next
|
||||
// panel's left cap, because the run between two panels is the one piece of
|
||||
// this that no panel can see. All a panel owes it is where its own caps
|
||||
// ended up.
|
||||
readonly property real rightCapX: panel.width + panel.capOffset
|
||||
readonly property real rightCapY: (panel.offsetY + panel.activeChamfer) / 2 - panel.capOffset
|
||||
readonly property real leftCapX: -panel.capOffset
|
||||
readonly property real leftCapY: panel.height - panel.activeChamfer / 2 + panel.capOffset
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
// Cap on the top-right chamfer: the corner the cut removed, sitting
|
||||
// just outside it. The two ends of its hypotenuse are the same points
|
||||
// the outline turns on, shifted clear along the cut's normal; the third
|
||||
// is the corner itself, which the outline never reaches.
|
||||
ShapePath {
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
startX: panelShape.width - panel.rightCut + panel.capOffset; startY: panel.offsetY - panel.capOffset
|
||||
PathLine {
|
||||
x: panelShape.width + panel.capOffset
|
||||
y: (panel.rightChamfer ? panel.activeChamfer : panel.offsetY) - panel.capOffset
|
||||
}
|
||||
PathLine { x: panelShape.width + panel.capOffset; y: panel.offsetY - panel.capOffset }
|
||||
PathLine { x: panelShape.width - panel.rightCut + panel.capOffset; y: panel.offsetY - panel.capOffset }
|
||||
}
|
||||
|
||||
// Cap on the bottom-left chamfer, the same triangle mirrored, clearing
|
||||
// the panel in the other direction.
|
||||
ShapePath {
|
||||
fillColor: Theme.accent
|
||||
strokeWidth: 0
|
||||
startX: panel.leftCut - panel.capOffset; startY: panelShape.height + panel.capOffset
|
||||
PathLine { x: -panel.capOffset; y: panelShape.height - panel.leftCut + panel.capOffset }
|
||||
PathLine { x: -panel.capOffset; y: panelShape.height + panel.capOffset }
|
||||
PathLine { x: panel.leftCut - panel.capOffset; y: panelShape.height + panel.capOffset }
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user