From 055ba9a16f9192bd23fc01fe571def0b2581379c Mon Sep 17 00:00:00 2001 From: DawnFire42 Date: Sat, 27 Jun 2026 18:53:21 -0400 Subject: [PATCH] Add subtype breakdown counter for card selection (#6923) * Add subtype breakdown counter for card selection Display a categorized count of creature subtypes (and other card type subtypes) when multiple cards are selected. The breakdown appears above the total selection counter in the bottom-right corner. Subtypes are grouped by main card type and sorted by frequency, with the most common subtypes positioned adjacent to the total count for quick reference. The feature can be toggled via a new checkbox in Settings > User Interface. * Alignment fix * Computation logic moved to helper funtction in separate file * Rename SubtypeCounter to SubtypeTally * Fix subtype tally alignment by using grid layout instead of character padding * Rename count to tally in the subtype breakdown feature * partial rename * list position fixed * Clean up code and documentation * Rename subtypeCountLabelStyle to subtypeTallyLabelStyle and fix include ordering * Fix include path for selection_subtype_tally.h after file relocation * fixed count to tally rename inconsistencies --- cockatrice/CMakeLists.txt | 1 + .../src/client/settings/cache_settings.cpp | 7 + .../src/client/settings/cache_settings.h | 6 + .../src/game/selection_subtype_tally.cpp | 64 ++++++++ cockatrice/src/game/selection_subtype_tally.h | 36 +++++ cockatrice/src/game_graphics/game_view.cpp | 142 +++++++++++++++--- cockatrice/src/game_graphics/game_view.h | 9 ++ cockatrice/src/interface/theme_manager.cpp | 3 + .../user_interface_settings_page.cpp | 14 +- .../user_interface_settings_page.h | 1 + .../libcockatrice/utility/qt_utils.h | 1 + 11 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 cockatrice/src/game/selection_subtype_tally.cpp create mode 100644 cockatrice/src/game/selection_subtype_tally.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 18679664b..166b807d9 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -83,6 +83,7 @@ set(cockatrice_SOURCES src/game/game_state.cpp src/game_graphics/game_view.cpp src/game_graphics/hand_counter.cpp + src/game/selection_subtype_tally.cpp src/game_graphics/log/message_log_widget.cpp src/game/phase.cpp src/game_graphics/phases_toolbar.cpp diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 28e5eb187..b6bc8a47d 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -313,6 +313,7 @@ SettingsCache::SettingsCache() showDragSelectionCount = settings->value("interface/showlassoselectioncount", true).toBool(); showTotalSelectionCount = settings->value("interface/showpersistentselectioncount", true).toBool(); + showSubtypeSelectionTally = settings->value("interface/showsubtypeselectiontally", true).toBool(); showShortcuts = settings->value("menu/showshortcuts", true).toBool(); showGameSelectorFilterToolbar = settings->value("menu/showgameselectorfiltertoolbar", true).toBool(); @@ -1395,6 +1396,12 @@ void SettingsCache::setShowTotalSelectionCount(QT_STATE_CHANGED_T _showTotalSele settings->setValue("interface/showpersistentselectioncount", showTotalSelectionCount); } +void SettingsCache::setShowSubtypeSelectionTally(QT_STATE_CHANGED_T _showSubtypeSelectionTally) +{ + showSubtypeSelectionTally = static_cast(_showSubtypeSelectionTally); + settings->setValue("interface/showsubtypeselectiontally", showSubtypeSelectionTally); +} + void SettingsCache::loadPaths() { QString dataPath = getDataPath(); diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 5a5e0c546..29af89587 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -355,6 +355,7 @@ private: bool showStatusBar; bool showDragSelectionCount; bool showTotalSelectionCount; + bool showSubtypeSelectionTally; public: SettingsCache(); @@ -478,6 +479,10 @@ public: { return showTotalSelectionCount; } + [[nodiscard]] bool getShowSubtypeSelectionTally() const + { + return showSubtypeSelectionTally; + } [[nodiscard]] bool getNotificationsEnabled() const { return notificationsEnabled; @@ -1176,5 +1181,6 @@ public slots: void setRoundCardCorners(bool _roundCardCorners); void setShowDragSelectionCount(QT_STATE_CHANGED_T _showDragSelectionCount); void setShowTotalSelectionCount(QT_STATE_CHANGED_T _showTotalSelectionCount); + void setShowSubtypeSelectionTally(QT_STATE_CHANGED_T _showSubtypeSelectionTally); }; #endif diff --git a/cockatrice/src/game/selection_subtype_tally.cpp b/cockatrice/src/game/selection_subtype_tally.cpp new file mode 100644 index 000000000..e9f87fab9 --- /dev/null +++ b/cockatrice/src/game/selection_subtype_tally.cpp @@ -0,0 +1,64 @@ +#include "selection_subtype_tally.h" + +#include "../game_graphics/board/card_item.h" + +#include +#include + +namespace +{ + +/** @brief Extracts subtypes from a single card face's type line. */ +QStringList extractSubtypesFromFace(const QString &faceType) +{ + // Card type format: "Creature — Goblin Warrior" or "Legendary Enchantment — Saga" + QStringList parts = faceType.split(QStringLiteral(" — ")); + if (parts.size() > 1) { + return parts[1].split(QStringLiteral(" "), Qt::SkipEmptyParts); + } + return {}; +} + +} // anonymous namespace + +namespace SelectionSubtypeTally +{ + +QList countSubtypes(const QList &cards) +{ + QMap subtypeCounts; + + for (CardItem *card : cards) { + if (card->getFaceDown() || card->getCard().isEmpty()) { + continue; + } + + QString cardType = card->getCardInfo().getCardType(); + // Handle double-faced cards: "Creature — Human // Creature — Werewolf" + QStringList cardFaces = cardType.split(QStringLiteral(" // ")); + + for (const QString &face : cardFaces) { + QStringList subtypes = extractSubtypesFromFace(face); + for (const QString &subtype : subtypes) { + subtypeCounts[subtype]++; + } + } + } + + QList entries; + for (auto it = subtypeCounts.constBegin(); it != subtypeCounts.constEnd(); ++it) { + entries.append({it.key(), it.value()}); + } + + // Sort by count ascending, then alphabetically (lowest counts at bottom of display) + std::sort(entries.begin(), entries.end(), [](const SubtypeEntry &a, const SubtypeEntry &b) { + if (a.count != b.count) { + return a.count < b.count; + } + return a.name < b.name; + }); + + return entries; +} + +} // namespace SelectionSubtypeTally diff --git a/cockatrice/src/game/selection_subtype_tally.h b/cockatrice/src/game/selection_subtype_tally.h new file mode 100644 index 000000000..9038653f6 --- /dev/null +++ b/cockatrice/src/game/selection_subtype_tally.h @@ -0,0 +1,36 @@ +#ifndef SELECTION_SUBTYPE_TALLY_H +#define SELECTION_SUBTYPE_TALLY_H + +#include +#include + +class CardItem; + +/** @brief A single subtype (e.g., "Goblin", "Warrior") with its occurrence count. */ +struct SubtypeEntry +{ + QString name; ///< The subtype name + int count; ///< Number of selected cards with this subtype + + bool operator==(const SubtypeEntry &other) const + { + return name == other.name && count == other.count; + } +}; + +/** + * @brief Extracts and tallies subtypes from selected cards. + */ +namespace SelectionSubtypeTally +{ +/** + * @brief Parses card type lines and counts each subtype occurrence. + * + * Skips face-down cards and cards without type info. + * @param cards The list of selected card items to analyze. + * @return Entries sorted by count ascending, then alphabetically. + */ +QList countSubtypes(const QList &cards); +} // namespace SelectionSubtypeTally + +#endif diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index 41befd9a4..c2d9b2b3b 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -1,12 +1,16 @@ #include "game_view.h" #include "../client/settings/cache_settings.h" +#include "../game/selection_subtype_tally.h" #include "game_scene.h" #include +#include #include +#include #include #include +#include // QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings. // This subclass disables that behavior so dragCountLabel can appear above it. @@ -55,31 +59,40 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par refreshShortcuts(); rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this); - const QString countLabelStyle = "color: white; " - "font-size: 14px; " - "font-weight: bold; " - "background-color: rgba(0, 0, 0, 160); " - "border-radius: 3px; " - "padding: 1px 2px;"; + const QString baseProperties = "color: white; " + "font-family: monospace; " + "background-color: rgba(0, 0, 0, 160); " + "border-radius: 3px; " + "padding: 1px 2px; " + "white-space: pre;"; + + const QString dragCountLabelStyle = baseProperties + "font-size: 14px; font-weight: bold;"; + const QString totalCountLabelStyle = baseProperties + "font-size: 16px; font-weight: bold;"; + const QString subtypeTallyLabelStyle = baseProperties + "font-size: 12px;"; dragCountLabel = new QLabel(this); - dragCountLabel->setStyleSheet(countLabelStyle); + dragCountLabel->setStyleSheet(dragCountLabelStyle); dragCountLabel->hide(); dragCountLabel->raise(); totalCountLabel = new QLabel(this); - totalCountLabel->setStyleSheet(countLabelStyle); + totalCountLabel->setStyleSheet(totalCountLabelStyle); totalCountLabel->hide(); + + subtypeTallyContainer = new QWidget(this); + subtypeTallyContainer->setStyleSheet(subtypeTallyLabelStyle); + subtypeTallyLayout = new QGridLayout(subtypeTallyContainer); + subtypeTallyLayout->setContentsMargins(2, 2, 2, 2); + subtypeTallyLayout->setSpacing(2); + subtypeTallyContainer->hide(); } void GameView::resizeEvent(QResizeEvent *event) { QGraphicsView::resizeEvent(event); - GameScene *s = dynamic_cast(scene()); - if (s) { - s->processViewSizeChange(event->size()); - } + GameScene *s = static_cast(scene()); + s->processViewSizeChange(event->size()); updateSceneRect(scene()->sceneRect()); updateTotalSelectionCount(event->size()); @@ -164,29 +177,114 @@ void GameView::refreshShortcuts() SettingsCache::instance().shortcuts().getShortcut("Player/aCloseMostRecentZoneView")); } +void GameView::clearSubtypeLabels() +{ + QtUtils::clearLayoutRec(subtypeTallyLayout); +} + +QSize GameView::rebuildSubtypeLabels(const QList &entries) +{ + clearSubtypeLabels(); + + const QString nameStyle = QStringLiteral("color: white; font-size: 12px; background: transparent;"); + const QString countStyle = + QStringLiteral("color: white; font-size: 14px; font-weight: bold; background: transparent;"); + + int totalHeight = 0; + int maxNameWidth = 0; + int maxCountWidth = 0; + + int row = 0; + for (const SubtypeEntry &entry : entries) { + auto *nameLabel = new QLabel(entry.name, subtypeTallyContainer); + nameLabel->setStyleSheet(nameStyle); + nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + subtypeTallyLayout->addWidget(nameLabel, row, 0); + + auto *countLabel = new QLabel(QString::number(entry.count), subtypeTallyContainer); + countLabel->setStyleSheet(countStyle); + countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + subtypeTallyLayout->addWidget(countLabel, row, 1); + + QSize nameSize = nameLabel->sizeHint(); + QSize countSize = countLabel->sizeHint(); + maxNameWidth = qMax(maxNameWidth, nameSize.width()); + maxCountWidth = qMax(maxCountWidth, countSize.width()); + totalHeight += qMax(nameSize.height(), countSize.height()); + + ++row; + } + + int spacing = subtypeTallyLayout->spacing(); + int margins = subtypeTallyLayout->contentsMargins().left() + subtypeTallyLayout->contentsMargins().right(); + int verticalMargins = subtypeTallyLayout->contentsMargins().top() + subtypeTallyLayout->contentsMargins().bottom(); + + int width = maxNameWidth + spacing + maxCountWidth + margins; + int height = totalHeight + (row - 1) * spacing + verticalMargins; + + return QSize(width, height); +} + void GameView::updateTotalSelectionCount(const QSize &viewSize) { - if (!SettingsCache::instance().getShowTotalSelectionCount()) { - totalCountLabel->hide(); - return; - } + constexpr int kMarginInPixels = 10; + constexpr int kSpacingBetweenLabels = 4; + + int availableWidth = viewSize.isValid() ? viewSize.width() : viewport()->width(); + int availableHeight = viewSize.isValid() ? viewSize.height() : viewport()->height(); int count = scene()->selectedItems().count(); - if (count > 1) { + if (!SettingsCache::instance().getShowTotalSelectionCount() || count <= 1) { + totalCountLabel->hide(); + } else { totalCountLabel->setText(QString::number(count)); totalCountLabel->adjustSize(); - constexpr int kMarginInPixels = 10; - int availableWidth = viewSize.isValid() ? viewSize.width() : viewport()->width(); - int availableHeight = viewSize.isValid() ? viewSize.height() : viewport()->height(); int x = availableWidth - totalCountLabel->width() - kMarginInPixels; int y = availableHeight - totalCountLabel->height() - kMarginInPixels; totalCountLabel->move(x, y); totalCountLabel->show(); - } else { - totalCountLabel->hide(); } + + if (!SettingsCache::instance().getShowSubtypeSelectionTally() || count <= 1) { + subtypeTallyContainer->hide(); + cachedSubtypeEntries.clear(); + return; + } + + GameScene *gameScene = static_cast(scene()); + QList entries = SelectionSubtypeTally::countSubtypes(gameScene->selectedCards()); + + if (entries.isEmpty()) { + subtypeTallyContainer->hide(); + cachedSubtypeEntries.clear(); + return; + } + + // Only rebuild labels if entries changed + QSize containerSize; + if (entries != cachedSubtypeEntries) { + cachedSubtypeEntries = entries; + containerSize = rebuildSubtypeLabels(entries); + subtypeTallyContainer->resize(containerSize); + } else { + containerSize = subtypeTallyContainer->size(); + } + + int x = availableWidth - containerSize.width() - kMarginInPixels; + int y; + + if (totalCountLabel->isVisible()) { + y = totalCountLabel->y() - containerSize.height() - kSpacingBetweenLabels; + } else { + y = availableHeight - containerSize.height() - kMarginInPixels; + } + + y = qMax(kMarginInPixels, y); + + subtypeTallyContainer->move(x, y); + subtypeTallyContainer->show(); } /** diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index 80e8e96b5..4047c87ab 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -7,9 +7,12 @@ #ifndef GAMEVIEW_H #define GAMEVIEW_H +#include "../game/selection_subtype_tally.h" + #include class GameScene; +class QGridLayout; class QLabel; class QRubberBand; @@ -21,7 +24,13 @@ private: QRubberBand *rubberBand; QLabel *dragCountLabel; QLabel *totalCountLabel; + QWidget *subtypeTallyContainer; + QGridLayout *subtypeTallyLayout; QPointF selectionOrigin; + QList cachedSubtypeEntries; ///< Cached entries to avoid redundant rebuilds + + QSize rebuildSubtypeLabels(const QList &entries); + void clearSubtypeLabels(); protected: void resizeEvent(QResizeEvent *event) override; diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 086845fe6..4ba35a00e 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -271,6 +271,9 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, const PaletteConfig &palCfg, const QString &activeScheme) { +#if (QT_VERSION < QT_VERSION_CHECK(6, 5, 0)) + Q_UNUSED(activeScheme) +#endif QString styleName = themeCfg.styleName; if (styleName.isEmpty() || styleName.compare("Default", Qt::CaseInsensitive) == 0) { if (themeName == FUSION_THEME_NAME) { diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 6039e3758..44b30d29c 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -68,6 +68,10 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&showTotalSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), &SettingsCache::setShowTotalSelectionCount); + showSubtypeSelectionTallyCheckBox.setChecked(SettingsCache::instance().getShowSubtypeSelectionTally()); + connect(&showSubtypeSelectionTallyCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), + &SettingsCache::setShowSubtypeSelectionTally); + useTearOffMenusCheckBox.setChecked(SettingsCache::instance().getUseTearOffMenus()); connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), [](const QT_STATE_CHANGED_T state) { SettingsCache::instance().setUseTearOffMenus(state == Qt::Checked); }); @@ -86,8 +90,9 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() generalGrid->addWidget(&annotateTokensCheckBox, 6, 0); generalGrid->addWidget(&showDragSelectionCountCheckBox, 7, 0); generalGrid->addWidget(&showTotalSelectionCountCheckBox, 8, 0); - generalGrid->addWidget(&useTearOffMenusCheckBox, 9, 0); - generalGrid->addWidget(&keepGameChatFocusCheckBox, 10, 0); + generalGrid->addWidget(&showSubtypeSelectionTallyCheckBox, 9, 0); + generalGrid->addWidget(&useTearOffMenusCheckBox, 10, 0); + generalGrid->addWidget(&keepGameChatFocusCheckBox, 11, 0); generalGroupBox = new QGroupBox; generalGroupBox->setLayout(generalGrid); @@ -209,8 +214,9 @@ void UserInterfaceSettingsPage::retranslateUi() closeEmptyCardViewCheckBox.setText(tr("Close card view window when last card is removed")); focusCardViewSearchBarCheckBox.setText(tr("Auto focus search bar when card view window is opened")); annotateTokensCheckBox.setText(tr("Annotate card text on tokens")); - showDragSelectionCountCheckBox.setText(tr("Show selection counter during drag selection")); - showTotalSelectionCountCheckBox.setText(tr("Show total selection counter")); + showDragSelectionCountCheckBox.setText(tr("Show selection count during drag selection")); + showTotalSelectionCountCheckBox.setText(tr("Show total selection count")); + showSubtypeSelectionTallyCheckBox.setText(tr("Show subtype breakdown in selection tally")); useTearOffMenusCheckBox.setText(tr("Use tear-off menus, allowing right click menus to persist on screen")); keepGameChatFocusCheckBox.setText( tr("Keep game chat focused when clicking in game (Note: disables card view search bar)")); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index e10ed2a06..06f0e6b83 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h @@ -29,6 +29,7 @@ private: QCheckBox annotateTokensCheckBox; QCheckBox showDragSelectionCountCheckBox; QCheckBox showTotalSelectionCountCheckBox; + QCheckBox showSubtypeSelectionTallyCheckBox; QCheckBox useTearOffMenusCheckBox; QCheckBox keepGameChatFocusCheckBox; QCheckBox tapAnimationCheckBox; diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 334e56027..8e5212031 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -1,5 +1,6 @@ #ifndef COCKATRICE_QT_UTILS_H #define COCKATRICE_QT_UTILS_H +#include #include namespace QtUtils