From 12dc7ae558ba11b0ee9bca66d762b1edc3e34138 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 19 Jul 2026 11:03:00 -0700 Subject: [PATCH] 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 --- shell/plugins/lock/LockView.qml | 19 ++++- .../fixtures/lock-password-overflow/shell.qml | 84 +++++++++++++++++++ test/shell.d/lock-password-overflow-test.sh | 71 ++++++++++++++++ 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 test/shell.d/fixtures/lock-password-overflow/shell.qml create mode 100755 test/shell.d/lock-password-overflow-test.sh 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"