diff --git a/Modules/LockScreen/LockScreenPanel.qml b/Modules/LockScreen/LockScreenPanel.qml index 1b6d0fcd7..91866620c 100644 --- a/Modules/LockScreen/LockScreenPanel.qml +++ b/Modules/LockScreen/LockScreenPanel.qml @@ -760,166 +760,197 @@ Item { } } - // Password dots display with selection support + // Host for dots / plain text and the caret (caret x follows passwordInput.cursorPosition) Item { - width: Math.min(passwordDisplayContent.width, 550) + id: passwordVisualHost height: 20 - visible: passwordInput.text.length > 0 && !parent.parent.parent.passwordVisible + width: passwordInputContainer.passwordVisible ? Math.min(visiblePasswordPlainText.implicitWidth, 550) : Math.min(passwordDisplayContent.width, 550) anchors.verticalCenter: parent.verticalCenter - clip: true - // Proportional selection highlight behind the dots - Rectangle { - id: selectionHighlight - visible: passwordInput.selectionStart !== passwordInput.selectionEnd && passwordInput.text.length > 0 - color: Qt.alpha(Color.mPrimary, 0.8) - height: parent.height + Style.marginS - anchors.verticalCenter: parent.verticalCenter - x: (passwordInput.selectionStart / passwordInput.text.length) * passwordDisplayContent.width - width: ((passwordInput.selectionEnd - passwordInput.selectionStart) / passwordInput.text.length) * passwordDisplayContent.width + readonly property real caretVisualX: { + const len = passwordInput.text.length; + if (len <= 0) + return 0; + if (passwordInputContainer.passwordVisible) { + const adv = passwordCaretFontMetrics.advanceWidth(passwordInput.text.substring(0, passwordInput.cursorPosition)); + return Math.max(0, Math.min(adv, width)); + } + const w = passwordDisplayContent.width; + if (w <= 0) + return 0; + return Math.max(0, Math.min((passwordInput.cursorPosition / len) * w, width)); } - Row { - id: passwordDisplayContent - spacing: Style.marginXXXS + // Password dots display with selection support + Item { + width: Math.min(passwordDisplayContent.width, 550) + height: 20 + visible: passwordInput.text.length > 0 && !passwordInputContainer.passwordVisible + anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter + clip: true - Repeater { - id: iconRepeater - model: ScriptModel { - values: Array(passwordInput.text.length) - } + // Proportional selection highlight behind the dots + Rectangle { + id: selectionHighlight + visible: passwordInput.selectionStart !== passwordInput.selectionEnd && passwordInput.text.length > 0 + color: Qt.alpha(Color.mPrimary, 0.8) + height: parent.height + Style.marginS + anchors.verticalCenter: parent.verticalCenter + x: (passwordInput.selectionStart / passwordInput.text.length) * passwordDisplayContent.width + width: ((passwordInput.selectionEnd - passwordInput.selectionStart) / passwordInput.text.length) * passwordDisplayContent.width + } - property list passwordChars: ["circle-filled", "pentagon-filled", "michelin-star-filled", "square-rounded-filled", "guitar-pick-filled", "blob-filled", "triangle-filled"] + Row { + id: passwordDisplayContent + spacing: Style.marginXXXS + anchors.verticalCenter: parent.verticalCenter - NIcon { - id: icon - - required property int index - // This will be called with index = -1 when the TextInput is deleted - // So we make sur index is positive to avoid warning on array accesses - property bool drawCustomChar: index >= 0 && Settings.data.general.passwordChars - // Flip color when this dot falls inside the active selection range - property bool isSelected: index >= 0 && passwordInput.selectionStart !== passwordInput.selectionEnd && index >= passwordInput.selectionStart && index < passwordInput.selectionEnd - - icon: drawCustomChar ? iconRepeater.passwordChars[index % iconRepeater.passwordChars.length] : "circle-filled" - pointSize: Style.fontSizeL - color: isSelected ? Color.mOnPrimary : Color.mPrimary - opacity: 1.0 - scale: animationsEnabled ? 0.5 : 1 - ParallelAnimation { - id: iconAnim - NumberAnimation { - target: icon - properties: "scale" - to: 1 - duration: Style.animationFast - easing.type: Easing.BezierSpline - easing.bezierCurve: Easing.OutInBounce - } + Repeater { + id: iconRepeater + model: ScriptModel { + values: Array(passwordInput.text.length) } - Component.onCompleted: { - if (animationsEnabled) { - iconAnim.start(); + + property list passwordChars: ["circle-filled", "pentagon-filled", "michelin-star-filled", "square-rounded-filled", "guitar-pick-filled", "blob-filled", "triangle-filled"] + + NIcon { + id: icon + + required property int index + // This will be called with index = -1 when the TextInput is deleted + // So we make sur index is positive to avoid warning on array accesses + property bool drawCustomChar: index >= 0 && Settings.data.general.passwordChars + // Flip color when this dot falls inside the active selection range + property bool isSelected: index >= 0 && passwordInput.selectionStart !== passwordInput.selectionEnd && index >= passwordInput.selectionStart && index < passwordInput.selectionEnd + + icon: drawCustomChar ? iconRepeater.passwordChars[index % iconRepeater.passwordChars.length] : "circle-filled" + pointSize: Style.fontSizeL + color: isSelected ? Color.mOnPrimary : Color.mPrimary + opacity: 1.0 + scale: animationsEnabled ? 0.5 : 1 + ParallelAnimation { + id: iconAnim + NumberAnimation { + target: icon + properties: "scale" + to: 1 + duration: Style.animationFast + easing.type: Easing.BezierSpline + easing.bezierCurve: Easing.OutInBounce + } + } + Component.onCompleted: { + if (animationsEnabled) { + iconAnim.start(); + } } } } } - } - // Mouse area for click-to-position and drag-to-select - MouseArea { - anchors.fill: parent - cursorShape: Qt.IBeamCursor + // Mouse area for click-to-position and drag-to-select + MouseArea { + anchors.fill: parent + cursorShape: Qt.IBeamCursor - property int dragStartPos: 0 - property bool pendingSelectAll: false + property int dragStartPos: 0 + property bool pendingSelectAll: false - // Resets double-click state if user pauses too long between clicks - Timer { - id: doubleClickResetTimer - interval: 600 - onTriggered: parent.pendingSelectAll = false - } - - function charIndexFromX(mouseX) { - if (passwordInput.text.length === 0) - return 0; - var charWidth = passwordDisplayContent.width / passwordInput.text.length; - // floor so clicking anywhere on a dot selects that dot, not the next - return Math.max(0, Math.min(passwordInput.text.length - 1, Math.floor(mouseX / charWidth))); - } - - onPressed: function (mouse) { - doubleClickResetTimer.stop(); - passwordInput.forceActiveFocus(); - dragStartPos = charIndexFromX(mouse.x); - passwordInput.cursorPosition = dragStartPos; - } - - onPositionChanged: function (mouse) { - pendingSelectAll = false; - var curPos = charIndexFromX(mouse.x); - if (curPos <= dragStartPos) { - passwordInput.select(curPos, dragStartPos + 1); - } else { - passwordInput.select(dragStartPos, curPos + 1); + // Resets double-click state if user pauses too long between clicks + Timer { + id: doubleClickResetTimer + interval: 600 + onTriggered: parent.pendingSelectAll = false } - } - onDoubleClicked: function (mouse) { - passwordInput.forceActiveFocus(); - if (pendingSelectAll) { - passwordInput.selectAll(); + function charIndexFromX(mouseX) { + if (passwordInput.text.length === 0) + return 0; + var charWidth = passwordDisplayContent.width / passwordInput.text.length; + // floor so clicking anywhere on a dot selects that dot, not the next + return Math.max(0, Math.min(passwordInput.text.length - 1, Math.floor(mouseX / charWidth))); + } + + onPressed: function (mouse) { + doubleClickResetTimer.stop(); + passwordInput.forceActiveFocus(); + dragStartPos = charIndexFromX(mouse.x); + passwordInput.cursorPosition = dragStartPos; + } + + onPositionChanged: function (mouse) { pendingSelectAll = false; - } else { - var pos = charIndexFromX(mouse.x); - passwordInput.select(pos, Math.min(pos + 1, passwordInput.text.length)); - pendingSelectAll = true; - doubleClickResetTimer.restart(); + var curPos = charIndexFromX(mouse.x); + if (curPos <= dragStartPos) { + passwordInput.select(curPos, dragStartPos + 1); + } else { + passwordInput.select(dragStartPos, curPos + 1); + } + } + + onDoubleClicked: function (mouse) { + passwordInput.forceActiveFocus(); + if (pendingSelectAll) { + passwordInput.selectAll(); + pendingSelectAll = false; + } else { + var pos = charIndexFromX(mouse.x); + passwordInput.select(pos, Math.min(pos + 1, passwordInput.text.length)); + pendingSelectAll = true; + doubleClickResetTimer.restart(); + } } } } - } - NText { - text: passwordInput.text - color: Color.mPrimary - pointSize: Style.fontSizeM - visible: passwordInput.text.length > 0 && parent.parent.parent.passwordVisible - anchors.verticalCenter: parent.verticalCenter - elide: Text.ElideRight - width: Math.min(implicitWidth, 550) - } - - Rectangle { - width: 2 - height: 20 - color: Color.mPrimary - // Hide the cursor when text is selected - visible: passwordInput.activeFocus && passwordInput.text.length > 0 && passwordInput.selectionStart === passwordInput.selectionEnd - anchors.verticalCenter: parent.verticalCenter - - // Smooth fade animation (when animations enabled) - SequentialAnimation on opacity { - loops: Animation.Infinite - running: root.animationsEnabled && passwordInput.activeFocus && passwordInput.text.length > 0 && passwordInput.selectionStart === passwordInput.selectionEnd - NumberAnimation { - to: 0 - duration: 530 - } - NumberAnimation { - to: 1 - duration: 530 - } + NText { + id: visiblePasswordPlainText + text: passwordInput.text + color: Color.mPrimary + pointSize: Style.fontSizeM + visible: passwordInput.text.length > 0 && passwordInputContainer.passwordVisible + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + width: Math.min(implicitWidth, 550) } - // Simple toggle (when animations disabled) — no per-frame repaints - Timer { - interval: 530 - running: !root.animationsEnabled && passwordInput.activeFocus && passwordInput.text.length > 0 && passwordInput.selectionStart === passwordInput.selectionEnd - repeat: true - onTriggered: parent.opacity = parent.opacity > 0.5 ? 0 : 1 + FontMetrics { + id: passwordCaretFontMetrics + font: visiblePasswordPlainText.font + } + + Rectangle { + width: 2 + height: 20 + x: passwordVisualHost.caretVisualX + color: Color.mPrimary + // Hide the cursor when text is selected + visible: passwordInput.activeFocus && passwordInput.text.length > 0 && passwordInput.selectionStart === passwordInput.selectionEnd + anchors.verticalCenter: parent.verticalCenter + + // Smooth fade animation (when animations enabled) + SequentialAnimation on opacity { + loops: Animation.Infinite + running: root.animationsEnabled && passwordInput.activeFocus && passwordInput.text.length > 0 && passwordInput.selectionStart === passwordInput.selectionEnd + NumberAnimation { + to: 0 + duration: 530 + } + NumberAnimation { + to: 1 + duration: 530 + } + } + + // Simple toggle (when animations disabled) — no per-frame repaints + Timer { + interval: 530 + running: !root.animationsEnabled && passwordInput.activeFocus && passwordInput.text.length > 0 && passwordInput.selectionStart === passwordInput.selectionEnd + repeat: true + onTriggered: parent.opacity = parent.opacity > 0.5 ? 0 : 1 + } } } }