diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp index fc0e5c2b3..fd529ff69 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp @@ -6,7 +6,7 @@ #include DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) - : QWidget(parent), layout(new QHBoxLayout(this)), visualDeckStorageWidget(parent) + : QWidget(parent), layout(new QHBoxLayout(this)) { setLayout(layout); 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 DeckPreviewColorIdentityFilterWidget::getActiveColors() const { QSet activeColorSet; for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { @@ -65,15 +65,13 @@ void DeckPreviewColorIdentityFilterWidget::applyFilter() activeColorSet.insert(it.key()); } } - - // The VDS owns the proxy model, which exists for the widget's whole lifetime. - visualDeckStorageWidget->proxyModel()->setColorFilter(filterMode, activeColorSet); + return activeColorSet; } void DeckPreviewColorIdentityFilterWidget::handleColorToggled(QChar color, bool active) { activeColors[color] = active; - applyFilter(); + emit activeColorsChanged(); } void DeckPreviewColorIdentityFilterWidget::updateFilterMode() @@ -92,5 +90,5 @@ void DeckPreviewColorIdentityFilterWidget::updateFilterMode() } retranslateUi(); // Update the button text - applyFilter(); + emit filterModeChanged(filterMode); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h index 5b6a05646..def45de66 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h @@ -11,6 +11,7 @@ #include #include #include +#include #include class VisualDeckStorageWidget; @@ -23,18 +24,40 @@ public: explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); 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 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: void handleColorToggled(QChar color, bool active); void updateFilterMode(); private: - void applyFilter(); - QHBoxLayout *layout; QPushButton *toggleButton; QMap activeColors; VisualDeckStorageSortFilterProxyModel::FilterMode filterMode = VisualDeckStorageSortFilterProxyModel::Includes; - VisualDeckStorageWidget *visualDeckStorageWidget; ///< The VDS owning the proxy model. }; #endif // DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index 8c6557ad0..41d87ccb1 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp @@ -52,12 +52,12 @@ void DeckPreviewDeckTagsDisplayWidget::refreshTags() void DeckPreviewDeckTagsDisplayWidget::setKnownTagsProvider(const std::function &provider) { - knownTagsProvider_ = provider; + knownTagsProvider = provider; } void DeckPreviewDeckTagsDisplayWidget::setConversionPromptHandler(const std::function &handler) { - conversionPromptHandler_ = handler; + conversionPromptHandler = handler; } /** @@ -99,11 +99,11 @@ static QStringList findAllKnownTags() void DeckPreviewDeckTagsDisplayWidget::openTagEditDlg() { // The deck editor path has no conversion prompt; the VDS path registers one. - if (conversionPromptHandler_ && !conversionPromptHandler_()) { + if (conversionPromptHandler && !conversionPromptHandler()) { return; } - const QStringList knownTags = knownTagsProvider_ ? knownTagsProvider_() : findAllKnownTags(); + const QStringList knownTags = knownTagsProvider ? knownTagsProvider() : findAllKnownTags(); execTagDialog(knownTags); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h index a9238aef1..64bd5aa1a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h @@ -18,8 +18,8 @@ class DeckPreviewDeckTagsDisplayWidget : public QWidget QStringList currentTags; FlowWidget *flowWidget; - std::function knownTagsProvider_; - std::function conversionPromptHandler_; + std::function knownTagsProvider; + std::function conversionPromptHandler; public: explicit DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags = {}); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index d16c904a5..42ba73278 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -27,9 +27,9 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, VisualDeckStorageWidget *_visualDeckStorageWidget, - VisualDeckStorageModel *model, + VisualDeckStorageModel *_model, const QString &_filePath) - : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model_(model), filePath(_filePath) + : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath) { layout = new QVBoxLayout(this); setLayout(layout); @@ -83,7 +83,16 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, layout->addWidget(bannerCardLabel); 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(); syncFromModel(); @@ -119,7 +128,7 @@ void DeckPreviewWidget::enterEvent(QEnterEvent *event) */ int DeckPreviewWidget::row() const { - return model_->rowForFilePath(filePath); + return model->rowForFilePath(filePath); } /** @@ -131,7 +140,7 @@ QString DeckPreviewWidget::getDisplayName() const if (r == -1) { return {}; } - return model_->dataForRow(r).displayName; + return model->dataForRow(r).displayName; } /** @@ -140,11 +149,11 @@ QString DeckPreviewWidget::getDisplayName() const void DeckPreviewWidget::reloadIfModified() { const int r = row(); - if (r == -1 || !model_->dataForRow(r).loadSucceeded) { + if (r == -1 || !model->dataForRow(r).loadSucceeded) { return; } - model_->reloadIfModified(r); + model->reloadIfModified(r); } /** @@ -157,7 +166,7 @@ void DeckPreviewWidget::syncFromModel() return; } - const DeckPreviewData &data = model_->dataForRow(r); + const DeckPreviewData &data = model->dataForRow(r); filePath = data.filePath; const CardRef bannerCardRef = data.deck.deckList.getBannerCard(); @@ -246,7 +255,7 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) const int r = row(); if (r != -1) { - const DeckList &deckList = model_->dataForRow(r).deck.deckList; + const DeckList &deckList = model->dataForRow(r).deck.deckList; const QList cardsInDeck = deckList.getCardNodes(); for (auto currentCard : cardsInDeck) { @@ -265,16 +274,16 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // 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; for (const auto &pair : pairList) { QStandardItem *item = new QStandardItem(pair.first); 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 int restoredIndex = bannerCardComboBox->findText(currentText); @@ -283,7 +292,7 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) } else { // Add a placeholder "-" and set it as the current selection 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); if (bannerIndex != -1) { bannerCardComboBox->setCurrentIndex(bannerIndex); @@ -306,7 +315,7 @@ void DeckPreviewWidget::setBannerCard(int /* changedIndex */) if (r == -1) { return; } - model_->setBannerCard(r, cardRef); + model->setBannerCard(r, cardRef); bannerCardDisplayWidget->setCard(CardDatabaseManager::query()->getCard(cardRef)); } @@ -330,7 +339,7 @@ void DeckPreviewWidget::setTags(const QStringList &tags) { const int r = row(); 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] { 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] { 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] { 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] { 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] { 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 - const QString oldName = model_->dataForRow(r).deckName; + const QString oldName = model->dataForRow(r).deckName; bool 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 - model_->renameDeck(r, newName); + model->renameDeck(r, newName); // The banner card text updates via the model's dataChanged signal. } @@ -450,7 +459,7 @@ void DeckPreviewWidget::actRenameFile() } // write change - if (!model_->renameFile(r, newName)) { + if (!model->renameFile(r, newName)) { QMessageBox::critical(this, tr("Error"), tr("Rename failed")); } @@ -472,7 +481,7 @@ void DeckPreviewWidget::actDeleteFile() } // write change - if (!model_->deleteFile(r)) { + if (!model->deleteFile(r)) { QMessageBox::critical(this, tr("Error"), tr("Delete failed")); } @@ -515,7 +524,7 @@ bool DeckPreviewWidget::promptFileConversionIfRequired() return false; } - model_->convertToCockatriceFormat(row()); + model->convertToCockatriceFormat(row()); return true; } @@ -534,7 +543,7 @@ bool DeckPreviewWidget::promptFileConversionIfRequired() return false; } - model_->convertToCockatriceFormat(row()); + model->convertToCockatriceFormat(row()); if (conversionDialog.dontAskAgain()) { SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index e2f744ae0..c93c00472 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h @@ -32,7 +32,7 @@ class DeckPreviewWidget final : public QWidget public: explicit DeckPreviewWidget(QWidget *parent, VisualDeckStorageWidget *_visualDeckStorageWidget, - VisualDeckStorageModel *model, + VisualDeckStorageModel *_model, const QString &_filePath); void retranslateUi(); @@ -83,7 +83,7 @@ private: void actDeleteFile(); VisualDeckStorageWidget *visualDeckStorageWidget; - VisualDeckStorageModel *model_; + VisualDeckStorageModel *model; QString filePath; QVBoxLayout *layout; ColorIdentityWidget *colorIdentityWidget; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp index 6e1217ccf..3537a9b70 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp @@ -9,6 +9,7 @@ #include "visual_deck_storage_sort_filter_proxy_model.h" #include "visual_deck_storage_widget.h" +#include #include #include #include @@ -59,55 +60,103 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( reconcileTimer->setInterval(150); 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() { + 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(); } /** - * @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() { - reconcileDecks(); - createSubFolderWidgets(); - refreshVisibility(); + beginDeckPass(); } -/** - * @brief Reconciles the deck preview widgets in the flow layout with the accepted proxy rows. - * - * Decks are keyed by file path so that already-created preview widgets are reused - * (their banner combo box, tag editor state, etc. is preserved) whenever possible. - */ -void VisualDeckStorageFolderDisplayWidget::reconcileDecks() +void VisualDeckStorageFolderDisplayWidget::beginDeckPass() +{ + deckPassActive = true; + deckPassRestartRequested = false; + deckPassRow = 0; + visibleDeckCount = 0; + 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 *model = visualDeckStorageWidget->model(); - // Collect the file paths this folder should display, in proxy (sorted) order. - 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 desiredSet(desiredOrder.cbegin(), desiredOrder.cend()); - - // Drop previews of decks that no longer match. + // Drop previews of decks that no longer exist in the source model. for (auto it = deckWidgets.begin(); it != deckWidgets.end();) { - if (!desiredSet.contains(it.key())) { + if (!deckPassPresentPaths.contains(it.key())) { flowWidget->removeWidget(it.value()); it.value()->deleteLater(); it = deckWidgets.erase(it); @@ -116,37 +165,69 @@ void VisualDeckStorageFolderDisplayWidget::reconcileDecks() } } - // Create or reuse the preview widgets in the desired order. - QList orderedWidgets; - orderedWidgets.reserve(desiredOrder.size()); - for (const QString &filePath : desiredOrder) { - DeckPreviewWidget *deckPreviewWidget = deckWidgets.value(filePath, nullptr); - if (!deckPreviewWidget) { - deckPreviewWidget = new DeckPreviewWidget(flowWidget, 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); + // Order the flow layout like the proxy sorts its rows. + QStringList orderedFilePaths; + orderedFilePaths.reserve(proxy->rowCount()); + for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { + const QModelIndex sourceIndex = proxy->mapToSource(proxy->index(proxyRow, 0)); + if (!sourceIndex.isValid()) { + continue; + } + const QString &filePath = model->dataForRow(sourceIndex.row()).filePath; + if (deckWidgets.contains(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 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 // order is unchanged so that data-only updates don't invalidate the flow layout. - if (desiredOrder != lastOrderedFilePaths) { - for (DeckPreviewWidget *deckPreviewWidget : orderedWidgets) { - flowWidget->removeWidget(deckPreviewWidget); + if (orderedFilePaths != lastOrderedFilePaths) { + for (const QString &filePath : orderedFilePaths) { + flowWidget->removeWidget(deckWidgets.value(filePath)); } - for (DeckPreviewWidget *deckPreviewWidget : orderedWidgets) { - flowWidget->addWidget(deckPreviewWidget); + for (const QString &filePath : orderedFilePaths) { + 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(); } - reconcile(); + scheduleReconcile(); } /** @@ -218,10 +299,13 @@ void VisualDeckStorageFolderDisplayWidget::refreshVisibility() /** * @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 { - if (!deckWidgets.isEmpty()) { + if (!initialPassCompleted || visibleDeckCount > 0) { return true; } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h index 84e12a0ae..47370e8b8 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h @@ -3,17 +3,20 @@ * @ingroup VisualDeckStorageWidgets * @brief Renders the decks of one folder of the Visual Deck Storage. * - * This is a pure view: it reads the accepted rows of the - * VisualDeckStorageSortFilterProxyModel whose folder matches this widget's - * folder path, and keeps a set of child DeckPreviewWidgets in sync with those - * rows. Subfolders are shown as nested VisualDeckStorageFolderDisplayWidgets - * when the "show folders" setting is enabled. + * 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 + * of the VisualDeckStorageSortFilterProxyModel. Subfolders are 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 + * between chunks, so scanning a large collection never stalls the ui thread. */ #ifndef VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H #define VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H #include +#include #include #include @@ -36,8 +39,8 @@ public: public slots: /** - * @brief Re-reads the proxy and rebuilds the deck previews and subfolder - * widgets to match. + * @brief Starts a new chunked reconcile pass that re-reads the proxy and rebuilds + * the deck previews and subfolder widgets to match. */ void reconcile(); @@ -56,14 +59,26 @@ signals: void contentVisibilityChanged(); private: - void reconcileDecks(); + void beginDeckPass(); + void continueDeckPass(); + void finishDeckPass(); + [[nodiscard]] DeckPreviewWidget *createDeckPreviewWidget(const QString &filePath); void createSubFolderWidgets(); void refreshVisibility(); [[nodiscard]] bool hasContent() 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; - 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; QWidget *container; QVBoxLayout *containerLayout; @@ -74,6 +89,16 @@ private: QHash subFolderWidgets; ///< Folder path -> subfolder widget. QTimer *reconcileTimer = nullptr; ///< Coalesces proxy change bursts. 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 deckPassPresentPaths; ///< File paths seen so far in the active pass. }; #endif // VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index 428cbd5f4..baa5e5792 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -2,7 +2,6 @@ #include "../../../filters/syntax_help.h" #include "../../pixel_map_generator.h" -#include "visual_deck_storage_widget.h" #include #include @@ -13,9 +12,9 @@ * 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. * - * @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->setContentsMargins(0, 0, 0, 0); @@ -38,6 +37,5 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWi searchDebounceTimer->start(300); // 300ms debounce }); - connect(searchDebounceTimer, &QTimer::timeout, this, - [this] { this->parent->proxyModel()->setSearchText(searchBar->text()); }); + connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); }); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h index b674f28e2..7769ea911 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h @@ -11,17 +11,23 @@ #include class QTimer; -class VisualDeckStorageWidget; + class VisualDeckStorageSearchWidget : public QWidget { Q_OBJECT 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: QHBoxLayout *layout; - VisualDeckStorageWidget *parent; QLineEdit *searchBar; QTimer *searchDebounceTimer; }; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 1bd1148d7..a4b504280 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -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 // display the proxy's accepted rows, so nothing touches the filesystem outside the model. - model_ = new VisualDeckStorageModel(this); - proxyModel_ = new VisualDeckStorageSortFilterProxyModel(this); - proxyModel_->setSourceModel(model_); + storageModel = new VisualDeckStorageModel(this); + storageProxyModel = new VisualDeckStorageSortFilterProxyModel(this); + storageProxyModel->setSourceModel(storageModel); layout = new QVBoxLayout(this); layout->setSpacing(0); @@ -86,17 +86,25 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare refreshTimer->setSingleShot(true); refreshTimer->setInterval(150); 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 // the burst settles instead of on every individual load. tagFilterWidget->refreshTags(); }); - connect(model_, &QAbstractItemModel::dataChanged, this, [this] { refreshTimer->start(); }); - connect(model_, &VisualDeckStorageModel::deckLoaded, this, [this] { refreshTimer->start(); }); + connect(storageModel, &QAbstractItemModel::dataChanged, 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. - connect(model_, &VisualDeckStorageModel::deckFilePathChanged, this, [this] { proxyModel_->resort(); }); + connect(storageModel, &VisualDeckStorageModel::deckFilePathChanged, this, [this] { storageProxyModel->resort(); }); connect(sortWidget, &VisualDeckStorageSortWidget::sortOrderChanged, this, &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, &VisualDeckStorageWidget::createRootFolderWidget); @@ -163,8 +171,8 @@ const VisualDeckStorageQuickSettingsWidget *VisualDeckStorageWidget::settings() */ void VisualDeckStorageWidget::reapplySortAndFilters() { - proxyModel_->setSortOrder(sortWidget->currentSortOrder()); - proxyModel_->reapplyFilters(); + storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); + storageProxyModel->reapplyFilters(); } /** @@ -172,7 +180,7 @@ void VisualDeckStorageWidget::reapplySortAndFilters() */ void VisualDeckStorageWidget::createRootFolderWidget() { - model_->setDeckPath(SettingsCache::instance().paths().getDeckPath()); + storageModel->setDeckPath(SettingsCache::instance().paths().getDeckPath()); folderWidget = new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); @@ -194,19 +202,36 @@ void VisualDeckStorageWidget::updateShowFolders(bool enabled) void VisualDeckStorageWidget::updateSortOrder() { - proxyModel_->setSortOrder(sortWidget->currentSortOrder()); + storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); } void VisualDeckStorageWidget::updateTagFilter() { const QStringList selected = tagFilterWidget->selectedTags(); const QStringList excluded = tagFilterWidget->excludedTags(); - proxyModel_->setTagFilter(QSet(selected.cbegin(), selected.cend()), - QSet(excluded.cbegin(), excluded.cend())); + storageProxyModel->setTagFilter(QSet(selected.cbegin(), selected.cend()), + QSet(excluded.cbegin(), excluded.cend())); // The visible deck set changed, so the chips are re-gathered from it. 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) { if (visible) { diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index 8a8c19066..fe6389414 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -40,11 +40,11 @@ public: [[nodiscard]] const VisualDeckStorageQuickSettingsWidget *settings() const; [[nodiscard]] VisualDeckStorageModel *model() const { - return model_; + return storageModel; } [[nodiscard]] VisualDeckStorageSortFilterProxyModel *proxyModel() const { - return proxyModel_; + return storageProxyModel; } public slots: @@ -57,6 +57,8 @@ public slots: void updateSelectionAnimationEnabled(bool enabled); void updateSortOrder(); void updateTagFilter(); + void updateColorFilter(); + void updateSearchFilter(const QString &text); signals: void deckLoadRequested(const QString &filePath); @@ -81,8 +83,8 @@ private: VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; QScrollArea *scrollArea; VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr; - VisualDeckStorageModel *model_ = nullptr; - VisualDeckStorageSortFilterProxyModel *proxyModel_ = nullptr; + VisualDeckStorageModel *storageModel = nullptr; + VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr; QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads. };