diff --git a/Config/Config.qml b/Config/Config.qml index 2812799..bd666d3 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -14,6 +14,7 @@ Singleton { property alias wallust: adapter.wallust property alias workspaceWidget: adapter.workspaceWidget property alias colors: adapter.colors + property alias gpuType: adapter.gpuType FileView { id: root @@ -37,6 +38,7 @@ Singleton { property bool wallust: false property WorkspaceWidget workspaceWidget: WorkspaceWidget {} property Colors colors: Colors {} + property string gpuType: "" } } } diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 2c9b419..2e3f9ce 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -5,8 +5,10 @@ import Quickshell import Quickshell.Io import Quickshell.Services.Notifications import QtQuick +import ZShell import qs.Modules import qs.Helpers +import qs.Paths Singleton { id: root @@ -81,7 +83,7 @@ Singleton { FileView { id: storage - path: NotifPath.notifPath + path: `${Paths.state}/notifs.json` onLoaded: { const data = JSON.parse(text()); @@ -90,6 +92,7 @@ Singleton { root.list.sort((a, b) => b.time - a.time); root.loaded = true; } + onLoadFailed: err => { if (err === FileViewError.FileNotFound) { root.loaded = true; @@ -144,6 +147,50 @@ Singleton { } } + readonly property LazyLoader dummyImageLoader: LazyLoader { + active: false + + PanelWindow { + implicitWidth: 48 + implicitHeight: 48 + color: "transparent" + mask: Region {} + + Image { + anchors.fill: parent + source: Qt.resolvedUrl(notif.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + opacity: 0 + + onStatusChanged: { + if (status !== Image.Ready) + 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; + }); + } + } + } + } + readonly property Connections conn: Connections { target: notif.notification @@ -169,6 +216,8 @@ Singleton { function onImageChanged(): void { notif.image = notif.notification.image; + if (notif.notification?.image) + notif.dummyImageLoader.active = true; } function onExpireTimeoutChanged(): void { @@ -225,6 +274,8 @@ Singleton { appIcon = notification.appIcon; appName = notification.appName; image = notification.image; + if (notification?.image) + dummyImageLoader.active = true; expireTimeout = notification.expireTimeout; urgency = notification.urgency; resident = notification.resident; diff --git a/Helpers/CachingImage.qml b/Helpers/CachingImage.qml new file mode 100644 index 0000000..905349b --- /dev/null +++ b/Helpers/CachingImage.qml @@ -0,0 +1,28 @@ +import ZShell.Internal +import Quickshell +import QtQuick +import qs.Paths + +Image { + id: root + + property alias path: manager.path + + asynchronous: true + fillMode: Image.PreserveAspectCrop + + Connections { + target: QsWindow.window + + function onDevicePixelRatioChanged(): void { + manager.updateSource(); + } + } + + CachingImageManager { + id: manager + + item: root + cacheDir: Qt.resolvedUrl(Paths.imagecache) + } +} diff --git a/Helpers/SearchWallpapers.qml b/Helpers/SearchWallpapers.qml index 52e77a1..95d1684 100644 --- a/Helpers/SearchWallpapers.qml +++ b/Helpers/SearchWallpapers.qml @@ -4,11 +4,33 @@ import Quickshell import Quickshell.Io import qs.Config import qs.Modules +import qs.Helpers import ZShell.Models Searcher { id: root + readonly property string currentNamePath: WallpaperPath.currentWallpaperPath + + property bool showPreview: false + readonly property string current: showPreview ? previewPath : actualCurrent + property string previewPath + property string actualCurrent: WallpaperPath.currentWallpaperPath + + function setWallpaper(path: string): void { + actualCurrent = path; + WallpaperPath.currentWallpaperPath = path; + } + + function preview(path: string): void { + previewPath = path; + showPreview = true; + } + + function stopPreview(): void { + showPreview = false; + } + list: wallpapers.entries key: "relativePath" useFuzzy: true @@ -16,6 +38,15 @@ Searcher { forward: false }) + FileView { + path: root.currentNamePath + watchChanges: true + onFileChanged: reload() + onLoaded: { + root.actualCurrent = this.text; + } + } + FileSystemModel { id: wallpapers diff --git a/Helpers/TextRender.qml b/Helpers/TextRender.qml new file mode 100644 index 0000000..715a3f1 --- /dev/null +++ b/Helpers/TextRender.qml @@ -0,0 +1,6 @@ +import QtQuick + +Text { + renderType: Text.NativeRendering + textFormat: Text.PlainText +} diff --git a/Modules/Background.qml b/Modules/Background.qml new file mode 100644 index 0000000..dd80626 --- /dev/null +++ b/Modules/Background.qml @@ -0,0 +1,77 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import qs.Helpers + +Item { + id: root + + property string source: SearchWallpapers.current + property Image current: one + + anchors.fill: parent + + onSourceChanged: { + if (!source) { + current = null; + } else if (current === one) { + two.update(); + } else { + one.update(); + } + } + + Component.onCompleted: { + console.log(root.source) + if (source) + Qt.callLater(() => one.update()); + } + + Img { + id: one + } + + Img { + id: two + } + + component Img: CachingImage { + id: img + + function update(): void { + if (path === root.source) { + root.current = this; + } else { + path = root.source; + } + } + + anchors.fill: parent + + opacity: 0 + scale: SearchWallpapers.showPreview ? 1 : 0.8 + asynchronous: true + onStatusChanged: { + if (status === Image.Ready) { + root.current = this; + } + } + + states: State { + name: "visible" + when: root.current === img + + PropertyChanges { + img.opacity: 1 + img.scale: 1 + } + } + + transitions: Transition { + Anim { + target: img + properties: "opacity,scale" + } + } + } +} diff --git a/Modules/CustomTextField.qml b/Modules/CustomTextField.qml index 55de91d..d0d8561 100644 --- a/Modules/CustomTextField.qml +++ b/Modules/CustomTextField.qml @@ -83,13 +83,16 @@ TextField { Search.launch(appListLoader.item.currentItem.modelData); launcherWindow.visible = false; } else if ( wallpaperPickerLoader.active ) { - WallpaperPath.currentWallpaperPath = wallpaperPickerLoader.item.currentItem.modelData.path; + SearchWallpapers.setWallpaper(wallpaperPickerLoader.item.currentItem.modelData.path) if ( Config.wallust ) { Wallust.generateColors(WallpaperPath.currentWallpaperPath); } + closeAnim.start(); } event.accepted = true; } else if ( event.key === Qt.Key_Escape ) { + if ( wallpaperPickerLoader.active ) + SearchWallpapers.stopPreview(); closeAnim.start(); event.accepted = true; } diff --git a/Modules/GroupListView.qml b/Modules/GroupListView.qml index ea77724..e2db28d 100644 --- a/Modules/GroupListView.qml +++ b/Modules/GroupListView.qml @@ -19,6 +19,7 @@ Repeater { root.flagChanged(); } } + Column { id: groupColumn required property string modelData @@ -28,6 +29,7 @@ Repeater { property bool shouldShow: false property bool isExpanded: false + property bool collapseAnimRunning: false function closeAll(): void { for ( const n of NotifServer.notClosed.filter( n => n.appName === modelData )) @@ -49,7 +51,7 @@ Repeater { id: addTrans SequentialAnimation { PauseAnimation { - duration: ( addTrans.ViewTransition.index - addTrans.ViewTransition.targetIndexes[ 0 ]) * 50 + duration: ( addTrans.ViewTransition.index - addTrans.ViewTransition.targetIndexes[ 0 ]) * 30 } ParallelAnimation { NumberAnimation { @@ -77,6 +79,16 @@ Repeater { } } + Timer { + interval: addTrans.ViewTransition.targetIndexes.length * 30 + 100 + running: groupColumn.isExpanded + repeat: false + onTriggered: { + groupColumn.shouldShow = true; + console.log("ran timer"); + } + } + move: Transition { id: moveTrans NumberAnimation { @@ -84,6 +96,11 @@ Repeater { duration: 100; easing.type: Easing.OutCubic } + + NumberAnimation { + properties: "opacity, scale"; + to: 1.0; + } } RowLayout { @@ -120,11 +137,11 @@ Repeater { anchors.fill: parent hoverEnabled: true onClicked: { - groupColumn.shouldShow = false; + groupColumn.collapseAnimRunning = true; } } } } - NotifGroupRepeater { } + NotifGroupRepeater { id: groupRepeater } } } diff --git a/Modules/Launcher.qml b/Modules/Launcher.qml index 1ef14b9..de7ca52 100644 --- a/Modules/Launcher.qml +++ b/Modules/Launcher.qml @@ -259,6 +259,12 @@ Scope { } Component.onCompleted: currentIndex = SearchWallpapers.list.findIndex( w => w.path === WallpaperPath.currentWallpaperPath ) + Component.onDestruction: SearchWallpapers.stopPreview() + + onCurrentItemChanged: { + if ( currentItem ) + SearchWallpapers.preview( currentItem.modelData.path ); + } cacheItemCount: 5 snapMode: PathView.SnapToItem diff --git a/Modules/NotifGroupRepeater.qml b/Modules/NotifGroupRepeater.qml index f7ad940..60e795f 100644 --- a/Modules/NotifGroupRepeater.qml +++ b/Modules/NotifGroupRepeater.qml @@ -4,21 +4,21 @@ import QtQuick import QtQuick.Layouts import qs.Config import qs.Daemons +import qs.Helpers Repeater { id: groupListView model: ScriptModel { id: groupModel - values: groupColumn.isExpanded ? groupColumn.notifications : groupColumn.notifications.slice( 0, 1 ) + values: groupColumn.isExpanded || groupColumn.shouldShow ? groupColumn.notifications : groupColumn.notifications.slice( 0, 1 ) } - Rectangle { id: groupHeader required property int index required property NotifServer.Notif modelData property alias notifHeight: groupHeader.height - property bool previewHidden: groupColumn.shouldShow && index > 0 + property bool previewHidden: !groupColumn.shouldShow && index > 0 width: parent.width height: contentColumn.height + 15 @@ -26,10 +26,12 @@ Repeater { border.color: "#555555" border.width: 1 radius: 8 - opacity: previewHidden ? 0 : 1.0 + opacity: previewHidden ? 0 : 1 scale: previewHidden ? 0.7 : 1.0 - Component.onCompleted: modelData.lock(this); + Component.onCompleted: { + modelData.lock(this); + } Component.onDestruction: modelData.unlock(this); MouseArea { @@ -40,7 +42,6 @@ Repeater { groupHeader.modelData.actions[0].invoke(); } } else { - groupColumn.shouldShow = true; groupColumn.isExpanded = true; } } @@ -48,7 +49,7 @@ Repeater { ParallelAnimation { id: collapseAnim - running: !groupColumn.shouldShow && index > 0 + running: groupColumn.collapseAnimRunning Anim { target: groupHeader @@ -66,6 +67,8 @@ Repeater { } onFinished: { groupColumn.isExpanded = false; + groupColumn.shouldShow = false; + groupColumn.collapseAnimRunning = false; } } @@ -107,13 +110,6 @@ Repeater { } } - // Behavior on height { - // Anim { - // duration: MaterialEasing.expressiveDefaultSpatialTime - // easing.bezierCurve: MaterialEasing.expressiveDefaultSpatial - // } - // } - Column { id: contentColumn anchors.top: parent.top @@ -122,7 +118,6 @@ Repeater { anchors.leftMargin: 10 anchors.rightMargin: 10 anchors.topMargin: 5 - // width: parent.width - 20 spacing: 10 RowLayout { id: infoRow @@ -130,19 +125,19 @@ Repeater { spacing: 10 IconImage { - source: groupHeader.modelData.image + source: groupHeader.modelData.image === "" ? Qt.resolvedUrl(groupHeader.modelData.appIcon) : Qt.resolvedUrl(groupHeader.modelData.image) Layout.preferredWidth: 48 Layout.preferredHeight: 48 Layout.alignment: Qt.AlignTop | Qt.AlignLeft Layout.topMargin: 5 - visible: groupHeader.modelData.image !== "" + visible: source !== "" } ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true - Text { + TextRender { text: groupHeader.modelData.summary color: "white" font.bold: true @@ -152,21 +147,26 @@ Repeater { Layout.alignment: Qt.AlignTop } - Text { + TextRender { text: groupHeader.modelData.body - font.pointSize: 12 color: "#dddddd" + font.pointSize: 12 elide: Text.ElideRight - lineHeightMode: Text.FixedHeight - lineHeight: 20 + textFormat: Text.MarkdownText wrapMode: Text.WordWrap maximumLineCount: 5 + linkColor: Config.accentColor.accents.primaryAlt + + onLinkActivated: link => { + Quickshell.execDetached(["app2unit", "-O", "--", link]); + } + Layout.fillWidth: true Layout.fillHeight: true } } - Text { + TextRender { text: groupHeader.modelData.timeStr font.pointSize: 10 color: "#666666" @@ -191,7 +191,7 @@ Repeater { required property var modelData color: buttonArea.containsMouse ? "#15FFFFFF" : "#09FFFFFF" radius: 4 - Text { + TextRender { anchors.centerIn: parent text: actionButton.modelData.text color: "white" @@ -220,7 +220,7 @@ Repeater { color: closeArea.containsMouse ? "#FF6077" : "transparent" radius: 9 - Text { + TextRender { anchors.centerIn: parent text: "✕" color: closeArea.containsMouse ? "white" : "#888888" diff --git a/Modules/NotificationCenter.qml b/Modules/NotificationCenter.qml index 4530238..a16d433 100644 --- a/Modules/NotificationCenter.qml +++ b/Modules/NotificationCenter.qml @@ -71,7 +71,7 @@ PanelWindow { id: showAnimation target: backgroundRect property: "x" - to: root.bar.screen.width - backgroundRect.implicitWidth - 10 + to: Math.round(root.bar.screen.width - backgroundRect.implicitWidth - 10) from: root.bar.screen.width duration: MaterialEasing.expressiveEffectsTime easing.bezierCurve: MaterialEasing.expressiveEffects diff --git a/Modules/ResourceUsage.qml b/Modules/ResourceUsage.qml index d2909af..d333318 100644 --- a/Modules/ResourceUsage.qml +++ b/Modules/ResourceUsage.qml @@ -4,8 +4,10 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Io +import qs.Config Singleton { + id: root property double memoryTotal: 1 property double memoryFree: 1 property double memoryUsed: memoryTotal - memoryFree @@ -19,6 +21,8 @@ Singleton { property double gpuUsage: 0 property double gpuMemUsage: 0 property double totalMem: 0 + readonly property string gpuType: Config.gpuType.toUpperCase() || autoGpuType + property string autoGpuType: "NONE" Timer { interval: 1 @@ -52,7 +56,9 @@ Singleton { previousCpuStats = { total, idle } } - processGpu.running = true + if ( root.gpuType === "NVIDIA" ) { + processGpu.running = true + } interval = 1000 } @@ -61,10 +67,20 @@ Singleton { FileView { id: fileMeminfo; path: "/proc/meminfo" } FileView { id: fileStat; path: "/proc/stat" } + Process { + id: gpuTypeCheck + + running: !Config.gpuType + command: ["sh", "-c", "if command -v nvidia-smi &>/dev/null && nvidia-smi -L &>/dev/null; then echo NVIDIA; elif ls /sys/class/drm/card*/device/gpu_busy_percent 2>/dev/null | grep -q .; then echo GENERIC; else echo NONE; fi"] + stdout: StdioCollector { + onStreamFinished: root.autoGpuType = text.trim() + } + } + Process { id: oneshotMem command: ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"] - running: true + running: root.gpuType === "NVIDIA" && totalMem === 0 stdout: StdioCollector { onStreamFinished: { totalMem = Number(this.text.trim()) diff --git a/Modules/TrackedNotification.qml b/Modules/TrackedNotification.qml index af96291..c8af5b3 100644 --- a/Modules/TrackedNotification.qml +++ b/Modules/TrackedNotification.qml @@ -146,11 +146,11 @@ PanelWindow { RowLayout { spacing: 12 IconImage { - source: rootItem.modelData.image + source: rootItem.modelData.image === "" ? Qt.resolvedUrl(rootItem.modelData.appIcon) : Qt.resolvedUrl(rootItem.modelData.image) Layout.preferredWidth: 48 Layout.preferredHeight: 48 Layout.alignment: Qt.AlignHCenter | Qt.AlignLeft - visible: rootItem.modelData.image !== "" + // visible: rootItem.modelData.image !== "" } ColumnLayout { @@ -185,10 +185,16 @@ PanelWindow { text: rootItem.modelData.body color: "#dddddd" font.pointSize: 14 + textFormat: Text.MarkdownText elide: Text.ElideRight wrapMode: Text.WordWrap maximumLineCount: 4 width: parent.width + linkColor: Config.accentColor.accents.primaryAlt + + onLinkActivated: link => { + Quickshell.execDetached(["app2unit", "-O", "--", link]); + } } } diff --git a/Modules/Updates.qml b/Modules/Updates.qml index 7e74316..89a67b7 100644 --- a/Modules/Updates.qml +++ b/Modules/Updates.qml @@ -15,7 +15,7 @@ Singleton { repeat: true onTriggered: { updatesProc.running = true - interval = 60000 + interval = 5000 } } diff --git a/Paths/Paths.qml b/Paths/Paths.qml new file mode 100644 index 0000000..373c7f6 --- /dev/null +++ b/Paths/Paths.qml @@ -0,0 +1,37 @@ +pragma Singleton + +import ZShell +import Quickshell +import qs.Config + +Singleton { + id: root + + readonly property string home: Quickshell.env("HOME") + readonly property string pictures: Quickshell.env("XDG_PICTURES_DIR") || `${home}/Pictures` + readonly property string videos: Quickshell.env("XDG_VIDEOS_DIR") || `${home}/Videos` + + readonly property string data: `${Quickshell.env("XDG_DATA_HOME") || `${home}/.local/share`}/zshell` + readonly property string state: `${Quickshell.env("XDG_STATE_HOME") || `${home}/.local/state`}/zshell` + readonly property string cache: `${Quickshell.env("XDG_CACHE_HOME") || `${home}/.cache`}/zshell` + readonly property string config: `${Quickshell.env("XDG_CONFIG_HOME") || `${home}/.config`}/zshell` + + readonly property string imagecache: `${cache}/imagecache` + readonly property string notifimagecache: `${imagecache}/notifs` + readonly property string wallsdir: Quickshell.env("ZSHELL_WALLPAPERS_DIR") || absolutePath(Config.wallpaperPath) + readonly property string recsdir: Quickshell.env("ZSHELL_RECORDINGS_DIR") || `${videos}/Recordings` + readonly property string libdir: Quickshell.env("ZSHELL_LIB_DIR") || "/usr/lib/zshell" + + function toLocalFile(path: url): string { + path = Qt.resolvedUrl(path); + return path.toString() ? ZShellIo.toLocalFile(path) : ""; + } + + function absolutePath(path: string): string { + return toLocalFile(path.replace("~", home)); + } + + function shortenHome(path: string): string { + return path.replace(home, "~"); + } +} diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 7537037..46c9786 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -3,4 +3,10 @@ qml_module(ZShell-internal SOURCES hyprextras.hpp hyprextras.cpp hyprdevices.hpp hyprdevices.cpp + cachingimagemanager.hpp cachingimagemanager.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent + Qt::Core ) diff --git a/Plugins/ZShell/Internal/cachingimagemanager.cpp b/Plugins/ZShell/Internal/cachingimagemanager.cpp new file mode 100644 index 0000000..493b405 --- /dev/null +++ b/Plugins/ZShell/Internal/cachingimagemanager.cpp @@ -0,0 +1,223 @@ +#include "cachingimagemanager.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::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; + + const auto future = QtConcurrent::run(&CachingImageManager::sha256sum, path); + + const auto watcher = new QFutureWatcher(this); + + connect(watcher, &QFutureWatcher::finished, this, [watcher, path, this]() { + if (m_path != path) { + // Object is destroyed or path has changed, ignore + watcher->deleteLater(); + return; + } + + const QSize size = effectiveSize(); + + if (!m_item || !size.width() || !size.height()) { + watcher->deleteLater(); + return; + } + + const QString fillMode = m_item->property("fillMode").toString(); + // clang-format off + const QString filename = QString("%1@%2x%3-%4.png") + .arg(watcher->result()).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) { + watcher->deleteLater(); + return; + } + + m_cachePath = cache; + emit cachePathChanged(); + + if (!cache.isLocalFile()) { + qWarning() << "CachingImageManager::updateSource: cachePath" << cache << "is not a local file"; + watcher->deleteLater(); + 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(); + } + + watcher->deleteLater(); + }); + + watcher->setFuture(future); +} + +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()) { + qWarning() << "CachingImageManager::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)) { + qWarning() << "CachingImageManager::createCache: failed to save to" << cache; + } + }); +} + +QString CachingImageManager::sha256sum(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + qWarning() << "CachingImageManager::sha256sum: failed to open" << path; + return ""; + } + + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(&file); + file.close(); + + return hash.result().toHex(); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/cachingimagemanager.hpp b/Plugins/ZShell/Internal/cachingimagemanager.hpp new file mode 100644 index 0000000..242f92b --- /dev/null +++ b/Plugins/ZShell/Internal/cachingimagemanager.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +namespace ZShell::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) + , m_item(nullptr) {} + + [[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; + + QQuickItem* 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 ZShell::internal diff --git a/Wallpaper.qml b/Wallpaper.qml index 5c9b4f8..8fd7db8 100644 --- a/Wallpaper.qml +++ b/Wallpaper.qml @@ -21,23 +21,7 @@ Scope { right: true bottom: true } - Image { - id: wallpaperImage - anchors.fill: parent - source: WallpaperPath.currentWallpaperPath - fillMode: Image.PreserveAspectCrop - - smooth: true - asynchronous: true - retainWhileLoading: true - - // Behavior on source { - // Anim { - // properties: "opacity" - // duration: 500 - // } - // } - } + Background {} } } }