Compare commits

...

5 Commits

Author SHA1 Message Date
190570da7b qs: improve bar's animations 2026-03-21 07:14:00 +01:00
b677b8aa49 Update asset link in README.md 2026-03-21 06:40:43 +01:00
bb56ae1598 qs: better notes 2026-03-21 06:39:33 +01:00
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
14 changed files with 410 additions and 279 deletions
+2
View File
@@ -15,6 +15,8 @@
<summary>Niri & Quickshell</summary>
https://github.com/user-attachments/assets/1fd0f3be-e83f-4d1c-9e4f-16cc77e3981b
<figure>
<img src="https://github.com/Uyanide/backgrounds/blob/master/screenshots/desktop-alt.webp?raw=true"/>
</figure>
+13 -1
View File
@@ -43,9 +43,16 @@ layout {
left 2
}
background-color "#1e1e2e"
background-color "transparent"
}
overview {
backdrop-color "#1e1e2e"
workspace-shadow {
off
}
}
// Disable the "Important Hotkeys" pop-up at startup.
hotkey-overlay {
@@ -57,6 +64,11 @@ prefer-no-csd
animations {
// off
// slowdown 3.0
workspace-switch {
duration-ms 200
curve "ease-out-cubic"
}
}
layer-rule {
@@ -92,10 +92,11 @@ Singleton {
}
Connections {
target: ShellState
onColorStateChanged: {
function onColorStateChanged() {
reloadTimer.restart();
}
target: ShellState
}
Timer {
@@ -3,6 +3,7 @@ import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import qs.Constants
import qs.Modules.Background
import qs.Services
Variants {
@@ -17,124 +18,9 @@ Variants {
readonly property real blurPercentage: BackgroundService.blurPercentage
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 {
id: bdWindow
property bool doBlur: true
property string imagePath: BackgroundService.displayPath
screen: modelData
WlrLayershell.namespace: "quickshell-backdrop"
WlrLayershell.layer: WlrLayer.Background
@@ -147,96 +33,10 @@ Variants {
right: true
}
Rectangle {
anchors.fill: parent
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
}
}
}
}
ScrollBackground {
id: scrollBg
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,161 @@
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.5;
// Center
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
easing.type: Easing.InOutCubic
}
}
Behavior on colorization {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.InOutCubic
}
}
}
}
@@ -31,6 +31,21 @@ Variants {
top: true
}
Rectangle {
anchors.fill: parent
color: Colors.mSurface
opacity: BarService.focusMode ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Style.animationSlow
easing.type: Easing.InOutCubic
}
}
}
Rectangle {
id: barBackground
@@ -39,30 +54,12 @@ Variants {
gradient: Gradient {
GradientStop {
position: 0
color: Qt.rgba(Colors.mSurface.r, Colors.mSurface.g, Colors.mSurface.b, BarService.focusMode ? 1 : 0.8)
Behavior on color {
ColorAnimation {
duration: Style.animationSlowest
easing.type: Easing.InOutCubic
}
}
color: Qt.rgba(Colors.mSurface.r, Colors.mSurface.g, Colors.mSurface.b, 0.8)
}
GradientStop {
position: 1
color: Qt.rgba(Colors.mSurface.r, Colors.mSurface.g, Colors.mSurface.b, BarService.focusMode ? 1 : 0)
Behavior on color {
ColorAnimation {
duration: Style.animationSlowest
easing.type: Easing.InOutCubic
}
}
color: Qt.rgba(Colors.mSurface.r, Colors.mSurface.g, Colors.mSurface.b, 0)
}
}
@@ -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
Component.onCompleted: syncWorkspaces()
@@ -63,6 +77,10 @@ Item {
syncWorkspaces();
}
function onFocusedWorkspaceIdChanged() {
syncWorkspaceFocus();
}
target: Niri
}
@@ -173,7 +173,7 @@ Scope {
Behavior on opacity {
NumberAnimation {
duration: Style.animationSlowest
duration: Style.animationSlow
easing.type: Easing.InOutCubic
}
@@ -107,14 +107,6 @@ Rectangle {
onClicked: NotesService.openNote(model.notePath)
}
FileView {
id: fileView
path: model.notePath
watchChanges: true
onFileChanged: reload()
}
RowLayout {
id: noteLayout
@@ -124,13 +116,7 @@ Rectangle {
UText {
Layout.fillWidth: true
text: {
var t = fileView.text();
if (!t)
return "(empty note)";
return t.trim().split('\n').slice(0, 5).join('\n');
}
text: model.contentPreview
wrapMode: Text.Wrap
elide: Text.ElideRight
maximumLineCount: 5
@@ -10,7 +10,7 @@ Singleton {
id: root
readonly property string backgroundWidth: "2560"
readonly property string backgroundHeight: "1440"
readonly property string backgroundHeight: "1600"
property string cachedPath: ""
property string previewPath: ""
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) {
if (!sourcePath || sourcePath === "") {
@@ -89,28 +89,29 @@ Singleton {
callback(sourcePath, false);
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) {
// const fitsScreen = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height;
// if (fitsScreen) {
// // Only skip if format is natively supported by Qt
// if (!needsConversion(sourcePath)) {
// Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} fits screen ${width}x${height}, using original`);
// callback(sourcePath, false);
// return ;
// }
// Logger.d("ImageCache", `Image needs conversion despite fitting screen`);
// }
// 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;
const sourceWidth = imgWidth > 0 ? imgWidth : width;
const targetWidth = Math.min(sourceWidth, width);
let cropWidth = 0;
let cropHeight = 0;
if (shouldCropToAspect && imgWidth > 0 && imgHeight > 0) {
const sourceRatio = imgWidth / imgHeight;
const targetRatio = width / height;
if (sourceRatio > targetRatio) {
cropWidth = Math.max(1, Math.floor(imgHeight * targetRatio));
cropHeight = imgHeight;
}
}
getMtime(sourcePath, function(mtime) {
const cacheKey = generateLargeKey(sourcePath, width, height, mtime);
const cacheKey = generateLargeKey(sourcePath, targetWidth, height, mtime);
const cachedPath = wpLargeDir + cacheKey + ".png";
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
// -------------------------------------------------
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);
}
@@ -249,11 +250,13 @@ Singleton {
// -------------------------------------------------
// ImageMagick Processing: Large
// -------------------------------------------------
function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) {
function startLargeProcessing(sourcePath, outputPath, width, cropWidth, cropHeight, cacheKey) {
const srcEsc = sourcePath.replace(/'/g, "'\\''");
const dstEsc = outputPath.replace(/'/g, "'\\''");
// Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output
const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '${width}x${height}' -gravity center -unsharp 0x0.5 '${dstEsc}'`;
// `${width}x>` means keep aspect ratio, cap by width, and never upscale.
// 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);
}
@@ -15,6 +15,7 @@ Singleton {
})
property bool hasFocusedWindow: focusedWindowIndex >= 0
property int focusedWindowIndex: -1
property int focusedWorkspaceId: -1
property string focusedWindowAppId: hasFocusedWindow ? windows[focusedWindowIndex].appId : ""
property string focusedWindowTitle: hasFocusedWindow ? windows[focusedWindowIndex].title : ""
property string focusedOutput: ""
@@ -38,7 +39,7 @@ Singleton {
signal workspaceChanged()
signal activeWindowChanged()
signal windowListChanged()
signal displayScalesChanged()
signal outputsChanged()
function initialize() {
niriEventStream.connected = true;
@@ -111,12 +112,14 @@ Singleton {
scales[output.name] = outputData;
}
}
outputsChanged();
}
function _recollectWorkspaces(workspacesData) {
const workspacesList = [];
workspaceCache = {
};
focusedWorkspaceId = -1;
for (const ws of workspacesData) {
const wsData = {
"id": ws.id,
@@ -130,9 +133,10 @@ Singleton {
};
workspacesList.push(wsData);
workspaceCache[ws.id] = wsData;
if (wsData.isFocused)
if (wsData.isFocused) {
focusedOutput = wsData.output || "";
focusedWorkspaceId = wsData.id;
}
}
workspacesList.sort((a, b) => {
if (a.output !== b.output)
@@ -234,7 +238,6 @@ Singleton {
nextIndex = cachedIndex;
}
if (nextIndex < 0 && focusedWindowIndex >= 0 && focusedWindowIndex < windows.length && windows[focusedWindowIndex].isFocused)
nextIndex = focusedWindowIndex;
@@ -291,7 +294,6 @@ Singleton {
activeWindowChanged();
}
windowListChanged();
workspaceUpdateTimer.restart();
} catch (e) {
Logger.e("NiriService", "Error handling WindowOpenedOrChanged:", e);
}
@@ -310,7 +312,6 @@ Singleton {
activeWindowChanged();
windowListChanged();
workspaceUpdateTimer.restart();
}
} catch (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) {
try {
const focusedId = eventData.id;
@@ -361,7 +400,6 @@ Singleton {
window.position = getWindowPosition(layout);
hasMatchedWindow = hasMatchedWindow || window !== null;
}
if (!hasMatchedWindow)
return ;
@@ -527,7 +565,7 @@ Singleton {
else if (event.WindowsChanged)
_handleWindowsChanged(event.WindowsChanged);
else if (event.WorkspaceActivated)
workspaceUpdateTimer.restart();
_handleWorkspaceActivated(event.WorkspaceActivated);
else if (event.WindowFocusChanged)
_handleWindowFocusChanged(event.WindowFocusChanged);
else if (event.WindowLayoutsChanged)
@@ -20,7 +20,8 @@ Singleton {
const path = Paths.notesDir + "/" + fileName;
createProcess.currentNote = {
"notePath": path,
"colorIdx": strToColor(fileName)
"colorIdx": strToColor(fileName),
"contentPreview": ""
};
createProcess.command = ["touch", path];
createProcess.running = true;
@@ -124,7 +125,8 @@ Singleton {
root.notesModel.append({
"notePath": Paths.notesDir + "/" + fileName,
"colorIdx": strToColor(fileName)
"colorIdx": strToColor(fileName),
"contentPreview": ""
});
Logger.d("Notes", "Loaded note: " + fileName);
}
@@ -136,6 +138,24 @@ Singleton {
}
Instantiator {
model: notesModel
delegate: FileView {
path: model.notePath
watchChanges: true
onFileChanged: reload()
onLoaded: {
const content = text();
if (!content)
model.contentPreview = "(empty note)";
else
model.contentPreview = content.trim().split('\n').slice(0, 5).join('\n');
}
}
}
notesModel: ListModel {
}