mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-23 10:05:10 -07:00
Compare commits
10 commits
ddb5c3ff90
...
9e6672c2b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6672c2b5 | ||
|
|
334515b8ad | ||
|
|
78f3abfb50 | ||
|
|
834389600f | ||
|
|
30c0ba2cd6 | ||
|
|
2a6f5a6953 | ||
|
|
68e4fa054d | ||
|
|
6f86c45ea8 | ||
|
|
dade7ae78a | ||
|
|
8f52223322 |
42 changed files with 1670 additions and 137 deletions
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout repository"
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: "Initialize CodeQL"
|
||||
uses: github/codeql-action/init@v4
|
||||
|
|
|
|||
2
.github/workflows/docker-release.yml
vendored
2
.github/workflows/docker-release.yml
vendored
|
|
@ -127,7 +127,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Download digests"
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp
|
||||
src/interface/widgets/deck_editor/deck_list_style_proxy.cpp
|
||||
src/interface/widgets/deck_editor/deck_state_manager.cpp
|
||||
src/interface/widgets/deck_editor/deck_zone_dialog.cpp
|
||||
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
|
||||
src/interface/widgets/general/background_sources.cpp
|
||||
src/interface/widgets/general/display/background_plate_widget.cpp
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
#include <QMouseEvent>
|
||||
#include <QtMath>
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
|
|
@ -381,18 +382,25 @@ void DeckViewScene::rebuildTree()
|
|||
addItem(container);
|
||||
}
|
||||
|
||||
for (int j = 0; j < currentZone->size(); j++) {
|
||||
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
|
||||
if (!currentCard) {
|
||||
continue;
|
||||
// Cards in custom zones nested under a board are regular board cards in-game.
|
||||
// They are collected recursively and reported with the top-level board zone
|
||||
// as their origin, so that sideboard plans keep working.
|
||||
std::function<void(const InnerDecklistNode *)> addZoneCards = [&addZoneCards, container, currentZone,
|
||||
this](const InnerDecklistNode *zone) {
|
||||
for (int j = 0; j < zone->size(); j++) {
|
||||
auto *currentCard = dynamic_cast<DecklistCardNode *>(zone->at(j));
|
||||
if (currentCard) {
|
||||
for (int k = 0; k < currentCard->getNumber(); ++k) {
|
||||
auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName());
|
||||
container->addCard(newCard);
|
||||
emit newCardAdded(newCard);
|
||||
}
|
||||
} else if (auto *innerZone = dynamic_cast<InnerDecklistNode *>(zone->at(j))) {
|
||||
addZoneCards(innerZone);
|
||||
}
|
||||
}
|
||||
|
||||
for (int k = 0; k < currentCard->getNumber(); ++k) {
|
||||
auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName());
|
||||
container->addCard(newCard);
|
||||
emit newCardAdded(newCard);
|
||||
}
|
||||
}
|
||||
};
|
||||
addZoneCards(currentZone);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,18 @@ QWidget *CardGroupDisplayWidget::constructWidgetForIndex(QPersistentModelIndex i
|
|||
|
||||
void CardGroupDisplayWidget::updateCardDisplays()
|
||||
{
|
||||
// Custom zones are user-defined containers: they display their cards in the same
|
||||
// order as the tree view, i.e. the model row order. Only criteria groups apply
|
||||
// the visual sort criteria.
|
||||
const bool isCustomZone = trackedIndex.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
|
||||
if (isCustomZone) {
|
||||
for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) {
|
||||
addCardWidgets(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
DeckListSortFilterProxyModel proxy;
|
||||
proxy.setSourceModel(deckListModel);
|
||||
proxy.setSortCriteria(activeSortCriteria);
|
||||
|
|
@ -174,16 +186,18 @@ void CardGroupDisplayWidget::updateCardDisplays()
|
|||
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
|
||||
|
||||
// 4. persist the source index
|
||||
QPersistentModelIndex persistent(sourceIndex);
|
||||
addCardWidgets(QPersistentModelIndex(sourceIndex));
|
||||
}
|
||||
}
|
||||
|
||||
// Get the card amount
|
||||
int cardAmount =
|
||||
sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||
void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent)
|
||||
{
|
||||
// Get the card amount
|
||||
int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||
|
||||
// Create multiple widgets for the card count
|
||||
for (int copy = 0; copy < cardAmount; ++copy) {
|
||||
addToLayout(constructWidgetForIndex(persistent));
|
||||
}
|
||||
// Create multiple widgets for the card count
|
||||
for (int copy = 0; copy < cardAmount; ++copy) {
|
||||
addToLayout(constructWidgetForIndex(persistent));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ public:
|
|||
void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected);
|
||||
void refreshSelectionForIndex(const QPersistentModelIndex &persistent);
|
||||
void clearAllDisplayWidgets();
|
||||
void addCardWidgets(const QPersistentModelIndex &persistent);
|
||||
|
||||
DeckListModel *deckListModel;
|
||||
QItemSelectionModel *selectionModel;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "libcockatrice/card/database/card_database_manager.h"
|
||||
|
||||
#include <QResizeEvent>
|
||||
#include <algorithm>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
|
||||
DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
||||
|
|
@ -51,11 +52,6 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
|
|||
// User Interaction
|
||||
// =====================================================================================================================
|
||||
|
||||
void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card)
|
||||
{
|
||||
emit cardClicked(event, card, zoneName);
|
||||
}
|
||||
|
||||
void DeckCardZoneDisplayWidget::onHover(const ExactCard &card)
|
||||
{
|
||||
emit cardHovered(card);
|
||||
|
|
@ -95,12 +91,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
|||
}
|
||||
|
||||
auto categoryName = index.sibling(index.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
// Cards in a custom zone belong to that zone, not the board zone, so that
|
||||
// increment/decrement/swap actions target the custom zone.
|
||||
const bool isCustomZone = index.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
const QString effectiveZoneName = isCustomZone ? categoryName : zoneName;
|
||||
const auto routeCardClick = [this, effectiveZoneName](QMouseEvent *event, const ExactCard &card) {
|
||||
emit cardClicked(event, card, effectiveZoneName);
|
||||
};
|
||||
if (displayType == DisplayType::Overlap) {
|
||||
auto *displayWidget = new OverlappedCardGroupDisplayWidget(
|
||||
cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria,
|
||||
activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this,
|
||||
&DeckCardZoneDisplayWidget::onClick);
|
||||
cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName,
|
||||
activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick);
|
||||
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this,
|
||||
&DeckCardZoneDisplayWidget::onHover);
|
||||
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
||||
|
|
@ -111,9 +113,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
|||
indexToWidgetMap.insert(index, displayWidget);
|
||||
} else if (displayType == DisplayType::Flat) {
|
||||
auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index,
|
||||
zoneName, categoryName, activeGroupCriteria,
|
||||
effectiveZoneName, categoryName, activeGroupCriteria,
|
||||
activeSortCriteria, subBannerOpacity, cardSizeWidget);
|
||||
connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick);
|
||||
connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick);
|
||||
connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover);
|
||||
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
|
||||
&DeckCardZoneDisplayWidget::cleanupInvalidCardGroup);
|
||||
|
|
@ -126,24 +128,22 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
|
|||
|
||||
void DeckCardZoneDisplayWidget::displayCards()
|
||||
{
|
||||
QSortFilterProxyModel proxy;
|
||||
proxy.setSourceModel(deckListModel);
|
||||
proxy.setSortRole(Qt::EditRole);
|
||||
proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder);
|
||||
if (!trackedIndex.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. trackedIndex is a source index → map it to proxy space
|
||||
QModelIndex proxyParent = proxy.mapFromSource(trackedIndex);
|
||||
// Iterate the direct children of the tracked zone, keeping the tree view's row
|
||||
// order (criteria groups first, then custom zones in their creation order).
|
||||
QList<QPersistentModelIndex> rows;
|
||||
for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) {
|
||||
rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex)));
|
||||
}
|
||||
|
||||
// 2. iterate children under the proxy parent
|
||||
for (int i = 0; i < proxy.rowCount(proxyParent); ++i) {
|
||||
QModelIndex proxyIndex = proxy.index(i, 0, proxyParent);
|
||||
|
||||
// 3. map back to source
|
||||
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
|
||||
|
||||
// 4. persist the source index
|
||||
QPersistentModelIndex persistent(sourceIndex);
|
||||
std::stable_partition(rows.begin(), rows.end(), [](const QPersistentModelIndex &row) {
|
||||
return !row.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
});
|
||||
|
||||
for (const QPersistentModelIndex &persistent : rows) {
|
||||
constructAppropriateWidget(persistent);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ public:
|
|||
void addCardsToOverlapWidget();
|
||||
|
||||
public slots:
|
||||
void onClick(QMouseEvent *event, const ExactCard &card);
|
||||
void onHover(const ExactCard &card);
|
||||
void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget);
|
||||
void constructAppropriateWidget(QPersistentModelIndex index);
|
||||
|
|
|
|||
|
|
@ -90,6 +90,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName)
|
|||
emit cardDecremented(currentCardName(), zoneName);
|
||||
}
|
||||
|
||||
void CardDatabaseView::setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
|
||||
const std::function<void()> &newZoneHandler)
|
||||
{
|
||||
zoneMenuProvider = provider;
|
||||
this->newZoneHandler = newZoneHandler;
|
||||
}
|
||||
|
||||
void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/)
|
||||
{
|
||||
if (!current.isValid()) {
|
||||
|
|
@ -142,6 +149,39 @@ void CardDatabaseView::openCustomMenu(QPoint point)
|
|||
[this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); });
|
||||
connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked);
|
||||
|
||||
if (zoneMenuProvider) {
|
||||
QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone"));
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
connect(action, &QAction::triggered, this,
|
||||
[this, card, boardName] { emit cardAdded(card->getName(), boardName); });
|
||||
}
|
||||
|
||||
bool anyCustomZone = false;
|
||||
const auto zoneBoards = zoneMenuProvider();
|
||||
for (const auto &zoneBoard : zoneBoards) {
|
||||
const QString &boardName = zoneBoard.first;
|
||||
const QStringList &customZones = zoneBoard.second;
|
||||
if (customZones.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
anyCustomZone = true;
|
||||
QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
for (const QString &zoneName : customZones) {
|
||||
QAction *action = boardSubmenu->addAction(zoneName);
|
||||
connect(action, &QAction::triggered, this,
|
||||
[this, card, zoneName] { emit cardAdded(card->getName(), zoneName); });
|
||||
}
|
||||
}
|
||||
|
||||
if (anyCustomZone) {
|
||||
addToZoneMenu->addSeparator();
|
||||
}
|
||||
|
||||
QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone..."));
|
||||
connect(newZoneAction, &QAction::triggered, this, [this] { newZoneHandler(); });
|
||||
}
|
||||
|
||||
if (canBeCommander(*card)) {
|
||||
QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)"));
|
||||
connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "../../key_signals.h"
|
||||
|
||||
#include <QTreeView>
|
||||
#include <functional>
|
||||
#include <libcockatrice/card/card_info.h>
|
||||
|
||||
class CardDatabaseModel;
|
||||
|
|
@ -19,6 +20,12 @@ class CardDatabaseView : public QTreeView
|
|||
KeySignals searchKeySignals;
|
||||
CardDatabaseDisplayModel *databaseDisplayModel;
|
||||
|
||||
/// Provides the custom zones available in the current deck, grouped by board zone.
|
||||
/// The list contains (board zone name, custom zone names) pairs for every board.
|
||||
std::function<QList<QPair<QString, QStringList>>()> zoneMenuProvider;
|
||||
/// Handler invoked when the user picks "New zone..." from the add-to-zone menu.
|
||||
std::function<void()> newZoneHandler;
|
||||
|
||||
public:
|
||||
explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model);
|
||||
|
||||
|
|
@ -33,6 +40,16 @@ public:
|
|||
return &searchKeySignals;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sets the provider used to populate the "Add to zone" submenu of the context menu.
|
||||
* If no provider is set, the submenu is not shown.
|
||||
*
|
||||
* @param provider Returns the custom zones of the current deck, grouped by board zone
|
||||
* @param newZoneHandler Invoked when the user chooses "New zone..." in the submenu
|
||||
*/
|
||||
void setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
|
||||
const std::function<void()> &newZoneHandler);
|
||||
|
||||
signals:
|
||||
void cardChanged(const QString &cardName);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
#include "deck_editor_card_database_dock_widget.h"
|
||||
|
||||
#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h"
|
||||
#include "card_database_view.h"
|
||||
#include "deck_state_manager.h"
|
||||
#include "deck_zone_dialog.h"
|
||||
|
||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
||||
|
||||
DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent)
|
||||
{
|
||||
setObjectName("databaseDisplayDock");
|
||||
|
|
@ -15,6 +22,26 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck
|
|||
{
|
||||
databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel);
|
||||
|
||||
databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider(
|
||||
[deckEditor]() -> QList<QPair<QString, QStringList>> {
|
||||
QList<QPair<QString, QStringList>> result;
|
||||
auto *deckListModel = deckEditor->deckStateManager->getModel();
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[this, deckEditor] {
|
||||
QString boardName;
|
||||
const QString zoneName =
|
||||
DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) {
|
||||
return deckEditor->deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!zoneName.isEmpty()) {
|
||||
deckEditor->deckStateManager->createCustomZone(boardName, zoneName);
|
||||
}
|
||||
});
|
||||
|
||||
auto *frame = new QVBoxLayout;
|
||||
frame->setObjectName("databaseDisplayFrame");
|
||||
frame->addWidget(databaseDisplayWidget);
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@
|
|||
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
|
||||
#include "deck_list_style_proxy.h"
|
||||
#include "deck_state_manager.h"
|
||||
#include "deck_zone_dialog.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDockWidget>
|
||||
#include <QHeaderView>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QSplitter>
|
||||
#include <QTextEdit>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
||||
#include <libcockatrice/settings/deck_editor_settings.h>
|
||||
#include <libcockatrice/settings/interface_settings.h>
|
||||
#include <libcockatrice/utility/macros.h>
|
||||
|
|
@ -772,14 +775,143 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i
|
|||
|
||||
void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point)
|
||||
{
|
||||
const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point));
|
||||
|
||||
QMenu menu;
|
||||
|
||||
const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid();
|
||||
const bool isCardRow =
|
||||
sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex);
|
||||
|
||||
if (isCardRow) {
|
||||
addMoveToZoneMenu(&menu, sourceIndex);
|
||||
menu.addSeparator();
|
||||
} else if (isCustomZoneRow) {
|
||||
const QString zoneName =
|
||||
sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
|
||||
QAction *renameAction = menu.addAction(tr("&Rename zone..."));
|
||||
connect(renameAction, &QAction::triggered, this, [this, zoneName] {
|
||||
const QString newName = DeckZoneDialog::promptForRename(this, zoneName, [this](const QString &candidate) {
|
||||
return deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!newName.isEmpty() && newName != zoneName) {
|
||||
deckStateManager->renameCustomZone(zoneName, newName);
|
||||
}
|
||||
});
|
||||
|
||||
QMenu *boardMenu = menu.addMenu(tr("Change &board"));
|
||||
addChangeBoardMenu(boardMenu, zoneName);
|
||||
|
||||
QAction *deleteAction = menu.addAction(tr("&Delete zone"));
|
||||
deleteAction->setEnabled(!getModel()->hasChildren(sourceIndex));
|
||||
deleteAction->setStatusTip(tr("Move or remove all cards first."));
|
||||
connect(deleteAction, &QAction::triggered, this, [this, zoneName] {
|
||||
const auto result =
|
||||
QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName),
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
|
||||
if (result == QMessageBox::Yes) {
|
||||
deckStateManager->removeCustomZone(zoneName);
|
||||
}
|
||||
});
|
||||
menu.addSeparator();
|
||||
} else if (isBoardZoneRow) {
|
||||
const QString boardName =
|
||||
sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
// Tokens cannot host custom zones, so only offer the action on real boards.
|
||||
const bool canHostCustomZones =
|
||||
boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD;
|
||||
if (canHostCustomZones) {
|
||||
addNewZoneAction(&menu, boardName);
|
||||
menu.addSeparator();
|
||||
}
|
||||
} else if (!sourceIndex.isValid()) {
|
||||
addNewZoneAction(&menu);
|
||||
menu.addSeparator();
|
||||
}
|
||||
|
||||
QAction *selectPrinting = menu.addAction(tr("Select Printing"));
|
||||
connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector);
|
||||
|
||||
menu.exec(deckView->mapToGlobal(point));
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex)
|
||||
{
|
||||
const auto moveToZone = [this, sourceCardIndex](const QString &targetZoneName) {
|
||||
deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName);
|
||||
};
|
||||
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
connect(action, &QAction::triggered, this, [moveToZone, boardName] { moveToZone(boardName); });
|
||||
}
|
||||
|
||||
const auto tree = deckStateManager->getDeckListShared()->getTree();
|
||||
bool anyCustomZone = false;
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
QList<const InnerDecklistNode *> customZones = tree->getCustomZones(boardName);
|
||||
if (customZones.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
anyCustomZone = true;
|
||||
QMenu *boardSubmenu = menu->addMenu(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
for (const auto *customZone : customZones) {
|
||||
QAction *action = boardSubmenu->addAction(customZone->getName());
|
||||
connect(action, &QAction::triggered, this, [moveToZone, customZone] { moveToZone(customZone->getName()); });
|
||||
}
|
||||
}
|
||||
|
||||
if (anyCustomZone) {
|
||||
menu->addSeparator();
|
||||
}
|
||||
|
||||
addNewZoneAction(menu);
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName)
|
||||
{
|
||||
const auto tree = deckStateManager->getDeckListShared()->getTree();
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
|
||||
|
||||
// The board currently holding the zone is marked instead of offered.
|
||||
// Duplicate names cannot come up through the editor, so this doubles as
|
||||
// the uniqueness guard for imported decks.
|
||||
bool holdsTheZone = false;
|
||||
for (const auto *customZone : tree->getCustomZones(boardName)) {
|
||||
if (customZone->getName() == zoneName) {
|
||||
holdsTheZone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (holdsTheZone) {
|
||||
action->setCheckable(true);
|
||||
action->setChecked(true);
|
||||
continue;
|
||||
}
|
||||
|
||||
connect(action, &QAction::triggered, this,
|
||||
[this, zoneName, boardName] { deckStateManager->moveCustomZone(zoneName, boardName); });
|
||||
}
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &initialBoardName)
|
||||
{
|
||||
QAction *newZoneAction = menu->addAction(tr("Create &new zone..."));
|
||||
connect(newZoneAction, &QAction::triggered, this, [this, initialBoardName] {
|
||||
QString boardName;
|
||||
const QString zoneName =
|
||||
DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) {
|
||||
return deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!zoneName.isEmpty()) {
|
||||
deckStateManager->createCustomZone(boardName, zoneName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void DeckEditorDeckDockWidget::refreshShortcuts()
|
||||
{
|
||||
ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts();
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
#include <QComboBox>
|
||||
#include <QDockWidget>
|
||||
#include <QLabel>
|
||||
#include <QMenu>
|
||||
#include <QPushButton>
|
||||
#include <QTextEdit>
|
||||
#include <QTreeView>
|
||||
|
|
@ -102,6 +103,10 @@ private:
|
|||
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
|
||||
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);
|
||||
|
||||
void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex);
|
||||
void addChangeBoardMenu(QMenu *menu, const QString &zoneName);
|
||||
void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {});
|
||||
|
||||
private slots:
|
||||
void decklistCustomMenu(QPoint point);
|
||||
void updateCard(QModelIndex, const QModelIndex ¤t);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list_history_manager.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
|
||||
DeckStateManager::DeckStateManager(QObject *parent)
|
||||
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
|
||||
|
|
@ -307,6 +308,187 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx)
|
|||
return offsetCountAtIndex(idx, -1);
|
||||
}
|
||||
|
||||
bool DeckStateManager::moveCardToZone(const QModelIndex &idx, const QString &targetZoneName)
|
||||
{
|
||||
if (!idx.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only actual card rows can be moved. Group or zone rows report an
|
||||
// aggregate amount and must never be deleted by this operation.
|
||||
if (!idx.data(DeckRoles::IsCardRole).toBool()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString();
|
||||
int copies = idx.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
|
||||
|
||||
if (copies <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tokens only live in the tokens zone and cannot be moved into decks.
|
||||
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
|
||||
if (info && info->getIsToken()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine the zone the card currently lives in: the enclosing custom
|
||||
// zone, or the nearest top-level zone (board zone or legacy zone).
|
||||
QString currentZoneName;
|
||||
for (QModelIndex ancestor = idx.parent(); ancestor.isValid(); ancestor = ancestor.parent()) {
|
||||
bool isCustomZone = ancestor.data(DeckRoles::IsCustomZoneRole).toBool();
|
||||
if (isCustomZone || !ancestor.parent().isValid()) {
|
||||
currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentZoneName == targetZoneName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString reason = tr("Moved %1 × \"%2\" (%3) to %4")
|
||||
.arg(copies)
|
||||
.arg(cardName)
|
||||
.arg(providerId)
|
||||
.arg(InnerDecklistNode::visibleNameFromName(targetZoneName));
|
||||
|
||||
return modifyDeck(reason, [&idx, &cardName, &providerId, &targetZoneName, copies](auto model) {
|
||||
if (!model->removeRow(idx.row(), idx.parent())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId})) {
|
||||
for (int i = 0; i < copies; ++i) {
|
||||
model->addCard(card, targetZoneName);
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < copies; ++i) {
|
||||
model->addPreferredPrintingCard(cardName, targetZoneName, true);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::createCustomZone(const QString &boardZoneName, const QString &zoneName)
|
||||
{
|
||||
const QString trimmedZoneName = zoneName.trimmed();
|
||||
if (trimmedZoneName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString reason =
|
||||
tr("Created zone \"%1\" in %2").arg(trimmedZoneName, InnerDecklistNode::visibleNameFromName(boardZoneName));
|
||||
|
||||
return modifyTree(reason, [&boardZoneName, &trimmedZoneName](DecklistNodeTree *tree) {
|
||||
return tree->addCustomZone(boardZoneName, trimmedZoneName) != nullptr;
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::renameCustomZone(const QString &oldZoneName, const QString &newZoneName)
|
||||
{
|
||||
const QString trimmedNewZoneName = newZoneName.trimmed();
|
||||
if (trimmedNewZoneName.isEmpty() || oldZoneName == trimmedNewZoneName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QString reason = tr("Renamed zone \"%1\" to \"%2\"").arg(oldZoneName, trimmedNewZoneName);
|
||||
|
||||
return modifyTree(reason, [&oldZoneName, &trimmedNewZoneName](DecklistNodeTree *tree) {
|
||||
return tree->renameCustomZone(oldZoneName, trimmedNewZoneName);
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName)
|
||||
{
|
||||
const auto *tree = deckList->getTree();
|
||||
|
||||
// Locate the board currently holding the zone.
|
||||
QString currentBoardName;
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
for (const auto *zone : tree->getCustomZones(boardName)) {
|
||||
if (zone->getName() == zoneName) {
|
||||
currentBoardName = boardName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!currentBoardName.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBoardName.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same-board moves are no-ops and must not pollute the history.
|
||||
if (currentBoardName == newBoardZoneName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Zone names are deck-unique among zones created through this manager, so a
|
||||
// same-named zone on the target board can only come from an imported deck.
|
||||
// Refuse the move instead of silently stacking same-named zones.
|
||||
for (const auto *zone : tree->getCustomZones(newBoardZoneName)) {
|
||||
if (zone->getName() == zoneName) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
QString reason =
|
||||
tr("Moved zone \"%1\" to %2").arg(zoneName, InnerDecklistNode::visibleNameFromName(newBoardZoneName));
|
||||
|
||||
return modifyTree(reason, [&zoneName, &newBoardZoneName](DecklistNodeTree *tree) {
|
||||
return tree->moveCustomZone(zoneName, newBoardZoneName);
|
||||
});
|
||||
}
|
||||
|
||||
bool DeckStateManager::removeCustomZone(const QString &zoneName)
|
||||
{
|
||||
QString reason = tr("Deleted zone \"%1\"").arg(zoneName);
|
||||
|
||||
return modifyTree(reason, [&zoneName](DecklistNodeTree *tree) { return tree->removeCustomZone(zoneName); });
|
||||
}
|
||||
|
||||
QString DeckStateManager::validateNewZoneName(const QString &zoneName) const
|
||||
{
|
||||
if (zoneName.trimmed().isEmpty()) {
|
||||
return tr("Enter a zone name.");
|
||||
}
|
||||
|
||||
const QString trimmedZoneName = zoneName.trimmed();
|
||||
|
||||
// The standard zone names are reserved even before they exist.
|
||||
if (trimmedZoneName == DECK_ZONE_MAIN || trimmedZoneName == DECK_ZONE_SIDE ||
|
||||
trimmedZoneName == DECK_ZONE_MAYBEBOARD || trimmedZoneName == DECK_ZONE_TOKENS) {
|
||||
return tr("This name is reserved.");
|
||||
}
|
||||
|
||||
const auto *tree = deckList->getTree();
|
||||
|
||||
// Top-level zones (boards and legacy zones) claim their names too.
|
||||
for (int i = 0; i < tree->getRoot()->size(); i++) {
|
||||
if (tree->getRoot()->at(i)->getName() == trimmedZoneName) {
|
||||
return tr("A zone with this name already exists.");
|
||||
}
|
||||
}
|
||||
|
||||
// Custom zone names are unique across the whole deck.
|
||||
for (const QString &board : InnerDecklistNode::boardZoneNames()) {
|
||||
for (const auto *customZone : tree->getCustomZones(board)) {
|
||||
if (customZone->getName() == trimmedZoneName) {
|
||||
return tr("A zone with this name already exists.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset)
|
||||
{
|
||||
if (!idx.isValid()) {
|
||||
|
|
@ -367,6 +549,21 @@ void DeckStateManager::requestHistorySave(const QString &reason)
|
|||
historyManager->save(deckList->createMemento(reason));
|
||||
}
|
||||
|
||||
bool DeckStateManager::modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation)
|
||||
{
|
||||
DeckListMemento memento = deckList->createMemento(reason);
|
||||
bool success = operation(deckList->getTree());
|
||||
|
||||
if (success) {
|
||||
historyManager->save(memento);
|
||||
deckListModel->rebuildTree();
|
||||
deckList->refreshDeckHash();
|
||||
doCardModified();
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Handles updating state and emitting signals whenever the cards are modified
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "deck_list_model.h"
|
||||
|
||||
#include <QSharedPointer>
|
||||
#include <functional>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
|
||||
class DeckListHistoryManager;
|
||||
|
|
@ -236,6 +237,68 @@ public:
|
|||
*/
|
||||
bool decrementCountAtIndex(const QModelIndex &idx);
|
||||
|
||||
/**
|
||||
* @brief Moves all copies of the card at the given index to the given zone.
|
||||
* No-ops if the index is invalid, not a card node, the card is a token, or the
|
||||
* card is already in the target zone.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param idx The model index of the card to move
|
||||
* @param targetZoneName The zone to move the card to (board zone or custom zone name)
|
||||
* @return Whether the operation was successfully performed
|
||||
*/
|
||||
bool moveCardToZone(const QModelIndex &idx, const QString &targetZoneName);
|
||||
|
||||
/**
|
||||
* @brief Creates a new custom zone nested under a board zone.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param boardZoneName The board zone to nest the custom zone under
|
||||
* @param zoneName The name of the new custom zone. Gets trimmed and must be
|
||||
* unique across the deck.
|
||||
* @return Whether the zone was created
|
||||
*/
|
||||
bool createCustomZone(const QString &boardZoneName, const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Renames a custom zone.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param oldZoneName The current name of the custom zone
|
||||
* @param newZoneName The new name. Gets trimmed and must be unique across the deck.
|
||||
* @return Whether the rename succeeded
|
||||
*/
|
||||
bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName);
|
||||
|
||||
/**
|
||||
* @brief Moves a custom zone (and its cards) to a different board zone.
|
||||
* Same-board moves succeed without creating a history entry.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param zoneName The custom zone to move
|
||||
* @param newBoardZoneName The board zone to move the custom zone under
|
||||
* @return Whether the move succeeded
|
||||
*/
|
||||
bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName);
|
||||
|
||||
/**
|
||||
* @brief Removes a custom zone and all its cards.
|
||||
* Saves the operation to history if successful.
|
||||
*
|
||||
* @param zoneName The custom zone to remove
|
||||
* @return Whether the zone was removed
|
||||
*/
|
||||
bool removeCustomZone(const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Checks whether a candidate name is usable for a new custom zone.
|
||||
*
|
||||
* @param zoneName The candidate name
|
||||
* @return An empty string when the name is usable, otherwise a user-facing
|
||||
* error message describing the problem
|
||||
*/
|
||||
[[nodiscard]] QString validateNewZoneName(const QString &zoneName) const;
|
||||
|
||||
/**
|
||||
* Undoes n steps of the history, setting the decklist state and updating the current step in the historyManager.
|
||||
* @param steps Number of steps to undo.
|
||||
|
|
@ -257,6 +320,7 @@ public slots:
|
|||
|
||||
private:
|
||||
bool offsetCountAtIndex(const QModelIndex &idx, int offset);
|
||||
bool modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation);
|
||||
void doCardModified();
|
||||
void doMetadataModified();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
#include "deck_zone_dialog.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/utility/string_limits.h>
|
||||
|
||||
DeckZoneDialog::DeckZoneDialog(QWidget *parent,
|
||||
const QString &initialBoardName,
|
||||
const std::function<QString(const QString &)> &_nameValidator,
|
||||
bool _allowBoardSelection)
|
||||
: QDialog(parent), nameValidator(_nameValidator), allowBoardSelection(_allowBoardSelection)
|
||||
{
|
||||
nameLabel = new QLabel(this);
|
||||
nameEdit = new QLineEdit(this);
|
||||
nameEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
|
||||
errorLabel = new QLabel(this);
|
||||
errorLabel->hide();
|
||||
|
||||
boardLabel = new QLabel(this);
|
||||
boardCombo = new QComboBox(this);
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
// Use the icon overload explicitly so `boardName` lands in the user data role
|
||||
// (visible text is applied below in retranslateUi). The two-argument form
|
||||
// addItem({}, boardName) would be ambiguous and resolve to the icon overload
|
||||
// with empty user data, yielding empty entries and an empty getBoardName().
|
||||
boardCombo->addItem({}, {}, boardName);
|
||||
}
|
||||
if (!initialBoardName.isEmpty()) {
|
||||
int idx = boardCombo->findData(initialBoardName);
|
||||
if (idx != -1) {
|
||||
boardCombo->setCurrentIndex(idx);
|
||||
}
|
||||
}
|
||||
|
||||
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(nameLabel);
|
||||
layout->addWidget(nameEdit);
|
||||
layout->addWidget(errorLabel);
|
||||
if (allowBoardSelection) {
|
||||
layout->addWidget(boardLabel);
|
||||
layout->addWidget(boardCombo);
|
||||
}
|
||||
layout->addWidget(buttonBox);
|
||||
|
||||
retranslateUi();
|
||||
|
||||
connect(nameEdit, &QLineEdit::textChanged, this, [this] { validateName(); });
|
||||
validateName();
|
||||
|
||||
nameEdit->setFocus();
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::getZoneName() const
|
||||
{
|
||||
return nameEdit->text().trimmed();
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::getBoardName() const
|
||||
{
|
||||
return boardCombo->currentData().toString();
|
||||
}
|
||||
|
||||
void DeckZoneDialog::setZoneName(const QString &zoneName)
|
||||
{
|
||||
nameEdit->setText(zoneName);
|
||||
nameEdit->selectAll();
|
||||
}
|
||||
|
||||
void DeckZoneDialog::changeEvent(QEvent *event)
|
||||
{
|
||||
QDialog::changeEvent(event);
|
||||
|
||||
if (event->type() == QEvent::LanguageChange) {
|
||||
retranslateUi();
|
||||
}
|
||||
}
|
||||
|
||||
void DeckZoneDialog::retranslateUi()
|
||||
{
|
||||
setWindowTitle(allowBoardSelection ? tr("New zone") : tr("Rename zone"));
|
||||
|
||||
nameLabel->setText(tr("Zone &name:"));
|
||||
nameLabel->setBuddy(nameEdit);
|
||||
|
||||
boardLabel->setText(tr("&Parent zone:"));
|
||||
boardLabel->setBuddy(boardCombo);
|
||||
|
||||
for (int i = 0; i < boardCombo->count(); i++) {
|
||||
boardCombo->setItemText(i, InnerDecklistNode::visibleNameFromName(boardCombo->itemData(i).toString()));
|
||||
}
|
||||
}
|
||||
|
||||
void DeckZoneDialog::validateName()
|
||||
{
|
||||
const QString zoneName = nameEdit->text().trimmed();
|
||||
QString error;
|
||||
if (zoneName.isEmpty()) {
|
||||
error = tr("Enter a zone name.");
|
||||
} else if (nameValidator) {
|
||||
error = nameValidator(zoneName);
|
||||
}
|
||||
|
||||
errorLabel->setText(error);
|
||||
errorLabel->setVisible(!error.isEmpty());
|
||||
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty());
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::promptForNewZone(QWidget *parent,
|
||||
const QString &initialBoardName,
|
||||
QString *chosenBoardName,
|
||||
const std::function<QString(const QString &)> &nameValidator)
|
||||
{
|
||||
DeckZoneDialog dialog(parent, initialBoardName, nameValidator);
|
||||
if (dialog.exec() != QDialog::Accepted) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (chosenBoardName) {
|
||||
*chosenBoardName = dialog.getBoardName();
|
||||
}
|
||||
return dialog.getZoneName();
|
||||
}
|
||||
|
||||
QString DeckZoneDialog::promptForRename(QWidget *parent,
|
||||
const QString ¤tZoneName,
|
||||
const std::function<QString(const QString &)> &nameValidator)
|
||||
{
|
||||
DeckZoneDialog dialog(parent, {}, nameValidator, false);
|
||||
dialog.setZoneName(currentZoneName);
|
||||
return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString();
|
||||
}
|
||||
123
cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h
Normal file
123
cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* @file deck_zone_dialog.h
|
||||
* @ingroup DeckEditorWidgets
|
||||
* @brief Shared dialog for creating custom deck zones.
|
||||
*/
|
||||
|
||||
#ifndef DECK_ZONE_DIALOG_H
|
||||
#define DECK_ZONE_DIALOG_H
|
||||
|
||||
#include <QDialog>
|
||||
#include <QEvent>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
|
||||
class QComboBox;
|
||||
class QDialogButtonBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QWidget;
|
||||
|
||||
/**
|
||||
* @brief Modal dialog asking for the name and parent zone of a new custom deck zone.
|
||||
*
|
||||
* Menus construct the dialog transiently around exec(), so validation state only
|
||||
* ever reflects the name currently typed.
|
||||
*/
|
||||
class DeckZoneDialog : public QDialog
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Constructs the dialog and runs the initial validation pass.
|
||||
*
|
||||
* @param parent The parent widget for the dialog
|
||||
* @param initialBoardName The board zone to preselect in the combo. Unknown names
|
||||
* fall back to main.
|
||||
* @param _nameValidator Given the trimmed candidate name, returns an empty string
|
||||
* when it is usable, otherwise a user-facing error message. May be empty.
|
||||
* @param _allowBoardSelection When false the parent-zone combo is hidden and the
|
||||
* dialog acts as a rename prompt for an existing zone.
|
||||
*/
|
||||
explicit DeckZoneDialog(QWidget *parent = nullptr,
|
||||
const QString &initialBoardName = {},
|
||||
const std::function<QString(const QString &)> &_nameValidator = {},
|
||||
bool _allowBoardSelection = true);
|
||||
|
||||
/**
|
||||
* @brief The trimmed zone name entered by the user.
|
||||
*/
|
||||
[[nodiscard]] QString getZoneName() const;
|
||||
|
||||
/**
|
||||
* @brief The internal name of the board zone selected in the combo.
|
||||
*/
|
||||
[[nodiscard]] QString getBoardName() const;
|
||||
|
||||
/**
|
||||
* @brief Prefills the name field, e.g. with the current name when renaming.
|
||||
*
|
||||
* @param zoneName The text to put into the name field, selected for quick editing
|
||||
*/
|
||||
void setZoneName(const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Prompts the user for a new custom zone name and the board zone to nest it under.
|
||||
*
|
||||
* Convenience wrapper that runs DeckZoneDialog modally.
|
||||
*
|
||||
* @param parent The parent widget for the dialog
|
||||
* @param initialBoardName The board zone to preselect in the dialog. Unknown names fall
|
||||
* back to main.
|
||||
* @param chosenBoardName (out) The internal name of the board zone the user chose
|
||||
* @param nameValidator Optional validator forwarded to the dialog
|
||||
* @return The trimmed zone name, or an empty string if the user cancelled
|
||||
*/
|
||||
static QString promptForNewZone(QWidget *parent,
|
||||
const QString &initialBoardName,
|
||||
QString *chosenBoardName,
|
||||
const std::function<QString(const QString &)> &nameValidator = {});
|
||||
|
||||
/**
|
||||
* @brief Prompts the user for a new name for an existing custom zone.
|
||||
*
|
||||
* Same inline validation as promptForNewZone, but without a parent-zone picker.
|
||||
*
|
||||
* @param parent The parent widget for the dialog
|
||||
* @param currentZoneName The current name, prefilled for editing
|
||||
* @param nameValidator Validator deciding whether a candidate name is usable. It sees
|
||||
* the current name too, so callers wanting to allow unchanged names must
|
||||
* special-case that themselves.
|
||||
* @return The trimmed new name, or an empty string if the user cancelled
|
||||
*/
|
||||
static QString promptForRename(QWidget *parent,
|
||||
const QString ¤tZoneName,
|
||||
const std::function<QString(const QString &)> &nameValidator = {});
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Sets every user-visible string. Runs on construction and on runtime
|
||||
* language changes.
|
||||
*/
|
||||
void retranslateUi();
|
||||
|
||||
/**
|
||||
* @brief Validates the current input, toggling Ok and the inline error label.
|
||||
*/
|
||||
void validateName();
|
||||
|
||||
QLabel *nameLabel;
|
||||
QLineEdit *nameEdit;
|
||||
QLabel *errorLabel;
|
||||
QLabel *boardLabel;
|
||||
QComboBox *boardCombo;
|
||||
QDialogButtonBox *buttonBox;
|
||||
std::function<QString(const QString &)> nameValidator;
|
||||
bool allowBoardSelection;
|
||||
};
|
||||
|
||||
#endif // DECK_ZONE_DIALOG_H
|
||||
|
|
@ -1,9 +1,17 @@
|
|||
#include "dlg_convert_deck_to_cod_format.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../deck_loader/deck_loader.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/settings/visual_deck_storage_settings.h>
|
||||
|
||||
DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
|
|
@ -38,3 +46,71 @@ bool DialogConvertDeckToCodFormat::dontAskAgain() const
|
|||
{
|
||||
return dontAskAgainCheckbox->isChecked();
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
|
||||
{
|
||||
QFileInfo fileInfo(filePath);
|
||||
QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod");
|
||||
|
||||
if (QFile::exists(newFileName)) {
|
||||
QMessageBox::StandardButton reply =
|
||||
QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"),
|
||||
QObject::tr("A .cod version of this deck already exists. Overwrite it?"),
|
||||
QMessageBox::Yes | QMessageBox::No);
|
||||
return reply == QMessageBox::Yes;
|
||||
}
|
||||
return true; // Safe to proceed
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool DialogConvertDeckToCodFormat::promptIfRequired(QWidget *parent,
|
||||
const QString &filePath,
|
||||
const std::function<bool()> &convert)
|
||||
{
|
||||
if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retrieve saved preference if the prompt is disabled
|
||||
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) {
|
||||
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!confirmOverwriteIfExists(parent, filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return convert();
|
||||
}
|
||||
|
||||
// Show the dialog to the user
|
||||
DialogConvertDeckToCodFormat conversionDialog(parent);
|
||||
if (conversionDialog.exec() != QDialog::Accepted) {
|
||||
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(
|
||||
!conversionDialog.dontAskAgain());
|
||||
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to convert file
|
||||
if (!confirmOverwriteIfExists(parent, filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!convert()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (conversionDialog.dontAskAgain()) {
|
||||
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false);
|
||||
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
#include <QDialogButtonBox>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
#include <functional>
|
||||
|
||||
class QWidget;
|
||||
|
||||
class DialogConvertDeckToCodFormat : public QDialog
|
||||
{
|
||||
|
|
@ -24,6 +27,21 @@ public:
|
|||
|
||||
[[nodiscard]] bool dontAskAgain() const;
|
||||
|
||||
/**
|
||||
* @brief Checks whether the deck file at \a filePath can store tags.
|
||||
*
|
||||
* If the file is not a .cod deck, prompts the user for conversion to the
|
||||
* Cockatrice format, honoring the saved "always convert / don't ask again"
|
||||
* preference. On acceptance \a convert is called to perform the conversion.
|
||||
*
|
||||
* @param parent The widget to parent the prompt to.
|
||||
* @param filePath The path of the deck file to check.
|
||||
* @param convert Called to convert the deck once the user agrees.
|
||||
* @return true if tags can be stored (no conversion needed, or the conversion
|
||||
* was performed), false if the user declined to convert.
|
||||
*/
|
||||
static bool promptIfRequired(QWidget *parent, const QString &filePath, const std::function<bool()> &convert);
|
||||
|
||||
private:
|
||||
QVBoxLayout *layout;
|
||||
QLabel *label;
|
||||
|
|
|
|||
|
|
@ -525,6 +525,13 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
|
|||
connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); });
|
||||
add(games);
|
||||
|
||||
// ── Invite (only while the inviter has a joinable game for this user) ────
|
||||
if (!isSelf && online && gameInviteAvailable && gameInviteAvailable(name)) {
|
||||
auto *invite = makeBtn(tr("Invite"), tr("Invite to your game"), actionArea, theme);
|
||||
connect(invite, &QPushButton::clicked, this, [this, name] { emit inviteRequested(name); });
|
||||
add(invite);
|
||||
}
|
||||
|
||||
// ── Buddy / ignore (registered users only) ────────────────────────────────
|
||||
if (!isSelf && isReg) {
|
||||
if (isBuddy) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include <QMap>
|
||||
#include <QPixmap>
|
||||
#include <QStandardItemModel>
|
||||
#include <functional>
|
||||
#include <libcockatrice/network/server/remote/user_level.h>
|
||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
|
||||
|
|
@ -149,6 +150,17 @@ public:
|
|||
/** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */
|
||||
void refreshHeader();
|
||||
|
||||
/**
|
||||
* Sets a predicate evaluated on every action-button rebuild. It receives
|
||||
* the name of the user the popup currently shows; when it returns true an
|
||||
* "Invite" button is shown. The popup itself never resolves the invite
|
||||
* link, it just forwards the request.
|
||||
*/
|
||||
void setGameInviteAvailable(std::function<bool(const QString &userName)> available)
|
||||
{
|
||||
gameInviteAvailable = std::move(available);
|
||||
}
|
||||
|
||||
signals:
|
||||
void mouseEnteredPopup();
|
||||
void mouseLeftPopup();
|
||||
|
|
@ -159,6 +171,7 @@ signals:
|
|||
|
||||
// ── Action signals — connect to UserContextMenu::exec*() ──────────────────
|
||||
void chatRequested(const QString &userName);
|
||||
void inviteRequested(const QString &userName);
|
||||
void detailsRequested(const QString &userName);
|
||||
void showGamesRequested(const QString &userName);
|
||||
void addBuddyRequested(const QString &userName);
|
||||
|
|
@ -200,6 +213,7 @@ private:
|
|||
QString currentUser;
|
||||
ServerInfo_User currentUserInfo;
|
||||
bool currentOnline = false;
|
||||
std::function<bool(const QString &userName)> gameInviteAvailable;
|
||||
|
||||
UserInfoHeaderWidget *header;
|
||||
QWidget *actionArea; ///< rebuilt per user
|
||||
|
|
|
|||
|
|
@ -345,6 +345,11 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
|
|||
&cardArtProvider->cache(), &cardArtParamsMap,
|
||||
window()); // parented to main window so it floats above siblings
|
||||
|
||||
// The invite availability is scoped to the room this list belongs to,
|
||||
// and gated on the room's buddy-only setting for the hovered user.
|
||||
userInfoPopup->setGameInviteAvailable(
|
||||
[this](const QString &userName) { return userContextMenu->hasGameInviteLink(userName); });
|
||||
|
||||
userInfoPopup->hide();
|
||||
userInfoPopup->setWindowOpacity(0.0);
|
||||
userInfoPopup->installEventFilter(this);
|
||||
|
|
@ -662,6 +667,8 @@ void UserListWidget::connectPopupSignals()
|
|||
|
||||
// Wire all action signals to UserContextMenu::exec*()
|
||||
connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat);
|
||||
connect(userInfoPopup, &UserInfoPopup::inviteRequested, this,
|
||||
[this](const QString &userName) { userContextMenu->execInvite(userName); });
|
||||
connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails);
|
||||
connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames);
|
||||
connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
#include <QTextEdit>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <functional>
|
||||
#include <libcockatrice/network/server/remote/user_level.h>
|
||||
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
|
||||
|
||||
class QTreeWidget;
|
||||
|
|
|
|||
|
|
@ -1091,7 +1091,8 @@ QList<GameInviteOption> TabSupervisor::getGameInviteLinksForRoom(int roomId) con
|
|||
// The inviter may be in several games of the same room (hosting one and
|
||||
// spectating another, for example). Return every game so the caller can
|
||||
// let the user choose which one to invite to.
|
||||
for (TabGame *tab : gameTabs) {
|
||||
for (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) {
|
||||
TabGame *tab = it.value();
|
||||
GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo();
|
||||
if (metaInfo->proto().room_id() != roomId) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include "../../../../client/settings/shortcuts_settings.h"
|
||||
#include "../../cards/card_info_display_widget.h"
|
||||
#include "../../deck_editor/deck_state_manager.h"
|
||||
#include "../../deck_editor/deck_zone_dialog.h"
|
||||
#include "../../filters/filter_builder.h"
|
||||
#include "../../interface/pixel_map_generator.h"
|
||||
#include "../../interface/widgets/cards/card_info_frame_widget.h"
|
||||
|
|
@ -84,6 +85,7 @@ void TabDeckEditorVisual::createCentralFrame()
|
|||
connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this,
|
||||
&TabDeckEditorVisual::showPrintingSelector);
|
||||
connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo);
|
||||
connect(tabContainer, &TabDeckEditorVisualTabWidget::newZoneRequested, this, &TabDeckEditorVisual::createNewZone);
|
||||
|
||||
centralFrame->addWidget(tabContainer);
|
||||
setCentralWidget(centralWidget);
|
||||
|
|
@ -269,6 +271,18 @@ bool TabDeckEditorVisual::actSaveDeckAs()
|
|||
return result;
|
||||
}
|
||||
|
||||
/** @brief Prompts for and creates a new custom deck zone. */
|
||||
void TabDeckEditorVisual::createNewZone()
|
||||
{
|
||||
QString boardName;
|
||||
const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) {
|
||||
return deckStateManager->validateNewZoneName(candidate);
|
||||
});
|
||||
if (!zoneName.isEmpty()) {
|
||||
deckStateManager->createCustomZone(boardName, zoneName);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief Refreshes keyboard shortcuts for this tab from settings. */
|
||||
void TabDeckEditorVisual::refreshShortcuts()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -165,6 +165,11 @@ public slots:
|
|||
*/
|
||||
bool actSaveDeckAs() override;
|
||||
|
||||
/**
|
||||
* @brief Prompts for and creates a new custom deck zone.
|
||||
*/
|
||||
void createNewZone();
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Sets the deck for this tab and selects the sub-tab to open on
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ TabDeckEditorVisualTabWidget::TabDeckEditorVisualTabWidget(QWidget *parent,
|
|||
&TabDeckEditorVisualTabWidget::printingSelectorRequested);
|
||||
connect(visualDatabaseDisplay, &VisualDatabaseDisplayWidget::cardInfoRequested, this,
|
||||
&TabDeckEditorVisualTabWidget::cardInfoRequested);
|
||||
connect(visualDatabaseDisplay, &VisualDatabaseDisplayWidget::newZoneRequested, this,
|
||||
&TabDeckEditorVisualTabWidget::newZoneRequested);
|
||||
|
||||
statsAnalyzer = new DeckListStatisticsAnalyzer(this, deckModel);
|
||||
statsAnalyzer->analyze();
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ signals:
|
|||
void edhrecRequested(const CardInfoPtr &cardInfo, bool isCommander);
|
||||
void printingSelectorRequested();
|
||||
void cardInfoRequested(const ExactCard &cardName);
|
||||
void newZoneRequested();
|
||||
|
||||
private:
|
||||
QVBoxLayout *layout; ///< Layout for tabs and controls.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
#include <libcockatrice/card/card_info_comparator.h>
|
||||
#include <libcockatrice/card/database/card_database.h>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
#include <utility>
|
||||
|
||||
|
|
@ -89,6 +90,19 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
|||
databaseView->setItemDelegate(nullptr);
|
||||
databaseView->setVisible(false);
|
||||
|
||||
// Without a deck model there is nothing to add cards to, so the zone menu stays hidden.
|
||||
if (deckListModel) {
|
||||
databaseView->setZoneMenuProvider(
|
||||
[deckListModel]() -> QList<QPair<QString, QStringList>> {
|
||||
QList<QPair<QString, QStringList>> result;
|
||||
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
|
||||
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[this] { emit newZoneRequested(); });
|
||||
}
|
||||
|
||||
searchEdit->setTreeView(databaseView);
|
||||
searchEdit->installEventFilter(databaseView->getKeySignals());
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ signals:
|
|||
void edhrecRequested(const CardInfoPtr &cardInfo, bool isCommander);
|
||||
void printingSelectorRequested();
|
||||
void cardInfoRequested(const ExactCard &cardName);
|
||||
void newZoneRequested();
|
||||
|
||||
protected slots:
|
||||
void initialize();
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@
|
|||
#include "../visual_deck_storage_widget.h"
|
||||
#include "deck_preview_deck_tags_display_widget.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QInputDialog>
|
||||
#include <QLabel>
|
||||
|
|
@ -499,21 +497,6 @@ void DeckPreviewWidget::actDeleteFile()
|
|||
// The folder widget removes this preview once the row is gone.
|
||||
}
|
||||
|
||||
static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
|
||||
{
|
||||
QFileInfo fileInfo(filePath);
|
||||
QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod");
|
||||
|
||||
if (QFile::exists(newFileName)) {
|
||||
QMessageBox::StandardButton reply =
|
||||
QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"),
|
||||
QObject::tr("A .cod version of this deck already exists. Overwrite it?"),
|
||||
QMessageBox::Yes | QMessageBox::No);
|
||||
return reply == QMessageBox::Yes;
|
||||
}
|
||||
return true; // Safe to proceed
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the deck's file format supports tags.
|
||||
* If not, then prompt the user for file conversion.
|
||||
|
|
@ -521,45 +504,8 @@ static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
|
|||
*/
|
||||
bool DeckPreviewWidget::promptFileConversionIfRequired()
|
||||
{
|
||||
if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retrieve saved preference if the prompt is disabled
|
||||
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) {
|
||||
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!confirmOverwriteIfExists(this, filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DialogConvertDeckToCodFormat::promptIfRequired(this, filePath, [this] {
|
||||
model->convertToCockatriceFormat(row());
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show the dialog to the user
|
||||
DialogConvertDeckToCodFormat conversionDialog(this);
|
||||
if (conversionDialog.exec() != QDialog::Accepted) {
|
||||
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(
|
||||
!conversionDialog.dontAskAgain());
|
||||
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to convert file
|
||||
if (!confirmOverwriteIfExists(this, filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
model->convertToCockatriceFormat(row());
|
||||
|
||||
if (conversionDialog.dontAskAgain()) {
|
||||
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false);
|
||||
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method)
|
|||
}
|
||||
}
|
||||
|
||||
const QList<QString> &InnerDecklistNode::boardZoneNames()
|
||||
{
|
||||
static const QList<QString> names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE),
|
||||
QString(DECK_ZONE_MAYBEBOARD)};
|
||||
return names;
|
||||
}
|
||||
|
||||
QString InnerDecklistNode::getVisibleName() const
|
||||
{
|
||||
return visibleNameFromName(name);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@
|
|||
|
||||
#include "abstract_deck_list_node.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
/** @brief Constant for the "main" deck zone name. */
|
||||
#define DECK_ZONE_MAIN "main"
|
||||
/** @brief Constant for the "sideboard" zone name. */
|
||||
|
|
@ -118,6 +121,13 @@ public:
|
|||
*/
|
||||
static QString visibleNameFromName(const QString &_name);
|
||||
|
||||
/**
|
||||
* @brief The standard board zone names, in display order.
|
||||
*
|
||||
* @return main, side and maybeboard.
|
||||
*/
|
||||
static const QList<QString> &boardZoneNames();
|
||||
|
||||
/**
|
||||
* @brief Get this node’s display-friendly name.
|
||||
* @return Human-readable name (zone/group name).
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h)
|
|||
qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
|
||||
|
||||
add_library(
|
||||
libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp
|
||||
libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp
|
||||
deck_list_sort_filter_proxy_model.cpp
|
||||
)
|
||||
|
||||
target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ void DeckListModel::rebuildTree()
|
|||
for (int j = 0; j < currentZone->size(); j++) {
|
||||
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
|
||||
|
||||
//! \todo Better sanity checking.
|
||||
// Non-card children are custom zones; they are mirrored in a single
|
||||
// pass below so each is mirrored exactly once.
|
||||
if (currentCard == nullptr) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -82,8 +83,19 @@ void DeckListModel::rebuildTree()
|
|||
|
||||
new DecklistModelCardNode(currentCard, groupNode);
|
||||
}
|
||||
|
||||
// Custom zones nested under the board zone are mirrored as-is, with their
|
||||
// cards as direct children (no further grouping).
|
||||
DeckListModelCustomZones::mirrorCustomZones(currentZone, node);
|
||||
}
|
||||
|
||||
// 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 +166,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
|
|||
case DeckRoles::IsLegalRole:
|
||||
return true;
|
||||
|
||||
case DeckRoles::IsCustomZoneRole:
|
||||
return DeckListModelCustomZones::isCustomZone(group);
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
|
|
@ -190,6 +205,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
|
|||
return card->getFormatLegality();
|
||||
}
|
||||
|
||||
case DeckRoles::IsCustomZoneRole: {
|
||||
return false;
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
|
|
@ -327,6 +346,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 (DeckListModelCustomZones::isCustomZone(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 +363,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) && !DeckListModelCustomZones::isCustomZone(node)) {
|
||||
removeRows(parent.row(), 1, parent.parent());
|
||||
} else {
|
||||
emitRecursiveUpdates(parent);
|
||||
|
|
@ -365,24 +392,44 @@ 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) {
|
||||
if (!DeckListModelCustomZones::isCustomZone(child)) {
|
||||
continue;
|
||||
}
|
||||
auto *customZone = dynamic_cast<InnerDecklistNode *>(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 = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) {
|
||||
return dynamic_cast<DecklistModelCardNode *>(
|
||||
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QModelIndex DeckListModel::findCard(const QString &cardName,
|
||||
|
|
@ -423,29 +470,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 : DeckListModelCustomZones::findSubZoneByName(root, 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 +637,41 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const
|
|||
return createIndex(node->getParent()->indexOf(node), 0, node);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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). Reorders every node just like
|
||||
* sortHelper does, but ignores the movement mapping because there are no
|
||||
* persistent indices established yet.
|
||||
*/
|
||||
void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
// The mapping is not needed: fresh shadow nodes have no persistent indices yet.
|
||||
(void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
|
||||
|
||||
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);
|
||||
// Sort children (custom zones always sorted after groups within a board) and
|
||||
// use the movement mapping to update the list of persistent indices.
|
||||
const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
|
||||
|
||||
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 (const auto &move : mapping) {
|
||||
const int preSortRow = move.first;
|
||||
const int finalRow = move.second;
|
||||
AbstractDecklistNode *temp = node->at(finalRow);
|
||||
for (int j = 0; j < columns; ++j) {
|
||||
from << createIndex(fromRow, j, temp);
|
||||
to << createIndex(toRow, j, temp);
|
||||
from << createIndex(preSortRow, j, temp);
|
||||
to << createIndex(finalRow, j, temp);
|
||||
}
|
||||
}
|
||||
changePersistentIndexList(from, to);
|
||||
|
|
@ -704,6 +785,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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#ifndef DECKLISTMODEL_H
|
||||
#define DECKLISTMODEL_H
|
||||
|
||||
#include "deck_list_model_custom_zones.h"
|
||||
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h>
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <QAbstractItemModel>
|
||||
|
|
@ -30,7 +32,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
|
||||
|
||||
|
|
@ -391,6 +394,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. */
|
||||
|
|
@ -427,6 +438,7 @@ private:
|
|||
void emitRecursiveUpdates(const QModelIndex &index);
|
||||
|
||||
void sortHelper(InnerDecklistNode *node, Qt::SortOrder order);
|
||||
void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order);
|
||||
|
||||
template <typename T> T getNode(const QModelIndex &index) const
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
#include "deck_list_model_custom_zones.h"
|
||||
|
||||
#include "deck_list_model.h"
|
||||
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <QHash>
|
||||
#include <QVector>
|
||||
|
||||
namespace DeckListModelCustomZones
|
||||
{
|
||||
|
||||
bool isCustomZone(const AbstractDecklistNode *node)
|
||||
{
|
||||
return dynamic_cast<const DecklistModelSubZoneNode *>(node) != nullptr;
|
||||
}
|
||||
|
||||
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone)
|
||||
{
|
||||
for (int j = 0; j < deckBoardZone->size(); j++) {
|
||||
auto *customCard = dynamic_cast<DecklistCardNode *>(deckBoardZone->at(j));
|
||||
if (customCard) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *customZone = dynamic_cast<const InnerDecklistNode *>(deckBoardZone->at(j));
|
||||
if (!customZone) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone);
|
||||
for (int k = 0; k < customZone->size(); k++) {
|
||||
if (auto *zoneCard = dynamic_cast<DecklistCardNode *>(customZone->at(k))) {
|
||||
new DecklistModelCardNode(zoneCard, shadowZone);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping.
|
||||
*/
|
||||
QList<QPair<int, int>> plainSort(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
const QVector<QPair<int, int>> sortResult = node->sort(order);
|
||||
|
||||
QList<QPair<int, int>> mapping;
|
||||
mapping.reserve(node->size());
|
||||
for (int i = 0; i < node->size(); ++i) {
|
||||
mapping.append({sortResult[i].first, i});
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Sorts a board zone's children, then stably moves custom zones to the end.
|
||||
*
|
||||
* @return The (preSortRow, finalRow) mapping covering both the sort and the shift.
|
||||
*/
|
||||
QList<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
const QVector<QPair<int, int>> sortResult = node->sort(order);
|
||||
|
||||
QVector<AbstractDecklistNode *> groups;
|
||||
QVector<AbstractDecklistNode *> customZones;
|
||||
QHash<AbstractDecklistNode *, int> preSortRowOf;
|
||||
|
||||
groups.reserve(node->size());
|
||||
customZones.reserve(node->size());
|
||||
|
||||
for (int i = 0; i < node->size(); ++i) {
|
||||
AbstractDecklistNode *child = node->at(i);
|
||||
preSortRowOf.insert(child, sortResult[i].first);
|
||||
if (isCustomZone(child)) {
|
||||
customZones.append(child);
|
||||
} else {
|
||||
groups.append(child);
|
||||
}
|
||||
}
|
||||
|
||||
QVector<AbstractDecklistNode *> ordered = groups + customZones;
|
||||
for (int i = 0; i < ordered.size(); ++i) {
|
||||
node->replace(i, ordered[i]);
|
||||
}
|
||||
|
||||
QList<QPair<int, int>> mapping;
|
||||
mapping.reserve(ordered.size());
|
||||
for (int i = 0; i < ordered.size(); ++i) {
|
||||
mapping.append({preSortRowOf.value(ordered[i]), i});
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QList<QPair<int, int>> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order)
|
||||
{
|
||||
const bool isBoardZone = (node != root) && (node->getParent() == root);
|
||||
return isBoardZone ? boardSort(node, order) : plainSort(node, order);
|
||||
}
|
||||
|
||||
} // namespace DeckListModelCustomZones
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
#ifndef DECK_LIST_MODEL_CUSTOM_ZONES_H
|
||||
#define DECK_LIST_MODEL_CUSTOM_ZONES_H
|
||||
|
||||
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <QList>
|
||||
#include <QPair>
|
||||
#include <QtGlobal>
|
||||
|
||||
/**
|
||||
* @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 by type.
|
||||
*/
|
||||
class DecklistModelSubZoneNode : public InnerDecklistNode
|
||||
{
|
||||
public:
|
||||
using InnerDecklistNode::InnerDecklistNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* @namespace DeckListModelCustomZones
|
||||
* @ingroup DeckModels
|
||||
* @brief Tree-level helpers for the deck list model's custom-zone shadow nodes.
|
||||
*
|
||||
* The deck list model keeps a second "shadow" tree of InnerDecklistNode that
|
||||
* mirrors the canonical deck tree for grouping and sorting. Custom zones add a
|
||||
* layer of bookkeeping to that shadow tree: they must be mirrored alongside
|
||||
* criteria groups, always sort after the groups within a board, and be
|
||||
* resolvable by deck-unique name.
|
||||
*
|
||||
* This namespace centralizes every "what is / where is a custom zone" decision
|
||||
* so the model itself only wires the results into Qt model signals.
|
||||
*/
|
||||
namespace DeckListModelCustomZones
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief Whether the given node is a custom zone (as opposed to a criteria group).
|
||||
*/
|
||||
[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node);
|
||||
|
||||
/**
|
||||
* @brief Mirrors the custom zones of a deck board zone into its shadow board node.
|
||||
*
|
||||
* Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone
|
||||
* with its cards as direct (un-grouped) children.
|
||||
*
|
||||
* @param deckBoardZone The board zone in the canonical deck tree.
|
||||
* @param shadowBoardZone The matching board zone in the model's shadow tree.
|
||||
*/
|
||||
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone);
|
||||
|
||||
/**
|
||||
* @brief Finds a custom zone in the shadow tree by deck-unique name.
|
||||
* @param root Root of the shadow tree.
|
||||
* @param zoneName The custom zone name to find.
|
||||
* @return The matching custom zone node, or nullptr if not found.
|
||||
*/
|
||||
[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName);
|
||||
|
||||
/**
|
||||
* @brief Sorts a shadow node's children, keeping a board's custom zones last.
|
||||
*
|
||||
* Sorting alone would interleave custom zones with criteria groups by name, but
|
||||
* custom zones must always stay after the groups within a board, regardless of
|
||||
* name. This applies the sort and, for board zones, stably moves the custom
|
||||
* zones to the end.
|
||||
*
|
||||
* @param root Root of the shadow tree (used to classify board zones).
|
||||
* @param node The shadow node whose children are reordered.
|
||||
* @param order Sort order to apply.
|
||||
* @return A list of (preSortRow, finalRow) pairs describing how each node moved.
|
||||
*/
|
||||
[[nodiscard]] QList<QPair<int, int>>
|
||||
sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order);
|
||||
|
||||
} // namespace DeckListModelCustomZones
|
||||
|
||||
#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H
|
||||
|
|
@ -114,6 +114,7 @@ target_link_libraries(
|
|||
|
||||
add_subdirectory(card_zone_algorithms)
|
||||
add_subdirectory(carddatabase)
|
||||
add_subdirectory(deck_list_model)
|
||||
add_subdirectory(deck_list_zones)
|
||||
add_subdirectory(loading_from_clipboard)
|
||||
add_subdirectory(movecard_tests)
|
||||
|
|
|
|||
33
tests/deck_list_model/CMakeLists.txt
Normal file
33
tests/deck_list_model/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
add_executable(deck_list_model_custom_zones_test deck_list_model_custom_zones_test.cpp)
|
||||
|
||||
if(NOT GTEST_FOUND)
|
||||
add_dependencies(deck_list_model_custom_zones_test gtest)
|
||||
endif()
|
||||
|
||||
target_link_libraries(
|
||||
deck_list_model_custom_zones_test
|
||||
libcockatrice_models
|
||||
libcockatrice_card
|
||||
libcockatrice_deck_list
|
||||
Threads::Threads
|
||||
${GTEST_BOTH_LIBRARIES}
|
||||
${TEST_QT_MODULES}
|
||||
)
|
||||
add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test)
|
||||
|
||||
add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp)
|
||||
|
||||
if(NOT GTEST_FOUND)
|
||||
add_dependencies(deck_list_model_zone_integration_test gtest)
|
||||
endif()
|
||||
|
||||
target_link_libraries(
|
||||
deck_list_model_zone_integration_test
|
||||
libcockatrice_models
|
||||
libcockatrice_card
|
||||
libcockatrice_deck_list
|
||||
Threads::Threads
|
||||
${GTEST_BOTH_LIBRARIES}
|
||||
${TEST_QT_MODULES}
|
||||
)
|
||||
add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test)
|
||||
225
tests/deck_list_model/deck_list_model_custom_zones_test.cpp
Normal file
225
tests/deck_list_model/deck_list_model_custom_zones_test.cpp
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/**
|
||||
* @file deck_list_model_custom_zones_test.cpp
|
||||
* @brief Tests for the deck list model's custom-zone shadow-tree helpers.
|
||||
*
|
||||
* DeckListModelCustomZones centralizes every "what is / where is a custom zone"
|
||||
* decision for the model's shadow tree: type testing, mirroring from the deck
|
||||
* tree, name lookup, and the sort-with-custom-zones-last ordering. These tests
|
||||
* exercise that logic directly on hand-built shadow trees, independent of the
|
||||
* full model and card database machinery.
|
||||
*/
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
DecklistModelCardNode *cardNode(InnerDecklistNode *parent, const QString &name, int number)
|
||||
{
|
||||
// The underlying data node is detached; only the model wrapper is attached to the shadow tree.
|
||||
auto *data = new DecklistCardNode(name, number, nullptr);
|
||||
return new DecklistModelCardNode(data, parent);
|
||||
}
|
||||
|
||||
QStringList childNames(const InnerDecklistNode *node)
|
||||
{
|
||||
QStringList names;
|
||||
for (int i = 0; i < node->size(); ++i) {
|
||||
names.append(node->at(i)->getName());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// =====================================================================================================================
|
||||
// isCustomZone
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelCustomZones, IsCustomZoneDistinguishesZoneFromGroup)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
auto *group = new InnerDecklistNode("Creature", board);
|
||||
auto *zone = new DecklistModelSubZoneNode("Removal", board);
|
||||
|
||||
auto *card = cardNode(group, "A", 1);
|
||||
|
||||
EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(board));
|
||||
EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(group));
|
||||
EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(card));
|
||||
EXPECT_TRUE(DeckListModelCustomZones::isCustomZone(zone));
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// findSubZoneByName
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelCustomZones, FindSubZoneByNameFindsAcrossBoards)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *main = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
auto *side = new InnerDecklistNode(DECK_ZONE_SIDE, &root);
|
||||
new DecklistModelSubZoneNode("Removal", main);
|
||||
new DecklistModelSubZoneNode("Utility", side);
|
||||
new InnerDecklistNode("Plain", main); // not a custom zone
|
||||
|
||||
auto *removal = DeckListModelCustomZones::findSubZoneByName(&root, "Removal");
|
||||
ASSERT_NE(removal, nullptr);
|
||||
EXPECT_EQ(removal->getName(), QString("Removal"));
|
||||
|
||||
auto *utility = DeckListModelCustomZones::findSubZoneByName(&root, "Utility");
|
||||
ASSERT_NE(utility, nullptr);
|
||||
EXPECT_EQ(utility->getName(), QString("Utility"));
|
||||
|
||||
// Names are deck-unique; a plain group or built-in board is not matched.
|
||||
EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Plain"), nullptr);
|
||||
EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, DECK_ZONE_MAIN), nullptr);
|
||||
EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Missing"), nullptr);
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// mirrorCustomZones
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelCustomZones, MirrorCustomZonesCopiesCardsFlat)
|
||||
{
|
||||
// Deck-tree board zone: one direct card plus one nested custom zone.
|
||||
auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN);
|
||||
new DecklistCardNode("Direct", 2, deckBoard);
|
||||
|
||||
auto *deckZone = new InnerDecklistNode("Removal", deckBoard);
|
||||
auto *deckCard1 = new DecklistCardNode("Bolt", 3, deckZone);
|
||||
auto *deckCard2 = new DecklistCardNode("Swords", 1, deckZone);
|
||||
|
||||
InnerDecklistNode shadowRoot;
|
||||
auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot);
|
||||
|
||||
DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard);
|
||||
|
||||
// Only the custom zone is mirrored as a sub-zone; the direct card is not.
|
||||
ASSERT_EQ(shadowBoard->size(), 1);
|
||||
auto *shadowZone = dynamic_cast<DecklistModelSubZoneNode *>(shadowBoard->at(0));
|
||||
ASSERT_NE(shadowZone, nullptr);
|
||||
EXPECT_EQ(shadowZone->getName(), QString("Removal"));
|
||||
|
||||
// Cards live flat (un-grouped) inside the mirrored zone, wrapping the same data nodes.
|
||||
ASSERT_EQ(shadowZone->size(), 2);
|
||||
auto *shadowCard1 = dynamic_cast<DecklistModelCardNode *>(shadowZone->at(0));
|
||||
auto *shadowCard2 = dynamic_cast<DecklistModelCardNode *>(shadowZone->at(1));
|
||||
ASSERT_NE(shadowCard1, nullptr);
|
||||
ASSERT_NE(shadowCard2, nullptr);
|
||||
EXPECT_EQ(shadowCard1->getDataNode(), deckCard1);
|
||||
EXPECT_EQ(shadowCard2->getDataNode(), deckCard2);
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, MirrorCustomZonesWithNoCustomZonesIsNoop)
|
||||
{
|
||||
// A board zone with only direct cards has nothing to mirror.
|
||||
auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN);
|
||||
new DecklistCardNode("Direct", 2, deckBoard);
|
||||
|
||||
InnerDecklistNode shadowRoot;
|
||||
auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot);
|
||||
|
||||
DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard);
|
||||
EXPECT_EQ(shadowBoard->size(), 0);
|
||||
}
|
||||
|
||||
// =====================================================================================================================
|
||||
// sortWithCustomZonesLast
|
||||
// =====================================================================================================================
|
||||
|
||||
TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsAscending)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
new DecklistModelSubZoneNode("Zebra", board);
|
||||
new InnerDecklistNode("Creature", board);
|
||||
new InnerDecklistNode("Instant", board);
|
||||
new DecklistModelSubZoneNode("Alpha", board);
|
||||
|
||||
root.setSortMethod(DeckSortMethod::ByName);
|
||||
|
||||
auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder);
|
||||
|
||||
// Groups sort first (by name), then custom zones (by name), always after groups.
|
||||
EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"}));
|
||||
|
||||
// Some non-identity movement occurred.
|
||||
EXPECT_FALSE(mapping.isEmpty());
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsDescending)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
new DecklistModelSubZoneNode("Zebra", board);
|
||||
new InnerDecklistNode("Creature", board);
|
||||
new InnerDecklistNode("Instant", board);
|
||||
new DecklistModelSubZoneNode("Alpha", board);
|
||||
|
||||
root.setSortMethod(DeckSortMethod::ByName);
|
||||
|
||||
(void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder);
|
||||
|
||||
// Groups still lead (descending), custom zones still last.
|
||||
EXPECT_EQ(childNames(board), (QStringList{"Instant", "Creature", "Zebra", "Alpha"}));
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, SortBoardMappingIsConsistent)
|
||||
{
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
|
||||
QList<AbstractDecklistNode *> originalOrder;
|
||||
auto *g0 = new InnerDecklistNode("Creature", board);
|
||||
originalOrder.append(g0);
|
||||
auto *z0 = new DecklistModelSubZoneNode("Zebra", board);
|
||||
originalOrder.append(z0);
|
||||
auto *g1 = new InnerDecklistNode("Instant", board);
|
||||
originalOrder.append(g1);
|
||||
auto *z1 = new DecklistModelSubZoneNode("Alpha", board);
|
||||
originalOrder.append(z1);
|
||||
|
||||
root.setSortMethod(DeckSortMethod::ByName);
|
||||
|
||||
auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder);
|
||||
|
||||
// The mapping reports, for each final row, the original row of the node now sitting there.
|
||||
ASSERT_EQ(mapping.size(), board->size());
|
||||
for (const auto &move : mapping) {
|
||||
const int preSortRow = move.first;
|
||||
const int finalRow = move.second;
|
||||
ASSERT_GE(preSortRow, 0);
|
||||
ASSERT_LT(preSortRow, originalOrder.size());
|
||||
EXPECT_EQ(board->at(finalRow), originalOrder[preSortRow]) << "row " << finalRow;
|
||||
}
|
||||
|
||||
// Final order sanity: groups first in name order, then custom zones.
|
||||
EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"}));
|
||||
}
|
||||
|
||||
TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones)
|
||||
{
|
||||
// A non-board node (e.g. a group whose children are cards) is sorted plainly;
|
||||
// custom zones are not a special case there. Cards sort by name.
|
||||
InnerDecklistNode root;
|
||||
auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root);
|
||||
auto *group = new InnerDecklistNode("Creature", board);
|
||||
cardNode(group, "Swords", 1);
|
||||
cardNode(group, "Bolt", 3);
|
||||
|
||||
root.setSortMethod(DeckSortMethod::ByName);
|
||||
|
||||
auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, group, Qt::AscendingOrder);
|
||||
EXPECT_EQ(childNames(group), (QStringList{"Bolt", "Swords"}));
|
||||
ASSERT_EQ(mapping.size(), 2);
|
||||
EXPECT_EQ(mapping[0].first, 1); // "Bolt" was originally at row 1
|
||||
EXPECT_EQ(mapping[0].second, 0);
|
||||
EXPECT_EQ(mapping[1].first, 0);
|
||||
EXPECT_EQ(mapping[1].second, 1);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
#include <gtest/gtest.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
#include <libcockatrice/deck_list/deck_list_node_tree.h>
|
||||
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
|
||||
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
|
||||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
int totalCustomZoneRows(const DeckListModel &model)
|
||||
{
|
||||
int count = 0;
|
||||
const int rootRows = model.rowCount(QModelIndex());
|
||||
for (int r = 0; r < rootRows; ++r) {
|
||||
const QModelIndex board = model.index(r, 0, QModelIndex());
|
||||
const int childRows = model.rowCount(board);
|
||||
for (int c = 0; c < childRows; ++c) {
|
||||
const QModelIndex child = model.index(c, 0, board);
|
||||
if (child.data(DeckRoles::IsCustomZoneRole).toBool()) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// The "Add to Zone" combobox/submenu lists getCustomZoneNames(), which reads the
|
||||
// deck tree. These verify the source data a freshly-created zone populates.
|
||||
|
||||
TEST(DeckListModelZoneIntegration, CreateZoneThenReadCustomZoneNames)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal"}));
|
||||
}
|
||||
|
||||
TEST(DeckListModelZoneIntegration, CreateTwoZonesThenReadBoth)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr);
|
||||
EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"}));
|
||||
}
|
||||
|
||||
// Mirroring regression: rebuildTree must mirror each custom zone exactly once.
|
||||
TEST(DeckListModelZoneIntegration, RebuildTreeMirrorsEachZoneOnce)
|
||||
{
|
||||
QSharedPointer<DeckList> deck(new DeckList());
|
||||
DeckListModel model(nullptr, deck);
|
||||
auto *tree = deck->getTree();
|
||||
|
||||
// One direct mainboard card plus two nested custom zones.
|
||||
tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1);
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr);
|
||||
tree->addCard("Swords to Plowshares", 1, "Removal", -1);
|
||||
ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr);
|
||||
|
||||
model.rebuildTree();
|
||||
|
||||
EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"}));
|
||||
EXPECT_EQ(totalCustomZoneRows(model), 2);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue