#include "snapview.hpp" #include #include #include #include #include #include #include namespace ZShell::components { namespace { // The same device-pixel helpers SnapLayout uses: the residual is the fractional // part of an absolute device-pixel position, which is how a sub-pixel move of // the view or its ancestors is noticed without reacting to whole-pixel moves. qint64 originResidual(qreal logical, qreal dpr) { const qreal device = logical * dpr; return std::llround((device - std::floor(device)) * 1024.0) % 1024; } QPointF absoluteOrigin(const QQuickItem* item) { qreal x = 0.0; qreal y = 0.0; for (const QQuickItem* it = item; it; it = it->parentItem()) { x += it->x(); y += it->y(); } return QPointF(x, y); } constexpr int AsyncBatchSize = 4; // Sizes are compared with a tolerance rather than ==, because the snapped // logical values are quotients of device pixels and never exact. bool differs(qreal a, qreal b) { return qAbs(a - b) > 0.001; } // Geometry is watched to notice sub-pixel moves; Destroyed is watched so a // listener can be dropped while the item is still valid instead of at teardown. constexpr QFlags WatchedChanges = QQuickItemPrivate::Geometry | QQuickItemPrivate::Destroyed; void addGeometryListener(QQuickItem* item, QQuickItemChangeListener* listener) { if (item) QQuickItemPrivate::get(item)->addItemChangeListener( listener, WatchedChanges); } void removeGeometryListener( QQuickItem* item, QQuickItemChangeListener* listener) { if (item) QQuickItemPrivate::get(item)->removeItemChangeListener( listener, WatchedChanges); } } // namespace SnapView::SnapView(QQuickItem* parent) : QQuickFlickable(parent) { setClip(true); // Asynchronous batches are spread over event loop turns, so the frames // between them can render; polish() from updatePolish would run them all // inside one polish flush. m_asyncTimer = new QTimer(this); m_asyncTimer->setSingleShot(true); m_asyncTimer->setInterval(0); connect(m_asyncTimer, &QTimer::timeout, this, &SnapView::polish); } SnapView::~SnapView() { clearListeners(); disconnectModel(); } QAbstractItemModel* SnapView::model() const { return m_model.data(); } void SnapView::setModel(QAbstractItemModel* model) { if (m_model == model) return; disconnectModel(); for (int index : m_items.keys()) destroyDelegate(index); m_sizeDevCache.clear(); m_rowSizes.clear(); m_model = model; invalidateRoleNames(); connectModel(); rebuildRows(); if (m_componentComplete) polish(); emit modelChanged(); } QQmlComponent* SnapView::delegate() const { return m_delegate.data(); } void SnapView::setDelegate(QQmlComponent* delegate) { if (m_delegate == delegate) return; m_delegate = delegate; for (int index : m_items.keys()) destroyDelegate(index); m_pendingCreate.clear(); m_sizeDevCache.clear(); if (m_componentComplete) polish(); emit delegateChanged(); } Qt::Orientation SnapView::orientation() const { return m_orientation; } void SnapView::setOrientation(Qt::Orientation orientation) { if (m_orientation == orientation) return; m_orientation = orientation; invalidateMainAxisCaches(); if (m_componentComplete) polish(); emit orientationChanged(); } qreal SnapView::spacing() const { return m_spacing; } void SnapView::setSpacing(qreal spacing) { if (qFuzzyCompare(m_spacing, spacing)) return; m_spacing = spacing; if (m_componentComplete) polish(); emit spacingChanged(); } qreal SnapView::cacheBuffer() const { return m_cacheBuffer; } void SnapView::setCacheBuffer(qreal buffer) { if (qFuzzyCompare(m_cacheBuffer, buffer)) return; m_cacheBuffer = buffer; if (m_componentComplete) polish(); emit cacheBufferChanged(); } qreal SnapView::estimatedSize() const { return m_estimatedSize; } void SnapView::setEstimatedSize(qreal size) { if (qFuzzyCompare(m_estimatedSize, size)) return; m_estimatedSize = size; if (m_componentComplete) polish(); emit estimatedSizeChanged(); } bool SnapView::snapSizes() const { return m_snapSizes; } void SnapView::setSnapSizes(bool snapSizes) { if (m_snapSizes == snapSizes) return; m_snapSizes = snapSizes; if (m_componentComplete) polish(); emit snapSizesChanged(); } bool SnapView::asynchronous() const { return m_asynchronous; } void SnapView::setAsynchronous(bool asynchronous) { if (m_asynchronous == asynchronous) return; m_asynchronous = asynchronous; if (m_componentComplete) polish(); emit asynchronousChanged(); } int SnapView::count() const { return m_model ? m_model->rowCount() : 0; } int SnapView::currentIndex() const { return m_currentIndex; } void SnapView::setCurrentIndex(int index) { if (m_currentIndex == index) return; m_currentIndex = index; emit currentIndexChanged(); emit currentItemChanged(); } QQuickItem* SnapView::currentItem() const { return m_items.value(m_currentIndex, nullptr); } void SnapView::positionViewAtBeginning() { positionViewAtIndex(0); } void SnapView::positionViewAtEnd() { positionViewAtIndex(count() - 1); } void SnapView::positionViewAtIndex(int index) { if (!m_componentComplete || index < 0 || index >= m_rows.size()) return; // Summed here rather than read from m_rows, whose offsets only refresh on // polish: positioning must be right even when called between passes. const bool horiz = m_orientation == Qt::Horizontal; const qreal extent = horiz ? width() : height(); const qreal spacing = effectiveSpacing(); qreal offset = 0.0; qreal target = 0.0; qreal total = 0.0; for (int i = 0; i < m_rows.size(); ++i) { if (i == index) target = offset; const qreal size = rowSize(i); total = offset + size; offset += size + spacing; } const qreal position = std::clamp(target, 0.0, (std::max)(0.0, total - extent)); const qint64 positionDev = std::llround(position * devicePixelRatio()); const qreal snapped = qreal(positionDev) / devicePixelRatio(); if (horiz) setContentX(snapped); else setContentY(snapped); } qreal SnapView::devicePixelRatio() const { const QQuickWindow* win = window(); const qreal dpr = win ? win->effectiveDevicePixelRatio() : 1.0; return dpr > 0.0 ? dpr : 1.0; } void SnapView::componentComplete() { QQuickFlickable::componentComplete(); m_componentComplete = true; rebuildRows(); updateAncestorListeners(); polish(); } void SnapView::geometryChange( const QRectF& newGeometry, const QRectF& oldGeometry) { QQuickFlickable::geometryChange(newGeometry, oldGeometry); if (m_componentComplete && !m_placing && newGeometry.size() != oldGeometry.size()) { polish(); } } void SnapView::itemChange(ItemChange change, const ItemChangeData& value) { QQuickFlickable::itemChange(change, value); if (change == ItemSceneChange) { // Leaving the scene means the ancestors are going away: drop the // listeners now, while both sides are still whole. if (value.window) { updateAncestorListeners(); polish(); } else { clearListeners(); } return; } if (change == ItemDevicePixelRatioHasChanged) { updateAncestorListeners(); polish(); } } void SnapView::updatePolish() { if (!m_componentComplete) return; layoutAndPlace(); if (!m_pendingCreate.isEmpty() && m_asynchronous) m_asyncTimer->start(); } void SnapView::itemGeometryChanged( QQuickItem* item, QQuickGeometryChange change, const QRectF& oldGeometry) { Q_UNUSED(oldGeometry) if (!m_componentComplete || m_placing) return; const qreal dpr = devicePixelRatio(); QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem; const bool watched = std::any_of( m_ancestorListeners.constBegin(), m_ancestorListeners.constEnd(), [item](const QPointer& p) { return p == item; }); if (item == content || watched) { const QPointF origin = contentOrigin(); if (originResidual(origin.x(), dpr) != m_originResidualX || originResidual(origin.y(), dpr) != m_originResidualY) { polish(); } return; } if (change.sizeChange() && m_items.key(item, -1) >= 0) polish(); } void SnapView::itemDestroyed(QQuickItem* item) { m_sizeDevCache.remove(item); const auto isItem = [item](const QPointer& p) { return p == item; }; m_delegateListeners.erase( std::remove_if( m_delegateListeners.begin(), m_delegateListeners.end(), isItem), m_delegateListeners.end()); m_ancestorListeners.erase( std::remove_if( m_ancestorListeners.begin(), m_ancestorListeners.end(), isItem), m_ancestorListeners.end()); const int index = m_items.key(item, -1); if (index >= 0) { m_items.remove(index); emit currentItemChanged(); } } qreal SnapView::effectiveSpacing() const { return qreal(std::llround(m_spacing * devicePixelRatio())) / devicePixelRatio(); } qreal SnapView::rowSize(int index) const { QQuickItem* item = m_items.value(index, nullptr); if (item) { const qreal measured = m_orientation == Qt::Horizontal ? item->width() : item->height(); if (measured > 0.0) return measured; } const qreal cached = m_rowSizes.value(index, 0.0); if (cached > 0.0) return cached; return m_estimatedSize > 0.0 ? m_estimatedSize : 0.0; } qint64 SnapView::snappedDeviceMainSize(QQuickItem* item) { const qreal dpr = devicePixelRatio(); const qreal own = m_orientation == Qt::Horizontal ? item->width() : item->height(); if (own <= 0.0) return 0; // Reuse the cached device size while the item's own size is still within a // device pixel of it, so an item that only moves keeps its size. const qint64 cached = m_sizeDevCache.value(item, -1); if (cached >= 0 && qAbs(own * dpr - qreal(cached)) <= 1.0) return cached; const qint64 snapped = std::llround(own * dpr); m_sizeDevCache[item] = snapped; return snapped; } int SnapView::firstVisibleIndex() const { if (m_rows.isEmpty()) return 0; // Row ends are non-decreasing (offsets accumulate size plus spacing), so the // visible range can be found by binary search instead of a linear scan. const qreal start = m_orientation == Qt::Horizontal ? contentX() : contentY(); const qreal from = (std::max)(0.0, start - m_cacheBuffer); const auto it = std::lower_bound( m_rows.constBegin(), m_rows.constEnd(), from, [](const Row& row, qreal value) { return row.exactOffset + row.exactSize < value; }); return static_cast(std::distance(m_rows.constBegin(), it)); } int SnapView::lastVisibleIndex() const { if (m_rows.isEmpty()) return 0; const bool horiz = m_orientation == Qt::Horizontal; const qreal start = horiz ? contentX() : contentY(); const qreal extent = horiz ? width() : height(); const qreal to = start + extent + m_cacheBuffer; const auto it = std::lower_bound( m_rows.constBegin(), m_rows.constEnd(), to, [](const Row& row, qreal value) { return row.exactOffset <= value; }); if (it == m_rows.constBegin()) return 0; return static_cast(std::distance(m_rows.constBegin(), it)) - 1; } QPointF SnapView::contentOrigin() const { // QQuickFlickablePrivate::get has no const overload; the item private is the // base of the flickable private, so casting is the const path to it. const auto* priv = static_cast( QQuickItemPrivate::get(this)); return absoluteOrigin(priv->contentItem); } void SnapView::layoutAndPlace() { const qreal dpr = devicePixelRatio(); const bool horiz = m_orientation == Qt::Horizontal; const qreal spacing = effectiveSpacing(); // Cumulative exact offsets, so the rounding below has the same exact // reference SnapLayout's arrangeChildren works from. Running this before // updateDelegates gives the viewport range to recycle against, and running // it again afterwards picks up sizes that changed when a re-keyed delegate // got a new index. const auto accumulate = [&]() { qreal offset = 0.0; for (int i = 0; i < m_rows.size(); ++i) { qreal size = rowSize(i); QQuickItem* item = m_items.value(i, nullptr); if (item) { const qreal measured = horiz ? item->width() : item->height(); if (measured > 0.0) m_rowSizes[i] = measured; } m_rows[i].exactOffset = offset; m_rows[i].exactSize = size; offset += size + spacing; } return m_rows.isEmpty() ? 0.0 : offset - spacing; }; accumulate(); updateDelegates(); const qreal total = accumulate(); if (horiz) setContentWidth(qreal(std::llround(total * dpr)) / dpr); else setContentHeight(qreal(std::llround(total * dpr)) / dpr); QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem; if (!content) return; const QPointF origin = absoluteOrigin(content); const qint64 originXDev = std::llround(origin.x() * dpr); const qint64 originYDev = std::llround(origin.y() * dpr); const qint64 crossDev = std::llround((horiz ? height() : width()) * dpr); qint64 prevNear = 0; qint64 prevSizeDev = 0; qreal prevEndExact = 0.0; bool started = false; m_placing = true; for (int i = 0; i < m_rows.size(); ++i) { const Row& row = m_rows.at(i); const qreal mainExact = row.exactOffset * dpr; qint64 mainNear = 0; if (!started) { mainNear = std::llround(mainExact); started = true; } else { mainNear = prevNear + prevSizeDev + std::llround(mainExact - prevEndExact); } QQuickItem* item = m_items.value(i, nullptr); qint64 mainDev = std::llround(row.exactSize * dpr); if (item) { const qint64 ownDev = snappedDeviceMainSize(item); if (ownDev > 0) mainDev = ownDev; } prevEndExact = mainExact + row.exactSize * dpr; prevSizeDev = mainDev; prevNear = mainNear; if (!item) continue; const qreal mainSize = qreal(mainDev) / dpr; const qreal crossSize = qreal(crossDev) / dpr; const qreal posX = horiz ? qreal(originXDev + mainNear) / dpr - origin.x() : qreal(originXDev) / dpr - origin.x(); const qreal posY = horiz ? qreal(originYDev) / dpr - origin.y() : qreal(originYDev + mainNear) / dpr - origin.y(); item->setPosition(QPointF(posX, posY)); // Only the cross axis is written unconditionally. Writing the main axis // would replace the delegate's implicit-size fallback with an explicit // value, so a delegate whose content changes size could never grow // again. It is resized only when snapSizes asks for exactly that. if (horiz) { if (m_snapSizes && mainDev > 0 && differs(item->width(), mainSize)) item->setWidth(mainSize); if (differs(item->height(), crossSize)) item->setHeight(crossSize); } else { if (differs(item->width(), crossSize)) item->setWidth(crossSize); if (m_snapSizes && mainDev > 0 && differs(item->height(), mainSize)) item->setHeight(mainSize); } } m_placing = false; trackOriginResidual(); } void SnapView::updateDelegates() { const int first = firstVisibleIndex(); const int last = lastVisibleIndex(); for (int index : m_items.keys()) { if (index < first || index > last) destroyDelegate(index); } QVector missing; for (int index = first; index <= last; ++index) { if (m_items.contains(index)) updateDelegateData(index); else missing.append(index); } int budget = m_asynchronous ? AsyncBatchSize : (std::numeric_limits::max)(); m_pendingCreate.clear(); for (int index : std::as_const(missing)) { if (budget <= 0) { m_pendingCreate.append(index); continue; } (void)createDelegate(index); --budget; } } QQuickItem* SnapView::createDelegate(int index) { if (!m_delegate || !m_model || index < 0 || index >= m_model->rowCount()) return nullptr; if (m_items.contains(index)) return m_items.value(index); // Same as LazyListView: beginCreate + setInitialProperties + completeCreate, // so delegates with pragma ComponentBehavior: Bound are accepted. auto* compContext = m_delegate->creationContext(); if (!compContext) compContext = qmlContext(this); if (!compContext) return nullptr; QObject* obj = m_delegate->beginCreate(compContext); QQuickItem* item = qobject_cast(obj); if (!item) { qWarning( "SnapView: delegate creation failed for row %d: %s", index, qPrintable(m_delegate->errorString())); if (obj) m_delegate->completeCreate(); delete obj; return nullptr; } m_delegate->setInitialProperties(item, delegateProperties(index)); QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem; item->setParentItem(content ? content : this); addGeometryListener(item, this); m_delegateListeners.append(item); m_items.insert(index, item); m_delegate->completeCreate(); emit currentItemChanged(); return item; } void SnapView::destroyDelegate(int index) { QQuickItem* item = m_items.take(index); if (!item) return; removeGeometryListener(item, this); m_delegateListeners.removeAll(item); m_sizeDevCache.remove(item); item->deleteLater(); emit currentItemChanged(); } void SnapView::updateDelegateData(int index) { QQuickItem* item = m_items.value(index, nullptr); if (!item) return; const QVariantMap props = delegateProperties(index); for (auto it = props.constBegin(); it != props.constEnd(); ++it) { const QByteArray name = it.key().toUtf8(); // Scrolls refresh every visible delegate each pass; writing only the // values that actually changed keeps that cheap. if (item->property(name.constData()) == it.value()) continue; item->setProperty(name.constData(), it.value()); } } QVariantMap SnapView::delegateProperties(int index) { QVariantMap props; if (!m_model || index < 0 || index >= m_model->rowCount()) return props; const QModelIndex srcIndex = m_model->index(index, 0); const auto& names = roleNames(); bool hasModelData = false; for (auto it = names.constBegin(); it != names.constEnd(); ++it) { const auto name = QString::fromUtf8(it.value()); props.insert(name, m_model->data(srcIndex, it.key())); if (name == QStringLiteral("modelData")) hasModelData = true; } props.insert(QStringLiteral("index"), index); if (!hasModelData) { const auto role = names.isEmpty() ? Qt::DisplayRole : names.constBegin().key(); props.insert(QStringLiteral("modelData"), m_model->data(srcIndex, role)); } return props; } const QHash& SnapView::roleNames() { if (!m_roleNamesValid) { m_roleNames = m_model ? m_model->roleNames() : QHash(); m_roleNamesValid = true; } return m_roleNames; } void SnapView::invalidateRoleNames() { m_roleNamesValid = false; } void SnapView::invalidateMainAxisCaches() { // Cached sizes belong to the axis that is no longer the main one. m_rowSizes.fill(0.0); m_sizeDevCache.clear(); } void SnapView::rebuildRows() { m_rows.resize(count()); m_rowSizes.resize(count()); if (m_currentIndex >= count()) m_currentIndex = -1; emit countChanged(); emit currentItemChanged(); } void SnapView::trackOriginResidual() { const qreal dpr = devicePixelRatio(); const QPointF origin = contentOrigin(); m_originResidualX = originResidual(origin.x(), dpr); m_originResidualY = originResidual(origin.y(), dpr); } void SnapView::shiftDelegateIndices(int from, int delta) { if (delta == 0) return; QHash shifted; for (auto it = m_items.constBegin(); it != m_items.constEnd(); ++it) { shifted.insert( it.key() >= from ? it.key() + delta : it.key(), it.value()); } m_items = shifted; for (int& index : m_pendingCreate) { if (index >= from) index += delta; } } void SnapView::updateAncestorListeners() { QQuickItem* content = QQuickFlickablePrivate::get(this)->contentItem; QVector> want; if (content) want.append(content); for (QQuickItem* it = parentItem(); it; it = it->parentItem()) want.append(it); for (const QPointer& item : std::as_const(m_ancestorListeners)) { if (item && !want.contains(item)) removeGeometryListener(item.data(), this); } for (const QPointer& item : std::as_const(want)) { const bool known = std::any_of( m_ancestorListeners.constBegin(), m_ancestorListeners.constEnd(), [&item](const QPointer& p) { return p == item; }); if (!known) addGeometryListener(item.data(), this); } m_ancestorListeners = want; trackOriginResidual(); } void SnapView::clearListeners() { for (const QPointer& item : std::as_const(m_ancestorListeners)) removeGeometryListener(item.data(), this); m_ancestorListeners.clear(); m_delegateListeners.clear(); } void SnapView::connectModel() { if (!m_model) return; m_modelConnections.append( QObject::connect( m_model, &QAbstractItemModel::rowsInserted, this, &SnapView::onRowsInserted)); m_modelConnections.append( QObject::connect( m_model, &QAbstractItemModel::rowsAboutToBeRemoved, this, &SnapView::onRowsAboutToBeRemoved)); m_modelConnections.append( QObject::connect( m_model, &QAbstractItemModel::rowsRemoved, this, &SnapView::onRowsRemoved)); m_modelConnections.append( QObject::connect( m_model, &QAbstractItemModel::dataChanged, this, &SnapView::onDataChanged)); m_modelConnections.append( QObject::connect( m_model, &QAbstractItemModel::modelReset, this, &SnapView::onModelReset)); m_modelConnections.append( QObject::connect( m_model, &QAbstractItemModel::layoutChanged, this, [this]() { rebuildRows(); polish(); })); } void SnapView::disconnectModel() { for (const auto& conn : std::as_const(m_modelConnections)) disconnect(conn); m_modelConnections.clear(); } void SnapView::onRowsInserted(const QModelIndex& parent, int first, int last) { if (parent.isValid()) return; // Delegates at or after the inserted range now address a different row, and // the inserted rows have no measured size yet. shiftDelegateIndices(first, last - first + 1); m_rowSizes.resize(count()); for (int index = first; index <= last; ++index) m_rowSizes[index] = 0.0; rebuildRows(); polish(); } void SnapView::onRowsAboutToBeRemoved( const QModelIndex& parent, int first, int last) { if (parent.isValid()) return; for (int index = first; index <= last; ++index) destroyDelegate(index); m_rowSizes.remove(first, last - first + 1); shiftDelegateIndices(last + 1, -(last - first + 1)); } void SnapView::onRowsRemoved(const QModelIndex& parent, int first, int last) { if (parent.isValid()) return; Q_UNUSED(first) Q_UNUSED(last) rebuildRows(); polish(); } void SnapView::onDataChanged( const QModelIndex& topLeft, const QModelIndex& bottomRight) { if (topLeft.parent().isValid() || bottomRight.parent().isValid()) return; for (int index = topLeft.row(); index <= bottomRight.row(); ++index) updateDelegateData(index); // A row's content can change its size, so re-place as well. polish(); } void SnapView::onModelReset() { for (int index : m_items.keys()) destroyDelegate(index); m_sizeDevCache.clear(); m_rowSizes.clear(); rebuildRows(); polish(); } } // namespace ZShell::components