Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions shell/plugins/lock/LockView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,25 @@ Item {
}

onPasswordTextChanged: syncPasswordText()
onInputEnabledChanged: {
if (inputEnabled) Qt.callLater(forcePasswordFocus)
}
Component.onCompleted: {
syncPasswordText()
if (inputEnabled) Qt.callLater(forcePasswordFocus)
}

// The lock surface is created before Hyprland maps it and hands over
// keyboard focus, so a single forceActiveFocus() from Component.onCompleted
// lands while the window is still inactive and never re-applies once the
// surface activates. Focus is also dropped again on suspend/resume (the
// surface survives but the compositor keeps its keyboard focus), after the
// idle screensaver that dies up to a second after the lock request, and when
// a failed password re-enables the field. Ask again while the field should
// hold focus but does not; the running binding re-arms on every later focus
// loss. Idle while the display is blanked: there is no focus to win with the
// output down, and a lock can sit blanked all night.
Timer {
interval: 100
repeat: true
running: root.inputEnabled && !root.authenticatingPassword && !root.displaysBlank && !passwordInput.activeFocus
onTriggered: root.forcePasswordFocus()
}

// Measures the masked password at full size; passwordDotScale compares this
Expand Down
34 changes: 34 additions & 0 deletions test/shell.d/lock-focus-retry-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/bin/bash

set -euo pipefail

source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"

run_node_test <<'JS'
const fs = require('fs')
const lockView = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8')

// A single forceActiveFocus() at Component.onCompleted races the compositor
// handing the lock surface keyboard focus, and nothing re-applies it when the
// surface loses focus later (suspend/resume, the idle screensaver dying, a
// failed password re-enabling the field). The field must retry until it holds
// focus, re-armed by the running binding on every focus loss, and idled while
// the display is blanked so a surface that can never take focus (a monitor
// hotplugged mid-lock) does not spin all night.
assert(
/running:\s*root\.inputEnabled\s*&&\s*!root\.authenticatingPassword\s*&&\s*!root\.displaysBlank\s*&&\s*!passwordInput\.activeFocus/.test(lockView),
'the password field retries focus until it holds it, and idles while blanked'
)

assert(
/onTriggered:\s*root\.forcePasswordFocus\(\)/.test(lockView),
'the focus retry re-asserts focus on the password field'
)

// The single-shot grab cannot cover focus lost after completion; the retry
// timer must replace it rather than sit beside it.
assert(
!/if\s*\(inputEnabled\)\s*Qt\.callLater\(forcePasswordFocus\)/.test(lockView),
'the one-shot focus grab no longer replaces the retry timer'
)
JS