From 3e481a566b1502a19484bdda2c1e330e44880359 Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 6 Jul 2026 15:57:19 -0400 Subject: [PATCH] wallpaper: more resilience to updatesEnabled missing expose/update events --- core/internal/log/log.go | 8 +- .../FileBrowser/FileBrowserGridDelegate.qml | 135 ++++++++---------- .../FileBrowser/FileBrowserListDelegate.qml | 97 +++++-------- .../Modules/BlurredWallpaperBackground.qml | 57 +++++++- quickshell/Modules/WallpaperBackground.qml | 58 +++++++- 5 files changed, 208 insertions(+), 147 deletions(-) diff --git a/core/internal/log/log.go b/core/internal/log/log.go index c52ed6234..4d1e19ee9 100644 --- a/core/internal/log/log.go +++ b/core/internal/log/log.go @@ -68,6 +68,8 @@ func GetQtLoggingRules() string { level = "info" } + // scene carries QML engine warnings (e.g. QQuickImage "Cannot open" cache + // probes); suppressed except at debug level var rules []string switch strings.ToLower(level) { case "fatal": @@ -75,13 +77,13 @@ func GetQtLoggingRules() string { case "error": rules = []string{"*.debug=false", "*.info=false", "*.warning=false"} case "warn", "warning": - rules = []string{"*.debug=false", "*.info=false"} + rules = []string{"*.debug=false", "*.info=false", "scene.warning=false"} case "info": - rules = []string{"*.debug=false"} + rules = []string{"*.debug=false", "scene.warning=false"} case "debug": return "" default: - rules = []string{"*.debug=false"} + rules = []string{"*.debug=false", "scene.warning=false"} } return strings.Join(rules, ";") diff --git a/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml index ff8e528ef..47b2b2796 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserGridDelegate.qml @@ -1,5 +1,5 @@ import QtQuick -import QtQuick.Effects +import Quickshell.Widgets import qs.Common import qs.Widgets @@ -98,25 +98,25 @@ StyledRect { } property string _videoThumb: "" + property bool _thumbGenAttempted: false + // Probe the thumbnail optimistically; Image.Error triggers generation onVideoThumbnailPathChanged: { - _videoThumb = ""; - if (!videoThumbnailPath) + _thumbGenAttempted = false; + _videoThumb = videoThumbnailPath; + } + + function generateVideoThumbnail() { + if (_thumbGenAttempted) return; + _thumbGenAttempted = true; + _videoThumb = ""; const thumbPath = videoThumbnailPath; const thumbDir = _xdgCacheHome + "/thumbnails/" + _thumbnailSize; - const size = _thumbnailPx; - const fp = delegateRoot.filePath; - Paths.mkdir(thumbDir); - Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) { - if (exitCode === 0) { + const script = "mkdir -p \"$1\" && ffmpegthumbnailer -i \"$2\" -o \"$3\" -s " + _thumbnailPx + " -f"; + Proc.runCommand(null, ["sh", "-c", script, "thumb", thumbDir, delegateRoot.filePath, thumbPath], function (output, exitCode) { + if (exitCode === 0) _videoThumb = thumbPath; - } else { - Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function (output, exitCode) { - if (exitCode === 0) - _videoThumb = thumbPath; - }); - } }); } @@ -160,75 +160,52 @@ StyledRect { height: weMode ? 165 : (iconSizes[iconSizeIndex] - 8) anchors.horizontalCenter: parent.horizontalCenter - Image { - id: gridPreviewImage + ClippingRectangle { anchors.fill: parent anchors.margins: 2 - property var weExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tga", ".jxl", ".avif", ".heif", ".exr"] - property int weExtIndex: 0 - property string imagePath: { - if (weMode && delegateRoot.fileIsDir) - return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex]; - if (_videoThumb) - return _videoThumb; - return ""; - } - source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : "" - onStatusChanged: { - if (weMode && delegateRoot.fileIsDir && status === Image.Error) { - if (weExtIndex < weExtensions.length - 1) { - weExtIndex++; - } else { - imagePath = ""; - } - } - } - fillMode: Image.PreserveAspectCrop - sourceSize.width: weMode ? 225 : iconSizes[iconSizeIndex] - sourceSize.height: weMode ? 225 : iconSizes[iconSizeIndex] - asynchronous: true - visible: false - } + radius: Theme.cornerRadius + color: "transparent" - 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 && isVideo) || (weMode && delegateRoot.fileIsDir)) - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 - } - - Item { - id: gridImageMask - anchors.fill: parent - anchors.margins: 2 - layer.enabled: true - layer.smooth: true - visible: false - - Rectangle { + Image { + id: gridPreviewImage anchors.fill: parent - radius: Theme.cornerRadius - color: "black" - antialiasing: true + property var weExtensions: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tga", ".jxl", ".avif", ".heif", ".exr"] + property int weExtIndex: 0 + property string imagePath: { + if (weMode && delegateRoot.fileIsDir) + return delegateRoot.filePath + "/preview" + weExtensions[weExtIndex]; + if (_videoThumb) + return _videoThumb; + return ""; + } + source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : "" + onStatusChanged: { + if (status !== Image.Error) + return; + if (weMode && delegateRoot.fileIsDir) { + if (weExtIndex < weExtensions.length - 1) { + weExtIndex++; + } else { + imagePath = ""; + } + return; + } + if (_videoThumb) + generateVideoThumbnail(); + } + fillMode: Image.PreserveAspectCrop + sourceSize.width: weMode ? 225 : iconSizes[iconSizeIndex] + sourceSize.height: weMode ? 225 : iconSizes[iconSizeIndex] + asynchronous: true + visible: status === Image.Ready && ((!delegateRoot.fileIsDir && isVideo) || (weMode && delegateRoot.fileIsDir)) + } + + CachingImage { + anchors.fill: parent + imagePath: !delegateRoot.fileIsDir && isImage ? delegateRoot.filePath : "" + maxCacheSize: 256 + animate: false + visible: !delegateRoot.fileIsDir && isImage } } diff --git a/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml b/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml index 6d0ea8bfb..99101738c 100644 --- a/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml +++ b/quickshell/Modals/FileBrowser/FileBrowserListDelegate.qml @@ -1,5 +1,5 @@ import QtQuick -import QtQuick.Effects +import Quickshell.Widgets import qs.Common import qs.Widgets @@ -95,23 +95,25 @@ StyledRect { } property string _videoThumb: "" + property bool _thumbGenAttempted: false + // Probe the thumbnail optimistically; Image.Error triggers generation onVideoThumbnailPathChanged: { - _videoThumb = ""; - if (!videoThumbnailPath) + _thumbGenAttempted = false; + _videoThumb = videoThumbnailPath; + } + + function generateVideoThumbnail() { + if (_thumbGenAttempted) return; + _thumbGenAttempted = true; + _videoThumb = ""; const thumbPath = videoThumbnailPath; - const fp = listDelegateRoot.filePath; - Paths.mkdir(_xdgCacheHome + "/thumbnails/normal"); - Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) { - if (exitCode === 0) { + const thumbDir = _xdgCacheHome + "/thumbnails/normal"; + const script = "mkdir -p \"$1\" && ffmpegthumbnailer -i \"$2\" -o \"$3\" -s 128 -f"; + Proc.runCommand(null, ["sh", "-c", script, "thumb", thumbDir, listDelegateRoot.filePath, thumbPath], function (output, exitCode) { + if (exitCode === 0) _videoThumb = thumbPath; - } else { - Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function (output, exitCode) { - if (exitCode === 0) - _videoThumb = thumbPath; - }); - } }); } @@ -165,54 +167,33 @@ StyledRect { height: 28 anchors.verticalCenter: parent.verticalCenter - Image { - id: listPreviewImage + ClippingRectangle { anchors.fill: parent - property string imagePath: _videoThumb - source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : "" - fillMode: Image.PreserveAspectCrop - sourceSize.width: 32 - sourceSize.height: 32 - asynchronous: true - visible: false - } + radius: Theme.cornerRadius + color: "transparent" - 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 && isVideo - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 - } - - Item { - id: listImageMask - anchors.fill: parent - layer.enabled: true - layer.smooth: true - visible: false - - Rectangle { + Image { + id: listPreviewImage anchors.fill: parent - radius: Theme.cornerRadius - color: "black" - antialiasing: true + property string imagePath: _videoThumb + source: imagePath ? "file://" + imagePath.split('/').map(s => encodeURIComponent(s)).join('/') : "" + fillMode: Image.PreserveAspectCrop + sourceSize.width: 32 + sourceSize.height: 32 + asynchronous: true + visible: status === Image.Ready && !listDelegateRoot.fileIsDir && isVideo + onStatusChanged: { + if (status === Image.Error && _videoThumb) + generateVideoThumbnail(); + } + } + + CachingImage { + anchors.fill: parent + imagePath: !listDelegateRoot.fileIsDir && isImage ? listDelegateRoot.filePath : "" + maxCacheSize: 256 + animate: false + visible: !listDelegateRoot.fileIsDir && isImage } } diff --git a/quickshell/Modules/BlurredWallpaperBackground.qml b/quickshell/Modules/BlurredWallpaperBackground.qml index 8d1799969..d403e7df1 100644 --- a/quickshell/Modules/BlurredWallpaperBackground.qml +++ b/quickshell/Modules/BlurredWallpaperBackground.qml @@ -108,16 +108,48 @@ Variants { function invalidate() { _settleFrames = 3; backingWindow?.update(); + if (!_wedgeBounced) + wedgeWatchdog.restart(); } onRenderActiveChanged: invalidate() onBackingWindowChanged: invalidate() + // Same wedge recovery as WallpaperBackground + property bool _wedgeBounced: false + + Timer { + id: wedgeWatchdog + interval: 3000 + repeat: false + onTriggered: { + if (!root.backingWindow || !blurWallpaperWindow.visible || IdleService.isShellLocked) + return; + log.warn("no frame swapped on", modelData.name, "since last invalidate, re-attaching surface"); + root._wedgeBounced = true; + surfaceReattach.restart(); + } + } + + Timer { + id: surfaceReattach + interval: 0 + repeat: false + onTriggered: { + blurWallpaperWindow.visible = false; + Qt.callLater(() => { + blurWallpaperWindow.visible = true; + }); + } + } + Connections { target: root.backingWindow function onFrameSwapped() { if (root._settleFrames > 0) root._settleFrames--; + root._wedgeBounced = false; + wedgeWatchdog.stop(); } function onVisibleChanged() { root.invalidate(); @@ -142,10 +174,29 @@ Variants { function onWallpaperFillModeChanged() { root.invalidate(); } - function onWallpaperBackgroundColorModeChanged() { + function onEffectiveWallpaperBackgroundColorChanged() { root.invalidate(); } - function onWallpaperBackgroundCustomColorChanged() { + } + + Connections { + target: SessionData + function onMonitorWallpaperFillModesChanged() { + root.invalidate(); + } + function onPerMonitorWallpaperChanged() { + root.invalidate(); + } + } + + // Theme changes repaint DankBackdrop but nothing else wakes the render loop + Connections { + target: Theme + enabled: root.isColorSource || currentWallpaper.status === Image.Error + function onPrimaryChanged() { + root.invalidate(); + } + function onBackgroundChanged() { root.invalidate(); } } @@ -170,6 +221,7 @@ Variants { } onSourceChanged: { + invalidate(); if (!source || source.startsWith("#")) { setWallpaperImmediate(""); return; @@ -190,6 +242,7 @@ Variants { } function setWallpaperImmediate(newSource) { + transitionDelayTimer.stop(); transitionAnimation.stop(); root.transitionProgress = 0.0; root.effectActive = false; diff --git a/quickshell/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml index 8719f7f17..0f38f71fa 100644 --- a/quickshell/Modules/WallpaperBackground.qml +++ b/quickshell/Modules/WallpaperBackground.qml @@ -141,16 +141,37 @@ Variants { function invalidate() { _settleFrames = 3; backingWindow?.update(); + if (!_wedgeBounced) + wedgeWatchdog.restart(); } onRenderActiveChanged: invalidate() onBackingWindowChanged: invalidate() + // No swap after a requested frame: dropped frame callback left the window + // unexposed (qtwayland mFrameCallbackTimedOut); only surface re-attach recovers. + property bool _wedgeBounced: false + + Timer { + id: wedgeWatchdog + interval: 3000 + repeat: false + onTriggered: { + if (!root.backingWindow || !wallpaperWindow.visible || IdleService.isShellLocked) + return; + log.warn("no frame swapped on", modelData.name, "since last invalidate, re-attaching surface"); + root._wedgeBounced = true; + surfaceReattach.restart(); + } + } + Connections { target: root.backingWindow function onFrameSwapped() { if (root._settleFrames > 0) root._settleFrames--; + root._wedgeBounced = false; + wedgeWatchdog.stop(); } function onVisibleChanged() { root.invalidate(); @@ -176,10 +197,29 @@ Variants { function onWallpaperFillModeChanged() { root.invalidate(); } - function onWallpaperBackgroundColorModeChanged() { + function onEffectiveWallpaperBackgroundColorChanged() { root.invalidate(); } - function onWallpaperBackgroundCustomColorChanged() { + } + + Connections { + target: SessionData + function onMonitorWallpaperFillModesChanged() { + root.invalidate(); + } + function onPerMonitorWallpaperChanged() { + root.invalidate(); + } + } + + // Theme changes repaint DankBackdrop but nothing else wakes the render loop + Connections { + target: Theme + enabled: root.isColorSource || currentWallpaper.status === Image.Error + function onPrimaryChanged() { + root.invalidate(); + } + function onBackgroundChanged() { root.invalidate(); } } @@ -502,13 +542,13 @@ Variants { } onSourceChanged: { + invalidate(); if (!source || source.startsWith("#")) { setWallpaperImmediate(""); return; } root.changePending = true; - invalidate(); const formattedSource = source.startsWith("file://") ? source : encodeFileUrl(source); @@ -528,10 +568,14 @@ Variants { } function setWallpaperImmediate(newSource) { + transitionDelayTimer.stop(); transitionAnimation.stop(); root.transitionProgress = 0.0; root.effectActive = false; root.screenScale = CompositorService.getScreenScale(modelData); + // No status change coming to clear the flag + if (!newSource || currentWallpaper.source.toString() === newSource) + root.changePending = false; currentWallpaper.source = newSource; nextWallpaper.source = ""; @@ -567,10 +611,14 @@ Variants { } function changeWallpaper(newPath, force) { - if (!force && newPath === currentWallpaper.source) + if (!force && newPath === currentWallpaper.source.toString()) { + root.changePending = false; return; - if (!newPath || newPath.startsWith("#")) + } + if (!newPath || newPath.startsWith("#")) { + root.changePending = false; return; + } root.screenScale = CompositorService.getScreenScale(modelData); if (root.transitioning || root.effectActive) { root.pendingWallpaper = newPath;