Compare commits

...

8 commits

Author SHA1 Message Date
Lukas Brübach
78f3abfb50
[Client] Expose custom zone management in the deck editor
Wires the state layer into every editor surface that shows deck zones.

- Deck dock: context menu on zones gains New/Rename/Delete/Change
  board actions, with per-zone submenus for adding cards.
- Card database dock and visual database display gain an add-to-zone
  submenu listing custom zones per board plus a create-zone entry.
- All prompt call sites pass validateNewZoneName so duplicates and
  reserved names are rejected inline before Ok unlocks.
- Rename reuses the same dialog in name-only mode, keeping one
  validation contract for every zone-name entry point.
- Change board marks the current board instead of offering a no-op,
  and the state layer refuses moves onto boards holding a same-named
  zone from imported decks.
2026-08-30 13:31:12 +02:00
Lukas Brübach
834389600f
[Client] Add zone management to the deck state manager
State-layer operations for custom deck zones, plus the shared prompt
dialog that later editor menus will call into.

- moveCardToZone relocates every copy of a card row into any zone,
  refusing non-card rows and tokens so miswired selections can never
  shred a group or turn tokens into deck cards. The current zone is
  found by walking ancestors, which also handles legacy top-level
  zones.
- createCustomZone, renameCustomZone, moveCustomZone and
  removeCustomZone wrap the tree API with memento history, model
  rebuilds and deck hash refreshes via modifyTree.
- Same-board zone moves return success without minting a history
  entry, keeping the undo log honest.
- promptForNewZone asks for a name and the parent zone, keeps Ok
  disabled until the trimmed name passes a caller-supplied validator
  (shown inline as an error), and reports its own translation context.

Took 14 minutes

# Commit time for manual adjustment:
# Took 6 minutes

# Commit time for manual adjustment:
# Took 33 seconds
2026-08-30 13:31:12 +02:00
Lukas Brübach
30c0ba2cd6
[Client] Add zone management to the deck state manager
State-layer operations for custom deck zones, plus the shared prompt
dialog that later editor menus will call into.

- moveCardToZone relocates every copy of a card row into any zone,
  refusing non-card rows and tokens so miswired selections can never
  shred a group or turn tokens into deck cards. The current zone is
  found by walking ancestors, which also handles legacy top-level
  zones.
- createCustomZone, renameCustomZone, moveCustomZone and
  removeCustomZone wrap the tree API with memento history, model
  rebuilds and deck hash refreshes via modifyTree.
- Same-board zone moves return success without minting a history
  entry, keeping the undo log honest.
- promptForNewZone asks for a name and the parent zone, keeps Ok
  disabled until the trimmed name passes a caller-supplied validator
  (shown inline as an error), and reports its own translation context.

Took 14 minutes

# Commit time for manual adjustment:
# Took 6 minutes

# Commit time for manual adjustment:
# Took 33 seconds
2026-08-30 13:26:08 +02:00
Lukas Brübach
2a6f5a6953
[Models] Mirror custom deck zones in the deck list model
DeckListModel now surfaces the custom zones from the deck tree so
views can render and edit them alongside criteria groups.

The custom-zone bookkeeping that made the model unwieldy is extracted
into DeckListModelCustomZones (deck_list_model_custom_zones.h/.cpp), a
single self-contained unit owning every "what is / where is a custom
zone" decision for the model's shadow tree:

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

A new deck_list_model_custom_zones_test suite locks the extracted
shadow-tree logic (type testing, mirroring, name lookup, and the
sort-with-custom-zones-last mapping).

No behavior change.
2026-08-30 13:26:08 +02:00
BruebachL
68e4fa054d
[UserList] Add invite button to hover popup (#7144)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
* [Client] Send game invites from the user context menu via a private message

The user context menu gains an "Invite to Game" submenu listing the
inviteable games in the room (the inviter's own games, honoring the
buddy-only setting). Picking one opens a private message to the target
user with a cockatrice://joingame link naming the game, so the target
gets a clickable invite instead of a raw URL. Multi-game rooms offer a
picker; a single inviteable game sends directly. Sending a message to
an offline user no longer swallows the draft — it reports that the
user is offline and keeps the typed text.

Took 30 seconds

Took 1 minute

* [Client] Open the invite dialog taller by default without enforcing a minimum size

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-29 21:35:28 +02:00
BruebachL
6f86c45ea8
[Refactor] Extract shared deck conversion prompt helper (#7107)
Move the deck-to-.cod conversion prompt logic (format check, saved
preference handling, overwrite confirmation, dialog) out of
DeckPreviewWidget into dlg_convert_deck_to_cod_format so the deck
editor can reuse it without duplicating it.

Took 4 minutes

Took 4 minutes

Took 1 minute

# Commit time for manual adjustment:
# Took 3 minutes

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-29 21:12:11 +02:00
dependabot[bot]
dade7ae78a
Bump actions/checkout from 6 to 7 (#7210)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 18:23:45 +02:00
dependabot[bot]
8f52223322
Bump actions/download-artifact from 7 to 8 (#7209)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 18:11:11 +02:00
37 changed files with 1602 additions and 91 deletions

View file

@ -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

View file

@ -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-*

View file

@ -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

View file

@ -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 &current, 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); });

View file

@ -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);

View file

@ -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);

View file

@ -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();

View file

@ -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 &current);

View file

@ -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
*/

View file

@ -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();

View file

@ -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 &currentZoneName,
const std::function<QString(const QString &)> &nameValidator)
{
DeckZoneDialog dialog(parent, {}, nameValidator, false);
dialog.setZoneName(currentZoneName);
return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString();
}

View 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 &currentZoneName,
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

View file

@ -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;
}

View file

@ -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;

View file

@ -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) {

View file

@ -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

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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()
{

View file

@ -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

View file

@ -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();

View file

@ -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.

View file

@ -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());

View file

@ -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();

View file

@ -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;
});
}

View file

@ -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);

View file

@ -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 nodes display-friendly name.
* @return Human-readable name (zone/group name).

View file

@ -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})

View file

@ -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) {

View file

@ -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
{

View file

@ -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

View file

@ -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

View file

@ -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)

View 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)

View 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);
}

View file

@ -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);
}