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
258 lines
9.3 KiB
QML
258 lines
9.3 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import Quickshell.Widgets
|
|
import qs.HyprChrome.Theme
|
|
|
|
// Headlessly renderable visual core of the polkit authentication prompt.
|
|
//
|
|
// Nothing here imports Quickshell.Services.Polkit: every field an AuthFlow
|
|
// exposes arrives as a plain property and every action leaves as a signal, so
|
|
// the whole dialog can be rendered offscreen (tools/quickshell-preview) and
|
|
// staged in DebugWindow without a real authorization request. PolkitPrompt.qml
|
|
// owns the agent and does the mapping.
|
|
//
|
|
// `identities` is read structurally — each entry only needs `displayName` — so
|
|
// the adapter can hand over the flow's QList<Identity*> unchanged while the
|
|
// headless test passes plain JS objects.
|
|
Item {
|
|
id: root
|
|
|
|
// ---- flow state, mirrored ----
|
|
property string message: ""
|
|
property string actionId: ""
|
|
property string iconName: ""
|
|
property bool showIcon: true
|
|
|
|
// Who may authenticate. One entry is the common case and renders as a
|
|
// plain line; the picker only appears when polkit actually offers a
|
|
// choice (a user in several admin groups, or root plus wheel).
|
|
property var identities: []
|
|
property int selectedIdentity: 0
|
|
|
|
// PAM conversation. `responseVisible` is polkit's echo flag — it is NOT
|
|
// always false: a smartcard PIN prompt or a security-question stack asks
|
|
// for echoed input, and masking those makes the prompt unusable.
|
|
property bool responseRequired: false
|
|
property string inputPrompt: ""
|
|
property bool responseVisible: false
|
|
|
|
// pam_info / pam_error text, and whether the last attempt was rejected.
|
|
property string supplementaryMessage: ""
|
|
property bool supplementaryIsError: false
|
|
property bool failed: false
|
|
|
|
property alias response: responseInput.text
|
|
|
|
signal submitted(string value)
|
|
signal cancelled
|
|
signal identityRequested(int index)
|
|
|
|
function focusInput() { responseInput.forceActiveFocus(); }
|
|
function clearResponse() { responseInput.text = ""; }
|
|
|
|
implicitWidth: 520
|
|
implicitHeight: panel.implicitHeight
|
|
|
|
// Escape reaches here by propagating up the focus chain from the TextInput,
|
|
// which does not consume it — so cancelling works whether or not the input
|
|
// currently has focus.
|
|
Keys.onEscapePressed: event => {
|
|
root.cancelled();
|
|
event.accepted = true;
|
|
}
|
|
|
|
component MicroText: Text {
|
|
color: Theme.muted
|
|
font.family: Theme.microFont
|
|
font.pixelSize: 8
|
|
font.letterSpacing: 0.9
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
PolkitPanel {
|
|
id: panel
|
|
|
|
width: root.width
|
|
panelId: "PKT"
|
|
title: "AUTHORIZATION REQUIRED"
|
|
// The action id is the one piece that says WHAT is being authorized
|
|
// independently of the (localizable, often vague) message.
|
|
meta: root.actionId
|
|
|
|
// 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
|
|
|
|
// ---- what is being asked ----
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 10
|
|
|
|
IconImage {
|
|
visible: root.showIcon && root.iconName !== ""
|
|
implicitSize: 32
|
|
source: root.showIcon && root.iconName !== ""
|
|
? Quickshell.iconPath(root.iconName, "dialog-password")
|
|
: ""
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
wrapMode: Text.Wrap
|
|
text: root.message
|
|
color: Theme.text
|
|
font.family: Theme.displayFont
|
|
font.pixelSize: 13
|
|
font.letterSpacing: 0.6
|
|
}
|
|
}
|
|
|
|
// ---- identity ----
|
|
// Single identity: stated, not offered. Several: chips, because a
|
|
// combo box would be the only QtQuick.Controls widget in the rail.
|
|
MicroText {
|
|
Layout.fillWidth: true
|
|
visible: root.identities.length === 1
|
|
text: "AS " + (root.identities.length === 1
|
|
? root.identities[0].displayName : "")
|
|
}
|
|
|
|
Flow {
|
|
Layout.fillWidth: true
|
|
visible: root.identities.length > 1
|
|
spacing: 6
|
|
|
|
Repeater {
|
|
model: root.identities
|
|
|
|
Rectangle {
|
|
id: chip
|
|
|
|
required property int index
|
|
required property var modelData
|
|
|
|
readonly property bool current: chip.index === root.selectedIdentity
|
|
|
|
width: chipLabel.implicitWidth + 14
|
|
height: chipLabel.implicitHeight + 8
|
|
color: chip.current ? Theme.accent : "transparent"
|
|
border.width: 1
|
|
border.color: chip.current ? Theme.accent : Theme.disabled
|
|
|
|
Text {
|
|
id: chipLabel
|
|
anchors.centerIn: parent
|
|
text: chip.modelData.displayName
|
|
color: chip.current ? Theme.surface : Theme.muted
|
|
font.family: Theme.microFont
|
|
font.pixelSize: 9
|
|
font.letterSpacing: 0.8
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.identityRequested(chip.index)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- the conversation ----
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
implicitHeight: 34
|
|
color: Theme.selection
|
|
border.width: 1
|
|
border.color: root.failed ? Theme.hot : Theme.hair
|
|
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 10
|
|
spacing: 10
|
|
|
|
Text {
|
|
text: ">_"
|
|
color: root.responseRequired ? Theme.accent : Theme.disabled
|
|
font.family: Theme.displayFont
|
|
font.pixelSize: 15
|
|
font.bold: true
|
|
}
|
|
|
|
TextInput {
|
|
id: responseInput
|
|
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
verticalAlignment: TextInput.AlignVCenter
|
|
enabled: root.responseRequired
|
|
color: Theme.text
|
|
selectionColor: Theme.accent
|
|
selectedTextColor: Theme.surface
|
|
font.family: Theme.displayFont
|
|
font.pixelSize: 14
|
|
font.letterSpacing: 1
|
|
clip: true
|
|
|
|
echoMode: root.responseVisible
|
|
? TextInput.Normal : TextInput.Password
|
|
passwordCharacter: "▪"
|
|
// Qt reveals the last typed character for a moment by
|
|
// default. On a screen-visible layer-shell overlay
|
|
// that is a shoulder-surfing hole, so: never.
|
|
passwordMaskDelay: 0
|
|
|
|
onAccepted: {
|
|
if (root.responseRequired)
|
|
root.submitted(responseInput.text);
|
|
}
|
|
|
|
// Placeholder: TextInput has none of its own, and the
|
|
// PAM prompt ("Password:", "PIN:") is the only label
|
|
// this field gets.
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: responseInput.text.length === 0
|
|
text: root.inputPrompt
|
|
color: Theme.disabled
|
|
font: responseInput.font
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- pam_info / pam_error ----
|
|
Text {
|
|
Layout.fillWidth: true
|
|
visible: root.supplementaryMessage !== ""
|
|
wrapMode: Text.Wrap
|
|
text: root.supplementaryMessage
|
|
color: root.supplementaryIsError ? Theme.hot : Theme.muted
|
|
font.family: Theme.microFont
|
|
font.pixelSize: 9
|
|
font.letterSpacing: 0.7
|
|
}
|
|
|
|
// ---- key hints ----
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 14
|
|
|
|
MicroText { text: "ENTER AUTHENTICATE" }
|
|
MicroText { text: "ESC CANCEL" }
|
|
Item { Layout.fillWidth: true }
|
|
MicroText {
|
|
text: root.responseVisible ? "ECHO ON" : ""
|
|
color: Theme.hot
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|