From 75de349ff4765f5067e5b9a290220c20c55964b6 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 00:15:52 +0200 Subject: [PATCH 01/10] MD3 wallpaper launcher carousel --- Modules/Launcher/CarouselView.qml | 327 +++++++++++++++++++++++ Modules/Launcher/ContentList.qml | 4 +- Modules/Launcher/Items/WallpaperItem.qml | 95 ------- Modules/Launcher/Items/WallpaperTile.qml | 67 +++++ Modules/Launcher/WallpaperList.qml | 93 ------- 5 files changed, 396 insertions(+), 190 deletions(-) create mode 100755 Modules/Launcher/CarouselView.qml delete mode 100644 Modules/Launcher/Items/WallpaperItem.qml create mode 100755 Modules/Launcher/Items/WallpaperTile.qml delete mode 100644 Modules/Launcher/WallpaperList.qml diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml new file mode 100755 index 0000000..695a048 --- /dev/null +++ b/Modules/Launcher/CarouselView.qml @@ -0,0 +1,327 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import ZShell.Config +import qs.Modules.Launcher.Items +import qs.Components +import qs.Helpers +import qs.Services + +Item { + id: root + + readonly property var arrangement: { + const W = width; + const F = focalWidth; + const M = minEdgeWidth; + const sp = itemSpacing; + + if (W <= 0 || F <= 0) + return { + n: 0, + sizes: [F], + effectiveExtraWidth: 0 + }; + + let bestN = 0; + let bestSizes = [F]; + let bestExtraWidth = 0; + + for (let n = 1; ; n++) { + const ratio = Math.pow(M / F, 1 / n); + const sizes = [F]; + + for (let i = 1; i <= n; i++) { + sizes.push(i === n ? M : F * Math.pow(ratio, i)); + } + + let decreasedWidth = 0; + + for (let i = 1; i <= n; i++) + decreasedWidth += pitch - sizes[i]; + + const effectiveWidth = W + decreasedWidth * 2; + const requiredWidth = (2 * n + 1) * pitch; + + if (requiredWidth > effectiveWidth) + break; + + bestN = n; + bestSizes = sizes; + bestExtraWidth = decreasedWidth; + } + + return { + n: bestN, + sizes: bestSizes, + effectiveExtraWidth: bestExtraWidth + }; + } + readonly property int centerBlockIndex: Math.floor(loopMultiplier / 2) + readonly property int centerBlockStart: centerBlockIndex * realCount + readonly property int currentRealIndex: realIndexOf(view.currentIndex) + readonly property real focalWidth: Config.launcher.sizes.wallpaperWidth + readonly property real itemSpacing: Tokens.spacing.small + readonly property var keylines: { + const sp = itemSpacing; + const sizes = arrangement.sizes; + const n = arrangement.n; + + const right = [ + { + io: 0, + lo: 0, + sz: sizes[0] + } + ]; + + let lo = sizes[0] / 2; + + for (let i = 1; i <= n; i++) { + const size = sizes[i]; + + lo += sp + size / 2; + + right.push({ + io: i, + lo: lo, + sz: size + }); + + lo += size / 2; + } + + right.push({ + io: n + 1, + lo: width / 2 - sp, + sz: 0 + }); + + const k = []; + + for (let i = right.length - 1; i >= 1; i--) { + k.push({ + io: -right[i].io, + lo: -right[i].lo, + sz: right[i].sz + }); + } + + for (const item of right) + k.push(item); + + for (let i = 0; i < k.length; i++) + k[i].loc = k[i].io * pitch; + + return k; + } + readonly property int loopMultiplier: 401 + readonly property real minEdgeWidth: 56 + required property var panels + readonly property real pitch: focalWidth + itemSpacing + readonly property int realCount: values.length + readonly property var screen: QsWindow.window?.screen + required property SearchBar search + readonly property real shrinkRatio: 0.63 + readonly property real tileHeight: focalWidth / 16 * 9 + readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) + readonly property int virtualCount: realCount > 0 ? realCount * loopMultiplier : 0 + required property var visibilities + + function contentXForIndex(index) { + return index * pitch + pitch / 2 - width / 2; + } + + function decrementCurrentIndex() { + if (realCount === 0) + return; + + goTo(view.currentIndex - 1); + } + + function glideTo(dest) { + if (Math.abs(dest - view.contentX) < 0.5) + return; + view.cancelFlick(); + snapAnim.stop(); + snapAnim.from = view.contentX; + snapAnim.to = dest; + snapAnim.start(); + } + + function goTo(virtualIndex) { + glideTo(contentXForIndex(virtualIndex)); + } + + function incrementCurrentIndex() { + if (realCount === 0) + return; + + goTo(view.currentIndex + 1); + } + + function indexForContentX(x) { + return Math.round((x + width / 2 - pitch / 2) / pitch); + } + + function realIndexOf(virtualIndex) { + if (realCount === 0) + return -1; + return ((virtualIndex % realCount) + realCount) % realCount; + } + + function sample(childLoc) { + const k = keylines; + + if (childLoc <= k[0].loc) + return { + c: k[0].lo, + s: 0 + }; + + if (childLoc >= k[k.length - 1].loc) + return { + c: k[k.length - 1].lo, + s: 0 + }; + + for (let i = 0; i < k.length - 1; i++) { + const a = k[i]; + const b = k[i + 1]; + + if (childLoc > b.loc) + continue; + + const span = b.loc - a.loc; + const t = span > 0 ? (childLoc - a.loc) / span : 0; + + return { + c: a.lo + (b.lo - a.lo) * t, + s: a.sz + (b.sz - a.sz) * t + }; + } + + return { + c: 0, + s: 0 + }; + } + + function selectReal(realIdx) { + if (realCount === 0) + return; + + const curBlock = Math.floor(view.currentIndex / realCount); + const current = view.currentIndex; + + let target = curBlock * realCount + realIdx; + + if (target - current > realCount / 2) + target -= realCount; + else if (current - target > realCount / 2) + target += realCount; + + goTo(target); + } + + function snapTarget(x) { + return contentXForIndex(indexForContentX(x)); + } + + function snapToNearest() { + glideTo(snapTarget(view.contentX)); + } + + implicitHeight: tileHeight + Tokens.spacing.small / 2 + Tokens.padding.large + Tokens.padding.normal + implicitWidth: screen ? Math.max(0, screen.width - Config.bar.rounding * 50 - panels.bar.implicitWidth * 2) : 0 + + Component.onCompleted: { + const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); + + if (idx >= 0) + view.contentX = contentXForIndex(centerBlockStart + idx); + } + Component.onDestruction: Wallpapers.stopPreview() + + ListView { + id: view + + anchors.fill: parent + boundsBehavior: Flickable.StopAtBounds + cacheBuffer: root.width * 10 + clip: false + currentIndex: root.centerBlockStart + displayMarginBeginning: root.arrangement.effectiveExtraWidth + displayMarginEnd: root.arrangement.effectiveExtraWidth + interactive: true + model: root.virtualCount + orientation: ListView.Horizontal + snapMode: ListView.NoSnap + + Anim on contentX { + id: snapAnim + + running: false + type: Anim.Standard + } + delegate: Item { + id: slot + + readonly property real childLoc: index * root.pitch + root.pitch / 2 - view.contentX - view.width / 2 + required property int index + readonly property bool isCurrent: index === view.currentIndex + readonly property var keyline: root.sample(childLoc) + readonly property var modelData: realIdx >= 0 ? root.values[realIdx] : null + readonly property int realIdx: root.realIndexOf(index) + readonly property real tileWidth: keyline.s + + height: view.height + visible: modelData !== null + width: root.pitch + + WallpaperTile { + anchors.verticalCenter: parent.verticalCenter + isCurrent: slot.isCurrent + modelData: slot.modelData + tileHeight: root.tileHeight + tileWidth: slot.tileWidth + visibilities: root.visibilities + x: (slot.keyline.c - slot.childLoc) + (slot.width - width) / 2 + + onRequestActivate: { + if (slot.isCurrent) { + Wallpapers.setWallpaper(slot.modelData.path); + root.visibilities.launcher = false; + } else { + root.goTo(slot.index); + } + } + } + } + + onContentXChanged: { + if (root.realCount === 0) + return; + const blockWidth = root.realCount * root.pitch; + const centerX = root.contentXForIndex(root.centerBlockStart); + const blocksFromCenter = Math.round((contentX - centerX) / blockWidth); + if (Math.abs(blocksFromCenter) >= 2) { + const shift = blocksFromCenter * blockWidth; + + contentX -= shift; + + if (snapAnim.running) + snapAnim.to -= shift; + } + currentIndex = Math.round((contentX + width / 2 - root.pitch / 2) / root.pitch); + } + onCurrentIndexChanged: { + const realIdx = root.realIndexOf(currentIndex); + if (realIdx >= 0) + Wallpapers.preview(root.values[realIdx].path); + } + onFlickEnded: root.snapToNearest() + onMovementEnded: root.snapToNearest() + } +} diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index 6b62f2e..5ccdea4 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -110,8 +110,8 @@ Item { anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top - sourceComponent: WallpaperList { - content: root.content + sourceComponent: CarouselView { + // content: root.content panels: root.panels search: root.search visibilities: root.visibilities diff --git a/Modules/Launcher/Items/WallpaperItem.qml b/Modules/Launcher/Items/WallpaperItem.qml deleted file mode 100644 index 024633c..0000000 --- a/Modules/Launcher/Items/WallpaperItem.qml +++ /dev/null @@ -1,95 +0,0 @@ -import ZShell.Models -import Quickshell -import QtQuick -import qs.Components -import qs.Helpers -import qs.Config -import qs.Modules - -Item { - id: root - - required property FileSystemEntry modelData - required property PersistentProperties visibilities - - implicitHeight: image.height + label.height + Appearance.spacing.small / 2 + Appearance.padding.large + Appearance.padding.normal - implicitWidth: image.width + Appearance.padding.larger * 2 - opacity: 0 - scale: 0.5 - z: PathView.z ?? 0 - - Behavior on opacity { - Anim { - } - } - Behavior on scale { - Anim { - } - } - - Component.onCompleted: { - scale = Qt.binding(() => PathView.isCurrentItem ? 1 : PathView.onPath ? 0.8 : 0); - opacity = Qt.binding(() => PathView.onPath ? 1 : 0); - } - - StateLayer { - radius: Appearance.rounding.normal - - onClicked: { - Wallpapers.setWallpaper(root.modelData.path); - root.visibilities.launcher = false; - } - } - - Elevation { - anchors.fill: image - level: 4 - opacity: root.PathView.isCurrentItem ? 1 : 0 - radius: image.radius - - Behavior on opacity { - Anim { - } - } - } - - CustomClippingRect { - id: image - - anchors.horizontalCenter: parent.horizontalCenter - color: DynamicColors.tPalette.m3surfaceContainer - implicitHeight: implicitWidth / 16 * 9 - implicitWidth: Config.launcher.sizes.wallpaperWidth - radius: Appearance.rounding.small - y: Appearance.padding.large - - MaterialIcon { - anchors.centerIn: parent - color: DynamicColors.tPalette.m3outline - font.pointSize: Appearance.font.size.extraLarge * 2 - font.weight: 600 - text: "image" - } - - CachingImage { - anchors.fill: parent - cache: true - path: root.modelData.path - smooth: !root.PathView.view.moving - } - } - - CustomText { - id: label - - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: image.bottom - anchors.topMargin: Appearance.spacing.small / 2 - elide: Text.ElideRight - font.pointSize: Appearance.font.size.normal - horizontalAlignment: Text.AlignHCenter - renderType: Text.QtRendering - text: root.modelData.relativePath - width: image.width - Appearance.padding.normal * 2 - } -} diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml new file mode 100755 index 0000000..ae5f103 --- /dev/null +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -0,0 +1,67 @@ +import Quickshell +import QtQuick +import ZShell.Models +import ZShell.Config +import qs.Components +import qs.Helpers +import qs.Modules +import qs.Services + +Item { + id: root + + required property bool isCurrent + required property var modelData // FileSystemEntry-like {path, relativePath} + required property real tileHeight + required property real tileWidth + required property PersistentProperties visibilities + + // Emitted on click: caller decides whether that means "activate" (if + // already current) or "bring to center" (if not) - see CarouselView. + signal requestActivate + + implicitHeight: tileHeight + Tokens.padding.large * 2 + implicitWidth: tileWidth + + CustomClippingRect { + id: image + + anchors.horizontalCenter: parent.horizontalCenter + color: Colors.tPalette.m3surfaceContainer + implicitHeight: root.tileHeight + implicitWidth: root.tileWidth + radius: Tokens.rounding.small + y: Tokens.padding.large + + Behavior on implicitWidth { + enabled: !root.visibilities.launcher // avoid fighting drag-driven updates + + Anim { + } + } + + MaterialIcon { + anchors.centerIn: parent + color: Colors.tPalette.m3outline + font.pointSize: Tokens.font.size.extraLarge * 2 + font.weight: 600 + text: "image" + } + + CachingImage { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + cache: true + fillMode: Image.PreserveAspectCrop + height: root.tileHeight + path: root.modelData ? root.modelData.path : "" + smooth: root.isCurrent + width: root.tileHeight * (16 / 9) + } + + StateLayer { + onClicked: root.requestActivate() + } + } +} diff --git a/Modules/Launcher/WallpaperList.qml b/Modules/Launcher/WallpaperList.qml deleted file mode 100644 index 735191f..0000000 --- a/Modules/Launcher/WallpaperList.qml +++ /dev/null @@ -1,93 +0,0 @@ -pragma ComponentBehavior: Bound - -import Quickshell -import QtQuick -import qs.Components -import qs.Helpers -import qs.Config -import qs.Modules.Launcher.Items - -PathView { - id: root - - required property var content - readonly property int itemWidth: Config.launcher.sizes.wallpaperWidth * 0.9 + Appearance.padding.larger * 2 - readonly property int numItems: { - const screen = QsWindow.window?.screen; - if (!screen) - return 0; - - // Screen width - 4x outer rounding - 2x max side thickness (cause centered) - const barMargins = panels.bar.implicitWidth; - let outerMargins = 0; - if ((visibilities.utilities || visibilities.sidebar) && panels.utilities.implicitWidth > outerMargins) - outerMargins = panels.utilities.implicitWidth; - const maxWidth = screen.width - Config.bar.rounding * 4 - (barMargins + outerMargins) * 2; - - if (maxWidth <= 0) - return 0; - - const maxItemsOnScreen = Math.floor(maxWidth / itemWidth); - const visible = Math.min(maxItemsOnScreen, Config.launcher.maxWallpapers, scriptModel.values.length); - - if (visible === 2) - return 1; - if (visible > 1 && visible % 2 === 0) - return visible - 1; - return visible; - } - required property var panels - required property SearchBar search - required property var visibilities - - cacheItemCount: 4 - highlightRangeMode: PathView.StrictlyEnforceRange - implicitWidth: Math.min(numItems, count) * itemWidth - pathItemCount: numItems - preferredHighlightBegin: 0.5 - preferredHighlightEnd: 0.5 - snapMode: PathView.SnapToItem - - delegate: WallpaperItem { - visibilities: root.visibilities - } - model: ScriptModel { - id: scriptModel - - readonly property string search: root.search.text.split(" ").slice(1).join(" ") - - values: Wallpapers.query(search) - - onValuesChanged: root.currentIndex = search ? 0 : values.findIndex(w => w.path === Wallpapers.actualCurrent) - } - path: Path { - startY: root.height / 2 - - PathAttribute { - name: "z" - value: 0 - } - - PathLine { - relativeY: 0 - x: root.width / 2 - } - - PathAttribute { - name: "z" - value: 1 - } - - PathLine { - relativeY: 0 - x: root.width - } - } - - Component.onCompleted: currentIndex = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent) - Component.onDestruction: Wallpapers.stopPreview() - onCurrentItemChanged: { - if (currentItem) - Wallpapers.preview(currentItem.modelData.path); - } -} -- 2.47.3 From 88a2808a47f98b85d523d0a9da6f311c665ee615 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 19:23:47 +0200 Subject: [PATCH 02/10] clean up imports --- Modules/Launcher/CarouselView.qml | 1 - Modules/Launcher/Items/WallpaperTile.qml | 2 -- 2 files changed, 3 deletions(-) diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index 695a048..c28d0a9 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -6,7 +6,6 @@ import ZShell.Config import qs.Modules.Launcher.Items import qs.Components import qs.Helpers -import qs.Services Item { id: root diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index ae5f103..1c640a8 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -1,10 +1,8 @@ import Quickshell import QtQuick -import ZShell.Models import ZShell.Config import qs.Components import qs.Helpers -import qs.Modules import qs.Services Item { -- 2.47.3 From ec80adf0a59ec8987b3dea2c0669d520633e8be4 Mon Sep 17 00:00:00 2001 From: zach Date: Fri, 14 Aug 2026 19:49:48 +0200 Subject: [PATCH 03/10] improve image caching for better performance --- Modules/Launcher/CarouselView.qml | 16 +- Modules/Launcher/Items/WallpaperTile.qml | 2 + .../ZShell/Internal/cachingimagemanager.cpp | 186 +++++++++++++----- 3 files changed, 150 insertions(+), 54 deletions(-) diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index c28d0a9..f6a3c04 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -233,7 +233,19 @@ Item { } implicitHeight: tileHeight + Tokens.spacing.small / 2 + Tokens.padding.large + Tokens.padding.normal - implicitWidth: screen ? Math.max(0, screen.width - Config.bar.rounding * 50 - panels.bar.implicitWidth * 2) : 0 + implicitWidth: { + if (!screen) + return 0; + + const barMargins = Config.bar.border * 2; + let margins = 0; + if (visibilities.utilities || visibilities.sidebar) + margins = panels.utilities.implicitWidth; + const maxWidth = screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; + + return maxWidth; + } + width: implicitWidth Component.onCompleted: { const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); @@ -248,7 +260,7 @@ Item { anchors.fill: parent boundsBehavior: Flickable.StopAtBounds - cacheBuffer: root.width * 10 + cacheBuffer: root.width clip: false currentIndex: root.centerBlockStart displayMarginBeginning: root.arrangement.effectiveExtraWidth diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 1c640a8..4923ef9 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -55,6 +55,8 @@ Item { height: root.tileHeight path: root.modelData ? root.modelData.path : "" smooth: root.isCurrent + sourceSize.height: root.tileHeight + sourceSize.width: root.tileHeight * (16 / 9) width: root.tileHeight * (16 / 9) } diff --git a/Plugins/ZShell/Internal/cachingimagemanager.cpp b/Plugins/ZShell/Internal/cachingimagemanager.cpp index 58e3112..7c58c72 100644 --- a/Plugins/ZShell/Internal/cachingimagemanager.cpp +++ b/Plugins/ZShell/Internal/cachingimagemanager.cpp @@ -1,5 +1,7 @@ #include "cachingimagemanager.hpp" +#include + #include #include #include @@ -7,10 +9,27 @@ #include #include #include +#include +#include #include namespace ZShell::internal { +namespace { + +QThreadPool* imageIoPool() { + static QThreadPool pool; + static const bool init = [] { + pool.setMaxThreadCount(std::max(2, QThread::idealThreadCount() / 2)); + return true; + }(); + (void)init; + + return &pool; +} + +} // namespace + qreal CachingImageManager::effectiveScale() const { if (m_item && m_item->window()) { return m_item->window()->devicePixelRatio(); @@ -101,14 +120,13 @@ void CachingImageManager::updateSource() { 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); + QtConcurrent::run(imageIoPool(), &CachingImageManager::sha256sum, path); const auto watcher = new QFutureWatcher(this); @@ -118,7 +136,16 @@ void CachingImageManager::updateSource(const QString& path) { this, [watcher, path, this]() { if (m_path != path) { - // Object is destroyed or path has changed, ignore + watcher->deleteLater(); + return; + } + + const QString key = watcher->result(); + + if (key.isEmpty()) { + if (m_shaPath == path) { + m_shaPath = QString(); + } watcher->deleteLater(); return; } @@ -126,19 +153,29 @@ void CachingImageManager::updateSource(const QString& path) { const QSize size = effectiveSize(); if (!m_item || !size.width() || !size.height()) { + if (m_shaPath == path) { + m_shaPath = QString(); + } 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 QString filename = + QString("%1@%2x%3-%4.png") + .arg(key) + .arg(size.width()) + .arg(size.height()) + .arg( + fillMode == "PreserveAspectCrop" ? "crop" + : fillMode == "PreserveAspectFit" ? "fit" + : "stretch"); const QUrl cache = m_cacheDir.resolved(QUrl(filename)); if (m_cachePath == cache) { + if (m_shaPath == path) { + m_shaPath = QString(); + } watcher->deleteLater(); return; } @@ -149,6 +186,9 @@ void CachingImageManager::updateSource(const QString& path) { if (!cache.isLocalFile()) { qWarning() << "CachingImageManager::updateSource: cachePath" << cache << "is not a local file"; + if (m_shaPath == path) { + m_shaPath = QString(); + } watcher->deleteLater(); return; } @@ -161,7 +201,6 @@ void CachingImageManager::updateSource(const QString& path) { createCache(path, cache.toLocalFile(), fillMode, size); } - // Clear current running sha if same if (m_shaPath == path) { m_shaPath = QString(); } @@ -170,7 +209,6 @@ void CachingImageManager::updateSource(const QString& path) { }); watcher->setFuture(future); - // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) } QUrl CachingImageManager::cachePath() const { @@ -182,61 +220,105 @@ void CachingImageManager::createCache( const QString& cache, const QString& fillMode, const QSize& size) const { - QThreadPool::globalInstance()->start([path, cache, fillMode, size] { - QImage image(path); + auto* watcher = + new QFutureWatcher(const_cast(this)); - if (image.isNull()) { - qWarning() << "CachingImageManager::createCache: failed to read" - << path; - return; - } + connect( + watcher, + &QFutureWatcher::finished, + this, + [this, watcher, cache, path]() { + if (watcher->result() && m_item && m_path == path && + m_cachePath.toLocalFile() == cache) { + m_item->setProperty("source", QUrl::fromLocalFile(cache)); + } - image.convertTo(QImage::Format_ARGB32); + watcher->deleteLater(); + }); - 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); - } + const auto future = + QtConcurrent::run( + imageIoPool(), [path, cache, fillMode, size]() -> bool { + QImageReader reader(path); - if (fillMode == "PreserveAspectCrop" || - fillMode == "PreserveAspectFit") { - QImage canvas(size, QImage::Format_ARGB32); - canvas.fill(Qt::transparent); + if (reader.supportsOption(QImageIOHandler::ScaledSize)) { + const QSize full = reader.size(); + if (full.isValid() && !full.isEmpty()) { + const QSize hint = size * 2; + QSize scaled = full; + scaled.scale(hint, Qt::KeepAspectRatioByExpanding); + scaled = QSize( + std::min(scaled.width(), full.width()), + std::min(scaled.height(), full.height())); + reader.setScaledSize(scaled); + } + } - QPainter painter(&canvas); - painter.drawImage( - (size.width() - image.width()) / 2, - (size.height() - image.height()) / 2, - image); - painter.end(); + QImage image = reader.read(); - image = canvas; - } + if (image.isNull()) { + qWarning() + << "CachingImageManager::createCache: failed to read" + << path << reader.errorString(); + return false; + } - const QString parent = QFileInfo(cache).absolutePath(); - if (!QDir().mkpath(parent) || !image.save(cache)) { - qWarning() << "CachingImageManager::createCache: failed to save to" - << cache; - } - }); + 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; + return false; + } + + return true; + }); + + watcher->setFuture(future); } QString CachingImageManager::sha256sum(const QString& path) { - QFile file(path); - if (!file.open(QIODevice::ReadOnly)) { - qWarning() << "CachingImageManager::sha256sum: failed to open" << path; - return ""; + const QFileInfo info(path); + + if (!info.exists() || !info.isFile()) { + qWarning() << "CachingImageManager::sha256sum: failed to stat" << path; + return QString(); } QCryptographicHash hash(QCryptographicHash::Sha256); - hash.addData(&file); - file.close(); + hash.addData(path.toUtf8()); + hash.addData(QByteArray::number(info.size())); + hash.addData(QByteArray::number(info.lastModified().toMSecsSinceEpoch())); return hash.result().toHex(); } -- 2.47.3 From a41c0adb3e25f07e5e84e191559455915262c872 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 17:59:23 +0200 Subject: [PATCH 04/10] move carouselview to standalone c++ component, fix crash due to memory leak in wallpaper object creation --- Modules/Launcher/CarouselView.qml | 331 +-------- Modules/Launcher/Items/WallpaperTile.qml | 9 - Modules/Wallpaper/WallBackground.qml | 101 +-- Plugins/ZShell/Components/CMakeLists.txt | 1 + Plugins/ZShell/Components/carouselview.cpp | 785 +++++++++++++++++++++ Plugins/ZShell/Components/carouselview.hpp | 220 ++++++ Plugins/ZShell/Internal/wallpaperimage.cpp | 285 +++++--- Plugins/ZShell/Internal/wallpaperimage.hpp | 42 +- 8 files changed, 1285 insertions(+), 489 deletions(-) create mode 100644 Plugins/ZShell/Components/carouselview.cpp create mode 100644 Plugins/ZShell/Components/carouselview.hpp diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index f6a3c04..ca11095 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -1,338 +1,55 @@ -pragma ComponentBehavior: Bound - import Quickshell import QtQuick +import ZShell.Components import ZShell.Config import qs.Modules.Launcher.Items import qs.Components import qs.Helpers -Item { +CarouselView { id: root - readonly property var arrangement: { - const W = width; - const F = focalWidth; - const M = minEdgeWidth; - const sp = itemSpacing; - - if (W <= 0 || F <= 0) - return { - n: 0, - sizes: [F], - effectiveExtraWidth: 0 - }; - - let bestN = 0; - let bestSizes = [F]; - let bestExtraWidth = 0; - - for (let n = 1; ; n++) { - const ratio = Math.pow(M / F, 1 / n); - const sizes = [F]; - - for (let i = 1; i <= n; i++) { - sizes.push(i === n ? M : F * Math.pow(ratio, i)); - } - - let decreasedWidth = 0; - - for (let i = 1; i <= n; i++) - decreasedWidth += pitch - sizes[i]; - - const effectiveWidth = W + decreasedWidth * 2; - const requiredWidth = (2 * n + 1) * pitch; - - if (requiredWidth > effectiveWidth) - break; - - bestN = n; - bestSizes = sizes; - bestExtraWidth = decreasedWidth; - } - - return { - n: bestN, - sizes: bestSizes, - effectiveExtraWidth: bestExtraWidth - }; - } - readonly property int centerBlockIndex: Math.floor(loopMultiplier / 2) - readonly property int centerBlockStart: centerBlockIndex * realCount - readonly property int currentRealIndex: realIndexOf(view.currentIndex) - readonly property real focalWidth: Config.launcher.sizes.wallpaperWidth - readonly property real itemSpacing: Tokens.spacing.small - readonly property var keylines: { - const sp = itemSpacing; - const sizes = arrangement.sizes; - const n = arrangement.n; - - const right = [ - { - io: 0, - lo: 0, - sz: sizes[0] - } - ]; - - let lo = sizes[0] / 2; - - for (let i = 1; i <= n; i++) { - const size = sizes[i]; - - lo += sp + size / 2; - - right.push({ - io: i, - lo: lo, - sz: size - }); - - lo += size / 2; - } - - right.push({ - io: n + 1, - lo: width / 2 - sp, - sz: 0 - }); - - const k = []; - - for (let i = right.length - 1; i >= 1; i--) { - k.push({ - io: -right[i].io, - lo: -right[i].lo, - sz: right[i].sz - }); - } - - for (const item of right) - k.push(item); - - for (let i = 0; i < k.length; i++) - k[i].loc = k[i].io * pitch; - - return k; - } - readonly property int loopMultiplier: 401 - readonly property real minEdgeWidth: 56 required property var panels - readonly property real pitch: focalWidth + itemSpacing - readonly property int realCount: values.length readonly property var screen: QsWindow.window?.screen required property SearchBar search - readonly property real shrinkRatio: 0.63 - readonly property real tileHeight: focalWidth / 16 * 9 readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) - readonly property int virtualCount: realCount > 0 ? realCount * loopMultiplier : 0 required property var visibilities - function contentXForIndex(index) { - return index * pitch + pitch / 2 - width / 2; - } - - function decrementCurrentIndex() { - if (realCount === 0) - return; - - goTo(view.currentIndex - 1); - } - - function glideTo(dest) { - if (Math.abs(dest - view.contentX) < 0.5) - return; - view.cancelFlick(); - snapAnim.stop(); - snapAnim.from = view.contentX; - snapAnim.to = dest; - snapAnim.start(); - } - - function goTo(virtualIndex) { - glideTo(contentXForIndex(virtualIndex)); - } - - function incrementCurrentIndex() { - if (realCount === 0) - return; - - goTo(view.currentIndex + 1); - } - - function indexForContentX(x) { - return Math.round((x + width / 2 - pitch / 2) / pitch); - } - - function realIndexOf(virtualIndex) { - if (realCount === 0) - return -1; - return ((virtualIndex % realCount) + realCount) % realCount; - } - - function sample(childLoc) { - const k = keylines; - - if (childLoc <= k[0].loc) - return { - c: k[0].lo, - s: 0 - }; - - if (childLoc >= k[k.length - 1].loc) - return { - c: k[k.length - 1].lo, - s: 0 - }; - - for (let i = 0; i < k.length - 1; i++) { - const a = k[i]; - const b = k[i + 1]; - - if (childLoc > b.loc) - continue; - - const span = b.loc - a.loc; - const t = span > 0 ? (childLoc - a.loc) / span : 0; - - return { - c: a.lo + (b.lo - a.lo) * t, - s: a.sz + (b.sz - a.sz) * t - }; - } - - return { - c: 0, - s: 0 - }; - } - - function selectReal(realIdx) { - if (realCount === 0) - return; - - const curBlock = Math.floor(view.currentIndex / realCount); - const current = view.currentIndex; - - let target = curBlock * realCount + realIdx; - - if (target - current > realCount / 2) - target -= realCount; - else if (current - target > realCount / 2) - target += realCount; - - goTo(target); - } - - function snapTarget(x) { - return contentXForIndex(indexForContentX(x)); - } - - function snapToNearest() { - glideTo(snapTarget(view.contentX)); - } - + // Forwarded verbatim as a property of the same name onto every delegate + // instance (the delegate declares `required property PersistentProperties visibilities`). + delegateProperties: ({ + visibilities: root.visibilities + }) + focalWidth: Config.launcher.sizes.wallpaperWidth + glideDuration: 250 + glideEasingType: Easing.OutCubic implicitHeight: tileHeight + Tokens.spacing.small / 2 + Tokens.padding.large + Tokens.padding.normal - implicitWidth: { + itemSpacing: Tokens.spacing.small + maxWidth: { if (!screen) return 0; - const barMargins = Config.bar.border * 2; let margins = 0; if (visibilities.utilities || visibilities.sidebar) margins = panels.utilities.implicitWidth; - const maxWidth = screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; - - return maxWidth; + return screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; + } + minEdgeWidth: 56 + model: values + tileHeight: focalWidth / 16 * 9 + + delegate: Component { + WallpaperTile { + } // your existing delegate file, unchanged } - width: implicitWidth Component.onCompleted: { const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); - if (idx >= 0) - view.contentX = contentXForIndex(centerBlockStart + idx); + jumpToIndex(idx); } Component.onDestruction: Wallpapers.stopPreview() - - ListView { - id: view - - anchors.fill: parent - boundsBehavior: Flickable.StopAtBounds - cacheBuffer: root.width - clip: false - currentIndex: root.centerBlockStart - displayMarginBeginning: root.arrangement.effectiveExtraWidth - displayMarginEnd: root.arrangement.effectiveExtraWidth - interactive: true - model: root.virtualCount - orientation: ListView.Horizontal - snapMode: ListView.NoSnap - - Anim on contentX { - id: snapAnim - - running: false - type: Anim.Standard - } - delegate: Item { - id: slot - - readonly property real childLoc: index * root.pitch + root.pitch / 2 - view.contentX - view.width / 2 - required property int index - readonly property bool isCurrent: index === view.currentIndex - readonly property var keyline: root.sample(childLoc) - readonly property var modelData: realIdx >= 0 ? root.values[realIdx] : null - readonly property int realIdx: root.realIndexOf(index) - readonly property real tileWidth: keyline.s - - height: view.height - visible: modelData !== null - width: root.pitch - - WallpaperTile { - anchors.verticalCenter: parent.verticalCenter - isCurrent: slot.isCurrent - modelData: slot.modelData - tileHeight: root.tileHeight - tileWidth: slot.tileWidth - visibilities: root.visibilities - x: (slot.keyline.c - slot.childLoc) + (slot.width - width) / 2 - - onRequestActivate: { - if (slot.isCurrent) { - Wallpapers.setWallpaper(slot.modelData.path); - root.visibilities.launcher = false; - } else { - root.goTo(slot.index); - } - } - } - } - - onContentXChanged: { - if (root.realCount === 0) - return; - const blockWidth = root.realCount * root.pitch; - const centerX = root.contentXForIndex(root.centerBlockStart); - const blocksFromCenter = Math.round((contentX - centerX) / blockWidth); - if (Math.abs(blocksFromCenter) >= 2) { - const shift = blocksFromCenter * blockWidth; - - contentX -= shift; - - if (snapAnim.running) - snapAnim.to -= shift; - } - currentIndex = Math.round((contentX + width / 2 - root.pitch / 2) / root.pitch); - } - onCurrentIndexChanged: { - const realIdx = root.realIndexOf(currentIndex); - if (realIdx >= 0) - Wallpapers.preview(root.values[realIdx].path); - } - onFlickEnded: root.snapToNearest() - onMovementEnded: root.snapToNearest() + onPreviewIndexChanged: (realIndex, modelData) => { + Wallpapers.preview(modelData.path); } } diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml index 4923ef9..806f89e 100755 --- a/Modules/Launcher/Items/WallpaperTile.qml +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -14,8 +14,6 @@ Item { required property real tileWidth required property PersistentProperties visibilities - // Emitted on click: caller decides whether that means "activate" (if - // already current) or "bring to center" (if not) - see CarouselView. signal requestActivate implicitHeight: tileHeight + Tokens.padding.large * 2 @@ -31,13 +29,6 @@ Item { radius: Tokens.rounding.small y: Tokens.padding.large - Behavior on implicitWidth { - enabled: !root.visibilities.launcher // avoid fighting drag-driven updates - - Anim { - } - } - MaterialIcon { anchors.centerIn: parent color: Colors.tPalette.m3outline diff --git a/Modules/Wallpaper/WallBackground.qml b/Modules/Wallpaper/WallBackground.qml index a7851e1..76e44dd 100644 --- a/Modules/Wallpaper/WallBackground.qml +++ b/Modules/Wallpaper/WallBackground.qml @@ -5,103 +5,46 @@ import Quickshell.Hyprland import QtQuick import qs.Components import qs.Helpers -import ZShell.Config import ZShell.Internal Item { id: root - property bool completed - property real cropHeight: displayData?.height ?? 1.0 - property real cropWidth: displayData?.width ?? 1.0 - property real cropX: displayData?.x ?? 0.0 - property real cropY: displayData?.y ?? 0.0 - property WallpaperImage current readonly property var displayData: Wallpapers.getCrop(screen.name) required property ShellScreen screen property size screenResolution: Qt.size(screen.width * screenScale, screen.height * screenScale) property real screenScale: Hyprland.monitorFor(screen).scale - property string source: Wallpapers.current anchors.fill: parent - Component.onCompleted: { - Hyprland.refreshMonitors(); + Component.onCompleted: Hyprland.refreshMonitors() - if (source) - Qt.callLater(() => { - current = imgComp.createObject(this, { - source - }); - completed = true; - }); - } - onSourceChanged: { - if (!source) - current = null; - else - current = imgComp.createObject(this, { - source: source - }); - } + WallpaperImage { + id: img - Component { - id: imgComp + anchors.fill: parent + cropHeight: root.displayData?.height ?? 1.0 + cropWidth: root.displayData?.width ?? 1.0 + cropX: root.displayData?.x ?? 0.0 + cropY: root.displayData?.y ?? 0.0 + fadeDuration: 150 + screenResolution: root.screenResolution + source: Wallpapers.current - WallpaperImage { - id: img - - anchors.fill: parent - cropHeight: root.cropHeight - cropWidth: root.cropWidth - cropX: root.cropX - cropY: root.cropY - opacity: 0 - screenResolution: root.screenResolution - source: root.source - - Behavior on cropHeight { - Anim { - id: heightAnim - } + Behavior on cropHeight { + Anim { } - Behavior on cropWidth { - Anim { - id: widthAnim - } + } + Behavior on cropWidth { + Anim { } - Behavior on cropX { - Anim { - id: xAnim - } + } + Behavior on cropX { + Anim { } - Behavior on cropY { - Anim { - id: yAnim - } - } - Anim on opacity { - id: anim - - from: 0 - running: false - to: 1 - type: Anim.SlowEffects - } - - onStatusChanged: { - if (status === Image.Ready) { - anim.start(); - } - } - - Timer { - id: destroyTimer - - interval: anim.duration * 2 - running: root.current !== img && root.current?.status === Image.Ready - - onTriggered: Qt.callLater(() => img.destroy()) + } + Behavior on cropY { + Anim { } } } diff --git a/Plugins/ZShell/Components/CMakeLists.txt b/Plugins/ZShell/Components/CMakeLists.txt index 3edbffc..3d3e4a7 100644 --- a/Plugins/ZShell/Components/CMakeLists.txt +++ b/Plugins/ZShell/Components/CMakeLists.txt @@ -4,6 +4,7 @@ qml_module(ZShell-components lazylistview.hpp lazylistview.cpp wavyline.hpp wavyline.cpp buttonrow.hpp buttonrow.cpp + carouselview.hpp carouselview.cpp LIBRARIES Qt::Quick ) diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp new file mode 100644 index 0000000..e896c39 --- /dev/null +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -0,0 +1,785 @@ +#include "carouselview.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +namespace { +constexpr qreal kDragThreshold = 4.0; // px before a press becomes a drag +constexpr qreal kFlickFriction = 0.0022; // px/ms^2 deceleration +constexpr qreal kMinFlickVelocity = 60.0; // px/sec below which we just snap +constexpr qreal kMaxFlickVelocity = 6000.0; +} // namespace + +CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { + setAcceptedMouseButtons(Qt::LeftButton); + setFlag(QQuickItem::ItemHasContents, false); + setClip(false); + + m_glideAnim = new QVariantAnimation(this); + m_glideAnim->setEasingCurve(m_glideEasingType); + connect( + m_glideAnim, + &QVariantAnimation::valueChanged, + this, + [this](const QVariant& v) { + m_contentX = v.toReal(); + wrapContentIfNeeded(); + updateCurrentIndexFromContentX(true); + relayout(); + }); + connect(m_glideAnim, &QVariantAnimation::finished, this, [this]() { + updateCurrentIndexFromContentX(true); + }); + + m_flickAnim = new QVariantAnimation(this); + m_flickAnim->setEasingCurve(QEasingCurve::Linear); + connect( + m_flickAnim, + &QVariantAnimation::valueChanged, + this, + [this](const QVariant& v) { + m_contentX = v.toReal(); + wrapContentIfNeeded(); + updateCurrentIndexFromContentX(true); + relayout(); + }); + connect(m_flickAnim, &QVariantAnimation::finished, this, [this]() { + snapToNearest(); + }); +} + +CarouselView::~CarouselView() { + releaseAllItems(); +} + +// ---------------------------------------------------------------- properties + +void CarouselView::setDelegate(QQmlComponent* c) { + if (m_delegate == c) return; + m_delegate = c; + releaseAllItems(); + emit delegateChanged(); + relayout(); +} + +void CarouselView::setModel(const QVariantList& m) { + m_model = m; + emit modelChanged(); + + // Re-anchor on the currently selected path if possible, otherwise clamp. + int newCount = m_model.size(); + if (newCount == 0) { + m_currentRealIndex = -1; + releaseAllItems(); + rebuildArrangement(); + return; + } + if (!m_initializedLayout) { + m_centerBlockStart = (kLoopMultiplier / 2) * newCount; + m_currentVirtualIndex = m_centerBlockStart; + m_contentX = contentXForIndex(m_currentVirtualIndex); + m_currentRealIndex = realIndexOf(m_currentVirtualIndex); + m_previewRealIndex = m_currentRealIndex; + m_initializedLayout = true; + rebuildArrangement(); + relayout(); + return; + } + + // Model contents changed under us (e.g. search text filtered the list). + // Re-anchor instantly on the clamped selection -- no glide, since this + // isn't a user-initiated navigation. + rebuildArrangement(); + centerInstantlyOnReal(qBound(0, m_currentRealIndex, newCount - 1)); +} + +void CarouselView::setFocalWidth(qreal v) { + if (qFuzzyCompare(m_focalWidth, v)) return; + m_focalWidth = v; + emit focalWidthChanged(); + rebuildArrangement(); + relayout(); +} + +void CarouselView::setMinEdgeWidth(qreal v) { + if (qFuzzyCompare(m_minEdgeWidth, v)) return; + m_minEdgeWidth = v; + emit minEdgeWidthChanged(); + rebuildArrangement(); + relayout(); +} + +void CarouselView::setItemSpacing(qreal v) { + if (qFuzzyCompare(m_itemSpacing, v)) return; + m_itemSpacing = v; + emit itemSpacingChanged(); + rebuildArrangement(); + relayout(); +} + +void CarouselView::setTileHeight(qreal v) { + if (qFuzzyCompare(m_tileHeight, v)) return; + m_tileHeight = v; + emit tileHeightChanged(); + for (const Slot& s : std::as_const(m_slots)) { + if (s.active && s.item) + QQmlProperty::write( + s.item, QStringLiteral("tileHeight"), m_tileHeight); + } +} + +void CarouselView::setMaxWidth(qreal v) { + if (qFuzzyCompare(m_maxWidth, v)) return; + m_maxWidth = v; + emit maxWidthChanged(); + rebuildArrangement(); + relayout(); +} + +void CarouselView::setGlideDuration(int v) { + if (m_glideDuration == v) return; + m_glideDuration = v; + emit glideDurationChanged(); +} + +void CarouselView::setGlideEasingType(QEasingCurve::Type t) { + if (m_glideEasingType == t) return; + m_glideEasingType = t; + m_glideAnim->setEasingCurve(t); + emit glideEasingTypeChanged(); +} + +void CarouselView::setDelegateProperties(const QVariantMap& v) { + m_delegateProperties = v; + emit delegatePropertiesChanged(); + for (const Slot& s : std::as_const(m_slots)) { + if (s.active && s.item) { + for (auto it = m_delegateProperties.constBegin(); + it != m_delegateProperties.constEnd(); + ++it) + QQmlProperty::write(s.item, it.key(), it.value()); + } + } +} + +void CarouselView::setCurrentIndex(int idx) { + goToIndex(idx); +} + +// ---------------------------------------------------------------- geometry + +void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { + QQuickItem::geometryChange(newGeo, oldGeo); + if (!qFuzzyCompare(newGeo.width(), oldGeo.width()) || + !qFuzzyCompare(newGeo.height(), oldGeo.height())) { + // Recentre on the same virtual index rather than letting contentX + // drift, exactly like the QML version's onWidthChanged handler -- + // but here it happens in the same pass as the arrangement rebuild, + // so x/width for every slot are recomputed together. + rebuildArrangement(); + if (m_initializedLayout) + m_contentX = contentXForIndex(m_currentVirtualIndex); + relayout(); + } +} + +// ---------------------------------------------------------------- arrangement math +// Direct port of the QML `arrangement` computed property + keylineCenter/ +// keylineSize/sampleCenter/sampleSize helpers. Kept numerically identical on +// purpose so behaviour doesn't change, just *when* it's applied. + +void CarouselView::rebuildArrangement() { + Arrangement& a = m_arrangement; + a = Arrangement{}; + + // IMPORTANT: this must be the external constraint (m_maxWidth), never + // width() -- width() is a *result* of this computation (see below), and + // using it here would make the arrangement depend on its own previous + // output. On first layout, before width() has ever been set, that + // bootstraps to W==0 -> n==0 -> every side item falls back to a flat + // minEdgeWidth with no taper at all, which is what caused items to slide + // off looking "pushed out" instead of shrinking. + const qreal W = m_maxWidth; + const qreal F = m_focalWidth; + const qreal M = m_minEdgeWidth; + const qreal sp = m_itemSpacing; + + if (W <= 0 || F <= 0) { + a.sizes = {F}; + a.centers = {0}; + return; + } + + int bestN = 0; + QVector bestSizes = {F}; + qreal bestWidth = F; + + for (int n = 1;; ++n) { + const qreal ratio = std::pow(M / F, 1.0 / n); + QVector sizes = {F}; + qreal sideWidth = 0; + + for (int i = 1; i <= n; ++i) { + const qreal size = (i == n) ? M : F * std::pow(ratio, i); + sizes.push_back(size); + sideWidth += size; + } + + const qreal requiredWidth = F + 2 * n * sp + 2 * sideWidth; + if (requiredWidth > W) break; + + bestN = n; + bestSizes = sizes; + bestWidth = requiredWidth; + + // Safety valve: arrangement size is bounded by how many halvings fit, + // this just guards against pathological inputs (F <= M etc). + if (n > 64) break; + } + + a.n = bestN; + a.sizes = bestSizes; + a.width = bestWidth; + + QVector centers = {0}; + qreal center = 0; + for (int i = 1; i <= bestN; ++i) { + center += bestSizes[i - 1] / 2.0 + sp + bestSizes[i] / 2.0; + centers.push_back(center); + } + a.centers = centers; + + qreal extra = 0; + for (int i = 1; i <= bestN; ++i) + extra += pitch() - bestSizes[i]; + a.effectiveExtraWidth = extra; + + setImplicitWidth(m_maxWidth); + // This mirrors the original `width: arrangement.width` binding. It's + // safe against feedback because the arrangement above only ever reads + // m_maxWidth, never width() -- so if a.width doesn't change, setWidth() + // here is a no-op and geometryChange() won't recurse. + setWidth(a.width); +} + +qreal CarouselView::keylineCenter(int io) const { + const int n = m_arrangement.n; + const int i = std::abs(io); + if (i == 0) return 0; + const int sign = io < 0 ? -1 : 1; + + if (i <= n) return sign * m_arrangement.centers[i]; + + if (i == n + 1) { + const qreal lastCenter = m_arrangement.centers[n]; + const qreal lastSize = m_arrangement.sizes[n]; + return sign * (lastCenter + lastSize / 2.0 + m_itemSpacing + + m_minEdgeWidth / 2.0); + } + + const qreal outgoingCenter = std::abs(keylineCenter(sign * (n + 1))); + return sign * (outgoingCenter + m_minEdgeWidth / 2.0 + m_itemSpacing); +} + +qreal CarouselView::keylineSize(int io) const { + const int n = m_arrangement.n; + const int i = std::abs(io); + if (i == 0) return m_focalWidth; + if (i <= n) return m_arrangement.sizes[i]; + if (i == n + 1) return m_minEdgeWidth; + return 0; +} + +qreal CarouselView::sampleCenter(qreal childLoc) const { + const int n = m_arrangement.n; + if (n <= 0) return 0; + + const int minIo = -(n + 2); + const int maxIo = n + 2; + const qreal p = pitch(); + + const int a = static_cast(std::floor(childLoc / p)); + if (a < minIo) return keylineCenter(minIo); + if (a >= maxIo) return keylineCenter(maxIo); + + const int b = a + 1; + const qreal t = (childLoc - a * p) / p; + return keylineCenter(a) + (keylineCenter(b) - keylineCenter(a)) * t; +} + +qreal CarouselView::sampleSize(qreal childLoc) const { + const int n = m_arrangement.n; + if (n <= 0) return 0; + + const int minIo = -(n + 2); + const int maxIo = n + 2; + const qreal p = pitch(); + + const int a = static_cast(std::floor(childLoc / p)); + if (a < minIo) return keylineSize(minIo); + if (a >= maxIo) return keylineSize(maxIo); + + const int b = a + 1; + const qreal t = (childLoc - a * p) / p; + return keylineSize(a) + (keylineSize(b) - keylineSize(a)) * t; +} + +// ---------------------------------------------------------------- index math + +int CarouselView::realIndexOf(int virtualIndex) const { + const int count = m_model.size(); + if (count == 0) return -1; + return ((virtualIndex % count) + count) % count; +} + +qreal CarouselView::contentXForIndex(qreal index) const { + return index * pitch() + pitch() / 2.0 - width() / 2.0; +} + +int CarouselView::indexForContentX(qreal x) const { + return static_cast( + std::round((x + width() / 2.0 - pitch() / 2.0) / pitch())); +} + +qreal CarouselView::snapTargetX(qreal x) const { + return contentXForIndex(indexForContentX(x)); +} + +void CarouselView::wrapContentIfNeeded() { + const int count = m_model.size(); + if (count == 0) return; + + const qreal blockWidth = count * pitch(); + const qreal centerX = contentXForIndex(m_centerBlockStart); + const int blocksFromCenter = + static_cast(std::round((m_contentX - centerX) / blockWidth)); + + if (std::abs(blocksFromCenter) >= 2) { + const qreal shift = blocksFromCenter * blockWidth; + m_contentX -= shift; + + if (m_glideAnim->state() == QAbstractAnimation::Running) { + const qreal newEnd = m_glideAnim->endValue().toReal() - shift; + m_glideAnim->setEndValue(newEnd); + } + if (m_flickAnim->state() == QAbstractAnimation::Running) { + const qreal newEnd = m_flickAnim->endValue().toReal() - shift; + m_flickAnim->setEndValue(newEnd); + } + // Also shift every currently-live slot's virtual index bookkeeping so + // it doesn't think it needs to be recycled just because of the wrap. + const int indexShift = static_cast(std::round(shift / pitch())); + for (Slot& s : m_slots) { + if (s.active) s.virtualIndex -= indexShift; + } + } +} + +void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) { + const int count = m_model.size(); + if (count == 0) return; + + const int newVirtual = static_cast( + std::round((m_contentX + width() / 2.0 - pitch() / 2.0) / pitch())); + if (newVirtual == m_currentVirtualIndex) return; + + m_currentVirtualIndex = newVirtual; + const int newReal = realIndexOf(newVirtual); + + if (newReal != m_currentRealIndex) { + m_currentRealIndex = newReal; + emit currentIndexChanged(); + } + if (emitPreview && newReal != m_previewRealIndex) { + m_previewRealIndex = newReal; + if (newReal >= 0) + emit previewIndexChanged(newReal, m_model.at(newReal)); + } +} + +// ---------------------------------------------------------------- pool / delegates + +QQuickItem* CarouselView::acquireItem() { + if (!m_delegate) return nullptr; + + QQmlContext* ctx = QQmlEngine::contextForObject(this); + QVariantMap initial; + initial.insert(QStringLiteral("isCurrent"), false); + initial.insert(QStringLiteral("modelData"), QVariant()); + initial.insert(QStringLiteral("tileHeight"), m_tileHeight); + initial.insert(QStringLiteral("tileWidth"), m_focalWidth); + for (auto it = m_delegateProperties.constBegin(); + it != m_delegateProperties.constEnd(); + ++it) + initial.insert(it.key(), it.value()); + + QObject* obj = m_delegate->createWithInitialProperties(initial, ctx); + if (!obj) { + qWarning( + "CarouselView: failed to create delegate instance: %s", + qPrintable(m_delegate->errorString())); + return nullptr; + } + QQuickItem* item = qobject_cast(obj); + if (!item) { + qWarning("CarouselView: delegate root is not an Item"); + delete obj; + return nullptr; + } + + item->setParentItem(this); + QQmlEngine::setObjectOwnership(item, QQmlEngine::CppOwnership); + + const QMetaMethod activateSignal = item->metaObject()->method( + item->metaObject()->indexOfSignal("requestActivate()")); + if (activateSignal.isValid()) { + static const QMetaMethod slot = metaObject()->method( + metaObject()->indexOfSlot("handleDelegateActivate()")); + connect(item, activateSignal, this, slot); + } + + return item; +} + +void CarouselView::applyPropertiesToItem(QQuickItem* item, int realIndex) const { + if (!item) return; + const bool isCurrent = (realIndex == m_currentRealIndex); + QQmlProperty::write(item, QStringLiteral("isCurrent"), isCurrent); + QQmlProperty::write( + item, + QStringLiteral("modelData"), + realIndex >= 0 && realIndex < m_model.size() ? m_model.at(realIndex) + : QVariant()); + QQmlProperty::write(item, QStringLiteral("tileHeight"), m_tileHeight); +} + +void CarouselView::updateSlotContent(Slot& slot, int virtualIndex) { + if (!slot.item) slot.item = acquireItem(); + if (!slot.item) return; + + slot.virtualIndex = virtualIndex; + slot.realIndex = realIndexOf(virtualIndex); + slot.active = slot.realIndex >= 0; + slot.item->setVisible(slot.active); + if (slot.active) applyPropertiesToItem(slot.item, slot.realIndex); +} + +void CarouselView::positionSlot(const Slot& slot) const { + if (!slot.item || !slot.active) return; + + const qreal childLoc = slot.virtualIndex * pitch() + pitch() / 2.0 - + m_contentX - width() / 2.0; + const qreal center = sampleCenter(childLoc); + const qreal size = sampleSize(childLoc); + + // slot's "column" spans one pitch, centred at width()/2 + center; item is + // centred within that column, matching the QML delegate's x expression. + const qreal slotX = childLoc + width() / 2.0 - pitch() / 2.0; + const qreal itemX = (center - childLoc) + (pitch() - size) / 2.0 + slotX; + + QQmlProperty::write(slot.item, QStringLiteral("tileWidth"), size); + slot.item->setX(itemX); + slot.item->setY(0); + slot.item->setVisible(size > 0.5); +} + +void CarouselView::releaseAllItems() { + for (Slot& s : m_slots) { + if (s.item) s.item->deleteLater(); + } + m_slots.clear(); +} + +// ---------------------------------------------------------------- layout pass + +void CarouselView::relayout() { + const int count = m_model.size(); + if (count == 0 || width() <= 0 || m_focalWidth <= 0) { + for (Slot& s : m_slots) + if (s.item) s.item->setVisible(false); + return; + } + + const int n = m_arrangement.n; + // Window of virtual indices that can ever be visible, plus a small buffer + // so items don't pop in/out right at the edge during a fast flick. + const int half = n + 3; + const int lo = m_currentVirtualIndex - half; + const int hi = m_currentVirtualIndex + half; + const int needed = hi - lo + 1; + + // Grow the pool if needed (shrinking is unnecessary: idle items are just + // marked inactive/invisible, which is cheap and avoids churn). + while (m_slots.size() < needed) + m_slots.append(Slot{}); + + // Figure out which virtual indices are already backed by a slot. + QHash virtualToSlot; // virtualIndex -> slot list position + virtualToSlot.reserve(m_slots.size()); + for (int i = 0; i < m_slots.size(); ++i) { + if (m_slots[i].active) virtualToSlot.insert(m_slots[i].virtualIndex, i); + } + + QVector slotUsedThisPass(m_slots.size(), false); + + for (int v = lo; v <= hi; ++v) { + auto it = virtualToSlot.find(v); + if (it != virtualToSlot.end()) { + slotUsedThisPass[it.value()] = true; + // Content (isCurrent / modelData) may still need refreshing if + // currentRealIndex changed since last pass. + applyPropertiesToItem( + m_slots[it.value()].item, m_slots[it.value()].realIndex); + continue; + } + + // find a free slot: one not used this pass and not already at a + // virtual index inside [lo, hi] (those are all still needed). + int freeSlot = -1; + for (int i = 0; i < m_slots.size(); ++i) { + if (slotUsedThisPass[i]) continue; + if (m_slots[i].active && m_slots[i].virtualIndex >= lo && + m_slots[i].virtualIndex <= hi) + continue; + freeSlot = i; + break; + } + if (freeSlot < 0) { + // Shouldn't happen given the pool growth above, but guard anyway. + m_slots.append(Slot{}); + slotUsedThisPass.append(false); + freeSlot = m_slots.size() - 1; + } + + updateSlotContent(m_slots[freeSlot], v); + slotUsedThisPass[freeSlot] = true; + } + + for (int i = 0; i < m_slots.size(); ++i) { + if (!slotUsedThisPass[i] && m_slots[i].item) { + m_slots[i].active = false; + m_slots[i].item->setVisible(false); + } + } + + for (const Slot& s : std::as_const(m_slots)) + positionSlot(s); +} + +// ---------------------------------------------------------------- animation + +void CarouselView::cancelAnimations() { + if (m_glideAnim->state() == QAbstractAnimation::Running) + m_glideAnim->stop(); + if (m_flickAnim->state() == QAbstractAnimation::Running) + m_flickAnim->stop(); +} + +void CarouselView::glideTo(qreal dest) { + if (std::abs(dest - m_contentX) < 0.5) return; + + cancelAnimations(); + m_glideAnim->setDuration(m_glideDuration); + m_glideAnim->setStartValue(m_contentX); + m_glideAnim->setEndValue(dest); + m_glideAnim->start(); +} + +void CarouselView::snapToNearest() { + glideTo(snapTargetX(m_contentX)); +} + +// ---------------------------------------------------------------- public API + +void CarouselView::goToIndex(int realIndex) { + const int count = m_model.size(); + if (count == 0) return; + realIndex = qBound(0, realIndex, count - 1); + + const int curBlock = + static_cast(std::floor(qreal(m_currentVirtualIndex) / count)); + const int current = m_currentVirtualIndex; + int target = curBlock * count + realIndex; + + if (target - current > count / 2) + target -= count; + else if (current - target > count / 2) + target += count; + + glideTo(contentXForIndex(target)); +} + +void CarouselView::incrementCurrentIndex() { + if (m_model.isEmpty()) return; + glideTo(contentXForIndex(m_currentVirtualIndex + 1)); +} + +void CarouselView::decrementCurrentIndex() { + if (m_model.isEmpty()) return; + glideTo(contentXForIndex(m_currentVirtualIndex - 1)); +} + +void CarouselView::jumpToIndex(int realIndex) { + centerInstantlyOnReal(realIndex); +} + +void CarouselView::centerInstantlyOnReal(int realIndex) { + const int count = m_model.size(); + if (count == 0) return; + realIndex = qBound(0, realIndex, count - 1); + + cancelAnimations(); + + const int curBlock = + static_cast(std::floor(qreal(m_currentVirtualIndex) / count)); + const int current = m_currentVirtualIndex; + int target = curBlock * count + realIndex; + + if (target - current > count / 2) + target -= count; + else if (current - target > count / 2) + target += count; + + m_currentVirtualIndex = target; + m_contentX = contentXForIndex(target); + + const bool changed = (realIndex != m_currentRealIndex); + m_currentRealIndex = realIndex; + m_previewRealIndex = realIndex; + if (changed) emit currentIndexChanged(); + emit previewIndexChanged(realIndex, m_model.at(realIndex)); + + relayout(); +} + +// ---------------------------------------------------------------- input + +void CarouselView::mousePressEvent(QMouseEvent* event) { + // Deliberately do NOT cancel animations here. A press that turns out to + // be a plain tap (never crosses the drag threshold) should leave any + // in-flight glide/flick completely untouched. We only touch the + // animation once we know it's actually a drag (see mouseMoveEvent). + m_dragging = true; + m_dragActive = false; + m_pressPos = event->position(); + // Capture wherever contentX currently is, even mid-animation -- if this + // does turn into a drag, it should pick up smoothly from the visual + // position, not teleport to some rest position first. + m_pressContentX = m_contentX; + m_dragTimer.start(); + m_lastMoveX = event->position().x(); + m_lastMoveT = 0; + m_velocity = 0; + event->accept(); +} + +void CarouselView::mouseMoveEvent(QMouseEvent* event) { + if (!m_dragging) return; + + const qreal dx = event->position().x() - m_pressPos.x(); + if (!m_dragActive && std::abs(dx) > kDragThreshold) { + m_dragActive = true; + m_pressContentX = + m_contentX; // re-anchor to the live (possibly mid-animation) value + cancelAnimations(); + grabMouse(); + } + if (!m_dragActive) return; + + m_contentX = m_pressContentX - dx; + wrapContentIfNeeded(); + updateCurrentIndexFromContentX(true); + relayout(); + + const qint64 t = m_dragTimer.elapsed(); + const qint64 dt = t - m_lastMoveT; + if (dt > 0) { + const qreal instVel = + (m_lastMoveX - event->position().x()) / (dt / 1000.0); + // Light smoothing so a single jittery sample doesn't dominate the flick. + m_velocity = m_velocity * 0.7 + instVel * 0.3; + } + m_lastMoveX = event->position().x(); + m_lastMoveT = t; + + event->accept(); +} + +void CarouselView::mouseReleaseEvent(QMouseEvent* event) { + if (!m_dragging) return; + m_dragging = false; + ungrabMouse(); + + if (!m_dragActive) { + // A plain click/tap with no drag: let it fall through to the + // delegate's own StateLayer/MouseArea via requestActivate(). + event->accept(); + return; + } + m_dragActive = false; + + qreal v = qBound(-kMaxFlickVelocity, m_velocity, kMaxFlickVelocity); + if (std::abs(v) < kMinFlickVelocity) { + snapToNearest(); + event->accept(); + return; + } + + // Simple constant-deceleration flick: distance = v^2 / (2*friction). + const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); // ms + const qreal distance = + (v * (duration / 1000.0)) / 2.0; // average-velocity approximation + const qreal rawDest = m_contentX + distance; + const qreal dest = snapTargetX(rawDest); + + cancelAnimations(); + m_flickAnim->setDuration( + static_cast(qBound(120, duration, 900))); + m_flickAnim->setEasingCurve(QEasingCurve::OutCubic); + m_flickAnim->setStartValue(m_contentX); + m_flickAnim->setEndValue(dest); + m_flickAnim->start(); + + event->accept(); +} + +void CarouselView::mouseUngrabEvent() { + m_dragging = false; + m_dragActive = false; +} + +void CarouselView::wheelEvent(QWheelEvent* event) { + const int steps = event->angleDelta().y() / 120; + if (steps > 0) + decrementCurrentIndex(); + else if (steps < 0) + incrementCurrentIndex(); + event->accept(); +} + +// ---------------------------------------------------------------- delegate signal + +void CarouselView::handleDelegateActivate() { + QQuickItem* item = qobject_cast(sender()); + if (!item) return; + + for (const Slot& s : std::as_const(m_slots)) { + if (s.item == item && s.active) { + if (s.realIndex == m_currentRealIndex) { + emit activated(s.realIndex, m_model.at(s.realIndex)); + } else { + goToIndex(s.realIndex); + } + return; + } + } +} + +} // namespace ZShell::components diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp new file mode 100644 index 0000000..23a8e36 --- /dev/null +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -0,0 +1,220 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::components { + +class CarouselView : public QQuickItem { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY( + QQmlComponent* delegate READ delegate WRITE setDelegate NOTIFY + delegateChanged) + Q_PROPERTY(QVariantList model READ model WRITE setModel NOTIFY modelChanged) + + Q_PROPERTY( + qreal focalWidth READ focalWidth WRITE setFocalWidth NOTIFY + focalWidthChanged) + Q_PROPERTY( + qreal minEdgeWidth READ minEdgeWidth WRITE setMinEdgeWidth NOTIFY + minEdgeWidthChanged) + Q_PROPERTY( + qreal itemSpacing READ itemSpacing WRITE setItemSpacing NOTIFY + itemSpacingChanged) + Q_PROPERTY( + qreal tileHeight READ tileHeight WRITE setTileHeight NOTIFY + tileHeightChanged) + Q_PROPERTY( + qreal maxWidth READ maxWidth WRITE setMaxWidth NOTIFY maxWidthChanged) + + Q_PROPERTY( + int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY + currentIndexChanged) + Q_PROPERTY( + int glideDuration READ glideDuration WRITE setGlideDuration NOTIFY + glideDurationChanged) + Q_PROPERTY( + QEasingCurve::Type glideEasingType READ glideEasingType WRITE + setGlideEasingType NOTIFY glideEasingTypeChanged) + + // Extra properties (e.g. { visibilities: root.visibilities }) forwarded + // verbatim onto every delegate instance as a property of the same name. + Q_PROPERTY( + QVariantMap delegateProperties READ delegateProperties WRITE + setDelegateProperties NOTIFY delegatePropertiesChanged) + + public: + explicit CarouselView(QQuickItem* parent = nullptr); + ~CarouselView() override; + + QQmlComponent* delegate() const { return m_delegate; } + void setDelegate(QQmlComponent* c); + + QVariantList model() const { return m_model; } + void setModel(const QVariantList& m); + + qreal focalWidth() const { return m_focalWidth; } + void setFocalWidth(qreal v); + + qreal minEdgeWidth() const { return m_minEdgeWidth; } + void setMinEdgeWidth(qreal v); + + qreal itemSpacing() const { return m_itemSpacing; } + void setItemSpacing(qreal v); + + qreal tileHeight() const { return m_tileHeight; } + void setTileHeight(qreal v); + + qreal maxWidth() const { return m_maxWidth; } + void setMaxWidth(qreal v); + + int currentIndex() const { return m_currentRealIndex; } + void setCurrentIndex(int idx); // jumps (animated glide) to a real index + + int glideDuration() const { return m_glideDuration; } + void setGlideDuration(int v); + + QEasingCurve::Type glideEasingType() const { return m_glideEasingType; } + void setGlideEasingType(QEasingCurve::Type t); + + QVariantMap delegateProperties() const { return m_delegateProperties; } + void setDelegateProperties(const QVariantMap& v); + + // Animated (glides). + Q_INVOKABLE void goToIndex(int realIndex); + Q_INVOKABLE void incrementCurrentIndex(); + Q_INVOKABLE void decrementCurrentIndex(); + + // Instant, no animation -- use for initial placement (e.g. restoring the + // saved wallpaper on Component.onCompleted) where a glide would be wrong. + Q_INVOKABLE void jumpToIndex(int realIndex); + + signals: + void delegateChanged(); + void modelChanged(); + void focalWidthChanged(); + void minEdgeWidthChanged(); + void itemSpacingChanged(); + void tileHeightChanged(); + void maxWidthChanged(); + void currentIndexChanged(); + void glideDurationChanged(); + void glideEasingTypeChanged(); + void delegatePropertiesChanged(); + + // Emitted when the currently-centred delegate is clicked while already + // centred (i.e. "activate/select this one"). + void activated(int realIndex, QVariant modelData); + // Emitted continuously as the centred item changes, for preview-on-scroll + // behaviour (used to drive Wallpapers.preview() from QML). + void previewIndexChanged(int realIndex, QVariant modelData); + + protected: + void geometryChange(const QRectF& newGeo, const QRectF& oldGeo) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + void mouseUngrabEvent() override; + void wheelEvent(QWheelEvent* event) override; + + private slots: + void handleDelegateActivate(); + + private: + struct Arrangement { + int n = 0; + qreal width = 0; + QVector sizes; // sizes[0] = focal, sizes[i] = i-th side item + QVector + centers; // centers[0] = 0, centers[i] = i-th side centre offset + qreal effectiveExtraWidth = 0; + }; + + struct Slot { + QQuickItem* item = nullptr; + int virtualIndex = + 0; // which "virtual" (unbounded) index this slot currently renders + int realIndex = -1; // resolved model index, -1 if none / hidden + bool active = false; + }; + + void rebuildArrangement(); + void relayout(); // recomputes window + positions every slot (one pass) + void updateSlotContent(Slot& slot, int virtualIndex); + void positionSlot(const Slot& slot) const; + void applyPropertiesToItem(QQuickItem* item, int realIndex) const; + + qreal pitch() const { return m_focalWidth + m_itemSpacing; } + qreal contentXForIndex(qreal index) const; + int indexForContentX(qreal x) const; + qreal snapTargetX(qreal x) const; + void snapToNearest(); + void glideTo(qreal dest); + void cancelAnimations(); + + qreal keylineCenter(int io) const; + qreal keylineSize(int io) const; + qreal sampleCenter(qreal childLoc) const; + qreal sampleSize(qreal childLoc) const; + + int realIndexOf(int virtualIndex) const; + void updateCurrentIndexFromContentX(bool emitPreview); + void wrapContentIfNeeded(); + void centerInstantlyOnReal(int realIndex); // no animation, no glide + + QQuickItem* acquireItem(); + void releaseAllItems(); + + QQmlComponent* m_delegate = nullptr; + QVariantList m_model; + QVariantMap m_delegateProperties; + + qreal m_focalWidth = 0; + qreal m_minEdgeWidth = 56; + qreal m_itemSpacing = 8; + qreal m_tileHeight = 0; + qreal m_maxWidth = 0; + + Arrangement m_arrangement; + + qreal m_contentX = 0; + int m_currentVirtualIndex = 0; + int m_currentRealIndex = -1; + int m_previewRealIndex = -1; + + static constexpr int kLoopMultiplier = 401; + int m_centerBlockStart = 0; + + QList + m_slots; // pooled delegate slots, unordered; window recomputed each relayout + + int m_glideDuration = 250; + QEasingCurve::Type m_glideEasingType = QEasingCurve::OutCubic; + QVariantAnimation* m_glideAnim = nullptr; + + // Drag / flick state + bool m_dragging = false; + bool m_dragActive = false; // exceeded the drag threshold + QPointF m_pressPos; + qreal m_pressContentX = 0; + QElapsedTimer m_dragTimer; + qreal m_lastMoveX = 0; + qint64 m_lastMoveT = 0; + qreal m_velocity = 0; // px/sec + QVariantAnimation* m_flickAnim = nullptr; + + bool m_initializedLayout = false; + bool m_relayoutQueued = false; +}; + +}; // namespace ZShell::components diff --git a/Plugins/ZShell/Internal/wallpaperimage.cpp b/Plugins/ZShell/Internal/wallpaperimage.cpp index e0e6da9..b6ad891 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.cpp +++ b/Plugins/ZShell/Internal/wallpaperimage.cpp @@ -4,14 +4,31 @@ #include #include #include +#include #include -#include #include namespace ZShell::internal { +QThreadPool* WallpaperImage::loadPool() { + static QThreadPool* pool = [] { + auto* p = new QThreadPool(); + p->setMaxThreadCount(4); + return p; + }(); + return pool; +} + WallpaperImage::WallpaperImage(QQuickItem* parent) : QQuickItem(parent) { setFlag(ItemHasContents, true); + + m_loadDebounceTimer.setSingleShot(true); + connect( + &m_loadDebounceTimer, + &QTimer::timeout, + this, + &WallpaperImage::beginLoad); + connect( &m_imageWatcher, &QFutureWatcher::finished, @@ -20,12 +37,15 @@ WallpaperImage::WallpaperImage(QQuickItem* parent) : QQuickItem(parent) { } WallpaperImage::~WallpaperImage() { + m_requestGeneration->fetch_add(1, std::memory_order_relaxed); + m_imageWatcher.disconnect(); + if (m_texture) delete m_texture; + if (m_previousTexture) delete m_previousTexture; } void WallpaperImage::setStatus(const Status s) { if (m_status == s) return; - m_status = s; emit statusChanged(); } @@ -36,14 +56,14 @@ void WallpaperImage::setSource(const QUrl& source) { emit sourceChanged(); setStatus(Loading); - loadImage(); + m_loadDebounceTimer.start(80); } void WallpaperImage::setScreenResolution(const QSize& screenResolution) { if (m_screenResolution == screenResolution) return; m_screenResolution = screenResolution; emit screenResolutionChanged(); - loadImage(); + beginLoad(); } void WallpaperImage::setZoom(qreal zoom) { @@ -53,6 +73,13 @@ void WallpaperImage::setZoom(qreal zoom) { update(); } +void WallpaperImage::setFadeDuration(int ms) { + if (ms < 0) ms = 0; + if (m_fadeDuration == ms) return; + m_fadeDuration = ms; + emit fadeDurationChanged(); +} + void WallpaperImage::setCropX(qreal x) { if (qFuzzyCompare(m_cropX, x)) return; m_cropX = x; @@ -100,143 +127,227 @@ QString WallpaperImage::getCacheFilePath() const { return cachePath + "/" + hash + ".png"; } -void WallpaperImage::loadImage() { +void WallpaperImage::beginLoad() { if (m_source.isEmpty()) { setStatus(Null); return; } + const quint64 requestId = + m_requestGeneration->fetch_add(1, std::memory_order_relaxed) + 1; + QString cacheFile = getCacheFilePath(); QString sourceFile = m_source.isLocalFile() ? m_source.toLocalFile() : m_source.toString(); - - if (sourceFile.startsWith("qrc:/")) { - sourceFile = sourceFile.mid(3); - } + if (sourceFile.startsWith("qrc:/")) sourceFile = sourceFile.mid(3); QSize targetRes = m_screenResolution; + auto generation = m_requestGeneration; + + QFuture future = QtConcurrent::run( + loadPool(), + [sourceFile, cacheFile, targetRes, requestId, generation]() -> QImage { + auto stale = [&] { + return generation->load(std::memory_order_relaxed) != requestId; + }; + + if (stale()) return QImage(); - QFuture 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 (stale()) return QImage(); + + QImage original; + QImageReader reader(sourceFile); + if (!targetRes.isEmpty() && + reader.supportsOption(QImageIOHandler::Size)) { + QSize actual = reader.size(); + if (actual.isValid() && + (actual.width() > targetRes.width() || + actual.height() > targetRes.height())) { + QSize scaledSize = + actual.scaled(targetRes, Qt::KeepAspectRatioByExpanding); + reader.setScaledSize(scaledSize); + } + original = reader.read(); + } else { + original = QImage(sourceFile); + } if (original.isNull()) return QImage(); - if (targetRes.isEmpty()) { - return original; - } + if (stale()) return QImage(); - 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; - } + if (targetRes.isEmpty()) return original; - if (!cacheFile.isEmpty()) original.save(cacheFile, "PNG"); - return original; + QImage scaled = (original.width() > targetRes.width() || + original.height() > targetRes.height()) + ? original.scaled( + targetRes, + Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation) + : original; + + if (stale()) return scaled; + + if (!cacheFile.isEmpty()) scaled.save(cacheFile, "PNG"); + return scaled; }); m_imageWatcher.setFuture(future); } void WallpaperImage::handleImageLoaded() { - m_image = m_imageWatcher.result(); + QImage result = m_imageWatcher.result(); + if (result.isNull()) { + setStatus(Error); + return; + } - setStatus(m_image.isNull() ? Error : Ready); - - m_textureDirty = true; + m_pendingImage = result; + m_hasPending = true; + setStatus(Ready); update(); } -QSGNode* WallpaperImage::updatePaintNode( - QSGNode* oldNode, UpdatePaintNodeData*) { - auto* node = static_cast(oldNode); +QRectF WallpaperImage::computeSourceRect(QSGTexture* texture) const { + QSize texSize = texture->textureSize(); - if (m_image.isNull()) { - delete node; - return nullptr; + qreal cW = m_cropWidth / m_zoom; + qreal cH = m_cropHeight / m_zoom; + + QRectF reqRect( + m_cropX * texSize.width(), + m_cropY * texSize.height(), + cW * texSize.width(), + cH * texSize.height()); + + QRectF bounds = boundingRect(); + if (bounds.isEmpty() || reqRect.isEmpty()) return reqRect; + + qreal targetRatio = bounds.width() / bounds.height(); + qreal reqRatio = reqRect.width() / reqRect.height(); + + QRectF sourceRect = reqRect; + + if (reqRatio > targetRatio) { + qreal newWidth = reqRect.height() * targetRatio; + qreal xOffset = (reqRect.width() - newWidth) / 2.0; + sourceRect.setX(reqRect.x() + xOffset); + sourceRect.setWidth(newWidth); + } else if (reqRatio < targetRatio) { + qreal newHeight = reqRect.width() / targetRatio; + qreal yOffset = (reqRect.height() - newHeight) / 2.0; + sourceRect.setY(reqRect.y() + yOffset); + sourceRect.setHeight(newHeight); } - if (!node) { - node = window()->createImageNode(); - } + return sourceRect; +} - if (m_textureDirty) { - if (m_texture) delete m_texture; - m_texture = window()->createTextureFromImage( - m_image, QQuickWindow::TextureHasAlphaChannel); - m_textureDirty = false; - } +void WallpaperImage::configureImageNode( + QSGImageNode* node, QSGTexture* texture) { + node->setTexture(texture); + node->setRect(boundingRect()); + node->setFiltering(QSGTexture::Linear); - 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; - - if (reqRatio > targetRatio) { - qreal newWidth = reqRect.height() * targetRatio; - qreal xOffset = (reqRect.width() - newWidth) / 2.0; - sourceRect.setX(reqRect.x() + xOffset); - sourceRect.setWidth(newWidth); - } else if (reqRatio < targetRatio) { - qreal newHeight = reqRect.width() / targetRatio; - qreal yOffset = (reqRect.height() - newHeight) / 2.0; - sourceRect.setY(reqRect.y() + yOffset); - sourceRect.setHeight(newHeight); - } + QRectF sourceRect = computeSourceRect(texture); + node->setSourceRect(sourceRect); + if (texture == m_texture) { + QSize texSize = texture->textureSize(); QRectF normalizedActual( - sourceRect.x() / m_texture->textureSize().width(), - sourceRect.y() / m_texture->textureSize().height(), - sourceRect.width() / m_texture->textureSize().width(), - sourceRect.height() / m_texture->textureSize().height()); + sourceRect.x() / texSize.width(), + sourceRect.y() / texSize.height(), + sourceRect.width() / texSize.width(), + sourceRect.height() / texSize.height()); bool changed = false; - auto updateIfChanged = [&](qreal& dst, qreal value) { if (!qFuzzyCompare(dst, value)) { dst = value; changed = true; } }; - updateIfChanged(m_actualCropX, normalizedActual.x()); updateIfChanged(m_actualCropY, normalizedActual.y()); updateIfChanged(m_actualCropWidth, normalizedActual.width()); updateIfChanged(m_actualCropHeight, normalizedActual.height()); - if (changed) emit actualCropChanged(); + } +} - node->setSourceRect(sourceRect); +QSGNode* WallpaperImage::updatePaintNode( + QSGNode* oldNode, UpdatePaintNodeData*) { + Q_UNUSED(oldNode); + + if (m_hasPending) { + if (m_previousTexture) { + delete m_previousTexture; + m_previousTexture = nullptr; + } + m_previousTexture = m_texture; + m_texture = window()->createTextureFromImage( + m_pendingImage, QQuickWindow::TextureHasAlphaChannel); + m_pendingImage = QImage(); + m_hasPending = false; + + if (m_previousTexture && m_fadeDuration > 0) { + m_fading = true; + m_fadeTimer.restart(); + } else { + if (m_previousTexture) { + delete m_previousTexture; + m_previousTexture = nullptr; + } + m_fading = false; + } } - return node; + if (!m_texture) { + return m_rootNode; + } + + if (!m_rootNode) { + m_rootNode = new QSGNode(); + + m_newOpacityNode = new QSGOpacityNode(); + m_newImageNode = window()->createImageNode(); + m_newOpacityNode->appendChildNode(m_newImageNode); + m_rootNode->appendChildNode(m_newOpacityNode); + + m_oldOpacityNode = new QSGOpacityNode(); + m_oldImageNode = window()->createImageNode(); + m_oldOpacityNode->appendChildNode(m_oldImageNode); + m_rootNode->appendChildNode(m_oldOpacityNode); + } + + configureImageNode(m_newImageNode, m_texture); + m_newOpacityNode->setOpacity(1.0); + + if (m_fading && m_previousTexture) { + qreal progress = + qBound(0.0, qreal(m_fadeTimer.elapsed()) / m_fadeDuration, 1.0); + + configureImageNode(m_oldImageNode, m_previousTexture); + m_oldOpacityNode->setOpacity(1.0 - progress); + + if (progress >= 1.0) { + m_fading = false; + delete m_previousTexture; + m_previousTexture = nullptr; + m_oldOpacityNode->setOpacity(0.0); + } else { + update(); + } + } else { + m_oldOpacityNode->setOpacity(0.0); + } + + return m_rootNode; } } // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/wallpaperimage.hpp b/Plugins/ZShell/Internal/wallpaperimage.hpp index 9c0d2a9..6ecf562 100644 --- a/Plugins/ZShell/Internal/wallpaperimage.hpp +++ b/Plugins/ZShell/Internal/wallpaperimage.hpp @@ -4,9 +4,15 @@ #include #include #include +#include +#include #include +#include +#include +#include #include #include +#include namespace ZShell::internal { @@ -19,6 +25,9 @@ class WallpaperImage : public QQuickItem { NOTIFY screenResolutionChanged) Q_PROPERTY(qreal zoom READ zoom WRITE setZoom NOTIFY zoomChanged) Q_PROPERTY(Status status READ status NOTIFY statusChanged) + Q_PROPERTY( + int fadeDuration READ fadeDuration WRITE setFadeDuration NOTIFY + fadeDurationChanged) Q_PROPERTY(qreal actualCropX READ actualCropX NOTIFY actualCropChanged) Q_PROPERTY(qreal actualCropY READ actualCropY NOTIFY actualCropChanged) @@ -54,12 +63,12 @@ class WallpaperImage : public QQuickItem { qreal zoom() const { return m_zoom; } void setZoom(qreal zoom); + int fadeDuration() const { return m_fadeDuration; } + void setFadeDuration(int ms); + qreal actualCropX() const { return m_actualCropX; } - qreal actualCropY() const { return m_actualCropY; } - qreal actualCropWidth() const { return m_actualCropWidth; } - qreal actualCropHeight() const { return m_actualCropHeight; } qreal cropX() const { return m_cropX; } @@ -82,6 +91,7 @@ class WallpaperImage : public QQuickItem { void sourceChanged(); void screenResolutionChanged(); void zoomChanged(); + void fadeDurationChanged(); void actualCropChanged(); void cropXChanged(); void cropYChanged(); @@ -90,15 +100,18 @@ class WallpaperImage : public QQuickItem { void statusChanged(); private: - void loadImage(); + void beginLoad(); void handleImageLoaded(); QString getCacheFilePath() const; void setStatus(const Status s); + QRectF computeSourceRect(QSGTexture* texture) const; + void configureImageNode(QSGImageNode* node, QSGTexture* texture); Status m_status = Null; QUrl m_source; QSize m_screenResolution; qreal m_zoom = 1.0; + int m_fadeDuration = 400; qreal m_actualCropX = 0.0; qreal m_actualCropY = 0.0; @@ -110,10 +123,25 @@ class WallpaperImage : public QQuickItem { qreal m_cropWidth = 1.0; qreal m_cropHeight = 1.0; - QImage m_image; - QSGTexture* m_texture = nullptr; - bool m_textureDirty = false; + QTimer m_loadDebounceTimer; QFutureWatcher m_imageWatcher; + std::shared_ptr> m_requestGeneration = + std::make_shared>(0); + static QThreadPool* loadPool(); + QImage m_pendingImage; + bool m_hasPending = false; + + QSGTexture* m_texture = nullptr; + QSGTexture* m_previousTexture = nullptr; + + bool m_fading = false; + QElapsedTimer m_fadeTimer; + + QSGNode* m_rootNode = nullptr; + QSGOpacityNode* m_newOpacityNode = nullptr; + QSGOpacityNode* m_oldOpacityNode = nullptr; + QSGImageNode* m_newImageNode = nullptr; + QSGImageNode* m_oldImageNode = nullptr; }; } // namespace ZShell::internal -- 2.47.3 From 654632128f411807d7429dc7bf9608687604bed5 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 18:14:04 +0200 Subject: [PATCH 05/10] clean-up plugin files --- Plugins/ZShell/Components/carouselview.cpp | 83 ++-------------------- Plugins/ZShell/Components/carouselview.hpp | 33 +++------ 2 files changed, 16 insertions(+), 100 deletions(-) diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index e896c39..e7a8bda 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -11,9 +11,9 @@ namespace ZShell::components { namespace { -constexpr qreal kDragThreshold = 4.0; // px before a press becomes a drag -constexpr qreal kFlickFriction = 0.0022; // px/ms^2 deceleration -constexpr qreal kMinFlickVelocity = 60.0; // px/sec below which we just snap +constexpr qreal kDragThreshold = 4.0; +constexpr qreal kFlickFriction = 0.0022; +constexpr qreal kMinFlickVelocity = 60.0; constexpr qreal kMaxFlickVelocity = 6000.0; } // namespace @@ -59,8 +59,6 @@ CarouselView::~CarouselView() { releaseAllItems(); } -// ---------------------------------------------------------------- properties - void CarouselView::setDelegate(QQmlComponent* c) { if (m_delegate == c) return; m_delegate = c; @@ -73,7 +71,6 @@ void CarouselView::setModel(const QVariantList& m) { m_model = m; emit modelChanged(); - // Re-anchor on the currently selected path if possible, otherwise clamp. int newCount = m_model.size(); if (newCount == 0) { m_currentRealIndex = -1; @@ -93,9 +90,6 @@ void CarouselView::setModel(const QVariantList& m) { return; } - // Model contents changed under us (e.g. search text filtered the list). - // Re-anchor instantly on the clamped selection -- no glide, since this - // isn't a user-initiated navigation. rebuildArrangement(); centerInstantlyOnReal(qBound(0, m_currentRealIndex, newCount - 1)); } @@ -173,16 +167,10 @@ void CarouselView::setCurrentIndex(int idx) { goToIndex(idx); } -// ---------------------------------------------------------------- geometry - void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { QQuickItem::geometryChange(newGeo, oldGeo); if (!qFuzzyCompare(newGeo.width(), oldGeo.width()) || !qFuzzyCompare(newGeo.height(), oldGeo.height())) { - // Recentre on the same virtual index rather than letting contentX - // drift, exactly like the QML version's onWidthChanged handler -- - // but here it happens in the same pass as the arrangement rebuild, - // so x/width for every slot are recomputed together. rebuildArrangement(); if (m_initializedLayout) m_contentX = contentXForIndex(m_currentVirtualIndex); @@ -190,22 +178,10 @@ void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { } } -// ---------------------------------------------------------------- arrangement math -// Direct port of the QML `arrangement` computed property + keylineCenter/ -// keylineSize/sampleCenter/sampleSize helpers. Kept numerically identical on -// purpose so behaviour doesn't change, just *when* it's applied. - void CarouselView::rebuildArrangement() { Arrangement& a = m_arrangement; a = Arrangement{}; - // IMPORTANT: this must be the external constraint (m_maxWidth), never - // width() -- width() is a *result* of this computation (see below), and - // using it here would make the arrangement depend on its own previous - // output. On first layout, before width() has ever been set, that - // bootstraps to W==0 -> n==0 -> every side item falls back to a flat - // minEdgeWidth with no taper at all, which is what caused items to slide - // off looking "pushed out" instead of shrinking. const qreal W = m_maxWidth; const qreal F = m_focalWidth; const qreal M = m_minEdgeWidth; @@ -239,8 +215,6 @@ void CarouselView::rebuildArrangement() { bestSizes = sizes; bestWidth = requiredWidth; - // Safety valve: arrangement size is bounded by how many halvings fit, - // this just guards against pathological inputs (F <= M etc). if (n > 64) break; } @@ -262,10 +236,6 @@ void CarouselView::rebuildArrangement() { a.effectiveExtraWidth = extra; setImplicitWidth(m_maxWidth); - // This mirrors the original `width: arrangement.width` binding. It's - // safe against feedback because the arrangement above only ever reads - // m_maxWidth, never width() -- so if a.width doesn't change, setWidth() - // here is a no-op and geometryChange() won't recurse. setWidth(a.width); } @@ -331,8 +301,6 @@ qreal CarouselView::sampleSize(qreal childLoc) const { return keylineSize(a) + (keylineSize(b) - keylineSize(a)) * t; } -// ---------------------------------------------------------------- index math - int CarouselView::realIndexOf(int virtualIndex) const { const int count = m_model.size(); if (count == 0) return -1; @@ -373,8 +341,6 @@ void CarouselView::wrapContentIfNeeded() { const qreal newEnd = m_flickAnim->endValue().toReal() - shift; m_flickAnim->setEndValue(newEnd); } - // Also shift every currently-live slot's virtual index bookkeeping so - // it doesn't think it needs to be recycled just because of the wrap. const int indexShift = static_cast(std::round(shift / pitch())); for (Slot& s : m_slots) { if (s.active) s.virtualIndex -= indexShift; @@ -404,8 +370,6 @@ void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) { } } -// ---------------------------------------------------------------- pool / delegates - QQuickItem* CarouselView::acquireItem() { if (!m_delegate) return nullptr; @@ -479,8 +443,6 @@ void CarouselView::positionSlot(const Slot& slot) const { const qreal center = sampleCenter(childLoc); const qreal size = sampleSize(childLoc); - // slot's "column" spans one pitch, centred at width()/2 + center; item is - // centred within that column, matching the QML delegate's x expression. const qreal slotX = childLoc + width() / 2.0 - pitch() / 2.0; const qreal itemX = (center - childLoc) + (pitch() - size) / 2.0 + slotX; @@ -497,8 +459,6 @@ void CarouselView::releaseAllItems() { m_slots.clear(); } -// ---------------------------------------------------------------- layout pass - void CarouselView::relayout() { const int count = m_model.size(); if (count == 0 || width() <= 0 || m_focalWidth <= 0) { @@ -508,20 +468,15 @@ void CarouselView::relayout() { } const int n = m_arrangement.n; - // Window of virtual indices that can ever be visible, plus a small buffer - // so items don't pop in/out right at the edge during a fast flick. const int half = n + 3; const int lo = m_currentVirtualIndex - half; const int hi = m_currentVirtualIndex + half; const int needed = hi - lo + 1; - // Grow the pool if needed (shrinking is unnecessary: idle items are just - // marked inactive/invisible, which is cheap and avoids churn). while (m_slots.size() < needed) m_slots.append(Slot{}); - // Figure out which virtual indices are already backed by a slot. - QHash virtualToSlot; // virtualIndex -> slot list position + QHash virtualToSlot; virtualToSlot.reserve(m_slots.size()); for (int i = 0; i < m_slots.size(); ++i) { if (m_slots[i].active) virtualToSlot.insert(m_slots[i].virtualIndex, i); @@ -533,15 +488,11 @@ void CarouselView::relayout() { auto it = virtualToSlot.find(v); if (it != virtualToSlot.end()) { slotUsedThisPass[it.value()] = true; - // Content (isCurrent / modelData) may still need refreshing if - // currentRealIndex changed since last pass. applyPropertiesToItem( m_slots[it.value()].item, m_slots[it.value()].realIndex); continue; } - // find a free slot: one not used this pass and not already at a - // virtual index inside [lo, hi] (those are all still needed). int freeSlot = -1; for (int i = 0; i < m_slots.size(); ++i) { if (slotUsedThisPass[i]) continue; @@ -552,7 +503,6 @@ void CarouselView::relayout() { break; } if (freeSlot < 0) { - // Shouldn't happen given the pool growth above, but guard anyway. m_slots.append(Slot{}); slotUsedThisPass.append(false); freeSlot = m_slots.size() - 1; @@ -573,8 +523,6 @@ void CarouselView::relayout() { positionSlot(s); } -// ---------------------------------------------------------------- animation - void CarouselView::cancelAnimations() { if (m_glideAnim->state() == QAbstractAnimation::Running) m_glideAnim->stop(); @@ -596,8 +544,6 @@ void CarouselView::snapToNearest() { glideTo(snapTargetX(m_contentX)); } -// ---------------------------------------------------------------- public API - void CarouselView::goToIndex(int realIndex) { const int count = m_model.size(); if (count == 0) return; @@ -659,19 +605,10 @@ void CarouselView::centerInstantlyOnReal(int realIndex) { relayout(); } -// ---------------------------------------------------------------- input - void CarouselView::mousePressEvent(QMouseEvent* event) { - // Deliberately do NOT cancel animations here. A press that turns out to - // be a plain tap (never crosses the drag threshold) should leave any - // in-flight glide/flick completely untouched. We only touch the - // animation once we know it's actually a drag (see mouseMoveEvent). m_dragging = true; m_dragActive = false; m_pressPos = event->position(); - // Capture wherever contentX currently is, even mid-animation -- if this - // does turn into a drag, it should pick up smoothly from the visual - // position, not teleport to some rest position first. m_pressContentX = m_contentX; m_dragTimer.start(); m_lastMoveX = event->position().x(); @@ -686,8 +623,7 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { const qreal dx = event->position().x() - m_pressPos.x(); if (!m_dragActive && std::abs(dx) > kDragThreshold) { m_dragActive = true; - m_pressContentX = - m_contentX; // re-anchor to the live (possibly mid-animation) value + m_pressContentX = m_contentX; cancelAnimations(); grabMouse(); } @@ -703,7 +639,6 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { if (dt > 0) { const qreal instVel = (m_lastMoveX - event->position().x()) / (dt / 1000.0); - // Light smoothing so a single jittery sample doesn't dominate the flick. m_velocity = m_velocity * 0.7 + instVel * 0.3; } m_lastMoveX = event->position().x(); @@ -718,8 +653,6 @@ void CarouselView::mouseReleaseEvent(QMouseEvent* event) { ungrabMouse(); if (!m_dragActive) { - // A plain click/tap with no drag: let it fall through to the - // delegate's own StateLayer/MouseArea via requestActivate(). event->accept(); return; } @@ -732,10 +665,8 @@ void CarouselView::mouseReleaseEvent(QMouseEvent* event) { return; } - // Simple constant-deceleration flick: distance = v^2 / (2*friction). const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); // ms - const qreal distance = - (v * (duration / 1000.0)) / 2.0; // average-velocity approximation + const qreal distance = (v * (duration / 1000.0)) / 2.0; const qreal rawDest = m_contentX + distance; const qreal dest = snapTargetX(rawDest); @@ -764,8 +695,6 @@ void CarouselView::wheelEvent(QWheelEvent* event) { event->accept(); } -// ---------------------------------------------------------------- delegate signal - void CarouselView::handleDelegateActivate() { QQuickItem* item = qobject_cast(sender()); if (!item) return; diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index 23a8e36..2925cf9 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -47,8 +47,6 @@ class CarouselView : public QQuickItem { QEasingCurve::Type glideEasingType READ glideEasingType WRITE setGlideEasingType NOTIFY glideEasingTypeChanged) - // Extra properties (e.g. { visibilities: root.visibilities }) forwarded - // verbatim onto every delegate instance as a property of the same name. Q_PROPERTY( QVariantMap delegateProperties READ delegateProperties WRITE setDelegateProperties NOTIFY delegatePropertiesChanged) @@ -79,7 +77,7 @@ class CarouselView : public QQuickItem { void setMaxWidth(qreal v); int currentIndex() const { return m_currentRealIndex; } - void setCurrentIndex(int idx); // jumps (animated glide) to a real index + void setCurrentIndex(int idx); int glideDuration() const { return m_glideDuration; } void setGlideDuration(int v); @@ -90,13 +88,10 @@ class CarouselView : public QQuickItem { QVariantMap delegateProperties() const { return m_delegateProperties; } void setDelegateProperties(const QVariantMap& v); - // Animated (glides). Q_INVOKABLE void goToIndex(int realIndex); Q_INVOKABLE void incrementCurrentIndex(); Q_INVOKABLE void decrementCurrentIndex(); - // Instant, no animation -- use for initial placement (e.g. restoring the - // saved wallpaper on Component.onCompleted) where a glide would be wrong. Q_INVOKABLE void jumpToIndex(int realIndex); signals: @@ -112,11 +107,7 @@ class CarouselView : public QQuickItem { void glideEasingTypeChanged(); void delegatePropertiesChanged(); - // Emitted when the currently-centred delegate is clicked while already - // centred (i.e. "activate/select this one"). void activated(int realIndex, QVariant modelData); - // Emitted continuously as the centred item changes, for preview-on-scroll - // behaviour (used to drive Wallpapers.preview() from QML). void previewIndexChanged(int realIndex, QVariant modelData); protected: @@ -134,22 +125,20 @@ class CarouselView : public QQuickItem { struct Arrangement { int n = 0; qreal width = 0; - QVector sizes; // sizes[0] = focal, sizes[i] = i-th side item - QVector - centers; // centers[0] = 0, centers[i] = i-th side centre offset + QVector sizes; + QVector centers; qreal effectiveExtraWidth = 0; }; struct Slot { QQuickItem* item = nullptr; - int virtualIndex = - 0; // which "virtual" (unbounded) index this slot currently renders - int realIndex = -1; // resolved model index, -1 if none / hidden + int virtualIndex = 0; + int realIndex = -1; bool active = false; }; void rebuildArrangement(); - void relayout(); // recomputes window + positions every slot (one pass) + void relayout(); void updateSlotContent(Slot& slot, int virtualIndex); void positionSlot(const Slot& slot) const; void applyPropertiesToItem(QQuickItem* item, int realIndex) const; @@ -170,7 +159,7 @@ class CarouselView : public QQuickItem { int realIndexOf(int virtualIndex) const; void updateCurrentIndexFromContentX(bool emitPreview); void wrapContentIfNeeded(); - void centerInstantlyOnReal(int realIndex); // no animation, no glide + void centerInstantlyOnReal(int realIndex); QQuickItem* acquireItem(); void releaseAllItems(); @@ -195,22 +184,20 @@ class CarouselView : public QQuickItem { static constexpr int kLoopMultiplier = 401; int m_centerBlockStart = 0; - QList - m_slots; // pooled delegate slots, unordered; window recomputed each relayout + QList m_slots; int m_glideDuration = 250; QEasingCurve::Type m_glideEasingType = QEasingCurve::OutCubic; QVariantAnimation* m_glideAnim = nullptr; - // Drag / flick state bool m_dragging = false; - bool m_dragActive = false; // exceeded the drag threshold + bool m_dragActive = false; QPointF m_pressPos; qreal m_pressContentX = 0; QElapsedTimer m_dragTimer; qreal m_lastMoveX = 0; qint64 m_lastMoveT = 0; - qreal m_velocity = 0; // px/sec + qreal m_velocity = 0; QVariantAnimation* m_flickAnim = nullptr; bool m_initializedLayout = false; -- 2.47.3 From 4e75f2144613756171f5cc8dbc03c7e2d442a576 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 20:28:21 +0200 Subject: [PATCH 06/10] improve sizing and dynamic width for view --- Modules/Launcher/CarouselView.qml | 8 +- Modules/Launcher/ContentList.qml | 2 +- Plugins/ZShell/Components/carouselview.cpp | 814 +++++++++++++++++---- Plugins/ZShell/Components/carouselview.hpp | 37 +- 4 files changed, 701 insertions(+), 160 deletions(-) diff --git a/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml index ca11095..595dfa2 100755 --- a/Modules/Launcher/CarouselView.qml +++ b/Modules/Launcher/CarouselView.qml @@ -15,8 +15,6 @@ CarouselView { readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) required property var visibilities - // Forwarded verbatim as a property of the same name onto every delegate - // instance (the delegate declares `required property PersistentProperties visibilities`). delegateProperties: ({ visibilities: root.visibilities }) @@ -29,9 +27,9 @@ CarouselView { if (!screen) return 0; const barMargins = Config.bar.border * 2; - let margins = 0; + let margins = Math.round(screen.width / 6); if (visibilities.utilities || visibilities.sidebar) - margins = panels.utilities.implicitWidth; + margins = Config.sidebar.sizes.width; return screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; } minEdgeWidth: 56 @@ -40,7 +38,7 @@ CarouselView { delegate: Component { WallpaperTile { - } // your existing delegate file, unchanged + } } Component.onCompleted: { diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index c9ab7f1..f34d16f 100644 --- a/Modules/Launcher/ContentList.qml +++ b/Modules/Launcher/ContentList.qml @@ -110,9 +110,9 @@ Item { anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top + width: root.implicitWidth sourceComponent: CarouselView { - // content: root.content panels: root.panels search: root.search visibilities: root.visibilities diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index e7a8bda..73ba4f4 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -4,17 +4,19 @@ #include #include #include -#include -#include + #include namespace ZShell::components { namespace { + constexpr qreal kDragThreshold = 4.0; constexpr qreal kFlickFriction = 0.0022; constexpr qreal kMinFlickVelocity = 60.0; constexpr qreal kMaxFlickVelocity = 6000.0; +constexpr int kMaxLayoutCapacity = 64; + } // namespace CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { @@ -24,32 +26,39 @@ CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { m_glideAnim = new QVariantAnimation(this); m_glideAnim->setEasingCurve(m_glideEasingType); + connect( m_glideAnim, &QVariantAnimation::valueChanged, this, [this](const QVariant& v) { m_contentX = v.toReal(); + wrapContentIfNeeded(); updateCurrentIndexFromContentX(true); relayout(); }); + connect(m_glideAnim, &QVariantAnimation::finished, this, [this]() { updateCurrentIndexFromContentX(true); + relayout(); }); m_flickAnim = new QVariantAnimation(this); m_flickAnim->setEasingCurve(QEasingCurve::Linear); + connect( m_flickAnim, &QVariantAnimation::valueChanged, this, [this](const QVariant& v) { m_contentX = v.toReal(); + wrapContentIfNeeded(); updateCurrentIndexFromContentX(true); relayout(); }); + connect(m_flickAnim, &QVariantAnimation::finished, this, [this]() { snapToNearest(); }); @@ -61,104 +70,186 @@ CarouselView::~CarouselView() { void CarouselView::setDelegate(QQmlComponent* c) { if (m_delegate == c) return; + m_delegate = c; + releaseAllItems(); + emit delegateChanged(); + relayout(); } void CarouselView::setModel(const QVariantList& m) { m_model = m; + emit modelChanged(); - int newCount = m_model.size(); + const int newCount = m_model.size(); + if (newCount == 0) { m_currentRealIndex = -1; + releaseAllItems(); rebuildArrangement(); - return; - } - if (!m_initializedLayout) { - m_centerBlockStart = (kLoopMultiplier / 2) * newCount; - m_currentVirtualIndex = m_centerBlockStart; - m_contentX = contentXForIndex(m_currentVirtualIndex); - m_currentRealIndex = realIndexOf(m_currentVirtualIndex); - m_previewRealIndex = m_currentRealIndex; - m_initializedLayout = true; - rebuildArrangement(); - relayout(); + return; } + if (!m_initializedLayout) { + m_centerBlockStart = 0; + m_currentVirtualIndex = 0; + + m_contentX = contentXForIndex(0); + + m_currentRealIndex = realIndexOf(m_currentVirtualIndex); + + m_previewRealIndex = m_currentRealIndex; + + m_initializedLayout = true; + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + + rebuildArrangement(); + relayout(); + + return; + } + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + rebuildArrangement(); + centerInstantlyOnReal(qBound(0, m_currentRealIndex, newCount - 1)); } void CarouselView::setFocalWidth(qreal v) { if (qFuzzyCompare(m_focalWidth, v)) return; + m_focalWidth = v; + emit focalWidthChanged(); + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + rebuildArrangement(); relayout(); } void CarouselView::setMinEdgeWidth(qreal v) { if (qFuzzyCompare(m_minEdgeWidth, v)) return; + m_minEdgeWidth = v; + emit minEdgeWidthChanged(); + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + rebuildArrangement(); relayout(); } void CarouselView::setItemSpacing(qreal v) { if (qFuzzyCompare(m_itemSpacing, v)) return; + m_itemSpacing = v; + emit itemSpacingChanged(); + + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + + setImplicitWidth(targetWidthForMaxWidth(m_maxWidth)); + rebuildArrangement(); relayout(); } void CarouselView::setTileHeight(qreal v) { if (qFuzzyCompare(m_tileHeight, v)) return; + m_tileHeight = v; + emit tileHeightChanged(); + for (const Slot& s : std::as_const(m_slots)) { - if (s.active && s.item) + if (s.active && s.item) { QQmlProperty::write( s.item, QStringLiteral("tileHeight"), m_tileHeight); + } } } void CarouselView::setMaxWidth(qreal v) { if (qFuzzyCompare(m_maxWidth, v)) return; + m_maxWidth = v; + emit maxWidthChanged(); + + /* + * Advertise the target width immediately. + * + * The parent QML object can then animate its own implicitWidth + * immediately instead of waiting for the delegate geometry. + */ + const qreal targetWidth = targetWidthForMaxWidth(m_maxWidth); + + setImplicitWidth(targetWidth); + + /* + * Grow the instantiated delegate pool if the new maximum needs + * more items. Never throw them away during a resize. + */ + m_layoutCapacity = + qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); + rebuildArrangement(); relayout(); } void CarouselView::setGlideDuration(int v) { if (m_glideDuration == v) return; + m_glideDuration = v; + emit glideDurationChanged(); } void CarouselView::setGlideEasingType(QEasingCurve::Type t) { if (m_glideEasingType == t) return; + m_glideEasingType = t; + m_glideAnim->setEasingCurve(t); + emit glideEasingTypeChanged(); } void CarouselView::setDelegateProperties(const QVariantMap& v) { m_delegateProperties = v; + emit delegatePropertiesChanged(); + for (const Slot& s : std::as_const(m_slots)) { - if (s.active && s.item) { - for (auto it = m_delegateProperties.constBegin(); - it != m_delegateProperties.constEnd(); - ++it) - QQmlProperty::write(s.item, it.key(), it.value()); + if (!s.active || !s.item) continue; + + for (auto it = m_delegateProperties.constBegin(); + it != m_delegateProperties.constEnd(); + ++it) { + QQmlProperty::write(s.item, it.key(), it.value()); } } } @@ -169,141 +260,319 @@ void CarouselView::setCurrentIndex(int idx) { void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { QQuickItem::geometryChange(newGeo, oldGeo); - if (!qFuzzyCompare(newGeo.width(), oldGeo.width()) || - !qFuzzyCompare(newGeo.height(), oldGeo.height())) { - rebuildArrangement(); - if (m_initializedLayout) - m_contentX = contentXForIndex(m_currentVirtualIndex); - relayout(); + + const qreal oldWidth = oldGeo.width(); + + const qreal newWidth = newGeo.width(); + + if (qFuzzyCompare(oldWidth, newWidth)) { + return; } + + /* + * The carousel is horizontally centered. + * + * Changing its width therefore moves the local coordinate system + * by half the width delta. Keep the logical content stationary by + * moving contentX by the opposite amount. + * + * Crucially, do this directly instead of recomputing a logical + * index and converting it back. That avoids quantization and + * floating-point drift on every animation frame. + */ + const qreal delta = (newWidth - oldWidth) / 2.0; + + m_contentX -= delta; + + /* + * A running glide/flick animation is expressed in contentX + * coordinates too. Shift both endpoints by exactly the same + * amount so the animation continues seamlessly in the new + * geometry. + */ + auto shiftAnimation = [delta](QVariantAnimation* anim) { + if (anim->state() != QAbstractAnimation::Running) { + return; + } + + anim->setStartValue(anim->startValue().toReal() - delta); + + anim->setEndValue(anim->endValue().toReal() - delta); + }; + + shiftAnimation(m_glideAnim); + shiftAnimation(m_flickAnim); + + rebuildArrangement(); + relayout(); +} + +CarouselView::Arrangement CarouselView::arrangementForCount(int n) const { + Arrangement a; + + const qreal F = m_focalWidth; + + const qreal M = m_minEdgeWidth; + + const qreal sp = m_itemSpacing; + + if (F <= 0) { + a.n = 0; + a.width = 0; + a.outerSpacing = 0; + a.sizes = {F}; + a.centers = {0}; + + return a; + } + + if (n <= 0) { + a.n = 0; + a.width = F; + a.outerSpacing = 0; + + a.sizes = {F}; + a.centers = {0}; + + return a; + } + + a.n = n; + a.outerSpacing = sp; + + a.sizes.reserve(n + 1); + a.centers.reserve(n + 1); + + a.sizes.append(F); + + const qreal ratio = std::pow(M / F, 1.0 / n); + + qreal sideWidth = 0; + + for (int i = 1; i <= n; ++i) { + const qreal size = (i == n) ? M : F * std::pow(ratio, i); + + a.sizes.append(size); + sideWidth += size; + } + + /* + * This is exactly the original full arrangement width. + */ + a.width = F + 2.0 * n * sp + 2.0 * sideWidth; + + a.centers.append(0); + + qreal center = 0; + + for (int i = 1; i <= n; ++i) { + center += a.sizes[i - 1] / 2.0 + sp + a.sizes[i] / 2.0; + + a.centers.append(center); + } + + a.effectiveExtraWidth = 0; + + for (int i = 1; i <= n; ++i) { + a.effectiveExtraWidth += pitch() - a.sizes[i]; + } + + return a; +} + +int CarouselView::layoutCapacityForWidth(qreal width) const { + if (width <= 0 || m_focalWidth <= 0) { + return 0; + } + + int bestN = 0; + + for (int n = 1; n <= kMaxLayoutCapacity; ++n) { + const Arrangement a = arrangementForCount(n); + + if (a.width > width) break; + + bestN = n; + } + + return bestN; +} + +qreal CarouselView::targetWidthForMaxWidth(qreal maxWidth) const { + if (maxWidth <= 0 || m_focalWidth <= 0) { + return 0; + } + + /* + * This deliberately preserves the old behavior: + * + * choose the largest complete arrangement whose outermost + * delegate can remain at minEdgeWidth. + * + * Therefore the component's desired width can be smaller than + * maxWidth. + */ + const int n = layoutCapacityForWidth(maxWidth); + + return arrangementForCount(n).width; } void CarouselView::rebuildArrangement() { Arrangement& a = m_arrangement; + a = Arrangement{}; - const qreal W = m_maxWidth; - const qreal F = m_focalWidth; - const qreal M = m_minEdgeWidth; - const qreal sp = m_itemSpacing; + const qreal W = width() > 0 ? width() : targetWidthForMaxWidth(m_maxWidth); + + if (W <= 0 || m_focalWidth <= 0) { + a = arrangementForCount(0); + a.width = W; - if (W <= 0 || F <= 0) { - a.sizes = {F}; - a.centers = {0}; return; } - int bestN = 0; - QVector bestSizes = {F}; - qreal bestWidth = F; + if (m_layoutCapacity <= 0) { + m_layoutCapacity = layoutCapacityForWidth(m_maxWidth); + } - for (int n = 1;; ++n) { - const qreal ratio = std::pow(M / F, 1.0 / n); - QVector sizes = {F}; - qreal sideWidth = 0; + if (m_layoutCapacity <= 0) { + a = arrangementForCount(0); + a.width = W; - for (int i = 1; i <= n; ++i) { - const qreal size = (i == n) ? M : F * std::pow(ratio, i); - sizes.push_back(size); - sideWidth += size; + return; + } + + const Arrangement full = arrangementForCount(m_layoutCapacity); + + /* + * If the current width can display the complete capacity + * arrangement, use that exact geometry. + */ + if (W >= full.width) { + a = full; + return; + } + + /* + * Otherwise find the complete arrangement immediately above W. + * + * Example: + * + * width(8) >= W >= width(7) + * + * means we're in the 8 -> 7 transition. + */ + int highN = 1; + + for (int n = 1; n <= m_layoutCapacity; ++n) { + const Arrangement candidate = arrangementForCount(n); + + if (W < candidate.width) { + highN = n; + break; } - - const qreal requiredWidth = F + 2 * n * sp + 2 * sideWidth; - if (requiredWidth > W) break; - - bestN = n; - bestSizes = sizes; - bestWidth = requiredWidth; - - if (n > 64) break; } - a.n = bestN; - a.sizes = bestSizes; - a.width = bestWidth; + const int lowN = qMax(0, highN - 1); + + const Arrangement high = arrangementForCount(highN); + + const Arrangement low = arrangementForCount(lowN); + + if (high.width <= low.width) { + a = low; + return; + } + + qreal t = (high.width - W) / (high.width - low.width); + + t = qBound(0.0, t, 1.0); + + /* + * Keep the outgoing slot instantiated and visible while it + * smoothly transitions to zero. + */ + a.n = highN; + a.width = W; + + a.sizes.resize(highN + 1); + + a.sizes[0] = m_focalWidth; + + for (int i = 1; i < highN; ++i) { + a.sizes[i] = high.sizes[i] + (low.sizes[i] - high.sizes[i]) * t; + } + + a.sizes[highN] = high.sizes[highN] * (1.0 - t); + + /* + * All normal gaps stay constant. + * + * Only the gap immediately before the disappearing delegate + * collapses together with that delegate. + */ + a.outerSpacing = m_itemSpacing * (1.0 - t); + + a.centers.append(0); - QVector centers = {0}; qreal center = 0; - for (int i = 1; i <= bestN; ++i) { - center += bestSizes[i - 1] / 2.0 + sp + bestSizes[i] / 2.0; - centers.push_back(center); - } - a.centers = centers; - qreal extra = 0; - for (int i = 1; i <= bestN; ++i) - extra += pitch() - bestSizes[i]; - a.effectiveExtraWidth = extra; + for (int i = 1; i <= highN; ++i) { + const qreal gap = i == highN ? a.outerSpacing : m_itemSpacing; - setImplicitWidth(m_maxWidth); - setWidth(a.width); -} + center += a.sizes[i - 1] / 2.0 + gap + a.sizes[i] / 2.0; -qreal CarouselView::keylineCenter(int io) const { - const int n = m_arrangement.n; - const int i = std::abs(io); - if (i == 0) return 0; - const int sign = io < 0 ? -1 : 1; - - if (i <= n) return sign * m_arrangement.centers[i]; - - if (i == n + 1) { - const qreal lastCenter = m_arrangement.centers[n]; - const qreal lastSize = m_arrangement.sizes[n]; - return sign * (lastCenter + lastSize / 2.0 + m_itemSpacing + - m_minEdgeWidth / 2.0); + a.centers.append(center); } - const qreal outgoingCenter = std::abs(keylineCenter(sign * (n + 1))); - return sign * (outgoingCenter + m_minEdgeWidth / 2.0 + m_itemSpacing); + a.effectiveExtraWidth = 0; + + for (int i = 1; i <= highN; ++i) { + a.effectiveExtraWidth += pitch() - a.sizes[i]; + } } qreal CarouselView::keylineSize(int io) const { const int n = m_arrangement.n; + const int i = std::abs(io); + if (i == 0) return m_focalWidth; + if (i <= n) return m_arrangement.sizes[i]; - if (i == n + 1) return m_minEdgeWidth; + return 0; } -qreal CarouselView::sampleCenter(qreal childLoc) const { - const int n = m_arrangement.n; - if (n <= 0) return 0; - - const int minIo = -(n + 2); - const int maxIo = n + 2; - const qreal p = pitch(); - - const int a = static_cast(std::floor(childLoc / p)); - if (a < minIo) return keylineCenter(minIo); - if (a >= maxIo) return keylineCenter(maxIo); - - const int b = a + 1; - const qreal t = (childLoc - a * p) / p; - return keylineCenter(a) + (keylineCenter(b) - keylineCenter(a)) * t; -} - qreal CarouselView::sampleSize(qreal childLoc) const { const int n = m_arrangement.n; + if (n <= 0) return 0; const int minIo = -(n + 2); + const int maxIo = n + 2; + const qreal p = pitch(); const int a = static_cast(std::floor(childLoc / p)); + if (a < minIo) return keylineSize(minIo); + if (a >= maxIo) return keylineSize(maxIo); const int b = a + 1; + const qreal t = (childLoc - a * p) / p; + return keylineSize(a) + (keylineSize(b) - keylineSize(a)) * t; } int CarouselView::realIndexOf(int virtualIndex) const { const int count = m_model.size(); + if (count == 0) return -1; + return ((virtualIndex % count) + count) % count; } @@ -322,51 +591,71 @@ qreal CarouselView::snapTargetX(qreal x) const { void CarouselView::wrapContentIfNeeded() { const int count = m_model.size(); + if (count == 0) return; const qreal blockWidth = count * pitch(); + const qreal centerX = contentXForIndex(m_centerBlockStart); + const int blocksFromCenter = static_cast(std::round((m_contentX - centerX) / blockWidth)); - if (std::abs(blocksFromCenter) >= 2) { - const qreal shift = blocksFromCenter * blockWidth; - m_contentX -= shift; + if (std::abs(blocksFromCenter) < 2) return; - if (m_glideAnim->state() == QAbstractAnimation::Running) { - const qreal newEnd = m_glideAnim->endValue().toReal() - shift; - m_glideAnim->setEndValue(newEnd); - } - if (m_flickAnim->state() == QAbstractAnimation::Running) { - const qreal newEnd = m_flickAnim->endValue().toReal() - shift; - m_flickAnim->setEndValue(newEnd); - } - const int indexShift = static_cast(std::round(shift / pitch())); - for (Slot& s : m_slots) { - if (s.active) s.virtualIndex -= indexShift; + const int indexShift = blocksFromCenter * count; + + const qreal shift = indexShift * pitch(); + + m_contentX -= shift; + m_currentVirtualIndex -= indexShift; + + auto shiftAnimation = [shift](QVariantAnimation* anim) { + if (anim->state() != QAbstractAnimation::Running) { + return; } + + anim->setStartValue(anim->startValue().toReal() - shift); + + anim->setEndValue(anim->endValue().toReal() - shift); + }; + + shiftAnimation(m_glideAnim); + shiftAnimation(m_flickAnim); + + for (Slot& s : m_slots) { + if (s.active) s.virtualIndex -= indexShift; } } void CarouselView::updateCurrentIndexFromContentX(bool emitPreview) { const int count = m_model.size(); + if (count == 0) return; const int newVirtual = static_cast( std::round((m_contentX + width() / 2.0 - pitch() / 2.0) / pitch())); - if (newVirtual == m_currentVirtualIndex) return; + + if (newVirtual == m_currentVirtualIndex) { + return; + } m_currentVirtualIndex = newVirtual; + const int newReal = realIndexOf(newVirtual); if (newReal != m_currentRealIndex) { m_currentRealIndex = newReal; + emit currentIndexChanged(); } + if (emitPreview && newReal != m_previewRealIndex) { m_previewRealIndex = newReal; - if (newReal >= 0) + + if (newReal >= 0) { emit previewIndexChanged(newReal, m_model.at(newReal)); + } } } @@ -374,38 +663,54 @@ QQuickItem* CarouselView::acquireItem() { if (!m_delegate) return nullptr; QQmlContext* ctx = QQmlEngine::contextForObject(this); + QVariantMap initial; + initial.insert(QStringLiteral("isCurrent"), false); + initial.insert(QStringLiteral("modelData"), QVariant()); + initial.insert(QStringLiteral("tileHeight"), m_tileHeight); + initial.insert(QStringLiteral("tileWidth"), m_focalWidth); + for (auto it = m_delegateProperties.constBegin(); it != m_delegateProperties.constEnd(); - ++it) + ++it) { initial.insert(it.key(), it.value()); + } QObject* obj = m_delegate->createWithInitialProperties(initial, ctx); + if (!obj) { qWarning( "CarouselView: failed to create delegate instance: %s", qPrintable(m_delegate->errorString())); + return nullptr; } + QQuickItem* item = qobject_cast(obj); + if (!item) { qWarning("CarouselView: delegate root is not an Item"); + delete obj; + return nullptr; } item->setParentItem(this); + QQmlEngine::setObjectOwnership(item, QQmlEngine::CppOwnership); const QMetaMethod activateSignal = item->metaObject()->method( item->metaObject()->indexOfSignal("requestActivate()")); + if (activateSignal.isValid()) { static const QMetaMethod slot = metaObject()->method( metaObject()->indexOfSlot("handleDelegateActivate()")); + connect(item, activateSignal, this, slot); } @@ -414,39 +719,172 @@ QQuickItem* CarouselView::acquireItem() { void CarouselView::applyPropertiesToItem(QQuickItem* item, int realIndex) const { if (!item) return; - const bool isCurrent = (realIndex == m_currentRealIndex); + + const bool isCurrent = realIndex == m_currentRealIndex; + QQmlProperty::write(item, QStringLiteral("isCurrent"), isCurrent); + QQmlProperty::write( item, QStringLiteral("modelData"), realIndex >= 0 && realIndex < m_model.size() ? m_model.at(realIndex) : QVariant()); + QQmlProperty::write(item, QStringLiteral("tileHeight"), m_tileHeight); } void CarouselView::updateSlotContent(Slot& slot, int virtualIndex) { if (!slot.item) slot.item = acquireItem(); + if (!slot.item) return; slot.virtualIndex = virtualIndex; + slot.realIndex = realIndexOf(virtualIndex); + slot.active = slot.realIndex >= 0; + slot.item->setVisible(slot.active); - if (slot.active) applyPropertiesToItem(slot.item, slot.realIndex); + + if (slot.active) { + applyPropertiesToItem(slot.item, slot.realIndex); + } +} + +qreal CarouselView::continuousIndex() const { + return (m_contentX + width() / 2.0 - pitch() / 2.0) / pitch(); } void CarouselView::positionSlot(const Slot& slot) const { if (!slot.item || !slot.active) return; - const qreal childLoc = slot.virtualIndex * pitch() + pitch() / 2.0 - - m_contentX - width() / 2.0; - const qreal center = sampleCenter(childLoc); + const qreal current = continuousIndex(); + + const int leftIndex = static_cast(std::floor(current)); + + const qreal fraction = current - leftIndex; + + const int v = slot.virtualIndex; + + const qreal childLoc = (qreal(v) - current) * pitch(); + const qreal size = sampleSize(childLoc); - const qreal slotX = childLoc + width() / 2.0 - pitch() / 2.0; - const qreal itemX = (center - childLoc) + (pitch() - size) / 2.0 + slotX; + if (size <= 0.5) { + slot.item->setVisible(false); + return; + } + + const int n = m_arrangement.n; + + const auto rightGap = [this, n](int rightIndex) { + return rightIndex == n ? m_arrangement.outerSpacing : m_itemSpacing; + }; + + const auto leftGap = [this, n](int leftIndex) { + return leftIndex == -n ? m_arrangement.outerSpacing : m_itemSpacing; + }; + + qreal center = 0; + + if (std::abs(fraction) < 1e-6) { + if (v == leftIndex) { + center = 0; + } else if (v < leftIndex) { + qreal x = 0; + + qreal previousSize = sampleSize(0); + + for (int i = leftIndex - 1; i >= v; --i) { + const qreal s = sampleSize((qreal(i) - current) * pitch()); + + const qreal gap = leftGap(i); + + x -= previousSize / 2.0 + gap + s / 2.0; + + previousSize = s; + } + + center = x; + } else { + qreal x = 0; + + qreal previousSize = sampleSize(0); + + for (int i = leftIndex + 1; i <= v; ++i) { + const qreal s = sampleSize((qreal(i) - current) * pitch()); + + const qreal gap = rightGap(i); + + x += previousSize / 2.0 + gap + s / 2.0; + + previousSize = s; + } + + center = x; + } + } else { + const qreal leftSize = + sampleSize((qreal(leftIndex) - current) * pitch()); + + const qreal rightSize = + sampleSize((qreal(leftIndex + 1) - current) * pitch()); + + qreal centerGap = m_itemSpacing; + + if (leftIndex == -n || leftIndex + 1 == n) { + centerGap = m_arrangement.outerSpacing; + } + + const qreal distance = leftSize / 2.0 + centerGap + rightSize / 2.0; + + const qreal leftCenter = -fraction * distance; + + const qreal rightCenter = (1.0 - fraction) * distance; + + if (v == leftIndex) { + center = leftCenter; + } else if (v == leftIndex + 1) { + center = rightCenter; + } else if (v < leftIndex) { + qreal x = leftCenter; + + qreal previousSize = leftSize; + + for (int i = leftIndex - 1; i >= v; --i) { + const qreal s = sampleSize((qreal(i) - current) * pitch()); + + const qreal gap = leftGap(i); + + x -= previousSize / 2.0 + gap + s / 2.0; + + previousSize = s; + } + + center = x; + } else { + qreal x = rightCenter; + + qreal previousSize = rightSize; + + for (int i = leftIndex + 2; i <= v; ++i) { + const qreal s = sampleSize((qreal(i) - current) * pitch()); + + const qreal gap = rightGap(i); + + x += previousSize / 2.0 + gap + s / 2.0; + + previousSize = s; + } + + center = x; + } + } + + const qreal itemX = width() / 2.0 + center - size / 2.0; QQmlProperty::write(slot.item, QStringLiteral("tileWidth"), size); + slot.item->setX(itemX); slot.item->setY(0); slot.item->setVisible(size > 0.5); @@ -456,59 +894,83 @@ void CarouselView::releaseAllItems() { for (Slot& s : m_slots) { if (s.item) s.item->deleteLater(); } + m_slots.clear(); } void CarouselView::relayout() { const int count = m_model.size(); + if (count == 0 || width() <= 0 || m_focalWidth <= 0) { - for (Slot& s : m_slots) + for (Slot& s : m_slots) { if (s.item) s.item->setVisible(false); + } + return; } - const int n = m_arrangement.n; - const int half = n + 3; + /* + * Keep enough slots for the largest layout we've needed. + * The currently visible count is determined by m_arrangement. + */ + const int half = m_layoutCapacity + 2; + const int lo = m_currentVirtualIndex - half; + const int hi = m_currentVirtualIndex + half; + const int needed = hi - lo + 1; while (m_slots.size() < needed) m_slots.append(Slot{}); QHash virtualToSlot; + virtualToSlot.reserve(m_slots.size()); + for (int i = 0; i < m_slots.size(); ++i) { - if (m_slots[i].active) virtualToSlot.insert(m_slots[i].virtualIndex, i); + if (m_slots[i].active) { + virtualToSlot.insert(m_slots[i].virtualIndex, i); + } } QVector slotUsedThisPass(m_slots.size(), false); for (int v = lo; v <= hi; ++v) { auto it = virtualToSlot.find(v); + if (it != virtualToSlot.end()) { slotUsedThisPass[it.value()] = true; + applyPropertiesToItem( m_slots[it.value()].item, m_slots[it.value()].realIndex); + continue; } int freeSlot = -1; + for (int i = 0; i < m_slots.size(); ++i) { if (slotUsedThisPass[i]) continue; + if (m_slots[i].active && m_slots[i].virtualIndex >= lo && - m_slots[i].virtualIndex <= hi) + m_slots[i].virtualIndex <= hi) { continue; + } + freeSlot = i; break; } + if (freeSlot < 0) { m_slots.append(Slot{}); slotUsedThisPass.append(false); + freeSlot = m_slots.size() - 1; } updateSlotContent(m_slots[freeSlot], v); + slotUsedThisPass[freeSlot] = true; } @@ -519,39 +981,53 @@ void CarouselView::relayout() { } } - for (const Slot& s : std::as_const(m_slots)) + for (const Slot& s : std::as_const(m_slots)) { positionSlot(s); + } } void CarouselView::cancelAnimations() { - if (m_glideAnim->state() == QAbstractAnimation::Running) + if (m_glideAnim->state() == QAbstractAnimation::Running) { m_glideAnim->stop(); - if (m_flickAnim->state() == QAbstractAnimation::Running) + } + + if (m_flickAnim->state() == QAbstractAnimation::Running) { m_flickAnim->stop(); + } } void CarouselView::glideTo(qreal dest) { if (std::abs(dest - m_contentX) < 0.5) return; cancelAnimations(); + m_glideAnim->setDuration(m_glideDuration); + m_glideAnim->setStartValue(m_contentX); + m_glideAnim->setEndValue(dest); + m_glideAnim->start(); } void CarouselView::snapToNearest() { - glideTo(snapTargetX(m_contentX)); + if (m_model.isEmpty()) return; + + glideTo(contentXForIndex(m_currentVirtualIndex)); } void CarouselView::goToIndex(int realIndex) { const int count = m_model.size(); + if (count == 0) return; + realIndex = qBound(0, realIndex, count - 1); const int curBlock = static_cast(std::floor(qreal(m_currentVirtualIndex) / count)); + const int current = m_currentVirtualIndex; + int target = curBlock * count + realIndex; if (target - current > count / 2) @@ -564,11 +1040,13 @@ void CarouselView::goToIndex(int realIndex) { void CarouselView::incrementCurrentIndex() { if (m_model.isEmpty()) return; + glideTo(contentXForIndex(m_currentVirtualIndex + 1)); } void CarouselView::decrementCurrentIndex() { if (m_model.isEmpty()) return; + glideTo(contentXForIndex(m_currentVirtualIndex - 1)); } @@ -578,14 +1056,18 @@ void CarouselView::jumpToIndex(int realIndex) { void CarouselView::centerInstantlyOnReal(int realIndex) { const int count = m_model.size(); + if (count == 0) return; + realIndex = qBound(0, realIndex, count - 1); cancelAnimations(); const int curBlock = static_cast(std::floor(qreal(m_currentVirtualIndex) / count)); + const int current = m_currentVirtualIndex; + int target = curBlock * count + realIndex; if (target - current > count / 2) @@ -594,12 +1076,17 @@ void CarouselView::centerInstantlyOnReal(int realIndex) { target += count; m_currentVirtualIndex = target; + m_contentX = contentXForIndex(target); - const bool changed = (realIndex != m_currentRealIndex); + const bool changed = realIndex != m_currentRealIndex; + m_currentRealIndex = realIndex; + m_previewRealIndex = realIndex; + if (changed) emit currentIndexChanged(); + emit previewIndexChanged(realIndex, m_model.at(realIndex)); relayout(); @@ -608,12 +1095,18 @@ void CarouselView::centerInstantlyOnReal(int realIndex) { void CarouselView::mousePressEvent(QMouseEvent* event) { m_dragging = true; m_dragActive = false; + m_pressPos = event->position(); + m_pressContentX = m_contentX; + m_dragTimer.start(); + m_lastMoveX = event->position().x(); + m_lastMoveT = 0; m_velocity = 0; + event->accept(); } @@ -621,27 +1114,36 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { if (!m_dragging) return; const qreal dx = event->position().x() - m_pressPos.x(); + if (!m_dragActive && std::abs(dx) > kDragThreshold) { m_dragActive = true; m_pressContentX = m_contentX; + cancelAnimations(); grabMouse(); } + if (!m_dragActive) return; m_contentX = m_pressContentX - dx; + wrapContentIfNeeded(); updateCurrentIndexFromContentX(true); relayout(); const qint64 t = m_dragTimer.elapsed(); + const qint64 dt = t - m_lastMoveT; + if (dt > 0) { const qreal instVel = (m_lastMoveX - event->position().x()) / (dt / 1000.0); + m_velocity = m_velocity * 0.7 + instVel * 0.3; } + m_lastMoveX = event->position().x(); + m_lastMoveT = t; event->accept(); @@ -649,33 +1151,49 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { void CarouselView::mouseReleaseEvent(QMouseEvent* event) { if (!m_dragging) return; + m_dragging = false; + ungrabMouse(); if (!m_dragActive) { + cancelAnimations(); + snapToNearest(); + event->accept(); return; } + m_dragActive = false; qreal v = qBound(-kMaxFlickVelocity, m_velocity, kMaxFlickVelocity); + if (std::abs(v) < kMinFlickVelocity) { snapToNearest(); + event->accept(); return; } - const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); // ms + const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); + const qreal distance = (v * (duration / 1000.0)) / 2.0; + const qreal rawDest = m_contentX + distance; - const qreal dest = snapTargetX(rawDest); + + const qreal dest = contentXForIndex(indexForContentX(rawDest)); cancelAnimations(); + m_flickAnim->setDuration( static_cast(qBound(120, duration, 900))); + m_flickAnim->setEasingCurve(QEasingCurve::OutCubic); + m_flickAnim->setStartValue(m_contentX); + m_flickAnim->setEndValue(dest); + m_flickAnim->start(); event->accept(); @@ -688,26 +1206,32 @@ void CarouselView::mouseUngrabEvent() { void CarouselView::wheelEvent(QWheelEvent* event) { const int steps = event->angleDelta().y() / 120; + if (steps > 0) decrementCurrentIndex(); else if (steps < 0) incrementCurrentIndex(); + event->accept(); } void CarouselView::handleDelegateActivate() { QQuickItem* item = qobject_cast(sender()); + if (!item) return; for (const Slot& s : std::as_const(m_slots)) { - if (s.item == item && s.active) { - if (s.realIndex == m_currentRealIndex) { - emit activated(s.realIndex, m_model.at(s.realIndex)); - } else { - goToIndex(s.realIndex); - } - return; + if (s.item != item || !s.active) { + continue; } + + if (s.realIndex == m_currentRealIndex) { + emit activated(s.realIndex, m_model.at(s.realIndex)); + } else { + goToIndex(s.realIndex); + } + + return; } } diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index 2925cf9..a5b2ef9 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -1,15 +1,15 @@ #pragma once +#include +#include +#include +#include #include #include #include -#include #include -#include -#include -#include +#include #include -#include namespace ZShell::components { @@ -112,6 +112,7 @@ class CarouselView : public QQuickItem { protected: void geometryChange(const QRectF& newGeo, const QRectF& oldGeo) override; + void mousePressEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; @@ -125,8 +126,11 @@ class CarouselView : public QQuickItem { struct Arrangement { int n = 0; qreal width = 0; + qreal outerSpacing = 0; + QVector sizes; QVector centers; + qreal effectiveExtraWidth = 0; }; @@ -139,25 +143,36 @@ class CarouselView : public QQuickItem { void rebuildArrangement(); void relayout(); + + Arrangement arrangementForCount(int n) const; + + int layoutCapacityForWidth(qreal width) const; + qreal targetWidthForMaxWidth(qreal maxWidth) const; + void updateSlotContent(Slot& slot, int virtualIndex); + void positionSlot(const Slot& slot) const; + void applyPropertiesToItem(QQuickItem* item, int realIndex) const; qreal pitch() const { return m_focalWidth + m_itemSpacing; } + qreal contentXForIndex(qreal index) const; int indexForContentX(qreal x) const; qreal snapTargetX(qreal x) const; + void snapToNearest(); void glideTo(qreal dest); void cancelAnimations(); - qreal keylineCenter(int io) const; qreal keylineSize(int io) const; - qreal sampleCenter(qreal childLoc) const; qreal sampleSize(qreal childLoc) const; + qreal continuousIndex() const; int realIndexOf(int virtualIndex) const; + void updateCurrentIndexFromContentX(bool emitPreview); + void wrapContentIfNeeded(); void centerInstantlyOnReal(int realIndex); @@ -176,12 +191,13 @@ class CarouselView : public QQuickItem { Arrangement m_arrangement; + int m_layoutCapacity = 0; + qreal m_contentX = 0; int m_currentVirtualIndex = 0; int m_currentRealIndex = -1; int m_previewRealIndex = -1; - static constexpr int kLoopMultiplier = 401; int m_centerBlockStart = 0; QList m_slots; @@ -192,16 +208,19 @@ class CarouselView : public QQuickItem { bool m_dragging = false; bool m_dragActive = false; + QPointF m_pressPos; qreal m_pressContentX = 0; + QElapsedTimer m_dragTimer; qreal m_lastMoveX = 0; qint64 m_lastMoveT = 0; qreal m_velocity = 0; + QVariantAnimation* m_flickAnim = nullptr; bool m_initializedLayout = false; bool m_relayoutQueued = false; }; -}; // namespace ZShell::components +} // namespace ZShell::components -- 2.47.3 From 43f070f887e9339b35a6c62cfc3a2c96eadb366d Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 20:29:48 +0200 Subject: [PATCH 07/10] clean-up plugin files --- Plugins/ZShell/Components/carouselview.cpp | 66 ---------------------- 1 file changed, 66 deletions(-) diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index 73ba4f4..027b0aa 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -199,20 +199,10 @@ void CarouselView::setMaxWidth(qreal v) { emit maxWidthChanged(); - /* - * Advertise the target width immediately. - * - * The parent QML object can then animate its own implicitWidth - * immediately instead of waiting for the delegate geometry. - */ const qreal targetWidth = targetWidthForMaxWidth(m_maxWidth); setImplicitWidth(targetWidth); - /* - * Grow the instantiated delegate pool if the new maximum needs - * more items. Never throw them away during a resize. - */ m_layoutCapacity = qMax(m_layoutCapacity, layoutCapacityForWidth(m_maxWidth)); @@ -269,27 +259,10 @@ void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { return; } - /* - * The carousel is horizontally centered. - * - * Changing its width therefore moves the local coordinate system - * by half the width delta. Keep the logical content stationary by - * moving contentX by the opposite amount. - * - * Crucially, do this directly instead of recomputing a logical - * index and converting it back. That avoids quantization and - * floating-point drift on every animation frame. - */ const qreal delta = (newWidth - oldWidth) / 2.0; m_contentX -= delta; - /* - * A running glide/flick animation is expressed in contentX - * coordinates too. Shift both endpoints by exactly the same - * amount so the animation continues seamlessly in the new - * geometry. - */ auto shiftAnimation = [delta](QVariantAnimation* anim) { if (anim->state() != QAbstractAnimation::Running) { return; @@ -356,9 +329,6 @@ CarouselView::Arrangement CarouselView::arrangementForCount(int n) const { sideWidth += size; } - /* - * This is exactly the original full arrangement width. - */ a.width = F + 2.0 * n * sp + 2.0 * sideWidth; a.centers.append(0); @@ -403,15 +373,6 @@ qreal CarouselView::targetWidthForMaxWidth(qreal maxWidth) const { return 0; } - /* - * This deliberately preserves the old behavior: - * - * choose the largest complete arrangement whose outermost - * delegate can remain at minEdgeWidth. - * - * Therefore the component's desired width can be smaller than - * maxWidth. - */ const int n = layoutCapacityForWidth(maxWidth); return arrangementForCount(n).width; @@ -444,24 +405,11 @@ void CarouselView::rebuildArrangement() { const Arrangement full = arrangementForCount(m_layoutCapacity); - /* - * If the current width can display the complete capacity - * arrangement, use that exact geometry. - */ if (W >= full.width) { a = full; return; } - /* - * Otherwise find the complete arrangement immediately above W. - * - * Example: - * - * width(8) >= W >= width(7) - * - * means we're in the 8 -> 7 transition. - */ int highN = 1; for (int n = 1; n <= m_layoutCapacity; ++n) { @@ -488,10 +436,6 @@ void CarouselView::rebuildArrangement() { t = qBound(0.0, t, 1.0); - /* - * Keep the outgoing slot instantiated and visible while it - * smoothly transitions to zero. - */ a.n = highN; a.width = W; @@ -505,12 +449,6 @@ void CarouselView::rebuildArrangement() { a.sizes[highN] = high.sizes[highN] * (1.0 - t); - /* - * All normal gaps stay constant. - * - * Only the gap immediately before the disappearing delegate - * collapses together with that delegate. - */ a.outerSpacing = m_itemSpacing * (1.0 - t); a.centers.append(0); @@ -909,10 +847,6 @@ void CarouselView::relayout() { return; } - /* - * Keep enough slots for the largest layout we've needed. - * The currently visible count is determined by m_arrangement. - */ const int half = m_layoutCapacity + 2; const int lo = m_currentVirtualIndex - half; -- 2.47.3 From 43647dd4863af5de1225cd10bdd7c73e44510eab Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 20:45:55 +0200 Subject: [PATCH 08/10] fix currentItem property and signals --- Plugins/ZShell/Components/carouselview.cpp | 21 +++++++++++++++++++++ Plugins/ZShell/Components/carouselview.hpp | 11 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index 027b0aa..9428c1a 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -89,6 +89,8 @@ void CarouselView::setModel(const QVariantList& m) { if (newCount == 0) { m_currentRealIndex = -1; + m_currentItem = nullptr; + emit currentItemChanged(); releaseAllItems(); rebuildArrangement(); @@ -248,6 +250,22 @@ void CarouselView::setCurrentIndex(int idx) { goToIndex(idx); } +void CarouselView::updateCurrentItem() { + QQuickItem* item = nullptr; + + for (const Slot& s : std::as_const(m_slots)) { + if (s.active && s.virtualIndex == m_currentVirtualIndex) { + item = s.item; + break; + } + } + + if (item == m_currentItem) return; + + m_currentItem = item; + emit currentItemChanged(); +} + void CarouselView::geometryChange(const QRectF& newGeo, const QRectF& oldGeo) { QQuickItem::geometryChange(newGeo, oldGeo); @@ -844,6 +862,7 @@ void CarouselView::relayout() { if (s.item) s.item->setVisible(false); } + updateCurrentItem(); return; } @@ -918,6 +937,8 @@ void CarouselView::relayout() { for (const Slot& s : std::as_const(m_slots)) { positionSlot(s); } + + updateCurrentItem(); } void CarouselView::cancelAnimations() { diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index a5b2ef9..d822c79 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -40,6 +40,10 @@ class CarouselView : public QQuickItem { Q_PROPERTY( int currentIndex READ currentIndex WRITE setCurrentIndex NOTIFY currentIndexChanged) + + Q_PROPERTY( + QQuickItem* currentItem READ currentItem NOTIFY currentItemChanged) + Q_PROPERTY( int glideDuration READ glideDuration WRITE setGlideDuration NOTIFY glideDurationChanged) @@ -79,6 +83,8 @@ class CarouselView : public QQuickItem { int currentIndex() const { return m_currentRealIndex; } void setCurrentIndex(int idx); + QQuickItem* currentItem() const { return m_currentItem; } + int glideDuration() const { return m_glideDuration; } void setGlideDuration(int v); @@ -103,6 +109,7 @@ class CarouselView : public QQuickItem { void tileHeightChanged(); void maxWidthChanged(); void currentIndexChanged(); + void currentItemChanged(); void glideDurationChanged(); void glideEasingTypeChanged(); void delegatePropertiesChanged(); @@ -173,6 +180,8 @@ class CarouselView : public QQuickItem { void updateCurrentIndexFromContentX(bool emitPreview); + void updateCurrentItem(); + void wrapContentIfNeeded(); void centerInstantlyOnReal(int realIndex); @@ -198,6 +207,8 @@ class CarouselView : public QQuickItem { int m_currentRealIndex = -1; int m_previewRealIndex = -1; + QQuickItem* m_currentItem = nullptr; + int m_centerBlockStart = 0; QList m_slots; -- 2.47.3 From 69c2bfae9d5b7effd44d5a4b810cd74d0da62a83 Mon Sep 17 00:00:00 2001 From: zach Date: Sun, 16 Aug 2026 23:13:37 +0200 Subject: [PATCH 09/10] fix search trickle through actions and variants view and mouse event stealing by wallpapertile --- Helpers/Searcher.qml | 1 + Modules/Launcher/AppList.qml | 111 +++++++++++++++------ Plugins/ZShell/Components/carouselview.cpp | 84 ++++++++++++++++ Plugins/ZShell/Components/carouselview.hpp | 2 + 4 files changed, 168 insertions(+), 30 deletions(-) diff --git a/Helpers/Searcher.qml b/Helpers/Searcher.qml index 23f15e5..d03bc41 100644 --- a/Helpers/Searcher.qml +++ b/Helpers/Searcher.qml @@ -26,6 +26,7 @@ Singleton { function query(search: string): list { search = transformSearch(search); + console.log(!search, search, ...list); if (!search) return [...list]; diff --git a/Modules/Launcher/AppList.qml b/Modules/Launcher/AppList.qml index 497bbec..4bb7921 100644 --- a/Modules/Launcher/AppList.qml +++ b/Modules/Launcher/AppList.qml @@ -1,28 +1,36 @@ pragma ComponentBehavior: Bound -import Quickshell import QtQuick -import qs.Modules.Launcher.Services -import qs.Modules.Launcher.Items -import qs.Components +import Quickshell import ZShell.Config +import qs.Components import qs.Services +import qs.Modules.Launcher.Items +import qs.Modules.Launcher.Services CustomListView { id: root + readonly property string displayState: stateForText(displayText) + property string displayText + readonly property string requestedState: stateForText(search.text) required property SearchBar search required property PersistentProperties visibilities - highlightFollowsCurrentItem: false - highlightRangeMode: ListView.ApplyRange - implicitHeight: (Config.launcher.sizes.itemHeight + spacing) * Math.min(Config.launcher.maxAppsShown, count) - spacing - orientation: Qt.Vertical - preferredHighlightBegin: 0 - preferredHighlightEnd: height - spacing: Tokens.spacing.small - state: { - const text = search.text; + function resultsForText(text: string): var { + switch (stateForText(text)) { + case "actions": + return Actions.query(text); + case "calc": + return [0]; + case "variant": + return SchemeVariants.query(text); + default: + return Apps.search(text); + } + } + + function stateForText(text: string): string { const prefix = Config.launcher.actionPrefix; if (text.startsWith(prefix)) { for (const action of ["calc", "scheme", "variant"]) @@ -34,6 +42,20 @@ CustomListView { return "apps"; } + + function syncDisplayText(): void { + if (visibilities.launcher && requestedState === displayState) + displayText = search.text; + } + + highlightFollowsCurrentItem: false + highlightRangeMode: ListView.ApplyRange + implicitHeight: (Config.launcher.sizes.itemHeight + spacing) * Math.min(Config.launcher.maxAppsShown, count) - spacing + orientation: Qt.Vertical + preferredHighlightBegin: 0 + preferredHighlightEnd: height + spacing: Tokens.spacing.small + state: visibilities.launcher ? requestedState : displayState verticalLayoutDirection: ListView.BottomToTop CustomScrollBar.vertical: CustomScrollBar { @@ -44,19 +66,21 @@ CustomListView { Anim { from: 0 - properties: "opacity,scale" + property: "opacity" to: 1 + type: Anim.DefaultEffects } } addDisplaced: Transition { Anim { - duration: Tokens.anim.durations.small property: "y" + type: Anim.StandardSmall } Anim { - properties: "opacity,scale" + property: "opacity" to: 1 + type: Anim.DefaultEffects } } displaced: Transition { @@ -65,8 +89,9 @@ CustomListView { } Anim { - properties: "opacity,scale" + property: "opacity" to: 1 + type: Anim.DefaultEffects } } highlight: CustomRect { @@ -85,7 +110,7 @@ CustomListView { } } model: ScriptModel { - id: model + values: root.resultsForText(root.displayText) onValuesChanged: root.currentIndex = 0 } @@ -95,8 +120,9 @@ CustomListView { } Anim { - properties: "opacity,scale" + property: "opacity" to: 1 + type: Anim.DefaultEffects } } remove: Transition { @@ -104,8 +130,9 @@ CustomListView { Anim { from: 1 - properties: "opacity,scale" + property: "opacity" to: 0 + type: Anim.DefaultEffects } } states: [ @@ -113,7 +140,6 @@ CustomListView { name: "apps" PropertyChanges { - model.values: Apps.search(search.text) root.delegate: appItem } }, @@ -121,7 +147,6 @@ CustomListView { name: "actions" PropertyChanges { - model.values: Actions.query(search.text) root.delegate: actionItem } }, @@ -129,7 +154,6 @@ CustomListView { name: "calc" PropertyChanges { - model.values: [0] root.delegate: calcItem } }, @@ -137,7 +161,6 @@ CustomListView { name: "variant" PropertyChanges { - model.values: SchemeVariants.query(search.text) root.delegate: variantItem } } @@ -147,7 +170,7 @@ CustomListView { ParallelAnimation { Anim { duration: Tokens.anim.durations.small - easing.bezierCurve: Tokens.anim.curves.expressiveEffects + easing.bezierCurve: Tokens.anim.curves.standardAccel from: 1 property: "opacity" target: root @@ -156,7 +179,7 @@ CustomListView { Anim { duration: Tokens.anim.durations.small - easing.bezierCurve: Tokens.anim.curves.expressiveEffects + easing.bezierCurve: Tokens.anim.curves.standardAccel from: 1 property: "scale" target: root @@ -165,14 +188,24 @@ CustomListView { } PropertyAction { - properties: "values,delegate" - targets: [model, root] + property: "delegate" + target: root + value: null + } + + ScriptAction { + script: root.displayText = root.search.text + } + + PropertyAction { + property: "delegate" + target: root } ParallelAnimation { Anim { duration: Tokens.anim.durations.small - easing.bezierCurve: Tokens.anim.curves.expressiveEffects + easing.bezierCurve: Tokens.anim.curves.standardDecel from: 0 property: "opacity" target: root @@ -181,7 +214,7 @@ CustomListView { Anim { duration: Tokens.anim.durations.small - easing.bezierCurve: Tokens.anim.curves.expressiveEffects + easing.bezierCurve: Tokens.anim.curves.standardDecel from: 0.9 property: "scale" target: root @@ -197,6 +230,8 @@ CustomListView { } } + Component.onCompleted: displayText = search.text + Component { id: appItem @@ -228,4 +263,20 @@ CustomListView { list: root } } + + Connections { + function onTextChanged() { + root.syncDisplayText(); + } + + target: root.search + } + + Connections { + function onLauncherChanged() { + root.syncDisplayText(); + } + + target: root.visibilities + } } diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index 9428c1a..c1341d5 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -21,6 +21,7 @@ constexpr int kMaxLayoutCapacity = 64; CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { setAcceptedMouseButtons(Qt::LeftButton); + setFiltersChildMouseEvents(true); setFlag(QQuickItem::ItemHasContents, false); setClip(false); @@ -80,6 +81,89 @@ void CarouselView::setDelegate(QQmlComponent* c) { relayout(); } +bool CarouselView::childMouseEventFilter(QQuickItem* item, QEvent* event) { + Q_UNUSED(item); + + auto* mouseEvent = dynamic_cast(event); + if (!mouseEvent) return false; + + const QPointF pos = mapFromItem(item, mouseEvent->position()); + + switch (mouseEvent->type()) { + case QEvent::MouseButtonPress: { + if (mouseEvent->button() != Qt::LeftButton) return false; + + m_dragging = true; + m_dragActive = false; + + m_pressPos = pos; + m_pressContentX = m_contentX; + + m_dragTimer.start(); + + m_lastMoveX = pos.x(); + m_lastMoveT = 0; + m_velocity = 0; + + return false; + } + + case QEvent::MouseMove: { + if (!m_dragging) return false; + + const qreal dx = pos.x() - m_pressPos.x(); + + if (!m_dragActive && std::abs(dx) > kDragThreshold) { + m_dragActive = true; + m_pressContentX = m_contentX; + + cancelAnimations(); + grabMouse(); + } + + if (!m_dragActive) return false; + + m_contentX = m_pressContentX - dx; + + wrapContentIfNeeded(); + updateCurrentIndexFromContentX(true); + relayout(); + + const qint64 t = m_dragTimer.elapsed(); + const qint64 dt = t - m_lastMoveT; + + if (dt > 0) { + const qreal instVel = (m_lastMoveX - pos.x()) / (dt / 1000.0); + + m_velocity = m_velocity * 0.7 + instVel * 0.3; + } + + m_lastMoveX = pos.x(); + m_lastMoveT = t; + + mouseEvent->accept(); + return true; + } + + case QEvent::MouseButtonRelease: { + if (!m_dragging) return false; + + if (!m_dragActive) { + m_dragging = false; + return false; + } + + return false; + } + + case QEvent::MouseButtonDblClick: + return false; + + default: + return false; + } +} + void CarouselView::setModel(const QVariantList& m) { m_model = m; diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index d822c79..14dea74 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -118,6 +118,8 @@ class CarouselView : public QQuickItem { void previewIndexChanged(int realIndex, QVariant modelData); protected: + bool childMouseEventFilter(QQuickItem* item, QEvent* event) override; + void geometryChange(const QRectF& newGeo, const QRectF& oldGeo) override; void mousePressEvent(QMouseEvent* event) override; -- 2.47.3 From babfe4b3de29beacd7d237db21c42dc13c44e5b3 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 17 Aug 2026 10:37:00 +0200 Subject: [PATCH 10/10] flick velocity and deceleration --- Helpers/Searcher.qml | 1 - Plugins/ZShell/Components/carouselview.cpp | 77 ++++++++++------------ Plugins/ZShell/Components/carouselview.hpp | 16 ++++- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/Helpers/Searcher.qml b/Helpers/Searcher.qml index d03bc41..23f15e5 100644 --- a/Helpers/Searcher.qml +++ b/Helpers/Searcher.qml @@ -26,7 +26,6 @@ Singleton { function query(search: string): list { search = transformSearch(search); - console.log(!search, search, ...list); if (!search) return [...list]; diff --git a/Plugins/ZShell/Components/carouselview.cpp b/Plugins/ZShell/Components/carouselview.cpp index c1341d5..70776fe 100644 --- a/Plugins/ZShell/Components/carouselview.cpp +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -46,7 +46,7 @@ CarouselView::CarouselView(QQuickItem* parent) : QQuickItem(parent) { }); m_flickAnim = new QVariantAnimation(this); - m_flickAnim->setEasingCurve(QEasingCurve::Linear); + m_flickAnim->setEasingCurve(QEasingCurve::OutQuad); connect( m_flickAnim, @@ -101,9 +101,8 @@ bool CarouselView::childMouseEventFilter(QQuickItem* item, QEvent* event) { m_dragTimer.start(); - m_lastMoveX = pos.x(); - m_lastMoveT = 0; - m_velocity = 0; + m_velocitySamples.clear(); + addVelocitySample(0, pos.x()); return false; } @@ -129,17 +128,7 @@ bool CarouselView::childMouseEventFilter(QQuickItem* item, QEvent* event) { updateCurrentIndexFromContentX(true); relayout(); - const qint64 t = m_dragTimer.elapsed(); - const qint64 dt = t - m_lastMoveT; - - if (dt > 0) { - const qreal instVel = (m_lastMoveX - pos.x()) / (dt / 1000.0); - - m_velocity = m_velocity * 0.7 + instVel * 0.3; - } - - m_lastMoveX = pos.x(); - m_lastMoveT = t; + addVelocitySample(m_dragTimer.elapsed(), pos.x()); mouseEvent->accept(); return true; @@ -1141,10 +1130,8 @@ void CarouselView::mousePressEvent(QMouseEvent* event) { m_dragTimer.start(); - m_lastMoveX = event->position().x(); - - m_lastMoveT = 0; - m_velocity = 0; + m_velocitySamples.clear(); + addVelocitySample(0, event->position().x()); event->accept(); } @@ -1170,32 +1157,43 @@ void CarouselView::mouseMoveEvent(QMouseEvent* event) { updateCurrentIndexFromContentX(true); relayout(); - const qint64 t = m_dragTimer.elapsed(); - - const qint64 dt = t - m_lastMoveT; - - if (dt > 0) { - const qreal instVel = - (m_lastMoveX - event->position().x()) / (dt / 1000.0); - - m_velocity = m_velocity * 0.7 + instVel * 0.3; - } - - m_lastMoveX = event->position().x(); - - m_lastMoveT = t; + addVelocitySample(m_dragTimer.elapsed(), event->position().x()); event->accept(); } +void CarouselView::addVelocitySample(qint64 t, qreal x) { + m_velocitySamples.append({t, x}); + + while (m_velocitySamples.size() > 1 && + t - m_velocitySamples.first().t > kVelocitySampleWindowMs) { + m_velocitySamples.removeFirst(); + } +} + +qreal CarouselView::sampledVelocity() const { + if (m_velocitySamples.size() < 2) return 0; + + const VelocitySample& oldest = m_velocitySamples.first(); + const VelocitySample& newest = m_velocitySamples.last(); + + const qreal dt = newest.t - oldest.t; + + if (dt < 8) return 0; + + return (oldest.x - newest.x) / (dt / 1000.0); +} + void CarouselView::mouseReleaseEvent(QMouseEvent* event) { if (!m_dragging) return; m_dragging = false; + const bool wasDragActive = m_dragActive; + ungrabMouse(); - if (!m_dragActive) { + if (!wasDragActive) { cancelAnimations(); snapToNearest(); @@ -1205,7 +1203,7 @@ void CarouselView::mouseReleaseEvent(QMouseEvent* event) { m_dragActive = false; - qreal v = qBound(-kMaxFlickVelocity, m_velocity, kMaxFlickVelocity); + qreal v = qBound(-kMaxFlickVelocity, sampledVelocity(), kMaxFlickVelocity); if (std::abs(v) < kMinFlickVelocity) { snapToNearest(); @@ -1215,24 +1213,17 @@ void CarouselView::mouseReleaseEvent(QMouseEvent* event) { } const qreal duration = std::abs(v) / (kFlickFriction * 1000.0); - const qreal distance = (v * (duration / 1000.0)) / 2.0; - const qreal rawDest = m_contentX + distance; - const qreal dest = contentXForIndex(indexForContentX(rawDest)); cancelAnimations(); m_flickAnim->setDuration( static_cast(qBound(120, duration, 900))); - - m_flickAnim->setEasingCurve(QEasingCurve::OutCubic); - + m_flickAnim->setEasingCurve(QEasingCurve::OutQuad); m_flickAnim->setStartValue(m_contentX); - m_flickAnim->setEndValue(dest); - m_flickAnim->start(); event->accept(); diff --git a/Plugins/ZShell/Components/carouselview.hpp b/Plugins/ZShell/Components/carouselview.hpp index 14dea74..3f98b35 100644 --- a/Plugins/ZShell/Components/carouselview.hpp +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include namespace ZShell::components { @@ -150,6 +152,11 @@ class CarouselView : public QQuickItem { bool active = false; }; + struct VelocitySample { + qint64 t; + qreal x; + }; + void rebuildArrangement(); void relayout(); @@ -190,6 +197,9 @@ class CarouselView : public QQuickItem { QQuickItem* acquireItem(); void releaseAllItems(); + void addVelocitySample(qint64 t, qreal x); + qreal sampledVelocity() const; + QQmlComponent* m_delegate = nullptr; QVariantList m_model; QVariantMap m_delegateProperties; @@ -226,9 +236,9 @@ class CarouselView : public QQuickItem { qreal m_pressContentX = 0; QElapsedTimer m_dragTimer; - qreal m_lastMoveX = 0; - qint64 m_lastMoveT = 0; - qreal m_velocity = 0; + + QVector m_velocitySamples; + static constexpr qint64 kVelocitySampleWindowMs = 80; QVariantAnimation* m_flickAnim = nullptr; -- 2.47.3