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