[Models] Mirror custom deck zones in the deck list model

DeckListModel now surfaces the custom zones from the deck tree so
views can render and edit them alongside criteria groups.

- rebuildTree mirrors each custom zone as a DecklistModelSubZoneNode
  under its board zone, cards flat inside (no further grouping).
- The freshly built shadow tree is sorted while the model reset is
  still open, so views never observe unsorted intermediate order and
  proxies cannot desync.
- Custom zones always sort after criteria groups within a board,
  regardless of their names. sortHelper remaps persistent indexes
  through the pre-shift rows to keep selections valid.
- addCard inserts flat into a custom zone by name and keeps grouping
  by active criteria for board zones. findCardNode resolves cards in
  both layouts, legacy top-level zones unchanged.
- New IsCustomZoneRole lets views tell zones apart from groups.
- Empty custom zones survive row removal. Zone rows themselves are
  only mutable through the deck tree API.

Took 5 minutes


Took 20 minutes
This commit is contained in:
Lukas Brübach 2026-08-23 22:50:37 +02:00
parent 0a09884c78
commit f796e2e419
2 changed files with 209 additions and 27 deletions

View file

@ -68,6 +68,16 @@ void DeckListModel::rebuildTree()
//! \todo Better sanity checking.
if (currentCard == nullptr) {
// Custom zones nested under the board zone are mirrored as-is,
// with their cards as direct children (no further grouping).
if (auto *customZone = dynamic_cast<InnerDecklistNode *>(currentZone->at(j))) {
auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), node);
for (int k = 0; k < customZone->size(); k++) {
if (auto *customCard = dynamic_cast<DecklistCardNode *>(customZone->at(k))) {
new DecklistModelCardNode(customCard, shadowZone);
}
}
}
continue;
}
@ -84,6 +94,13 @@ void DeckListModel::rebuildTree()
}
}
// The shadow tree was built in deck file order. Apply the active sort while
// the reset is still open so every consumer (tree view and visual editor)
// sees the canonical order from the start. sortShadowTree emits no signals,
// which is only valid before endResetModel closes the reset.
root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName);
sortShadowTree(root, lastKnownOrder);
endResetModel();
refreshCardFormatLegalities();
@ -154,6 +171,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
case DeckRoles::IsLegalRole:
return true;
case DeckRoles::IsCustomZoneRole:
return dynamic_cast<const DecklistModelSubZoneNode *>(group) != nullptr;
default:
return {};
}
@ -190,6 +210,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
return card->getFormatLegality();
}
case DeckRoles::IsCustomZoneRole: {
return false;
}
default: {
return {};
}
@ -327,6 +351,13 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
return false;
}
// Custom zone rows are managed through the deck tree, never removed as model rows.
for (int i = 0; i < count; i++) {
if (dynamic_cast<DecklistModelSubZoneNode *>(node->at(row + i))) {
return false;
}
}
beginRemoveRows(parent, row, row + count - 1);
for (int i = 0; i < count; i++) {
AbstractDecklistNode *toDelete = node->takeAt(row);
@ -337,7 +368,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
}
endRemoveRows();
if (node->empty() && (node != root)) {
// Empty criteria groups get pruned, but custom zones stay until explicitly deleted.
if (node->empty() && (node != root) && !dynamic_cast<DecklistModelSubZoneNode *>(node)) {
removeRows(parent.row(), 1, parent.parent());
} else {
emitRecursiveUpdates(parent);
@ -365,24 +397,41 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName,
const QString &providerId,
const QString &cardNumber) const
{
InnerDecklistNode *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
if (!zoneNode) {
return nullptr;
}
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
if (!info) {
return nullptr;
}
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
InnerDecklistNode *groupNode = dynamic_cast<InnerDecklistNode *>(zoneNode->findChild(groupCriteria));
if (!groupNode) {
return nullptr;
// 1. Board zone lookup: search the criteria groups, then the custom zones
// nested under the board.
if (auto *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName))) {
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
if (auto *groupNode = dynamic_cast<InnerDecklistNode *>(zoneNode->findChild(groupCriteria))) {
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
return card;
}
}
for (auto *child : *zoneNode) {
auto *customZone = dynamic_cast<DecklistModelSubZoneNode *>(child);
if (!customZone) {
continue;
}
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
return card;
}
}
}
return dynamic_cast<DecklistModelCardNode *>(
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
// 2. Custom zone lookup by name (custom zone names are deck-unique).
if (auto *customZone = findSubZoneNodeByName(zoneName)) {
return dynamic_cast<DecklistModelCardNode *>(
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
}
return nullptr;
}
QModelIndex DeckListModel::findCard(const QString &cardName,
@ -398,6 +447,25 @@ QModelIndex DeckListModel::findCard(const QString &cardName,
return nodeToIndex(cardNode);
}
DecklistModelSubZoneNode *DeckListModel::findSubZoneNodeByName(const QString &zoneName) const
{
for (int i = 0; i < root->size(); i++) {
auto *boardZone = dynamic_cast<InnerDecklistNode *>(root->at(i));
if (!boardZone) {
continue;
}
for (int j = 0; j < boardZone->size(); j++) {
auto *customZone = dynamic_cast<DecklistModelSubZoneNode *>(boardZone->at(j));
if (customZone && customZone->getName() == zoneName) {
return customZone;
}
}
}
return nullptr;
}
QModelIndex DeckListModel::addPreferredPrintingCard(const QString &cardName, const QString &zoneName, bool abAddAnyway)
{
ExactCard card = CardDatabaseManager::query()->getCard({cardName});
@ -423,29 +491,43 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam
return {};
}
InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root);
CardInfoPtr cardInfo = card.getCardPtr();
PrintingInfo printingInfo = card.getPrinting();
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode);
InnerDecklistNode *cardParent = nullptr;
const QModelIndex parentIndex = nodeToIndex(groupNode);
auto *cardNode = dynamic_cast<DecklistModelCardNode *>(groupNode->findCardChildByNameProviderIdAndNumber(
auto *boardNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
auto *customZoneNode = boardNode ? nullptr : findSubZoneNodeByName(zoneName);
if (boardNode) {
// Board zone: cards are grouped by the active criteria.
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
cardParent = createNodeIfNeeded(groupCriteria, boardNode);
} else if (customZoneNode) {
// Custom zone: cards live flat inside the zone.
cardParent = customZoneNode;
} else {
// Unknown zone: create a top-level zone (legacy behavior).
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
auto *newZone = createNodeIfNeeded(zoneName, root);
cardParent = createNodeIfNeeded(groupCriteria, newZone);
}
const QModelIndex parentIndex = nodeToIndex(cardParent);
auto *cardNode = dynamic_cast<DecklistModelCardNode *>(cardParent->findCardChildByNameProviderIdAndNumber(
card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num")));
const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName();
bool cardNodeAdded = false;
if (!cardNode) {
// Determine the correct index
int insertRow = findSortedInsertRow(groupNode, cardInfo);
int insertRow = findSortedInsertRow(cardParent, cardInfo);
auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName,
printingInfo.getProperty("num"), printingInfo.getProperty("uuid"));
beginInsertRows(parentIndex, insertRow, insertRow);
cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow);
cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow);
endInsertRows();
cardNodeAdded = true;
@ -576,21 +658,76 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const
return createIndex(node->getParent()->indexOf(node), 0, node);
}
/**
* @brief Moves custom zones of a board zone after the criteria groups, stably.
*
* Sorting would interleave custom zones with criteria groups by name; custom
* zones must always stay after the groups, regardless of their names.
*/
void DeckListModel::shiftCustomZonesToEnd(InnerDecklistNode *boardZone)
{
QList<AbstractDecklistNode *> customZones;
for (int i = boardZone->size() - 1; i >= 0; --i) {
if (dynamic_cast<DecklistModelSubZoneNode *>(boardZone->at(i))) {
customZones.prepend(boardZone->takeAt(i));
}
}
for (auto *customZone : customZones) {
boardZone->append(customZone);
}
}
/**
* @brief Sorts a freshly built shadow subtree without emitting model signals.
*
* Used by rebuildTree while the model reset is still open (emitting layout
* changes during a reset is invalid). Mirrors the reordering part of
* sortHelper: sort every node, then move board-level custom zones after groups.
*/
void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order)
{
node->sort(order);
if (node != root && node->getParent() == root) {
shiftCustomZonesToEnd(node);
}
for (int i = node->size() - 1; i >= 0; --i) {
if (auto *subNode = dynamic_cast<InnerDecklistNode *>(node->at(i))) {
sortShadowTree(subNode, order);
}
}
}
void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order)
{
// Sort children of node and save the information needed to
// update the list of persistent indexes.
QVector<QPair<int, int>> sortResult = node->sort(order);
// For board zones, custom zones must stay after the criteria groups,
// regardless of their names. Shift them to the end (stable).
QHash<AbstractDecklistNode *, int> rowAfterSort;
bool isBoardZone = (node != root) && (node->getParent() == root);
if (isBoardZone) {
for (int i = 0; i < node->size(); ++i) {
rowAfterSort.insert(node->at(i), i);
}
shiftCustomZonesToEnd(node);
}
QModelIndexList from, to;
int columns = columnCount();
for (int i = sortResult.size() - 1; i >= 0; --i) {
const int fromRow = sortResult[i].first;
const int toRow = sortResult[i].second;
AbstractDecklistNode *temp = node->at(toRow);
for (int newRow = 0; newRow < node->size(); ++newRow) {
AbstractDecklistNode *temp = node->at(newRow);
int preSortRow = sortResult.value(newRow).first;
if (isBoardZone) {
const int rowAfterSortOfNode = rowAfterSort.value(temp, newRow);
preSortRow = sortResult.value(rowAfterSortOfNode).first;
}
for (int j = 0; j < columns; ++j) {
from << createIndex(fromRow, j, temp);
to << createIndex(toRow, j, temp);
from << createIndex(preSortRow, j, temp);
to << createIndex(newRow, j, temp);
}
}
changePersistentIndexList(from, to);
@ -704,6 +841,15 @@ QList<QString> DeckListModel::getZones() const
return zones;
}
QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const
{
QStringList zoneNames;
for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) {
zoneNames.append(customZone->getName());
}
return zoneNames;
}
static int maxAllowedForLegality(const FormatRules &format, const QString &legality)
{
for (const AllowedCount &c : format.allowedCounts) {

View file

@ -30,7 +30,8 @@ enum
{
IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */
DepthRole, /**< Depth level within the deck's grouping hierarchy. */
IsLegalRole /**< Whether the card is legal in the current deck format. */
IsLegalRole, /**< Whether the card is legal in the current deck format. */
IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */
};
} // namespace DeckRoles
@ -190,6 +191,21 @@ public:
}
};
/**
* @class DecklistModelSubZoneNode
* @ingroup DeckModels
* @brief Model node representing a custom zone nested under a board zone.
*
* Custom zones group cards by user-defined names (e.g. "Removal", "Utility")
* inside a board zone. They are mirrored from the underlying deck tree so that
* they can be told apart from criteria group nodes.
*/
class DecklistModelSubZoneNode : public InnerDecklistNode
{
public:
using InnerDecklistNode::InnerDecklistNode;
};
/**
* @class DeckListModel
* @ingroup DeckModels
@ -391,6 +407,14 @@ public:
*/
[[nodiscard]] QList<QString> getZones() const;
/**
* @brief Gets the names of the custom zones nested under the given board zone.
*
* @param boardZoneName The board zone to query (main/side/maybeboard)
* @return The custom zone names, in deck order
*/
[[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const;
private:
QSharedPointer<DeckList> deckList; /**< Pointer to the decklist providing the underlying data. */
InnerDecklistNode *root; /**< Root node of the model tree. */
@ -405,6 +429,16 @@ private:
const QString &providerId = "",
const QString &cardNumber = "") const;
/**
* @brief Finds a custom zone node in the model tree by name.
*
* Custom zone names are unique across the whole deck.
*
* @param zoneName Name of the custom zone.
* @return The custom zone node, or nullptr if not found.
*/
[[nodiscard]] DecklistModelSubZoneNode *findSubZoneNodeByName(const QString &zoneName) const;
/**
* @brief Determines the sorted insertion row for a card.
* @param parent The parent node where the card will be inserted.
@ -427,6 +461,8 @@ private:
void emitRecursiveUpdates(const QModelIndex &index);
void sortHelper(InnerDecklistNode *node, Qt::SortOrder order);
void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order);
void shiftCustomZonesToEnd(InnerDecklistNode *boardZone);
template <typename T> T getNode(const QModelIndex &index) const
{