diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-17 14:28:41 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-17 14:28:41 +0200 |
| commit | 8ead58fb5baa8d781159cf443c0f7ffd68cb6775 (patch) | |
| tree | 83e64a459dd45e028b7073f1d5cc2dafcfd05252 | |
| parent | cdcf62416feffcb81cb07fdfe2e2dc0320490829 (diff) | |
| download | sddm-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.md | 18 | ||||
| -rw-r--r-- | theme/Main.qml | 19 | ||||
| -rw-r--r-- | theme/components/LoginCard.qml | 285 | ||||
| -rw-r--r-- | theme/components/Panel.qml | 273 |
4 files changed, 304 insertions, 291 deletions
@@ -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() + } +} |
