diff --git a/.gitignore b/.gitignore index fd491dc..5d0df78 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ # Symlinks created by preview/lint scripts at runtime +preview/Main.qml preview/components preview/assets preview/.reload-signal +*/__pycache__ diff --git a/CLAUDE.md b/CLAUDE.md index ff14455..201ac01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,20 +9,25 @@ A development environment for building and customizing SDDM login themes for KDE ## Commands ### Live preview (primary development loop) + ```sh ./scripts/preview.sh # preview the default theme ./scripts/preview.sh -theme # preview a specific theme -qml6 preview/Preview.qml # one-shot preview (no file watching) ``` +The preview requires the Python host (`preview-host.py`) — there is no standalone `qml6` launch path. The host injects mock SDDM context properties and provides hot-reload via file watching. + ### Lint (static analysis + runtime type check) + ```sh ./scripts/lint-qml.sh # lint all themes ./scripts/lint-qml.sh -theme # lint a specific theme ``` -CI runs this on every push/PR. It runs `qmllint` for static analysis, then launches the preview under `xvfb` for 3 seconds to catch runtime type errors (`Unable to assign`, `ReferenceError`, `TypeError`). + +CI runs this on every push/PR. It runs `qmllint` for static analysis, then launches the preview host under `xvfb` for 3 seconds to catch runtime type errors (`Unable to assign`, `ReferenceError`, `TypeError`). ### Full-fidelity SDDM test + ```sh ./scripts/test-sddm.sh # test default theme with real SDDM greeter ./scripts/test-sddm.sh -theme @@ -31,29 +36,294 @@ CI runs this on every push/PR. It runs `qmllint` for static analysis, then launc ## Architecture ### Theme structure + Themes live under `themes//`. The default theme is at `themes/default/`. -Each theme contains: -- `Main.qml` — SDDM entry point; root `Item` that uses SDDM-injected context properties -- `theme.conf` — all configurable values (colors, fonts, background, clock format, screen dimensions) -- `metadata.desktop` — SDDM theme registration metadata -- `components/` — UI components (Clock, LoginForm, SessionSelector, PowerBar) -- `assets/` — background image and other static assets +Each theme is fully self-contained: + +``` +themes// + Main.qml # SDDM entry point; root Item using SDDM context properties + theme.conf # all configurable values (colors, fonts, background, etc.) + metadata.desktop # SDDM theme registration metadata + components/ # UI components (Clock, LoginForm, SessionSelector, PowerBar) + Clock.qml + LoginForm.qml + PowerBar.qml + SessionSelector.qml + assets/ # background image and other static assets + background.jpg + faces/ # user avatar images (optional) +``` + +Any number of themes can coexist in `themes/` and be previewed interchangeably. ### Preview harness -`preview/Preview.qml` mirrors `Main.qml` layout but replaces SDDM context properties with mock objects (`mockSddm`, `mockConfig`, `mockUserModel`, `mockSessionModel`, `mockKeyboard`). The preview script symlinks the selected theme's `components/` and `assets/` into `preview/` so relative QML imports resolve correctly. + +The preview system is designed so themes need zero awareness of the preview environment. The real SDDM greeter and the preview host both inject the same five context properties, so `Main.qml` works identically in both. + +**How it works:** + +1. `preview.sh` symlinks the selected theme's `Main.qml`, `components/`, and `assets/` into `preview/` so QML relative imports resolve correctly. +2. `preview-host.py` (PyQt6) creates mock objects for all five SDDM context properties and registers them via `engine.rootContext().setContextProperty()` — the same mechanism SDDM uses. +3. `Preview.qml` is a thin window shell with a `Loader` that loads `Main.qml`. It also binds `config.screenWidth`/`config.screenHeight` to the window dimensions and provides the hot-reload overlay badge. +4. The `HotReloader` watches theme files and triggers a 3-phase reload: unload Loader -> clear component cache -> reload Loader. + +**Key design principle:** No theme-specific layout logic exists outside `themes/`. The `preview/` directory contains only the generic harness. Adding a new theme requires no changes to preview infrastructure. In preview mode, use password `test` for successful login; any other password triggers "Login Failed". ### SDDM context properties -SDDM injects five globals that themes depend on: `sddm`, `config`, `userModel`, `sessionModel`, `keyboard`. Components receive these as explicit properties rather than accessing globals directly, making them portable between Main.qml (real SDDM) and Preview.qml (mocks). + +SDDM injects five globals that themes depend on: + +| Property | Type | Description | +|----------|------|-------------| +| `sddm` | QObject | Proxy with `login()`, `powerOff()`, `reboot()`, `suspend()` and capability booleans (`canPowerOff`, `canReboot`, `canSuspend`) | +| `config` | QObject | Key-value access to `theme.conf` `[General]` section (e.g., `config.primaryColor`, `config.fontPointSize`) | +| `userModel` | QAbstractListModel | List of system users with roles: `name`, `realName`, `icon`, `needsPassword`. Also has `lastUser` property. | +| `sessionModel` | QAbstractListModel | List of desktop sessions with roles: `name`, `comment`. Also has `lastIndex` property. | +| `keyboard` | QObject | Keyboard state: `capsLock`, `numLock` booleans | + +Components receive these as explicit properties rather than accessing globals directly, making them portable and testable. + +### Config values + +All config values from `theme.conf` are strings in QML. Main.qml parses them as needed: + +```qml +// Integer parsing with fallback +property int baseFontSize: config.fontPointSize ? parseInt(config.fontPointSize) : 12 + +// Float parsing with fallback +opacity: parseFloat(config.backgroundOverlayOpacity) || 0.3 + +// String comparison for booleans +visible: config.clockVisible === "true" +``` ### QML style -All scripts set `QT_QUICK_CONTROLS_STYLE=Basic` to avoid Breeze/Plasma-specific errors outside a full Plasma session. + +All scripts set `QT_QUICK_CONTROLS_STYLE=Basic` to avoid Breeze/Plasma-specific errors outside a full Plasma session. Without this, ComboBox and other controls fail with `TypeError: Cannot read property 'Overlay' of undefined`. + +## Key Files + +### Theme files (per theme) + +- `themes//Main.qml` — Root layout: background, clock, login form, power bar, footer. Wires SDDM context properties to component props. +- `themes//theme.conf` — INI-format config under `[General]`. Keys: `background`, `primaryColor`, `accentColor`, `backgroundOverlayColor`, `backgroundOverlayOpacity`, `fontFamily`, `fontPointSize`, `clockVisible`, `clockFormat`, `dateFormat`, `screenWidth`, `screenHeight`. +- `themes//components/LoginForm.qml` — Password field + login icon button in a horizontal Row. No username field; uses `defaultUsername` from `userModel.lastUser`. +- `themes//components/PowerBar.qml` — Suspend/Reboot/Shut Down buttons using unicode icons. Inline `component PowerButton` definition. +- `themes//components/Clock.qml` — Time + date display with configurable formats via Qt `timeFormat`/`dateFormat` strings. +- `themes//components/SessionSelector.qml` — ComboBox for desktop session selection, bound to `sessionModel`. + +### Preview infrastructure + +- `preview/Preview.qml` — Thin window shell. Loader + hot-reload wiring + overlay badge. No mock objects or theme layout logic. +- `scripts/preview-host.py` — PyQt6 host. Defines `MockSddm`, `MockConfig`, `MockUserModel`, `MockSessionModel`, `MockKeyboard`. Reads `theme.conf` for config values. Provides `HotReloader` for live preview. +- `scripts/preview.sh` — Sets up symlinks (`Main.qml`, `components/`, `assets/` into `preview/`), launches `preview-host.py` with the theme directory as argument. +- `scripts/lint-qml.sh` — Static + runtime lint. Uses `qmllint` then launches `preview-host.py` under `xvfb` to catch runtime type errors. + +## QML / Qt Quick Guidelines + +### Imports available + +```qml +import QtQuick // core types: Item, Rectangle, Text, Image, etc. +import QtQuick.Layouts // RowLayout, ColumnLayout +import QtQuick.Controls // TextField, Button, ComboBox +import Qt5Compat.GraphicalEffects // DropShadow, InnerShadow, etc. (Qt 6 compat module) +``` + +**Never import:** `org.kde.plasma.*`, `org.kde.breeze.components`, `Kirigami`, `PlasmaExtras`, or any KDE-specific modules. These break outside a full Plasma session. + +### Applying graphical effects (DropShadow, etc.) + +Use `layer.enabled` + `layer.effect` to apply effects inline without restructuring the component tree or hiding the source: + +```qml +TextField { + layer.enabled: true + layer.effect: DropShadow { + horizontalOffset: 0 + verticalOffset: 2 + radius: 12.0 + samples: 25 // recommended: 1 + radius * 2 + color: "#40000000" + transparentBorder: true + } +} +``` + +The alternative (separate `DropShadow` item with `visible: false` on the source) works but requires restructuring sibling elements. + +### Inset / recessed field effect + +Simulate an inset border using two nested Rectangles inside a background `Item`: + +```qml +background: Item { + Rectangle { + anchors.fill: parent + radius: 8 + color: "transparent" + border.color: Qt.rgba(0, 0, 0, 0.5) // dark outer ring = shadow edge + border.width: 1 + } + Rectangle { + anchors.fill: parent + anchors.margins: 1 + radius: 7 + color: "#1e293b" // dark slate fill + border.color: Qt.rgba(255, 255, 255, 0.06) // subtle light rim + border.width: 1 + } +} +``` + +### Component.onCompleted + +**Critical:** A QML component can only have ONE `Component.onCompleted` handler. If you define two, QML reports "Property value set multiple times" and the component silently fails to load. Merge all initialization into a single block: + +```qml +// WRONG — will fail silently +Component.onCompleted: { doA() } +Component.onCompleted: { doB() } + +// CORRECT +Component.onCompleted: { + doA() + doB() +} +``` + +### Color functions + +```qml +Qt.rgba(r, g, b, a) // 0.0–1.0 range +Qt.darker(color, factor) // factor > 1.0 = darker +Qt.lighter(color, factor) // factor > 1.0 = lighter +"#AARRGGBB" // hex with alpha prefix +``` + +### Focus management + +```qml +Component.onCompleted: { + passwordField.forceActiveFocus() +} + +Keys.onReturnPressed: doLogin() +Keys.onEnterPressed: doLogin() // numpad Enter — always handle both +``` + +### Common QML patterns in this codebase + +- `renderType: Text.NativeRendering` on all Text elements for crisp rendering at login screen resolution +- `Behavior on color { ColorAnimation { duration: 120 } }` for smooth hover transitions +- Components expose signals (e.g., `signal loginRequest(string username, string password)`) rather than calling SDDM directly +- Inline component definitions via `component Name: Item { ... }` (used in PowerBar.qml) + +## PyQt6 / Preview Host Guidelines + +### Object lifetime (garbage collection) + +When registering Python QObjects as QML context properties, keep Python references alive for the process lifetime. Without this, Python's GC frees the objects while QML still holds pointers, causing `null` access errors: + +```python +# WRONG — objects are GC'd immediately after setContextProperty returns +ctx.setContextProperty("sddm", MockSddm()) + +# CORRECT — dict keeps references alive +mocks = { + "sddm": MockSddm(), + "config": MockConfig(theme_dir), +} +for name, obj in mocks.items(): + ctx.setContextProperty(name, obj) +``` + +### configparser key casing + +Python's `configparser` lowercases all keys by default. `primaryColor` in theme.conf becomes `primarycolor`. Map explicitly: + +```python +@pyqtProperty(str, constant=True) +def primaryColor(self): # QML accesses config.primaryColor + return self._get("primarycolor") # configparser stored it lowercase +``` + +### File paths for QML + +QML `Image.source` expects either a relative path (resolved from the QML file's location) or a `file:///` URL. When providing paths from Python, always use absolute file URLs: + +```python +abs_bg = os.path.join(theme_dir, bg) +self._values["background"] = QUrl.fromLocalFile(abs_bg).toString() +# produces: file:///home/.../themes/default/assets/background.jpg +``` + +**The `theme_dir` argument must be an absolute path** — use `os.path.abspath()` before any path joins. + +### Writable properties from QML + +To let QML write to a Python property (e.g., `config.screenWidth = width`), use the `fget`/`fset` form: + +```python +screenWidthChanged = pyqtSignal() + +def _get_screen_width(self): + return self._screen_width + +def _set_screen_width(self, val): + if self._screen_width != val: + self._screen_width = val + self.screenWidthChanged.emit() + +screenWidth = pyqtProperty(int, fget=_get_screen_width, fset=_set_screen_width, + notify=screenWidthChanged) +``` + +### QAbstractListModel roles + +SDDM models expose data via named roles. Python mocks must define `roleNames()` returning a dict of `{role_int: b"name"}` and handle those roles in `data()`: + +```python +_NameRole = Qt.ItemDataRole.UserRole + 1 + +def roleNames(self): + return {self._NameRole: b"name"} + +def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if role == self._NameRole: + return self._items[index.row()]["name"] +``` ## Key Conventions -- Pure QtQuick only — no `org.kde.plasma.*`, `org.kde.breeze.components`, or `Kirigami` imports +- Pure QtQuick only — no KDE/Plasma imports - Components take dependencies as explicit props (not SDDM context property access) - `qmllint` warnings about unresolved SDDM globals are expected and tolerated; only actual errors fail CI - All config values come through `theme.conf` and are accessed via `config.` in QML +- Themes are fully self-contained — no files outside `themes//` should reference theme-specific layout +- Always handle both `Keys.onReturnPressed` and `Keys.onEnterPressed` (keyboard Enter vs numpad Enter) +- Always set `QT_QUICK_CONTROLS_STYLE=Basic` in any script that runs QML outside a Plasma session + +## Documentation References (Context7) + +Use Context7 to look up Qt/QML documentation. Key library IDs: + +- **Qt 6 (full docs):** `/websites/doc_qt_io_qt-6_8` — covers all Qt modules, QML types, properties, and examples + - DropShadow, InnerShadow: query `Qt5Compat.GraphicalEffects DropShadow` + - Item layer effects: query `QML layer.enabled layer.effect` + - TextField, ComboBox, Button: query `QtQuick.Controls ` + - Image, Rectangle, Text: query `QtQuick properties` + - Loader: query `QML Loader setSource` + - Property types and bindings: query `QML property binding` + - Animations: query `QML Behavior ColorAnimation NumberAnimation` + +### Useful external references + +- SDDM theme API: the SDDM wiki on GitHub documents what context properties are injected and what roles the models expose. Search for `sddm theme api` or `sddm theme development`. +- Qt Quick Controls styling: the `Basic` style is Qt's unstyled fallback. Search `Qt Quick Controls styles Basic`. +- `eos-breeze-sddm` (GitHub: `endeavouros-team/eos-breeze-sddm`): reference SDDM theme this project draws design inspiration from. diff --git a/preview/Preview.qml b/preview/Preview.qml index d36f001..c400498 100644 --- a/preview/Preview.qml +++ b/preview/Preview.qml @@ -1,15 +1,13 @@ import QtQuick import QtQuick.Window -import QtQuick.Layouts -import QtQuick.Controls /* * Preview.qml - Development harness for the SDDM theme. * - * Provides mock objects in place of the SDDM context properties, then loads - * the theme layout through a Loader. A file-watcher timer detects changes - * (via a signal file touched by entr) and reloads the Loader in-place, - * keeping the window open for a true live-preview experience. + * A thin window shell that loads the theme's Main.qml via a Loader. + * Mock SDDM context properties (sddm, config, userModel, sessionModel, + * keyboard) are injected by preview-host.py as engine context properties, + * so Main.qml works identically here and under the real SDDM greeter. * * Usage: ./scripts/preview.sh [-theme ] */ @@ -21,113 +19,26 @@ Window { visible: true title: "KDE Lockscreen Builder — Preview" - // ═══════════════════════════════════════════════════════════════ - // Mock objects - // ═══════════════════════════════════════════════════════════════ - - property QtObject mockConfig: QtObject { - property string background: Qt.resolvedUrl("assets/background.jpg") - property string type: "image" - property string color: "#1a1a2e" - property string primaryColor: "#ffffff" - property string accentColor: "#4a9eff" - property string backgroundOverlayColor: "#000000" - property real backgroundOverlayOpacity: 0.3 - property string fontFamily: "" - property int fontPointSize: 12 - property string clockVisible: "true" - property string clockFormat: "hh:mm" - property string dateFormat: "dddd, MMMM d" - property int screenWidth: previewWindow.width - property int screenHeight: previewWindow.height - } - - property QtObject mockSddm: QtObject { - property string hostname: "preview-host" - property bool canPowerOff: true - property bool canReboot: true - property bool canSuspend: true - property bool canHibernate: true - property bool canHybridSleep: false - - signal loginFailed() - signal loginSucceeded() - - function login(user, password, sessionIndex) { - console.log("[mock] sddm.login:", user, "session:", sessionIndex) - if (password === "test") { - console.log("[mock] Login succeeded") - loginSucceeded() - } else { - console.log("[mock] Login failed (use 'test' as password)") - loginFailed() - } - } - function powerOff() { console.log("[mock] sddm.powerOff()") } - function reboot() { console.log("[mock] sddm.reboot()") } - function suspend() { console.log("[mock] sddm.suspend()") } - function hibernate() { console.log("[mock] sddm.hibernate()") } - function hybridSleep() { console.log("[mock] sddm.hybridSleep()") } - } - - property ListModel mockUserModel: ListModel { - property string lastUser: "user" - property int lastIndex: 0 - property int disableAvatarsThreshold: 7 - property bool containsAllUsers: true - - ListElement { name: "user"; realName: "User"; icon: ""; needsPassword: true } - ListElement { name: "guest"; realName: "Guest User"; icon: ""; needsPassword: true } - } - - property ListModel mockSessionModel: ListModel { - property int lastIndex: 0 - - ListElement { name: "Plasma (Wayland)"; comment: "" } - ListElement { name: "Plasma (X11)"; comment: "" } - ListElement { name: "GNOME"; comment: "" } - } - - property QtObject mockKeyboard: QtObject { - property bool capsLock: false - property bool numLock: true - } - - // ═══════════════════════════════════════════════════════════════ - // Derived properties (mirrors Main.qml logic) - // ═══════════════════════════════════════════════════════════════ - - property string notificationMessage: "" - property int baseFontSize: mockConfig.fontPointSize || 12 - property string fontFamily: mockConfig.fontFamily || "" - property color primaryColor: mockConfig.primaryColor || "#ffffff" - property color accentColor: mockConfig.accentColor || "#4a9eff" + // Keep config.screenWidth/Height in sync with this window's dimensions + onWidthChanged: config.screenWidth = width + onHeightChanged: config.screenHeight = height // ═══════════════════════════════════════════════════════════════ // Hot-reload Loader // ═══════════════════════════════════════════════════════════════ - // Hot-reload: `hotReloader` is a context property injected by - // preview-host.py. Reload is a 3-phase process orchestrated by Python: - // 1. unloadRequested → QML destroys all component instances - // 2. Python clears the now-unreferenced component cache - // 3. reloadRequested → QML reloads components from disk - // - // When hotReloader is not present (e.g. launched via plain qml6), - // the theme loads once without hot-reload. - Loader { id: themeLoader anchors.fill: parent onStatusChanged: { if (status === Loader.Error) - console.log("[hot-reload] Error loading ThemeLayout.qml — check for syntax errors") + console.log("[hot-reload] Error loading Main.qml — check for syntax errors") } } function loadTheme() { - themeLoader.setSource("ThemeLayout.qml", { "preview": previewWindow }) + themeLoader.setSource("Main.qml") } function unloadTheme() { @@ -140,7 +51,11 @@ Window { function onReloadRequested() { previewWindow.loadTheme() } } - Component.onCompleted: loadTheme() + Component.onCompleted: { + config.screenWidth = width + config.screenHeight = height + loadTheme() + } // ── Preview overlay ────────────────────────────────────────── Rectangle { diff --git a/preview/ThemeLayout.qml b/preview/ThemeLayout.qml deleted file mode 100644 index 1d83341..0000000 --- a/preview/ThemeLayout.qml +++ /dev/null @@ -1,148 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls - -import "components" - -/* - * ThemeLayout.qml - Theme content loaded via Loader for hot reload. - * - * Accesses mock objects and derived properties from the parent Window - * through the `preview` property, which must be set by the Loader. - */ - -Item { - id: layout - anchors.fill: parent - - required property var preview - - // Convenience aliases - property color primaryColor: preview.primaryColor - property color accentColor: preview.accentColor - property int baseFontSize: preview.baseFontSize - property string fontFamily: preview.fontFamily - - // ── Background ─────────────────────────────────────────────── - Image { - id: backgroundImage - anchors.fill: parent - source: preview.mockConfig.background || "" - fillMode: Image.PreserveAspectCrop - asynchronous: true - cache: false - onStatusChanged: { - if (status === Image.Error && source != "") - console.log("Background image not found — using fallback color. " + - "Drop an image into assets/background.jpg or update theme.conf.") - } - } - - Rectangle { - id: backgroundFallback - anchors.fill: parent - color: preview.mockConfig.color || "#1a1a2e" - visible: backgroundImage.status !== Image.Ready - } - - Rectangle { - anchors.fill: parent - color: preview.mockConfig.backgroundOverlayColor || "#000000" - opacity: preview.mockConfig.backgroundOverlayOpacity || 0.3 - } - - // ── Clock ──────────────────────────────────────────────────── - Clock { - id: clock - visible: preview.mockConfig.clockVisible === "true" - anchors { - top: parent.top - horizontalCenter: parent.horizontalCenter - topMargin: preview.height * 0.08 - } - textColor: primaryColor - fontSize: baseFontSize * 4 - timeFormat: preview.mockConfig.clockFormat || "hh:mm" - dateFormat: preview.mockConfig.dateFormat || "dddd, MMMM d" - } - - // ── Login form ─────────────────────────────────────────────── - LoginForm { - id: loginForm - anchors.centerIn: parent - width: 320 - - textColor: primaryColor - accentColor: layout.accentColor - fontSize: baseFontSize - fontFamily: layout.fontFamily - defaultUsername: preview.mockUserModel.lastUser || "" - notificationMessage: preview.notificationMessage - capsLockOn: preview.mockKeyboard.capsLock - - sessionIndex: sessionSelector.currentIndex - - onLoginRequest: function(username, password) { - preview.notificationMessage = "" - preview.mockSddm.login(username, password, sessionSelector.currentIndex) - } - } - - // ── Footer ─────────────────────────────────────────────────── - RowLayout { - id: footer - anchors { - bottom: parent.bottom - left: parent.left - right: parent.right - margins: 16 - } - height: 48 - - SessionSelector { - id: sessionSelector - textColor: primaryColor - fontSize: baseFontSize - 2 - fontFamily: layout.fontFamily - sessions: preview.mockSessionModel - Layout.preferredWidth: 180 - Layout.preferredHeight: 32 - } - - Item { Layout.fillWidth: true } - - PowerBar { - id: powerBar - textColor: primaryColor - fontSize: baseFontSize - 2 - iconSize: baseFontSize + 6 - Layout.preferredHeight: 48 - - canSuspend: preview.mockSddm.canSuspend - canReboot: preview.mockSddm.canReboot - canPowerOff: preview.mockSddm.canPowerOff - - onSuspendClicked: preview.mockSddm.suspend() - onRebootClicked: preview.mockSddm.reboot() - onPowerOffClicked: preview.mockSddm.powerOff() - } - } - - // ── SDDM connections ───────────────────────────────────────── - Connections { - target: preview.mockSddm - function onLoginFailed() { - preview.notificationMessage = "Login Failed" - loginForm.clearPassword() - } - function onLoginSucceeded() { - preview.notificationMessage = "" - } - } - - Timer { - interval: 3000 - running: preview.notificationMessage !== "" - onTriggered: preview.notificationMessage = "" - } -} diff --git a/scripts/lint-qml.sh b/scripts/lint-qml.sh index 5f03881..cf526b5 100755 --- a/scripts/lint-qml.sh +++ b/scripts/lint-qml.sh @@ -122,20 +122,23 @@ fi export QT_QUICK_CONTROLS_STYLE=Basic STDERR_LOG=$(mktemp) -trap 'rm -f "$STDERR_LOG" "$PROJECT_DIR/preview/components" "$PROJECT_DIR/preview/assets"' EXIT +trap 'rm -f "$STDERR_LOG" "$PROJECT_DIR/preview/components" "$PROJECT_DIR/preview/assets" "$PROJECT_DIR/preview/Main.qml"' EXIT # Test with the default theme (or the specified one). RUNTIME_THEME="${THEME_DIRS[0]}" RUNTIME_THEME_DIR="$PROJECT_DIR/$RUNTIME_THEME" -# Symlink the theme's components and assets into preview/ so QML resolves them. -ln -sfn "$RUNTIME_THEME_DIR/components" "$PROJECT_DIR/preview/components" -ln -sfn "$RUNTIME_THEME_DIR/assets" "$PROJECT_DIR/preview/assets" +# Symlink the theme's Main.qml, components, and assets into preview/ so QML resolves them. +ln -sfn "$RUNTIME_THEME_DIR/Main.qml" "$PROJECT_DIR/preview/Main.qml" +ln -sfn "$RUNTIME_THEME_DIR/components" "$PROJECT_DIR/preview/components" +ln -sfn "$RUNTIME_THEME_DIR/assets" "$PROJECT_DIR/preview/assets" # Run preview for 3 seconds under a virtual framebuffer, capture stderr. +# Use the Python preview host so SDDM context properties are injected. +PREVIEW_CMD="python3 $SCRIPT_DIR/preview-host.py $RUNTIME_THEME_DIR" if command -v xvfb-run &>/dev/null; then - timeout 3 xvfb-run -a "$QML_RUNTIME" preview/Preview.qml 2>"$STDERR_LOG" || true + timeout 3 xvfb-run -a $PREVIEW_CMD 2>"$STDERR_LOG" || true elif [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then - timeout 3 "$QML_RUNTIME" preview/Preview.qml 2>"$STDERR_LOG" || true + timeout 3 $PREVIEW_CMD 2>"$STDERR_LOG" || true else echo "SKIP: no display and xvfb-run not available." echo "" diff --git a/scripts/preview-host.py b/scripts/preview-host.py index 104c1a6..f2a3f5c 100644 --- a/scripts/preview-host.py +++ b/scripts/preview-host.py @@ -2,28 +2,338 @@ """ preview-host.py - PyQt6 host for the SDDM theme preview with hot reload. -Replaces `qml6 preview/Preview.qml` as the QML host process. Watches all -theme files for changes and calls engine.clearComponentCache() before -triggering a reload, so edits to any QML component are picked up immediately -without restarting the window. +Injects mock SDDM context properties (sddm, config, userModel, sessionModel, +keyboard) so that the theme's Main.qml can be loaded directly — the same way +the real SDDM greeter does it. File watching + cache clearing gives true +live-preview without restarting the window. + +Usage: preview-host.py """ +import configparser import os import sys import glob from PyQt6.QtCore import ( + QAbstractListModel, QFileSystemWatcher, + QModelIndex, QObject, + Qt, QTimer, QUrl, pyqtProperty, pyqtSignal, + pyqtSlot, ) from PyQt6.QtGui import QGuiApplication from PyQt6.QtQml import QQmlApplicationEngine +# ═══════════════════════════════════════════════════════════════════ +# Mock SDDM context objects +# ═══════════════════════════════════════════════════════════════════ + +class MockSddm(QObject): + """Mimics the `sddm` context property injected by the real SDDM greeter.""" + + loginFailed = pyqtSignal() + loginSucceeded = pyqtSignal() + + @pyqtProperty(str, constant=True) + def hostname(self): + return "preview-host" + + @pyqtProperty(bool, constant=True) + def canPowerOff(self): + return True + + @pyqtProperty(bool, constant=True) + def canReboot(self): + return True + + @pyqtProperty(bool, constant=True) + def canSuspend(self): + return True + + @pyqtProperty(bool, constant=True) + def canHibernate(self): + return True + + @pyqtProperty(bool, constant=True) + def canHybridSleep(self): + return False + + @pyqtSlot(str, str, int) + def login(self, user, password, session_index): + print(f"[mock] sddm.login: {user} session: {session_index}") + if password == "test": + print("[mock] Login succeeded") + self.loginSucceeded.emit() + else: + print("[mock] Login failed (use 'test' as password)") + self.loginFailed.emit() + + @pyqtSlot() + def powerOff(self): + print("[mock] sddm.powerOff()") + + @pyqtSlot() + def reboot(self): + print("[mock] sddm.reboot()") + + @pyqtSlot() + def suspend(self): + print("[mock] sddm.suspend()") + + @pyqtSlot() + def hibernate(self): + print("[mock] sddm.hibernate()") + + @pyqtSlot() + def hybridSleep(self): + print("[mock] sddm.hybridSleep()") + + +class MockConfig(QObject): + """Mimics the `config` context property — values read from theme.conf. + + screenWidth / screenHeight are writable so Preview.qml can bind them + to the window dimensions, matching what SDDM does at runtime. + """ + + screenWidthChanged = pyqtSignal() + screenHeightChanged = pyqtSignal() + + def __init__(self, theme_dir: str): + super().__init__() + self._screen_width = 1920 + self._screen_height = 1080 + + # Ensure absolute path for reliable file:// URL generation + theme_dir = os.path.abspath(theme_dir) + + # Read [General] section from theme.conf + self._values: dict[str, str] = {} + conf_path = os.path.join(theme_dir, "theme.conf") + if os.path.isfile(conf_path): + cp = configparser.ConfigParser() + cp.read(conf_path) + if cp.has_section("General"): + self._values = dict(cp.items("General")) + + # Seed screen dimensions from theme.conf (overridden by window binding) + sw = self._values.get("screenwidth", "") + sh = self._values.get("screenheight", "") + if sw: + self._screen_width = int(sw) + if sh: + self._screen_height = int(sh) + + # Resolve background path to a file:// URL + bg = self._values.get("background", "") + if bg and not bg.startswith("file://") and not os.path.isabs(bg): + abs_bg = os.path.join(theme_dir, bg) + self._values["background"] = QUrl.fromLocalFile(abs_bg).toString() + + def _get(self, key: str, default: str = "") -> str: + return self._values.get(key, default) + + # ── Dynamic screen dimensions (bound to window size by Preview.qml) ── + + def _get_screen_width(self): + return self._screen_width + + def _set_screen_width(self, val): + if self._screen_width != val: + self._screen_width = val + self.screenWidthChanged.emit() + + screenWidth = pyqtProperty( + int, fget=_get_screen_width, fset=_set_screen_width, + notify=screenWidthChanged, + ) + + def _get_screen_height(self): + return self._screen_height + + def _set_screen_height(self, val): + if self._screen_height != val: + self._screen_height = val + self.screenHeightChanged.emit() + + screenHeight = pyqtProperty( + int, fget=_get_screen_height, fset=_set_screen_height, + notify=screenHeightChanged, + ) + + # ── Static theme config values (read from theme.conf) ──────────── + + # configparser lowercases keys, so "primaryColor" → "primarycolor" + + @pyqtProperty(str, constant=True) + def background(self): + return self._get("background") + + @pyqtProperty(str, constant=True) + def color(self): + return self._get("color", "#1a1a2e") + + @pyqtProperty(str, constant=True) + def primaryColor(self): + return self._get("primarycolor", "#ffffff") + + @pyqtProperty(str, constant=True) + def accentColor(self): + return self._get("accentcolor", "#4a9eff") + + @pyqtProperty(str, constant=True) + def backgroundOverlayColor(self): + return self._get("backgroundoverlaycolor", "#000000") + + @pyqtProperty(str, constant=True) + def backgroundOverlayOpacity(self): + return self._get("backgroundoverlayopacity", "0.3") + + @pyqtProperty(str, constant=True) + def fontFamily(self): + return self._get("fontfamily", "") + + @pyqtProperty(str, constant=True) + def fontPointSize(self): + return self._get("fontpointsize", "12") + + @pyqtProperty(str, constant=True) + def clockVisible(self): + return self._get("clockvisible", "true") + + @pyqtProperty(str, constant=True) + def clockFormat(self): + return self._get("clockformat", "hh:mm") + + @pyqtProperty(str, constant=True) + def dateFormat(self): + return self._get("dateformat", "dddd, MMMM d") + + +class MockUserModel(QAbstractListModel): + """Mimics SDDM's userModel — a list model with user metadata.""" + + _NameRole = Qt.ItemDataRole.UserRole + 1 + _RealNameRole = Qt.ItemDataRole.UserRole + 2 + _IconRole = Qt.ItemDataRole.UserRole + 3 + _NeedsPasswordRole = Qt.ItemDataRole.UserRole + 4 + + def __init__(self): + super().__init__() + self._users = [ + {"name": "user", "realName": "User", "icon": "", "needsPassword": True}, + {"name": "guest", "realName": "Guest User", "icon": "", "needsPassword": True}, + ] + + @pyqtProperty(str, constant=True) + def lastUser(self): + return "user" + + @pyqtProperty(int, constant=True) + def lastIndex(self): + return 0 + + @pyqtProperty(int, constant=True) + def disableAvatarsThreshold(self): + return 7 + + @pyqtProperty(bool, constant=True) + def containsAllUsers(self): + return True + + def rowCount(self, parent=QModelIndex()): + return len(self._users) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if not index.isValid() or index.row() >= len(self._users): + return None + user = self._users[index.row()] + role_map = { + self._NameRole: "name", + self._RealNameRole: "realName", + self._IconRole: "icon", + self._NeedsPasswordRole: "needsPassword", + } + return user.get(role_map.get(role)) + + def roleNames(self): + return { + self._NameRole: b"name", + self._RealNameRole: b"realName", + self._IconRole: b"icon", + self._NeedsPasswordRole: b"needsPassword", + } + + +class MockSessionModel(QAbstractListModel): + """Mimics SDDM's sessionModel — a list model of desktop sessions.""" + + _NameRole = Qt.ItemDataRole.UserRole + 1 + _CommentRole = Qt.ItemDataRole.UserRole + 2 + + def __init__(self): + super().__init__() + self._sessions = [ + {"name": "Plasma (Wayland)", "comment": ""}, + {"name": "Plasma (X11)", "comment": ""}, + {"name": "GNOME", "comment": ""}, + ] + + @pyqtProperty(int, constant=True) + def lastIndex(self): + return 0 + + def rowCount(self, parent=QModelIndex()): + return len(self._sessions) + + def data(self, index, role=Qt.ItemDataRole.DisplayRole): + if not index.isValid() or index.row() >= len(self._sessions): + return None + session = self._sessions[index.row()] + role_map = { + self._NameRole: "name", + self._CommentRole: "comment", + } + return session.get(role_map.get(role)) + + def roleNames(self): + return { + self._NameRole: b"name", + self._CommentRole: b"comment", + } + + +class MockKeyboard(QObject): + """Mimics SDDM's keyboard context property.""" + + capsLockChanged = pyqtSignal() + numLockChanged = pyqtSignal() + + def __init__(self): + super().__init__() + self._capsLock = False + self._numLock = True + + @pyqtProperty(bool, notify=capsLockChanged) + def capsLock(self): + return self._capsLock + + @pyqtProperty(bool, notify=numLockChanged) + def numLock(self): + return self._numLock + + +# ═══════════════════════════════════════════════════════════════════ +# Hot-reloader (unchanged logic, moved below mocks for readability) +# ═══════════════════════════════════════════════════════════════════ + class HotReloader(QObject): """Exposed to QML as `hotReloader` — provides a reload generation counter. @@ -108,29 +418,57 @@ def _phase2_clear_and_reload(self): print(f"[hot-reload] Reloading (gen {self._generation})") +# ═══════════════════════════════════════════════════════════════════ +# Entry point +# ═══════════════════════════════════════════════════════════════════ + def main(): os.environ["QT_QUICK_CONTROLS_STYLE"] = "Basic" os.environ["QML_DISABLE_DISK_CACHE"] = "1" + if len(sys.argv) < 2: + print("Usage: preview-host.py ", file=sys.stderr) + sys.exit(1) + + theme_dir = os.path.abspath(sys.argv[1]) + if not os.path.isdir(theme_dir): + print(f"Error: theme directory not found: {theme_dir}", file=sys.stderr) + sys.exit(1) + app = QGuiApplication(sys.argv) engine = QQmlApplicationEngine() - # Resolve the preview directory (this script lives in scripts/) + # Resolve paths script_dir = os.path.dirname(os.path.abspath(__file__)) project_dir = os.path.dirname(script_dir) preview_dir = os.path.join(project_dir, "preview") - # Directories to watch: the preview dir (includes symlinked components/assets) - # and the themes dir for direct edits + # ── Mock SDDM context properties ───────────────────────────── + # Registered with the same names SDDM injects, so Main.qml works + # identically in both the real greeter and the preview. + # Keep references so Python doesn't garbage-collect them while QML + # still holds the context property pointers. + mocks = { + "sddm": MockSddm(), + "config": MockConfig(theme_dir), + "userModel": MockUserModel(), + "sessionModel": MockSessionModel(), + "keyboard": MockKeyboard(), + } + ctx = engine.rootContext() + for name, obj in mocks.items(): + ctx.setContextProperty(name, obj) + + # ── Hot-reloader ───────────────────────────────────────────── watch_dirs = [ preview_dir, os.path.join(preview_dir, "components"), os.path.join(preview_dir, "assets"), ] - reloader = HotReloader(engine, watch_dirs) - engine.rootContext().setContextProperty("hotReloader", reloader) + ctx.setContextProperty("hotReloader", reloader) + # ── Load ───────────────────────────────────────────────────── qml_path = os.path.join(preview_dir, "Preview.qml") engine.load(QUrl.fromLocalFile(qml_path)) diff --git a/scripts/preview.sh b/scripts/preview.sh index 898b786..cd07428 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -56,7 +56,7 @@ fi # ── Setup ─────────────────────────────────────────────────────── cleanup() { trap - SIGINT SIGTERM # prevent re-entry - rm -f "$PROJECT_DIR/preview/components" "$PROJECT_DIR/preview/assets" + rm -f "$PROJECT_DIR/preview/components" "$PROJECT_DIR/preview/assets" "$PROJECT_DIR/preview/Main.qml" echo "" echo "Preview stopped." exit 0 @@ -68,10 +68,11 @@ trap cleanup SIGINT SIGTERM # unavailable outside a full Plasma session. export QT_QUICK_CONTROLS_STYLE=Basic -# Symlink the theme's components and assets into preview/ so QML's +# Symlink the theme's Main.qml, components, and assets into preview/ so QML's # relative imports and asset paths resolve to the selected theme. -ln -sfn "$THEME_DIR/components" "$PROJECT_DIR/preview/components" -ln -sfn "$THEME_DIR/assets" "$PROJECT_DIR/preview/assets" +ln -sfn "$THEME_DIR/Main.qml" "$PROJECT_DIR/preview/Main.qml" +ln -sfn "$THEME_DIR/components" "$PROJECT_DIR/preview/components" +ln -sfn "$THEME_DIR/assets" "$PROJECT_DIR/preview/assets" echo "=== KDE Lockscreen Builder — Live Preview ===" echo "Project: $PROJECT_DIR" @@ -81,6 +82,7 @@ echo "Press Ctrl+C to stop" echo "" # Launch the PyQt6 preview host (handles file watching + cache clearing). -python3 "$SCRIPT_DIR/preview-host.py" +# Pass the theme directory so mock objects can read theme.conf. +python3 "$SCRIPT_DIR/preview-host.py" "$THEME_DIR" cleanup diff --git a/themes/default/Main.qml b/themes/default/Main.qml index 5f56d40..dd3ffe0 100644 --- a/themes/default/Main.qml +++ b/themes/default/Main.qml @@ -96,6 +96,28 @@ Item { } } + // ── Power buttons (centered below login form) ─────────────── + + PowerBar { + id: powerBar + anchors { + top: loginForm.bottom + horizontalCenter: parent.horizontalCenter + topMargin: 24 + } + textColor: root.primaryColor + fontSize: root.baseFontSize - 2 + iconSize: root.baseFontSize + 6 + + canSuspend: sddm.canSuspend + canReboot: sddm.canReboot + canPowerOff: sddm.canPowerOff + + onSuspendClicked: sddm.suspend() + onRebootClicked: sddm.reboot() + onPowerOffClicked: sddm.powerOff() + } + // ── Footer ─────────────────────────────────────────────────── RowLayout { @@ -119,22 +141,6 @@ Item { } Item { Layout.fillWidth: true } - - PowerBar { - id: powerBar - textColor: root.primaryColor - fontSize: root.baseFontSize - 2 - iconSize: root.baseFontSize + 6 - Layout.preferredHeight: 48 - - canSuspend: sddm.canSuspend - canReboot: sddm.canReboot - canPowerOff: sddm.canPowerOff - - onSuspendClicked: sddm.suspend() - onRebootClicked: sddm.reboot() - onPowerOffClicked: sddm.powerOff() - } } // ── SDDM Connections ───────────────────────────────────────── diff --git a/themes/default/components/LoginForm.qml b/themes/default/components/LoginForm.qml index 5483df8..3d0d5d9 100644 --- a/themes/default/components/LoginForm.qml +++ b/themes/default/components/LoginForm.qml @@ -1,15 +1,13 @@ import QtQuick import QtQuick.Layouts import QtQuick.Controls +import Qt5Compat.GraphicalEffects /* - * LoginForm.qml - Username/password entry and login trigger. + * LoginForm.qml - Password entry and login trigger. * - * Expects these context properties from the parent: - * - sddm (proxy object with login() function) - * - userModel (list model with name, realName, icon, needsPassword) - * - sessionModel (list model with session names) - * - config (theme configuration) + * Username is not shown; it uses defaultUsername (from userModel.lastUser). + * The login button is a small icon button inline to the right of the field. */ Item { @@ -26,7 +24,7 @@ Item { // Which session index to pass to sddm.login() property int sessionIndex: 0 - // The username to pre-fill (from userModel) + // The username to submit (from userModel) property string defaultUsername: "" signal loginRequest(string username, string password) @@ -51,58 +49,90 @@ Item { renderType: Text.NativeRendering } - // ── Username field ─────────────────────────── - TextField { - id: usernameField + // ── Password row (field + icon login button) ─ + Row { + id: passwordRow width: parent.width - height: 44 - placeholderText: "Username" - text: root.defaultUsername - font.pointSize: root.fontSize - font.family: root.fontFamily + spacing: 8 + + TextField { + id: passwordField + width: parent.width - loginButton.width - passwordRow.spacing + height: 44 + placeholderText: "Password" + echoMode: TextInput.Password + font.pointSize: root.fontSize + font.family: root.fontFamily + + color: root.textColor + placeholderTextColor: Qt.rgba(1, 1, 1, 0.35) + + background: Item { + // Outer dark ring — acts as the inset shadow edge + Rectangle { + anchors.fill: parent + radius: 8 + color: "transparent" + border.color: Qt.rgba(0, 0, 0, 0.5) + border.width: 1 + } + // Inner fill — dark slate with subtle light rim + Rectangle { + anchors.fill: parent + anchors.margins: 1 + radius: 7 + color: "#1e293b" + border.color: Qt.rgba(255, 255, 255, 0.06) + border.width: 1 + } + } - color: root.textColor - placeholderTextColor: Qt.rgba(1, 1, 1, 0.5) + leftPadding: 14 + rightPadding: 14 + + layer.enabled: true + layer.effect: DropShadow { + horizontalOffset: 0 + verticalOffset: 1 + radius: 6.0 + samples: 13 + color: "#30000000" + transparentBorder: true + } - background: Rectangle { - color: Qt.rgba(1, 1, 1, 0.12) - radius: 8 - border.color: usernameField.activeFocus ? root.accentColor : Qt.rgba(1, 1, 1, 0.2) - border.width: usernameField.activeFocus ? 2 : 1 + Keys.onReturnPressed: doLogin() + Keys.onEnterPressed: doLogin() } - leftPadding: 14 - rightPadding: 14 + // Small icon button to the right of password field + Button { + id: loginButton + width: 44 + height: 44 + + contentItem: Text { + text: "\u25B6" // ▶ + font.pointSize: root.fontSize + 2 + font.family: root.fontFamily + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + renderType: Text.NativeRendering + } - Keys.onReturnPressed: passwordField.forceActiveFocus() - Keys.onEnterPressed: passwordField.forceActiveFocus() - } + background: Rectangle { + color: loginButton.down ? Qt.darker(root.accentColor, 1.2) : + loginButton.hovered ? Qt.lighter(root.accentColor, 1.1) : + root.accentColor + radius: 8 - // ── Password field ─────────────────────────── - TextField { - id: passwordField - width: parent.width - height: 44 - placeholderText: "Password" - echoMode: TextInput.Password - font.pointSize: root.fontSize - font.family: root.fontFamily - - color: root.textColor - placeholderTextColor: Qt.rgba(1, 1, 1, 0.5) + Behavior on color { + ColorAnimation { duration: 120 } + } + } - background: Rectangle { - color: Qt.rgba(1, 1, 1, 0.12) - radius: 8 - border.color: passwordField.activeFocus ? root.accentColor : Qt.rgba(1, 1, 1, 0.2) - border.width: passwordField.activeFocus ? 2 : 1 + onClicked: doLogin() } - - leftPadding: 14 - rightPadding: 14 - - Keys.onReturnPressed: doLogin() - Keys.onEnterPressed: doLogin() } // ── Caps Lock warning ──────────────────────── @@ -115,38 +145,6 @@ Item { renderType: Text.NativeRendering } - // ── Login button ───────────────────────────── - Button { - id: loginButton - width: parent.width - height: 44 - text: "Log In" - font.pointSize: root.fontSize - font.family: root.fontFamily - - contentItem: Text { - text: loginButton.text - font: loginButton.font - color: "white" - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - renderType: Text.NativeRendering - } - - background: Rectangle { - color: loginButton.down ? Qt.darker(root.accentColor, 1.2) : - loginButton.hovered ? Qt.lighter(root.accentColor, 1.1) : - root.accentColor - radius: 8 - - Behavior on color { - ColorAnimation { duration: 120 } - } - } - - onClicked: doLogin() - } - // ── Notification / error message ───────────── Text { anchors.horizontalCenter: parent.horizontalCenter @@ -163,7 +161,7 @@ Item { } function doLogin() { - root.loginRequest(usernameField.text, passwordField.text) + root.loginRequest(root.defaultUsername, passwordField.text) } // Reset password field on failed login (called externally) @@ -174,9 +172,6 @@ Item { // Focus management Component.onCompleted: { - if (usernameField.text === "") - usernameField.forceActiveFocus() - else - passwordField.forceActiveFocus() + passwordField.forceActiveFocus() } }