From 1266c049499eeef76153ef4ee88d1f652f377534 Mon Sep 17 00:00:00 2001 From: Derryl Carter Date: Thu, 26 Feb 2026 16:47:42 -0800 Subject: [PATCH 1/2] Add hot-reload to preview: update components in-place without restarting window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, preview.sh used `entr -r` which killed and relaunched the entire qml6 process on every file change, causing the window to close and reopen. Now the preview window stays open and theme content reloads in-place via a QML Loader. - Extract theme UI from Preview.qml into ThemeLayout.qml - Load ThemeLayout via Loader.setSource() with required property passing - preview.sh launches qml6 once, entr writes a timestamp to a signal file, and a QML Timer polls it to trigger reload - Set QML_XHR_ALLOW_FILE_READ=1 for local file polling - Cleanly exit when user closes the preview window 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 1 + preview/Preview.qml | 187 +++++++++++----------------------------- preview/ThemeLayout.qml | 148 +++++++++++++++++++++++++++++++ scripts/preview.sh | 33 ++++++- 4 files changed, 231 insertions(+), 138 deletions(-) create mode 100644 preview/ThemeLayout.qml diff --git a/.gitignore b/.gitignore index 03368e4..fd491dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ # Symlinks created by preview/lint scripts at runtime preview/components preview/assets +preview/.reload-signal diff --git a/preview/Preview.qml b/preview/Preview.qml index ed86396..c72957a 100644 --- a/preview/Preview.qml +++ b/preview/Preview.qml @@ -3,20 +3,13 @@ import QtQuick.Window import QtQuick.Layouts import QtQuick.Controls -import "components" - /* * Preview.qml - Development harness for the SDDM theme. * - * Mirrors the Main.qml layout but provides mock objects in place of - * the SDDM context properties (sddm, userModel, sessionModel, config, - * screenModel, keyboard). - * - * Components receive their dependencies as explicit properties, so they - * work identically whether driven by SDDM or by this preview. - * - * The preview script symlinks components/ and assets/ into preview/ - * so that relative imports and asset paths resolve to the selected 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. * * Usage: ./scripts/preview.sh [-theme ] */ @@ -32,10 +25,7 @@ Window { // Mock objects // ═══════════════════════════════════════════════════════════════ - QtObject { - id: mockConfig - // Set to "" to use the solid fallback color, or provide a path - // to an image (e.g. drop your own into assets/background.jpg). + property QtObject mockConfig: QtObject { property string background: Qt.resolvedUrl("assets/background.jpg") property string type: "image" property string color: "#1a1a2e" @@ -52,8 +42,7 @@ Window { property int screenHeight: previewWindow.height } - QtObject { - id: mockSddm + property QtObject mockSddm: QtObject { property string hostname: "preview-host" property bool canPowerOff: true property bool canReboot: true @@ -81,8 +70,7 @@ Window { function hybridSleep() { console.log("[mock] sddm.hybridSleep()") } } - ListModel { - id: mockUserModel + property ListModel mockUserModel: ListModel { property string lastUser: "user" property int lastIndex: 0 property int disableAvatarsThreshold: 7 @@ -92,8 +80,7 @@ Window { ListElement { name: "guest"; realName: "Guest User"; icon: ""; needsPassword: true } } - ListModel { - id: mockSessionModel + property ListModel mockSessionModel: ListModel { property int lastIndex: 0 ListElement { name: "Plasma (Wayland)"; comment: "" } @@ -101,8 +88,7 @@ Window { ListElement { name: "GNOME"; comment: "" } } - QtObject { - id: mockKeyboard + property QtObject mockKeyboard: QtObject { property bool capsLock: false property bool numLock: true } @@ -118,129 +104,60 @@ Window { property color accentColor: mockConfig.accentColor || "#4a9eff" // ═══════════════════════════════════════════════════════════════ - // Theme layout (identical to Main.qml) + // Hot-reload Loader // ═══════════════════════════════════════════════════════════════ - // ── Background ─────────────────────────────────────────────── - Image { - id: backgroundImage - anchors.fill: parent - source: mockConfig.background || "" - fillMode: Image.PreserveAspectCrop - asynchronous: true - 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: mockConfig.color || "#1a1a2e" - visible: backgroundImage.status !== Image.Ready - } + // Reload generation counter — incremented by the file watcher. + property int reloadGeneration: 0 - Rectangle { + Loader { + id: themeLoader anchors.fill: parent - color: mockConfig.backgroundOverlayColor || "#000000" - opacity: mockConfig.backgroundOverlayOpacity || 0.3 - } - // ── Clock ──────────────────────────────────────────────────── - Clock { - id: clock - visible: mockConfig.clockVisible === "true" - anchors { - top: parent.top - horizontalCenter: parent.horizontalCenter - topMargin: previewWindow.height * 0.08 + onStatusChanged: { + if (status === Loader.Error) + console.log("[hot-reload] Error loading ThemeLayout.qml — check for syntax errors") } - textColor: primaryColor - fontSize: baseFontSize * 4 - timeFormat: mockConfig.clockFormat || "hh:mm" - dateFormat: mockConfig.dateFormat || "dddd, MMMM d" } - // ── Login form ─────────────────────────────────────────────── - LoginForm { - id: loginForm - anchors.centerIn: parent - width: 320 - - textColor: primaryColor - accentColor: previewWindow.accentColor - fontSize: baseFontSize - fontFamily: previewWindow.fontFamily - defaultUsername: mockUserModel.lastUser || "" - notificationMessage: previewWindow.notificationMessage - capsLockOn: mockKeyboard.capsLock - - sessionIndex: sessionSelector.currentIndex - - onLoginRequest: function(username, password) { - previewWindow.notificationMessage = "" - mockSddm.login(username, password, sessionSelector.currentIndex) - } + // (Re)load the theme layout, passing the preview reference as an + // initial property so `required property` is satisfied at creation time. + function reloadTheme() { + themeLoader.setSource("ThemeLayout.qml", { "preview": previewWindow }) } - // ── 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: previewWindow.fontFamily - sessions: 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: mockSddm.canSuspend - canReboot: mockSddm.canReboot - canPowerOff: mockSddm.canPowerOff + onReloadGenerationChanged: reloadTheme() + Component.onCompleted: reloadTheme() - onSuspendClicked: mockSddm.suspend() - onRebootClicked: mockSddm.reboot() - onPowerOffClicked: mockSddm.powerOff() - } - } - - // ── SDDM connections ───────────────────────────────────────── - Connections { - target: mockSddm - function onLoginFailed() { - previewWindow.notificationMessage = "Login Failed" - loginForm.clearPassword() - } - function onLoginSucceeded() { - previewWindow.notificationMessage = "" - } - } + // Poll a signal file whose content is a timestamp written by entr. + // When the content changes, we bump the Loader's generation counter. + // This avoids needing C++ filesystem-watcher plugins. + property string signalFileUrl: Qt.resolvedUrl(".reload-signal") + property string lastSignalContent: "" Timer { - interval: 3000 - running: notificationMessage !== "" - onTriggered: notificationMessage = "" + id: fileWatcher + interval: 500 + running: true + repeat: true + onTriggered: { + var xhr = new XMLHttpRequest() + // Append cache-buster so QML doesn't serve a stale cached read + xhr.open("GET", previewWindow.signalFileUrl + "?t=" + Date.now(), false) + try { + xhr.send() + if (xhr.status === 200 || xhr.responseText !== "") { + var content = xhr.responseText.trim() + if (content !== "" && content !== previewWindow.lastSignalContent) { + previewWindow.lastSignalContent = content + previewWindow.reloadGeneration++ + console.log("[hot-reload] Change detected — reloading theme (gen " + previewWindow.reloadGeneration + ")") + } + } + } catch (e) { + // Signal file doesn't exist yet — ignore + } + } } // ── Preview overlay ────────────────────────────────────────── @@ -266,8 +183,8 @@ Window { } Text { text: "|"; color: "#80ffffff"; font.pointSize: 9 } Text { - text: "Preview Mode" - color: "#ffcc00" + text: "Live Preview" + color: "#00ff88" font.pointSize: 9 font.bold: true } diff --git a/preview/ThemeLayout.qml b/preview/ThemeLayout.qml new file mode 100644 index 0000000..1d83341 --- /dev/null +++ b/preview/ThemeLayout.qml @@ -0,0 +1,148 @@ +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/preview.sh b/scripts/preview.sh index d8914e5..f249c31 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -2,8 +2,9 @@ # # preview.sh - Live-reload preview for the SDDM theme. # -# Watches all QML, conf, and image files. On any change, kills the -# running qml6 process and relaunches the Preview.qml harness. +# Launches the QML preview window once, then watches for file changes. +# On any change, touches a signal file that the QML Loader polls, causing +# it to reload components in-place without restarting the window. # # Usage: ./scripts/preview.sh [-theme ] # @@ -53,11 +54,15 @@ if [[ ! -d "$THEME_DIR" ]]; then fi # ── Setup ─────────────────────────────────────────────────────── +SIGNAL_FILE="$PROJECT_DIR/preview/.reload-signal" +QML_PID="" ENTR_PID="" cleanup() { trap - SIGINT SIGTERM # prevent re-entry [[ -n "$ENTR_PID" ]] && kill "$ENTR_PID" 2>/dev/null && wait "$ENTR_PID" 2>/dev/null + [[ -n "$QML_PID" ]] && kill "$QML_PID" 2>/dev/null && wait "$QML_PID" 2>/dev/null + rm -f "$SIGNAL_FILE" rm -f "$PROJECT_DIR/preview/components" "$PROJECT_DIR/preview/assets" echo "" echo "Preview stopped." @@ -69,24 +74,46 @@ trap cleanup SIGINT SIGTERM # load Breeze, which depends on Plasma-specific overlay types that are # unavailable outside a full Plasma session. export QT_QUICK_CONTROLS_STYLE=Basic +# Allow QML's XMLHttpRequest to read local files so the hot-reload +# file watcher can poll the signal file. +export QML_XHR_ALLOW_FILE_READ=1 # Symlink the theme's 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" +# Create the initial signal file so the QML poller has something to read. +touch "$SIGNAL_FILE" + echo "=== KDE Lockscreen Builder — Live Preview ===" echo "Project: $PROJECT_DIR" echo "Theme: $THEME_NAME ($THEME_DIR)" +echo "Hot reload: editing theme files will update the preview in-place" echo "Press Ctrl+C to stop" echo "" cd "$PROJECT_DIR" +# Launch qml6 once — it stays running for the entire session. +qml6 preview/Preview.qml & +QML_PID=$! + +# Watch for file changes and touch the signal file to trigger QML reload. +# Using entr without -r so it doesn't kill/restart anything. while true; do find . \( -name '*.qml' -o -name '*.conf' -o -name '*.jpg' -o -name '*.png' -o -name '*.svg' \) \ - | entr -d -r qml6 preview/Preview.qml & + -not -name '.reload-signal' \ + | entr -d -p sh -c 'date +%s%N > "'"$SIGNAL_FILE"'"' & ENTR_PID=$! wait "$ENTR_PID" || true ENTR_PID="" + + # If qml6 exited on its own (user closed window), stop everything. + if ! kill -0 "$QML_PID" 2>/dev/null; then + echo "Preview window closed." + rm -f "$SIGNAL_FILE" + rm -f "$PROJECT_DIR/preview/components" "$PROJECT_DIR/preview/assets" + exit 0 + fi done From 4d28a4d3534c7a88d600dac0a0e4e606eefcc581 Mon Sep 17 00:00:00 2001 From: Derryl Carter Date: Thu, 26 Feb 2026 17:06:02 -0800 Subject: [PATCH 2/2] Use PyQt6 host for true hot-reload with component cache clearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach used entr + QML XHR polling with a Loader, but QML's component cache prevented sub-component changes from being picked up. clearComponentCache() only works when no live instances reference the cached components. Fix: replace qml6 with a PyQt6 host (preview-host.py) that uses a 3-phase reload cycle: 1. Signal QML to unload the Loader (destroys all component instances) 2. Call engine.clearComponentCache() on the now-unreferenced types 3. Signal QML to reload the Loader from disk Also disable QML disk cache (QML_DISABLE_DISK_CACHE=1) and drop the entr/signal-file mechanism in favor of QFileSystemWatcher. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- preview/Preview.qml | 56 ++++++---------- scripts/preview-host.py | 145 ++++++++++++++++++++++++++++++++++++++++ scripts/preview.sh | 45 ++----------- 3 files changed, 170 insertions(+), 76 deletions(-) create mode 100644 scripts/preview-host.py diff --git a/preview/Preview.qml b/preview/Preview.qml index c72957a..d36f001 100644 --- a/preview/Preview.qml +++ b/preview/Preview.qml @@ -107,8 +107,14 @@ Window { // Hot-reload Loader // ═══════════════════════════════════════════════════════════════ - // Reload generation counter — incremented by the file watcher. - property int reloadGeneration: 0 + // 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 @@ -120,46 +126,22 @@ Window { } } - // (Re)load the theme layout, passing the preview reference as an - // initial property so `required property` is satisfied at creation time. - function reloadTheme() { + function loadTheme() { themeLoader.setSource("ThemeLayout.qml", { "preview": previewWindow }) } - onReloadGenerationChanged: reloadTheme() - Component.onCompleted: reloadTheme() - - // Poll a signal file whose content is a timestamp written by entr. - // When the content changes, we bump the Loader's generation counter. - // This avoids needing C++ filesystem-watcher plugins. - property string signalFileUrl: Qt.resolvedUrl(".reload-signal") - property string lastSignalContent: "" - - Timer { - id: fileWatcher - interval: 500 - running: true - repeat: true - onTriggered: { - var xhr = new XMLHttpRequest() - // Append cache-buster so QML doesn't serve a stale cached read - xhr.open("GET", previewWindow.signalFileUrl + "?t=" + Date.now(), false) - try { - xhr.send() - if (xhr.status === 200 || xhr.responseText !== "") { - var content = xhr.responseText.trim() - if (content !== "" && content !== previewWindow.lastSignalContent) { - previewWindow.lastSignalContent = content - previewWindow.reloadGeneration++ - console.log("[hot-reload] Change detected — reloading theme (gen " + previewWindow.reloadGeneration + ")") - } - } - } catch (e) { - // Signal file doesn't exist yet — ignore - } - } + function unloadTheme() { + themeLoader.source = "" } + Connections { + target: typeof hotReloader !== "undefined" ? hotReloader : null + function onUnloadRequested() { previewWindow.unloadTheme() } + function onReloadRequested() { previewWindow.loadTheme() } + } + + Component.onCompleted: loadTheme() + // ── Preview overlay ────────────────────────────────────────── Rectangle { anchors.top: parent.top diff --git a/scripts/preview-host.py b/scripts/preview-host.py new file mode 100644 index 0000000..104c1a6 --- /dev/null +++ b/scripts/preview-host.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +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. +""" + +import os +import sys +import glob + +from PyQt6.QtCore import ( + QFileSystemWatcher, + QObject, + QTimer, + QUrl, + pyqtProperty, + pyqtSignal, +) +from PyQt6.QtGui import QGuiApplication +from PyQt6.QtQml import QQmlApplicationEngine + + +class HotReloader(QObject): + """Exposed to QML as `hotReloader` — provides a reload generation counter. + + Reload is a 3-phase process: + 1. Signal QML to unload the Loader (destroy all component instances) + 2. After a tick, clear the now-unreferenced component cache + 3. Signal QML to reload the Loader from disk + This ordering is required because clearComponentCache() skips + components that still have live instances. + """ + + # Phase 1: tells QML to unload the Loader + unloadRequested = pyqtSignal() + # Phase 3: tells QML to reload the Loader + reloadRequested = pyqtSignal() + + reloadGenerationChanged = pyqtSignal() + + def __init__(self, engine: QQmlApplicationEngine, watch_dirs: list[str]): + super().__init__() + self._engine = engine + self._generation = 0 + self._watcher = QFileSystemWatcher() + + # Debounce rapid saves (e.g. editor write + rename) + self._debounce = QTimer() + self._debounce.setSingleShot(True) + self._debounce.setInterval(150) + self._debounce.timeout.connect(self._phase1_unload) + + # Timer for phase 2 (clear cache after unload has been processed) + self._clear_timer = QTimer() + self._clear_timer.setSingleShot(True) + self._clear_timer.setInterval(50) + self._clear_timer.timeout.connect(self._phase2_clear_and_reload) + + self._watch_dirs = watch_dirs + self._populate_watches() + + self._watcher.fileChanged.connect(self._on_change) + self._watcher.directoryChanged.connect(self._on_dir_change) + + @pyqtProperty(int, notify=reloadGenerationChanged) + def reloadGeneration(self) -> int: + return self._generation + + # ── Private ──────────────────────────────────────────────────── + + def _populate_watches(self): + """Watch all QML, conf, and image files plus their directories.""" + extensions = ("*.qml", "*.conf", "*.jpg", "*.png", "*.svg") + for d in self._watch_dirs: + if os.path.isdir(d): + self._watcher.addPath(d) + for ext in extensions: + for path in glob.glob(os.path.join(d, "**", ext), recursive=True): + self._watcher.addPath(path) + + def _on_change(self, path: str): + """A watched file changed — start/restart the debounce timer.""" + if os.path.exists(path): + self._watcher.addPath(path) + self._debounce.start() + + def _on_dir_change(self, path: str): + """A watched directory changed — re-scan for new files and reload.""" + self._populate_watches() + self._debounce.start() + + def _phase1_unload(self): + """Phase 1: tell QML to destroy all loaded components.""" + self.unloadRequested.emit() + # Give QML one event-loop tick to process the unload + self._clear_timer.start() + + def _phase2_clear_and_reload(self): + """Phase 2+3: clear the (now unreferenced) cache, then reload.""" + self._engine.clearComponentCache() + self._generation += 1 + self.reloadGenerationChanged.emit() + self.reloadRequested.emit() + print(f"[hot-reload] Reloading (gen {self._generation})") + + +def main(): + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Basic" + os.environ["QML_DISABLE_DISK_CACHE"] = "1" + + app = QGuiApplication(sys.argv) + engine = QQmlApplicationEngine() + + # Resolve the preview directory (this script lives in scripts/) + 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 + 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) + + qml_path = os.path.join(preview_dir, "Preview.qml") + engine.load(QUrl.fromLocalFile(qml_path)) + + if not engine.rootObjects(): + print("Error: failed to load Preview.qml", file=sys.stderr) + sys.exit(1) + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/scripts/preview.sh b/scripts/preview.sh index f249c31..898b786 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -2,9 +2,9 @@ # # preview.sh - Live-reload preview for the SDDM theme. # -# Launches the QML preview window once, then watches for file changes. -# On any change, touches a signal file that the QML Loader polls, causing -# it to reload components in-place without restarting the window. +# Launches the PyQt6 preview host which watches theme files and calls +# engine.clearComponentCache() on changes, giving true in-place hot +# reload without restarting the window. # # Usage: ./scripts/preview.sh [-theme ] # @@ -54,15 +54,8 @@ if [[ ! -d "$THEME_DIR" ]]; then fi # ── Setup ─────────────────────────────────────────────────────── -SIGNAL_FILE="$PROJECT_DIR/preview/.reload-signal" -QML_PID="" -ENTR_PID="" - cleanup() { trap - SIGINT SIGTERM # prevent re-entry - [[ -n "$ENTR_PID" ]] && kill "$ENTR_PID" 2>/dev/null && wait "$ENTR_PID" 2>/dev/null - [[ -n "$QML_PID" ]] && kill "$QML_PID" 2>/dev/null && wait "$QML_PID" 2>/dev/null - rm -f "$SIGNAL_FILE" rm -f "$PROJECT_DIR/preview/components" "$PROJECT_DIR/preview/assets" echo "" echo "Preview stopped." @@ -74,18 +67,12 @@ trap cleanup SIGINT SIGTERM # load Breeze, which depends on Plasma-specific overlay types that are # unavailable outside a full Plasma session. export QT_QUICK_CONTROLS_STYLE=Basic -# Allow QML's XMLHttpRequest to read local files so the hot-reload -# file watcher can poll the signal file. -export QML_XHR_ALLOW_FILE_READ=1 # Symlink the theme's 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" -# Create the initial signal file so the QML poller has something to read. -touch "$SIGNAL_FILE" - echo "=== KDE Lockscreen Builder — Live Preview ===" echo "Project: $PROJECT_DIR" echo "Theme: $THEME_NAME ($THEME_DIR)" @@ -93,27 +80,7 @@ echo "Hot reload: editing theme files will update the preview in-place" echo "Press Ctrl+C to stop" echo "" -cd "$PROJECT_DIR" - -# Launch qml6 once — it stays running for the entire session. -qml6 preview/Preview.qml & -QML_PID=$! +# Launch the PyQt6 preview host (handles file watching + cache clearing). +python3 "$SCRIPT_DIR/preview-host.py" -# Watch for file changes and touch the signal file to trigger QML reload. -# Using entr without -r so it doesn't kill/restart anything. -while true; do - find . \( -name '*.qml' -o -name '*.conf' -o -name '*.jpg' -o -name '*.png' -o -name '*.svg' \) \ - -not -name '.reload-signal' \ - | entr -d -p sh -c 'date +%s%N > "'"$SIGNAL_FILE"'"' & - ENTR_PID=$! - wait "$ENTR_PID" || true - ENTR_PID="" - - # If qml6 exited on its own (user closed window), stop everything. - if ! kill -0 "$QML_PID" 2>/dev/null; then - echo "Preview window closed." - rm -f "$SIGNAL_FILE" - rm -f "$PROJECT_DIR/preview/components" "$PROJECT_DIR/preview/assets" - exit 0 - fi -done +cleanup