From ec7e5e9fcbe0f99f5eebb14e236880f51019af28 Mon Sep 17 00:00:00 2001 From: Uyanide Date: Fri, 20 Mar 2026 09:30:05 +0100 Subject: [PATCH] feat: scrollble wallpaper --- config/niri/.config/niri/config/styles.kdl | 14 +- .../.config/quickshell/Constants/Colors.qml | 5 +- .../Modules/Background/Background.qml | 208 +----------------- .../Modules/Background/BackgroundImage.qml | 93 ++++++++ .../Modules/Background/ScrollBackground.qml | 159 +++++++++++++ .../Modules/Bar/Modules/Workspace.qml | 18 ++ .../quickshell/Services/BackgroundService.qml | 2 +- .../quickshell/Services/ImageCacheService.qml | 49 +++-- .../.config/quickshell/Services/Niri.qml | 58 ++++- 9 files changed, 365 insertions(+), 241 deletions(-) create mode 100644 config/quickshell/.config/quickshell/Modules/Background/BackgroundImage.qml create mode 100644 config/quickshell/.config/quickshell/Modules/Background/ScrollBackground.qml diff --git a/config/niri/.config/niri/config/styles.kdl b/config/niri/.config/niri/config/styles.kdl index 5e4f03f..77e6278 100644 --- a/config/niri/.config/niri/config/styles.kdl +++ b/config/niri/.config/niri/config/styles.kdl @@ -43,9 +43,16 @@ layout { left 2 } - background-color "#1e1e2e" + background-color "transparent" } +overview { + backdrop-color "#1e1e2e" + + workspace-shadow { + off + } +} // Disable the "Important Hotkeys" pop-up at startup. hotkey-overlay { @@ -57,6 +64,11 @@ prefer-no-csd animations { // off // slowdown 3.0 + + workspace-switch { + duration-ms 200 + curve "ease-out-cubic" + } } layer-rule { diff --git a/config/quickshell/.config/quickshell/Constants/Colors.qml b/config/quickshell/.config/quickshell/Constants/Colors.qml index f635cb7..3fbf02d 100644 --- a/config/quickshell/.config/quickshell/Constants/Colors.qml +++ b/config/quickshell/.config/quickshell/Constants/Colors.qml @@ -92,10 +92,11 @@ Singleton { } Connections { - target: ShellState - onColorStateChanged: { + function onColorStateChanged() { reloadTimer.restart(); } + + target: ShellState } Timer { diff --git a/config/quickshell/.config/quickshell/Modules/Background/Background.qml b/config/quickshell/.config/quickshell/Modules/Background/Background.qml index 1276f25..41dda20 100644 --- a/config/quickshell/.config/quickshell/Modules/Background/Background.qml +++ b/config/quickshell/.config/quickshell/Modules/Background/Background.qml @@ -3,6 +3,7 @@ import QtQuick.Effects import Quickshell import Quickshell.Wayland import qs.Constants +import qs.Modules.Background import qs.Services Variants { @@ -17,124 +18,9 @@ Variants { readonly property real blurPercentage: BackgroundService.blurPercentage readonly property real blurRadius: BackgroundService.blurRadius - PanelWindow { - id: bgWindow - - readonly property bool doBlur: BarService.focusMode && !BackgroundService.inPreviewMode - readonly property string imagePath: BackgroundService.displayPath - - screen: modelData - WlrLayershell.namespace: "quickshell-background" - WlrLayershell.layer: WlrLayer.Background - WlrLayershell.exclusionMode: ExclusionMode.Ignore - - anchors { - top: true - bottom: true - left: true - right: true - } - - Rectangle { - anchors.fill: parent - color: Colors.mSurface - - Item { - anchors.fill: parent - - Item { - id: bgManager - - property string activeSource: bgWindow.imagePath - property bool showFirst: true - - anchors.fill: parent - visible: false - onActiveSourceChanged: { - showFirst = !showFirst; - if (showFirst) - bgImg1.source = activeSource; - else - bgImg2.source = activeSource; - } - Component.onCompleted: { - if (showFirst) - bgImg1.source = activeSource; - else - bgImg2.source = activeSource; - } - - Image { - id: bgImg1 - - anchors.fill: parent - fillMode: Image.PreserveAspectCrop - opacity: (bgManager.showFirst && status === Image.Ready) ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationSlow - } - - } - - } - - Image { - id: bgImg2 - - anchors.fill: parent - fillMode: Image.PreserveAspectCrop - opacity: (!bgManager.showFirst && status === Image.Ready) ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationSlow - } - - } - - } - - } - - MultiEffect { - source: bgManager - anchors.fill: bgManager - colorizationColor: root.tintColor - colorization: bgWindow.doBlur ? root.tintOpacity : 0 - blurEnabled: true - blur: bgWindow.doBlur ? root.blurPercentage : 0 - blurMax: root.blurRadius - - Behavior on blur { - NumberAnimation { - duration: Style.animationSlow - } - - } - - Behavior on colorization { - NumberAnimation { - duration: Style.animationSlow - } - - } - - } - - } - - } - - } - PanelWindow { id: bdWindow - property bool doBlur: true - property string imagePath: BackgroundService.displayPath - screen: modelData WlrLayershell.namespace: "quickshell-backdrop" WlrLayershell.layer: WlrLayer.Background @@ -147,96 +33,10 @@ Variants { right: true } - Rectangle { - anchors.fill: parent - color: Colors.mSurface - - Item { - anchors.fill: parent - - Item { - id: backdropManager - - property string activeSource: bdWindow.imagePath - property bool showFirst: true - - anchors.fill: parent - visible: false - onActiveSourceChanged: { - showFirst = !showFirst; - if (showFirst) - bdImg1.source = activeSource; - else - bdImg2.source = activeSource; - } - Component.onCompleted: { - if (showFirst) - bdImg1.source = activeSource; - else - bdImg2.source = activeSource; - } - - Image { - id: bdImg1 - - anchors.fill: parent - fillMode: Image.PreserveAspectCrop - opacity: (backdropManager.showFirst && status === Image.Ready) ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationSlow - } - - } - - } - - Image { - id: bdImg2 - - anchors.fill: parent - fillMode: Image.PreserveAspectCrop - opacity: (!backdropManager.showFirst && status === Image.Ready) ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationSlow - } - - } - - } - - } - - MultiEffect { - source: backdropManager - anchors.fill: backdropManager - colorizationColor: root.tintColor - colorization: bdWindow.doBlur ? root.tintOpacity : 0 - blurEnabled: true - blur: bdWindow.doBlur ? root.blurPercentage : 0 - blurMax: root.blurRadius - - Behavior on blur { - NumberAnimation { - duration: Style.animationSlow - } - - } - - Behavior on colorization { - NumberAnimation { - duration: Style.animationSlow - } - - } - - } - - } + ScrollBackground { + id: scrollBg + screen: modelData } } diff --git a/config/quickshell/.config/quickshell/Modules/Background/BackgroundImage.qml b/config/quickshell/.config/quickshell/Modules/Background/BackgroundImage.qml new file mode 100644 index 0000000..ce9ef74 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Background/BackgroundImage.qml @@ -0,0 +1,93 @@ +import QtQuick +import qs.Constants +import qs.Utils + +Image { + id: root + + required property real viewportHeight + required property real scrollProgress + required property bool isOverview + required property real centerCropRatio + property bool isActive: true + readonly property real clampedCenterCropRatio: Math.max(0.01, Math.min(1, centerCropRatio)) + property real imageScale: isOverview ? 1 : 1 / clampedCenterCropRatio + readonly property real imageSourceWidth: sourceSize.width > 0 ? sourceSize.width : implicitWidth + readonly property real imageSourceHeight: sourceSize.height > 0 ? sourceSize.height : implicitHeight + readonly property real imageScaledHeight: { + if (imageSourceWidth <= 0 || imageSourceHeight <= 0 || width <= 0) + return 0; + + return Math.max(viewportHeight, width * imageSourceHeight / imageSourceWidth); + } + readonly property real effectiveScaledHeight: imageScaledHeight * imageScale + readonly property real initialYOffset: (imageScale - 1) * imageScaledHeight / 2 + readonly property real maxScrollOffset: Math.max(0, effectiveScaledHeight - viewportHeight) + + fillMode: Image.PreserveAspectCrop + width: parent.width + height: imageScaledHeight + transformOrigin: Item.Center + scale: imageScale + y: initialYOffset - maxScrollOffset * scrollProgress + + Connections { + function onStatusChanged() { + if (status === Image.Ready && isActive) + opacity = 1; + else + opacity = 0; + } + + function onIsActiveChanged() { + deleteTimer.stop(); + if (isActive && status === Image.Ready) { + opacity = 1; + } else { + opacity = 0; + if (!isActive) + deleteTimer.start(); + + } + } + + target: root + } + + Timer { + id: deleteTimer + + function onTriggered() { + root.source = ""; + } + + interval: Style.animationNormal + 100 + running: false + repeat: false + } + + Behavior on y { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + + } + + Behavior on scale { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + + } + + Behavior on opacity { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Background/ScrollBackground.qml b/config/quickshell/.config/quickshell/Modules/Background/ScrollBackground.qml new file mode 100644 index 0000000..8193e78 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Background/ScrollBackground.qml @@ -0,0 +1,159 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import qs.Constants +import qs.Services + +Item { + id: root + + required property ShellScreen screen + readonly property bool doBlur: BarService.focusMode && !BackgroundService.inPreviewMode + readonly property bool isOverview: Niri.overviewActive + readonly property real centerCropRatio: 0.9 + readonly property color tintColor: BackgroundService.tintColor + readonly property real tintOpacity: BackgroundService.tintOpacity + readonly property real blurPercentage: BackgroundService.blurPercentage + readonly property real blurRadius: BackgroundService.blurRadius + property string imagePath: "" + property bool showFirst: true + property int outputHeight: 0 + property int workspaceCount: 0 + property int focusedIndex: -1 + readonly property real viewportHeight: outputHeight > 0 ? outputHeight : height + readonly property real scrollProgress: { + if (workspaceCount <= 1 || focusedIndex < 0) + return 0; + + return Math.max(0, Math.min(1, (focusedIndex - 1) / (workspaceCount - 1))); + } + + function updateOutput() { + const outputInfo = Niri.outputCache[screen.name]; + if (!outputInfo) + return ; + + outputHeight = outputInfo.height; + } + + function updateWorkspaces() { + let count = 0; + for (let i = 0; i < Niri.workspaces.count; i++) { + const ws = Niri.workspaces.get(i); + if (ws.output.toLowerCase() === screen.name.toLowerCase()) + count++; + + } + workspaceCount = count; + updateFocusedIndex(); + } + + function updateFocusedIndex() { + const focusedWorkspaceId = Niri.focusedWorkspaceId; + if (focusedWorkspaceId === -1) + return ; + + const ws = Niri.workspaceCache[focusedWorkspaceId]; + if (!ws) + return ; + + if (ws.output.toLowerCase() !== screen.name.toLowerCase()) + return ; + + focusedIndex = ws.idx; + } + + anchors.fill: parent + Component.onCompleted: { + updateOutput(); + updateWorkspaces(); + } + + Connections { + function updateDisplay() { + showFirst = !showFirst; + if (showFirst) + bgImg1.source = imagePath; + else + bgImg2.source = imagePath; + } + + function onDisplayPathChanged() { + imagePath = BackgroundService.displayPath; + Qt.callLater(updateDisplay); + } + + target: BackgroundService + } + + Connections { + function onOutputsChanged() { + updateOutput(); + } + + function onWorkspaceChanged() { + updateWorkspaces(); + } + + function onFocusedWorkspaceIdChanged() { + updateFocusedIndex(); + } + + target: Niri + } + + Rectangle { + id: bgContainer + + anchors.fill: parent + clip: true + color: Colors.mSurface + + BackgroundImage { + id: bgImg1 + + viewportHeight: root.viewportHeight + scrollProgress: root.scrollProgress + isOverview: root.isOverview + centerCropRatio: root.centerCropRatio + isActive: root.showFirst + } + + BackgroundImage { + id: bgImg2 + + viewportHeight: root.viewportHeight + scrollProgress: root.scrollProgress + isOverview: root.isOverview + centerCropRatio: root.centerCropRatio + isActive: !root.showFirst + } + + } + + MultiEffect { + source: bgContainer + anchors.fill: bgContainer + colorizationColor: tintColor + colorization: doBlur ? tintOpacity : 0 + blurEnabled: true + blur: doBlur ? blurPercentage : 0 + blurMax: blurRadius + + Behavior on blur { + NumberAnimation { + duration: Style.animationSlow + } + + } + + Behavior on colorization { + NumberAnimation { + duration: Style.animationSlow + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml index e1be775..7921e43 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml @@ -55,6 +55,20 @@ Item { } + function syncWorkspaceFocus() { + for (let i = 0; i < localWorkspaces.count; i++) { + const ws = localWorkspaces.get(i); + const wsData = Niri.workspaceCache[ws.id]; + if (!wsData) { + localWorkspaces.setProperty(i, "isFocused", false); + localWorkspaces.setProperty(i, "isActive", false); + } else { + localWorkspaces.setProperty(i, "isFocused", wsData.isFocused); + localWorkspaces.setProperty(i, "isActive", wsData.isActive); + } + } + } + implicitWidth: pillRow.implicitWidth + horizontalPadding * 2 Component.onCompleted: syncWorkspaces() @@ -63,6 +77,10 @@ Item { syncWorkspaces(); } + function onFocusedWorkspaceIdChanged() { + syncWorkspaceFocus(); + } + target: Niri } diff --git a/config/quickshell/.config/quickshell/Services/BackgroundService.qml b/config/quickshell/.config/quickshell/Services/BackgroundService.qml index fb0e2bf..55cf289 100644 --- a/config/quickshell/.config/quickshell/Services/BackgroundService.qml +++ b/config/quickshell/.config/quickshell/Services/BackgroundService.qml @@ -10,7 +10,7 @@ Singleton { id: root readonly property string backgroundWidth: "2560" - readonly property string backgroundHeight: "1440" + readonly property string backgroundHeight: "1600" property string cachedPath: "" property string previewPath: "" property string displayPath: "" diff --git a/config/quickshell/.config/quickshell/Services/ImageCacheService.qml b/config/quickshell/.config/quickshell/Services/ImageCacheService.qml index 900862b..b93ddd2 100644 --- a/config/quickshell/.config/quickshell/Services/ImageCacheService.qml +++ b/config/quickshell/.config/quickshell/Services/ImageCacheService.qml @@ -77,7 +77,7 @@ Singleton { } // ------------------------------------------------- - // Public API: Get Large Image (scaled to specified dimensions) + // Public API: Get Large Image (scaled by max width, preserve aspect ratio) // ------------------------------------------------- function getLarge(sourcePath, width, height, callback) { if (!sourcePath || sourcePath === "") { @@ -89,28 +89,29 @@ Singleton { callback(sourcePath, false); return ; } - // Fast dimension check - skip processing if image fits screen AND format is Qt-native + if (width <= 0) { + callback(sourcePath, false); + return ; + } + const shouldCropToAspect = height > 0; getImageDimensions(sourcePath, function(imgWidth, imgHeight) { - // const fitsScreen = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height; - // if (fitsScreen) { - // // Only skip if format is natively supported by Qt - // if (!needsConversion(sourcePath)) { - // Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} fits screen ${width}x${height}, using original`); - // callback(sourcePath, false); - // return ; - // } - // Logger.d("ImageCache", `Image needs conversion despite fitting screen`); - // } - // Use actual image dimensions if it fits (convert without upscaling), otherwise use screen dimensions - // const targetWidth = fitsScreen ? imgWidth : width; - // const targetHeight = fitsScreen ? imgHeight : height; - const targetWidth = width; - const targetHeight = height; + const sourceWidth = imgWidth > 0 ? imgWidth : width; + const targetWidth = Math.min(sourceWidth, width); + let cropWidth = 0; + let cropHeight = 0; + if (shouldCropToAspect && imgWidth > 0 && imgHeight > 0) { + const sourceRatio = imgWidth / imgHeight; + const targetRatio = width / height; + if (sourceRatio > targetRatio) { + cropWidth = Math.max(1, Math.floor(imgHeight * targetRatio)); + cropHeight = imgHeight; + } + } getMtime(sourcePath, function(mtime) { - const cacheKey = generateLargeKey(sourcePath, width, height, mtime); + const cacheKey = generateLargeKey(sourcePath, targetWidth, height, mtime); const cachedPath = wpLargeDir + cacheKey + ".png"; processRequest(cacheKey, cachedPath, sourcePath, callback, function() { - startLargeProcessing(sourcePath, cachedPath, targetWidth, targetHeight, cacheKey); + startLargeProcessing(sourcePath, cachedPath, targetWidth, cropWidth, cropHeight, cacheKey); }); }); }); @@ -187,7 +188,7 @@ Singleton { // Cache Key Generation // ------------------------------------------------- function generateLargeKey(sourcePath, width, height, mtime) { - const keyString = sourcePath + "@" + width + "x" + height + "@" + (mtime || "unknown"); + const keyString = sourcePath + "@w" + width + "@h" + height + "@" + (mtime || "unknown"); return Checksum.sha256(keyString); } @@ -249,11 +250,13 @@ Singleton { // ------------------------------------------------- // ImageMagick Processing: Large // ------------------------------------------------- - function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) { + function startLargeProcessing(sourcePath, outputPath, width, cropWidth, cropHeight, cacheKey) { const srcEsc = sourcePath.replace(/'/g, "'\\''"); const dstEsc = outputPath.replace(/'/g, "'\\''"); - // Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output - const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '${width}x${height}' -gravity center -unsharp 0x0.5 '${dstEsc}'`; + // `${width}x>` means keep aspect ratio, cap by width, and never upscale. + // Crop is prepared in QML for compatibility with ImageMagick versions lacking `-if`. + const cropStep = (cropWidth > 0 && cropHeight > 0) ? ` -gravity center -crop '${cropWidth}x${cropHeight}+0+0' +repage` : ""; + const command = `magick '${srcEsc}' -auto-orient${cropStep} -filter Lanczos -resize '${width}x>' -unsharp 0x0.5 '${dstEsc}'`; runProcess(command, cacheKey, outputPath, sourcePath); } diff --git a/config/quickshell/.config/quickshell/Services/Niri.qml b/config/quickshell/.config/quickshell/Services/Niri.qml index dc59434..47a1509 100644 --- a/config/quickshell/.config/quickshell/Services/Niri.qml +++ b/config/quickshell/.config/quickshell/Services/Niri.qml @@ -15,6 +15,7 @@ Singleton { }) property bool hasFocusedWindow: focusedWindowIndex >= 0 property int focusedWindowIndex: -1 + property int focusedWorkspaceId: -1 property string focusedWindowAppId: hasFocusedWindow ? windows[focusedWindowIndex].appId : "" property string focusedWindowTitle: hasFocusedWindow ? windows[focusedWindowIndex].title : "" property string focusedOutput: "" @@ -38,7 +39,7 @@ Singleton { signal workspaceChanged() signal activeWindowChanged() signal windowListChanged() - signal displayScalesChanged() + signal outputsChanged() function initialize() { niriEventStream.connected = true; @@ -111,12 +112,14 @@ Singleton { scales[output.name] = outputData; } } + outputsChanged(); } function _recollectWorkspaces(workspacesData) { const workspacesList = []; workspaceCache = { }; + focusedWorkspaceId = -1; for (const ws of workspacesData) { const wsData = { "id": ws.id, @@ -130,9 +133,10 @@ Singleton { }; workspacesList.push(wsData); workspaceCache[ws.id] = wsData; - if (wsData.isFocused) + if (wsData.isFocused) { focusedOutput = wsData.output || ""; - + focusedWorkspaceId = wsData.id; + } } workspacesList.sort((a, b) => { if (a.output !== b.output) @@ -234,14 +238,13 @@ Singleton { nextIndex = cachedIndex; } - if (nextIndex < 0 && focusedWindowIndex >= 0 && focusedWindowIndex < windows.length && windows[focusedWindowIndex].isFocused) nextIndex = focusedWindowIndex; if (nextIndex < 0) nextIndex = windows.findIndex((w) => { - return w.isFocused; - }); + return w.isFocused; + }); const hasChanged = nextIndex !== focusedWindowIndex; focusedWindowIndex = nextIndex; @@ -291,7 +294,6 @@ Singleton { activeWindowChanged(); } windowListChanged(); - workspaceUpdateTimer.restart(); } catch (e) { Logger.e("NiriService", "Error handling WindowOpenedOrChanged:", e); } @@ -310,7 +312,6 @@ Singleton { activeWindowChanged(); windowListChanged(); - workspaceUpdateTimer.restart(); } } catch (e) { Logger.e("NiriService", "Error handling WindowClosed:", e); @@ -326,6 +327,44 @@ Singleton { } } + function _handleWorkspaceActivated(eventData) { + try { + const workspaceId = eventData.id; + if (workspaceId === focusedWorkspaceId) + return ; + + // update workspaceCache + const workspace = workspaceCache[workspaceId]; + if (!workspace) { + workspaceUpdateTimer.restart(); + return ; + } + const focusedOutputName = workspace.output; + focusedOutput = focusedOutputName || ""; + const oldWorkspace = workspaceCache[focusedWorkspaceId]; + if (oldWorkspace) { + oldWorkspace.isFocused = false; + oldWorkspace.isActive = oldWorkspace.output !== focusedOutputName; + } + workspace.isFocused = true; + workspace.isActive = true; + // update workspaces ListModel + for (var i = 0; i < workspaces.count; i++) { + const ws = workspaces.get(i); + if (ws.id === workspaceId) { + workspaces.setProperty(i, "isFocused", true); + workspaces.setProperty(i, "isActive", true); + } else if (ws.id === focusedWorkspaceId) { + workspaces.setProperty(i, "isFocused", false); + workspaces.setProperty(i, "isActive", ws.output !== focusedOutputName); + } + } + focusedWorkspaceId = workspaceId; + } catch (e) { + Logger.e("NiriService", "Error handling WorkspaceActivated:", e); + } + } + function _handleWindowFocusChanged(eventData) { try { const focusedId = eventData.id; @@ -361,7 +400,6 @@ Singleton { window.position = getWindowPosition(layout); hasMatchedWindow = hasMatchedWindow || window !== null; - } if (!hasMatchedWindow) return ; @@ -527,7 +565,7 @@ Singleton { else if (event.WindowsChanged) _handleWindowsChanged(event.WindowsChanged); else if (event.WorkspaceActivated) - workspaceUpdateTimer.restart(); + _handleWorkspaceActivated(event.WorkspaceActivated); else if (event.WindowFocusChanged) _handleWindowFocusChanged(event.WindowFocusChanged); else if (event.WindowLayoutsChanged)