Hide widgets instead of destroying, go back to signals, rename for consistency.

Took 13 minutes

Took 4 seconds

Took 26 minutes

Took 5 seconds

# Commit time for manual adjustment:
# Took 9 minutes
This commit is contained in:
Lukas Brübach 2026-08-23 12:45:21 +02:00
parent 7bbee72ec9
commit b6a7f6d52a
12 changed files with 305 additions and 135 deletions

View file

@ -6,7 +6,7 @@
#include <QSet> #include <QSet>
DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent)
: QWidget(parent), layout(new QHBoxLayout(this)), visualDeckStorageWidget(parent) : QWidget(parent), layout(new QHBoxLayout(this))
{ {
setLayout(layout); setLayout(layout);
layout->setSpacing(5); layout->setSpacing(5);
@ -55,9 +55,9 @@ void DeckPreviewColorIdentityFilterWidget::retranslateUi()
} }
/** /**
* @brief Pushes the current filter state to the proxy model. * @brief The colors that are currently toggled on.
*/ */
void DeckPreviewColorIdentityFilterWidget::applyFilter() QSet<QChar> DeckPreviewColorIdentityFilterWidget::getActiveColors() const
{ {
QSet<QChar> activeColorSet; QSet<QChar> activeColorSet;
for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) {
@ -65,15 +65,13 @@ void DeckPreviewColorIdentityFilterWidget::applyFilter()
activeColorSet.insert(it.key()); activeColorSet.insert(it.key());
} }
} }
return activeColorSet;
// The VDS owns the proxy model, which exists for the widget's whole lifetime.
visualDeckStorageWidget->proxyModel()->setColorFilter(filterMode, activeColorSet);
} }
void DeckPreviewColorIdentityFilterWidget::handleColorToggled(QChar color, bool active) void DeckPreviewColorIdentityFilterWidget::handleColorToggled(QChar color, bool active)
{ {
activeColors[color] = active; activeColors[color] = active;
applyFilter(); emit activeColorsChanged();
} }
void DeckPreviewColorIdentityFilterWidget::updateFilterMode() void DeckPreviewColorIdentityFilterWidget::updateFilterMode()
@ -92,5 +90,5 @@ void DeckPreviewColorIdentityFilterWidget::updateFilterMode()
} }
retranslateUi(); // Update the button text retranslateUi(); // Update the button text
applyFilter(); emit filterModeChanged(filterMode);
} }

View file

@ -11,6 +11,7 @@
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QMap> #include <QMap>
#include <QPushButton> #include <QPushButton>
#include <QSet>
#include <QWidget> #include <QWidget>
class VisualDeckStorageWidget; class VisualDeckStorageWidget;
@ -23,18 +24,40 @@ public:
explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent);
void retranslateUi(); void retranslateUi();
/**
* @brief The currently active color identity filter mode.
*/
[[nodiscard]] VisualDeckStorageSortFilterProxyModel::FilterMode getFilterMode() const
{
return filterMode;
}
/**
* @brief The colors that are currently toggled on.
*/
[[nodiscard]] QSet<QChar> getActiveColors() const;
signals:
/**
* Emitted when the set of active colors changed due to user interaction.
*/
void activeColorsChanged();
/**
* Emitted when the user cycles the color identity filter mode.
* @param mode The new filter mode.
*/
void filterModeChanged(VisualDeckStorageSortFilterProxyModel::FilterMode mode);
private slots: private slots:
void handleColorToggled(QChar color, bool active); void handleColorToggled(QChar color, bool active);
void updateFilterMode(); void updateFilterMode();
private: private:
void applyFilter();
QHBoxLayout *layout; QHBoxLayout *layout;
QPushButton *toggleButton; QPushButton *toggleButton;
QMap<QChar, bool> activeColors; QMap<QChar, bool> activeColors;
VisualDeckStorageSortFilterProxyModel::FilterMode filterMode = VisualDeckStorageSortFilterProxyModel::Includes; VisualDeckStorageSortFilterProxyModel::FilterMode filterMode = VisualDeckStorageSortFilterProxyModel::Includes;
VisualDeckStorageWidget *visualDeckStorageWidget; ///< The VDS owning the proxy model.
}; };
#endif // DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H #endif // DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H

View file

