PolkitPanel replaces BarPanel as the dialog's chrome. Deliberately not a
subclass or a fork: most of BarPanel is density machinery — summary slot,
animated height, state pair, transitions — that a modal never uses, and
inheriting it would tie the dialog's look to a component whose real job is the
rail, so every restyle here would have to be justified against the panels up
there. It keeps the shell's silhouette (cut corners with detached accent caps,
the accent rules, the header strip) and drops the rail's tick decoration.
ESC closes the rail. The bar had no keyboard focus at all, so this adds it,
gated by the shell rather than left to the compositor to arbitrate between two
exclusive surfaces — that resolves by stacking and would invert silently the
day the layers change:
grabsKeyboard: shell.expanded && !polkit.prompting
so ESC dismisses the prompt while one is open and closes the rail afterwards.
Verified by instrumenting the handoff: expanded -> true, prompt open -> false,
prompt dismissed -> true, collapsed -> false. Note the rail now takes EXCLUSIVE
keyboard focus while expanded, which is the cost of answering a keypress the
user has not aimed at anything.
The dialog sits a third of the way down rather than centred, panel centre on
the third so it grows symmetrically as the message wraps, floored at a margin
so a tall prompt on a short output cannot be pushed off the top.
Also carries the backdrop tuning: dim 0.75 -> 0.65, gridOpacity 0.15 -> 0.10,
crossOpacity 0.45 -> 0.15, now that the scrim is used by the prompt as well as
the rail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud
264 lines
12 KiB
QML
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.65
|
|
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.10
|
|
|
|
// 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.15
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|