Files
z-bar-qt/Plugins/ZShell/Components/snapview.cpp
T
zach 11e7aac195
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 11s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 12s
JS/TS / lint (pull_request) Successful in 10s
Python / static (pull_request) Failing after 27s
Rust / build (pull_request) Successful in 54s
Python / verify (pull_request) Failing after 1m26s
Rust / fmt (pull_request) Successful in 46s
Rust / clippy (pull_request) Successful in 1m6s
feat: better notification storage and image caching + blobs
2026-11-02 19:00:43 +01:00

807 lines
22 KiB
C++

#include "snapview.hpp"
#include <QQuickWindow>
#include <QTimer>
#include <private/qquickflickable_p.h>
#include <private/qquickflickable_p_p.h>
#include <algorithm>
#include <cmath>
#include <limits>
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<QQuickItemPrivate::ChangeType> 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<QQuickItem>& 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<QQuickItem>& 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<int>(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<int>(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<const QQuickFlickablePrivate*>(
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<int> 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<int>::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<QQuickItem*>(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<int, QByteArray>& SnapView::roleNames() {
if (!m_roleNamesValid) {
m_roleNames = m_model ? m_model->roleNames() : QHash<int, QByteArray>();
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<int, QQuickItem*> 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<QPointer<QQuickItem>> want;
if (content) want.append(content);
for (QQuickItem* it = parentItem(); it; it = it->parentItem())
want.append(it);
for (const QPointer<QQuickItem>& item :
std::as_const(m_ancestorListeners)) {
if (item && !want.contains(item))
removeGeometryListener(item.data(), this);
}
for (const QPointer<QQuickItem>& item : std::as_const(want)) {
const bool known = std::any_of(
m_ancestorListeners.constBegin(),
m_ancestorListeners.constEnd(),
[&item](const QPointer<QQuickItem>& p) { return p == item; });
if (!known) addGeometryListener(item.data(), this);
}
m_ancestorListeners = want;
trackOriginResidual();
}
void SnapView::clearListeners() {
for (const QPointer<QQuickItem>& 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