@ -52,12 +52,12 @@ void DeckPreviewDeckTagsDisplayWidget::refreshTags()
void DeckPreviewDeckTagsDisplayWidget::setKnownTagsProvider(const std::function<QStringList()> &provider) void DeckPreviewDeckTagsDisplayWidget::setKnownTagsProvider(const std::function<QStringList()> &provider)
{ {
knownTagsProvider_ = provider; knownTagsProvider = provider;
} }
void DeckPreviewDeckTagsDisplayWidget::setConversionPromptHandler(const std::function<bool()> &handler) void DeckPreviewDeckTagsDisplayWidget::setConversionPromptHandler(const std::function<bool()> &handler)
{ {
conversionPromptHandler_ = handler; conversionPromptHandler = handler;
} }
/** /**
@ -99,11 +99,11 @@ static QStringList findAllKnownTags()
void DeckPreviewDeckTagsDisplayWidget::openTagEditDlg() void DeckPreviewDeckTagsDisplayWidget::openTagEditDlg()
{ {
// The deck editor path has no conversion prompt; the VDS path registers one. // The deck editor path has no conversion prompt; the VDS path registers one.
if (conversionPromptHandler_ && !conversionPromptHandler_()) { if (conversionPromptHandler && !conversionPromptHandler()) {
return; return;
} }
const QStringList knownTags = knownTagsProvider_ ? knownTagsProvider_() : findAllKnownTags(); const QStringList knownTags = knownTagsProvider ? knownTagsProvider() : findAllKnownTags();
execTagDialog(knownTags); execTagDialog(knownTags);
} }

View file

@ -18,8 +18,8 @@ class DeckPreviewDeckTagsDisplayWidget : public QWidget
QStringList currentTags; QStringList currentTags;
FlowWidget *flowWidget; FlowWidget *flowWidget;
std::function<QStringList()> knownTagsProvider_; std::function<QStringList()> knownTagsProvider;
std::function<bool()> conversionPromptHandler_; std::function<bool()> conversionPromptHandler;
public: public:
explicit DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags = {}); explicit DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags = {});

View file

@ -27,9 +27,9 @@
DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
VisualDeckStorageWidget *_visualDeckStorageWidget, VisualDeckStorageWidget *_visualDeckStorageWidget,
VisualDeckStorageModel *model, VisualDeckStorageModel *_model,
const QString &_filePath) const QString &_filePath)
: QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model_(model), filePath(_filePath) : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath)
{ {
layout = new QVBoxLayout(this); layout = new QVBoxLayout(this);
setLayout(layout); setLayout(layout);
@ -83,7 +83,16 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
layout->addWidget(bannerCardLabel); layout->addWidget(bannerCardLabel);
layout->addWidget(bannerCardComboBox); layout->addWidget(bannerCardComboBox);
connect(model_, &QAbstractItemModel::dataChanged, this, &DeckPreviewWidget::syncFromModel); // Only re-sync when this widget's own row changed. Without the row check, every
// finished deck load would trigger a full resync (card db lookup + combo rebuild)
// in every preview widget.
connect(model, &QAbstractItemModel::dataChanged, this,
[this](const QModelIndex &topLeft, const QModelIndex &bottomRight) {
const int r = row();
if (r >= topLeft.row() && r <= bottomRight.row()) {
syncFromModel();
}
});
retranslateUi(); retranslateUi();
syncFromModel(); syncFromModel();
@ -119,7 +128,7 @@ void DeckPreviewWidget::enterEvent(QEnterEvent *event)
*/ */
int DeckPreviewWidget::row() const int DeckPreviewWidget::row() const
{ {
return model_->rowForFilePath(filePath); return model->rowForFilePath(filePath);
} }
/** /**
@ -131,7 +140,7 @@ QString DeckPreviewWidget::getDisplayName() const
if (r == -1) { if (r == -1) {
return {}; return {};
} }
return model_->dataForRow(r).displayName; return model->dataForRow(r).displayName;
} }
/** /**
@ -140,11 +149,11 @@ QString DeckPreviewWidget::getDisplayName() const
void DeckPreviewWidget::reloadIfModified() void DeckPreviewWidget::reloadIfModified()
{ {
const int r = row(); const int r = row();
if (r == -1 || !model_->dataForRow(r).loadSucceeded) { if (r == -1 || !model->dataForRow(r).loadSucceeded) {
return; return;
} }
model_->reloadIfModified(r); model->reloadIfModified(r);
} }
/** /**
@ -157,7 +166,7 @@ void DeckPreviewWidget::syncFromModel()
return; return;
} }
const DeckPreviewData &data = model_->dataForRow(r); const DeckPreviewData &data = model->dataForRow(r);
filePath = data.filePath; filePath = data.filePath;
const CardRef bannerCardRef = data.deck.deckList.getBannerCard(); const CardRef bannerCardRef = data.deck.deckList.getBannerCard();
@ -246,7 +255,7 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString &currentText)
const int r = row(); const int r = row();
if (r != -1) { if (r != -1) {
const DeckList &deckList = model_->dataForRow(r).deck.deckList; const DeckList &deckList = model->dataForRow(r).deck.deckList;
const QList<const DecklistCardNode *> cardsInDeck = deckList.getCardNodes(); const QList<const DecklistCardNode *> cardsInDeck = deckList.getCardNodes();
for (auto currentCard : cardsInDeck) { for (auto currentCard : cardsInDeck) {
@ -265,16 +274,16 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString &currentText)
// This is *slightly* more performant than using addItem in a loop. // This is *slightly* more performant than using addItem in a loop.
QStandardItemModel *model = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); QStandardItemModel *comboModel = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox);
int row = 0; int row = 0;
for (const auto &pair : pairList) { for (const auto &pair : pairList) {
QStandardItem *item = new QStandardItem(pair.first); QStandardItem *item = new QStandardItem(pair.first);
item->setData(QVariant::fromValue(pair), Qt::UserRole); item->setData(QVariant::fromValue(pair), Qt::UserRole);
model->setItem(row++, 0, item); comboModel->setItem(row++, 0, item);
} }
bannerCardComboBox->setModel(model); bannerCardComboBox->setModel(comboModel);
// Try to restore the previous selection by finding the currentText // Try to restore the previous selection by finding the currentText
int restoredIndex = bannerCardComboBox->findText(currentText); int restoredIndex = bannerCardComboBox->findText(currentText);
@ -283,7 +292,7 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString &currentText)
} else { } else {
// Add a placeholder "-" and set it as the current selection // Add a placeholder "-" and set it as the current selection
const QString currentBannerCardName = const QString currentBannerCardName =
r == -1 ? QString() : model_->dataForRow(r).deck.deckList.getBannerCard().name; r == -1 ? QString() : model->dataForRow(r).deck.deckList.getBannerCard().name;
int bannerIndex = bannerCardComboBox->findText(currentBannerCardName); int bannerIndex = bannerCardComboBox->findText(currentBannerCardName);
if (bannerIndex != -1) { if (bannerIndex != -1) {
bannerCardComboBox->setCurrentIndex(bannerIndex); bannerCardComboBox->setCurrentIndex(bannerIndex);
@ -306,7 +315,7 @@ void DeckPreviewWidget::setBannerCard(int /* changedIndex */)
if (r == -1) { if (r == -1) {
return; return;
} }
model_->setBannerCard(r, cardRef); model->setBannerCard(r, cardRef);
bannerCardDisplayWidget->setCard(CardDatabaseManager::query()->getCard(cardRef)); bannerCardDisplayWidget->setCard(CardDatabaseManager::query()->getCard(cardRef));
} }
@ -330,7 +339,7 @@ void DeckPreviewWidget::setTags(const QStringList &tags)
{ {
const int r = row(); const int r = row();
if (r != -1) { if (r != -1) {
model_->setTags(r, tags); model->setTags(r, tags);
} }
} }
@ -343,7 +352,7 @@ QMenu *DeckPreviewWidget::createRightClickMenu()
connect(menu->addAction(tr("Open in deck editor")), &QAction::triggered, this, [this, r] { connect(menu->addAction(tr("Open in deck editor")), &QAction::triggered, this, [this, r] {
if (r != -1) { if (r != -1) {
emit openDeckEditor(model_->deckForRow(r)); emit openDeckEditor(model->deckForRow(r));
} }
}); });
@ -360,22 +369,22 @@ QMenu *DeckPreviewWidget::createRightClickMenu()
connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, [this, r] { connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, [this, r] {
if (r != -1) { if (r != -1) {
DeckLoader::saveToClipboard(model_->dataForRow(r).deck.deckList, true, true); DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, true);
} }
}); });
connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, [this, r] { connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, [this, r] {
if (r != -1) { if (r != -1) {
DeckLoader::saveToClipboard(model_->dataForRow(r).deck.deckList, true, false); DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, false);
} }
}); });
connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, [this, r] { connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, [this, r] {
if (r != -1) { if (r != -1) {
DeckLoader::saveToClipboard(model_->dataForRow(r).deck.deckList, false, true); DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, true);
} }
}); });
connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, [this, r] { connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, [this, r] {
if (r != -1) { if (r != -1) {
DeckLoader::saveToClipboard(model_->dataForRow(r).deck.deckList, false, false); DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, false);
} }
}); });
@ -418,7 +427,7 @@ void DeckPreviewWidget::actRenameDeck()
} }
// read input // read input
const QString oldName = model_->dataForRow(r).deckName; const QString oldName = model->dataForRow(r).deckName;
bool ok; bool ok;
QString newName = QInputDialog::getText(this, tr("Rename deck"), tr("New name:"), QLineEdit::Normal, oldName, &ok); QString newName = QInputDialog::getText(this, tr("Rename deck"), tr("New name:"), QLineEdit::Normal, oldName, &ok);
@ -427,7 +436,7 @@ void DeckPreviewWidget::actRenameDeck()
} }
// write change // write change
model_->renameDeck(r, newName); model->renameDeck(r, newName);
// The banner card text updates via the model's dataChanged signal. // The banner card text updates via the model's dataChanged signal.
} }
@ -450,7 +459,7 @@ void DeckPreviewWidget::actRenameFile()
} }
// write change // write change
if (!model_->renameFile(r, newName)) { if (!model->renameFile(r, newName)) {
QMessageBox::critical(this, tr("Error"), tr("Rename failed")); QMessageBox::critical(this, tr("Error"), tr("Rename failed"));
} }
@ -472,7 +481,7 @@ void DeckPreviewWidget::actDeleteFile()
} }
// write change // write change
if (!model_->deleteFile(r)) { if (!model->deleteFile(r)) {
QMessageBox::critical(this, tr("Error"), tr("Delete failed")); QMessageBox::critical(this, tr("Error"), tr("Delete failed"));
} }
@ -515,7 +524,7 @@ bool DeckPreviewWidget::promptFileConversionIfRequired()
return false; return false;
} }
model_->convertToCockatriceFormat(row()); model->convertToCockatriceFormat(row());
return true; return true;
} }
@ -534,7 +543,7 @@ bool DeckPreviewWidget::promptFileConversionIfRequired()
return false; return false;
} }
model_->convertToCockatriceFormat(row()); model->convertToCockatriceFormat(row());
if (conversionDialog.dontAskAgain()) { if (conversionDialog.dontAskAgain()) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false);

