From cc91156f9f564b2a3265ad530116a999ffe2122b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 29 Oct 2026 12:37:47 +0100 Subject: wallpaper: burn the old picture away on Apply swaybg has no transitions: wallp kills the running one and starts a new one, which can show a frame of nothing in between. WallpaperSpread puts a click-through surface on the bottom layer of each screen, above swaybg and below every window, holding the old picture while wallp runs. A shader burns it away over the new one: each pixel's threshold mixes its distance from a random starting point with fractal noise, and a flame and char band runs just ahead of the front. The surface goes once wallp has exited, or at once if it failed, since a failed set may have left either picture. Wallpapers.apply() announces each swap with the old path before wallp runs, while current still names it. Sets from a terminal get no effect: by the time wall_h changes, swaybg has already swapped. The animation is linear, 3.5s: an ease spent its slow ends where nothing was left to burn, and read as well under the 2.6s it was given. The compiled .qsb is committed beside its source, since nothing builds it. AGENTS.md gains the shader cache trap hit on the way: a running shell keeps a shader by URL, so a recompiled .qsb under the same name is ignored. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 ++ desktop/modules/appearance/WallpaperSpread.qml | 116 +++++++++++++++++++++++++ desktop/modules/appearance/Wallpapers.qml | 5 ++ desktop/modules/appearance/burn.frag | 60 +++++++++++++ desktop/modules/appearance/burn.frag.qsb | Bin 0 -> 3983 bytes desktop/shell.qml | 2 + 6 files changed, 189 insertions(+) create mode 100644 desktop/modules/appearance/WallpaperSpread.qml create mode 100644 desktop/modules/appearance/burn.frag create mode 100644 desktop/modules/appearance/burn.frag.qsb diff --git a/AGENTS.md b/AGENTS.md index ca862dd..b7e617e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,6 +270,12 @@ changing that component. The ones that generalise: hl.dsp.focus({ window = "address:0x..." })`. This is why `~/bin/hypr-windows.sh` is written the way it is, and its syntax was misread as legacy oddity worth modernising. +- **A running shell caches a `ShaderEffect` shader by URL.** Recompiling + `burn.frag.qsb` under the same name left the wallpaper transition on the + old shader, which logged `ShaderEffect: 'blocks' does not have a matching + property` for a uniform the new one had dropped, and drew nothing. A hot + reload does not clear it. After changing a shader, rename the `.qsb` or + restart the shell. - **A focus dispatched while an overlay holds the keyboard is accepted and then ignored.** A layer surface with `keyboardFocus: Exclusive` grabs the keyboard, and the compositor will not move window focus out from under that diff --git a/desktop/modules/appearance/WallpaperSpread.qml b/desktop/modules/appearance/WallpaperSpread.qml new file mode 100644 index 0000000..c133343 --- /dev/null +++ b/desktop/modules/appearance/WallpaperSpread.qml @@ -0,0 +1,116 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Wayland +import QtQuick + +// The transition when Apply sets a wallpaper. swaybg has none: wallp kills +// the old one and starts a new one, which can show a frame of nothing. A +// surface on the bottom layer, above swaybg and below every window, covers +// the swap: the old picture burns away over the new one, and the +// surface goes once wallp has finished, by when the new swaybg is drawing +// the same picture underneath. Sets from a terminal get no transition: by +// the time wall_h changes, swaybg has already swapped. +Scope { + // ponytail: wallp's default outputs, like Wallpapers.panels; read + // wallp.conf if they are ever changed there. + Variants { + model: [{ which: "H", output: "DP-1" }, { which: "V", output: "DP-3" }] + + PanelWindow { + id: win + required property var modelData + screen: Quickshell.screens.find(s => s.name === modelData.output) ?? null + + property bool running: false + property bool done: false + property real progress: 0 + property point origin: Qt.point(0.5, 0.5) + visible: running + + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.layer: WlrLayer.Bottom + WlrLayershell.namespace: "keystone-wallpaper-spread" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + Connections { + target: Wallpapers + function onSwapping(which, from, to) { + if (which !== win.modelData.which || !win.screen) return; + anim.stop(); + // Behind the char band, so no pixel starts out scorched. + win.progress = -0.05; + // A different place each time the fire starts. + win.origin = Qt.point(Math.random(), Math.random()); + win.done = false; + oldImg.source = from; + newImg.source = to; + win.running = true; + } + // A failed set may have left either picture underneath, so + // the cover goes at once rather than claiming the new one. + function onApplied(ok) { + win.done = true; + if (!ok) win.running = false; + else if (!anim.running) win.running = false; + } + } + + // Both decoded before the first block drops, or the old one + // would pop in late over a half-drawn screen. + readonly property bool ready: running && oldImg.status === Image.Ready + && newImg.status === Image.Ready + onReadyChanged: if (ready) anim.restart() + + // -m fill is a centred crop, which is what PreserveAspectCrop does. + Image { + id: newImg + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(win.width, win.height) + asynchronous: true + cache: false + } + + Image { + id: oldImg + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(win.width, win.height) + asynchronous: true + cache: false + layer.enabled: win.running + layer.effect: ShaderEffect { + property real progress: win.progress + property real aspect: win.width / Math.max(1, win.height) + property point origin: win.origin + fragmentShader: Qt.resolvedUrl("burn.frag.qsb") + } + } + + NumberAnimation { + id: anim + target: win + property: "progress" + // Past 1: fbm can push a threshold a little over it. Linear, + // since an ease spends its slow ends where nothing burns. + to: 1.05 + duration: 3500 + easing.type: Easing.Linear + onFinished: if (win.done) win.running = false + } + } + } +} diff --git a/desktop/modules/appearance/Wallpapers.qml b/desktop/modules/appearance/Wallpapers.qml index 7d671e1..9e9d4ad 100644 --- a/desktop/modules/appearance/Wallpapers.qml +++ b/desktop/modules/appearance/Wallpapers.qml @@ -57,6 +57,9 @@ Singleton { property bool scanning: true property bool applying: false 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) // Sorted by most recent first: the wallpaper wanted now is usually one // added recently. -maxdepth keeps a stray git checkout from being walked. @@ -225,6 +228,8 @@ Singleton { function apply() { if (applying || !dirty) return; applying = true; + if (pendingH !== "" && currentH !== "") swapping("H", currentH, pendingH); + if (pendingV !== "" && currentV !== "") swapping("V", currentV, pendingV); const args = []; if (pendingH !== "") args.push(`H=${JSON.stringify(pendingH)}`); if (pendingV !== "") args.push(`V=${JSON.stringify(pendingV)}`); diff --git a/desktop/modules/appearance/burn.frag b/desktop/modules/appearance/burn.frag new file mode 100644 index 0000000..a5cb9c3 --- /dev/null +++ b/desktop/modules/appearance/burn.frag @@ -0,0 +1,60 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// The old wallpaper burning away like a sheet of paper. A pixel goes once +// progress passes its threshold: its distance from where the fire started +// mixed with fractal noise, so the front is ragged at every scale. Just ahead +// of the front the paper glows and chars. Compiled with: +// qsb --qt6 -o burn.frag.qsb burn.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; + +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 asp = vec2(aspect, 1.0); + float d = length((qt_TexCoord0 - origin) * asp) / length(max(origin, 1.0 - origin) * asp); + float t = mix(d, fbm(qt_TexCoord0 * asp * 5.0), 0.5); + float e = t - progress; + if (e <= 0.0) { fragColor = vec4(0.0); return; } + + // Front to intact paper: flame, then char, then the picture. + const float w = 0.05; + float k = clamp(e / w, 0.0, 1.0); + vec3 fire = vec3(1.6, 0.75, 0.2); + vec3 ash = vec3(0.07, 0.04, 0.02); + vec3 paper = texture(source, qt_TexCoord0).rgb; + vec3 col = k < 0.3 ? mix(fire, ash, k / 0.3) : mix(ash, paper, smoothstep(0.3, 1.0, k)); + float a = smoothstep(0.0, 0.004, e); + fragColor = vec4(col * a, a) * qt_Opacity; +} diff --git a/desktop/modules/appearance/burn.frag.qsb b/desktop/modules/appearance/burn.frag.qsb new file mode 100644 index 0000000..599a911 Binary files /dev/null and b/desktop/modules/appearance/burn.frag.qsb differ diff --git a/desktop/shell.qml b/desktop/shell.qml index c5e44ad..4444cd1 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -65,6 +65,8 @@ ShellRoot { Component.onCompleted: Status.inhibitWindow = keepalive } + WallpaperSpread {} + Drawer { id: drawer settingsWindow: settings -- cgit v1.2.3