Files
homelab/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml
T
darmanandClaude Opus 5 2bf71494f4 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
2026-08-31 00:30:58 +02:00

264 lines
12 KiB
QML

pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.HyprChrome.Theme
// Scrim behind the bar: dims the desktop and lays the dense bar's drafting grid
// over it. It is always on screen — only how far DOWN it reaches changes.
// Expanded, it covers the whole output; collapsed, it shrinks to the band the
// rail itself occupies, so the bar keeps its backing without the desktop being
// dimmed. That collapsed band ends in a fade rather than a cut, so there is no
// hard line across the wallpaper; expanded there is nothing to fade against —
// the scrim runs to the bottom of the output.
//
// Its layer arrives from HyprChromeShell, which derives it together with the
// bar's so the two stay exactly one level apart — see that file for why the
// pair cannot be split. Collapsed that puts it on BACKGROUND, shared with the
// wallpaper (hyprpaper), where order IS creation order: if the wallpaper is
// restarted under a running shell it comes up on top and the collapsed band
// goes with it. A `layerrule = order` in the Hyprland config is the fix if that
// ever bites.
//
// It reserves nothing and takes no input: the mask is an empty Region, so
// clicks land on whatever is underneath rather than on the scrim.
PanelWindow {
id: backdrop
property bool active: true
// Which layer to sit on. An int rather than a private decision: it is half
// of a pair with the bar's, so the shell derives both.
property int wlrLayer: WlrLayer.Top
property real dim: 0.75
property int gridSpacing: 60
// Height of the collapsed rail, including its margins — the band the scrim
// stays behind while the bar is collapsed. Driven by the host, which is the
// only thing that knows what the panels currently measure.
property real barHeight: 0
// Share of the COLLAPSED band spent fading to nothing at the bottom, as a
// fraction rather than a pixel length so the tail scales with whatever the
// rail currently measures. Expanded there is no fade at all: the scrim runs
// to the bottom of the output, where the screen edge ends it.
property real fade: 0.5
readonly property real fadeAmount: Math.max(0, Math.min(0.95, backdrop.fade))
// The share actually in force. Animated rather than switched, so expanding
// shrinks the tail away as the scrim grows instead of dropping a hard edge
// onto the desktop the moment the state flips.
property real fadeSpan: backdrop.active ? 0 : backdrop.fadeAmount
// Position, in fractions of revealHeight, where the falloff starts. 1 while
// expanded, i.e. no falloff.
readonly property real fadeStart: 1 - backdrop.fadeSpan
// Collapsed, the SOLID part is the bar band and the tail hangs below it,
// hence the division: barHeight is what must survive the fade, not what the
// whole scrim measures. Sized off the static fadeAmount, not the animated
// fadeSpan — the height and the ramp have to animate independently or each
// would be chasing the other. Bound rather than readonly so the Behavior
// below can animate the state change.
property real revealHeight: backdrop.active
? backdrop.height
: Math.min(backdrop.height, backdrop.barHeight / (1 - backdrop.fadeAmount))
// Both matched to the bar's own collapse so the scrim and the panels
// resolve together rather than one trailing the other.
Behavior on revealHeight {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
Behavior on fadeSpan {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
// The dense bar drew this grid at 0.018 against its own near-black panel.
// Over a 55% scrim on top of lit windows that is invisible, so it is a
// knob rather than a constant.
property real gridOpacity: 0.15
// The accent with its saturation pulled back: warm enough to read as part
// of the palette, not so loud that a full-screen grid competes with the
// bar. Derived rather than a literal so it tracks a palette change.
// Registration crosses sit on every other intersection of the grid.
property color crossColor: Theme.muted
property real crossOpacity: 0.45
property int crossSize: 20
// Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd
// widths can straddle a 1px rule symmetrically, so an even pixel count
// would push every mark half a pixel off the grid it registers against.
property int crossThickness: 2
readonly property int crossWeight: Math.max(1, backdrop.crossThickness) * 2 - 1
property color gridColor: Qt.hsla(Theme.accent.hslHue,
Theme.accent.hslSaturation * 0.45,
Theme.accent.hslLightness,
1)
// Fade targets keep the source RGB and drop only the alpha: interpolating
// toward a plain "transparent" would run the gradient through black.
readonly property color gridSolid: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g,
backdrop.gridColor.b, backdrop.gridOpacity)
readonly property color gridClear: Qt.rgba(backdrop.gridColor.r, backdrop.gridColor.g,
backdrop.gridColor.b, 0)
readonly property color dimSolid: Qt.rgba(Theme.surface.r, Theme.surface.g,
Theme.surface.b, backdrop.dim)
readonly property color dimClear: Qt.rgba(Theme.surface.r, Theme.surface.g,
Theme.surface.b, 0)
// What the gradients END on. With no fade the ramp has zero length, so its
// start stop and its end stop sit on the SAME position — and Qt sorts stops
// with an unstable sort, leaving which of the two wins undefined. It picked
// the transparent one, which turned "no fade" into a ramp across the entire
// scrim. Ending on the solid color instead makes the degenerate case
// unambiguous: all three stops match and the fill is flat.
readonly property color gridEnd: backdrop.fadeSpan > 0 ? backdrop.gridClear : backdrop.gridSolid
readonly property color dimEnd: backdrop.fadeSpan > 0 ? backdrop.dimClear : backdrop.dimSolid
// Same ramp as the gradients above, for the marks that are placed at a
// single y and so cannot carry a gradient of their own. Reads revealHeight
// and fadeStart, so bindings that call it re-evaluate when either changes.
function fadeAt(y: real): real {
const start = backdrop.revealHeight * backdrop.fadeStart;
if (y <= start)
return 1;
if (y >= backdrop.revealHeight)
return 0;
return 1 - (y - start) / (backdrop.revealHeight - start);
}
visible: backdrop.revealHeight > 0
WlrLayershell.namespace: "hyprchrome-scrim"
WlrLayershell.layer: backdrop.wlrLayer
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
color: "transparent"
readonly property int crossColumns: Math.ceil(backdrop.width / (backdrop.gridSpacing * 2)) + 1
readonly property int crossRows: Math.ceil(backdrop.height / (backdrop.gridSpacing * 2)) + 1
anchors {
top: true
left: true
right: true
bottom: true
}
mask: Region {}
Item {
id: scrim
// Only as tall as the scrim currently reaches; everything inside is
// laid out against this, so shrinking it scopes the whole drawing
// rather than just clipping it.
width: backdrop.width
height: backdrop.revealHeight
clip: true
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0; color: backdrop.dimSolid }
GradientStop { position: backdrop.fadeStart; color: backdrop.dimSolid }
GradientStop { position: 1; color: backdrop.dimEnd }
}
}
// Faint drafting grid; no gradient and deliberately subordinate to
// whatever is showing through it — except at the bottom, where it has to
// fade with the scrim it sits on.
Repeater {
model: Math.ceil(scrim.width / backdrop.gridSpacing)
Rectangle {
required property int index
x: index * backdrop.gridSpacing
width: 1
height: scrim.height
gradient: Gradient {
GradientStop { position: 0; color: backdrop.gridSolid }
GradientStop { position: backdrop.fadeStart; color: backdrop.gridSolid }
GradientStop { position: 1; color: backdrop.gridEnd }
}
}
}
Repeater {
// Modelled against the whole output, not the current reveal, so a
// collapse fades the rules out where they stand instead of
// restocking the Repeater on every animation frame.
model: Math.ceil(backdrop.height / backdrop.gridSpacing)
Rectangle {
required property int index
readonly property real line: index * backdrop.gridSpacing
y: line
width: scrim.width
height: 1
color: backdrop.gridColor
opacity: backdrop.gridOpacity * backdrop.fadeAt(line)
}
}
// Register marks on every other line, so they land on a 2x grid rather
// than on every crossing — sparse enough to read as drafting registration
// rather than as texture.
Repeater {
model: backdrop.crossColumns * backdrop.crossRows
Item {
required property int index
readonly property int column: index % backdrop.crossColumns
readonly property int row: Math.floor(index / backdrop.crossColumns)
// Marks on every other rule in both directions, so they line up
// in columns as well as rows. Both offsets are whole multiples of
// gridSpacing, so every mark lands on a real intersection.
//
// Math.floor, not /2: the item offset and the bars inside it must
// round the same way, or an even crossSize sits half a pixel off the
// rule it marks.
x: column * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
y: row * backdrop.gridSpacing * 2 - Math.floor(backdrop.crossSize / 2)
width: backdrop.crossSize
height: backdrop.crossSize
// Sampled at the intersection the mark registers against, not at
// its own top edge, so a cross fades as one piece.
opacity: backdrop.crossOpacity * backdrop.fadeAt(row * backdrop.gridSpacing * 2)
Rectangle {
// Placed with the same Math.floor the item's own offset uses.
// anchors.verticalCenter halves the height unfloored, so an even
// crossSize put the 1px bar half a pixel off the rule it marks.
y: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
width: parent.width
height: backdrop.crossWeight
color: backdrop.crossColor
}
Rectangle {
x: Math.floor(backdrop.crossSize / 2) - Math.floor(backdrop.crossWeight / 2)
width: backdrop.crossWeight
height: parent.height
color: backdrop.crossColor
}
}
}
}
}