Compare commits

...

2 Commits

Author SHA1 Message Date
ec7e5e9fcb feat: scrollble wallpaper 2026-03-20 09:30:05 +01:00
22f22b570a Update README with video
Added a link to user attachments and a video.
2026-03-19 00:30:14 +01:00
10 changed files with 367 additions and 241 deletions
+2
View File
@@ -15,6 +15,8 @@
<summary>Niri & Quickshell</summary> <summary>Niri & Quickshell</summary>
https://github.com/user-attachments/assets/af29bcac-7207-4f23-88bb-d8c5d447776a
<figure> <figure>
<img src="https://github.com/Uyanide/backgrounds/blob/master/screenshots/desktop-alt.webp?raw=true"/> <img src="https://github.com/Uyanide/backgrounds/blob/master/screenshots/desktop-alt.webp?raw=true"/>
</figure> </figure>
+13 -1
View File
@@ -43,9 +43,16 @@ layout {
left 2 left 2
} }
background-color "#1e1e2e" background-color "transparent"
} }
overview {
backdrop-color "#1e1e2e"
workspace-shadow {
off
}
}
// Disable the "Important Hotkeys" pop-up at startup. // Disable the "Important Hotkeys" pop-up at startup.
hotkey-overlay { hotkey-overlay {
@@ -57,6 +64,11 @@ prefer-no-csd
animations { animations {
// off // off
// slowdown 3.0 // slowdown 3.0
workspace-switch {
duration-ms 200
curve "ease-out-cubic"
}
} }
layer-rule { layer-rule {
@@ -92,10 +92,11 @@ Singleton {
} }
Connections { Connections {
target: ShellState function onColorStateChanged() {
onColorStateChanged: {
reloadTimer.restart(); reloadTimer.restart();
} }
target: ShellState
} }
Timer { Timer {
@@ -3,6 +3,7 @@ import QtQuick.Effects
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import qs.Constants import qs.Constants
import qs.Modules.Background
import qs.Services import qs.Services
Variants { Variants {
@@ -17,124 +18,9 @@ Variants {
readonly property real blurPercentage: BackgroundService.blurPercentage readonly property real blurPercentage: BackgroundService.blurPercentage
readonly property real blurRadius: BackgroundService.blurRadius 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 { PanelWindow {
id: bdWindow id: bdWindow
property bool doBlur: true
property string imagePath: BackgroundService.displayPath
screen: modelData screen: modelData
WlrLayershell.namespace: "quickshell-backdrop" WlrLayershell.namespace: "quickshell-backdrop"
WlrLayershell.layer: WlrLayer.Background WlrLayershell.layer: WlrLayer.Background
@@ -147,96 +33,10 @@ Variants {
right: true right: true
} }
Rectangle { ScrollBackground {
anchors.fill: parent id: scrollBg
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
}
}
}
}
screen: modelData
} }
} }
@@ -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
}
}
}
@@ -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
}
}
}
}
@@ -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 implicitWidth: pillRow.implicitWidth + horizontalPadding * 2
Component.onCompleted: syncWorkspaces() Component.onCompleted: syncWorkspaces()
@@ -63,6 +77,10 @@ Item {
syncWorkspaces(); syncWorkspaces();
} }
function onFocusedWorkspaceIdChanged() {
syncWorkspaceFocus();
}
target: Niri target: Niri
} }
@@ -10,7 +10,7 @@ Singleton {
id: root id: root
readonly property string backgroundWidth: "2560" readonly property string backgroundWidth: "2560"
readonly property string backgroundHeight: "1440" readonly property string backgroundHeight: "1600"
property string cachedPath: "" property string cachedPath: ""
property string previewPath: "" property string previewPath: ""
property string displayPath: "" property string displayPath: ""
@@ -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) { function getLarge(sourcePath, width, height, callback) {
if (!sourcePath || sourcePath === "") { if (!sourcePath || sourcePath === "") {
@@ -89,28 +89,29 @@ Singleton {
callback(sourcePath, false); callback(sourcePath, false);
return ; 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) { getImageDimensions(sourcePath, function(imgWidth, imgHeight) {
// const fitsScreen = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height; const sourceWidth = imgWidth > 0 ? imgWidth : width;
// if (fitsScreen) { const targetWidth = Math.min(sourceWidth, width);
// // Only skip if format is natively supported by Qt let cropWidth = 0;
// if (!needsConversion(sourcePath)) { let cropHeight = 0;
// Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} fits screen ${width}x${height}, using original`); if (shouldCropToAspect && imgWidth > 0 && imgHeight > 0) {
// callback(sourcePath, false); const sourceRatio = imgWidth / imgHeight;
// return ; const targetRatio = width / height;
// } if (sourceRatio > targetRatio) {
// Logger.d("ImageCache", `Image needs conversion despite fitting screen`); cropWidth = Math.max(1, Math.floor(imgHeight * targetRatio));
// } cropHeight = imgHeight;
// 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;
getMtime(sourcePath, function(mtime) { getMtime(sourcePath, function(mtime) {
const cacheKey = generateLargeKey(sourcePath, width, height, mtime); const cacheKey = generateLargeKey(sourcePath, targetWidth, height, mtime);
const cachedPath = wpLargeDir + cacheKey + ".png"; const cachedPath = wpLargeDir + cacheKey + ".png";
processRequest(cacheKey, cachedPath, sourcePath, callback, function() { 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 // Cache Key Generation
// ------------------------------------------------- // -------------------------------------------------
function generateLargeKey(sourcePath, width, height, mtime) { 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); return Checksum.sha256(keyString);
} }
@@ -249,11 +250,13 @@ Singleton {
// ------------------------------------------------- // -------------------------------------------------
// ImageMagick Processing: Large // ImageMagick Processing: Large
// ------------------------------------------------- // -------------------------------------------------
function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) { function startLargeProcessing(sourcePath, outputPath, width, cropWidth, cropHeight, cacheKey) {
const srcEsc = sourcePath.replace(/'/g, "'\\''"); const srcEsc = sourcePath.replace(/'/g, "'\\''");
const dstEsc = outputPath.replace(/'/g, "'\\''"); const dstEsc = outputPath.replace(/'/g, "'\\''");
// Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output // `${width}x>` means keep aspect ratio, cap by width, and never upscale.
const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '${width}x${height}' -gravity center -unsharp 0x0.5 '${dstEsc}'`; // 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); runProcess(command, cacheKey, outputPath, sourcePath);
} }
@@ -15,6 +15,7 @@ Singleton {
}) })
property bool hasFocusedWindow: focusedWindowIndex >= 0 property bool hasFocusedWindow: focusedWindowIndex >= 0
property int focusedWindowIndex: -1 property int focusedWindowIndex: -1
property int focusedWorkspaceId: -1
property string focusedWindowAppId: hasFocusedWindow ? windows[focusedWindowIndex].appId : "" property string focusedWindowAppId: hasFocusedWindow ? windows[focusedWindowIndex].appId : ""
property string focusedWindowTitle: hasFocusedWindow ? windows[focusedWindowIndex].title : "" property string focusedWindowTitle: hasFocusedWindow ? windows[focusedWindowIndex].title : ""
property string focusedOutput: "" property string focusedOutput: ""
@@ -38,7 +39,7 @@ Singleton {
signal workspaceChanged() signal workspaceChanged()
signal activeWindowChanged() signal activeWindowChanged()
signal windowListChanged() signal windowListChanged()
signal displayScalesChanged() signal outputsChanged()
function initialize() { function initialize() {
niriEventStream.connected = true; niriEventStream.connected = true;
@@ -111,12 +112,14 @@ Singleton {
scales[output.name] = outputData; scales[output.name] = outputData;
} }
} }
outputsChanged();
} }
function _recollectWorkspaces(workspacesData) { function _recollectWorkspaces(workspacesData) {
const workspacesList = []; const workspacesList = [];
workspaceCache = { workspaceCache = {
}; };
focusedWorkspaceId = -1;
for (const ws of workspacesData) { for (const ws of workspacesData) {
const wsData = { const wsData = {
"id": ws.id, "id": ws.id,
@@ -130,9 +133,10 @@ Singleton {
}; };
workspacesList.push(wsData); workspacesList.push(wsData);
workspaceCache[ws.id] = wsData; workspaceCache[ws.id] = wsData;
if (wsData.isFocused) if (wsData.isFocused) {
focusedOutput = wsData.output || ""; focusedOutput = wsData.output || "";
focusedWorkspaceId = wsData.id;
}
} }
workspacesList.sort((a, b) => { workspacesList.sort((a, b) => {
if (a.output !== b.output) if (a.output !== b.output)
@@ -234,14 +238,13 @@ Singleton {
nextIndex = cachedIndex; nextIndex = cachedIndex;
} }
if (nextIndex < 0 && focusedWindowIndex >= 0 && focusedWindowIndex < windows.length && windows[focusedWindowIndex].isFocused) if (nextIndex < 0 && focusedWindowIndex >= 0 && focusedWindowIndex < windows.length && windows[focusedWindowIndex].isFocused)
nextIndex = focusedWindowIndex; nextIndex = focusedWindowIndex;
if (nextIndex < 0) if (nextIndex < 0)
nextIndex = windows.findIndex((w) => { nextIndex = windows.findIndex((w) => {
return w.isFocused; return w.isFocused;
}); });
const hasChanged = nextIndex !== focusedWindowIndex; const hasChanged = nextIndex !== focusedWindowIndex;
focusedWindowIndex = nextIndex; focusedWindowIndex = nextIndex;
@@ -291,7 +294,6 @@ Singleton {
activeWindowChanged(); activeWindowChanged();
} }
windowListChanged(); windowListChanged();
workspaceUpdateTimer.restart();
} catch (e) { } catch (e) {
Logger.e("NiriService", "Error handling WindowOpenedOrChanged:", e); Logger.e("NiriService", "Error handling WindowOpenedOrChanged:", e);
} }
@@ -310,7 +312,6 @@ Singleton {
activeWindowChanged(); activeWindowChanged();
windowListChanged(); windowListChanged();
workspaceUpdateTimer.restart();
} }
} catch (e) { } catch (e) {
Logger.e("NiriService", "Error handling WindowClosed:", 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) { function _handleWindowFocusChanged(eventData) {
try { try {
const focusedId = eventData.id; const focusedId = eventData.id;
@@ -361,7 +400,6 @@ Singleton {
window.position = getWindowPosition(layout); window.position = getWindowPosition(layout);
hasMatchedWindow = hasMatchedWindow || window !== null; hasMatchedWindow = hasMatchedWindow || window !== null;
} }
if (!hasMatchedWindow) if (!hasMatchedWindow)
return ; return ;
@@ -527,7 +565,7 @@ Singleton {
else if (event.WindowsChanged) else if (event.WindowsChanged)
_handleWindowsChanged(event.WindowsChanged); _handleWindowsChanged(event.WindowsChanged);
else if (event.WorkspaceActivated) else if (event.WorkspaceActivated)
workspaceUpdateTimer.restart(); _handleWorkspaceActivated(event.WorkspaceActivated);
else if (event.WindowFocusChanged) else if (event.WindowFocusChanged)
_handleWindowFocusChanged(event.WindowFocusChanged); _handleWindowFocusChanged(event.WindowFocusChanged);
else if (event.WindowLayoutsChanged) else if (event.WindowLayoutsChanged)