From 411e013ea01d64b8dc0fdbf334d2ab8589318bf2 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:04:50 +1000 Subject: [PATCH 01/10] feat: use image provider for caching --- components/images/CachingImage.qml | 24 +-- plugin/src/Caelestia/CMakeLists.txt | 1 + plugin/src/Caelestia/Images/CMakeLists.txt | 10 + .../Caelestia/Images/cachingimageprovider.cpp | 180 ++++++++++++++++++ .../Caelestia/Images/cachingimageprovider.hpp | 23 +++ plugin/src/Caelestia/Images/iutils.cpp | 39 ++++ plugin/src/Caelestia/Images/iutils.hpp | 24 +++ 7 files changed, 282 insertions(+), 19 deletions(-) create mode 100644 plugin/src/Caelestia/Images/CMakeLists.txt create mode 100644 plugin/src/Caelestia/Images/cachingimageprovider.cpp create mode 100644 plugin/src/Caelestia/Images/cachingimageprovider.hpp create mode 100644 plugin/src/Caelestia/Images/iutils.cpp create mode 100644 plugin/src/Caelestia/Images/iutils.hpp diff --git a/components/images/CachingImage.qml b/components/images/CachingImage.qml index 5c5f8bbb..167f6b9b 100644 --- a/components/images/CachingImage.qml +++ b/components/images/CachingImage.qml @@ -1,28 +1,14 @@ import QtQuick -import Quickshell -import Caelestia.Internal -import qs.utils +import Caelestia.Images Image { id: root - property alias path: manager.path + property string path asynchronous: true fillMode: Image.PreserveAspectCrop - - Connections { - function onDevicePixelRatioChanged(): void { - manager.updateSource(); - } - - target: QsWindow.window - } - - CachingImageManager { - id: manager - - item: root - cacheDir: Qt.resolvedUrl(Paths.imagecache) - } + source: IUtils.urlForPath(path, fillMode) + sourceSize.width: width + sourceSize.height: height } diff --git a/plugin/src/Caelestia/CMakeLists.txt b/plugin/src/Caelestia/CMakeLists.txt index 6f4ca980..6e73f4fe 100644 --- a/plugin/src/Caelestia/CMakeLists.txt +++ b/plugin/src/Caelestia/CMakeLists.txt @@ -79,3 +79,4 @@ add_subdirectory(Internal) add_subdirectory(Models) add_subdirectory(Services) add_subdirectory(Blobs) +add_subdirectory(Images) diff --git a/plugin/src/Caelestia/Images/CMakeLists.txt b/plugin/src/Caelestia/Images/CMakeLists.txt new file mode 100644 index 00000000..10707ae9 --- /dev/null +++ b/plugin/src/Caelestia/Images/CMakeLists.txt @@ -0,0 +1,10 @@ +qml_module(caelestia-images + URI Caelestia.Images + SOURCES + cachingimageprovider.cpp + iutils.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent +) diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.cpp b/plugin/src/Caelestia/Images/cachingimageprovider.cpp new file mode 100644 index 00000000..ed726018 --- /dev/null +++ b/plugin/src/Caelestia/Images/cachingimageprovider.cpp @@ -0,0 +1,180 @@ +#include "cachingimageprovider.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcCProv, "caelestia.images.cacheprovider", QtInfoMsg) + +namespace caelestia::images { + +namespace { + +const QString& cacheDir() { + static const QString s_dir = [] { + QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); + if (cache.isEmpty()) + cache = QDir::homePath() + QStringLiteral("/.cache"); + return cache + QStringLiteral("/caelestia/imagecache"); + }(); + return s_dir; +} + +QString sha256sum(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcCProv).noquote() << "sha256sum: failed to open" << path; + return {}; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(&file); + file.close(); + + return hash.result().toHex(); +} + +QString fillSuffix(CachingImageProvider::FillMode fillMode) { + switch (fillMode) { + case CachingImageProvider::FillMode::Crop: + return QStringLiteral("crop"); + case CachingImageProvider::FillMode::Fit: + return QStringLiteral("fit"); + default: + return QStringLiteral("stretch"); + } +} + +class CachingImageResponse final : public QQuickImageResponse, public QRunnable { +public: + CachingImageResponse(const QString& id, const QSize& requestedSize, CachingImageProvider::FillMode fillMode) + : m_id(id) + , m_requestedSize(requestedSize) + , m_fillMode(fillMode) { + setAutoDelete(false); + } + + [[nodiscard]] QQuickTextureFactory* textureFactory() const override { + return QQuickTextureFactory::textureFactoryForImage(m_image); + } + + [[nodiscard]] QString errorString() const override { return m_error; } + + void run() override { + process(); + emit finished(); + } + +private: + void process() { + QString path = QString::fromUtf8(m_id.toUtf8().percentDecoded()); + if (!path.startsWith(QLatin1Char('/'))) + path.prepend(QLatin1Char('/')); + + if (!QFileInfo::exists(path)) { + m_error = QStringLiteral("Source file does not exist: ") + path; + qCWarning(lcCProv).noquote() << m_error; + return; + } + + // Get size from requested size, or the source's original size + QSize size = m_requestedSize; + if (size.width() <= 0 || size.height() <= 0) { + const QImageReader reader(path); + size = reader.size(); + if (!size.isValid() || size.isEmpty()) { + m_error = QStringLiteral("Could not determine size for: ") + path; + qCWarning(lcCProv).noquote() << m_error; + return; + } + } + + const QString sha = sha256sum(path); + if (sha.isEmpty()) { + m_error = QStringLiteral("Failed to hash: ") + path; + return; + } + + // clang-format off + const QString filename = QStringLiteral("%1@%2x%3-%4.png") + .arg(sha).arg(size.width()).arg(size.height()).arg(fillSuffix(m_fillMode)); + // clang-format on + const QString cache = cacheDir() + QLatin1Char('/') + filename; + + // Check cache, if it already exists, set and return + QImageReader cacheReader(cache); + if (cacheReader.canRead()) { + m_image = cacheReader.read(); + if (!m_image.isNull()) + return; + } + + QImage image(path); + if (image.isNull()) { + m_error = QStringLiteral("Failed to decode: ") + path; + qCWarning(lcCProv).noquote() << m_error; + return; + } + + image.convertTo(QImage::Format_ARGB32); + + // Scale to requested size + switch (m_fillMode) { + case CachingImageProvider::FillMode::Crop: + image = image.scaled(size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); + break; + case CachingImageProvider::FillMode::Fit: + image = image.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + break; + case CachingImageProvider::FillMode::Stretch: + image = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + break; + } + + if (m_fillMode == CachingImageProvider::FillMode::Stretch) { + m_image = image; + } else { + // Crop or fit + QImage canvas(size, QImage::Format_ARGB32); + canvas.fill(Qt::transparent); + + QPainter painter(&canvas); + painter.drawImage((size.width() - image.width()) / 2, (size.height() - image.height()) / 2, image); + painter.end(); + + m_image = canvas; + } + + const QString parent = QFileInfo(cache).absolutePath(); + if (QDir().mkpath(parent) && m_image.save(cache)) + qCDebug(lcCProv).noquote() << "Saved to" << cache; + else + qCWarning(lcCProv).noquote() << "Failed to save to" << cache; + } + + QString m_id; + QSize m_requestedSize; + CachingImageProvider::FillMode m_fillMode; + QImage m_image; + QString m_error; +}; + +} // namespace + +CachingImageProvider::CachingImageProvider(FillMode fillMode) + : m_fillMode(fillMode) {} + +QQuickImageResponse* CachingImageProvider::requestImageResponse(const QString& id, const QSize& requestedSize) { + auto* const response = new CachingImageResponse(id, requestedSize, m_fillMode); + QThreadPool::globalInstance()->start(response); + return response; +} + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.hpp b/plugin/src/Caelestia/Images/cachingimageprovider.hpp new file mode 100644 index 00000000..9e9c9772 --- /dev/null +++ b/plugin/src/Caelestia/Images/cachingimageprovider.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace caelestia::images { + +class CachingImageProvider : public QQuickAsyncImageProvider { +public: + enum class FillMode { + Crop, + Fit, + Stretch + }; + + explicit CachingImageProvider(FillMode fillMode); + + QQuickImageResponse* requestImageResponse(const QString& id, const QSize& requestedSize) override; + +private: + FillMode m_fillMode; +}; + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/iutils.cpp b/plugin/src/Caelestia/Images/iutils.cpp new file mode 100644 index 00000000..aeee7684 --- /dev/null +++ b/plugin/src/Caelestia/Images/iutils.cpp @@ -0,0 +1,39 @@ +#include "iutils.hpp" + +#include "cachingimageprovider.hpp" + +namespace caelestia::images { + +IUtils* IUtils::create(QQmlEngine* engine, QJSEngine* jsEngine) { + Q_UNUSED(jsEngine); + + engine->addImageProvider(QStringLiteral("ccache"), new CachingImageProvider(CachingImageProvider::FillMode::Crop)); + engine->addImageProvider(QStringLiteral("fcache"), new CachingImageProvider(CachingImageProvider::FillMode::Fit)); + engine->addImageProvider( + QStringLiteral("scache"), new CachingImageProvider(CachingImageProvider::FillMode::Stretch)); + + return new IUtils(engine); +} + +QUrl IUtils::urlForPath(const QString& path, int fillMode) { + QString prefix; + switch (fillMode) { + case 1: // Image.PreserveAspectFit + prefix = QStringLiteral("fcache"); + break; + case 2: // Image.PreserveAspectCrop + prefix = QStringLiteral("ccache"); + break; + default: // Image.Stretch or any other ones + prefix = QStringLiteral("scache"); + break; + } + + QUrl url; + url.setScheme(QStringLiteral("image")); + url.setHost(prefix); + url.setPath(path.startsWith(QLatin1Char('/')) ? path : QLatin1Char('/') + path); + return url; +} + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/iutils.hpp b/plugin/src/Caelestia/Images/iutils.hpp new file mode 100644 index 00000000..2187c8d0 --- /dev/null +++ b/plugin/src/Caelestia/Images/iutils.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +namespace caelestia::images { + +class IUtils : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +public: + static IUtils* create(QQmlEngine* engine, QJSEngine* jsEngine); + + Q_INVOKABLE static QUrl urlForPath(const QString& path, int fillMode); + +private: + explicit IUtils(QObject* parent = nullptr) + : QObject(parent) {}; +}; + +} // namespace caelestia::images From 4f1f609b55936daa7d6a981e90da53217b1ada41 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:51:06 +1000 Subject: [PATCH 02/10] fix: use QSaveFile for atomic writes --- .../Caelestia/Images/cachingimageprovider.cpp | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.cpp b/plugin/src/Caelestia/Images/cachingimageprovider.cpp index ed726018..8491b641 100644 --- a/plugin/src/Caelestia/Images/cachingimageprovider.cpp +++ b/plugin/src/Caelestia/Images/cachingimageprovider.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include Q_LOGGING_CATEGORY(lcCProv, "caelestia.images.cacheprovider", QtInfoMsg) @@ -152,11 +153,20 @@ private: m_image = canvas; } + // Save to cache const QString parent = QFileInfo(cache).absolutePath(); - if (QDir().mkpath(parent) && m_image.save(cache)) - qCDebug(lcCProv).noquote() << "Saved to" << cache; - else - qCWarning(lcCProv).noquote() << "Failed to save to" << cache; + if (!QDir().mkpath(parent)) { + qCWarning(lcCProv).noquote() << "Failed to create cache dir" << parent; + return; + } + + QSaveFile saveFile(cache); + if (!saveFile.open(QIODevice::WriteOnly) || !m_image.save(&saveFile, "PNG") || !saveFile.commit()) { + qCWarning(lcCProv).noquote() << "Failed to save to" << cache << ":" << saveFile.errorString(); + return; + } + + qCDebug(lcCProv).noquote() << "Saved to" << cache; } QString m_id; From 1f7aeec8c44e51c2856ef3c00b64284165409fb7 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:51:42 +1000 Subject: [PATCH 03/10] fix: specify sourceSize as a single prop So we don't get 2 updates when width/height change --- components/images/CachingImage.qml | 3 +-- modules/launcher/items/WallpaperItem.qml | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/components/images/CachingImage.qml b/components/images/CachingImage.qml index 167f6b9b..e782b882 100644 --- a/components/images/CachingImage.qml +++ b/components/images/CachingImage.qml @@ -9,6 +9,5 @@ Image { asynchronous: true fillMode: Image.PreserveAspectCrop source: IUtils.urlForPath(path, fillMode) - sourceSize.width: width - sourceSize.height: height + sourceSize: Qt.size(width, height) } diff --git a/modules/launcher/items/WallpaperItem.qml b/modules/launcher/items/WallpaperItem.qml index 58be068d..3f2143ae 100644 --- a/modules/launcher/items/WallpaperItem.qml +++ b/modules/launcher/items/WallpaperItem.qml @@ -63,11 +63,9 @@ Item { } CachingImage { + anchors.fill: parent path: root.modelData.path smooth: !root.PathView.view.moving - cache: true - - anchors.fill: parent } } From b4d490a9bc18e64ab4b313af43e4591367e7a9d4 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:56:35 +1000 Subject: [PATCH 04/10] fix: use original image if requested size is invalid --- .../Caelestia/Images/cachingimageprovider.cpp | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.cpp b/plugin/src/Caelestia/Images/cachingimageprovider.cpp index 8491b641..2bd1c8b8 100644 --- a/plugin/src/Caelestia/Images/cachingimageprovider.cpp +++ b/plugin/src/Caelestia/Images/cachingimageprovider.cpp @@ -85,16 +85,11 @@ private: return; } - // Get size from requested size, or the source's original size - QSize size = m_requestedSize; - if (size.width() <= 0 || size.height() <= 0) { - const QImageReader reader(path); - size = reader.size(); - if (!size.isValid() || size.isEmpty()) { - m_error = QStringLiteral("Could not determine size for: ") + path; - qCWarning(lcCProv).noquote() << m_error; - return; - } + // Use original image if requested size is invalid + if (m_requestedSize.width() <= 0 || m_requestedSize.height() <= 0) { + m_image = QImage(path); + qCDebug(lcCProv) << "Given source size is invalid, not caching."; + return; } const QString sha = sha256sum(path); @@ -105,7 +100,7 @@ private: // clang-format off const QString filename = QStringLiteral("%1@%2x%3-%4.png") - .arg(sha).arg(size.width()).arg(size.height()).arg(fillSuffix(m_fillMode)); + .arg(sha).arg(m_requestedSize.width()).arg(m_requestedSize.height()).arg(fillSuffix(m_fillMode)); // clang-format on const QString cache = cacheDir() + QLatin1Char('/') + filename; @@ -129,13 +124,13 @@ private: // Scale to requested size switch (m_fillMode) { case CachingImageProvider::FillMode::Crop: - image = image.scaled(size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); + image = image.scaled(m_requestedSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); break; case CachingImageProvider::FillMode::Fit: - image = image.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + image = image.scaled(m_requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); break; case CachingImageProvider::FillMode::Stretch: - image = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + image = image.scaled(m_requestedSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); break; } @@ -143,11 +138,12 @@ private: m_image = image; } else { // Crop or fit - QImage canvas(size, QImage::Format_ARGB32); + QImage canvas(m_requestedSize, QImage::Format_ARGB32); canvas.fill(Qt::transparent); QPainter painter(&canvas); - painter.drawImage((size.width() - image.width()) / 2, (size.height() - image.height()) / 2, image); + painter.drawImage( + (m_requestedSize.width() - image.width()) / 2, (m_requestedSize.height() - image.height()) / 2, image); painter.end(); m_image = canvas; From 8e373ced175e948dd91c8b87bccea6cb8dedb638 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:23:56 +1000 Subject: [PATCH 05/10] feat: split caching into separate service Return original image if no cache hit instead of waiting for cache to finish --- plugin/src/Caelestia/Images/CMakeLists.txt | 1 + .../Caelestia/Images/cachingimageprovider.cpp | 132 +++------------ .../Caelestia/Images/cachingimageprovider.hpp | 8 +- plugin/src/Caelestia/Images/imagecacher.cpp | 159 ++++++++++++++++++ plugin/src/Caelestia/Images/imagecacher.hpp | 38 +++++ 5 files changed, 224 insertions(+), 114 deletions(-) create mode 100644 plugin/src/Caelestia/Images/imagecacher.cpp create mode 100644 plugin/src/Caelestia/Images/imagecacher.hpp diff --git a/plugin/src/Caelestia/Images/CMakeLists.txt b/plugin/src/Caelestia/Images/CMakeLists.txt index 10707ae9..d869667d 100644 --- a/plugin/src/Caelestia/Images/CMakeLists.txt +++ b/plugin/src/Caelestia/Images/CMakeLists.txt @@ -2,6 +2,7 @@ qml_module(caelestia-images URI Caelestia.Images SOURCES cachingimageprovider.cpp + imagecacher.cpp iutils.cpp LIBRARIES Qt::Gui diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.cpp b/plugin/src/Caelestia/Images/cachingimageprovider.cpp index 2bd1c8b8..45cd77a8 100644 --- a/plugin/src/Caelestia/Images/cachingimageprovider.cpp +++ b/plugin/src/Caelestia/Images/cachingimageprovider.cpp @@ -1,15 +1,12 @@ #include "cachingimageprovider.hpp" -#include -#include -#include +#include "imagecacher.hpp" + #include #include #include #include -#include #include -#include #include Q_LOGGING_CATEGORY(lcCProv, "caelestia.images.cacheprovider", QtInfoMsg) @@ -18,44 +15,9 @@ namespace caelestia::images { namespace { -const QString& cacheDir() { - static const QString s_dir = [] { - QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); - if (cache.isEmpty()) - cache = QDir::homePath() + QStringLiteral("/.cache"); - return cache + QStringLiteral("/caelestia/imagecache"); - }(); - return s_dir; -} - -QString sha256sum(const QString& path) { - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - qCWarning(lcCProv).noquote() << "sha256sum: failed to open" << path; - return {}; - } - - QCryptographicHash hash(QCryptographicHash::Sha256); - hash.addData(&file); - file.close(); - - return hash.result().toHex(); -} - -QString fillSuffix(CachingImageProvider::FillMode fillMode) { - switch (fillMode) { - case CachingImageProvider::FillMode::Crop: - return QStringLiteral("crop"); - case CachingImageProvider::FillMode::Fit: - return QStringLiteral("fit"); - default: - return QStringLiteral("stretch"); - } -} - class CachingImageResponse final : public QQuickImageResponse, public QRunnable { public: - CachingImageResponse(const QString& id, const QSize& requestedSize, CachingImageProvider::FillMode fillMode) + CachingImageResponse(const QString& id, const QSize& requestedSize, ImageCacher::FillMode fillMode) : m_id(id) , m_requestedSize(requestedSize) , m_fillMode(fillMode) { @@ -87,87 +49,39 @@ private: // Use original image if requested size is invalid if (m_requestedSize.width() <= 0 || m_requestedSize.height() <= 0) { + qCDebug(lcCProv).noquote() << "Given source size is invalid, returning original:" << path; m_image = QImage(path); - qCDebug(lcCProv) << "Given source size is invalid, not caching."; + if (m_image.isNull()) { + m_error = QStringLiteral("Failed to decode source: ") + path; + qCWarning(lcCProv).noquote() << m_error; + } return; } - const QString sha = sha256sum(path); - if (sha.isEmpty()) { - m_error = QStringLiteral("Failed to hash: ") + path; - return; + // Try to use cached image + const auto cachePath = ImageCacher::cachePathFor(path, m_requestedSize, m_fillMode); + if (!cachePath.isEmpty()) { + QImageReader cacheReader(cachePath); + if (cacheReader.canRead()) { + m_image = cacheReader.read(); + if (!m_image.isNull()) + return; + } } - // clang-format off - const QString filename = QStringLiteral("%1@%2x%3-%4.png") - .arg(sha).arg(m_requestedSize.width()).arg(m_requestedSize.height()).arg(fillSuffix(m_fillMode)); - // clang-format on - const QString cache = cacheDir() + QLatin1Char('/') + filename; + // Schedule cache job (this call will return the original image, but later ones will use cache) + ImageCacher::instance()->schedule(path, cachePath, m_requestedSize, m_fillMode); - // Check cache, if it already exists, set and return - QImageReader cacheReader(cache); - if (cacheReader.canRead()) { - m_image = cacheReader.read(); - if (!m_image.isNull()) - return; - } - - QImage image(path); - if (image.isNull()) { - m_error = QStringLiteral("Failed to decode: ") + path; + m_image = QImage(path); + if (m_image.isNull()) { + m_error = QStringLiteral("Failed to decode source: ") + path; qCWarning(lcCProv).noquote() << m_error; - return; } - - image.convertTo(QImage::Format_ARGB32); - - // Scale to requested size - switch (m_fillMode) { - case CachingImageProvider::FillMode::Crop: - image = image.scaled(m_requestedSize, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - break; - case CachingImageProvider::FillMode::Fit: - image = image.scaled(m_requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); - break; - case CachingImageProvider::FillMode::Stretch: - image = image.scaled(m_requestedSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - break; - } - - if (m_fillMode == CachingImageProvider::FillMode::Stretch) { - m_image = image; - } else { - // Crop or fit - QImage canvas(m_requestedSize, QImage::Format_ARGB32); - canvas.fill(Qt::transparent); - - QPainter painter(&canvas); - painter.drawImage( - (m_requestedSize.width() - image.width()) / 2, (m_requestedSize.height() - image.height()) / 2, image); - painter.end(); - - m_image = canvas; - } - - // Save to cache - const QString parent = QFileInfo(cache).absolutePath(); - if (!QDir().mkpath(parent)) { - qCWarning(lcCProv).noquote() << "Failed to create cache dir" << parent; - return; - } - - QSaveFile saveFile(cache); - if (!saveFile.open(QIODevice::WriteOnly) || !m_image.save(&saveFile, "PNG") || !saveFile.commit()) { - qCWarning(lcCProv).noquote() << "Failed to save to" << cache << ":" << saveFile.errorString(); - return; - } - - qCDebug(lcCProv).noquote() << "Saved to" << cache; } QString m_id; QSize m_requestedSize; - CachingImageProvider::FillMode m_fillMode; + ImageCacher::FillMode m_fillMode; QImage m_image; QString m_error; }; diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.hpp b/plugin/src/Caelestia/Images/cachingimageprovider.hpp index 9e9c9772..97082c76 100644 --- a/plugin/src/Caelestia/Images/cachingimageprovider.hpp +++ b/plugin/src/Caelestia/Images/cachingimageprovider.hpp @@ -1,16 +1,14 @@ #pragma once +#include "imagecacher.hpp" + #include namespace caelestia::images { class CachingImageProvider : public QQuickAsyncImageProvider { public: - enum class FillMode { - Crop, - Fit, - Stretch - }; + using FillMode = ImageCacher::FillMode; explicit CachingImageProvider(FillMode fillMode); diff --git a/plugin/src/Caelestia/Images/imagecacher.cpp b/plugin/src/Caelestia/Images/imagecacher.cpp new file mode 100644 index 00000000..dfef8f0b --- /dev/null +++ b/plugin/src/Caelestia/Images/imagecacher.cpp @@ -0,0 +1,159 @@ +#include "imagecacher.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcCacher, "caelestia.images.cacher", QtInfoMsg) + +namespace caelestia::images { + +namespace { + +QString sha256sum(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcCacher).noquote() << "sha256sum: failed to open" << path; + return {}; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(&file); + file.close(); + + return hash.result().toHex(); +} + +QString fillSuffix(ImageCacher::FillMode fillMode) { + switch (fillMode) { + case ImageCacher::FillMode::Crop: + return QStringLiteral("crop"); + case ImageCacher::FillMode::Fit: + return QStringLiteral("fit"); + default: + return QStringLiteral("stretch"); + } +} + +} // namespace + +const QString& ImageCacher::cacheDir() { + static const QString s_dir = [] { + QString cache = qEnvironmentVariable("XDG_CACHE_HOME"); + if (cache.isEmpty()) + cache = QDir::homePath() + QStringLiteral("/.cache"); + return cache + QStringLiteral("/caelestia/imagecache"); + }(); + return s_dir; +} + +QString ImageCacher::cachePathFor(const QString& sourcePath, const QSize& size, FillMode fillMode) { + const QString sha = sha256sum(sourcePath); + if (sha.isEmpty()) + return {}; + + const QString filename = + QStringLiteral("%1@%2x%3-%4.png") + .arg(sha, QString::number(size.width()), QString::number(size.height()), fillSuffix(fillMode)); + + return cacheDir() + QLatin1Char('/') + filename; +} + +ImageCacher* ImageCacher::instance() { + static ImageCacher s_instance; + return &s_instance; +} + +ImageCacher::ImageCacher(QObject* parent) + : QObject(parent) {} + +void ImageCacher::schedule(const QString& sourcePath, const QSize& size, FillMode fillMode) { + schedule(sourcePath, cachePathFor(sourcePath, size, fillMode), size, fillMode); +} + +void ImageCacher::schedule(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode) { + if (cachePath.isEmpty()) + return; + + { + QMutexLocker locker(&m_mutex); + if (m_inflight.contains(cachePath)) + return; + m_inflight.insert(cachePath); + } + + QThreadPool::globalInstance()->start([this, sourcePath, cachePath, size, fillMode]() { + runJob(sourcePath, cachePath, size, fillMode); + QMutexLocker locker(&m_mutex); + m_inflight.remove(cachePath); + }); +} + +void ImageCacher::runJob(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode) { + if (QFile::exists(cachePath)) { + return; + } + + QImage image(sourcePath); + if (image.isNull()) { + qCWarning(lcCacher).noquote() << "Failed to decode source" << sourcePath; + return; + } + + Qt::AspectRatioMode scaleMode; + switch (fillMode) { + case FillMode::Crop: + scaleMode = Qt::KeepAspectRatioByExpanding; + break; + case FillMode::Fit: + scaleMode = Qt::KeepAspectRatio; + break; + case FillMode::Stretch: + scaleMode = Qt::IgnoreAspectRatio; + break; + } + + image.convertTo(QImage::Format_ARGB32); + image = image.scaled(size, scaleMode, Qt::SmoothTransformation); + + if (image.isNull()) { + qCWarning(lcCacher).noquote() << "Failed to scale" << sourcePath; + return; + } + + QImage canvas; + if (fillMode == FillMode::Stretch) { + canvas = image; + } else { + canvas = QImage(size, QImage::Format_ARGB32); + canvas.fill(Qt::transparent); + + QPainter painter(&canvas); + painter.drawImage((size.width() - image.width()) / 2, (size.height() - image.height()) / 2, image); + painter.end(); + } + + const QString parent = QFileInfo(cachePath).absolutePath(); + if (!QDir().mkpath(parent)) { + qCWarning(lcCacher).noquote() << "Failed to create cache dir" << parent; + return; + } + + QSaveFile saveFile(cachePath); + if (!saveFile.open(QIODevice::WriteOnly) || !canvas.save(&saveFile, "PNG") || !saveFile.commit()) { + qCWarning( + lcCacher, "Failed to save to %s: %s", qUtf8Printable(cachePath), qUtf8Printable(saveFile.errorString())); + return; + } + + qCDebug(lcCacher).noquote() << "Saved to" << cachePath; +} + +} // namespace caelestia::images diff --git a/plugin/src/Caelestia/Images/imagecacher.hpp b/plugin/src/Caelestia/Images/imagecacher.hpp new file mode 100644 index 00000000..79608419 --- /dev/null +++ b/plugin/src/Caelestia/Images/imagecacher.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace caelestia::images { + +class ImageCacher : public QObject { + Q_OBJECT + +public: + enum class FillMode { + Crop, + Fit, + Stretch, + }; + + static ImageCacher* instance(); + + static const QString& cacheDir(); + static QString cachePathFor(const QString& sourcePath, const QSize& size, FillMode fillMode); + + void schedule(const QString& sourcePath, const QSize& size, FillMode fillMode); + void schedule(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode); + +private: + explicit ImageCacher(QObject* parent = nullptr); + + static void runJob(const QString& sourcePath, const QString& cachePath, const QSize& size, FillMode fillMode); + + QMutex m_mutex; + QSet m_inflight; +}; + +} // namespace caelestia::images From 5b88995b39ce80785c822cc6f79d4b4fe80561d4 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:30:53 +1000 Subject: [PATCH 06/10] fix: initial 0 size on launcher wallpaper item --- modules/launcher/items/WallpaperItem.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/launcher/items/WallpaperItem.qml b/modules/launcher/items/WallpaperItem.qml index 3f2143ae..ddf0d61a 100644 --- a/modules/launcher/items/WallpaperItem.qml +++ b/modules/launcher/items/WallpaperItem.qml @@ -66,6 +66,7 @@ Item { anchors.fill: parent path: root.modelData.path smooth: !root.PathView.view.moving + sourceSize: Qt.size(image.implicitWidth, image.implicitHeight) } } From 6777ad0a2d9a1df586647ebc2c60db4ec96993c8 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:33:06 +1000 Subject: [PATCH 07/10] chore: remove old CachingImageManager --- plugin/src/Caelestia/Internal/CMakeLists.txt | 18 +- .../Internal/cachingimagemanager.cpp | 213 ------------------ .../Internal/cachingimagemanager.hpp | 65 ------ 3 files changed, 8 insertions(+), 288 deletions(-) delete mode 100644 plugin/src/Caelestia/Internal/cachingimagemanager.cpp delete mode 100644 plugin/src/Caelestia/Internal/cachingimagemanager.hpp diff --git a/plugin/src/Caelestia/Internal/CMakeLists.txt b/plugin/src/Caelestia/Internal/CMakeLists.txt index bc4a6948..f4bbc5fd 100644 --- a/plugin/src/Caelestia/Internal/CMakeLists.txt +++ b/plugin/src/Caelestia/Internal/CMakeLists.txt @@ -1,19 +1,17 @@ qml_module(caelestia-internal URI Caelestia.Internal SOURCES - arcgauge.hpp arcgauge.cpp - cachingimagemanager.hpp cachingimagemanager.cpp - circularbuffer.hpp circularbuffer.cpp - circularindicatormanager.hpp circularindicatormanager.cpp - hyprdevices.hpp hyprdevices.cpp - hyprextras.hpp hyprextras.cpp - logindmanager.hpp logindmanager.cpp - sparklineitem.hpp sparklineitem.cpp - visualiserbars.hpp visualiserbars.cpp + arcgauge.cpp + circularbuffer.cpp + circularindicatormanager.cpp + hyprdevices.cpp + hyprextras.cpp + logindmanager.cpp + sparklineitem.cpp + visualiserbars.cpp LIBRARIES Qt::Gui Qt::Quick - Qt::Concurrent Qt::Network Qt::DBus ) diff --git a/plugin/src/Caelestia/Internal/cachingimagemanager.cpp b/plugin/src/Caelestia/Internal/cachingimagemanager.cpp deleted file mode 100644 index 46152d2a..00000000 --- a/plugin/src/Caelestia/Internal/cachingimagemanager.cpp +++ /dev/null @@ -1,213 +0,0 @@ -#include "cachingimagemanager.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -Q_LOGGING_CATEGORY(lcCim, "caelestia.internal.cim", QtInfoMsg) - -namespace caelestia::internal { - -qreal CachingImageManager::effectiveScale() const { - if (m_item && m_item->window()) { - return m_item->window()->devicePixelRatio(); - } - - return 1.0; -} - -QSize CachingImageManager::effectiveSize() const { - if (!m_item) { - return QSize(); - } - - const qreal scale = effectiveScale(); - const QSize size = QSizeF(m_item->width() * scale, m_item->height() * scale).toSize(); - m_item->setProperty("sourceSize", size); - return size; -} - -QQuickItem* CachingImageManager::item() const { - return m_item; -} - -void CachingImageManager::setItem(QQuickItem* item) { - if (m_item == item) { - return; - } - - if (m_widthConn) { - disconnect(m_widthConn); - } - if (m_heightConn) { - disconnect(m_heightConn); - } - - m_item = item; - emit itemChanged(); - - if (item) { - m_widthConn = connect(item, &QQuickItem::widthChanged, this, [this]() { - updateSource(); - }); - m_heightConn = connect(item, &QQuickItem::heightChanged, this, [this]() { - updateSource(); - }); - updateSource(); - } -} - -QUrl CachingImageManager::cacheDir() const { - return m_cacheDir; -} - -void CachingImageManager::setCacheDir(const QUrl& cacheDir) { - if (m_cacheDir == cacheDir) { - return; - } - - m_cacheDir = cacheDir; - if (!m_cacheDir.path().endsWith("/")) { - m_cacheDir.setPath(m_cacheDir.path() + "/"); - } - emit cacheDirChanged(); -} - -QString CachingImageManager::path() const { - return m_path; -} - -void CachingImageManager::setPath(const QString& path) { - if (m_path == path) { - return; - } - - m_path = path; - emit pathChanged(); - - if (!path.isEmpty()) { - updateSource(path); - } -} - -void CachingImageManager::updateSource() { - updateSource(m_path); -} - -void CachingImageManager::updateSource(const QString& path) { - if (path.isEmpty() || path == m_shaPath) { - // Path is empty or already calculating sha for path - return; - } - - m_shaPath = path; - - QtConcurrent::run(&CachingImageManager::sha256sum, path).then(this, [path, this](const QString& sha) { - if (m_path != path) { - return; - } - - const QSize size = effectiveSize(); - - if (!m_item || !size.width() || !size.height()) { - return; - } - - const QString fillMode = m_item->property("fillMode").toString(); - // clang-format off - const QString filename = QString("%1@%2x%3-%4.png") - .arg(sha).arg(size.width()).arg(size.height()) - .arg(fillMode == "PreserveAspectCrop" ? "crop" : fillMode == "PreserveAspectFit" ? "fit" : "stretch"); - // clang-format on - - const QUrl cache = m_cacheDir.resolved(QUrl(filename)); - if (m_cachePath == cache) { - return; - } - - m_cachePath = cache; - emit cachePathChanged(); - - if (!cache.isLocalFile()) { - qCWarning(lcCim) << "updateSource: cachePath" << cache << "is not a local file"; - return; - } - - const QImageReader reader(cache.toLocalFile()); - if (reader.canRead()) { - m_item->setProperty("source", cache); - } else { - m_item->setProperty("source", QUrl::fromLocalFile(path)); - createCache(path, cache.toLocalFile(), fillMode, size); - } - - // Clear current running sha if same - if (m_shaPath == path) { - m_shaPath = QString(); - } - }); -} - -QUrl CachingImageManager::cachePath() const { - return m_cachePath; -} - -void CachingImageManager::createCache( - const QString& path, const QString& cache, const QString& fillMode, const QSize& size) const { - QThreadPool::globalInstance()->start([path, cache, fillMode, size] { - QImage image(path); - - if (image.isNull()) { - qCWarning(lcCim) << "createCache: failed to read" << path; - return; - } - - image.convertTo(QImage::Format_ARGB32); - - if (fillMode == "PreserveAspectCrop") { - image = image.scaled(size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - } else if (fillMode == "PreserveAspectFit") { - image = image.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); - } else { - image = image.scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - } - - if (fillMode == "PreserveAspectCrop" || fillMode == "PreserveAspectFit") { - QImage canvas(size, QImage::Format_ARGB32); - canvas.fill(Qt::transparent); - - QPainter painter(&canvas); - painter.drawImage((size.width() - image.width()) / 2, (size.height() - image.height()) / 2, image); - painter.end(); - - image = canvas; - } - - const QString parent = QFileInfo(cache).absolutePath(); - if (!QDir().mkpath(parent) || !image.save(cache)) { - qCWarning(lcCim) << "createCache: failed to save to" << cache; - } - }); -} - -QString CachingImageManager::sha256sum(const QString& path) { - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - qCWarning(lcCim) << "sha256sum: failed to open" << path; - return ""; - } - - QCryptographicHash hash(QCryptographicHash::Sha256); - hash.addData(&file); - file.close(); - - return hash.result().toHex(); -} - -} // namespace caelestia::internal diff --git a/plugin/src/Caelestia/Internal/cachingimagemanager.hpp b/plugin/src/Caelestia/Internal/cachingimagemanager.hpp deleted file mode 100644 index 1b707414..00000000 --- a/plugin/src/Caelestia/Internal/cachingimagemanager.hpp +++ /dev/null @@ -1,65 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace caelestia::internal { - -class CachingImageManager : public QObject { - Q_OBJECT - QML_ELEMENT - - Q_PROPERTY(QQuickItem* item READ item WRITE setItem NOTIFY itemChanged REQUIRED) - Q_PROPERTY(QUrl cacheDir READ cacheDir WRITE setCacheDir NOTIFY cacheDirChanged REQUIRED) - - Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) - Q_PROPERTY(QUrl cachePath READ cachePath NOTIFY cachePathChanged) - -public: - explicit CachingImageManager(QObject* parent = nullptr) - : QObject(parent) {} - - [[nodiscard]] QQuickItem* item() const; - void setItem(QQuickItem* item); - - [[nodiscard]] QUrl cacheDir() const; - void setCacheDir(const QUrl& cacheDir); - - [[nodiscard]] QString path() const; - void setPath(const QString& path); - - [[nodiscard]] QUrl cachePath() const; - - Q_INVOKABLE void updateSource(); - Q_INVOKABLE void updateSource(const QString& path); - -signals: - void itemChanged(); - void cacheDirChanged(); - - void pathChanged(); - void cachePathChanged(); - void usingCacheChanged(); - -private: - QString m_shaPath; - - QPointer m_item; - QUrl m_cacheDir; - - QString m_path; - QUrl m_cachePath; - - QMetaObject::Connection m_widthConn; - QMetaObject::Connection m_heightConn; - - [[nodiscard]] qreal effectiveScale() const; - [[nodiscard]] QSize effectiveSize() const; - - void createCache(const QString& path, const QString& cache, const QString& fillMode, const QSize& size) const; - [[nodiscard]] static QString sha256sum(const QString& path); -}; - -} // namespace caelestia::internal From dea8efcc97162267db7bbd44515294a298525eb1 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:49:21 +1000 Subject: [PATCH 08/10] fix: handle hidpi properly --- components/images/CachingImage.qml | 6 +++++- modules/launcher/items/WallpaperItem.qml | 6 +++++- plugin/src/Caelestia/Images/iutils.cpp | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/components/images/CachingImage.qml b/components/images/CachingImage.qml index e782b882..1e932dba 100644 --- a/components/images/CachingImage.qml +++ b/components/images/CachingImage.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell import Caelestia.Images Image { @@ -9,5 +10,8 @@ Image { asynchronous: true fillMode: Image.PreserveAspectCrop source: IUtils.urlForPath(path, fillMode) - sourceSize: Qt.size(width, height) + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } } diff --git a/modules/launcher/items/WallpaperItem.qml b/modules/launcher/items/WallpaperItem.qml index ddf0d61a..d09a894c 100644 --- a/modules/launcher/items/WallpaperItem.qml +++ b/modules/launcher/items/WallpaperItem.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell import Caelestia.Config import Caelestia.Models import qs.components @@ -66,7 +67,10 @@ Item { anchors.fill: parent path: root.modelData.path smooth: !root.PathView.view.moving - sourceSize: Qt.size(image.implicitWidth, image.implicitHeight) + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(image.implicitWidth * dpr, image.implicitHeight * dpr); + } } } diff --git a/plugin/src/Caelestia/Images/iutils.cpp b/plugin/src/Caelestia/Images/iutils.cpp index aeee7684..996f93dd 100644 --- a/plugin/src/Caelestia/Images/iutils.cpp +++ b/plugin/src/Caelestia/Images/iutils.cpp @@ -16,6 +16,9 @@ IUtils* IUtils::create(QQmlEngine* engine, QJSEngine* jsEngine) { } QUrl IUtils::urlForPath(const QString& path, int fillMode) { + if (path.isEmpty()) + return QUrl(); + QString prefix; switch (fillMode) { case 1: // Image.PreserveAspectFit From 775d0a81015da00120b14ff10d3aa5bfebdd5558 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:57:30 +1000 Subject: [PATCH 09/10] fix: handle hidpi for rest of sourceSize uses --- modules/dashboard/Media.qml | 6 ++++-- modules/dashboard/dash/Media.qml | 7 +++++-- modules/lock/Media.qml | 7 +++++-- modules/lock/NotifDock.qml | 2 +- modules/lock/NotifGroup.qml | 6 ++++-- modules/notifications/Notification.qml | 6 ++++-- modules/session/Content.qml | 2 +- modules/sidebar/NotifDock.qml | 3 ++- modules/sidebar/NotifGroup.qml | 6 ++++-- 9 files changed, 30 insertions(+), 15 deletions(-) diff --git a/modules/dashboard/Media.qml b/modules/dashboard/Media.qml index ccb5f0c4..367d9e98 100644 --- a/modules/dashboard/Media.qml +++ b/modules/dashboard/Media.qml @@ -184,8 +184,10 @@ Item { source: Players.getArtUrl(Players.active) asynchronous: true fillMode: Image.PreserveAspectCrop - sourceSize.width: width - sourceSize.height: height + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } MouseArea { anchors.fill: parent diff --git a/modules/dashboard/dash/Media.qml b/modules/dashboard/dash/Media.qml index b8e78abd..a14e5e38 100644 --- a/modules/dashboard/dash/Media.qml +++ b/modules/dashboard/dash/Media.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Shapes +import Quickshell import Caelestia.Config import Caelestia.Services import qs.components @@ -109,8 +110,10 @@ Item { source: Players.getArtUrl(Players.active) asynchronous: true fillMode: Image.PreserveAspectCrop - sourceSize.width: width - sourceSize.height: height + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } } } diff --git a/modules/lock/Media.qml b/modules/lock/Media.qml index 05c9daaf..c55333c7 100644 --- a/modules/lock/Media.qml +++ b/modules/lock/Media.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import Quickshell import Caelestia.Config import qs.components import qs.components.effects @@ -22,8 +23,10 @@ Item { asynchronous: true fillMode: Image.PreserveAspectCrop - sourceSize.width: width - sourceSize.height: height + sourceSize: { + const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1; + return Qt.size(width * dpr, height * dpr); + } layer.enabled: true layer.effect: OpacityMask { diff --git a/modules/lock/NotifDock.qml b/modules/lock/NotifDock.qml index bf827579..2254dfe0 100644 --- a/modules/lock/NotifDock.qml +++ b/modules/lock/NotifDock.qml @@ -52,7 +52,7 @@ ColumnLayout { asynchronous: true source: Paths.absolutePath(Config.paths.lockNoNotifsPic) fillMode: Image.PreserveAspectFit - sourceSize.width: clipRect.width * 0.8 + sourceSize.width: clipRect.width * 0.8 * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1) layer.enabled: true layer.effect: Colouriser { diff --git a/modules/lock/NotifGroup.qml b/modules/lock/NotifGroup.qml index 0a5b6d73..971b921a 100644 --- a/modules/lock/NotifGroup.qml +++ b/modules/lock/NotifGroup.qml @@ -73,8 +73,10 @@ StyledRect { Image { source: Qt.resolvedUrl(root.image) fillMode: Image.PreserveAspectCrop - sourceSize.width: TokenConfig.sizes.notifs.image - sourceSize.height: TokenConfig.sizes.notifs.image + sourceSize: { + const size = TokenConfig.sizes.notifs.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); + return Qt.size(size, size); + } cache: false asynchronous: true width: TokenConfig.sizes.notifs.image diff --git a/modules/notifications/Notification.qml b/modules/notifications/Notification.qml index 70f58af0..fe6fd056 100644 --- a/modules/notifications/Notification.qml +++ b/modules/notifications/Notification.qml @@ -126,8 +126,10 @@ StyledRect { anchors.fill: parent source: Qt.resolvedUrl(root.modelData.image) fillMode: Image.PreserveAspectCrop - sourceSize.width: TokenConfig.sizes.notifs.image - sourceSize.height: TokenConfig.sizes.notifs.image + sourceSize: { + const size = TokenConfig.sizes.notifs.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); + return Qt.size(size, size); + } cache: false asynchronous: true } diff --git a/modules/session/Content.qml b/modules/session/Content.qml index bf16b148..e1ba2809 100644 --- a/modules/session/Content.qml +++ b/modules/session/Content.qml @@ -48,7 +48,7 @@ Column { AnimatedImage { width: Tokens.sizes.session.button height: Tokens.sizes.session.button - sourceSize.width: width + sourceSize.width: width * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1) playing: visible asynchronous: true diff --git a/modules/sidebar/NotifDock.qml b/modules/sidebar/NotifDock.qml index bf978ec0..4509ce26 100644 --- a/modules/sidebar/NotifDock.qml +++ b/modules/sidebar/NotifDock.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import Quickshell import Quickshell.Widgets import Caelestia.Config import qs.components @@ -98,7 +99,7 @@ Item { asynchronous: true source: Paths.absolutePath(Config.paths.noNotifsPic) fillMode: Image.PreserveAspectFit - sourceSize.width: clipRect.width * 0.8 + sourceSize.width: clipRect.width * 0.8 * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1) layer.enabled: true layer.effect: Colouriser { diff --git a/modules/sidebar/NotifGroup.qml b/modules/sidebar/NotifGroup.qml index 57ee06b4..2920af74 100644 --- a/modules/sidebar/NotifGroup.qml +++ b/modules/sidebar/NotifGroup.qml @@ -87,8 +87,10 @@ StyledRect { Image { source: Qt.resolvedUrl(root.image) fillMode: Image.PreserveAspectCrop - sourceSize.width: TokenConfig.sizes.notifs.image - sourceSize.height: TokenConfig.sizes.notifs.image + sourceSize: { + const size = TokenConfig.sizes.notifs.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); + return Qt.size(size, size); + } cache: false asynchronous: true width: TokenConfig.sizes.notifs.image From 665d784186ddded833cf158edcafe67111242e48 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:07:11 +1000 Subject: [PATCH 10/10] feat: calculate size from source when single dim requested --- .../Caelestia/Images/cachingimageprovider.cpp | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/plugin/src/Caelestia/Images/cachingimageprovider.cpp b/plugin/src/Caelestia/Images/cachingimageprovider.cpp index 45cd77a8..768e05da 100644 --- a/plugin/src/Caelestia/Images/cachingimageprovider.cpp +++ b/plugin/src/Caelestia/Images/cachingimageprovider.cpp @@ -47,8 +47,12 @@ private: return; } - // Use original image if requested size is invalid - if (m_requestedSize.width() <= 0 || m_requestedSize.height() <= 0) { + QSize size = m_requestedSize; + const bool needsW = size.width() <= 0; + const bool needsH = size.height() <= 0; + + // If both dimensions are missing, return the original directly + if (needsW && needsH) { qCDebug(lcCProv).noquote() << "Given source size is invalid, returning original:" << path; m_image = QImage(path); if (m_image.isNull()) { @@ -58,8 +62,24 @@ private: return; } + // If one dimension is missing, derive it from the source aspect ratio + if (needsW || needsH) { + const QImageReader sourceReader(path); + const QSize sourceSize = sourceReader.size(); + if (!sourceSize.isValid() || sourceSize.isEmpty()) { + m_error = QStringLiteral("Could not determine source size for: ") + path; + qCWarning(lcCProv).noquote() << m_error; + return; + } + + if (needsW) + size.setWidth(qRound(size.height() * sourceSize.width() / static_cast(sourceSize.height()))); + else + size.setHeight(qRound(size.width() * sourceSize.height() / static_cast(sourceSize.width()))); + } + // Try to use cached image - const auto cachePath = ImageCacher::cachePathFor(path, m_requestedSize, m_fillMode); + const auto cachePath = ImageCacher::cachePathFor(path, size, m_fillMode); if (!cachePath.isEmpty()) { QImageReader cacheReader(cachePath); if (cacheReader.canRead()) { @@ -70,7 +90,7 @@ private: } // Schedule cache job (this call will return the original image, but later ones will use cache) - ImageCacher::instance()->schedule(path, cachePath, m_requestedSize, m_fillMode); + ImageCacher::instance()->schedule(path, cachePath, size, m_fillMode); m_image = QImage(path); if (m_image.isNull()) {