441 lines
17 KiB
QML
441 lines
17 KiB
QML
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);
|
|
}
|
|
|
|
readonly property var searchRoots: [
|
|
`${Quickshell.env("HOME")}/Pictures/Wallpapers`,
|
|
`${Quickshell.env("HOME")}/Pictures/Backgrounds`,
|
|
`${Quickshell.env("HOME")}/.local/share/backgrounds`,
|
|
"/usr/share/backgrounds"
|
|
]
|
|
|
|
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)
|
|
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",
|
|
globalPath: 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 {
|
|
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([]);
|
|
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;
|
|
return root.applyPolicy(policy, true, false);
|
|
}
|
|
|
|
function setMode(mode: string): bool {
|
|
if (!["single", "slideshow", "per-monitor"].includes(mode))
|
|
return false;
|
|
const policy = root.currentPolicy();
|
|
policy.mode = mode;
|
|
if (mode === "slideshow") {
|
|
const collection = WallpaperPolicy.validCollection(
|
|
policy.collection, root.candidates([]));
|
|
if (!collection.includes(policy.slideshowPath))
|
|
policy.slideshowPath = collection[0] || policy.globalPath;
|
|
}
|
|
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, " ");
|
|
}
|
|
}
|