Long passwords used to overflow the input field and clip with no feedback that typing was still registering. Scale the dot size and letter spacing down as the password grows, like macOS, so every keystroke stays visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
196 lines
6.8 KiB
QML
196 lines
6.8 KiB
QML
import QtQuick
|
|
import QtQuick.Effects
|
|
import qs.Commons
|
|
import qs.Ui
|
|
|
|
Item {
|
|
id: root
|
|
|
|
property string backgroundPath: ""
|
|
property int backgroundVersion: 0
|
|
property bool fingerprintConfigured: false
|
|
property bool authenticatingPassword: false
|
|
property string failureMessage: ""
|
|
property int failedAttempts: 0
|
|
property bool inputEnabled: true
|
|
property bool loadBackground: true
|
|
property string passwordText: ""
|
|
property bool syncingPasswordText: false
|
|
|
|
readonly property string placeholderText: "Enter Password"
|
|
readonly property int fieldWidth: 381
|
|
readonly property int fieldHeight: 67
|
|
readonly property int outlineThickness: 3
|
|
readonly property int fieldFontSize: Math.round(Style.font.heading * 1.125)
|
|
readonly property int passwordDotFontSize: Math.round(Style.font.heading * 1.33)
|
|
readonly property int passwordDotLetterSpacing: Math.round(Style.font.heading * 0.19)
|
|
// Shrink the dots to fit once the password outgrows the field, so every
|
|
// keystroke stays visible — otherwise long passwords clip with no feedback.
|
|
readonly property real passwordDotScale: dotMetrics.advanceWidth > 0
|
|
? Math.min(1, (passwordInput.width - 4) / dotMetrics.advanceWidth)
|
|
: 1
|
|
readonly property bool showPasswordCursor: inputEnabled && !authenticatingPassword && failureMessage.length === 0
|
|
readonly property bool errorState: failureMessage.length > 0
|
|
readonly property var inputBorderSpec: errorState
|
|
? Border.surfaceSpec("lock", "border-error", Color.lock.borderError, root.outlineThickness, "border-alpha")
|
|
: Border.surfaceSpec("lock", "border-active", Color.lock.borderActive, root.outlineThickness, "border-alpha")
|
|
|
|
signal submitPassword(string password)
|
|
signal passwordTextEdited(string password)
|
|
signal clearFailureRequested()
|
|
signal wakeRequested()
|
|
|
|
// Cache-busts the lock background by appending `?v=`. Adding a query
|
|
// string keeps Image's loader happy while forcing it to reload when the
|
|
// user picks a new background mid-session.
|
|
function fileUrl(path) {
|
|
if (!path) return ""
|
|
var encoded = String(path).split("/").map(encodeURIComponent).join("/")
|
|
return "file://" + encoded + "?v=" + backgroundVersion
|
|
}
|
|
|
|
function forcePasswordFocus() {
|
|
passwordInput.forceActiveFocus()
|
|
}
|
|
|
|
function clearPassword() {
|
|
passwordTextEdited("")
|
|
}
|
|
|
|
function syncPasswordText() {
|
|
if (passwordInput.text === passwordText) return
|
|
syncingPasswordText = true
|
|
passwordInput.text = passwordText
|
|
syncingPasswordText = false
|
|
}
|
|
|
|
onPasswordTextChanged: syncPasswordText()
|
|
onInputEnabledChanged: {
|
|
if (inputEnabled) Qt.callLater(forcePasswordFocus)
|
|
}
|
|
Component.onCompleted: {
|
|
syncPasswordText()
|
|
if (inputEnabled) Qt.callLater(forcePasswordFocus)
|
|
}
|
|
|
|
// Measures the masked password at full size; passwordDotScale compares this
|
|
// against the field width to decide how far the dots must shrink to fit.
|
|
TextMetrics {
|
|
id: dotMetrics
|
|
font.family: Style.font.family
|
|
font.pixelSize: root.passwordDotFontSize
|
|
font.letterSpacing: root.passwordDotLetterSpacing
|
|
text: "●".repeat(passwordInput.text.length)
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: Color.background
|
|
|
|
Image {
|
|
id: wallpaper
|
|
anchors.fill: parent
|
|
source: root.loadBackground ? root.fileUrl(root.backgroundPath) : ""
|
|
fillMode: Image.PreserveAspectCrop
|
|
asynchronous: true
|
|
cache: false
|
|
sourceSize.width: width
|
|
sourceSize.height: height
|
|
}
|
|
|
|
MultiEffect {
|
|
anchors.fill: wallpaper
|
|
source: wallpaper
|
|
autoPaddingEnabled: false
|
|
blurEnabled: root.loadBackground && wallpaper.status === Image.Ready
|
|
blur: 1.0
|
|
blurMax: 128
|
|
blurMultiplier: 1.25
|
|
contrast: -0.08
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
onClicked: { root.wakeRequested(); root.forcePasswordFocus() }
|
|
onPositionChanged: root.wakeRequested()
|
|
}
|
|
|
|
BorderSurface {
|
|
id: inputField
|
|
width: root.fieldWidth
|
|
height: root.fieldHeight
|
|
anchors.centerIn: parent
|
|
color: Color.lock.background
|
|
borderSpec: root.inputBorderSpec
|
|
radius: Style.cornerRadius
|
|
clip: true
|
|
|
|
TextInput {
|
|
id: passwordInput
|
|
anchors.fill: parent
|
|
anchors.topMargin: inputField.borderTop
|
|
anchors.rightMargin: inputField.borderRight + 18
|
|
anchors.bottomMargin: inputField.borderBottom
|
|
anchors.leftMargin: inputField.borderLeft + 18
|
|
verticalAlignment: TextInput.AlignVCenter
|
|
horizontalAlignment: TextInput.AlignHCenter
|
|
activeFocusOnPress: true
|
|
clip: true
|
|
enabled: root.inputEnabled && !root.authenticatingPassword
|
|
readOnly: root.authenticatingPassword
|
|
echoMode: TextInput.Password
|
|
passwordCharacter: "\u25CF"
|
|
passwordMaskDelay: 0
|
|
color: Color.lock.text
|
|
selectionColor: Color.lock.selection
|
|
selectedTextColor: Color.lock.text
|
|
font.family: Style.font.family
|
|
font.pixelSize: text.length > 0 ? Math.max(1, Math.floor(root.passwordDotFontSize * root.passwordDotScale)) : root.fieldFontSize
|
|
font.letterSpacing: text.length > 0 ? root.passwordDotLetterSpacing * root.passwordDotScale : 0
|
|
cursorVisible: activeFocus && root.showPasswordCursor && text.length > 0
|
|
cursorDelegate: Rectangle {
|
|
width: 2
|
|
color: Color.lock.text
|
|
visible: passwordInput.cursorVisible
|
|
}
|
|
|
|
onTextChanged: {
|
|
if (!root.syncingPasswordText) root.passwordTextEdited(text)
|
|
if (text.length > 0) {
|
|
root.wakeRequested()
|
|
}
|
|
if (text.length > 0 && root.failureMessage.length > 0) root.clearFailureRequested()
|
|
}
|
|
|
|
onAccepted: {
|
|
var submitted = root.passwordText
|
|
root.passwordTextEdited("")
|
|
if (submitted.length > 0) root.submitPassword(submitted)
|
|
}
|
|
|
|
Keys.onPressed: function(event) {
|
|
root.wakeRequested()
|
|
if (event.key === Qt.Key_Escape || (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_U)) {
|
|
root.passwordTextEdited("")
|
|
event.accepted = true
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
anchors.fill: passwordInput
|
|
text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText)
|
|
visible: passwordInput.text.length === 0
|
|
color: root.authenticatingPassword ? Color.lock.text : (root.failureMessage.length > 0 ? Color.lock.textError : Color.lock.placeholder)
|
|
font.family: Style.font.family
|
|
font.pixelSize: root.fieldFontSize
|
|
font.italic: !root.authenticatingPassword && root.failureMessage.length > 0
|
|
horizontalAlignment: Text.AlignHCenter
|
|
verticalAlignment: Text.AlignVCenter
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
}
|