From 1c1289225262b0967e60ac462ee3b5d1ca2a29b1 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 14:25:44 -0400 Subject: [PATCH] Add event-driven wallpaper rotation --- config/dot/quickshell/services/Wallpaper.qml | 94 ++++++++++++++++++- .../quickshell/wallpaper-service-harness.qml | 28 +++++- .../quickshell/wallpaper-service-contract.sh | 42 +++++++++ 3 files changed, 160 insertions(+), 4 deletions(-) diff --git a/config/dot/quickshell/services/Wallpaper.qml b/config/dot/quickshell/services/Wallpaper.qml index 545d233..3745b13 100644 --- a/config/dot/quickshell/services/Wallpaper.qml +++ b/config/dot/quickshell/services/Wallpaper.qml @@ -20,6 +20,13 @@ Singleton { property bool scanning: false property var transaction: null property bool startupRestoreEnabled: true + property var outputOverride: null + property var candidateOverride: null + property string slideshowPath: "" + property int slideshowIndex: -1 + property var shuffleBag: [] + property int slideshowIntervalOverrideMs: 0 + property bool pendingHotplugReapply: false readonly property string configured: DesktopPreferences.get("wallpaperPath") readonly property string shippedPath: `${Quickshell.env("HOME")}/Pictures/Wallpapers/faroe_islands.jpg` @@ -32,8 +39,14 @@ Singleton { } readonly property bool busy: root.transaction !== null || applyProcess.running || verifyProcess.running + readonly property var slideshowCollection: WallpaperPolicy.validCollection( + DesktopPreferences.get("wallpaperSlideshowPaths") ?? [], root.candidates([])) + readonly property bool slideshowTimerRunning: slideshowTimer.running + readonly property string outputSignature: root.outputNames().slice().sort().join("|") property var outputNames: function() { + if (Array.isArray(root.outputOverride)) + return root.outputOverride.slice(); return Quickshell.screens.map(screen => screen.name).filter(name => !!name); } @@ -79,6 +92,7 @@ Singleton { if (exitCode !== 0) { root.lastError = "Hyprpaper did not apply that background."; root.transaction = null; + root.schedulePendingHotplug(); return; } root.drainTransaction(); @@ -110,6 +124,34 @@ Singleton { } } + Timer { + id: slideshowTimer + interval: root.slideshowIntervalOverrideMs > 0 + ? root.slideshowIntervalOverrideMs + : DesktopPreferences.get("wallpaperIntervalMinutes") * 60000 + repeat: true + running: DesktopPreferences.get("wallpaperMode") === "slideshow" + && root.slideshowCollection.length >= 2 && !root.busy + onTriggered: root.advanceSlideshow() + } + + Timer { + id: outputSettle + interval: 350 + onTriggered: { + if (root.busy) { + root.pendingHotplugReapply = true; + return; + } + root.applyCurrentPolicy(false); + } + } + + onOutputSignatureChanged: { + if (Object.keys(root.activeByOutput).length > 0) + outputSettle.restart(); + } + function rescan(): void { if (scan.running) return; @@ -124,7 +166,9 @@ Singleton { function candidates(extra: var): var { const result = []; - for (const path of root.available.concat([root.shippedPath, root.configured]).concat(extra || [])) { + const discovered = Array.isArray(root.candidateOverride) + ? root.candidateOverride : root.available; + for (const path of discovered.concat([root.shippedPath, root.configured]).concat(extra || [])) { if (typeof path === "string" && /^\/[^,\n]+$/.test(path) && !result.includes(path)) result.push(path); } @@ -139,7 +183,7 @@ Singleton { intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30, shuffle: DesktopPreferences.get("wallpaperShuffle") !== false, assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({}), - slideshowPath: root.active + slideshowPath: root.slideshowPath !== "" ? root.slideshowPath : root.active }; } @@ -165,6 +209,10 @@ Singleton { assignments: WallpaperPolicy.validAssignments(policy.assignments || {}, candidatePaths), slideshowPath: WallpaperPolicy.validPath(policy.slideshowPath, candidatePaths) ? policy.slideshowPath : rawGlobal, + nextShuffleBag: Array.isArray(policy.nextShuffleBag) + ? policy.nextShuffleBag.slice() : root.shuffleBag.slice(), + nextSlideshowIndex: Number.isInteger(policy.nextSlideshowIndex) + ? policy.nextSlideshowIndex : root.slideshowIndex, candidates: candidatePaths }; } @@ -240,6 +288,7 @@ Singleton { if (!matches) { root.lastError = "Hyprpaper did not confirm that background."; root.transaction = null; + root.schedulePendingHotplug(); return; } @@ -247,8 +296,14 @@ Singleton { root.activeByOutput = observed; root.transaction = null; root.lastError = ""; + if (completed.automatic) { + root.slideshowPath = completed.policy.slideshowPath; + root.shuffleBag = completed.policy.nextShuffleBag; + root.slideshowIndex = completed.policy.nextSlideshowIndex; + } if (completed.persist) root.persistPolicy(completed.policy); + root.schedulePendingHotplug(); } function persistPolicy(policy: var): void { @@ -264,6 +319,41 @@ Singleton { return root.applyPolicy(root.currentPolicy(), persist === true, false); } + function schedulePendingHotplug(): void { + if (!root.pendingHotplugReapply) + return; + root.pendingHotplugReapply = false; + outputSettle.restart(); + } + + function advanceSlideshow(): bool { + if (root.busy) + return false; + const collection = root.slideshowCollection; + if (collection.length < 2) + return false; + + const current = root.slideshowPath !== "" + ? root.slideshowPath + : (root.active !== "" ? root.active : collection[0]); + const policy = root.currentPolicy(); + policy.mode = "slideshow"; + policy.collection = collection; + + if (DesktopPreferences.get("wallpaperShuffle") !== false) { + const next = WallpaperPolicy.shuffledNext( + collection, root.shuffleBag, current, Math.random); + policy.slideshowPath = next.path; + policy.nextShuffleBag = next.bag; + policy.nextSlideshowIndex = collection.indexOf(next.path); + } else { + policy.slideshowPath = WallpaperPolicy.orderedNext(collection, current); + policy.nextShuffleBag = []; + policy.nextSlideshowIndex = collection.indexOf(policy.slideshowPath); + } + return root.applyPolicy(policy, false, true); + } + function setSingle(path: string): bool { const effectivePath = path === "" ? root.shippedPath : path; const allowed = root.candidates([]); diff --git a/config/dot/quickshell/wallpaper-service-harness.qml b/config/dot/quickshell/wallpaper-service-harness.qml index a96b15c..57ef178 100644 --- a/config/dot/quickshell/wallpaper-service-harness.qml +++ b/config/dot/quickshell/wallpaper-service-harness.qml @@ -7,8 +7,10 @@ import qs.services ShellRoot { Component.onCompleted: { - Wallpaper.outputNames = function() { return ["DP-2", "HDMI-A-1"]; }; + Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"]; + Wallpaper.candidateOverride = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"]; Wallpaper.startupRestoreEnabled = false; + Wallpaper.slideshowIntervalOverrideMs = 60000; Wallpaper.available = ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"]; } @@ -31,6 +33,25 @@ ShellRoot { return Wallpaper.set(path); } + function seedSlideshow(paths: string, shuffle: bool): void { + DesktopPreferences.set("wallpaperMode", "slideshow"); + DesktopPreferences.set("wallpaperPath", "/images/a.jpg"); + DesktopPreferences.set("wallpaperSlideshowPaths", JSON.parse(paths)); + DesktopPreferences.set("wallpaperShuffle", shuffle); + Wallpaper.slideshowPath = "/images/a.jpg"; + Wallpaper.shuffleBag = []; + } + + function advanceSlideshow(): bool { + return Wallpaper.advanceSlideshow(); + } + + function rapidOutputs(): void { + Wallpaper.outputOverride = ["DP-2"]; + Wallpaper.outputOverride = ["HDMI-A-1"]; + Wallpaper.outputOverride = ["DP-2", "HDMI-A-1"]; + } + function status(): string { return JSON.stringify({ busy: Wallpaper.busy, @@ -39,7 +60,10 @@ ShellRoot { active: Wallpaper.active, mode: DesktopPreferences.get("wallpaperMode"), path: DesktopPreferences.get("wallpaperPath"), - assignments: DesktopPreferences.get("wallpaperPerMonitor") + assignments: DesktopPreferences.get("wallpaperPerMonitor"), + slideshowPath: Wallpaper.slideshowPath, + shuffleBag: Wallpaper.shuffleBag, + slideshowTimerRunning: Wallpaper.slideshowTimerRunning }); } } diff --git a/tests/quickshell/wallpaper-service-contract.sh b/tests/quickshell/wallpaper-service-contract.sh index 23f7fde..769ce4f 100755 --- a/tests/quickshell/wallpaper-service-contract.sh +++ b/tests/quickshell/wallpaper-service-contract.sh @@ -154,6 +154,48 @@ jq -e '.path == "/images/a.jpg" and .mode == "per-monitor" [[ "$(tail -1 "$command_log")" == 'hyprctl hyprpaper wallpaper HDMI-A-1,/images/b.jpg' ]] \ || fail 'transaction continued after the failed output' +printf '\n' >"$control" +printf '%s\n' '{}' >"$active_state" +: >"$command_log" +qs_for_harness ipc call wallpaper-service-test seedSlideshow \ + ' ["/images/a.jpg","/images/b.jpg","/images/c.jpg"]' false >/dev/null +seeded="$(qs_for_harness ipc call wallpaper-service-test status)" +jq -e '.slideshowTimerRunning == true and .slideshowPath == "/images/a.jpg"' \ + <<<"$seeded" >/dev/null || fail "multi-image slideshow did not arm: $seeded" +qs_for_harness ipc call wallpaper-service-test advanceSlideshow >/dev/null +advanced="$(wait_idle)" +jq -e '.mode == "slideshow" and .path == "/images/a.jpg" + and .slideshowPath == "/images/b.jpg" + and .activeByOutput == {"DP-2":"/images/b.jpg","HDMI-A-1":"/images/b.jpg"}' \ + <<<"$advanced" >/dev/null || fail "automatic advance persisted policy or lost runtime state: $advanced" + +printf 'wrong\n' >"$control" +qs_for_harness ipc call wallpaper-service-test advanceSlideshow >/dev/null +automatic_failure="$(wait_idle)" +jq -e '.path == "/images/a.jpg" and .slideshowPath == "/images/b.jpg" + and .slideshowTimerRunning == true + and .lastError == "Hyprpaper did not confirm that background."' \ + <<<"$automatic_failure" >/dev/null \ + || fail "failed automatic advance changed runtime policy or stopped normal scheduling: $automatic_failure" +printf '\n' >"$control" + +qs_for_harness ipc call wallpaper-service-test seedSlideshow ' ["/images/a.jpg"]' false >/dev/null +single_item="$(qs_for_harness ipc call wallpaper-service-test status)" +jq -e '.slideshowTimerRunning == false' <<<"$single_item" >/dev/null \ + || fail "one-item slideshow armed a repeating timer: $single_item" + +qs_for_harness ipc call wallpaper-service-test applySingle /images/a.jpg >/dev/null +wait_idle >/dev/null +printf '%s\n' '{}' >"$active_state" +: >"$command_log" +qs_for_harness ipc call wallpaper-service-test rapidOutputs >/dev/null +sleep 0.6 +hotplug="$(wait_idle)" +[[ "$(rg -c '^hyprctl hyprpaper listactive$' "$command_log")" -eq 1 ]] \ + || fail "rapid output changes did not coalesce: $(<"$command_log")" +jq -e '.lastError == "" and .activeByOutput == {"DP-2":"/images/a.jpg","HDMI-A-1":"/images/a.jpg"}' \ + <<<"$hotplug" >/dev/null || fail "hotplug reapply was not verified: $hotplug" + if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then fail 'service harness emitted a QML runtime warning' fi