aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-17 14:28:41 +0200
committerDanilo M. <danix@danix.xyz>2026-09-17 14:28:41 +0200
commit8ead58fb5baa8d781159cf443c0f7ffd68cb6775 (patch)
tree83e64a459dd45e028b7073f1d5cc2dafcfd05252
parentcdcf62416feffcb81cb07fdfe2e2dc0320490829 (diff)
downloadsddm-theme-udt-8ead58fb5baa8d781159cf443c0f7ffd68cb6775.tar.gz
sddm-theme-udt-8ead58fb5baa8d781159cf443c0f7ffd68cb6775.zip
feat: replace the login card with a left panel
Draw the greeter as a full-height left panel with a clock, greeting, avatar, a staged username-then-password slot, a power row and the session selector at the bottom. Delete the centered login card.
-rw-r--r--README.md18
-rw-r--r--theme/Main.qml19
-rw-r--r--theme/components/LoginCard.qml285
-rw-r--r--theme/components/Panel.qml273
4 files changed, 304 insertions, 291 deletions
diff --git a/README.md b/README.md
index 348ffac..8316215 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# sddm-theme-udt
An SDDM login theme for the unified-desktop-theme (UDT) family. It draws the
-current wallpaper on every screen, pins the login card to one screen, and takes
+current wallpaper on every screen, pins the login panel to one screen, and takes
its colours from the UDT palette, including the wallpaper-snapped accent.
## How it is wired
@@ -16,6 +16,22 @@ Set `uiScreen` in the root-owned `theme/theme.conf` to pin the login card to a
named screen; leave it empty for the primary screen. It does not belong in the
generated `theme.conf.user`, which is overwritten on every wallpaper change.
+## Panel
+
+The greeter draws a full-height panel on the left edge, `panelWidth` of the
+screen wide (0.30 by default), frosted over the wallpaper with square corners.
+From top to bottom it shows the clock (`timeFormat`) and date (`dateFormat`),
+the `greeting`, the avatar, one credential slot, the power row, and the session
+selector at the bottom.
+
+The credential slot is staged: type the username, confirm, and it slides out to
+reveal the password; confirm again to log in. Esc or the back chevron returns
+to the username.
+
+Power icons come from the icon theme the appearance drawer selected. `udt-accent`
+resolves them and writes absolute paths into the generated `theme.conf.user`;
+when a theme has no match, the bundled `theme/icons/*.svg` draw instead.
+
## Install
The theme code is copied by root, then SDDM is pointed at it. Both are one-time
diff --git a/theme/Main.qml b/theme/Main.qml
index 63cb6ea..a5acac9 100644
--- a/theme/Main.qml
+++ b/theme/Main.qml
@@ -86,7 +86,7 @@ Item {
function wake() {
if (!root.uiVisible) {
root.uiVisible = true
- card.restoreFocus()
+ panel.restoreFocus()
}
idle.restart()
}
@@ -103,10 +103,19 @@ Item {
event.accepted = false
}
- LoginCard {
- id: card
- anchors.centerIn: parent
- anchors.horizontalCenterOffset: card.shakeOffset
+ // Panel width as a fraction of the window, with a sane fallback when the
+ // key is missing or malformed.
+ readonly property real panelWidth: {
+ var v = parseFloat(config.stringValue("panelWidth"))
+ return v > 0 ? v : 0.30
+ }
+
+ Panel {
+ id: panel
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width * root.panelWidth
visible: root.cardScreen
enabled: root.uiVisible
opacity: root.uiVisible ? 1 : 0
diff --git a/theme/components/LoginCard.qml b/theme/components/LoginCard.qml
deleted file mode 100644
index e743142..0000000
--- a/theme/components/LoginCard.qml
+++ /dev/null
@@ -1,285 +0,0 @@
-// 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. Main reads this as the card's
- // anchors.horizontalCenterOffset, so the shake never touches the card's
- // own position bindings.
- property real shakeOffset: 0
-
- implicitWidth: 380
- implicitHeight: column.implicitHeight + 2 * 28
-
- // Frosted surface: the wallpaper behind the card, blurred and tinted.
- Backdrop {
- anchors.fill: parent
- radius: card.radius
- sourceItem: card.backdropSource
- tint: config.stringValue("mantle")
- blurEnabled: config.boolValue("blur")
- }
-
- 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()
- }
- }
-
- PowerBar {
- anchors.horizontalCenter: parent.horizontalCenter
- }
-
- 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)
- }
-
- // Return keyboard focus after the idle fade disabled the card: password
- // when a username is already filled, else the username field.
- function restoreFocus() {
- if (usernameField.text.length > 0)
- passwordField.forceActiveFocus()
- else
- usernameField.forceActiveFocus()
- }
-
- // 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()
- }
-}
diff --git a/theme/components/Panel.qml b/theme/components/Panel.qml
new file mode 100644
index 0000000..4df989b
--- /dev/null
+++ b/theme/components/Panel.qml
@@ -0,0 +1,273 @@
+// SPDX-License-Identifier: GPL-2.0-only
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// The left login panel: clock and date, greeting, avatar, the staged credential
+// slot, the power row and the session selector. Main shows it on the window
+// whose screen matches uiScreen, else on the primary one.
+
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Effects
+
+Item {
+ id: panel
+
+ signal wake()
+
+ // The wallpaper image to frost behind the panel, and the failed-login shake
+ // applied to the content so the frost stays put.
+ property Item backdropSource: null
+ property real shakeOffset: 0
+
+ readonly property string accentColor: config.stringValue("accent")
+ readonly property string textColor: config.stringValue("text")
+ readonly property string subtextColor: config.stringValue("subtext0")
+ readonly property string greeting: config.stringValue("greeting") !== ""
+ ? config.stringValue("greeting")
+ : "hello there"
+ readonly property string timeFormat: config.stringValue("timeFormat") !== ""
+ ? config.stringValue("timeFormat")
+ : "HH:mm"
+ readonly property string dateFormat: config.stringValue("dateFormat") !== ""
+ ? config.stringValue("dateFormat")
+ : "dddd, d MMMM"
+
+ // Live clock, ticked once a second.
+ property date now: new Date()
+ Timer {
+ interval: 1000
+ running: true
+ repeat: true
+ onTriggered: panel.now = new Date()
+ }
+
+ // Frosted ground, square corners.
+ Backdrop {
+ anchors.fill: parent
+ radius: 0
+ sourceItem: panel.backdropSource
+ tint: config.stringValue("mantle")
+ blurEnabled: config.boolValue("blur")
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ border.width: 2
+ border.color: panel.accentColor
+ }
+
+ // Non-visual: userModel faces, looked up by the entered username.
+ Repeater {
+ id: faces
+ model: userModel
+ delegate: Item {
+ readonly property string faceName: name
+ readonly property string faceIcon: icon
+ visible: false
+ }
+ }
+
+ property string avatarPath: ""
+ readonly property string initials: creds.username.length > 0
+ ? creds.username.charAt(0).toUpperCase()
+ : "?"
+
+ function refreshAvatar() {
+ for (var i = 0; i < faces.count; ++i) {
+ var it = faces.itemAt(i)
+ if (it && it.faceName === creds.username) {
+ avatarPath = it.faceIcon || ""
+ return
+ }
+ }
+ avatarPath = ""
+ }
+
+ // Everything below shifts on a failed login.
+ Item {
+ id: content
+ anchors.fill: parent
+ transform: Translate { x: panel.shakeOffset }
+
+ Column {
+ id: top
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.margins: 32
+ spacing: 14
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ text: Qt.formatDateTime(panel.now, panel.timeFormat)
+ color: panel.textColor
+ font.family: "Noto Sans"
+ font.pixelSize: 56
+ font.weight: Font.Light
+ }
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ text: Qt.formatDateTime(panel.now, panel.dateFormat)
+ color: panel.subtextColor
+ font.family: "Noto Sans"
+ font.pixelSize: 18
+ }
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ text: panel.greeting
+ color: panel.textColor
+ font.family: "Noto Sans"
+ font.pixelSize: 18
+ topPadding: 8
+ }
+
+ 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: panel.initials
+ color: panel.textColor
+ font.family: "Noto Sans"
+ font.pixelSize: 34
+ }
+ }
+
+ Image {
+ id: avatarImage
+ anchors.fill: parent
+ source: panel.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: panel.accentColor
+ }
+ }
+
+ Credentials {
+ id: creds
+ width: parent.width
+ onSubmitted: panel.submit()
+ onWake: panel.wake()
+ onUsernameChanged: panel.refreshAvatar()
+ }
+
+ Text {
+ width: parent.width
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ font.family: "Noto Sans"
+ font.pixelSize: 12
+ color: panel.loginError !== ""
+ ? config.stringValue("red")
+ : config.stringValue("yellow")
+ text: panel.message
+ }
+ }
+
+ PowerBar {
+ anchors.horizontalCenter: parent.horizontalCenter
+ anchors.bottom: sessionBox.top
+ anchors.bottomMargin: 20
+ }
+
+ ComboBox {
+ id: sessionBox
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: 32
+ 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")
+ }
+ }
+ }
+
+ function submit() {
+ if (creds.username.length === 0)
+ return
+ sddm.login(creds.username, creds.password, sessionBox.currentIndex)
+ }
+
+ function restoreFocus() {
+ creds.focusStage()
+ }
+
+ // Login failure wins the notice line, otherwise Caps Lock.
+ 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() {
+ panel.loginError = "Login failed"
+ creds.clearPassword()
+ shake.restart()
+ }
+ function onLoginSucceeded() {
+ panel.loginError = ""
+ }
+ }
+
+ SequentialAnimation {
+ id: shake
+ NumberAnimation { target: panel; property: "shakeOffset"; to: -10; duration: 40 }
+ NumberAnimation { target: panel; property: "shakeOffset"; to: 10; duration: 40 }
+ NumberAnimation { target: panel; property: "shakeOffset"; to: 0; duration: 40 }
+ }
+
+ Component.onCompleted: {
+ refreshAvatar()
+ creds.focusStage()
+ }
+}