diff --git a/assets/systemd/dms.service b/assets/systemd/dms.service index 6beabb819..59b2a0764 100644 --- a/assets/systemd/dms.service +++ b/assets/systemd/dms.service @@ -7,6 +7,8 @@ Requisite=graphical-session.target [Service] Type=dbus BusName=org.freedesktop.Notifications +# Use elapsed-time animation driver for high refresh rate support (240Hz+) +Environment=QSG_USE_SIMPLE_ANIMATION_DRIVER=1 ExecStart=/usr/bin/dms run --session ExecReload=/usr/bin/pkill -USR1 -x dms Restart=on-failure diff --git a/core/cmd/dms/shell.go b/core/cmd/dms/shell.go index 157093eee..f44c0f77e 100644 --- a/core/cmd/dms/shell.go +++ b/core/cmd/dms/shell.go @@ -212,6 +212,11 @@ func runShellInteractive(session bool) { if os.Getenv("QT_QPA_PLATFORM") == "" { cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") } + // Use elapsed-time animation driver for high refresh rate support (240Hz+) + // Without this, Qt's vsync-based animation driver locks FrameAnimation to 60Hz + if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" { + cmd.Env = append(cmd.Env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1") + } cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout @@ -452,6 +457,11 @@ func runShellDaemon(session bool) { if os.Getenv("QT_QPA_PLATFORM") == "" { cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") } + // Use elapsed-time animation driver for high refresh rate support (240Hz+) + // Without this, Qt's vsync-based animation driver locks FrameAnimation to 60Hz + if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" { + cmd.Env = append(cmd.Env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1") + } devNull, err := os.OpenFile("/dev/null", os.O_RDWR, 0) if err != nil { diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index 62df4fa3a..fa8d3ce8e 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -59,6 +59,26 @@ Singleton { property var monitorWallpapersLight: ({}) property var monitorWallpapersDark: ({}) property var monitorWallpaperFillModes: ({}) + + // Map: screenName -> { scrollX, scrollY } (0-100 range, like workspace percentage) + property var monitorScrollPositions: ({}) + + function setMonitorScrollPosition(screenName, scrollX, scrollY) { + var newPositions = Object.assign({}, monitorScrollPositions); + newPositions[screenName] = { scrollX: scrollX, scrollY: scrollY }; + monitorScrollPositions = newPositions; + } + + function getMonitorScrollPosition(screenName) { + return monitorScrollPositions[screenName] || { scrollX: 50, scrollY: 50 }; + } + + function clearMonitorScrollPosition(screenName) { + var newPositions = Object.assign({}, monitorScrollPositions); + delete newPositions[screenName]; + monitorScrollPositions = newPositions; + } + property string wallpaperTransition: "fade" readonly property var availableWallpaperTransitions: ["none", "fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none") diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index 5ac940858..d7aa7ca41 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -1618,6 +1618,23 @@ Singleton { } } + // Returns numeric fillMode value for shader use (matches shader calculateUV logic) + function getShaderFillMode(modeName) { + switch (modeName) { + case "Stretch": return 0; + case "Fit": + case "PreserveAspectFit": return 1; + case "Fill": + case "PreserveAspectCrop": return 2; + case "Tile": return 3; + case "TileVertically": return 4; + case "TileHorizontally": return 5; + case "Pad": return 6; + case "Scrolling": return 7; + default: return 2; + } + } + function snap(value, dpr) { const s = dpr || 1; return Math.round(value * s) / s; diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index 9a1a1f458..149ef9d99 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -159,20 +159,50 @@ Item { } Image { - id: wallpaperBackground - + id: wallpaperSource + visible: false anchors.fill: parent source: { var currentWallpaper = SessionData.getMonitorWallpaper(screenName); return (currentWallpaper && !currentWallpaper.startsWith("#")) ? encodeFileUrl(currentWallpaper) : ""; } - fillMode: Theme.getFillMode(SessionData.getMonitorWallpaperFillMode(screenName)) - smooth: true asynchronous: false cache: true - visible: source !== "" - layer.enabled: true + } + + ShaderEffectSource { + id: srcWallpaper + sourceItem: wallpaperSource + hideSource: true + live: false + } + + ShaderEffect { + id: wallpaperBackground + anchors.fill: parent + visible: wallpaperSource.source !== "" + + property variant source1: srcWallpaper + property variant source2: srcWallpaper // Same source for lock screen (no transition) + property real progress: 0.0 + + readonly property string fillModeName: SessionData.getMonitorWallpaperFillMode(screenName) + readonly property var scrollPos: SessionData.getMonitorScrollPosition(screenName) + property real fillMode: Theme.getShaderFillMode(fillModeName) + property real scrollX: scrollPos.scrollX + property real scrollY: scrollPos.scrollY + property real imageWidth1: wallpaperSource.implicitWidth > 0 ? wallpaperSource.implicitWidth : 1 + property real imageHeight1: wallpaperSource.implicitHeight > 0 ? wallpaperSource.implicitHeight : 1 + property real imageWidth2: wallpaperSource.implicitWidth > 0 ? wallpaperSource.implicitWidth : 1 + property real imageHeight2: wallpaperSource.implicitHeight > 0 ? wallpaperSource.implicitHeight : 1 + property real screenWidth: width > 0 ? width : 1 + property real screenHeight: height > 0 ? height : 1 + property vector4d fillColor: Qt.vector4d(0, 0, 0, 1) + + fragmentShader: Qt.resolvedUrl("../../Shaders/qsb/wp_fade.frag.qsb") + + layer.enabled: true layer.effect: MultiEffect { autoPaddingEnabled: false blurEnabled: true diff --git a/quickshell/Modules/Settings/WallpaperTab.qml b/quickshell/Modules/Settings/WallpaperTab.qml index b6e197c0f..b9dd698e4 100644 --- a/quickshell/Modules/Settings/WallpaperTab.qml +++ b/quickshell/Modules/Settings/WallpaperTab.qml @@ -307,9 +307,9 @@ Item { DankButtonGroup { id: fillModeGroup - property var internalModes: ["Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"] + property var internalModes: ["Stretch", "Fit", "Fill", "Scrolling", "Tile", "TileVertically", "TileHorizontally", "Pad"] anchors.horizontalCenter: parent.horizontalCenter - model: [I18n.tr("Stretch", "wallpaper fill mode"), I18n.tr("Fit", "wallpaper fill mode"), I18n.tr("Fill", "wallpaper fill mode"), I18n.tr("Tile", "wallpaper fill mode"), I18n.tr("Tile V", "wallpaper fill mode"), I18n.tr("Tile H", "wallpaper fill mode"), I18n.tr("Pad", "wallpaper fill mode")] + model: [I18n.tr("Stretch", "wallpaper fill mode"), I18n.tr("Fit", "wallpaper fill mode"), I18n.tr("Fill", "wallpaper fill mode"), I18n.tr("Scroll", "wallpaper fill mode"), I18n.tr("Tile", "wallpaper fill mode"), I18n.tr("Tile V", "wallpaper fill mode"), I18n.tr("Tile H", "wallpaper fill mode"), I18n.tr("Pad", "wallpaper fill mode")] selectionMode: "single" buttonHeight: 28 minButtonWidth: 48 diff --git a/quickshell/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml index 46dd3070b..4d55c70a8 100644 --- a/quickshell/Modules/WallpaperBackground.qml +++ b/quickshell/Modules/WallpaperBackground.qml @@ -51,6 +51,13 @@ Variants { property string actualTransitionType: transitionType property bool isInitialized: false + property string scrollMode: SettingsData.wallpaperFillMode + property bool scrollingEnabled: scrollMode === "Scrolling" + property bool isVerticalScrolling: CompositorService.isNiri + property int currentWorkspaceIndex: 0 + property int totalWorkspaces: 1 + property bool effectiveScrolling: scrollingEnabled && totalWorkspaces > 1 + Connections { target: SessionData function onIsLightModeChanged() { @@ -62,6 +69,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; @@ -88,6 +116,8 @@ Variants { function getFillMode(modeName) { switch (modeName) { + case "Scrolling": + return Image.Pad; case "Stretch": return Image.Stretch; case "Fit": @@ -109,6 +139,161 @@ 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 + } + } + + onTriggered: { + currentTime += frameTime; + + 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: { if (!source) { isInitialized = true; @@ -117,6 +302,29 @@ Variants { const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source); setWallpaperImmediate(formattedSource); isInitialized = true; + + if (scrollingEnabled) { + updateWorkspaceData(); + } + + Qt.callLater(publishScrollPosition); + } + + Component.onDestruction: { + SessionData.clearMonitorScrollPosition(modelData.name); + } + + onScrollingEnabledChanged: { + if (scrollingEnabled) { + firstScrollUpdate = true; + updateWorkspaceData(); + } else { + frameAnim.stop(); + } + } + + onEffectiveScrollingChanged: { + publishScrollPosition(); } onSourceChanged: { @@ -145,6 +353,17 @@ Variants { root.effectActive = false; 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() { @@ -180,6 +399,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)]; } @@ -226,17 +450,110 @@ 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; + } + } + } + + QtObject { + id: nextImageMetrics + 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 smooth: true cache: true + sourceSize: Qt.size(root.textureWidth, root.textureHeight) fillMode: root.getFillMode(SessionData.getMonitorWallpaperFillMode(modelData.name)) + + onStatusChanged: { + if (status === Image.Ready) { + imageMetrics.capture(implicitWidth, implicitHeight); + } + } + + onSourceChanged: { + imageMetrics.reset(); + } } Image { @@ -248,10 +565,14 @@ Variants { asynchronous: true smooth: true cache: true + sourceSize: Qt.size(root.textureWidth, root.textureHeight) fillMode: root.getFillMode(SessionData.getMonitorWallpaperFillMode(modelData.name)) onStatusChanged: { + if (status === Image.Ready) { + nextImageMetrics.capture(implicitWidth, implicitHeight); + } if (status !== Image.Ready) return; if (root.actualTransitionType === "none") { @@ -262,6 +583,10 @@ Variants { root.startTransition(); } } + + onSourceChanged: { + nextImageMetrics.reset(); + } } ShaderEffectSource { @@ -301,6 +626,133 @@ Variants { recursive: false } + // ============================================================ + // ISOLATED PARALLAX SCROLLING - for performance debugging + // Bypasses transition effect machinery entirely + // ============================================================ + + 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: false + 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 + } + + 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") + } + } + + // ============================================================ + // Legacy parallax components (kept for transition effects) + // TODO: Remove once isolated parallax is validated + // ============================================================ + + Image { + id: parallaxCurrentImage + visible: false + width: imageMetrics.canvasWidth + height: imageMetrics.canvasHeight + source: "" // Disabled - using parallaxImage instead + asynchronous: true + smooth: true + cache: true + sourceSize: Qt.size(imageMetrics.canvasWidth, imageMetrics.canvasHeight) + fillMode: Image.Stretch + } + + Image { + id: parallaxNextImage + visible: false + width: nextImageMetrics.canvasWidth + height: nextImageMetrics.canvasHeight + source: root.effectiveScrolling && nextWallpaper.source ? nextWallpaper.source : "" + asynchronous: true + smooth: true + cache: true + sourceSize: Qt.size(nextImageMetrics.canvasWidth, nextImageMetrics.canvasHeight) + fillMode: Image.Stretch + } + + ShaderEffectSource { + id: srcParallaxCurrent + sourceItem: null // Disabled + hideSource: false + live: false + mipmap: false + recursive: false + textureSize: Qt.size(imageMetrics.canvasWidth, imageMetrics.canvasHeight) + } + + ShaderEffectSource { + id: srcParallaxNext + sourceItem: null // Disabled + hideSource: false + live: false + mipmap: false + recursive: false + textureSize: Qt.size(nextImageMetrics.canvasWidth, nextImageMetrics.canvasHeight) + } + Loader { id: effectLoader anchors.fill: parent @@ -518,7 +970,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 diff --git a/quickshell/Shaders/frag/wp_disc.frag b/quickshell/Shaders/frag/wp_disc.frag index 254ae419f..8dfe8c132 100644 --- a/quickshell/Shaders/frag/wp_disc.frag +++ b/quickshell/Shaders/frag/wp_disc.frag @@ -16,7 +16,7 @@ layout(std140, binding = 0) uniform buf { float smoothness; // Edge smoothness (0.0 to 1.0, 0=sharp, 1=very smooth) float aspectRatio; // Width / Height of the screen - float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad + float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad, 7=scroll float imageWidth1; float imageHeight1; float imageWidth2; @@ -24,6 +24,10 @@ layout(std140, binding = 0) uniform buf { float screenWidth; float screenHeight; vec4 fillColor; + + // Scroll position (0-100 range, only used when fillMode >= 6.5) + float scrollX; + float scrollY; } ubuf; vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { @@ -57,12 +61,33 @@ vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { vec2 tileUV = uv * vec2(ubuf.screenWidth, ubuf.screenHeight) / vec2(imgWidth, imgHeight); transformedUV = vec2(fract(tileUV.x), uv.y); } - else { + else if (ubuf.fillMode < 6.5) { + // fillMode 6 = Pad vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight); vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5; vec2 imagePixel = screenPixel - imageOffset; transformedUV = imagePixel / vec2(imgWidth, imgHeight); } + else { + // fillMode 7 = Scroll (Crop with variable offset) + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale; + vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize; + + // Determine scroll axis based on aspect ratio + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + vec2 scrollOffset = vec2( + scrollHorizontal ? offset.x * (ubuf.scrollX / 100.0) : offset.x * 0.5, + scrollVertical ? offset.y * (ubuf.scrollY / 100.0) : offset.y * 0.5 + ); + + transformedUV = uv * (vec2(1.0) - offset) + scrollOffset; + } return transformedUV; } diff --git a/quickshell/Shaders/frag/wp_fade.frag b/quickshell/Shaders/frag/wp_fade.frag index 31a404d38..ef5eb6690 100644 --- a/quickshell/Shaders/frag/wp_fade.frag +++ b/quickshell/Shaders/frag/wp_fade.frag @@ -13,7 +13,7 @@ layout(std140, binding = 0) uniform buf { float progress; // Fill mode parameters - float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad + float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad, 7=scroll float imageWidth1; // Width of source1 image float imageHeight1; // Height of source1 image float imageWidth2; // Width of source2 image @@ -21,6 +21,10 @@ layout(std140, binding = 0) uniform buf { float screenWidth; // Screen width float screenHeight; // Screen height vec4 fillColor; // Fill color for empty areas (default: black) + + // Scroll position (0-100 range, only used when fillMode >= 6.5) + float scrollX; + float scrollY; } ubuf; vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { @@ -54,12 +58,33 @@ vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { vec2 tileUV = uv * vec2(ubuf.screenWidth, ubuf.screenHeight) / vec2(imgWidth, imgHeight); transformedUV = vec2(fract(tileUV.x), uv.y); } - else { + else if (ubuf.fillMode < 6.5) { + // fillMode 6 = Pad vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight); vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5; vec2 imagePixel = screenPixel - imageOffset; transformedUV = imagePixel / vec2(imgWidth, imgHeight); } + else { + // fillMode 7 = Scroll (Crop with variable offset) + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale; + vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize; + + // Determine scroll axis based on aspect ratio + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + vec2 scrollOffset = vec2( + scrollHorizontal ? offset.x * (ubuf.scrollX / 100.0) : offset.x * 0.5, + scrollVertical ? offset.y * (ubuf.scrollY / 100.0) : offset.y * 0.5 + ); + + transformedUV = uv * (vec2(1.0) - offset) + scrollOffset; + } return transformedUV; } diff --git a/quickshell/Shaders/frag/wp_iris_bloom.frag b/quickshell/Shaders/frag/wp_iris_bloom.frag index 548d5bd3c..80e36edf0 100644 --- a/quickshell/Shaders/frag/wp_iris_bloom.frag +++ b/quickshell/Shaders/frag/wp_iris_bloom.frag @@ -16,7 +16,7 @@ layout(std140, binding = 0) uniform buf { float smoothness; float aspectRatio; - float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad + float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad, 7=scroll float imageWidth1; float imageHeight1; float imageWidth2; @@ -24,6 +24,10 @@ layout(std140, binding = 0) uniform buf { float screenWidth; float screenHeight; vec4 fillColor; + + // Scroll position (0-100 range, only used when fillMode >= 6.5) + float scrollX; + float scrollY; } ubuf; vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { @@ -57,12 +61,33 @@ vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { vec2 tileUV = uv * vec2(ubuf.screenWidth, ubuf.screenHeight) / vec2(imgWidth, imgHeight); transformedUV = vec2(fract(tileUV.x), uv.y); } - else { + else if (ubuf.fillMode < 6.5) { + // fillMode 6 = Pad vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight); vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5; vec2 imagePixel = screenPixel - imageOffset; transformedUV = imagePixel / vec2(imgWidth, imgHeight); } + else { + // fillMode 7 = Scroll (Crop with variable offset) + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale; + vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize; + + // Determine scroll axis based on aspect ratio + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + vec2 scrollOffset = vec2( + scrollHorizontal ? offset.x * (ubuf.scrollX / 100.0) : offset.x * 0.5, + scrollVertical ? offset.y * (ubuf.scrollY / 100.0) : offset.y * 0.5 + ); + + transformedUV = uv * (vec2(1.0) - offset) + scrollOffset; + } return transformedUV; } diff --git a/quickshell/Shaders/frag/wp_parallax.frag b/quickshell/Shaders/frag/wp_parallax.frag new file mode 100644 index 000000000..c5929df22 --- /dev/null +++ b/quickshell/Shaders/frag/wp_parallax.frag @@ -0,0 +1,72 @@ +// ===== wp_parallax.frag ===== +// Parallax wallpaper shader - optimized version with CPU-side spring calculation +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(binding = 1) uniform sampler2D source1; +layout(binding = 2) uniform sampler2D source2; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float progress; + + // Pre-computed scroll position from CPU (replaces per-pixel spring calculation) + float scrollX; + float scrollY; + + float imageWidth1; + float imageHeight1; + float imageWidth2; + float imageHeight2; + float screenWidth; + float screenHeight; + vec4 fillColor; +} ubuf; + +vec2 calculateParallaxUV(vec2 uv, float imgWidth, float imgHeight) { + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledSize = vec2(imgWidth, imgHeight) * scale; + + vec2 uvScale = vec2(ubuf.screenWidth, ubuf.screenHeight) / scaledSize; + vec2 uvScrollRange = vec2(1.0) - uvScale; + + vec2 scrollOffset = vec2( + scrollHorizontal ? (ubuf.scrollX / 100.0) * uvScrollRange.x : uvScrollRange.x * 0.5, + scrollVertical ? (ubuf.scrollY / 100.0) * uvScrollRange.y : uvScrollRange.y * 0.5 + ); + + return uv * uvScale + scrollOffset; +} + +vec4 sampleParallax(sampler2D tex, vec2 uv, float imgWidth, float imgHeight) { + if (imgWidth <= 0.0 || imgHeight <= 0.0) { + return ubuf.fillColor; + } + + vec2 transformedUV = calculateParallaxUV(uv, imgWidth, imgHeight); + + if (transformedUV.x < 0.0 || transformedUV.x > 1.0 || + transformedUV.y < 0.0 || transformedUV.y > 1.0) { + return ubuf.fillColor; + } + + return texture(tex, transformedUV); +} + +void main() { + vec2 uv = qt_TexCoord0; + + vec4 color1 = sampleParallax(source1, uv, ubuf.imageWidth1, ubuf.imageHeight1); + vec4 color2 = sampleParallax(source2, uv, ubuf.imageWidth2, ubuf.imageHeight2); + + fragColor = mix(color1, color2, ubuf.progress) * ubuf.qt_Opacity; +} diff --git a/quickshell/Shaders/frag/wp_parallax_scroll.frag b/quickshell/Shaders/frag/wp_parallax_scroll.frag new file mode 100644 index 000000000..99e0ce2e9 --- /dev/null +++ b/quickshell/Shaders/frag/wp_parallax_scroll.frag @@ -0,0 +1,35 @@ +// ===== wp_parallax_scroll.frag ===== +// Minimal parallax scrolling shader - single texture, UV offset only +// For performance debugging - isolated from transition effects +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(binding = 1) uniform sampler2D source; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + + float scrollX; // 0-100 scroll position + float scrollY; // 0-100 scroll position + float uvScaleX; // Pre-computed: screenWidth / scaledImageWidth + float uvScaleY; // Pre-computed: screenHeight / scaledImageHeight + float scrollRangeX; // Pre-computed: 1.0 - uvScaleX (or 0 if not scrollable) + float scrollRangeY; // Pre-computed: 1.0 - uvScaleY (or 0 if not scrollable) +} ubuf; + +void main() { + vec2 uv = qt_TexCoord0; + + // Apply UV scale and scroll offset + vec2 scrollOffset = vec2( + ubuf.scrollRangeX * (ubuf.scrollX / 100.0), + ubuf.scrollRangeY * (ubuf.scrollY / 100.0) + ); + + vec2 finalUV = uv * vec2(ubuf.uvScaleX, ubuf.uvScaleY) + scrollOffset; + + fragColor = texture(source, finalUV) * ubuf.qt_Opacity; +} diff --git a/quickshell/Shaders/frag/wp_pixelate.frag b/quickshell/Shaders/frag/wp_pixelate.frag index 2b6fb5369..80ab0ea1b 100644 --- a/quickshell/Shaders/frag/wp_pixelate.frag +++ b/quickshell/Shaders/frag/wp_pixelate.frag @@ -16,7 +16,7 @@ layout(std140, binding = 0) uniform buf { float smoothness; // controls starting block size (0..1) float aspectRatio; // (unused) - float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad + float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad, 7=scroll float imageWidth1; float imageHeight1; float imageWidth2; @@ -24,6 +24,10 @@ layout(std140, binding = 0) uniform buf { float screenWidth; float screenHeight; vec4 fillColor; + + // Scroll position (0-100 range, only used when fillMode >= 6.5) + float scrollX; + float scrollY; } ubuf; vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { @@ -57,12 +61,33 @@ vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { vec2 tileUV = uv * vec2(ubuf.screenWidth, ubuf.screenHeight) / vec2(imgWidth, imgHeight); transformedUV = vec2(fract(tileUV.x), uv.y); } - else { + else if (ubuf.fillMode < 6.5) { + // fillMode 6 = Pad vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight); vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5; vec2 imagePixel = screenPixel - imageOffset; transformedUV = imagePixel / vec2(imgWidth, imgHeight); } + else { + // fillMode 7 = Scroll (Crop with variable offset) + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale; + vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize; + + // Determine scroll axis based on aspect ratio + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + vec2 scrollOffset = vec2( + scrollHorizontal ? offset.x * (ubuf.scrollX / 100.0) : offset.x * 0.5, + scrollVertical ? offset.y * (ubuf.scrollY / 100.0) : offset.y * 0.5 + ); + + transformedUV = uv * (vec2(1.0) - offset) + scrollOffset; + } return transformedUV; } diff --git a/quickshell/Shaders/frag/wp_portal.frag b/quickshell/Shaders/frag/wp_portal.frag index 21e939c34..f5f44ea0f 100644 --- a/quickshell/Shaders/frag/wp_portal.frag +++ b/quickshell/Shaders/frag/wp_portal.frag @@ -16,7 +16,7 @@ layout(std140, binding = 0) uniform buf { float smoothness; // 0..1 (edge softness) float aspectRatio; // width / height - float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad + float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad, 7=scroll float imageWidth1; float imageHeight1; float imageWidth2; @@ -24,6 +24,10 @@ layout(std140, binding = 0) uniform buf { float screenWidth; float screenHeight; vec4 fillColor; + + // Scroll position (0-100 range, only used when fillMode >= 6.5) + float scrollX; + float scrollY; } ubuf; vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { @@ -57,12 +61,33 @@ vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { vec2 tileUV = uv * vec2(ubuf.screenWidth, ubuf.screenHeight) / vec2(imgWidth, imgHeight); transformedUV = vec2(fract(tileUV.x), uv.y); } - else { + else if (ubuf.fillMode < 6.5) { + // fillMode 6 = Pad vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight); vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5; vec2 imagePixel = screenPixel - imageOffset; transformedUV = imagePixel / vec2(imgWidth, imgHeight); } + else { + // fillMode 7 = Scroll (Crop with variable offset) + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale; + vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize; + + // Determine scroll axis based on aspect ratio + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + vec2 scrollOffset = vec2( + scrollHorizontal ? offset.x * (ubuf.scrollX / 100.0) : offset.x * 0.5, + scrollVertical ? offset.y * (ubuf.scrollY / 100.0) : offset.y * 0.5 + ); + + transformedUV = uv * (vec2(1.0) - offset) + scrollOffset; + } return transformedUV; } diff --git a/quickshell/Shaders/frag/wp_stripes.frag b/quickshell/Shaders/frag/wp_stripes.frag index fcc216bb1..4419bf68d 100644 --- a/quickshell/Shaders/frag/wp_stripes.frag +++ b/quickshell/Shaders/frag/wp_stripes.frag @@ -16,7 +16,7 @@ layout(std140, binding = 0) uniform buf { float smoothness; // Edge smoothness (0.0 to 1.0, 0=sharp, 1=very smooth) float aspectRatio; // Width / Height of the screen - float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad + float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad, 7=scroll float imageWidth1; float imageHeight1; float imageWidth2; @@ -24,6 +24,10 @@ layout(std140, binding = 0) uniform buf { float screenWidth; float screenHeight; vec4 fillColor; + + // Scroll position (0-100 range, only used when fillMode >= 6.5) + float scrollX; + float scrollY; } ubuf; vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { @@ -57,12 +61,33 @@ vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { vec2 tileUV = uv * vec2(ubuf.screenWidth, ubuf.screenHeight) / vec2(imgWidth, imgHeight); transformedUV = vec2(fract(tileUV.x), uv.y); } - else { + else if (ubuf.fillMode < 6.5) { + // fillMode 6 = Pad vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight); vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5; vec2 imagePixel = screenPixel - imageOffset; transformedUV = imagePixel / vec2(imgWidth, imgHeight); } + else { + // fillMode 7 = Scroll (Crop with variable offset) + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale; + vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize; + + // Determine scroll axis based on aspect ratio + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + vec2 scrollOffset = vec2( + scrollHorizontal ? offset.x * (ubuf.scrollX / 100.0) : offset.x * 0.5, + scrollVertical ? offset.y * (ubuf.scrollY / 100.0) : offset.y * 0.5 + ); + + transformedUV = uv * (vec2(1.0) - offset) + scrollOffset; + } return transformedUV; } diff --git a/quickshell/Shaders/frag/wp_wipe.frag b/quickshell/Shaders/frag/wp_wipe.frag index 5fc6d3271..0e616fd72 100644 --- a/quickshell/Shaders/frag/wp_wipe.frag +++ b/quickshell/Shaders/frag/wp_wipe.frag @@ -14,7 +14,7 @@ layout(std140, binding = 0) uniform buf { float direction; // 0=left, 1=right, 2=up, 3=down float smoothness; // Edge smoothness (0.0 to 1.0, 0=sharp, 1=very smooth) - float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad + float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad, 7=scroll float imageWidth1; float imageHeight1; float imageWidth2; @@ -22,6 +22,10 @@ layout(std140, binding = 0) uniform buf { float screenWidth; float screenHeight; vec4 fillColor; + + // Scroll position (0-100 range, only used when fillMode >= 6.5) + float scrollX; + float scrollY; } ubuf; vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { @@ -55,12 +59,33 @@ vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { vec2 tileUV = uv * vec2(ubuf.screenWidth, ubuf.screenHeight) / vec2(imgWidth, imgHeight); transformedUV = vec2(fract(tileUV.x), uv.y); } - else { + else if (ubuf.fillMode < 6.5) { + // fillMode 6 = Pad vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight); vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5; vec2 imagePixel = screenPixel - imageOffset; transformedUV = imagePixel / vec2(imgWidth, imgHeight); } + else { + // fillMode 7 = Scroll (Crop with variable offset) + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale; + vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize; + + // Determine scroll axis based on aspect ratio + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + vec2 scrollOffset = vec2( + scrollHorizontal ? offset.x * (ubuf.scrollX / 100.0) : offset.x * 0.5, + scrollVertical ? offset.y * (ubuf.scrollY / 100.0) : offset.y * 0.5 + ); + + transformedUV = uv * (vec2(1.0) - offset) + scrollOffset; + } return transformedUV; } diff --git a/quickshell/Shaders/qsb/wp_disc.frag.qsb b/quickshell/Shaders/qsb/wp_disc.frag.qsb index 364ba2580..f865a4d9b 100644 Binary files a/quickshell/Shaders/qsb/wp_disc.frag.qsb and b/quickshell/Shaders/qsb/wp_disc.frag.qsb differ diff --git a/quickshell/Shaders/qsb/wp_fade.frag.qsb b/quickshell/Shaders/qsb/wp_fade.frag.qsb index a91469598..75a6ba131 100644 Binary files a/quickshell/Shaders/qsb/wp_fade.frag.qsb and b/quickshell/Shaders/qsb/wp_fade.frag.qsb differ diff --git a/quickshell/Shaders/qsb/wp_iris_bloom.frag.qsb b/quickshell/Shaders/qsb/wp_iris_bloom.frag.qsb index 565a76175..e7d1327be 100644 Binary files a/quickshell/Shaders/qsb/wp_iris_bloom.frag.qsb and b/quickshell/Shaders/qsb/wp_iris_bloom.frag.qsb differ diff --git a/quickshell/Shaders/qsb/wp_parallax.frag.qsb b/quickshell/Shaders/qsb/wp_parallax.frag.qsb new file mode 100644 index 000000000..47c5edbb8 Binary files /dev/null and b/quickshell/Shaders/qsb/wp_parallax.frag.qsb differ diff --git a/quickshell/Shaders/qsb/wp_parallax_scroll.frag.qsb b/quickshell/Shaders/qsb/wp_parallax_scroll.frag.qsb new file mode 100644 index 000000000..9a3d462f6 Binary files /dev/null and b/quickshell/Shaders/qsb/wp_parallax_scroll.frag.qsb differ diff --git a/quickshell/Shaders/qsb/wp_pixelate.frag.qsb b/quickshell/Shaders/qsb/wp_pixelate.frag.qsb index 85236a5d7..9008055b9 100644 Binary files a/quickshell/Shaders/qsb/wp_pixelate.frag.qsb and b/quickshell/Shaders/qsb/wp_pixelate.frag.qsb differ diff --git a/quickshell/Shaders/qsb/wp_portal.frag.qsb b/quickshell/Shaders/qsb/wp_portal.frag.qsb index 6ae2fece2..0b0b1d1e6 100644 Binary files a/quickshell/Shaders/qsb/wp_portal.frag.qsb and b/quickshell/Shaders/qsb/wp_portal.frag.qsb differ diff --git a/quickshell/Shaders/qsb/wp_stripes.frag.qsb b/quickshell/Shaders/qsb/wp_stripes.frag.qsb index e3d28b8aa..d1509983d 100644 Binary files a/quickshell/Shaders/qsb/wp_stripes.frag.qsb and b/quickshell/Shaders/qsb/wp_stripes.frag.qsb differ diff --git a/quickshell/Shaders/qsb/wp_wipe.frag.qsb b/quickshell/Shaders/qsb/wp_wipe.frag.qsb index 83364542c..9626a2533 100644 Binary files a/quickshell/Shaders/qsb/wp_wipe.frag.qsb and b/quickshell/Shaders/qsb/wp_wipe.frag.qsb differ