From c8004e400a41c1d6569aeffdb84da907120177aa Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 14:19:23 -0400 Subject: [PATCH] Model wallpaper display policies --- .../quickshell/config/PreferenceSchema.qml | 26 +++++ .../quickshell/services/WallpaperPolicy.js | 97 +++++++++++++++++++ .../quickshell/wallpaper-policy-harness.qml | 58 +++++++++++ tests/quickshell/wallpaper-policy-contract.sh | 82 ++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 config/dot/quickshell/services/WallpaperPolicy.js create mode 100644 config/dot/quickshell/wallpaper-policy-harness.qml create mode 100755 tests/quickshell/wallpaper-policy-contract.sh diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index ac0dbd2..86653e8 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -747,6 +747,32 @@ Singleton { label: "Wallpaper", detail: "Shown on every output" }, + { + key: "wallpaperMode", type: "enum", def: "single", group: "wallpaper", + label: "Wallpaper mode", detail: "Use one image, rotate a collection, or choose per display", + options: [ + { value: "single", label: "Single" }, + { value: "slideshow", label: "Slideshow" }, + { value: "per-monitor", label: "Per display" } + ] + }, + { + key: "wallpaperSlideshowPaths", type: "json", def: ([]), group: "wallpaper", internal: true, + label: "Slideshow collection", detail: "Backgrounds selected for rotation" + }, + { + key: "wallpaperIntervalMinutes", type: "int", def: 30, min: 5, max: 1440, step: 5, + unit: "min", group: "wallpaper", label: "Change background every", + detail: "Time between slideshow images" + }, + { + key: "wallpaperShuffle", type: "bool", def: true, group: "wallpaper", + label: "Shuffle", detail: "Show every selected image before repeating" + }, + { + key: "wallpaperPerMonitor", type: "json", def: ({}), group: "wallpaper", internal: true, + label: "Per-display backgrounds", detail: "Background assigned to each connected display" + }, // ── Lock-screen appearance ───────────────────────────────────────── // scripts/panama-lock validates these again before generating a state diff --git a/config/dot/quickshell/services/WallpaperPolicy.js b/config/dot/quickshell/services/WallpaperPolicy.js new file mode 100644 index 0000000..a7983af --- /dev/null +++ b/config/dot/quickshell/services/WallpaperPolicy.js @@ -0,0 +1,97 @@ +function validPath(path, candidates) { + if (typeof path !== "string" || !/^\/[^,\n]+$/.test(path)) + return false; + return (candidates || []).includes(path); +} + +function validCollection(paths, candidates) { + const result = []; + const seen = {}; + for (const path of paths || []) { + if (!validPath(path, candidates) || seen[path]) + continue; + seen[path] = true; + result.push(path); + } + return result; +} + +function validAssignments(assignments, candidates) { + const result = {}; + if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) + return result; + for (const output of Object.keys(assignments)) { + if (!/^[A-Za-z0-9_.-]+$/.test(output)) + continue; + const path = assignments[output]; + if (validPath(path, candidates)) + result[output] = path; + } + return result; +} + +function effectiveMap(mode, globalPath, slideshowPath, assignments, outputs, candidates) { + const result = {}; + const fallback = validPath(globalPath, candidates) + ? globalPath + : ((candidates || [])[0] || ""); + const selectedAssignments = validAssignments(assignments, candidates); + const slideshow = validPath(slideshowPath, candidates) ? slideshowPath : fallback; + for (const output of outputs || []) { + if (typeof output !== "string" || !/^[A-Za-z0-9_.-]+$/.test(output)) + continue; + if (mode === "per-monitor") + result[output] = selectedAssignments[output] || fallback; + else if (mode === "slideshow") + result[output] = slideshow; + else + result[output] = fallback; + } + return result; +} + +function orderedNext(collection, current) { + const paths = collection || []; + if (paths.length === 0) + return ""; + const index = paths.indexOf(current); + return paths[(index + 1 + paths.length) % paths.length]; +} + +function shuffledBag(collection, random) { + const result = Array.from(collection || []); + const nextRandom = typeof random === "function" ? random : Math.random; + for (let index = result.length - 1; index > 0; index--) { + const swap = Math.floor(Math.max(0, Math.min(0.999999999, nextRandom())) * (index + 1)); + const value = result[index]; + result[index] = result[swap]; + result[swap] = value; + } + return result; +} + +function shuffledNext(collection, bag, current, random) { + const paths = Array.from(collection || []); + if (paths.length === 0) + return { path: "", bag: [] }; + + const remaining = []; + const seen = {}; + for (const path of bag || []) { + if (!paths.includes(path) || seen[path]) + continue; + seen[path] = true; + remaining.push(path); + } + if (remaining.length === 0) + remaining.push(...shuffledBag(paths, random)); + + if (remaining.length > 1 && remaining[0] === current) { + const replacement = remaining.findIndex(path => path !== current); + const value = remaining[0]; + remaining[0] = remaining[replacement]; + remaining[replacement] = value; + } + + return { path: remaining[0], bag: remaining.slice(1) }; +} diff --git a/config/dot/quickshell/wallpaper-policy-harness.qml b/config/dot/quickshell/wallpaper-policy-harness.qml new file mode 100644 index 0000000..f0c536a --- /dev/null +++ b/config/dot/quickshell/wallpaper-policy-harness.qml @@ -0,0 +1,58 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import "services/WallpaperPolicy.js" as WallpaperPolicy + +ShellRoot { + readonly property var candidates: ["/images/a.jpg", "/images/b.jpg", "/images/c.jpg"] + readonly property var outputs: ["DP-2", "HDMI-A-1"] + + IpcHandler { + target: "wallpaper-policy-test" + + function status(): string { + const collection = WallpaperPolicy.validCollection([ + "/images/a.jpg", "/invalid.jpg", "/images/b.jpg", + "/images/a.jpg", "relative.jpg", "/images/c.jpg" + ], candidates); + const assignments = WallpaperPolicy.validAssignments({ + "DP-2": "/images/b.jpg", + "HDMI-A-1": "/invalid.jpg", + "bad connector!": "/images/c.jpg" + }, candidates); + return JSON.stringify({ + validAbsolute: WallpaperPolicy.validPath("/images/a.jpg", candidates), + invalidComma: WallpaperPolicy.validPath("/images/a,b.jpg", candidates), + invalidUnknown: WallpaperPolicy.validPath("/images/nope.jpg", candidates), + collection, + assignments, + single: WallpaperPolicy.effectiveMap( + "single", "/images/a.jpg", "", assignments, outputs, candidates), + perMonitor: WallpaperPolicy.effectiveMap( + "per-monitor", "/images/a.jpg", "", assignments, outputs, candidates), + slideshow: WallpaperPolicy.effectiveMap( + "slideshow", "/images/a.jpg", "/images/c.jpg", assignments, outputs, candidates), + ordered: [ + WallpaperPolicy.orderedNext(collection, "/images/a.jpg"), + WallpaperPolicy.orderedNext(collection, "/images/b.jpg"), + WallpaperPolicy.orderedNext(collection, "/images/c.jpg") + ] + }); + } + + function shuffle(): string { + const values = [0.8, 0.1, 0.6, 0.2, 0.9, 0.4]; + let index = 0; + const random = () => values[index++ % values.length]; + let state = { bag: [], current: "" }; + const emitted = []; + for (let count = 0; count < 4; count++) { + state = WallpaperPolicy.shuffledNext(candidates, state.bag, state.current, random); + emitted.push(state.path); + state.current = state.path; + } + return JSON.stringify({ emitted, remaining: state.bag }); + } + } +} diff --git a/tests/quickshell/wallpaper-policy-contract.sh b/tests/quickshell/wallpaper-policy-contract.sh new file mode 100755 index 0000000..ba6f14c --- /dev/null +++ b/tests/quickshell/wallpaper-policy-contract.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/wallpaper-policy-harness.qml" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" +state_home="$(mktemp -d /tmp/panama-wallpaper-policy-state.XXXXXX)" +shell_log="$state_home/quickshell.log" +harness_pid="" + +fail() { + printf 'wallpaper policy contract: %s\n' "$1" >&2 + [[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2 + exit 1 +} + +instances_for_harness() { + qs list --all 2>/dev/null | awk -v expected="$harness" ' + /^Instance / { pid = "" } + /^[[:space:]]*Process ID:/ { pid = $3 } + /^[[:space:]]*Config path:/ { + path = $0 + sub(/^[[:space:]]*Config path: /, "", path) + if (path == expected && pid ~ /^[0-9]+$/) print pid + } + ' +} + +cleanup() { + if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then + kill "$harness_pid" 2>/dev/null || true + fi + rm -rf "$state_home" +} +trap cleanup EXIT + +for key in wallpaperMode wallpaperSlideshowPaths wallpaperIntervalMinutes wallpaperShuffle wallpaperPerMonitor; do + rg -Fq "key: \"$key\"" "$schema" || fail "schema is missing $key" +done + +qs_for_harness() { + if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then + XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}" + else + XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" + fi +} + +qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'isolated policy harness did not launch' +for _ in $(seq 1 60); do + harness_pid="$(instances_for_harness | head -1)" + if [[ "$harness_pid" =~ ^[0-9]+$ ]] \ + && qs_for_harness ipc show 2>/dev/null | rg -q '^target wallpaper-policy-test$'; then + break + fi + sleep 0.1 +done +[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'isolated policy harness process did not start' + +status="$(qs_for_harness ipc call wallpaper-policy-test status)" +jq -e ' + .validAbsolute == true and .invalidComma == false and .invalidUnknown == false + and .collection == ["/images/a.jpg","/images/b.jpg","/images/c.jpg"] + and .assignments == {"DP-2":"/images/b.jpg"} + and .single == {"DP-2":"/images/a.jpg","HDMI-A-1":"/images/a.jpg"} + and .perMonitor == {"DP-2":"/images/b.jpg","HDMI-A-1":"/images/a.jpg"} + and .slideshow == {"DP-2":"/images/c.jpg","HDMI-A-1":"/images/c.jpg"} + and .ordered == ["/images/b.jpg","/images/c.jpg","/images/a.jpg"] +' <<<"$status" >/dev/null || fail "policy result was wrong: $status" + +shuffle="$(qs_for_harness ipc call wallpaper-policy-test shuffle)" +jq -e ' + (.emitted[0:3] | sort) == ["/images/a.jpg","/images/b.jpg","/images/c.jpg"] + and .emitted[3] != .emitted[2] +' <<<"$shuffle" >/dev/null || fail "shuffle repeated before exhausting its bag: $shuffle" + +if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then + fail 'policy harness emitted a QML runtime warning' +fi + +printf 'wallpaper policy contract: PASS\n'