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 42ba73278..04dcdf7f2 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 @@ -96,6 +96,11 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, retranslateUi(); syncFromModel(); + + // resizeEvent clamps every child to the picture's width, so collect them once here + // to keep the resize handler from searching the widget tree on every layout pass. + fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel, + bannerCardComboBox}; } void DeckPreviewWidget::retranslateUi() @@ -109,9 +114,15 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event) if (bannerCardDisplayWidget == nullptr) { return; } - QList widgets = findChildren(); - for (QWidget *widget : widgets) { - widget->setMaximumWidth(bannerCardDisplayWidget->width()); + + const int width = bannerCardDisplayWidget->width(); + if (width == lastKnownBannerWidth) { + return; + } + lastKnownBannerWidth = width; + + for (QWidget *widget : fixedWidthChildren) { + widget->setMaximumWidth(width); } } 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 c93c00472..7bb69f9b9 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 @@ -90,6 +90,8 @@ private: DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget; QLabel *bannerCardLabel; QComboBox *bannerCardComboBox; + QList fixedWidthChildren; ///< Children clamped to the picture width on resize. + int lastKnownBannerWidth = -1; ///< The picture width last applied to the children. }; class NoScrollFilter : public QObject diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp index bf2c49604..a771136e1 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -32,8 +32,18 @@ struct DeckLoadResult { LoadedDeck deck; ///< The parsed deck. QDateTime lastModified; ///< File modification time at load. + QString colorIdentity; ///< WUBRG color identity, computed off the UI thread. }; +/** + * @brief How many finished background deck loads one event loop turn applies. + * + * Applying a load emits dataChanged and wakes up the proxies, which is not + * free, and the bound keeps a burst of simultaneous completions from stalling + * the UI thread. + */ +constexpr int DECK_LOADS_PER_TURN = 24; + /** * @brief The path of \a path relative to the deck root, or empty if \a path * is not below it. @@ -108,6 +118,8 @@ DeckScanResult scanDeckDirectory(const QString &deckPath) } } // namespace +static QString computeColorIdentity(const LoadedDeck &deck); + VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) { } @@ -196,6 +208,7 @@ void VisualDeckStorageModel::startScan() beginResetModel(); decks.clear(); folderPaths.clear(); + pendingLoads.clear(); endResetModel(); if (deckPath.isEmpty()) { @@ -257,26 +270,20 @@ void VisualDeckStorageModel::beginLoad(int row) return; // The deck list was re-scanned while this load was running; drop the stale result. } - const int row = rowForFilePath(filePath); - if (row == -1) { - return; + // Queue the result and apply a bounded number per event loop turn so that + // finishing hundreds of loads at once cannot stall the UI thread. + const std::optional result = watcher->result(); + PendingDeckLoad pending; + pending.filePath = filePath; + pending.generation = generation; + if (result) { + pending.ok = true; + pending.deck = std::move(result->deck); + pending.lastModified = result->lastModified; + pending.colorIdentity = std::move(result->colorIdentity); } - - DeckPreviewData &data = decks[row]; - data.loadInProgress = false; - - std::optional result = watcher->result(); - if (!result) { - return; // Leave the row unloaded; it stays visible but without deck data. - } - - data.deck = std::move(result->deck); - data.loadSucceeded = true; - data.lastModified = result->lastModified; - recomputeDeckMetadata(data); - - emit dataChanged(index(row), index(row)); - emit deckLoaded(row); + pendingLoads.append(std::move(pending)); + schedulePendingLoadDrain(); }); watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional { @@ -284,10 +291,63 @@ void VisualDeckStorageModel::beginLoad(int row) if (!deck) { return std::nullopt; } - return DeckLoadResult{*deck, QFileInfo(filePath).lastModified()}; + // Color identity walks every card through the database, so compute it here to + // keep the completion handler on the UI thread cheap. + const QString colorIdentity = computeColorIdentity(*deck); + return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; })); } +void VisualDeckStorageModel::schedulePendingLoadDrain() +{ + if (drainScheduled) { + return; + } + drainScheduled = true; + QMetaObject::invokeMethod(this, &VisualDeckStorageModel::drainPendingLoads, Qt::QueuedConnection); +} + +void VisualDeckStorageModel::drainPendingLoads() +{ + drainScheduled = false; + + int applied = 0; + while (!pendingLoads.isEmpty() && applied < DECK_LOADS_PER_TURN) { + PendingDeckLoad pending = pendingLoads.takeFirst(); + + if (pending.generation != scanGeneration) { + continue; + } + + const int row = rowForFilePath(pending.filePath); + if (row == -1) { + continue; + } + + DeckPreviewData &data = decks[row]; + data.loadInProgress = false; + + if (!pending.ok) { + ++applied; + continue; // Leave the row unloaded so it stays visible without deck data. + } + + data.deck = std::move(pending.deck); + data.loadSucceeded = true; + data.lastModified = pending.lastModified; + recomputeDeckMetadata(data, false); + data.colorIdentity = std::move(pending.colorIdentity); + + emit dataChanged(index(row), index(row)); + emit deckLoaded(row); + ++applied; + } + + if (!pendingLoads.isEmpty()) { + schedulePendingLoadDrain(); + } +} + /** * @brief Computes the color identity of a deck in WUBRG order. */ @@ -325,7 +385,7 @@ static QString computeColorIdentity(const LoadedDeck &deck) /** * @brief Recomputes all derived metadata of a row from its loaded deck. */ -void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data) +void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity) { const DeckList &deckList = data.deck.deckList; @@ -334,7 +394,9 @@ void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data) data.tags = deckList.getTags(); data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); data.bannerCard = deckList.getBannerCard(); - data.colorIdentity = computeColorIdentity(data.deck); + if (recomputeColorIdentity) { + data.colorIdentity = computeColorIdentity(data.deck); + } } void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath) diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h index a44e7412d..e6fb31f50 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h @@ -65,6 +65,19 @@ struct DeckPreviewData bool loadInProgress = false; ///< Whether the deck file is currently being loaded. }; +/** + * @brief One finished background deck load that has not been applied to the model yet. + */ +struct PendingDeckLoad +{ + QString filePath; ///< Identifies the row the result belongs to. + int generation; ///< Scan generation the load was started in. + bool ok = false; ///< Whether the file parsed successfully. + LoadedDeck deck; ///< The parsed deck, valid when ok. + QDateTime lastModified; ///< File modification time at load, valid when ok. + QString colorIdentity; ///< WUBRG color identity computed off the UI thread, valid when ok. +}; + /** * @brief The list model backing the Visual Deck Storage widget tree. * @@ -144,13 +157,21 @@ signals: private: void startScan(); void beginLoad(int row); - static void recomputeDeckMetadata(DeckPreviewData &data); + static void recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity = true); + void schedulePendingLoadDrain(); + +private slots: + void drainPendingLoads(); + +private: void setFilePathForRow(int row, const QString &newFilePath); QString deckPath; QList decks; - QStringList folderPaths; ///< All subdirectories of the deck folder, sorted. - int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored. + QStringList folderPaths; ///< All subdirectories of the deck folder, sorted. + int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored. + QVector pendingLoads; ///< Finished background loads waiting to be applied. + bool drainScheduled = false; ///< Whether a queued drain pass is already pending. }; #endif // VISUAL_DECK_STORAGE_MODEL_H