aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 12:57:22 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 12:57:22 +0200
commit5f73e8c655e65d31f1d400c95d5f8aa4578e8e41 (patch)
tree1ed9d54c2c2f180f15448a81ae89db84542c88ed
parent46b3571bdc4fe37b1cccd1fa4400d17a62ad44f4 (diff)
downloadsddm-theme-udt-5f73e8c655e65d31f1d400c95d5f8aa4578e8e41.tar.gz
sddm-theme-udt-5f73e8c655e65d31f1d400c95d5f8aa4578e8e41.zip
feat: login card with avatar, inputs and idle fade
-rw-r--r--theme/Main.qml14
-rw-r--r--theme/components/LoginCard.qml270
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()
+ }
+}