From 405ea708b3fc926f0e6b594542a4ac2c6f00bb76 Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 6 Jul 2026 13:52:31 -0400 Subject: [PATCH] wallpaper: optimize caching and performance of wallpaper tab --- quickshell/Common/CacheUtils.qml | 47 +--------- quickshell/Common/Paths.qml | 3 + .../Modules/DankDash/DankDashPopout.qml | 7 ++ quickshell/Modules/DankDash/WallpaperTab.qml | 94 ++++++++----------- quickshell/Widgets/CachingImage.qml | 29 +++--- .../Widgets/WallpaperThumbnailPreloader.qml | 94 ------------------- 6 files changed, 62 insertions(+), 212 deletions(-) delete mode 100644 quickshell/Widgets/WallpaperThumbnailPreloader.qml diff --git a/quickshell/Common/CacheUtils.qml b/quickshell/Common/CacheUtils.qml index 5e277a566..85d4f2b4e 100644 --- a/quickshell/Common/CacheUtils.qml +++ b/quickshell/Common/CacheUtils.qml @@ -7,62 +7,17 @@ import qs.Common Singleton { id: root - // Filenames present on disk, so CachingImage never points an Image at a - // missing cache file (Qt logs that as a "Cannot open" warning). - property var cachedFiles: ({}) - - Component.onCompleted: scan() - - function scan() { - Proc.runCommand("imagecache_scan", ["ls", "-1", Paths.stringify(Paths.imagecache)], function (output, exitCode) { - const map = {}; - if (exitCode === 0 && output) { - const lines = output.split("\n"); - for (var i = 0; i < lines.length; i++) { - const name = lines[i].trim(); - if (name) - map[name] = true; - } - } - root.cachedFiles = map; - }); - } - - function hasCachedFile(name) { - return name ? root.cachedFiles[name] === true : false; - } - - function recordCachedFile(name) { - if (name) - root.cachedFiles[name] = true; - } - - function forgetCachedFile(name) { - if (name && root.cachedFiles[name] !== undefined) - delete root.cachedFiles[name]; - } - function clearImageCache() { Quickshell.execDetached(["rm", "-rf", Paths.stringify(Paths.imagecache)]); Paths.mkdir(Paths.imagecache); - root.cachedFiles = ({}); } function clearOldCache(ageInMinutes) { - // Rescan on delete completion since we can't know which files matched. - Proc.runCommand("imagecache_prune", ["find", Paths.stringify(Paths.imagecache), "-name", "*.png", "-mmin", `+${ageInMinutes}`, "-delete"], function (output, exitCode) { - root.scan(); - }); + Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", "*.png", "-mmin", `+${ageInMinutes}`, "-delete"]); } function clearCacheForSize(size) { Quickshell.execDetached(["find", Paths.stringify(Paths.imagecache), "-name", `*@${size}x${size}.png`, "-delete"]); - const suffix = `@${size}x${size}.png`; - const map = {}; - for (const key in root.cachedFiles) - if (!key.endsWith(suffix)) - map[key] = true; - root.cachedFiles = map; } function getCacheSize(callback) { diff --git a/quickshell/Common/Paths.qml b/quickshell/Common/Paths.qml index b38f6192f..1e9784761 100644 --- a/quickshell/Common/Paths.qml +++ b/quickshell/Common/Paths.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import Quickshell import QtCore +import QtQuick import qs.Services Singleton { @@ -19,6 +20,8 @@ Singleton { readonly property url imagecache: `${cache}/imagecache` + Component.onCompleted: mkdir(imagecache) + function stringify(path: url): string { return path.toString().replace(/%20/g, " "); } diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml index 4b4df9b8e..79b62f28a 100644 --- a/quickshell/Modules/DankDash/DankDashPopout.qml +++ b/quickshell/Modules/DankDash/DankDashPopout.qml @@ -447,6 +447,7 @@ DankPopout { anchors.fill: parent active: root.currentTabId === "wallpaper" visible: active + asynchronous: true sourceComponent: Component { WallpaperTab { active: true @@ -458,6 +459,12 @@ DankPopout { } } + DankSpinner { + anchors.centerIn: parent + size: 40 + visible: wallpaperLoader.active && wallpaperLoader.status === Loader.Loading + } + Loader { id: weatherLoader anchors.fill: parent diff --git a/quickshell/Modules/DankDash/WallpaperTab.qml b/quickshell/Modules/DankDash/WallpaperTab.qml index 33231a152..14bad9c53 100644 --- a/quickshell/Modules/DankDash/WallpaperTab.qml +++ b/quickshell/Modules/DankDash/WallpaperTab.qml @@ -1,7 +1,8 @@ import Qt.labs.folderlistmodel import QtCore import QtQuick -import QtQuick.Effects +import Quickshell +import Quickshell.Widgets import qs.Common import qs.Modals.FileBrowser import qs.Widgets @@ -350,17 +351,6 @@ Item { } } - function collectWallpaperPaths() { - const paths = []; - for (var i = 0; i < wallpaperFolderModel.count; i++) { - const filePath = wallpaperFolderModel.get(i, "filePath"); - if (filePath) { - paths.push(filePath.toString().replace(/^file:\/\//, '')); - } - } - return paths; - } - Connections { target: wallpaperFolderModel function onCountChanged() { @@ -369,7 +359,6 @@ Item { setInitialSelection(); } updateSelectedFileName(); - thumbnailPreloader.paths = collectWallpaperPaths(); } } function onStatusChanged() { @@ -378,16 +367,10 @@ Item { setInitialSelection(); } updateSelectedFileName(); - thumbnailPreloader.paths = collectWallpaperPaths(); } } } - WallpaperThumbnailPreloader { - id: thumbnailPreloader - cacheSize: 256 - } - FolderListModel { id: wallpaperFolderModel @@ -466,6 +449,7 @@ Item { activeFocusOnTab: false focus: false cacheBuffer: Math.max(0, height * 2) + reuseItems: true model: root.totalPages onCurrentIndexChanged: { @@ -517,18 +501,21 @@ Item { } } - 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:\/\//, '')); + reuseItems: true + model: ScriptModel { + values: { + root.gridRevision; // re-evaluate when sort order changes in place + const startIndex = pageGrid.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; } - return items; } onCountChanged: { @@ -553,7 +540,6 @@ Item { anchors.margins: Theme.spacingXS color: Theme.withAlpha(Theme.surfaceContainerHighest, Theme.popupTransparency) radius: Theme.cornerRadius - clip: true Rectangle { anchors.fill: parent @@ -568,34 +554,24 @@ Item { } } - Rectangle { - id: maskRect - width: thumbnailImage.width - height: thumbnailImage.height - radius: Theme.cornerRadius - visible: false - layer.enabled: true - } - - CachingImage { - id: thumbnailImage + ClippingRectangle { anchors.fill: parent - imagePath: modelData || "" - maxCacheSize: 256 - opacity: status === Image.Ready ? 1 : 0 + radius: wallpaperCard.radius + color: "transparent" - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskThresholdMin: 0.5 - maskSpreadAtMin: 1.0 - maskSource: maskRect - } + CachingImage { + id: thumbnailImage + anchors.fill: parent + imagePath: modelData || "" + maxCacheSize: 256 + animate: false + opacity: status === Image.Ready ? 1 : 0 - Behavior on opacity { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } } } } @@ -624,9 +600,15 @@ Item { } } + DankSpinner { + anchors.centerIn: parent + size: 40 + visible: wallpaperFolderModel.status === FolderListModel.Loading && wallpaperFolderModel.count === 0 + } + StyledText { anchors.centerIn: parent - visible: wallpaperFolderModel.count === 0 + visible: wallpaperFolderModel.status === FolderListModel.Ready && wallpaperFolderModel.count === 0 text: I18n.tr("No wallpapers found\n\nClick the folder icon below to browse") font.pixelSize: 14 color: Theme.outline diff --git a/quickshell/Widgets/CachingImage.qml b/quickshell/Widgets/CachingImage.qml index f20096e26..9ea80934c 100644 --- a/quickshell/Widgets/CachingImage.qml +++ b/quickshell/Widgets/CachingImage.qml @@ -8,11 +8,14 @@ Item { property int maxCacheSize: 512 property int status: isAnimated ? animatedImg.status : staticImg.status property int fillMode: Image.PreserveAspectCrop + // AnimatedImage decodes full-size on the GUI thread and is never cached; + // disable for thumbnail grids + property bool animate: true property bool _fromCache: false readonly property bool isRemoteUrl: imagePath.startsWith("http://") || imagePath.startsWith("https://") readonly property bool isAnimated: { - if (!imagePath) + if (!animate || !imagePath) return false; const lower = imagePath.toLowerCase(); return lower.endsWith(".gif") || lower.endsWith(".webp"); @@ -75,7 +78,6 @@ Item { if (!root._fromCache) return; root._fromCache = false; - CacheUtils.forgetCachedFile(root.cacheFileName); source = root.encodedImagePath; return; case Image.Ready: @@ -83,19 +85,16 @@ Item { return; if (!visible || width <= 0 || height <= 0 || !Window.window?.visible) return; - Paths.mkdir(Paths.imagecache); const grabPath = root.cachePath; - const grabName = root.cacheFileName; grabToImage(res => { - if (res.saveToFile(grabPath)) - CacheUtils.recordCachedFile(grabName); + res.saveToFile(grabPath); }); return; } } } - onImagePathChanged: { + function resolveSource() { if (!imagePath) { _fromCache = false; staticImg.source = ""; @@ -113,14 +112,12 @@ Item { staticImg.source = encodedImagePath; return; } - Paths.mkdir(Paths.imagecache); - // Read cache only when present; else load source and cache it on Ready - if (CacheUtils.hasCachedFile(cacheFileName)) { - _fromCache = true; - staticImg.source = cachePath; - } else { - _fromCache = false; - staticImg.source = encodedImagePath; - } + // Cache-first; a miss errors and falls back to encodedImagePath + _fromCache = true; + staticImg.source = cachePath; } + + onImagePathChanged: resolveSource() + // During creation onImagePathChanged fires before sibling properties (maxCacheSize) initialize + onCachePathChanged: resolveSource() } diff --git a/quickshell/Widgets/WallpaperThumbnailPreloader.qml b/quickshell/Widgets/WallpaperThumbnailPreloader.qml deleted file mode 100644 index 32befdfaf..000000000 --- a/quickshell/Widgets/WallpaperThumbnailPreloader.qml +++ /dev/null @@ -1,94 +0,0 @@ -pragma ComponentBehavior: Bound - -import QtQuick -import qs.Common - -// Preload the CachingImage disk cache for a folder of wallpapers via ffmpegthumbnailer -// so the switcher grid renders instantly. No-op (graceful fallback) if the tool is absent. -Item { - id: root - - visible: false - - property var paths: [] - property int cacheSize: 256 - property bool autoStart: true - property int maxConcurrent: 3 - - property int _active: 0 - property var _queue: [] - property int _toolState: -1 // -1 unknown, 0 unavailable, 1 available - - onPathsChanged: if (autoStart) - preload() - - // Must match djb2Hash + cachePath in Widgets/CachingImage.qml. - function _hash(str) { - if (!str) - return ""; - let hash = 5381; - for (let i = 0; i < str.length; i++) { - hash = ((hash << 5) + hash) + str.charCodeAt(i); - hash = hash & 0x7FFFFFFF; - } - return hash.toString(16).padStart(8, '0'); - } - - function _cachePathFor(path) { - const hash = _hash(path); - if (!hash) - return ""; - return `${Paths.stringify(Paths.imagecache)}/${hash}@${cacheSize}x${cacheSize}.png`; - } - - function _isAnimated(path) { - const lower = path.toLowerCase(); - return lower.endsWith(".gif") || lower.endsWith(".webp"); - } - - function preload() { - if (!paths || paths.length === 0 || _toolState === 0) - return; - if (_toolState === -1) { - Proc.runCommand("wallpaperThumbToolCheck", ["sh", "-c", "command -v ffmpegthumbnailer"], function (out, code) { - root._toolState = code === 0 ? 1 : 0; - if (root._toolState === 1) - root._start(); - }); - return; - } - _start(); - } - - function _start() { - Paths.mkdir(Paths.imagecache); - const q = []; - for (let i = 0; i < paths.length; i++) { - const p = paths[i]; - if (!p || p.startsWith("#") || _isAnimated(p)) - continue; - q.push(p); - } - _queue = q; - for (let i = 0; i < maxConcurrent; i++) - _pump(); - } - - function _pump() { - if (_queue.length === 0) - return; - const path = _queue.shift(); - const cachePath = _cachePathFor(path); - if (!cachePath) { - _pump(); - return; - } - _active++; - // One process per file: skip if already cached, otherwise generate. - const script = "test -f \"$1\" || ffmpegthumbnailer -i \"$2\" -o \"$1\" -s " + cacheSize; - Proc.runCommand(null, ["sh", "-c", script, "thumb", cachePath, path], function (out, code) { - root._active--; - root._pump(); - }, 0, 20000); - } -}