feat(quickshell): give the prompt its own panel, ESC handling and placement

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
This commit is contained in:
2026-09-01 23:38:23 +02:00
co-authored by Claude Opus 5
parent 27b924f5e9
commit b3c3cc38f0
6 changed files with 273 additions and 22 deletions
@@ -28,6 +28,50 @@ PanelWindow {
// HyprChromeShell. // HyprChromeShell.
property int wlrLayer: WlrLayer.Overlay 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 // Own namespace so a layerrule can exempt the rail from Hyprland's layer
// animation without also catching the launchers, which share the default // animation without also catching the launchers, which share the default
// "quickshell" namespace and do want their fade. // "quickshell" namespace and do want their fade.
@@ -32,7 +32,7 @@ PanelWindow {
// of a pair with the bar's, so the shell derives both. // of a pair with the bar's, so the shell derives both.
property int wlrLayer: WlrLayer.Top property int wlrLayer: WlrLayer.Top
property real dim: 0.75 property real dim: 0.65
property int gridSpacing: 60 property int gridSpacing: 60
// Height of the collapsed rail, including its margins — the band the scrim // 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. // 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 // Over a 55% scrim on top of lit windows that is invisible, so it is a
// knob rather than a constant. // 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 // 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 // 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. // bar. Derived rather than a literal so it tracks a palette change.
// Registration crosses sit on every other intersection of the grid. // Registration crosses sit on every other intersection of the grid.
property color crossColor: Theme.muted property color crossColor: Theme.muted
property real crossOpacity: 0.45 property real crossOpacity: 0.15
property int crossSize: 20 property int crossSize: 20
// Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd // Thickness in STEPS, not pixels: 1 -> 1px, 2 -> 3px, 3 -> 5px. Only odd
@@ -189,6 +189,16 @@ Scope {
visible: shell.targetScreen !== null visible: shell.targetScreen !== null
expanded: shell.expanded expanded: shell.expanded
wlrLayer: shell.barLayer 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 // Polkit authentication agent. It registers for this logind session on
@@ -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
}
}
@@ -185,7 +185,16 @@ Scope {
PolkitPromptContent { PolkitPromptContent {
id: content 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 width: 520
message: root.flow ? root.flow.message : "" message: root.flow ? root.flow.message : ""
@@ -5,7 +5,6 @@ import QtQuick.Layouts
import Quickshell import Quickshell
import Quickshell.Widgets import Quickshell.Widgets
import qs.HyprChrome.Theme import qs.HyprChrome.Theme
import qs.HyprChrome.Widgets.Bar.Panels
// Headlessly renderable visual core of the polkit authentication prompt. // Headlessly renderable visual core of the polkit authentication prompt.
// //
@@ -73,7 +72,7 @@ Item {
elide: Text.ElideRight elide: Text.ElideRight
} }
BarPanel { PolkitPanel {
id: panel id: panel
width: root.width width: root.width
@@ -82,23 +81,10 @@ Item {
// The action id is the one piece that says WHAT is being authorized // The action id is the one piece that says WHAT is being authorized
// independently of the (localizable, often vague) message. // independently of the (localizable, often vague) message.
meta: root.actionId meta: root.actionId
chamfer: 16
// A modal, not a rail panel: clicking the body must reach the input // The body slot decides the width and the layout's implicitHeight
// rather than collapse the dialog out from under it. // becomes its height, so a wrapped message or an extra pam_info line
expanded: true // grows the panel instead of being clipped.
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.
ColumnLayout { ColumnLayout {
width: parent.width width: parent.width
spacing: 10 spacing: 10