Skip to content

Commit 1266c04

Browse files
derrylclaude
andcommitted
Add hot-reload to preview: update components in-place without restarting window
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 <noreply@anthropic.com>
1 parent d9d1468 commit 1266c04

4 files changed

Lines changed: 231 additions & 138 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# Symlinks created by preview/lint scripts at runtime
22
preview/components
33
preview/assets
4+
preview/.reload-signal

preview/Preview.qml

Lines changed: 52 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,13 @@ import QtQuick.Window
33
import QtQuick.Layouts
44
import QtQuick.Controls
55

6-
import "components"
7-
86
/*
97
* Preview.qml - Development harness for the SDDM theme.
108
*
11-
* Mirrors the Main.qml layout but provides mock objects in place of
12-
* the SDDM context properties (sddm, userModel, sessionModel, config,
13-
* screenModel, keyboard).
14-
*
15-
* Components receive their dependencies as explicit properties, so they
16-
* work identically whether driven by SDDM or by this preview.
17-
*
18-
* The preview script symlinks components/ and assets/ into preview/
19-
* so that relative imports and asset paths resolve to the selected theme.
9+
* Provides mock objects in place of the SDDM context properties, then loads
10+
* the theme layout through a Loader. A file-watcher timer detects changes
11+
* (via a signal file touched by entr) and reloads the Loader in-place,
12+
* keeping the window open for a true live-preview experience.
2013
*
2114
* Usage: ./scripts/preview.sh [-theme <name>]
2215
*/
@@ -32,10 +25,7 @@ Window {
3225
// Mock objects
3326
// ═══════════════════════════════════════════════════════════════
3427

35-
QtObject {
36-
id: mockConfig
37-
// Set to "" to use the solid fallback color, or provide a path
38-
// to an image (e.g. drop your own into assets/background.jpg).
28+
property QtObject mockConfig: QtObject {
3929
property string background: Qt.resolvedUrl("assets/background.jpg")
4030
property string type: "image"
4131
property string color: "#1a1a2e"
@@ -52,8 +42,7 @@ Window {
5242
property int screenHeight: previewWindow.height
5343
}
5444

55-
QtObject {
56-
id: mockSddm
45+
property QtObject mockSddm: QtObject {
5746
property string hostname: "preview-host"
5847
property bool canPowerOff: true
5948
property bool canReboot: true
@@ -81,8 +70,7 @@ Window {
8170
function hybridSleep() { console.log("[mock] sddm.hybridSleep()") }
8271
}
8372

84-
ListModel {
85-
id: mockUserModel
73+
property ListModel mockUserModel: ListModel {
8674
property string lastUser: "user"
8775
property int lastIndex: 0
8876
property int disableAvatarsThreshold: 7
@@ -92,17 +80,15 @@ Window {
9280
ListElement { name: "guest"; realName: "Guest User"; icon: ""; needsPassword: true }
9381
}
9482

95-
ListModel {
96-
id: mockSessionModel
83+
property ListModel mockSessionModel: ListModel {
9784
property int lastIndex: 0
9885

9986
ListElement { name: "Plasma (Wayland)"; comment: "" }
10087
ListElement { name: "Plasma (X11)"; comment: "" }
10188
ListElement { name: "GNOME"; comment: "" }
10289
}
10390

104-
QtObject {
105-
id: mockKeyboard
91+
property QtObject mockKeyboard: QtObject {
10692
property bool capsLock: false
10793
property bool numLock: true
10894
}
@@ -118,129 +104,60 @@ Window {
118104
property color accentColor: mockConfig.accentColor || "#4a9eff"
119105

120106
// ═══════════════════════════════════════════════════════════════
121-
// Theme layout (identical to Main.qml)
107+
// Hot-reload Loader
122108
// ═══════════════════════════════════════════════════════════════
123109

124-
// ── Background ───────────────────────────────────────────────
125-
Image {
126-
id: backgroundImage
127-
anchors.fill: parent
128-
source: mockConfig.background || ""
129-
fillMode: Image.PreserveAspectCrop
130-
asynchronous: true
131-
onStatusChanged: {
132-
if (status === Image.Error && source != "")
133-
console.log("Background image not found — using fallback color. " +
134-
"Drop an image into assets/background.jpg or update theme.conf.")
135-
}
136-
}
137-
138-
Rectangle {
139-
id: backgroundFallback
140-
anchors.fill: parent
141-
color: mockConfig.color || "#1a1a2e"
142-
visible: backgroundImage.status !== Image.Ready
143-
}
110+
// Reload generation counter — incremented by the file watcher.
111+
property int reloadGeneration: 0
144112

145-
Rectangle {
113+
Loader {
114+
id: themeLoader
146115
anchors.fill: parent
147-
color: mockConfig.backgroundOverlayColor || "#000000"
148-
opacity: mockConfig.backgroundOverlayOpacity || 0.3
149-
}
150116

151-
// ── Clock ────────────────────────────────────────────────────
152-
Clock {
153-
id: clock
154-
visible: mockConfig.clockVisible === "true"
155-
anchors {
156-
top: parent.top
157-
horizontalCenter: parent.horizontalCenter
158-
topMargin: previewWindow.height * 0.08
117+
onStatusChanged: {
118+
if (status === Loader.Error)
119+
console.log("[hot-reload] Error loading ThemeLayout.qml — check for syntax errors")
159120
}
160-
textColor: primaryColor
161-
fontSize: baseFontSize * 4
162-
timeFormat: mockConfig.clockFormat || "hh:mm"
163-
dateFormat: mockConfig.dateFormat || "dddd, MMMM d"
164121
}
165122

166-
// ── Login form ───────────────────────────────────────────────
167-
LoginForm {
168-
id: loginForm
169-
anchors.centerIn: parent
170-
width: 320
171-
172-
textColor: primaryColor
173-
accentColor: previewWindow.accentColor
174-
fontSize: baseFontSize
175-
fontFamily: previewWindow.fontFamily
176-
defaultUsername: mockUserModel.lastUser || ""
177-
notificationMessage: previewWindow.notificationMessage
178-
capsLockOn: mockKeyboard.capsLock
179-
180-
sessionIndex: sessionSelector.currentIndex
181-
182-
onLoginRequest: function(username, password) {
183-
previewWindow.notificationMessage = ""
184-
mockSddm.login(username, password, sessionSelector.currentIndex)
185-
}
123+
// (Re)load the theme layout, passing the preview reference as an
124+
// initial property so `required property` is satisfied at creation time.
125+
function reloadTheme() {
126+
themeLoader.setSource("ThemeLayout.qml", { "preview": previewWindow })
186127
}
187128

188-
// ── Footer ───────────────────────────────────────────────────
189-
RowLayout {
190-
id: footer
191-
anchors {
192-
bottom: parent.bottom
193-
left: parent.left
194-
right: parent.right
195-
margins: 16
196-
}
197-
height: 48
198-
199-
SessionSelector {
200-
id: sessionSelector
201-
textColor: primaryColor
202-
fontSize: baseFontSize - 2
203-
fontFamily: previewWindow.fontFamily
204-
sessions: mockSessionModel
205-
Layout.preferredWidth: 180
206-
Layout.preferredHeight: 32
207-
}
208-
209-
Item { Layout.fillWidth: true }
210-
211-
PowerBar {
212-
id: powerBar
213-
textColor: primaryColor
214-
fontSize: baseFontSize - 2
215-
iconSize: baseFontSize + 6
216-
Layout.preferredHeight: 48
217-
218-
canSuspend: mockSddm.canSuspend
219-
canReboot: mockSddm.canReboot
220-
canPowerOff: mockSddm.canPowerOff
129+
onReloadGenerationChanged: reloadTheme()
130+
Component.onCompleted: reloadTheme()
221131

222-
onSuspendClicked: mockSddm.suspend()
223-
onRebootClicked: mockSddm.reboot()
224-
onPowerOffClicked: mockSddm.powerOff()
225-
}
226-
}
227-
228-
// ── SDDM connections ─────────────────────────────────────────
229-
Connections {
230-
target: mockSddm
231-
function onLoginFailed() {
232-
previewWindow.notificationMessage = "Login Failed"
233-
loginForm.clearPassword()
234-
}
235-
function onLoginSucceeded() {
236-
previewWindow.notificationMessage = ""
237-
}
238-
}
132+
// Poll a signal file whose content is a timestamp written by entr.
133+
// When the content changes, we bump the Loader's generation counter.
134+
// This avoids needing C++ filesystem-watcher plugins.
135+
property string signalFileUrl: Qt.resolvedUrl(".reload-signal")
136+
property string lastSignalContent: ""
239137

240138
Timer {
241-
interval: 3000
242-
running: notificationMessage !== ""
243-
onTriggered: notificationMessage = ""
139+
id: fileWatcher
140+
interval: 500
141+
running: true
142+
repeat: true
143+
onTriggered: {
144+
var xhr = new XMLHttpRequest()
145+
// Append cache-buster so QML doesn't serve a stale cached read
146+
xhr.open("GET", previewWindow.signalFileUrl + "?t=" + Date.now(), false)
147+
try {
148+
xhr.send()
149+
if (xhr.status === 200 || xhr.responseText !== "") {
150+
var content = xhr.responseText.trim()
151+
if (content !== "" && content !== previewWindow.lastSignalContent) {
152+
previewWindow.lastSignalContent = content
153+
previewWindow.reloadGeneration++
154+
console.log("[hot-reload] Change detected — reloading theme (gen " + previewWindow.reloadGeneration + ")")
155+
}
156+
}
157+
} catch (e) {
158+
// Signal file doesn't exist yet — ignore
159+
}
160+
}
244161
}
245162

246163
// ── Preview overlay ──────────────────────────────────────────
@@ -266,8 +183,8 @@ Window {
266183
}
267184
Text { text: "|"; color: "#80ffffff"; font.pointSize: 9 }
268185
Text {
269-
text: "Preview Mode"
270-
color: "#ffcc00"
186+
text: "Live Preview"
187+
color: "#00ff88"
271188
font.pointSize: 9
272189
font.bold: true
273190
}

0 commit comments

Comments
 (0)