Merge branch 'main' into zshell-img-tools
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 12s
Python / lint-format (pull_request) Successful in 21s
Python / test (pull_request) Successful in 51s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m3s

This commit is contained in:
2026-05-28 17:06:19 +02:00
44 changed files with 823 additions and 1311 deletions
+1
View File
@@ -8,6 +8,7 @@ qml_module(ZShell-internal
circularbuffer.hpp circularbuffer.cpp
sparklineitem.hpp sparklineitem.cpp
arcgauge.hpp arcgauge.cpp
wallpaperimage.hpp wallpaperimage.cpp
LIBRARIES
Qt::Gui
Qt::Quick
+2 -2
View File
@@ -4,7 +4,7 @@
#include <qpainter.h>
#include <qpen.h>
namespace caelestia::internal {
namespace ZShell::internal {
ArcGauge::ArcGauge(QQuickItem* parent)
: QQuickPaintedItem(parent) {
@@ -116,4 +116,4 @@ void ArcGauge::setLineWidth(qreal width) {
update();
}
} // namespace caelestia::internal
} // namespace ZShell::internal
+2 -2
View File
@@ -5,7 +5,7 @@
#include <qqmlintegration.h>
#include <qquickpainteditem.h>
namespace caelestia::internal {
namespace ZShell::internal {
class ArcGauge : public QQuickPaintedItem {
Q_OBJECT
@@ -58,4 +58,4 @@ qreal m_sweepAngle = 1.5 * M_PI;
qreal m_lineWidth = 10.0;
};
} // namespace caelestia::internal
} // namespace ZShell::internal
+199
View File
@@ -0,0 +1,199 @@
#include "wallpaperimage.hpp"
#include <QStandardPaths>
#include <QCryptographicHash>
#include <QDir>
#include <QFileInfo>
#include <QtConcurrent>
#include <QSGImageNode>
#include <QQuickWindow>
namespace ZShell::internal {
WallpaperImage::WallpaperImage(QQuickItem *parent)
: QQuickItem(parent)
{
setFlag(ItemHasContents, true);
connect(&m_imageWatcher, &QFutureWatcher<QImage>::finished, this, &WallpaperImage::handleImageLoaded);
}
WallpaperImage::~WallpaperImage() {
if (m_texture) delete m_texture;
}
void WallpaperImage::setSource(const QUrl &source) {
if (m_source == source) return;
m_source = source;
emit sourceChanged();
loadImage();
}
void WallpaperImage::setScreenResolution(const QSize &screenResolution) {
if (m_screenResolution == screenResolution) return;
m_screenResolution = screenResolution;
emit screenResolutionChanged();
loadImage();
}
void WallpaperImage::setZoom(qreal zoom) {
if (qFuzzyCompare(m_zoom, zoom)) return;
m_zoom = zoom;
emit zoomChanged();
update();
}
void WallpaperImage::setCropX(qreal x) {
if (qFuzzyCompare(m_cropX, x)) return;
m_cropX = x;
emit cropXChanged();
update();
}
void WallpaperImage::setCropY(qreal y) {
if (qFuzzyCompare(m_cropY, y)) return;
m_cropY = y;
emit cropYChanged();
update();
}
void WallpaperImage::setCropWidth(qreal w) {
if (w <= 0.0) w = 1.0;
if (qFuzzyCompare(m_cropWidth, w)) return;
m_cropWidth = w;
emit cropWidthChanged();
update();
}
void WallpaperImage::setCropHeight(qreal h) {
if (h <= 0.0) h = 1.0;
if (qFuzzyCompare(m_cropHeight, h)) return;
m_cropHeight = h;
emit cropHeightChanged();
update();
}
QString WallpaperImage::getCacheFilePath() const {
if (m_source.isEmpty() || m_screenResolution.isEmpty()) return QString();
QString cachePath = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + "/zshell/imagecache";
QDir().mkpath(cachePath);
// Hash the source URL + resolution
QString id = m_source.toString() + "_" + QString::number(m_screenResolution.width()) + "x" + QString::number(m_screenResolution.height());
QByteArray hash = QCryptographicHash::hash(id.toUtf8(), QCryptographicHash::Md5).toHex();
return cachePath + "/" + hash + ".png";
}
void WallpaperImage::loadImage() {
if (m_source.isEmpty()) return;
QString cacheFile = getCacheFilePath();
QString sourceFile = m_source.isLocalFile() ? m_source.toLocalFile() : m_source.toString();
// Qt resource path correction if passed as a standard URL string
if (sourceFile.startsWith("qrc:/")) {
sourceFile = sourceFile.mid(3); // Converts "qrc:/" to ":/"
}
QSize targetRes = m_screenResolution;
// Run off the main thread to avoid blocking the UI
QFuture<QImage> future = QtConcurrent::run([sourceFile, cacheFile, targetRes]() -> QImage {
if (!targetRes.isEmpty() && !cacheFile.isEmpty() && QFileInfo::exists(cacheFile)) {
QImage cached(cacheFile);
if (!cached.isNull()) return cached;
}
QImage original(sourceFile);
if (original.isNull()) return QImage();
if (targetRes.isEmpty()) {
// Screen resolution not set yet by QML, return the unscaled original for now to prevent a black screen
return original;
}
// Check if original is strictly larger than screen resolution
if (original.width() > targetRes.width() || original.height() > targetRes.height()) {
QImage scaled = original.scaled(targetRes, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation);
if (!cacheFile.isEmpty()) scaled.save(cacheFile, "PNG");
return scaled;
}
// Otherwise just cache and return the original
if (!cacheFile.isEmpty()) original.save(cacheFile, "PNG");
return original;
});
m_imageWatcher.setFuture(future);
}
void WallpaperImage::handleImageLoaded() {
m_image = m_imageWatcher.result();
m_textureDirty = true;
update(); // Request redraw
}
QSGNode *WallpaperImage::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) {
auto *node = static_cast<QSGImageNode *>(oldNode);
if (m_image.isNull()) {
delete node;
return nullptr;
}
if (!node) {
node = window()->createImageNode();
}
if (m_textureDirty) {
if (m_texture) delete m_texture;
m_texture = window()->createTextureFromImage(m_image, QQuickWindow::TextureHasAlphaChannel);
m_textureDirty = false;
}
if (m_texture) {
node->setTexture(m_texture);
node->setRect(boundingRect());
node->setFiltering(QSGTexture::Linear);
qreal cW = m_cropWidth / m_zoom;
qreal cH = m_cropHeight / m_zoom;
QRectF reqRect(
m_cropX * m_texture->textureSize().width(),
m_cropY * m_texture->textureSize().height(),
cW * m_texture->textureSize().width(),
cH * m_texture->textureSize().height()
);
QRectF bounds = boundingRect();
if (bounds.isEmpty() || reqRect.isEmpty()) return node;
qreal targetRatio = bounds.width() / bounds.height();
qreal reqRatio = reqRect.width() / reqRect.height();
QRectF sourceRect = reqRect;
// Force 'PreserveAspectCrop' behavior on the requested region
if (reqRatio > targetRatio) {
// Requested region is too wide, center-crop the sides
qreal newWidth = reqRect.height() * targetRatio;
qreal xOffset = (reqRect.width() - newWidth) / 2.0;
sourceRect.setX(reqRect.x() + xOffset);
sourceRect.setWidth(newWidth);
} else if (reqRatio < targetRatio) {
// Requested region is too tall, center-crop the top/bottom
qreal newHeight = reqRect.width() / targetRatio;
qreal yOffset = (reqRect.height() - newHeight) / 2.0;
sourceRect.setY(reqRect.y() + yOffset);
sourceRect.setHeight(newHeight);
}
node->setSourceRect(sourceRect);
}
return node;
}
} // namespace ZShell::internal
@@ -0,0 +1,95 @@
#pragma once
#include <QQuickItem>
#include <QImage>
#include <QUrl>
#include <QSGTexture>
#include <QFutureWatcher>
#include <QtQml/qqml.h>
namespace ZShell::internal {
class WallpaperImage : public QQuickItem {
Q_OBJECT
QML_NAMED_ELEMENT(WallpaperImage)
Q_PROPERTY(QUrl source READ source WRITE setSource NOTIFY sourceChanged)
Q_PROPERTY(QSize screenResolution READ screenResolution WRITE setScreenResolution NOTIFY screenResolutionChanged)
Q_PROPERTY(qreal zoom READ zoom WRITE setZoom NOTIFY zoomChanged)
Q_PROPERTY(qreal cropX READ cropX WRITE setCropX NOTIFY cropXChanged)
Q_PROPERTY(qreal cropY READ cropY WRITE setCropY NOTIFY cropYChanged)
Q_PROPERTY(qreal cropWidth READ cropWidth WRITE setCropWidth NOTIFY cropWidthChanged)
Q_PROPERTY(qreal cropHeight READ cropHeight WRITE setCropHeight NOTIFY cropHeightChanged)
public:
explicit WallpaperImage(QQuickItem *parent = nullptr);
~WallpaperImage() override;
QUrl source() const {
return m_source;
}
void setSource(const QUrl &source);
QSize screenResolution() const {
return m_screenResolution;
}
void setScreenResolution(const QSize &screenResolution);
qreal zoom() const {
return m_zoom;
}
void setZoom(qreal zoom);
qreal cropX() const {
return m_cropX;
}
void setCropX(qreal x);
qreal cropY() const {
return m_cropY;
}
void setCropY(qreal y);
qreal cropWidth() const {
return m_cropWidth;
}
void setCropWidth(qreal w);
qreal cropHeight() const {
return m_cropHeight;
}
void setCropHeight(qreal h);
protected:
QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *updatePaintNodeData) override;
signals:
void sourceChanged();
void screenResolutionChanged();
void zoomChanged();
void cropXChanged();
void cropYChanged();
void cropWidthChanged();
void cropHeightChanged();
private:
void loadImage();
void handleImageLoaded();
QString getCacheFilePath() const;
QUrl m_source;
QSize m_screenResolution;
qreal m_zoom = 1.0;
qreal m_cropX = 0.0;
qreal m_cropY = 0.0;
qreal m_cropWidth = 1.0;
qreal m_cropHeight = 1.0;
QImage m_image;
QSGTexture *m_texture = nullptr;
bool m_textureDirty = false;
QFutureWatcher<QImage> m_imageWatcher;
};
} // namespace ZShell::internal
+141 -155
View File
@@ -1,17 +1,22 @@
#include "writefile.hpp"
#include <QtConcurrent/qtconcurrentrun.h>
#include <QtCore/QCryptographicHash>
#include <QtCore/QSaveFile>
#include <QtGui/QImageReader>
#include <QtQuick/qquickimageprovider.h>
#include <QtQuick/qquickitemgrabresult.h>
#include <QtQuick/qquickwindow.h>
#include <qdir.h>
#include <qfile.h>
#include <qfileinfo.h>
#include <qfuturewatcher.h>
#include <qimage.h>
#include <qjsengine.h>
#include <qqmlengine.h>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QImage>
#include <QJSValue>
#include <QQmlEngine>
#include <QSize>
#include <QVariant>
namespace ZShell {
@@ -84,54 +89,51 @@ void ZShellIo::saveItem(
&QQuickItemGrabResult::ready,
this,
[grabResult, scaledRect, path, onSaved, onFailed, this]() {
const auto future = QtConcurrent::run([grabResult, scaledRect, path]() {
QImage image = grabResult->image();
QImage image = grabResult->image();
if (scaledRect.isValid()) {
image = image.copy(scaledRect);
}
const auto future = QtConcurrent::run([image, path]() {
if (scaledRect.isValid()) {
image = image.copy(scaledRect);
}
const QString file = path.toLocalFile();
const QString parent = QFileInfo(file).absolutePath();
return QDir().mkpath(parent) && image.save(file);
QDir().mkpath(parent);
QSaveFile out(file);
if (!out.open(QIODevice::WriteOnly)) {
return false;
}
if (!image.save(&out, "PNG")) {
return false;
}
return out.commit();
});
auto* watcher = new QFutureWatcher<bool>(this);
auto* engine = qmlEngine(this);
QObject::connect(
watcher,
&QFutureWatcher<bool>::finished,
this,
[=]() {
QObject::connect(watcher, &QFutureWatcher<bool>::finished, this, [=]() {
if (watcher->result()) {
if (onSaved.isCallable()) {
if (onSaved.isCallable() && engine) {
onSaved.call({
QJSValue(path.toLocalFile()),
engine->toScriptValue(QVariant::fromValue(path))
engine->toScriptValue(path.toLocalFile()),
engine->toScriptValue(path)
});
}
} else {
qWarning() << "ZShellIo::saveItem: failed to save"
<< path;
if (onFailed.isCallable()) {
qWarning() << "ZShellIo::saveItem: failed to save" << path;
if (onFailed.isCallable() && engine) {
onFailed.call({
engine->toScriptValue(QVariant::fromValue(path))
engine->toScriptValue(path)
});
}
}
watcher->deleteLater();
}
);
});
watcher->setFuture(future);
}
@@ -139,106 +141,130 @@ void ZShellIo::saveItem(
}
// ============================================================
// saveImage
// cacheImage
// ============================================================
void ZShellIo::saveImage(const QUrl& source, const QUrl& target) {
this->saveImage(source, target, QJSValue(), QJSValue());
void ZShellIo::cacheImage(const QUrl& source, const QString& cacheDir) {
this->cacheImage(source, cacheDir, QJSValue(), QJSValue());
}
void ZShellIo::saveImage(const QUrl& source, const QUrl& target, QJSValue onSaved) {
this->saveImage(source, target, onSaved, QJSValue());
void ZShellIo::cacheImage(const QUrl& source, const QString& cacheDir, QJSValue onSaved) {
this->cacheImage(source, cacheDir, onSaved, QJSValue());
}
void ZShellIo::saveImage(
void ZShellIo::cacheImage(
const QUrl& source,
const QUrl& target,
const QString& cacheDir,
QJSValue onSaved,
QJSValue onFailed
) {
auto* engine = qmlEngine(this);
if (cacheDir.isEmpty()) {
qWarning() << "ZShellIo::cacheImage: cacheDir is empty";
return;
}
const auto future = QtConcurrent::run([this, source, target]() {
return this->saveImageInternal(source, target);
QImage image;
if (!loadSourceImage(source, image)) {
qWarning() << "ZShellIo::cacheImage: failed to load source image" << source;
auto* engine = qmlEngine(this);
if (onFailed.isCallable() && engine) {
onFailed.call({
engine->toScriptValue(source),
engine->toScriptValue(cacheDir)
});
}
return;
}
const auto future = QtConcurrent::run([image, cacheDir]() -> QString {
if (image.isNull()) {
return QString();
}
const QImage normalized = image.convertToFormat(QImage::Format_RGBA8888);
const QByteArray bytes(
reinterpret_cast<const char*>(normalized.constBits()),
qsizetype(normalized.sizeInBytes())
);
const QByteArray digest =
QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex();
QDir dir(cacheDir);
if (!dir.exists() && !QDir().mkpath(cacheDir)) {
return QString();
}
const QString finalPath = dir.filePath(QString::fromLatin1(digest) + ".png");
if (QFile::exists(finalPath)) {
return finalPath;
}
QSaveFile out(finalPath);
if (!out.open(QIODevice::WriteOnly)) {
return QString();
}
if (!normalized.save(&out, "PNG")) {
return QString();
}
if (!out.commit()) {
return QString();
}
return finalPath;
});
auto* watcher = new QFutureWatcher<bool>(this);
auto* watcher = new QFutureWatcher<QString>(this);
auto* engine = qmlEngine(this);
QObject::connect(
watcher,
&QFutureWatcher<bool>::finished,
this,
[=]() {
QObject::connect(watcher, &QFutureWatcher<QString>::finished, this, [=]() {
const QString finalPath = watcher->result();
if (watcher->result()) {
if (onSaved.isCallable()) {
if (!finalPath.isEmpty()) {
if (onSaved.isCallable() && engine) {
onSaved.call({
QJSValue(target.toLocalFile()),
engine->toScriptValue(QVariant::fromValue(target))
engine->toScriptValue(finalPath),
engine->toScriptValue(QUrl::fromLocalFile(finalPath))
});
}
} else {
qWarning() << "ZShellIo::saveImage: failed to save"
<< source
<< "to"
<< target;
if (onFailed.isCallable()) {
qWarning() << "ZShellIo::cacheImage: failed to cache" << source;
if (onFailed.isCallable() && engine) {
onFailed.call({
engine->toScriptValue(QVariant::fromValue(target))
engine->toScriptValue(source),
engine->toScriptValue(cacheDir)
});
}
}
watcher->deleteLater();
}
);
});
watcher->setFuture(future);
}
bool ZShellIo::saveImageInternal(const QUrl& source, const QUrl& target) const {
// ============================================================
// loadSourceImage
// ============================================================
if (!target.isLocalFile()) {
qWarning() << "ZShellIo::saveImage: target"
<< target
<< "is not a local file";
return false;
}
const QString targetFile = target.toLocalFile();
if (!QDir().mkpath(QFileInfo(targetFile).absolutePath())) {
return false;
}
// ========================================================
// Local file path
// ========================================================
bool ZShellIo::loadSourceImage(const QUrl& source, QImage& image) const {
image = QImage();
if (source.isLocalFile()) {
QFile::remove(targetFile);
return QFile::copy(
source.toLocalFile(),
targetFile
);
QImageReader reader(source.toLocalFile());
reader.setAutoTransform(true);
image = reader.read();
return !image.isNull();
}
// ========================================================
// image:// provider path
// ========================================================
if (source.scheme() == "image") {
auto* engine = qmlEngine(const_cast<ZShellIo*>(this));
if (!engine) {
qWarning() << "ZShellIo::saveImage: no QQmlEngine";
qWarning() << "ZShellIo::loadSourceImage: no QQmlEngine";
return false;
}
@@ -246,70 +272,44 @@ bool ZShellIo::saveImageInternal(const QUrl& source, const QUrl& target) const {
const QString imageId =
source.path().startsWith('/')
? source.path().mid(1)
: source.path();
auto* providerBase =
engine->imageProvider(providerId);
? source.path().mid(1)
: source.path();
auto* providerBase = engine->imageProvider(providerId);
if (!providerBase) {
qWarning() << "ZShellIo::saveImage: provider not found"
qWarning() << "ZShellIo::loadSourceImage: provider not found"
<< providerId;
return false;
}
auto* provider =
dynamic_cast<QQuickImageProvider*>(providerBase);
auto* provider = dynamic_cast<QQuickImageProvider*>(providerBase);
if (!provider) {
qWarning() << "ZShellIo::saveImage: provider is not a QQuickImageProvider"
<< providerId;
return false;
}
if (!provider) {
qWarning() << "ZShellIo::saveImage: provider not found"
qWarning() << "ZShellIo::loadSourceImage: provider is not a QQuickImageProvider"
<< providerId;
return false;
}
QSize size;
QImage image;
switch (provider->imageType()) {
case QQuickImageProvider::Image:
image = provider->requestImage(
imageId,
&size,
QSize()
);
image = provider->requestImage(imageId, &size, QSize());
break;
case QQuickImageProvider::Pixmap:
image = provider->requestPixmap(
imageId,
&size,
QSize()
).toImage();
image = provider->requestPixmap(imageId, &size, QSize()).toImage();
break;
default:
qWarning() << "ZShellIo::saveImage: unsupported provider type";
qWarning() << "ZShellIo::loadSourceImage: unsupported provider type"
<< providerId;
return false;
}
if (image.isNull()) {
qWarning() << "ZShellIo::saveImage: provider returned null image";
return false;
}
return image.save(targetFile);
return !image.isNull();
}
qWarning() << "ZShellIo::saveImage: unsupported source"
<< source;
qWarning() << "ZShellIo::loadSourceImage: unsupported source" << source;
return false;
}
@@ -318,18 +318,12 @@ bool ZShellIo::saveImageInternal(const QUrl& source, const QUrl& target) const {
// ============================================================
bool ZShellIo::copyFile(const QUrl& source, const QUrl& target, bool overwrite) const {
if (!source.isLocalFile()) {
qWarning() << "ZShellIo::copyFile: source"
<< source
<< "is not a local file";
qWarning() << "ZShellIo::copyFile: source" << source << "is not a local file";
return false;
}
if (!target.isLocalFile()) {
qWarning() << "ZShellIo::copyFile: target"
<< target
<< "is not a local file";
qWarning() << "ZShellIo::copyFile: target" << target << "is not a local file";
return false;
}
@@ -337,18 +331,12 @@ bool ZShellIo::copyFile(const QUrl& source, const QUrl& target, bool overwrite)
QFile::remove(target.toLocalFile());
}
return QFile::copy(
source.toLocalFile(),
target.toLocalFile()
);
return QFile::copy(source.toLocalFile(), target.toLocalFile());
}
bool ZShellIo::deleteFile(const QUrl& path) const {
if (!path.isLocalFile()) {
qWarning() << "ZShellIo::deleteFile: path"
<< path
<< "is not a local file";
qWarning() << "ZShellIo::deleteFile: path" << path << "is not a local file";
return false;
}
@@ -356,10 +344,8 @@ bool ZShellIo::deleteFile(const QUrl& path) const {
}
QString ZShellIo::toLocalFile(const QUrl& url) const {
if (!url.isLocalFile()) {
qWarning() << "ZShellIo::toLocalFile: given url is not a local file"
<< url;
qWarning() << "ZShellIo::toLocalFile: given url is not a local file" << url;
return QString();
}
+8 -7
View File
@@ -1,10 +1,11 @@
#pragma once
#include <QtQuick/qquickitem.h>
#include <qjsvalue.h>
#include <qobject.h>
#include <QImage>
#include <QJSValue>
#include <QObject>
#include <qqmlintegration.h>
#include <qurl.h>
#include <QUrl>
namespace ZShell {
@@ -23,9 +24,9 @@ Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved);
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed);
Q_INVOKABLE void saveImage(const QUrl& source, const QUrl& target);
Q_INVOKABLE void saveImage(const QUrl& source, const QUrl& target, QJSValue onSaved);
Q_INVOKABLE void saveImage(const QUrl& source, const QUrl& target, QJSValue onSaved, QJSValue onFailed);
Q_INVOKABLE void cacheImage(const QUrl& source, const QString& cacheDir);
Q_INVOKABLE void cacheImage(const QUrl& source, const QString& cacheDir, QJSValue onSaved);
Q_INVOKABLE void cacheImage(const QUrl& source, const QString& cacheDir, QJSValue onSaved, QJSValue onFailed);
// clang-format on
Q_INVOKABLE bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true) const;
@@ -33,7 +34,7 @@ Q_INVOKABLE bool deleteFile(const QUrl& path) const;
Q_INVOKABLE QString toLocalFile(const QUrl& url) const;
private:
bool saveImageInternal(const QUrl& source, const QUrl& target) const;
bool loadSourceImage(const QUrl& source, QImage& image) const;
};
} // namespace ZShell