diff --git a/quickshell/Common/LockScrollAtlas.js b/quickshell/Common/LockScrollAtlas.js new file mode 100644 index 000000000..ef373497c --- /dev/null +++ b/quickshell/Common/LockScrollAtlas.js @@ -0,0 +1,35 @@ +.pragma library + +function scrollWindow(imageWidth, imageHeight, screenWidth, screenHeight, scrollX, scrollY) { + const scale = Math.max(screenWidth / imageWidth, screenHeight / imageHeight); + const width = screenWidth / (imageWidth * scale); + const height = screenHeight / (imageHeight * scale); + const imageAspect = imageWidth / imageHeight; + const screenAspect = screenWidth / screenHeight; + return { + x: imageAspect > screenAspect + 0.01 ? (1 - width) * scrollX / 100 : (1 - width) / 2, + y: imageAspect < screenAspect - 0.01 ? (1 - height) * scrollY / 100 : (1 - height) / 2, + width, + height + }; +} + +function atlasSize(imageWidth, imageHeight, screenWidth, screenHeight, sampleWidth, sampleHeight) { + const view = scrollWindow(imageWidth, imageHeight, screenWidth, screenHeight, 0, 0); + return { + width: Math.ceil(sampleWidth / view.width), + height: Math.ceil(sampleHeight / view.height) + }; +} + +function crop(atlas, atlasWidth, atlasHeight, view, sampleWidth, sampleHeight) { + const result = new Array(sampleWidth * sampleHeight); + for (let y = 0; y < sampleHeight; y++) { + const row = Math.min(atlasHeight - 1, Math.floor((view.y + (y + 0.5) / sampleHeight * view.height) * atlasHeight)); + for (let x = 0; x < sampleWidth; x++) { + const column = Math.min(atlasWidth - 1, Math.floor((view.x + (x + 0.5) / sampleWidth * view.width) * atlasWidth)); + result[y * sampleWidth + x] = atlas[row * atlasWidth + column]; + } + } + return result; +} diff --git a/quickshell/Modules/Lock/LockBackgroundSampler.qml b/quickshell/Modules/Lock/LockBackgroundSampler.qml index f680551ad..901ab8fbf 100644 --- a/quickshell/Modules/Lock/LockBackgroundSampler.qml +++ b/quickshell/Modules/Lock/LockBackgroundSampler.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Window +import qs.Services import "../../Common/WidgetPlacement.js" as Placement Item { @@ -12,7 +13,7 @@ Item { property string capturedKey: "" property var capture: null readonly property size sourceSize: Qt.size(sourceItem.width, sourceItem.height) - readonly property real sampleScale: 128 / Math.max(1, sourceItem.width, sourceItem.height) + readonly property real sampleScale: LockPlacementService.sampleSize / Math.max(1, sourceItem.width, sourceItem.height) readonly property int sampleWidth: Math.max(1, Math.round(sourceItem.width * sampleScale)) readonly property int sampleHeight: Math.max(1, Math.round(sourceItem.height * sampleScale)) readonly property bool sourceVisible: sourceItem.visible && (sourceItem.Window.window?.visible ?? false) diff --git a/quickshell/Modules/Lock/LockPlacementPreparer.qml b/quickshell/Modules/Lock/LockPlacementPreparer.qml index 7ce607b62..906f502a6 100644 --- a/quickshell/Modules/Lock/LockPlacementPreparer.qml +++ b/quickshell/Modules/Lock/LockPlacementPreparer.qml @@ -19,7 +19,7 @@ Variants { Loader { id: preparation - active: scope.sampling || (!IdleService.isShellLocked && LockPlacementService.neededFor(scope.modelData) && scope.failedKey !== scope.backgroundKey && !LockPlacementService.hasSample(scope.modelData.name, scope.backgroundKey)) + active: scope.sampling || (!IdleService.isShellLocked && !LockPlacementService.usesScrollAtlas(scope.modelData.name) && LockPlacementService.neededFor(scope.modelData) && scope.failedKey !== scope.backgroundKey && !LockPlacementService.hasSample(scope.modelData.name, scope.backgroundKey)) asynchronous: true sourceComponent: PanelWindow { @@ -43,6 +43,7 @@ Variants { id: background anchors.fill: parent screenName: scope.modelData.name + asynchronous: true } LockBackgroundSampler { diff --git a/quickshell/Modules/Lock/LockScreenBackground.qml b/quickshell/Modules/Lock/LockScreenBackground.qml index 730be50ab..94a514543 100644 --- a/quickshell/Modules/Lock/LockScreenBackground.qml +++ b/quickshell/Modules/Lock/LockScreenBackground.qml @@ -11,6 +11,7 @@ import qs.DCommon.Session Item { id: root property string screenName: "" + property bool asynchronous: false readonly property bool hasCustomWallpaper: SettingsData.lockScreenWallpaperPath !== "" function encodeFileUrl(path) { @@ -35,7 +36,7 @@ Item { var currentWallpaper = SessionData.getMonitorWallpaper(screenName); return !currentWallpaper || (currentWallpaper && currentWallpaper.startsWith("#")); } - asynchronous: false + asynchronous: root.asynchronous sourceComponent: DBackdrop { screenName: root.screenName @@ -64,7 +65,7 @@ Item { readonly property size decodeSize: Qt.size(Math.round(width * screenScale), Math.round(height * screenScale)) active: wallpaperSource !== "" && width > 0 && height > 0 - asynchronous: false + asynchronous: root.asynchronous sourceComponent: fillModeName === "Scrolling" ? scrollWallpaperComp : plainWallpaperComp @@ -95,7 +96,7 @@ Item { fillMode: Theme.getFillMode(wallpaperBackground.fillModeName) smooth: true cache: true - asynchronous: false + asynchronous: root.asynchronous } } @@ -109,7 +110,7 @@ Item { visible: false source: wallpaperBackground.wallpaperSource sourceSize: wallpaperBackground.decodeSize - asynchronous: false + asynchronous: root.asynchronous cache: true } diff --git a/quickshell/Modules/Lock/LockScrollSampler.qml b/quickshell/Modules/Lock/LockScrollSampler.qml new file mode 100644 index 000000000..9cf9c6539 --- /dev/null +++ b/quickshell/Modules/Lock/LockScrollSampler.qml @@ -0,0 +1,141 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Common +import qs.Services +import "../../Common/WidgetPlacement.js" as Placement +import "../../Common/LockScrollAtlas.js" as Atlas + +Item { + id: root + + required property var screen + + readonly property string screenName: screen.name + readonly property string source: { + const path = SessionData.getMonitorWallpaper(screenName); + return path && !path.startsWith("#") ? "file://" + path.split('/').map(s => encodeURIComponent(s)).join('/') : ""; + } + readonly property string backgroundKey: LockPlacementService.backgroundKey(screenName, screen.width, screen.height) + readonly property bool needed: !IdleService.isShellLocked && LockPlacementService.usesScrollAtlas(screenName) && LockPlacementService.neededFor(screen) && !LockPlacementService.hasSample(screenName, backgroundKey) + readonly property real sampleScale: LockPlacementService.sampleSize / Math.max(1, screen.width, screen.height) + readonly property int sampleWidth: Math.max(1, Math.round(screen.width * sampleScale)) + readonly property int sampleHeight: Math.max(1, Math.round(screen.height * sampleScale)) + readonly property string atlasKey: JSON.stringify([source, screen.width, screen.height, Theme.lockScreenScrimAlpha, Theme.screenOffColor.toString(), SettingsData.effectiveWallpaperBackgroundColor.toString()]) + property var atlas: null + readonly property bool busy: builder.active + + onNeededChanged: sample() + onBackgroundKeyChanged: sample() + Component.onCompleted: LockPlacementService.setScrollSampler(screenName, true) + Component.onDestruction: LockPlacementService.setScrollSampler(screenName, false) + + function sample() { + if (!needed || busy) + return; + if (atlas?.key !== atlasKey) { + builder.active = true; + return; + } + if (atlas.failed) + return; + const scroll = SessionData.getMonitorScrollPosition(screenName); + const view = Atlas.scrollWindow(atlas.imageWidth, atlas.imageHeight, screen.width, screen.height, scroll.scrollX, scroll.scrollY); + LockPlacementService.storeSample(screenName, backgroundKey, Atlas.crop(atlas.luminances, atlas.width, atlas.height, view, sampleWidth, sampleHeight), sampleWidth, sampleHeight); + } + + function finish(result) { + atlas = result; + Qt.callLater(() => { + builder.active = false; + sample(); + }); + } + + Loader { + id: builder + active: false + + sourceComponent: Item { + property string key + property string url + + Component.onCompleted: { + key = root.atlasKey; + url = root.source; + } + + Image { + id: probe + visible: false + asynchronous: true + cache: false + source: parent.url + sourceSize: Qt.size(LockPlacementService.sampleSize * 8, LockPlacementService.sampleSize * 8) + + onStatusChanged: { + if (status === Image.Error) { + root.finish({ + key: parent.key, + failed: true + }); + return; + } + if (status !== Image.Ready) + return; + const size = Atlas.atlasSize(implicitWidth, implicitHeight, root.screen.width, root.screen.height, root.sampleWidth, root.sampleHeight); + canvas.width = size.width; + canvas.height = size.height; + canvas.load(); + } + } + + Canvas { + id: canvas + readonly property string key: parent.key + readonly property string imageUrl: parent.url + + opacity: 0 + + function load() { + if (available && width > 0) + loadImage(imageUrl, Qt.size(width, height)); + } + + onAvailableChanged: load() + onImageLoaded: requestPaint() + onPaint: { + if (isImageError(imageUrl)) { + root.finish({ + key, + failed: true + }); + return; + } + if (!isImageLoaded(imageUrl)) + return; + const context = getContext("2d"); + context.fillStyle = SettingsData.effectiveWallpaperBackgroundColor; + context.fillRect(0, 0, width, height); + context.drawImage(imageUrl, 0, 0, width, height); + const pixels = context.getImageData(0, 0, width, height).data; + unloadImage(imageUrl); + const scrim = Theme.screenOffColor; + const alpha = Theme.lockScreenScrimAlpha; + const blend = (value, over) => value / 255 * (1 - alpha) + over * alpha; + const luminances = new Array(width * height); + for (let i = 0; i < luminances.length; i++) + luminances[i] = Placement.luminance(blend(pixels[i * 4], scrim.r), blend(pixels[i * 4 + 1], scrim.g), blend(pixels[i * 4 + 2], scrim.b)); + root.finish({ + key, + imageWidth: probe.implicitWidth, + imageHeight: probe.implicitHeight, + width, + height, + luminances + }); + } + } + } + } +} diff --git a/quickshell/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml index e7b292173..d0bc7a5d6 100644 --- a/quickshell/Modules/WallpaperBackground.qml +++ b/quickshell/Modules/WallpaperBackground.qml @@ -6,6 +6,7 @@ import Quickshell.Wayland import qs.Common import qs.Widgets import qs.Services +import qs.Modules.Lock import "../Common/WorkspaceModel.js" as WorkspaceModel Variants { @@ -177,7 +178,7 @@ Variants { readonly property var backingWindow: Window.window readonly property bool showsBackdrop: !source || isColorSource || currentWallpaper.status === Image.Error readonly property bool backdropBusy: showsBackdrop && !(backdropLoader.item?.ready ?? false) - readonly property bool renderActive: backdropBusy || effectActive || overviewBlurActive || pendingWallpaper !== "" || _deferredSource !== "" || changePending || _freezeWaitFrames > 0 || frameAnim.running || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading || parallaxImage.status === Image.Loading + readonly property bool renderActive: backdropBusy || effectActive || overviewBlurActive || pendingWallpaper !== "" || _deferredSource !== "" || changePending || _freezeWaitFrames > 0 || frameAnim.running || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading || parallaxImage.status === Image.Loading || lockScrollSampler.busy property int _settleFrames: 3 function invalidate() { @@ -772,6 +773,11 @@ Variants { property int textureWidth: Math.min(Math.round(modelData.width * screenScale), maxTextureSize) property int textureHeight: Math.min(Math.round(modelData.height * screenScale), maxTextureSize) + LockScrollSampler { + id: lockScrollSampler + screen: modelData + } + QtObject { id: imageMetrics property real nativeWidth: 0 diff --git a/quickshell/Services/LockPlacementService.qml b/quickshell/Services/LockPlacementService.qml index 54e810baa..ac8813ba0 100644 --- a/quickshell/Services/LockPlacementService.qml +++ b/quickshell/Services/LockPlacementService.qml @@ -7,8 +7,26 @@ import qs.Common Singleton { id: root + readonly property int sampleSize: 128 property var decodedSamples: ({}) property var layouts: ({}) + property var scrollSamplers: ({}) + + function setScrollSampler(screenName, present) { + const next = Object.assign({}, scrollSamplers); + if (present) + next[screenName] = true; + else + delete next[screenName]; + scrollSamplers = next; + } + + function usesScrollAtlas(screenName) { + if (!scrollSamplers[screenName] || SettingsData.lockScreenWallpaperPath !== "") + return false; + const source = SessionData.getMonitorWallpaper(screenName); + return !!source && !source.startsWith("#") && (SettingsData.lockScreenWallpaperFillMode || SessionData.getMonitorWallpaperFillMode(screenName)) === "Scrolling"; + } function neededFor(screen) { return SettingsData.lockScreenWidgetInstances.some(instance => { diff --git a/quickshell/tests/lock-scroll-atlas.test.mjs b/quickshell/tests/lock-scroll-atlas.test.mjs new file mode 100644 index 000000000..be2108409 --- /dev/null +++ b/quickshell/tests/lock-scroll-atlas.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import test from "node:test"; + +const atlas = vm.createContext({}); +vm.runInContext(readFileSync(new URL("../Common/LockScrollAtlas.js", import.meta.url), "utf8").replace(/^\.pragma.*$/m, ""), atlas); + +test("the scroll window follows the axis the lock shader scrolls", () => { + const cases = [ + ["tall image scrolls vertically", 1000, 3000, 2000, 1000, 0, 0, { x: 0, y: 0, width: 1, height: 1 / 6 }], + ["tall image at the last workspace", 1000, 3000, 2000, 1000, 100, 100, { x: 0, y: 5 / 6, width: 1, height: 1 / 6 }], + ["wide image scrolls horizontally", 6000, 1000, 2000, 1000, 50, 0, { x: 1 / 3, y: 0, width: 1 / 3, height: 1 }], + ["matching aspect stays centred", 4000, 2000, 2000, 1000, 100, 100, { x: 0, y: 0, width: 1, height: 1 }] + ]; + for (const [name, iw, ih, sw, sh, sx, sy, expected] of cases) { + const view = atlas.scrollWindow(iw, ih, sw, sh, sx, sy); + for (const key of ["x", "y", "width", "height"]) + assert.ok(Math.abs(view[key] - expected[key]) < 1e-9, `${name}: ${key} ${view[key]} != ${expected[key]}`); + } +}); + +test("crops pick the atlas rows the lock shows at each scroll position", () => { + const size = atlas.atlasSize(1000, 3000, 2000, 1000, 4, 2); + assert.deepEqual({ ...size }, { width: 4, height: 12 }); + const rows = Array.from({ length: size.width * size.height }, (_, i) => Math.floor(i / size.width)); + const sampleAt = scroll => atlas.crop(rows, size.width, size.height, atlas.scrollWindow(1000, 3000, 2000, 1000, 0, scroll), 4, 2); + assert.deepEqual([...sampleAt(0)], [0, 0, 0, 0, 1, 1, 1, 1]); + assert.deepEqual([...sampleAt(100)], [10, 10, 10, 10, 11, 11, 11, 11]); +});