From 8f52223322bf18eb9ac387ff2b4f471251d5d967 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:11:11 +0200 Subject: [PATCH 1/8] 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] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index df4fe233c..255e8b045 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -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-* From dade7ae78a79cff8cc8b4db1cc7cd61398ec10fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:23:45 +0200 Subject: [PATCH 2/8] 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] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e895e2220..75fbc59f1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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 From 6f86c45ea8e858f1ee7a3738acd51b3cf1a1137e Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:12:11 +0200 Subject: [PATCH 3/8] [Refactor] Extract shared deck conversion prompt helper (#7107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../dlg_convert_deck_to_cod_format.cpp | 76 +++++++++++++++++++ .../dialogs/dlg_convert_deck_to_cod_format.h | 18 +++++ .../deck_preview/deck_preview_widget.cpp | 58 +------------- 3 files changed, 96 insertions(+), 56 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index 198fa259b..a4a31d78d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp @@ -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 #include +#include +#include +#include #include +#include #include +#include 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 &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; +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h index 6642ad8c6..526582135 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h @@ -13,6 +13,9 @@ #include #include #include +#include + +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 &convert); + private: QVBoxLayout *layout; QLabel *label; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index 04dcdf7f2..876fbf6ad 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -10,8 +10,6 @@ #include "../visual_deck_storage_widget.h" #include "deck_preview_deck_tags_display_widget.h" -#include -#include #include #include #include @@ -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; + }); } From 68e4fa054de040105fb0a6dd7676c30ad66e9935 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:35:28 +0200 Subject: [PATCH 4/8] [UserList] Add invite button to hover popup (#7144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 --- .../widgets/server/user/user_info_popup.cpp | 7 +++++++ .../widgets/server/user/user_info_popup.h | 14 ++++++++++++++ .../widgets/server/user/user_list_widget.cpp | 7 +++++++ .../widgets/server/user/user_list_widget.h | 1 + .../src/interface/widgets/tabs/tab_supervisor.cpp | 3 ++- 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index f6f34a6a5..fb610e814 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -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) { diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h index 02cc2b44e..ed7320fba 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -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 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 gameInviteAvailable; UserInfoHeaderWidget *header; QWidget *actionArea; ///< rebuilt per user diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 2cacfc4f9..a8c99c979 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -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); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 7531ef925..412271160 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -22,6 +22,7 @@ #include #include #include +#include #include class QTreeWidget; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index f96c139b3..b0dac3e7c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1091,7 +1091,8 @@ QList 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; From 2a6f5a69536c813b07e363d96e98a8a7240ae881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 23 Aug 2026 22:50:37 +0200 Subject: [PATCH 5/8] [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. --- .../models/deck_list/CMakeLists.txt | 3 +- .../models/deck_list/deck_list_model.cpp | 150 +++++++++--- .../models/deck_list/deck_list_model.h | 14 +- .../deck_list_model_custom_zones.cpp | 123 ++++++++++ .../deck_list/deck_list_model_custom_zones.h | 83 +++++++ tests/CMakeLists.txt | 1 + tests/deck_list_model/CMakeLists.txt | 33 +++ .../deck_list_model_custom_zones_test.cpp | 225 ++++++++++++++++++ .../deck_list_model_zone_integration_test.cpp | 71 ++++++ 9 files changed, 671 insertions(+), 32 deletions(-) create mode 100644 libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp create mode 100644 libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h create mode 100644 tests/deck_list_model/CMakeLists.txt create mode 100644 tests/deck_list_model/deck_list_model_custom_zones_test.cpp create mode 100644 tests/deck_list_model/deck_list_model_zone_integration_test.cpp diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index d4aee3686..a6ab2a204 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -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}) diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 9b43281c1..ad278c8bf 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -66,7 +66,8 @@ void DeckListModel::rebuildTree() for (int j = 0; j < currentZone->size(); j++) { auto *currentCard = dynamic_cast(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(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(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(root->findChild(zoneName))) { + QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); + if (auto *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria))) { + if (auto *card = dynamic_cast( + groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } + + for (auto *child : *zoneNode) { + if (!DeckListModelCustomZones::isCustomZone(child)) { + continue; + } + auto *customZone = dynamic_cast(child); + if (!customZone) { + continue; + } + if (auto *card = dynamic_cast( + customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } } - return dynamic_cast( - 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( + 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(groupNode->findCardChildByNameProviderIdAndNumber( + auto *boardNode = dynamic_cast(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(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(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> 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 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) { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 209ec8c42..09600ca67 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -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 @@ -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 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; /**< 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 T getNode(const QModelIndex &index) const { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp new file mode 100644 index 000000000..f10ee1a72 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp @@ -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 +#include + +namespace DeckListModelCustomZones +{ + +bool isCustomZone(const AbstractDecklistNode *node) +{ + return dynamic_cast(node) != nullptr; +} + +void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone) +{ + for (int j = 0; j < deckBoardZone->size(); j++) { + auto *customCard = dynamic_cast(deckBoardZone->at(j)); + if (customCard) { + continue; + } + + auto *customZone = dynamic_cast(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(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(root->at(i)); + if (!boardZone) { + continue; + } + + for (int j = 0; j < boardZone->size(); j++) { + auto *customZone = dynamic_cast(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> plainSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QList> 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> boardSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QVector groups; + QVector customZones; + QHash 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 ordered = groups + customZones; + for (int i = 0; i < ordered.size(); ++i) { + node->replace(i, ordered[i]); + } + + QList> mapping; + mapping.reserve(ordered.size()); + for (int i = 0; i < ordered.size(); ++i) { + mapping.append({preSortRowOf.value(ordered[i]), i}); + } + return mapping; +} + +} // namespace + +QList> 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 diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h new file mode 100644 index 000000000..a973b127e --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h @@ -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 +#include +#include + +/** + * @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> +sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); + +} // namespace DeckListModelCustomZones + +#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 34784538b..18ab60d06 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt new file mode 100644 index 000000000..8f2cfcc2c --- /dev/null +++ b/tests/deck_list_model/CMakeLists.txt @@ -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) diff --git a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp new file mode 100644 index 000000000..c51c72d8f --- /dev/null +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -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 +#include +#include +#include + +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(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(shadowZone->at(0)); + auto *shadowCard2 = dynamic_cast(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 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); +} diff --git a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp new file mode 100644 index 000000000..530e1c351 --- /dev/null +++ b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp @@ -0,0 +1,71 @@ +#include +#include +#include +#include +#include +#include + +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 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 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 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); +} From 30c0ba2cd6867fe44811898eb30b20a34df46826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 23 Aug 2026 23:23:42 +0200 Subject: [PATCH 6/8] [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 --- cockatrice/CMakeLists.txt | 1 + .../deck_editor/deck_state_manager.cpp | 197 ++++++++++++++++++ .../widgets/deck_editor/deck_state_manager.h | 64 ++++++ .../widgets/deck_editor/deck_zone_dialog.cpp | 142 +++++++++++++ .../widgets/deck_editor/deck_zone_dialog.h | 123 +++++++++++ .../deck_list/tree/inner_deck_list_node.cpp | 7 + .../deck_list/tree/inner_deck_list_node.h | 10 + 7 files changed, 544 insertions(+) create mode 100644 cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp create mode 100644 cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 2f629fed2..4a42520f7 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -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 diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index eda741728..b60685d20 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -2,6 +2,7 @@ #include #include +#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(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 &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 */ diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index b9c99903e..2c8b34a39 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -5,6 +5,7 @@ #include "deck_list_model.h" #include +#include #include 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 &operation); void doCardModified(); void doMetadataModified(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp new file mode 100644 index 000000000..a14beec0e --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp @@ -0,0 +1,142 @@ +#include "deck_zone_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DeckZoneDialog::DeckZoneDialog(QWidget *parent, + const QString &initialBoardName, + const std::function &_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 &nameValidator) +{ + DeckZoneDialog dialog(parent, initialBoardName, nameValidator); + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + + if (chosenBoardName) { + *chosenBoardName = dialog.getBoardName(); + } + return dialog.getZoneName(); +} + +QString DeckZoneDialog::promptForRename(QWidget *parent, + const QString ¤tZoneName, + const std::function &nameValidator) +{ + DeckZoneDialog dialog(parent, {}, nameValidator, false); + dialog.setZoneName(currentZoneName); + return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString(); +} diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h new file mode 100644 index 000000000..6f55617a8 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h @@ -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 +#include +#include +#include + +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 &_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 &nameValidator = {}); + + /** + * @brief Prompts the user for a new name for an existing custom zone. + * + * Same inline validation as promptForNewZone, but without a parent-zone picker. + * + * @param parent The parent widget for the dialog + * @param currentZoneName The current name, prefilled for editing + * @param nameValidator Validator deciding whether a candidate name is usable. It sees + * the current name too, so callers wanting to allow unchanged names must + * special-case that themselves. + * @return The trimmed new name, or an empty string if the user cancelled + */ + static QString promptForRename(QWidget *parent, + const QString ¤tZoneName, + const std::function &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 nameValidator; + bool allowBoardSelection; +}; + +#endif // DECK_ZONE_DIALOG_H diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index ec860dc56..5e7ba403b 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -43,6 +43,13 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method) } } +const QList &InnerDecklistNode::boardZoneNames() +{ + static const QList names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), + QString(DECK_ZONE_MAYBEBOARD)}; + return names; +} + QString InnerDecklistNode::getVisibleName() const { return visibleNameFromName(name); diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 906ed6cb5..0d454c11e 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -18,6 +18,9 @@ #include "abstract_deck_list_node.h" +#include +#include + /** @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 &boardZoneNames(); + /** * @brief Get this node’s display-friendly name. * @return Human-readable name (zone/group name). From 78f3abfb50838e04f9f12cb14f1c86895b692e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 23 Aug 2026 23:39:46 +0200 Subject: [PATCH 8/8] [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. --- .../deck_editor/card_database_view.cpp | 40 ++++++ .../widgets/deck_editor/card_database_view.h | 17 +++ .../deck_editor_card_database_dock_widget.cpp | 27 ++++ .../deck_editor_deck_dock_widget.cpp | 132 ++++++++++++++++++ .../deck_editor_deck_dock_widget.h | 5 + .../tab_deck_editor_visual.cpp | 14 ++ .../tab_deck_editor_visual.h | 5 + .../tab_deck_editor_visual_tab_widget.cpp | 2 + .../tab_deck_editor_visual_tab_widget.h | 1 + .../visual_database_display_widget.cpp | 14 ++ .../visual_database_display_widget.h | 1 + 11 files changed, 258 insertions(+) diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp index 7c782b074..c51830cf0 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp @@ -90,6 +90,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName) emit cardDecremented(currentCardName(), zoneName); } +void CardDatabaseView::setZoneMenuProvider(const std::function>()> &provider, + const std::function &newZoneHandler) +{ + zoneMenuProvider = provider; + this->newZoneHandler = newZoneHandler; +} + void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/) { if (!current.isValid()) { @@ -142,6 +149,39 @@ void CardDatabaseView::openCustomMenu(QPoint point) [this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); }); connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked); + if (zoneMenuProvider) { + QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone")); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + connect(action, &QAction::triggered, this, + [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); + } + + bool anyCustomZone = false; + const auto zoneBoards = zoneMenuProvider(); + for (const auto &zoneBoard : zoneBoards) { + const QString &boardName = zoneBoard.first; + const QStringList &customZones = zoneBoard.second; + if (customZones.isEmpty()) { + continue; + } + anyCustomZone = true; + QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName)); + for (const QString &zoneName : customZones) { + QAction *action = boardSubmenu->addAction(zoneName); + connect(action, &QAction::triggered, this, + [this, card, zoneName] { emit cardAdded(card->getName(), zoneName); }); + } + } + + if (anyCustomZone) { + addToZoneMenu->addSeparator(); + } + + QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone...")); + connect(newZoneAction, &QAction::triggered, this, [this] { newZoneHandler(); }); + } + if (canBeCommander(*card)) { QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)")); connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); }); diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h index 175ec12b9..72040b97e 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h @@ -4,6 +4,7 @@ #include "../../key_signals.h" #include +#include #include 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>()> zoneMenuProvider; + /// Handler invoked when the user picks "New zone..." from the add-to-zone menu. + std::function 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>()> &provider, + const std::function &newZoneHandler); + signals: void cardChanged(const QString &cardName); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp index 2a491de4f..d78758375 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp @@ -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 + 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> { + QList> 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); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 14defc8e9..9296f8697 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -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 #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -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 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(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index 9db01e2e5..fc3b01dc7 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -102,6 +103,10 @@ private: [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); + void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex); + void addChangeBoardMenu(QMenu *menu, const QString &zoneName); + void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {}); + private slots: void decklistCustomMenu(QPoint point); void updateCard(QModelIndex, const QModelIndex ¤t); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index 209a30642..46d1b08b6 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -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() { diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h index 21335d2d0..e0ad6c914 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h @@ -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 diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp index 5ccfcc28f..843ddf493 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp @@ -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(); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h index 4f04b51f6..a625aaad7 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h @@ -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. diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 0cdf60d5d..2b58142b7 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -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> { + QList> 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()); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index 6e4d87876..bd6b45fdd 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -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();