Add event-driven wallpaper rotation

This commit is contained in:
Gabriel Brown
2026-08-18 15:00:18 -04:00
parent 52818b7290
commit 1c12892252
3 changed files with 160 additions and 4 deletions
+92 -2
View File
@@ -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([]);
@@ -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
});
}
}
@@ -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