From 5c3463bb02de7c342c5db616339a7a54c0278397 Mon Sep 17 00:00:00 2001 From: hecate cantus Date: Tue, 2 Dec 2025 12:42:20 -0800 Subject: [PATCH 1/2] WIP: vibe-coded parallax scrolling wallpaper impl needs integration with: lockscreen, wallpaper transition shaders, and a performance/optimization pass --- .../Modules/Settings/PersonalizationTab.qml | 10 +- quickshell/Modules/WallpaperBackground.qml | 353 ++++++++++++++++-- 2 files changed, 329 insertions(+), 34 deletions(-) diff --git a/quickshell/Modules/Settings/PersonalizationTab.qml b/quickshell/Modules/Settings/PersonalizationTab.qml index b19caa34d..5f7b97471 100644 --- a/quickshell/Modules/Settings/PersonalizationTab.qml +++ b/quickshell/Modules/Settings/PersonalizationTab.qml @@ -389,7 +389,7 @@ Item { DankButtonGroup { id: fillModeGroup anchors.horizontalCenter: parent.horizontalCenter - model: ["Stretch", "Fit", "Fill", "Tile", "Tile V", "Tile H", "Pad"] + model: ["Scrolling", "Stretch", "Fit", "Fill", "Tile", "Tile V", "Tile H", "Pad"] selectionMode: "single" buttonHeight: 28 minButtonWidth: 48 @@ -398,12 +398,12 @@ Item { textSize: Theme.fontSizeSmall checkEnabled: false currentIndex: { - const modes = ["Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"]; + const modes = ["Scrolling", "Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"]; return modes.indexOf(SettingsData.wallpaperFillMode); } onSelectionChanged: (index, selected) => { if (selected) { - const modes = ["Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"]; + const modes = ["Scrolling", "Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"]; SettingsData.set("wallpaperFillMode", modes[index]); } } @@ -411,7 +411,7 @@ Item { Connections { target: SettingsData function onWallpaperFillModeChanged() { - const modes = ["Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"]; + const modes = ["Scrolling", "Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"]; fillModeGroup.currentIndex = modes.indexOf(SettingsData.wallpaperFillMode); } } @@ -420,7 +420,7 @@ Item { target: personalizationTab function onSelectedMonitorNameChanged() { Qt.callLater(() => { - const modes = ["Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"]; + const modes = ["Scrolling", "Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"]; fillModeGroup.currentIndex = modes.indexOf(SettingsData.wallpaperFillMode); }); } diff --git a/quickshell/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml index b0305888e..507f3fab1 100644 --- a/quickshell/Modules/WallpaperBackground.qml +++ b/quickshell/Modules/WallpaperBackground.qml @@ -45,6 +45,15 @@ 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 real targetScrollPercentage: 0.0 + property real currentScrollPercentage: 0.0 + property bool effectiveScrolling: scrollingEnabled && totalWorkspaces > 1 + Connections { target: SessionData function onIsLightModeChanged() { @@ -56,6 +65,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") { if (SessionData.includedTransitions.length === 0) { @@ -85,6 +115,8 @@ Variants { function getFillMode(modeName) { switch (modeName) { + case "Scrolling": + return Image.Pad; case "Stretch": return Image.Stretch; case "Fit": @@ -106,12 +138,145 @@ Variants { } } + function updateWorkspaceData() { + if (!scrollingEnabled) return; + + 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; + + targetScrollPercentage = totalWorkspaces > 1 + ? ((currentWorkspaceIndex - 1) / (totalWorkspaces - 1)) * 100.0 + : 0.0; + + scrollAnimation.restart(); + } 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; + + targetScrollPercentage = totalWorkspaces > 1 + ? ((currentWorkspaceIndex - 1) / (totalWorkspaces - 1)) * 100.0 + : 0.0; + + scrollAnimation.restart(); + } + } + + QtObject { + id: springParams + + property real dampingRatio: 1.0 + property real stiffness: CompositorService.isNiri ? 1000.0 : 2000.0 + property real epsilon: 0.0001 + + readonly property real mass: 1.0 + readonly property real criticalDamping: 2.0 * Math.sqrt(mass * stiffness) + readonly property real damping: dampingRatio * criticalDamping + } + + Timer { + id: scrollAnimation + interval: 16 + repeat: true + running: false + + property real startTime: 0 + property real startValue: 0 + property real targetValue: 0 + property real initialVelocity: 0.0 + + function springOscillate(t, from, to) { + const b = springParams.damping; + const m = springParams.mass; + const k = springParams.stiffness; + const v0 = initialVelocity; + + const beta = b / (2.0 * m); + const omega0 = Math.sqrt(k / m); + const x0 = from - to; + const envelope = Math.exp(-beta * t); + + const epsilonFloat32 = 1.1920929e-7; + + if (Math.abs(beta - omega0) <= epsilonFloat32) { + return to + envelope * (x0 + (beta * x0 + v0) * t); + } else if (beta < omega0) { + const omega1 = Math.sqrt((omega0 * omega0) - (beta * beta)); + return to + envelope * (x0 * Math.cos(omega1 * t) + ((beta * x0 + v0) / omega1) * Math.sin(omega1 * t)); + } else { + const omega2 = Math.sqrt((beta * beta) - (omega0 * omega0)); + return to + envelope * (x0 * Math.cosh(omega2 * t) + ((beta * x0 + v0) / omega2) * Math.sinh(omega2 * t)); + } + } + + onTriggered: { + const t = (Date.now() - startTime) / 1000.0; + const value = springOscillate(t, startValue, targetValue); + + root.currentScrollPercentage = value; + + const settled = Math.abs(targetValue - value) < springParams.epsilon; + + if (settled) { + root.currentScrollPercentage = targetValue; + stop(); + } + } + + function restart() { + if (!root.effectiveScrolling) { + stop(); + return; + } + + startValue = root.currentScrollPercentage; + targetValue = root.targetScrollPercentage; + + initialVelocity = 0.0; + startTime = Date.now(); + running = true; + } + } + Component.onCompleted: { + Math.cosh = Math.cosh || function(x) { + return (Math.exp(x) + Math.exp(-x)) / 2; + }; + + Math.sinh = Math.sinh || function(x) { + return (Math.exp(x) - Math.exp(-x)) / 2; + }; + if (source) { const formattedSource = source.startsWith("file://") ? source : "file://" + source; setWallpaperImmediate(formattedSource); } isInitialized = true; + + if (scrollingEnabled) { + updateWorkspaceData(); + } + } + + onScrollingEnabledChanged: { + if (scrollingEnabled) { + updateWorkspaceData(); + } else { + scrollAnimation.stop(); + } } onSourceChanged: { @@ -219,40 +384,170 @@ Variants { property int physicalWidth: Math.round(modelData.width * screenScale) property int physicalHeight: Math.round(modelData.height * screenScale) - Image { - id: currentWallpaper + Rectangle { + id: currentWallpaperContainer anchors.fill: parent - visible: true - opacity: 1 - layer.enabled: false - asynchronous: true - smooth: true - cache: true - sourceSize: Qt.size(root.physicalWidth, root.physicalHeight) - fillMode: root.getFillMode(SettingsData.wallpaperFillMode) + color: "transparent" + clip: true + + Image { + id: currentWallpaper + visible: true + opacity: 1 + layer.enabled: false + asynchronous: true + smooth: true + cache: true + + fillMode: root.effectiveScrolling ? Image.PreserveAspectFit : root.getFillMode(SettingsData.wallpaperFillMode) + + sourceSize: { + if (root.effectiveScrolling) { + if (root.isVerticalScrolling) { + return Qt.size(root.physicalWidth * 2, 0); + } else { + return Qt.size(0, root.physicalHeight * 2); + } + } + return Qt.size(root.physicalWidth, root.physicalHeight); + } + + width: { + if (!root.effectiveScrolling) return undefined; + + if (root.isVerticalScrolling) { + return parent.width; + } else { + if (implicitWidth > 0 && implicitHeight > 0) { + return (parent.height / implicitHeight) * implicitWidth; + } + return parent.width * root.totalWorkspaces; + } + } + + height: { + if (!root.effectiveScrolling) return undefined; + + if (root.isVerticalScrolling) { + if (implicitWidth > 0 && implicitHeight > 0) { + return (parent.width / implicitWidth) * implicitHeight; + } + return parent.height * root.totalWorkspaces; + } else { + return parent.height; + } + } + + x: { + if (!root.effectiveScrolling) return 0; + + if (root.isVerticalScrolling) { + return 0; + } else { + const scrollRange = Math.max(0, width - parent.width); + return -(scrollRange * root.currentScrollPercentage / 100.0); + } + } + + y: { + if (!root.effectiveScrolling) return 0; + + if (root.isVerticalScrolling) { + const scrollRange = Math.max(0, height - parent.height); + return -(scrollRange * root.currentScrollPercentage / 100.0); + } else { + return 0; + } + } + } } - Image { - id: nextWallpaper + Rectangle { + id: nextWallpaperContainer anchors.fill: parent - visible: true - opacity: 0 - layer.enabled: false - asynchronous: true - smooth: true - cache: false - sourceSize: Qt.size(root.physicalWidth, root.physicalHeight) - fillMode: root.getFillMode(SettingsData.wallpaperFillMode) + color: "transparent" + clip: true + + Image { + id: nextWallpaper + visible: true + opacity: 0 + layer.enabled: false + asynchronous: true + smooth: true + cache: false + + fillMode: root.effectiveScrolling ? Image.PreserveAspectFit : root.getFillMode(SettingsData.wallpaperFillMode) + + sourceSize: { + if (root.effectiveScrolling) { + if (root.isVerticalScrolling) { + return Qt.size(root.physicalWidth * 2, 0); + } else { + return Qt.size(0, root.physicalHeight * 2); + } + } + return Qt.size(root.physicalWidth, root.physicalHeight); + } - onStatusChanged: { - if (status !== Image.Ready) - return; - if (root.actualTransitionType === "none") { - currentWallpaper.source = source; - nextWallpaper.source = ""; - root.transitionProgress = 0.0; - } else if (!root.transitioning) { - root.startTransition(); + width: { + if (!root.effectiveScrolling) return undefined; + + if (root.isVerticalScrolling) { + return parent.width; + } else { + if (implicitWidth > 0 && implicitHeight > 0) { + return (parent.height / implicitHeight) * implicitWidth; + } + return parent.width * root.totalWorkspaces; + } + } + + height: { + if (!root.effectiveScrolling) return undefined; + + if (root.isVerticalScrolling) { + if (implicitWidth > 0 && implicitHeight > 0) { + return (parent.width / implicitWidth) * implicitHeight; + } + return parent.height * root.totalWorkspaces; + } else { + return parent.height; + } + } + + x: { + if (!root.effectiveScrolling) return 0; + + if (root.isVerticalScrolling) { + return 0; + } else { + const scrollRange = Math.max(0, width - parent.width / 2); + return -(scrollRange * root.currentScrollPercentage / 100.0); + } + } + + y: { + if (!root.effectiveScrolling) return 0; + + if (root.isVerticalScrolling) { + const scrollRange = Math.max(0, height - parent.height / 2); + return -(scrollRange * root.currentScrollPercentage / 100.0); + } else { + return 0; + } + } + + onStatusChanged: { + if (status !== Image.Ready) + return; + if (root.actualTransitionType === "none") { + currentWallpaper.source = source; + nextWallpaper.source = ""; + root.transitionProgress = 0.0; + } else if (!root.transitioning) { + root.startTransition(); + } } } } From 5cbca602b49c68c49dbc314dca8ac41acd03c0ca Mon Sep 17 00:00:00 2001 From: hecate cantus Date: Tue, 2 Dec 2025 13:52:58 -0800 Subject: [PATCH 2/2] WIP: mild optimization pass --- quickshell/Modules/WallpaperBackground.qml | 60 ++++++++++------------ 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/quickshell/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml index 507f3fab1..5e186be64 100644 --- a/quickshell/Modules/WallpaperBackground.qml +++ b/quickshell/Modules/WallpaperBackground.qml @@ -384,11 +384,10 @@ Variants { property int physicalWidth: Math.round(modelData.width * screenScale) property int physicalHeight: Math.round(modelData.height * screenScale) - Rectangle { + Item { id: currentWallpaperContainer anchors.fill: parent - color: "transparent" - clip: true + clip: false Image { id: currentWallpaper @@ -399,6 +398,11 @@ Variants { smooth: true cache: true + anchors.left: root.effectiveScrolling && root.isVerticalScrolling ? parent.left : undefined + anchors.right: root.effectiveScrolling && root.isVerticalScrolling ? parent.right : undefined + anchors.top: root.effectiveScrolling && !root.isVerticalScrolling ? parent.top : undefined + anchors.bottom: root.effectiveScrolling && !root.isVerticalScrolling ? parent.bottom : undefined + fillMode: root.effectiveScrolling ? Image.PreserveAspectFit : root.getFillMode(SettingsData.wallpaperFillMode) sourceSize: { @@ -439,34 +443,25 @@ Variants { } x: { - if (!root.effectiveScrolling) return 0; + if (!root.effectiveScrolling || root.isVerticalScrolling) return 0; - if (root.isVerticalScrolling) { - return 0; - } else { - const scrollRange = Math.max(0, width - parent.width); - return -(scrollRange * root.currentScrollPercentage / 100.0); - } + const scrollRange = Math.max(0, width - parent.width); + return -(scrollRange * root.currentScrollPercentage / 100.0); } y: { - if (!root.effectiveScrolling) return 0; + if (!root.effectiveScrolling || !root.isVerticalScrolling) return 0; - if (root.isVerticalScrolling) { - const scrollRange = Math.max(0, height - parent.height); - return -(scrollRange * root.currentScrollPercentage / 100.0); - } else { - return 0; - } + const scrollRange = Math.max(0, height - parent.height); + return -(scrollRange * root.currentScrollPercentage / 100.0); } } } - Rectangle { + Item { id: nextWallpaperContainer anchors.fill: parent - color: "transparent" - clip: true + clip: false Image { id: nextWallpaper @@ -477,6 +472,11 @@ Variants { smooth: true cache: false + anchors.left: root.effectiveScrolling && root.isVerticalScrolling ? parent.left : undefined + anchors.right: root.effectiveScrolling && root.isVerticalScrolling ? parent.right : undefined + anchors.top: root.effectiveScrolling && !root.isVerticalScrolling ? parent.top : undefined + anchors.bottom: root.effectiveScrolling && !root.isVerticalScrolling ? parent.bottom : undefined + fillMode: root.effectiveScrolling ? Image.PreserveAspectFit : root.getFillMode(SettingsData.wallpaperFillMode) sourceSize: { @@ -517,25 +517,17 @@ Variants { } x: { - if (!root.effectiveScrolling) return 0; + if (!root.effectiveScrolling || root.isVerticalScrolling) return 0; - if (root.isVerticalScrolling) { - return 0; - } else { - const scrollRange = Math.max(0, width - parent.width / 2); - return -(scrollRange * root.currentScrollPercentage / 100.0); - } + const scrollRange = Math.max(0, width - parent.width / 2); + return -(scrollRange * root.currentScrollPercentage / 100.0); } y: { - if (!root.effectiveScrolling) return 0; + if (!root.effectiveScrolling || !root.isVerticalScrolling) return 0; - if (root.isVerticalScrolling) { - const scrollRange = Math.max(0, height - parent.height / 2); - return -(scrollRange * root.currentScrollPercentage / 100.0); - } else { - return 0; - } + const scrollRange = Math.max(0, height - parent.height / 2); + return -(scrollRange * root.currentScrollPercentage / 100.0); } onStatusChanged: {