fix(Lockscreen): adjust password cursor so it properly follows position

Fixes: #2308
This commit is contained in:
Lysec
2026-03-26 15:29:55 +01:00
parent 70082a6491
commit 9222070803
+164 -133
View File
@@ -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<string> 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<string> 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
}
}
}
}