Files
omarchy/shell/plugins/lock/LockView.qml
T
David Heinemeier HanssonandClaude Opus 5.5 387fcf599a Keep the lock wallpaper decoded so waking shows it with the password field (#13431)
The lock started decoding its wallpaper only once locked, with the cache
off, and first at the view's unsized native resolution. A machine
suspending right after locking froze that decode partway, so waking
showed the password field on a bare background and the wallpaper
popped in after it. On this machine the wallpaper took ~208ms to become
ready, and the suspend followed the lock by 66ms.

The lock service now keeps each screen's lock wallpaper decoded in the
image cache, as the lock view requests it: same URL, the screen's
logical size, PreserveAspectCrop. The view waits for its size and reads
from the cache, so the wallpaper is ready within ~3ms of the lock
starting. The version in the cached URL follows the file's mtime and
size, so a wallpaper overwritten in place still reloads.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:08 +02:00

243 lines
9.1 KiB
QML

import QtQuick
import QtQuick.Effects
import qs.Commons
import qs.Ui
Item {
id: root
property string backgroundPath: ""
property string videoPosterPath: ""
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
// A locked session blanks the displays after a few seconds. Nothing is
// visible from then until the user wakes it, so a video must not keep
// decoding through what is usually the longest part of a lock.
property bool displaysBlank: false
property bool powerSaverActive: false
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)
// Space to keep clear on each side of the field for the fingerprint icon
// (icon width plus a gap) so the centered dots never run under it.
readonly property real fingerprintReserve: fingerprintConfigured ? Math.round(fingerprintIcon.implicitWidth + 12) : 0
// 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")
readonly property bool video: Util.isVideoPath(root.backgroundPath)
readonly property bool feedActive: root.video && root.loadBackground && !root.displaysBlank && !root.powerSaverActive
signal submitPassword(string password)
signal passwordTextEdited(string password)
signal clearFailureRequested()
signal wakeRequested()
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
BackgroundMedia {
id: wallpaper
objectName: "lockWallpaper"
anchors.fill: parent
path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : ""
version: root.backgroundVersion
// Decode only once sized, at the lock's own size: an unsized first
// request decoded the file at its native resolution, then again once
// sized. That size is what the lock service keeps decoded ahead of the
// lock, so the first frame has the wallpaper.
cached: true
constrainDecode: true
decodeSize: Qt.size(width, height)
}
MultiEffect {
anchors.fill: wallpaper
source: wallpaper
autoPaddingEnabled: false
blurEnabled: root.loadBackground && wallpaper.ready
blur: 1.0
blurMax: 128
blurMultiplier: 1.25
contrast: -0.08
}
// The cached poster stays behind the feed when policy pauses playback,
// the module is unavailable, or a new connection has not received a frame.
Loader {
id: feedLoader
objectName: "lockFeedLoader"
anchors.fill: parent
active: root.feedActive
source: "LockFeedSurface.qml"
visible: status === Loader.Ready
}
// The feed item cannot be sampled by MultiEffect on every renderer.
// Keep video wallpapers visible and darken them slightly for legibility.
Rectangle {
anchors.fill: feedLoader
visible: root.video
color: "#22000000"
}
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
// Reserve the fingerprint icon's width on both sides so the centered
// dots stay symmetric and never slide under the icon as they grow.
anchors.rightMargin: inputField.borderRight + 18 + root.fingerprintReserve
anchors.bottomMargin: inputField.borderBottom
anchors.leftMargin: inputField.borderLeft + 18 + root.fingerprintReserve
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 {
textFormat: Text.PlainText
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
}
// Fingerprint hint pinned inside the field's right edge when a sensor is
// enrolled, so the user knows they can touch to unlock instead of typing.
// Matches hyprlock, which draws its fingerprint icon in the same spot.
Text {
id: fingerprintIcon
objectName: "fingerprintIndicator"
anchors.right: parent.right
anchors.rightMargin: inputField.borderRight + 18
anchors.verticalCenter: parent.verticalCenter
visible: root.fingerprintConfigured
text: "󰈷"
color: Color.lock.placeholder
font.family: Style.font.family
font.pixelSize: Math.round(root.fieldFontSize * 1.1)
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}