[VDS] Expose filter matches as a proxy role instead of dropping rows

The folder display scanned source-model rows and probed acceptance with
mapFromSource(...).isValid(), reaching into both models for one answer.

The proxy now keeps every row and exposes each row's search/tag/color
filter result through FilterMatchRole. The folder display and the tag
filter read everything off proxy indexes, and hidden previews keep their
sorted position in the flow layout instead of being appended at the end.

Took 11 minutes
This commit is contained in:
Lukas Brübach 2026-08-24 19:46:19 +02:00
parent 0709cd04c0
commit 717a2deff8
7 changed files with 68 additions and 45 deletions

View file

@ -105,29 +105,29 @@ void VisualDeckStorageFolderDisplayWidget::continueDeckPass()
passTimer.start(); passTimer.start();
auto *proxy = visualDeckStorageWidget->proxyModel(); auto *proxy = visualDeckStorageWidget->proxyModel();
auto *model = visualDeckStorageWidget->model(); const int proxyRowCount = proxy->rowCount();
const int sourceRowCount = model->rowCount();
// Scan rows of this folder, creating missing previews, until the time budget for this // Scan rows of this folder, creating missing previews, until the time budget for this
// event loop turn runs out. The rest continues on the next turn. // event loop turn runs out. The rest continues on the next turn.
while (deckPassRow < sourceRowCount) { while (deckPassRow < proxyRowCount) {
const int row = deckPassRow++; const int row = deckPassRow++;
const DeckPreviewData &data = model->dataForRow(row); const QModelIndex index = proxy->index(row, 0);
if (showFolders && data.folderPath != folderPath) { if (showFolders && index.data(VisualDeckStorageRoles::FolderPathRole).toString() != folderPath) {
continue; continue;
} }
deckPassPresentPaths.insert(data.filePath); const QString filePath = index.data(VisualDeckStorageRoles::FilePathRole).toString();
deckPassPresentPaths.insert(filePath);
DeckPreviewWidget *deckPreviewWidget = deckWidgets.value(data.filePath, nullptr); DeckPreviewWidget *deckPreviewWidget = deckWidgets.value(filePath, nullptr);
if (!deckPreviewWidget) { if (!deckPreviewWidget) {
deckPreviewWidget = createDeckPreviewWidget(data.filePath); deckPreviewWidget = createDeckPreviewWidget(filePath);
} }
const bool accepted = proxy->mapFromSource(model->index(row, 0)).isValid(); const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool();
if (accepted == deckPreviewWidget->isHidden()) { if (matches == deckPreviewWidget->isHidden()) {
deckPreviewWidget->setVisible(accepted); deckPreviewWidget->setVisible(matches);
} }
if (accepted) { if (matches) {
++visibleDeckCount; ++visibleDeckCount;
} }
@ -141,7 +141,7 @@ void VisualDeckStorageFolderDisplayWidget::continueDeckPass()
return; return;
} }
if (deckPassRow < sourceRowCount) { if (deckPassRow < proxyRowCount) {
QMetaObject::invokeMethod(this, &VisualDeckStorageFolderDisplayWidget::continueDeckPass, Qt::QueuedConnection); QMetaObject::invokeMethod(this, &VisualDeckStorageFolderDisplayWidget::continueDeckPass, Qt::QueuedConnection);
return; return;
} }
@ -152,9 +152,8 @@ void VisualDeckStorageFolderDisplayWidget::continueDeckPass()
void VisualDeckStorageFolderDisplayWidget::finishDeckPass() void VisualDeckStorageFolderDisplayWidget::finishDeckPass()
{ {
auto *proxy = visualDeckStorageWidget->proxyModel(); auto *proxy = visualDeckStorageWidget->proxyModel();
auto *model = visualDeckStorageWidget->model();
// Drop previews of decks that no longer exist in the source model. // Drop previews of decks that no longer exist in the model.
for (auto it = deckWidgets.begin(); it != deckWidgets.end();) { for (auto it = deckWidgets.begin(); it != deckWidgets.end();) {
if (!deckPassPresentPaths.contains(it.key())) { if (!deckPassPresentPaths.contains(it.key())) {
flowWidget->removeWidget(it.value()); flowWidget->removeWidget(it.value());
@ -165,28 +164,16 @@ void VisualDeckStorageFolderDisplayWidget::finishDeckPass()
} }
} }
// Order the flow layout like the proxy sorts its rows. // Order the flow layout like the proxy sorts its rows. Filtered-out decks stay part of
// the layout, hidden in their sorted place until a filter lets them through again.
QStringList orderedFilePaths; QStringList orderedFilePaths;
orderedFilePaths.reserve(proxy->rowCount()); orderedFilePaths.reserve(proxy->rowCount());
for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) {
const QModelIndex sourceIndex = proxy->mapToSource(proxy->index(proxyRow, 0)); const QString filePath = proxy->index(proxyRow, 0).data(VisualDeckStorageRoles::FilePathRole).toString();
if (!sourceIndex.isValid()) {
continue;
}
const QString &filePath = model->dataForRow(sourceIndex.row()).filePath;
if (deckWidgets.contains(filePath)) { if (deckWidgets.contains(filePath)) {
orderedFilePaths.append(filePath); orderedFilePaths.append(filePath);
} }
} }
// Hidden previews are appended so they remain part of the layout and take their
// sorted place once a filter lets them through again.
QSet<QString> orderedSet(orderedFilePaths.cbegin(), orderedFilePaths.cend());
for (auto it = deckWidgets.constBegin(); it != deckWidgets.constEnd(); ++it) {
if (!orderedSet.contains(it.key())) {
orderedSet.insert(it.key());
orderedFilePaths.append(it.key());
}
}
// Re-add all widgets so the flow layout order matches the proxy order. Skipped when the // Re-add all widgets so the flow layout order matches the proxy order. Skipped when the
// order is unchanged so that data-only updates don't invalidate the flow layout. // order is unchanged so that data-only updates don't invalidate the flow layout.