View file

@ -32,7 +32,7 @@ class DeckPreviewWidget final : public QWidget
public: public:
explicit DeckPreviewWidget(QWidget *parent, explicit DeckPreviewWidget(QWidget *parent,
VisualDeckStorageWidget *_visualDeckStorageWidget, VisualDeckStorageWidget *_visualDeckStorageWidget,
VisualDeckStorageModel *model, VisualDeckStorageModel *_model,
const QString &_filePath); const QString &_filePath);
void retranslateUi(); void retranslateUi();
@ -83,7 +83,7 @@ private:
void actDeleteFile(); void actDeleteFile();
VisualDeckStorageWidget *visualDeckStorageWidget; VisualDeckStorageWidget *visualDeckStorageWidget;
VisualDeckStorageModel *model_; VisualDeckStorageModel *model;
QString filePath; QString filePath;
QVBoxLayout *layout; QVBoxLayout *layout;
ColorIdentityWidget *colorIdentityWidget; ColorIdentityWidget *colorIdentityWidget;

View file

@ -9,6 +9,7 @@
#include "visual_deck_storage_sort_filter_proxy_model.h" #include "visual_deck_storage_sort_filter_proxy_model.h"
#include "visual_deck_storage_widget.h" #include "visual_deck_storage_widget.h"
#include <QElapsedTimer>
#include <QSet> #include <QSet>
#include <QTimer> #include <QTimer>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -59,55 +60,103 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget(
reconcileTimer->setInterval(150); reconcileTimer->setInterval(150);
connect(reconcileTimer, &QTimer::timeout, this, &VisualDeckStorageFolderDisplayWidget::reconcile); connect(reconcileTimer, &QTimer::timeout, this, &VisualDeckStorageFolderDisplayWidget::reconcile);
reconcile(); // Building the whole folder subtree synchronously here would stall the ui thread on large
// collections, so the first reconcile runs as a chunked pass on later event loop turns.
scheduleReconcile();
} }
void VisualDeckStorageFolderDisplayWidget::scheduleReconcile() void VisualDeckStorageFolderDisplayWidget::scheduleReconcile()
{ {
if (deckPassActive) {
// The active pass may be scanning stale model state, so restart it from a clean
// slate once the current chunk yields.
deckPassRestartRequested = true;
return;
}
reconcileTimer->start(); reconcileTimer->start();
} }
/** /**
* @brief Re-reads the proxy and rebuilds the deck previews and subfolder widgets to match. * @brief Starts a new chunked scan of the source model, yielding to the event loop between chunks.
*/ */
void VisualDeckStorageFolderDisplayWidget::reconcile() void VisualDeckStorageFolderDisplayWidget::reconcile()
{ {
reconcileDecks(); beginDeckPass();
createSubFolderWidgets();
refreshVisibility();
} }
/** void VisualDeckStorageFolderDisplayWidget::beginDeckPass()
* @brief Reconciles the deck preview widgets in the flow layout with the accepted proxy rows. {
* deckPassActive = true;
* Decks are keyed by file path so that already-created preview widgets are reused deckPassRestartRequested = false;
* (their banner combo box, tag editor state, etc. is preserved) whenever possible. deckPassRow = 0;
*/ visibleDeckCount = 0;
void VisualDeckStorageFolderDisplayWidget::reconcileDecks() deckPassPresentPaths.clear();
continueDeckPass();
}
void VisualDeckStorageFolderDisplayWidget::continueDeckPass()
{
if (!deckPassActive) {
return;
}
QElapsedTimer passTimer;
passTimer.start();
auto *proxy = visualDeckStorageWidget->proxyModel();
auto *model = visualDeckStorageWidget->model();
const int sourceRowCount = model->rowCount();
// 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.
while (deckPassRow < sourceRowCount) {
const int row = deckPassRow++;
const DeckPreviewData &data = model->dataForRow(row);
if (showFolders && data.folderPath != folderPath) {
continue;
}
deckPassPresentPaths.insert(data.filePath);
DeckPreviewWidget *deckPreviewWidget = deckWidgets.value(data.filePath, nullptr);
if (!deckPreviewWidget) {
deckPreviewWidget = createDeckPreviewWidget(data.filePath);
}
const bool accepted = proxy->mapFromSource(model->index(row, 0)).isValid();
if (accepted == deckPreviewWidget->isHidden()) {
deckPreviewWidget->setVisible(accepted);
}
if (accepted) {
++visibleDeckCount;
}
if (passTimer.elapsed() >= DECK_PASS_TIME_BUDGET_MS) {
break;
}
}
if (deckPassRestartRequested) {
beginDeckPass();
return;
}
if (deckPassRow < sourceRowCount) {
QMetaObject::invokeMethod(this, &VisualDeckStorageFolderDisplayWidget::continueDeckPass, Qt::QueuedConnection);
return;
}
finishDeckPass();
}
void VisualDeckStorageFolderDisplayWidget::finishDeckPass()
{ {
auto *proxy = visualDeckStorageWidget->proxyModel(); auto *proxy = visualDeckStorageWidget->proxyModel();
auto *model = visualDeckStorageWidget->model(); auto *model = visualDeckStorageWidget->model();
// Collect the file paths this folder should display, in proxy (sorted) order. // Drop previews of decks that no longer exist in the source model.
QStringList desiredOrder;
for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) {
const QModelIndex sourceIndex = proxy->mapToSource(proxy->index(proxyRow, 0));
if (!sourceIndex.isValid()) {
continue;
}
const DeckPreviewData &data = model->dataForRow(sourceIndex.row());
if (showFolders && data.folderPath != folderPath) {
continue;
}
desiredOrder.append(data.filePath);
}
const QSet<QString> desiredSet(desiredOrder.cbegin(), desiredOrder.cend());
// Drop previews of decks that no longer match.
for (auto it = deckWidgets.begin(); it != deckWidgets.end();) { for (auto it = deckWidgets.begin(); it != deckWidgets.end();) {
if (!desiredSet.contains(it.key())) { if (!deckPassPresentPaths.contains(it.key())) {
flowWidget->removeWidget(it.value()); flowWidget->removeWidget(it.value());
it.value()->deleteLater(); it.value()->deleteLater();
it = deckWidgets.erase(it); it = deckWidgets.erase(it);
@ -116,37 +165,69 @@ void VisualDeckStorageFolderDisplayWidget::reconcileDecks()
} }
} }
// Create or reuse the preview widgets in the desired order. // Order the flow layout like the proxy sorts its rows.
QList<DeckPreviewWidget *> orderedWidgets; QStringList orderedFilePaths;
orderedWidgets.reserve(desiredOrder.size()); orderedFilePaths.reserve(proxy->rowCount());
for (const QString &filePath : desiredOrder) { for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) {
DeckPreviewWidget *deckPreviewWidget = deckWidgets.value(filePath, nullptr); const QModelIndex sourceIndex = proxy->mapToSource(proxy->index(proxyRow, 0));
if (!deckPreviewWidget) { if (!sourceIndex.isValid()) {
deckPreviewWidget = new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, model, filePath); continue;
connect(deckPreviewWidget, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget, }
&VisualDeckStorageWidget::deckLoadRequested); const QString &filePath = model->dataForRow(sourceIndex.row()).filePath;
connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, if (deckWidgets.contains(filePath)) {
&VisualDeckStorageWidget::openDeckEditor); orderedFilePaths.append(filePath);
connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, }
deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); }
deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor( // Hidden previews are appended so they remain part of the layout and take their
visualDeckStorageWidget->settings()->getCardSize()); // sorted place once a filter lets them through again.
deckWidgets.insert(filePath, deckPreviewWidget); 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());
} }
orderedWidgets.append(deckPreviewWidget);
} }
// 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.
if (desiredOrder != lastOrderedFilePaths) { if (orderedFilePaths != lastOrderedFilePaths) {
for (DeckPreviewWidget *deckPreviewWidget : orderedWidgets) { for (const QString &filePath : orderedFilePaths) {
flowWidget->removeWidget(deckPreviewWidget); flowWidget->removeWidget(deckWidgets.value(filePath));
} }
for (DeckPreviewWidget *deckPreviewWidget : orderedWidgets) { for (const QString &filePath : orderedFilePaths) {
flowWidget->addWidget(deckPreviewWidget); flowWidget->addWidget(deckWidgets.value(filePath));
} }
lastOrderedFilePaths = desiredOrder; lastOrderedFilePaths = orderedFilePaths;
} }
createSubFolderWidgets();
// Mark completion before evaluating visibility so this pass's own numbers decide whether
// the folder has content. The flag only guards evaluations made *during* a build.
deckPassActive = false;
initialPassCompleted = true;
refreshVisibility();
}
/**
* @brief Creates a deck preview widget and wires it up to the storage widget.
*
* @param filePath The absolute path of the deck file to preview.
*/
DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget(const QString &filePath)
{
auto *deckPreviewWidget =
new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, visualDeckStorageWidget->model(), filePath);
connect(deckPreviewWidget, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget,
&VisualDeckStorageWidget::deckLoadRequested);
connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget,
&VisualDeckStorageWidget::openDeckEditor);
connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged,
deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor);
deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize());
deckWidgets.insert(filePath, deckPreviewWidget);
return deckPreviewWidget;
} }
/** /**
@ -200,7 +281,7 @@ void VisualDeckStorageFolderDisplayWidget::updateShowFolders(bool enabled)
subFolderWidgets.clear(); subFolderWidgets.clear();
} }
reconcile(); scheduleReconcile();
} }
/** /**
@ -218,10 +299,13 @@ void VisualDeckStorageFolderDisplayWidget::refreshVisibility()
/** /**
* @brief Whether this folder shows any deck previews or has any visible subfolder. * @brief Whether this folder shows any deck previews or has any visible subfolder.
*
* While the first pass is still building, the folder counts as having content so it
* doesn't flicker or hide prematurely before its previews have been created.
*/ */
bool VisualDeckStorageFolderDisplayWidget::hasContent() const bool VisualDeckStorageFolderDisplayWidget::hasContent() const
{ {
if (!deckWidgets.isEmpty()) { if (!initialPassCompleted || visibleDeckCount > 0) {
return true; return true;
} }

View file

@ -3,17 +3,20 @@
* @ingroup VisualDeckStorageWidgets * @ingroup VisualDeckStorageWidgets
* @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 reads the accepted rows of the * This is a pure view: it keeps one persistent DeckPreviewWidget alive per deck
* VisualDeckStorageSortFilterProxyModel whose folder matches this widget's * in its folder, and shows or hides those widgets according to the accepted rows
* folder path, and keeps a set of child DeckPreviewWidgets in sync with those * of the VisualDeckStorageSortFilterProxyModel. Subfolders are shown as nested
* rows. Subfolders are shown as nested VisualDeckStorageFolderDisplayWidgets * VisualDeckStorageFolderDisplayWidgets when the "show folders" setting is enabled.
* when the "show folders" setting is enabled. *
* 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.
*/ */
#ifndef VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H #ifndef VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H
#define VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H #define VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H
#include <QHash> #include <QHash>
#include <QSet>
#include <QString> #include <QString>
#include <QWidget> #include <QWidget>
@ -36,8 +39,8 @@ public:
public slots: public slots:
/** /**
* @brief Re-reads the proxy and rebuilds the deck previews and subfolder * @brief Starts a new chunked reconcile pass that re-reads the proxy and rebuilds
* widgets to match. * the deck previews and subfolder widgets to match.
*/ */
void reconcile(); void reconcile();
@ -56,14 +59,26 @@ signals:
void contentVisibilityChanged(); void contentVisibilityChanged();
private: private:
void reconcileDecks(); void beginDeckPass();
void continueDeckPass();
void finishDeckPass();
[[nodiscard]] DeckPreviewWidget *createDeckPreviewWidget(const QString &filePath);
void createSubFolderWidgets(); void createSubFolderWidgets();
void refreshVisibility(); void refreshVisibility();
[[nodiscard]] bool hasContent() const; [[nodiscard]] bool hasContent() const;
[[nodiscard]] QStringList childFolderPaths() const; [[nodiscard]] QStringList childFolderPaths() const;
/**
* @brief The maximum time in milliseconds spent creating deck previews per event loop turn.
*
* Creating all previews of a large folder at once blocks the ui thread for hundreds of
* milliseconds, so the pass is split into chunks that yield to the event loop instead.
*/
static constexpr int DECK_PASS_TIME_BUDGET_MS = 20;
bool showFolders; bool showFolders;
QString folderPath; ///< Path relative to the deck folder; empty for the root folder. QString folderPath; ///< Path relative to the deck folder, empty for the root folder.
int visibleDeckCount = 0; ///< The number of this folder's deck previews not filtered out.
QVBoxLayout *layout; QVBoxLayout *layout;
QWidget *container; QWidget *container;
QVBoxLayout *containerLayout; QVBoxLayout *containerLayout;
@ -74,6 +89,16 @@ private:
QHash<QString, VisualDeckStorageFolderDisplayWidget *> subFolderWidgets; ///< Folder path -> subfolder widget. QHash<QString, VisualDeckStorageFolderDisplayWidget *> subFolderWidgets; ///< Folder path -> subfolder widget.
QTimer *reconcileTimer = nullptr; ///< Coalesces proxy change bursts. QTimer *reconcileTimer = nullptr; ///< Coalesces proxy change bursts.
QStringList lastOrderedFilePaths; ///< The deck order last applied to the flow layout. QStringList lastOrderedFilePaths; ///< The deck order last applied to the flow layout.
/// Whether a chunked reconcile pass is currently running.
bool deckPassActive = false;
/// Set when the model changes mid-pass. Discards progress and restarts the scan once
/// the current chunk finishes so the pass always converges on the latest model state.
bool deckPassRestartRequested = false;
/// Whether the first reconcile pass has run to completion at least once.
bool initialPassCompleted = false;
int deckPassRow = 0; ///< Next source row to scan in the active pass.
QSet<QString> deckPassPresentPaths; ///< File paths seen so far in the active pass.
}; };
#endif // VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H #endif // VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H

