From 5f73e8c655e65d31f1d400c95d5f8aa4578e8e41 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 17 Sep 2026 12:57:22 +0200 Subject: feat: login card with avatar, inputs and idle fade --- theme/components/LoginCard.qml | 270 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 theme/components/LoginCard.qml (limited to 'theme/components') diff --git a/theme/components/LoginCard.qml b/theme/components/LoginCard.qml new file mode 100644 index 0000000..b12eecb --- /dev/null +++ b/theme/components/LoginCard.qml @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: GPL-2.0-only +// Copyright (C) 2026 Danilo M. +// +// The login card: avatar, username, password, session choice, feedback and the +// power bar. Reads the palette straight from `config`; SDDM merges theme.conf +// and the live theme.conf.user into it. + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Effects + +Item { + id: card + + // The wallpaper image to frost behind the card. Null falls back to a flat + // translucent surface. + property Item backdropSource: null + readonly property int radius: 10 + signal wake() + + // Shake on a failed login without touching the card's own x binding, which + // Main owns: animating x directly would destroy that binding. + property real shakeOffset: 0 + + implicitWidth: 380 + implicitHeight: column.implicitHeight + 2 * 28 + + // Flat translucent surface. Task 4 layers the blur behind it. + Rectangle { + anchors.fill: parent + radius: card.radius + color: config.stringValue("mantle") + opacity: 0.82 + } + + Rectangle { + anchors.fill: parent + radius: card.radius + color: "transparent" + border.width: 2 + border.color: config.stringValue("accent") + } + + // Avatar lookup. userModel is a list model; Repeater delegates expose its + // roles by name. The matching face is copied out on demand because the + // username can change. + property string avatarPath: "" + readonly property string initials: + usernameField.text.length > 0 ? usernameField.text.charAt(0).toUpperCase() : "?" + + Repeater { + id: faces + model: userModel + delegate: Item { + readonly property string faceName: name + readonly property string faceIcon: icon + visible: false + } + } + + function refreshAvatar() { + for (var i = 0; i < faces.count; ++i) { + var it = faces.itemAt(i) + if (it && it.faceName === usernameField.text) { + avatarPath = it.faceIcon || "" + return + } + } + avatarPath = "" + } + + Column { + id: column + anchors.centerIn: parent + width: parent.width - 2 * 28 + spacing: 16 + + Item { + width: 96 + height: 96 + anchors.horizontalCenter: parent.horizontalCenter + + Rectangle { + anchors.fill: parent + radius: width / 2 + color: config.stringValue("surface0") + visible: avatarImage.status !== Image.Ready + Text { + anchors.centerIn: parent + text: card.initials + color: config.stringValue("text") + font.family: "Noto Sans" + font.pixelSize: 34 + } + } + + Image { + id: avatarImage + anchors.fill: parent + source: card.avatarPath + fillMode: Image.PreserveAspectCrop + visible: false + } + + MultiEffect { + anchors.fill: parent + source: avatarImage + visible: avatarImage.status === Image.Ready + maskEnabled: true + maskSource: avatarMask + } + + Rectangle { + id: avatarMask + anchors.fill: parent + radius: width / 2 + layer.enabled: true + visible: false + } + + Rectangle { + anchors.fill: parent + radius: width / 2 + color: "transparent" + border.width: 2 + border.color: config.stringValue("accent") + } + } + + TextField { + id: usernameField + width: parent.width + text: userModel.lastUser !== undefined ? userModel.lastUser : "" + placeholderText: "Username" + font.family: "Noto Sans" + color: config.stringValue("text") + palette.base: config.stringValue("surface0") + palette.text: config.stringValue("text") + palette.highlight: config.stringValue("accent") + palette.highlightedText: config.stringValue("base") + background: Rectangle { + radius: 6 + color: config.stringValue("surface0") + } + onTextChanged: card.refreshAvatar() + onAccepted: card.login() + Keys.onPressed: event => { + card.wake() + event.accepted = false + } + } + + TextField { + id: passwordField + width: parent.width + echoMode: TextInput.Password + placeholderText: "Password" + font.family: "Noto Sans" + color: config.stringValue("text") + palette.base: config.stringValue("surface0") + palette.text: config.stringValue("text") + palette.highlight: config.stringValue("accent") + palette.highlightedText: config.stringValue("base") + background: Rectangle { + radius: 6 + color: config.stringValue("surface0") + } + onAccepted: card.login() + Keys.onPressed: event => { + card.wake() + event.accepted = false + } + } + + Row { + width: parent.width + spacing: 8 + + ComboBox { + id: sessionBox + width: parent.width - loginButton.width - parent.spacing + model: sessionModel + textRole: "name" + currentIndex: sessionModel.lastIndex + font.family: "Noto Sans" + palette.base: config.stringValue("surface0") + palette.text: config.stringValue("text") + palette.highlight: config.stringValue("accent") + palette.highlightedText: config.stringValue("base") + background: Rectangle { + radius: 6 + color: config.stringValue("surface0") + } + } + + Button { + id: loginButton + text: "Log in" + font.family: "Noto Sans" + palette.button: config.stringValue("accent") + palette.buttonText: config.stringValue("base") + background: Rectangle { + radius: 6 + color: config.stringValue("accent") + } + contentItem: Text { + text: loginButton.text + color: config.stringValue("base") + font: loginButton.font + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + padding: 8 + } + onClicked: card.login() + } + } + + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + font.family: "Noto Sans" + font.pixelSize: 12 + color: card.loginError !== "" + ? config.stringValue("red") + : config.stringValue("yellow") + text: card.message + } + } + + function login() { + if (usernameField.text.length === 0) + return + sddm.login(usernameField.text, passwordField.text, sessionBox.currentIndex) + } + + // Login failure wins the notice line; otherwise Caps Lock, if on. + property string loginError: "" + readonly property string message: { + if (loginError !== "") + return loginError + if (keyboard.capsLock) + return "Caps Lock is on" + return "" + } + + Connections { + target: sddm + function onLoginFailed() { + loginError = "Login failed" + passwordField.text = "" + shake.restart() + } + function onLoginSucceeded() { + loginError = "" + } + } + + SequentialAnimation { + id: shake + NumberAnimation { target: card; property: "shakeOffset"; to: -10; duration: 40 } + NumberAnimation { target: card; property: "shakeOffset"; to: 10; duration: 40 } + NumberAnimation { target: card; property: "shakeOffset"; to: 0; duration: 40 } + } + + Component.onCompleted: { + refreshAvatar() + usernameField.forceActiveFocus() + } +} -- cgit v1.2.3