diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml b/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml index 4f5f2f2..f85e001 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Bar/HyprChromeBar.qml @@ -28,6 +28,50 @@ PanelWindow { // HyprChromeShell. property int wlrLayer: WlrLayer.Overlay + // Whether the rail should hold the keyboard, so ESC can close it. Driven by + // the shell rather than derived from `expanded`, because the rail is not the + // only thing that wants the keyboard: while a polkit prompt is up the shell + // withholds this, so ESC reaches the DIALOG and dismisses that instead. + // Once the prompt is gone the rail gets the keyboard back and a second ESC + // closes the rail — one key, one thing at a time, innermost first. + property bool grabsKeyboard: false + + // EXCLUSIVE rather than OnDemand: OnDemand only offers focus to a surface + // the user clicks, and the whole point here is to answer a keypress the + // user has not aimed at anything. Taking the keyboard is defensible because + // an expanded rail is already a modal-ish state — it dims the desktop + // behind itself with the same scrim the prompt uses. + WlrLayershell.keyboardFocus: window.grabsKeyboard + ? WlrKeyboardFocus.Exclusive + : WlrKeyboardFocus.None + + signal dismissed + + // A layer surface only delivers keys to an item that has active focus, and + // nothing in the rail wants focus for its own sake — the panels are + // readouts. So one focus sink covers the whole surface. It re-takes focus + // whenever the grab is handed back, since losing the surface's focus drops + // the item's too. + Item { + id: keySink + + anchors.fill: parent + focus: true + + Keys.onEscapePressed: event => { + window.dismissed(); + event.accepted = true; + } + + Connections { + target: window + function onGrabsKeyboardChanged() { + if (window.grabsKeyboard) + keySink.forceActiveFocus(); + } + } + } + // Own namespace so a layerrule can exempt the rail from Hyprland's layer // animation without also catching the launchers, which share the default // "quickshell" namespace and do want their fade. diff --git a/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml b/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml index da699c6..a9aff52 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/ChromeBackdrop.qml @@ -32,7 +32,7 @@ PanelWindow { // of a pair with the bar's, so the shell derives both. property int wlrLayer: WlrLayer.Top - property real dim: 0.75 + property real dim: 0.65 property int gridSpacing: 60 // Height of the collapsed rail, including its margins — the band the scrim @@ -84,14 +84,14 @@ PanelWindow { // 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 + 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.45 + property real crossOpacity: 0.15 property int crossSize: 20 // Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd diff --git a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml index 8b9c3c1..6f90fd5 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/HyprChromeShell.qml @@ -189,6 +189,16 @@ Scope { visible: shell.targetScreen !== null expanded: shell.expanded wlrLayer: shell.barLayer + + // ESC closes the rail — but only when it is the innermost thing open. + // While a prompt is up the rail gives up the keyboard so ESC dismisses + // the DIALOG; the prompt closing hands it back, and the next ESC closes + // the rail. Withheld rather than left to the compositor to arbitrate + // between two exclusive surfaces, which would decide by stacking and + // silently swap the order the day the layers change. + grabsKeyboard: shell.expanded && !polkit.prompting + + onDismissed: shell.expanded = false } // Polkit authentication agent. It registers for this logind session on diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPanel.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPanel.qml new file mode 100644 index 0000000..57451ec --- /dev/null +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPanel.qml @@ -0,0 +1,202 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Shapes +import qs.HyprChrome.Theme + +// Panel chrome for the authentication dialog. +// +// Deliberately NOT BarPanel. The rail's panel exists to carry two renderings of +// the same data and cross-fade between them as the rail changes density, and +// almost all of its size is that machinery: the summary slot, the animated +// height, the state pair, the transitions. A modal has exactly one density and +// never collapses, so inheriting all of that would mean carrying dead weight +// and, worse, tying the dialog's look to a component whose real job is the rail +// — every restyle here would have to be justified against the panels up there. +// +// What it does keep is the silhouette, because that is the shell's visual +// signature rather than the rail's: two cut corners (top-right, bottom-left) +// with detached accent caps outside them, an accent rule under the header slug, +// its mirror at the lower right, and a header strip of slug / title / meta. +// +// This is the file to edit to restyle the prompt. Nothing else reads it. +Item { + id: panel + + property string panelId: "" + property string title: "" + property string meta: "" + + property int chamfer: 16 + property int padding: 14 + property int outlineWidth: 1 + property int accentLineThickness: 3 + + readonly property int headerHeight: 30 + + // Gap between the header rule and the body. + property int headerGap: 10 + readonly property int headerPadding: 10 + + // Detached corner caps: the corner each chamfer removed, put back outside + // the panel as an accent triangle whose hypotenuse faces the cut. capGap is + // the perpendicular distance from the cut, so the per-axis shift is it over + // root 2 — the cap moves along the cut's normal, not along an axis. + property real capGap: 4 + readonly property real capOffset: panel.capGap / Math.SQRT2 + + // Never let the two cuts cross, which would turn the outline inside out on + // a panel shorter than twice the chamfer. + readonly property real activeChamfer: Math.max(2, Math.min(panel.chamfer, panel.height / 2 - 1)) + + // The accent rule under the slug is sized to the slug, not to the panel. + readonly property real accentLineWidth: Math.min(panel.width, slugChip.width + panel.headerPadding * 2) + + default property alias content: body.data + + implicitHeight: Math.round(body.y + body.height + panel.padding) + + Shape { + id: panelShape + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + // Outline: square except for the two cut corners. + ShapePath { + fillColor: Theme.surface + strokeColor: Theme.hair + strokeWidth: panel.outlineWidth + + startX: 0; startY: 0 + PathLine { x: panelShape.width - panel.activeChamfer; y: 0 } + 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: 0 } + } + + // Cap on the top-right cut. + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + + startX: panelShape.width - panel.activeChamfer + panel.capOffset + startY: -panel.capOffset + PathLine { x: panelShape.width + panel.capOffset; y: panel.activeChamfer - panel.capOffset } + PathLine { x: panelShape.width + panel.capOffset; y: -panel.capOffset } + PathLine { x: panelShape.width - panel.activeChamfer + panel.capOffset; y: -panel.capOffset } + } + + // Cap on the bottom-left cut, the same triangle mirrored. + ShapePath { + fillColor: Theme.accent + strokeWidth: 0 + + startX: panel.activeChamfer - panel.capOffset + startY: panelShape.height + panel.capOffset + PathLine { x: -panel.capOffset; y: panelShape.height - panel.activeChamfer + panel.capOffset } + PathLine { x: -panel.capOffset; y: panelShape.height + panel.capOffset } + PathLine { x: panel.activeChamfer - panel.capOffset; y: panelShape.height + panel.capOffset } + } + + // Accent rule under the slug. + 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 } + } + + // Its mirror at the lower right. + 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: slug chip, title, and the meta text pinned right. + Row { + id: headerRow + + x: panel.headerPadding + y: Math.round((panel.headerHeight - height) / 2) + spacing: panel.headerPadding + 6 + + Rectangle { + id: slugChip + + width: slugText.implicitWidth + 8 + height: slugText.implicitHeight + 4 + color: Theme.accent + + Text { + id: slugText + + anchors.centerIn: parent + text: panel.panelId + color: Theme.surface + font.family: Theme.microFont + font.pixelSize: 11 + font.bold: true + } + } + + Text { + anchors.verticalCenter: slugChip.verticalCenter + text: panel.title + color: Theme.text + font.family: Theme.displayFont + font.pixelSize: 12 + font.bold: true + font.letterSpacing: 1.1 + elide: Text.ElideRight + } + } + + Text { + anchors.right: parent.right + anchors.rightMargin: panel.headerPadding + 2 + y: Math.round((panel.headerHeight - implicitHeight) / 2) + 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 + } + + // Header rule. + Rectangle { + x: 1 + y: panel.headerHeight + width: parent.width - 2 + height: 1 + color: Theme.text + opacity: 0.12 + } + + // Body. Measured by childrenRect, so a child must carry its own size and + // must NOT anchor to this slot. + Item { + id: body + + x: panel.padding + y: panel.headerHeight + panel.headerGap + width: Math.max(0, panel.width - panel.padding * 2) + height: childrenRect.height + } +} diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml index a2e7b7a..2674bff 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPrompt.qml @@ -185,7 +185,16 @@ Scope { PolkitPromptContent { id: content - anchors.centerIn: parent + // A third of the way down rather than centred: a password prompt + // reads better above the middle, and on a tall output dead-centre + // puts it below the natural resting line of the eye. + // + // The panel's own CENTRE lands on the third, so the dialog grows + // symmetrically about that line as the message wraps or a pam_info + // line appears. Floored at the same margin the width leaves, so a + // tall prompt on a short output cannot be pushed off the top. + anchors.horizontalCenter: parent.horizontalCenter + y: Math.max(32, Math.round(parent.height / 3 - height / 2)) width: 520 message: root.flow ? root.flow.message : "" diff --git a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml index 952cfc9..135594f 100644 --- a/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml +++ b/dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPromptContent.qml @@ -5,7 +5,6 @@ import QtQuick.Layouts import Quickshell import Quickshell.Widgets import qs.HyprChrome.Theme -import qs.HyprChrome.Widgets.Bar.Panels // Headlessly renderable visual core of the polkit authentication prompt. // @@ -73,7 +72,7 @@ Item { elide: Text.ElideRight } - BarPanel { + PolkitPanel { id: panel width: root.width @@ -82,23 +81,10 @@ Item { // The action id is the one piece that says WHAT is being authorized // independently of the (localizable, often vague) message. meta: root.actionId - chamfer: 16 - // A modal, not a rail panel: clicking the body must reach the input - // rather than collapse the dialog out from under it. - expanded: true - toggleOnClick: false - - // Collapsed rendering is never shown here, but BarPanel keeps both - // slots instantiated, so the summary stays bound to the same truth. - summary: MicroText { - text: root.inputPrompt - color: Theme.text - } - - // Sized like every other BarPanel body: the slot decides the width and - // the layout's implicitHeight becomes its height, so a wrapped message - // or an extra pam_info line grows the panel instead of being clipped. + // The body slot decides the width and the layout's implicitHeight + // becomes its height, so a wrapped message or an extra pam_info line + // grows the panel instead of being clipped. ColumnLayout { width: parent.width spacing: 10