pragma Singleton // Verified wallpaper policy application. hyprpaper 0.8 applies one output per // IPC call, so Panama queues every connected output and persists a policy only // after listactive confirms the complete map. import Quickshell import Quickshell.Io import QtQuick import "WallpaperPolicy.js" as WallpaperPolicy import qs.config Singleton { id: root property var available: [] property var activeByOutput: ({}) property string lastError: "" 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` readonly property string active: { const outputs = root.outputNames(); if (outputs.length > 0 && root.activeByOutput[outputs[0]]) return root.activeByOutput[outputs[0]]; const paths = Object.values(root.activeByOutput); return paths.length > 0 ? paths[0] : ""; } 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() { const outputs = Array.isArray(root.outputOverride) ? root.outputOverride.slice() : Quickshell.screens.map(screen => screen.name).filter(name => !!name); return root.primaryFirstOutputs(outputs); } // One folder, chosen in Settings, rather than four swept blindly. A leading // slash means an absolute path -- a drive that is not under home -- and // anything else is read relative to it, which is how the capture folders // work too. readonly property var searchRoots: { const configured = String(DesktopPreferences.get("wallpaperDir") ?? "").trim(); const folder = configured === "" ? "Pictures/Wallpapers" : configured; const home = Quickshell.env("HOME"); if (folder.startsWith("/")) return [folder]; if (folder.startsWith("~/")) return [home + folder.slice(1)]; return [`${home}/${folder}`]; } Process { id: scan command: [Quickshell.shellDir + "/scripts/panama-wallpaper-scan"] .concat(root.searchRoots) stdout: StdioCollector { onStreamFinished: { root.available = this.text.split("\n") .map(line => line.trim()).filter(line => line.length > 0); root.scanning = false; } } } Process { id: activeQuery command: ["hyprctl", "hyprpaper", "listactive"] stdout: StdioCollector { onStreamFinished: { const parsed = root.parseActive(this.text); if (parsed !== null) root.activeByOutput = parsed; } } } Process { id: applyProcess onExited: (exitCode, exitStatus) => { if (root.transaction === null) return; if (exitCode !== 0) { root.lastError = "Hyprpaper did not apply that background."; root.transaction = null; root.schedulePendingHotplug(); return; } root.drainTransaction(); } } Process { id: verifyProcess property string outputText: "" onStarted: outputText = "" stdout: StdioCollector { onStreamFinished: verifyProcess.outputText = this.text } onExited: (exitCode, exitStatus) => root.finishVerification(exitCode) } Component.onCompleted: { root.rescan(); root.refreshActive(); restore.restart(); } Timer { id: restore interval: 1500 onTriggered: { if (!root.startupRestoreEnabled) return; // A stored video wallpaper restores through mpvpaper — the video // service owns that reactively (VideoWallpaper.tryRestore), so // this only avoids fighting it with a still policy. if (VideoWallpaper.isVideo(String(DesktopPreferences.get("videoWallpaperPath") || ""))) { VideoWallpaper.tryRestore(); return; } root.applyCurrentPolicy(false); } } 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; root.scanning = true; scan.running = true; } function refreshActive(): void { if (!activeQuery.running && !root.busy) activeQuery.running = true; } // The saved primary role anchors Panama's per-monitor choices. Keep the // compositor's remaining order stable so a reconnect does not reshuffle // the rest of the picker unnecessarily. function primaryFirstOutputs(outputs: var): var { const stored = DesktopPreferences.get("displays"); const layouts = stored && typeof stored === "object" ? stored : {}; const primary = outputs.find(output => layouts[output]?.primary === true); return primary === undefined ? outputs : [primary].concat(outputs.filter(output => output !== primary)); } function candidates(extra: var): var { const result = []; 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); } return result; } function currentPolicy(): var { return { mode: DesktopPreferences.get("wallpaperMode") ?? "single", // A stored video path cannot serve as a hyprpaper still; policies // built while one is stored fall back to the shipped image. globalPath: root.configured === "" || VideoWallpaper.isVideo(root.configured) ? root.shippedPath : root.configured, collection: DesktopPreferences.get("wallpaperSlideshowPaths") ?? [], intervalMinutes: DesktopPreferences.get("wallpaperIntervalMinutes") ?? 30, shuffle: DesktopPreferences.get("wallpaperShuffle") !== false, assignments: DesktopPreferences.get("wallpaperPerMonitor") ?? ({}), slideshowPath: root.slideshowPath !== "" ? root.slideshowPath : root.active }; } function normalizePolicy(policy: var): var { if (!policy || typeof policy !== "object") return null; const mode = ["single", "slideshow", "per-monitor"].includes(policy.mode) ? policy.mode : "single"; const rawGlobal = policy.globalPath === "" ? root.shippedPath : policy.globalPath; const candidatePaths = root.candidates([rawGlobal] .concat(policy.collection || []) .concat(Object.values(policy.assignments || {})) .concat([policy.slideshowPath || ""])); if (!WallpaperPolicy.validPath(rawGlobal, candidatePaths)) return null; return { mode, globalPath: rawGlobal, storedPath: rawGlobal === root.shippedPath ? "" : rawGlobal, collection: WallpaperPolicy.validCollection(policy.collection || [], candidatePaths), intervalMinutes: Math.max(5, Math.min(1440, Number(policy.intervalMinutes) || 30)), shuffle: policy.shuffle !== false, 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 }; } function applyPolicy(policy: var, persist: bool, automatic: bool): bool { if (root.busy) return false; const normalized = root.normalizePolicy(policy); if (normalized === null) { root.lastError = "That wallpaper policy is not valid."; return false; } const outputs = root.outputNames(); if (outputs.length === 0) { root.lastError = "No display to set a wallpaper on."; return false; } const expected = WallpaperPolicy.effectiveMap( normalized.mode, normalized.globalPath, normalized.slideshowPath, normalized.assignments, outputs, normalized.candidates); if (Object.keys(expected).length !== outputs.length || Object.values(expected).some(path => path === "")) { root.lastError = "That wallpaper policy is not valid."; return false; } root.lastError = ""; root.transaction = { expected, remaining: outputs.slice(), policy: normalized, persist: persist === true, automatic: automatic === true }; root.drainTransaction(); return true; } function drainTransaction(): void { if (root.transaction === null || applyProcess.running || verifyProcess.running) return; if (root.transaction.remaining.length === 0) { verifyProcess.exec(["hyprctl", "hyprpaper", "listactive"]); return; } const output = root.transaction.remaining[0]; root.transaction.remaining = root.transaction.remaining.slice(1); applyProcess.exec([ "hyprctl", "hyprpaper", "wallpaper", `${output},${root.transaction.expected[output]}` ]); } function parseActive(text: string): var { const result = {}; const lines = String(text).split("\n").map(line => line.trim()).filter(line => line !== ""); for (const line of lines) { const match = line.match(/^([A-Za-z0-9_.-]+): (\/[^,\n]+)$/); if (!match || result[match[1]] !== undefined) return null; result[match[1]] = match[2]; } return result; } function finishVerification(exitCode: int): void { if (root.transaction === null) return; const observed = exitCode === 0 ? root.parseActive(verifyProcess.outputText) : null; const expected = root.transaction.expected; const matches = observed !== null && Object.keys(observed).length === Object.keys(expected).length && Object.keys(expected).every(output => observed[output] === expected[output]); if (!matches) { root.lastError = "Hyprpaper did not confirm that background."; root.transaction = null; root.schedulePendingHotplug(); return; } const completed = root.transaction; 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 { DesktopPreferences.set("wallpaperMode", policy.mode); DesktopPreferences.set("wallpaperPath", policy.storedPath); DesktopPreferences.set("wallpaperSlideshowPaths", policy.collection); DesktopPreferences.set("wallpaperIntervalMinutes", policy.intervalMinutes); DesktopPreferences.set("wallpaperShuffle", policy.shuffle); DesktopPreferences.set("wallpaperPerMonitor", policy.assignments); } function applyCurrentPolicy(persist: bool): bool { // While mpvpaper holds the background, hyprpaper has nothing to apply // — hotplug is VideoWallpaper's problem and it respawns per output. if (VideoWallpaper.active) return true; 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 { // A video routes to mpvpaper instead of hyprpaper; it still rides // wallpaperPath so restore, backup, and the picker's "current" ring // treat both kinds the same. if (VideoWallpaper.isVideo(path)) { if (!VideoWallpaper.start(path)) { root.lastError = VideoWallpaper.lastError; return false; } root.lastError = ""; DesktopPreferences.set("videoWallpaperPath", path); return true; } if (DesktopPreferences.get("videoWallpaperPath") !== "") DesktopPreferences.set("videoWallpaperPath", ""); const effectivePath = path === "" ? root.shippedPath : path; const allowed = root.candidates([]); if (!WallpaperPolicy.validPath(effectivePath, allowed)) { root.lastError = "That file path cannot be used as a wallpaper."; return false; } const policy = root.currentPolicy(); policy.mode = "single"; policy.globalPath = effectivePath; policy.slideshowPath = effectivePath; // Returning from a video: stop mpvpaper first, then give hyprpaper's // service a beat to come back before the transaction talks to it. if (VideoWallpaper.active) { VideoWallpaper.stop(); root.pendingStillPolicy = policy; stillAfterVideo.restart(); return true; } return root.applyPolicy(policy, true, false); } property var pendingStillPolicy: null Timer { id: stillAfterVideo interval: 900 onTriggered: { if (root.pendingStillPolicy) { root.applyPolicy(root.pendingStillPolicy, true, false); root.pendingStillPolicy = null; } } } function setMode(mode: string): bool { if (!["single", "slideshow", "per-monitor"].includes(mode)) return false; const policy = root.currentPolicy(); policy.mode = mode; // Slideshow and per-display are hyprpaper's modes; a stored video path // cannot serve as their global still, so fall back to the shipped one. if (VideoWallpaper.isVideo(policy.globalPath)) { policy.globalPath = root.shippedPath; policy.slideshowPath = root.shippedPath; } if (mode === "slideshow") { const collection = WallpaperPolicy.validCollection( policy.collection, root.candidates([])); if (!collection.includes(policy.slideshowPath)) policy.slideshowPath = collection[0] || policy.globalPath; } if (VideoWallpaper.active) { DesktopPreferences.set("videoWallpaperPath", ""); VideoWallpaper.stop(); root.pendingStillPolicy = policy; stillAfterVideo.restart(); return true; } return root.applyPolicy(policy, true, false); } function setAssignment(output: string, path: string): bool { if (!root.outputNames().includes(output) || !WallpaperPolicy.validPath(path, root.candidates([]))) return false; const policy = root.currentPolicy(); policy.mode = "per-monitor"; policy.assignments = Object.assign({}, policy.assignments); policy.assignments[output] = path; return root.applyPolicy(policy, true, false); } function toggleSlideshowPath(path: string): bool { if (!WallpaperPolicy.validPath(path, root.candidates([]))) return false; const policy = root.currentPolicy(); const collection = WallpaperPolicy.validCollection(policy.collection, root.candidates([])); policy.collection = collection.includes(path) ? collection.filter(candidate => candidate !== path) : collection.concat([path]); policy.mode = "slideshow"; if (!policy.collection.includes(policy.slideshowPath)) policy.slideshowPath = policy.collection[0] || policy.globalPath; return root.applyPolicy(policy, true, false); } function setIntervalMinutes(minutes: int): bool { const value = PreferenceSchema.coerce("wallpaperIntervalMinutes", minutes); return value !== undefined && DesktopPreferences.set("wallpaperIntervalMinutes", value); } function setShuffle(enabled: bool): bool { return DesktopPreferences.set("wallpaperShuffle", enabled === true); } // Compatibility boundary used by backup/reset and the existing picker. function set(path: string): bool { return root.setSingle(path); } function titleFor(path: string): string { const file = String(path).split("/").pop(); return file.replace(/\.[^.]+$/, "").replace(/[_-]+/g, " "); } }