diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-17 12:57:22 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-17 12:57:22 +0200 |
| commit | 5f73e8c655e65d31f1d400c95d5f8aa4578e8e41 (patch) | |
| tree | 1ed9d54c2c2f180f15448a81ae89db84542c88ed | |
| parent | 46b3571bdc4fe37b1cccd1fa4400d17a62ad44f4 (diff) | |
| download | sddm-theme-udt-5f73e8c655e65d31f1d400c95d5f8aa4578e8e41.tar.gz sddm-theme-udt-5f73e8c655e65d31f1d400c95d5f8aa4578e8e41.zip | |
feat: login card with avatar, inputs and idle fade
| -rw-r--r-- | theme/Main.qml | 14 | ||||
| -rw-r--r-- | theme/components/LoginCard.qml | 270 |
2 files changed, 276 insertions, 8 deletions
diff --git a/theme/Main.qml b/theme/Main.qml index f1b91ee..e5c529f 100644 --- a/theme/Main.qml +++ b/theme/Main.qml @@ -6,6 +6,7 @@ // else the primary screen); input wakes the card after it fades. import QtQuick +import "components" Item { id: root @@ -110,17 +111,14 @@ Item { event.accepted = false } - // Placeholder card. Task 2 replaces this with components/LoginCard.qml. - Rectangle { + LoginCard { id: card - x: root.cardGeometry.x + (root.cardGeometry.width - width) / 2 + x: root.cardGeometry.x + (root.cardGeometry.width - width) / 2 + card.shakeOffset y: root.cardGeometry.y + (root.cardGeometry.height - height) / 2 - width: 380 - height: 220 - radius: root.cardRadius - color: config.stringValue("mantle") - opacity: root.uiVisible ? 0.9 : 0 + backdropSource: root.cardScreenImage visible: opacity > 0 + opacity: root.uiVisible ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } } + onWake: root.wake() } } 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. <danix@danix.xyz> +// +// 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() + } +} |