View file

@ -2,7 +2,6 @@
#include "../../../filters/syntax_help.h" #include "../../../filters/syntax_help.h"
#include "../../pixel_map_generator.h" #include "../../pixel_map_generator.h"
#include "visual_deck_storage_widget.h"
#include <QAction> #include <QAction>
#include <QTimer> #include <QTimer>
@ -13,9 +12,9 @@
* Provides a search bar that allows users to search decks by filename or search * Provides a search bar that allows users to search decks by filename or search
* expression, with a debounced timer to trigger the search after the user stops typing. * expression, with a debounced timer to trigger the search after the user stops typing.
* *
* @param parent The VisualDeckStorageWidget owning the search. * @param parent The parent widget.
*/ */
VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent) : parent(parent) VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : QWidget(parent)
{ {
layout = new QHBoxLayout(this); layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0); layout->setContentsMargins(0, 0, 0, 0);
@ -38,6 +37,5 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWi
searchDebounceTimer->start(300); // 300ms debounce searchDebounceTimer->start(300); // 300ms debounce
}); });
connect(searchDebounceTimer, &QTimer::timeout, this, connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); });
[this] { this->parent->proxyModel()->setSearchText(searchBar->text()); });
} }

View file

@ -11,17 +11,23 @@
#include <QWidget> #include <QWidget>
class QTimer; class QTimer;
class VisualDeckStorageWidget;
class VisualDeckStorageSearchWidget : public QWidget class VisualDeckStorageSearchWidget : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent); explicit VisualDeckStorageSearchWidget(QWidget *parent);
signals:
/**
* Emitted once the debounce timer fires after the user stopped typing.
* @param text The current contents of the search bar.
*/
void searchTextChanged(const QString &text);
private: private:
QHBoxLayout *layout; QHBoxLayout *layout;
VisualDeckStorageWidget *parent;
QLineEdit *searchBar; QLineEdit *searchBar;
QTimer *searchDebounceTimer; QTimer *searchDebounceTimer;
}; };

