From b6a27dc7135755b8c7d2b6b32cd0dc1b11bb5b4b Mon Sep 17 00:00:00 2001 From: bbedward Date: Sun, 5 Jul 2026 11:17:21 -0400 Subject: [PATCH] wallpaper: rewrite CachingImage and make it work with file browser fixes #2756 --- .../FileBrowser/FileBrowserGridDelegate.qml | 19 +- .../FileBrowser/FileBrowserListDelegate.qml | 24 +- quickshell/Modules/DankDash/WallpaperTab.qml | 259 ++++++++++-------- quickshell/Widgets/CachingImage.qml | 56 ++-- 4 files changed, 198 insertions(+), 160 deletions(-) diff --git a/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml index 8793aa5f9..ff8e528ef 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml @@ -169,8 +169,6 @@ StyledRect { property string imagePath: { if (weMode && delegateRoot.fileIsDir) return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex]; - if (!delegateRoot.fileIsDir && isImage) - return delegateRoot.filePath; if (_videoThumb) return _videoThumb; return ""; @@ -192,13 +190,28 @@ StyledRect { visible: false } + CachingImage { + anchors.fill: parent + anchors.margins: 2 + imagePath: !delegateRoot.fileIsDir && isImage ? delegateRoot.filePath : "" + maxCacheSize: 256 + visible: !delegateRoot.fileIsDir && isImage + layer.enabled: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: gridImageMask + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + } + MultiEffect { anchors.fill: parent anchors.margins: 2 source: gridPreviewImage maskEnabled: true maskSource: gridImageMask - visible: gridPreviewImage.status === Image.Ready && ((!delegateRoot.fileIsDir && (isImage || isVideo)) || (weMode && delegateRoot.fileIsDir)) + visible: gridPreviewImage.status === Image.Ready && ((!delegateRoot.fileIsDir && isVideo) || (weMode && delegateRoot.fileIsDir)) maskThresholdMin: 0.5 maskSpreadAtMin: 1 } diff --git a/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml index 970b5542f..6d0ea8bfb 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml @@ -168,13 +168,7 @@ StyledRect { Image { id: listPreviewImage anchors.fill: parent - property string imagePath: { - if (!listDelegateRoot.fileIsDir && isImage) - return listDelegateRoot.filePath; - if (_videoThumb) - return _videoThumb; - return ""; - } + property string imagePath: _videoThumb source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : "" fillMode: Image.PreserveAspectCrop sourceSize.width: 32 @@ -183,12 +177,26 @@ StyledRect { visible: false } + CachingImage { + anchors.fill: parent + imagePath: !listDelegateRoot.fileIsDir && isImage ? listDelegateRoot.filePath : "" + maxCacheSize: 256 + visible: !listDelegateRoot.fileIsDir && isImage + layer.enabled: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: listImageMask + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 + } + } + MultiEffect { anchors.fill: parent source: listPreviewImage maskEnabled: true maskSource: listImageMask - visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && (isImage || isVideo) + visible: listPreviewImage.status === Image.Ready && !listDelegateRoot.fileIsDir && isVideo maskThresholdMin: 0.5 maskSpreadAtMin: 1 } diff --git a/quickshell/Modules/DankDash/WallpaperTab.qml b/quickshell/Modules/DankDash/WallpaperTab.qml index 0bf30db22..33231a152 100644 --- a/quickshell/Modules/DankDash/WallpaperTab.qml +++ b/quickshell/Modules/DankDash/WallpaperTab.qml @@ -20,12 +20,11 @@ Item { property int itemsPerPage: 16 property int totalPages: Math.max(1, Math.ceil(wallpaperFolderModel.count / itemsPerPage)) property bool active: false - property Item focusTarget: wallpaperGrid + property Item focusTarget: pager property Item tabBarItem: null property int gridIndex: 0 property Item keyForwardTarget: null property var parentPopout: null - property int lastPage: 0 property bool enableAnimation: false property string homeDir: StandardPaths.writableLocation(StandardPaths.HomeLocation) property string selectedFileName: "" @@ -86,12 +85,12 @@ Item { } } - onCurrentPageChanged: { - if (currentPage !== lastPage) { - enableAnimation = false; - lastPage = currentPage; + onCurrentPageChanged: updateSelectedFileName() + + onTotalPagesChanged: { + if (currentPage >= totalPages) { + currentPage = Math.max(0, totalPages - 1); } - updateSelectedFileName(); } onGridIndexChanged: { @@ -257,6 +256,7 @@ Item { } function setInitialSelection() { + enableAnimation = false; const currentWallpaper = getCurrentWallpaper(); if (!currentWallpaper || wallpaperFolderModel.count === 0) { gridIndex = 0; @@ -447,147 +447,176 @@ Item { width: parent.width height: parent.height - 50 - GridView { - id: wallpaperGrid + ListView { + id: pager anchors.centerIn: parent width: parent.width - Theme.spacingS height: parent.height - Theme.spacingS - cellWidth: width / 4 - cellHeight: height / 4 + orientation: ListView.Vertical + snapMode: ListView.SnapOneItem + highlightRangeMode: ListView.StrictlyEnforceRange + preferredHighlightBegin: 0 + preferredHighlightEnd: height + highlightMoveDuration: root.enableAnimation ? Theme.mediumDuration : 0 + boundsBehavior: Flickable.StopAtBounds clip: true enabled: root.active interactive: root.active - boundsBehavior: Flickable.StopAtBounds keyNavigationEnabled: false activeFocusOnTab: false - highlightFollowsCurrentItem: true - highlightMoveDuration: enableAnimation ? Theme.shortDuration : 0 focus: false + cacheBuffer: Math.max(0, height * 2) + model: root.totalPages - highlight: Item { - z: 1000 - Rectangle { - anchors.fill: parent - anchors.margins: Theme.spacingXS - color: "transparent" - border.width: 3 - border.color: Theme.primary - radius: Theme.cornerRadius + onCurrentIndexChanged: { + if (!moving) { + return; + } + if (currentIndex >= 0 && currentIndex !== root.currentPage) { + root.currentPage = currentIndex; } } - model: { - root.gridRevision; // re-evaluate when sort order changes in place - const startIndex = currentPage * itemsPerPage; - const endIndex = Math.min(startIndex + itemsPerPage, wallpaperFolderModel.count); - const items = []; - for (var i = startIndex; i < endIndex; i++) { - const filePath = wallpaperFolderModel.get(i, "filePath"); - if (filePath) { - items.push(filePath.toString().replace(/^file:\/\//, '')); - } - } - return items; - } - - onModelChanged: { - const clampedIndex = model.length > 0 ? Math.min(Math.max(0, gridIndex), model.length - 1) : 0; - if (gridIndex !== clampedIndex) { - gridIndex = clampedIndex; - } - } - - onCountChanged: { - if (count > 0) { - const clampedIndex = Math.min(gridIndex, count - 1); - currentIndex = clampedIndex; - positionViewAtIndex(clampedIndex, GridView.Contain); - } - Qt.callLater(() => { - enableAnimation = true; - }); - } + Component.onCompleted: currentIndex = root.currentPage Connections { target: root - function onGridIndexChanged() { - if (wallpaperGrid.count > 0) { - wallpaperGrid.currentIndex = gridIndex; - if (!enableAnimation) { - wallpaperGrid.positionViewAtIndex(gridIndex, GridView.Contain); - } + function onCurrentPageChanged() { + if (pager.currentIndex !== root.currentPage) { + pager.currentIndex = root.currentPage; } } } - delegate: Item { - width: wallpaperGrid.cellWidth - height: wallpaperGrid.cellHeight + delegate: GridView { + id: pageGrid - property string wallpaperPath: modelData || "" - property bool isSelected: getCurrentWallpaper() === modelData + property int pageIndex: index - Rectangle { - id: wallpaperCard - anchors.fill: parent - anchors.margins: Theme.spacingXS - color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) - radius: Theme.cornerRadius - clip: true + width: pager.width + height: pager.height + cellWidth: width / 4 + cellHeight: height / 4 + interactive: false + keyNavigationEnabled: false + activeFocusOnTab: false + focus: false + highlightFollowsCurrentItem: true + highlightMoveDuration: root.enableAnimation ? Theme.shortDuration : 0 + currentIndex: root.currentPage === pageIndex ? root.gridIndex : -1 + highlight: Item { + z: 1000 Rectangle { anchors.fill: parent - color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) - radius: parent.radius + anchors.margins: Theme.spacingXS + color: "transparent" + border.width: 3 + border.color: Theme.primary + radius: Theme.cornerRadius + } + } - Behavior on color { - ColorAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing + model: { + root.gridRevision; // re-evaluate when sort order changes in place + const startIndex = pageIndex * root.itemsPerPage; + const endIndex = Math.min(startIndex + root.itemsPerPage, wallpaperFolderModel.count); + const items = []; + for (var i = startIndex; i < endIndex; i++) { + const filePath = wallpaperFolderModel.get(i, "filePath"); + if (filePath) { + items.push(filePath.toString().replace(/^file:\/\//, '')); + } + } + return items; + } + + onCountChanged: { + if (root.currentPage !== pageIndex || count === 0) { + return; + } + if (root.gridIndex >= count) { + root.gridIndex = count - 1; + } + } + + delegate: Item { + width: pageGrid.cellWidth + height: pageGrid.cellHeight + + property string wallpaperPath: modelData || "" + property bool isSelected: getCurrentWallpaper() === modelData + + Rectangle { + id: wallpaperCard + anchors.fill: parent + anchors.margins: Theme.spacingXS + color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) + radius: Theme.cornerRadius + clip: true + + Rectangle { + anchors.fill: parent + color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) + radius: parent.radius + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } } } - } - Rectangle { - id: maskRect - width: thumbnailImage.width - height: thumbnailImage.height - radius: Theme.cornerRadius - visible: false - layer.enabled: true - } - - CachingImage { - id: thumbnailImage - anchors.fill: parent - imagePath: modelData || "" - maxCacheSize: 256 - - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskThresholdMin: 0.5 - maskSpreadAtMin: 1.0 - maskSource: maskRect + Rectangle { + id: maskRect + width: thumbnailImage.width + height: thumbnailImage.height + radius: Theme.cornerRadius + visible: false + layer.enabled: true } - } - StateLayer { - anchors.fill: parent - cornerRadius: parent.radius - stateColor: Theme.primary - } + CachingImage { + id: thumbnailImage + anchors.fill: parent + imagePath: modelData || "" + maxCacheSize: 256 + opacity: status === Image.Ready ? 1 : 0 - MouseArea { - id: wallpaperMouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor + layer.enabled: true + layer.effect: MultiEffect { + maskEnabled: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 1.0 + maskSource: maskRect + } - onClicked: { - gridIndex = index; - if (modelData) { - setCurrentWallpaper(modelData); + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + + StateLayer { + anchors.fill: parent + cornerRadius: parent.radius + stateColor: Theme.primary + } + + MouseArea { + id: wallpaperMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + gridIndex = index; + if (modelData) { + setCurrentWallpaper(modelData); + } } } } diff --git a/quickshell/Widgets/CachingImage.qml b/quickshell/Widgets/CachingImage.qml index a8a3b281e..b8f410406 100644 --- a/quickshell/Widgets/CachingImage.qml +++ b/quickshell/Widgets/CachingImage.qml @@ -1,5 +1,4 @@ import QtQuick -import Quickshell.Io import qs.Common Item { @@ -9,6 +8,7 @@ Item { property int maxCacheSize: 512 property int status: isAnimated ? animatedImg.status : staticImg.status property int fillMode: Image.PreserveAspectCrop + property bool _fromCache: false readonly property bool isRemoteUrl: imagePath.startsWith("http://") || imagePath.startsWith("https://") readonly property bool isAnimated: { @@ -69,58 +69,46 @@ Item { smooth: true onStatusChanged: { - if (source === root.cachePath && status === Image.Error) { + switch (status) { + case Image.Error: + if (!root._fromCache) + return; + root._fromCache = false; source = root.encodedImagePath; return; - } - if (root.isRemoteUrl || source !== root.encodedImagePath || status !== Image.Ready || !root.cachePath) - return; - Paths.mkdir(Paths.imagecache); - const grabPath = root.cachePath; - if (visible && width > 0 && height > 0 && Window.window?.visible) { + case Image.Ready: + if (root._fromCache || root.isRemoteUrl || !root.cachePath) + return; + if (!visible || width <= 0 || height <= 0 || !Window.window?.visible) + return; + Paths.mkdir(Paths.imagecache); + const grabPath = root.cachePath; grabToImage(res => res.saveToFile(grabPath)); - } - } - } - - Process { - id: cacheProbe - - property string cachePath: "" - property string fallbackSource: "" - - running: false - command: ["test", "-f", cachePath] - - onExited: exitCode => { - if (cacheProbe.cachePath !== root.cachePath) return; - staticImg.source = exitCode === 0 ? cacheProbe.cachePath : cacheProbe.fallbackSource; + } } } onImagePathChanged: { if (!imagePath) { + _fromCache = false; staticImg.source = ""; return; } if (isAnimated) return; if (isRemoteUrl) { + _fromCache = false; staticImg.source = imagePath; return; } - Paths.mkdir(Paths.imagecache); - const hash = djb2Hash(normalizedPath); - const cPath = hash ? `${Paths.stringify(Paths.imagecache)}/${hash}@${maxCacheSize}x${maxCacheSize}.png` : ""; - const encoded = "file://" + normalizedPath.split('/').map(s => encodeURIComponent(s)).join('/'); - if (!cPath) { - staticImg.source = encoded; + if (!cachePath) { + _fromCache = false; + staticImg.source = encodedImagePath; return; } - cacheProbe.running = false; - cacheProbe.cachePath = cPath; - cacheProbe.fallbackSource = encoded; - cacheProbe.running = true; + Paths.mkdir(Paths.imagecache); + _fromCache = true; + staticImg.source = cachePath; } }