mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-22 03:04:01 -04:00
1225 lines
54 KiB
QML
1225 lines
54 KiB
QML
pragma ComponentBehavior: Bound
|
|
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import QtQuick.Shapes
|
|
import Quickshell
|
|
import qs.Common
|
|
import qs.Services
|
|
import qs.DCommon.Widgets
|
|
import qs.DCommon.Session
|
|
import "../../../Common/KeyUtils.js" as KeyUtils
|
|
|
|
Item {
|
|
id: root
|
|
|
|
readonly property var log: Log.scoped("LockAuthWidget")
|
|
|
|
property var instanceData: null
|
|
property var lockHost: null
|
|
readonly property var cfg: instanceData?.config ?? ({})
|
|
readonly property string profileVisibility: cfg.profileVisibility ?? (cfg.showProfileImage === false ? "never" : "always")
|
|
readonly property string passwordVisibility: cfg.passwordVisibility ?? (cfg.showPasswordField === false ? "typing" : "always")
|
|
readonly property bool inputRevealed: lockHost?.inputRevealed ?? false
|
|
readonly property bool authVisible: demoMode || inputRevealed || passwordBuffer.length > 0 || authenticating || unlocking || failed || (pam?.u2fPending ?? false) || currentAuthFeedbackText() !== ""
|
|
readonly property bool showProfileImage: profileVisibility === "always" || (profileVisibility === "typing" && authVisible)
|
|
readonly property bool showPasswordField: passwordVisibility === "always" || authVisible
|
|
readonly property string style: cfg.style ?? "expressive"
|
|
readonly property bool contained: style === "pill" || style === "expressive"
|
|
readonly property bool dots: style === "dots"
|
|
readonly property bool ring: style === "ring"
|
|
readonly property bool morph: style === "expressive"
|
|
readonly property bool minimal: style === "minimal"
|
|
readonly property bool authenticating: (pam?.authBusy ?? false) && !unlocking
|
|
readonly property bool failed: pamState !== ""
|
|
readonly property var morphShapes: ["cookie4", "clover4", "sunny", "cookie9", "softBurst", "pentagon", "oval", "cookie6"]
|
|
readonly property string morphShape: morphShapes[passwordBuffer.length % morphShapes.length]
|
|
readonly property real ringSize: LockMetrics.fieldHeight * 2.5
|
|
readonly property real ringStroke: Theme.spacingS
|
|
property int typedCount: 0
|
|
property real keyAngle: 0
|
|
|
|
onPasswordBufferChanged: {
|
|
const grew = passwordBuffer.length > typedCount;
|
|
typedCount = passwordBuffer.length;
|
|
if (!grew || demoMode)
|
|
return;
|
|
lockHost.setInputRevealed(true);
|
|
keyAngle = (passwordBuffer.length * 137.5) % 360;
|
|
if (ring)
|
|
keyPulse.restart();
|
|
if (morph)
|
|
morphPulse.restart();
|
|
}
|
|
readonly property color accentColor: Theme.primary
|
|
readonly property color plainColor: Theme.lockScreenContentColor
|
|
readonly property bool resizable: true
|
|
readonly property real minWidth: ring ? ringSize : LockMetrics.fieldWidth / 2
|
|
readonly property real minHeight: implicitHeight
|
|
property alias showPassword: passwordBox.showPassword
|
|
|
|
readonly property var pam: lockHost?.pam ?? null
|
|
readonly property bool demoMode: lockHost?.demoMode ?? true
|
|
readonly property bool unlocking: lockHost?.unlocking ?? false
|
|
readonly property string pamState: lockHost?.pamState ?? ""
|
|
readonly property string passwordBuffer: lockHost?.passwordBuffer ?? ""
|
|
|
|
implicitWidth: ring ? ringSize : Math.min(LockMetrics.passwordRowWidth, (lockHost?.width ?? LockMetrics.passwordRowWidth) - Theme.spacingXL * 2)
|
|
implicitHeight: passwordLayout.implicitHeight
|
|
// Feedback and the caps lock warning hang below the box so centring the box centres the field.
|
|
readonly property real bottomOverflow: Theme.spacingS + Math.max(capsLockRow.implicitHeight, authFeedbackText.height)
|
|
|
|
function encodeFileUrl(path) {
|
|
if (!path)
|
|
return "";
|
|
return "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/');
|
|
}
|
|
|
|
function focusPasswordField() {
|
|
if (demoMode)
|
|
return;
|
|
passwordField.forceActiveFocus();
|
|
}
|
|
|
|
function currentAuthFeedbackText() {
|
|
if (!pam)
|
|
return "";
|
|
const promptInFeedback = dots || ring;
|
|
if (pam.u2fState === "insert" && (!pam.u2fPending || promptInFeedback))
|
|
return I18n.tr("Insert your security key...");
|
|
if (pam.u2fState === "waiting" && (!pam.u2fPending || promptInFeedback))
|
|
return I18n.tr("Touch your security key...");
|
|
if (pam.lockMessage && pam.lockMessage.length > 0)
|
|
return pam.lockMessage;
|
|
if (pamState === "error")
|
|
return I18n.tr("Authentication error - try again");
|
|
if (pamState === "max")
|
|
return I18n.tr("Too many attempts - locked out");
|
|
if (pamState === "fail")
|
|
return I18n.tr("Incorrect password - try again");
|
|
if (pam.fprint.status === "disabled")
|
|
return "";
|
|
if (pam.fprintState === "error")
|
|
return I18n.tr("Fingerprint error");
|
|
if (pam.fprintState === "max")
|
|
return I18n.tr("Maximum fingerprint attempts reached. Please use password.");
|
|
if (pam.fprintState === "fail")
|
|
return I18n.tr("Fingerprint not recognized (%1/%2). Please try again or use password.", "lock screen message, %1 is attempts used, %2 is max attempts").arg(pam.fprint.tries).arg(SettingsData.maxFprintTries);
|
|
return "";
|
|
}
|
|
|
|
function authFeedbackIsHint() {
|
|
return pam && (pam.u2fState === "waiting" || pam.u2fState === "insert") && (!pam.u2fPending || dots || ring);
|
|
}
|
|
|
|
function canStartSecurityKeyUnlock() {
|
|
return !demoMode && pam && pam.u2f && pam.u2f.available && SettingsData.enableU2f && SettingsData.u2fMode === "or" && !pam.authBusy && !pam.u2f.active && !pam.u2fPending && !unlocking;
|
|
}
|
|
|
|
function triggerSecurityKeyUnlock() {
|
|
if (!canStartSecurityKeyUnlock())
|
|
return;
|
|
passwordField.clear();
|
|
pam.u2f.startForAlternativeAuth();
|
|
}
|
|
|
|
function securityKeyShortcutMatches(event) {
|
|
return SettingsData.lockScreenSecurityKeyShortcutEnabled && KeyUtils.eventMatchesCombo(event, SettingsData.lockScreenSecurityKeyShortcut);
|
|
}
|
|
|
|
onLockHostChanged: {
|
|
if (!lockHost)
|
|
return;
|
|
lockHost.authWidget = root;
|
|
focusPasswordField();
|
|
}
|
|
|
|
Component.onDestruction: {
|
|
if (lockHost && lockHost.authWidget === root)
|
|
lockHost.authWidget = null;
|
|
}
|
|
|
|
Connections {
|
|
target: root.lockHost
|
|
enabled: root.lockHost !== null
|
|
|
|
function onAuthFailed() {
|
|
errorShake.restart();
|
|
passwordBox.showPassword = false;
|
|
passwordField.clear();
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: root.pam
|
|
enabled: root.pam !== null
|
|
|
|
function onU2fPendingChanged() {
|
|
if (!root.pam.u2fPending)
|
|
return;
|
|
passwordField.clear();
|
|
if (keyboardController.isKeyboardActive)
|
|
keyboardController.hide();
|
|
}
|
|
|
|
// onStateChanged only fires when the conversation ends, too late between prompts
|
|
function onAwaitingUserInputChanged() {
|
|
if (root.pam.awaitingUserInput)
|
|
passwordField.clear();
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: passwordLayout
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
spacing: 0
|
|
|
|
Item {
|
|
id: ringIndicator
|
|
|
|
readonly property color toneColor: root.failed ? Theme.error : root.accentColor
|
|
readonly property real radius: root.ringSize / 2 - root.ringStroke / 2
|
|
|
|
Layout.alignment: Qt.AlignHCenter
|
|
Layout.preferredWidth: root.ringSize
|
|
Layout.preferredHeight: root.ringSize
|
|
visible: root.ring
|
|
opacity: root.showPasswordField || root.showProfileImage ? 1 : 0
|
|
transform: Translate {
|
|
x: Math.max(-LockMetrics.shakeDistance, Math.min(LockMetrics.shakeDistance, passwordBox.errorOffset))
|
|
}
|
|
|
|
Shape {
|
|
anchors.fill: parent
|
|
opacity: root.showPasswordField ? 1 : 0
|
|
preferredRendererType: Shape.CurveRenderer
|
|
|
|
ShapePath {
|
|
strokeColor: {
|
|
if (root.failed)
|
|
return Theme.withAlpha(Theme.error, Theme.pendingOpacity);
|
|
if (root.authenticating || root.passwordBuffer.length === 0)
|
|
return Theme.withAlpha(root.plainColor, Theme.stateLayerDrag);
|
|
return Theme.withAlpha(root.accentColor, Theme.pendingOpacity);
|
|
}
|
|
strokeWidth: root.ringStroke
|
|
fillColor: "transparent"
|
|
capStyle: ShapePath.RoundCap
|
|
|
|
PathAngleArc {
|
|
centerX: root.ringSize / 2
|
|
centerY: root.ringSize / 2
|
|
radiusX: ringIndicator.radius
|
|
radiusY: ringIndicator.radius
|
|
startAngle: 0
|
|
sweepAngle: 360
|
|
}
|
|
|
|
Behavior on strokeColor {
|
|
ColorAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Shape {
|
|
id: keySegment
|
|
anchors.fill: parent
|
|
preferredRendererType: Shape.CurveRenderer
|
|
opacity: 0
|
|
|
|
ShapePath {
|
|
strokeColor: root.accentColor
|
|
strokeWidth: root.ringStroke
|
|
fillColor: "transparent"
|
|
capStyle: ShapePath.RoundCap
|
|
|
|
PathAngleArc {
|
|
centerX: root.ringSize / 2
|
|
centerY: root.ringSize / 2
|
|
radiusX: ringIndicator.radius
|
|
radiusY: ringIndicator.radius
|
|
startAngle: root.keyAngle - 90
|
|
sweepAngle: 30
|
|
}
|
|
}
|
|
|
|
SequentialAnimation {
|
|
id: keyPulse
|
|
PropertyAction {
|
|
target: keySegment
|
|
property: "opacity"
|
|
value: 1
|
|
}
|
|
NumberAnimation {
|
|
target: keySegment
|
|
property: "opacity"
|
|
to: 0
|
|
duration: Theme.expressiveDurations.expressiveSlowEffects
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
|
|
Shape {
|
|
id: verifyArc
|
|
anchors.fill: parent
|
|
preferredRendererType: Shape.CurveRenderer
|
|
visible: root.ring && root.authenticating
|
|
|
|
ShapePath {
|
|
strokeColor: root.accentColor
|
|
strokeWidth: root.ringStroke
|
|
fillColor: "transparent"
|
|
capStyle: ShapePath.RoundCap
|
|
|
|
PathAngleArc {
|
|
centerX: root.ringSize / 2
|
|
centerY: root.ringSize / 2
|
|
radiusX: ringIndicator.radius
|
|
radiusY: ringIndicator.radius
|
|
startAngle: -90
|
|
sweepAngle: 90
|
|
}
|
|
}
|
|
|
|
RotationAnimator on rotation {
|
|
running: verifyArc.visible
|
|
loops: Animation.Infinite
|
|
duration: Anims.durLong
|
|
from: 0
|
|
to: 360
|
|
}
|
|
}
|
|
|
|
DCircularImage {
|
|
anchors.centerIn: parent
|
|
width: root.ringSize - root.ringStroke * 4
|
|
height: width
|
|
visible: root.showProfileImage
|
|
imageSource: {
|
|
if (PortalService.profileImage === "")
|
|
return "";
|
|
if (PortalService.profileImage.startsWith("/"))
|
|
return root.encodeFileUrl(PortalService.profileImage);
|
|
return PortalService.profileImage;
|
|
}
|
|
fallbackIcon: "material:person"
|
|
}
|
|
|
|
DIcon {
|
|
anchors.centerIn: parent
|
|
visible: !root.showProfileImage
|
|
name: root.unlocking ? "lock_open" : "lock"
|
|
size: Theme.iconSizeLarge
|
|
color: ringIndicator.toneColor
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
enabled: !root.demoMode
|
|
onClicked: passwordField.forceActiveFocus()
|
|
}
|
|
}
|
|
|
|
RowLayout {
|
|
LayoutMirroring.enabled: I18n.isRtl
|
|
LayoutMirroring.childrenInherit: true
|
|
spacing: Theme.spacingM
|
|
Layout.fillWidth: true
|
|
|
|
DCircularImage {
|
|
Layout.preferredWidth: LockMetrics.avatarSize
|
|
ringWidth: Theme.avatarRingWidth
|
|
ringColor: Theme.avatarRingColor
|
|
Layout.preferredHeight: LockMetrics.fieldHeight
|
|
imageSource: {
|
|
if (PortalService.profileImage === "")
|
|
return "";
|
|
if (PortalService.profileImage.startsWith("/"))
|
|
return root.encodeFileUrl(PortalService.profileImage);
|
|
return PortalService.profileImage;
|
|
}
|
|
fallbackIcon: "material:person"
|
|
visible: root.showProfileImage && !root.ring
|
|
}
|
|
|
|
Rectangle {
|
|
id: passwordBox
|
|
|
|
property bool showPassword: false
|
|
property real errorOffset: 0
|
|
readonly property bool focusRingShown: passwordField.activeFocus && Theme.focusRingWidth > 0
|
|
transform: Translate {
|
|
x: Math.max(-LockMetrics.shakeDistance, Math.min(LockMetrics.shakeDistance, passwordBox.errorOffset))
|
|
}
|
|
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: root.ring ? 0 : LockMetrics.fieldHeight
|
|
radius: root.style === "expressive" ? Theme.cornerRadiusXL : Theme.fullRadius(width, height)
|
|
color: {
|
|
if (!root.contained)
|
|
return "transparent";
|
|
return root.style === "expressive" ? Theme.surfaceContainerHigh : Theme.cardSurface;
|
|
}
|
|
border.width: {
|
|
if (!root.contained)
|
|
return 0;
|
|
return focusRingShown ? Math.max(Theme.outlineWidth, Theme.focusRingWidth) : Theme.layerOutlineWidth;
|
|
}
|
|
border.color: focusRingShown ? Theme.focusRingColor : Theme.outlineMedium
|
|
Accessible.name: I18n.tr("Password")
|
|
opacity: root.showPasswordField ? 1 : 0
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: underline
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
visible: root.style === "minimal"
|
|
height: passwordField.activeFocus ? Theme.outlineWidthFocused : Theme.dividerWidth
|
|
color: {
|
|
if (root.pamState !== "")
|
|
return Theme.error;
|
|
return passwordField.activeFocus ? root.accentColor : Theme.withAlpha(root.plainColor, Theme.pendingOpacity);
|
|
}
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: dotViewport
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Theme.spacingM
|
|
anchors.right: passwordViewport.right
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
clip: true
|
|
visible: root.dots && !passwordBox.showPassword
|
|
|
|
Row {
|
|
id: dotRow
|
|
x: Math.min(0, dotViewport.width - width)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Theme.spacingS
|
|
|
|
Repeater {
|
|
model: root.demoMode ? 6 : Math.max(1, root.passwordBuffer.length)
|
|
|
|
Rectangle {
|
|
required property int index
|
|
readonly property bool filled: root.demoMode || index < root.passwordBuffer.length
|
|
readonly property color tone: root.pamState !== "" ? Theme.error : root.accentColor
|
|
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Theme.iconSizeSmall
|
|
height: width
|
|
radius: Theme.fullRadius(width, height)
|
|
color: filled ? tone : "transparent"
|
|
border.width: Theme.outlineWidthFocused
|
|
border.color: filled ? tone : Theme.withAlpha(root.plainColor, Theme.pendingOpacity)
|
|
scale: filled ? 1 : 0.8
|
|
|
|
Behavior on scale {
|
|
NumberAnimation {
|
|
duration: LockMetrics.shakeDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: lockIconContainer
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: root.morph ? Theme.spacingS : Theme.spacingM
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: !root.dots && !root.ring && !root.minimal
|
|
width: !visible ? 0 : (root.morph ? LockMetrics.fieldHeight - Theme.spacingS * 2 : Theme.iconSizeSmall)
|
|
height: root.morph ? width : Theme.iconSizeSmall
|
|
|
|
DMaterialShape {
|
|
id: morphContainer
|
|
anchors.fill: parent
|
|
visible: root.morph
|
|
shape: root.morphShape
|
|
color: root.failed ? Theme.errorContainer : Theme.primaryContainer
|
|
|
|
SequentialAnimation {
|
|
id: morphPulse
|
|
NumberAnimation {
|
|
target: morphContainer
|
|
property: "scale"
|
|
to: 1.15
|
|
duration: LockMetrics.shakeDuration / 2
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
|
|
}
|
|
NumberAnimation {
|
|
target: morphContainer
|
|
property: "scale"
|
|
to: 1
|
|
duration: LockMetrics.shakeDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveDefaultSpatial
|
|
}
|
|
}
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
|
|
DLoadingIndicator {
|
|
anchors.centerIn: parent
|
|
size: parent.width
|
|
contained: true
|
|
visible: root.morph && root.authenticating
|
|
running: visible
|
|
}
|
|
|
|
DIcon {
|
|
id: lockIcon
|
|
|
|
anchors.centerIn: parent
|
|
visible: !(root.morph && root.authenticating)
|
|
name: {
|
|
if (!root.pam)
|
|
return "lock";
|
|
if (root.pam.u2fPending)
|
|
return "passkey";
|
|
switch (root.pam.fprint.status) {
|
|
case "max":
|
|
case "stopped":
|
|
return "fingerprint_off";
|
|
case "active":
|
|
return "fingerprint";
|
|
case "retrying":
|
|
return "hourglass_empty";
|
|
}
|
|
if (root.pam.u2f.active)
|
|
return "passkey";
|
|
return "lock";
|
|
}
|
|
size: Theme.iconSizeSmall
|
|
color: {
|
|
if (root.morph)
|
|
return root.failed ? Theme.onErrorContainer : Theme.onPrimaryContainer;
|
|
if (root.pam && root.pam.fprint.tries >= SettingsData.maxFprintTries)
|
|
return Theme.error;
|
|
if (root.pam && root.pam.u2fState !== "")
|
|
return Theme.tertiary;
|
|
if (passwordField.activeFocus)
|
|
return root.accentColor;
|
|
return root.contained ? Theme.surfaceVariantText : Theme.withAlpha(root.plainColor, Theme.pendingOpacity);
|
|
}
|
|
opacity: root.pam?.authBusy && !root.morph ? 0 : 1
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
FocusScope {
|
|
id: passwordField
|
|
|
|
KeyNavigation.tab: virtualKeyboardButton.visible ? virtualKeyboardButton : passwordField
|
|
KeyNavigation.backtab: virtualKeyboardButton.visible ? virtualKeyboardButton : passwordField
|
|
Accessible.role: Accessible.EditableText
|
|
Accessible.name: I18n.tr("Password")
|
|
|
|
readonly property string text: root.passwordBuffer
|
|
property int cursorPosition: text.length
|
|
|
|
signal accepted
|
|
|
|
function clampCursorPosition() {
|
|
cursorPosition = Math.max(0, Math.min(cursorPosition, text.length));
|
|
}
|
|
|
|
function clear() {
|
|
root.lockHost?.passwordEdited("");
|
|
cursorPosition = 0;
|
|
}
|
|
|
|
function insertText(value) {
|
|
if (value.length === 0)
|
|
return;
|
|
clampCursorPosition();
|
|
const pos = cursorPosition;
|
|
root.lockHost.passwordEdited(text.slice(0, pos) + value + text.slice(pos));
|
|
cursorPosition = pos + value.length;
|
|
}
|
|
|
|
function backspace() {
|
|
clampCursorPosition();
|
|
if (cursorPosition === 0)
|
|
return;
|
|
const pos = cursorPosition;
|
|
root.lockHost.passwordEdited(text.slice(0, pos - 1) + text.slice(pos));
|
|
cursorPosition = pos - 1;
|
|
}
|
|
|
|
function deleteForward() {
|
|
clampCursorPosition();
|
|
if (cursorPosition === text.length)
|
|
return;
|
|
const pos = cursorPosition;
|
|
root.lockHost.passwordEdited(text.slice(0, pos) + text.slice(pos + 1));
|
|
cursorPosition = pos;
|
|
}
|
|
|
|
function deleteToLineStart() {
|
|
clampCursorPosition();
|
|
if (cursorPosition === 0)
|
|
return;
|
|
root.lockHost.passwordEdited(text.slice(cursorPosition));
|
|
cursorPosition = 0;
|
|
}
|
|
|
|
function deleteToLineEnd() {
|
|
clampCursorPosition();
|
|
if (cursorPosition === text.length)
|
|
return;
|
|
root.lockHost.passwordEdited(text.slice(0, cursorPosition));
|
|
}
|
|
|
|
function deleteWordBackward() {
|
|
clampCursorPosition();
|
|
if (cursorPosition === 0)
|
|
return;
|
|
let pos = cursorPosition;
|
|
while (pos > 0 && text.charAt(pos - 1) === " ")
|
|
pos--;
|
|
while (pos > 0 && text.charAt(pos - 1) !== " ")
|
|
pos--;
|
|
root.lockHost.passwordEdited(text.slice(0, pos) + text.slice(cursorPosition));
|
|
cursorPosition = pos;
|
|
}
|
|
|
|
function isPrintableText(value) {
|
|
if (value.length === 0)
|
|
return false;
|
|
const code = value.charCodeAt(0);
|
|
return code >= 0x20 && code !== 0x7f;
|
|
}
|
|
|
|
anchors.fill: parent
|
|
anchors.leftMargin: lockIconContainer.width + Theme.spacingM * 2
|
|
anchors.rightMargin: {
|
|
let margin = Theme.spacingM;
|
|
if (loadingSpinner.visible)
|
|
margin += loadingSpinner.width;
|
|
if (enterButton.visible)
|
|
margin += enterButton.width + Theme.spacingXXS;
|
|
if (securityKeyButton.visible)
|
|
margin += securityKeyButton.width;
|
|
if (virtualKeyboardButton.visible)
|
|
margin += virtualKeyboardButton.width;
|
|
if (revealButton.visible)
|
|
margin += revealButton.width;
|
|
return margin;
|
|
}
|
|
opacity: 0
|
|
focus: true
|
|
enabled: !root.demoMode
|
|
activeFocusOnTab: !root.demoMode
|
|
onTextChanged: cursorPosition = text.length
|
|
onAccepted: {
|
|
if (!root.demoMode && !root.unlocking && !root.pam.authBusy && !root.pam.u2fPending)
|
|
root.pam.submitPassword();
|
|
}
|
|
Keys.onPressed: event => handleKey(event)
|
|
|
|
function handleKey(event) {
|
|
if (root.demoMode)
|
|
return;
|
|
|
|
root.pam.retryFprintOnActivity();
|
|
|
|
if (root.unlocking) {
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (event.key === Qt.Key_Escape) {
|
|
if (keyboardController.isKeyboardActive) {
|
|
keyboardController.hide();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
if (root.pam.u2fPending) {
|
|
root.pam.cancelU2fPending();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
clear();
|
|
root.lockHost.setInputRevealed(false);
|
|
passwordBox.showPassword = false;
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (root.pam.authBusy) {
|
|
root.log.debug("PAM is active, ignoring input");
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if ((event.modifiers & Qt.ControlModifier) && !(event.modifiers & (Qt.AltModifier | Qt.MetaModifier))) {
|
|
if (root.securityKeyShortcutMatches(event) && root.canStartSecurityKeyUnlock()) {
|
|
root.triggerSecurityKeyUnlock();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
switch (event.key) {
|
|
case Qt.Key_A:
|
|
cursorPosition = 0;
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_E:
|
|
cursorPosition = text.length;
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_B:
|
|
clampCursorPosition();
|
|
cursorPosition = Math.max(0, cursorPosition - 1);
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_F:
|
|
clampCursorPosition();
|
|
cursorPosition = Math.min(text.length, cursorPosition + 1);
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_U:
|
|
deleteToLineStart();
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_K:
|
|
deleteToLineEnd();
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_W:
|
|
case Qt.Key_Backspace:
|
|
deleteWordBackward();
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_H:
|
|
backspace();
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_D:
|
|
deleteForward();
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
switch (event.key) {
|
|
case Qt.Key_Return:
|
|
case Qt.Key_Enter:
|
|
accepted();
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_Backspace:
|
|
backspace();
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_Delete:
|
|
deleteForward();
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_Left:
|
|
clampCursorPosition();
|
|
cursorPosition = Math.max(0, cursorPosition - 1);
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_Right:
|
|
clampCursorPosition();
|
|
cursorPosition = Math.min(text.length, cursorPosition + 1);
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_Home:
|
|
cursorPosition = 0;
|
|
event.accepted = true;
|
|
return;
|
|
case Qt.Key_End:
|
|
cursorPosition = text.length;
|
|
event.accepted = true;
|
|
return;
|
|
}
|
|
|
|
if (isPrintableText(event.text)) {
|
|
insertText(event.text);
|
|
event.accepted = true;
|
|
}
|
|
}
|
|
|
|
// IME commits use a hidden password input: https://github.com/duxlabs-dev/dux-shell/issues/2950
|
|
// An in-process module like fcitx5-qt draws its popup as an xdg_popup, which the lock surface cannot parent: https://github.com/duxlabs-dev/dux-shell/issues/3669
|
|
Loader {
|
|
focus: true
|
|
active: KeyUtils.compositorTextInputSelected(Quickshell.env("QT_IM_MODULES"), Quickshell.env("QT_IM_MODULE"))
|
|
sourceComponent: TextInput {
|
|
id: imeCommitSink
|
|
|
|
focus: true
|
|
width: Theme.dividerWidth
|
|
height: 1
|
|
opacity: 0
|
|
cursorDelegate: Item {}
|
|
echoMode: TextInput.Password
|
|
inputMethodHints: Qt.ImhHiddenText | Qt.ImhSensitiveData | Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase
|
|
KeyNavigation.tab: passwordField.KeyNavigation.tab
|
|
KeyNavigation.backtab: passwordField.KeyNavigation.backtab
|
|
Keys.onPressed: event => {
|
|
passwordField.handleKey(event);
|
|
if (!event.accepted && (event.modifiers & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier)))
|
|
event.accepted = true;
|
|
}
|
|
onTextChanged: {
|
|
if (text.length === 0)
|
|
return;
|
|
const committed = text;
|
|
text = "";
|
|
if (root.demoMode || root.unlocking || root.pam.authBusy)
|
|
return;
|
|
passwordField.insertText(committed);
|
|
}
|
|
}
|
|
}
|
|
|
|
onVisibleChanged: {
|
|
if (visible)
|
|
root.focusPasswordField();
|
|
}
|
|
}
|
|
|
|
KeyboardController {
|
|
id: keyboardController
|
|
target: passwordField
|
|
rootObject: root.lockHost
|
|
expressive: true
|
|
}
|
|
|
|
StyledText {
|
|
id: placeholder
|
|
|
|
anchors.left: lockIconContainer.right
|
|
anchors.leftMargin: root.minimal ? 0 : Theme.spacingM
|
|
anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))))
|
|
anchors.rightMargin: Theme.spacingXXS
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: !root.dots && !root.ring
|
|
text: {
|
|
if (root.demoMode || !root.pam)
|
|
return "";
|
|
if (root.unlocking)
|
|
return I18n.tr("Unlocking...", "lock screen status text while unlocking");
|
|
if (root.pam.u2fPending) {
|
|
if (root.pam.u2fState === "insert")
|
|
return I18n.tr("Insert your security key...");
|
|
return I18n.tr("Touch your security key...");
|
|
}
|
|
if (root.pam.authBusy)
|
|
return I18n.tr("Authenticating...", "lock screen status text while the password is checked");
|
|
if (root.passwordVisibility !== "always")
|
|
return "";
|
|
if (root.pam.authPromptText !== "")
|
|
return root.pam.authPromptText;
|
|
return I18n.tr("Password", "lock screen password field placeholder") + "…";
|
|
}
|
|
color: {
|
|
if (root.unlocking || (root.pam?.authBusy ?? false))
|
|
return root.accentColor;
|
|
return root.contained ? Theme.outline : Theme.withAlpha(root.plainColor, Theme.pendingOpacity);
|
|
}
|
|
font.pixelSize: Theme.fontSizeMedium
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
opacity: (root.demoMode || root.passwordBuffer.length === 0) ? 1 : 0
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: passwordViewport
|
|
|
|
property real scrollX: 0
|
|
property real scrollY: 0
|
|
|
|
function followCursor() {
|
|
const rect = passwordDisplay.cursorRectangle;
|
|
const contentWidth = passwordDisplay.contentWidth + passwordCursor.width;
|
|
const contentHeight = passwordDisplay.contentHeight;
|
|
scrollX = followAxis(scrollX, rect.x, rect.x + passwordCursor.width, contentWidth, width);
|
|
scrollY = contentHeight < height ? (height - contentHeight) / 2 : followAxis(scrollY, rect.y, rect.y + rect.height, contentHeight, height);
|
|
}
|
|
|
|
function followAxis(offset, start, end, contentSize, viewSize) {
|
|
if (contentSize <= viewSize)
|
|
return 0;
|
|
if (start + offset < 0)
|
|
return -start;
|
|
if (end + offset > viewSize)
|
|
return viewSize - end;
|
|
return Math.max(viewSize - contentSize, Math.min(0, offset));
|
|
}
|
|
|
|
anchors.left: lockIconContainer.right
|
|
anchors.leftMargin: root.minimal ? 0 : Theme.spacingM
|
|
anchors.right: (revealButton.visible ? revealButton.left : (virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))))
|
|
anchors.rightMargin: Theme.spacingXXS
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
anchors.topMargin: Theme.spacingXS
|
|
anchors.bottomMargin: Theme.spacingXS
|
|
clip: true
|
|
visible: (!root.dots || passwordBox.showPassword) && !root.ring
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
enabled: !root.demoMode
|
|
onClicked: passwordField.forceActiveFocus()
|
|
}
|
|
|
|
onWidthChanged: followCursor()
|
|
onHeightChanged: followCursor()
|
|
|
|
TextEdit {
|
|
id: passwordDisplay
|
|
LayoutMirroring.enabled: false
|
|
|
|
x: passwordViewport.scrollX
|
|
y: passwordViewport.scrollY
|
|
width: implicitWidth
|
|
readOnly: true
|
|
activeFocusOnPress: false
|
|
selectByMouse: false
|
|
wrapMode: TextEdit.NoWrap
|
|
text: {
|
|
if (root.demoMode)
|
|
return "••••••••";
|
|
if (passwordBox.showPassword)
|
|
return root.passwordBuffer;
|
|
return "•".repeat(root.passwordBuffer.length);
|
|
}
|
|
color: root.contained ? Theme.surfaceText : root.plainColor
|
|
font.family: Theme.fontFamily
|
|
font.weight: Theme.fontWeight
|
|
font.pixelSize: passwordBox.showPassword ? Theme.fontSizeMedium : (root.contained ? Theme.fontSizeLarge : Theme.fontSizeXLarge)
|
|
opacity: (root.demoMode || root.passwordBuffer.length > 0) ? 1 : 0
|
|
|
|
onTextChanged: cursorPosition = passwordField.cursorPosition
|
|
onCursorRectangleChanged: passwordViewport.followCursor()
|
|
onContentWidthChanged: passwordViewport.followCursor()
|
|
onContentHeightChanged: passwordViewport.followCursor()
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
|
|
DTextCursor {
|
|
id: passwordCursor
|
|
|
|
x: passwordDisplay.x + passwordDisplay.cursorRectangle.x
|
|
y: passwordDisplay.y + passwordDisplay.cursorRectangle.y
|
|
height: passwordDisplay.cursorRectangle.height
|
|
shown: !root.demoMode && root.showPasswordField && !root.ring && passwordField.activeFocus && !(root.pam?.authBusy ?? false) && !(root.pam?.u2fPending ?? false) && !root.unlocking
|
|
|
|
readonly property int fieldCursorPosition: passwordField.cursorPosition
|
|
readonly property string fieldText: passwordField.text
|
|
|
|
onFieldCursorPositionChanged: {
|
|
passwordDisplay.cursorPosition = fieldCursorPosition;
|
|
resetBlink();
|
|
}
|
|
|
|
onFieldTextChanged: resetBlink()
|
|
}
|
|
}
|
|
|
|
LockActionButton {
|
|
id: revealButton
|
|
|
|
activeFocusOnTab: false
|
|
Accessible.name: parent.showPassword ? I18n.tr("Hide password") : I18n.tr("Show password")
|
|
|
|
anchors.right: virtualKeyboardButton.visible ? virtualKeyboardButton.left : (securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)))
|
|
anchors.rightMargin: 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconName: parent.showPassword ? "visibility_off" : "visibility"
|
|
buttonSize: Theme.buttonHeightXS
|
|
visible: !root.demoMode && !root.ring && !root.minimal && root.passwordBuffer.length > 0 && !(root.pam?.authBusy ?? false) && !root.unlocking
|
|
enabled: visible
|
|
onClicked: parent.showPassword = !parent.showPassword
|
|
}
|
|
|
|
LockActionButton {
|
|
id: securityKeyButton
|
|
|
|
activeFocusOnTab: false
|
|
|
|
anchors.right: enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right)
|
|
anchors.rightMargin: 0
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconName: "passkey"
|
|
buttonSize: Theme.buttonHeightXS
|
|
visible: root.canStartSecurityKeyUnlock() && !root.ring
|
|
enabled: visible && root.showPasswordField
|
|
tooltipText: SettingsData.lockScreenSecurityKeyShortcutEnabled ? I18n.tr("Security key (%1)", "lock screen security key button tooltip with shortcut").arg(SettingsData.lockScreenSecurityKeyShortcut) : I18n.tr("Security key", "lock screen security key button tooltip")
|
|
onClicked: root.triggerSecurityKeyUnlock()
|
|
}
|
|
|
|
LockActionButton {
|
|
id: virtualKeyboardButton
|
|
Keys.onEscapePressed: {
|
|
keyboardController.hide();
|
|
passwordField.forceActiveFocus();
|
|
}
|
|
|
|
KeyNavigation.tab: passwordField
|
|
KeyNavigation.backtab: passwordField
|
|
Accessible.name: I18n.tr("On-screen keyboard")
|
|
|
|
anchors.right: securityKeyButton.visible ? securityKeyButton.left : (enterButton.visible ? enterButton.left : (loadingSpinner.visible ? loadingSpinner.left : parent.right))
|
|
anchors.rightMargin: securityKeyButton.visible || enterButton.visible ? 0 : Theme.spacingS
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconName: "keyboard"
|
|
buttonSize: Theme.buttonHeightXS
|
|
visible: !root.demoMode && !root.ring && !root.minimal && !(root.pam?.authBusy ?? false) && !root.unlocking && !(root.pam?.u2fPending ?? false)
|
|
enabled: visible && root.showPasswordField
|
|
onClicked: {
|
|
if (keyboardController.isKeyboardActive)
|
|
keyboardController.hide();
|
|
else
|
|
keyboardController.show();
|
|
}
|
|
}
|
|
|
|
Item {
|
|
id: loadingSpinner
|
|
|
|
anchors.right: enterButton.visible ? enterButton.left : parent.right
|
|
anchors.rightMargin: Theme.spacingM
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Theme.iconSize
|
|
height: Theme.iconSize
|
|
visible: !root.demoMode && !root.ring && !root.morph && ((root.pam?.authBusy ?? false) || root.unlocking)
|
|
|
|
DIcon {
|
|
anchors.centerIn: parent
|
|
name: "check_circle"
|
|
size: Theme.iconSizeSmall
|
|
color: root.accentColor
|
|
visible: root.unlocking
|
|
|
|
opacity: root.unlocking ? 1 : 0
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
|
|
DLoadingIndicator {
|
|
anchors.centerIn: parent
|
|
size: Theme.iconSize
|
|
contained: root.style === "expressive"
|
|
color: root.style === "expressive" ? Theme.onPrimaryContainer : root.accentColor
|
|
visible: (root.pam?.authBusy ?? false) && !root.unlocking
|
|
running: visible
|
|
}
|
|
}
|
|
|
|
LockActionButton {
|
|
id: enterButton
|
|
|
|
activeFocusOnTab: false
|
|
Accessible.name: I18n.tr("Unlock", "verb, lock screen submit button")
|
|
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Theme.spacingXXS
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconName: "keyboard_return"
|
|
buttonSize: Theme.buttonHeightXS
|
|
visible: !root.ring && !root.minimal && (root.demoMode || (!(root.pam?.authBusy ?? false) && !root.unlocking && !(root.pam?.u2fPending ?? false)))
|
|
enabled: !root.demoMode && root.showPasswordField
|
|
onClicked: {
|
|
if (!root.demoMode && !root.unlocking && !root.pam.u2fPending)
|
|
root.pam.submitPassword();
|
|
}
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
|
|
Behavior on border.color {
|
|
ColorAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
id: belowField
|
|
anchors.top: passwordLayout.bottom
|
|
anchors.topMargin: Theme.spacingS
|
|
width: Math.max(parent.width, LockMetrics.passwordRowWidth)
|
|
x: (parent.width - width) / 2
|
|
spacing: Theme.spacingXS
|
|
|
|
StyledText {
|
|
id: authFeedbackText
|
|
|
|
width: parent.width
|
|
height: text.length > 0 ? Math.min(implicitHeight, Math.ceil(Theme.fontSizeSmall * 4.5)) : 0
|
|
text: root.currentAuthFeedbackText()
|
|
color: root.authFeedbackIsHint() ? Theme.outline : Theme.error
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
horizontalAlignment: Text.AlignHCenter
|
|
wrapMode: Text.WordWrap
|
|
maximumLineCount: 3
|
|
elide: Text.ElideRight
|
|
opacity: text.length > 0 ? 1 : 0
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
|
|
Row {
|
|
id: capsLockRow
|
|
x: (parent.width - width) / 2
|
|
spacing: Theme.spacingXS
|
|
opacity: DMSService.capsLockState ? 1 : 0
|
|
|
|
DIcon {
|
|
name: "shift_lock"
|
|
size: Theme.iconSizeSmall
|
|
color: Theme.error
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
StyledText {
|
|
text: I18n.tr("Caps Lock is on")
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
color: Theme.error
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: LockMetrics.effectsDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
visible: !root.demoMode && !root.showPasswordField
|
|
onClicked: {
|
|
root.lockHost.setInputRevealed(true);
|
|
root.focusPasswordField();
|
|
}
|
|
}
|
|
|
|
SequentialAnimation {
|
|
id: errorShake
|
|
NumberAnimation {
|
|
target: passwordBox
|
|
property: "errorOffset"
|
|
to: LockMetrics.shakeDistance
|
|
duration: LockMetrics.shakeDuration / 3
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
|
|
}
|
|
NumberAnimation {
|
|
target: passwordBox
|
|
property: "errorOffset"
|
|
to: -LockMetrics.shakeDistance
|
|
duration: LockMetrics.shakeDuration / 3
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
|
|
}
|
|
NumberAnimation {
|
|
target: passwordBox
|
|
property: "errorOffset"
|
|
to: 0
|
|
duration: LockMetrics.shakeDuration / 3
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.expressiveFastSpatial
|
|
}
|
|
}
|
|
}
|