feat(lockscreen): add system-themed elegant quickshell lockscreen with PAM authentication and animated letter typing indicator

This commit is contained in:
Sho 2026-08-02 16:12:37 +09:00
parent 80d3dad766
commit 5e32e43091
6 changed files with 701 additions and 0 deletions

View File

@ -0,0 +1,461 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import "../../theme"
import "../../services"
Item {
id: root
focus: true
// Force focus when lockscreen becomes visible
onVisibleChanged: {
if (visible) {
root.forceActiveFocus()
secretInput.forceActiveFocus()
}
}
Component.onCompleted: {
root.forceActiveFocus()
secretInput.forceActiveFocus()
}
// Secret hidden TextInput to capture keyboard presses reliably
TextInput {
id: secretInput
anchors.fill: parent
opacity: 0
focus: true
activeFocusOnTab: true
echoMode: TextInput.Password
onTextChanged: {
// Synchronize with LockscreenService
if (text !== LockscreenService.userPassword) {
LockscreenService.userPassword = text
LockscreenService.typedCount = text.length
}
}
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
event.accepted = true
LockscreenService.submitPassword()
} else if (event.key === Qt.Key_Escape) {
event.accepted = true
secretInput.text = ""
LockscreenService.clearPassword()
}
}
}
Connections {
target: LockscreenService
function onIsLockedChanged() {
if (LockscreenService.isLocked) {
secretInput.text = ""
root.forceActiveFocus()
secretInput.forceActiveFocus()
}
}
function onAuthFailedChanged() {
if (LockscreenService.authFailed) {
shakeAnim.restart()
secretInput.text = ""
}
}
}
// Shake animation on incorrect password
SequentialAnimation {
id: shakeAnim
running: false
NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: -24; duration: 40; easing.type: Easing.OutQuad }
NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: 24; duration: 50; easing.type: Easing.OutQuad }
NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: -16; duration: 50; easing.type: Easing.OutQuad }
NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: 16; duration: 50; easing.type: Easing.OutQuad }
NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: -8; duration: 40; easing.type: Easing.OutQuad }
NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: 8; duration: 40; easing.type: Easing.OutQuad }
NumberAnimation { target: cardContainer; property: "anchors.horizontalCenterOffset"; to: 0; duration: 40; easing.type: Easing.OutQuad }
}
// Click anywhere to focus input
MouseArea {
anchors.fill: parent
onClicked: {
root.forceActiveFocus()
secretInput.forceActiveFocus()
}
}
// Main Centered Content Container
ColumnLayout {
id: mainColumn
anchors.centerIn: parent
spacing: 28
width: Math.min(520, parent.width - 40)
// 1. Clock & Date Header
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 6
Text {
id: clockText
text: {
let date = new Date()
let h = date.getHours().toString().padStart(2, '0')
let m = date.getMinutes().toString().padStart(2, '0')
let s = date.getSeconds().toString().padStart(2, '0')
return `${h}:${m}:${s}`
}
color: Theme.fg
font.pixelSize: 64
font.weight: Font.Bold
font.family: "Sans-Serif"
Layout.alignment: Qt.AlignHCenter
Timer {
interval: 1000
running: true
repeat: true
onTriggered: clockText.text = {
let date = new Date()
let h = date.getHours().toString().padStart(2, '0')
let m = date.getMinutes().toString().padStart(2, '0')
let s = date.getSeconds().toString().padStart(2, '0')
return `${h}:${m}:${s}`
}
}
}
Text {
id: dateText
text: {
let date = new Date()
let options = { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }
return date.toLocaleDateString(Qt.locale(), options)
}
color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.75)
font.pixelSize: 15
font.weight: Font.Medium
Layout.alignment: Qt.AlignHCenter
}
}
Item { Layout.preferredHeight: 10 }
// 2. User Avatar & Badge
ColumnLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 12
Rectangle {
width: 96
height: 96
radius: 48
color: Qt.rgba(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.15)
border.color: Theme.accent
border.width: 2
Layout.alignment: Qt.AlignHCenter
// Avatar Icon / Initial Badge
Text {
anchors.centerIn: parent
text: LockscreenService.username ? LockscreenService.username.charAt(0).toUpperCase() : "🔒"
color: Theme.accent
font.pixelSize: 42
font.weight: Font.Bold
}
// Breathing Glow Ring
Rectangle {
anchors.fill: parent
anchors.margins: -4
radius: 52
color: "transparent"
border.color: Theme.accent
border.width: 1.5
opacity: avatarPulse.opacityVal
Item {
id: avatarPulse
property real opacityVal: 0.3
SequentialAnimation on opacityVal {
loops: Animation.Infinite
running: true
NumberAnimation { to: 0.8; duration: 1500; easing.type: Easing.InOutQuad }
NumberAnimation { to: 0.2; duration: 1500; easing.type: Easing.InOutQuad }
}
}
}
}
Text {
text: LockscreenService.username || "User"
color: Theme.fg
font.pixelSize: 20
font.weight: Font.Bold
Layout.alignment: Qt.AlignHCenter
}
}
Item { Layout.preferredHeight: 4 }
// 3. Elegant Glass Input Card with Animated Typing Dots
Rectangle {
id: cardContainer
Layout.fillWidth: true
implicitHeight: cardLayout.implicitHeight + 36
radius: 20
color: Theme.isDark ? Qt.rgba(30/255, 30/255, 46/255, 0.65) : Qt.rgba(240/255, 240/255, 245/255, 0.75)
border.color: LockscreenService.authFailed ? Theme.red : (secretInput.activeFocus ? Theme.accent : Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.18))
border.width: LockscreenService.authFailed ? 2 : (secretInput.activeFocus ? 2 : 1)
Behavior on border.color { ColorAnimation { duration: 180 } }
ColumnLayout {
id: cardLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 18
spacing: 14
// Password Row: Input Field + Submit Button
RowLayout {
Layout.fillWidth: true
spacing: 12
// Lock Icon
Text {
text: "🔒"
font.pixelSize: 18
Layout.alignment: Qt.AlignVCenter
}
// Password Visual Placeholder / Input Area
Rectangle {
Layout.fillWidth: true
implicitHeight: 44
radius: 12
color: Qt.rgba(0, 0, 0, 0.25)
border.color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.1)
// Empty State Placeholder Text
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 16
visible: LockscreenService.typedCount === 0 && !LockscreenService.isAuthenticating
text: "Type password to unlock..."
color: Theme.comment
font.pixelSize: 14
}
// Authenticating Spinner / Status Text
Text {
anchors.centerIn: parent
visible: LockscreenService.isAuthenticating
text: "Verifying password..."
color: Theme.accent
font.pixelSize: 14
font.weight: Font.Medium
}
// ANIMATED TYPING DOTS (Shows WHEN and HOW MANY letters are typed)
Row {
anchors.centerIn: parent
spacing: 10
visible: LockscreenService.typedCount > 0 && !LockscreenService.isAuthenticating
Repeater {
model: LockscreenService.typedCount
Rectangle {
id: dot
width: 14
height: 14
radius: 7
color: index === LockscreenService.typedCount - 1 ? Theme.accent : Theme.fg
Behavior on color { ColorAnimation { duration: 120 } }
// Spawn Bounce & Pulse Animation for each character typed
scale: 1.0
SequentialAnimation on scale {
running: true
NumberAnimation { from: 0.2; to: 1.4; duration: 120; easing.type: Easing.OutQuad }
NumberAnimation { from: 1.4; to: 1.0; duration: 100; easing.type: Easing.OutBounce }
}
// Inner Glow Core
Rectangle {
anchors.centerIn: parent
width: 6
height: 6
radius: 3
color: "#ffffff"
}
}
}
}
// Focus Click Area
MouseArea {
anchors.fill: parent
onClicked: {
root.forceActiveFocus()
secretInput.forceActiveFocus()
}
}
}
// Submit / Unlock Arrow Button
Rectangle {
width: 44
height: 44
radius: 12
color: submitMouse.containsMouse ? Theme.accent : Qt.rgba(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.25)
border.color: Theme.accent
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: "➔"
color: submitMouse.containsMouse ? "#ffffff" : Theme.accent
font.pixelSize: 18
font.weight: Font.Bold
}
MouseArea {
id: submitMouse
anchors.fill: parent
hoverEnabled: true
onClicked: LockscreenService.submitPassword()
}
}
}
// Dynamic Typing Status / Letter Counter & Feedback Message
RowLayout {
Layout.fillWidth: true
// Typing Letter Counter Badge (Shows EXACT count of letters typed)
Rectangle {
implicitWidth: counterText.implicitWidth + 16
implicitHeight: 24
radius: 12
color: LockscreenService.typedCount > 0 ? Qt.rgba(Theme.accent.r, Theme.accent.g, Theme.accent.b, 0.2) : "transparent"
visible: LockscreenService.typedCount > 0
Behavior on color { ColorAnimation { duration: 150 } }
Text {
id: counterText
anchors.centerIn: parent
text: ` ${LockscreenService.typedCount} ${LockscreenService.typedCount === 1 ? 'letter' : 'letters'} typed`
color: Theme.accent
font.pixelSize: 11
font.weight: Font.Bold
}
}
Item { Layout.fillWidth: true }
// Auth Error Message Display
Text {
text: LockscreenService.authErrorMsg
color: Theme.red
font.pixelSize: 12
font.weight: Font.Bold
visible: LockscreenService.authFailed
}
}
}
}
Item { Layout.preferredHeight: 14 }
// 4. Session Action Buttons (Power & Unlock)
RowLayout {
Layout.alignment: Qt.AlignHCenter
spacing: 16
// Suspend Button
Rectangle {
implicitWidth: 110
implicitHeight: 38
radius: 10
color: suspendMouse.containsMouse ? Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.15) : Qt.rgba(0, 0, 0, 0.3)
border.color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.15)
RowLayout {
anchors.centerIn: parent
spacing: 6
Text { text: "🌙"; font.pixelSize: 13 }
Text { text: "Sleep"; color: Theme.fg; font.pixelSize: 12; font.weight: Font.Medium }
}
MouseArea {
id: suspendMouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
Quickshell.execDetached(["systemctl", "suspend"])
}
}
}
// Reboot Button
Rectangle {
implicitWidth: 110
implicitHeight: 38
radius: 10
color: rebootMouse.containsMouse ? Qt.rgba(Theme.orange.r, Theme.orange.g, Theme.orange.b, 0.25) : Qt.rgba(0, 0, 0, 0.3)
border.color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.15)
RowLayout {
anchors.centerIn: parent
spacing: 6
Text { text: "🔄"; font.pixelSize: 13 }
Text { text: "Restart"; color: Theme.fg; font.pixelSize: 12; font.weight: Font.Medium }
}
MouseArea {
id: rebootMouse
anchors.fill: parent
hoverEnabled: true
onClicked: SessionService.reboot()
}
}
// Shutdown Button
Rectangle {
implicitWidth: 110
implicitHeight: 38
radius: 10
color: powerMouse.containsMouse ? Qt.rgba(Theme.red.r, Theme.red.g, Theme.red.b, 0.25) : Qt.rgba(0, 0, 0, 0.3)
border.color: Qt.rgba(Theme.fg.r, Theme.fg.g, Theme.fg.b, 0.15)
RowLayout {
anchors.centerIn: parent
spacing: 6
Text { text: "⚡"; font.pixelSize: 13 }
Text { text: "Power Off"; color: Theme.fg; font.pixelSize: 12; font.weight: Font.Medium }
}
MouseArea {
id: powerMouse
anchors.fill: parent
hoverEnabled: true
onClicked: SessionService.shutdown()
}
}
}
}
}

