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