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/Modules/Launcher/CarouselView.qml b/Modules/Launcher/CarouselView.qml new file mode 100755 index 0000000..595dfa2 --- /dev/null +++ b/Modules/Launcher/CarouselView.qml @@ -0,0 +1,53 @@ +import Quickshell +import QtQuick +import ZShell.Components +import ZShell.Config +import qs.Modules.Launcher.Items +import qs.Components +import qs.Helpers + +CarouselView { + id: root + + required property var panels + readonly property var screen: QsWindow.window?.screen + required property SearchBar search + readonly property var values: Wallpapers.query(root.search.text.split(" ").slice(1).join(" ")) + required property var 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 + itemSpacing: Tokens.spacing.small + maxWidth: { + if (!screen) + return 0; + const barMargins = Config.bar.border * 2; + let margins = Math.round(screen.width / 6); + if (visibilities.utilities || visibilities.sidebar) + margins = Config.sidebar.sizes.width; + return screen.width - (barMargins + margins) * 2 - Config.bar.rounding * 4; + } + minEdgeWidth: 56 + model: values + tileHeight: focalWidth / 16 * 9 + + delegate: Component { + WallpaperTile { + } + } + + Component.onCompleted: { + const idx = Wallpapers.list.findIndex(w => w.path === Wallpapers.actualCurrent); + if (idx >= 0) + jumpToIndex(idx); + } + Component.onDestruction: Wallpapers.stopPreview() + onPreviewIndexChanged: (realIndex, modelData) => { + Wallpapers.preview(modelData.path); + } +} diff --git a/Modules/Launcher/ContentList.qml b/Modules/Launcher/ContentList.qml index af5122a..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: WallpaperList { - content: root.content + sourceComponent: CarouselView { 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 138f175..0000000 --- a/Modules/Launcher/Items/WallpaperItem.qml +++ /dev/null @@ -1,96 +0,0 @@ -import ZShell.Models -import Quickshell -import QtQuick -import qs.Components -import qs.Helpers -import ZShell.Config -import qs.Modules -import qs.Services - -Item { - id: root - - required property FileSystemEntry modelData - required property PersistentProperties visibilities - - implicitHeight: image.height + label.height + Tokens.spacing.small / 2 + Tokens.padding.large + Tokens.padding.normal - implicitWidth: image.width + Tokens.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: Tokens.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: Colors.tPalette.m3surfaceContainer - implicitHeight: implicitWidth / 16 * 9 - implicitWidth: Config.launcher.sizes.wallpaperWidth - radius: Tokens.rounding.small - y: Tokens.padding.large - - MaterialIcon { - anchors.centerIn: parent - color: Colors.tPalette.m3outline - font.pointSize: Tokens.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: Tokens.spacing.small / 2 - elide: Text.ElideRight - font.pointSize: Tokens.font.size.normal - horizontalAlignment: Text.AlignHCenter - renderType: Text.QtRendering - text: root.modelData.relativePath - width: image.width - Tokens.padding.normal * 2 - } -} diff --git a/Modules/Launcher/Items/WallpaperTile.qml b/Modules/Launcher/Items/WallpaperTile.qml new file mode 100755 index 0000000..806f89e --- /dev/null +++ b/Modules/Launcher/Items/WallpaperTile.qml @@ -0,0 +1,58 @@ +import Quickshell +import QtQuick +import ZShell.Config +import qs.Components +import qs.Helpers +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 + + 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 + + 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 + sourceSize.height: root.tileHeight + sourceSize.width: root.tileHeight * (16 / 9) + 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 d01938e..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 ZShell.Config -import qs.Modules.Launcher.Items - -PathView { - id: root - - required property var content - readonly property int itemWidth: Config.launcher.sizes.wallpaperWidth * 0.9 + Tokens.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); - } -} 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..70776fe --- /dev/null +++ b/Plugins/ZShell/Components/carouselview.cpp @@ -0,0 +1,1268 @@ +#include "carouselview.hpp" + +#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) { + setAcceptedMouseButtons(Qt::LeftButton); + setFiltersChildMouseEvents(true); + 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); + relayout(); + }); + + m_flickAnim = new QVariantAnimation(this); + m_flickAnim->setEasingCurve(QEasingCurve::OutQuad); + + 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(); +} + +void CarouselView::setDelegate(QQmlComponent* c) { + if (m_delegate == c) return; + + m_delegate = c; + + releaseAllItems(); + + emit delegateChanged(); + + 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_velocitySamples.clear(); + addVelocitySample(0, pos.x()); + + 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(); + + addVelocitySample(m_dragTimer.elapsed(), pos.x()); + + 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; + + emit modelChanged(); + + const int newCount = m_model.size(); + + if (newCount == 0) { + m_currentRealIndex = -1; + m_currentItem = nullptr; + emit currentItemChanged(); + + releaseAllItems(); + rebuildArrangement(); + + 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) { + QQmlProperty::write( + s.item, QStringLiteral("tileHeight"), m_tileHeight); + } + } +} + +void CarouselView::setMaxWidth(qreal v) { + if (qFuzzyCompare(m_maxWidth, v)) return; + + m_maxWidth = v; + + emit maxWidthChanged(); + + const qreal targetWidth = targetWidthForMaxWidth(m_maxWidth); + + setImplicitWidth(targetWidth); + + 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) continue; + + 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); +} + +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); + + const qreal oldWidth = oldGeo.width(); + + const qreal newWidth = newGeo.width(); + + if (qFuzzyCompare(oldWidth, newWidth)) { + return; + } + + const qreal delta = (newWidth - oldWidth) / 2.0; + + m_contentX -= delta; + + 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; + } + + 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; + } + + const int n = layoutCapacityForWidth(maxWidth); + + return arrangementForCount(n).width; +} + +void CarouselView::rebuildArrangement() { + Arrangement& a = m_arrangement; + + a = Arrangement{}; + + const qreal W = width() > 0 ? width() : targetWidthForMaxWidth(m_maxWidth); + + if (W <= 0 || m_focalWidth <= 0) { + a = arrangementForCount(0); + a.width = W; + + return; + } + + if (m_layoutCapacity <= 0) { + m_layoutCapacity = layoutCapacityForWidth(m_maxWidth); + } + + if (m_layoutCapacity <= 0) { + a = arrangementForCount(0); + a.width = W; + + return; + } + + const Arrangement full = arrangementForCount(m_layoutCapacity); + + if (W >= full.width) { + a = full; + return; + } + + int highN = 1; + + for (int n = 1; n <= m_layoutCapacity; ++n) { + const Arrangement candidate = arrangementForCount(n); + + if (W < candidate.width) { + highN = n; + break; + } + } + + 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); + + 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); + + a.outerSpacing = m_itemSpacing * (1.0 - t); + + a.centers.append(0); + + qreal center = 0; + + for (int i = 1; i <= highN; ++i) { + const qreal gap = i == highN ? a.outerSpacing : m_itemSpacing; + + center += a.sizes[i - 1] / 2.0 + gap + a.sizes[i] / 2.0; + + a.centers.append(center); + } + + 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]; + + return 0; +} + +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; +} + +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) return; + + 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; + } + + 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)); + } + } +} + +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); + } +} + +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 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); + + 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); +} + +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) { + if (s.item) s.item->setVisible(false); + } + + updateCurrentItem(); + return; + } + + 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); + } + } + + 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) { + 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; + } + + 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); + } + + updateCurrentItem(); +} + +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() { + 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) + 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(); +} + +void CarouselView::mousePressEvent(QMouseEvent* event) { + m_dragging = true; + m_dragActive = false; + + m_pressPos = event->position(); + + m_pressContentX = m_contentX; + + m_dragTimer.start(); + + m_velocitySamples.clear(); + addVelocitySample(0, event->position().x()); + + 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; + + cancelAnimations(); + grabMouse(); + } + + if (!m_dragActive) return; + + m_contentX = m_pressContentX - dx; + + wrapContentIfNeeded(); + updateCurrentIndexFromContentX(true); + relayout(); + + 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 (!wasDragActive) { + cancelAnimations(); + snapToNearest(); + + event->accept(); + return; + } + + m_dragActive = false; + + qreal v = qBound(-kMaxFlickVelocity, sampledVelocity(), kMaxFlickVelocity); + + if (std::abs(v) < kMinFlickVelocity) { + snapToNearest(); + + event->accept(); + return; + } + + 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::OutQuad); + 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(); +} + +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) { + continue; + } + + 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..3f98b35 --- /dev/null +++ b/Plugins/ZShell/Components/carouselview.hpp @@ -0,0 +1,249 @@ +#pragma once + +#include +#include +#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( + QQuickItem* currentItem READ currentItem NOTIFY currentItemChanged) + + Q_PROPERTY( + int glideDuration READ glideDuration WRITE setGlideDuration NOTIFY + glideDurationChanged) + Q_PROPERTY( + QEasingCurve::Type glideEasingType READ glideEasingType WRITE + setGlideEasingType NOTIFY glideEasingTypeChanged) + + 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); + + QQuickItem* currentItem() const { return m_currentItem; } + + 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); + + Q_INVOKABLE void goToIndex(int realIndex); + Q_INVOKABLE void incrementCurrentIndex(); + Q_INVOKABLE void decrementCurrentIndex(); + + Q_INVOKABLE void jumpToIndex(int realIndex); + + signals: + void delegateChanged(); + void modelChanged(); + void focalWidthChanged(); + void minEdgeWidthChanged(); + void itemSpacingChanged(); + void tileHeightChanged(); + void maxWidthChanged(); + void currentIndexChanged(); + void currentItemChanged(); + void glideDurationChanged(); + void glideEasingTypeChanged(); + void delegatePropertiesChanged(); + + void activated(int realIndex, QVariant modelData); + 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; + 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; + qreal outerSpacing = 0; + + QVector sizes; + QVector centers; + + qreal effectiveExtraWidth = 0; + }; + + struct Slot { + QQuickItem* item = nullptr; + int virtualIndex = 0; + int realIndex = -1; + bool active = false; + }; + + struct VelocitySample { + qint64 t; + qreal x; + }; + + 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 keylineSize(int io) const; + qreal sampleSize(qreal childLoc) const; + qreal continuousIndex() const; + + int realIndexOf(int virtualIndex) const; + + void updateCurrentIndexFromContentX(bool emitPreview); + + void updateCurrentItem(); + + void wrapContentIfNeeded(); + void centerInstantlyOnReal(int realIndex); + + QQuickItem* acquireItem(); + void releaseAllItems(); + + void addVelocitySample(qint64 t, qreal x); + qreal sampledVelocity() const; + + 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; + + int m_layoutCapacity = 0; + + qreal m_contentX = 0; + int m_currentVirtualIndex = 0; + int m_currentRealIndex = -1; + int m_previewRealIndex = -1; + + QQuickItem* m_currentItem = nullptr; + + int m_centerBlockStart = 0; + + QList m_slots; + + int m_glideDuration = 250; + QEasingCurve::Type m_glideEasingType = QEasingCurve::OutCubic; + QVariantAnimation* m_glideAnim = nullptr; + + bool m_dragging = false; + bool m_dragActive = false; + + QPointF m_pressPos; + qreal m_pressContentX = 0; + + QElapsedTimer m_dragTimer; + + QVector m_velocitySamples; + static constexpr qint64 kVelocitySampleWindowMs = 80; + + QVariantAnimation* m_flickAnim = nullptr; + + bool m_initializedLayout = false; + bool m_relayoutQueued = false; +}; + +} // namespace ZShell::components 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(); } 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