Make VDS startup smooth: batch deck loads, guard preview resizes

- Move color identity computation into the background load task and apply
  finished deck loads in bounded batches per event loop turn, so finishing
  hundreds of loads at once cannot stall the UI thread
- Skip redundant resize work in DeckPreviewWidget when the banner width did
  not change, and collect the clamped children once instead of searching the
  widget tree on every layout pass

Took 19 minutes

# Commit time for manual adjustment:
# Took 3 minutes
This commit is contained in:
Lukas Brübach 2026-08-23 15:55:06 +02:00
parent b6a7f6d52a
commit 0709cd04c0
4 changed files with 124 additions and 28 deletions

View file

@ -96,6 +96,11 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
retranslateUi(); retranslateUi();
syncFromModel(); 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() void DeckPreviewWidget::retranslateUi()
@ -109,9 +114,15 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event)
if (bannerCardDisplayWidget == nullptr) { if (bannerCardDisplayWidget == nullptr) {
return; return;
} }
QList<QWidget *> widgets = findChildren<QWidget *>();
for (QWidget *widget : widgets) { const int width = bannerCardDisplayWidget->width();
widget->setMaximumWidth(bannerCardDisplayWidget->width()); if (width == lastKnownBannerWidth) {
return;
}
lastKnownBannerWidth = width;
for (QWidget *widget : fixedWidthChildren) {
widget->setMaximumWidth(width);
} }
} }

View file

@ -90,6 +90,8 @@ private:
DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget; DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget;
QLabel *bannerCardLabel; QLabel *bannerCardLabel;
QComboBox *bannerCardComboBox; QComboBox *bannerCardComboBox;
QList<QWidget *> fixedWidthChildren; ///< Children clamped to the picture width on resize.
int lastKnownBannerWidth = -1; ///< The picture width last applied to the children.
}; };
class NoScrollFilter : public QObject class NoScrollFilter : public QObject

View file

@ -32,8 +32,18 @@ struct DeckLoadResult
{ {
LoadedDeck deck; ///< The parsed deck. LoadedDeck deck; ///< The parsed deck.
QDateTime lastModified; ///< File modification time at load. 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 * @brief The path of \a path relative to the deck root, or empty if \a path
* is not below it. * is not below it.
@ -108,6 +118,8 @@ DeckScanResult scanDeckDirectory(const QString &deckPath)
} }
} // namespace } // namespace
static QString computeColorIdentity(const LoadedDeck &deck);
VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent)
{ {
} }
@ -196,6 +208,7 @@ void VisualDeckStorageModel::startScan()
beginResetModel(); beginResetModel();
decks.clear(); decks.clear();
folderPaths.clear(); folderPaths.clear();
pendingLoads.clear();
endResetModel(); endResetModel();
if (deckPath.isEmpty()) { 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. return; // The deck list was re-scanned while this load was running; drop the stale result.
} }
const int row = rowForFilePath(filePath); // Queue the result and apply a bounded number per event loop turn so that
if (row == -1) { // finishing hundreds of loads at once cannot stall the UI thread.
return; const std::optional<DeckLoadResult> 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);
} }
pendingLoads.append(std::move(pending));
DeckPreviewData &data = decks[row]; schedulePendingLoadDrain();
data.loadInProgress = false;
std::optional<DeckLoadResult> 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);
}); });
watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> { watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
@ -284,10 +291,63 @@ void VisualDeckStorageModel::beginLoad(int row)
if (!deck) { if (!deck) {
return std::nullopt; 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. * @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. * @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; const DeckList &deckList = data.deck.deckList;
@ -334,7 +394,9 @@ void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data)
data.tags = deckList.getTags(); data.tags = deckList.getTags();
data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp());
data.bannerCard = deckList.getBannerCard(); data.bannerCard = deckList.getBannerCard();
if (recomputeColorIdentity) {
data.colorIdentity = computeColorIdentity(data.deck); data.colorIdentity = computeColorIdentity(data.deck);
}
} }
void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath) void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath)

View file

@ -65,6 +65,19 @@ struct DeckPreviewData
bool loadInProgress = false; ///< Whether the deck file is currently being loaded. 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. * @brief The list model backing the Visual Deck Storage widget tree.
* *
@ -144,13 +157,21 @@ signals:
private: private:
void startScan(); void startScan();
void beginLoad(int row); 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); void setFilePathForRow(int row, const QString &newFilePath);
QString deckPath; QString deckPath;
QList<DeckPreviewData> decks; QList<DeckPreviewData> decks;
QStringList folderPaths; ///< All subdirectories of the deck folder, sorted. QStringList folderPaths; ///< All subdirectories of the deck folder, sorted.
int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored. int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored.
QVector<PendingDeckLoad> 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 #endif // VISUAL_DECK_STORAGE_MODEL_H