1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-25 04:25:22 -04:00

shader-based scrolling wallpaper mode (#1802)

* feat: parallax-scroll wallpaper

Add a `Scrolling` wallpaper fill mode that translates the wallpaper crop
with the active workspace — like Android home-screen parallax, but along
niri's vertical workspace axis.

The image is scaled to cover the screen on its non-scroll axis, and the
active workspace index drives a fractional offset into the cropped
overflow along the scroll axis. Scroll position is spring-animated
CPU-side and handed to a minimal single-texture shader as a UV offset.
Per-monitor scroll position is published into SessionData so the lock
screen renders the same crop as the active workspace, keeping visual
continuity across lock/unlock.

Two implementation details worth calling out for review:

- QSG_USE_SIMPLE_ANIMATION_DRIVER=1 is exported to the spawned quickshell
  process. The default animation driver advances in fixed ~16ms steps,
  capping the scroll at 60Hz and desyncing it from compositor motion on
  high-refresh displays; the simple driver advances by real elapsed time,
  restoring native-refresh pacing. Removing it visibly regresses to 60Hz.

- The wallpaper survives wl_output rebind cycles (e.g. OLED image-cleaning
  on DPMS soft-off), which otherwise leave a stuck or void background.
  Recovery re-anchors the scroll target on output-lifecycle signals,
  rebuilds the ShaderEffect against the current render context, and
  re-attaches the wallpaper-layer surface on unlock for parallax-active
  monitors — guarded against lock state so the shader gets reliable frame
  hints.

* simplify bindings and gate lock screen shader in a loader

---------

Co-authored-by: bbedward <bbedward@gmail.com>
This commit is contained in:
hecate cantus
2026-06-23 21:14:42 -07:00
committed by GitHub
parent aea5189abb
commit 1a39b7f66c
10 changed files with 590 additions and 20 deletions
+414 -3
View File
@@ -7,7 +7,11 @@ import qs.Widgets
import qs.Services
Variants {
id: variants
readonly property var log: Log.scoped("WallpaperBackground")
// An entry present in PanelWindow.onCompleted means we're recreating
// after a wl_output rebind, not at initial startup.
property var _seenScreens: ({})
model: {
if (SessionData.isGreeterMode) {
return Quickshell.screens;
@@ -59,6 +63,15 @@ Variants {
property string actualTransitionType: transitionType
property bool isInitialized: false
property string scrollMode: SettingsData.wallpaperFillMode
property bool scrollingEnabled: scrollMode === "Scrolling"
property int currentWorkspaceIndex: 0
property int totalWorkspaces: 1
// Also requires the image to overflow on the compositor's scroll
// axis — niri scrolls Y, Hyprland scrolls X — otherwise the
// currentWallpaper Fill fallback handles it.
property bool effectiveScrolling: scrollingEnabled && totalWorkspaces > 1 && (!imageMetrics.ready || (CompositorService.isNiri && imageMetrics.nativeWidth / imageMetrics.nativeHeight < root.textureWidth / root.textureHeight - 0.01) || (CompositorService.isHyprland && imageMetrics.nativeWidth / imageMetrics.nativeHeight > root.textureWidth / root.textureHeight + 0.01))
Connections {
target: SessionData
function onIsLightModeChanged() {
@@ -70,6 +83,27 @@ Variants {
}
}
}
Connections {
target: NiriService
enabled: CompositorService.isNiri && root.scrollingEnabled
function onAllWorkspacesChanged() {
root.updateWorkspaceData();
}
}
Connections {
target: CompositorService.isHyprland ? Hyprland : null
enabled: CompositorService.isHyprland && root.scrollingEnabled
function onRawEvent(event) {
if (event.name === "workspace" || event.name === "workspacev2") {
root.updateWorkspaceData();
}
}
}
onTransitionTypeChanged: {
if (transitionType !== "random") {
actualTransitionType = transitionType;
@@ -96,7 +130,7 @@ Variants {
property string _deferredSource: ""
readonly property bool overviewBlurActive: CompositorService.isNiri && SettingsData.blurWallpaperOnOverview && NiriService.inOverview && currentWallpaper.source !== ""
readonly property var backingWindow: Window.window
readonly property bool renderActive: !source || effectActive || overviewBlurActive || pendingWallpaper !== "" || _deferredSource !== "" || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading
readonly property bool renderActive: !source || effectActive || overviewBlurActive || pendingWallpaper !== "" || _deferredSource !== "" || frameAnim.running || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading
property int _settleFrames: 3
function invalidate() {
@@ -128,6 +162,7 @@ Variants {
target: Quickshell
function onScreensChanged() {
root.invalidate();
root._onOutputRebind();
}
}
@@ -150,6 +185,9 @@ Variants {
if (IdleService.isShellLocked)
return;
root.invalidate();
// Catches silent rebinds during lock that no signal reports.
if (root.effectiveScrolling)
surfaceReattach.restart();
}
}
@@ -161,11 +199,38 @@ Variants {
}
}
// Workspace/scale signals don't re-fire when the output list is
// unchanged across a rebind, so re-derive scroll state by hand.
function _onOutputRebind() {
if (!root.scrollingEnabled)
return;
root.firstScrollUpdate = true;
Qt.callLater(root.updateWorkspaceData);
parallaxLoader.rebuild();
if (root.effectiveScrolling && !IdleService.isShellLocked)
surfaceReattach.restart();
}
// Bouncing visible re-attaches a layer surface wedged by a rebind;
// debounced so a burst of signals yields one re-attach.
Timer {
id: surfaceReattach
interval: 0
repeat: false
onTriggered: {
wallpaperWindow.visible = false;
Qt.callLater(() => {
wallpaperWindow.visible = true;
});
}
}
Connections {
target: NiriService
function onDisplayScalesChanged() {
root._recheckScreenScale();
root.invalidate();
root._onOutputRebind();
}
}
@@ -174,6 +239,7 @@ Variants {
function onWlrOutputAvailableChanged() {
root._recheckScreenScale();
root.invalidate();
root._onOutputRebind();
}
}
@@ -187,6 +253,7 @@ Variants {
} else {
root._recheckScreenScale();
}
root._onOutputRebind();
}
}
@@ -210,6 +277,8 @@ Variants {
function getFillMode(modeName) {
switch (modeName) {
case "Scrolling":
return Image.PreserveAspectCrop;
case "Stretch":
return Image.Stretch;
case "Fit":
@@ -231,8 +300,199 @@ Variants {
}
}
function updateWorkspaceData() {
if (!scrollingEnabled)
return;
let newTargetX = 50.0;
let newTargetY = 50.0;
if (CompositorService.isNiri) {
const outputWorkspaces = NiriService.allWorkspaces.filter(ws => ws.output === modelData.name);
totalWorkspaces = outputWorkspaces.length;
const activeWs = outputWorkspaces.find(ws => ws.is_active);
currentWorkspaceIndex = activeWs ? activeWs.idx : 0;
const scrollPercent = totalWorkspaces > 1 ? ((currentWorkspaceIndex - 1) / (totalWorkspaces - 1)) * 100.0 : 0.0;
newTargetY = scrollPercent;
} else if (CompositorService.isHyprland) {
const workspaces = Hyprland.workspaces?.values || [];
const monitorWorkspaces = workspaces.filter(ws => ws.monitor?.name === modelData.name).sort((a, b) => a.id - b.id);
totalWorkspaces = monitorWorkspaces.length;
const focusedId = Hyprland.focusedWorkspace?.id;
currentWorkspaceIndex = monitorWorkspaces.findIndex(ws => ws.id === focusedId);
if (currentWorkspaceIndex < 0)
currentWorkspaceIndex = 0;
const scrollPercent = totalWorkspaces > 1 ? ((currentWorkspaceIndex - 1) / (totalWorkspaces - 1)) * 100.0 : 0.0;
newTargetX = scrollPercent;
}
scrollAnim.startAnimation(newTargetX, newTargetY);
}
property bool firstScrollUpdate: true
QtObject {
id: scrollAnim
property real startTime: 0
property real startX: 0.0
property real startY: 0.0
property real targetX: 0.0
property real targetY: 0.0
property real damping: CompositorService.isNiri ? 63.25 : 89.44
property real stiffness: CompositorService.isNiri ? 1000.0 : 2000.0
property real mass: 1.0
function springPositionJS(t, from, to) {
if (t <= 0)
return from;
const beta = damping / (2 * mass);
const omega0 = Math.sqrt(stiffness / mass);
const x0 = from - to;
const envelope = Math.exp(-beta * t);
if (Math.abs(x0 * envelope) < 0.01)
return to;
if (Math.abs(beta - omega0) < 0.0001) {
return to + envelope * (x0 + beta * x0 * t);
} else if (beta < omega0) {
const omega1 = Math.sqrt(omega0 * omega0 - beta * beta);
return to + envelope * (x0 * Math.cos(omega1 * t) + (beta * x0 / omega1) * Math.sin(omega1 * t));
} else {
const omega2 = Math.sqrt(beta * beta - omega0 * omega0);
const cosh = x => (Math.exp(x) + Math.exp(-x)) / 2;
const sinh = x => (Math.exp(x) - Math.exp(-x)) / 2;
return to + envelope * (x0 * cosh(omega2 * t) + (beta * x0 / omega2) * sinh(omega2 * t));
}
}
function startAnimation(newTargetX, newTargetY) {
const now = Date.now() / 1000.0;
const t = Math.max(0, frameAnim.currentTime - startTime);
const currentX = springPositionJS(t, startX, targetX);
const currentY = springPositionJS(t, startY, targetY);
if (Math.abs(newTargetX - currentX) < 0.01 && Math.abs(newTargetY - currentY) < 0.01) {
if (root.firstScrollUpdate)
root.firstScrollUpdate = false;
return;
}
// First update: use much stiffer spring for quick snap-to
if (root.firstScrollUpdate) {
root.firstScrollUpdate = false;
damping = 200.0;
stiffness = 8000.0;
} else {
// Restore normal spring parameters
damping = CompositorService.isNiri ? 63.25 : 89.44;
stiffness = CompositorService.isNiri ? 1000.0 : 2000.0;
}
startX = currentX;
startY = currentY;
targetX = newTargetX;
targetY = newTargetY;
startTime = frameAnim.running ? frameAnim.currentTime : now;
if (!frameAnim.running) {
frameAnim.currentTime = now;
frameAnim.running = true;
}
}
}
// CPU-side scroll position - computed once per frame instead of per-pixel in shader
// Initialize at (0, 0) to avoid pillarbox flash; first update will snap to correct position
property real currentScrollX: 0.0
property real currentScrollY: 0.0
function publishScrollPosition() {
if (effectiveScrolling) {
SessionData.setMonitorScrollPosition(modelData.name, currentScrollX, currentScrollY);
} else {
// Not scrolling - publish centered (50, 50)
SessionData.setMonitorScrollPosition(modelData.name, 50, 50);
}
}
FrameAnimation {
id: frameAnim
running: false
property real currentTime: 0
onRunningChanged: {
if (running) {
currentTime = Date.now() / 1000.0;
} else {
root.publishScrollPosition(); // Animation settled
// Hold the render loop open so the final settled frame
// commits before updatesEnabled drops out from under us.
root.invalidate();
}
}
onTriggered: {
// Clamp huge frameTime from a paused-render-loop wakeup;
// otherwise the spring's `t` jumps past settling.
const dt = frameTime > 0.1 ? 0.0 : frameTime;
currentTime += dt;
const t = currentTime - scrollAnim.startTime;
root.currentScrollX = scrollAnim.springPositionJS(t, scrollAnim.startX, scrollAnim.targetX);
root.currentScrollY = scrollAnim.springPositionJS(t, scrollAnim.startY, scrollAnim.targetY);
const settledX = Math.abs(scrollAnim.targetX - root.currentScrollX) < 0.01;
const settledY = Math.abs(scrollAnim.targetY - root.currentScrollY) < 0.01;
if (settledX && settledY) {
running = false;
}
}
}
Component.onCompleted: {
isInitialized = true;
if (scrollingEnabled) {
updateWorkspaceData();
}
Qt.callLater(publishScrollPosition);
// Detect rebind via _seenScreens; schedule surface re-attach
// (deferred to unlock if locked).
const wasSeen = variants._seenScreens[modelData.name] === true;
variants._seenScreens[modelData.name] = true;
// If currently locked, the unlock handler will re-attach;
// otherwise re-attach now.
if (wasSeen && root.effectiveScrolling && !IdleService.isShellLocked) {
surfaceReattach.restart();
}
}
Component.onDestruction: {
SessionData.clearMonitorScrollPosition(modelData.name);
}
onScrollingEnabledChanged: {
if (scrollingEnabled) {
firstScrollUpdate = true;
updateWorkspaceData();
} else {
frameAnim.stop();
}
}
onEffectiveScrollingChanged: {
publishScrollPosition();
}
onSourceChanged: {
@@ -265,6 +525,17 @@ Variants {
root.screenScale = CompositorService.getScreenScale(modelData);
currentWallpaper.source = newSource;
nextWallpaper.source = "";
// Reset scroll state for new image - will snap to correct position on first update
if (scrollingEnabled) {
firstScrollUpdate = true;
currentScrollX = 0.0;
currentScrollY = 0.0;
scrollAnim.startX = 0.0;
scrollAnim.startY = 0.0;
scrollAnim.targetX = 0.0;
scrollAnim.targetY = 0.0;
}
}
function startTransition() {
@@ -301,6 +572,11 @@ Variants {
return;
}
if (root.effectiveScrolling) {
setWallpaperImmediate(newPath);
return;
}
if (root.transitionType === "random") {
root.actualTransitionType = SessionData.includedTransitions.length === 0 ? "none" : SessionData.includedTransitions[Math.floor(Math.random() * SessionData.includedTransitions.length)];
}
@@ -347,16 +623,60 @@ Variants {
property int textureWidth: Math.min(Math.round(modelData.width * screenScale), maxTextureSize)
property int textureHeight: Math.min(Math.round(modelData.height * screenScale), maxTextureSize)
QtObject {
id: imageMetrics
property real nativeWidth: 0
property real nativeHeight: 0
property bool ready: nativeWidth > 0 && nativeHeight > 0
function capture(w, h) {
if (nativeWidth === 0 && w > 0) {
nativeWidth = w;
nativeHeight = h;
}
}
function reset() {
nativeWidth = 0;
nativeHeight = 0;
}
readonly property real canvasWidth: {
if (!ready || !root.effectiveScrolling)
return root.textureWidth;
const imageAspect = nativeWidth / nativeHeight;
const screenAspect = root.textureWidth / root.textureHeight;
if (imageAspect < screenAspect) {
return root.textureWidth;
} else {
return root.textureHeight * imageAspect;
}
}
readonly property real canvasHeight: {
if (!ready || !root.effectiveScrolling)
return root.textureHeight;
const imageAspect = nativeWidth / nativeHeight;
const screenAspect = root.textureWidth / root.textureHeight;
if (imageAspect < screenAspect) {
return root.textureWidth / imageAspect;
} else {
return root.textureHeight;
}
}
}
Image {
id: currentWallpaper
anchors.fill: parent
visible: true
visible: !root.effectiveScrolling
opacity: 1
layer.enabled: false
asynchronous: true
retainWhileLoading: true
smooth: true
cache: true
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SessionData.getMonitorWallpaperFillMode(modelData.name))
@@ -364,6 +684,13 @@ Variants {
if (status === Image.Error) {
log.warn("failed to load active wallpaper for", modelData.name + ":", source);
}
if (status === Image.Ready) {
imageMetrics.capture(implicitWidth, implicitHeight);
}
}
onSourceChanged: {
imageMetrics.reset();
}
}
@@ -377,6 +704,7 @@ Variants {
retainWhileLoading: true
smooth: true
cache: true
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SessionData.getMonitorWallpaperFillMode(modelData.name))
@@ -434,6 +762,89 @@ Variants {
recursive: false
}
// Parallax scrolling pipeline — bypasses transition machinery.
Image {
id: parallaxImage
visible: false
width: imageMetrics.canvasWidth
height: imageMetrics.canvasHeight
source: root.effectiveScrolling ? currentWallpaper.source : ""
asynchronous: true
smooth: true
cache: true
sourceSize: Qt.size(imageMetrics.canvasWidth, imageMetrics.canvasHeight)
fillMode: Image.Stretch
}
ShaderEffectSource {
id: srcParallax
sourceItem: root.effectiveScrolling && imageMetrics.ready && parallaxImage.status === Image.Ready ? parallaxImage : null
hideSource: false
live: true
mipmap: false
recursive: false
textureSize: Qt.size(imageMetrics.canvasWidth, imageMetrics.canvasHeight)
}
// Pre-computed UV parameters for shader
QtObject {
id: parallaxUV
readonly property real imageAspect: imageMetrics.ready ? imageMetrics.canvasWidth / imageMetrics.canvasHeight : 1.0
readonly property real screenAspect: root.textureWidth / root.textureHeight
// Scale factor to fit image to screen (preserving aspect, cropping excess)
readonly property real scale: Math.max(root.textureWidth / imageMetrics.canvasWidth, root.textureHeight / imageMetrics.canvasHeight)
readonly property real scaledWidth: imageMetrics.canvasWidth * scale
readonly property real scaledHeight: imageMetrics.canvasHeight * scale
// UV scale: portion of texture visible on screen
readonly property real uvScaleX: root.textureWidth / scaledWidth
readonly property real uvScaleY: root.textureHeight / scaledHeight
// Scroll range: how much UV space we can scroll through
// Only allow scrolling in the dimension where image exceeds screen
readonly property real scrollRangeX: imageAspect > screenAspect + 0.01 ? (1.0 - uvScaleX) : (1.0 - uvScaleX) * 0.5
readonly property real scrollRangeY: imageAspect < screenAspect - 0.01 ? (1.0 - uvScaleY) : (1.0 - uvScaleY) * 0.5
readonly property bool scrollsHorizontal: imageAspect > screenAspect + 0.01
readonly property bool scrollsVertical: imageAspect < screenAspect - 0.01
}
Loader {
id: parallaxLoader
anchors.fill: parent
active: root.effectiveScrolling && !root.effectActive && imageMetrics.ready && parallaxImage.status === Image.Ready
sourceComponent: parallaxScrollComp
// Rebuild after a rebind orphans the texture; callLater defeats
// sourceComponent coalescing.
function rebuild() {
if (!active)
return;
sourceComponent = null;
Qt.callLater(() => {
sourceComponent = parallaxScrollComp;
});
}
}
Component {
id: parallaxScrollComp
ShaderEffect {
anchors.fill: parent
property variant source: srcParallax.sourceItem ? srcParallax : srcDummy
property real scrollX: root.currentScrollX
property real scrollY: root.currentScrollY
property real uvScaleX: parallaxUV.uvScaleX
property real uvScaleY: parallaxUV.uvScaleY
property real scrollRangeX: parallaxUV.scrollsHorizontal ? parallaxUV.scrollRangeX : 0.0
property real scrollRangeY: parallaxUV.scrollsVertical ? parallaxUV.scrollRangeY : 0.0
fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_parallax_scroll.frag.qsb")
}
}
Loader {
id: effectLoader
anchors.fill: parent
@@ -652,7 +1063,7 @@ Variants {
sourceComponent: MultiEffect {
anchors.fill: parent
source: effectLoader.active ? effectLoader.item : currentWallpaper
source: effectLoader.active ? effectLoader.item : (parallaxLoader.active ? parallaxLoader.item : currentWallpaper)
blurEnabled: true
blur: 0.8
blurMax: 75