mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 09:05:10 -07:00
[VDS] Add async-scanned model and sort/filter proxy (#7105)
* [VDS] Add async-scanned model and sort/filter proxy (model layer) Took 1 minute Took 27 seconds * Address comments --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
parent
765ebf8fb1
commit
a8bacc5296
5 changed files with 1034 additions and 0 deletions
|
|
@ -0,0 +1,503 @@
|
|||
#include "visual_deck_storage_model.h"
|
||||
|
||||
#include "../../deck_loader/deck_loader.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QFutureWatcher>
|
||||
#include <QSet>
|
||||
#include <QtConcurrentRun>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* @brief The result of a background directory scan: the deck rows in scan order
|
||||
* plus the sorted list of subfolder paths.
|
||||
*/
|
||||
struct DeckScanResult
|
||||
{
|
||||
QList<DeckPreviewData> decks; ///< Deck rows in scan order.
|
||||
QStringList folderPaths; ///< Sorted list of subfolder paths.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief The result of a background deck file load: the parsed deck plus the
|
||||
* file's modification time, so the disk stat happens off the UI thread.
|
||||
*/
|
||||
struct DeckLoadResult
|
||||
{
|
||||
LoadedDeck deck; ///< The parsed deck.
|
||||
QDateTime lastModified; ///< File modification time at load.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief The path of \a path relative to the deck root, or empty if \a path
|
||||
* is not below it.
|
||||
*/
|
||||
QString relativePathFromDeckRoot(const QString &path, const QString &deckPath)
|
||||
{
|
||||
if (!path.startsWith(deckPath)) {
|
||||
return {};
|
||||
}
|
||||
QString relativePath = path.mid(deckPath.length());
|
||||
if (relativePath.startsWith('/')) {
|
||||
relativePath.remove(0, 1);
|
||||
}
|
||||
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
|
||||
|
||||
VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent)
|
||||
{
|
||||
}
|
||||
|
||||
int VisualDeckStorageModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
return parent.isValid() ? 0 : decks.size();
|
||||
}
|
||||
|
||||
QVariant VisualDeckStorageModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= decks.size()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const DeckPreviewData &data = decks.at(index.row());
|
||||
switch (role) {
|
||||
case Qt::DisplayRole:
|
||||
case VisualDeckStorageRoles::DisplayNameRole:
|
||||
return data.displayName;
|
||||
case VisualDeckStorageRoles::FilePathRole:
|
||||
return data.filePath;
|
||||
case VisualDeckStorageRoles::RelativeFilePathRole:
|
||||
return data.relativeFilePath;
|
||||
case VisualDeckStorageRoles::FolderPathRole:
|
||||
return data.folderPath;
|
||||
case VisualDeckStorageRoles::TagsRole:
|
||||
return data.tags;
|
||||
case VisualDeckStorageRoles::ColorIdentityRole:
|
||||
return data.colorIdentity;
|
||||
case VisualDeckStorageRoles::LastModifiedRole:
|
||||
return data.lastModified;
|
||||
case VisualDeckStorageRoles::LastLoadedRole:
|
||||
return data.lastLoaded;
|
||||
case VisualDeckStorageRoles::BannerCardNameRole:
|
||||
return data.bannerCard.name;
|
||||
case VisualDeckStorageRoles::BannerCardProviderIdRole:
|
||||
return data.bannerCard.providerId;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDeckStorageModel::setDeckPath(const QString &path)
|
||||
{
|
||||
QString cleanedPath = QDir::cleanPath(path);
|
||||
if (cleanedPath == ".") {
|
||||
cleanedPath.clear();
|
||||
}
|
||||
deckPath = cleanedPath;
|
||||
startScan();
|
||||
}
|
||||
|
||||
void VisualDeckStorageModel::refresh()
|
||||
{
|
||||
startScan();
|
||||
}
|
||||
|
||||
const DeckPreviewData &VisualDeckStorageModel::dataForRow(int row) const
|
||||
{
|
||||
static const DeckPreviewData emptyData;
|
||||
if (row < 0 || row >= decks.size()) {
|
||||
return emptyData;
|
||||
}
|
||||
return decks.at(row);
|
||||
}
|
||||
|
||||
const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const
|
||||
{
|
||||
return dataForRow(row).deck;
|
||||
}
|
||||
|
||||
int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const
|
||||
{
|
||||
for (int i = 0; i < decks.size(); ++i) {
|
||||
if (decks.at(i).filePath == filePath) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void VisualDeckStorageModel::startScan()
|
||||
{
|
||||
++scanGeneration;
|
||||
beginResetModel();
|
||||
decks.clear();
|
||||
folderPaths.clear();
|
||||
endResetModel();
|
||||
|
||||
if (deckPath.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString currentDeckPath = deckPath;
|
||||
const int generation = scanGeneration;
|
||||
|
||||
// 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.
|
||||
auto *watcher = new QFutureWatcher<DeckScanResult>(this);
|
||||
connect(watcher, &QFutureWatcher<DeckScanResult>::finished, this, [this, watcher, generation] {
|
||||
watcher->deleteLater();
|
||||
|
||||
if (generation != scanGeneration) {
|
||||
return; // A newer scan started while this one was running; drop the stale result.
|
||||
}
|
||||
|
||||
const DeckScanResult result = watcher->result();
|
||||
folderPaths = result.folderPaths;
|
||||
|
||||
if (result.decks.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
beginInsertRows(QModelIndex(), 0, result.decks.size() - 1);
|
||||
decks = result.decks;
|
||||
endInsertRows();
|
||||
|
||||
for (int row = 0; row < decks.size(); ++row) {
|
||||
beginLoad(row);
|
||||
}
|
||||
});
|
||||
|
||||
watcher->setFuture(
|
||||
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }));
|
||||
}
|
||||
|
||||
void VisualDeckStorageModel::beginLoad(int row)
|
||||
{
|
||||
if (row < 0 || row >= decks.size() || decks.at(row).loadInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
DeckPreviewData &data = decks[row];
|
||||
data.loadInProgress = true;
|
||||
|
||||
const QString filePath = data.filePath;
|
||||
const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath);
|
||||
const int generation = scanGeneration;
|
||||
|
||||
auto *watcher = new QFutureWatcher<std::optional<DeckLoadResult>>(this);
|
||||
connect(watcher, &QFutureWatcher<std::optional<DeckLoadResult>>::finished, this,
|
||||
[this, watcher, filePath, generation] {
|
||||
watcher->deleteLater();
|
||||
|
||||
if (generation != scanGeneration) {
|
||||
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;
|
||||
}
|
||||
|
||||
DeckPreviewData &data = decks[row];
|
||||
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> {
|
||||
std::optional<LoadedDeck> deck = DeckLoader::loadFromFile(filePath, fmt, false);
|
||||
if (!deck) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return DeckLoadResult{*deck, QFileInfo(filePath).lastModified()};
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Computes the color identity of a deck in WUBRG order.
|
||||
*/
|
||||
static QString computeColorIdentity(const LoadedDeck &deck)
|
||||
{
|
||||
QStringList cardList = deck.deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE});
|
||||
if (cardList.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QSet<QChar> colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G)
|
||||
|
||||
for (const QString &cardName : cardList) {
|
||||
CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName);
|
||||
if (currentCard) {
|
||||
const QString colors = currentCard->getColors(); // Something like "WUB"
|
||||
for (const QChar &color : colors) {
|
||||
colorSet.insert(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the color identity is in WUBRG order
|
||||
QString colorIdentity;
|
||||
const QString wubrgOrder = "WUBRG";
|
||||
for (const QChar &color : wubrgOrder) {
|
||||
if (colorSet.contains(color)) {
|
||||
colorIdentity.append(color);
|
||||
}
|
||||
}
|
||||
|
||||
return colorIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Recomputes all derived metadata of a row from its loaded deck.
|
||||
*/
|
||||
void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data)
|
||||
{
|
||||
const DeckList &deckList = data.deck.deckList;
|
||||
|
||||
data.deckName = deckList.getName();
|
||||
data.displayName = !data.deckName.isEmpty() ? data.deckName : QFileInfo(data.deck.lastLoadInfo.fileName).fileName();
|
||||
data.tags = deckList.getTags();
|
||||
data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp());
|
||||
data.bannerCard = deckList.getBannerCard();
|
||||
data.colorIdentity = computeColorIdentity(data.deck);
|
||||
}
|
||||
|
||||
void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath)
|
||||
{
|
||||
if (row < 0 || row >= decks.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DeckPreviewData &data = decks[row];
|
||||
data.filePath = newFilePath;
|
||||
data.relativeFilePath = relativeFilePathFor(newFilePath, deckPath);
|
||||
data.folderPath = folderPathFor(newFilePath, deckPath);
|
||||
}
|
||||
|
||||
bool VisualDeckStorageModel::renameDeck(int row, const QString &newName)
|
||||
{
|
||||
if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DeckPreviewData &data = decks[row];
|
||||
data.deck.deckList.setName(newName);
|
||||
if (!DeckLoader::saveToFile(data.deck)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
recomputeDeckMetadata(data);
|
||||
emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::DisplayNameRole});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VisualDeckStorageModel::renameFile(int row, const QString &newBaseName)
|
||||
{
|
||||
if (row < 0 || row >= decks.size() || newBaseName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DeckPreviewData &data = decks[row];
|
||||
const QFileInfo info(data.filePath);
|
||||
if (newBaseName == info.baseName()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString newFileName = newBaseName;
|
||||
if (!info.suffix().isEmpty()) {
|
||||
newFileName += "." + info.suffix();
|
||||
}
|
||||
|
||||
const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath();
|
||||
if (!QFile::rename(info.filePath(), newFilePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString oldFilePath = data.filePath;
|
||||
data.deck.lastLoadInfo.fileName = newFilePath;
|
||||
setFilePathForRow(row, newFilePath);
|
||||
data.lastModified = QFileInfo(newFilePath).lastModified();
|
||||
|
||||
emit dataChanged(index(row), index(row));
|
||||
emit deckFilePathChanged(oldFilePath, newFilePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VisualDeckStorageModel::deleteFile(int row)
|
||||
{
|
||||
if (row < 0 || row >= decks.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString filePath = decks.at(row).filePath;
|
||||
if (!QFile::remove(QFileInfo(filePath).filePath())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
beginRemoveRows(QModelIndex(), row, row);
|
||||
decks.removeAt(row);
|
||||
endRemoveRows();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VisualDeckStorageModel::setTags(int row, const QStringList &tags)
|
||||
{
|
||||
if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DeckPreviewData &data = decks[row];
|
||||
data.deck.deckList.setTags(tags);
|
||||
if (!DeckLoader::saveToFile(data.deck)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data.tags = tags;
|
||||
emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::TagsRole});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VisualDeckStorageModel::setBannerCard(int row, const CardRef &cardRef)
|
||||
{
|
||||
if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DeckPreviewData &data = decks[row];
|
||||
data.deck.deckList.setBannerCard(cardRef);
|
||||
if (!DeckLoader::saveToFile(data.deck)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data.bannerCard = cardRef;
|
||||
emit dataChanged(index(row), index(row),
|
||||
{VisualDeckStorageRoles::BannerCardNameRole, VisualDeckStorageRoles::BannerCardProviderIdRole});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VisualDeckStorageModel::convertToCockatriceFormat(int row)
|
||||
{
|
||||
if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DeckPreviewData &data = decks[row];
|
||||
const QString oldFilePath = data.filePath;
|
||||
if (!DeckLoader::convertToCockatriceFormat(data.deck)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setFilePathForRow(row, data.deck.lastLoadInfo.fileName);
|
||||
data.lastModified = QFileInfo(data.filePath).lastModified();
|
||||
recomputeDeckMetadata(data);
|
||||
|
||||
emit dataChanged(index(row), index(row));
|
||||
if (oldFilePath != data.filePath) {
|
||||
emit deckFilePathChanged(oldFilePath, data.filePath);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VisualDeckStorageModel::reloadIfModified(int row)
|
||||
{
|
||||
if (row < 0 || row >= decks.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DeckPreviewData &data = decks[row];
|
||||
QFileInfo fileInfo(data.filePath);
|
||||
const QDateTime newLastModified = fileInfo.lastModified();
|
||||
if (!newLastModified.isValid() || newLastModified <= data.lastModified) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<LoadedDeck> result =
|
||||
DeckLoader::loadFromFile(data.filePath, DeckFileFormat::getFormatFromName(data.filePath), false);
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
data.deck = *result;
|
||||
data.loadSucceeded = true;
|
||||
data.lastModified = fileInfo.lastModified();
|
||||
recomputeDeckMetadata(data);
|
||||
|
||||
emit dataChanged(index(row), index(row));
|
||||
emit deckLoaded(row);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* @file visual_deck_storage_model.h
|
||||
* @ingroup VisualDeckStorageWidgets
|
||||
* @brief Source model for the Visual Deck Storage: the deck files on disk.
|
||||
*
|
||||
* The model owns the deck metadata (name, tags, color identity, banner card,
|
||||
* modification times) and the parsed deck list, loading each deck file in the
|
||||
* background. Views read through the roles or the direct accessors, and all
|
||||
* mutations (rename, tags, banner card, delete, conversion) go through this
|
||||
* class so the view layer never touches the filesystem directly.
|
||||
*/
|
||||
|
||||
#ifndef VISUAL_DECK_STORAGE_MODEL_H
|
||||
#define VISUAL_DECK_STORAGE_MODEL_H
|
||||
|
||||
#include "../../deck_loader/loaded_deck.h"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QStringList>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <optional>
|
||||
|
||||
namespace VisualDeckStorageRoles
|
||||
{
|
||||
/**
|
||||
* @brief Custom roles exposed by the VisualDeckStorageModel.
|
||||
*/
|
||||
enum
|
||||
{
|
||||
FilePathRole = Qt::UserRole + 1, /**< Absolute file path of the deck. */
|
||||
RelativeFilePathRole, /**< File path relative to the deck folder. */
|
||||
FolderPathRole, /**< Directory of the deck relative to the deck folder ("" for root). */
|
||||
DisplayNameRole, /**< Deck name, or the file name if the deck has no name. */
|
||||
TagsRole, /**< The deck's tags. */
|
||||
ColorIdentityRole, /**< The deck's color identity (WUBRG order). */
|
||||
LastModifiedRole, /**< QDateTime of the deck file's last modification. */
|
||||
LastLoadedRole, /**< QDateTime when the deck was last loaded from the file. */
|
||||
BannerCardNameRole, /**< Name of the deck's banner card. */
|
||||
BannerCardProviderIdRole /**< Provider id of the deck's banner card. */
|
||||
};
|
||||
} // namespace VisualDeckStorageRoles
|
||||
|
||||
/**
|
||||
* @brief One deck file as seen by the Visual Deck Storage.
|
||||
*
|
||||
* The metadata is computed once when the deck loads and refreshed on reloads
|
||||
* and mutations, so filters and sorts never re-read the file from disk.
|
||||
*/
|
||||
struct DeckPreviewData
|
||||
{
|
||||
QString filePath; ///< Absolute file path.
|
||||
QString relativeFilePath; ///< File path relative to the deck folder.
|
||||
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 displayName; ///< Deck name, or the file name if the deck has no name.
|
||||
QStringList tags; ///< The deck's tags.
|
||||
QString colorIdentity; ///< The deck's color identity in WUBRG order.
|
||||
QDateTime lastModified; ///< File modification time at last check.
|
||||
QDateTime lastLoaded; ///< When the deck was last loaded from the file.
|
||||
CardRef bannerCard; ///< The deck's banner card (name + provider id).
|
||||
LoadedDeck deck; ///< The parsed deck; empty until the file has been loaded.
|
||||
bool loadSucceeded = false; ///< Whether the deck file finished loading successfully.
|
||||
bool loadInProgress = false; ///< Whether the deck file is currently being loaded.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief The list model backing the Visual Deck Storage widget tree.
|
||||
*
|
||||
* Rows are in filesystem scan order; ordering and filtering are handled by
|
||||
* VisualDeckStorageSortFilterProxyModel on top of this model.
|
||||
*/
|
||||
class VisualDeckStorageModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit VisualDeckStorageModel(QObject *parent = nullptr);
|
||||
|
||||
/// @name Qt model overrides
|
||||
///@{
|
||||
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
|
||||
///@}
|
||||
|
||||
/**
|
||||
* @brief Sets the folder to scan for deck files and starts (re)loading.
|
||||
* Clears the model immediately (modelReset), then populates it asynchronously
|
||||
* as the background scan discovers deck files.
|
||||
*/
|
||||
void setDeckPath(const QString &path);
|
||||
|
||||
/**
|
||||
* @brief Re-scans the current deck folder, reloading every deck file.
|
||||
*/
|
||||
void refresh();
|
||||
|
||||
[[nodiscard]] QString getDeckPath() const
|
||||
{
|
||||
return deckPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
[[nodiscard]] QStringList getFolderPaths() const
|
||||
{
|
||||
return folderPaths;
|
||||
}
|
||||
|
||||
/// @name Data accessors
|
||||
///@{
|
||||
[[nodiscard]] const DeckPreviewData &dataForRow(int row) const;
|
||||
[[nodiscard]] const LoadedDeck &deckForRow(int row) const;
|
||||
[[nodiscard]] int rowForFilePath(const QString &filePath) const;
|
||||
///@}
|
||||
|
||||
/// @name Mutations (persist to disk and update the row)
|
||||
///@{
|
||||
bool renameDeck(int row, const QString &newName);
|
||||
bool renameFile(int row, const QString &newBaseName);
|
||||
bool deleteFile(int row);
|
||||
bool setTags(int row, const QStringList &tags);
|
||||
bool setBannerCard(int row, const CardRef &cardRef);
|
||||
bool convertToCockatriceFormat(int row);
|
||||
bool reloadIfModified(int row);
|
||||
///@}
|
||||
|
||||
signals:
|
||||
/**
|
||||
* @brief Emitted when a deck file finishes loading.
|
||||
* @param row The row of the deck that finished loading.
|
||||
*/
|
||||
void deckLoaded(int row);
|
||||
|
||||
/**
|
||||
* @brief Emitted when a deck's file path changes (rename file, conversion).
|
||||
* @param oldFilePath The previous file path.
|
||||
* @param newFilePath The new file path.
|
||||
*/
|
||||
void deckFilePathChanged(const QString &oldFilePath, const QString &newFilePath);
|
||||
|
||||
private:
|
||||
void startScan();
|
||||
void beginLoad(int row);
|
||||
static void recomputeDeckMetadata(DeckPreviewData &data);
|
||||
void setFilePathForRow(int row, const QString &newFilePath);
|
||||
|
||||
QString deckPath;
|
||||
QList<DeckPreviewData> decks;
|
||||
QStringList folderPaths; ///< All subdirectories of the deck folder, sorted.
|
||||
int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored.
|
||||
};
|
||||
|
||||
#endif // VISUAL_DECK_STORAGE_MODEL_H
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
#include "visual_deck_storage_sort_filter_proxy_model.h"
|
||||
|
||||
#include "../../filters/deck_filter_string.h"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <algorithm>
|
||||
|
||||
VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent)
|
||||
: QSortFilterProxyModel(parent)
|
||||
{
|
||||
setDynamicSortFilter(false);
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model)
|
||||
{
|
||||
if (QAbstractItemModel *oldModel = sourceModel()) {
|
||||
disconnect(oldModel, &QAbstractItemModel::modelReset, this,
|
||||
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
|
||||
disconnect(oldModel, &QAbstractItemModel::rowsInserted, this,
|
||||
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
|
||||
disconnect(oldModel, &QAbstractItemModel::rowsRemoved, this,
|
||||
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
|
||||
}
|
||||
|
||||
QSortFilterProxyModel::setSourceModel(model);
|
||||
|
||||
if (model) {
|
||||
connect(model, &QAbstractItemModel::modelReset, this, &VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
|
||||
connect(model, &QAbstractItemModel::rowsInserted, this,
|
||||
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
|
||||
connect(model, &QAbstractItemModel::rowsRemoved, this,
|
||||
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
|
||||
}
|
||||
|
||||
resizeMatchLists();
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::setSearchText(const QString &text)
|
||||
{
|
||||
if (searchText == text) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchText = text;
|
||||
updateSearchMatches();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::setTagFilter(const QSet<QString> &newSelectedTags,
|
||||
const QSet<QString> &newExcludedTags)
|
||||
{
|
||||
if (selectedTags == newSelectedTags && excludedTags == newExcludedTags) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedTags = newSelectedTags;
|
||||
excludedTags = newExcludedTags;
|
||||
updateTagMatches();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::setColorFilter(FilterMode mode, const QSet<QChar> &colors)
|
||||
{
|
||||
if (colorFilterMode == mode && activeColors == colors) {
|
||||
return;
|
||||
}
|
||||
|
||||
colorFilterMode = mode;
|
||||
activeColors = colors;
|
||||
updateColorMatches();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::setSortOrder(SortOrder order)
|
||||
{
|
||||
// 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.
|
||||
sortOrder = order;
|
||||
sort(0);
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::reapplyFilters()
|
||||
{
|
||||
const QList<bool> oldSearchMatches = searchMatches;
|
||||
const QList<bool> oldTagMatches = tagMatches;
|
||||
const QList<bool> oldColorMatches = colorMatches;
|
||||
|
||||
updateSearchMatches();
|
||||
updateTagMatches();
|
||||
updateColorMatches();
|
||||
|
||||
if (searchMatches != oldSearchMatches || tagMatches != oldTagMatches || colorMatches != oldColorMatches) {
|
||||
invalidate();
|
||||
}
|
||||
|
||||
if (sortOrder == ByName || sortOrder == ByLastLoaded) {
|
||||
// These orders depend on data that only becomes available when a deck finishes loading.
|
||||
sort(0);
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::resort()
|
||||
{
|
||||
sort(0);
|
||||
}
|
||||
|
||||
bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
|
||||
{
|
||||
if (sourceParent.isValid()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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() ||
|
||||
sourceRow >= colorMatches.size()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return searchMatches.at(sourceRow) && tagMatches.at(sourceRow) && colorMatches.at(sourceRow);
|
||||
}
|
||||
|
||||
bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
|
||||
{
|
||||
const auto *source = deckSourceModel();
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const DeckPreviewData &leftData = source->dataForRow(left.row());
|
||||
const DeckPreviewData &rightData = source->dataForRow(right.row());
|
||||
|
||||
switch (sortOrder) {
|
||||
case ByName:
|
||||
return leftData.deckName < rightData.deckName;
|
||||
case Alphabetical:
|
||||
return QString::localeAwareCompare(QFileInfo(leftData.filePath).fileName(),
|
||||
QFileInfo(rightData.filePath).fileName()) < 0;
|
||||
case ByLastModified:
|
||||
return leftData.lastModified > rightData.lastModified;
|
||||
case ByLastLoaded:
|
||||
return leftData.lastLoaded > rightData.lastLoaded;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::resizeMatchLists()
|
||||
{
|
||||
const int count = sourceModel() ? sourceModel()->rowCount() : 0;
|
||||
searchMatches.resize(count);
|
||||
searchMatches.fill(true);
|
||||
tagMatches.resize(count);
|
||||
tagMatches.fill(true);
|
||||
colorMatches.resize(count);
|
||||
colorMatches.fill(true);
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::updateSearchMatches()
|
||||
{
|
||||
const auto *source = deckSourceModel();
|
||||
if (!source) {
|
||||
searchMatches.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const int count = source->rowCount();
|
||||
searchMatches.resize(count);
|
||||
if (searchText.isEmpty()) {
|
||||
searchMatches.fill(true);
|
||||
return;
|
||||
}
|
||||
|
||||
DeckFilterString filterString(searchText);
|
||||
for (int row = 0; row < count; ++row) {
|
||||
const DeckPreviewData &data = source->dataForRow(row);
|
||||
|
||||
// isEmpty() is intentional: if a deck fails to load, loadInProgress becomes false
|
||||
// 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()) {
|
||||
searchMatches[row] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
DeckSearchData searchData{
|
||||
.deck = &data.deck,
|
||||
.filePath = data.filePath,
|
||||
.displayName = data.displayName,
|
||||
.relativeFilePath = data.relativeFilePath,
|
||||
};
|
||||
searchMatches[row] = filterString.check(searchData);
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::updateTagMatches()
|
||||
{
|
||||
const auto *source = deckSourceModel();
|
||||
if (!source) {
|
||||
tagMatches.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const int count = source->rowCount();
|
||||
tagMatches.resize(count);
|
||||
|
||||
if (selectedTags.isEmpty() && excludedTags.isEmpty()) {
|
||||
tagMatches.fill(true);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int row = 0; row < count; ++row) {
|
||||
const QStringList deckTags = source->dataForRow(row).tags;
|
||||
|
||||
const bool hasAllSelected = std::all_of(selectedTags.begin(), selectedTags.end(),
|
||||
[&deckTags](const QString &tag) { return deckTags.contains(tag); });
|
||||
const bool hasAnyExcluded = std::any_of(excludedTags.begin(), excludedTags.end(),
|
||||
[&deckTags](const QString &tag) { return deckTags.contains(tag); });
|
||||
|
||||
tagMatches[row] = hasAllSelected && !hasAnyExcluded;
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDeckStorageSortFilterProxyModel::updateColorMatches()
|
||||
{
|
||||
const auto *source = deckSourceModel();
|
||||
if (!source) {
|
||||
colorMatches.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const int count = source->rowCount();
|
||||
colorMatches.resize(count);
|
||||
|
||||
if (activeColors.isEmpty()) {
|
||||
colorMatches.fill(true);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int row = 0; row < count; ++row) {
|
||||
const QString colorIdentity = source->dataForRow(row).colorIdentity;
|
||||
|
||||
bool matches = true;
|
||||
switch (colorFilterMode) {
|
||||
case ExactMatch: {
|
||||
QSet<QChar> activeColorSet;
|
||||
for (const QChar &color : activeColors) {
|
||||
activeColorSet.insert(color.toUpper());
|
||||
}
|
||||
|
||||
QSet<QChar> colorIdentitySet;
|
||||
for (const QChar &color : colorIdentity) {
|
||||
colorIdentitySet.insert(color.toUpper());
|
||||
}
|
||||
|
||||
matches = activeColorSet == colorIdentitySet;
|
||||
break;
|
||||
}
|
||||
case Includes:
|
||||
matches = std::all_of(activeColors.begin(), activeColors.end(),
|
||||
[&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
|
||||
break;
|
||||
case Excludes:
|
||||
matches = std::none_of(activeColors.begin(), activeColors.end(),
|
||||
[&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
|
||||
break;
|
||||
}
|
||||
|
||||
colorMatches[row] = matches;
|
||||
}
|
||||
}
|
||||
|
||||
const VisualDeckStorageModel *VisualDeckStorageSortFilterProxyModel::deckSourceModel() const
|
||||
{
|
||||
return qobject_cast<const VisualDeckStorageModel *>(sourceModel());
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* @file visual_deck_storage_sort_filter_proxy_model.h
|
||||
* @ingroup VisualDeckStorageWidgets
|
||||
* @brief Sorting and filtering proxy on top of VisualDeckStorageModel.
|
||||
*
|
||||
* Owns all search / tag / color filter state and the sort order. Filtering is
|
||||
* evaluated against the model's data (never against widgets), so it can run
|
||||
* before any view exists and re-evaluate whenever deck data finishes loading.
|
||||
*/
|
||||
|
||||
#ifndef VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H
|
||||
#define VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H
|
||||
|
||||
#include "visual_deck_storage_model.h"
|
||||
|
||||
#include <QSet>
|
||||
#include <QSortFilterProxyModel>
|
||||
#include <QString>
|
||||
|
||||
class VisualDeckStorageSortFilterProxyModel : public QSortFilterProxyModel
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/**
|
||||
* @brief The order in which decks are sorted. Values must match the
|
||||
* entries of the sort widget's combo box and the stored settings value.
|
||||
*/
|
||||
enum SortOrder
|
||||
{
|
||||
ByName,
|
||||
Alphabetical,
|
||||
ByLastModified,
|
||||
ByLastLoaded,
|
||||
};
|
||||
Q_ENUM(SortOrder)
|
||||
|
||||
/**
|
||||
* @brief How the color identity filter is applied.
|
||||
*/
|
||||
enum FilterMode
|
||||
{
|
||||
ExactMatch,
|
||||
Includes,
|
||||
Excludes
|
||||
};
|
||||
Q_ENUM(FilterMode)
|
||||
|
||||
explicit VisualDeckStorageSortFilterProxyModel(QObject *parent = nullptr);
|
||||
|
||||
void setSourceModel(QAbstractItemModel *model) override;
|
||||
|
||||
/// @name Filter input setters (each re-evaluates the affected matches)
|
||||
///@{
|
||||
void setSearchText(const QString &text);
|
||||
void setTagFilter(const QSet<QString> &newSelectedTags, const QSet<QString> &newExcludedTags);
|
||||
void setColorFilter(FilterMode mode, const QSet<QChar> &colors);
|
||||
///@}
|
||||
|
||||
/**
|
||||
* @brief Sets the sort order and applies it immediately.
|
||||
*/
|
||||
void setSortOrder(SortOrder order);
|
||||
|
||||
/**
|
||||
* @brief Re-evaluates all matches against the current model data and
|
||||
* re-applies filtering and sorting. Called after deck data changes.
|
||||
*/
|
||||
void reapplyFilters();
|
||||
|
||||
/**
|
||||
* @brief Re-applies the current sort order without touching the filters.
|
||||
*/
|
||||
void resort();
|
||||
|
||||
protected:
|
||||
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
|
||||
bool lessThan(const QModelIndex &left, const QModelIndex &right) const override;
|
||||
|
||||
private:
|
||||
void resizeMatchLists();
|
||||
void updateSearchMatches();
|
||||
void updateTagMatches();
|
||||
void updateColorMatches();
|
||||
[[nodiscard]] const VisualDeckStorageModel *deckSourceModel() const;
|
||||
|
||||
QString searchText;
|
||||
QSet<QString> selectedTags;
|
||||
QSet<QString> excludedTags;
|
||||
FilterMode colorFilterMode = ExactMatch;
|
||||
QSet<QChar> activeColors;
|
||||
SortOrder sortOrder = Alphabetical;
|
||||
|
||||
QList<bool> searchMatches; ///< Per-row search match, sized like the source model.
|
||||
QList<bool> tagMatches; ///< Per-row tag match.
|
||||
QList<bool> colorMatches; ///< Per-row color identity match.
|
||||
};
|
||||
|
||||
#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H
|
||||
Loading…
Add table
Add a link
Reference in a new issue