View file

@ -4,9 +4,10 @@
* @brief Renders the decks of one folder of the Visual Deck Storage. * @brief Renders the decks of one folder of the Visual Deck Storage.
* *
* This is a pure view: it keeps one persistent DeckPreviewWidget alive per deck * This is a pure view: it keeps one persistent DeckPreviewWidget alive per deck
* in its folder, and shows or hides those widgets according to the accepted rows * in its folder, and shows or hides those widgets according to each row's
* of the VisualDeckStorageSortFilterProxyModel. Subfolders are shown as nested * FilterMatchRole in the VisualDeckStorageSortFilterProxyModel. Subfolders are
* VisualDeckStorageFolderDisplayWidgets when the "show folders" setting is enabled. * shown as nested VisualDeckStorageFolderDisplayWidgets when the "show folders"
* setting is enabled.
* *
* Reconciling runs as a time-budgeted chunked pass that yields to the event loop * Reconciling runs as a time-budgeted chunked pass that yields to the event loop
* between chunks, so scanning a large collection never stalls the ui thread. * between chunks, so scanning a large collection never stalls the ui thread.
@ -97,7 +98,7 @@ private:
bool deckPassRestartRequested = false; bool deckPassRestartRequested = false;
/// Whether the first reconcile pass has run to completion at least once. /// Whether the first reconcile pass has run to completion at least once.
bool initialPassCompleted = false; bool initialPassCompleted = false;
int deckPassRow = 0; ///< Next source row to scan in the active pass. int deckPassRow = 0; ///< Next proxy row to scan in the active pass.
QSet<QString> deckPassPresentPaths; ///< File paths seen so far in the active pass. QSet<QString> deckPassPresentPaths; ///< File paths seen so far in the active pass.
}; };

View file

