Screenshots and recordings offered three folders to choose between, and three
guesses cannot include the folder somebody's other software already writes to --
which is the only folder that matters. This machine has had ~/Pictures/Screenshots
and ~/Videos/Screencasts since long before Panama, and Panama was writing
recordings to a Videos/Recordings it invented. Both are free text now, and the
recording default is the folder that was already there.
Wallpapers were swept from four directories at once, so the distribution's stock
images arrived mixed in with the user's own and there was no way to ask for just
one. Where wallpapers live is something somebody knows about their own machine.
It is a setting, not a search.
All three accept an absolute path as well as one relative to home, which meant
fixing Capture: it prefixed $HOME unconditionally, so naming /mnt/captures would
have written screenshots to ~/mnt/captures and left nobody able to find them.
The generator turned out to skip any entry whose comment sits inside the braces
rather than above them -- it looks for `key:` immediately after `{`. Three
settings were invisible in the reference because of it, one of them dockScreens,
which has never appeared there at all. The staleness contract could not see it
either: regenerating reproduced the same omission, so the copy was current and
incomplete at once. It now counts what was declared against what it could read
and refuses rather than quietly documenting less than exists.
Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
449 lines
17 KiB
QML
449 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);
|
|
}
|
|
|
|
// 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)
|
|
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, " ");
|
|
}
|
|
}
|