From 72b9087d39f04b81767aa316e2792737de323b5f Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 29 Oct 2026 13:04:04 +0100 Subject: plan: wallpaper engine Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-29-wallpaper-engine.md | 803 +++++++++++++++++++++ 1 file changed, 803 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-29-wallpaper-engine.md (limited to 'docs/superpowers') diff --git a/docs/superpowers/plans/2026-10-29-wallpaper-engine.md b/docs/superpowers/plans/2026-10-29-wallpaper-engine.md new file mode 100644 index 0000000..b5e03ad --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-wallpaper-engine.md @@ -0,0 +1,803 @@ +# Wallpaper Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Change both wallpapers on a timer from per-screen hearted lists, with a light accent run for timer changes and a home wallpaper restored on switch-off. + +**Architecture:** A `WallEngine` singleton in `desktop/modules/appearance/` owns the timer, the shuffle and the homes, and sets wallpapers through a refactored `Wallpapers.run(h, v, kind)`, the same path Apply uses, so the burn transition plays. Its settings and lists live in `Prefs` (`desktop.json`). `udt-accent` skips GRUB, homepage and SDDM when `UDT_ROTATION` is set, which `wallp` passes through untouched. + +**Tech Stack:** Quickshell 0.3.1 QML, QML JS libraries tested under node, Python 3 (`udt-accent`), Hyprland Lua config. + +**Spec:** `docs/superpowers/specs/2026-10-29-wallpaper-engine-design.md` + +**Repos:** `quickshell` (this one), `../unified-desktop-theme` (Task 1), `../conky-theme-udt` (Task 8, it owns `hypr/dashboard.lua`, which `~/.config/hypr/sections/dashboard.lua` links to). + +**Rules from AGENTS.md that apply:** every new source file carries the GPLv2 header; no home paths in committed files; never run `./reload.sh` or a detached `qs` from a tool call; `pkill -x qs`, never `-f`; QML JS has no `flatMap`, `Object.fromEntries` or `matchAll`; check `qs log -p desktop --log-times` against file mtimes after each QML edit; a new file in `modules/appearance/` needs a `desktop/shell.qml` change or a restart to be seen; changing `shell.qml` usually needs `./reload.sh desktop`, run by the user. + +--- + +## File map + +| File | Change | +| --- | --- | +| `../unified-desktop-theme/bin/udt-accent` | `UDT_ROTATION` gate in `main`, selftest case | +| `desktop/modules/appearance/Shuffle.js` | new: shuffle bag `draw()` | +| `desktop/modules/appearance/test_shuffle.js` | new: node check | +| `desktop/Prefs.qml` | `wallEngine` object and `setWallEngine()` | +| `desktop/modules/appearance/Wallpapers.qml` | `run(h, v, kind)`, `finished`, `manualApplied` | +| `desktop/modules/appearance/WallpaperSpread.qml` | hide on `finished` instead of `applied` | +| `desktop/modules/appearance/WallEngine.qml` | new singleton: timer, homes, lists | +| `desktop/modules/appearance/WallpaperTab.qml` | the heart on each thumbnail | +| `desktop/modules/appearance/WallEngineModule.qml` | new settings page | +| `desktop/modules/appearance/WallEngineTab.qml` | new page body | +| `desktop/shell.qml` | register `WallEngineModule` | +| `desktop/modules/appearance/README.md` | engine section | +| `../conky-theme-udt/hypr/dashboard.lua` | `silent` on the workspace rule | + +--- + +### Task 1: udt-accent light run + +**Files:** +- Modify: `../unified-desktop-theme/bin/udt-accent` (`main`, around lines 629-665; `selftest`, before `print("selftest OK")`) + +- [ ] **Step 1: Write the failing selftest case** + +Insert before `print("selftest OK")` in `selftest()`: + +```python + # UDT_ROTATION is the wallpaper engine's light run: GRUB, the homepage and + # SDDM and their helpers are skipped, everything else still runs. Every + # writer and both process calls are swapped for recorders, so nothing on + # disk is touched and no helper starts. + every = ("write_accent", "write_border", "write_hyprlock_border", + "write_qml", "write_sublime", "write_sddm", "write_colors_json", + "write_firefox", "write_grub", "write_homepage", "write_conky") + helpers = {"udt-homepage", "udt-grub", "udt-keyboard", "udt-ledstrip", + "udt-bulb", "udt-tv"} + calls = [] + + class FakeSubprocess: + @staticmethod + def run(*a, **k): + pass + + @staticmethod + def Popen(cmd, **k): + calls.append(Path(cmd[0]).name) + + saved, saved_env = dict(globals()), os.environ.get("UDT_ROTATION") + try: + for fn in every: + globals()[fn] = (lambda n: lambda *a: calls.append(n))(fn) + globals()["signature_color"] = lambda image: None + globals()["subprocess"] = FakeSubprocess + skipped = {"write_sddm", "write_grub", "write_homepage", + "udt-grub", "udt-homepage"} + for env, want in (("", set(every) | helpers), + ("1", (set(every) | helpers) - skipped)): + calls.clear() + os.environ["UDT_ROTATION"] = env + main("unused.png") + assert set(calls) == want, (env, sorted(set(calls) ^ want)) + finally: + globals().update(saved) + if saved_env is None: + os.environ.pop("UDT_ROTATION", None) + else: + os.environ["UDT_ROTATION"] = saved_env +``` + +- [ ] **Step 2: Run it, expect failure** + +Run: `cd ../unified-desktop-theme && bin/udt-accent --selftest` +Expected: `AssertionError: ('1', ['udt-grub', 'udt-homepage', 'write_grub', 'write_homepage', 'write_sddm'])` + +- [ ] **Step 3: Gate main** + +Above `def main`, add: + +```python +# Set by keystone's wallpaper engine on its timer changes. They recolour the +# live desktop and leave the boot and login surfaces alone: nobody sees GRUB, +# the homepage or SDDM mid-session, and GRUB costs a rebuild and the homepage +# an scp on every change. A manual change is a full run and brings them level. +LIGHT_SKIPS_HELPERS = ("udt-homepage", "udt-grub") +``` + +In `main`, first line of the body: `light = bool(os.environ.get("UDT_ROTATION"))`. Replace `write_sddm(name, image)` with: + +```python + if not light: + write_sddm(name, image) +``` + +Replace the two lines `write_grub(name)` / `write_homepage(name)` (keep the comment above them) with: + +```python + if not light: + write_grub(name) + write_homepage(name) +``` + +In the helper loop, as the loop body's first line: + +```python + if light and helper in LIGHT_SKIPS_HELPERS: + continue +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `bin/udt-accent --selftest` +Expected: `selftest OK` + +- [ ] **Step 5: Document and commit (unified-desktop-theme)** + +Add `UDT_ROTATION=1` to the module docstring's usage notes: + +``` +With UDT_ROTATION set and non-empty (keystone's wallpaper engine), GRUB, the +homepage and SDDM are left alone, and udt-grub and udt-homepage do not start. +``` + +```bash +git add bin/udt-accent +git commit -m "accent: UDT_ROTATION, a light run for timed wallpaper changes" -m "" +``` + +--- + +### Task 2: Shuffle bag + +**Files:** +- Create: `desktop/modules/appearance/Shuffle.js` +- Test: `desktop/modules/appearance/test_shuffle.js` + +- [ ] **Step 1: Write the test** (GPL header as in `desktop/modules/fonts/test_fonts.js`) + +```js +// Run: node desktop/modules/appearance/test_shuffle.js +// Shuffle.js is a QML library, so its .pragma line is stripped before node +// evaluates it. + +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/Shuffle.js", "utf8").replace(/^\.pragma library$/m, ""); +const S = new Function(src + "\nreturn { draw };")(); + +// Every round shows each path once, and no path twice running, across the +// refill included. +const list = ["a", "b", "c", "d"]; +let bag = [], last = ""; +for (let r = 0; r < 200; r++) { + const round = []; + for (let i = 0; i < list.length; i++) { + const d = S.draw(list, bag, last); + assert.notStrictEqual(d.pick, last, `round ${r}: ${d.pick} twice running`); + round.push(d.pick); + bag = d.bag; + last = d.pick; + } + assert.deepStrictEqual(round.slice().sort(), list); +} + +// A path removed from the list leaves the bag with it. +assert.deepStrictEqual(S.draw(["a", "b"], ["c", "b"], "a"), { pick: "b", bag: [] }); +// One path is all there is, every time. +assert.strictEqual(S.draw(["a"], [], "a").pick, "a"); +// Nothing listed, nothing picked. +assert.deepStrictEqual(S.draw([], [], "a"), { pick: "", bag: [] }); + +console.log("test_shuffle OK"); +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `node desktop/modules/appearance/test_shuffle.js` +Expected: `ENOENT` for `Shuffle.js`. + +- [ ] **Step 3: Implement** (`.pragma library` first line, then the GPL header) + +```js +// The wallpaper engine's order, tested by test_shuffle.js under node: a +// shuffle bag. Each round shows every path once in random order, and a new +// round never opens with the path on screen, so a reshuffle cannot show the +// same picture twice running. + +// The next path and what is left of the round. `list` is what may be shown +// now, so a heart removed since the last draw drops out of the bag; `last` is +// the path on screen. pick is "" for an empty list. +function draw(list, bag, last) { + let left = bag.filter(p => list.indexOf(p) >= 0); + if (left.length === 0) left = shuffled(list, last); + return { pick: left.length ? left[0] : "", bag: left.slice(1) }; +} + +function shuffled(list, last) { + const a = list.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = a[i]; a[i] = a[j]; a[j] = t; + } + if (a.length > 1 && a[0] === last) { const t = a[0]; a[0] = a[1]; a[1] = t; } + return a; +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `node desktop/modules/appearance/test_shuffle.js` +Expected: `test_shuffle OK` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/appearance/Shuffle.js desktop/modules/appearance/test_shuffle.js +git commit -m "wallpaper: a shuffle bag for the engine's order" +``` + +--- + +### Task 3: Prefs carries the engine + +**Files:** +- Modify: `desktop/Prefs.qml` + +- [ ] **Step 1: Property and setter**, after `settingsSidebar`: + +```qml + // The wallpaper engine (modules/appearance/WallEngine.qml): on or off, + // the interval, a list of paths per screen, and each screen's home, the + // wallpaper switching off returns to. In here rather than in list files: + // the lists can be empty, and an empty FileView write writes nothing. + readonly property var wallEngineDefaults: ({ on: false, minutes: 30, h: [], v: [], homeH: "", homeV: "" }) + property var wallEngine: wallEngineDefaults + function setWallEngine(changes) { + const w = Object.assign({}, root.wallEngine, changes); + root.wallEngine = w; + root.write(Object.assign({}, root.doc, { wallEngine: w })); + } +``` + +- [ ] **Step 2: Parse it**, at the end of `reparse()`: + +```qml + const we = parsed.wallEngine && typeof parsed.wallEngine === "object" ? parsed.wallEngine : {}; + const paths = a => Array.isArray(a) ? a.filter(s => typeof s === "string" && s !== "") : []; + const str = s => typeof s === "string" ? s : ""; + root.wallEngine = { + on: we.on === true, + minutes: Number.isInteger(we.minutes) && we.minutes >= 1 ? we.minutes : 30, + h: paths(we.h), v: paths(we.v), + homeH: str(we.homeH), homeV: str(we.homeV), + }; +``` + +and in `onLoadFailed` add `root.wallEngine = root.wallEngineDefaults;`. + +- [ ] **Step 3: Check the reload** + +Run: `qs log -p desktop --log-times | tail -5` +Expected: a `Configuration Loaded` after the file's mtime, no new warning naming `Prefs.qml`. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/Prefs.qml +git commit -m "prefs: the wallpaper engine's settings, lists and homes" +``` + +--- + +### Task 4: Wallpapers.run, one path for every set + +**Files:** +- Modify: `desktop/modules/appearance/Wallpapers.qml` (signals near `applied`; `apply()` and `applyProc` at the end) +- Modify: `desktop/modules/appearance/WallpaperSpread.qml` (the `Connections`) + +- [ ] **Step 1: Signals**, replacing the `swapping` block added by the burn commit: + +```qml + signal applied(bool ok, string message) + // Sent before wallp runs, while current still names the old picture, so + // WallpaperSpread can cover the swap with it. + signal swapping(string which, string from, string to) + // Every run's end, Apply's or the engine's. applied is Apply's alone: it + // reports to the save bar and closes the window. + signal finished(bool ok) + // A successful Apply, with what it set ("" for a screen it left). + signal manualApplied(string h, string v) +``` + +- [ ] **Step 2: Replace `apply()` and `applyProc`** with: + +```qml + // wallp does the work, including running udt-accent, so the accent follows + // the new wallpaper exactly as it does from the command line. Both screens + // go in one call: wallp accepts H= and V= together. + function apply() { + if (dirty) run(pendingH, pendingV, "manual"); + } + + // What started the run in flight: "manual" (Apply), "tick" (the engine's + // timer) or "home" (the engine switched off). A tick is the light run, + // UDT_ROTATION, which udt-accent reads; the others are full. + property string kind: "" + + // false when nothing was started: a run in flight, or nothing to set. + function run(h, v, kind) { + if (applying || (h === "" && v === "")) return false; + applying = true; + root.kind = kind; + if (h !== "" && currentH !== "" && h !== currentH) swapping("H", currentH, h); + if (v !== "" && currentV !== "" && v !== currentV) swapping("V", currentV, v); + const args = []; + if (h !== "") args.push(`H=${JSON.stringify(h)}`); + if (v !== "") args.push(`V=${JSON.stringify(v)}`); + const env = kind === "tick" ? "UDT_ROTATION=1 " : ""; + applyProc.command = ["sh", "-c", `${env}wallp --set ${args.join(" ")} >/dev/null 2>&1`]; + applyProc.running = true; + return true; + } + + Process { + id: applyProc + stderr: StdioCollector { id: applyErr } + onExited: code => { + const ok = code === 0; + root.applying = false; + if (root.kind === "manual") { + if (ok) { + root.manualApplied(root.pendingH, root.pendingV); + root.clearPending(); + } + root.applied(ok, ok ? "" : (applyErr.text.trim() || `wallp exited ${code}`)); + } else if (!ok) { + console.warn(`Wallpapers: the engine's ${root.kind} set failed, wallp exited ${code}`); + } + root.finished(ok); + } + } +``` + +- [ ] **Step 3: WallpaperSpread hides on any run's end.** In `WallpaperSpread.qml` replace `function onApplied(ok) {` with `function onFinished(ok) {`, and the comment above it with: + +```qml + // Any run's end, Apply's or the engine's. A failed set may + // have left either picture underneath, so the cover goes at + // once rather than claiming the new one. +``` + +- [ ] **Step 4: Check by hand** + +Check the reload in the log as in Task 3. Ask the user to Apply a wallpaper on DP-1: the burn plays, the window closes, the accent changes as before. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/appearance/Wallpapers.qml desktop/modules/appearance/WallpaperSpread.qml +git commit -m "wallpaper: one run() for Apply and the engine, with a finished signal" +``` + +--- + +### Task 5: The engine + +**Files:** +- Create: `desktop/modules/appearance/WallEngine.qml` (GPL header). Not `Rotation`: QtQuick has that type and would shadow it (AGENTS.md, Sprite). + +- [ ] **Step 1: Write it** + +```qml +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; } + // 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(); + } + } + + function setMinutes(m) { + if (Number.isInteger(m) && m >= 1 && m !== cfg.minutes) Prefs.setWallEngine({ minutes: m }); + } + + // 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(cfg.h.filter(present), bagH, Wallpapers.currentH); + const v = Shuffle.draw(cfg.v.filter(present), 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(); + timer.restart(); + } + + // 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.tick() + } + + 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 }); + timer.restart(); + } + function onFinished() { + if (!root.homeWaiting || root.on) return; + root.homeWaiting = false; + root.goHome(); + } + } +} +``` + +- [ ] **Step 2: Commit** (exercised once the page references it, Task 7) + +```bash +git add desktop/modules/appearance/WallEngine.qml +git commit -m "wallpaper: the engine, a timer over the hearted lists with a home per screen" +``` + +--- + +### Task 6: The heart + +**Files:** +- Modify: `desktop/modules/appearance/WallpaperTab.qml` (thumbnail delegate, after the `Column` holding the `Image` and label, inside the bordered `Rectangle`) + +- [ ] **Step 1: Add it**, as the Rectangle's last child: + +```qml + // In the engine's list for the screen the + // picker targets (WallEngine.qml). Last, so it + // is above the pick MouseArea and takes its own + // click. Outlined, so it reads on a light + // image too. + Text { + readonly property bool on: WallEngine.listed(modelData, Wallpapers.target) + anchors { top: parent.top; right: parent.right; margins: 10 } + visible: on || thumbHover.hovered + // nf-md-heart, nf-md-heart_outline. + text: on ? "\u{F02D1}" : "\u{F02D5}" + color: on ? Theme.red : Theme.text + style: Text.Outline + styleColor: Qt.alpha(Theme.base, 0.6) + font { family: Theme.iconFamily; pixelSize: Theme.fontSize + 8 } + MouseArea { + anchors { fill: parent; margins: -4 } + cursorShape: Qt.PointingHandCursor + onClicked: WallEngine.toggle(modelData, Wallpapers.target) + } + } +``` + +- [ ] **Step 2: Check the reload, then by hand** + +Log as in Task 3. Ask the user: hovering a thumbnail shows an outlined heart; clicking fills it red; switching the picker to Vertical shows that screen's hearts instead. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/appearance/WallpaperTab.qml +git commit -m "wallpaper: the heart is back, for the engine's per-screen lists" +``` + +--- + +### Task 7: The engine's page + +**Files:** +- Create: `desktop/modules/appearance/WallEngineModule.qml`, `desktop/modules/appearance/WallEngineTab.qml` (GPL headers) +- Modify: `desktop/shell.qml` (modules list, after `WallpaperModule {},`) + +- [ ] **Step 1: The module** + +```qml +import QtQuick +import "../.." + +// The wallpaper engine's page: on or off, the interval, and the two lists. +Module { + name: "wallengine" + label: "Wallpaper engine" + // nf-fa-shuffle, present in Inconsolata Nerd Font. + icon: "" + settings: true + group: "Appearance" + keywords: ["wallpaper", "timer", "rotate", "rotation", "shuffle", "slideshow", "heart"] + + // A singleton is built on first use, and the engine's timer has to run + // from the start of the session, page opened or not. Modules exist for + // the whole session, so this reference is what starts it. + readonly property bool engineOn: WallEngine.on + + page: Component { + Page { + title: "Wallpaper engine" + WallEngineTab { width: parent.width } + } + } +} +``` + +- [ ] **Step 2: The page body** + +```qml +import QtQuick +import "../.." + +Item { + implicitHeight: col.implicitHeight + + Column { + id: col + width: parent.width + spacing: 14 + + Row { + spacing: 8 + Toggle { + anchors.verticalCenter: parent.verticalCenter + checked: WallEngine.on + label: "Change the wallpapers every" + onToggled: WallEngine.setOn(checked) + } + Field { + anchors.verticalCenter: parent.verticalCenter + width: 50 + value: String(WallEngine.cfg.minutes) + onEdited: { + WallEngine.setMinutes(Number(value)); + value = String(WallEngine.cfg.minutes); + } + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: "minutes" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Tab { + text: "Next" + enabled: WallEngine.on && !Wallpapers.applying + onClicked: if (enabled) WallEngine.next() + } + } + + Text { + width: col.width + wrapMode: Text.Wrap + text: "Timer changes recolour the desktop and leave GRUB, the homepage and the login screen alone. " + + "Switching off puts back each screen's home wallpaper, the one last applied by hand, with a full run." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Repeater { + model: [{ which: "H", name: "Horizontal, DP-1" }, { which: "V", name: "Vertical, DP-3" }] + + Column { + id: screen + required property var modelData + readonly property var paths: WallEngine.list(modelData.which) + readonly property string home: modelData.which === "V" ? WallEngine.cfg.homeV : WallEngine.cfg.homeH + width: col.width + spacing: 8 + + Text { + text: `${screen.modelData.name}: ${screen.paths.length} hearted` + + (WallEngine.on && screen.home !== "" ? `, home ${Wallpapers.label(screen.home)}` : "") + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + + Text { + visible: screen.paths.length === 0 + text: "Heart a wallpaper on the Wallpaper page, with this screen selected, to add it." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Flow { + width: col.width + spacing: 8 + + Repeater { + model: screen.paths + + // Dimmed when the gallery's scan did not find it: the + // timer skips it. + Rectangle { + required property string modelData + width: 192; height: 108 + 20 + radius: 6 + color: Qt.alpha(Theme.surface, 0.3) + opacity: WallEngine.present(modelData) ? 1 : 0.4 + clip: true + + Image { + id: thumb + anchors { top: parent.top; left: parent.left; right: parent.right; margins: 4 } + height: 100 + source: "file://" + parent.modelData + asynchronous: true + cache: false + fillMode: Image.PreserveAspectCrop + sourceSize.width: 192 + } + Text { + anchors { top: thumb.bottom; left: parent.left; right: parent.right; margins: 4 } + elide: Text.ElideMiddle + text: Wallpapers.label(parent.modelData) + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 6 } + } + // nf-fa-xmark: out of the list. + Text { + anchors { top: parent.top; right: parent.right; margins: 8 } + text: "" + color: Theme.text + style: Text.Outline + styleColor: Qt.alpha(Theme.base, 0.6) + font { family: Theme.iconFamily; pixelSize: Theme.fontSize } + MouseArea { + anchors { fill: parent; margins: -4 } + cursorShape: Qt.PointingHandCursor + onClicked: WallEngine.toggle(parent.parent.modelData, screen.modelData.which) + } + } + } + } + } + } + } + } +} +``` + +- [ ] **Step 3: Register it** in `desktop/shell.qml`, after `WallpaperModule {},`: + +```qml + WallEngineModule {}, +``` + +- [ ] **Step 4: Load check** + +`shell.qml` changed, so expect a restart: ask the user to run `./reload.sh desktop`, then read `qs log -p desktop --log-times | tail -20` for errors naming the new files. + +- [ ] **Step 5: Check by hand, with the user** + +1. Heart two wallpapers for H and one for V. The page lists them with counts. +2. Set the interval to 1, switch on. Within a minute both screens burn to a hearted image; `cat ~/.config/wallp/wall_h` names it; `git -C ../grub-theme-udt diff --stat` and `git -C ../unified-desktop-theme diff --stat templates/homepage` show no new change from the tick. +3. Next changes at once. +4. Apply a wallpaper by hand: the page shows it as home. +5. Switch off: the screens burn back to the home wallpapers, and GRUB and the homepage take its accent. +6. Restart with the engine on (`./reload.sh desktop`): it keeps ticking. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/appearance/WallEngineModule.qml desktop/modules/appearance/WallEngineTab.qml desktop/shell.qml +git commit -m "wallpaper: the engine's settings page" +``` + +--- + +### Task 8: Conky stays on its special workspace + +**Files:** +- Modify: `../conky-theme-udt/hypr/dashboard.lua` (`dash-workspace` rule) + +- [ ] **Step 1: Make the rule silent** + +```lua +-- silent: conky reloads conky.conf in place on every accent change and maps +-- a new window doing it, and without silent the rule then shows the special +-- workspace over whatever was on screen. The exec rule in autostart only +-- covers the first window. +hl.window_rule({ + name = "dash-workspace", + match = { class = "conky-dash" }, + workspace = WS .. " silent", +}) +``` + +- [ ] **Step 2: Validate and apply** + +Run: `hyprctl eval 'hl.window_rule({ name = "dash-workspace-probe", match = { class = "conky-dash-probe" }, workspace = "special:dash silent" })'; echo $?` +Expected: `0`. Then `hyprctl reload` and `hyprctl configerrors` (empty). + +- [ ] **Step 3: Check by hand** + +Ask the user to change the wallpaper (any way): the dashboard no longer comes up. + +- [ ] **Step 4: Commit (conky-theme-udt)** + +```bash +git add hypr/dashboard.lua +git commit -m "hypr: dash-workspace rule silent, conky's reload no longer shows the dashboard" +``` + +--- + +### Task 9: Docs + +**Files:** +- Modify: `desktop/modules/appearance/README.md`, the spec + +- [ ] **Step 1: README section** "Wallpaper engine": what it does, where its state is (`wallEngine` in `$XDG_STATE_HOME/keystone/desktop.json`), the light run and `UDT_ROTATION`, homes and switch-off, `node desktop/modules/appearance/test_shuffle.js`. + +- [ ] **Step 2: Spec**: lists in `Prefs`, not list files, because an empty `FileView` write writes nothing; a page of its own in the Appearance group instead of a section, so the picker keeps its height. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/appearance/README.md docs/superpowers/specs/2026-10-29-wallpaper-engine-design.md +git commit -m "docs: the wallpaper engine" +``` -- cgit v1.2.3