1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
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 "<why, as in the comment above LIGHT_SKIPS_HELPERS>"
```
---
### 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"
```
|