From b3c3cc38f0a7030f32d26b6957bb88107b200e73 Mon Sep 17 00:00:00 2001 From: Erik Simon Date: Tue, 1 Sep 2026 23:38:23 +0200 Subject: [PATCH] feat(quickshell): give the prompt its own panel, ESC handling and placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01GAq2kKCLazZmrKvkd3akud --- .../HyprChrome/Widgets/Bar/HyprChromeBar.qml | 44 ++++ .../HyprChrome/Widgets/ChromeBackdrop.qml | 6 +- .../HyprChrome/Widgets/HyprChromeShell.qml | 10 + .../HyprChrome/Widgets/Polkit/PolkitPanel.qml | 202 ++++++++++++++++++ .../Widgets/Polkit/PolkitPrompt.qml | 11 +- .../Widgets/Polkit/PolkitPromptContent.qml | 22 +- 6 files changed, 273 insertions(+), 22 deletions(-) create mode 100644 dotfiles/quickshell/HyprChrome/Widgets/Polkit/PolkitPanel.qml 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