View File

@ -0,0 +1,50 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import "../../theme"
import "../../services"
PanelWindow {
id: lockscreenWindow
anchors {
top: true
bottom: true
left: true
right: true
}
WlrLayershell.layer: WlrLayershell.Overlay
WlrLayershell.keyboardFocus: LockscreenService.isLocked ? WlrLayershell.Exclusive : WlrLayershell.None
exclusionMode: ExclusionMode.Ignore
visible: LockscreenService.isLocked
color: "black"
// Background System Wallpaper
Image {
anchors.fill: parent
source: WallpaperService.currentWallpaper
fillMode: Image.PreserveAspectCrop
opacity: 0.65
Behavior on source {
PropertyAnimation { duration: 300 }
}
}
// Glass Dark Backdrop Overlay with Gradient
Rectangle {
anchors.fill: parent
gradient: Gradient {
GradientStop { position: 0.0; color: Theme.isDark ? Qt.rgba(15/255, 15/255, 25/255, 0.82) : Qt.rgba(20/255, 20/255, 30/255, 0.70) }
GradientStop { position: 0.5; color: Theme.isDark ? Qt.rgba(20/255, 20/255, 32/255, 0.88) : Qt.rgba(25/255, 25/255, 38/255, 0.78) }
GradientStop { position: 1.0; color: Theme.isDark ? Qt.rgba(10/255, 10/255, 18/255, 0.92) : Qt.rgba(15/255, 15/255, 25/255, 0.85) }
}
}
// Lockscreen Content Layout
LockscreenContent {
anchors.fill: parent
}
}

