// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. pragma Singleton import Quickshell import QtQuick import "../.." import "Shuffle.js" as Shuffle // The wallpaper engine: both wallpapers on a timer, from a list per screen // picked with the heart in the picker. Ticks go through Wallpapers.run like // Apply, so the burn plays, but as the light run: udt-accent leaves GRUB, the // homepage and SDDM alone. Each screen has a home, the wallpaper last chosen // for it by hand, and switching off goes back to it with a full run, so those // three match the desktop again. See the spec, // docs/superpowers/specs/2026-10-29-wallpaper-engine-design.md. Singleton { id: root readonly property var cfg: Prefs.wallEngine readonly property bool on: cfg.on function list(which) { return which === "V" ? cfg.v : cfg.h; } function listed(path, which) { return list(which).indexOf(path) >= 0; } // A gallery folder by its Wallpapers.groups key, "" for the hearted list. function folder(which) { return which === "V" ? cfg.folderV : cfg.folderH; } // What a tick draws from. A folder that is no longer in the gallery // gives nothing, so that screen is left alone rather than falling back // to the list behind the user's back. function pool(which) { const key = folder(which); if (key === "") return list(which).filter(present); const g = Wallpapers.groups.find(g => g.key === key); return g ? g.files : []; } // ponytail: present means in the gallery's last scan, so a file deleted // since stays drawable until a rescan, and wallp then skips it. function present(path) { return Wallpapers.files.indexOf(path) >= 0; } function toggle(path, which) { const l = list(which).slice(); const i = l.indexOf(path); if (i >= 0) l.splice(i, 1); else l.push(path); Prefs.setWallEngine(which === "V" ? { v: l } : { h: l }); } function setOn(v) { if (v === on) return; if (v) { Prefs.setWallEngine({ on: true, homeH: Wallpapers.currentH, homeV: Wallpapers.currentV }); } else { Prefs.setWallEngine({ on: false }); goHome(); } } // The page's draft, like every settings page: the interval and the two // sources change here and reach Prefs on Start or Apply. Reset binds // them back to what is saved. Hearts are not drafted: the picker writes // them as they are clicked. property int draftMinutes: cfg.minutes property string draftFolderH: cfg.folderH property string draftFolderV: cfg.folderV readonly property bool dirty: draftMinutes !== cfg.minutes || draftFolderH !== cfg.folderH || draftFolderV !== cfg.folderV function draftFolder(which) { return which === "V" ? draftFolderV : draftFolderH; } function setDraftFolder(which, key) { if (which === "V") draftFolderV = key; else draftFolderH = key; } function setDraftMinutes(m) { if (Number.isInteger(m) && m >= 1) draftMinutes = m; } function reset() { draftMinutes = Qt.binding(() => cfg.minutes); draftFolderH = Qt.binding(() => cfg.folderH); draftFolderV = Qt.binding(() => cfg.folderV); } function commit() { Prefs.setWallEngine({ minutes: draftMinutes, folderH: draftFolderH, folderV: draftFolderV }); reset(); } // Start changes the wallpapers at once: the first timer change would // otherwise be a whole interval away, which read as the engine not // working. function start() { commit(); setOn(true); next(); } function apply() { commit(); restartTimer(); } function stop() { setOn(false); } // ponytail: in memory, so a restart starts a new round. property var bagH: [] property var bagV: [] // A screen whose list is empty, or whose pick is already up, is left out. function tick() { if (Wallpapers.applying) return; const h = Shuffle.draw(pool("H"), bagH, Wallpapers.currentH); const v = Shuffle.draw(pool("V"), bagV, Wallpapers.currentV); const ph = h.pick !== Wallpapers.currentH ? h.pick : ""; const pv = v.pick !== Wallpapers.currentV ? v.pick : ""; if (Wallpapers.run(ph, pv, "tick")) { bagH = h.bag; bagV = v.bag; } } function next() { tick(); restartTimer(); } // When the timer next fires, for the page's header; 0 while off. Timer // has no such property, so it is kept beside it. property double nextAt: 0 function restartTimer() { timer.restart(); nextAt = Date.now() + timer.interval; } // The pair up now differs from home only when a tick put it there, with // the light run. readonly property bool keepable: on && (cfg.homeH !== Wallpapers.currentH || cfg.homeV !== Wallpapers.currentV) // Liked what a tick put up: a full run of the same pair brings GRUB, the // homepage and SDDM along, and it becomes home, so switching off does not // undo that, and stays up a full interval like a pick by hand. function keep() { const h = Wallpapers.currentH, v = Wallpapers.currentV; if (!keepable || !Wallpapers.run(h, v, "keep")) return; Prefs.setWallEngine({ homeH: h, homeV: v }); restartTimer(); } // A switch-off during a run cannot start its own; it goes when that run // finishes. property bool homeWaiting: false function goHome() { const h = cfg.homeH !== Wallpapers.currentH ? cfg.homeH : ""; const v = cfg.homeV !== Wallpapers.currentV ? cfg.homeV : ""; if (h === "" && v === "") return; homeWaiting = !Wallpapers.run(h, v, "home"); } Timer { id: timer interval: root.cfg.minutes * 60000 repeat: true running: root.on onTriggered: { root.nextAt = Date.now() + interval; root.tick(); } // A running Timer restarts on a new interval. onIntervalChanged: if (running) root.nextAt = Date.now() + interval onRunningChanged: root.nextAt = running ? Date.now() + interval : 0 } Connections { target: Wallpapers // A pick by hand is the new home, and stays up a full interval. function onManualApplied(h, v) { if (!root.on) return; Prefs.setWallEngine({ homeH: h !== "" ? h : root.cfg.homeH, homeV: v !== "" ? v : root.cfg.homeV }); root.restartTimer(); } function onFinished() { if (!root.homeWaiting || root.on) return; root.homeWaiting = false; root.goHome(); } } }