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

This commit is contained in:
2026-05-26 23:03:28 +02:00
9 changed files with 408 additions and 204 deletions
+55 -57
View File
@@ -179,6 +179,8 @@ Singleton {
property string appIcon
property string appName
property string body
property string cachedImageSource: ""
property bool cachingImage: false
property bool closed
readonly property Connections conn: Connections {
function onActionsChanged(): void {
@@ -214,9 +216,9 @@ Singleton {
}
function onImageChanged(): void {
notif.image = notif.notification.image;
if (notif.notification?.image)
notif.dummyImageLoader.active = true;
notif.imageSource = notif.notification.image || "";
notif.image = notif.imageSource;
notif.cacheImageIfNeeded();
}
function onResidentChanged(): void {
@@ -233,60 +235,12 @@ Singleton {
target: notif.notification
}
readonly property LazyLoader dummyImageLoader: LazyLoader {
active: false
PanelWindow {
color: "transparent"
implicitHeight: Config.notifs.sizes.image
implicitWidth: Config.notifs.sizes.image
mask: Region {
}
Image {
function tryCache(): void {
if (status !== Image.Ready || width != Config.notifs.sizes.image || height != Config.notifs.sizes.image)
return;
const cacheKey = notif.appName + notif.summary + notif.id;
let h1 = 0xdeadbeef, h2 = 0x41c6ce57, ch;
for (let i = 0; i < cacheKey.length; i++) {
ch = cacheKey.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
const hash = (h2 >>> 0).toString(16).padStart(8, 0) + (h1 >>> 0).toString(16).padStart(8, 0);
const cache = `${Paths.notifimagecache}/${hash}.png`;
ZShellIo.saveItem(this, Qt.resolvedUrl(cache), () => {
notif.image = cache;
notif.dummyImageLoader.active = false;
});
}
anchors.fill: parent
asynchronous: true
cache: false
fillMode: Image.PreserveAspectCrop
opacity: 0
source: Qt.resolvedUrl(notif.image)
onHeightChanged: tryCache()
onStatusChanged: tryCache()
onWidthChanged: tryCache()
}
}
}
property real expireTimeout: 5
property bool hasActionIcons
property string id
property string image
property string imageSource
property var locks: new Set()
property string notifId
property Notification notification
property bool popup
property bool resident
@@ -329,6 +283,35 @@ Singleton {
}
property int urgency: NotificationUrgency.Normal
function cacheImageIfNeeded(): void {
const source = imageSource;
if (!source || cachingImage)
return;
if (cachedImageSource === source)
return;
if (source.startsWith("file:")) {
cachedImageSource = source;
image = source;
return;
}
const hash = hashForString(source);
const cache = `${Paths.notifimagecache}/${hash}.png`;
const cacheUrl = Qt.resolvedUrl(cache);
cachingImage = true;
ZShellIo.saveImage(source, cacheUrl, () => {
cachedImageSource = source;
image = cache;
cachingImage = false;
}, () => {
cachingImage = false;
});
}
function close(): void {
closed = true;
if (locks.size === 0 && root.list.includes(this)) {
@@ -338,6 +321,20 @@ Singleton {
}
}
function hashForString(s: string): string {
let h1 = 0xdeadbeef, h2 = 0x41c6ce57, ch;
for (let i = 0; i < s.length; i++) {
ch = s.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return (h2 >>> 0).toString(16).padStart(8, "0") + (h1 >>> 0).toString(16).padStart(8, "0");
}
function lock(item: Item): void {
locks.add(item);
}
@@ -352,14 +349,13 @@ Singleton {
if (!notification)
return;
id = notification.id;
notifId = notification.id;
summary = notification.summary;
body = notification.body;
appIcon = notification.appIcon;
appName = notification.appName;
image = notification.image;
if (notification?.image)
dummyImageLoader.active = true;
imageSource = notification.image || "";
image = imageSource;
expireTimeout = notification.expireTimeout;
urgency = notification.urgency;
resident = notification.resident;
@@ -369,6 +365,8 @@ Singleton {
text: a.text,
invoke: () => a.invoke()
}));
cacheImageIfNeeded();
}
}
}
+1
View File
@@ -229,6 +229,7 @@ Variants {
id: notifsBg
panel: panels.notifications
radius: Appearance.rounding.normal
}
PanelBg {
+1 -1
View File
@@ -20,7 +20,7 @@ Item {
required property PersistentProperties visibilities
implicitHeight: content.implicitHeight
implicitWidth: content.implicitWidth || 854 // Hard coded fallback for first open
implicitWidth: content.implicitWidth || 854
opacity: 1 - offsetScale
visible: offsetScale < 1
+5 -6
View File
@@ -9,18 +9,17 @@ Item {
id: root
property int contentHeight
property real offsetScale: shouldBeActive ? 0 : 1
required property var panels
required property ShellScreen screen
readonly property bool shouldBeActive: visibilities.dock && Config.dock.enable
required property PersistentProperties visibilities
readonly property bool shouldBeActive: visibilities.dock
property real offsetScale: shouldBeActive ? 0 : 1
visible: offsetScale < 1
anchors.bottomMargin: (-implicitHeight - 5) * offsetScale
implicitHeight: content.implicitHeight
implicitWidth: content.implicitWidth || 400
opacity: 1 - offsetScale
visible: offsetScale < 1
Behavior on offsetScale {
Anim {
@@ -32,10 +31,10 @@ Item {
Loader {
id: content
active: root.shouldBeActive || root.visible
anchors.left: parent.left
anchors.top: parent.top
active: root.shouldBeActive || root.visible
asynchronous: true
sourceComponent: Content {
panels: root.panels
+2
View File
@@ -47,6 +47,7 @@ Item {
active: Qt.binding(() => root.shouldBeActive || root.visible)
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
asynchronous: true
height: content.contentItem.height
opacity: root.expanded ? 0 : 1
@@ -65,6 +66,7 @@ Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
asynchronous: true
opacity: root.expanded ? 1 : 0
Behavior on opacity {
+3 -45
View File
@@ -8,7 +8,7 @@ import QtQuick
Item {
id: root
readonly property int padding: 6
readonly property int padding: Appearance.padding.smaller
required property Item panels
required property PersistentProperties visibilities
@@ -54,7 +54,7 @@ Item {
anchors.fill: parent
anchors.margins: root.padding
color: "transparent"
radius: Appearance.rounding.smallest / 2
radius: Appearance.rounding.normal - root.padding
CustomListView {
id: list
@@ -72,7 +72,7 @@ Item {
required property NotifServer.Notif modelData
readonly property alias nonAnimHeight: notif.nonAnimHeight
implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : 8)
implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Appearance.spacing.small)
implicitWidth: notif.implicitWidth
ListView.onRemove: removeAnim.start()
@@ -151,48 +151,6 @@ Item {
property: "y"
}
}
ExtraIndicator {
anchors.top: parent.top
extra: {
const count = list.count;
if (count === 0)
return 0;
const scrollY = list.contentY;
let height = 0;
for (let i = 0; i < count; i++) {
height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + 8;
if (height - 8 >= scrollY)
return i;
}
return count;
}
}
ExtraIndicator {
anchors.bottom: parent.bottom
extra: {
const count = list.count;
if (count === 0)
return 0;
const scrollY = list.contentHeight - (list.contentY + list.height);
let height = 0;
for (let i = count - 1; i >= 0; i--) {
height += (list.itemAtIndex(i)?.nonAnimHeight ?? 0) + 8;
if (height - 8 >= scrollY)
return count - i - 1;
}
return 0;
}
}
}
}
+1 -1
View File
@@ -134,7 +134,7 @@ Item {
anchors.right: parent.right
anchors.top: searchBar.bottom
anchors.topMargin: Appearance.spacing.smaller
color: DynamicColors.tPalette.m3surfaceContainerLowest
color: DynamicColors.tPalette.m3surfaceContainer
radius: Appearance.rounding.normal
StackView {
+317 -79
View File
@@ -1,131 +1,369 @@
#include "writefile.hpp"
#include <QtConcurrent/qtconcurrentrun.h>
#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>
namespace ZShell {
// ============================================================
// saveItem
// ============================================================
void ZShellIo::saveItem(QQuickItem* target, const QUrl& path) {
this->saveItem(target, path, QRect(), QJSValue(), QJSValue());
this->saveItem(target, path, QRect(), QJSValue(), QJSValue());
}
void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect) {
this->saveItem(target, path, rect, QJSValue(), QJSValue());
this->saveItem(target, path, rect, QJSValue(), QJSValue());
}
void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved) {
this->saveItem(target, path, QRect(), onSaved, QJSValue());
this->saveItem(target, path, QRect(), onSaved, QJSValue());
}
void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed) {
this->saveItem(target, path, QRect(), onSaved, onFailed);
this->saveItem(target, path, QRect(), onSaved, onFailed);
}
void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved) {
this->saveItem(target, path, rect, onSaved, QJSValue());
this->saveItem(target, path, rect, onSaved, QJSValue());
}
void ZShellIo::saveItem(QQuickItem* target, const QUrl& path, const QRect& rect, QJSValue onSaved, QJSValue onFailed) {
if (!target) {
qWarning() << "ZShellIo::saveItem: a target is required";
return;
}
void ZShellIo::saveItem(
QQuickItem* target,
const QUrl& path,
const QRect& rect,
QJSValue onSaved,
QJSValue onFailed
) {
if (!target) {
qWarning() << "ZShellIo::saveItem: a target is required";
return;
}
if (!path.isLocalFile()) {
qWarning() << "ZShellIo::saveItem:" << path << "is not a local file";
return;
}
if (!path.isLocalFile()) {
qWarning() << "ZShellIo::saveItem:" << path << "is not a local file";
return;
}
if (!target->window()) {
qWarning() << "ZShellIo::saveItem: unable to save target" << target << "without a window";
return;
}
if (!target->window()) {
qWarning() << "ZShellIo::saveItem: unable to save target"
<< target
<< "without a window";
return;
}
auto scaledRect = rect;
const qreal scale = target->window()->devicePixelRatio();
if (rect.isValid() && !qFuzzyCompare(scale + 1.0, 2.0)) {
scaledRect =
QRectF(rect.left() * scale, rect.top() * scale, rect.width() * scale, rect.height() * scale).toRect();
}
auto scaledRect = rect;
const QSharedPointer<const QQuickItemGrabResult> grabResult = target->grabToImage();
const qreal scale = target->window()->devicePixelRatio();
QObject::connect(grabResult.data(), &QQuickItemGrabResult::ready, this,
[grabResult, scaledRect, path, onSaved, onFailed, this]() {
const auto future = QtConcurrent::run([=]() {
QImage image = grabResult->image();
if (rect.isValid() && !qFuzzyCompare(scale + 1.0, 2.0)) {
scaledRect = QRectF(
rect.left() * scale,
rect.top() * scale,
rect.width() * scale,
rect.height() * scale
).toRect();
}
if (scaledRect.isValid()) {
image = image.copy(scaledRect);
}
const QSharedPointer<const QQuickItemGrabResult> grabResult =
target->grabToImage();
const QString file = path.toLocalFile();
const QString parent = QFileInfo(file).absolutePath();
return QDir().mkpath(parent) && image.save(file);
});
QObject::connect(
grabResult.data(),
&QQuickItemGrabResult::ready,
this,
[grabResult, scaledRect, path, onSaved, onFailed, this]() {
auto* watcher = new QFutureWatcher<bool>(this);
auto* engine = qmlEngine(this);
QImage image = grabResult->image();
QObject::connect(watcher, &QFutureWatcher<bool>::finished, this, [=]() {
if (watcher->result()) {
if (onSaved.isCallable()) {
onSaved.call(
{ QJSValue(path.toLocalFile()), engine->toScriptValue(QVariant::fromValue(path)) });
}
} else {
qWarning() << "ZShellIo::saveItem: failed to save" << path;
if (onFailed.isCallable()) {
onFailed.call({ engine->toScriptValue(QVariant::fromValue(path)) });
}
}
watcher->deleteLater();
});
watcher->setFuture(future);
});
if (scaledRect.isValid()) {
image = image.copy(scaledRect);
}
const auto future = QtConcurrent::run([image, path]() {
const QString file = path.toLocalFile();
const QString parent = QFileInfo(file).absolutePath();
return QDir().mkpath(parent) && image.save(file);
});
auto* watcher = new QFutureWatcher<bool>(this);
auto* engine = qmlEngine(this);
QObject::connect(
watcher,
&QFutureWatcher<bool>::finished,
this,
[=]() {
if (watcher->result()) {
if (onSaved.isCallable()) {
onSaved.call({
QJSValue(path.toLocalFile()),
engine->toScriptValue(QVariant::fromValue(path))
});
}
} else {
qWarning() << "ZShellIo::saveItem: failed to save"
<< path;
if (onFailed.isCallable()) {
onFailed.call({
engine->toScriptValue(QVariant::fromValue(path))
});
}
}
watcher->deleteLater();
}
);
watcher->setFuture(future);
}
);
}
// ============================================================
// saveImage
// ============================================================
void ZShellIo::saveImage(const QUrl& source, const QUrl& target) {
this->saveImage(source, target, QJSValue(), QJSValue());
}
void ZShellIo::saveImage(const QUrl& source, const QUrl& target, QJSValue onSaved) {
this->saveImage(source, target, onSaved, QJSValue());
}
void ZShellIo::saveImage(
const QUrl& source,
const QUrl& target,
QJSValue onSaved,
QJSValue onFailed
) {
auto* engine = qmlEngine(this);
const auto future = QtConcurrent::run([this, source, target]() {
return this->saveImageInternal(source, target);
});
auto* watcher = new QFutureWatcher<bool>(this);
QObject::connect(
watcher,
&QFutureWatcher<bool>::finished,
this,
[=]() {
if (watcher->result()) {
if (onSaved.isCallable()) {
onSaved.call({
QJSValue(target.toLocalFile()),
engine->toScriptValue(QVariant::fromValue(target))
});
}
} else {
qWarning() << "ZShellIo::saveImage: failed to save"
<< source
<< "to"
<< target;
if (onFailed.isCallable()) {
onFailed.call({
engine->toScriptValue(QVariant::fromValue(target))
});
}
}
watcher->deleteLater();
}
);
watcher->setFuture(future);
}
bool ZShellIo::saveImageInternal(const QUrl& source, const QUrl& target) const {
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
// ========================================================
if (source.isLocalFile()) {
QFile::remove(targetFile);
return QFile::copy(
source.toLocalFile(),
targetFile
);
}
// ========================================================
// image:// provider path
// ========================================================
if (source.scheme() == "image") {
auto* engine = qmlEngine(const_cast<ZShellIo*>(this));
if (!engine) {
qWarning() << "ZShellIo::saveImage: no QQmlEngine";
return false;
}
const QString providerId = source.host();
const QString imageId =
source.path().startsWith('/')
? source.path().mid(1)
: source.path();
auto* providerBase =
engine->imageProvider(providerId);
if (!providerBase) {
qWarning() << "ZShellIo::saveImage: provider not found"
<< providerId;
return false;
}
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"
<< providerId;
return false;
}
QSize size;
QImage image;
switch (provider->imageType()) {
case QQuickImageProvider::Image:
image = provider->requestImage(
imageId,
&size,
QSize()
);
break;
case QQuickImageProvider::Pixmap:
image = provider->requestPixmap(
imageId,
&size,
QSize()
).toImage();
break;
default:
qWarning() << "ZShellIo::saveImage: unsupported provider type";
return false;
}
if (image.isNull()) {
qWarning() << "ZShellIo::saveImage: provider returned null image";
return false;
}
return image.save(targetFile);
}
qWarning() << "ZShellIo::saveImage: unsupported source"
<< source;
return false;
}
// ============================================================
// File ops
// ============================================================
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";
return false;
}
if (!target.isLocalFile()) {
qWarning() << "ZShellIo::copyFile: target" << target << "is not a local file";
return false;
}
if (overwrite) {
if (!QFile::remove(target.toLocalFile())) {
qWarning() << "ZShellIo::copyFile: overwrite was specified but failed to remove" << target.toLocalFile();
return false;
}
}
if (!source.isLocalFile()) {
qWarning() << "ZShellIo::copyFile: source"
<< source
<< "is not a local file";
return false;
}
return QFile::copy(source.toLocalFile(), target.toLocalFile());
if (!target.isLocalFile()) {
qWarning() << "ZShellIo::copyFile: target"
<< target
<< "is not a local file";
return false;
}
if (overwrite) {
QFile::remove(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";
return false;
}
return QFile::remove(path.toLocalFile());
if (!path.isLocalFile()) {
qWarning() << "ZShellIo::deleteFile: path"
<< path
<< "is not a local file";
return false;
}
return QFile::remove(path.toLocalFile());
}
QString ZShellIo::toLocalFile(const QUrl& url) const {
if (!url.isLocalFile()) {
qWarning() << "ZShellIo::toLocalFile: given url is not a local file" << url;
return QString();
}
return url.toLocalFile();
if (!url.isLocalFile()) {
qWarning() << "ZShellIo::toLocalFile: given url is not a local file"
<< url;
return QString();
}
return url.toLocalFile();
}
} // namespace ZShell
+23 -15
View File
@@ -1,31 +1,39 @@
#pragma once
#include <QtQuick/qquickitem.h>
#include <qjsvalue.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qurl.h>
namespace ZShell {
class ZShellIo : public QObject {
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
Q_OBJECT
QML_ELEMENT
QML_SINGLETON
public:
// clang-format off
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path);
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect);
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved);
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed);
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);
// clang-format on
// clang-format off
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path);
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, const QRect& rect);
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved);
Q_INVOKABLE void saveItem(QQuickItem* target, const QUrl& path, QJSValue onSaved, QJSValue onFailed);
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 bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true) const;
Q_INVOKABLE bool deleteFile(const QUrl& path) const;
Q_INVOKABLE QString toLocalFile(const QUrl& url) const;
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);
// clang-format on
Q_INVOKABLE bool copyFile(const QUrl& source, const QUrl& target, bool overwrite = true) const;
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;
};
} // namespace ZShell