diff options
| author | Danilo M. <danix@danix.xyz> | 2026-11-06 17:42:18 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-11-06 17:42:18 +0100 |
| commit | 187682aa4bd212632204d23706c5cb317028b5d3 (patch) | |
| tree | ab30ae7394eedbfa007a2f7e9a2fb3ed1a55266b /docs | |
| parent | 2ae4deaf7ac420ad5871e3957fb027a856ce7edf (diff) | |
| download | quickshell-187682aa4bd212632204d23706c5cb317028b5d3.tar.gz quickshell-187682aa4bd212632204d23706c5cb317028b5d3.zip | |
wallpaper: choose a transition, by name or at random
A named pick plays as it is; random draws from the ticked pool and skips
what played last while anything else is there. Saved values are checked
here too, so a stale or hand-edited name lands on something playable. A
JS library rather than QML, to test it under node as Shuffle.js is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-11-06-wallpaper-transitions.md | 947 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md | 122 |
2 files changed, 1069 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-11-06-wallpaper-transitions.md b/docs/superpowers/plans/2026-11-06-wallpaper-transitions.md new file mode 100644 index 0000000..f4de6c2 --- /dev/null +++ b/docs/superpowers/plans/2026-11-06-wallpaper-transitions.md @@ -0,0 +1,947 @@ +# Wallpaper Transitions 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:** Six new wallpaper transitions beside the burn, chosen on a settings page with a preview per tile, plus a Random option drawing from ticked transitions. + +**Architecture:** One `.frag` per transition, run through a `WallShader` wrapper shared by the cover and the page's thumbnails. A `Transitions` singleton holds the choice and its draft, saved in `Prefs`, and `Wallpapers.run()` asks it for one name per run and passes it through `swapping`. + +**Tech Stack:** Quickshell 0.3.1 QML, GLSL 440 compiled with `qsb`, QML JS library tested under node. + +**Spec:** `docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md` + +**Rules from AGENTS.md that apply:** every new source file starts with the GPLv2 header, copied from lines 1-10 of `desktop/modules/appearance/burn.frag` (code blocks below omit it, `.js` and `.qml` and `.frag` all use the same `//` form); 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`; a new file in `modules/appearance/` needs a `desktop/shell.qml` change or a restart to be seen; a `shell.qml` edit and a recompiled `burn.frag.qsb` both need `./reload.sh desktop`, run by the user. Commits are GPG-signed. + +All paths below are relative to the repo root. `A` means `desktop/modules/appearance`. + +--- + +## File map + +| File | Change | +| --- | --- | +| `A/TransitionPick.js` | new: `choose()`, `clean()` | +| `A/test_transitions.js` | new: node check | +| `desktop/Prefs.qml` | `wallTransition`, `setWallTransition()` | +| `A/Transitions.qml` | new singleton: list, choice, draft, `next()` | +| `A/WallShader.qml` | new: the shared `ShaderEffect` | +| `A/burn.frag`, `A/burn.frag.qsb` | `progress` remapped to a plain 0 to 1 | +| `A/Wallpapers.qml` | `swapping` gains the effect, `run()` asks for it | +| `A/WallpaperSpread.qml` | plays the named effect through `WallShader` | +| `A/{ink,shatter,ripple,melt,blinds,crosswarp}.frag` and `.qsb` | new shaders | +| `A/TransitionTile.qml`, `A/TransitionsTab.qml`, `A/TransitionsModule.qml` | new: the page | +| `desktop/shell.qml` | lists the module | +| `A/README.md`, `AGENTS.md` | documentation | + +--- + +### Task 1: The draw and the validation + +**Files:** +- Create: `A/TransitionPick.js` +- Test: `A/test_transitions.js` + +- [ ] **Step 1: Write the failing test** + +`A/test_transitions.js`: + +```js +// Run: node desktop/modules/appearance/test_transitions.js +// TransitionPick.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 + "/TransitionPick.js", "utf8").replace(/^\.pragma library$/m, ""); +const T = new Function(src + "\nreturn { choose, clean };")(); + +// A named pick is returned as it is, whatever the pool holds. +assert.strictEqual(T.choose("melt", [], "melt"), "melt"); + +// Random stays in the pool and never plays the same one twice running. +const pool = ["ink", "melt", "blinds"]; +let last = ""; +const seen = {}; +for (let i = 0; i < 500; i++) { + const n = T.choose("random", pool, last); + assert.ok(pool.indexOf(n) >= 0, `${n} is not in the pool`); + assert.notStrictEqual(n, last, `${n} twice running`); + seen[n] = true; + last = n; +} +assert.strictEqual(Object.keys(seen).length, 3); + +// A pool of one has to repeat, and an empty one falls back. +assert.strictEqual(T.choose("random", ["ink"], "ink"), "ink"); +assert.strictEqual(T.choose("random", [], "ink"), "burn"); + +// Saved values: missing, wrong and unknown all land on something playable, +// and the pool comes back in the list's order. +const names = ["burn", "ink", "melt"]; +assert.deepStrictEqual(T.clean(null, names), { pick: "burn", pool: names }); +assert.deepStrictEqual(T.clean({ pick: "gone", pool: "x" }, names), { pick: "burn", pool: names }); +assert.deepStrictEqual(T.clean({ pick: "random", pool: ["melt", "gone", "burn"] }, names), + { pick: "random", pool: ["burn", "melt"] }); +assert.deepStrictEqual(T.clean({ pick: "ink", pool: [] }, names), { pick: "ink", pool: [] }); + +console.log("ok"); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `node desktop/modules/appearance/test_transitions.js` +Expected: FAIL with `ENOENT` on `TransitionPick.js` + +- [ ] **Step 3: Write the implementation** + +`A/TransitionPick.js`: + +```js +.pragma library + +// Which transition plays, tested by test_transitions.js under node. + +// The name for a run. `pick` is a name or "random"; `pool` is what random may +// draw; `last` is what played last, skipped while there is anything else. +function choose(pick, pool, last) { + if (pick !== "random") return pick; + const from = pool.length > 1 ? pool.filter(n => n !== last) : pool; + return from.length ? from[Math.floor(Math.random() * from.length)] : "burn"; +} + +// What was saved, made safe against `names`. Nothing saved ticks everything; +// a pool is kept in the order of `names`, so two can be compared as strings. +function clean(saved, names) { + const s = saved && typeof saved === "object" ? saved : {}; + return { + pick: s.pick === "random" || names.indexOf(s.pick) >= 0 ? s.pick : "burn", + pool: Array.isArray(s.pool) ? names.filter(n => s.pool.indexOf(n) >= 0) : names, + }; +} +``` + +Note `.pragma library` stays on line 1, with the GPL header after it, as `Shuffle.js` does. + +- [ ] **Step 4: Run it to verify it passes** + +Run: `node desktop/modules/appearance/test_transitions.js` +Expected: `ok` + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md docs/superpowers/plans/2026-11-06-wallpaper-transitions.md desktop/modules/appearance/TransitionPick.js desktop/modules/appearance/test_transitions.js +git commit -m "wallpaper: choose a transition, by name or at random" +``` + +--- + +### Task 2: Saved choice + +**Files:** +- Modify: `desktop/Prefs.qml` + +- [ ] **Step 1: Add the property and setter** + +After `setWallEngine()`: + +```qml + // The wallpaper transition as saved, or null for the defaults + // (modules/appearance/TransitionPick.js, which also validates what is + // read here). + property var wallTransition: null + function setWallTransition(t) { + root.wallTransition = t; + root.write(Object.assign({}, root.doc, { wallTransition: t })); + } +``` + +- [ ] **Step 2: Read it in `reparse()`** + +At the end of `reparse()`: + +```qml + // Only on a real change: any page's save rereads this file, and a new + // object here would throw away the transitions page's draft. + const wt = parsed.wallTransition !== undefined ? parsed.wallTransition : null; + if (JSON.stringify(wt) !== JSON.stringify(root.wallTransition)) root.wallTransition = wt; +``` + +- [ ] **Step 3: Reset it in `onLoadFailed`** + +Append `root.wallTransition = null;` inside the braces of `onLoadFailed`. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/Prefs.qml +git commit -m "prefs: keep the wallpaper transition" +``` + +--- + +### Task 3: The singleton + +**Files:** +- Create: `A/Transitions.qml` + +- [ ] **Step 1: Write it** + +```qml +pragma Singleton + +import Quickshell +import QtQuick +import "../.." +import "TransitionPick.js" as TransitionPick + +// Which transition covers a wallpaper change. The page edits the draft, Apply +// saves it, and each run asks next() once, so both screens play the same one. +// See the spec, docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md. +Singleton { + id: root + + readonly property var all: [ + { name: "burn", label: "Burn" }, + { name: "ink", label: "Ink" }, + { name: "shatter", label: "Shatter" }, + { name: "ripple", label: "Ripple" }, + { name: "melt", label: "Melt" }, + { name: "blinds", label: "Blinds" }, + { name: "crosswarp", label: "Cross-warp" }, + ] + readonly property var names: all.map(t => t.name) + readonly property int duration: 3500 + + readonly property var saved: TransitionPick.clean(Prefs.wallTransition, names) + property string draftPick: saved.pick + property var draftPool: saved.pool + onSavedChanged: reset() + + readonly property bool dirty: draftPick !== saved.pick + || JSON.stringify(draftPool) !== JSON.stringify(saved.pool) + readonly property bool valid: draftPick !== "random" || draftPool.length > 0 + + function toggle(name) { + const on = draftPool.indexOf(name) >= 0; + draftPool = names.filter(n => n === name ? !on : draftPool.indexOf(n) >= 0); + } + function reset() { + draftPick = saved.pick; + draftPool = saved.pool; + } + function apply() { + if (valid) Prefs.setWallTransition({ pick: draftPick, pool: draftPool }); + } + + property string last: "" + function next() { + last = TransitionPick.choose(saved.pick, saved.pool, last); + return last; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add desktop/modules/appearance/Transitions.qml +git commit -m "wallpaper: a singleton for the chosen transition" +``` + +--- + +### Task 4: The cover plays a named effect + +Burn is still the only shader that exists after this task, and it is what `next()` returns by default, so behaviour does not change. + +**Files:** +- Create: `A/WallShader.qml` +- Modify: `A/burn.frag`, `A/burn.frag.qsb`, `A/Wallpapers.qml`, `A/WallpaperSpread.qml` + +- [ ] **Step 1: Write `WallShader.qml`** + +```qml +import QtQuick + +// One transition, by name, for the cover and the settings page's thumbnails. +// Goes in the old picture's layer.effect, which fills in source. Every shader +// draws the old picture at progress 0 and the new one at 1, and picks what it +// needs from these properties. target must be an item with a layer: an Image +// sampled directly gives the whole picture, not its PreserveAspectCrop crop. +ShaderEffect { + property string name: "burn" + property var source + property var target + property real progress: 0 + property real aspect: 1 + property point origin: Qt.point(0.5, 0.5) + fragmentShader: Qt.resolvedUrl(name + ".frag.qsb") +} +``` + +- [ ] **Step 2: Remap `burn.frag`** + +Replace the two lines: + +```glsl + float t = mix(d, fbm(qt_TexCoord0 * asp * 5.0), 0.5); + float e = t - progress; +``` + +with: + +```glsl + float t = mix(d, fbm(qt_TexCoord0 * asp * 5.0), 0.5); + // Starts behind the char band, so no pixel starts out scorched, and ends + // past 1, since fbm can push a threshold a little over it. + float e = t - (progress * 1.1 - 0.05); +``` + +Recompile: `cd desktop/modules/appearance && /usr/lib64/qt6/bin/qsb --qt6 -o burn.frag.qsb burn.frag` +Expected: exit 0, no output. + +- [ ] **Step 3: Pass the effect through `Wallpapers.qml`** + +Change the signal at line 62: + +```qml + signal swapping(string which, string from, string to, string effect) +``` + +In `run()`, replace the `let covers` line and the two `swapping` lines: + +```qml + let covers = 0; + const effect = Transitions.next(); + if (h !== "" && currentH !== "" && h !== currentH) { covers++; swapping("H", currentH, h, effect); } + if (v !== "" && currentV !== "" && v !== currentV) { covers++; swapping("V", currentV, v, effect); } +``` + +- [ ] **Step 4: Update `WallpaperSpread.qml`** + +Header comment: replace `the old picture burns away over the new one` with `the old picture gives way to the new one, through the transition Transitions picked`. + +Add beside `property real progress: 0`: + +```qml + property string effect: "burn" +``` + +Replace `onSwapping`: + +```qml + function onSwapping(which, from, to, effect) { + if (which !== win.modelData.which || !win.screen) return; + anim.stop(); + win.effect = effect; + win.progress = 0; + // A different place each time it starts. + win.origin = Qt.point(Math.random(), Math.random()); + win.done = false; + oldImg.source = from; + newImg.source = to; + win.running = true; + } +``` + +On `newImg`, add after `cache: false`: + +```qml + // A layer, so the shaders that read it get the crop. + layer.enabled: win.running +``` + +On `oldImg`, replace the `layer.effect` block: + +```qml + layer.effect: WallShader { + name: win.effect + target: newImg + progress: win.progress + aspect: win.width / Math.max(1, win.height) + origin: win.origin + } +``` + +Replace the animation's comment and bounds: + +```qml + // Linear, since an ease spends its slow ends where nothing + // happens. + to: 1 + duration: Transitions.duration +``` + +- [ ] **Step 5: Smoke check** + +Start a scratch shell so the harness owns it (`run_in_background`, `timeout 8 qs -p desktop/`, not detached) only if no `desktop` shell is running; otherwise rely on the running shell's reload. Then: + +Run: `qs log -p desktop --log-times | tail -30` +Expected: no `WallShader`, `Transitions` or `ShaderEffect` warning. + +- [ ] **Step 6: Commit** + +```bash +cd desktop/modules/appearance +git add WallShader.qml burn.frag burn.frag.qsb Wallpapers.qml WallpaperSpread.qml +git commit -m "wallpaper: play the transition by name" +``` + +--- + +### Task 5: Six shaders + +**Files:** +- Create: `A/ink.frag`, `A/shatter.frag`, `A/ripple.frag`, `A/melt.frag`, `A/blinds.frag`, `A/crosswarp.frag` and their `.qsb` + +Each must draw the old picture exactly at `progress` 0 and the new picture exactly at 1. Each block below goes after the GPL header. + +- [ ] **Step 1: `ink.frag`** + +```glsl +// The old wallpaper dissolving like ink in water: a soft edge through noise +// warped by more noise, the picture smearing as it goes. Compiled with: +// qsb --qt6 -o ink.frag.qsb ink.frag +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float progress; + float aspect; +}; +layout(binding = 1) uniform sampler2D source; + +float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } + +float noise(vec2 p) { + vec2 i = floor(p), f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y); +} + +float fbm(vec2 p) { + float v = 0.0, a = 0.5; + for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; } + return v; +} + +void main() { + vec2 p = qt_TexCoord0 * vec2(aspect, 1.0); + vec2 warp = vec2(fbm(p * 3.0 + progress), fbm(p * 3.0 + 7.3 - progress)); + // fbm sits around 0.2 to 0.8; stretched and clamped, so the edge below + // sweeps past every pixel. + float t = clamp((fbm(p * 2.0 + warp * 1.5) - 0.2) / 0.6, 0.0, 1.0); + + const float w = 0.15; + float a = smoothstep(0.0, w, t - mix(-w, 1.0, progress)); + vec2 uv = qt_TexCoord0 + (warp - 0.5) * 0.04 * (1.0 - a); + fragColor = vec4(texture(source, uv).rgb * a, a) * qt_Opacity; +} +``` + +- [ ] **Step 2: `shatter.frag`** + +```glsl +// The old wallpaper breaking into Voronoi shards that shrink, slide away from +// where the blow landed and fade, each on its own delay. A shard only moves +// inside the room its shrinking leaves, so a pixel needs one cell lookup to +// find it, rather than a search of the neighbours. Compiled with: +// qsb --qt6 -o shatter.frag.qsb shatter.frag +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float progress; + float aspect; + vec2 origin; +}; +layout(binding = 1) uniform sampler2D source; + +vec2 hash2(vec2 p) { + return fract(sin(vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)))) * 43758.5453); +} + +// The nearest seed to p: its cell in xy, its position in zw. +vec4 cell(vec2 p) { + vec2 i = floor(p), f = fract(p); + float best = 9.0; + vec4 r = vec4(0.0); + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 g = vec2(float(x), float(y)); + vec2 s = g + hash2(i + g); + float d = dot(s - f, s - f); + if (d < best) { best = d; r = vec4(i + g, i + s); } + } + } + return r; +} + +void main() { + const float n = 6.0; + vec2 asp = vec2(aspect, 1.0); + vec2 p = qt_TexCoord0 * asp * n; + vec4 c = cell(p); + vec2 seed = c.zw; + + vec2 away = seed / n - origin * asp; + float d = clamp(length(away) / length(max(origin, 1.0 - origin) * asp), 0.0, 1.0); + float l = clamp((progress - 0.6 * mix(d, hash2(c.xy).x, 0.3)) / 0.4, 0.0, 1.0); + + float s = 1.0 - 0.85 * l; + vec2 q = seed + (p - seed - normalize(away + 1e-4) * (1.0 - s) * 0.4) / s; + if (cell(q).xy != c.xy) { fragColor = vec4(0.0); return; } + + float a = 1.0 - smoothstep(0.4, 1.0, l); + fragColor = vec4(texture(source, q / (asp * n)).rgb * a, a) * qt_Opacity; +} +``` + +- [ ] **Step 3: `ripple.frag`** + +```glsl +// A ring spreading from a random point, bending both pictures as it passes, +// the new one behind it. Opaque throughout. Compiled with: +// qsb --qt6 -o ripple.frag.qsb ripple.frag +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float progress; + float aspect; + vec2 origin; +}; +layout(binding = 1) uniform sampler2D source; +layout(binding = 2) uniform sampler2D target; + +void main() { + vec2 asp = vec2(aspect, 1.0); + vec2 v = (qt_TexCoord0 - origin) * asp; + float d = length(v) / length(max(origin, 1.0 - origin) * asp); + + // e runs -1 to 1 across the ring. It starts wholly outside the picture + // and ends wholly past it, so neither end is bent. + const float w = 0.12; + float e = (d - mix(-w, 1.0 + w, progress)) / w; + float ring = 1.0 - smoothstep(0.0, 1.0, abs(e)); + vec2 off = v / max(length(v), 1e-4) / asp * sin(e * 3.14159) * ring * 0.03; + + vec2 uv = qt_TexCoord0 + off; + vec3 col = mix(texture(target, uv).rgb, texture(source, uv).rgb, smoothstep(-0.6, 0.6, e)); + fragColor = vec4(col, 1.0) * qt_Opacity; +} +``` + +- [ ] **Step 4: `melt.frag`** + +```glsl +// The old wallpaper running down the screen like wet paint, each column +// starting late by its noise and accelerating. Compiled with: +// qsb --qt6 -o melt.frag.qsb melt.frag +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float progress; + float aspect; +}; +layout(binding = 1) uniform sampler2D source; + +float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } + +float noise(vec2 p) { + vec2 i = floor(p), f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y); +} + +void main() { + float x = qt_TexCoord0.x * aspect; + float delay = 0.25 * (noise(vec2(x * 40.0, 0.0)) + noise(vec2(x * 9.0, 3.0))); + float l = clamp((progress - delay) / 0.5, 0.0, 1.0); + + // Past 1, so the bottom row clears the screen. + vec2 uv = vec2(qt_TexCoord0.x, qt_TexCoord0.y - l * l * 1.02); + float a = smoothstep(-0.004, 0.0, uv.y); + fragColor = vec4(texture(source, uv).rgb * a, a) * qt_Opacity; +} +``` + +- [ ] **Step 5: `blinds.frag`** + +```glsl +// Vertical slats turning edge-on in a wave from a random column, darkening as +// they turn, with the new wallpaper between them. Compiled with: +// qsb --qt6 -o blinds.frag.qsb blinds.frag +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float progress; + vec2 origin; +}; +layout(binding = 1) uniform sampler2D source; + +void main() { + const float n = 16.0; + float slat = floor(qt_TexCoord0.x * n); + float f = fract(qt_TexCoord0.x * n) - 0.5; + + float wave = abs((slat + 0.5) / n - origin.x) / max(origin.x, 1.0 - origin.x); + float l = clamp((progress - 0.6 * min(wave, 1.0)) / 0.4, 0.0, 1.0); + + float half_ = 0.5 * cos(l * 1.5708); + if (l >= 1.0 || abs(f) > half_) { fragColor = vec4(0.0); return; } + + vec2 uv = vec2((slat + 0.5 + f * 0.5 / half_) / n, qt_TexCoord0.y); + fragColor = vec4(texture(source, uv).rgb * mix(1.0, 0.55, l), 1.0) * qt_Opacity; +} +``` + +- [ ] **Step 6: `crosswarp.frag`** + +```glsl +// Both pictures pulled away from a random point by their own brightness, +// flowing into each other: the old one stretches as it leaves, the new one +// settles as it arrives. Opaque throughout. Compiled with: +// qsb --qt6 -o crosswarp.frag.qsb crosswarp.frag +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float progress; + vec2 origin; +}; +layout(binding = 1) uniform sampler2D source; +layout(binding = 2) uniform sampler2D target; + +float luma(vec3 c) { return dot(c, vec3(0.299, 0.587, 0.114)); } + +void main() { + vec2 uv = qt_TexCoord0; + vec2 dir = uv - origin; + float x = smoothstep(0.0, 1.0, progress); + vec2 ua = uv + dir * luma(texture(source, uv).rgb) * x * 0.3; + vec2 ub = uv - dir * luma(texture(target, uv).rgb) * (1.0 - x) * 0.3; + fragColor = vec4(mix(texture(source, ua).rgb, texture(target, ub).rgb, x), 1.0) * qt_Opacity; +} +``` + +- [ ] **Step 7: Compile all six** + +Run: + +```bash +cd desktop/modules/appearance +for f in ink shatter ripple melt blinds crosswarp; do /usr/lib64/qt6/bin/qsb --qt6 -o $f.frag.qsb $f.frag || echo "FAILED $f"; done +``` + +Expected: no `FAILED` line. + +- [ ] **Step 8: Commit** + +```bash +cd desktop/modules/appearance +git add ink.frag* shatter.frag* ripple.frag* melt.frag* blinds.frag* crosswarp.frag* +git commit -m "wallpaper: six more transitions" +``` + +--- + +### Task 6: The page + +**Files:** +- Create: `A/TransitionTile.qml`, `A/TransitionsTab.qml`, `A/TransitionsModule.qml` +- Modify: `desktop/shell.qml` + +- [ ] **Step 1: `TransitionTile.qml`** + +```qml +import QtQuick +import "../.." + +// One choice on the transitions page. A click selects it in the draft and +// plays it once between the two current wallpapers. The tick puts it in +// Random's pool. The tile named "random" has no shader and no tick. +Item { + id: tile + required property string name + required property string label + readonly property bool random: name === "random" + readonly property bool selected: Transitions.draftPick === name + readonly property bool shows: !random && Wallpapers.currentH !== "" && Wallpapers.currentV !== "" + property real progress: 0 + property point origin: Qt.point(0.5, 0.5) + + implicitHeight: thumb.height + 30 + + Glow { + anchors.fill: thumb + on: tile.selected + } + + Rectangle { + id: thumb + width: tile.width + height: Math.round(width * 9 / 16) + color: Theme.surface + clip: true + + // Same layering as the cover, WallpaperSpread.qml. ponytail: rests on + // the old picture again after playing, with a cut. + Image { + id: newImg + visible: tile.shows + anchors.fill: parent + source: tile.shows ? Wallpapers.currentV : "" + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(thumb.width, thumb.height) + asynchronous: true + layer.enabled: tile.shows + } + Image { + id: oldImg + visible: tile.shows + anchors.fill: parent + source: tile.shows ? Wallpapers.currentH : "" + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(thumb.width, thumb.height) + asynchronous: true + layer.enabled: tile.shows + layer.effect: WallShader { + name: tile.name + target: newImg + progress: tile.progress + aspect: thumb.width / Math.max(1, thumb.height) + origin: tile.origin + } + } + + Text { + anchors.centerIn: parent + visible: tile.random + // nf-fa-shuffle, present in Inconsolata Nerd Font. + text: "" + color: Theme.subtext + font { family: Theme.iconFamily; pixelSize: Theme.fontSize * 2 } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + Transitions.draftPick = tile.name; + if (!tile.shows) return; + tile.origin = Qt.point(Math.random(), Math.random()); + play.restart(); + } + } + } + + NumberAnimation { + id: play + target: tile + property: "progress" + from: 0 + to: 1 + duration: Transitions.duration + onFinished: tile.progress = 0 + } + + Toggle { + anchors { top: thumb.bottom; topMargin: 6; left: parent.left } + visible: !tile.random + label: tile.label + // Toggle assigns checked itself, which would break a plain binding + // and leave the tick stale after Reset. + Binding on checked { value: Transitions.draftPool.indexOf(tile.name) >= 0 } + onToggled: Transitions.toggle(tile.name) + } + + Text { + anchors { top: thumb.bottom; topMargin: 8; left: parent.left } + visible: tile.random + text: tile.label + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } +} +``` + +- [ ] **Step 2: `TransitionsTab.qml`** + +```qml +import QtQuick +import "../.." + +Item { + id: tab + implicitHeight: col.implicitHeight + + readonly property int gap: 14 + readonly property int columns: Math.max(1, Math.floor((width + gap) / 260)) + readonly property real tileWidth: (width - (columns - 1) * gap) / columns + + Column { + id: col + width: parent.width + spacing: 14 + + Text { + width: col.width + wrapMode: Text.Wrap + text: "Click a transition to see it between your two current wallpapers. " + + "Random draws from the ticked ones, and never the same one twice running." + color: Theme.overlay + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Grid { + columns: tab.columns + spacing: tab.gap + + Repeater { + model: Transitions.all + TransitionTile { + required property var modelData + width: tab.tileWidth + name: modelData.name + label: modelData.label + } + } + TransitionTile { + width: tab.tileWidth + name: "random" + label: "Random" + } + } + } +} +``` + +- [ ] **Step 3: `TransitionsModule.qml`** + +```qml +import QtQuick +import "../.." + +// The transitions page: which effect covers a wallpaper change. +Module { + name: "transitions" + label: "Transitions" + // nf-fa-magic, present in Inconsolata Nerd Font. + icon: "" + settings: true + dirty: Transitions.dirty + group: "Appearance" + keywords: ["wallpaper", "transition", "effect", "burn", "ink", "shatter", "ripple", "melt", "blinds", "random"] + + page: Component { + Page { + title: "Transitions" + actions: Component { + SaveBar { + dirty: Transitions.dirty + valid: Transitions.valid + note: "tick at least one transition for Random" + saveText: "Apply" + discardText: "Reset" + onSave: Transitions.apply() + onDiscard: Transitions.reset() + } + } + TransitionsTab { width: parent.width } + } + } +} +``` + +- [ ] **Step 4: List it in `desktop/shell.qml`** + +After `WallEngineModule {},` add: + +```qml + TransitionsModule {}, +``` + +- [ ] **Step 5: Smoke check with a fresh process** + +`pgrep -cx qs` before; if the desktop shell runs, a second `qs -p desktop` would double its windows, so load from the running shell's log after the reload instead: + +Run: `qs log -p desktop --log-times | tail -30` +Expected: a `Reloading configuration` line after the `shell.qml` mtime with no `Transition` warning. AGENTS.md warns a `shell.qml` edit may report a type as missing until restart: if so, this check waits for the user's `./reload.sh desktop`. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/shell.qml desktop/modules/appearance/TransitionTile.qml desktop/modules/appearance/TransitionsTab.qml desktop/modules/appearance/TransitionsModule.qml +git commit -m "wallpaper: a page to preview and pick the transition" +``` + +--- + +### Task 7: Documentation + +**Files:** +- Modify: `A/README.md:159-172`, `AGENTS.md:16` + +- [ ] **Step 1: README** + +Replace the `### The burn` heading with `### Transitions`, and its first sentence with: + +```markdown +Apply, and every engine change, plays a transition over the swap, the old +picture giving way to the new one (`WallpaperSpread.qml`, `WallShader.qml`, +one `.frag` each: burn, ink, shatter, ripple, melt, blinds, crosswarp). The +Transitions page picks one, or Random, which draws from the ticked ones and +never repeats the last. Clicking a tile plays it between the two current +wallpapers through the same shader. Both screens play the same one in a run. +Each shader must draw the old picture exactly at `progress` 0 and the new one +at 1, or the cover pops as it comes and goes. Pictures reach a shader through +a layer, since an `Image` sampled directly gives the whole picture and not its +crop. +``` + +In the rest of the section change `before the burn` to `before the transition`, `gets no burn` to `gets no transition`, and the closing sentence to: `After editing a `.frag`, recompile with `qsb --qt6 -o <name>.frag.qsb <name>.frag` under a new name or restart the shell: a running shell keeps a shader by URL.` + +- [ ] **Step 2: AGENTS.md** + +In the layout block, change `window for pages set once: wallpaper, theme, theme status, icons,` to `window for pages set once: wallpaper, transitions, theme, theme status, icons,`, rewrapping if it passes the block's width. In the note on the shader cache, leave `burn.frag.qsb` as the example. + +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md desktop/modules/appearance/README.md +git commit -m "docs: wallpaper transitions" +``` + +--- + +### Task 8: User check + +The motion cannot be judged from here. Ask the user to run `./reload.sh desktop`, then: + +1. Settings > Transitions shows eight tiles, Burn glowing, all ticks on, Apply disabled. +2. Clicking each tile plays it, and the header reads unsaved changes. +3. Reset restores the selection and ticks. +4. Select Random, untick all: Apply is held back with the note. Tick two, Apply, then change wallpaper a few times: only those two play, alternating. +5. Pick each transition in turn, Apply, change wallpaper: no pop at the start or the end, on either screen, on DP-3 in portrait too. +6. Saving another page with unsaved transitions keeps the draft. + +Tune `w`, `n` and the delays in the `.frag` files from what is reported. Recompiling under the same name needs a restart. diff --git a/docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md b/docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md new file mode 100644 index 0000000..7a9d4b7 --- /dev/null +++ b/docs/superpowers/specs/2026-11-06-wallpaper-transitions-design.md @@ -0,0 +1,122 @@ +# Wallpaper transitions + +Apply and the wallpaper engine play one effect today, the burn. This adds six +more, a settings page to preview and choose between them, and a Random option +that draws from a chosen subset. + +## Transitions + +`burn`, `ink`, `shatter`, `ripple`, `melt`, `blinds`, `crosswarp`. + +| Name | Effect | +| --- | --- | +| burn | the existing effect, unchanged to the eye | +| ink | old picture dissolves through warped noise, soft edge | +| shatter | Voronoi cells crack outward from `origin`, each sliding and fading on its own delay | +| ripple | expanding ring refracts the picture, new one revealed behind it | +| melt | columns drip down at noise-driven speeds | +| blinds | slats rotate shut in a wave across the screen | +| crosswarp | both pictures stretch along their brightness and flow into each other | + +## Shaders + +One `.frag` and one compiled `.qsb` per transition, in +`desktop/modules/appearance/`, following `burn.frag`. An uber-shader with a +`mode` uniform was rejected: every effect would pay for every branch, and one +error would take out all seven. + +Each shader declares what it uses out of `progress`, `aspect`, `origin`, +`source` (old picture) and `target` (new picture). The wrapper's properties +are a superset; a property with no matching uniform is harmless, the reverse +is not. + +Every shader must meet two conditions, because the cover appears over and +leaves over swaybg: + +- at `progress` 0 it draws the old picture exactly; +- at `progress` 1 it draws the new picture exactly, by full transparency over + `newImg` or by sampling `target`. + +`progress` runs 0 to 1 for all of them. `burn.frag` remaps it internally, with +`progress * 1.1 - 0.05`, to keep the range it was tuned for, and the cover's +`-0.05` start and `1.05` end go away. + +## WallShader.qml + +One `ShaderEffect` wrapper with `name`, `source`, `target`, `progress`, +`aspect` and `origin`, resolving `name` to its `.qsb`. Both the cover and the +thumbnails use it, so a preview runs the real shader. It is not called +`Transition`: QtQuick has that type and it would shadow the file. + +In `WallpaperSpread.qml` it replaces the `ShaderEffect` in `oldImg`'s +`layer.effect`. `newImg` gains `layer.enabled` so it can be passed as `target`. +Both pictures must reach the shader through a layer: an `Image` used directly +as a texture gives the whole picture, not the `PreserveAspectCrop` crop, and +the cover would no longer line up with swaybg. + +## Transitions.qml + +A singleton beside `WallEngine.qml`, holding: + +- `all`, the seven names with their labels; +- `duration`, 3500ms, used by the cover and the thumbnails; +- `pick`, a transition name or `"random"`, and `pool`, the ticked names, both + saved; +- `draftPick`, `draftPool`, `dirty`, `valid`, `apply()`, `reset()`; +- `next()`, returning the name to play for a run. + +`next()` returns `pick` unless it is `"random"`, in which case it draws from +`pool`, excluding the last name played when the pool holds two or more. The +draw and the validation of saved values live in `TransitionPick.js` beside +it, as `Shuffle.js` does, so they can be tested outside `qs`. + +Saved through a new `Prefs.setWallTransition()`, under `wallTransition` in +`desktop.json`, default `{ pick: "burn", pool: <all seven> }`, so nothing +changes until the user chooses. An unknown name read from the file is dropped +from `pool`, and an unknown `pick` falls back to `burn`. `next()` falls back +to `burn` if the pool ends up empty. + +## Settings page + +`TransitionsModule.qml`, `TransitionsTab.qml` and `TransitionTile.qml`, in the Appearance group, +listed in `shell.qml` after `WallEngineModule`. A grid of seven tiles plus a +Random tile. + +- Clicking a tile selects it in the draft and plays it once in its thumbnail, + then it rests. Clicking it again replays it. Random selects without playing. +- Each of the seven tiles carries a tick, which edits the draft's pool. Ticks + stay available whatever is selected. +- `SaveBar` sits in the header through `Page.actions`, with `saveText` Apply + and `discardText` Reset. +- `valid` is false when Random is selected with nothing ticked, and the note + says so. +- A thumbnail plays `Wallpapers.currentH` into `Wallpapers.currentV`, so no + gallery scan is needed. It uses a random `origin` per play. With either path + empty the tile is flat and still selectable. + +## Runtime + +`Wallpapers.run()` calls `Transitions.next()` once per run and passes the name +as a fourth argument of `swapping`, so both screens play the same effect. +`WallpaperSpread` sets it on its `WallShader`. Apply and the engine share +`run()`, so both follow the choice. Sets from a terminal still get no +transition. + +## Left out + +A duration setting, a choice per screen, and a full-screen try button. + +## Checks + +- All seven `.frag` compile with `qsb --qt6`. +- `test_transitions.js` covers the draw: the pool is respected, no immediate + repeat with two or more, a pool of one repeats, an empty pool gives `burn`. +- A fresh `qs -p desktop` loads with no new warnings. +- The motion itself is judged by eye by the user. A `shell.qml` edit needs + `./reload.sh desktop` from the user's terminal. + +## Documentation + +`desktop/modules/appearance/README.md`: "The burn" becomes a section on +transitions, and the page is described. `AGENTS.md`: the settings window's +page list gains transitions. |
