Address comments

This commit is contained in:
Lukas Brübach 2026-08-17 10:42:27 +02:00
parent 1627f377ef
commit 6f631e0954
4 changed files with 216 additions and 218 deletions

View file

@ -20,8 +20,8 @@ namespace
*/ */
struct DeckScanResult struct DeckScanResult
{ {
QList<DeckPreviewData> decks; QList<DeckPreviewData> decks; ///< Deck rows in scan order.
QStringList folderPaths; QStringList folderPaths; ///< Sorted list of subfolder paths.
}; };
/** /**
@ -30,8 +30,8 @@ struct DeckScanResult
*/ */
struct DeckLoadResult struct DeckLoadResult
{ {
LoadedDeck deck; LoadedDeck deck; ///< The parsed deck.
QDateTime lastModified; QDateTime lastModified; ///< File modification time at load.
}; };
/** /**
@ -49,6 +49,63 @@ QString relativePathFromDeckRoot(const QString &path, const QString &deckPath)
} }
return relativePath; return relativePath;
} }
/**
* @brief The path of \a filePath relative to \a deckPath, or the bare file name
* if \a filePath is not below \a deckPath.
*/
QString relativeFilePathFor(const QString &filePath, const QString &deckPath)
{
if (filePath.startsWith(deckPath)) {
return filePath.mid(deckPath.length());
}
return QFileInfo(filePath).fileName();
}
/**
* @brief The directory of \a filePath relative to \a deckPath, or empty if the
* file sits directly in the deck root.
*/
QString folderPathFor(const QString &filePath, const QString &deckPath)
{
return relativePathFromDeckRoot(QFileInfo(filePath).absolutePath(), deckPath);
}
/**
* @brief Scans a deck directory on a worker thread, returning discovered deck
* files and subfolder paths.
*/
DeckScanResult scanDeckDirectory(const QString &deckPath)
{
DeckScanResult result;
QDirIterator fileIt(deckPath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files,
QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
while (fileIt.hasNext()) {
const QString filePath = fileIt.next();
DeckPreviewData data;
data.filePath = filePath;
data.relativeFilePath = relativeFilePathFor(filePath, deckPath);
data.folderPath = folderPathFor(filePath, deckPath);
data.lastModified = QFileInfo(filePath).lastModified();
result.decks.append(std::move(data));
}
QSet<QString> seenFolders;
QDirIterator folderIt(deckPath, QDir::Dirs | QDir::NoDotAndDotDot,
QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
while (folderIt.hasNext()) {
const QString folderPath = relativePathFromDeckRoot(folderIt.next(), deckPath);
if (!folderPath.isEmpty() && !seenFolders.contains(folderPath)) {
seenFolders.insert(folderPath);
result.folderPaths.append(folderPath);
}
}
result.folderPaths.sort();
return result;
}
} // namespace } // namespace
VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent)
@ -57,16 +114,16 @@ VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListM
int VisualDeckStorageModel::rowCount(const QModelIndex &parent) const int VisualDeckStorageModel::rowCount(const QModelIndex &parent) const
{ {
return parent.isValid() ? 0 : decks_.size(); return parent.isValid() ? 0 : decks.size();
} }
QVariant VisualDeckStorageModel::data(const QModelIndex &index, int role) const QVariant VisualDeckStorageModel::data(const QModelIndex &index, int role) const
{ {
if (!index.isValid() || index.row() < 0 || index.row() >= decks_.size()) { if (!index.isValid() || index.row() < 0 || index.row() >= decks.size()) {
return {}; return {};
} }
const DeckPreviewData &data = decks_.at(index.row()); const DeckPreviewData &data = decks.at(index.row());
switch (role) { switch (role) {
case Qt::DisplayRole: case Qt::DisplayRole:
case VisualDeckStorageRoles::DisplayNameRole: case VisualDeckStorageRoles::DisplayNameRole:
@ -86,21 +143,21 @@ QVariant VisualDeckStorageModel::data(const QModelIndex &index, int role) const
case VisualDeckStorageRoles::LastLoadedRole: case VisualDeckStorageRoles::LastLoadedRole:
return data.lastLoaded; return data.lastLoaded;
case VisualDeckStorageRoles::BannerCardNameRole: case VisualDeckStorageRoles::BannerCardNameRole:
return data.bannerCardName; return data.bannerCard.name;
case VisualDeckStorageRoles::BannerCardProviderIdRole: case VisualDeckStorageRoles::BannerCardProviderIdRole:
return data.bannerCardProviderId; return data.bannerCard.providerId;
default: default:
return {}; return {};
} }
} }
void VisualDeckStorageModel::setDeckPath(const QString &deckPath) void VisualDeckStorageModel::setDeckPath(const QString &path)
{ {
QString cleanedPath = QDir::cleanPath(deckPath); QString cleanedPath = QDir::cleanPath(path);
if (cleanedPath == ".") { if (cleanedPath == ".") {
cleanedPath.clear(); cleanedPath.clear();
} }
deckPath_ = cleanedPath; deckPath = cleanedPath;
startScan(); startScan();
} }
@ -112,10 +169,10 @@ void VisualDeckStorageModel::refresh()
const DeckPreviewData &VisualDeckStorageModel::dataForRow(int row) const const DeckPreviewData &VisualDeckStorageModel::dataForRow(int row) const
{ {
static const DeckPreviewData emptyData; static const DeckPreviewData emptyData;
if (row < 0 || row >= decks_.size()) { if (row < 0 || row >= decks.size()) {
return emptyData; return emptyData;
} }
return decks_.at(row); return decks.at(row);
} }
const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const
@ -125,8 +182,8 @@ const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const
int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const
{ {
for (int i = 0; i < decks_.size(); ++i) { for (int i = 0; i < decks.size(); ++i) {
if (decks_.at(i).filePath == filePath) { if (decks.at(i).filePath == filePath) {
return i; return i;
} }
} }
@ -135,18 +192,18 @@ int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const
void VisualDeckStorageModel::startScan() void VisualDeckStorageModel::startScan()
{ {
++scanGeneration_; ++scanGeneration;
beginResetModel(); beginResetModel();
decks_.clear(); decks.clear();
folderPaths_.clear(); folderPaths.clear();
endResetModel(); endResetModel();
if (deckPath_.isEmpty()) { if (deckPath.isEmpty()) {
return; return;
} }
const QString deckPath = deckPath_; const QString currentDeckPath = deckPath;
const int generation = scanGeneration_; const int generation = scanGeneration;
// The scan (directory walk + one stat per file) runs on a worker thread so that // The scan (directory walk + one stat per file) runs on a worker thread so that
// constructing the widget never stalls the UI thread on a large deck folder. // constructing the widget never stalls the UI thread on a large deck folder.
@ -154,74 +211,49 @@ void VisualDeckStorageModel::startScan()
connect(watcher, &QFutureWatcher<DeckScanResult>::finished, this, [this, watcher, generation] { connect(watcher, &QFutureWatcher<DeckScanResult>::finished, this, [this, watcher, generation] {
watcher->deleteLater(); watcher->deleteLater();
if (generation != scanGeneration_) { if (generation != scanGeneration) {
return; // A newer scan started while this one was running; drop the stale result. return; // A newer scan started while this one was running; drop the stale result.
} }
const DeckScanResult result = watcher->result(); const DeckScanResult result = watcher->result();
folderPaths_ = result.folderPaths; folderPaths = result.folderPaths;
if (result.decks.isEmpty()) { if (result.decks.isEmpty()) {
return; return;
} }
beginInsertRows(QModelIndex(), 0, result.decks.size() - 1); beginInsertRows(QModelIndex(), 0, result.decks.size() - 1);
decks_ = result.decks; decks = result.decks;
endInsertRows(); endInsertRows();
for (int row = 0; row < decks_.size(); ++row) { for (int row = 0; row < decks.size(); ++row) {
beginLoad(row); beginLoad(row);
} }
}); });
watcher->setFuture(QtConcurrent::run([deckPath]() -> DeckScanResult { watcher->setFuture(
DeckScanResult result; QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }));
QDirIterator fileIt(deckPath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files,
QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
while (fileIt.hasNext()) {
const QString filePath = fileIt.next();
DeckPreviewData data;
data.filePath = filePath;
data.relativeFilePath = VisualDeckStorageModel::relativeFilePathFor(filePath, deckPath);
data.folderPath = VisualDeckStorageModel::folderPathFor(filePath, deckPath);
data.lastModified = QFileInfo(filePath).lastModified();
result.decks.append(std::move(data));
}
QDirIterator folderIt(deckPath, QDir::Dirs | QDir::NoDotAndDotDot,
QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
while (folderIt.hasNext()) {
const QString folderPath = relativePathFromDeckRoot(folderIt.next(), deckPath);
if (!folderPath.isEmpty() && !result.folderPaths.contains(folderPath)) {
result.folderPaths.append(folderPath);
}
}
result.folderPaths.sort();
return result;
}));
} }
void VisualDeckStorageModel::beginLoad(int row) void VisualDeckStorageModel::beginLoad(int row)
{ {
if (row < 0 || row >= decks_.size() || decks_.at(row).loadInProgress) { if (row < 0 || row >= decks.size() || decks.at(row).loadInProgress) {
return; return;
} }
DeckPreviewData &data = decks_[row]; DeckPreviewData &data = decks[row];
data.loadInProgress = true; data.loadInProgress = true;
const QString filePath = data.filePath; const QString filePath = data.filePath;
const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath); const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath);
const int generation = scanGeneration_; const int generation = scanGeneration;
auto *watcher = new QFutureWatcher<std::optional<DeckLoadResult>>(this); auto *watcher = new QFutureWatcher<std::optional<DeckLoadResult>>(this);
connect(watcher, &QFutureWatcher<std::optional<DeckLoadResult>>::finished, this, connect(watcher, &QFutureWatcher<std::optional<DeckLoadResult>>::finished, this,
[this, watcher, filePath, generation] { [this, watcher, filePath, generation] {
watcher->deleteLater(); watcher->deleteLater();
if (generation != scanGeneration_) { if (generation != scanGeneration) {
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.
} }
@ -230,7 +262,7 @@ void VisualDeckStorageModel::beginLoad(int row)
return; return;
} }
DeckPreviewData &data = decks_[row]; DeckPreviewData &data = decks[row];
data.loadInProgress = false; data.loadInProgress = false;
std::optional<DeckLoadResult> result = watcher->result(); std::optional<DeckLoadResult> result = watcher->result();
@ -241,7 +273,7 @@ void VisualDeckStorageModel::beginLoad(int row)
data.deck = std::move(result->deck); data.deck = std::move(result->deck);
data.loadSucceeded = true; data.loadSucceeded = true;
data.lastModified = result->lastModified; data.lastModified = result->lastModified;
computeDeckMetadata(data); recomputeDeckMetadata(data);
emit dataChanged(index(row), index(row)); emit dataChanged(index(row), index(row));
emit deckLoaded(row); emit deckLoaded(row);
@ -293,7 +325,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::computeDeckMetadata(DeckPreviewData &data) const void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data)
{ {
const DeckList &deckList = data.deck.deckList; const DeckList &deckList = data.deck.deckList;
@ -301,64 +333,46 @@ void VisualDeckStorageModel::computeDeckMetadata(DeckPreviewData &data) const
data.displayName = !data.deckName.isEmpty() ? data.deckName : QFileInfo(data.deck.lastLoadInfo.fileName).fileName(); data.displayName = !data.deckName.isEmpty() ? data.deckName : QFileInfo(data.deck.lastLoadInfo.fileName).fileName();
data.tags = deckList.getTags(); data.tags = deckList.getTags();
data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp());
data.bannerCard = deckList.getBannerCard();
const CardRef bannerCard = deckList.getBannerCard();
data.bannerCardName = bannerCard.name;
data.bannerCardProviderId = bannerCard.providerId;
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)
{ {
if (row < 0 || row >= decks_.size()) { if (row < 0 || row >= decks.size()) {
return; return;
} }
DeckPreviewData &data = decks_[row]; DeckPreviewData &data = decks[row];
data.filePath = newFilePath; data.filePath = newFilePath;
data.relativeFilePath = relativeFilePathFor(newFilePath, deckPath_); data.relativeFilePath = relativeFilePathFor(newFilePath, deckPath);
data.folderPath = folderPathFor(newFilePath, deckPath_); data.folderPath = folderPathFor(newFilePath, deckPath);
}
QString VisualDeckStorageModel::relativeFilePathFor(const QString &filePath, const QString &deckPath)
{
if (filePath.startsWith(deckPath)) {
return filePath.mid(deckPath.length());
}
return QFileInfo(filePath).fileName();
}
QString VisualDeckStorageModel::folderPathFor(const QString &filePath, const QString &deckPath)
{
return relativePathFromDeckRoot(QFileInfo(filePath).absolutePath(), deckPath);
} }
bool VisualDeckStorageModel::renameDeck(int row, const QString &newName) bool VisualDeckStorageModel::renameDeck(int row, const QString &newName)
{ {
if (row < 0 || row >= decks_.size() || decks_.at(row).deck.isEmpty()) { if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
return false; return false;
} }
DeckPreviewData &data = decks_[row]; DeckPreviewData &data = decks[row];
data.deck.deckList.setName(newName); data.deck.deckList.setName(newName);
if (!DeckLoader::saveToFile(data.deck)) { if (!DeckLoader::saveToFile(data.deck)) {
return false; return false;
} }
computeDeckMetadata(data); recomputeDeckMetadata(data);
emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::DisplayNameRole}); emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::DisplayNameRole});
return true; return true;
} }
bool VisualDeckStorageModel::renameFile(int row, const QString &newBaseName) bool VisualDeckStorageModel::renameFile(int row, const QString &newBaseName)
{ {
if (row < 0 || row >= decks_.size() || newBaseName.isEmpty()) { if (row < 0 || row >= decks.size() || newBaseName.isEmpty()) {
return false; return false;
} }
DeckPreviewData &data = decks_[row]; DeckPreviewData &data = decks[row];
const QFileInfo info(data.filePath); const QFileInfo info(data.filePath);
if (newBaseName == info.baseName()) { if (newBaseName == info.baseName()) {
return false; return false;
@ -386,28 +400,28 @@ bool VisualDeckStorageModel::renameFile(int row, const QString &newBaseName)
bool VisualDeckStorageModel::deleteFile(int row) bool VisualDeckStorageModel::deleteFile(int row)
{ {
if (row < 0 || row >= decks_.size()) { if (row < 0 || row >= decks.size()) {
return false; return false;
} }
const QString filePath = decks_.at(row).filePath; const QString filePath = decks.at(row).filePath;
if (!QFile::remove(QFileInfo(filePath).filePath())) { if (!QFile::remove(QFileInfo(filePath).filePath())) {
return false; return false;
} }
beginRemoveRows(QModelIndex(), row, row); beginRemoveRows(QModelIndex(), row, row);
decks_.removeAt(row); decks.removeAt(row);
endRemoveRows(); endRemoveRows();
return true; return true;
} }
bool VisualDeckStorageModel::setTags(int row, const QStringList &tags) bool VisualDeckStorageModel::setTags(int row, const QStringList &tags)
{ {
if (row < 0 || row >= decks_.size() || decks_.at(row).deck.isEmpty()) { if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
return false; return false;
} }
DeckPreviewData &data = decks_[row]; DeckPreviewData &data = decks[row];
data.deck.deckList.setTags(tags); data.deck.deckList.setTags(tags);
if (!DeckLoader::saveToFile(data.deck)) { if (!DeckLoader::saveToFile(data.deck)) {
return false; return false;
@ -420,18 +434,17 @@ bool VisualDeckStorageModel::setTags(int row, const QStringList &tags)
bool VisualDeckStorageModel::setBannerCard(int row, const CardRef &cardRef) bool VisualDeckStorageModel::setBannerCard(int row, const CardRef &cardRef)
{ {
if (row < 0 || row >= decks_.size() || decks_.at(row).deck.isEmpty()) { if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
return false; return false;
} }
DeckPreviewData &data = decks_[row]; DeckPreviewData &data = decks[row];
data.deck.deckList.setBannerCard(cardRef); data.deck.deckList.setBannerCard(cardRef);
if (!DeckLoader::saveToFile(data.deck)) { if (!DeckLoader::saveToFile(data.deck)) {
return false; return false;
} }
data.bannerCardName = cardRef.name; data.bannerCard = cardRef;
data.bannerCardProviderId = cardRef.providerId;
emit dataChanged(index(row), index(row), emit dataChanged(index(row), index(row),
{VisualDeckStorageRoles::BannerCardNameRole, VisualDeckStorageRoles::BannerCardProviderIdRole}); {VisualDeckStorageRoles::BannerCardNameRole, VisualDeckStorageRoles::BannerCardProviderIdRole});
return true; return true;
@ -439,11 +452,11 @@ bool VisualDeckStorageModel::setBannerCard(int row, const CardRef &cardRef)
bool VisualDeckStorageModel::convertToCockatriceFormat(int row) bool VisualDeckStorageModel::convertToCockatriceFormat(int row)
{ {
if (row < 0 || row >= decks_.size() || decks_.at(row).deck.isEmpty()) { if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
return false; return false;
} }
DeckPreviewData &data = decks_[row]; DeckPreviewData &data = decks[row];
const QString oldFilePath = data.filePath; const QString oldFilePath = data.filePath;
if (!DeckLoader::convertToCockatriceFormat(data.deck)) { if (!DeckLoader::convertToCockatriceFormat(data.deck)) {
return false; return false;
@ -451,7 +464,7 @@ bool VisualDeckStorageModel::convertToCockatriceFormat(int row)
setFilePathForRow(row, data.deck.lastLoadInfo.fileName); setFilePathForRow(row, data.deck.lastLoadInfo.fileName);
data.lastModified = QFileInfo(data.filePath).lastModified(); data.lastModified = QFileInfo(data.filePath).lastModified();
computeDeckMetadata(data); recomputeDeckMetadata(data);
emit dataChanged(index(row), index(row)); emit dataChanged(index(row), index(row));
if (oldFilePath != data.filePath) { if (oldFilePath != data.filePath) {
@ -462,11 +475,11 @@ bool VisualDeckStorageModel::convertToCockatriceFormat(int row)
bool VisualDeckStorageModel::reloadIfModified(int row) bool VisualDeckStorageModel::reloadIfModified(int row)
{ {
if (row < 0 || row >= decks_.size()) { if (row < 0 || row >= decks.size()) {
return false; return false;
} }
DeckPreviewData &data = decks_[row]; DeckPreviewData &data = decks[row];
QFileInfo fileInfo(data.filePath); QFileInfo fileInfo(data.filePath);
const QDateTime newLastModified = fileInfo.lastModified(); const QDateTime newLastModified = fileInfo.lastModified();
if (!newLastModified.isValid() || newLastModified <= data.lastModified) { if (!newLastModified.isValid() || newLastModified <= data.lastModified) {
@ -482,7 +495,7 @@ bool VisualDeckStorageModel::reloadIfModified(int row)
data.deck = *result; data.deck = *result;
data.loadSucceeded = true; data.loadSucceeded = true;
data.lastModified = fileInfo.lastModified(); data.lastModified = fileInfo.lastModified();
computeDeckMetadata(data); recomputeDeckMetadata(data);
emit dataChanged(index(row), index(row)); emit dataChanged(index(row), index(row));
emit deckLoaded(row); emit deckLoaded(row);

View file

@ -50,20 +50,19 @@ enum
*/ */
struct DeckPreviewData struct DeckPreviewData
{ {
QString filePath; ///< Absolute file path. QString filePath; ///< Absolute file path.
QString relativeFilePath; ///< File path relative to the deck folder. QString relativeFilePath; ///< File path relative to the deck folder.
QString folderPath; ///< Directory relative to the deck folder ("" for the deck folder itself). QString folderPath; ///< Directory relative to the deck folder ("" for the deck folder itself).
QString deckName; ///< The deck name as stored in the file (may be empty). QString deckName; ///< The deck name as stored in the file (may be empty).
QString displayName; ///< Deck name, or the file name if the deck has no name. QString displayName; ///< Deck name, or the file name if the deck has no name.
QStringList tags; ///< The deck's tags. QStringList tags; ///< The deck's tags.
QString colorIdentity; ///< The deck's color identity in WUBRG order. QString colorIdentity; ///< The deck's color identity in WUBRG order.
QDateTime lastModified; ///< File modification time at last check. QDateTime lastModified; ///< File modification time at last check.
QDateTime lastLoaded; ///< When the deck was last loaded from the file. QDateTime lastLoaded; ///< When the deck was last loaded from the file.
QString bannerCardName; ///< Name of the deck's banner card. CardRef bannerCard; ///< The deck's banner card (name + provider id).
QString bannerCardProviderId; ///< Provider id of the deck's banner card. LoadedDeck deck; ///< The parsed deck; empty until the file has been loaded.
LoadedDeck deck; ///< The parsed deck; empty until the file has been loaded. bool loadSucceeded = false; ///< Whether the deck file finished loading successfully.
bool loadSucceeded = false; ///< Whether the deck file finished loading successfully. bool loadInProgress = false; ///< Whether the deck file is currently being loaded.
bool loadInProgress = false; ///< Whether the deck file is currently being loaded.
}; };
/** /**
@ -86,27 +85,28 @@ public:
/** /**
* @brief Sets the folder to scan for deck files and starts (re)loading. * @brief Sets the folder to scan for deck files and starts (re)loading.
* Emits modelReset once the scan finishes and deck loads begin. * Clears the model immediately (modelReset), then populates it asynchronously
* as the background scan discovers deck files.
*/ */
void setDeckPath(const QString &deckPath); void setDeckPath(const QString &path);
/** /**
* @brief Re-scans the current deck folder, reloading every deck file. * @brief Re-scans the current deck folder, reloading every deck file.
*/ */
void refresh(); void refresh();
[[nodiscard]] QString deckPath() const [[nodiscard]] QString getDeckPath() const
{ {
return deckPath_; return deckPath;
} }
/** /**
* @brief The relative paths of all subdirectories of the deck folder, one level at a time. * @brief The relative paths of all subdirectories of the deck folder, one level at a time.
* Used by the view to build the folder tree. Sorted for deterministic order. * Used by the view to build the folder tree. Sorted for deterministic order.
*/ */
[[nodiscard]] QStringList folderPaths() const [[nodiscard]] QStringList getFolderPaths() const
{ {
return folderPaths_; return folderPaths;
} }
/// @name Data accessors /// @name Data accessors
@ -144,15 +144,13 @@ signals:
private: private:
void startScan(); void startScan();
void beginLoad(int row); void beginLoad(int row);
void computeDeckMetadata(DeckPreviewData &data) const; static void recomputeDeckMetadata(DeckPreviewData &data);
void setFilePathForRow(int row, const QString &newFilePath); void setFilePathForRow(int row, const QString &newFilePath);
[[nodiscard]] static QString relativeFilePathFor(const QString &filePath, const QString &deckPath);
[[nodiscard]] static QString folderPathFor(const QString &filePath, const QString &deckPath);
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.
}; };
#endif // VISUAL_DECK_STORAGE_MODEL_H #endif // VISUAL_DECK_STORAGE_MODEL_H

View file

@ -3,6 +3,7 @@
#include "../../filters/deck_filter_string.h" #include "../../filters/deck_filter_string.h"
#include <QFileInfo> #include <QFileInfo>
#include <algorithm>
VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent) VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent)
: QSortFilterProxyModel(parent) : QSortFilterProxyModel(parent)
@ -36,36 +37,36 @@ void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *m
void VisualDeckStorageSortFilterProxyModel::setSearchText(const QString &text) void VisualDeckStorageSortFilterProxyModel::setSearchText(const QString &text)
{ {
if (searchText_ == text) { if (searchText == text) {
return; return;
} }
searchText_ = text; searchText = text;
updateSearchMatches(); updateSearchMatches();
invalidate(); invalidate();
} }
void VisualDeckStorageSortFilterProxyModel::setTagFilter(const QSet<QString> &selectedTags, void VisualDeckStorageSortFilterProxyModel::setTagFilter(const QSet<QString> &newSelectedTags,
const QSet<QString> &excludedTags) const QSet<QString> &newExcludedTags)
{ {
if (selectedTags_ == selectedTags && excludedTags_ == excludedTags) { if (selectedTags == newSelectedTags && excludedTags == newExcludedTags) {
return; return;
} }
selectedTags_ = selectedTags; selectedTags = newSelectedTags;
excludedTags_ = excludedTags; excludedTags = newExcludedTags;
updateTagMatches(); updateTagMatches();
invalidate(); invalidate();
} }
void VisualDeckStorageSortFilterProxyModel::setColorFilter(FilterMode mode, const QSet<QChar> &activeColors) void VisualDeckStorageSortFilterProxyModel::setColorFilter(FilterMode mode, const QSet<QChar> &colors)
{ {
if (colorFilterMode_ == mode && activeColors_ == activeColors) { if (colorFilterMode == mode && activeColors == colors) {
return; return;
} }
colorFilterMode_ = mode; colorFilterMode = mode;
activeColors_ = activeColors; activeColors = colors;
updateColorMatches(); updateColorMatches();
invalidate(); invalidate();
} }
@ -74,25 +75,25 @@ void VisualDeckStorageSortFilterProxyModel::setSortOrder(SortOrder order)
{ {
// No equality guard: the initial reapply (with the default order) must still // No equality guard: the initial reapply (with the default order) must still
// trigger sort(0), since without a sort the proxy would show scan order. // trigger sort(0), since without a sort the proxy would show scan order.
sortOrder_ = order; sortOrder = order;
sort(0); sort(0);
} }
void VisualDeckStorageSortFilterProxyModel::reapplyFilters() void VisualDeckStorageSortFilterProxyModel::reapplyFilters()
{ {
const QList<bool> oldSearchMatches = searchMatches_; const QList<bool> oldSearchMatches = searchMatches;
const QList<bool> oldTagMatches = tagMatches_; const QList<bool> oldTagMatches = tagMatches;
const QList<bool> oldColorMatches = colorMatches_; const QList<bool> oldColorMatches = colorMatches;
updateSearchMatches(); updateSearchMatches();
updateTagMatches(); updateTagMatches();
updateColorMatches(); updateColorMatches();
if (searchMatches_ != oldSearchMatches || tagMatches_ != oldTagMatches || colorMatches_ != oldColorMatches) { if (searchMatches != oldSearchMatches || tagMatches != oldTagMatches || colorMatches != oldColorMatches) {
invalidate(); invalidate();
} }
if (sortOrder_ == ByName || sortOrder_ == ByLastLoaded) { if (sortOrder == ByName || sortOrder == ByLastLoaded) {
// These orders depend on data that only becomes available when a deck finishes loading. // These orders depend on data that only becomes available when a deck finishes loading.
sort(0); sort(0);
} }
@ -110,12 +111,12 @@ bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int sourceRow, cons
} }
// If the match lists aren't sized to the current model yet, don't hide anything. // If the match lists aren't sized to the current model yet, don't hide anything.
if (sourceRow < 0 || sourceRow >= searchMatches_.size() || sourceRow >= tagMatches_.size() || if (sourceRow < 0 || sourceRow >= searchMatches.size() || sourceRow >= tagMatches.size() ||
sourceRow >= colorMatches_.size()) { sourceRow >= colorMatches.size()) {
return true; return true;
} }
return searchMatches_.at(sourceRow) && tagMatches_.at(sourceRow) && colorMatches_.at(sourceRow); return searchMatches.at(sourceRow) && tagMatches.at(sourceRow) && colorMatches.at(sourceRow);
} }
bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
@ -128,7 +129,7 @@ bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, co
const DeckPreviewData &leftData = source->dataForRow(left.row()); const DeckPreviewData &leftData = source->dataForRow(left.row());
const DeckPreviewData &rightData = source->dataForRow(right.row()); const DeckPreviewData &rightData = source->dataForRow(right.row());
switch (sortOrder_) { switch (sortOrder) {
case ByName: case ByName:
return leftData.deckName < rightData.deckName; return leftData.deckName < rightData.deckName;
case Alphabetical: case Alphabetical:
@ -146,43 +147,48 @@ bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, co
void VisualDeckStorageSortFilterProxyModel::resizeMatchLists() void VisualDeckStorageSortFilterProxyModel::resizeMatchLists()
{ {
const int count = sourceModel() ? sourceModel()->rowCount() : 0; const int count = sourceModel() ? sourceModel()->rowCount() : 0;
searchMatches_ = QList<bool>(count, true); searchMatches.resize(count);
tagMatches_ = QList<bool>(count, true); searchMatches.fill(true);
colorMatches_ = QList<bool>(count, true); tagMatches.resize(count);
tagMatches.fill(true);
colorMatches.resize(count);
colorMatches.fill(true);
} }
void VisualDeckStorageSortFilterProxyModel::updateSearchMatches() void VisualDeckStorageSortFilterProxyModel::updateSearchMatches()
{ {
const auto *source = deckSourceModel(); const auto *source = deckSourceModel();
if (!source) { if (!source) {
searchMatches_.clear(); searchMatches.clear();
return; return;
} }
const int count = source->rowCount(); const int count = source->rowCount();
if (searchText_.isEmpty()) { searchMatches.resize(count);
searchMatches_ = QList<bool>(count, true); if (searchText.isEmpty()) {
searchMatches.fill(true);
return; return;
} }
searchMatches_.resize(count); DeckFilterString filterString(searchText);
DeckFilterString filterString(searchText_);
for (int row = 0; row < count; ++row) { for (int row = 0; row < count; ++row) {
const DeckPreviewData &data = source->dataForRow(row); const DeckPreviewData &data = source->dataForRow(row);
// Decks that haven't finished loading yet can't be evaluated; show them and // isEmpty() is intentional: if a deck fails to load, loadInProgress becomes false
// re-evaluate once the load finishes (reapplyFilters is triggered by deckLoaded). // but the deck remains empty. Using loadInProgress alone would pass failed decks
// to DeckFilterString::check, which requires a non-empty deck.
if (data.deck.isEmpty()) { if (data.deck.isEmpty()) {
searchMatches_[row] = true; searchMatches[row] = true;
continue; continue;
} }
DeckSearchData searchData; DeckSearchData searchData{
searchData.deck = &data.deck; .deck = &data.deck,
searchData.filePath = data.filePath; .filePath = data.filePath,
searchData.displayName = data.displayName; .displayName = data.displayName,
searchData.relativeFilePath = data.relativeFilePath; .relativeFilePath = data.relativeFilePath,
searchMatches_[row] = filterString.check(searchData); };
searchMatches[row] = filterString.check(searchData);
} }
} }
@ -190,38 +196,27 @@ void VisualDeckStorageSortFilterProxyModel::updateTagMatches()
{ {
const auto *source = deckSourceModel(); const auto *source = deckSourceModel();
if (!source) { if (!source) {
tagMatches_.clear(); tagMatches.clear();
return; return;
} }
const int count = source->rowCount(); const int count = source->rowCount();
tagMatches_.resize(count); tagMatches.resize(count);
if (selectedTags_.isEmpty() && excludedTags_.isEmpty()) { if (selectedTags.isEmpty() && excludedTags.isEmpty()) {
tagMatches_.fill(true); tagMatches.fill(true);
return; return;
} }
for (int row = 0; row < count; ++row) { for (int row = 0; row < count; ++row) {
const QStringList deckTags = source->dataForRow(row).tags; const QStringList deckTags = source->dataForRow(row).tags;
bool hasAllSelected = true; const bool hasAllSelected = std::all_of(selectedTags.begin(), selectedTags.end(),
for (const QString &tag : selectedTags_) { [&deckTags](const QString &tag) { return deckTags.contains(tag); });
if (!deckTags.contains(tag)) { const bool hasAnyExcluded = std::any_of(excludedTags.begin(), excludedTags.end(),
hasAllSelected = false; [&deckTags](const QString &tag) { return deckTags.contains(tag); });
break;
}
}
bool hasAnyExcluded = false; tagMatches[row] = hasAllSelected && !hasAnyExcluded;
for (const QString &tag : excludedTags_) {
if (deckTags.contains(tag)) {
hasAnyExcluded = true;
break;
}
}
tagMatches_[row] = hasAllSelected && !hasAnyExcluded;
} }
} }
@ -229,15 +224,15 @@ void VisualDeckStorageSortFilterProxyModel::updateColorMatches()
{ {
const auto *source = deckSourceModel(); const auto *source = deckSourceModel();
if (!source) { if (!source) {
colorMatches_.clear(); colorMatches.clear();
return; return;
} }
const int count = source->rowCount(); const int count = source->rowCount();
colorMatches_.resize(count); colorMatches.resize(count);
if (activeColors_.isEmpty()) { if (activeColors.isEmpty()) {
colorMatches_.fill(true); colorMatches.fill(true);
return; return;
} }
@ -245,10 +240,10 @@ void VisualDeckStorageSortFilterProxyModel::updateColorMatches()
const QString colorIdentity = source->dataForRow(row).colorIdentity; const QString colorIdentity = source->dataForRow(row).colorIdentity;
bool matches = true; bool matches = true;
switch (colorFilterMode_) { switch (colorFilterMode) {
case ExactMatch: { case ExactMatch: {
QSet<QChar> activeColorSet; QSet<QChar> activeColorSet;
for (const QChar &color : activeColors_) { for (const QChar &color : activeColors) {
activeColorSet.insert(color.toUpper()); activeColorSet.insert(color.toUpper());
} }
@ -261,24 +256,16 @@ void VisualDeckStorageSortFilterProxyModel::updateColorMatches()
break; break;
} }
case Includes: case Includes:
for (const QChar &color : activeColors_) { matches = std::all_of(activeColors.begin(), activeColors.end(),
if (!colorIdentity.contains(color)) { [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
matches = false;
break;
}
}
break; break;
case Excludes: case Excludes:
for (const QChar &color : activeColors_) { matches = std::none_of(activeColors.begin(), activeColors.end(),
if (colorIdentity.contains(color)) { [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
matches = false;
break;
}
}
break; break;
} }
colorMatches_[row] = matches; colorMatches[row] = matches;
} }
} }

View file

@ -52,8 +52,8 @@ public:
/// @name Filter input setters (each re-evaluates the affected matches) /// @name Filter input setters (each re-evaluates the affected matches)
///@{ ///@{
void setSearchText(const QString &text); void setSearchText(const QString &text);
void setTagFilter(const QSet<QString> &selectedTags, const QSet<QString> &excludedTags); void setTagFilter(const QSet<QString> &newSelectedTags, const QSet<QString> &newExcludedTags);
void setColorFilter(FilterMode mode, const QSet<QChar> &activeColors); void setColorFilter(FilterMode mode, const QSet<QChar> &colors);
///@} ///@}
/** /**
@ -83,16 +83,16 @@ private:
void updateColorMatches(); void updateColorMatches();
[[nodiscard]] const VisualDeckStorageModel *deckSourceModel() const; [[nodiscard]] const VisualDeckStorageModel *deckSourceModel() const;
QString searchText_; QString searchText;
QSet<QString> selectedTags_; QSet<QString> selectedTags;
QSet<QString> excludedTags_; QSet<QString> excludedTags;
FilterMode colorFilterMode_ = ExactMatch; FilterMode colorFilterMode = ExactMatch;
QSet<QChar> activeColors_; QSet<QChar> activeColors;
SortOrder sortOrder_ = Alphabetical; SortOrder sortOrder = Alphabetical;
QList<bool> searchMatches_; ///< Per-row search match, sized like the source model. QList<bool> searchMatches; ///< Per-row search match, sized like the source model.
QList<bool> tagMatches_; ///< Per-row tag match. QList<bool> tagMatches; ///< Per-row tag match.
QList<bool> colorMatches_; ///< Per-row color identity match. QList<bool> colorMatches; ///< Per-row color identity match.
}; };
#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H #endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H