View file

@ -21,9 +21,9 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
{ {
// 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 only
// display the proxy's accepted rows, so nothing touches the filesystem outside the model. // display the proxy's accepted rows, so nothing touches the filesystem outside the model.
model_ = new VisualDeckStorageModel(this); storageModel = new VisualDeckStorageModel(this);
proxyModel_ = new VisualDeckStorageSortFilterProxyModel(this); storageProxyModel = new VisualDeckStorageSortFilterProxyModel(this);
proxyModel_->setSourceModel(model_); storageProxyModel->setSourceModel(storageModel);
layout = new QVBoxLayout(this); layout = new QVBoxLayout(this);
layout->setSpacing(0); layout->setSpacing(0);
@ -86,17 +86,25 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
refreshTimer->setSingleShot(true); refreshTimer->setSingleShot(true);
refreshTimer->setInterval(150); refreshTimer->setInterval(150);
connect(refreshTimer, &QTimer::timeout, this, [this] { connect(refreshTimer, &QTimer::timeout, this, [this] {
proxyModel_->reapplyFilters(); storageProxyModel->reapplyFilters();
// A batch of decks finished loading: re-gather the tag chips from the visible decks once // A batch of decks finished loading: re-gather the tag chips from the visible decks once
// the burst settles instead of on every individual load. // the burst settles instead of on every individual load.
tagFilterWidget->refreshTags(); tagFilterWidget->refreshTags();
}); });
connect(model_, &QAbstractItemModel::dataChanged, this, [this] { refreshTimer->start(); }); connect(storageModel, &QAbstractItemModel::dataChanged, this, [this] { refreshTimer->start(); });
connect(model_, &VisualDeckStorageModel::deckLoaded, this, [this] { refreshTimer->start(); }); connect(storageModel, &VisualDeckStorageModel::deckLoaded, this, [this] { refreshTimer->start(); });
// A deck's file path changed: re-apply the sort, since orders like "filename" depend on it. // A deck's file path changed: re-apply the sort, since orders like "filename" depend on it.
connect(model_, &VisualDeckStorageModel::deckFilePathChanged, this, [this] { proxyModel_->resort(); }); connect(storageModel, &VisualDeckStorageModel::deckFilePathChanged, this, [this] { storageProxyModel->resort(); });
connect(sortWidget, &VisualDeckStorageSortWidget::sortOrderChanged, this, connect(sortWidget, &VisualDeckStorageSortWidget::sortOrderChanged, this,
&VisualDeckStorageWidget::updateSortOrder); &VisualDeckStorageWidget::updateSortOrder);
// The filter widgets only own their ui state. Pushing it into the proxy model
// happens here, so the children stay decoupled from the model layer.
connect(deckPreviewColorIdentityFilterWidget, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this,
&VisualDeckStorageWidget::updateColorFilter);
connect(deckPreviewColorIdentityFilterWidget, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this,
&VisualDeckStorageWidget::updateColorFilter);
connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this,
&VisualDeckStorageWidget::updateSearchFilter);
connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this,
&VisualDeckStorageWidget::createRootFolderWidget); &VisualDeckStorageWidget::createRootFolderWidget);
@ -163,8 +171,8 @@ const VisualDeckStorageQuickSettingsWidget *VisualDeckStorageWidget::settings()
*/ */
void VisualDeckStorageWidget::reapplySortAndFilters() void VisualDeckStorageWidget::reapplySortAndFilters()
{ {
proxyModel_->setSortOrder(sortWidget->currentSortOrder()); storageProxyModel->setSortOrder(sortWidget->currentSortOrder());
proxyModel_->reapplyFilters(); storageProxyModel->reapplyFilters();
} }
/** /**
@ -172,7 +180,7 @@ void VisualDeckStorageWidget::reapplySortAndFilters()
*/ */
void VisualDeckStorageWidget::createRootFolderWidget() void VisualDeckStorageWidget::createRootFolderWidget()
{ {
model_->setDeckPath(SettingsCache::instance().paths().getDeckPath()); storageModel->setDeckPath(SettingsCache::instance().paths().getDeckPath());
folderWidget = folderWidget =
new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders());
@ -194,19 +202,36 @@ void VisualDeckStorageWidget::updateShowFolders(bool enabled)
void VisualDeckStorageWidget::updateSortOrder() void VisualDeckStorageWidget::updateSortOrder()
{ {
proxyModel_->setSortOrder(sortWidget->currentSortOrder()); storageProxyModel->setSortOrder(sortWidget->currentSortOrder());
} }
void VisualDeckStorageWidget::updateTagFilter() void VisualDeckStorageWidget::updateTagFilter()
{ {
const QStringList selected = tagFilterWidget->selectedTags(); const QStringList selected = tagFilterWidget->selectedTags();
const QStringList excluded = tagFilterWidget->excludedTags(); const QStringList excluded = tagFilterWidget->excludedTags();
proxyModel_->setTagFilter(QSet<QString>(selected.cbegin(), selected.cend()), storageProxyModel->setTagFilter(QSet<QString>(selected.cbegin(), selected.cend()),
QSet<QString>(excluded.cbegin(), excluded.cend())); QSet<QString>(excluded.cbegin(), excluded.cend()));
// The visible deck set changed, so the chips are re-gathered from it. // The visible deck set changed, so the chips are re-gathered from it.
tagFilterWidget->refreshTags(); tagFilterWidget->refreshTags();
} }
/**
* Pushes the color identity filter widget's state into the proxy model.
*/
void VisualDeckStorageWidget::updateColorFilter()
{
storageProxyModel->setColorFilter(deckPreviewColorIdentityFilterWidget->getFilterMode(),
deckPreviewColorIdentityFilterWidget->getActiveColors());
}
/**
* Pushes the search bar's text into the proxy model.
*/
void VisualDeckStorageWidget::updateSearchFilter(const QString &text)
{
storageProxyModel->setSearchText(text);
}
void VisualDeckStorageWidget::updateTagsVisibility(const bool visible) void VisualDeckStorageWidget::updateTagsVisibility(const bool visible)
{ {
if (visible) { if (visible) {

View file

@ -40,11 +40,11 @@ public:
[[nodiscard]] const VisualDeckStorageQuickSettingsWidget *settings() const; [[nodiscard]] const VisualDeckStorageQuickSettingsWidget *settings() const;
[[nodiscard]] VisualDeckStorageModel *model() const [[nodiscard]] VisualDeckStorageModel *model() const
{ {
return model_; return storageModel;
} }
[[nodiscard]] VisualDeckStorageSortFilterProxyModel *proxyModel() const [[nodiscard]] VisualDeckStorageSortFilterProxyModel *proxyModel() const
{ {
return proxyModel_; return storageProxyModel;
} }
public slots: public slots:
@ -57,6 +57,8 @@ public slots:
void updateSelectionAnimationEnabled(bool enabled); void updateSelectionAnimationEnabled(bool enabled);
void updateSortOrder(); void updateSortOrder();
void updateTagFilter(); void updateTagFilter();
void updateColorFilter();
void updateSearchFilter(const QString &text);
signals: signals:
void deckLoadRequested(const QString &filePath); void deckLoadRequested(const QString &filePath);
@ -81,8 +83,8 @@ private:
VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; VisualDeckStorageQuickSettingsWidget *quickSettingsWidget;
QScrollArea *scrollArea; QScrollArea *scrollArea;
VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr; VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr;
VisualDeckStorageModel *model_ = nullptr; VisualDeckStorageModel *storageModel = nullptr;
VisualDeckStorageSortFilterProxyModel *proxyModel_ = nullptr; VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr;
QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads. QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads.
}; };