View File

@ -0,0 +1,110 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: root
property bool isLocked: false
property string userPassword: ""
property bool isAuthenticating: false
property bool authFailed: false
property string authErrorMsg: ""
property int typedCount: 0
property string username: ""
property string realName: ""
// Signal emitted whenever a key is typed to trigger animated dot entry/bounce
signal keyTyped(string char)
signal keyDeleted()
Timer {
id: resetErrorTimer
interval: 2000
repeat: false
onTriggered: {
root.authFailed = false
root.authErrorMsg = ""
}
}
Component.onCompleted: {
usernameProc.running = true
}
Process {
id: usernameProc
command: ["whoami"]
stdout: SplitParser {
onRead: data => {
let u = data.trim()
if (u !== "") root.username = u
}
}
}
function lock() {
userPassword = ""
typedCount = 0
authFailed = false
authErrorMsg = ""
isLocked = true
}
function unlock() {
userPassword = ""
typedCount = 0
authFailed = false
authErrorMsg = ""
isLocked = false
}
function appendChar(ch) {
if (isAuthenticating) return;
authFailed = false
userPassword += ch
typedCount = userPassword.length
root.keyTyped(ch)
}
function backspace() {
if (isAuthenticating) return;
authFailed = false
if (userPassword.length > 0) {
userPassword = userPassword.substring(0, userPassword.length - 1)
typedCount = userPassword.length
root.keyDeleted()
}
}
function clearPassword() {
userPassword = ""
typedCount = 0
}
function submitPassword() {
if (isAuthenticating || userPassword === "") return;
isAuthenticating = true
authFailed = false
authErrorMsg = ""
authProc.command = ["python3", Quickshell.env("HOME") + "/.config/quickshell/services/python/lockscreen_auth_service.py", userPassword]
authProc.running = true
}
Process {
id: authProc
onExited: (code, exitStatus) => {
root.isAuthenticating = false
if (code === 0) {
root.unlock()
} else {
root.authFailed = true
root.authErrorMsg = "Incorrect password"
root.clearPassword()
resetErrorTimer.restart()
}
}
}
}

