[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.
This commit is contained in:
Lukas Brübach 2026-08-23 23:39:46 +02:00 committed by GitHub
parent 2253e4ff5e
commit 3a23d955d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 258 additions and 0 deletions

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

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