diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 8853039b..c6c16f7d 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -24,6 +24,11 @@ Item { 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 @@ -68,6 +73,16 @@ Item { 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 @@ -131,8 +146,8 @@ Item { selectionColor: Color.lock.selection selectedTextColor: Color.lock.text font.family: Style.font.family - font.pixelSize: text.length > 0 ? root.passwordDotFontSize : root.fieldFontSize - font.letterSpacing: text.length > 0 ? root.passwordDotLetterSpacing : 0 + 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 diff --git a/test/shell.d/fixtures/lock-password-overflow/shell.qml b/test/shell.d/fixtures/lock-password-overflow/shell.qml new file mode 100644 index 00000000..2e9f70ba --- /dev/null +++ b/test/shell.d/fixtures/lock-password-overflow/shell.qml @@ -0,0 +1,84 @@ +import QtQuick +import Quickshell +import qs.Commons + +ShellRoot { + id: root + + readonly property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT") + readonly property string rootPath: Quickshell.env("OMARCHY_PATH") + property var failures: [] + + function fail(message) { + failures.push(String(message)) + } + + function assertTrue(condition, message) { + if (!condition) fail(message) + } + + function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" + } + + function writeResult() { + var payload = JSON.stringify({ + ok: failures.length === 0, + failures: failures + }) + + if (resultPath) { + Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)]) + } + } + + Item { id: host; width: 800; height: 600 } + + TextMetrics { + id: probe + font.family: Style.font.family + } + + Timer { + interval: 1 + running: true + repeat: false + onTriggered: { + try { + var component = Qt.createComponent("file://" + root.rootPath + "/shell/plugins/lock/LockView.qml", Component.PreferSynchronous) + if (component.status !== Component.Ready) { + root.fail("LockView failed to load: " + component.errorString()) + return + } + + var view = component.createObject(host, { width: 800, height: 600, loadBackground: false }) + if (!view) { + root.fail("LockView failed to instantiate: " + component.errorString()) + return + } + + view.passwordText = "x".repeat(6) + root.assertTrue(view.passwordDotScale === 1, "short passwords keep full-size dots, got scale " + view.passwordDotScale) + + view.passwordText = "x".repeat(40) + var longScale = view.passwordDotScale + root.assertTrue(longScale > 0 && longScale < 1, "overflowing passwords shrink the dots, got scale " + longScale) + + view.passwordText = "x".repeat(80) + var longerScale = view.passwordDotScale + root.assertTrue(longerScale < longScale, "dots keep shrinking as the password grows, got " + longerScale + " vs " + longScale) + + probe.font.pixelSize = Math.max(1, Math.floor(view.passwordDotFontSize * longerScale)) + probe.font.letterSpacing = view.passwordDotLetterSpacing * longerScale + probe.text = "●".repeat(80) + root.assertTrue(probe.advanceWidth <= view.fieldWidth, "all 80 dots fit inside the field, need " + probe.advanceWidth + "px of " + view.fieldWidth) + + view.destroy() + } catch (error) { + root.fail("lock password overflow fixture threw: " + error) + } finally { + root.writeResult() + } + } + } +} diff --git a/test/shell.d/lock-password-overflow-test.sh b/test/shell.d/lock-password-overflow-test.sh new file mode 100755 index 00000000..77a6a3bd --- /dev/null +++ b/test/shell.d/lock-password-overflow-test.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" +QS_PID="" + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +if [[ -z ${WAYLAND_DISPLAY:-} ]]; then + pass "no Wayland compositor; skipping lock password overflow test" + exit 0 +fi + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping lock password overflow test" + exit 0 +fi + +require_command jq + +TMPDIR=$(mktemp -d) +result="$TMPDIR/result.json" +log="$TMPDIR/quickshell.log" +config_dir="$TMPDIR/lock-password-overflow" +mkdir -p "$config_dir" "$TMPDIR/home" +cp "$SHELL_TEST_DIR/fixtures/lock-password-overflow/shell.qml" "$config_dir/shell.qml" +ln -s "$ROOT/shell/Ui" "$config_dir/Ui" +ln -s "$ROOT/shell/Commons" "$config_dir/Commons" + +OMARCHY_PATH="$ROOT" \ +OMARCHY_QML_TEST_RESULT="$result" \ +HOME="$TMPDIR/home" \ +QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ +QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ +PATH="$ROOT/bin:$PATH" \ + quickshell -p "$config_dir" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..80}; do + [[ -s $result ]] && break + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,220p' "$log" >&2 + fail "lock password overflow quickshell exited before writing result" + fi + sleep 0.1 +done + +[[ -s $result ]] || { + sed -n '1,220p' "$log" >&2 + fail "lock password overflow test timed out" +} + +if ! jq -e '.ok == true' "$result" >/dev/null; then + printf 'Lock password overflow result:\n' >&2 + jq . "$result" >&2 + printf 'Lock password overflow log:\n' >&2 + sed -n '1,220p' "$log" >&2 + fail "lock password dots shrink to fit the field" +fi + +pass "lock password dots shrink to fit the field"