Files
omarchycn/shell/plugins/lock/LockView.qml
T
David Heinemeier HanssonandClaude Fable 5 12dc7ae558 Shrink lock screen password dots to fit the field
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>
2026-07-19 11:03:00 -07:00

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