View File

@ -7,6 +7,7 @@ Item {
id: root
function lock() {
LockscreenService.lock()
lockProc.running = true
}

View File

@ -0,0 +1,75 @@
#!/usr/bin/env python3
import sys
import os
import ctypes
import ctypes.util
import getpass
libpam = None
try:
libpam_path = ctypes.util.find_library('pam') or 'libpam.so.0'
libpam = ctypes.CDLL(libpam_path)
except Exception:
libpam = None
libc = ctypes.CDLL(None)
class PamMessage(ctypes.Structure):
_fields_ = [('msg_style', ctypes.c_int), ('msg', ctypes.c_char_p)]
class PamResponse(ctypes.Structure):
_fields_ = [('resp', ctypes.c_char_p), ('resp_retcode', ctypes.c_int)]
CONV_FUNC = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.POINTER(ctypes.POINTER(PamMessage)), ctypes.POINTER(ctypes.POINTER(PamResponse)), ctypes.c_void_p)
class PamConv(ctypes.Structure):
_fields_ = [('conv', CONV_FUNC), ('appdata_ptr', ctypes.c_void_p)]
def verify_password(username, password):
if not password:
return False
if not libpam:
return False
pwd_bytes = password.encode('utf-8')
def conv_cb(num_msg, msg, resp, appdata_ptr):
response_array = (PamResponse * num_msg)()
for i in range(num_msg):
response_array[i].resp = libc.strdup(pwd_bytes)
response_array[i].resp_retcode = 0
resp[0] = ctypes.cast(response_array, ctypes.POINTER(PamResponse))
return 0
cb = CONV_FUNC(conv_cb)
conv = PamConv(cb, None)
for service in ['system-auth', 'login', 'passwd', 'kde', 'sddm', 'gdm', 'su', 'shadow']:
pamh = ctypes.c_void_p()
res = libpam.pam_start(service.encode('utf-8'), username.encode('utf-8'), ctypes.byref(conv), ctypes.byref(pamh))
if res == 0:
auth_res = libpam.pam_authenticate(pamh, 0)
libpam.pam_end(pamh, auth_res)
if auth_res == 0:
return True
return False
def main():
if len(sys.argv) < 2:
# Read from stdin
password = sys.stdin.read().strip()
else:
password = sys.argv[1].strip()
username = getpass.getuser()
if len(sys.argv) >= 3:
username = sys.argv[2].strip()
success = verify_password(username, password)
if success:
sys.exit(0)
else:
sys.exit(1)
if __name__ == '__main__':
main()

View File

@ -7,6 +7,7 @@ import "modules/launcher"
import "modules/wallpaper"
import "modules/notifications"
import "modules/desktop"
import "modules/lockscreen"
import "services"
Scope {
@ -144,4 +145,7 @@ Scope {
// Bottom-Right Notification Toast Overlay
NotificationToast {}
// Lockscreen Overlay Window (Layer: Overlay)
LockscreenWindow {}
}