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