@ -38,7 +38,14 @@ enum
LastModifiedRole, /**< QDateTime of the deck file's last modification. */ LastModifiedRole, /**< QDateTime of the deck file's last modification. */
LastLoadedRole, /**< QDateTime when the deck was last loaded from the file. */ LastLoadedRole, /**< QDateTime when the deck was last loaded from the file. */
BannerCardNameRole, /**< Name of the deck's banner card. */ BannerCardNameRole, /**< Name of the deck's banner card. */
BannerCardProviderIdRole /**< Provider id of the deck's banner card. */ BannerCardProviderIdRole, /**< Provider id of the deck's banner card. */
/**
* @brief Whether the row passes the proxy's current search / tag / color filters.
*
* Not served by this model, but by VisualDeckStorageSortFilterProxyModel on top
* of it. Declared here so every role read through a proxy index stays unique.
*/
FilterMatchRole
}; };
} // namespace VisualDeckStorageRoles } // namespace VisualDeckStorageRoles
@ -82,7 +89,8 @@ struct PendingDeckLoad
* @brief The list model backing the Visual Deck Storage widget tree. * @brief The list model backing the Visual Deck Storage widget tree.
* *
* Rows are in filesystem scan order; ordering and filtering are handled by * Rows are in filesystem scan order; ordering and filtering are handled by
* VisualDeckStorageSortFilterProxyModel on top of this model. * VisualDeckStorageSortFilterProxyModel on top of this model. The proxy keeps
* every row and exposes each row's filter result through its FilterMatchRole.
*/ */
class VisualDeckStorageModel : public QAbstractListModel class VisualDeckStorageModel : public QAbstractListModel
{ {

View file

@ -104,12 +104,21 @@ void VisualDeckStorageSortFilterProxyModel::resort()
sort(0); sort(0);
} }
bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const QVariant VisualDeckStorageSortFilterProxyModel::data(const QModelIndex &index, int role) const
{ {
if (sourceParent.isValid()) { if (role == VisualDeckStorageRoles::FilterMatchRole) {
return true; if (!index.isValid()) {
return true;
}
const QModelIndex sourceIndex = mapToSource(index);
return rowMatches(sourceIndex.row());
} }
return QSortFilterProxyModel::data(index, role);
}
bool VisualDeckStorageSortFilterProxyModel::rowMatches(int sourceRow) const
{
// If the match lists aren't sized to the current model yet, don't hide anything. // If the match lists aren't sized to the current model yet, don't hide anything.
if (sourceRow < 0 || sourceRow >= searchMatches.size() || sourceRow >= tagMatches.size() || if (sourceRow < 0 || sourceRow >= searchMatches.size() || sourceRow >= tagMatches.size() ||
sourceRow >= colorMatches.size()) { sourceRow >= colorMatches.size()) {
@ -119,6 +128,14 @@ bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int sourceRow, cons
return searchMatches.at(sourceRow) && tagMatches.at(sourceRow) && colorMatches.at(sourceRow); return searchMatches.at(sourceRow) && tagMatches.at(sourceRow) && colorMatches.at(sourceRow);
} }
bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int /*sourceRow*/,
const QModelIndex & /*sourceParent*/) const
{
// Rows are never dropped: the filter result is exposed per row through
// FilterMatchRole, so views can keep their widgets alive and just hide them.
return true;
}
bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{ {
const auto *source = deckSourceModel(); const auto *source = deckSourceModel();

View file

@ -6,6 +6,10 @@
* Owns all search / tag / color filter state and the sort order. Filtering is * Owns all search / tag / color filter state and the sort order. Filtering is
* evaluated against the model's data (never against widgets), so it can run * evaluated against the model's data (never against widgets), so it can run
* before any view exists and re-evaluate whenever deck data finishes loading. * before any view exists and re-evaluate whenever deck data finishes loading.
*
* Rows are never removed by filtering. Instead, every row carries the
* FilterMatchRole, which views read to show or hide their widgets while keeping
* them alive; all rows stay in the proxy so they keep their sorted position.
*/ */
#ifndef VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H #ifndef VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H
@ -47,6 +51,8 @@ public:
explicit VisualDeckStorageSortFilterProxyModel(QObject *parent = nullptr); explicit VisualDeckStorageSortFilterProxyModel(QObject *parent = nullptr);
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
void setSourceModel(QAbstractItemModel *model) override; void setSourceModel(QAbstractItemModel *model) override;
/// @name Filter input setters (each re-evaluates the affected matches) /// @name Filter input setters (each re-evaluates the affected matches)
@ -77,6 +83,7 @@ protected:
bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; bool lessThan(const QModelIndex &left, const QModelIndex &right) const override;
private: private:
[[nodiscard]] bool rowMatches(int sourceRow) const;
void resizeMatchLists(); void resizeMatchLists();
void updateSearchMatches(); void updateSearchMatches();
void updateTagMatches(); void updateTagMatches();

View file

@ -2,6 +2,8 @@
#include "../general/layout_containers/flow_widget.h" #include "../general/layout_containers/flow_widget.h"
#include "deck_preview/deck_preview_tag_display_widget.h" #include "deck_preview/deck_preview_tag_display_widget.h"
#include "visual_deck_storage_model.h"
#include "visual_deck_storage_sort_filter_proxy_model.h"
#include "visual_deck_storage_widget.h" #include "visual_deck_storage_widget.h"
#include <QHBoxLayout> #include <QHBoxLayout>
@ -36,14 +38,14 @@ QSet<QString> VisualDeckStorageTagFilterWidget::gatherAllTags() const
{ {
QSet<QString> allTags; QSet<QString> allTags;
auto *proxy = parent->proxyModel(); auto *proxy = parent->proxyModel();
auto *model = parent->model();
for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) {
const QModelIndex sourceIndex = proxy->mapToSource(proxy->index(proxyRow, 0)); const QModelIndex index = proxy->index(proxyRow, 0);
if (!sourceIndex.isValid()) { if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) {
continue; continue;
} }
for (const QString &tag : model->dataForRow(sourceIndex.row()).tags) { const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList();
for (const QString &tag : deckTags) {
allTags.insert(tag); allTags.insert(tag);
} }
} }

View file

@ -19,8 +19,9 @@
VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent) VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent)
{ {
// The model and proxy own all deck data, sorting and filtering. The view widgets below only // The model and proxy own all deck data, sorting and filtering. The view widgets below
// display the proxy's accepted rows, so nothing touches the filesystem outside the model. // only display the proxy's rows and their FilterMatchRole, so nothing touches the
// filesystem outside the model.
storageModel = new VisualDeckStorageModel(this); storageModel = new VisualDeckStorageModel(this);
storageProxyModel = new VisualDeckStorageSortFilterProxyModel(this); storageProxyModel = new VisualDeckStorageSortFilterProxyModel(this);
storageProxyModel->setSourceModel(storageModel); storageProxyModel->setSourceModel(storageModel);