diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-17 14:19:48 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-17 14:19:48 +0200 |
| commit | bac97cd59f2f9149254b2a22227214e8efbd7e07 (patch) | |
| tree | 0dc24a58ae551b59843159b83eafabdb2bd74411 | |
| parent | 81973e758a5bc543d2d38a16b0251bb0786b840d (diff) | |
| download | sddm-theme-udt-bac97cd59f2f9149254b2a22227214e8efbd7e07.tar.gz sddm-theme-udt-bac97cd59f2f9149254b2a22227214e8efbd7e07.zip | |
docs: plan the left panel greeter implementation
Six tasks across sddm-theme-udt, unified-desktop-theme and quickshell:
resolve the power icons in udt-accent, add the panel config keys, draw
the power bar from them, add the staged credential slot, build the
panel and swap Main, and track the icon-theme trigger follow-up.
| -rw-r--r-- | docs/superpowers/plans/2026-09-17-sddm-theme-udt-panel.md | 1005 |
1 files changed, 1005 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-17-sddm-theme-udt-panel.md b/docs/superpowers/plans/2026-09-17-sddm-theme-udt-panel.md new file mode 100644 index 0000000..ea0d778 --- /dev/null +++ b/docs/superpowers/plans/2026-09-17-sddm-theme-udt-panel.md @@ -0,0 +1,1005 @@ +# Left Panel Greeter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the centered login card with a full-height left panel that shows a clock, greeting, avatar, a staged username-then-password field, and power/session controls, with power icons resolved from the desktop's selected icon theme. + +**Architecture:** `udt-accent` keeps writing the palette INI, and additionally resolves the four FreeDesktop power icons from the gsettings icon theme into absolute paths. The QML theme becomes a `Panel` with a `Credentials` child that animates one field slot between a username stage and a password stage. The greeter still reads only INI data; no new file reads happen inside QML. + +**Tech Stack:** QML (Qt 6.11, SDDM theme API 2.0), Python 3 stdlib (`unified-desktop-theme/bin/udt-accent`). + +**Spec:** `docs/superpowers/specs/2026-09-17-sddm-theme-udt-panel-design.md` + +## Global Constraints + +- Target is SDDM 0.21.0, Qt 6.11.2, theme API 2.0. +- The greeter runs as the unprivileged `sddm` user. QML must not read files; all live input arrives through the INI `config` object. +- Python code is stdlib only. No new dependency. +- Every QML and Python file carries the SPDX line `// SPDX-License-Identifier: GPL-2.0-only` (or `#` for Python) and `Copyright (C) 2026 Danilo M. <danix@danix.xyz>`. +- Only `Noto Sans` is used in the greeter; no bundled fonts. +- The panel has square corners (no radius). +- Power icons are drawn as flat silhouettes by `MultiEffect`; the icon theme supplies the shape, the UDT palette supplies the colour. + +--- + +### Task 1: Resolve the power icons in udt-accent + +**Repository:** `~/Programming/GIT/unified-desktop-theme` + +**Files:** +- Modify: `bin/udt-accent` (constants near line 50, `sddm_conf`/`write_sddm` around lines 311-342, `selftest` around line 415) + +**Interfaces:** +- Consumes: nothing new. +- Produces: `icon_theme() -> str`, `find_icon(theme: str, names: list[str], dirs: list[Path] | None = None) -> str`, `power_icons() -> dict[str, str]`, and `sddm_conf(palette, name, image, icons: dict | None = None) -> str`. The emitted INI gains the keys `powerIcon`, `rebootIcon`, `suspendIcon`, `hibernateIcon`. + +- [ ] **Step 1: Write the failing selftest additions** + +In `bin/udt-accent`, insert this block immediately before the `print("selftest OK")` line at the end of `selftest()`: + +```python + # Power icons: an unknown or empty theme resolves to empty, so the greeter + # falls back to its bundled SVGs; a real theme prefers the symbolic variant + # and a name list is honoured in order. + assert find_icon("", ["system-shutdown"]) == "" + assert find_icon("no-such-theme-udt", ["system-shutdown"]) == "" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) / "icons" + base = root / "probe" + (base / "actions" / "16").mkdir(parents=True) + (base / "actions" / "symbolic").mkdir(parents=True) + (base / "actions" / "16" / "system-shutdown.svg").write_text("<svg/>") + (base / "actions" / "symbolic" / "system-shutdown-symbolic.svg").write_text("<svg/>") + got = find_icon("probe", ["system-shutdown"], dirs=[root]) + assert got.endswith("actions/symbolic/system-shutdown-symbolic.svg"), got + (base / "actions" / "symbolic" / "system-shutdown-symbolic.svg").unlink() + got = find_icon("probe", ["system-shutdown"], dirs=[root]) + assert got.endswith("actions/16/system-shutdown.svg"), got + assert find_icon("probe", ["system-hibernate", "system-suspend-hibernate"], + dirs=[root]) == "" + + with tempfile.TemporaryDirectory() as tmpdir: + probe_image = Path(tmpdir) / "wall.png" + probe_image.write_bytes(b"") + palette = {k: "#1e2030" for k in SDDM_KEYS} + icons = {"powerIcon": "/tmp/power.svg", "rebootIcon": "", + "suspendIcon": "", "hibernateIcon": ""} + text = sddm_conf(palette, FALLBACK, str(probe_image), icons) + for key in ("powerIcon", "rebootIcon", "suspendIcon", "hibernateIcon"): + assert f"\n{key}=" in "\n" + text, f"sddm conf missing {key}" + assert "\npowerIcon=/tmp/power.svg" in "\n" + text + + got = power_icons() + assert set(got) == {"powerIcon", "rebootIcon", "suspendIcon", "hibernateIcon"} +``` + +- [ ] **Step 2: Run the selftest to verify it fails** + +Run: `python3 bin/udt-accent --selftest` +Expected: FAIL with `NameError: name 'find_icon' is not defined`. + +- [ ] **Step 3: Add the constants** + +In `bin/udt-accent`, directly below the `SDDM_KEYS` list (around line 60), add: + +```python +# The SDDM panel's power icons. The greeter runs as the sddm user and cannot +# read the login user's dconf, so udt-accent resolves the selected icon theme +# to absolute paths and bakes them into the INI. An unresolved key stays empty +# and the theme draws its bundled SVG instead. +ICON_SCHEMA = "org.gnome.desktop.interface" +ICON_KEY = "icon-theme" +ICON_DIRS = [Path.home() / ".local" / "share" / "icons", + Path("/usr/share/icons")] +POWER_ICONS = { + "powerIcon": ["system-shutdown"], + "rebootIcon": ["system-reboot"], + "suspendIcon": ["system-suspend"], + "hibernateIcon": ["system-hibernate", "system-suspend-hibernate"], +} +``` + +- [ ] **Step 4: Add the resolver functions** + +In `bin/udt-accent`, directly above `def sddm_conf(...)`, add: + +```python +def icon_theme(): + """The icon theme the appearance drawer selected, or "" if unreadable. + + gsettings runs as the login user, so it reads the user's dconf. Empty on + any failure: the theme then keeps its bundled power icons. + """ + try: + got = subprocess.run(["gsettings", "get", ICON_SCHEMA, ICON_KEY], + capture_output=True, text=True, check=True) + except (OSError, subprocess.CalledProcessError): + return "" + return got.stdout.strip().strip("'") + + +def find_icon(theme, names, dirs=None): + """Absolute path to the first of `names` found in `theme`, else "". + + A theme may ship an icon flat or as a symbolic variant at several sizes. + Prefer the symbolic variant, then the largest size directory, so the panel + gets a tintable silhouette. `dirs` is injectable for the selftest. + """ + if not theme: + return "" + dirs = ICON_DIRS if dirs is None else dirs + wanted = set(names) | {f"{name}-symbolic" for name in names} + matches = [] + for base in dirs: + root = base / theme + if not root.is_dir(): + continue + matches = [p for p in root.rglob("*.svg") if p.stem in wanted] + if matches: + break + + def rank(path): + symbolic = path.stem.endswith("-symbolic") + sizes = [int(part) for part in path.parts if part.isdigit()] + return (symbolic, max(sizes, default=0)) + + return str(max(matches, key=rank)) if matches else "" + + +def power_icons(): + """The four power icon paths, resolved from the selected icon theme.""" + theme = icon_theme() + return {key: find_icon(theme, names) for key, names in POWER_ICONS.items()} +``` + +- [ ] **Step 5: Emit the icon keys** + +Change the `sddm_conf` signature and body: + +```python +def sddm_conf(palette, name, image, icons=None): +``` + +Inside the function, after the `lines` list is created and before the final `return`, add: + +```python + icons = icons or {} +``` + +and after the existing palette-keys line add: + +```python + lines += [f"{key}={icons.get(key, '')}" for key in POWER_ICONS] +``` + +Then update the `write_sddm` call so it passes the resolved icons: + +```python + write_atomic(SDDM_OUTPUT, sddm_conf(palette, name, image, power_icons()), + mode=0o644, dir_mode=0o755) +``` + +- [ ] **Step 6: Run the selftest to verify it passes** + +Run: `python3 bin/udt-accent --selftest` +Expected: `selftest OK` + +- [ ] **Step 7: Run it against the live wallpaper and inspect the output** + +Run: `./bin/udt-accent "$(python3 -c 'import os;print(os.path.realpath(os.path.expanduser("~/.cache/udt/wpaper")))')" && cat ~/.local/share/udt/sddm-theme.conf` +Expected: the four `powerIcon`/`rebootIcon`/`suspendIcon`/`hibernateIcon` keys are present; the ones the selected theme provides are absolute paths, the rest are empty. + +- [ ] **Step 8: Commit** + +```bash +git add bin/udt-accent +git commit -m "feat: resolve SDDM power icons from the selected icon theme + +The greeter runs as the sddm user and cannot read the login user's +dconf, so udt-accent resolves the four FreeDesktop power icon names in +the icon theme gsettings reports and bakes absolute paths into the +generated sddm-theme.conf. An unresolved key stays empty and the +theme falls back to its bundled SVGs." +``` + +--- + +### Task 2: Add the panel config keys + +**Repository:** `~/Programming/GIT/sddm-theme-udt` + +**Files:** +- Modify: `theme/theme.conf` + +**Interfaces:** +- Produces: the config keys `panelWidth`, `timeFormat`, `dateFormat`, `greeting`, and the four icon keys, read by `Panel.qml` and `PowerBar.qml` in Tasks 3 and 5. + +- [ ] **Step 1: Append the keys** + +Append to `theme/theme.conf`: + +```ini + +# Left panel width, as a fraction of the greeter window. 0.30 is roughly a +# third of the screen. +panelWidth=0.30 + +# Clock and greeting. Qt date/time format strings. The date follows the +# greeter's locale, which may be C. +timeFormat=HH:mm +dateFormat=dddd, d MMMM +greeting=hello there + +# Power button icons. udt-accent writes absolute paths here from the icon theme +# the appearance drawer selected; empty falls back to the bundled +# theme/icons/*.svg. Regenerated on every wallpaper change, so set these only +# through the appearance selection. +powerIcon= +rebootIcon= +suspendIcon= +hibernateIcon= +``` + +- [ ] **Step 2: Verify the file still parses as INI** + +Run: `python3 -c "import configparser;c=configparser.ConfigParser();c.read('theme/theme.conf');print(dict(c['General'])['panelWidth'], dict(c['General'])['greeting'])"` +Expected: `0.30 hello there` + +- [ ] **Step 3: Commit** + +```bash +git add theme/theme.conf +git commit -m "feat: add panel, clock and power-icon config keys" +``` + +--- + +### Task 3: Power bar reads the resolved icons + +**Repository:** `~/Programming/GIT/sddm-theme-udt` + +**Files:** +- Modify: `theme/components/PowerBar.qml` (replace the whole file) + +**Interfaces:** +- Consumes: `config.stringValue("powerIcon")`, `config.stringValue("rebootIcon")`, `config.stringValue("suspendIcon")`, `config.stringValue("hibernateIcon")`. +- Produces: unchanged `Row` of four action buttons; consumed by `Panel.qml` in Task 5. + +- [ ] **Step 1: Replace the file** + +Write `theme/components/PowerBar.qml`: + +```qml +// SPDX-License-Identifier: GPL-2.0-only +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// Shutdown, restart, sleep and hibernate. Each icon is the path udt-accent +// resolved from the selected icon theme, falling back to the bundled line art +// when the key is empty or the file fails to load. Each button is disabled when +// SDDM reports the action is unavailable. + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Effects + +Row { + id: bar + spacing: 12 + + readonly property var actions: [ + { icon: config.stringValue("powerIcon"), fallback: "../icons/power.svg", + label: "Shut down", enabled: sddm.canPowerOff, run: function() { sddm.powerOff() } }, + { icon: config.stringValue("rebootIcon"), fallback: "../icons/reboot.svg", + label: "Restart", enabled: sddm.canReboot, run: function() { sddm.reboot() } }, + { icon: config.stringValue("suspendIcon"), fallback: "../icons/suspend.svg", + label: "Sleep", enabled: sddm.canSuspend, run: function() { sddm.suspend() } }, + { icon: config.stringValue("hibernateIcon"), fallback: "../icons/hibernate.svg", + label: "Hibernate", enabled: sddm.canHibernate, run: function() { sddm.hibernate() } } + ] + + Repeater { + model: bar.actions + delegate: Item { + width: 56 + height: 56 + opacity: modelData.enabled ? 1 : 0.35 + + Rectangle { + anchors.fill: parent + radius: 10 + color: config.stringValue("surface0") + opacity: mouse.containsMouse && modelData.enabled ? 1 : 0 + } + + Image { + id: icon + anchors.centerIn: parent + width: 34 + height: 34 + sourceSize.width: width + sourceSize.height: height + source: modelData.icon !== "" ? modelData.icon : modelData.fallback + visible: false + onStatusChanged: { + if (status === Image.Error && source !== modelData.fallback) + source = modelData.fallback + } + } + + MultiEffect { + anchors.fill: icon + source: icon + colorization: 1.0 + colorizationColor: mouse.containsMouse && modelData.enabled + ? config.stringValue("accent") + : config.stringValue("subtext0") + } + + MouseArea { + id: mouse + anchors.fill: parent + hoverEnabled: true + enabled: modelData.enabled + onClicked: modelData.run() + } + + ToolTip.visible: mouse.containsMouse && modelData.enabled + ToolTip.text: modelData.label + ToolTip.delay: 400 + } + } +} +``` + +- [ ] **Step 2: Syntax check** + +Run: `/usr/lib64/qt6/bin/qmllint theme/components/PowerBar.qml; echo "exit=$?"` +Expected: exit 0. Only `Unqualified access` warnings for `config`/`sddm` are expected, which are SDDM context properties. + +- [ ] **Step 3: Commit** + +```bash +git add theme/components/PowerBar.qml +git commit -m "feat: draw the power bar from the resolved icon theme paths" +``` + +--- + +### Task 4: The staged credential slot + +**Repository:** `~/Programming/GIT/sddm-theme-udt` + +**Files:** +- Create: `theme/components/Credentials.qml` + +**Interfaces:** +- Produces: an `Item` with `property string stage` (`"user"`/`"pass"`), read-only `username`, `password`, `passwordStage`; functions `confirm()`, `clearPassword()`, `focusStage()`; signals `submitted()` and `wake()`. Consumed by `Panel.qml` in Task 5. + +- [ ] **Step 1: Write the component** + +Write `theme/components/Credentials.qml`: + +```qml +// SPDX-License-Identifier: GPL-2.0-only +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// One credential slot, two stages. The username shows first; confirming it +// slides the username out and the password in. Confirming the password emits +// submitted(). A back chevron, or Esc, returns to the username. + +import QtQuick +import QtQuick.Controls.Basic + +Item { + id: creds + + // Emitted when the password stage is confirmed, and on input so the panel + // can restart its idle timer. + signal submitted() + signal wake() + + property string stage: "user" + readonly property string username: usernameField.text + readonly property string password: passwordField.text + readonly property bool passwordStage: stage === "pass" + + implicitHeight: 44 + + function confirm() { + if (!creds.passwordStage) { + if (usernameField.text.length > 0) + creds.stage = "pass" + } else { + creds.submitted() + } + } + + function clearPassword() { + passwordField.text = "" + } + + function focusStage() { + if (creds.passwordStage) + passwordField.forceActiveFocus() + else + usernameField.forceActiveFocus() + } + + onStageChanged: focusStage() + Component.onCompleted: focusStage() + + // Back chevron, password stage only. The width animates so the slot grows + // and shrinks with it. + Item { + id: back + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: creds.passwordStage ? 44 : 0 + height: 44 + clip: true + visible: width > 0 + + Rectangle { + anchors.fill: parent + radius: 6 + color: config.stringValue("surface0") + opacity: backMouse.containsMouse ? 1 : 0 + } + + Text { + anchors.centerIn: parent + text: "\u2190" + color: config.stringValue("subtext0") + font.family: "Noto Sans" + font.pixelSize: 22 + } + + MouseArea { + id: backMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + creds.clearPassword() + creds.stage = "user" + } + } + } + + Button { + id: confirmButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 44 + height: 44 + padding: 0 + background: Rectangle { + radius: 6 + color: config.stringValue("accent") + } + contentItem: Text { + text: "\u2192" + color: config.stringValue("base") + font.family: "Noto Sans" + font.pixelSize: 22 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + onClicked: creds.confirm() + } + + // The sliding slot. Both fields live here, clipped, and slide across. + Item { + id: slot + anchors.left: back.right + anchors.leftMargin: 8 + anchors.right: confirmButton.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + height: 44 + clip: true + + TextField { + id: usernameField + width: slot.width + height: slot.height + 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") + } + x: creds.passwordStage ? -slot.width : 0 + opacity: creds.passwordStage ? 0 : 1 + Behavior on x { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 220 } } + onAccepted: creds.confirm() + onTextChanged: creds.wake() + Keys.onPressed: event => { + creds.wake() + event.accepted = false + } + } + + TextField { + id: passwordField + width: slot.width + height: slot.height + 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") + } + x: creds.passwordStage ? 0 : slot.width + opacity: creds.passwordStage ? 1 : 0 + Behavior on x { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 220 } } + onAccepted: creds.confirm() + Keys.onPressed: event => { + creds.wake() + if (event.key === Qt.Key_Escape) { + creds.clearPassword() + creds.stage = "user" + event.accepted = true + } else { + event.accepted = false + } + } + } + } +} +``` + +- [ ] **Step 2: Syntax check** + +Run: `/usr/lib64/qt6/bin/qmllint theme/components/Credentials.qml; echo "exit=$?"` +Expected: exit 0. Only `Unqualified access` warnings for `config`/`userModel` are expected. + +- [ ] **Step 3: Commit** + +```bash +git add theme/components/Credentials.qml +git commit -m "feat: add the staged username and password credential slot" +``` + +--- + +### Task 5: The left panel and the Main swap + +**Repository:** `~/Programming/GIT/sddm-theme-udt` + +**Files:** +- Create: `theme/components/Panel.qml` +- Modify: `theme/Main.qml` (lines 86-92 `wake()`, lines 106-116 the card block) +- Delete: `theme/components/LoginCard.qml` +- Modify: `README.md` (the description and the config-key list) + +**Interfaces:** +- Consumes: `Backdrop`, `PowerBar` (Task 3), `Credentials` (Task 4), and the config keys from Task 2. +- Produces: a `Panel` root with `property Item backdropSource`, `property real shakeOffset`, `signal wake()`, and `function restoreFocus()`, consumed by `Main.qml`. + +- [ ] **Step 1: Create the panel** + +Write `theme/components/Panel.qml`: + +```qml +// 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() + } +} +``` + +- [ ] **Step 2: Swap the card for the panel in Main** + +In `theme/Main.qml`, change the `wake()` function (around lines 86-92) to call the panel: + +```qml + function wake() { + if (!root.uiVisible) { + root.uiVisible = true + panel.restoreFocus() + } + idle.restart() + } +``` + +Then replace the whole `LoginCard { ... }` block (lines 106-116) with: + +```qml + // 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 + Behavior on opacity { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } } + backdropSource: wallpaperImage + onWake: root.wake() + } +``` + +- [ ] **Step 3: Delete the old card and lint all QML** + +Run: + +```bash +rm theme/components/LoginCard.qml +/usr/lib64/qt6/bin/qmllint theme/Main.qml theme/components/*.qml; echo "exit=$?" +``` + +Expected: exit 0. Only `Unqualified access` warnings for the SDDM context properties (`config`, `sddm`, `userModel`, `sessionModel`, `keyboard`, `screenModel`, `primaryScreen`) are expected. + +- [ ] **Step 4: Visual check in the greeter** + +Run: `sddm-greeter-qt6 --test-mode --theme "$PWD/theme"` +Expected: a left panel, roughly a third of the window, square corners, showing the clock and date, the greeting, the avatar, one field, the power row above a full-width session selector at the bottom. Type a username, press Enter, confirm the field slides to the password and the back chevron appears. Login and power actions are no-ops in test mode. If the environment has no display, this step is done by the maintainer. + +- [ ] **Step 5: Update the README** + +In `README.md`, after the "How it is wired" section, add a "Panel" section describing the layout and the new keys: + +```markdown +## 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. +``` + +- [ ] **Step 6: Commit** + +```bash +git add theme/Main.qml theme/components/Panel.qml README.md +git rm theme/components/LoginCard.qml +git commit -m "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." +``` + +--- + +### Task 6: Record the quickshell follow-up + +**Repository:** `~/Programming/GIT/quickshell` + +**Files:** +- Modify: `TODO.md` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing executable; a tracked follow-up. + +- [ ] **Step 1: Append the follow-up** + +Append to `TODO.md`, above the `## Done` section: + +```markdown +## Appearance follow-ups + +- [ ] When the icon theme changes, run `udt-accent` with the current wallpaper + so the SDDM theme's power icons follow without waiting for a wallpaper + change. `appearance/Icons.qml` already sets gsettings; it needs the same + `udt-accent <wpaper>` call `wallp` makes. +``` + +- [ ] **Step 2: Commit** + +```bash +git add TODO.md +git commit -m "docs: track running udt-accent on icon theme change + +The SDDM panel power icons are resolved by udt-accent, so an icon +theme switch does not reach the greeter until the next wallpaper +change. Record wiring the call into the appearance drawer." +``` + +--- + +## Self-Review + +- **Spec coverage:** left panel geometry and blur (Task 5), square corners (Task 5), clock/date/greeting config (Tasks 2 and 5), avatar (Task 5), staged credential flow with confirm and back (Task 4), power icons from gsettings (Tasks 1 and 3), session selector at the bottom (Task 5), quickshell TODO (Task 6), README (Task 5), config keys (Task 2). All spec sections map to a task. +- **Placeholder scan:** every step carries the actual code or command. +- **Type consistency:** `sddm_conf(palette, name, image, icons=None)` matches its call in `write_sddm` and the selftest. `find_icon(theme, names, dirs=None)` matches `power_icons()` and the selftest. `Panel` exposes `restoreFocus()`, `shakeOffset` and `wake()`, which Main uses. `Credentials` exposes `username`, `password`, `passwordStage`, `confirm()`, `clearPassword()`, `focusStage()`, `submitted` and `wake`, which Panel uses. |
