From c5b776ca46792b4d9fbcc1c17362958e4e604705 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:31:12 +0100 Subject: [PATCH 001/104] Translate oracle/oracle_en@source.ts in de (#7767) 100% translated source file: 'oracle/oracle_en@source.ts' on 'de'. Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- oracle/translations/oracle_de.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/oracle/translations/oracle_de.ts b/oracle/translations/oracle_de.ts index faf08d5ce..50a5f8e20 100644 --- a/oracle/translations/oracle_de.ts +++ b/oracle/translations/oracle_de.ts @@ -137,22 +137,22 @@ Downloading (0 MB) - + Herunterladen (0 MB) Downloading (%1 MB) - + Herunterladen (%1 MB) Parsing file (%1%) - + Datei wird verarbeitet (%1%) Extracting file... - + Extrahiere Datei... @@ -168,7 +168,7 @@ Parsing file (100%) - + Datei wird verarbeitet (100%) @@ -348,7 +348,7 @@ A Cockatrice card database file of %1 MB has been downloaded. - + Eine Cockatrice-Kartendatenbankdatei der Größe %1 MB wurde heruntergeladen. @@ -434,7 +434,7 @@ Downloading (0 MB) Downloading (0MB) - + Herunterladen (0 MB) From 381dd21e8c5441794a2df3f272037ff96641ffb8 Mon Sep 17 00:00:00 2001 From: PILIX123 <89544445+PILIX123@users.noreply.github.com> Date: Tue, 3 Nov 2026 04:33:21 -0500 Subject: [PATCH 002/104] [Client] Add option to load decks from ManaBox (#7642) * [Client] Add option to load decks from ManaBox * [Client] Fix PR comments * [Client] Fix UI and transaltion requests * [Client] Fix for UI and parser not doing what expected when loading maybe board * Add ManaBox support to docs As pointed out in review comments * [Client] Fix final PR comments --------- Co-authored-by: tooomm --- .../parsers/deck_link_to_api_transformer.cpp | 15 ++- .../parsers/deck_link_to_api_transformer.h | 1 + .../parsers/interface_json_deck_parser.h | 106 +++++++++++++++++- .../dialogs/dlg_load_deck_from_website.cpp | 16 ++- .../deck_management/importing_decks.md | 2 +- 5 files changed, 126 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp index 7127b0a29..2b307735d 100644 --- a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp +++ b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp @@ -15,14 +15,18 @@ static const QString ARCHIDEKT_SUFFIX = "/?format=json"; static const QString MOXFIELD_BASE = "https://api.moxfield.com/v2/decks/all/"; static const QString MOXFIELD_SUFFIX = "/"; +static const QString DECKSTATS_BASE = "https://deckstats.net/decks/"; static const QString DECKSTATS_SUFFIX = "?include_comments=1&export_mtgarena=1"; +static const QString MANABOX_BASE = "https://cloud.manabox.app/decks/"; + bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo) { static QRegularExpression rxTappedOut("tappedout\\.net/(?:mtg-decks/)?([^/?#]+)"); static QRegularExpression rxArchidekt("archidekt\\.com/decks/(\\d+)"); static QRegularExpression rxMoxfield("moxfield\\.com/decks/([a-zA-Z0-9_-]+)"); static QRegularExpression rxDeckstats("deckstats\\.net/decks/(\\d+/[a-zA-Z0-9_-]+)"); + static QRegularExpression rxManabox("manabox\\.app/decks/([a-zA-Z0-9_-]+)"); QRegularExpressionMatch match; @@ -49,11 +53,18 @@ bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo) return true; } else if ((match = rxDeckstats.match(url)).hasMatch()) { QString deckPath = match.captured(1); - outInfo = ParsedDeckInfo{.baseUrl = "https://deckstats.net/decks/", + outInfo = ParsedDeckInfo{.baseUrl = DECKSTATS_BASE, .deckID = deckPath, - .fullUrl = "https://deckstats.net/decks/" + deckPath + DECKSTATS_SUFFIX, + .fullUrl = DECKSTATS_BASE + deckPath + DECKSTATS_SUFFIX, .provider = DeckProvider::Deckstats}; return true; + } else if ((match = rxManabox.match(url)).hasMatch()) { + QString deckPath = match.captured(1); + outInfo = ParsedDeckInfo{.baseUrl = MANABOX_BASE, + .deckID = deckPath, + .fullUrl = MANABOX_BASE + deckPath, + .provider = DeckProvider::Manabox}; + return true; } return false; diff --git a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h index bbdd70c97..d3bff976f 100644 --- a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h +++ b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h @@ -15,6 +15,7 @@ enum class DeckProvider Archidekt, Moxfield, Deckstats, + Manabox, Unknown }; diff --git a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h index 87fde4d54..8f1defb7a 100644 --- a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h +++ b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h @@ -8,12 +8,19 @@ #define INTERFACE_JSON_DECK_PARSER_H #include "../../../interface/deck_loader/card_node_function.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" #include #include +#include +#include +#include +#include #include #include +inline Q_LOGGING_CATEGORY(InterfaceJsonDeckParserLog, "interface_json_deck_parser"); + class IJsonDeckParser { public: @@ -32,9 +39,6 @@ public: QString deckName = obj.value("name").toString(); QString deckDescription = obj.value("description").toString(); - deckList.setName(deckName); - deckList.setComments(deckDescription); - QString outputText; QTextStream outStream(&outputText); @@ -52,6 +56,8 @@ public: deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); + deckList.setName(deckName); + deckList.setComments(deckDescription); return deckList; } @@ -67,9 +73,6 @@ public: QString deckName = obj.value("name").toString(); QString deckDescription = obj.value("description").toString(); - deckList.setName(deckName); - deckList.setComments(deckDescription); - QString outputText; QTextStream outStream(&outputText); @@ -99,6 +102,8 @@ public: deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); + deckList.setName(deckName); + deckList.setComments(deckDescription); QJsonObject commandersObj = obj.value("commanders").toObject(); if (!commandersObj.isEmpty()) { @@ -118,4 +123,93 @@ public: } }; +class ManaboxJsonParser : public IJsonDeckParser +{ + enum BoardType : quint32 + { + MainBoard = 0, + SideBoard = 1, + Commander = 2, + MaybeBoard = 3, + }; + +public: + DeckList parse(const QJsonObject &obj) override + { + DeckList deckList; + + QString deckName = obj.value("name").toString(); + + QString outputText; + QString mainboardText; + QString sideboardText; + QTextStream mainboardStream(&mainboardText); + QTextStream sideboardStream(&sideboardText); + QTextStream outStream(&outputText); + + auto commanderList = QList(); + auto maybeboardList = QList(); + for (auto entry : obj.value("cards").toArray()) { + auto card = entry.toObject(); + + auto collectorNumber = card.value("collectorNumber").toString(); + auto cardName = card.value("name").toString(); + auto boardType = static_cast(card.value("boardCategory").toInt()); + auto setName = card.value("setId").toString(); + auto quantity = card.value("quantity").toInt(); + auto providerId = card.value("scryfallId").toString(); + + switch (boardType) { + case MainBoard: + mainboardStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber + << '\n'; + break; + case SideBoard: + sideboardStream << "SB: " << quantity << ' ' << cardName << " (" << setName << ") " + << collectorNumber << '\n'; + break; + case MaybeBoard: + maybeboardList.append(card); + break; + case Commander: + commanderList.append(card); + break; + default: + qCWarning(InterfaceJsonDeckParserLog) + << "Unknown ManaBox Zone" << card.value("boardCategory").toInt() << "for" << cardName; + break; + } + } + + outStream << mainboardText << sideboardText; + + deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.forEachCard(CardNodeFunction::ResolveProviderId()); + + for (const auto &maybeboardCard : maybeboardList) { + QString cardName = maybeboardCard.value("name").toString(); + QString setName = maybeboardCard.value("setId").toString().toUpper(); + QString collectorNumber = maybeboardCard.value("collectorNumber").toString(); + QString providerId = maybeboardCard.value("scryfallId").toString(); + + for (int x = 0; x < maybeboardCard.value("quantity").toInt(); x++) { + deckList.addCard(cardName, DECK_ZONE_MAYBEBOARD, -1, setName, collectorNumber, providerId); + } + } + for (const auto &commanderCard : commanderList) { + + QString commanderName = commanderCard.value("name").toString(); + QString setName = commanderCard.value("setId").toString().toUpper(); + QString collectorNumber = commanderCard.value("collectorNumber").toString(); + QString providerId = commanderCard.value("scryfallId").toString(); + + deckList.setBannerCard({commanderName, providerId}); + deckList.addCard(commanderName, DECK_ZONE_MAIN, -1, setName, collectorNumber, providerId); + } + + return deckList; + } +}; + #endif // INTERFACE_JSON_DECK_PARSER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp index 1c1ada302..04b0db2e3 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,7 @@ class QWidget; +static const QStringList SUPPORTED_PROVIDERS = {"Archidekt", "Deckstats", "ManaBox", "Moxfield", "TappedOut"}; DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent) { nam = NetworkManagerFactory::create(this); @@ -41,6 +43,7 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent setLayout(layout); instructionLabel = new QLabel(this); + instructionLabel->setWordWrap(true); layout->addWidget(instructionLabel); urlEdit = new QLineEdit(this); @@ -64,8 +67,8 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent void DlgLoadDeckFromWebsite::retranslateUi() { - instructionLabel->setText(tr("Paste a link to a decklist site here to import it.\n(Archidekt, Deckstats, Moxfield, " - "and TappedOut are supported.)")); + instructionLabel->setText(tr("Paste a link to a decklist site here to import it.") + "\n" + + tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", "))); } bool DlgLoadDeckFromWebsite::testValidUrl() @@ -84,8 +87,8 @@ void DlgLoadDeckFromWebsite::accept() if (!jsonParser && info.provider != DeckProvider::Deckstats && info.provider != DeckProvider::TappedOut) { qCWarning(DlgLoadDeckFromWebsiteLog) << "No parser found for provider"; QMessageBox::warning(this, tr("Load Deck from Website"), - tr("No parser available for this deck provider.\n (Archidekt, Deckstats, Moxfield, " - "and TappedOut are supported.)")); + tr("No parser available for this deck provider.") + "\n" + + tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", "))); QDialog::reject(); return; } @@ -152,6 +155,7 @@ void DlgLoadDeckFromWebsite::accept() "https://archidekt.com/decks/9999999\n" "https://deckstats.net/decks/99999/9999999-your-deck-name/en\n" "https://moxfield.com/decks/XYZxx-XYZ99Yyy-xyzXzzz\n" + "https://manabox.app/decks/XYZxxa-YZ99Yyyx_yzXzzz\n" "https://tappedout.net/mtg-decks/your-deck-name/")); QDialog::reject(); } @@ -164,7 +168,9 @@ QSharedPointer DlgLoadDeckFromWebsite::createParserForProvider( return QSharedPointer(new ArchidektJsonParser()); case DeckProvider::Moxfield: return QSharedPointer(new MoxfieldJsonParser()); + case DeckProvider::Manabox: + return QSharedPointer(new ManaboxJsonParser()); default: return QSharedPointer(nullptr); } -} \ No newline at end of file +} diff --git a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md index 4aab7ad2c..fbfb8fe3c 100644 --- a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md +++ b/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md @@ -70,4 +70,4 @@ confirm. The action will automatically import the deck from the online service without any other required user action. -Currently supported services are Archidekt, Deckstats, Moxfield, and TappedOut. \ No newline at end of file +Currently supported services are Archidekt, Deckstats, ManaBox, Moxfield, and TappedOut. From bfb53c5defeff356081789ff60bb103fd8fca75e Mon Sep 17 00:00:00 2001 From: Jurriaan Date: Tue, 3 Nov 2026 10:35:58 +0100 Subject: [PATCH 003/104] [Client] Visual Deck Editor: Added more mouse click modifiers to add/decrement cards to specific zones (#7589) * Make visual database mouse click modifiers properly add/decrement different card zones * Use event->modifiers(), refactor * Removed QApplication include * Added visual database display documentation page * Minor fixes * Added documentation for the mouse interaction of the table view --- .../tab_deck_editor_visual.cpp | 21 ++++++++++------ .../deck_management/editing_decks.md | 7 +++--- .../visual_database_display.md | 25 +++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) create mode 100644 doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md 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 bf4e15aa6..84bf026f7 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 @@ -20,7 +20,6 @@ #include "tab_deck_editor_visual_tab_widget.h" #include -#include #include #include #include @@ -261,17 +260,25 @@ void TabDeckEditorVisual::processMainboardCardClick(const QMouseEvent *event, } } +/** @brief Get a zone name corresponding to keyboard modifiers. */ +static QString zoneForClickModifiers(const Qt::KeyboardModifiers &modifiers) +{ + if (modifiers.testFlag(Qt::ControlModifier)) { + return DECK_ZONE_SIDE; + } else if (modifiers.testFlag(Qt::ShiftModifier)) { + return DECK_ZONE_MAYBEBOARD; + } else { + return DECK_ZONE_MAIN; + } +} + /** @brief Handles clicks on cards in the database display. */ void TabDeckEditorVisual::processDatabaseCardClick(const QMouseEvent *event, const ExactCard &card) { if (event->button() == Qt::LeftButton) { - if (QApplication::keyboardModifiers() & Qt::ControlModifier) { - addCard(card, DECK_ZONE_SIDE); - } else { - addCard(card, DECK_ZONE_MAIN); - } + addCard(card, zoneForClickModifiers(event->modifiers())); } else if (event->button() == Qt::RightButton) { - decrementCard(card, DECK_ZONE_MAIN); + decrementCard(card, zoneForClickModifiers(event->modifiers())); } else if (event->button() == Qt::MiddleButton) { deckDockWidget->actRemoveCard(); } diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md index 37b613461..04833282d 100644 --- a/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md +++ b/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md @@ -1,6 +1,7 @@ @page editing_decks Editing Decks - - @subpage editing_decks_classic - - @subpage editing_decks_visual +- @subpage editing_decks_classic +- @subpage editing_decks_visual - - @subpage editing_decks_printings +- @subpage visual_database_display +- @subpage editing_decks_printings diff --git a/doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md b/doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md new file mode 100644 index 000000000..097848ab8 --- /dev/null +++ b/doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md @@ -0,0 +1,25 @@ +@page visual_database_display Visual Database Display + +# Purpose + +The VisualDatabaseDisplay presents all the cards from the enabled sets in the CardDatabase, providing a visual way to find and add cards to your DeckList. + +# Using the Visual Database Display + +The VisualDatabaseDisplay can be opened by itself using the Tabs section in the menu bar at the top of the screen, or by selecting the "Visual Database Display" tab in the visual deck editor. When opened as a part of the visual deck editor, the VisualDatabaseDisplay can be used to add and remove cards from your deck. See @ref editing_decks_visual for more information about modifying your deck. + +By default the cards are displayed as a grid of pictures. However, a table view using card names can be toggled by pressing the Visual/Table button at the top right of the widget. + +# Filtering cards + +The top section of the VisualDatabaseDisplay can be used to filter cards based on various card properties, as well as specifying in which way they should be sorted. Filters can be saved and reused at a later time by using the Save/Load button at the right of this panel. + +# Modifying the Deck list + +When the VisualDatabaseDisplay is opened as part of the visual deck editor, clicking on cards adds or removes them to and from your deck. + +In the picture view, left-clicking on a card will add that card to the main board of your deck, while right-clicking will remove that printing of the card from the main board, if it exists. When clicking cards, modifier keys can be used to add (or remove) the card to zones other than the main board. Holding a Ctrl key on Windows and Linux or a Command key on MacOS adds/removes cards to the sideboard, while the Shift keys add/remove cards to the maybeboard. + +In the table view, double-clicking on a card adds that card to the main board of your deck, while right-click opens the card menu. Holding the Ctrl key on Windows and Linux or the Command key on MacOS when double-clicking adds the card to the sideboard instead. + +Tokens are always added to the token zone, regardless of which view is open or which modifier key is pressed. From bc09ec6afe31cd0fcf7e19a4afaf4ceadc6b1f2c Mon Sep 17 00:00:00 2001 From: Jurriaan Date: Tue, 3 Nov 2026 10:37:17 +0100 Subject: [PATCH 004/104] [Client] Make DeckStateManager::decrementCard remove tokens from the token zone if the token is not in the given zone (#7588) * Fixed decrementCard for tokens not using the token zone * Removed dead function actDecrementCard Updated documentation * Fixed no-op, added documentation * Removed stray blank line * Allow finding card nodes even if no database entry exists for the given card * Added DeckStateManager tests * Added tokens on mainboard test * Added if checks and sorted sources Added if checks * Library include fix * Renamed zone to targetZone * Revert "Allow finding card nodes even if no database entry exists for the given card" This reverts commit 88578cd0d954f50907a19ea539c824e7e83dfa3d. * Combined loader_local_matching_test and deck_state_manager_test settings sources * Added settings sandbox, refactor * Format * Renamed return variable * Flattened if statement * Style fix --- .../deck_editor_deck_dock_widget.cpp | 12 -- .../deck_editor_deck_dock_widget.h | 1 - .../deck_editor/deck_state_manager.cpp | 22 +- .../widgets/deck_editor/deck_state_manager.h | 5 +- tests/CMakeLists.txt | 60 ++++-- tests/deck_state_manager_test.cpp | 190 ++++++++++++++++++ 6 files changed, 253 insertions(+), 37 deletions(-) create mode 100644 tests/deck_state_manager_test.cpp 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 37dd57459..626a1586f 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 @@ -762,18 +762,6 @@ void DeckEditorDeckDockWidget::actSwapSelection() update(); } -void DeckEditorDeckDockWidget::actDecrementCard(const ExactCard &card, QString zoneName) -{ - if (!card) { - return; - } - if (card.getInfo().getIsToken()) { - zoneName = DECK_ZONE_TOKENS; - } - - deckStateManager->decrementCard(card, zoneName); -} - void DeckEditorDeckDockWidget::actDecrementSelection() { auto selectedRows = getSelectedCardNodeSourceIndices(); 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 97ab8823e..fe03f02ae 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 @@ -70,7 +70,6 @@ public slots: void syncDisplayWidgetsToModel(); void actAddCard(const ExactCard &card, const QString &zoneName); void actIncrementSelection(); - void actDecrementCard(const ExactCard &card, QString zoneName); void actDecrementSelection(); void actSwapCard(const ExactCard &card, const QString &zoneName); void actSwapSelection(); 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 a7e231ecf..cbf9dd3c4 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -211,15 +211,15 @@ QModelIndex DeckStateManager::addCard(const ExactCard &card, const QString &zone return {}; } - QString zone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName; + QString targetZone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName; CardSetPtr set = card.getPrinting().getSet(); QString setName = set ? set->getCorrectedShortName() : ""; QString reason = - tr("Added (%1): %2 (%3) %4").arg(zone, card.getName(), setName, card.getPrinting().getProperty("num")); + tr("Added (%1): %2 (%3) %4").arg(targetZone, card.getName(), setName, card.getPrinting().getProperty("num")); - QModelIndex idx = modifyDeck(reason, [&card, &zone](auto model) { return model->addCard(card, zone); }); + QModelIndex idx = modifyDeck(reason, [&card, &targetZone](auto model) { return model->addCard(card, targetZone); }); if (idx.isValid()) { emit focusIndexChanged(idx, true); @@ -234,10 +234,22 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString return {}; } + QString targetZone = zoneName; + QString providerId = card.getPrinting().getUuid(); QString collectorNumber = card.getPrinting().getProperty("num"); - QModelIndex idx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber); + QModelIndex idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber); + + // Tokens are usually added to the token zone, but it is possible for imported/hand-edited + // decks to contain them in the main or sideboards. This is why we only try to remove the + // token from the token zone if it does not exist in the given zone, allowing us to still + // remove it from there. + if (!idx.isValid() && card.getInfo().getIsToken() && targetZone != DECK_ZONE_TOKENS) { + targetZone = DECK_ZONE_TOKENS; + idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber); + } + if (!idx.isValid()) { return {}; } @@ -249,7 +261,7 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString } // old index is no longer safe since rows could have been removed - QModelIndex newIdx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber); + QModelIndex newIdx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber); if (newIdx.isValid()) { emit focusIndexChanged(newIdx, true); } 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 54c59eb79..0612147eb 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -188,17 +188,18 @@ public: ///@} /** - * @brief Adds the given card to the given zone. + * @brief Adds the given card to the given zone. Tokens always go to DECK_ZONE_TOKENS, ignoring the given zone. * Saves the operation to history if successful. * * @param card The card to add - * @param zoneName The zone to add the card to + * @param zoneName The zone to add the card to if the card is not a token * @return The index of the added card */ QModelIndex addCard(const ExactCard &card, const QString &zoneName); /** * @brief Removes 1 copy of the given card from the given zone. + * If the card is a token and is not in the given zone, then it is removed from DECK_ZONE_TOKENS instead. * Saves the operation to history if successful. * * @param card The card to remove diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0bb9654a1..cccff48c3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -412,23 +412,6 @@ cockatrice_add_test( INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src ) -cockatrice_add_test( - NAME - loader_local_matching_test - SOURCES - ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp - ${VERSION_STRING_CPP} - loader_local_matching_test.cpp - LIBS - libcockatrice_network - libcockatrice_settings - INCLUDE_DIRS - ${CMAKE_SOURCE_DIR}/cockatrice/src -) cockatrice_add_test( NAME deck_hash_performance_test @@ -548,3 +531,46 @@ endif() if(WITH_ORACLE OR WITH_CLIENT) add_subdirectory(picture_loader_benchmark) endif() + +# loader_local_matching_test and deck_state_manager_test links libcockatrice_settings, +# which only exists when a client/UI-capable target is being built. +if(WITH_ORACLE OR WITH_CLIENT) + set(SETTINGS_SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${VERSION_STRING_CPP} + ) + set(SETTINGS_LIBS libcockatrice_network libcockatrice_settings) + set(SETTINGS_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src) + + cockatrice_add_test( + NAME + loader_local_matching_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ${SETTINGS_SOURCES} + loader_local_matching_test.cpp + LIBS + ${SETTINGS_LIBS} + INCLUDE_DIRS + ${SETTINGS_INCLUDE_DIRS} + ) + cockatrice_add_test( + NAME + deck_state_manager_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp + ${SETTINGS_SOURCES} + deck_state_manager_test.cpp + LIBS + libcockatrice_card + libcockatrice_deck_list + libcockatrice_models + libcockatrice_utility + ${SETTINGS_LIBS} + INCLUDE_DIRS + ${SETTINGS_INCLUDE_DIRS} + ) +endif() diff --git a/tests/deck_state_manager_test.cpp b/tests/deck_state_manager_test.cpp new file mode 100644 index 000000000..ddd17912c --- /dev/null +++ b/tests/deck_state_manager_test.cpp @@ -0,0 +1,190 @@ +/** @file deck_state_manager_test.cpp + * @brief Tests for DeckStateManager operations. + * @ingroup Tests + */ + +#include "libcockatrice/card/database/card_database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Assert that the DeckStateManager's deck list contains only the given zone, + * and that that zone only contains the given number of cards, all with the given card name. + * + * @param manager the DeckStateManager + * @param restrictToZones In which zones to look for the zone and cards. Empty means use the entire deck + * @param zone The only zone that should exist in the deck + * @param cardName The name of the only card that should exist in the deck + * @param cardNumber The number of cards that should be in the deck + */ +static void containsExactly(const DeckStateManager &manager, + const QSet &restrictToZones, + QString zone, + QString cardName, + int cardNumber) +{ + QList zones = manager.getDeckList().getZoneNodes(restrictToZones); + ASSERT_EQ(zones.size(), 1); + ASSERT_EQ(zones[0]->getName(), zone); + ASSERT_EQ(zones[0]->size(), 1); + const DecklistCardNode *card = dynamic_cast((*zones[0])[0]); + ASSERT_NE(card, nullptr); + ASSERT_EQ(card->getName(), cardName); + ASSERT_EQ(card->getNumber(), cardNumber); +} + +/** @brief Return CardInfo of a token with the given name. */ +static CardInfoPtr makeToken(QString name) +{ + return CardInfo::newInstance(name, "", true, {}, {}, {}, {}, {}); +} + +// ===================================================================================================================== +// addCard +// ===================================================================================================================== + +TEST(DeckStateManager, AddCards) +{ + DeckStateManager manager; + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1); + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2); +} + +TEST(DeckStateManager, AddCardZones) +{ + DeckStateManager manager; + manager.addCard(ExactCard(CardInfo::newInstance("Grizzly Bears")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE); + manager.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), DECK_ZONE_MAYBEBOARD); + + // Check if the cards got put into the correct zones. + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 3); + containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Grizzly Bears", 1); + containsExactly(manager, {DECK_ZONE_SIDE}, DECK_ZONE_SIDE, "Lightning Bolt", 1); + containsExactly(manager, {DECK_ZONE_MAYBEBOARD}, DECK_ZONE_MAYBEBOARD, "Swords to Plowshares", 1); +} + +TEST(DeckStateManager, AddTokens) +{ + DeckStateManager manager; + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAYBEBOARD); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS); + + // A token should always be put into the tokens zone. + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 4); +} + +// ===================================================================================================================== +// decrementCard +// ===================================================================================================================== + +TEST(DeckStateManager, DecrementCards) +{ + DeckStateManager manager; + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2); + + // Removing from the sideboard should do nothing, as there is nothing in the side board. + manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2); + + // Remove the first card. + manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1); + + // Removing the second card should also remove the zone. + manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0); +} + +TEST(DeckStateManager, DecrementTokens) +{ + DeckStateManager manager; + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS); + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 3); + + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS); + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 2); + + // Removing tokens from a given zone should remove them from the + // token zone if the given zone does not contain the token. + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE); + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 1); + + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0); +} + +TEST(DeckStateManager, DecrementTokenMainboard) +{ + DeckStateManager manager; + + // Add cards to the deck list directly so tokens are not + // automatically added to the token zone. + manager.getDeckListShared()->addCard("Beast", DECK_ZONE_MAIN); + manager.getDeckListShared()->addCard("Beast", DECK_ZONE_TOKENS); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 2); + containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Beast", 1); + containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1); + + // decrementCard uses the model to find the card, so we need to rebuild it. + manager.getModel()->rebuildTree(); + + // Tokens should be removed from the main zone first. + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes({DECK_ZONE_MAIN}).size(), 0); + containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1); + + // If the main zone is empty they can be removed from the token zone. + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0); +} +} // namespace + +int main(int argc, char **argv) +{ + // Redirect SettingsCache reads/writes (app-data location) away from the real user profile. + QStandardPaths::setTestModeEnabled(true); + QCoreApplication app(argc, argv); + QCoreApplication::setOrganizationName(QStringLiteral("CockatriceTest")); + QCoreApplication::setApplicationName( + QStringLiteral("deck-state-manager-test-%1").arg(QCoreApplication::applicationPid())); + + ::testing::InitGoogleTest(&argc, argv); + + // Add cards to the card database + auto *db = CardDatabaseManager::getInstance(); + db->addCard(CardInfo::newInstance("Lightning Bolt")); + db->addCard(CardInfo::newInstance("Grizzly Bears")); + db->addCard(CardInfo::newInstance("Swords to Plowshares")); + db->addCard(makeToken("Beast")); + + int result = RUN_ALL_TESTS(); + + // Clean up the settings directory in ~/.qttest if the tests ran. + if (!::testing::GTEST_FLAG(list_tests)) { + QDir(SettingsCache::instance().getDataPath()).removeRecursively(); + QDir(SettingsCache::instance().getCachePath()).removeRecursively(); + } + + return result; +} From ada8f399f1a18152c1264943fd284c93ea605fac Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 04:38:54 -0500 Subject: [PATCH 005/104] Log server identification and connection lifecycle (#7731) * Log remote server identification and connection lifecycle [skip ci] * Update libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp Co-authored-by: RickyRister <42636155+RickyRister@users.noreply.github.com> --------- Co-authored-by: BruebachL <44814898+BruebachL@users.noreply.github.com> Co-authored-by: RickyRister <42636155+RickyRister@users.noreply.github.com> --- .../network/client/remote/remote_client.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index faacdc9f7..df1413dd2 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -137,6 +137,7 @@ RemoteClient::~RemoteClient() void RemoteClient::slotSocketError(QAbstractSocket::SocketError /*error*/) { QString errorString = socket->errorString(); + qCWarning(RemoteClientLog) << "Server connection error:" << errorString; doDisconnectFromServer(); emit socketError(errorString); } @@ -146,6 +147,7 @@ void RemoteClient::slotWebSocketError(QAbstractSocket::SocketError /*error*/) QString errorString = websocket->errorString(); if (getStatus() != ClientStatus::StatusDisconnected) { + qCWarning(RemoteClientLog) << "Server connection error:" << errorString; doDisconnectFromServer(); emit socketError(errorString); } @@ -153,6 +155,7 @@ void RemoteClient::slotWebSocketError(QAbstractSocket::SocketError /*error*/) void RemoteClient::slotConnected() { + qCInfo(RemoteClientLog) << "Server transport connected"; timeRunning = lastDataReceived = 0; timer->start(); @@ -166,6 +169,12 @@ void RemoteClient::slotConnected() void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentification &event) { + // Identification arrives before login, including when protocol versions differ. + // Quote the server-supplied strings in the log and limit their size. + const QString serverName = QString::fromStdString(event.server_name()).left(256); + const QString serverVersion = QString::fromStdString(event.server_version()).left(256); + qCInfo(RemoteClientLog) << "Server identification:" << serverName + << "version" << serverVersion << "protocol version" << event.protocol_version(); if (event.protocol_version() != protocolVersion) { emit protocolVersionMismatch(protocolVersion, event.protocol_version()); setStatus(StatusDisconnecting); @@ -698,6 +707,8 @@ void RemoteClient::sendCommandContainer(const CommandContainer &cont) void RemoteClient::connectToHost(const QString &hostname, unsigned int port) { usingWebSocket = port == 443 || port == 80 || port == 4748 || port == 8080; + qCInfo(RemoteClientLog) << "Connecting to server" << hostname << "port" << port + << (usingWebSocket ? "using WebSocket" : "using TCP"); if (usingWebSocket) { QUrl url(QString("%1://%2:%3/servatrice").arg(port == 443 ? "wss" : "ws").arg(hostname).arg(port)); websocket->open(url); From dc4aa19d23fa24dbbd61a7522d9baaee4ce695dd Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 04:40:28 -0500 Subject: [PATCH 006/104] Import Moxfield signature spells into the deck [skip ci] (#7751) Co-authored-by: Codex --- .../parsers/interface_json_deck_parser.h | 26 ++++++ .../libcockatrice/deck_list/deck_list.cpp | 19 ++++- .../libcockatrice/deck_list/deck_list.h | 8 ++ tests/CMakeLists.txt | 13 +++ tests/moxfield_signature_spells_test.cpp | 84 +++++++++++++++++++ 5 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 tests/moxfield_signature_spells_test.cpp diff --git a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h index 8f1defb7a..7e15d472f 100644 --- a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h +++ b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h @@ -54,7 +54,11 @@ public: outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; } + outStream.flush(); + outStream.seek(0); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.setComments(deckDescription); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.setName(deckName); deckList.setComments(deckDescription); @@ -100,7 +104,11 @@ public: outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; } + outStream.flush(); + outStream.seek(0); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.setComments(deckDescription); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); deckList.setName(deckName); deckList.setComments(deckDescription); @@ -119,6 +127,24 @@ public: } } + // Moxfield stores Oathbreaker signature spells in their own board, + // separate from both mainboard and commanders. They are playable deck + // cards, but must not replace the Oathbreaker as the banner card. + const QJsonObject signatureSpells = obj.value("signatureSpells").toObject(); + for (auto it = signatureSpells.begin(); it != signatureSpells.end(); ++it) { + const QJsonObject entry = it.value().toObject(); + const QJsonObject card = entry.value("card").toObject(); + const QString name = card.value("name").toString(); + const int quantity = entry.value("quantity").toInt(); + if (name.isEmpty() || quantity <= 0) { + continue; + } + const QString set = card.value("set").toString().toUpper(); + const QString collectorNumber = card.value("cn").toString(); + const QString providerId = card.value("scryfall_id").toString(); + deckList.addCards(name, quantity, DECK_ZONE_MAIN, -1, set, collectorNumber, providerId); + } + return deckList; } }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 821b53dff..2cd5ef914 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -423,8 +423,23 @@ DecklistCardNode *DeckList::addCard(const QString &cardName, const QString &cardProviderId, bool formatLegal) { - auto node = - tree.addCard(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); + return addCards(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); +} + +DecklistCardNode *DeckList::addCards(const QString &cardName, + int amount, + const QString &zoneName, + int position, + const QString &cardSetName, + const QString &cardSetCollectorNumber, + const QString &cardProviderId, + bool formatLegal) +{ + if (amount <= 0) { + return nullptr; + } + auto node = tree.addCard(cardName, amount, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, + formatLegal); refreshDeckHash(); return node; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index 6796e9c33..9d0f20586 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -284,6 +284,14 @@ public: const QString &cardSetCollectorNumber = QString(), const QString &cardProviderId = QString(), const bool formatLegal = true); + DecklistCardNode *addCards(const QString &cardName, + int amount, + const QString &zoneName, + int position = -1, + const QString &cardSetName = QString(), + const QString &cardSetCollectorNumber = QString(), + const QString &cardProviderId = QString(), + const bool formatLegal = true); ///@} /** @name Deck identity */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cccff48c3..6acd225c2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -463,6 +463,19 @@ endif() # Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt. if(WITH_CLIENT) + cockatrice_add_test( + NAME + moxfield_signature_spells_test + SOURCES + ${VERSION_STRING_CPP} + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_loader/card_node_function.cpp + moxfield_signature_spells_test.cpp + LIBS + libcockatrice_card + libcockatrice_deck_list + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ) cockatrice_add_test( NAME single_instance_manager_test diff --git a/tests/moxfield_signature_spells_test.cpp b/tests/moxfield_signature_spells_test.cpp new file mode 100644 index 000000000..54346a934 --- /dev/null +++ b/tests/moxfield_signature_spells_test.cpp @@ -0,0 +1,84 @@ +#include "client/network/parsers/interface_json_deck_parser.h" + +#include +#include +#include +#include + +TEST(MoxfieldImport, SignatureSpellIsAddedToMainWithoutReplacingCommanderBanner) +{ + // Moxfield v2 board objects map IDs to entries with quantity and card. + const auto input = QJsonDocument::fromJson(R"json({ + "name": "Oathbreaker example", "description": "Notes retained", + "mainboard": {"land": {"quantity": 1, "card": {"name": "Island", "set": "abc", "cn": "1"}}}, + "sideboard": {"side": {"quantity": 1, "card": {"name": "Mountain", "set": "abc", "cn": "2"}}}, + "commanders": {"walker": {"quantity": 1, "card": {"name": "Jace", "set": "abc", "cn": "3", "scryfall_id": "walker-id"}}}, + "signatureSpells": {"spell": {"quantity": 1, "card": {"name": "Lightning Bolt", "set": "xyz", "cn": "4", "scryfall_id": "spell-id"}}} + })json") + .object(); + MoxfieldJsonParser parser; + DeckList deck = parser.parse(input); + int mainSpellCount = 0; + int sideSpellCount = 0; + int mainLands = 0; + int sideLands = 0; + deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) { + if (card->getName() == "Island" && zone->getName() == DECK_ZONE_MAIN) { + mainLands += card->getNumber(); + } + if (card->getName() == "Mountain" && zone->getName() == DECK_ZONE_SIDE) { + sideLands += card->getNumber(); + } + if (card->getName() != "Lightning Bolt") { + return; + } + if (zone->getName() == DECK_ZONE_MAIN) { + mainSpellCount += card->getNumber(); + EXPECT_EQ(card->getCardSetShortName(), "XYZ"); + EXPECT_EQ(card->getCardCollectorNumber(), "4"); + EXPECT_EQ(card->getCardProviderId(), "spell-id"); + } else if (zone->getName() == DECK_ZONE_SIDE) { + sideSpellCount += card->getNumber(); + } + }); + EXPECT_EQ(mainSpellCount, 1); + EXPECT_EQ(sideSpellCount, 0); + EXPECT_EQ(mainLands, 1); + EXPECT_EQ(sideLands, 1); + EXPECT_EQ(deck.getName(), "Oathbreaker example"); + EXPECT_EQ(deck.getComments(), "Notes retained"); + EXPECT_EQ(deck.getBannerCard().name, "Jace"); + EXPECT_EQ(deck.getBannerCard().providerId, "walker-id"); +} + +TEST(MoxfieldImport, QuantityAwareDeckInsertionRetainsCountAndPrinting) +{ + DeckList deck; + auto *card = deck.addCards("Island", 2, DECK_ZONE_MAIN, -1, "ABC", "4", "id"); + ASSERT_NE(card, nullptr); + EXPECT_EQ(card->getNumber(), 2); + EXPECT_EQ(card->getCardProviderId(), "id"); + EXPECT_EQ(deck.addCards("Island", 0, DECK_ZONE_MAIN), nullptr); +} + +TEST(OnlineImport, ArchidektPreservesNameDescriptionAndCards) +{ + const auto input = QJsonDocument::fromJson(R"json({ + "name": "Online deck", "description": "Deck notes", + "cards": [{"quantity": 2, "card": { + "oracleCard": {"name": "Island"}, "edition": {"editioncode": "abc"}, "collectorNumber": "1" + }}] + })json") + .object(); + ArchidektJsonParser parser; + const DeckList deck = parser.parse(input); + int count = 0; + deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) { + if (zone->getName() == DECK_ZONE_MAIN && card->getName() == "Island") { + count += card->getNumber(); + } + }); + EXPECT_EQ(count, 2); + EXPECT_EQ(deck.getName(), "Online deck"); + EXPECT_EQ(deck.getComments(), "Deck notes"); +} From fd2a99e8973f4d44858b39a8539f25f6fe841c7e Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 04:41:47 -0500 Subject: [PATCH 007/104] Respect nonempty Qt hints and explain discovery failures [skip ci] (#7733) --- CMakeLists.txt | 10 ++++++---- cmake/FindQtRuntime.cmake | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a25fe2c6..677ef0c12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,6 +71,8 @@ if(WIN32 OR USE_VCPKG) ) # Qt path set by user or env var if(QTDIR + OR QTDIR32 + OR QTDIR64 OR DEFINED ENV{QTDIR} OR DEFINED ENV{QTDIR32} OR DEFINED ENV{QTDIR64} @@ -236,13 +238,13 @@ else() set(_lib_suffix 32) endif() -if(DEFINED QTDIR${_lib_suffix}) +if(NOT "${QTDIR${_lib_suffix}}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "${QTDIR${_lib_suffix}}") -elseif(DEFINED QTDIR) +elseif(NOT "${QTDIR}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "${QTDIR}") -elseif(DEFINED ENV{QTDIR${_lib_suffix}}) +elseif(NOT "$ENV{QTDIR${_lib_suffix}}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR${_lib_suffix}}") -elseif(DEFINED ENV{QTDIR}) +elseif(NOT "$ENV{QTDIR}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR}") endif() diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 2d74aca4d..7b644276d 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -1,6 +1,6 @@ # Find a compatible Qt version # Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE -# Optional Input: QT6_DIR -- Hint as to where Qt6 lives on the system +# Optional Input: Qt6_DIR -- Directory containing Qt6Config.cmake # Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt6 # Output: SERVATRICE_QT_MODULES # Output: COCKATRICE_QT_MODULES @@ -57,7 +57,17 @@ find_package( QUIET HINTS ${Qt6_DIR} ) if(NOT Qt6_FOUND) - message(FATAL_ERROR "No suitable version of Qt was found") + set(_QT_DIAGNOSTIC "") + if(Qt6_NOT_FOUND_MESSAGE) + set(_QT_DIAGNOSTIC "\nQt package diagnostic: ${Qt6_NOT_FOUND_MESSAGE}") + endif() + message(FATAL_ERROR + "No suitable version of Qt was found. Qt 6.4.2 or newer with these components is required: " + "${REQUIRED_QT_COMPONENTS};Linguist.\n" + "Set QTDIR or CMAKE_PREFIX_PATH to the Qt installation prefix, or Qt6_DIR to the directory " + "containing Qt6Config.cmake." + "${_QT_DIAGNOSTIC}" + ) endif() set(COCKATRICE_QT_VERSION_NAME Qt6) From 9588e1960d01817997bf8ea39c74fcf51b666ad2 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 05:10:50 -0500 Subject: [PATCH 008/104] Preserve saved deck formats through database reload and view refresh (#7745) --- .../deck_editor_deck_dock_widget.cpp | 49 +++++++++++-------- .../deck_editor_deck_dock_widget.h | 1 + 2 files changed, 29 insertions(+), 21 deletions(-) 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 626a1586f..30fde1595 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 @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -187,10 +188,17 @@ void DeckEditorDeckDockWidget::createDeckDock() formatLabel = new QLabel(this); formatComboBox = new QComboBox(this); + formatComboBox->setObjectName("deckFormatComboBox"); formatComboBox->addItem(tr("Loading Database...")); formatComboBox->setEnabled(false); // Disable until loaded commanderBracketWidget = new CommanderBracketWidget(this); + connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + formatComboBox->setPlaceholderText(QString()); + formatComboBox->setToolTip(QString()); + deckStateManager->setFormat(index >= 0 ? formatComboBox->itemData(index).toString() : QString()); + commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); + }); commentsLabel = new QLabel(); commentsLabel->setObjectName("commentsLabel"); @@ -355,6 +363,9 @@ void DeckEditorDeckDockWidget::initializeFormats() { QStringList allFormats = CardDatabaseManager::query()->getAllFormatsWithCount().keys(); + // Rebuilding the list is a view update, not a user edit. In particular, + // clear() emits currentIndexChanged(-1), which must not clear the deck. + const QSignalBlocker blocker(formatComboBox); formatComboBox->clear(); // Remove "Loading Database..." formatComboBox->setEnabled(true); @@ -364,28 +375,25 @@ void DeckEditorDeckDockWidget::initializeFormats() formatComboBox->addItem(formatName, formatName); // store the raw key in itemData } - QString format = deckStateManager->getMetadata().gameFormat; - if (!format.isEmpty()) { - formatComboBox->setCurrentIndex(formatComboBox->findData(format)); - } else { - // Ensure no selection is visible initially - formatComboBox->setCurrentIndex(-1); - } - connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { - QString formatKey; - if (index >= 0) { - formatKey = formatComboBox->itemData(index).toString(); - deckStateManager->setFormat(formatKey); - } else { - deckStateManager->setFormat(""); // clear format if deselected - } - - commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); - }); + syncFormatSelection(); commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); } +void DeckEditorDeckDockWidget::syncFormatSelection() +{ + if (!formatComboBox->isEnabled()) { + return; + } + const QSignalBlocker blocker(formatComboBox); + const QString format = deckStateManager->getMetadata().gameFormat; + const int index = format.isEmpty() ? -1 : formatComboBox->findData(format); + const QString unavailable = index < 0 && !format.isEmpty() ? tr("Unavailable: %1").arg(format) : QString(); + formatComboBox->setPlaceholderText(unavailable); + formatComboBox->setToolTip(unavailable); + formatComboBox->setCurrentIndex(index); +} + ExactCard DeckEditorDeckDockWidget::getCurrentCard() { QModelIndex current = deckView->selectionModel()->currentIndex(); @@ -596,9 +604,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel() updatePlaymatLabel(); updateHash(); - formatComboBox->blockSignals(true); - formatComboBox->setCurrentIndex(formatComboBox->findData(deckStateManager->getMetadata().gameFormat)); - formatComboBox->blockSignals(false); + syncFormatSelection(); commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); @@ -1042,6 +1048,7 @@ void DeckEditorDeckDockWidget::retranslateUi() commentsLabel->setText(tr("&Comments:")); activeGroupCriteriaLabel->setText(tr("Group by:")); formatLabel->setText(tr("Format:")); + syncFormatSelection(); commanderBracketWidget->retranslateUi(); hashLabel1->setText(tr("Hash:")); 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 fe03f02ae..02910e390 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 @@ -110,6 +110,7 @@ private: DeckListModel *getModel() const; [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); + void syncFormatSelection(); void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName); void addChangeBoardMenu(QMenu *menu, const QString &zoneName); From c72192fd8105d24b21e3b089053418a145b74f71 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Tue, 3 Nov 2026 15:59:32 +0100 Subject: [PATCH 009/104] [Format] Small fixup (#7768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Format] Small fixup. * [CardDB] Stop leaking the loader's mutexes. Both mutexes were heap-allocated through default member initializers and never freed, so every CardDatabase destruction leaked 16 bytes. LeakSanitizer flags this while discovering tests that create the database in main. Hold them by value instead, as the loader is a QObject and therefore neither copyable nor movable. --------- Co-authored-by: Lukas Brübach --- .../libcockatrice/card/database/card_database_loader.cpp | 4 ++-- .../libcockatrice/card/database/card_database_loader.h | 4 ++-- .../libcockatrice/network/client/remote/remote_client.cpp | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp index 00b21f88e..7ca5ba433 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp @@ -83,7 +83,7 @@ LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path, CardDatabas auto startTime = QTime::currentTime(); LoadStatus tempLoadStatus = NotLoaded; if (!path.isEmpty()) { - QMutexLocker locker(loadFromFileMutex); + QMutexLocker locker(&loadFromFileMutex); tempLoadStatus = loadFromFile(path, data); } @@ -102,7 +102,7 @@ LoadStatus CardDatabaseLoader::loadCardDatabases(bool ignoreCache) LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache) { - QMutexLocker locker(reloadDatabaseMutex); + QMutexLocker locker(&reloadDatabaseMutex); if (!database) { qCWarning(CardDatabaseLoadingLog) << "Loader has no database pointer"; diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h index bfbaa0f51..72aeeff3e 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h @@ -177,8 +177,8 @@ private: ICardSetPriorityController *priorityController; /**< Controller for reconstructing set state. */ QList availableParsers; /**< List of available parsers for different formats. */ - QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */ - QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */ + QBasicMutex loadFromFileMutex; /**< Mutex for single-file loading. */ + QBasicMutex reloadDatabaseMutex; /**< Mutex for reloading entire database. */ }; #endif // COCKATRICE_CARD_DATABASE_LOADER_H diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index df1413dd2..fa75e356a 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -173,8 +173,8 @@ void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentifica // Quote the server-supplied strings in the log and limit their size. const QString serverName = QString::fromStdString(event.server_name()).left(256); const QString serverVersion = QString::fromStdString(event.server_version()).left(256); - qCInfo(RemoteClientLog) << "Server identification:" << serverName - << "version" << serverVersion << "protocol version" << event.protocol_version(); + qCInfo(RemoteClientLog) << "Server identification:" << serverName << "version" << serverVersion + << "protocol version" << event.protocol_version(); if (event.protocol_version() != protocolVersion) { emit protocolVersionMismatch(protocolVersion, event.protocol_version()); setStatus(StatusDisconnecting); From 7768416091197d197807cc335785ae1b23ab59f3 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 22:37:25 -0500 Subject: [PATCH 010/104] Place current deck-editor docks before restoring saved layouts [skip ci] (#7734) --- .../src/interface/widgets/tabs/tab_deck_editor.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp index 7ca179cea..e88b2aad6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp @@ -131,10 +131,16 @@ void TabDeckEditor::loadLayout() LayoutsSettings &layouts = SettingsCache::instance().layouts(); auto layoutState = layouts.getDeckEditorLayoutState(); - if (layoutState.isNull()) { - restartLayout(); - } else { - restoreState(layoutState); + // Start with every current dock placed. Saved layouts from older versions + // cannot describe docks introduced later; restoreState leaves those docks + // in their existing positions while restoring the saved docks, including + // their visibility and floating state. + restartLayout(); + if (!layoutState.isEmpty()) { + if (!restoreState(layoutState)) { + // A corrupt or incompatible state must not leave a partial layout. + restartLayout(); + } restoreGeometry(layouts.getDeckEditorGeometry()); } } From 94f7de42f202799bccaae9735698f0b3746b49ed Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 23:29:51 -0500 Subject: [PATCH 011/104] Fix user card games for offline users and stale requests (#7738) --- cockatrice/CMakeLists.txt | 2 + .../widgets/server/user/user_games_model.cpp | 149 +++++++++++ .../widgets/server/user/user_games_model.h | 63 +++++ .../widgets/server/user/user_info_popup.cpp | 131 +++++---- .../widgets/server/user/user_info_popup.h | 12 +- .../widgets/server/user/user_list_widget.cpp | 9 +- tests/CMakeLists.txt | 12 + tests/user_games_model_test.cpp | 248 ++++++++++++++++++ 8 files changed, 550 insertions(+), 76 deletions(-) create mode 100644 cockatrice/src/interface/widgets/server/user/user_games_model.cpp create mode 100644 cockatrice/src/interface/widgets/server/user/user_games_model.h create mode 100644 tests/user_games_model_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 642d998a0..ccf2dfcea 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -449,6 +449,8 @@ set(cockatrice_SOURCES src/interface/intents/intent_open_shared_deck.h src/interface/intents/url_parser.cpp src/interface/intents/url_parser.h + src/interface/widgets/server/user/user_games_model.cpp + src/interface/widgets/server/user/user_games_model.h src/interface/widgets/server/user/user_info_popup.cpp src/interface/widgets/server/user/user_info_popup.h ) diff --git a/cockatrice/src/interface/widgets/server/user/user_games_model.cpp b/cockatrice/src/interface/widgets/server/user/user_games_model.cpp new file mode 100644 index 000000000..b7c728c45 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_games_model.cpp @@ -0,0 +1,149 @@ +#include "user_games_model.h" + +#include +#include +#include +#include +#include +#include +#include + +UserGamesModel::UserGamesModel(AbstractClient *_client, int requestTimeoutMs, QObject *parent) + : QAbstractListModel(parent), client(_client) +{ + timeout.setSingleShot(true); + timeout.setInterval(requestTimeoutMs); + connect(&timeout, &QTimer::timeout, this, [this] { + invalidateRequest(); + setState(State::Error); + }); + connected = client->getStatus() == StatusLoggedIn; + connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) { + // AbstractClient::setStatus emits this signal while holding the (non-recursive) + // status mutex, which getStatus() would try to lock again. Track the emitted + // value here, and read this cache from canRefresh() for the same reason. + connected = status == StatusLoggedIn; + if (!connected) { + invalidateRequest(); + replaceGames({}); + setState(online ? State::Disconnected : State::Offline); + } else if (online) { + refresh(); + } + }); + connect(client, &QObject::destroyed, this, [this] { + client = nullptr; + connected = false; + invalidateRequest(); + replaceGames({}); + setState(online ? State::Disconnected : State::Offline); + }); +} + +int UserGamesModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : games.size(); +} + +QVariant UserGamesModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.model() != this || index.column() != 0 || index.row() < 0 || + index.row() >= games.size() || role != GameRole) { + return {}; + } + return QVariant::fromValue(games.at(index.row())); +} + +void UserGamesModel::setUser(const QString &name, bool isOnline) +{ + userName = name; + online = isOnline; + refresh(); +} + +UserGamesModel::State UserGamesModel::getState() const +{ + return state; +} + +bool UserGamesModel::canRefresh() const +{ + return online && !userName.isEmpty() && client && connected && state != State::Loading; +} + +void UserGamesModel::invalidateRequest() +{ + ++requestGeneration; + // The client owns pending commands. Detach our interest without deleting + // an object that may already have moved to the client's thread. + disconnect(responseConnection); + responseConnection = {}; + timeout.stop(); +} + +void UserGamesModel::replaceGames(QList replacement) +{ + beginResetModel(); + games = std::move(replacement); + endResetModel(); +} + +void UserGamesModel::setState(State newState) +{ + state = newState; + emit stateChanged(); +} + +void UserGamesModel::refresh() +{ + invalidateRequest(); + const quint64 request = requestGeneration; + replaceGames({}); + if (!online || userName.isEmpty()) { + setState(State::Offline); + return; + } + if (!client || !connected) { + setState(State::Disconnected); + return; + } + + setState(State::Loading); + // State listeners may select another user or disconnect the client. + if (request != requestGeneration) { + return; + } + + Command_GetGamesOfUser command; + command.set_user_name(userName.toStdString()); + PendingCommand *pending = client->prepareSessionCommand(command); + responseConnection = connect(pending, &PendingCommand::finished, this, + [this, request](const Response &response) { finishRequest(response, request); }); + timeout.start(); + client->sendCommand(pending); +} + +void UserGamesModel::finishRequest(const Response &response, quint64 request) +{ + if (request != requestGeneration) { + return; + } + invalidateRequest(); + const quint64 completion = requestGeneration; + if (response.response_code() != Response::RespOk || !response.HasExtension(Response_GetGamesOfUser::ext)) { + setState(State::Error); + return; + } + + const auto &result = response.GetExtension(Response_GetGamesOfUser::ext); + QList replacement; + replacement.reserve(result.game_list_size()); + for (const auto &game : result.game_list()) { + replacement.append(game); + } + replaceGames(std::move(replacement)); + if (completion != requestGeneration) { + return; + } + setState(games.isEmpty() ? State::Empty : State::Ready); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_games_model.h b/cockatrice/src/interface/widgets/server/user/user_games_model.h new file mode 100644 index 000000000..b8ad71a67 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_games_model.h @@ -0,0 +1,63 @@ +#ifndef COCKATRICE_USER_GAMES_MODEL_H +#define COCKATRICE_USER_GAMES_MODEL_H + +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class Response; + +/** Current user's games and the lifetime of the request that supplies them. */ +class UserGamesModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum class State + { + Offline, + Disconnected, + Loading, + Ready, + Empty, + Error + }; + static constexpr int DefaultRequestTimeoutMs = 15000; + static constexpr int GameRole = Qt::UserRole + 10; + + explicit UserGamesModel(AbstractClient *client, + int requestTimeoutMs = DefaultRequestTimeoutMs, + QObject *parent = nullptr); + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + void setUser(const QString &name, bool isOnline); + void refresh(); + State getState() const; + bool canRefresh() const; + +signals: + void stateChanged(); + +private: + void invalidateRequest(); + void replaceGames(QList replacement); + void finishRequest(const Response &response, quint64 request); + void setState(State newState); + + QPointer client; + QString userName; + bool online = false; + bool connected = false; + State state = State::Offline; + QList games; + quint64 requestGeneration = 0; + QMetaObject::Connection responseConnection; + QTimer timeout; +}; + +#endif // COCKATRICE_USER_GAMES_MODEL_H 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 efb3b2e31..9f864d903 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -6,10 +6,9 @@ #include "../../interface/widgets/tabs/tab_supervisor.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/card/printing/exact_card.h" -#include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_game.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" -#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include "user_games_model.h" #include "user_level.h" #include "user_list_painter.h" @@ -34,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -41,8 +41,6 @@ #include #include #include -#include -#include #include #include #include @@ -52,8 +50,6 @@ #include #include #include -#include -#include #include #include #include @@ -427,6 +423,7 @@ void UserInfoPopup::buildUi() refreshBtn = new QPushButton(QStringLiteral("↻"), this); refreshBtn->setFixedSize(20, 20); refreshBtn->setFlat(true); + refreshBtn->setToolTip(tr("Refresh games")); connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); gh->addWidget(refreshBtn); root->addLayout(gh); @@ -437,7 +434,8 @@ void UserInfoPopup::buildUi() root->addWidget(gamesStatus); // Games list - gamesModel = new QStandardItemModel(this); + gamesModel = new UserGamesModel(client, UserGamesModel::DefaultRequestTimeoutMs, this); + connect(gamesModel, &UserGamesModel::stateChanged, this, &UserInfoPopup::updateGamesDisplay); gamesView = new QListView(this); gamesView->setModel(gamesModel); gamesView->setItemDelegate(new PopupGameDelegate(gamesView)); @@ -645,13 +643,21 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) { - rebuildActionButtons(userInfo, online, isBuddy, isIgnored); - adjustSize(); + currentUserInfo = userInfo; + currentBuddy = isBuddy; + currentIgnored = isIgnored; + if (currentOnline != online) { + setOnline(online); + } else { + refreshHeader(); + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + adjustSize(); + } } void UserInfoPopup::onGamesContextMenu(const QPoint &pos) { - const QModelIndex idx = gamesView->indexAt(pos); + const QPersistentModelIndex idx = gamesView->indexAt(pos); if (!idx.isValid()) { return; } @@ -679,7 +685,7 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos) } const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos)); - if (!chosen) { + if (!chosen || !idx.isValid()) { return; } @@ -716,6 +722,8 @@ void UserInfoPopup::showForUser(const QString &userName, currentUser = userName; currentUserInfo = userInfo; currentOnline = online; + currentBuddy = isBuddy; + currentIgnored = isIgnored; // Header refreshHeader(); @@ -723,88 +731,71 @@ void UserInfoPopup::showForUser(const QString &userName, // Actions rebuildActionButtons(userInfo, online, isBuddy, isIgnored); - // Games list reset - gamesModel->clear(); - gamesView->hide(); - gamesStatus->setText(tr("Loading games…")); - gamesStatus->show(); - // Close button, top right corner, above everything closeBtn->move(PopupWidth - closeBtn->width() - 6, 6); closeBtn->raise(); + gamesModel->setUser(currentUser, currentOnline); adjustSize(); - fetchGames(); } // ── Games fetch ─────────────────────────────────────────────────────────────── void UserInfoPopup::fetchGames() { - if (!client || currentUser.isEmpty()) { - return; - } - - Command_GetGamesOfUser cmd; - cmd.set_user_name(currentUser.toStdString()); - - const QString snapshot = currentUser; - PendingCommand *pend = client->prepareSessionCommand(cmd); - connect(pend, &PendingCommand::finished, this, - [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); - client->sendCommand(pend); + gamesModel->refresh(); } -void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) +void UserInfoPopup::setOnline(bool online) { - if (forUser != currentUser) { - return; // stale response, different user showing now - } - - gamesModel->clear(); - - if (r.response_code() != Response::RespOk) { - gamesStatus->setText(tr("Could not load games.")); - gamesStatus->show(); - gamesView->hide(); + if (currentOnline == online) { return; } + currentOnline = online; + refreshHeader(); + rebuildActionButtons(currentUserInfo, online, currentBuddy, currentIgnored); + gamesModel->setUser(currentUser, online); +} - const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); - if (resp.game_list_size() == 0) { - gamesStatus->setText(tr("No active games.")); - gamesStatus->show(); - gamesView->hide(); - return; +void UserInfoPopup::updateGamesDisplay() +{ + refreshBtn->setEnabled(gamesModel->canRefresh()); + const auto state = gamesModel->getState(); + const bool ready = state == UserGamesModel::State::Ready; + gamesView->setVisible(ready); + gamesStatus->setVisible(!ready); + + switch (state) { + case UserGamesModel::State::Offline: + gamesStatus->setText(tr("User is offline.")); + break; + case UserGamesModel::State::Disconnected: + gamesStatus->setText(tr("Not connected to the server.")); + break; + case UserGamesModel::State::Loading: + gamesStatus->setText(tr("Loading games…")); + break; + case UserGamesModel::State::Empty: + gamesStatus->setText(tr("No active games.")); + break; + case UserGamesModel::State::Error: + gamesStatus->setText(tr("Could not load games.")); + break; + case UserGamesModel::State::Ready: { + // Fit exactly to the number of visible rows, scroll when more than 5. + constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint + constexpr int maxRows = 5; + const int count = gamesModel->rowCount(); + gamesView->setFixedHeight(qMin(count, maxRows) * rowH + 2); + gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); + break; + } } - - for (int i = 0; i < resp.game_list_size(); ++i) { - auto *item = new QStandardItem; - item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); - item->setEditable(false); - gamesModel->appendRow(item); - } - - gamesStatus->hide(); - gamesView->show(); - - // Fit exactly to the number of visible rows, scroll when more than 5 - constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint - constexpr int maxRows = 5; - const int count = gamesModel->rowCount(); - const int visible = qMin(count, maxRows); - gamesView->setFixedHeight(visible * rowH + 2); - gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); - adjustSize(); } void UserInfoPopup::refreshGames() { - gamesModel->clear(); - gamesView->hide(); - gamesStatus->setText(tr("Loading games…")); - gamesStatus->show(); fetchGames(); } 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 f2a91d025..1af4ad0e1 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_USER_INFO_POPUP_H #define COCKATRICE_USER_INFO_POPUP_H +#include "user_games_model.h" #include "user_list_painter.h" #include @@ -21,15 +22,13 @@ class QPushButton; class TabSupervisor; class QListView; class QPoint; -class QStandardItemModel; -class Response; template class QMap; // ── Roles ───────────────────────────────────────────────────────────────────── namespace PopupRoles { -constexpr int GameData = Qt::UserRole + 10; +constexpr int GameData = UserGamesModel::GameRole; } // Popup theme @@ -143,6 +142,7 @@ public: void showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); void fetchGames(); + void setOnline(bool online); [[nodiscard]] QString getCurrentUser() const { @@ -199,7 +199,7 @@ protected: private slots: void refreshGames(); - void onGamesReceived(const Response &r, const QString &forUser); + void updateGamesDisplay(); void onGamesContextMenu(const QPoint &pos); private: @@ -218,6 +218,8 @@ private: QString currentUser; ServerInfo_User currentUserInfo; bool currentOnline = false; + bool currentBuddy = false; + bool currentIgnored = false; std::function gameInviteAvailable; UserInfoHeaderWidget *header; @@ -225,7 +227,7 @@ private: QLabel *gamesLabel; QFrame *separator; QListView *gamesView; - QStandardItemModel *gamesModel; + UserGamesModel *gamesModel; QLabel *gamesStatus; QPushButton *closeBtn; QPushButton *refreshBtn; 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 a20b39b82..3cb48c49e 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -617,7 +617,14 @@ void UserListWidget::bind(UserListManager *mgr) connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup); connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen); connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup); - connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen); + connect(manager, &UserListManager::userLeftOnline, this, [this, refreshIfPopupOpen](const QString &name) { + // The online row may already have been removed; the pinned popup must + // still invalidate its games request and display the offline state. + if (userInfoPopup && userInfoPopup->getCurrentUser() == name) { + userInfoPopup->setOnline(false); + } + refreshIfPopupOpen(name); + }); rebuild(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6acd225c2..4d7a02ea4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -178,6 +178,18 @@ cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test( + NAME + user_games_model_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/server/user/user_games_model.cpp + user_games_model_test.cpp + LIBS + libcockatrice_network_client_abstract + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ${CMAKE_SOURCE_DIR}/libcockatrice_network +) # The installer's holder probe is Windows-only, but the wording it produces and the # question which programs are the payload's own are plain C++ and are what a stuck user # has to act on, so they are tested everywhere. diff --git a/tests/user_games_model_test.cpp b/tests/user_games_model_test.cpp new file mode 100644 index 000000000..33c29d6d4 --- /dev/null +++ b/tests/user_games_model_test.cpp @@ -0,0 +1,248 @@ +#include "interface/widgets/server/user/user_games_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // required by the transitive server library; this suite sends no game commands + +namespace +{ +class GamesClient : public AbstractClient +{ +public: + QList sent; + + GamesClient() + { + setStatus(StatusLoggedIn); + } + + ~GamesClient() override + { + qDeleteAll(pendingCommands); + pendingCommands.clear(); + } + + void changeStatus(ClientStatus status) + { + setStatus(status); + } + + void answer(int request, Response response) + { + response.set_cmd_id(sent.at(request).cmd_id()); + ServerMessage message; + message.set_message_type(ServerMessage::RESPONSE); + *message.mutable_response() = response; + processProtocolItem(message); + } + +protected: + void sendCommandContainer(const CommandContainer &command) override + { + sent.append(command); + } +}; + +Response gamesResponse(std::initializer_list ids) +{ + Response response; + response.set_response_code(Response::RespOk); + auto *games = response.MutableExtension(Response_GetGamesOfUser::ext); + for (int id : ids) { + auto *game = games->add_game_list(); + game->set_game_id(id); + game->set_room_id(7); + } + return response; +} + +using State = UserGamesModel::State; + +TEST(UserGamesModel, OfflineUserDoesNotSendARequest) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", false); + model.refresh(); + EXPECT_TRUE(client.sent.isEmpty()); + EXPECT_EQ(State::Offline, model.getState()); + EXPECT_FALSE(model.canRefresh()); + EXPECT_EQ(0, model.rowCount()); +} + +TEST(UserGamesModel, PresenceLossInvalidatesTheOutstandingResponse) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + EXPECT_EQ(State::Loading, model.getState()); + EXPECT_FALSE(model.canRefresh()); + model.setUser("Alice", false); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Offline, model.getState()); + EXPECT_EQ(0, model.rowCount()); + + model.setUser("Alice", true); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); + EXPECT_TRUE(model.canRefresh()); + EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} + +TEST(UserGamesModel, SwitchingBackToTheSameUserDoesNotAcceptTheirOldRequest) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + model.setUser("Bob", true); + model.setUser("Alice", true); + client.answer(2, gamesResponse({3})); + client.answer(0, gamesResponse({1})); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(1, model.rowCount()); + EXPECT_EQ(3, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} + +TEST(UserGamesModel, RefreshSupersedesThePreviousRequest) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + model.refresh(); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Loading, model.getState()); + EXPECT_EQ(0, model.rowCount()); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); + EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} + +TEST(UserGamesModel, EmptySuccessAndMissingPayloadAreDifferentStates) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + client.answer(0, gamesResponse({})); + EXPECT_EQ(State::Empty, model.getState()); + EXPECT_TRUE(model.canRefresh()); + + model.refresh(); + Response missingPayload; + missingPayload.set_response_code(Response::RespOk); + client.answer(1, missingPayload); + EXPECT_EQ(State::Error, model.getState()); + EXPECT_TRUE(model.canRefresh()); +} + +TEST(UserGamesModel, FailedRequestsCanBeRetried) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + Response error; + error.set_response_code(Response::RespNameNotFound); + client.answer(0, error); + EXPECT_EQ(State::Error, model.getState()); + model.refresh(); + client.answer(1, gamesResponse({4})); + EXPECT_EQ(State::Ready, model.getState()); +} + +TEST(UserGamesModel, DisconnectRejectsLateRepliesAndReconnectFetchesAgain) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + client.changeStatus(StatusDisconnecting); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Disconnected, model.getState()); + EXPECT_FALSE(model.canRefresh()); + model.refresh(); + EXPECT_EQ(1, client.sent.size()); + client.changeStatus(StatusLoggedIn); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); +} + +TEST(UserGamesModel, ClientDestructionClearsGamesAndDisablesRefresh) +{ + auto client = std::make_unique(); + UserGamesModel model(client.get()); + model.setUser("Alice", true); + client->answer(0, gamesResponse({1})); + client.reset(); + EXPECT_EQ(State::Disconnected, model.getState()); + EXPECT_EQ(0, model.rowCount()); + EXPECT_FALSE(model.canRefresh()); + model.refresh(); + EXPECT_EQ(State::Disconnected, model.getState()); +} + +TEST(UserGamesModel, ResetInvalidatesAnOpenGameActionAndPreservesTheModelContract) +{ + GamesClient client; + UserGamesModel model(&client); + QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::Fatal); + model.setUser("Alice", true); + client.answer(0, gamesResponse({1, 2, 3})); + QPersistentModelIndex selected(model.index(1)); + EXPECT_TRUE(selected.isValid()); + model.refresh(); + EXPECT_FALSE(selected.isValid()); + EXPECT_EQ(0, model.rowCount()); + client.answer(1, gamesResponse({4})); + EXPECT_EQ(1, model.rowCount()); +} + +TEST(UserGamesModel, StateListenerCanSelectAnotherUserBeforeTheRequestIsSent) +{ + GamesClient client; + UserGamesModel model(&client); + bool switched = false; + QObject::connect(&model, &UserGamesModel::stateChanged, &model, [&] { + if (!switched && model.getState() == State::Loading) { + switched = true; + model.setUser("Bob", false); + } + }); + model.setUser("Alice", true); + EXPECT_TRUE(client.sent.isEmpty()); + EXPECT_EQ(State::Offline, model.getState()); +} + +TEST(UserGamesModel, TimedOutResponseCannotReplaceARetry) +{ + GamesClient client; + UserGamesModel model(&client, 50); + model.setUser("Alice", true); + QSignalSpy changes(&model, &UserGamesModel::stateChanged); + ASSERT_TRUE(changes.wait(1000)); + ASSERT_EQ(State::Error, model.getState()); + EXPECT_TRUE(model.canRefresh()); + model.refresh(); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Loading, model.getState()); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); + EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 624163916f6a7356653ceaafe35c37e624f4e696 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 23:49:31 -0500 Subject: [PATCH 012/104] [Client] Keep Commander Spellbook requests bound to the active deck (#7722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Keep Commander Spellbook requests bound to the active deck [skip ci] * [Client] Restore the bracket info tooltip when idle retranslateUi() used to install the static "Why this bracket?" tooltip, but it now calls displayEstimate(), which returned early while no estimate existed. That left the info button without its idle tooltip. Set the fallback where displayEstimate() owns the button's tooltip. An existing estimate still wins, and a language change refreshes the text. --------- Co-authored-by: Lukas Brübach --- .../commander_bracket_service.cpp | 5 + .../commander_bracket_service.h | 1 + .../commander_bracket_widget.cpp | 101 +++++++++++++++--- .../commander_bracket_widget.h | 8 ++ .../commander_spellbook_api_accessor.cpp | 49 ++++++--- .../commander_spellbook_api_accessor.h | 10 +- .../handle_commander_brackets.cpp | 49 +++++++-- .../handle_commander_brackets.h | 4 +- 8 files changed, 182 insertions(+), 45 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp index 8e0448faa..7b60ed23c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp @@ -26,6 +26,11 @@ quint64 CommanderBracketService::estimateBracket(const DeckList &deck, QObject * return CommanderSpellbookApiAccessor::instance().estimateBracket(deck, requester); } +void CommanderBracketService::cancelEstimate(quint64 requestId) +{ + CommanderSpellbookApiAccessor::instance().cancelEstimate(requestId); +} + void CommanderBracketService::onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id, QObject *requester, const EstimateBracketResult &result) diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h index 22962cc95..00debce86 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h @@ -30,6 +30,7 @@ public: static CommanderBracketService &instance(); quint64 estimateBracket(const DeckList &deck, QObject *requester); + void cancelEstimate(quint64 requestId); signals: void estimateFinished(quint64 requestId, QObject *requester, const CommanderBracketEstimate &estimate); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 5279165af..9adda008e 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -28,9 +28,18 @@ template class QSharedPointer; CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent) { + autoEstimateTimer = new QTimer(this); + autoEstimateTimer->setSingleShot(true); + connect(autoEstimateTimer, &QTimer::timeout, this, [this]() { + if (SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled() == + commanderSpellbookIntegrationEnabledIndexAutomatic) { + requestBracketEstimate(); + } + }); bracketLabel = new QLabel(tr("Bracket:"), this); bracketValueLabel = new QLabel(this); + bracketValueLabel->setTextFormat(Qt::PlainText); bracketValueLabel->setText("-"); bracketValueLabel->setObjectName("bracketValueLabel"); @@ -65,18 +74,38 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent this, &CommanderBracketWidget::maybeAutoEstimateBracket); connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::commanderSpellbookIntegrationUseOfficialBracketNamesChanged, this, - &CommanderBracketWidget::maybeAutoEstimateBracket); + &CommanderBracketWidget::displayEstimate); setVisible(false); } +CommanderBracketWidget::~CommanderBracketWidget() +{ + cancelEstimate(); +} + +void CommanderBracketWidget::cancelEstimate() +{ + autoEstimateTimer->stop(); + const quint64 canceledRequest = requestId; + requestId = 0; + CommanderBracketService::instance().cancelEstimate(canceledRequest); + bracketRefreshButton->setEnabled(true); + if (canceledRequest) { + bracketValueLabel->setText("-"); + bracketValueLabel->setToolTip({}); + } +} + void CommanderBracketWidget::setDeck(const QSharedPointer &_deck) { + cancelEstimate(); deck = _deck; - requestId = 0; // invalidate any in-flight estimate for the previous deck + hasEstimate = false; // Reset the displayed bracket bracketValueLabel->setText("-"); + bracketValueLabel->setToolTip({}); bracketInfoButton->setToolTip({}); bracketInfoButton->setEnabled(false); bracketRefreshButton->setEnabled(true); @@ -144,7 +173,7 @@ bool CommanderBracketWidget::promptCommanderSpellbookIntegration() dialog.exec(); // Persist naming choice (if not disabled) - if (clickedButton != disableBtn) { + if (clickedButton == enableBtn || clickedButton == automaticBtn) { bool useOfficial = namingCombo->currentIndex() == 1; SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(useOfficial); } @@ -176,9 +205,20 @@ void CommanderBracketWidget::updateBracketVisibility(bool visible) void CommanderBracketWidget::requestBracketEstimate() { + const int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + if (!deck || deck->getGameFormat().compare("commander", Qt::CaseInsensitive) != 0 || requestId || + (mode != commanderSpellbookIntegrationEnabledIndexEnabled && + mode != commanderSpellbookIntegrationEnabledIndexAutomatic)) { + return; + } + + autoEstimateTimer->stop(); + hasEstimate = false; bracketRefreshButton->setEnabled(false); bracketInfoButton->setEnabled(false); + bracketInfoButton->setToolTip({}); bracketValueLabel->setText(tr("Calculating…")); + bracketValueLabel->setToolTip({}); requestId = CommanderBracketService::instance().estimateBracket(*deck, this); } @@ -191,22 +231,35 @@ void CommanderBracketWidget::onEstimateBracketFinished(quint64 id, return; } + requestId = 0; + hasEstimate = true; + lastEstimate = result; + displayEstimate(); + bracketRefreshButton->setEnabled(true); +} + +void CommanderBracketWidget::displayEstimate() +{ + if (!hasEstimate) { + bracketInfoButton->setToolTip(tr("Why this bracket?")); + return; + } + BracketExplainer explainer; - lastBracketExplanation = explainer.explain(result.rawResult); + lastBracketExplanation = explainer.explain(lastEstimate.rawResult); // Display bracket bracketValueLabel->setText( SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() - ? result.officialName - : result.displayName); - bracketRefreshButton->setEnabled(true); + ? lastEstimate.officialName + : lastEstimate.displayName); // Build tooltip QString tooltip; for (const auto §ion : lastBracketExplanation.sections) { - tooltip += "" + section.title + "
"; + tooltip += "" + section.title.toHtmlEscaped() + "
"; for (const auto &line : section.bulletPoints) { - tooltip += "• " + line + "
"; + tooltip += "• " + line.toHtmlEscaped() + "
"; } tooltip += "
"; } @@ -215,13 +268,17 @@ void CommanderBracketWidget::onEstimateBracketFinished(quint64 id, bracketInfoButton->setEnabled(!tooltip.isEmpty()); } -void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString & /*error*/) +void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString &error) { if (requester != this || id != requestId) { return; } + requestId = 0; + hasEstimate = false; bracketValueLabel->setText("-"); + bracketValueLabel->setToolTip( + QStringLiteral("%1").arg(tr("Unable to estimate bracket: %1").arg(error).toHtmlEscaped())); bracketRefreshButton->setEnabled(true); bracketInfoButton->setToolTip({}); bracketInfoButton->setEnabled(false); @@ -229,6 +286,15 @@ void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *request void CommanderBracketWidget::maybeAutoEstimateBracket() { + if (prompting) { + return; + } + autoEstimateTimer->stop(); + if (!deck) { + cancelEstimate(); + updateBracketVisibility(false); + return; + } const QString formatKey = deck->getGameFormat(); const bool isCommander = (formatKey.compare("commander", Qt::CaseInsensitive) == 0); @@ -236,17 +302,20 @@ void CommanderBracketWidget::maybeAutoEstimateBracket() int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); if (!isCommander || mode == commanderSpellbookIntegrationEnabledIndexDisabled) { + cancelEstimate(); updateBracketVisibility(false); return; } if (mode == commanderSpellbookIntegrationEnabledIndexUnprompted) { - if (prompting) { - return; - } + const auto promptedDeck = deck; prompting = true; const bool accepted = promptCommanderSpellbookIntegration(); prompting = false; + if (deck != promptedDeck || !deck || deck->getGameFormat().compare("commander", Qt::CaseInsensitive) != 0) { + maybeAutoEstimateBracket(); + return; + } if (!accepted) { updateBracketVisibility(false); return; @@ -261,17 +330,17 @@ void CommanderBracketWidget::maybeAutoEstimateBracket() } // Avoid firing if we already have a result or a request in flight - if (!bracketRefreshButton->isEnabled()) { + if (requestId || hasEstimate) { return; } // Defer to avoid races during init / model rebuild - QTimer::singleShot(0, this, &CommanderBracketWidget::requestBracketEstimate); + autoEstimateTimer->start(0); } void CommanderBracketWidget::retranslateUi() { bracketLabel->setText(tr("Bracket:")); - bracketInfoButton->setToolTip(tr("Why this bracket?")); + displayEstimate(); bracketRefreshButton->setToolTip(tr("Recalculate bracket")); } diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h index f5b2b7958..0ef1eedee 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_COMMANDER_BRACKET_WIDGET_H #define COCKATRICE_COMMANDER_BRACKET_WIDGET_H +#include "commander_bracket_service.h" #include "commander_spellbook_bracket_explainer.h" #include @@ -11,6 +12,7 @@ class QLabel; class QToolButton; +class QTimer; struct CommanderBracketEstimate; class DeckList; class QObject; @@ -22,6 +24,7 @@ class CommanderBracketWidget : public QWidget public: explicit CommanderBracketWidget(QWidget *parent = nullptr); + ~CommanderBracketWidget() override; void setDeck(const QSharedPointer &_deck); void retranslateUi(); @@ -35,10 +38,15 @@ private slots: private: bool promptCommanderSpellbookIntegration(); void updateBracketVisibility(bool visible); + void cancelEstimate(); + void displayEstimate(); QSharedPointer deck; bool prompting = false; quint64 requestId = 0; + QTimer *autoEstimateTimer; + bool hasEstimate = false; + CommanderBracketEstimate lastEstimate; QLabel *bracketLabel; QLabel *bracketValueLabel; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp index bd8d9379d..8456e2e21 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp @@ -9,9 +9,10 @@ #include #include #include +#include #include #include -#include +#include #include class DeckList; @@ -24,13 +25,18 @@ CommanderSpellbookApiAccessor &CommanderSpellbookApiAccessor::instance() return instance; } -CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) : QObject(parent) +CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) + : QObject(parent), network(NetworkManagerFactory::create(this)) { } CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimateBracket(const DeckList &deck, QObject *requester) { + if (!requester) { + return 0; + } + CommanderSpellbookDeckRequest deckRequest = CommanderSpellbookDeckRequest::fromDeckList(deck); QJsonDocument doc(deckRequest.toJson()); @@ -40,30 +46,39 @@ CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimate req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - QNetworkReply *reply = network.post(req, body); + QNetworkReply *reply = network->post(req, body); const RequestId id = nextRequestId++; - reply->setProperty("requestId", QVariant::fromValue(id)); - reply->setProperty("requester", QVariant::fromValue(requester)); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { onEstimateReplyFinished(reply); }); + pendingRequests.insert(id, reply); + const QPointer guardedRequester(requester); + connect(requester, &QObject::destroyed, reply, [this, id]() { cancelEstimate(id); }); + connect(reply, &QNetworkReply::finished, this, [this, reply, id, guardedRequester]() { + if (pendingRequests.take(id) != reply) { + return; + } + reply->deleteLater(); + if (guardedRequester) { + onEstimateReplyFinished(reply, id, guardedRequester); + } + }); return id; } -void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply) +void CommanderSpellbookApiAccessor::cancelEstimate(RequestId id) { - reply->deleteLater(); - - const RequestId id = reply->property("requestId").toULongLong(); - QObject *requester = reply->property("requester").value(); - - if (!requester) { - // Requester died — silently drop + const QPointer reply = pendingRequests.take(id); + if (!reply) { return; } + disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); +} +void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply, RequestId id, QObject *requester) +{ if (reply->error() != QNetworkReply::NoError) { emit estimateBracketError(id, requester, reply->errorString()); return; @@ -78,6 +93,10 @@ void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply } EstimateBracketResult result = EstimateBracketResult::fromJson(doc.object()); + if (result.bracketTag.trimmed().isEmpty()) { + emit estimateBracketError(id, requester, QStringLiteral("Missing bracket in response")); + return; + } emit estimateBracketFinished(id, requester, result); } diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h index 98e7e3710..a1b2936bb 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h @@ -1,14 +1,16 @@ #ifndef COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H #define COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H -#include +#include #include +#include #include #include #include class DeckList; class QNetworkReply; +class QNetworkAccessManager; struct EstimateBracketResult; class CommanderSpellbookApiAccessor final : public QObject @@ -21,6 +23,7 @@ public: using RequestId = quint64; RequestId estimateBracket(const DeckList &deck, QObject *requester); + void cancelEstimate(RequestId id); signals: void estimateBracketFinished(RequestId id, QObject *requester, const EstimateBracketResult &result); @@ -31,9 +34,10 @@ private: explicit CommanderSpellbookApiAccessor(QObject *parent = nullptr); Q_DISABLE_COPY_MOVE(CommanderSpellbookApiAccessor) - void onEstimateReplyFinished(QNetworkReply *reply); + void onEstimateReplyFinished(QNetworkReply *reply, RequestId id, QObject *requester); - QNetworkAccessManager network; + QNetworkAccessManager *network; + QHash> pendingRequests; RequestId nextRequestId = 1; }; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp index 407f53e99..be490ce37 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp @@ -3,11 +3,14 @@ #include "../../../../../client/settings/cache_settings.h" #include +#include #include +#include #include #include #include #include +#include #include #include #include @@ -22,6 +25,14 @@ HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent) { } +HandleCommanderBrackets::~HandleCommanderBrackets() +{ + if (reply) { + disconnect(reply, nullptr, this, nullptr); + reply->abort(); + } +} + void HandleCommanderBrackets::downloadBracketDefinitions() { if (reply) { @@ -35,31 +46,49 @@ void HandleCommanderBrackets::downloadBracketDefinitions() void HandleCommanderBrackets::actFinishParsingDownloadedData() { - reply = qobject_cast(sender()); + auto *finishedReply = qobject_cast(sender()); + if (!finishedReply || finishedReply != reply) { + return; + } - if (reply->error() != QNetworkReply::NoError) { - emit sigBracketDefinitionsDownloadFailed(reply->error()); + // Clear the active request before notifying listeners, which may immediately retry. + reply = nullptr; + finishedReply->deleteLater(); - reply->deleteLater(); + if (finishedReply->error() != QNetworkReply::NoError) { + emit sigBracketDefinitionsDownloadFailed(finishedReply->error()); return; } QJsonParseError parseError; - auto document = QJsonDocument::fromJson(reply->readAll(), &parseError); + auto document = QJsonDocument::fromJson(finishedReply->readAll(), &parseError); - if (parseError.error != QJsonParseError::NoError) { + if (parseError.error != QJsonParseError::NoError || !document.isObject() || + !document.object().value("brackets").isArray() || document.object().value("brackets").toArray().isEmpty()) { emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError); - - reply->deleteLater(); return; } + // Validate the complete replacement before touching the last usable definitions. + // Tags are QSettings group names, so path separators must not create nested groups. + QSet tags; + for (const auto &value : document.object().value("brackets").toArray()) { + const auto definition = value.toObject(); + const auto tag = definition.value("tag").toString(); + if (!value.isObject() || tag.trimmed().isEmpty() || tag.contains('/') || tag.contains('\\') || + tags.contains(tag) || definition.value("officialName").toString().trimmed().isEmpty() || + definition.value("displayName").toString().trimmed().isEmpty() || + !definition.value("explanation").isString()) { + emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError); + return; + } + tags.insert(tag); + } + updateBracketDefinitions(document.toVariant().toMap()); emit sigBracketDefinitionsDownloaded(); - - reply->deleteLater(); } void HandleCommanderBrackets::updateBracketDefinitions(const QVariantMap &jsonMap) diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h index 39bddba05..99b15832c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -14,6 +15,7 @@ class HandleCommanderBrackets : public QObject public: explicit HandleCommanderBrackets(QObject *parent = nullptr); + ~HandleCommanderBrackets() override; void downloadBracketDefinitions(); @@ -28,7 +30,7 @@ private: void updateBracketDefinitions(const QVariantMap &jsonMap); QNetworkAccessManager *nam; - QNetworkReply *reply; + QPointer reply; }; #endif // COCKATRICE_HANDLE_COMMANDER_BRACKETS_H From 81a0b9724025e05ef32b3afd7ade66d79a53b513 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:56:32 +0100 Subject: [PATCH 013/104] [Replay] Seek to the exact time of an event (#7610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timeline seeks only expose whole timer intervals, so jumping to an event could leave that event unprocessed until the next tick. Add an event-time seek that also applies events at the target timestamp. Co-authored-by: Lukas Brübach --- .../widgets/replay/replay_manager.cpp | 44 ++++++++++++++----- .../interface/widgets/replay/replay_manager.h | 9 ++-- .../widgets/replay/replay_widget.cpp | 11 +++++ .../interface/widgets/replay/replay_widget.h | 7 +++ 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp index 8ba0cb653..b01d7f9d6 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp @@ -57,7 +57,7 @@ ReplayManager::ReplayManager(QObject *parent, GameReplay *replay) rewindBufferingTimer = new QTimer(this); rewindBufferingTimer->setSingleShot(true); - connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayManager::processRewind); + connect(rewindBufferingTimer, &QTimer::timeout, this, [this] { processRewind(); }); } ReplayManager::~ReplayManager() @@ -65,7 +65,7 @@ ReplayManager::~ReplayManager() delete replay; } -void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) +void ReplayManager::skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime) { // check boundary conditions if (newTime < 0) { @@ -75,15 +75,17 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) newTime = maxTime; } - newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + if (!includeEventsAtTime) { + newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + } const bool isBackwardsSkip = newTime < currentProcessedTime; currentVisualTime = newTime; if (isBackwardsSkip) { - handleBackwardsSkip(doRewindBuffering); + handleBackwardsSkip(doRewindBuffering, includeEventsAtTime); } else { - processNewEvents(FORWARD_SKIP); + processNewEvents(FORWARD_SKIP, includeEventsAtTime); } timeChanged(currentVisualTime); @@ -95,7 +97,7 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) * @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind * is processed at the end. When false, the backwards skip will always cause an immediate rewind. */ -void ReplayManager::handleBackwardsSkip(bool doRewindBuffering) +void ReplayManager::handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime) { if (doRewindBuffering) { // We use a one-shot timer to implement the rewind buffering. @@ -105,11 +107,11 @@ void ReplayManager::handleBackwardsSkip(bool doRewindBuffering) rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs()); } else { // otherwise, process the rewind immediately - processRewind(); + processRewind(includeEventsAtTime); } } -void ReplayManager::processRewind() +void ReplayManager::processRewind(bool includeEventsAtTime) { // stop any queued-up rewinds rewindBufferingTimer->stop(); @@ -117,7 +119,7 @@ void ReplayManager::processRewind() // process the rewind currentEvent = 0; emit rewound(); - processNewEvents(BACKWARD_SKIP); + processNewEvents(BACKWARD_SKIP, includeEventsAtTime); } void ReplayManager::replayTimerTimeout() @@ -134,11 +136,13 @@ void ReplayManager::replayTimerTimeout() } /** @brief Processes all unprocessed events up to the current time. */ -void ReplayManager::processNewEvents(PlaybackMode playbackMode) +void ReplayManager::processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime) { currentProcessedTime = currentVisualTime; - while (currentEvent < replayTimeline.size() && replayTimeline[currentEvent] < currentProcessedTime) { + while (currentEvent < replayTimeline.size() && + (replayTimeline[currentEvent] < currentProcessedTime || + (includeEventsAtTime && replayTimeline[currentEvent] == currentProcessedTime))) { EventProcessingOptions options; // backwards skip => always skip reveal windows @@ -236,6 +240,24 @@ void ReplayManager::setTime(int time) skipToTime(time, false); } +void ReplayManager::setTimeToEvent(int eventIndex) +{ + if (eventIndex < 0 || eventIndex >= replayTimeline.size()) { + return; + } + + // Flush a pending buffered rewind before seeking, otherwise its timer fires after this seek and rebuilds the + // replay state from an outdated currentVisualTime, dropping the event we seek to. Stopping the timer instead + // of processing it is not enough: playback ticks advance currentProcessedTime without rewinding currentEvent, + // so the board could still be ahead of the target. + if (rewindBufferingTimer->isActive()) { + processRewind(); + } + + // Unlike a timeline seek, jumping to an event should apply that event immediately. + skipToTime(replayTimeline.at(eventIndex), false, true); +} + void ReplayManager::skipByAmount(int amount) { skipToTime(currentVisualTime + amount, amount < 0); diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index 3849b86ae..34bae5f9a 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -40,10 +40,10 @@ class ReplayManager : public QObject ///< to rewind buffering int currentEvent = 0; ///< current event's index - void skipToTime(int newTime, bool doRewindBuffering); - void handleBackwardsSkip(bool doRewindBuffering); - void processRewind(); - void processNewEvents(PlaybackMode playbackMode); + void skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime = false); + void handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime = false); + void processRewind(bool includeEventsAtTime = false); + void processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime = false); void handleSkipEmptySection(); private slots: @@ -73,6 +73,7 @@ public slots: void startReplay(); void stopReplay(); void setTime(int time); + void setTimeToEvent(int eventIndex); void skipByAmount(int amount); // use a negative amount to skip backwards signals: diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp index 07b4e72ce..3f0bf35d9 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp @@ -28,6 +28,7 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) connect(replayManager, &ReplayManager::eventReplayed, this, &ReplayWidget::eventReplayed); connect(replayManager, &ReplayManager::replayFinished, this, &ReplayWidget::replayFinished); connect(replayManager, &ReplayManager::rewound, this, &ReplayWidget::rewound); + connect(replayManager, &ReplayManager::timeChanged, this, &ReplayWidget::timeChanged); // timeline widget timelineWidget = new ReplayTimelineWidget; @@ -96,6 +97,16 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) refreshShortcuts(); } +const QList &ReplayWidget::getReplayTimeline() const +{ + return replayManager->getReplayTimeline(); +} + +void ReplayWidget::seekToEvent(int eventIndex) +{ + replayManager->setTimeToEvent(eventIndex); +} + void ReplayWidget::replayFinished() { replayPlayButton->setChecked(false); diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h index 0e78442ce..5d23b358d 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -8,6 +8,7 @@ #ifndef REPLAY_WIDGET_H #define REPLAY_WIDGET_H +#include #include #include #include @@ -35,8 +36,14 @@ public: */ ReplayWidget(QWidget *parent, GameReplay *replay); + [[nodiscard]] const QList &getReplayTimeline() const; + +public slots: + void seekToEvent(int eventIndex); + signals: void rewound(); + void timeChanged(int time); void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options); private: From 75d7764643b16446f6a01652f0ed8544d31f5746 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Tue, 3 Nov 2026 23:59:17 -0500 Subject: [PATCH 014/104] [Client] Keep Archidekt navigation tied to its active request (#7723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Keep Archidekt navigation tied to its active request [skip ci] * [Client] Keep Archidekt listing pagination after a failed navigation Only update list mode and the next-page link when a listing or deck response is accepted, so a failed deck navigation leaves the existing listing scrollable. Retry still re-issues the failed request. --------- Co-authored-by: Lukas Brübach --- .../tabs/api/archidekt/tab_archidekt.cpp | 155 +++++++++++++----- .../tabs/api/archidekt/tab_archidekt.h | 23 ++- 2 files changed, 136 insertions(+), 42 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 7f7a2d3fd..9f7369435 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -60,8 +61,6 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) { // Initialize network networkManager = NetworkManagerFactory::create(this); - networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); - connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson); // Initialize debounce timer searchDebounceTimer = new QTimer(this); @@ -77,6 +76,48 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) getTopDecks(); } +TabArchidekt::~TabArchidekt() +{ + cancelRequest(); +} + +void TabArchidekt::cancelRequest() +{ + const QPointer previousReply = activeReply; + activeReply = nullptr; + isLoadingMore = false; + if (previousReply) { + disconnect(previousReply, nullptr, this, nullptr); + previousReply->abort(); + previousReply->deleteLater(); + } +} + +void TabArchidekt::startRequest(QUrl url, RequestKind kind, int page) +{ + cancelRequest(); + requestUrl = url; + requestKind = kind; + requestedPage = page; + isLoadingMore = true; + retryButton->hide(); + requestStatusLabel->setText(kind == RequestKind::Deck ? tr("Loading deck…") : tr("Loading decks…")); + requestStatusLabel->show(); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + auto *reply = networkManager->get(request); + activeReply = reply; + connect(reply, &QNetworkReply::finished, this, [this, reply]() { processApiJson(reply); }); +} + +void TabArchidekt::showRequestError(const QString &error) +{ + requestStatusLabel->setText(tr("Unable to load decks: %1").arg(error)); + requestStatusLabel->show(); + retryButton->show(); +} + void TabArchidekt::initializeUi() { // Main container @@ -194,6 +235,18 @@ void TabArchidekt::initializeUi() mainLayout->addWidget(primaryToolbar); mainLayout->addWidget(secondaryToolbar); + + auto *requestStatusRow = new QHBoxLayout; + requestStatusLabel = new QLabel(container); + requestStatusLabel->setTextFormat(Qt::PlainText); + requestStatusLabel->setWordWrap(true); + retryButton = new QPushButton(tr("Retry"), container); + retryButton->hide(); + connect(retryButton, &QPushButton::clicked, this, + [this]() { startRequest(requestUrl, requestKind, requestedPage); }); + requestStatusRow->addWidget(requestStatusLabel, 1); + requestStatusRow->addWidget(retryButton); + mainLayout->addLayout(requestStatusRow); mainLayout->addWidget(scrollArea); setCentralWidget(container); @@ -404,6 +457,7 @@ void TabArchidekt::retranslateUi() minDeckSizeLogicCombo->setItemText(0, tr("Exact")); searchButton->setText(tr("Search")); + retryButton->setText(tr("Retry")); settingsButton->setToolTip(tr("Display Settings")); } @@ -517,82 +571,95 @@ QString TabArchidekt::buildSearchUrl() void TabArchidekt::doSearch() { - // Reset to first page on new search - currentPage = 1; - // We're searching, so we'll be in list mode - isListMode = true; // Don't debounce - only called by explicit user actions now doSearchImmediate(); } void TabArchidekt::doSearchImmediate() { - QString url = buildSearchUrl(); - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(req); + searchDebounceTimer->stop(); + currentPage = 1; + startRequest(QUrl(buildSearchUrl()), RequestKind::Search); } void TabArchidekt::loadNextPage() { - if (isLoadingMore) { + if (activeReply || !isListMode || !listingsWidget || nextPageUrl.isEmpty()) { return; } - isLoadingMore = true; - currentPage++; - - QString url = buildSearchUrl(); - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(req); + // Continue the accepted search, even if the user has edited filters without submitting them. + // Advance currentPage only after this response has been accepted, so failures can retry it. + startRequest(nextPageUrl, RequestKind::NextPage, currentPage + 1); } void TabArchidekt::actNavigatePage(QString url) { - QNetworkRequest request{QUrl(url)}; - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(request); + searchDebounceTimer->stop(); + startRequest(QUrl(url), RequestKind::Deck); } void TabArchidekt::getTopDecks() { - currentPage = 1; - QNetworkRequest request{QUrl(buildSearchUrl())}; - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(request); + doSearchImmediate(); } void TabArchidekt::processApiJson(QNetworkReply *reply) { - if (reply->error() != QNetworkReply::NoError) { - isLoadingMore = false; + if (reply != activeReply) { reply->deleteLater(); return; } + activeReply = nullptr; + isLoadingMore = false; + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + showRequestError(reply->errorString()); + return; + } QByteArray responseData = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData); + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData, &parseError); - if (!jsonDoc.isObject()) { - isLoadingMore = false; - reply->deleteLater(); + if (parseError.error != QJsonParseError::NoError || !jsonDoc.isObject()) { + showRequestError(tr("Invalid response from Archidekt.")); return; } QJsonObject jsonObj = jsonDoc.object(); - QString responseUrl = reply->url().toString(); - - if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) { + if (requestKind != RequestKind::Deck) { + if (!jsonObj.value("results").isArray() || + (!jsonObj.value("next").isNull() && !jsonObj.value("next").isString())) { + showRequestError(tr("Invalid deck listing from Archidekt.")); + return; + } + for (const auto &value : jsonObj.value("results").toArray()) { + if (!value.isObject() || value.toObject().value("id").toInt() <= 0) { + showRequestError(tr("Invalid deck listing from Archidekt.")); + return; + } + } + currentPage = requestedPage; + const QString next = jsonObj.value("next").toString(); + nextPageUrl = next.isEmpty() ? QUrl() : reply->url().resolved(QUrl(next)); + // An empty or self-repeating page cannot make forward progress. + if (jsonObj.value("results").toArray().isEmpty() || nextPageUrl == reply->url()) { + nextPageUrl.clear(); + } + requestStatusLabel->setText( + currentPage == 1 && jsonObj.value("results").toArray().isEmpty() ? tr("No decks found.") : QString()); + requestStatusLabel->setVisible(!requestStatusLabel->text().isEmpty()); processTopDecksResponse(jsonObj); - } else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) { - processDeckResponse(jsonObj); } else { - prettyPrintJson(jsonObj, 4); + if (jsonObj.value("id").toInt() <= 0 || !jsonObj.value("cards").isArray()) { + showRequestError(tr("Invalid deck from Archidekt.")); + return; + } + requestStatusLabel->hide(); + processDeckResponse(jsonObj); } - - isLoadingMore = false; - reply->deleteLater(); } void TabArchidekt::processTopDecksResponse(QJsonObject reply) @@ -600,8 +667,12 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply) ArchidektDeckListingApiResponse deckData; deckData.fromJson(reply); + // An accepted listing restores list mode; a failed deck navigation leaves the listing usable. + isListMode = true; + // New search → clear everything - if (currentPage == 1) { + if (requestKind == RequestKind::Search) { + listingsWidget = nullptr; QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider); @@ -626,8 +697,10 @@ void TabArchidekt::processDeckResponse(QJsonObject reply) // We're in single deck mode - disable infinite scroll isListMode = false; + nextPageUrl.clear(); // Clear existing results for single deck view + listingsWidget = nullptr; QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h index c74380b63..81c9502df 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h @@ -8,8 +8,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -55,6 +57,7 @@ public: * connects signals and slots, and triggers an initial fetch of top decks. */ explicit TabArchidekt(TabSupervisor *_tabSupervisor); + ~TabArchidekt() override; /** * @brief Update all UI text to reflect the current language or translation @@ -190,6 +193,17 @@ protected: bool eventFilter(QObject *obj, QEvent *event) override; private: + enum class RequestKind + { + Search, + NextPage, + Deck + }; + + void startRequest(QUrl url, RequestKind kind, int page = 1); + void cancelRequest(); + void showRequestError(const QString &error); + /** * @brief Initialize the main UI layout and toolbars * @@ -236,7 +250,14 @@ private: int currentPage; ///< Current page number for infinite scroll bool isLoadingMore; ///< Flag to prevent multiple simultaneous page loads bool isListMode; - ArchidektApiResponseDeckListingsDisplayWidget *listingsWidget = nullptr; + QPointer activeReply; + RequestKind requestKind = RequestKind::Search; + QUrl requestUrl; + QUrl nextPageUrl; + int requestedPage = 1; + QPointer listingsWidget; + QLabel *requestStatusLabel; + QPushButton *retryButton; // --------------------------------------------------------------------- // Layout Containers From 28597ef716ec9439f15b5aaee61c2a23686cd703 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 00:22:29 -0500 Subject: [PATCH 015/104] Fix Deckstats and TappedOut request lifetime and result handling (#7705) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix deck analysis request lifetime, result URLs and form encoding [skip ci] * Preserve unknown cards in deck analysis exports * Share deck analysis URL validation and drop the unused request latch Both analysis interfaces carried a copy of normalizeAnalysisUrl; move it to one header next to them. They are constructed per analysis and analyzeDeck is called once, so the analysisStarted latch was unreachable; remove it from both. --------- Co-authored-by: Lukas Brübach --- .../network/interfaces/deck_analysis_url.h | 42 +++++++ .../interfaces/deck_stats_interface.cpp | 71 +++++++---- .../network/interfaces/deck_stats_interface.h | 3 + .../interfaces/tapped_out_interface.cpp | 110 ++++++++++-------- .../network/interfaces/tapped_out_interface.h | 3 + 5 files changed, 159 insertions(+), 70 deletions(-) create mode 100644 cockatrice/src/client/network/interfaces/deck_analysis_url.h diff --git a/cockatrice/src/client/network/interfaces/deck_analysis_url.h b/cockatrice/src/client/network/interfaces/deck_analysis_url.h new file mode 100644 index 000000000..425056270 --- /dev/null +++ b/cockatrice/src/client/network/interfaces/deck_analysis_url.h @@ -0,0 +1,42 @@ +/** + * @file deck_analysis_url.h + * @ingroup ApiInterfaces + */ + +#ifndef DECK_ANALYSIS_URL_H +#define DECK_ANALYSIS_URL_H + +#include +#include + +/** + * Validates a deck address returned by a deck analysis service. + * + * Only the expected host (optionally "www."-prefixed), HTTPS, default ports and + * URLs without user information are accepted. Legacy HTTP addresses for the same + * service are upgraded to HTTPS in place instead of being rejected. + * + * Relative addresses must be resolved against the response URL by the caller. + * + * @return true when @p url names a deck on @p serviceHost; false otherwise. + */ +inline bool normalizeAnalysisUrl(QUrl &url, const QString &serviceHost) +{ + const QString host = url.host().toLower(); + const bool legacyHttp = url.scheme() == QStringLiteral("http"); + const int defaultPort = legacyHttp ? 80 : 443; + if (!url.isValid() || (!legacyHttp && url.scheme() != QStringLiteral("https")) || !url.userInfo().isEmpty() || + !url.path().startsWith('/') || (url.port() != -1 && url.port() != defaultPort) || + (host != serviceHost && host != QStringLiteral("www.") + serviceHost)) { + return false; + } + // Both services accept HTTPS. Older public metadata may still identify the + // same deck with HTTP; retain its host/path/query while using HTTPS to open it. + if (legacyHttp) { + url.setScheme(QStringLiteral("https")); + url.setPort(-1); + } + return true; +} + +#endif diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp index 508f0bdc9..22be120c4 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -1,5 +1,6 @@ #include "deck_stats_interface.h" +#include "deck_analysis_url.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/inner_deck_list_node.h" @@ -10,12 +11,14 @@ #include #include #include +#include #include #include #include #include +#include #include -#include +#include #include #include #include @@ -29,30 +32,56 @@ DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent) connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished); } +void DeckStatsInterface::showError(const QString &message) +{ + // The notice belongs to the originating editor, not this one-shot request. + // Retire the request before opening UI: opening a window may reenter Qt. + QWidget *owner = qobject_cast(parent()); + auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner); + notice->setTextFormat(Qt::PlainText); + notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + notice->setAttribute(Qt::WA_DeleteOnClose); + deleteLater(); + notice->open(); +} + void DeckStatsInterface::queryFinished(QNetworkReply *reply) { - if (reply->error() != QNetworkReply::NoError) { - QMessageBox::critical(nullptr, tr("Error"), reply->errorString()); - reply->deleteLater(); - deleteLater(); + if (replyHandled) { return; } - - QString data(reply->readAll()); + replyHandled = true; + // All terminal paths release the reply before showing UI or invoking an + // external URL handler, either of which may destroy the originating editor. reply->deleteLater(); - - static const QRegularExpression rx("error() != QNetworkReply::NoError) { + showError(reply->errorString()); return; } - QString deckUrl = match.captured(1); - QDesktopServices::openUrl(deckUrl); + const QString data = QString::fromUtf8(reply->readAll()); + static const QRegularExpression rx( + R"(]*\bproperty\s*=\s*(['"])og:url\1)[^>]*\bcontent\s*=\s*(['"])(.*?)\2)", + QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); + const auto match = rx.match(data); + if (!match.hasMatch()) { + showError(tr("The reply from Deckstats did not contain a deck address.")); + return; + } + const QString address = QTextDocumentFragment::fromHtml(match.captured(3)).toPlainText(); + QUrl deckUrl = reply->url().resolved(QUrl(address, QUrl::StrictMode)); + if (address.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("deckstats.net"))) { + showError(tr("The reply from Deckstats contained an invalid deck address.")); + return; + } + + QPointer guard(this); deleteLater(); + if (!QDesktopServices::openUrl(deckUrl) && guard) { + showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1") + .arg(deckUrl.toDisplayString())); + } } void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data) @@ -60,12 +89,8 @@ void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray DeckList deckWithoutTokens; copyDeckWithoutTokens(deck, deckWithoutTokens); - QUrl params; - QUrlQuery urlQuery; - urlQuery.addQueryItem("deck", deckWithoutTokens.writeToString_Plain()); - urlQuery.addQueryItem("decktitle", deck.getName()); - params.setQuery(urlQuery); - data.append(params.query(QUrl::EncodeReserved).toUtf8()); + data.append("deck=" + QUrl::toPercentEncoding(deckWithoutTokens.writeToString_Plain())); + data.append("&decktitle=" + QUrl::toPercentEncoding(deck.getName())); } void DeckStatsInterface::analyzeDeck(const DeckList &deck) @@ -84,7 +109,9 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList { auto copyIfNotAToken = [&destination](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) { + // Missing local card data is not evidence that a card is a token. Keep + // its original name and quantity so the service analyzes the whole deck. + if ((!dbCard || !dbCard->getIsToken()) && node->getName() != DECK_ZONE_MAYBEBOARD) { DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1); addedCard->setNumber(card->getNumber()); } diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.h b/cockatrice/src/client/network/interfaces/deck_stats_interface.h index e7791075c..b70646403 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.h +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.h @@ -11,6 +11,7 @@ #include class QByteArray; +class QString; class QNetworkAccessManager; class QNetworkReply; class DeckList; @@ -20,6 +21,8 @@ class DeckStatsInterface : public QObject Q_OBJECT private: QNetworkAccessManager *manager; + bool replyHandled = false; + void showError(const QString &message); /** * Deckstats doesn't recognize token cards, and instead tries to find the diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 34dd03765..a57877b6a 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -1,5 +1,6 @@ #include "tapped_out_interface.h" +#include "deck_analysis_url.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/inner_deck_list_node.h" @@ -13,15 +14,17 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include -#include #include +#include #include #include #include @@ -35,54 +38,67 @@ TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent) connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished); } +void TappedOutInterface::showError(const QString &message) +{ + // The notice belongs to the originating editor, not this one-shot request. + // Retire the request before opening UI: opening a window may reenter Qt. + QWidget *owner = qobject_cast(parent()); + auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner); + notice->setTextFormat(Qt::PlainText); + notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + notice->setAttribute(Qt::WA_DeleteOnClose); + deleteLater(); + notice->open(); +} + void TappedOutInterface::queryFinished(QNetworkReply *reply) { + if (replyHandled) { + return; + } + replyHandled = true; + // All terminal paths release the reply before showing UI or invoking an + // external URL handler, either of which may destroy the originating editor. + reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { - QMessageBox::critical(nullptr, tr("Error"), reply->errorString()); - reply->deleteLater(); - deleteLater(); + showError(reply->errorString()); return; } - int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (reply->hasRawHeader("Location")) { - /* - * If the reply contains a "Location" header, a relative URL to the deck on TappedOut - * can be extracted from the header. The http status is a 302 "redirect". - */ - QString deckUrl = reply->rawHeader("Location"); - qCInfo(TappedOutInterfaceLog) << "Tappedout: good reply, http status" << httpStatus << "location" << deckUrl; - QDesktopServices::openUrl("https://tappedout.net" + deckUrl); - } else { - /* - * Otherwise, the deck has not been parsed correctly. Error messages can be extracted - * from the html. Css pseudo selector for errors: $("div.alert-danger > ul > li") - */ - QString data(reply->readAll()); - QStringList errorMessageList = {tr("Unable to analyze the deck.")}; - - static const QRegularExpression rx("
(.*?)"); - auto match = rx.match(data); - if (match.hasMatch()) { - QString errors = match.captured(1); - static const QRegularExpression rx2("
  • (.*?)
  • "); - static const QRegularExpression rxremove("<[^>]*>"); - auto matchIterator = rx2.globalMatch(errors); - while (matchIterator.hasNext()) { - auto match2 = matchIterator.next(); - errorMessageList.append(match2.captured(1).remove(rxremove).simplified()); - } + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (httpStatus >= 200 && httpStatus < 400 && reply->hasRawHeader("Location")) { + const QByteArray location = reply->rawHeader("Location"); + QUrl deckUrl = reply->url().resolved(QUrl::fromEncoded(location, QUrl::StrictMode)); + if (location.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("tappedout.net"))) { + showError(tr("The reply from TappedOut contained an invalid deck address.")); + return; } - - QString errorMessage = errorMessageList.join("\n"); - qCWarning(TappedOutInterfaceLog) << "Tappedout: bad reply, http status" << httpStatus << "size" << data.size() - << "message" << errorMessage; - - QMessageBox::critical(nullptr, tr("Error"), errorMessage); + QPointer guard(this); + deleteLater(); + if (!QDesktopServices::openUrl(deckUrl) && guard) { + showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1") + .arg(deckUrl.toDisplayString())); + } + return; } - reply->deleteLater(); - deleteLater(); + const QString data = QString::fromUtf8(reply->readAll()); + QStringList errorMessageList = {tr("Unable to analyze the deck on TappedOut.")}; + static const QRegularExpression errorsRx("
    (.*?)", + QRegularExpression::DotMatchesEverythingOption); + const auto match = errorsRx.match(data); + if (match.hasMatch()) { + static const QRegularExpression itemRx("
  • (.*?)
  • ", QRegularExpression::DotMatchesEverythingOption); + auto items = itemRx.globalMatch(match.captured(1)); + while (items.hasNext()) { + const QString error = QTextDocumentFragment::fromHtml(items.next().captured(1)).toPlainText().simplified(); + if (!error.isEmpty()) { + errorMessageList.append(error); + } + } + } + qCWarning(TappedOutInterfaceLog) << "TappedOut analysis failed, HTTP status" << httpStatus; + showError(errorMessageList.join('\n')); } void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data) @@ -90,13 +106,9 @@ void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray DeckList mainboard, sideboard; copyDeckSplitMainAndSide(deck, mainboard, sideboard); - QUrl params; - QUrlQuery urlQuery; - urlQuery.addQueryItem("name", deck.getName()); - urlQuery.addQueryItem("mainboard", mainboard.writeToString_Plain(false, true)); - urlQuery.addQueryItem("sideboard", sideboard.writeToString_Plain(false, true)); - params.setQuery(urlQuery); - data.append(params.query(QUrl::EncodeReserved).toUtf8()); + data.append("name=" + QUrl::toPercentEncoding(deck.getName())); + data.append("&mainboard=" + QUrl::toPercentEncoding(mainboard.writeToString_Plain(false, true))); + data.append("&sideboard=" + QUrl::toPercentEncoding(sideboard.writeToString_Plain(false, true))); } void TappedOutInterface::analyzeDeck(const DeckList &deck) @@ -117,7 +129,9 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi { auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) { + // Exclude confirmed tokens, but preserve cards missing from the local + // database, including newly released cards in an imported deck. + if ((dbCard && dbCard->getIsToken()) || node->getName() == DECK_ZONE_MAYBEBOARD) { return; } diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.h b/cockatrice/src/client/network/interfaces/tapped_out_interface.h index 615991b07..27e8c2273 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.h +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.h @@ -14,6 +14,7 @@ inline Q_LOGGING_CATEGORY(TappedOutInterfaceLog, "tapped_out_interface"); class QByteArray; +class QString; class QNetworkAccessManager; class QNetworkReply; class DeckList; @@ -29,6 +30,8 @@ class TappedOutInterface : public QObject Q_OBJECT private: QNetworkAccessManager *manager; + bool replyHandled = false; + void showError(const QString &message); void copyDeckSplitMainAndSide(const DeckList &source, DeckList &mainboard, DeckList &sideboard); private slots: From 2e0a718cd73ee38bfc0ddcb3ee439092e172cf2c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:43 +0100 Subject: [PATCH 016/104] [Tests] Vendor QtTesting behind an opt-in WITH_GUI_TEST flag (#7693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .gitmodules | 3 ++ CMakeLists.txt | 38 +++++++++++++++++++++++ cockatrice/CMakeLists.txt | 5 +++ cockatrice/resources/config/qtlogging.ini | 1 + external/qttesting | 1 + 5 files changed, 48 insertions(+) create mode 160000 external/qttesting diff --git a/.gitmodules b/.gitmodules index 732fec7ca..e69c8e686 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "doxygen-awesome-css"] path = doc/doxygen/theme url = https://github.com/jothepro/doxygen-awesome-css.git +[submodule "external/qttesting"] + path = external/qttesting + url = https://github.com/Kitware/QtTesting.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 677ef0c12..563f174e8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,8 @@ option(WITH_CLIENT "Build Cockatrice client" ON) option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON) # Compile Servatrice option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) +# Compile GUI tests (QtTesting widget-level playback) +option(WITH_GUI_TEST "Build the QtTesting-based GUI test harness" OFF) # Compile tests option(TEST "Build tests" OFF) # Build with AddressSanitizer and UndefinedBehaviorSanitizer @@ -29,6 +31,14 @@ option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) +# The GUI test harness lives in a submodule; a checkout without it must still configure. +if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/CMakeLists.txt") + message(WARNING "WITH_GUI_TEST is ON but external/qttesting is missing, so GUI tests are disabled.\n" + "Run 'git submodule update --init external/qttesting' to enable them." + ) + set(WITH_GUI_TEST OFF) +endif() + # Default to "Release" build type # User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call if(DEFINED CMAKE_BUILD_TYPE) @@ -463,6 +473,34 @@ if(WITH_ORACLE) set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) endif() +if(WITH_GUI_TEST) + set(QtTesting_QT_VERSION "6") + # QtTesting registers its own unit tests when BUILD_TESTING is on. They target the + # Qt version the harness was written for and fail on Qt 6.11 (toolbutton + # long-press recording), and they are not Cockatrice's tests, so suppress them + # for the duration of the subdirectory instead of painting ctest red. + if(DEFINED BUILD_TESTING) + set(_cockatrice_saved_build_testing ${BUILD_TESTING}) + endif() + set(BUILD_TESTING + OFF + CACHE BOOL "Build the testing tree." FORCE + ) + add_subdirectory(external/qttesting) + if(DEFINED _cockatrice_saved_build_testing) + set(BUILD_TESTING + ${_cockatrice_saved_build_testing} + CACHE BOOL "Build the testing tree." FORCE + ) + else() + unset(BUILD_TESTING CACHE) + endif() + # cl.exe has no warnings-as-errors here, and it rejects -Wno-error with D8021. + if(NOT MSVC) + target_compile_options(qttesting PRIVATE -Wno-error) + endif() +endif() + if(TEST) include(CTest) add_subdirectory(tests) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ccf2dfcea..79c26916f 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -566,6 +566,11 @@ target_link_libraries( cockatrice_target_enable_crash_reporting(cockatrice) +if(WITH_GUI_TEST) + target_link_libraries(cockatrice PRIVATE QtTesting) + target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST) +endif() + if(UNIX) if(APPLE) set(MACOSX_BUNDLE_INFO_STRING "${PROJECT_NAME}") diff --git a/cockatrice/resources/config/qtlogging.ini b/cockatrice/resources/config/qtlogging.ini index 7ac0d9ca4..e4b845af9 100644 --- a/cockatrice/resources/config/qtlogging.ini +++ b/cockatrice/resources/config/qtlogging.ini @@ -10,6 +10,7 @@ # or set = false to disable logging #main = true +#recording = true #qt_translator = true #window_main.* = true #release_channel = true diff --git a/external/qttesting b/external/qttesting new file mode 160000 index 000000000..c7bb5d372 --- /dev/null +++ b/external/qttesting @@ -0,0 +1 @@ +Subproject commit c7bb5d372133e94c75cbdc70b04e039b864ea379 From ee57d740c23e9c3fd5adfc2c57c4980623c18368 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:43 +0100 Subject: [PATCH 017/104] [Tests] Replay XML test files through a headless runner (#7694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/README.md | 52 ++++++++++ Testing/run_test.sh | 118 +++++++++++++++++++++++ Testing/smoke_test.xml | 8 ++ cockatrice/CMakeLists.txt | 3 + cockatrice/src/interface/recording.cpp | 102 ++++++++++++++++++++ cockatrice/src/interface/recording.h | 54 +++++++++++ cockatrice/src/interface/window_main.cpp | 63 +++++++++++- cockatrice/src/interface/window_main.h | 30 ++++++ cockatrice/src/main.cpp | 43 ++++++++- 9 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 Testing/README.md create mode 100755 Testing/run_test.sh create mode 100644 Testing/smoke_test.xml create mode 100644 cockatrice/src/interface/recording.cpp create mode 100644 cockatrice/src/interface/recording.h diff --git a/Testing/README.md b/Testing/README.md new file mode 100644 index 000000000..2a03abd37 --- /dev/null +++ b/Testing/README.md @@ -0,0 +1,52 @@ +# GUI tests + +Widget-level playback tests for the Cockatrice client, driven by +[QtTesting](https://github.com/Kitware/QtTesting) (`external/qttesting`, a submodule). +Each `*_test.xml` file is a list of events that QtTesting replays against the running +application; a test passes when every event is handled without error. + +## Running + +```bash +git submodule update --init external/qttesting # once +cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=0 -DWITH_GUI_TEST=ON +cmake --build build/debug -j 24 + +./Testing/run_test.sh --all # headless, offscreen +./Testing/run_test.sh Testing/smoke_test.xml # one file +./Testing/run_test.sh --visual Testing/smoke_test.xml # shows the window +``` + +`WITH_GUI_TEST` is on by default; a checkout without the submodule configures with a +warning and no harness. `COCKATRICE_BIN` points the runner at another build, and +`TEST_TIMEOUT` (seconds, default 180) bounds a single test — a failing event inside a +modal dialog can otherwise leave the process alive. The client also hard-exits a few +seconds after playback returns, whatever the result. + +The tests are **not** part of `ctest`: they need a card database and a developer's +settings, so they stay a local loop for now. + +## Caveats + +- **Not hermetic.** The client reads the local settings and card database, so a test can + pass on one machine and fail on another. Avoid asserting things that restored + workspace state or user preferences decide (for example the number of open tabs). +- **English labels only.** Menu entries are addressed by their (translated) text, so the + tests expect the source language. Widgets are addressed by objectName paths, which are + stable across translations. +- Actions that open a dialog are followed by a `pause` event: playback continues in the + dialog's own event loop, and the pause gives the UI time to build itself. +- Widget paths include Qt-internal names where a QTabWidget is involved + (`qt_tabwidget_stackedwidget`), and the deck views in a game are indexed + (`tabbedDeckView_0`, `tabbedDeckView_1`). + +## Writing a test + +A test is a `` list. Widgets are named by their path from the main window, e.g. +`MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu`. Useful commands: + +| Command | Arguments | Effect | +|---------|-----------|--------| +| `activate` | action or menu name | triggers a QAction (menus are opened first) | +| `key` | Qt key code | presses that key on the focused widget | +| `pause` | milliseconds | waits, letting the UI settle | diff --git a/Testing/run_test.sh b/Testing/run_test.sh new file mode 100755 index 000000000..8ff3fe028 --- /dev/null +++ b/Testing/run_test.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Run QtTesting XML test files against the Cockatrice build. +# +# Usage: +# ./Testing/run_test.sh [--visual] [--all | ...] +# +# Options: +# --visual Run with native display (shows the GUI window). +# Default is headless (QT_QPA_PLATFORM=offscreen). +# --all Run every *.xml file in Testing/. +# +# Environment: +# COCKATRICE_BIN Path to the client binary (default: build/debug/cockatrice/cockatrice, +# or the .app bundle binary on macOS). +# TEST_TIMEOUT Seconds a single test may run before it is killed (default: 180). +# Uses timeout, or gtimeout from coreutils on macOS. +# +# Requires the debug build to exist at build/debug/cockatrice/cockatrice. + +set -euo pipefail +shopt -s nullglob + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +if [ -n "${COCKATRICE_BIN:-}" ]; then + COCKATRICE="$COCKATRICE_BIN" +elif [ "$(uname -s)" = "Darwin" ]; then + # A macOS build produces an app bundle, not a bare executable. + COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice.app/Contents/MacOS/cockatrice" +else + COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice" +fi +TEST_DIR="$REPO_ROOT/Testing" +TEST_TIMEOUT="${TEST_TIMEOUT:-180}" + +# Stock macOS has no `timeout`; coreutils provides `gtimeout`. Without either the +# runner still works, but a hung test is not killed. +TIMEOUT=() +if command -v timeout >/dev/null 2>&1; then + TIMEOUT=(timeout --foreground "$TEST_TIMEOUT") +elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT=(gtimeout --foreground "$TEST_TIMEOUT") +else + echo "Warning: neither timeout nor gtimeout found; tests are not time-limited." >&2 +fi + +if [ ! -f "$COCKATRICE" ]; then + echo "Error: $COCKATRICE not found. Build first:" >&2 + echo " cmake --build build/debug" >&2 + exit 1 +fi + +VISUAL=0 +if [ "${1:-}" = "--visual" ]; then + VISUAL=1 + shift +fi + +if [ "${1:-}" = "--all" ]; then + shift + TEST_FILES=("$TEST_DIR"/*.xml) + if [ ${#TEST_FILES[@]} -eq 0 ]; then + echo "No *.xml files found in $TEST_DIR" >&2 + exit 1 + fi +elif [ $# -ge 1 ]; then + TEST_FILES=("$@") +else + echo "Usage: $0 [--visual] [--all | ...]" >&2 + echo " Run a single test: $0 Testing/smoke_test.xml" >&2 + echo " Run all tests: $0 --all" >&2 + echo " Run visually: $0 --visual Testing/smoke_test.xml" >&2 + echo " Run all visually: $0 --visual --all" >&2 + exit 1 +fi + +PASS=0 +FAIL=0 +FAILED_TESTS=() + +run_one() { + local file="$1" + + local -a cmd=() + if [ "${#TIMEOUT[@]}" -gt 0 ]; then + cmd=("${TIMEOUT[@]}") + fi + cmd+=("$COCKATRICE" --no-connect --test "$file") + + if [ "$VISUAL" -eq 1 ]; then + "${cmd[@]}" + else + env QT_QPA_PLATFORM=offscreen "${cmd[@]}" + fi +} + +for f in "${TEST_FILES[@]}"; do + name="$(basename "$f")" + printf "%-40s " "$name" + if run_one "$f"; then + echo "PASS" + PASS=$((PASS + 1)) + else + echo "FAIL" + FAIL=$((FAIL + 1)) + FAILED_TESTS+=("$name") + fi +done + +TOTAL=$((PASS + FAIL)) +echo "" +echo "Results: $PASS/$TOTAL passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + echo "Failed:" + for t in "${FAILED_TESTS[@]}"; do + echo " $t" + done + exit 1 +fi diff --git a/Testing/smoke_test.xml b/Testing/smoke_test.xml new file mode 100644 index 000000000..a31edbdd4 --- /dev/null +++ b/Testing/smoke_test.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 79c26916f..234c3ce5a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -567,6 +567,9 @@ target_link_libraries( cockatrice_target_enable_crash_reporting(cockatrice) if(WITH_GUI_TEST) + # recording.cpp translates between XML test files and the QtTesting event pipeline; + # it needs the QtTesting headers, so it only exists with the harness. + target_sources(cockatrice PRIVATE src/interface/recording.cpp) target_link_libraries(cockatrice PRIVATE QtTesting) target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST) endif() diff --git a/cockatrice/src/interface/recording.cpp b/cockatrice/src/interface/recording.cpp new file mode 100644 index 000000000..2b11255f9 --- /dev/null +++ b/cockatrice/src/interface/recording.cpp @@ -0,0 +1,102 @@ +#include "recording.h" + +#include "pqEventTypes.h" + +#include +#include +#include +#include + +XMLEventObserver::XMLEventObserver(QObject *parent) : pqEventObserver(parent) +{ +} + +XMLEventObserver::~XMLEventObserver() = default; + +void XMLEventObserver::setStream(QTextStream *stream) +{ + if (xmlStream) { + xmlStream->writeEndElement(); + xmlStream->writeEndDocument(); + xmlStream.reset(); + } + if (Stream) { + *Stream << xmlString; + } + xmlString.clear(); + pqEventObserver::setStream(stream); + if (Stream) { + xmlStream.reset(new QXmlStreamWriter(&xmlString)); + xmlStream->setAutoFormatting(true); + xmlStream->writeStartDocument(); + xmlStream->writeStartElement(QStringLiteral("events")); + } +} + +void XMLEventObserver::onRecordEvent(const QString &widget, + const QString &command, + const QString &arguments, + const int &eventType) +{ + if (!xmlStream) { + return; + } + + xmlStream->writeStartElement(QStringLiteral("event")); + xmlStream->writeAttribute(QStringLiteral("widget"), widget); + if (eventType == pqEventTypes::ACTION_EVENT) { + xmlStream->writeAttribute(QStringLiteral("command"), command); + } else { // pqEventTypes::CHECK_EVENT + xmlStream->writeAttribute(QStringLiteral("property"), command); + } + xmlStream->writeAttribute(QStringLiteral("arguments"), arguments); + xmlStream->writeEndElement(); +} + +XMLEventSource::XMLEventSource(QObject *parent) : pqEventSource(parent) +{ +} + +XMLEventSource::~XMLEventSource() = default; + +void XMLEventSource::setContent(const QString &xmlFileName) +{ + xmlStream.reset(); + + QFile xml(xmlFileName); + if (!xml.open(QIODevice::ReadOnly)) { + qCWarning(RecordingLog) << "Could not read test file" << xmlFileName; + return; + } + + xmlStream.reset(new QXmlStreamReader(xml.readAll())); +} + +int XMLEventSource::getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) +{ + if (!xmlStream) { + return DONE; + } + + while (!xmlStream->atEnd()) { + if (xmlStream->readNext() != QXmlStreamReader::StartElement || xmlStream->name() != QStringLiteral("event")) { + continue; + } + + const QXmlStreamAttributes attributes = xmlStream->attributes(); + eventType = pqEventTypes::ACTION_EVENT; + widget = attributes.value(QStringLiteral("widget")).toString(); + command = attributes.value(QStringLiteral("command")).toString(); + if (command.isEmpty()) { + // A recorded check event carries a property instead of a command. + command = attributes.value(QStringLiteral("property")).toString(); + } + arguments = attributes.value(QStringLiteral("arguments")).toString(); + return SUCCESS; + } + + if (xmlStream->hasError()) { + qCWarning(RecordingLog) << "Malformed test file:" << xmlStream->errorString(); + } + return DONE; +} diff --git a/cockatrice/src/interface/recording.h b/cockatrice/src/interface/recording.h new file mode 100644 index 000000000..a5d813ea9 --- /dev/null +++ b/cockatrice/src/interface/recording.h @@ -0,0 +1,54 @@ +#ifndef COCKATRICE_RECORDING_H +#define COCKATRICE_RECORDING_H + +#include "pqEventObserver.h" +#include "pqEventSource.h" + +#include +#include + +inline Q_LOGGING_CATEGORY(RecordingLog, "recording"); + +class QXmlStreamReader; +class QXmlStreamWriter; + +/** + * @brief Writes a session recording as an XML test file. + * + * The events are buffered in memory and flushed to the stream when recording + * ends, because the stream is handed over once the file name is known. + */ +class XMLEventObserver : public pqEventObserver +{ + QScopedPointer xmlStream; + QString xmlString; + +public: + explicit XMLEventObserver(QObject *parent); + ~XMLEventObserver() override; + +protected: + void setStream(QTextStream *stream) override; + void onRecordEvent(const QString &widget, + const QString &command, + const QString &arguments, + const int &eventType) override; +}; + +/** + * @brief Plays the events of an XML test file back, one at a time. + */ +class XMLEventSource : public pqEventSource +{ + QScopedPointer xmlStream; + +public: + explicit XMLEventSource(QObject *parent); + ~XMLEventSource() override; + +protected: + void setContent(const QString &xmlFileName) override; + int getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) override; +}; + +#endif // COCKATRICE_RECORDING_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index d546676bc..a69f6df87 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -55,6 +55,13 @@ #include "widgets/server/handle_public_servers.h" #include "widgets/settings_page/abstract_settings_page.h" #include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h" +#ifdef WITH_GUI_TEST +#include "pqEventObserver.h" +#include "pqEventSource.h" +#include "pqEventTypes.h" +#include "pqTestUtility.h" +#include "recording.h" +#endif #include #include @@ -310,6 +317,7 @@ void MainWindow::actAbout() mb.setIconPixmap( themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); mb.setTextInteractionFlags(Qt::TextBrowserInteraction); + mb.setObjectName("aboutDialog"); mb.exec(); } @@ -348,6 +356,24 @@ void MainWindow::actOpenSettingsFolder() QtUtils::openPathInFileManager(dir); } +#ifdef WITH_GUI_TEST +void MainWindow::actRecord() +{ + QString filename = QFileDialog::getSaveFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)")); + if (!filename.isEmpty()) { + testUtility->recordTests(filename); + } +} + +void MainWindow::actPlayRecording() +{ + QString filename = QFileDialog::getOpenFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)")); + if (!filename.isEmpty()) { + testUtility->playTests(filename); + } +} +#endif + void MainWindow::retranslateUi() { connectionController->refreshWindowTitle(); @@ -392,6 +418,10 @@ void MainWindow::retranslateUi() aViewLog->setText(tr("View &Debug Log")); aOpenSettingsFolder->setText(tr("Open Settings Folder")); aFirstRunWizard->setText(tr("Re-run Onboarding Wizard...")); +#ifdef WITH_GUI_TEST + aRecord->setText(tr("&Record Test...")); + aPlayRecording->setText(tr("&Play Test...")); +#endif aShow->setText(tr("Show/Hide")); @@ -455,6 +485,12 @@ void MainWindow::createActions() connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder); aFirstRunWizard = new QAction(this); connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); }); +#ifdef WITH_GUI_TEST + aRecord = new QAction(this); + connect(aRecord, &QAction::triggered, this, &MainWindow::actRecord); + aPlayRecording = new QAction(this); + connect(aPlayRecording, &QAction::triggered, this, &MainWindow::actPlayRecording); +#endif aShow = new QAction(this); connect(aShow, &QAction::triggered, this, &MainWindow::actShow); @@ -495,6 +531,7 @@ void MainWindow::createActions() void MainWindow::createMenus() { + menuBar()->setObjectName("menubar"); cockatriceMenu = menuBar()->addMenu(QString()); cockatriceMenu->addAction(aConnect); cockatriceMenu->addAction(aDisconnect); @@ -523,6 +560,7 @@ void MainWindow::createMenus() tabsMenu = menuBar()->addMenu(QString()); helpMenu = menuBar()->addMenu(QString()); + helpMenu->setObjectName("helpMenu"); helpMenu->addAction(aAbout); helpMenu->addAction(aTips); helpMenu->addSeparator(); @@ -535,12 +573,19 @@ void MainWindow::createMenus() helpMenu->addAction(aOpenSettingsFolder); helpMenu->addSeparator(); helpMenu->addAction(aFirstRunWizard); +#ifdef WITH_GUI_TEST + helpMenu->addSeparator(); + helpMenu->addAction(aRecord); + helpMenu->addAction(aPlayRecording); +#endif } MainWindow::MainWindow(QWidget *parent) - : QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), + : QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), noConnect(false), cardUpdateProcess(nullptr), logviewDialog(nullptr) { + setObjectName("MainWindow"); + connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::pixmapCacheSizeChanged, this, &MainWindow::pixmapCacheSizeChanged); pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); @@ -626,6 +671,13 @@ MainWindow::MainWindow(QWidget *parent) // run startup check async QTimer::singleShot(0, this, &MainWindow::startupConfigCheck); QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); + +#ifdef WITH_GUI_TEST + testUtility = new pqTestUtility(this); + testUtility->setRecordWithDialog(true); + testUtility->addEventObserver("xml", new XMLEventObserver(this)); + testUtility->addEventSource("xml", new XMLEventSource(this)); +#endif } void MainWindow::startupConfigCheck() @@ -780,6 +832,11 @@ void MainWindow::runFirstRunWizard(bool firstRun) */ void MainWindow::applyStartupDestination() { + if (noConnect) { + qCInfo(WindowMainStartupAutoconnectLog) << "Startup destination suppressed (--no-connect)"; + return; + } + // An explicit command-line connect takes precedence over the startup destination. if (!connectTo.isEmpty()) { return; @@ -1049,6 +1106,10 @@ void MainWindow::handleCockatriceLink(const QString &url) void MainWindow::attemptStartupAutoConnect() { + if (noConnect) { + qCInfo(WindowMainStartupAutoconnectLog) << "Auto-connect suppressed (--no-connect)"; + return; + } if (startupAutoConnectAttempted || skipStartupAutoConnect) { return; } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 9e342e4b0..48d093a38 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -48,6 +48,7 @@ class QAction; class QToolButton; class QWidget; +class pqTestUtility; struct LocalGameOptions; inline Q_LOGGING_CATEGORY(WindowMainLog, "window_main"); @@ -104,6 +105,11 @@ private slots: void actUpdate(); void actViewLog(); void actOpenSettingsFolder(); +#ifdef WITH_GUI_TEST + void actRecord(); + void actPlayRecording(); +#endif + void actShow(); void showWindowIfHidden(); void onUrlChainFinished(bool connected); @@ -185,6 +191,9 @@ private: *aReloadCardDatabase; QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar, *aViewLog, *aOpenSettingsFolder; +#ifdef WITH_GUI_TEST + QAction *aRecord, *aPlayRecording; +#endif TabSupervisor *tabSupervisor; IntentUrlParser *urlParser; @@ -208,6 +217,7 @@ private: bool skipStartupAutoConnect = false; bool startupAutoConnectAttempted = false; bool firstRunWizardActive = false; + bool noConnect = false; ///< --no-connect: skip every startup connection QProcess *cardUpdateProcess; QByteArray cardUpdateOutputBuffer; QByteArray cardUpdateErrorOutput; ///< diagnostic output collected from the updater's stderr @@ -219,6 +229,9 @@ private: GameReplay *replay; DlgTipOfTheDay *tip; QUrl connectTo; +#ifdef WITH_GUI_TEST + pqTestUtility *testUtility; +#endif public: explicit MainWindow(QWidget *parent = nullptr); @@ -226,6 +239,16 @@ public: { connectTo = QUrl(QString("cockatrice://%1").arg(url)); } + /** + * @brief Suppresses every startup connection: --connect, auto-connect and the + * configured startup destination. Used by the GUI test harness so a + * playback run never dials out. + */ + void setNoConnect(bool value) + { + noConnect = value; + } + // When set, the window's own startup connection (--connect or auto-connect // on first activation) is skipped. Used for activation launches: the intent // chain triggered by a cockatrice:// URL owns the connection, and letting @@ -266,6 +289,13 @@ public: */ void requestInstallerClose(); +#ifdef WITH_GUI_TEST + pqTestUtility *getTestUtility() const + { + return testUtility; + } +#endif + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 829a8ac42..b4dc0610f 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -39,6 +39,10 @@ #include "single_instance_manager.h" #include "version_string.h" +#ifdef WITH_GUI_TEST +#include "pqTestUtility.h" +#endif + #include #include #include @@ -62,10 +66,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -343,9 +349,16 @@ int main(int argc, char *argv[]) parser.addOptions( {{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"}, {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}, - {"simulate-crash", - QCoreApplication::translate("main", - "Show the crash-report dialog (saves a mock file when no DSN is configured)")}}); + {"simulate-crash", QCoreApplication::translate( + "main", "Show the crash-report dialog (saves a mock file when no DSN is configured)")}, + {"no-connect", QCoreApplication::translate("main", "Do not connect to any server on startup")} +#ifdef WITH_GUI_TEST + , + {{"t", "test"}, + QCoreApplication::translate("main", "Play back a QtTesting XML test file and exit"), + "test-file.xml"} +#endif + }); parser.process(app); @@ -483,6 +496,9 @@ int main(int argc, char *argv[]) if (parser.isSet("connect")) { ui.setConnectTo(parser.value("connect")); } + if (parser.isSet("no-connect")) { + ui.setNoConnect(true); + } qCInfo(MainLog) << "MainWindow constructor finished"; ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice"))); @@ -523,6 +539,27 @@ int main(int argc, char *argv[]) handleActivation(file); } +#ifdef WITH_GUI_TEST + if (parser.isSet("test")) { + QString testFile = parser.value("test"); + qCInfo(MainLog) << "Playing test file:" << testFile; + QTimer::singleShot(0, &ui, [&ui, testFile]() { + bool success = ui.getTestUtility()->playTests(testFile); + qCInfo(MainLog) << "Test playback" << (success ? "succeeded" : "FAILED"); + qApp->exit(success ? 0 : 1); + + // An event that fails while a modal dialog is open aborts the dispatcher, + // but the dialog's nested event loop can then keep the application alive + // forever. Hard-exit after a grace period so a red test always terminates. + auto *watchdog = QThread::create([success] { + QThread::sleep(10); + ::_Exit(success ? 0 : 1); + }); + watchdog->start(); + }); + } +#endif + // Connect to future file/URL events from other instances QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) { for (const QString &file : files) { From d2abd645e82c96cb2cf5987cb7a0f80dbd992c4f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:44 +0100 Subject: [PATCH 018/104] [Tests] Name the menus and dialogs the shell GUI tests drive (#7695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/dialog_lifecycle_test.xml | 11 +++++++++++ Testing/menu_open_close_test.xml | 19 +++++++++++++++++++ Testing/nonmodal_manage_sets_test.xml | 6 ++++++ Testing/nonmodal_tip_test.xml | 6 ++++++ Testing/nonmodal_view_log_test.xml | 6 ++++++ Testing/settings_dialog_test.xml | 6 ++++++ .../interface/widgets/dialogs/dlg_connect.cpp | 6 ++++++ .../widgets/dialogs/dlg_edit_tokens.cpp | 1 + .../widgets/dialogs/dlg_manage_sets.cpp | 2 ++ .../widgets/dialogs/dlg_settings.cpp | 3 +++ .../widgets/dialogs/dlg_tip_of_the_day.cpp | 1 + .../interface/widgets/dialogs/dlg_update.cpp | 1 + .../widgets/dialogs/dlg_view_log.cpp | 1 + cockatrice/src/interface/window_main.cpp | 12 ++++++++++++ 14 files changed, 81 insertions(+) create mode 100644 Testing/dialog_lifecycle_test.xml create mode 100644 Testing/menu_open_close_test.xml create mode 100644 Testing/nonmodal_manage_sets_test.xml create mode 100644 Testing/nonmodal_tip_test.xml create mode 100644 Testing/nonmodal_view_log_test.xml create mode 100644 Testing/settings_dialog_test.xml diff --git a/Testing/dialog_lifecycle_test.xml b/Testing/dialog_lifecycle_test.xml new file mode 100644 index 000000000..00510b802 --- /dev/null +++ b/Testing/dialog_lifecycle_test.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Testing/menu_open_close_test.xml b/Testing/menu_open_close_test.xml new file mode 100644 index 000000000..d894b47b5 --- /dev/null +++ b/Testing/menu_open_close_test.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Testing/nonmodal_manage_sets_test.xml b/Testing/nonmodal_manage_sets_test.xml new file mode 100644 index 000000000..5fe2bfc0c --- /dev/null +++ b/Testing/nonmodal_manage_sets_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/nonmodal_tip_test.xml b/Testing/nonmodal_tip_test.xml new file mode 100644 index 000000000..0f4ffc825 --- /dev/null +++ b/Testing/nonmodal_tip_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/nonmodal_view_log_test.xml b/Testing/nonmodal_view_log_test.xml new file mode 100644 index 000000000..3369f1fd5 --- /dev/null +++ b/Testing/nonmodal_view_log_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/settings_dialog_test.xml b/Testing/settings_dialog_test.xml new file mode 100644 index 000000000..2dd9dda0a --- /dev/null +++ b/Testing/settings_dialog_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 1f567989e..e4411ec1d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -33,6 +33,7 @@ class QWidget; DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) { + setObjectName("connectDialog"); previousHostButton = new QRadioButton(tr("Known Hosts"), this); previousHosts = new QComboBox(this); @@ -85,6 +86,11 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) passwordLabel->setBuddy(passwordEdit); passwordEdit->setEchoMode(QLineEdit::Password); + hostEdit->setObjectName("connectHostEdit"); + portEdit->setObjectName("connectPortEdit"); + playernameEdit->setObjectName("connectPlayernameEdit"); + passwordEdit->setObjectName("connectPasswordEdit"); + savePasswordCheckBox = new QCheckBox(tr("&Save password")); autoConnectCheckBox = new QCheckBox(tr("A&uto connect")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index cbf61a394..3845029f7 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -40,6 +40,7 @@ class QWidget; DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nullptr) { + setObjectName("editTokensDialog"); nameLabel = new QLabel(tr("&Name:")); nameEdit = new QLineEdit; nameEdit->setMaxLength(MAX_NAME_LENGTH); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index dbfe38e26..caca5da92 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -48,6 +48,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) { + setObjectName("manageSetsWindow"); setOrderIsSorted = false; // left toolbar @@ -221,6 +222,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) WndSets::~WndSets() { + disconnect(view->header(), &QHeaderView::geometriesChanged, this, &WndSets::saveHeaderState); } void WndSets::closeEvent(QCloseEvent * /*ev*/) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index ab870a2c8..3602e67c6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -73,6 +73,8 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) resize(calculateDialogSize(hostSize, available.size(), minimum)); setSizeGripEnabled(true); + setObjectName("settingsDialog"); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); settingsView = new SettingsView(this); @@ -82,6 +84,7 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) buttonBox->addStretch(); okButton = new QPushButton; okButton->setDefault(true); + okButton->setObjectName("settingsOkButton"); connect(okButton, &QPushButton::clicked, this, &DlgSettings::close); buttonBox->addWidget(okButton); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp index 511ff1c7d..c1df0c963 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -28,6 +28,7 @@ DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent) { + setObjectName("tipOfTheDayDialog"); successfulInit = false; QString xmlPath = "theme:tips/tips_of_the_day.xml"; tipDatabase = new TipsOfTheDay(xmlPath, this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index ca55c7b41..11dcd7e27 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -42,6 +42,7 @@ static const QString UPDATE_INSTALLER_OVERRIDE = "Cockatrice-Update-Override.exe DlgUpdate::DlgUpdate(QWidget *parent) : QDialog(parent) { + setObjectName("updateDialog"); // Handle layout statusLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp index d55e800a1..e75cf352a 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp @@ -18,6 +18,7 @@ class QWidget; DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent) { + setObjectName("viewLogDialog"); logArea = new QPlainTextEdit; logArea->setReadOnly(true); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index a69f6df87..13f2e43cd 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -431,24 +431,33 @@ void MainWindow::retranslateUi() void MainWindow::createActions() { aConnect = new QAction(this); + aConnect->setObjectName("actionConnect"); connect(aConnect, &QAction::triggered, this, &MainWindow::actConnect); aDisconnect = new QAction(this); + aDisconnect->setObjectName("actionDisconnect"); aDisconnect->setEnabled(false); connect(aDisconnect, &QAction::triggered, this, &MainWindow::actDisconnect); aSinglePlayer = new QAction(this); + aSinglePlayer->setObjectName("actionSinglePlayer"); connect(aSinglePlayer, &QAction::triggered, this, &MainWindow::actSinglePlayer); aWatchReplay = new QAction(this); + aWatchReplay->setObjectName("actionWatchReplay"); connect(aWatchReplay, &QAction::triggered, this, &MainWindow::actWatchReplay); aFullScreen = new QAction(this); + aFullScreen->setObjectName("actionFullScreen"); aFullScreen->setCheckable(true); connect(aFullScreen, &QAction::toggled, this, &MainWindow::actFullScreen); aRegister = new QAction(this); + aRegister->setObjectName("actionRegister"); connect(aRegister, &QAction::triggered, connectionController, &ConnectionController::registerToServer); aForgotPassword = new QAction(this); + aForgotPassword->setObjectName("actionForgotPassword"); connect(aForgotPassword, &QAction::triggered, connectionController, &ConnectionController::forgotPasswordRequest); aSettings = new QAction(this); + aSettings->setObjectName("actionSettings"); connect(aSettings, &QAction::triggered, this, &MainWindow::actSettings); aExit = new QAction(this); + aExit->setObjectName("actionExit"); connect(aExit, &QAction::triggered, this, &MainWindow::actExit); aManageSets = new QAction(QString(), this); @@ -533,6 +542,7 @@ void MainWindow::createMenus() { menuBar()->setObjectName("menubar"); cockatriceMenu = menuBar()->addMenu(QString()); + cockatriceMenu->setObjectName("cockatriceMenu"); cockatriceMenu->addAction(aConnect); cockatriceMenu->addAction(aDisconnect); cockatriceMenu->addAction(aRegister); @@ -548,6 +558,7 @@ void MainWindow::createMenus() cockatriceMenu->addAction(aExit); dbMenu = menuBar()->addMenu(QString()); + dbMenu->setObjectName("dbMenu"); dbMenu->addAction(aManageSets); dbMenu->addAction(aEditTokens); dbMenu->addSeparator(); @@ -558,6 +569,7 @@ void MainWindow::createMenus() dbMenu->addAction(aReloadCardDatabase); tabsMenu = menuBar()->addMenu(QString()); + tabsMenu->setObjectName("tabsMenu"); helpMenu = menuBar()->addMenu(QString()); helpMenu->setObjectName("helpMenu"); From 34201122ec013426610d2e9e621c6b3b3f5049f1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:44 +0100 Subject: [PATCH 019/104] [Tests] Name the tab, home and settings widgets the GUI tests address (#7696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/deck_editor_open_test.xml | 4 ++ Testing/deck_editor_search_test.xml | 14 ++++++ Testing/integration_test.xml | 45 +++++++++++++++++++ Testing/settings_search_test.xml | 13 ++++++ .../widgets/dialogs/settings_view.cpp | 3 ++ .../interface/widgets/general/home_widget.cpp | 9 ++++ .../src/interface/widgets/tabs/tab_home.cpp | 1 + .../interface/widgets/tabs/tab_supervisor.cpp | 13 ++++++ 8 files changed, 102 insertions(+) create mode 100644 Testing/deck_editor_open_test.xml create mode 100644 Testing/deck_editor_search_test.xml create mode 100644 Testing/integration_test.xml create mode 100644 Testing/settings_search_test.xml diff --git a/Testing/deck_editor_open_test.xml b/Testing/deck_editor_open_test.xml new file mode 100644 index 000000000..437c4bd23 --- /dev/null +++ b/Testing/deck_editor_open_test.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Testing/deck_editor_search_test.xml b/Testing/deck_editor_search_test.xml new file mode 100644 index 000000000..0b9f88fa1 --- /dev/null +++ b/Testing/deck_editor_search_test.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Testing/integration_test.xml b/Testing/integration_test.xml new file mode 100644 index 000000000..5fc7a971a --- /dev/null +++ b/Testing/integration_test.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/settings_search_test.xml b/Testing/settings_search_test.xml new file mode 100644 index 000000000..ba60251f0 --- /dev/null +++ b/Testing/settings_search_test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index d22a8d0b3..96b429a61 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -119,6 +119,8 @@ QWidget *fromWidgetId(WidgetId id) SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false) { + setObjectName("settingsView"); + setupUi(); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi); @@ -135,6 +137,7 @@ void SettingsView::setupUi() searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); + searchEdit->setObjectName("settingsSearchEdit"); auto *searchLayout = new QHBoxLayout; searchLayout->addWidget(searchEdit); diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 5905b2c40..5edf607be 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -68,6 +68,7 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))) { + setObjectName("homeWidget"); layout = new QGridLayout(this); backgroundSourceCard = new CardInfoPictureArtCropWidget(this); @@ -287,6 +288,7 @@ void HomeWidget::updateButtonsToBackgroundColor() QGroupBox *HomeWidget::createButtons() { QGroupBox *box = new QGroupBox(this); + box->setObjectName("homeButtonsBox"); box->setStyleSheet(R"( QGroupBox { font-size: 20px; @@ -311,27 +313,34 @@ QGroupBox *HomeWidget::createButtons() boxLayout->addSpacing(25); connectButton = new HomeStyledButton(QString(), gradientColors); + connectButton->setObjectName("homeConnectButton"); boxLayout->addWidget(connectButton); visualDeckEditorButton = new HomeStyledButton(QString(), gradientColors); + visualDeckEditorButton->setObjectName("homeCreateNewDeckButton"); connect(visualDeckEditorButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->openDeckInNewTab(LoadedDeck()); }); boxLayout->addWidget(visualDeckEditorButton); visualDeckStorageButton = new HomeStyledButton(QString(), gradientColors); + visualDeckStorageButton->setObjectName("homeBrowseDecksButton"); connect(visualDeckStorageButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabVisualDeckStorage(true); }); boxLayout->addWidget(visualDeckStorageButton); visualDatabaseDisplayButton = new HomeStyledButton(QString(), gradientColors); + visualDatabaseDisplayButton->setObjectName("homeBrowseCardDatabaseButton"); connect(visualDatabaseDisplayButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addVisualDatabaseDisplayTab); boxLayout->addWidget(visualDatabaseDisplayButton); edhrecButton = new HomeStyledButton(QString(), gradientColors); + edhrecButton->setObjectName("homeBrowseEdhrecButton"); connect(edhrecButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addEdhrecMainTab); boxLayout->addWidget(edhrecButton); archidektButton = new HomeStyledButton(QString(), gradientColors); + archidektButton->setObjectName("homeBrowseArchidektButton"); connect(archidektButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addArchidektTab); boxLayout->addWidget(archidektButton); replayButton = new HomeStyledButton(QString(), gradientColors); + replayButton->setObjectName("homeViewReplaysButton"); connect(replayButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); }); boxLayout->addWidget(replayButton); if (qobject_cast(tabSupervisor->parentWidget())) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_home.cpp b/cockatrice/src/interface/widgets/tabs/tab_home.cpp index af9994fdd..3ad16aa91 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_home.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_home.cpp @@ -6,6 +6,7 @@ class TabSupervisor; TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) { + setObjectName("TabHome"); homeWidget = new HomeWidget(this, tabSupervisor); setCentralWidget(homeWidget); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 3316232a4..cf71dd7ed 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -157,6 +158,15 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) { + setObjectName("tabSupervisor"); + + // QTabWidget keeps tab pages in an internal stacked widget. Give it a stable + // name so the GUI tests can address the pages by a readable path + // (tabSupervisor/stackedWidget/). + if (auto *stackedWidget = findChild()) { + stackedWidget->setObjectName("stackedWidget"); + } + setElideMode(Qt::ElideRight); setMovable(true); setIconSize(QSize(15, 15)); @@ -462,6 +472,9 @@ void TabSupervisor::addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager auto closeSide = static_cast( tabBar()->style()->styleHint(QStyle::SH_TabBar_CloseButtonPosition, nullptr, tabBar())); auto *closeButton = new CloseButton(tab); + // No object name: the tab bar index shifts when tabs move or close, so an + // index-derived name would go stale (or collide). Callers find the button + // through the tab widget instead. if (manager) { // If managed, all close requests should go through the menu action connect(closeButton, &CloseButton::clicked, this, [manager] { checkAndTrigger(manager, false); }); From 3fa8d78786a41e219d0789cfca418715910b15f2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:45 +0100 Subject: [PATCH 020/104] [Tests] Play card interactions through a Cockatrice event player (#7697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/README.md | 14 + Testing/card_actions_test.xml | 69 ++ Testing/game_mechanics_test.xml | 101 +++ Testing/local_game_lifecycle_test.xml | 178 +++++ cockatrice/CMakeLists.txt | 6 +- .../deckview/deck_view_container.cpp | 10 + .../deckview/tabbed_deck_view_container.cpp | 1 + cockatrice/src/game_graphics/game_view.cpp | 1 + .../pq_cockatrice_event_player.cpp | 628 ++++++++++++++++++ .../pq_cockatrice_event_player.h | 91 +++ .../dialogs/dlg_load_deck_from_clipboard.cpp | 4 + .../dialogs/dlg_local_game_options.cpp | 8 + .../src/interface/widgets/tabs/tab_game.cpp | 9 + cockatrice/src/interface/window_main.cpp | 2 + 14 files changed, 1119 insertions(+), 3 deletions(-) create mode 100644 Testing/card_actions_test.xml create mode 100644 Testing/game_mechanics_test.xml create mode 100644 Testing/local_game_lifecycle_test.xml create mode 100644 cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp create mode 100644 cockatrice/src/game_graphics/pq_cockatrice_event_player.h diff --git a/Testing/README.md b/Testing/README.md index 2a03abd37..cb990ec29 100644 --- a/Testing/README.md +++ b/Testing/README.md @@ -50,3 +50,17 @@ A test is a `` list. Widgets are named by their path from the main windo | `activate` | action or menu name | triggers a QAction (menus are opened first) | | `key` | Qt key code | presses that key on the focused widget | | `pause` | milliseconds | waits, letting the UI settle | +| `clickCard` | `card,zone` | double click the card found in that zone (plays it from hand) | +| `flipCard` | `card,zone` | right-click → *Turn Over* | +| `cardAction` | `card,zone,label` | right-click → first menu entry whose label contains `label` (submenus included) | +| `moveCard` | `card,srcZone,dstZone` | drags the card onto the destination zone | +| `drawArrow` | `srcCard,srcZone,dstCard,dstZone` | right-drag an arrow between two cards | +| `createToken` | `name,color,pt` | creates a token as the active local player | +| `selectItem` | `item label` | selects the tree/list entry with that label | +| `typeText` | `text` | types text one key event at a time | +| `expectProperty` | `property,value` | fails playback unless the widget property matches | + +The card, item and property commands are `pqCockatriceEventPlayer` +(`cockatrice/src/game_graphics/pq_cockatrice_event_player.h`). They resolve cards by name +and zone through the `QGraphicsScene` and send real mouse events at the card's current +viewport position, so a test does not depend on the window size or screen resolution. diff --git a/Testing/card_actions_test.xml b/Testing/card_actions_test.xml new file mode 100644 index 000000000..8d6de4c2b --- /dev/null +++ b/Testing/card_actions_test.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/game_mechanics_test.xml b/Testing/game_mechanics_test.xml new file mode 100644 index 000000000..efef78707 --- /dev/null +++ b/Testing/game_mechanics_test.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/local_game_lifecycle_test.xml b/Testing/local_game_lifecycle_test.xml new file mode 100644 index 000000000..bdc964921 --- /dev/null +++ b/Testing/local_game_lifecycle_test.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 234c3ce5a..9b467721d 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -567,9 +567,9 @@ target_link_libraries( cockatrice_target_enable_crash_reporting(cockatrice) if(WITH_GUI_TEST) - # recording.cpp translates between XML test files and the QtTesting event pipeline; - # it needs the QtTesting headers, so it only exists with the harness. - target_sources(cockatrice PRIVATE src/interface/recording.cpp) + # These two translate between XML test files and the QtTesting event pipeline; + # they need the QtTesting headers, so they only exist with the harness. + target_sources(cockatrice PRIVATE src/game_graphics/pq_cockatrice_event_player.cpp src/interface/recording.cpp) target_link_libraries(cockatrice PRIVATE QtTesting) target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST) endif() diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 3bef9e59e..fe63d7a6f 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -71,6 +71,8 @@ void ToggleButton::setState(bool _state) DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) : QWidget(nullptr), visualDeckStorageWidget(nullptr), parentGame(parent), playerId(_playerId) { + setObjectName("deckViewContainer"); + loadLocalButton = new QPushButton; loadRemoteButton = new QPushButton; loadFromClipboardButton = new QPushButton; @@ -80,6 +82,14 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) forceStartGameButton = new QPushButton; sideboardLockButton = new ToggleButton; + loadLocalButton->setObjectName("loadLocalButton"); + loadRemoteButton->setObjectName("loadRemoteButton"); + loadFromClipboardButton->setObjectName("loadFromClipboardButton"); + readyStartButton->setObjectName("readyStartButton"); + forceStartGameButton->setObjectName("forceStartGameButton"); + unloadDeckButton->setObjectName("unloadDeckButton"); + sideboardLockButton->setObjectName("sideboardLockButton"); + connect(loadLocalButton, &QPushButton::clicked, this, &DeckViewContainer::loadLocalDeck); connect(loadRemoteButton, &QPushButton::clicked, this, &DeckViewContainer::loadRemoteDeck); connect(loadFromClipboardButton, &QPushButton::clicked, this, &DeckViewContainer::loadFromClipboard); diff --git a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp index 90cbed9a1..75ede0ca8 100644 --- a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp @@ -14,6 +14,7 @@ class TabGame; TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent) : QTabWidget(nullptr), playerId(_playerId), parentGame(parent) { + setObjectName(QString("tabbedDeckView_%1").arg(_playerId)); setTabsClosable(true); connect(this, &QTabWidget::tabCloseRequested, this, &TabbedDeckViewContainer::closeTab); diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index cb59b3d7d..e6d94a035 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -48,6 +48,7 @@ protected: GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, parent), rubberBand(0) { + setObjectName("gameView"); setBackgroundBrush(QBrush(QColor(0, 0, 0))); setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing); setViewportUpdateMode(BoundingRectViewportUpdate); diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp new file mode 100644 index 000000000..c6376ef75 --- /dev/null +++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp @@ -0,0 +1,628 @@ +#include "pq_cockatrice_event_player.h" + +#include "../../interface/widgets/tabs/tab_game.h" +#include "board/card_item.h" +#include "game_scene.h" +#include "game_view.h" +#include "zones/card_zone.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(PQ_COCKATRICE_EVENT_PLAYER, "cockatrice.guiTest.eventPlayer") + +namespace +{ +/** + * Strips Qt's mnemonic markers so a test can name a menu entry by the label the + * user sees ("Turn Over" rather than "T&urn Over"). + */ +QString normalizeActionText(const QString &text) +{ + QString normalized = text; + normalized.remove('&'); + return normalized.simplified(); +} + +/** + * Maps a printable character onto the key a US keyboard would report for it, so + * typing produces the same QKeyEvents a recording would. + */ +Qt::Key keyForCharacter(QChar character) +{ + if (character.isLetter()) { + return static_cast(Qt::Key_A + (character.toUpper().unicode() - 'A')); + } + if (character.isDigit()) { + return static_cast(Qt::Key_0 + (character.unicode() - '0')); + } + switch (character.unicode()) { + case ' ': + return Qt::Key_Space; + case '\n': + return Qt::Key_Return; + case '\t': + return Qt::Key_Tab; + case '-': + return Qt::Key_Minus; + case '_': + return Qt::Key_Underscore; + case '/': + return Qt::Key_Slash; + case ',': + return Qt::Key_Comma; + case '.': + return Qt::Key_Period; + case '\'': + return Qt::Key_Apostrophe; + case ';': + return Qt::Key_Semicolon; + case ':': + return Qt::Key_Colon; + case '"': + return Qt::Key_QuoteDbl; + case '&': + return Qt::Key_Ampersand; + case '(': + return Qt::Key_ParenLeft; + case ')': + return Qt::Key_ParenRight; + case '[': + return Qt::Key_BracketLeft; + case ']': + return Qt::Key_BracketRight; + default: + return Qt::Key_unknown; + } +} + +/** Depth-first search for the first model index whose label matches @p text. */ +QModelIndex findModelIndexByText(const QAbstractItemModel *model, const QString &text, const QModelIndex &parent = {}) +{ + for (int row = 0; row < model->rowCount(parent); ++row) { + const QModelIndex index = model->index(row, 0, parent); + if (index.data(Qt::DisplayRole).toString() == text) { + return index; + } + const QModelIndex child = findModelIndexByText(model, text, index); + if (child.isValid()) { + return child; + } + } + return {}; +} + +bool needsShift(QChar character) +{ + if (character.isUpper()) { + return true; + } + return QStringLiteral("_:\"&()").contains(character); +} +} // namespace + +pqCockatriceEventPlayer::pqCockatriceEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent) +{ +} + +bool pqCockatriceEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) +{ + // A known command that fails is a handled-but-failed event: QtTesting logs + // "Unhandled event" when playEvent returns false, so report through `error` + // (the contract playExpectProperty already follows) and still return true. + const auto run = [&error](bool ok) { + if (!ok) { + error = true; + } + return true; + }; + + if (command == "clickCard") { + return run(playClickCard(object, arguments)); + } else if (command == "flipCard") { + return run(playFlipCard(object, arguments)); + } else if (command == "cardAction") { + return run(playCardAction(object, arguments)); + } else if (command == "moveCard") { + return run(playMoveCard(object, arguments)); + } else if (command == "drawArrow") { + return run(playDrawArrow(object, arguments)); + } else if (command == "createToken") { + return run(playCreateToken(object, arguments)); + } else if (command == "selectItem") { + return run(playSelectItem(object, arguments)); + } else if (command == "typeText") { + return run(playTypeText(object, arguments)); + } else if (command == "expectProperty") { + return playExpectProperty(object, arguments, error); + } + return false; +} + +QGraphicsItem * +pqCockatriceEventPlayer::findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName) +{ + for (QGraphicsItem *gi : scene->items()) { + if (gi->type() != typeCard) { + continue; + } + auto *card = static_cast(gi); + if (!card->getZone() || card->getZone()->getName() != zoneName) { + continue; + } + QString actualName = card->getName(); + if (actualName == cardName || actualName.endsWith(" " + cardName)) { + return card; + } + } + return nullptr; +} + +QGraphicsItem *pqCockatriceEventPlayer::findZoneInScene(QGraphicsScene *scene, const QString &zoneName) +{ + for (QGraphicsItem *gi : scene->items()) { + if (gi->type() != typeZone) { + continue; + } + auto *zone = static_cast(gi); + if (zone->getLogic() && zone->getLogic()->getName() == zoneName) { + return zone; + } + } + return nullptr; +} + +QAction *pqCockatriceEventPlayer::findActionInMenu(QMenu *menu, const QString &actionText) +{ + if (!menu || actionText.isEmpty()) { + return nullptr; + } + for (QAction *action : menu->actions()) { + if (action->isSeparator()) { + continue; + } + if (normalizeActionText(action->text()).contains(actionText, Qt::CaseInsensitive)) { + return action; + } + if (QMenu *subMenu = action->menu()) { + if (QAction *found = findActionInMenu(subMenu, actionText)) { + return found; + } + } + } + return nullptr; +} + +QMenu *pqCockatriceEventPlayer::visiblePopupMenu() +{ + for (QWidget *w : QApplication::topLevelWidgets()) { + auto *menu = qobject_cast(w); + if (menu && menu->isVisible()) { + return menu; + } + } + return nullptr; +} + +QPoint pqCockatriceEventPlayer::viewportPoint(QGraphicsView *view, const QGraphicsItem *item) +{ + return view->mapFromScene(item->sceneBoundingRect().center()); +} +void pqCockatriceEventPlayer::sendMouseEvent(QGraphicsView *view, + QEvent::Type type, + const QPoint &pos, + Qt::MouseButton button, + Qt::MouseButtons buttons) +{ + QMouseEvent event(type, pos, view->viewport()->mapToGlobal(pos), button, buttons, Qt::NoModifier); + qApp->notify(view->viewport(), &event); +} + +bool pqCockatriceEventPlayer::playClickCard(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: arguments must be 'cardName,zoneName', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: no scene on the QGraphicsView"; + return false; + } + + auto *target = findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()); + if (!target) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "clickCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + const QPoint pos = viewportPoint(gv, target); + // A double click is the play gesture from hand: press/release, then the + // double-click event and its release, exactly as a recording produces it. + sendMouseEvent(gv, QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseButtonDblClick, pos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton); + + return true; +} + +bool pqCockatriceEventPlayer::playFlipCard(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: arguments must be 'cardName,zoneName', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: no scene"; + return false; + } + + auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed())); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "flipCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + return invokeCardMenuAction("flipCard", gv, card, QStringLiteral("Turn Over")); +} + +bool pqCockatriceEventPlayer::playCardAction(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "cardAction: arguments must be 'cardName,zoneName,actionText', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: no scene"; + return false; + } + + auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed())); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "cardAction: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + return invokeCardMenuAction("cardAction", gv, card, parts.mid(2).join(',').trimmed()); +} + +bool pqCockatriceEventPlayer::invokeCardMenuAction(const char *command, + QGraphicsView *view, + CardItem *card, + const QString &actionText) +{ + const QPoint pos = viewportPoint(view, card); + sendMouseEvent(view, QEvent::MouseButtonPress, pos, Qt::RightButton, Qt::RightButton); + sendMouseEvent(view, QEvent::MouseButtonRelease, pos, Qt::RightButton, Qt::NoButton); + qApp->processEvents(); + + QMenu *menu = visiblePopupMenu(); + if (!menu) { + qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no popup menu appeared after right-click"; + return false; + } + + QAction *action = findActionInMenu(menu, actionText); + if (!action) { + qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no context menu action matching" << actionText; + menu->close(); + return false; + } + + action->trigger(); + menu->close(); + qApp->processEvents(); + return true; +} + +bool pqCockatriceEventPlayer::playMoveCard(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "moveCard: arguments must be 'cardName,srcZoneName,dstZoneName', got:" << arguments; + return false; + } + + const QString cardName = parts[0].trimmed(); + const QString srcZoneName = parts[1].trimmed(); + const QString dstZoneName = parts[2].trimmed(); + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: no scene"; + return false; + } + + auto *card = findCardInScene(scene, cardName, srcZoneName); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: card" << cardName << "not found in zone" << srcZoneName; + return false; + } + + auto *dstZone = findZoneInScene(scene, dstZoneName); + if (!dstZone) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: destination zone" << dstZoneName << "not found"; + return false; + } + + const QPoint srcPos = viewportPoint(gv, card); + const QPoint dstPos = viewportPoint(gv, dstZone); + + // A drag has to travel past Qt's start distance before the scene turns it + // into a card drag item, otherwise the move is interpreted as a click. + const int dragDistance = 2 * QApplication::startDragDistance() + 10; + const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0); + + sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::LeftButton, Qt::LeftButton); + qApp->processEvents(); + sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::LeftButton, Qt::LeftButton); + qApp->processEvents(); + sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::LeftButton, Qt::NoButton); + qApp->processEvents(); + + return true; +} + +bool pqCockatriceEventPlayer::playDrawArrow(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 4) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: arguments must be 'srcCard,srcZone,dstCard,dstZone', got:" << arguments; + return false; + } + + const QString srcCardName = parts[0].trimmed(); + const QString srcZoneName = parts[1].trimmed(); + const QString dstCardName = parts[2].trimmed(); + const QString dstZoneName = parts[3].trimmed(); + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: no scene"; + return false; + } + + auto *srcCard = findCardInScene(scene, srcCardName, srcZoneName); + if (!srcCard) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: source card" << srcCardName << "not found in zone" << srcZoneName; + return false; + } + + auto *dstCard = findCardInScene(scene, dstCardName, dstZoneName); + if (!dstCard) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: dest card" << dstCardName << "not found in zone" << dstZoneName; + return false; + } + + const QPoint srcPos = viewportPoint(gv, srcCard); + const QPoint dstPos = viewportPoint(gv, dstCard); + + const int dragDistance = 2 * QApplication::startDragDistance() + 10; + const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0); + + sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::RightButton, Qt::RightButton); + sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::RightButton, Qt::RightButton); + qApp->processEvents(); + sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::RightButton, Qt::RightButton); + sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::RightButton, Qt::NoButton); + qApp->processEvents(); + + return true; +} + +bool pqCockatriceEventPlayer::playCreateToken(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: arguments must be 'name,color,pt', got:" << arguments; + return false; + } + + QString tokenName = parts[0].trimmed(); + QString tokenColor = parts[1].trimmed(); + QString tokenPt = parts[2].trimmed(); + + TabGame *tabGame = nullptr; + QWidget *w = gv; + while (w) { + tabGame = qobject_cast(w); + if (tabGame) { + break; + } + w = w->parentWidget(); + } + if (!tabGame) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: TabGame not found"; + return false; + } + + AbstractGame *game = tabGame->getGame(); + if (!game) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no game"; + return false; + } + + int activePlayerId = game->getGameState()->getActivePlayer(); + PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(activePlayerId); + if (!player) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no active local player"; + return false; + } + + TokenInfo info; + info.name = tokenName; + info.color = tokenColor; + info.pt = tokenPt; + info.destroy = true; + info.faceDown = false; + + player->getPlayerActions()->actCreateToken(info); + + for (int i = 0; i < 10; ++i) { + qApp->processEvents(); + } + + return true; +} + +bool pqCockatriceEventPlayer::playSelectItem(QObject *object, const QString &arguments) +{ + const QString text = arguments.trimmed(); + if (text.isEmpty()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: needs the label of the item to select"; + return false; + } + + if (auto *tree = qobject_cast(object)) { + QTreeWidgetItemIterator item(tree); + while (*item) { + if ((*item)->text(0) == text) { + tree->setCurrentItem(*item); + qApp->processEvents(); + return true; + } + ++item; + } + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no tree item labelled" << text; + return false; + } + + if (auto *view = qobject_cast(object)) { + const QModelIndex index = findModelIndexByText(view->model(), text); + if (!index.isValid()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no item labelled" << text; + return false; + } + view->setCurrentIndex(index); + qApp->processEvents(); + return true; + } + + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: target is neither a tree widget nor an item view"; + return false; +} + +bool pqCockatriceEventPlayer::playTypeText(QObject *object, const QString &arguments) +{ + auto *widget = qobject_cast(object); + if (!widget) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "typeText: target is not a QWidget"; + return false; + } + + for (const QChar character : arguments) { + const Qt::Key key = keyForCharacter(character); + const Qt::KeyboardModifiers modifiers = needsShift(character) ? Qt::ShiftModifier : Qt::NoModifier; + QKeyEvent press(QEvent::KeyPress, key, modifiers, QString(character)); + QKeyEvent release(QEvent::KeyRelease, key, modifiers, QString(character)); + qApp->notify(widget, &press); + qApp->notify(widget, &release); + } + qApp->processEvents(); + + return true; +} + +bool pqCockatriceEventPlayer::playExpectProperty(QObject *object, const QString &arguments, bool &error) +{ + if (!object) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty: no target object"; + error = true; + return true; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "expectProperty: arguments must be 'propertyName,expectedValue', got:" << arguments; + error = true; + return true; + } + + const QString propertyName = parts[0].trimmed(); + const QString expected = parts.mid(1).join(','); + + const QVariant actual = object->property(propertyName.toUtf8().constData()); + if (!actual.isValid()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "expectProperty:" << object->objectName() << "has no property" << propertyName; + error = true; + return true; + } + + if (actual.toString() != expected) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty:" << object->objectName() << propertyName << "is" + << actual.toString() << "but the test expected" << expected; + error = true; + return true; + } + + return true; +} diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.h b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h new file mode 100644 index 000000000..89d88af77 --- /dev/null +++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h @@ -0,0 +1,91 @@ +#ifndef PQ_COCKATRICE_EVENT_PLAYER_H +#define PQ_COCKATRICE_EVENT_PLAYER_H + +#include "pqWidgetEventPlayer.h" + +#include + +class CardItem; +class QAction; +class QGraphicsItem; +class QGraphicsScene; +class QGraphicsView; +class QMenu; + +/** + * Custom event player for Cockatrice game interactions. + * + * Card commands are resolution independent: the card is looked up in the + * QGraphicsScene by name and zone, then mouse events are sent at the card's + * current viewport position. That keeps recordings usable across screen sizes + * and window geometries. + * + * Commands: + * clickCard "cardName,zoneName" + * left press/release + double click — plays a card from hand, taps a + * permanent, opens the deck view entry, depending on the zone + * flipCard "cardName,zoneName" + * right-click → "Turn Over" (shorthand for cardAction) + * cardAction "cardName,zoneName,actionText" + * right-click the card and trigger the first context menu action whose + * label contains actionText; submenus are searched as well, so + * "Add counter (A)" works + * moveCard "cardName,srcZoneName,dstZoneName" + * left-drag the card onto the destination zone — plays a card, moves it + * to the graveyard/exile, puts it back into hand, ... + * drawArrow "srcCard,srcZone,dstCard,dstZone" + * right-drag an arrow from one card to another + * createToken "name,color,pt" + * create a token through the active local player, as the table context + * menu's "Create token" dialog would + * selectItem "item text" + * selects the first item of a tree/list widget whose label matches, for + * navigating the settings navigation tree or similar label-driven views + * typeText "some text" + * types the text into the widget one key event at a time — use it for + * deck lists and search boxes instead of one event per character + * expectProperty "propertyName,expectedValue" + * assertion: playback fails unless object->property(propertyName) + * equals expectedValue — for example "count,2" on the tab widget + */ +class pqCockatriceEventPlayer : public pqWidgetEventPlayer +{ + Q_OBJECT + typedef pqWidgetEventPlayer Superclass; + +public: + explicit pqCockatriceEventPlayer(QObject *parent = nullptr); + + using Superclass::playEvent; + + bool playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) override; + +private: + bool playClickCard(QObject *object, const QString &arguments); + bool playFlipCard(QObject *object, const QString &arguments); + bool playCardAction(QObject *object, const QString &arguments); + bool playMoveCard(QObject *object, const QString &arguments); + bool playDrawArrow(QObject *object, const QString &arguments); + bool playCreateToken(QObject *object, const QString &arguments); + bool playSelectItem(QObject *object, const QString &arguments); + bool playTypeText(QObject *object, const QString &arguments); + bool playExpectProperty(QObject *object, const QString &arguments, bool &error); + + /** @brief Triggers the context menu entry @p actionText after right-clicking @p card. */ + bool invokeCardMenuAction(const char *command, QGraphicsView *view, CardItem *card, const QString &actionText); + + static QGraphicsItem *findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName); + static QGraphicsItem *findZoneInScene(QGraphicsScene *scene, const QString &zoneName); + static QAction *findActionInMenu(QMenu *menu, const QString &actionText); + static QMenu *visiblePopupMenu(); + + /** @brief Viewport position of the item's centre, as the mouse events have to be sent. */ + static QPoint viewportPoint(QGraphicsView *view, const QGraphicsItem *item); + static void sendMouseEvent(QGraphicsView *view, + QEvent::Type type, + const QPoint &pos, + Qt::MouseButton button, + Qt::MouseButtons buttons); +}; + +#endif // PQ_COCKATRICE_EVENT_PLAYER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp index 2c0fd3f6f..c1d0676ca 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp @@ -28,12 +28,16 @@ class QWidget; */ AbstractDlgDeckTextEdit::AbstractDlgDeckTextEdit(QWidget *parent) : QDialog(parent) { + setObjectName("dlgDeckTextEdit"); + contentsEdit = new QPlainTextEdit; + contentsEdit->setObjectName("contentsEdit"); refreshButton = new QPushButton(tr("&Refresh")); connect(refreshButton, &QPushButton::clicked, this, &AbstractDlgDeckTextEdit::actRefresh); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttonBox->setObjectName("buttonBox"); buttonBox->addButton(refreshButton, QDialogButtonBox::ActionRole); connect(buttonBox, &QDialogButtonBox::accepted, this, &AbstractDlgDeckTextEdit::actOK); connect(buttonBox, &QDialogButtonBox::rejected, this, &AbstractDlgDeckTextEdit::reject); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp index 858deb751..3ebb5c093 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp @@ -16,8 +16,11 @@ class QWidget; DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) { + setObjectName("localGameDialog"); + numberPlayersLabel = new QLabel(tr("Players:"), this); numberPlayersEdit = new QSpinBox(this); + numberPlayersEdit->setObjectName("numberPlayersEdit"); numberPlayersEdit->setMinimum(1); numberPlayersEdit->setMaximum(8); numberPlayersEdit->setValue(1); @@ -28,10 +31,12 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) generalGrid->addWidget(numberPlayersLabel, 0, 0); generalGrid->addWidget(numberPlayersEdit, 0, 1); generalGroupBox = new QGroupBox(tr("General"), this); + generalGroupBox->setObjectName("generalGroupBox"); generalGroupBox->setLayout(generalGrid); startingLifeTotalLabel = new QLabel(tr("Starting life total:"), this); startingLifeTotalEdit = new QSpinBox(this); + startingLifeTotalEdit->setObjectName("startingLifeTotalEdit"); startingLifeTotalEdit->setMinimum(-999999999); startingLifeTotalEdit->setMaximum(999999999); startingLifeTotalEdit->setValue(20); @@ -42,11 +47,14 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) gameSetupGrid->addWidget(startingLifeTotalLabel, 0, 0); gameSetupGrid->addWidget(startingLifeTotalEdit, 0, 1); gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"), this); + gameSetupOptionsGroupBox->setObjectName("gameSetupOptionsGroupBox"); gameSetupOptionsGroupBox->setLayout(gameSetupGrid); rememberSettingsCheckBox = new QCheckBox(tr("Remember settings"), this); + rememberSettingsCheckBox->setObjectName("rememberSettingsCheckBox"); buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->setObjectName("buttonBox"); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgLocalGameOptions::actOK); connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgLocalGameOptions::reject); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 9e724727f..cbff850a4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -99,6 +99,7 @@ class Message; TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr) { + setObjectName("TabGame"); // THIS CTOR IS USED ON REPLAY game = new Replay(this, _replay, tabSupervisor->getIsLocalGame(), &SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this)); @@ -116,6 +117,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) addDockWidget(Qt::BottomDockWidgetArea, replayDock); mainWidget = new QStackedWidget(this); + mainWidget->setObjectName("mainWidget"); mainWidget->addWidget(deckViewContainerWidget); mainWidget->addWidget(gamePlayAreaWidget); setCentralWidget(mainWidget); @@ -144,6 +146,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, const QMap &_roomGameTypes) : Tab(_tabSupervisor), userListProxy(_tabSupervisor->getUserListManager()) { + setObjectName("TabGame"); // THIS CTOR IS USED ON GAMES game = new Game(this, tabSupervisor->getIsLocalGame(), _clients, event, _roomGameTypes, &SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this)); @@ -160,6 +163,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock); mainWidget = new QStackedWidget(this); + mainWidget->setObjectName("mainWidget"); mainWidget->addWidget(deckViewContainerWidget); mainWidget->addWidget(gamePlayAreaWidget); mainWidget->setContentsMargins(0, 0, 0, 0); @@ -1118,6 +1122,7 @@ void TabGame::createMenuItems() connect(aFocusChat, &QAction::triggered, sayEdit, qOverload<>(&LineEditCompleter::setFocus)); phasesMenu = new TearOffMenu(this); + phasesMenu->setObjectName("phasesMenu"); for (int i = 0; i < phasesToolbar->phaseCount(); ++i) { auto *temp = new QAction(QString(), this); @@ -1128,12 +1133,16 @@ void TabGame::createMenuItems() phasesMenu->addSeparator(); phasesMenu->addAction(aNextPhase); + aNextPhase->setObjectName("aNextPhase"); phasesMenu->addAction(aNextPhaseAction); + aNextPhaseAction->setObjectName("aNextPhaseAction"); gameMenu = new QMenu(this); + gameMenu->setObjectName("gameMenu"); playersSeparator = gameMenu->addSeparator(); gameMenu->addMenu(phasesMenu); gameMenu->addAction(aNextTurn); + aNextTurn->setObjectName("aNextTurn"); gameMenu->addAction(aReverseTurn); gameMenu->addSeparator(); gameMenu->addAction(aRemoveLocalArrows); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 13f2e43cd..79a802234 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -56,6 +56,7 @@ #include "widgets/settings_page/abstract_settings_page.h" #include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h" #ifdef WITH_GUI_TEST +#include "../game_graphics/pq_cockatrice_event_player.h" #include "pqEventObserver.h" #include "pqEventSource.h" #include "pqEventTypes.h" @@ -689,6 +690,7 @@ MainWindow::MainWindow(QWidget *parent) testUtility->setRecordWithDialog(true); testUtility->addEventObserver("xml", new XMLEventObserver(this)); testUtility->addEventSource("xml", new XMLEventSource(this)); + testUtility->eventPlayer()->addWidgetEventPlayer(new pqCockatriceEventPlayer(this)); #endif } From 96f8c3a423931fa96a046f8ed1a2f99abfd56af5 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:23:45 +0100 Subject: [PATCH 021/104] [Tests] Assert settings navigation through the event player (#7698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- Testing/settings_navigation_test.xml | 33 +++++++++++++++++++ .../widgets/dialogs/settings_view.cpp | 3 ++ 2 files changed, 36 insertions(+) create mode 100644 Testing/settings_navigation_test.xml diff --git a/Testing/settings_navigation_test.xml b/Testing/settings_navigation_test.xml new file mode 100644 index 000000000..c112966e0 --- /dev/null +++ b/Testing/settings_navigation_test.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 96b429a61..9c0f12979 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -144,6 +144,7 @@ void SettingsView::setupUi() // Pages stacked widget pagesWidget = new QStackedWidget; + pagesWidget->setObjectName("pagesWidget"); auto *generalPage = new GeneralSettingsPage; auto *appearancePage = new AppearanceSettingsPage; @@ -208,6 +209,7 @@ void SettingsView::setupUi() // Pages container (stacked widget + search results overlay) pagesContainer = new QWidget; + pagesContainer->setObjectName("pagesContainer"); auto *containerLayout = new QStackedLayout; containerLayout->setStackingMode(QStackedLayout::StackAll); containerLayout->addWidget(pagesWidget); @@ -247,6 +249,7 @@ void SettingsView::setupUi() void SettingsView::setupNavTree() { navTree = new QTreeWidget; + navTree->setObjectName("navTree"); navTree->setHeaderHidden(true); navTree->setRootIsDecorated(true); navTree->setIconSize(QSize(24, 24)); From 99422c2bf002fab97e3a8ea13f89f072a53ea533 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:06:35 +0100 Subject: [PATCH 022/104] [Client] Stop sending the saved login when its checkbox is unticked (#7713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unticking 'Save password' only disabled auto-connect: the dialog still handed the stored verifier to the connection, so a password typed at the same time was ignored, and a server that still has the legacy hash refused the login before it could upgrade the account. Clear the verifier in effect on untick, restore it on re-tick unless a password has been typed, and drop the stale 'Password saved' hint. The downgrade abort can now point at unticking the box and logging in with the password, without the extra reconnect round-trip. Co-authored-by: Lukas Brübach --- .../interface/widgets/dialogs/dlg_connect.cpp | 22 +++++++++++++++---- .../interface/widgets/dialogs/dlg_connect.h | 4 ++++ .../network/client/remote/remote_client.cpp | 5 +++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index e4411ec1d..853a00828 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -296,11 +296,13 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) playernameEdit->setFocus(); savePasswordCheckBox->setChecked(savePasswordStatus); storedVerifier.clear(); + savedVerifier.clear(); if (savePasswordStatus) { const QString stored = _data.at(4); if (stored.startsWith("$")) { storedVerifier = stored; + savedVerifier = stored; passwordEdit->clear(); passwordEdit->setPlaceholderText(tr("Password saved")); } else { @@ -337,6 +339,7 @@ void DlgConnect::newHostSelected(bool state) passwordEdit->clear(); passwordEdit->setPlaceholderText(QString()); storedVerifier.clear(); + savedVerifier.clear(); saveEdit->clear(); saveEdit->setPlaceholderText(tr("Unique Server Name")); saveEdit->setDisabled(false); @@ -352,9 +355,19 @@ void DlgConnect::passwordSaved(QT_STATE_CHANGED_T state) Q_UNUSED(state); if (savePasswordCheckBox->isChecked()) { autoConnectCheckBox->setEnabled(true); + // Re-ticking keeps a password the user has started typing; otherwise restore the saved + // login this dialog was opened with, so unticking and changing your mind is harmless. + if (passwordEdit->text().isEmpty()) { + storedVerifier = savedVerifier; + passwordEdit->setPlaceholderText(savedVerifier.isEmpty() ? QString() : tr("Password saved")); + } } else { autoConnectCheckBox->setChecked(false); autoConnectCheckBox->setEnabled(false); + // Unticking means the password typed below is sent for this connection: stop sending the + // saved verifier instead, and stop claiming a password is saved. + storedVerifier.clear(); + passwordEdit->setPlaceholderText(QString()); } } @@ -362,10 +375,11 @@ void DlgConnect::actOk() { ServersSettings &servers = SettingsCache::instance().servers(); - // Never write a newly typed plaintext password to disk when a verifier is already stored: - // the typed value is used for this connection and a fresh verifier is persisted after a - // successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior. - const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier; + // Never write a newly typed plaintext password to disk when a verifier is available: the + // typed value is used for this connection and a fresh verifier is persisted after a + // successful login. Without any verifier we keep the previous (plaintext legacy) behavior. + const QString verifierToSave = storedVerifier.isEmpty() ? savedVerifier : storedVerifier; + const QString passwordToSave = verifierToSave.isEmpty() ? passwordEdit->text() : verifierToSave; if (newHostButton->isChecked()) { if (saveEdit->text().isEmpty()) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h index bef5f136e..dad3623f5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h @@ -98,6 +98,10 @@ private: QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer; QMap> savedHostList; HandlePublicServers *hps; + //! \brief Saved "$scrypt$..." verifier as loaded for the selected profile, kept so that + //! re-ticking "Save password" after unticking restores it. + QString savedVerifier; + //! \brief Verifier in effect for this connection: empty while "Save password" is unticked. QString storedVerifier; const QString placeHolderText = tr("Downloading..."); }; diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index fa75e356a..636c603c6 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -50,11 +50,12 @@ namespace // The server warns that this account still uses the legacy password hash while this client // holds a scrypt verifier. Sending the verifier would downgrade the proof, so the client // stops; the login only succeeds after the saved verifier is gone and the user types the -// password, which also upgrades the account. +// password, which also upgrades the account. Unticking "Save password" drops the verifier +// for the next connection. constexpr const char *const LOGIN_ABORT_AUTH_DOWNGRADE = QT_TRANSLATE_NOOP( "RemoteClient", "The server still uses an old password format for this account, so the saved login on this computer cannot be " - "used. Untick 'Save password' in the Connect window and connect; when it opens again, type your password."); + "used. Untick 'Save password' in the Connect window and log in with your password."); // The account is still on the legacy hash and only the plaintext password can produce it. constexpr const char *const LOGIN_ABORT_PASSWORD_REQUIRED = QT_TRANSLATE_NOOP("RemoteClient", From 75beb7f302c0b65aa43ae7954660191187c474e7 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:22:43 +0100 Subject: [PATCH 023/104] [CI] Run the Linux test suite against the release build (#7665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CI] Run the Linux test suite against the release build A packaging leg compiled the tree twice: once in Debug to run the suite, once in Release to package it. The suite is 655 tests that finish in four seconds, so the Debug build bought nothing the release build cannot provide - macOS and Windows have always tested their release build. The sanitizer and coverage legs keep their Debug build, because that is the configuration their instrumentation is for. Every other leg runs ctest against the release configuration, ahead of the package target, so a failing suite still stops the leg before anything is built into an installer. A leg that only tests, Arch, builds Release without the crash reporting SDK the packaging legs ship with. Debian 13 and Fedora 44 each drop a 15 minute compile from a job that gates a pull request. * [CI] Keep Fedora 44 and Arch on the Debug build Only the Debug configuration passes -Wall -Wextra -Werror, so moving those two legs to the release build would have stopped the newest distro toolchains from failing on a new warning. They keep the Debug compile and test master had; the other Linux legs still test the release build. Addresses review thread PRRT_kwDOACxbl86x2tOP (PR #7665). * [CI] Decide the release test run once per leg The step's if: and its shell both recomputed "does this leg test the release build", and the two could drift into a leg that builds a release it never tests. RUN_TESTS now holds that decision and both read it. It lives at job level because a step's own env is not visible to that step's if:. Addresses review thread PRRT_kwDOACxbl86x2tOV (PR #7665). --------- Co-authored-by: Lukas Brübach --- .github/workflows/desktop-build.yml | 45 +++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index df054ea26..16610d872 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -141,6 +141,7 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already + debug_test: yes # see the note on Fedora 44 below - distro: Servatrice_Debian version: 12 @@ -170,6 +171,11 @@ jobs: version: 44 package: RPM + # Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions + # in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug: + # only the Debug configuration passes -Werror, so these two are the legs where a + # new warning in either compiler still fails the build. + debug_test: yes - distro: Ubuntu version: 24.04 @@ -212,6 +218,15 @@ jobs: CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} + # Which build the suite runs in, decided once per leg here so the steps' if: conditions + # and their shells cannot drift apart. A step's own env is not visible to its if:, which + # is why these live at job level rather than on the release step. + # DEBUG_TEST the instrumented legs (symbols and instrumentation matter) and the two + # newest distro toolchains, which only fail on a warning in Debug. + # RUN_TESTS every other leg that tests runs the suite against the release build it may + # also package, so testing and packaging cost one compile instead of two. + DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} + RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} steps: - name: "Checkout" @@ -228,8 +243,11 @@ jobs: shell: bash run: source .ci/docker.sh --build + # The instrumented legs and the two newest distro toolchains build and test Debug; every + # other leg runs its suite against the release build below, so testing and packaging cost + # one compile instead of two. - name: "Build debug and test" - if: matrix.test != 'skip' + if: env.DEBUG_TEST == 'true' shell: bash env: sanitize: '${{ matrix.sanitize }}' @@ -269,15 +287,24 @@ jobs: path: coverage/ if-no-files-found: warn - - name: "Build release package" + # One build serves both purposes: the suite runs against the same configuration that + # gets packaged, so a packaging leg no longer pays for a full Debug compile it then + # throws away. macOS and Windows have always tested their release build. + # + # Instrumented and debug-test legs reach this step only when they package (the sanitizer + # leg currently does, the coverage leg does not); their suite already ran in the debug + # step above, so the tests are not repeated here. + - name: "Build release, test and package" id: build - if: matrix.package != 'skip' + if: matrix.package != 'skip' || env.RUN_TESTS == 'true' shell: bash env: SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}' package: '${{ matrix.package }}' server_only: '${{ matrix.server_only }}' - WITH_CRASH_REPORTING: 'ON' + # Only a leg that packages builds the shipping configuration; a release build that + # only runs tests stays free of the crash reporting SDK. + WITH_CRASH_REPORTING: ${{ matrix.package != 'skip' && 'ON' || '' }} # Tag builds only: artifacts from master pushes and PR branches must not report to # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way. # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN @@ -287,6 +314,7 @@ jobs: source .ci/docker.sh args=() [[ $server_only == yes ]] && args+=(--no-client) + [[ $RUN_TESTS == true ]] && args+=(--test) [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE") args+=(--ccache "$CCACHE_SIZE") args+=(--cmake-generator "$CMAKE_GENERATOR") @@ -296,7 +324,14 @@ jobs: # configures, and empty keeps the mock fallback. export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN" - RUN --server --release --package "$package" "${args[@]}" + # --package takes an optional type, so the flag and its argument have to travel + # together or the type would become the next flag. + package_args=() + [[ -n $package && $package != skip ]] && package_args+=(--package "$package") + + # compile.sh runs the tests before the package target, so a failing suite stops the + # leg before anything is built into an installer. + RUN --server --release "${package_args[@]}" "${args[@]}" - name: "Install sentry-cli" if: matrix.package != 'skip' && needs.configure.outputs.tag != null From 3f7aa4ca8222d8bd813aaa5b30df1b2be2cba45f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:24:24 +0100 Subject: [PATCH 024/104] [CI] Split the sanitizer leg from the Ubuntu packaging leg (#7666) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sanitize leg re-ran the Release packaging build after the instrumented one, so 18 minutes of Release compile queued behind the sanitizer run in a single job. Give sanitize its own leg with its own image and ccache, and keep the plain Ubuntu leg publishing the DEB. - The coverage leg's packaging comment now points at the leg that actually publishes the DEB. - The -DENABLE_SANITIZERS=OFF comment no longer describes a debug-sanitize then release-package sequence that no job runs. Co-authored-by: Lukas Brübach --- .ci/compile.sh | 6 +++--- .github/workflows/desktop-build.yml | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 9be8ba741..1db0cacd1 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -175,9 +175,9 @@ fi if [[ $MAKE_SANITIZE ]]; then flags+=("-DENABLE_SANITIZERS=ON") else - # The release-package step re-runs cmake in the same build dir, so an - # ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak - # into the shipped binaries (libasan/libubsan Depends in the DEB). + # Every leg states the flag explicitly rather than relying on the CMake + # default, so a build directory reused from a sanitized configuration can + # never leak libasan/libubsan into a shipped binary. flags+=("-DENABLE_SANITIZERS=OFF") fi if [[ $MAKE_COVERAGE ]]; then diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 16610d872..9f2d679fa 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -187,10 +187,21 @@ jobs: version: 26.04 package: DEB + test: skip # The sanitizer leg below runs this distro's suite - # The one leg that runs the suite under AddressSanitizer and - # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the - # uninstrumented suite, so nothing is lost by instrumenting this one. + # The one leg that runs the suite under AddressSanitizer and + # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the + # uninstrumented suite, so nothing is lost by instrumenting this one. + # + # It packages nothing. A sanitized Debug build is not something to ship, and + # carrying the package build here meant 18 minutes of Release compile queued + # behind the instrumented one in a single job. The leg above publishes this + # distro's DEB. + - distro: Ubuntu + version: 26.04 + variant: sanitize # separate image and ccache, same distro + + package: skip sanitize: yes - distro: Ubuntu @@ -202,7 +213,7 @@ jobs: # sanitizer leg because the two instruments answer different questions: the # sanitizer leg reports what the run did wrong, the coverage leg reports what the # run never touched at all. Combining them would also slow both down. Packaging is - # skipped because the leg above already publishes the DEB for this distro. + # skipped because the plain Ubuntu 26.04 leg publishes the DEB for this distro. coverage: yes package: skip From 140fbb4484d2a4854a2d4927ca34590514a3cde1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:42:35 +0100 Subject: [PATCH 025/104] [CI] Cover the legacy distros and the slow macOS runner in a nightly (#7668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CI] Cover the legacy distros and the slow macOS runner in a nightly Every pull request built ten Linux legs and five macOS/Windows legs, including the previous release of each distro and macOS 13 on the Intel runner. Those legs exist to catch a regression for users on an older distribution, which a change being reviewed almost never does, and macOS 13 Intel is the slowest leg of the matrix - 28 minutes against 19 for the rest. They now run in a nightly build of the default branch and on tag builds, which is when a change actually ships. Pull requests keep the current release of each distro, the sanitizer leg and the coverage leg. * [CI] Run the nightly legs from their own jobs jobs..if cannot read matrix, so the previous filter never validated and no leg of this workflow ran. matrix.exclude cannot express it either: GitHub processes exclude before include, and these matrices are include-only. Split the regular and nightly legs into two jobs per platform, sharing the step list through a YAML anchor and gating the jobs on the event instead of on matrix. The nightly run now covers only the legs a push leaves out, and the event name is part of the concurrency group so a nightly and a master push do not cancel each other. * [CI] Run the coverage leg in the nightly gcovr.cfg sets no fail_under, so the leg cannot fail a build, and nothing consumes coverage-report: it was an artifact produced on every pull request and read by no one. In the nightly it becomes a trend over the default branch instead, next to the legs a change being reviewed does not wait for. The leg itself is unchanged - same distro, version and variant, so the image name and ccache key still match the warm cache it built. It is marked allow-failure so a reporting leg cannot redden the nightly run that vcpkg-cache-import requires to conclude in success. The release-test comment loses its per-leg parenthetical: it claimed the sanitizer leg packages, which #7666 stopped being true, and the coverage leg is no longer in this job. --------- Co-authored-by: Lukas Brübach --- .github/workflows/desktop-build.yml | 178 +++++++++++++++-------- .github/workflows/vcpkg-cache-import.yml | 16 +- 2 files changed, 129 insertions(+), 65 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 9f2d679fa..d72cb881e 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -33,10 +33,18 @@ on: - 'CMakeLists.txt' - 'vcpkg.json' - 'vcpkg' # needed to match submodule bumps (gitlink) + # The legs the pull-request matrix leaves out run here instead, so the legacy distros and + # the slow macOS runner stay covered without gating a pull request on them. Tags still + # build every leg, because a tag is what ships. + schedule: + - cron: '23 3 * * *' # Cancel earlier, unfinished runs of this workflow on the same branch (unless on tag --> release) concurrency: - group: "${{ github.workflow }} @ ${{ github.ref_name }}" + # The event name is part of the group because a nightly run and a push run on master share + # refs/heads/master but must not cancel each other: the nightly would drop the push's vcpkg + # import, and a merge landing during the nightly would drop that night's coverage. + group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})" cancel-in-progress: ${{ github.ref_type != 'tag' }} jobs: @@ -150,23 +158,11 @@ jobs: server_only: yes test: skip - - distro: Debian - version: 12 - - package: DEB - test: skip # Running tests on all distros is superfluous - - distro: Debian version: 13 package: DEB - - distro: Fedora - version: 43 - - package: RPM - test: skip # Running tests on all distros is superfluous - - distro: Fedora version: 44 @@ -177,12 +173,6 @@ jobs: # new warning in either compiler still fails the build. debug_test: yes - - distro: Ubuntu - version: 24.04 - - package: DEB - test: skip # Running tests on all distros is superfluous - - distro: Ubuntu version: 26.04 @@ -204,21 +194,11 @@ jobs: package: skip sanitize: yes - - distro: Ubuntu - version: 26.04 - variant: cover # separate image and ccache, same distro - - # The one leg that runs instrumented for line coverage and publishes the report as - # a workflow artifact. It is a separate leg rather than a second flag on the - # sanitizer leg because the two instruments answer different questions: the - # sanitizer leg reports what the run did wrong, the coverage leg reports what the - # run never touched at all. Combining them would also slow both down. Packaging is - # skipped because the plain Ubuntu 26.04 leg publishes the DEB for this distro. - coverage: yes - package: skip - name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure + # The nightly legs live in build-linux-nightly below. A leg is in exactly one of the two + # matrices, so nothing that reviews a change waits for a distro nobody is changing for. + if: github.event_name != 'schedule' runs-on: ubuntu-latest # https://github.com/actions/runner-images continue-on-error: ${{ matrix.allow-failure == 'yes' }} timeout-minutes: 70 @@ -239,7 +219,8 @@ jobs: DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} - steps: + # Shared with build-linux-nightly so the two leg sets run the same steps. + steps: &linux-steps - name: "Checkout" uses: actions/checkout@v7 @@ -302,9 +283,8 @@ jobs: # gets packaged, so a packaging leg no longer pays for a full Debug compile it then # throws away. macOS and Windows have always tested their release build. # - # Instrumented and debug-test legs reach this step only when they package (the sanitizer - # leg currently does, the coverage leg does not); their suite already ran in the debug - # step above, so the tests are not repeated here. + # Instrumented and debug-test legs reach this step only when they also package; their + # suite already ran in the debug step above, so the tests are not repeated here. - name: "Build release, test and package" id: build if: matrix.package != 'skip' || env.RUN_TESTS == 'true' @@ -444,30 +424,72 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice + build-linux-nightly: + strategy: + fail-fast: false + matrix: + include: + - distro: Debian + version: 12 # previous stable, still covered by the nightly + + package: DEB + test: skip # Running tests on all distros is superfluous + + - distro: Fedora + version: 43 # previous release, still covered by the nightly + + package: RPM + test: skip # Running tests on all distros is superfluous + + - distro: Ubuntu + version: 24.04 # previous LTS, still covered by the nightly + + package: DEB + test: skip # Running tests on all distros is superfluous + + # The one leg that runs instrumented for line coverage and publishes the report as + # a workflow artifact. It stays a separate leg rather than a second flag on the + # sanitizer leg because the two instruments answer different questions: the + # sanitizer leg reports what the run did wrong, the coverage leg reports what the + # run never touched at all. Combining them would also slow both down. + # + # It moved here from build-linux: gcovr.cfg sets no fail_under, so the leg cannot + # fail a build, and nothing consumes the report per change, which made it an + # artifact nobody read on every pull request. On the default branch it becomes a + # trend instead. Image and ccache keys come from distro, version and variant, so + # they are unchanged and the warm cache that leg built still matches. + - distro: Ubuntu + version: 26.04 + variant: cover # separate image and ccache, same distro + + coverage: yes + package: skip + # A report must not redden the nightly run: vcpkg-cache-import only imports the + # archives of a run whose conclusion is success. + allow-failure: yes + + name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} + needs: configure + # A nightly and a tag build cover the legs a pull request or a merge does not wait for: + # the previous release of each distro, and the one instrument that only reports. + if: github.event_name == 'schedule' || github.ref_type == 'tag' + runs-on: ubuntu-latest # https://github.com/actions/runner-images + continue-on-error: ${{ matrix.allow-failure == 'yes' }} + timeout-minutes: 70 + env: + CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache + CCACHE_EVICTION_AGE: 7d + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + CMAKE_GENERATOR: 'Ninja' + NAME: ${{ matrix.distro }}${{ matrix.version }} + IMAGE_SUFFIX: ${{ matrix.variant }} + steps: *linux-steps + build-vcpkg: strategy: fail-fast: false matrix: include: - - os: macOS - target: 13 # EOL 2025-09-15 - runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md - # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout - - ccache_eviction_age: 7d - cmake_generator: Ninja - make_package: 1 - override_target: 13 - package_suffix: "-macOS13_Intel" - qt_version: 6.11.* - qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets - soc: Intel - type: Release - use_ccache: 1 - vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' - vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over - xcode: "26.3" - - os: macOS target: 14 # EOL 2026-?? runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md @@ -543,13 +565,16 @@ jobs: name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} needs: [configure, vcpkg-baseline] + # The nightly leg lives in build-vcpkg-nightly below, next to the Linux nightly legs. + if: github.event_name != 'schedule' runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy - steps: + # Shared with build-vcpkg-nightly so the two leg sets run the same steps. + steps: &vcpkg-steps - name: "Checkout" uses: actions/checkout@v7 with: @@ -1024,6 +1049,42 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice + build-vcpkg-nightly: + strategy: + fail-fast: false + matrix: + include: + - os: macOS + target: 13 # EOL 2025-09-15 + runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md + # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout + + ccache_eviction_age: 7d + cmake_generator: Ninja + make_package: 1 + override_target: 13 + package_suffix: "-macOS13_Intel" + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets + soc: Intel + type: Release + use_ccache: 1 + vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' + vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over + xcode: "26.3" + + name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} + needs: configure + # The EOL target on the slowest runner is too slow to gate a change, so it runs in the + # nightly and on tags only, where the vcpkg cache import also has to accept schedule runs. + if: github.event_name == 'schedule' || github.ref_type == 'tag' + runs-on: ${{ matrix.runner }} + timeout-minutes: 100 + env: + CCACHE_DIR: ${{ github.workspace }}/.cache/ + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + steps: *vcpkg-steps + prune-caches: name: Prune caches # Only master writes caches at all, so only master may clean them up too @@ -1042,8 +1103,9 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} - # Must list every vcpkg_scope of the build-vcpkg matrix above, or the entries of - # a scope that is still in use get deleted on the next run. + # Must list every vcpkg_scope of the build-vcpkg and build-vcpkg-nightly + # matrices above, or the entries of a scope that is still in use get deleted + # on the next run. LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ diff --git a/.github/workflows/vcpkg-cache-import.yml b/.github/workflows/vcpkg-cache-import.yml index 3cc7b2203..0a377a8fd 100644 --- a/.github/workflows/vcpkg-cache-import.yml +++ b/.github/workflows/vcpkg-cache-import.yml @@ -4,12 +4,14 @@ name: Import vcpkg cache # rebuilds protobuf, openssl and abseil from source and saves them into the shared # cache, where every later run can restore them. # -# Only master pushes are imported here: the collect step, keys.txt and the zip -# contents all come from the workflow run being imported, so importing from an -# unmerged pull request would let anyone who can push a branch — or a bot actor -# such as Dependabot, whose PRs are same-repo — promote arbitrary binaries into -# the scope that master and tag builds restore and link into signed releases. -# Forks build untrusted code and must never reach that scope either. +# Only master pushes and the nightly schedule run on master are imported here: the +# collect step, keys.txt and the zip contents all come from the workflow run being +# imported, so importing from an unmerged pull request would let anyone who can push +# a branch — or a bot actor such as Dependabot, whose PRs are same-repo — promote +# arbitrary binaries into the scope that master and tag builds restore and link into +# signed releases. Forks build untrusted code and must never reach that scope either. +# A scheduled run is the default branch at the commit a master push already built, so +# it carries the same trust as a push. # # repository_dispatch is not used on purpose: it would also resolve to the default branch, # but it counts as a low-trust trigger and may only write there when it declares a @@ -38,7 +40,7 @@ jobs: # the shared scope even if they somehow target the default branch. if: >- github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && + (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') && github.event.workflow_run.head_branch == 'master' && github.event.workflow_run.head_repository.full_name == github.repository && !endsWith(github.event.workflow_run.actor.login, '[bot]') && From d8700b29471cd061e5626af0e9befae25ad1ed53 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:52 +0100 Subject: [PATCH 026/104] [Fonts] Add AppFont roles and FontConfig fonts.toml round-trip (#7534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- cockatrice/src/interface/theme_config.cpp | 148 +++++++++++++++- cockatrice/src/interface/theme_config.h | 49 +++++- tests/CMakeLists.txt | 9 + tests/font_config_test.cpp | 195 ++++++++++++++++++++++ 4 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 tests/font_config_test.cpp diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 652e322c8..54143d7d3 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -304,4 +304,150 @@ QPalette PaletteConfig::apply(QPalette base) const } return base; -} \ No newline at end of file +} + +bool FontConfig::hasFonts() const +{ + return !fonts.isEmpty(); +} + +QString FontConfig::toToml() const +{ + QMetaEnum roleEnum = QMetaEnum::fromType(); + + QString entries; + + for (auto it = fonts.cbegin(); it != fonts.cend(); ++it) { + const char *roleName = roleEnum.valueToKey(it.key()); + + if (!roleName) { + continue; + } + + QString family = it.value().family().trimmed(); + int pointSize = it.value().pointSize(); + + if (family.isEmpty() || pointSize <= 0) { + continue; + } + + entries += QString("%1 = \"%2, %3\"\n").arg(QString(roleName), -20).arg(family).arg(pointSize); + } + + if (entries.isEmpty()) { + return {}; + } + + return "[Fonts]\n" + entries + "\n"; +} + +QString FontConfig::fileName() +{ + return "fonts.toml"; +} + +FontConfig FontConfig::fromFile(const QString &filePath) +{ + FontConfig cfg; + + QFile f(filePath); + + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return cfg; + } + + QMetaEnum roleEnum = QMetaEnum::fromType(); + + QString currentSection; + + QTextStream in(&f); + + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + + if (line.isEmpty() || line.startsWith('#') || line.startsWith(';')) { + continue; + } + + if (line.startsWith('[') && line.endsWith(']')) { + currentSection = line.mid(1, line.length() - 2).trimmed(); + continue; + } + + if (currentSection.compare("Fonts", Qt::CaseInsensitive) != 0) { + continue; + } + + int eq = line.indexOf('='); + + if (eq < 0) { + continue; + } + + QString key = line.left(eq).trimmed(); + QString value = line.mid(eq + 1).trimmed(); + + // Strip inline comments if preceded by whitespace + for (int i = 1; i < value.size(); ++i) { + if ((value[i] == '#' || value[i] == ';') && value[i - 1].isSpace()) { + value = value.left(i).trimmed(); + break; + } + } + + // Strip surrounding quotes, if any + if (value.size() >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) { + value = value.mid(1, value.size() - 2).trimmed(); + } + + int comma = value.lastIndexOf(','); + + if (comma < 0) { + continue; + } + + QString family = value.left(comma).trimmed(); + + // Tolerate a partially-quoted family, e.g. "Inter", 10 + if (family.size() >= 2 && + ((family.startsWith('"') && family.endsWith('"')) || (family.startsWith('\'') && family.endsWith('\'')))) { + family = family.mid(1, family.size() - 2).trimmed(); + } + + bool ok = false; + int pointSize = value.mid(comma + 1).trimmed().toInt(&ok); + + if (!ok || family.isEmpty() || pointSize <= 0) { + continue; + } + + if (key.startsWith("AppFont::")) { + key = key.mid(QStringLiteral("AppFont::").size()); + } + + int roleInt = roleEnum.keyToValue(key.toUtf8().constData()); + + if (roleInt < 0) { + continue; + } + + // Canonical family + point size only: a fresh QFont carries default + // (non-bold, non-italic) weight/style, so nothing caller-side leaks in. + // No validation against installed fonts; Qt substitution applies. + QFont font(family); + font.setPointSize(pointSize); + cfg.fonts[static_cast(roleInt)] = font; + } + + return cfg; +} + +FontConfig FontConfig::fromThemeDir(const QString &themeDirPath) +{ + if (themeDirPath.isEmpty()) { + return {}; + } + + return fromFile(QDir(themeDirPath).absoluteFilePath(fileName())); +} diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 8b91613f5..84601a2bf 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -2,6 +2,7 @@ #define COCKATRICE_THEME_CONFIG_H #include +#include #include #include #include @@ -23,6 +24,23 @@ enum Role Q_ENUM_NS(Role) } // namespace AppColor +// Application-specific font roles, one configurable family + point size per +// purpose. Stored in a shared fonts.toml under a [Fonts] section (one file per +// theme, not split by light/dark). Only family and point size are stored; +// weight/italic are stripped on load and stay caller-side concerns. +namespace AppFont +{ +Q_NAMESPACE +enum Role +{ + InterfaceDefault, + ChatLog, + CardText, + DeckList, +}; +Q_ENUM_NS(Role) +} // namespace AppFont + struct ThemeConfig { QString colorScheme; @@ -60,4 +78,33 @@ struct PaletteConfig QPalette apply(QPalette base) const; }; -#endif // COCKATRICE_THEME_CONFIG_H \ No newline at end of file +/** @brief Per-purpose font configuration, stored as family + point size only. + * + * Serialized to a shared fonts.toml file with a [Fonts] section, e.g. + * @code + * [Fonts] + * InterfaceDefault = "Inter, 10" + * @endcode + */ +struct FontConfig +{ + QMap fonts; + + bool operator==(const FontConfig &rhs) const + { + return fonts == rhs.fonts; + } + bool operator!=(const FontConfig &rhs) const + { + return !(*this == rhs); + } + bool hasFonts() const; + QString toToml() const; + + static QString fileName(); + + static FontConfig fromFile(const QString &filePath); + static FontConfig fromThemeDir(const QString &themeDirPath); +}; + +#endif // COCKATRICE_THEME_CONFIG_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4d7a02ea4..c87bec627 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -424,6 +424,15 @@ cockatrice_add_test( INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src ) +cockatrice_add_test( + NAME + font_config_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp + font_config_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) cockatrice_add_test( NAME deck_hash_performance_test diff --git a/tests/font_config_test.cpp b/tests/font_config_test.cpp new file mode 100644 index 000000000..c8b1fb10d --- /dev/null +++ b/tests/font_config_test.cpp @@ -0,0 +1,195 @@ +#include "interface/theme_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Writes content to a file inside tmp, creating parents as needed. +QString writeTempFile(QTemporaryDir &tmp, const QString &relativePath, const QString &content) +{ + const QString filePath = QDir(tmp.path()).absoluteFilePath(relativePath); + EXPECT_TRUE(QDir().mkpath(QFileInfo(filePath).absolutePath())); + QFile file(filePath); + EXPECT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << content; + file.close(); + return filePath; +} + +QFont makeFont(const QString &family, int pointSize) +{ + QFont font(family); + font.setPointSize(pointSize); + return font; +} +} // namespace + +TEST(FontConfigTest, FileNameIsFontsToml) +{ + EXPECT_EQ(FontConfig::fileName(), QStringLiteral("fonts.toml")); +} + +TEST(FontConfigTest, EmptyByDefault) +{ + FontConfig cfg; + EXPECT_FALSE(cfg.hasFonts()); + EXPECT_TRUE(cfg.toToml().isEmpty()); + EXPECT_EQ(cfg, FontConfig()); + EXPECT_EQ(cfg, FontConfig::fromFile("/nonexistent/path/fonts.toml")); + EXPECT_EQ(cfg, FontConfig::fromThemeDir(QString())); +} + +TEST(FontConfigTest, RoundTripPreservesFamilyAndSize) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + FontConfig cfg; + cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10); + cfg.fonts[AppFont::ChatLog] = makeFont("JetBrains Mono", 9); + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11); + cfg.fonts[AppFont::DeckList] = makeFont("Roboto", 12); + ASSERT_TRUE(cfg.hasFonts()); + + const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml()); + const FontConfig loaded = FontConfig::fromFile(filePath); + + EXPECT_EQ(loaded, cfg); + EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].family(), QStringLiteral("Inter")); + EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].pointSize(), 10); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("JetBrains Mono")); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 9); + EXPECT_EQ(loaded.fonts[AppFont::CardText].family(), QStringLiteral("Noto Serif")); + EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 11); + EXPECT_EQ(loaded.fonts[AppFont::DeckList].family(), QStringLiteral("Roboto")); + EXPECT_EQ(loaded.fonts[AppFont::DeckList].pointSize(), 12); +} + +TEST(FontConfigTest, RoundTripStripsWeightAndItalic) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + QFont styled("Inter"); + styled.setPointSize(10); + styled.setBold(true); + styled.setItalic(true); + + FontConfig cfg; + cfg.fonts[AppFont::ChatLog] = styled; + + const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml()); + const FontConfig loaded = FontConfig::fromFile(filePath); + + ASSERT_TRUE(loaded.fonts.contains(AppFont::ChatLog)); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("Inter")); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 10); + EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].bold()); + EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].italic()); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("Inter", 10)); +} + +TEST(FontConfigTest, ToTomlFormat) +{ + FontConfig cfg; + cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10); + + const QString toml = cfg.toToml(); + EXPECT_TRUE(toml.contains(QStringLiteral("[Fonts]"))); + EXPECT_TRUE(toml.contains(QStringLiteral("InterfaceDefault"))); + EXPECT_TRUE(toml.contains(QStringLiteral("\"Inter, 10\""))); +} + +TEST(FontConfigTest, MalformedLinesAreSkipped) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + const QString filePath = writeTempFile(tmp, "fonts.toml", + "# a comment\n" + "; another comment\n" + "\n" + "StrayBeforeSection = \"Inter, 10\"\n" + "\n" + "[Other]\n" + "ChatLog = \"ShouldBeIgnored, 10\"\n" + "\n" + "[Fonts]\n" + "InterfaceDefault = \"Inter, 10\"\n" + "ChatLog = \"JetBrains Mono\", 9 # inline comment\n" + "CardText = Noto Serif, 11\n" + "AppFont::DeckList = \"Roboto, 12\"\n" + "UnknownRole = \"Inter, 10\"\n" + "QPalette::Window = \"Inter, 10\"\n" + "NoComma = \"Inter\"\n" + "EmptyFamily = \", 10\"\n" + "ZeroSize = \"Inter, 0\"\n" + "NegativeSize = \"Inter, -3\"\n" + "NotANumber = \"Inter, big\"\n" + "NotAnEntry\n"); + + const FontConfig loaded = FontConfig::fromFile(filePath); + + ASSERT_TRUE(loaded.hasFonts()); + EXPECT_EQ(loaded.fonts.size(), 4); + EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault], makeFont("Inter", 10)); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("JetBrains Mono", 9)); + EXPECT_EQ(loaded.fonts[AppFont::CardText], makeFont("Noto Serif", 11)); + EXPECT_EQ(loaded.fonts[AppFont::DeckList], makeFont("Roboto", 12)); +} + +TEST(FontConfigTest, FromThemeDirReadsFontsToml) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + const QString themeDir = QDir(tmp.path()).absoluteFilePath("MyTheme"); + ASSERT_TRUE(QDir().mkpath(themeDir)); + + FontConfig cfg; + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11); + writeTempFile(tmp, "MyTheme/fonts.toml", cfg.toToml()); + + const FontConfig loaded = FontConfig::fromThemeDir(themeDir); + EXPECT_EQ(loaded, cfg); + + // A theme directory without fonts.toml loads as empty. + const QString bareDir = QDir(tmp.path()).absoluteFilePath("BareTheme"); + ASSERT_TRUE(QDir().mkpath(bareDir)); + EXPECT_FALSE(FontConfig::fromThemeDir(bareDir).hasFonts()); +} + +TEST(FontConfigTest, InequalityComparesRoles) +{ + FontConfig left; + left.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10); + + FontConfig right = left; + EXPECT_EQ(left, right); + EXPECT_FALSE(left != right); + + right.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 11); + EXPECT_NE(left, right); + + right = left; + right.fonts[AppFont::ChatLog] = makeFont("Inter", 10); + EXPECT_NE(left, right); +} + +// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and +// the fallback then links the bare gtest library without gtest_main. +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 54797452daf84604444f202edcf3f52072cdcd66 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:52 +0100 Subject: [PATCH 027/104] [Fonts] Wire FontConfig into ThemeManager with appFont and preview (#7535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Wire FontConfig into ThemeManager with appFont and preview * [Fonts] Restore the startup font when a config has no InterfaceDefault - Capture the untouched application font next to the default palette and use it in previewFonts' else branch, so a config without InterfaceDefault restores the startup font instead of leaving the last preview applied. - Drop the unpolish/polish loop from previewFonts: QApplication::setFont already notifies every widget, and the loop ran on every editor auto-apply. --------- Co-authored-by: Lukas Brübach --- cockatrice/src/interface/theme_manager.cpp | 80 +++++++++++++++++++++- cockatrice/src/interface/theme_manager.h | 14 ++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 893cdc795..b23f15a2b 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -127,8 +127,10 @@ static QString usableDefaultStyle(const QString &style) ThemeManager::ThemeManager(QObject *parent) : QObject(parent) { defaultStyleName = usableDefaultStyle(qApp->style()->objectName()); - // Capture the untouched application palette before any theme is applied. + // Capture the untouched application palette and font before any theme is + // applied. defaultPalette = qApp->palette(); + defaultFont = qApp->font(); ensureThemeDirectoryExists(); #if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)) connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] { @@ -440,6 +442,34 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath return cfg; } +FontConfig ThemeManager::loadFontConfig(const QString &themeDirPath) +{ + if (themeDirPath.isEmpty()) { + return {}; + } + return FontConfig::fromThemeDir(themeDirPath); +} + +bool ThemeManager::saveFontConfig(const QString &themeDirPath, const FontConfig &cfg) +{ + if (themeDirPath.isEmpty()) { + return false; + } + + QDir dir(themeDirPath); + if (!dir.exists()) { + dir.mkpath("."); + } + + QFile f(dir.absoluteFilePath(FontConfig::fileName())); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + return false; + } + + QTextStream(&f) << cfg.toToml(); + return true; +} + bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg) { if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) { @@ -488,6 +518,24 @@ void ThemeManager::previewPalette(const PaletteConfig &cfg, const QString &schem applyStyleAndPalette(themeName, themeCfg, cfg, scheme); } +void ThemeManager::previewFonts(const FontConfig &cfg) +{ + currentFonts = cfg; + + // QApplication::setFont already sends a font change to every widget, so a + // repolish pass is not needed for fonts (unlike the palette path). + const auto it = cfg.fonts.constFind(AppFont::InterfaceDefault); + if (it != cfg.fonts.constEnd()) { + qApp->setFont(it.value()); + } else { + // A config without InterfaceDefault means "no override": restore the + // startup font instead of leaving the last preview applied. + qApp->setFont(defaultFont); + } + + emit fontsChanged(); +} + void ThemeManager::applyStyleAndPalette(const QString &themeName, const ThemeConfig &themeCfg, const PaletteConfig &palCfg, @@ -591,6 +639,18 @@ QColor ThemeManager::appColor(AppColor::Role role) const return accent; } +QFont ThemeManager::appFont(AppFont::Role role) const +{ + const auto it = currentFonts.fonts.constFind(role); + if (it != currentFonts.fonts.constEnd()) { + return it.value(); + } + + // No per-role scaling: every unset role resolves to the application font, + // so with no fonts.toml present the result is identical to before. + return qApp->font(); +} + void ThemeManager::themeChangedSlot() { QString themeName = SettingsCache::instance().getThemeName(); @@ -642,6 +702,24 @@ void ThemeManager::themeChangedSlot() applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); + // ── Load fonts: shared per theme, no light/dark split ─────────────────── + FontConfig fonts = loadFontConfig(dirPath); + if (!fonts.hasFonts()) { + // The shipped fonts.toml may live in the system theme directory rather + // than the resolved (user) theme directory, so built-in themes still + // get their curated fonts. + const QString sysFontPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName); + if (sysFontPath != dirPath) { + fonts = loadFontConfig(sysFontPath); + } + } + currentFonts = fonts; + const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault); + if (fontIt != currentFonts.fonts.constEnd()) { + qApp->setFont(fontIt.value()); + } + emit fontsChanged(); + QStringList resources; if (!dirPath.isEmpty()) { resources << dir.absolutePath(); diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index c1ab65685..c8fba2eb4 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -47,10 +48,15 @@ private: // palette is applied. Used as the base when a theme supplies no palette, so // switching away from a custom palette restores the original colours. QPalette defaultPalette; + // Pristine application font captured at startup, before any theme font is + // applied. Restored when a theme (or a preview) supplies no InterfaceDefault + // font, so switching away from a themed font cannot leave it behind. + QFont defaultFont; QString currentThemePath; std::array brushes; QStringMap availableThemes; QMap currentAppColors; + FontConfig currentFonts; /* Internal cache for multiple backgrounds */ @@ -105,6 +111,9 @@ public: // Load/save per-scheme palette colors static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme); static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); + // Load/save shared per-theme fonts (fonts.toml, not split by scheme) + static FontConfig loadFontConfig(const QString &themeDirPath); + static bool saveFontConfig(const QString &themeDirPath, const FontConfig &cfg); // Resolve prefix to a scheme-qualified "theme:" path. Existence is probed // internally across the formats themes may ship (.png/.jpg/.svg), so // callers load the returned path directly. Prefers "-" @@ -132,10 +141,14 @@ public: void reloadCurrentTheme(); void previewPalette(const PaletteConfig &cfg, const QString &scheme); + void previewFonts(const FontConfig &cfg); // Resolves an application color role: the theme's stored [AppColors] value // when present, otherwise a palette-accent-derived fallback. QColor appColor(AppColor::Role role) const; + /** @brief Resolves an application font role: the theme's stored [Fonts] + * value when present, otherwise the application font. */ + QFont appFont(AppFont::Role role) const; QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); @@ -144,6 +157,7 @@ protected slots: signals: void themeChanged(); void paletteChanged(); + void fontsChanged(); }; extern ThemeManager *themeManager; From 634f8115a52d09d4d13f1e60800edffcc0fb962b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:53 +0100 Subject: [PATCH 028/104] [Fonts] Add per-purpose font section to palette editor (#7536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Add per-purpose font section to palette editor * [Fonts] Make the editor's fallback and Reset preview-independent - Add ThemeManager::defaultAppFont(), which resolves an unset role against the startup font instead of currentFonts, and use it in FontGridWidget::loadFonts so the grid never shows the live preview as the theme's default. - Reload the live theme in PaletteEditorDialog::onReset before re-reading the grid, so Reset restores the saved fonts before loadFonts resolves fallbacks. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../palette_editor/font_grid_widget.cpp | 127 ++++++++++++++++++ .../palette_editor/font_grid_widget.h | 43 ++++++ .../palette_editor/palette_editor_dialog.cpp | 112 +++++++++++++-- .../palette_editor/palette_editor_dialog.h | 12 ++ cockatrice/src/interface/theme_manager.cpp | 24 ++++ cockatrice/src/interface/theme_manager.h | 8 ++ 7 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 cockatrice/src/interface/palette_editor/font_grid_widget.cpp create mode 100644 cockatrice/src/interface/palette_editor/font_grid_widget.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 9b467721d..9a3172aa8 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -154,6 +154,7 @@ set(cockatrice_SOURCES src/interface/palette_editor/palette_generator.cpp src/interface/palette_editor/quick_setup_panel.cpp src/interface/palette_editor/palette_grid_widget.cpp + src/interface/palette_editor/font_grid_widget.cpp src/interface/palette_editor/palette_editor_dialog.cpp src/interface/widgets/cards/additional_info/color_identity_widget.cpp src/interface/widgets/cards/additional_info/deck_color_identity.cpp diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp new file mode 100644 index 000000000..0f01fbe09 --- /dev/null +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp @@ -0,0 +1,127 @@ +#include "font_grid_widget.h" + +#include "../theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr int MIN_FONT_SIZE = 6; +static constexpr int MAX_FONT_SIZE = 72; +static constexpr int FALLBACK_FONT_SIZE = 10; + +static const QMap APP_FONT_DESCRIPTIONS = { + {AppFont::InterfaceDefault, QT_TR_NOOP("Default font for menus, dialogs and widgets")}, + {AppFont::ChatLog, QT_TR_NOOP("Font for chat messages and logs")}, + {AppFont::CardText, QT_TR_NOOP("Font for text rendered on cards")}, + {AppFont::DeckList, QT_TR_NOOP("Font for deck lists and the deck editor")}, +}; + +static QString roleDisplayName(AppFont::Role role) +{ + switch (role) { + case AppFont::InterfaceDefault: + return FontGridWidget::tr("Interface default"); + case AppFont::ChatLog: + return FontGridWidget::tr("Chat log"); + case AppFont::CardText: + return FontGridWidget::tr("Card text"); + case AppFont::DeckList: + return FontGridWidget::tr("Deck list"); + default: + return QString(); + } +} + +FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) +{ + auto *grid = new QGridLayout(this); + grid->setSpacing(3); + grid->setContentsMargins(12, 8, 12, 8); + grid->setColumnStretch(0, 0); + grid->setColumnStretch(1, 1); + grid->setColumnStretch(2, 0); + + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + + auto *label = new QLabel(this); + grid->addWidget(label, i, 0); + + auto *familyCombo = new QFontComboBox(this); + grid->addWidget(familyCombo, i, 1); + connect(familyCombo, &QFontComboBox::currentFontChanged, this, [this] { emit fontsChanged(); }); + + auto *sizeSpin = new QSpinBox(this); + sizeSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE); + grid->addWidget(sizeSpin, i, 2); + connect(sizeSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); }); + + fontRows[role] = {label, familyCombo, sizeSpin}; + } + + retranslateUi(); +} + +void FontGridWidget::loadFonts(const FontConfig &cfg) +{ + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + const FontRow &row = fontRows[role]; + + // Roles absent from the file resolve the same way the runtime does: an unset + // role falls back to the config's InterfaceDefault, and only then to the + // startup font. appFont() would return the live preview instead, so Reset and + // a first load must not measure against it. + const QFont fallback = cfg.fonts.value(AppFont::InterfaceDefault, themeManager->defaultAppFont(role)); + const QFont font = cfg.fonts.value(role, fallback); + row.familyCombo->setCurrentFont(font); + + int size = font.pointSize(); + if (size <= 0) { + size = qApp->font().pointSize(); + } + if (size <= 0) { + size = FALLBACK_FONT_SIZE; + } + row.sizeSpin->setValue(qBound(MIN_FONT_SIZE, size, MAX_FONT_SIZE)); + } +} + +FontConfig FontGridWidget::currentFontConfig() const +{ + FontConfig cfg; + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + const FontRow &row = fontRows.value(role); + + // Canonical family + point size only: a fresh QFont carries default + // (non-bold, non-italic) weight/style, so nothing combo-side leaks in. + QFont font(row.familyCombo->currentFont().family()); + font.setPointSize(row.sizeSpin->value()); + cfg.fonts[role] = font; + } + return cfg; +} + +void FontGridWidget::retranslateUi() +{ + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + const FontRow &row = fontRows.value(role); + + row.label->setText(roleDisplayName(role)); + row.label->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); + row.familyCombo->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); + row.sizeSpin->setToolTip(tr("Point size")); + } +} diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.h b/cockatrice/src/interface/palette_editor/font_grid_widget.h new file mode 100644 index 000000000..8c80306d3 --- /dev/null +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.h @@ -0,0 +1,43 @@ +#ifndef COCKATRICE_FONT_GRID_WIDGET_H +#define COCKATRICE_FONT_GRID_WIDGET_H + +#include "../theme_config.h" + +#include +#include +#include + +class QFontComboBox; +class QGridLayout; +class QLabel; +class QSpinBox; + +// Per-role font editors — one (family, point size) row per AppFont::Role. +// Owns the load/read round-trip for FontConfig but has no file I/O, mirroring +// PaletteGridWidget. Only family and point size are edited; weight and italic +// stay caller-side concerns and are stripped when reading back. +class FontGridWidget : public QWidget +{ + Q_OBJECT +public: + explicit FontGridWidget(QWidget *parent = nullptr); + + void loadFonts(const FontConfig &cfg); + FontConfig currentFontConfig() const; + void retranslateUi(); + +signals: + void fontsChanged(); + +private: + struct FontRow + { + QLabel *label = nullptr; + QFontComboBox *familyCombo = nullptr; + QSpinBox *sizeSpin = nullptr; + }; + + QMap fontRows; +}; + +#endif // COCKATRICE_FONT_GRID_WIDGET_H diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index b6271291d..09c81df5d 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -2,6 +2,7 @@ #include "../theme_config.h" #include "../theme_manager.h" +#include "font_grid_widget.h" #include "palette_generator.h" #include "palette_grid_widget.h" #include "quick_setup_panel.h" @@ -41,6 +42,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt // Load both scheme configs upfront so switching is instant loadSchemes(); + loadFonts(); loadedScheme = themeManager->isDarkMode(themeDirPath) ? "Dark" : "Light"; @@ -125,6 +127,32 @@ void PaletteEditorDialog::setupUi() paletteGrid->setVisible(false); root->addWidget(paletteGrid, 1); + // Font section — sibling to the palette grid, sharing its collapsible + // advanced area pattern. Fonts are shared per theme, not split by scheme. + fontToggleButton = new QPushButton(this); + fontToggleButton->setCheckable(true); + fontToggleButton->setChecked(false); + fontToggleButton->setFlat(true); + fontToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }" + "QPushButton:checked { }"); + root->addWidget(fontToggleButton); + + fontSeparator = makeSeparator(); + fontSeparator->setVisible(false); + root->addWidget(fontSeparator); + + fontSection = new QWidget; + auto *fontSectionLayout = new QVBoxLayout(fontSection); + fontSectionLayout->setContentsMargins(0, 0, 0, 0); + fontSectionLayout->setSpacing(0); + fontNoteLabel = new QLabel(fontSection); + fontNoteLabel->setContentsMargins(12, 4, 12, 0); + fontSectionLayout->addWidget(fontNoteLabel); + fontGrid = new FontGridWidget(fontSection); + fontSectionLayout->addWidget(fontGrid); + fontSection->setVisible(false); + root->addWidget(fontSection); + // Footer root->addWidget(makeSeparator()); footer = new QWidget; @@ -157,6 +185,7 @@ void PaletteEditorDialog::setupUi() connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply); connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent); connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); }); + connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] { autoApplyTimer->start(); }); connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault); connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset); connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave); @@ -166,15 +195,32 @@ void PaletteEditorDialog::setupUi() paletteGridToggleButton->setText(open ? tr("▼ Edit Palette") : tr("▶ Edit Palette")); paletteGridSeparator->setVisible(open); paletteGrid->setVisible(open); - - if (open) { - setMinimumHeight(680); - resize(width(), 680); - } else { - setMinimumHeight(220); - adjustSize(); // shrinks to fit just the visible content - } + updateAdvancedHeight(); }); + connect(fontToggleButton, &QPushButton::toggled, this, [this](bool open) { + fontToggleButton->setText(open ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts")); + fontSeparator->setVisible(open); + fontSection->setVisible(open); + updateAdvancedHeight(); + }); +} + +void PaletteEditorDialog::updateAdvancedHeight() +{ + int height = 220; + if (paletteGridToggleButton->isChecked()) { + height += 460; + } + if (fontToggleButton->isChecked()) { + height += 200; + } + setMinimumHeight(height); + + if (paletteGrid->isVisible() || fontSection->isVisible()) { + resize(width(), height); + } else { + adjustSize(); // shrinks to fit just the visible content + } } void PaletteEditorDialog::retranslateUi() @@ -189,12 +235,16 @@ void PaletteEditorDialog::retranslateUi() editingLabel->setText(tr("Editing:")); paletteGridToggleButton->setText(tr("▶ Edit Palette")); paletteGridToggleButton->setToolTip(tr("Show or hide the per-role colour grid for manual tweaks")); + fontToggleButton->setText(fontSection->isVisible() ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts")); + fontToggleButton->setToolTip(tr("Show or hide the per-purpose font settings for this theme")); + fontNoteLabel->setText(tr("Fonts belong to theme %1").arg(themeName)); + fontGrid->retranslateUi(); revertButton->setText(tr("↺ Revert to theme default")); resetBtn->setText(tr("Reset")); saveBtn->setText(tr("Save")); - resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); - saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); + resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette and fonts")); + saveBtn->setToolTip(tr("Write palette-%1.toml and fonts.toml, then reload the theme").arg(loadedScheme.toLower())); if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) { saveBtn->setEnabled(false); @@ -228,6 +278,19 @@ void PaletteEditorDialog::loadSchemes() } } +void PaletteEditorDialog::loadFonts() +{ + // Same user-dir > system-dir fallback ThemeManager uses: the shipped + // fonts.toml may live in the system theme directory rather than the + // resolved (user) theme directory. + FontConfig cfg = ThemeManager::loadFontConfig(themeDirPath); + if (!cfg.hasFonts()) { + cfg = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); + } + savedFontConfig = cfg; + fontGrid->loadFonts(cfg); +} + void PaletteEditorDialog::seedAccentFromScheme(const QString &scheme) { QColor seed = workingConfig.value(scheme).colors.value(QPalette::Active).value(QPalette::Highlight); @@ -258,6 +321,7 @@ void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensi void PaletteEditorDialog::onApply() { themeManager->previewPalette(paletteGrid->currentPaletteConfig(), loadedScheme); + themeManager->previewFonts(fontGrid->currentFontConfig()); } void PaletteEditorDialog::onSave() @@ -308,6 +372,17 @@ void PaletteEditorDialog::onSave() savedConfig[it.key()] = it.value(); } + // Persist the shared per-theme fonts when they changed. + const FontConfig fontCfg = fontGrid->currentFontConfig(); + if (fontCfg != savedFontConfig) { + if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir)); + return; + } + savedFontConfig = fontCfg; + } + themeManager->reloadCurrentTheme(); accept(); } @@ -316,6 +391,11 @@ void PaletteEditorDialog::onReset() { workingConfig[loadedScheme] = savedConfig[loadedScheme]; paletteGrid->loadPalette(savedConfig[loadedScheme]); + // Restore the live theme before re-reading the grid: loadFonts resolves + // unset roles, and the preview has currentFonts pointing at the edited + // config until the theme is reloaded. + themeManager->reloadCurrentTheme(); + fontGrid->loadFonts(savedFontConfig); seedAccentFromScheme(loadedScheme); } @@ -350,6 +430,18 @@ void PaletteEditorDialog::onRevertToDefault() workingConfig[loadedScheme] = def; paletteGrid->loadPalette(def); seedAccentFromScheme(loadedScheme); + + // Revert the shared per-theme fonts the same way: delete the user-written + // fonts.toml so the theme falls back to its shipped default, then reflect + // the resolved fonts in the editor. + QFile::remove(QDir(saveDir).absoluteFilePath(FontConfig::fileName())); + + // Reload the live theme so the font revert takes effect immediately. + themeManager->reloadCurrentTheme(); + + const FontConfig fontDef = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); + savedFontConfig = fontDef; + fontGrid->loadFonts(fontDef); } void PaletteEditorDialog::changeEvent(QEvent *e) diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h index e29a70cb0..0a9d47c6c 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h @@ -15,6 +15,7 @@ class QComboBox; class QDialogButtonBox; class QPushButton; class PaletteGridWidget; +class FontGridWidget; class QuickSetupPanel; class QFrame; class QWidget; @@ -38,8 +39,10 @@ private: void setupUi(); void retranslateUi(); void refreshChromePalettes(); + void updateAdvancedHeight(); void loadScheme(const QString &scheme); // snapshot current, switch, load void seedAccentFromScheme(const QString &scheme); + void loadFonts(); // shared per theme, not split by light/dark // Sub-widgets QWidget *header; @@ -49,6 +52,11 @@ private: PaletteGridWidget *paletteGrid = nullptr; QPushButton *paletteGridToggleButton = nullptr; QFrame *paletteGridSeparator = nullptr; + FontGridWidget *fontGrid = nullptr; + QPushButton *fontToggleButton = nullptr; + QFrame *fontSeparator = nullptr; + QWidget *fontSection = nullptr; + QLabel *fontNoteLabel = nullptr; QWidget *footer; QComboBox *schemeComboBox = nullptr; QDialogButtonBox *buttonBox = nullptr; @@ -68,6 +76,10 @@ private: QMap workingConfig; QMap savedConfig; + // Fonts are shared per theme (fonts.toml, not split by light/dark), so a + // single saved config suffices; the live edits are read from fontGrid. + FontConfig savedFontConfig; + QTimer *autoApplyTimer = nullptr; protected: diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index b23f15a2b..bf275c7ac 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -450,6 +450,18 @@ FontConfig ThemeManager::loadFontConfig(const QString &themeDirPath) return FontConfig::fromThemeDir(themeDirPath); } +FontConfig ThemeManager::loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName) +{ + FontConfig cfg = FontConfig::fromThemeDir(themeDirPath); + if (!cfg.hasFonts()) { + // The shipped fonts.toml may live in the system theme directory rather + // than the resolved (user) theme directory, so built-in themes still + // get their curated fonts. + cfg = FontConfig::fromThemeDir(QDir(systemThemesBasePath()).absoluteFilePath(themeName)); + } + return cfg; +} + bool ThemeManager::saveFontConfig(const QString &themeDirPath, const FontConfig &cfg) { if (themeDirPath.isEmpty()) { @@ -651,6 +663,14 @@ QFont ThemeManager::appFont(AppFont::Role role) const return qApp->font(); } +QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const +{ + // No per-role scaling and no preview: an unset role resolves to the font + // captured at startup, which is what the theme falls back to once the + // editor closes. + return defaultFont; +} + void ThemeManager::themeChangedSlot() { QString themeName = SettingsCache::instance().getThemeName(); @@ -717,6 +737,10 @@ void ThemeManager::themeChangedSlot() const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault); if (fontIt != currentFonts.fonts.constEnd()) { qApp->setFont(fontIt.value()); + } else { + // No theme font: restore the pristine application font captured at + // startup, so a reverted preview cannot leak into the live theme. + qApp->setFont(defaultFont); } emit fontsChanged(); diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index c8fba2eb4..37a70f755 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -132,6 +132,9 @@ public: // theme directory when it is absent from the resolved (user) directory. static PaletteConfig loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme); + // Load the theme's shipped default fonts, falling back to the system theme + // directory when fonts.toml is absent from the resolved (user) directory. + static FontConfig loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName); /** @brief Writes cfg to disk as the theme's palette-.toml and updates the * theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave * and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */ @@ -149,6 +152,11 @@ public: /** @brief Resolves an application font role: the theme's stored [Fonts] * value when present, otherwise the application font. */ QFont appFont(AppFont::Role role) const; + /** @brief Resolves an application font role against the startup font only, + * ignoring any previewed config in currentFonts. Editors that show + * "what this role resolves to" must use this, because appFont() + * reflects a live preview while the editor is open. */ + QFont defaultAppFont(AppFont::Role role) const; QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); From 7a40ed9079f14ed1b91769deff6633a5e183fabe Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:53 +0100 Subject: [PATCH 029/104] [Fonts] Migrate ChatLog surfaces to theme font role (#7537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Migrate ChatLog surfaces to theme font role * [Fonts] Keep ChatView on the app font and share the report log font - ChatLog's unset fallback is qApp->font() again: ChatView never set a font before the role existed, so an unset role must not switch chat and the game log to monospace at the system GeneralFont size. - Move the monospace fallback the report dialogs used before into report_utils::reportLogFont(), which returns the theme's ChatLog role when it is set and the old monospace otherwise. tab_report, dlg_my_reports and dlg_report_user now share it, so they cannot drift and they honour a themed size instead of overriding it. --------- Co-authored-by: Lukas Brübach --- cockatrice/src/interface/theme_manager.cpp | 18 ++++++++++++++++-- cockatrice/src/interface/theme_manager.h | 5 ++++- .../widgets/dialogs/dlg_my_reports.cpp | 11 +++++++++-- .../interface/widgets/dialogs/dlg_my_reports.h | 1 + .../widgets/dialogs/dlg_report_user.cpp | 11 ++++++++++- .../widgets/dialogs/dlg_report_user.h | 1 + .../widgets/server/chat_view/chat_view.cpp | 14 ++++++++++++++ .../widgets/server/chat_view/chat_view.h | 1 + .../src/interface/widgets/tabs/tab_report.cpp | 10 +++++++++- .../src/interface/widgets/tabs/tab_report.h | 1 + .../interface/widgets/utility/report_utils.cpp | 16 ++++++++++++++++ .../interface/widgets/utility/report_utils.h | 7 +++++++ 12 files changed, 89 insertions(+), 7 deletions(-) diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index bf275c7ac..8d538c134 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -658,11 +659,24 @@ QFont ThemeManager::appFont(AppFont::Role role) const return it.value(); } - // No per-role scaling: every unset role resolves to the application font, - // so with no fonts.toml present the result is identical to before. + // Role fallbacks preserve the pre-theme defaults when no fonts.toml is + // present: card text starts from a serif basis, everything else from the + // application font. The report dialogs that used to hardcode monospace ask + // for it explicitly (report_utils::reportLogFont). + if (role == AppFont::CardText) { + return QFont(QStringLiteral("Serif")); + } + + // No per-role scaling: every other unset role resolves to the application + // font, so with no fonts.toml present the result is identical to before. return qApp->font(); } +bool ThemeManager::hasAppFont(AppFont::Role role) const +{ + return currentFonts.fonts.contains(role); +} + QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const { // No per-role scaling and no preview: an unset role resolves to the font diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 37a70f755..aba91faf7 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -150,8 +150,11 @@ public: // when present, otherwise a palette-accent-derived fallback. QColor appColor(AppColor::Role role) const; /** @brief Resolves an application font role: the theme's stored [Fonts] - * value when present, otherwise the application font. */ + * value when present, otherwise a per-role fallback (serif basis + * for CardText, the application font for the remaining roles). */ QFont appFont(AppFont::Role role) const; + /** @brief Whether the active theme defines this role in fonts.toml. */ + bool hasAppFont(AppFont::Role role) const; /** @brief Resolves an application font role against the startup font only, * ignoring any previewed config in currentFonts. Editors that show * "what this role resolves to" must use this, because appFont() diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index 2a2758ba5..a7387a068 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -1,11 +1,13 @@ #include "dlg_my_reports.h" +#include "../interface/theme_manager.h" #include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_report.pb.h" #include +#include #include #include #include @@ -24,7 +26,6 @@ #include #include #include -#include #include #include @@ -69,7 +70,8 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) auto *chatGroup = new QGroupBox(tr("Chat Log Context")); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &DlgMyReports::applyChatLogFont); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); @@ -122,6 +124,11 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) refreshList(); } +void DlgMyReports::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void DlgMyReports::refreshList() { selectedReportIdBeforeRefresh = selectedReportId; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h index dc9f90d2d..90772f051 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h @@ -29,6 +29,7 @@ private slots: void reportDetailsResponse(const Response &response); void addComment(); void addCommentResponse(const Response &response); + void applyChatLogFont(); private: void loadReportDetails(const ServerInfo_Report &report); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp index 3ef45020b..c7c32469b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -1,10 +1,13 @@ #include "dlg_report_user.h" +#include "../interface/theme_manager.h" +#include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include #include +#include #include #include #include @@ -91,7 +94,8 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, auto *chatLayout = new QVBoxLayout(chatGroup); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &DlgReportUser::applyChatLogFont); if (!_autoChatLog.isEmpty()) { chatLogEdit->setPlainText(_autoChatLog); } else { @@ -120,6 +124,11 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, layout->addWidget(buttonBox); } +void DlgReportUser::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void DlgReportUser::actSubmit() { const QString description = descriptionEdit->toPlainText().trimmed(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h index c7fce055d..0f2fa5080 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h @@ -27,6 +27,7 @@ public: private slots: void actSubmit(); void reportResponse(const Response &response); + void applyChatLogFont(); private: static QString categoryLabel(const QString &key); diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index 037a4a1e5..c57f65352 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -4,6 +4,7 @@ #include "../../client/sound_engine.h" #include "../../interface/card_localization.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../tabs/tab_supervisor.h" #include "../user/user_context_menu.h" #include "../user/user_list_manager.h" @@ -63,6 +64,9 @@ ChatView::ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _sho connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &ChatView::adjustColorsToPalette); + applyChatFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &ChatView::applyChatFont); + userContextMenu = new UserContextMenu(tabSupervisor, this, game); connect(userContextMenu, SIGNAL(openMessageDialog(QString, bool)), this, SIGNAL(openMessageDialog(QString, bool))); @@ -108,6 +112,16 @@ void ChatView::adjustColorsToPalette() QTimer::singleShot(0, this, &ChatView::refreshBlockColors); } +void ChatView::applyChatFont() +{ + // Family comes from the ChatLog theme role; the message char formats carry + // no family of their own, so they (and new content) follow the widget and + // document default fonts. Weights/italics stay caller-side as before. + const QFont chatFont = themeManager->appFont(AppFont::ChatLog); + setFont(chatFont); + document()->setDefaultFont(chatFont); +} + void ChatView::refreshBlockColors() { QTextDocument *doc = document(); diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index 3fa7224e5..a2e2f33dd 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -111,6 +111,7 @@ private slots: void openLink(const QUrl &link); void actMessageClicked(); void adjustColorsToPalette(); + void applyChatFont(); void refreshBlockColors(); void onScrollBarRangeChanged(); void onScrollBarValueChanged(int value); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 988c3e7d9..7986286b3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -1,5 +1,6 @@ #include "tab_report.h" +#include "../interface/theme_manager.h" #include "../server/user/user_context_menu.h" #include "../utility/report_utils.h" #include "abstract_client.h" @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -137,7 +139,8 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T chatGroup = new QGroupBox; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); @@ -269,6 +272,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T refreshList(); } +void TabReport::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void TabReport::retranslateUi() { userContextMenu->retranslateUi(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 32ebe1a3f..43e778f4b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -62,6 +62,7 @@ private slots: void statsResponse(const Response &response); void reopenReport(); void reopenResponse(const Response &response); + void applyChatLogFont(); private: int selectedReportId() const; diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp index d7fc083c6..df2400b14 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.cpp +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -1,5 +1,6 @@ #include "report_utils.h" +#include "../../theme_manager.h" #include "libcockatrice/protocol/pb/serverinfo_report.pb.h" #include @@ -7,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +21,20 @@ namespace report_utils { +QFont reportLogFont() +{ + if (themeManager->hasAppFont(AppFont::ChatLog)) { + return themeManager->appFont(AppFont::ChatLog); + } + QFont monoFont(QStringLiteral("monospace")); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + return monoFont; +} + namespace { QColor reportStatusColor(const QString &status) diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h index 679989486..32cc19eac 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.h +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -1,6 +1,7 @@ #ifndef REPORT_UTILS_H #define REPORT_UTILS_H +#include #include #include @@ -11,6 +12,12 @@ class ServerInfo_Report; namespace report_utils { +/** @brief Font for the report chat-log views: the theme's ChatLog role when it + * sets one, otherwise the monospace font these views used before the + * role existed. Shared by the three report surfaces so they cannot + * drift apart. */ +QFont reportLogFont(); + QString formatReportCategory(const QString &category); QString formatReportTime(qint64 secondsSinceEpoch); From 396196b7621e58bf8ded1d73d0243f0bf7c8b9e2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:54 +0100 Subject: [PATCH 030/104] [Fonts] Migrate CardText surfaces to theme font role (#7538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Migrate CardText surfaces to theme font role * [Fonts] Keep CardText call sites on the app font and fix px sizes - CardText's unset fallback is qApp->font() again: only the three counter painters (paintNumberEllipse, GeneralCounter, PlayerCounter) were Serif before the role existed, and they now ask for it through ThemeManager::cardTextFont(). Card names, player names, counters, the overlay, the Archidekt and EDHREC labels and the completer delegate keep the app font they used before. - Use setPixelSize for the EDHREC card name (20px) and commander name (28px) and details (16px) sizes: the stylesheets said px, and setPointSize made them about a third larger. --------- Co-authored-by: Lukas Brübach --- .../board/abstract_card_item.cpp | 3 ++- .../board/abstract_graphics_item.cpp | 4 ++- .../game_graphics/board/counter_general.cpp | 3 ++- .../game_graphics/player/player_target.cpp | 5 ++-- cockatrice/src/interface/theme_manager.cpp | 23 ++++++++++------- cockatrice/src/interface/theme_manager.h | 3 +++ .../widgets/cards/art_crop_attribution.cpp | 4 +++ ..._info_picture_with_text_overlay_widget.cpp | 18 ++++++++++--- ...rd_info_picture_with_text_overlay_widget.h | 3 +++ .../all_zones_card_amount_widget.cpp | 12 +++++++-- .../all_zones_card_amount_widget.h | 3 +++ .../printing_selector/card_amount_widget.cpp | 12 +++++++-- .../printing_selector/card_amount_widget.h | 1 + ...dekt_deck_preview_image_display_widget.cpp | 23 +++++++++++------ ...hidekt_deck_preview_image_display_widget.h | 3 +++ ...i_response_card_details_display_widget.cpp | 17 ++++++++++++- ...api_response_card_details_display_widget.h | 3 +++ ...ponse_commander_details_display_widget.cpp | 25 ++++++++++++++++--- ...esponse_commander_details_display_widget.h | 3 +++ .../utility/card_completer_delegate.cpp | 7 +++--- 20 files changed, 140 insertions(+), 35 deletions(-) diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 0e8b6ced3..84e062cd7 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "../../interface/card_localization.h" #include "../../interface/card_picture_loader/card_picture_loader.h" +#include "../../interface/theme_manager.h" #include "../card_dimensions.h" #include "../game_scene.h" #include "../z_values.h" @@ -154,7 +155,7 @@ void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &transla painter->rotate(angle); painter->translate(-translatedSize.width() / 2, -translatedSize.height() / 2); - QFont f; + QFont f = themeManager->appFont(AppFont::CardText); f.setPixelSize(fontSize * scale); painter->setFont(f); diff --git a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp index 50e80d359..3a740aa98 100644 --- a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp @@ -1,5 +1,7 @@ #include "abstract_graphics_item.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -20,7 +22,7 @@ void AbstractGraphicsItem::paintNumberEllipse(int number, painter->save(); QString numStr = QString::number(number); - QFont font("Serif"); + QFont font = themeManager->cardTextFont(); font.setPixelSize(fontSize); font.setWeight(QFont::Bold); diff --git a/cockatrice/src/game_graphics/board/counter_general.cpp b/cockatrice/src/game_graphics/board/counter_general.cpp index 4a8bbd6a4..23618e6bf 100644 --- a/cockatrice/src/game_graphics/board/counter_general.cpp +++ b/cockatrice/src/game_graphics/board/counter_general.cpp @@ -1,6 +1,7 @@ #include "counter_general.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "abstract_graphics_item.h" #include @@ -41,7 +42,7 @@ void GeneralCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * / painter->drawPixmap(QPoint(0, 0), pixmap); if (value) { - QFont f("Serif"); + QFont f = themeManager->cardTextFont(); f.setPixelSize(qMax((int)(radius * scaleFactor), 10)); f.setWeight(QFont::Bold); painter->setPen(Qt::black); diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 1cd97e7b5..0d10ba4ab 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -2,6 +2,7 @@ #include "../../client/settings/cache_settings.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../board/abstract_counter.h" #include "../board/abstract_graphics_item.h" #include "../board/arrow_target.h" @@ -76,7 +77,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /* QRectF translatedRect = path.controlPointRect(); QSize translatedSize = translatedRect.size().toSize(); - QFont font("Serif"); + QFont font = themeManager->cardTextFont(); font.setWeight(QFont::Bold); font.setPixelSize(qMax(qRound(translatedSize.height() / 1.3), 9)); painter->setFont(font); @@ -226,7 +227,7 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o name = name.mid(0, 10) + "..."; } - QFont font; + QFont font = themeManager->appFont(AppFont::CardText); font.setPixelSize(qMax(qRound(translatedNameRect.height() / 1.5), 9)); painter->setFont(font); painter->setPen(Qt::white); diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 8d538c134..fdd6bbc30 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -660,15 +660,10 @@ QFont ThemeManager::appFont(AppFont::Role role) const } // Role fallbacks preserve the pre-theme defaults when no fonts.toml is - // present: card text starts from a serif basis, everything else from the - // application font. The report dialogs that used to hardcode monospace ask - // for it explicitly (report_utils::reportLogFont). - if (role == AppFont::CardText) { - return QFont(QStringLiteral("Serif")); - } - - // No per-role scaling: every other unset role resolves to the application - // font, so with no fonts.toml present the result is identical to before. + // present: an unset role resolves to the application font, so the result is + // identical to before. The report dialogs that used to hardcode monospace + // ask for it explicitly (report_utils::reportLogFont), and the counter + // painters that used to hardcode Serif ask for it through cardTextFont(). return qApp->font(); } @@ -677,6 +672,16 @@ bool ThemeManager::hasAppFont(AppFont::Role role) const return currentFonts.fonts.contains(role); } +QFont ThemeManager::cardTextFont() const +{ + if (hasAppFont(AppFont::CardText)) { + return appFont(AppFont::CardText); + } + QFont serifFont(QStringLiteral("Serif")); + serifFont.setStyleHint(QFont::Serif); + return serifFont; +} + QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const { // No per-role scaling and no preview: an unset role resolves to the font diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index aba91faf7..35cc36d04 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -155,6 +155,9 @@ public: QFont appFont(AppFont::Role role) const; /** @brief Whether the active theme defines this role in fonts.toml. */ bool hasAppFont(AppFont::Role role) const; + /** @brief The CardText role with its historical Serif fallback. Used by the + * counter painters, which were Serif before the role existed. */ + QFont cardTextFont() const; /** @brief Resolves an application font role against the startup font only, * ignoring any previewed config in currentFonts. Editors that show * "what this role resolves to" must use this, because appFont() diff --git a/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp index c4b347047..8475a0ed9 100644 --- a/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp +++ b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp @@ -1,5 +1,6 @@ #include "art_crop_attribution.h" +#include "../../theme_manager.h" #include "libcockatrice/card/printing/printing_info.h" #include @@ -35,7 +36,10 @@ QRectF paintArtAttribution(QPainter &painter, painter.save(); + // Family comes from the CardText theme role; the incoming painter size + // stays the basis for the scale factor exactly as before. QFont font = painter.font(); + font.setFamily(themeManager->appFont(AppFont::CardText).family()); font.setPointSizeF(qMax(6.0, font.pointSizeF() * scale)); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp index 04fe9ab69..56fe0bda4 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp @@ -1,5 +1,7 @@ #include "card_info_picture_with_text_overlay_widget.h" +#include "../../theme_manager.h" + #include #include #include @@ -43,6 +45,7 @@ CardInfoPictureWithTextOverlayWidget::CardInfoPictureWithTextOverlayWidget(QWidg fontSize(fontSize), textAlignment(alignment), highlighted(false) { this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + connect(themeManager, &ThemeManager::fontsChanged, this, &CardInfoPictureWithTextOverlayWidget::applyCardTextFont); } /** @@ -97,6 +100,13 @@ void CardInfoPictureWithTextOverlayWidget::setTextAlignment(const Qt::Alignment update(); } +void CardInfoPictureWithTextOverlayWidget::applyCardTextFont() +{ + // The overlay paint reads the CardText role font at paint time, so a + // theme font change only needs a repaint here. + update(); +} + void CardInfoPictureWithTextOverlayWidget::setHighlighted(bool _highlighted) { if (highlighted == _highlighted) { @@ -168,8 +178,10 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) return; } - // Calculate the optimal font size - QFont font = painter.font(); + // Calculate the optimal font size. The family comes from the CardText + // theme role; the fontSize member stays the starting size and the + // shrink-to-fit logic below is unchanged. + QFont font = themeManager->appFont(AppFont::CardText); int optimalFontSize = fontSize; // Start with the user-defined font size const QFontMetrics baseMetrics(font); int textWidth = pixmapRect.width(); @@ -284,7 +296,7 @@ QSize CardInfoPictureWithTextOverlayWidget::minimumSizeHint() const const QSize pixmapSize = pixmap.isNull() ? QSize(0, 0) : pixmap.size(); // Get the font metrics for the overlay text - QFont font; + QFont font = themeManager->appFont(AppFont::CardText); font.setPointSize(fontSize); const QFontMetrics fontMetrics(font); diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h index 6c547dc13..41019f543 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h @@ -47,6 +47,9 @@ protected: void mousePressEvent(QMouseEvent *event) override; [[nodiscard]] QSize minimumSizeHint() const override; +private slots: + void applyCardTextFont(); + private: void drawOutlinedText(QPainter &painter, const QRect &textRect, diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp index bad88ddbb..13a43bd47 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp @@ -1,5 +1,6 @@ #include "all_zones_card_amount_widget.h" +#include "../../theme_manager.h" #include "../general/display/shadow_background_label.h" #include "card_amount_widget.h" #include "libcockatrice/card/card_info.h" @@ -67,6 +68,7 @@ AllZonesCardAmountWidget::AllZonesCardAmountWidget(QWidget *parent, buttonBoxSideboard->setVisible(!isToken); connect(cardSizeSlider, &QSlider::valueChanged, this, &AllZonesCardAmountWidget::adjustFontSize); + connect(themeManager, &ThemeManager::fontsChanged, this, &AllZonesCardAmountWidget::applyCardTextFont); QTimer::singleShot(10, this, [this]() { adjustFontSize(this->cardSizeSlider->value()); }); @@ -90,8 +92,9 @@ void AllZonesCardAmountWidget::adjustFontSize(int scalePercentage) int newFontSize = minFontSize + (scalePercentage - basePercentage) * (maxFontSize - minFontSize) / 225; newFontSize = std::clamp(newFontSize, minFontSize, maxFontSize); - // Update the font labels - QFont zoneLabelFont = zoneLabelMainboard->font(); + // Update the font labels. The family comes from the CardText theme + // role; the slider-driven point size stays as before. + QFont zoneLabelFont = themeManager->appFont(AppFont::CardText); zoneLabelFont.setPointSize(newFontSize); zoneLabelMainboard->setFont(zoneLabelFont); zoneLabelSideboard->setFont(zoneLabelFont); @@ -101,6 +104,11 @@ void AllZonesCardAmountWidget::adjustFontSize(int scalePercentage) repaint(); } +void AllZonesCardAmountWidget::applyCardTextFont() +{ + adjustFontSize(cardSizeSlider->value()); +} + void AllZonesCardAmountWidget::setAmounts(int mainboardAmount, int sideboardAmount, int tokensboardAmount) { buttonBoxMainboard->setAmount(mainboardAmount); diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h index cefabe293..3213cf16b 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h @@ -36,6 +36,9 @@ public slots: void adjustFontSize(int scalePercentage); void setAmounts(int mainboardAmount, int sideboardAmount, int tokensboardAmount); +private slots: + void applyCardTextFont(); + private: QVBoxLayout *layout; QSlider *cardSizeSlider; diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp index eae49d7ef..7f25ea1f4 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp @@ -1,5 +1,6 @@ #include "card_amount_widget.h" +#include "../../theme_manager.h" #include "../deck_editor/deck_state_manager.h" #include "../general/display/dynamic_font_size_push_button.h" #include "deck_list_model.h" @@ -77,6 +78,7 @@ CardAmountWidget::CardAmountWidget(QWidget *parent, // Connect slider for dynamic font size adjustment connect(cardSizeSlider, &QSlider::valueChanged, this, &CardAmountWidget::adjustFontSize); + connect(themeManager, &ThemeManager::fontsChanged, this, &CardAmountWidget::applyCardTextFont); } int CardAmountWidget::getAmount() @@ -138,8 +140,9 @@ void CardAmountWidget::adjustFontSize(int scalePercentage) int newFontSize = minFontSize + (scalePercentage - basePercentage) * (maxFontSize - minFontSize) / 225; newFontSize = std::clamp(newFontSize, minFontSize, maxFontSize); - // Update the font for card count label - QFont cardCountFont = cardCountInZone->font(); + // Update the font for card count label. The family comes from the + // CardText theme role; the slider-driven point size stays as before. + QFont cardCountFont = themeManager->appFont(AppFont::CardText); cardCountFont.setPointSize(newFontSize); cardCountInZone->setFont(cardCountFont); @@ -150,6 +153,11 @@ void CardAmountWidget::adjustFontSize(int scalePercentage) repaint(); } +void CardAmountWidget::applyCardTextFont() +{ + adjustFontSize(cardSizeSlider->value()); +} + /** * @brief Updates the card count display in the widget. */ diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h index 6f5889fc7..cef872157 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h @@ -67,6 +67,7 @@ private slots: void removePrintingSideboard(); void removePrintingTokensboard(); void adjustFontSize(int scalePercentage); + void applyCardTextFont(); }; #endif // CARD_AMOUNT_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp index 97383534f..3720aeb55 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp @@ -1,5 +1,6 @@ #include "archidekt_deck_preview_image_display_widget.h" +#include "../../../../../theme_manager.h" #include "../../../../general/display/shadow_background_label.h" #include @@ -17,13 +18,9 @@ ArchidektDeckPreviewImageDisplayWidget::ArchidektDeckPreviewImageDisplayWidget(Q bottomLeftLabel = new ShadowBackgroundLabel(this, ""); bottomRightLabel = new ShadowBackgroundLabel(this, ""); - QFont f; - f.setBold(true); - f.setPointSize(16); - topLeftLabel->setFont(f); - topRightLabel->setFont(f); - bottomLeftLabel->setFont(f); - bottomRightLabel->setFont(f); + applyCardTextFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &ArchidektDeckPreviewImageDisplayWidget::applyCardTextFont); // Raise so labels appear above image topLeftLabel->raise(); @@ -32,6 +29,18 @@ ArchidektDeckPreviewImageDisplayWidget::ArchidektDeckPreviewImageDisplayWidget(Q bottomRightLabel->raise(); } +void ArchidektDeckPreviewImageDisplayWidget::applyCardTextFont() +{ + // Family comes from the CardText theme role; size and weight stay caller-side as before. + QFont f = themeManager->appFont(AppFont::CardText); + f.setBold(true); + f.setPointSize(16); + topLeftLabel->setFont(f); + topRightLabel->setFont(f); + bottomLeftLabel->setFont(f); + bottomRightLabel->setFont(f); +} + void ArchidektDeckPreviewImageDisplayWidget::setAspectRatio(float ratio) { aspectRatio = ratio; diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h index 991f27b6a..41585e851 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h @@ -60,6 +60,9 @@ protected: */ void resizeEvent(QResizeEvent *event) override; +private slots: + void applyCardTextFont(); + private: float aspectRatio = 1.0f; ///< Aspect ratio to maintain for the preview image }; diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index b2ddace01..2b76de973 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp @@ -1,5 +1,6 @@ #include "edhrec_api_response_card_details_display_widget.h" +#include "../../../../../../theme_manager.h" #include "../../../../../cards/card_info_picture_widget.h" #include "../../../../../cards/card_size_widget.h" #include "../../../../../general/display/background_plate_widget.h" @@ -9,6 +10,7 @@ #include "edhrec_api_response_card_synergy_display_widget.h" #include "libcockatrice/card/database/card_database_querier.h" +#include #include #include #include @@ -32,7 +34,9 @@ EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWi nameLabel = new QLabel(this); nameLabel->setText(toDisplay.name); nameLabel->setAlignment(Qt::AlignHCenter); - nameLabel->setStyleSheet("font-size: 20px; font-weight: bold"); + applyCardTextFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecApiResponseCardDetailsDisplayWidget::applyCardTextFont); inclusionDisplayWidget = new EdhrecApiResponseCardInclusionDisplayWidget(this, toDisplay); @@ -86,3 +90,14 @@ void EdhrecApiResponseCardDetailsDisplayWidget::actRequestPageNavigation() { emit requestUrl(toDisplay.url); } + +void EdhrecApiResponseCardDetailsDisplayWidget::applyCardTextFont() +{ + // Family comes from the CardText theme role; the 20px size and bold + // weight previously carried by the stylesheet stay caller-side. Pixel + // size, not point size: the stylesheet said 20px, and 20pt is ~27px. + QFont cardFont = themeManager->appFont(AppFont::CardText); + cardFont.setPixelSize(20); + cardFont.setWeight(QFont::Bold); + nameLabel->setFont(cardFont); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h index d738f4685..8a3c8fba4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h @@ -30,6 +30,9 @@ public slots: signals: void requestUrl(QString url); +private slots: + void applyCardTextFont(); + private: EdhrecApiResponseCardDetails toDisplay; QVBoxLayout *layout; diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp index 2ff22f2b3..df0319f88 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp @@ -1,5 +1,6 @@ #include "edhrec_api_response_commander_details_display_widget.h" +#include "../../../../../../theme_manager.h" #include "../../../../../cards/card_info_picture_widget.h" #include "../../../../../cards/card_size_widget.h" #include "../../api_response/cards/edhrec_commander_api_response_commander_details.h" @@ -8,6 +9,7 @@ #include "edhrec_commander_api_response_navigation_widget.h" #include "libcockatrice/card/database/card_database_querier.h" +#include #include #include #include @@ -48,15 +50,16 @@ EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCom commanderName = new QLabel(this); commanderName->setText(commanderDetails.getName()); commanderName->setAlignment(Qt::AlignCenter); - commanderName->setStyleSheet("font-size: 28px; font-weight: bold"); label = new QLabel(this); label->setAlignment(Qt::AlignCenter); - label->setStyleSheet("font-size: 16px"); salt = new QLabel(this); salt->setAlignment(Qt::AlignCenter); - salt->setStyleSheet("font-size: 16px"); + + applyCardTextFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderResponseCommanderDetailsDisplayWidget::applyCardTextFont); cardPricesDisplayWidget = new EdhrecApiResponseCardPricesDisplayWidget(this, commanderDetails.getPrices()); @@ -83,3 +86,19 @@ void EdhrecCommanderResponseCommanderDetailsDisplayWidget::retranslateUi() label->setText(commanderDetails.getLabel()); salt->setText(tr("Salt: ") + QString::number(commanderDetails.getSalt())); } + +void EdhrecCommanderResponseCommanderDetailsDisplayWidget::applyCardTextFont() +{ + // Family comes from the CardText theme role; the sizes and the bold + // weight previously carried by the stylesheets stay caller-side. Pixel + // sizes, not point sizes: the stylesheets said 28px and 16px. + QFont nameFont = themeManager->appFont(AppFont::CardText); + nameFont.setPixelSize(28); + nameFont.setWeight(QFont::Bold); + commanderName->setFont(nameFont); + + QFont detailsFont = themeManager->appFont(AppFont::CardText); + detailsFont.setPixelSize(16); + label->setFont(detailsFont); + salt->setFont(detailsFont); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h index 197263512..99e6b3d43 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h @@ -30,6 +30,9 @@ public: QString baseUrl); void retranslateUi(); +private slots: + void applyCardTextFont(); + private: EdhrecCommanderApiResponseCommanderDetails commanderDetails; QHBoxLayout *layout; diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp index a629df43c..0090e9d65 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -1,5 +1,6 @@ #include "card_completer_delegate.h" +#include "../../interface/theme_manager.h" #include "../../pixel_map_generator.h" #include "../cards/additional_info/mana_cost_widget.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -182,7 +183,7 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt p->setBrush(Qt::NoBrush); p->drawEllipse(pip.adjusted(1, 1, -1, -1)); - QFont f = QtUtils::boldFont(p->font()); + QFont f = QtUtils::boldFont(themeManager->appFont(AppFont::CardText)); f.setPixelSize(qMax(radius - 1, 7)); p->setFont(f); @@ -429,7 +430,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem const QRect nameRect = showInfoBand ? QRect(textLeft, rect.top() + 2, textWidth, 20) : QRect(textLeft, rect.top() + 2, textWidth, rect.height() - 4); - QFont f = option.font; + QFont f = themeManager->appFont(AppFont::CardText); f.setPixelSize(13); f.setBold(true); @@ -451,7 +452,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem if (showInfoBand) { const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24); - QFont f = option.font; + QFont f = themeManager->appFont(AppFont::CardText); f.setPixelSize(10); painter->setFont(f); From 3bcdfc37931077dc2bb87c29c9a8877953145f1d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:54 +0100 Subject: [PATCH 031/104] [Fonts] Migrate DeckList and InterfaceDefault surfaces to theme roles (#7539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Migrate DeckList and InterfaceDefault surfaces to theme roles * [Fonts] Drop unused lambda captures in font-refresh handlers * [Fonts] Fix pixel sizes and game overlay fonts from review - Set the sizes the stylesheets carried with setPixelSize() rather than setPointSize(): home buttons (34), banner (24), home group box (20), the placeholder labels (14), the analytics drag handle (10), the EDHREC navigation buttons (24), the user info popup (11/14/11/10/11) and the game overlays (14/16/12). Points at 96 DPI were ~33% larger. - The drag/total counters and tally columns keep the monospace family the stylesheets set, so digit widths stop changing as counts tick and white-space: pre stays aligned. - applyDeckListFont() drops the cached tally rows before re-measuring, so previewing a larger DeckList font no longer leaves the tally clipped. - Tally labels and the constructor/fontsChanged pairs in the shared-deck preview, quick setup, analytics panel, home box and public decks build their font once in a local lambda instead of twice. --------- Co-authored-by: Lukas Brübach --- .../src/client/settings/shortcut_treeview.cpp | 4 +- cockatrice/src/filters/filter_tree_model.cpp | 5 +- .../src/game_graphics/deckview/deck_view.cpp | 6 ++- cockatrice/src/game_graphics/game_view.cpp | 52 ++++++++++++++++--- cockatrice/src/game_graphics/game_view.h | 1 + .../src/interface/deck_loader/deck_loader.cpp | 9 +++- .../interface/palette_editor/color_button.cpp | 6 ++- .../palette_editor/palette_grid_widget.cpp | 17 +++++- .../palette_editor/palette_grid_widget.h | 4 ++ .../palette_editor/quick_setup_panel.cpp | 14 +++-- .../deck_analytics/resizable_panel.cpp | 12 ++++- .../deck_editor/deck_list_style_proxy.cpp | 5 +- .../dialogs/dlg_shared_decks_preview.cpp | 12 +++-- .../widgets/general/display/banner_widget.cpp | 16 +++++- .../widgets/general/display/banner_widget.h | 1 + .../display/charts/bars/bar_chart_widget.cpp | 5 +- .../charts/bars/segmented_bar_widget.cpp | 5 +- .../display/dynamic_font_size_label.cpp | 13 ++++- .../display/dynamic_font_size_push_button.cpp | 7 ++- .../widgets/general/home_styled_button.cpp | 18 +++++-- .../widgets/general/home_styled_button.h | 3 ++ .../interface/widgets/general/home_widget.cpp | 15 ++++-- .../widgets/onboarding/first_run_wizard.cpp | 16 ++++-- .../widgets/onboarding/first_run_wizard.h | 1 + .../printing_selector_placeholder_widget.cpp | 15 +++++- .../printing_selector_placeholder_widget.h | 3 ++ .../widgets/server/user/user_info_box.cpp | 17 ++++-- .../widgets/server/user/user_info_box.h | 1 + .../widgets/server/user/user_info_popup.cpp | 44 +++++++++++----- .../widgets/server/user/user_list_painter.cpp | 9 +++- .../widgets/server/user/user_list_widget.cpp | 2 +- .../settings_search_delegate.cpp | 6 ++- ...api_response_bracket_navigation_widget.cpp | 25 +++++++-- ...r_api_response_bracket_navigation_widget.h | 3 ++ ..._api_response_budget_navigation_widget.cpp | 25 +++++++-- ...er_api_response_budget_navigation_widget.h | 3 ++ ...mmander_api_response_navigation_widget.cpp | 19 ++++++- ...commander_api_response_navigation_widget.h | 3 ++ .../interface/widgets/tabs/tab_developer.cpp | 4 +- .../widgets/tabs/tab_public_decks.cpp | 8 ++- .../visual_deck_editor_placeholder_widget.cpp | 15 +++++- .../visual_deck_editor_placeholder_widget.h | 3 ++ .../deck_preview_tag_addition_widget.cpp | 7 ++- .../deck_preview_tag_display_widget.cpp | 7 ++- 44 files changed, 387 insertions(+), 79 deletions(-) diff --git a/cockatrice/src/client/settings/shortcut_treeview.cpp b/cockatrice/src/client/settings/shortcut_treeview.cpp index 4a5bafb01..d81589f34 100644 --- a/cockatrice/src/client/settings/shortcut_treeview.cpp +++ b/cockatrice/src/client/settings/shortcut_treeview.cpp @@ -1,5 +1,6 @@ #include "shortcut_treeview.h" +#include "../../interface/theme_manager.h" #include "cache_settings.h" #include "shortcuts_settings.h" @@ -93,7 +94,8 @@ void ShortcutTreeView::populateShortcutsModel() curParent = parentItems.value(group); } else { curParent = new QStandardItem(group); - static QFont font = curParent->font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setBold(true); curParent->setFont(font); parentItems.insert(group, curParent); diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp index 9b55ce2fd..467c2564b 100644 --- a/cockatrice/src/filters/filter_tree_model.cpp +++ b/cockatrice/src/filters/filter_tree_model.cpp @@ -1,5 +1,7 @@ #include "filter_tree_model.h" +#include "../interface/theme_manager.h" + #include #include #include @@ -202,7 +204,8 @@ QVariant FilterTreeModel::data(const QModelIndex &index, int role) const switch (role) { case Qt::FontRole: if (!node->isLeaf()) { - QFont f; + // Family comes from the DeckList theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::DeckList); f.setBold(true); return f; } diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 6e0b0eaaa..63ee48e57 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -240,7 +240,8 @@ void DeckViewCardContainer::paint(QPainter *painter, const QStyleOptionGraphicsI painter->drawLine(QPointF(0, separatorY), QPointF(width, separatorY)); painter->setPen(QColor(Qt::white)); - QFont f("Serif"); + // Family comes from the DeckList theme role; pixel sizes/weights unchanged. + QFont f = themeManager->appFont(AppFont::DeckList); f.setStyleHint(QFont::Serif); f.setPixelSize(24); f.setWeight(QFont::Bold); @@ -292,7 +293,8 @@ QList> DeckViewCardContainer::getRowsAndCols() const int DeckViewCardContainer::getCardTypeTextWidth() const { - QFont f("Serif"); + // Family comes from the DeckList theme role; pixel size/weight unchanged. + QFont f = themeManager->appFont(AppFont::DeckList); f.setStyleHint(QFont::Serif); f.setPixelSize(16); f.setWeight(QFont::Bold); diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index e6d94a035..7a5aa073a 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -2,12 +2,14 @@ #include "../client/settings/cache_settings.h" #include "../client/settings/shortcuts_settings.h" +#include "../interface/theme_manager.h" #include "game_scene.h" #include #include #include #include +#include #include #include #include @@ -24,6 +26,21 @@ #include #include +namespace +{ +QFont deckOverlayFont(int pixelSize, bool bold) +{ + // The drag/total counters and the tally columns are in-game overlays, not + // deck list text: keep the monospace family the stylesheets used to set so + // digit widths stay stable as counts tick and `white-space: pre` lines up. + QFont font(QStringLiteral("monospace")); + font.setStyleHint(QFont::Monospace); + font.setPixelSize(pixelSize); + font.setBold(bold); + return font; +} +} // namespace + // QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings. // This subclass disables that behavior so dragCountLabel can appear above it. class SelectionRubberBand : public QRubberBand @@ -76,15 +93,14 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this); 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;"; + const QString dragCountLabelStyle = baseProperties; + const QString totalCountLabelStyle = baseProperties; + const QString subtypeTallyLabelStyle = baseProperties; dragCountLabel = new QLabel(this); dragCountLabel->setStyleSheet(dragCountLabelStyle); @@ -101,6 +117,27 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par tallyLayout->setContentsMargins(2, 2, 2, 2); tallyLayout->setSpacing(2); tallyContainer->hide(); + + applyDeckOverlayFonts(); +} + +void GameView::applyDeckOverlayFonts() +{ + dragCountLabel->setFont(deckOverlayFont(14, true)); + totalCountLabel->setFont(deckOverlayFont(16, true)); + tallyContainer->setFont(deckOverlayFont(12, false)); + for (int i = 0; i < tallyLayout->count(); ++i) { + if (auto *label = qobject_cast(tallyLayout->itemAt(i)->widget())) { + // Count column (odd positions) keeps the bold emphasis. + const bool count = i % 2 == 1; + label->setFont(deckOverlayFont(count ? 14 : 12, count)); + } + } + + // The container keeps the size computed from the previous fonts, so force + // rebuildTallyLabels() to re-measure it instead of reusing cachedTallyRows. + cachedTallyRows.clear(); + updateTotalSelectionCount(); } void GameView::resizeEvent(QResizeEvent *event) @@ -220,9 +257,8 @@ QSize GameView::rebuildTallyLabels(const QList &entries) { clearTallyLabels(); - 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;"); + const QString nameStyle = QStringLiteral("color: white; background: transparent;"); + const QString countStyle = QStringLiteral("color: white; background: transparent;"); int totalHeight = 0; int maxNameWidth = 0; @@ -232,11 +268,13 @@ QSize GameView::rebuildTallyLabels(const QList &entries) for (const TallyRow &entry : entries) { auto *nameLabel = new QLabel(entry.name, tallyContainer); nameLabel->setStyleSheet(nameStyle); + nameLabel->setFont(deckOverlayFont(12, false)); nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); tallyLayout->addWidget(nameLabel, row, 0); auto *countLabel = new QLabel(entry.value, tallyContainer); countLabel->setStyleSheet(countStyle); + countLabel->setFont(deckOverlayFont(14, true)); countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); tallyLayout->addWidget(countLabel, row, 1); diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index e57267734..99a73a0a8 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -50,6 +50,7 @@ private slots: void refreshShortcuts(); void updateTotalSelectionCount(const QSize &viewSize = QSize()); void setFocusDisabled(bool disabled); + void applyDeckOverlayFonts(); public slots: void updateSceneRect(const QRectF &rect); diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index d413a6c04..d5c7de9a4 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -1,5 +1,6 @@ #include "deck_loader.h" +#include "../theme_manager.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/abstract_deck_list_card_node.h" @@ -579,6 +580,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode if (!cards.isEmpty()) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; + // Family comes from the DeckList theme role; sizes/weights unchanged. + charFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); charFormat.setFontPointSize(11); charFormat.setFontWeight(QFont::Bold); cursor->insertBlock(blockFormat, charFormat); @@ -592,6 +595,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode const AbstractDecklistCardNode *card = cards[i]; QTextCharFormat cellCharFormat; + cellCharFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); cellCharFormat.setFontPointSize(9); QTextTableCell cell = table->cellAt(i, 0); @@ -613,6 +617,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode QTextBlockFormat blockFormat; QTextCharFormat charFormat; + charFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); charFormat.setFontPointSize(14); charFormat.setFontWeight(QFont::Bold); @@ -640,7 +645,8 @@ void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList) { QTextDocument doc; - QFont font("Serif"); + // Family comes from the DeckList theme role; the print sizes/weights below stay unchanged. + QFont font = themeManager->appFont(AppFont::DeckList); font.setStyleHint(QFont::Serif); doc.setDefaultFont(font); @@ -648,6 +654,7 @@ void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList) QTextBlockFormat headerBlockFormat; QTextCharFormat headerCharFormat; + headerCharFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); headerCharFormat.setFontPointSize(16); headerCharFormat.setFontWeight(QFont::Bold); diff --git a/cockatrice/src/interface/palette_editor/color_button.cpp b/cockatrice/src/interface/palette_editor/color_button.cpp index a7db23d31..ae9bd6297 100644 --- a/cockatrice/src/interface/palette_editor/color_button.cpp +++ b/cockatrice/src/interface/palette_editor/color_button.cpp @@ -1,5 +1,7 @@ #include "color_button.h" +#include "../theme_manager.h" + #include #include #include @@ -21,6 +23,7 @@ ColorButton::ColorButton(QWidget *parent) : QToolButton(parent) setCursor(Qt::PointingHandCursor); setToolTip(tr("Click to pick a color")); connect(this, &QToolButton::clicked, this, &ColorButton::pickColor); + connect(themeManager, &ThemeManager::fontsChanged, this, &ColorButton::updateSwatch); } void ColorButton::setColor(const QColor &c) @@ -73,7 +76,8 @@ void ColorButton::updateSwatch() // Hex label — black or white for contrast int luma = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue(); painter.setPen((luma > 128 && color.alpha() > 80) ? QColor(0, 0, 0, 180) : QColor(255, 255, 255, 200)); - QFont f = font(); + // Family comes from the InterfaceDefault theme role; the 8px size stays caller-side. + QFont f = themeManager->appFont(AppFont::InterfaceDefault); f.setPixelSize(8); painter.setFont(f); painter.drawText(rect(), Qt::AlignCenter, color.name().toUpper()); diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 463c4038d..392f01d0d 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -76,6 +76,18 @@ PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) layout = QtUtils::makeLayout(this); layout->addWidget(scroll); + + connect(themeManager, &ThemeManager::fontsChanged, this, &PaletteGridWidget::applyInterfaceDefaultFont); +} + +void PaletteGridWidget::applyInterfaceDefaultFont() +{ + // The editor's own chrome follows the InterfaceDefault theme role; bold stays caller-side. + for (auto *label : headerLabels) { + QFont headerFont = themeManager->appFont(AppFont::InterfaceDefault); + headerFont.setBold(true); + label->setFont(headerFont); + } } void PaletteGridWidget::buildGrid(QWidget *host) @@ -101,7 +113,8 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *label = new QLabel(groupHeaders[col], host); label->setAlignment(Qt::AlignCenter); label->setToolTip(groupTips[col]); - label->setFont(QtUtils::boldFont(label->font())); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + label->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); label->setAutoFillBackground(true); label->setContentsMargins(4, 4, 4, 4); grid->addWidget(label, 0, col + 1); @@ -146,7 +159,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *appHeader = new QLabel(tr("App colors"), host); appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); - appHeader->setFont(QtUtils::boldFont(appHeader->font())); + appHeader->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); appHeader->setAutoFillBackground(true); appHeader->setContentsMargins(4, 4, 4, 4); grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.h b/cockatrice/src/interface/palette_editor/palette_grid_widget.h index f1ff41ec2..7c527a064 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.h +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.h @@ -34,6 +34,10 @@ private: void changeEvent(QEvent *e); void refreshChromePalettes(); +private slots: + void applyInterfaceDefaultFont(); + +private: QMap> colorButtons; QMap appColorButtons; QScrollArea *scroll; diff --git a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp index 04f98dcc3..00eac3253 100644 --- a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp +++ b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp @@ -1,5 +1,6 @@ #include "quick_setup_panel.h" +#include "../theme_manager.h" #include "color_button.h" #include @@ -28,10 +29,15 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) labelLow = new QLabel(this); labelHigh = new QLabel(this); - QFont small = labelLow->font(); - small.setPointSizeF(small.pointSizeF() * 0.82); - labelLow->setFont(small); - labelHigh->setFont(small); + // Family comes from the InterfaceDefault theme role; the *0.82 size stays caller-side. + auto applySmallFont = [this]() { + QFont small = themeManager->appFont(AppFont::InterfaceDefault); + small.setPointSizeF(small.pointSizeF() * 0.82); + labelLow->setFont(small); + labelHigh->setFont(small); + }; + applySmallFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applySmallFont); QPalette dimmed = labelLow->palette(); dimmed.setColor(QPalette::WindowText, qApp->palette().color(QPalette::Mid)); labelLow->setPalette(dimmed); diff --git a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp index 85dba6468..df2a645f1 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp @@ -1,5 +1,6 @@ #include "resizable_panel.h" +#include "../interface/theme_manager.h" #include "abstract_analytics_panel_widget.h" #include "libcockatrice/utility/qt_utils.h" @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -93,9 +95,17 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid dragButton->setStyleSheet(QString("QPushButton { " "background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 %1, stop:1 %2); " - "border: none; color: %3; font-size: 10px; }" + "border: none; color: %3; }" "QPushButton:hover { background: %4; }") .arg(mid.name(), dark.name(), placeholderText.name(), midLight.name())); + // Family comes from the InterfaceDefault theme role; the 10px size stays caller-side. + auto applyDragFont = [this]() { + QFont dragFont = themeManager->appFont(AppFont::InterfaceDefault); + dragFont.setPixelSize(10); + dragButton->setFont(dragFont); + }; + applyDragFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyDragFont); resizeHandle->setStyleSheet(QString("QWidget { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, " "stop:0 %1, stop:1 %2); }") diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp index 1e1aefe58..59b7fff79 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp @@ -1,5 +1,7 @@ #include "deck_list_style_proxy.h" +#include "../../theme_manager.h" + #include #include #include @@ -20,7 +22,8 @@ QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const bool isCard = src.data(DeckRoles::IsCardRole).toBool(); if (role == Qt::FontRole && !isCard) { - QFont f; + // Family comes from the DeckList theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::DeckList); f.setBold(true); return f; } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp index d29aee27f..49bacd081 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -2,6 +2,7 @@ #include "../deck_share/shared_deck_preview_widget.h" #include "../general/layout_containers/flow_widget.h" +#include "../interface/theme_manager.h" #include #include @@ -35,9 +36,14 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, // QLabels render AutoText and markup would otherwise be shown as rich text. auto *titleLabel = new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); - QFont titleFont = QtUtils::boldFont(titleLabel->font()); - titleFont.setPointSize(titleFont.pointSize() + 2); - titleLabel->setFont(titleFont); + // Family comes from the InterfaceDefault theme role; the +2 size and bold stay caller-side. + auto applyTitleFont = [titleLabel]() { + QFont titleFont = QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault)); + titleFont.setPointSize(titleFont.pointSize() + 2); + titleLabel->setFont(titleFont); + }; + applyTitleFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyTitleFont); mainLayout->addWidget(titleLabel); if (!serverText.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp index baf46bbba..5cb62db0f 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp @@ -1,9 +1,11 @@ #include "banner_widget.h" #include "../../../pixel_map_generator.h" +#include "../../../theme_manager.h" #include #include +#include #include #include #include @@ -28,7 +30,9 @@ BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation if (transparency > 50) { textColor = " color: white;"; } - bannerLabel->setStyleSheet("font-size: 24px; font-weight: bold;" + textColor); + bannerLabel->setStyleSheet(textColor); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &BannerWidget::applyInterfaceDefaultFont); layout->addWidget(iconLabel); layout->addWidget(bannerLabel); @@ -56,6 +60,16 @@ void BannerWidget::setText(const QString &text) const bannerLabel->setText(text); } +void BannerWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size and + // bold weight previously carried by the stylesheet stay caller-side. + QFont bannerFont = themeManager->appFont(AppFont::InterfaceDefault); + bannerFont.setPixelSize(24); + bannerFont.setWeight(QFont::Bold); + bannerLabel->setFont(bannerFont); +} + void BannerWidget::setClickable(bool _clickable) { clickable = _clickable; diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.h b/cockatrice/src/interface/widgets/general/display/banner_widget.h index 06556089b..d46250bdb 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.h +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.h @@ -48,6 +48,7 @@ signals: private slots: void toggleBuddyVisibility() const; void updateDropdownIconState() const; + void applyInterfaceDefaultFont(); }; #endif // BANNER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp index 484ac861d..cd2a3702d 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp @@ -1,5 +1,7 @@ #include "bar_chart_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -146,7 +148,8 @@ void BarChartWidget::paintEvent(QPaintEvent *) // draw label below bar QRect labelRect(left + i * (barWidth + spacing), bottom, barWidth, LABEL_H); - QFont f = p.font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::InterfaceDefault); f.setBold(true); p.setFont(f); p.setPen(Qt::black); diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp index 5a3130964..d6c75f3bd 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp @@ -1,5 +1,7 @@ #include "segmented_bar_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -95,7 +97,8 @@ void SegmentedBarWidget::paintEvent(QPaintEvent *) // draw label QRect labelRect(0, h - LABEL_H, w, LABEL_H); - QFont f = p.font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::InterfaceDefault); f.setBold(true); p.setFont(f); p.setPen(Qt::black); diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp index 0609eb16a..598c70fe2 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp @@ -1,5 +1,7 @@ #include "dynamic_font_size_label.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -28,9 +30,13 @@ void DynamicFontSizeLabel::paintEvent(QPaintEvent *event) // QElapsedTimer timer; // timer.start(); - QFont newFont = font(); + // Family follows the InterfaceDefault theme role; the fitted point size + // stays computed from the widget geometry as before. + QFont newFont = themeManager->appFont(AppFont::InterfaceDefault); float fontSize = getWidgetMaximumFontSize(this, this->text()); newFont.setPointSizeF(fontSize); + // Preserve the caller's bold emphasis, which the role font does not carry. + newFont.setBold(font().bold()); setFont(newFont); // qDebug() << "Font size set to" << fontSize; @@ -40,7 +46,10 @@ void DynamicFontSizeLabel::paintEvent(QPaintEvent *event) float DynamicFontSizeLabel::getWidgetMaximumFontSize(QWidget *widget, const QString &text) { - QFont font = widget->font(); + // Measure with the InterfaceDefault family so the fit matches the painted font. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); + font.setBold(widget->font().bold()); + font.setPointSizeF(widget->font().pointSizeF()); const QRect widgetRect = widget->contentsRect(); const float widgetWidth = widgetRect.width(); const float widgetHeight = widgetRect.height(); diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp index 25e8ca48f..9b5f5bdda 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp @@ -1,5 +1,6 @@ #include "dynamic_font_size_push_button.h" +#include "../../interface/theme_manager.h" #include "dynamic_font_size_label.h" #include @@ -20,10 +21,12 @@ void DynamicFontSizePushButton::paintEvent(QPaintEvent *event) // Call the base class paintEvent to preserve any other painting behavior QPushButton::paintEvent(event); - // Adjust the font size dynamically based on the text - QFont newFont = font(); + // Adjust the font size dynamically based on the text. Family follows the + // InterfaceDefault theme role; the fitted size stays computed as before. + QFont newFont = themeManager->appFont(AppFont::InterfaceDefault); float fontSize = DynamicFontSizeLabel::getWidgetMaximumFontSize(this, this->text()); newFont.setPointSizeF(fontSize); + newFont.setBold(font().bold()); setFont(newFont); // Get painter for custom painting diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.cpp b/cockatrice/src/interface/widgets/general/home_styled_button.cpp index 7f23827bc..9c31d3815 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.cpp +++ b/cockatrice/src/interface/widgets/general/home_styled_button.cpp @@ -1,6 +1,9 @@ #include "home_styled_button.h" +#include "../../theme_manager.h" + #include +#include #include #include #include @@ -21,6 +24,18 @@ HomeStyledButton::HomeStyledButton(const QString &text, QPair _g setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); setMinimumHeight(25); setStyleSheet(generateButtonStylesheet(gradientColors)); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &HomeStyledButton::applyInterfaceDefaultFont); +} + +void HomeStyledButton::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 34px size and + // bold weight previously carried by the stylesheet stay caller-side. + QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault); + buttonFont.setPixelSize(34); + buttonFont.setWeight(QFont::Bold); + setFont(buttonFont); } void HomeStyledButton::updateStylesheet(const QPair &colors) @@ -50,9 +65,6 @@ QString HomeStyledButton::generateButtonStylesheet(const QPair & return QString(R"( QPushButton { - font-size: 34px; - font-weight: bold; - font-family: sans-serif, "Segoe UI", "Helvetica Neue"; padding: 30px; color: white; border: 2px solid %1; diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.h b/cockatrice/src/interface/widgets/general/home_styled_button.h index 55ac5550f..5756db5a1 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.h +++ b/cockatrice/src/interface/widgets/general/home_styled_button.h @@ -24,6 +24,9 @@ public: public slots: void paintEvent(QPaintEvent *event) override; +private slots: + void applyInterfaceDefaultFont(); + private: QPair gradientColors; }; diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 5edf607be..e6b5bf255 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -291,7 +291,6 @@ QGroupBox *HomeWidget::createButtons() box->setObjectName("homeButtonsBox"); box->setStyleSheet(R"( QGroupBox { - font-size: 20px; color: white; /* Title text color */ background: transparent; } @@ -302,6 +301,14 @@ QGroupBox *HomeWidget::createButtons() subcontrol-position: top center; /* or top left / right */ } )"); + // Family comes from the InterfaceDefault theme role; the 20px size stays caller-side. + auto applyBoxFont = [box]() { + QFont boxFont = themeManager->appFont(AppFont::InterfaceDefault); + boxFont.setPixelSize(20); + box->setFont(boxFont); + }; + applyBoxFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyBoxFont); box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); QVBoxLayout *boxLayout = new QVBoxLayout; boxLayout->setAlignment(Qt::AlignHCenter); @@ -472,7 +479,9 @@ void HomeWidget::paintEvent(QPaintEvent *event) constexpr int margin = 15; constexpr qreal attributionMargin = 4.0; - QFont attributionFont = painter.font(); + // Family comes from the InterfaceDefault theme role; the 14px sizes and + // bold weights stay caller-side as before. + QFont attributionFont = themeManager->appFont(AppFont::InterfaceDefault); attributionFont.setPointSize(14); attributionFont.setBold(true); painter.setFont(attributionFont); @@ -485,7 +494,7 @@ void HomeWidget::paintEvent(QPaintEvent *event) // Card name bubble above the attribution (when enabled). if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) { - QFont font = painter.font(); + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setPointSize(14); font.setBold(true); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp index c4298d6b6..e69687c54 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -1,5 +1,6 @@ #include "first_run_wizard.h" +#include "../interface/theme_manager.h" #include "first_run_wizard_page.h" #include "pages/account_setup_page.h" #include "pages/card_database_setup_page.h" @@ -32,9 +33,8 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) bannerHost = new BannerHost(this); titleLabel = new QLabel(this); - QFont titleFont = QtUtils::boldFont(titleLabel->font()); - titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); - titleLabel->setFont(titleFont); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &FirstRunWizard::applyInterfaceDefaultFont); subtitleLabel = new QLabel(this); subtitleLabel->setWordWrap(true); @@ -154,6 +154,16 @@ void FirstRunWizard::updateChrome() } } +void FirstRunWizard::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the *1.4 size + // multiplier and bold weight stay caller-side as before. + QFont titleFont = themeManager->appFont(AppFont::InterfaceDefault); + titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); + titleFont.setBold(true); + titleLabel->setFont(titleFont); +} + void FirstRunWizard::goNext() { FirstRunWizardPage *page = pages[currentIndex]; diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h index 812985a9f..a5fa3b4bd 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -55,6 +55,7 @@ private slots: void goBack(); void skip(); void updateChrome(); + void applyInterfaceDefaultFont(); private: void addPage(FirstRunWizardPage *page); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp index 7f9ad66c8..225c3e21d 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp @@ -1,5 +1,8 @@ #include "printing_selector_placeholder_widget.h" +#include "../interface/theme_manager.h" + +#include #include #include #include @@ -30,10 +33,12 @@ PrintingSelectorPlaceholderWidget::PrintingSelectorPlaceholderWidget(QWidget *pa textLabel->setStyleSheet(R"( QLabel { color: palette(mid); - font-size: 14px; padding: 10px; } )"); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &PrintingSelectorPlaceholderWidget::applyInterfaceDefaultFont); mainLayout->addWidget(imageLabel); mainLayout->addWidget(textLabel); @@ -44,4 +49,12 @@ PrintingSelectorPlaceholderWidget::PrintingSelectorPlaceholderWidget(QWidget *pa void PrintingSelectorPlaceholderWidget::retranslateUi() { textLabel->setText(tr("Select a card to view its available printings")); +} + +void PrintingSelectorPlaceholderWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 14px size stays caller-side. + QFont placeholderFont = themeManager->appFont(AppFont::InterfaceDefault); + placeholderFont.setPixelSize(14); + textLabel->setFont(placeholderFont); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h index 6702d325a..9e6e47bcb 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h @@ -14,6 +14,9 @@ class PrintingSelectorPlaceholderWidget : public QWidget public: explicit PrintingSelectorPlaceholderWidget(QWidget *parent = nullptr); +private slots: + void applyInterfaceDefaultFont(); + private: QVBoxLayout *mainLayout; QLabel *imageLabel; diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index fa7698037..997001c50 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -1,6 +1,7 @@ #include "user_info_box.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/dialogs/dlg_edit_avatar.h" #include "../../interface/widgets/dialogs/dlg_edit_password.h" #include "../../interface/widgets/dialogs/dlg_edit_user.h" @@ -39,10 +40,8 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *parent, Qt::WindowFlags flags) : QWidget(parent, flags), client(_client), editable(_editable) { - QFont nameFont = nameLabel.font(); - nameFont.setBold(true); - nameFont.setPointSizeF(nameFont.pointSizeF() * 1.5); - nameLabel.setFont(nameFont); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &UserInfoBox::applyInterfaceDefaultFont); auto *userIconAndNameLayout = new QHBoxLayout; userIconAndNameLayout->addWidget(&userLevelIcon, 0, Qt::AlignCenter); @@ -91,6 +90,16 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren retranslateUi(); } +void UserInfoBox::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the *1.5 size + // multiplier and bold weight stay caller-side as before. + QFont nameFont = themeManager->appFont(AppFont::InterfaceDefault); + nameFont.setBold(true); + nameFont.setPointSizeF(nameFont.pointSizeF() * 1.5); + nameLabel.setFont(nameFont); +} + void UserInfoBox::retranslateUi() { realNameLabel1.setText(tr("Real Name:")); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.h b/cockatrice/src/interface/widgets/server/user/user_info_box.h index 6bc571a37..98919b027 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.h @@ -48,6 +48,7 @@ private slots: void processEditResponse(const Response &r); void processPasswordResponse(const Response &r); void processAvatarResponse(const Response &r); + void applyInterfaceDefaultFont(); void actEdit(); void actEditInternal(const Response &r); 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 9f864d903..4b4887bc4 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -138,7 +138,8 @@ public: p->drawEllipse(QRectF(rect.left() + 9, rect.top() + (rect.height() - 8) / 2.0, 8, 8)); // Game title (bold, elided) - QFont tf = option.font; + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont tf = themeManager->appFont(AppFont::InterfaceDefault); tf.setBold(true); p->setFont(tf); p->setPen(pal.color(QPalette::Text)); @@ -150,7 +151,7 @@ public: // Player count const bool full = game.player_count() >= game.max_players(); - p->setFont(option.font); + p->setFont(themeManager->appFont(AppFont::InterfaceDefault)); p->setPen(full ? QColor(249, 115, 22) : pal.color(QPalette::Disabled, QPalette::Text)); p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()), Qt::AlignVCenter | Qt::AlignRight, @@ -319,7 +320,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) const int tx = ax + AvatarSize + AvatarToTextGap; const int tw = rect.width() - tx - 8; - QFont nf = font(); + // Family comes from the InterfaceDefault theme role; the *1.12 size and bold stay caller-side. + QFont nf = themeManager->appFont(AppFont::InterfaceDefault); nf.setBold(true); nf.setPointSizeF(nf.pointSizeF() * 1.12); p.setFont(nf); @@ -348,7 +350,7 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) } if (!badge.text.isEmpty()) { - QFont bf = font(); + QFont bf = themeManager->appFont(AppFont::InterfaceDefault); bf.setPointSizeF(bf.pointSizeF() * 0.70); bf.setBold(true); p.setFont(bf); @@ -365,7 +367,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) // The painter font at this point depends on whether a badge was drawn // (badge font vs username font), so pin an explicit font for the pill. - p.setFont(font()); + // Family follows the InterfaceDefault theme role like the rest of the popup chrome. + p.setFont(themeManager->appFont(AppFont::InterfaceDefault)); // Only show the attribution when there is actually art on screen: on a // cache miss cardArt is null and the pill would float over the plain @@ -394,6 +397,7 @@ UserInfoPopup::UserInfoPopup(TabSupervisor *_ts, // Restyle the popup chrome when the theme or its color scheme changes. if (themeManager) { connect(themeManager, &ThemeManager::themeChanged, this, &UserInfoPopup::applyTheme); + connect(themeManager, &ThemeManager::fontsChanged, this, &UserInfoPopup::applyTheme); } } @@ -473,15 +477,24 @@ void UserInfoPopup::applyTheme() separator->setStyleSheet(QStringLiteral("color:%1; margin: 0 8px;").arg(colorStr(theme.border))); - gamesLabel->setStyleSheet(QStringLiteral("color:%1; font-size:11px; font-weight:bold; background:transparent;") - .arg(colorStr(theme.subText))); + gamesLabel->setStyleSheet(QStringLiteral("color:%1; background:transparent;").arg(colorStr(theme.subText))); + QFont gamesLabelFont = themeManager->appFont(AppFont::InterfaceDefault); + gamesLabelFont.setPixelSize(11); + gamesLabelFont.setWeight(QFont::Bold); + gamesLabel->setFont(gamesLabelFont); - refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;font-size:14px;background:transparent;}" + refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;background:transparent;}" "QPushButton:hover{color:%2;}") .arg(colorStr(theme.subText), colorStr(theme.text))); + QFont refreshFont = themeManager->appFont(AppFont::InterfaceDefault); + refreshFont.setPixelSize(14); + refreshBtn->setFont(refreshFont); - gamesStatus->setStyleSheet(QStringLiteral("color:%1; font-size:11px; padding:10px; background:transparent;") - .arg(colorStr(theme.statusText))); + gamesStatus->setStyleSheet( + QStringLiteral("color:%1; padding:10px; background:transparent;").arg(colorStr(theme.statusText))); + QFont statusFont = themeManager->appFont(AppFont::InterfaceDefault); + statusFont.setPixelSize(11); + gamesStatus->setFont(statusFont); gamesView->setStyleSheet(QStringLiteral("QListView{background:%1;border:none;}" "QListView::item:selected{background:%2;}") @@ -489,9 +502,12 @@ void UserInfoPopup::applyTheme() closeBtn->setStyleSheet( QStringLiteral("QPushButton{background:%1;color:%2;" - "border:none;border-radius:11px;font-size:10px;}" + "border:none;border-radius:11px;}" "QPushButton:hover{color:%3;background:%4;}") .arg(colorStr(theme.closeBg), colorStr(theme.subText), colorStr(theme.text), colorStr(theme.closeHover))); + QFont closeFont = themeManager->appFont(AppFont::InterfaceDefault); + closeFont.setPixelSize(10); + closeBtn->setFont(closeFont); header->update(); } @@ -506,13 +522,17 @@ static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); b->setStyleSheet(QStringLiteral("QPushButton{" " background:%1;color:%2;border:1px solid %3;" - " border-radius:4px;font-size:11px;padding:0 4px;" + " border-radius:4px;padding:0 4px;" "}" "QPushButton:hover{background:%4;color:%5;}" "QPushButton:pressed{background:%6;}" "QPushButton:disabled{color:%7;border-color:%3;}") .arg(colorStr(t.buttonBg), colorStr(t.text), colorStr(t.buttonBorder), colorStr(t.buttonHover), colorStr(t.text), colorStr(t.buttonPressed), colorStr(t.buttonDisabled))); + // Family comes from the InterfaceDefault theme role; the 11px size stays caller-side. + QFont btnFont = themeManager->appFont(AppFont::InterfaceDefault); + btnFont.setPixelSize(11); + b->setFont(btnFont); return b; } diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index 08eaaaff7..acb91351b 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -1,6 +1,7 @@ #include "user_list_painter.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include @@ -292,7 +293,9 @@ void UserListPainter::drawUserName(QPainter *painter, bool online, const Style &style) { - QFont nameFont = option.font; + Q_UNUSED(option); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont nameFont = themeManager->appFont(AppFont::InterfaceDefault); nameFont.setBold(true); painter->setFont(nameFont); @@ -351,7 +354,9 @@ void UserListPainter::drawBadges(QPainter *painter, return; } - QFont badgeFont = option.font; + Q_UNUSED(option); + // Family comes from the InterfaceDefault theme role; the *0.68 size and bold stay caller-side. + QFont badgeFont = themeManager->appFont(AppFont::InterfaceDefault); badgeFont.setPointSizeF(badgeFont.pointSizeF() * 0.68); badgeFont.setBold(true); painter->setFont(badgeFont); 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 3cb48c49e..f1de7b34f 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1499,7 +1499,7 @@ QTreeWidgetItem *UserListWidget::createSectionItem(Section section) // They act as collapsible section headers once they have focus. divider->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); - QFont font = userTree->font(); + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setBold(true); divider->setFont(0, font); // A little taller than a plain text row so the header reads as a section diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp index bf35b7557..67354c9d7 100644 --- a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp @@ -5,6 +5,7 @@ */ #include "settings_search_delegate.h" +#include "../interface/theme_manager.h" #include "settings_search_model.h" #include @@ -88,7 +89,8 @@ void SettingsSearchDelegate::paint(QPainter *painter, QRect textRect = contentRect.adjusted(iconOffset, 0, 0, 0); // Breadcrumb: "Page > Group" - QFont breadcrumbFont = option.font; + // Family comes from the InterfaceDefault theme role; the -1 size and bold stay caller-side. + QFont breadcrumbFont = themeManager->appFont(AppFont::InterfaceDefault); breadcrumbFont.setPointSize(breadcrumbFont.pointSize() - 1); breadcrumbFont.setBold(true); @@ -113,7 +115,7 @@ void SettingsSearchDelegate::paint(QPainter *painter, yPos += 20; // Setting label - QFont labelFont = option.font; + QFont labelFont = themeManager->appFont(AppFont::InterfaceDefault); labelFont.setPointSize(labelFont.pointSize() + 1); labelFont.setBold(isSelected); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp index b26a7e574..749476619 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp @@ -1,6 +1,9 @@ #include "edhrec_commander_api_response_bracket_navigation_widget.h" +#include "../../interface/theme_manager.h" + #include +#include #include #include #include @@ -25,7 +28,7 @@ EdhrecCommanderApiResponseBracketNavigationWidget::EdhrecCommanderApiResponseBra QString label = option.isEmpty() ? "All" : option.at(0).toUpper() + option.mid(1); QPushButton *optionButton = new QPushButton(label, this); optionButton->setMinimumHeight(84); - optionButton->setStyleSheet("font-size: 24px"); + optionButton->setStyleSheet(QString()); gameChangerButtons[option] = optionButton; layout->addWidget(optionButton, 2, i); connect(optionButton, &QPushButton::clicked, this, [=, this]() { @@ -35,6 +38,10 @@ EdhrecCommanderApiResponseBracketNavigationWidget::EdhrecCommanderApiResponseBra }); } + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderApiResponseBracketNavigationWidget::applyInterfaceDefaultFont); + updateOptionButtonSelection(gameChangerButtons, ""); retranslateUi(); @@ -94,8 +101,18 @@ void EdhrecCommanderApiResponseBracketNavigationWidget::updateOptionButtonSelect const QString &selectedKey) { for (auto it = buttons.begin(); it != buttons.end(); ++it) { - it.value()->setStyleSheet(it.key() == selectedKey - ? "background-color: lightblue; font-weight: bold; font-size: 24px;" - : "font-size: 24px"); + const bool selected = (it.key() == selectedKey); + it.value()->setStyleSheet(selected ? QStringLiteral("background-color: lightblue;") : QString()); + QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault); + buttonFont.setPixelSize(24); + buttonFont.setWeight(selected ? QFont::Bold : QFont::Normal); + it.value()->setFont(buttonFont); } +} + +void EdhrecCommanderApiResponseBracketNavigationWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size stays + // caller-side and the selected button keeps its bold emphasis. + updateOptionButtonSelection(gameChangerButtons, selectedGameChanger); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h index a741b6696..aa4e61ff1 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h @@ -37,6 +37,9 @@ private: QMap gameChangerButtons; void updateOptionButtonSelection(QMap &buttons, const QString &selectedKey); + +private slots: + void applyInterfaceDefaultFont(); }; #endif // COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BRACKET_NAVIGATION_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp index 48704d010..27cd1c51f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp @@ -1,6 +1,9 @@ #include "edhrec_commander_api_response_budget_navigation_widget.h" +#include "../../interface/theme_manager.h" + #include +#include #include #include #include @@ -25,7 +28,7 @@ EdhrecCommanderApiResponseBudgetNavigationWidget::EdhrecCommanderApiResponseBudg QString label = option.isEmpty() ? "Any" : option.at(0).toUpper() + option.mid(1); QPushButton *btn = new QPushButton(label, this); btn->setMinimumHeight(84); - btn->setStyleSheet("font-size: 24px"); + btn->setStyleSheet(QString()); budgetButtons[option] = btn; layout->addWidget(btn, 4, i); connect(btn, &QPushButton::clicked, this, [=, this]() { @@ -35,6 +38,10 @@ EdhrecCommanderApiResponseBudgetNavigationWidget::EdhrecCommanderApiResponseBudg }); } + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderApiResponseBudgetNavigationWidget::applyInterfaceDefaultFont); + updateOptionButtonSelection(budgetButtons, ""); retranslateUi(); @@ -96,8 +103,18 @@ void EdhrecCommanderApiResponseBudgetNavigationWidget::updateOptionButtonSelecti const QString &selectedKey) { for (auto it = buttons.begin(); it != buttons.end(); ++it) { - it.value()->setStyleSheet(it.key() == selectedKey - ? "background-color: lightblue; font-weight: bold; font-size: 24px;" - : "font-size: 24px"); + const bool selected = (it.key() == selectedKey); + it.value()->setStyleSheet(selected ? QStringLiteral("background-color: lightblue;") : QString()); + QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault); + buttonFont.setPixelSize(24); + buttonFont.setWeight(selected ? QFont::Bold : QFont::Normal); + it.value()->setFont(buttonFont); } +} + +void EdhrecCommanderApiResponseBudgetNavigationWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size stays + // caller-side and the selected button keeps its bold emphasis. + updateOptionButtonSelection(budgetButtons, selectedBudget); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h index 8013d1d6b..d727225a5 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h @@ -37,6 +37,9 @@ private: QMap budgetButtons; void updateOptionButtonSelection(QMap &buttons, const QString &selectedKey); + +private slots: + void applyInterfaceDefaultFont(); }; #endif // COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BUDGET_NAVIGATION_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp index 220dcfbda..57070a5e1 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp @@ -1,9 +1,11 @@ #include "edhrec_commander_api_response_navigation_widget.h" +#include "../../interface/theme_manager.h" #include "../../tab_edhrec_main.h" #include "edhrec_commander_api_response_bracket_navigation_widget.h" #include "edhrec_commander_api_response_budget_navigation_widget.h" +#include #include #include #include @@ -30,10 +32,14 @@ EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigation comboPushButton = new QPushButton(this); comboPushButton->setMinimumHeight(84); - comboPushButton->setStyleSheet("font-size: 24px"); + comboPushButton->setStyleSheet(QString()); averageDeckPushButton = new QPushButton(this); averageDeckPushButton->setMinimumHeight(84); - averageDeckPushButton->setStyleSheet("font-size: 24px"); + averageDeckPushButton->setStyleSheet(QString()); + + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderApiResponseNavigationWidget::applyInterfaceDefaultFont); layout->addWidget(comboPushButton, 0, 0, 1, 1); layout->addWidget(averageDeckPushButton, 0, 1, 1, 1); @@ -61,6 +67,15 @@ void EdhrecCommanderApiResponseNavigationWidget::retranslateUi() averageDeckPushButton->setText(tr("Average Deck")); } +void EdhrecCommanderApiResponseNavigationWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size stays caller-side. + QFont navFont = themeManager->appFont(AppFont::InterfaceDefault); + navFont.setPixelSize(24); + comboPushButton->setFont(navFont); + averageDeckPushButton->setFont(navFont); +} + QString EdhrecCommanderApiResponseNavigationWidget::addNavigationOptionsToUrl(QString baseUrl) { if (!bracketNavigationWidget->getSelectedGameChanger().isEmpty()) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h index 737558625..456228554 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h @@ -35,6 +35,9 @@ public slots: signals: void requestUrl(QString url); +private slots: + void applyInterfaceDefaultFont(); + private: QGridLayout *layout; diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp index d641a1ea5..acb9baefe 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -6,6 +6,7 @@ #include "tab_developer.h" +#include "../interface/theme_manager.h" #include "libcockatrice/protocol/pb/response.pb.h" #include @@ -155,7 +156,8 @@ void TabDeveloper::appendSeparatorRow(const QString §ionTitle) const int row = statsTable->rowCount(); statsTable->insertRow(row); auto *labelItem = new QTableWidgetItem(sectionTitle); - auto font = labelItem->font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setBold(true); labelItem->setFont(font); labelItem->setFlags(labelItem->flags() & ~Qt::ItemIsSelectable); diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index ce37ce440..499f0d966 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -4,6 +4,7 @@ #include "../../deck_loader/deck_loader.h" #include "../cards/card_size_widget.h" #include "../general/layout_containers/flow_widget.h" +#include "../interface/theme_manager.h" #include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" #include "../visual_deck_storage/deck_preview/public_deck_preview_widget.h" #include "../visual_deck_storage/remote_public_decks_model.h" @@ -45,7 +46,12 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this); - titleLabel->setFont(QtUtils::boldFont(titleLabel->font())); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + auto applyTitleFont = [this]() { + titleLabel->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); + }; + applyTitleFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyTitleFont); auto *headerLayout = new QHBoxLayout; headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp index 76c3d0908..d3c087bc6 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp @@ -1,5 +1,8 @@ #include "visual_deck_editor_placeholder_widget.h" +#include "../interface/theme_manager.h" + +#include #include #include #include @@ -28,10 +31,12 @@ VisualDeckEditorPlaceholderWidget::VisualDeckEditorPlaceholderWidget(QWidget *pa textLabel->setStyleSheet(R"( QLabel { color: palette(mid); - font-size: 14px; padding: 10px; } )"); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &VisualDeckEditorPlaceholderWidget::applyInterfaceDefaultFont); mainLayout->addWidget(imageLabel); mainLayout->addWidget(textLabel); @@ -42,4 +47,12 @@ VisualDeckEditorPlaceholderWidget::VisualDeckEditorPlaceholderWidget(QWidget *pa void VisualDeckEditorPlaceholderWidget::retranslateUi() { textLabel->setText(tr("Add cards using the search bar or database tab to have them appear here")); +} + +void VisualDeckEditorPlaceholderWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 14px size stays caller-side. + QFont placeholderFont = themeManager->appFont(AppFont::InterfaceDefault); + placeholderFont.setPixelSize(14); + textLabel->setFont(placeholderFont); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h index 84a3142d8..794be671e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h @@ -15,6 +15,9 @@ public: explicit VisualDeckEditorPlaceholderWidget(QWidget *parent = nullptr); void retranslateUi(); +private slots: + void applyInterfaceDefaultFont(); + private: QVBoxLayout *mainLayout; QLabel *imageLabel; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp index 82ed6191c..cc809cc71 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp @@ -1,5 +1,7 @@ #include "deck_preview_tag_addition_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -65,8 +67,9 @@ void DeckPreviewTagAdditionWidget::paintEvent(QPaintEvent *event) painter.setPen(borderPen); painter.drawRect(rect().adjusted(0, 0, -1, -1)); // Adjust for pen width - // Calculate font size based on widget height - QFont font = painter.font(); + // Calculate font size based on widget height. Family comes from the + // InterfaceDefault theme role; the height-derived size stays as before. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); int fontSize = std::max(10, height() / 2); // Ensure a minimum font size of 10 font.setPointSize(fontSize); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp index e62570241..e946f6e27 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp @@ -1,5 +1,7 @@ #include "deck_preview_tag_display_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -117,8 +119,9 @@ void DeckPreviewTagDisplayWidget::paintEvent(QPaintEvent *event) painter.setPen(borderPen); painter.drawRect(rect().adjusted(0, 0, -1, -1)); - // Calculate font size based on widget height - QFont font = painter.font(); + // Calculate font size based on widget height. Family comes from the + // InterfaceDefault theme role; the height-derived size stays as before. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); int fontSize = std::max(10, height() / 2); // Ensure a minimum font size of 10 font.setPointSize(fontSize); painter.setFont(font); From 13e7a3baaf6637ac0262547458a7d6416a6edb4f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:55 +0100 Subject: [PATCH 032/104] [Fonts] Fix font preview leaks and reuse default-font loader (#7540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../palette_editor/font_grid_widget.cpp | 4 ++ .../palette_editor/palette_editor_dialog.cpp | 48 ++++++++++++------- .../palette_editor/palette_editor_dialog.h | 7 ++- cockatrice/src/interface/theme_manager.cpp | 11 +---- 4 files changed, 41 insertions(+), 29 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp index 0f01fbe09..20451afbc 100644 --- a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp @@ -71,6 +71,9 @@ FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) void FontGridWidget::loadFonts(const FontConfig &cfg) { + // Suppress fontsChanged while materializing fallback rows so the initial + // load never reads as a user edit (mirrors paletteGrid blocking). + blockSignals(true); QMetaEnum fontEnum = QMetaEnum::fromType(); for (int i = 0; i < fontEnum.keyCount(); ++i) { auto role = static_cast(fontEnum.value(i)); @@ -93,6 +96,7 @@ void FontGridWidget::loadFonts(const FontConfig &cfg) } row.sizeSpin->setValue(qBound(MIN_FONT_SIZE, size, MAX_FONT_SIZE)); } + blockSignals(false); } FontConfig FontGridWidget::currentFontConfig() const diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 09c81df5d..e1c9015d9 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -185,7 +185,10 @@ void PaletteEditorDialog::setupUi() connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply); connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent); connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); }); - connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] { autoApplyTimer->start(); }); + connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] { + fontsDirty = true; + autoApplyTimer->start(); + }); connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault); connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset); connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave); @@ -280,15 +283,9 @@ void PaletteEditorDialog::loadSchemes() void PaletteEditorDialog::loadFonts() { - // Same user-dir > system-dir fallback ThemeManager uses: the shipped - // fonts.toml may live in the system theme directory rather than the - // resolved (user) theme directory. - FontConfig cfg = ThemeManager::loadFontConfig(themeDirPath); - if (!cfg.hasFonts()) { - cfg = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); - } - savedFontConfig = cfg; - fontGrid->loadFonts(cfg); + savedFontConfig = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); + fontGrid->loadFonts(savedFontConfig); + fontsDirty = false; } void PaletteEditorDialog::seedAccentFromScheme(const QString &scheme) @@ -321,7 +318,9 @@ void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensi void PaletteEditorDialog::onApply() { themeManager->previewPalette(paletteGrid->currentPaletteConfig(), loadedScheme); - themeManager->previewFonts(fontGrid->currentFontConfig()); + if (fontsDirty) { + themeManager->previewFonts(fontGrid->currentFontConfig()); + } } void PaletteEditorDialog::onSave() @@ -373,14 +372,17 @@ void PaletteEditorDialog::onSave() } // Persist the shared per-theme fonts when they changed. - const FontConfig fontCfg = fontGrid->currentFontConfig(); - if (fontCfg != savedFontConfig) { - if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) { - QMessageBox::warning(this, tr("Save failed"), - tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir)); - return; + if (fontsDirty) { + const FontConfig fontCfg = fontGrid->currentFontConfig(); + if (fontCfg != savedFontConfig) { + if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir)); + return; + } + savedFontConfig = fontCfg; } - savedFontConfig = fontCfg; + fontsDirty = false; } themeManager->reloadCurrentTheme(); @@ -396,6 +398,7 @@ void PaletteEditorDialog::onReset() // config until the theme is reloaded. themeManager->reloadCurrentTheme(); fontGrid->loadFonts(savedFontConfig); + fontsDirty = false; seedAccentFromScheme(loadedScheme); } @@ -442,6 +445,15 @@ void PaletteEditorDialog::onRevertToDefault() const FontConfig fontDef = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); savedFontConfig = fontDef; fontGrid->loadFonts(fontDef); + fontsDirty = false; +} + +void PaletteEditorDialog::reject() +{ + // Discard any unsaved live preview (palette and fonts) so closing without + // Save restores the active theme instead of leaking the preview globally. + themeManager->reloadCurrentTheme(); + QDialog::reject(); } void PaletteEditorDialog::changeEvent(QEvent *e) diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h index 0a9d47c6c..5e47536b5 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h @@ -26,6 +26,7 @@ class PaletteEditorDialog : public QDialog public: explicit PaletteEditorDialog(const QString &themeDirPath, const QString &themeName, QWidget *parent = nullptr); void loadSchemes(); + void reject() override; private slots: void onSave(); @@ -79,6 +80,10 @@ private: // Fonts are shared per theme (fonts.toml, not split by light/dark), so a // single saved config suffices; the live edits are read from fontGrid. FontConfig savedFontConfig; + // Set only by user-driven fontsChanged after the initial load completes. + // Gates both previewFonts and the fonts.toml write so a palette-only Save + // never materializes fallback fonts to disk. + bool fontsDirty = false; QTimer *autoApplyTimer = nullptr; @@ -86,4 +91,4 @@ protected: void changeEvent(QEvent *e) override; }; -#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H \ No newline at end of file +#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index fdd6bbc30..d2580a18d 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -742,16 +742,7 @@ void ThemeManager::themeChangedSlot() applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); // ── Load fonts: shared per theme, no light/dark split ─────────────────── - FontConfig fonts = loadFontConfig(dirPath); - if (!fonts.hasFonts()) { - // The shipped fonts.toml may live in the system theme directory rather - // than the resolved (user) theme directory, so built-in themes still - // get their curated fonts. - const QString sysFontPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName); - if (sysFontPath != dirPath) { - fonts = loadFontConfig(sysFontPath); - } - } + FontConfig fonts = loadDefaultFontConfig(dirPath, themeName); currentFonts = fonts; const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault); if (fontIt != currentFonts.fonts.constEnd()) { From a791389c6abc3c15c98969397159c49dbd5689c1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:55 +0100 Subject: [PATCH 033/104] [Fonts] Restore DeckList Serif default and live-update all role readers (#7541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Fonts] Restore DeckList Serif default and live-update all role readers * [Fonts] Keep the DeckList fallback on the application font DeckList is read by the deck editor headers, the filter tree and the deck view too, not only by the two surfaces that hardcoded Serif. Resolving the unset role to Serif in appFont() made every user without a fonts.toml see Serif outside the deck list. - appFont(DeckList) falls back to the application font again. - ThemeManager::deckListFont() resolves the role and keeps the Serif fallback for the two historical sites: the deck view zone headers and the printed deck list. - DeckView no longer repaints its viewport on fontsChanged; touching the scene items already schedules the repaint in every attached view. --------- Co-authored-by: Lukas Brübach --- .../src/client/settings/shortcut_treeview.cpp | 12 +++++++++++ cockatrice/src/filters/filter_tree_model.cpp | 6 ++++++ .../src/game_graphics/deckview/deck_view.cpp | 15 ++++++++++---- cockatrice/src/game_graphics/game_scene.cpp | 9 +++++++++ .../src/interface/deck_loader/deck_loader.cpp | 14 +++++++------ cockatrice/src/interface/theme_manager.cpp | 20 +++++++++++++++---- cockatrice/src/interface/theme_manager.h | 10 ++++++++-- .../deck_editor/deck_list_style_proxy.cpp | 10 ++++++++++ .../deck_editor/deck_list_style_proxy.h | 2 +- .../widgets/dialogs/settings_view.cpp | 5 +++++ .../display/charts/bars/bar_chart_widget.cpp | 4 ++++ .../charts/bars/segmented_bar_widget.cpp | 4 ++++ .../widgets/server/user/user_list_widget.cpp | 13 ++++++++++++ .../interface/widgets/tabs/tab_developer.cpp | 14 +++++++++++++ .../widgets/tabs/tab_public_decks.cpp | 7 ++++++- .../widgets/utility/card_completer_styler.cpp | 5 +++++ .../deck_preview_tag_addition_widget.cpp | 4 ++++ .../deck_preview_tag_display_widget.cpp | 4 ++++ 18 files changed, 140 insertions(+), 18 deletions(-) diff --git a/cockatrice/src/client/settings/shortcut_treeview.cpp b/cockatrice/src/client/settings/shortcut_treeview.cpp index d81589f34..2501798cb 100644 --- a/cockatrice/src/client/settings/shortcut_treeview.cpp +++ b/cockatrice/src/client/settings/shortcut_treeview.cpp @@ -79,6 +79,18 @@ ShortcutTreeView::ShortcutTreeView(QWidget *parent) : QTreeView(parent) connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &ShortcutTreeView::refreshShortcuts); + + // Group headers store their font on the item; re-apply the role family on + // font changes (setFont emits itemChanged, which repaints the rows). + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + QFont groupFont = themeManager->appFont(AppFont::InterfaceDefault); + groupFont.setBold(true); + for (int row = 0; row < shortcutsModel->rowCount(); ++row) { + if (auto *groupItem = shortcutsModel->item(row)) { + groupItem->setFont(groupFont); + } + } + }); } void ShortcutTreeView::populateShortcutsModel() diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp index 467c2564b..66fe39821 100644 --- a/cockatrice/src/filters/filter_tree_model.cpp +++ b/cockatrice/src/filters/filter_tree_model.cpp @@ -41,6 +41,12 @@ FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent) connect(fTree, &FilterTree::postInsertRow, this, &FilterTreeModel::proxyEndInsertRow); connect(fTree, &FilterTree::preRemoveRow, this, &FilterTreeModel::proxyBeginRemoveRow); connect(fTree, &FilterTree::postRemoveRow, this, &FilterTreeModel::proxyEndRemoveRow); + // FontRole data reads the DeckList role live; rebroadcast so attached + // views re-query instead of waiting for an incidental repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + emit layoutAboutToBeChanged(); + emit layoutChanged(); + }); } FilterTreeModel::~FilterTreeModel() diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 63ee48e57..f62d33335 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -241,8 +241,7 @@ void DeckViewCardContainer::paint(QPainter *painter, const QStyleOptionGraphicsI painter->setPen(QColor(Qt::white)); // Family comes from the DeckList theme role; pixel sizes/weights unchanged. - QFont f = themeManager->appFont(AppFont::DeckList); - f.setStyleHint(QFont::Serif); + QFont f = themeManager->deckListFont(); f.setPixelSize(24); f.setWeight(QFont::Bold); painter->setFont(f); @@ -294,8 +293,7 @@ QList> DeckViewCardContainer::getRowsAndCols() const int DeckViewCardContainer::getCardTypeTextWidth() const { // Family comes from the DeckList theme role; pixel size/weight unchanged. - QFont f = themeManager->appFont(AppFont::DeckList); - f.setStyleHint(QFont::Serif); + QFont f = themeManager->deckListFont(); f.setPixelSize(16); f.setWeight(QFont::Bold); QFontMetrics fm(f); @@ -368,6 +366,15 @@ void DeckViewCardContainer::setWidth(qreal _width) DeckViewScene::DeckViewScene(QObject *parent) : QGraphicsScene(parent), locked(true), deck(0), optimalAspectRatio(1.0) { + // Zone headers read the DeckList role at paint time and cache their + // rendering, so touch every item to invalidate the caches and pick up the + // new family on font changes (containers cannot connect this themselves: + // QGraphicsItem is not a QObject). + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + for (auto *item : items()) { + item->update(); + } + }); } DeckViewScene::~DeckViewScene() diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index db1040006..1f780b795 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -1,6 +1,7 @@ #include "game_scene.h" #include "../client/settings/cache_settings.h" +#include "../interface/theme_manager.h" #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" @@ -64,6 +65,14 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) addItem(phasesToolbar); connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this, &GameScene::rearrange); + // CardText items (P/T boxes, counters, player names) read the role at + // paint time and cache their rendering, so touch every item to invalidate + // the caches and pick up the new family on font changes. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + for (auto *item : items()) { + item->update(); + } + }); rearrange(); } diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index d5c7de9a4..daa5dc98a 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -581,7 +581,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode QTextBlockFormat blockFormat; QTextCharFormat charFormat; // Family comes from the DeckList theme role; sizes/weights unchanged. - charFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); + charFormat.setFontFamilies({themeManager->deckListFont().family()}); charFormat.setFontPointSize(11); charFormat.setFontWeight(QFont::Bold); cursor->insertBlock(blockFormat, charFormat); @@ -595,7 +595,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode const AbstractDecklistCardNode *card = cards[i]; QTextCharFormat cellCharFormat; - cellCharFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); + cellCharFormat.setFontFamilies({themeManager->deckListFont().family()}); cellCharFormat.setFontPointSize(9); QTextTableCell cell = table->cellAt(i, 0); @@ -617,7 +617,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode QTextBlockFormat blockFormat; QTextCharFormat charFormat; - charFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); + charFormat.setFontFamilies({themeManager->deckListFont().family()}); charFormat.setFontPointSize(14); charFormat.setFontWeight(QFont::Bold); @@ -646,15 +646,17 @@ void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList) QTextDocument doc; // Family comes from the DeckList theme role; the print sizes/weights below stay unchanged. - QFont font = themeManager->appFont(AppFont::DeckList); - font.setStyleHint(QFont::Serif); + // Print output is a one-shot snapshot and intentionally does not subscribe + // to fontsChanged (there is no live view to repaint); reprinting picks up + // the current role font. + QFont font = themeManager->deckListFont(); doc.setDefaultFont(font); QTextCursor cursor(&doc); QTextBlockFormat headerBlockFormat; QTextCharFormat headerCharFormat; - headerCharFormat.setFontFamilies({themeManager->appFont(AppFont::DeckList).family()}); + headerCharFormat.setFontFamilies({themeManager->deckListFont().family()}); headerCharFormat.setFontPointSize(16); headerCharFormat.setFontWeight(QFont::Bold); diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index d2580a18d..f120af8e4 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -660,10 +660,12 @@ QFont ThemeManager::appFont(AppFont::Role role) const } // Role fallbacks preserve the pre-theme defaults when no fonts.toml is - // present: an unset role resolves to the application font, so the result is - // identical to before. The report dialogs that used to hardcode monospace - // ask for it explicitly (report_utils::reportLogFont), and the counter - // painters that used to hardcode Serif ask for it through cardTextFont(). + // present: an unset role resolves to the application font. Surfaces that + // hardcoded a different family ask for it explicitly: counter painters + // through cardTextFont(), the deck view headers and printed deck list + // through deckListFont(), the report dialogs through + // report_utils::reportLogFont(). Only the family basis is fixed there; + // sizes stay caller-side. return qApp->font(); } @@ -682,6 +684,16 @@ QFont ThemeManager::cardTextFont() const return serifFont; } +QFont ThemeManager::deckListFont() const +{ + if (hasAppFont(AppFont::DeckList)) { + return appFont(AppFont::DeckList); + } + QFont serifFont(QStringLiteral("Serif")); + serifFont.setStyleHint(QFont::Serif); + return serifFont; +} + QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const { // No per-role scaling and no preview: an unset role resolves to the font diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 35cc36d04..ffe1043b5 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -150,14 +150,20 @@ public: // when present, otherwise a palette-accent-derived fallback. QColor appColor(AppColor::Role role) const; /** @brief Resolves an application font role: the theme's stored [Fonts] - * value when present, otherwise a per-role fallback (serif basis - * for CardText, the application font for the remaining roles). */ + * value when present, otherwise the application font. Roles that + * had a historical hardcoded family ask for it through + * cardTextFont() and deckListFont() instead. */ QFont appFont(AppFont::Role role) const; /** @brief Whether the active theme defines this role in fonts.toml. */ bool hasAppFont(AppFont::Role role) const; /** @brief The CardText role with its historical Serif fallback. Used by the * counter painters, which were Serif before the role existed. */ QFont cardTextFont() const; + /** @brief The DeckList role with its historical Serif fallback. Used by the + * two surfaces that hardcoded Serif before the role existed (the + * deck view zone headers and the printed deck list); every other + * DeckList reader falls back to the application font. */ + QFont deckListFont() const; /** @brief Resolves an application font role against the startup font only, * ignoring any previewed config in currentFonts. Editors that show * "what this role resolves to" must use this, because appFont() diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp index 59b7fff79..a9c9cad7a 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp @@ -10,6 +10,16 @@ #include #include +DeckListStyleProxy::DeckListStyleProxy(QObject *parent) : QIdentityProxyModel(parent) +{ + // FontRole data reads the DeckList role live; rebroadcast so attached + // views re-query instead of waiting for an incidental repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + emit layoutAboutToBeChanged(); + emit layoutChanged(); + }); +} + QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const { QModelIndex src = mapToSource(index); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h index e4d1de4f4..3149da6de 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h @@ -8,7 +8,7 @@ class DeckListStyleProxy : public QIdentityProxyModel { Q_OBJECT public: - using QIdentityProxyModel::QIdentityProxyModel; + explicit DeckListStyleProxy(QObject *parent = nullptr); [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; }; diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 9c0f12979..005824995 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -7,6 +7,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../pixel_map_generator.h" +#include "../interface/theme_manager.h" #include "../settings_page/abstract_settings_page.h" #include "../settings_page/appearance_settings_page.h" #include "../settings_page/deck_editor_settings_page.h" @@ -203,6 +204,10 @@ void SettingsView::setupUi() searchDelegate->setPageIcons(icons); searchResultsView->viewport()->update(); }); + // The search delegate reads the InterfaceDefault role at paint time, so a + // font change only needs a repaint, mirroring the theme change above. + connect(themeManager, &ThemeManager::fontsChanged, searchResultsView, + [this] { searchResultsView->viewport()->update(); }); // Build search index after pages are created buildSearchIndex(); diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp index cd2a3702d..6bb1dcd62 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp @@ -21,6 +21,10 @@ BarChartWidget::BarChartWidget(QWidget *parent) : QWidget(parent) { setMouseTracking(true); + + // Labels read the InterfaceDefault role at paint time, so a font change + // only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); } void BarChartWidget::setBars(const QVector &newBars) diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp index d6c75f3bd..359d56772 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp @@ -24,6 +24,10 @@ SegmentedBarWidget::SegmentedBarWidget(QString label, QVector segments, setMinimumWidth(36); setMaximumWidth(50); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + + // The label reads the InterfaceDefault role at paint time, so a font + // change only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); } QSize SegmentedBarWidget::sizeHint() const 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 f1de7b34f..943b4d5de 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -519,6 +519,19 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->viewport()->update(); userTree->update(); }); + // Divider headers store their font on the item, so re-apply the role + // family here; the painter reads the role live and needs only the + // repaint below. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + QFont dividerFont = themeManager->appFont(AppFont::InterfaceDefault); + dividerFont.setBold(true); + for (auto it = sectionItems.constBegin(); it != sectionItems.constEnd(); ++it) { + it.value()->setFont(0, dividerFont); + it.value()->setSizeHint(0, QSize(0, QFontMetrics(dividerFont).height() + 16)); + } + userTree->viewport()->update(); + userTree->update(); + }); } QVBoxLayout *vbox = new QVBoxLayout; diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp index acb9baefe..024cd46f0 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -101,6 +101,20 @@ TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_clien central->setLayout(mainLayout); setCentralWidget(central); + // Separator rows store their font on the item; re-apply the role family on + // font changes (value cells keep the default font and need only a repaint). + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + QFont separatorFont = themeManager->appFont(AppFont::InterfaceDefault); + separatorFont.setBold(true); + for (int row = 0; row < statsTable->rowCount(); ++row) { + auto *labelItem = statsTable->item(row, 0); + if (labelItem && !(labelItem->flags() & Qt::ItemIsSelectable)) { + labelItem->setFont(separatorFont); + } + } + statsTable->viewport()->update(); + }); + retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index 499f0d966..dc14b6e4a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -51,7 +51,12 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c titleLabel->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); }; applyTitleFont(); - connect(themeManager, &ThemeManager::fontsChanged, this, applyTitleFont); + connect(themeManager, &ThemeManager::fontsChanged, this, [this, applyTitleFont] { + applyTitleFont(); + // Preview cells paint with the live role font; repaint the grid so the + // new family shows without waiting for a scroll or hover. + flowWidget->update(); + }); auto *headerLayout = new QHBoxLayout; headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index 8f1941412..1a17bb8be 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -1,5 +1,6 @@ #include "card_completer_styler.h" +#include "../../interface/theme_manager.h" #include "../cards/card_info_picture_enlarged_widget.h" #include "card_completer_delegate.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -73,6 +74,10 @@ CardCompleterStyler::CardCompleterStyler(QCompleter *completer, QObject *parent) popup->viewport()->setMouseTracking(true); + // The delegate reads the CardText role at paint time, so a font change + // only needs a repaint of the popup. + connect(themeManager, &ThemeManager::fontsChanged, popup, [popup] { popup->viewport()->update(); }); + popup->installEventFilter(this); popup->viewport()->installEventFilter(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp index cc809cc71..30e99f401 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp @@ -28,6 +28,10 @@ DeckPreviewTagAdditionWidget::DeckPreviewTagAdditionWidget(QWidget *_parent, QSt // Adjust widget size setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + + // The tag text reads the InterfaceDefault role at paint time, so a font + // change only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); } QSize DeckPreviewTagAdditionWidget::sizeHint() const diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp index e946f6e27..34809da2e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp @@ -43,6 +43,10 @@ DeckPreviewTagDisplayWidget::DeckPreviewTagDisplayWidget(QWidget *parent, const // Adjust widget size setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + // The tag text reads the InterfaceDefault role at paint time, so a font + // change only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); + // Connect close button to the remove signal // connect(closeButton, &QPushButton::clicked, this, &DeckPreviewTagDisplayWidget::tagClosed); } From ed119cc940dda57188cddda856e2850140e58f9c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:57:55 +0100 Subject: [PATCH 034/104] [Fonts] Polish font editor accessibility and note text (#7542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../src/interface/palette_editor/font_grid_widget.cpp | 7 ++++++- .../src/interface/palette_editor/palette_editor_dialog.cpp | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp index 20451afbc..44ad59551 100644 --- a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp @@ -63,6 +63,8 @@ FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) grid->addWidget(sizeSpin, i, 2); connect(sizeSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); }); + label->setBuddy(familyCombo); + fontRows[role] = {label, familyCombo, sizeSpin}; } @@ -126,6 +128,9 @@ void FontGridWidget::retranslateUi() row.label->setText(roleDisplayName(role)); row.label->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); row.familyCombo->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); - row.sizeSpin->setToolTip(tr("Point size")); + row.familyCombo->setAccessibleName(roleDisplayName(role)); + row.sizeSpin->setToolTip(tr("Point size for %1").arg(roleDisplayName(role))); + row.sizeSpin->setAccessibleName(tr("Point size for %1").arg(roleDisplayName(role))); + row.sizeSpin->setSuffix(tr(" pt")); } } diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index e1c9015d9..5bcfbb251 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -147,6 +147,7 @@ void PaletteEditorDialog::setupUi() fontSectionLayout->setSpacing(0); fontNoteLabel = new QLabel(fontSection); fontNoteLabel->setContentsMargins(12, 4, 12, 0); + fontNoteLabel->setWordWrap(true); fontSectionLayout->addWidget(fontNoteLabel); fontGrid = new FontGridWidget(fontSection); fontSectionLayout->addWidget(fontGrid); @@ -240,7 +241,7 @@ void PaletteEditorDialog::retranslateUi() paletteGridToggleButton->setToolTip(tr("Show or hide the per-role colour grid for manual tweaks")); fontToggleButton->setText(fontSection->isVisible() ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts")); fontToggleButton->setToolTip(tr("Show or hide the per-purpose font settings for this theme")); - fontNoteLabel->setText(tr("Fonts belong to theme %1").arg(themeName)); + fontNoteLabel->setText(tr("Fonts belong to theme %1 (shared by Light and Dark schemes)").arg(themeName)); fontGrid->retranslateUi(); revertButton->setText(tr("↺ Revert to theme default")); From 7ca697348b68ee60c6642f25687bc7399f4df3cc Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:06:56 +0100 Subject: [PATCH 035/104] [CI] Give ccache room to hold a configuration (#7667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [CI] Re-assert the ccache timestamp sloppiness after configure CMakeLists.txt sets sloppiness=pch_defines,time_macros at configure time, and ccache --set-config replaces the whole list rather than extending it. Setting the four entries before cmake would therefore leave the build with only two, so the entries CI needs have to be written after the configure step and before the build. The mtime and ctime entries cover files created during the build itself: moc/uic/rcc output and protobuf headers are generated after ccache starts, so without them every TU that includes one is read as a race and never cached. Checked-out sources are older than the compile that follows them and do not trip the check. * [Build] Keep risky ccache sloppiness out of the global config USE_CCACHE defaults to ON, so CMakeLists.txt runs on a developer machine, and ccache --set-config writes the user's global ccache.conf. The include_file_mtime/include_file_ctime entries turn off the modified-during-compile check for every project on the machine, not only this one, so they belong in CI (compile.sh) and not here. The two entries PCH caching needs stay. * [CI] Size each compiler cache against the cache quota The 600M bound sat below what a leg writes: ccache spent each build deleting the entries the next run asked for, with 2624-3720 cleanup passes and about 8% of calls served in the runs inspected on 2026-11-04. Raising it needs room first. The repository's 10 GB quota currently holds 1.1 GB of CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers. Two of those are now cheap to reclaim: - docker-release wrote its layer cache with mode=max, but the build stage sits behind COPY . ., so its compile layer is invalidated by every source change and was never read back. mode=min keeps only the final image stage. - the five nightly-only legs kept their own compiler caches, 600M each, that no pull request ever reads. They now keep none, and the prune job retires their leftover keys immediately instead of waiting for GitHub's whole-entry LRU eviction to pick them. That funds per-leg bounds: 700M for one configuration, 850M for the Debian 13 release that also runs the suite, 900M for Fedora 44's Debug and Release, 800M for the instrumented leg, 400M for the server-only leg, and 700M for each macOS leg. 6.45G of ccache plus the 3.2G of fixed entries leaves about 350M under the quota. The prune step also covers the buildkit entries by age now, so the mode=max generation of layer blobs is reclaimed instead of living forever: a layer a build needs is downloaded and its date refreshed, and dropping one that an index still lists only costs docker-release a rebuilt layer, which gates nothing. --------- Co-authored-by: Lukas Brübach --- .ci/compile.sh | 18 ++++- .github/workflows/desktop-build.yml | 105 ++++++++++++++++++++++----- .github/workflows/docker-release.yml | 10 ++- CMakeLists.txt | 4 + 4 files changed, 114 insertions(+), 23 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 1db0cacd1..569ae9f6b 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -185,9 +185,6 @@ if [[ $MAKE_COVERAGE ]]; then fi if [[ $USE_CCACHE ]]; then flags+=("-DUSE_CCACHE=1") - # PCH-aware caching is required or ccache refuses to cache any TU that - # consumes a precompiled header, silently recompiling everything on every run. - ccache --set-config sloppiness=pch_defines,time_macros if [[ $CCACHE_SIZE ]]; then # note, this setting persists after running the script ccache --max-size "$CCACHE_SIZE" @@ -378,6 +375,21 @@ echo "Running cmake with flags: ${flags[*]}" cmake .. "${flags[@]}" echo "::endgroup::" +if [[ $USE_CCACHE ]]; then + # PCH-aware caching is required or ccache refuses to cache any TU that consumes a + # precompiled header, silently recompiling everything on every run. The mtime and ctime + # entries cover files created during the build itself: the moc/uic/rcc output and the + # protobuf headers are generated after ccache starts, so without them every TU including + # one is read as a race and never cached. Checked-out sources are older than the compile + # that follows them and do not trip the check. + # + # This has to run after configure: CMakeLists.txt sets sloppiness=pch_defines,time_macros + # at configure time, and --set-config replaces the whole list rather than extending it, so + # setting it earlier would leave the build below with two entries. Re-asserting all four + # here is what actually reaches the compiler; the order is the point. + ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime +fi + echo "::group::Build project" cpuinfo echo "Running cmake --build with flags: ${buildflags[*]}" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index d72cb881e..be11c3748 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -157,11 +157,13 @@ jobs: package: DEB server_only: yes test: skip + ccache_size: 400M # server only, no client to compile - distro: Debian version: 13 package: DEB + ccache_size: 850M # the Release build plus the suite it runs - distro: Fedora version: 44 @@ -172,6 +174,7 @@ jobs: # only the Debug configuration passes -Werror, so these two are the legs where a # new warning in either compiler still fails the build. debug_test: yes + ccache_size: 900M # Debug for the suite and Release for the package - distro: Ubuntu version: 26.04 @@ -193,6 +196,7 @@ jobs: package: skip sanitize: yes + ccache_size: 800M # instrumented objects are larger name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure @@ -205,7 +209,27 @@ jobs: env: CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache CCACHE_EVICTION_AGE: 7d - CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + # The bound is sized against the repository's 10 GB cache quota + # (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy), + # not against what one leg would like. The quota currently holds about 1.1 GB of + # CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers + # (mode=min in docker-release.yml, added here, brings the last one down to roughly + # 0.3 GB - the one input here that is an expectation rather than a measurement, since + # mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five + # nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what + # makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache + # bounds + 3.2G of the fixed entries = 9.65G against the 10G quota. + # + # A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat + # below what a leg writes: ccache spent each build deleting the entries the next run + # asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each + # leg states the size it needs instead: one configuration for most, two for Fedora 44 + # (Debug for the suite, Release for the package), the server alone for Servatrice. + # Raising these further means caching fewer legs, since GitHub evicts a whole entry + # when the total is exceeded. + # + # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix. + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} @@ -218,6 +242,11 @@ jobs: # also package, so testing and packaging cost one compile instead of two. DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} + # Whether this leg restores and saves its compiler cache. Every leg a pull request + # runs keeps one; the nightly-only legs do not, so that the quota is spent on the + # builds that review a change. A tag build runs both sets, and the nightly legs are + # cold there - accepted, because a tag is rare and only the pull-request legs gate. + PERSIST_CCACHE: 'true' # Shared with build-linux-nightly so the two leg sets run the same steps. steps: &linux-steps @@ -226,6 +255,7 @@ jobs: - name: "Restore compiler cache (ccache)" id: restore_ccache + if: env.PERSIST_CCACHE == 'true' uses: actions/cache/restore@v6 with: key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} @@ -371,7 +401,7 @@ jobs: # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "Delete remote compiler cache (ccache)" - if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit + if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -382,7 +412,7 @@ jobs: fi - name: "Save updated compiler cache (ccache)" - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' uses: actions/cache/save@v6 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -479,7 +509,12 @@ jobs: env: CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache CCACHE_EVICTION_AGE: 7d - CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + # These legs do not keep a compiler cache: they run once a day (and on tags) and gate + # nothing, so their share of the 10 GB quota goes to the pull-request legs instead. + # A cold nightly build costs minutes on a runner nobody waits for; a cold pull request + # build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir. + PERSIST_CCACHE: 'false' + CCACHE_SIZE: 600M CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} @@ -571,7 +606,13 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + # macOS configurations compile about twice the TUs of a Linux one, so each of these + # three legs gets a bound above what it has been measured to write. The four macOS + # legs used to be bounded at 600M from the same quota as the Linux ones; the three + # pull-request legs now get 700M each while the nightly Intel leg keeps none, and + # that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic). + CCACHE_SIZE: 700M + PERSIST_CCACHE: 'true' # Shared with build-vcpkg-nightly so the two leg sets run the same steps. steps: &vcpkg-steps @@ -591,7 +632,7 @@ jobs: run: brew install ccache - name: "[macOS] Restore compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' id: restore_ccache uses: actions/cache/restore@v6 with: @@ -922,7 +963,7 @@ jobs: # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "[macOS] Delete remote compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -933,7 +974,7 @@ jobs: fi - name: "[macOS] Cache updated compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -1082,7 +1123,10 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + # Like build-linux-nightly: this leg keeps no compiler cache, so the quota stays with + # the pull-request legs (see PERSIST_CCACHE there). + PERSIST_CCACHE: 'false' + CCACHE_SIZE: 600M steps: *vcpkg-steps prune-caches: @@ -1093,12 +1137,23 @@ jobs: continue-on-error: true # freeing space must never fail a build steps: - # The repository holds 10Gi of cache against entries that want more, and GitHub - # evicts the least recently used entry whenever that is exceeded. The vcpkg - # entries are the reason that never happens: the restore above touches every - # entry under the prefix on every run, so a dead ABI generation keeps its access - # date fresh forever and age based eviction can never reach it. Deleting by age - # alone cannot fix that, only knowing which scope an entry belongs to can. + # The repository holds 10 GB of cache against entries that want more, and GitHub evicts + # the least recently used entry whenever that is exceeded. The vcpkg entries are the + # reason that never happens: the restore above touches every entry under the prefix on + # every run, so a dead ABI generation keeps its access date fresh forever and age based + # eviction can never reach it. Deleting by age alone cannot fix that, only knowing which + # scope an entry belongs to can. The same is true of a compiler cache of a leg that no + # longer keeps one: its restore would keep touching it, so the live key list below is + # what retires it. + # + # docker-release's image builds keep their layer cache in the same quota: one blob per + # layer, keyed buildkit-blob-1-, plus one index--1- entry per image + # scope that lists them. They are written with mode=min, so only the final image stage + # is kept and the set stays small. A layer a build actually needs is downloaded, which + # refreshes its access date, so an old date means the builds have stopped using it and + # age is the right test. Dropping a blob an index still lists costs the next image build + # one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the + # entries forever. - name: "Delete unusable cache entries" shell: bash env: @@ -1107,25 +1162,39 @@ jobs: # matrices above, or the entries of a scope that is still in use get deleted # on the next run. LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/' + # Must list the ccache key of every leg that keeps a compiler cache + # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. + # The nightly legs keep none, so their leftover entries are reclaimed here + # instead of waiting out the age rule. + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macos-26_14-Xcode26.6 ccache-macos-26_15-Xcode26.6 ccache-macos-26_-Xcode26.6' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))" live=" ${LIVE_VCPKG_SCOPES} " + live_ccache=" ${LIVE_CCACHE_KEYS} " + deleted=0 while IFS=$'\t' read -r id ref key accessed; do [[ -n $id ]] || continue - # Only ever touch the key shapes this workflow creates, so that an entry of - # some other workflow can never be deleted by accident + # Only ever touch the key shapes our workflows create (buildkit-* and index-* + # come from docker-release.yml), so that an entry of some other workflow can + # never be deleted by accident case "$key" in - vcpkg/* | ccache-* | Qt*) ;; + vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;; *) continue ;; esac reason="" if [[ $ref != refs/heads/master ]]; then reason="scoped to $ref" + elif [[ $key == ccache-* ]]; then + # An exact token compare: ccache-Ubuntu26.04 is a prefix of + # ccache-Ubuntu26.04sanitize, and both keys are live + if [[ $live_ccache != *" $key "* ]]; then + reason="no pull-request leg keeps it" + fi elif [[ $key == vcpkg/* ]]; then # "/", cut the hash off the end to get the scope scope="vcpkg/${key#vcpkg/}" diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 937386940..1537d6d4d 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -84,12 +84,18 @@ jobs: password: ${{ github.token }} # Don't push for non-publishing triggers + # + # The layer cache shares the repository's 10 GB Actions cache quota with the compiler + # caches (see CCACHE_SIZE in desktop-build.yml). mode=min keeps only the final image + # stage: the build stage sits behind `COPY . .`, so its compile layer is invalidated by + # every source change and was never read back, yet it was the bulk of the ~2 GB this + # cache held. The final runtime stage is the part that still pays off. - name: "Build image" if: steps.login.outcome != 'success' uses: docker/build-push-action@v7 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} - cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }} + cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }} context: . platforms: ${{ matrix.platform }} push: false @@ -101,7 +107,7 @@ jobs: uses: docker/build-push-action@v7 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} - cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} + cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }} context: . labels: | org.opencontainers.image.description=${{ env.OCI_DESCRIPTION }} diff --git a/CMakeLists.txt b/CMakeLists.txt index 563f174e8..9044bc005 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,10 @@ if(USE_CCACHE AND NOT WIN32) # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses # to cache any TU that consumes a precompiled header, so every PCH-backed # target recompiles from scratch on each build. + # The include_file_mtime/include_file_ctime entries that CI also sets are + # deliberately not set here: --set-config writes the user's global + # ccache.conf, and those two would turn off the modified-during-compile + # check for every project on the machine, not just this one. execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") endif() From 67330ead5a58e90de1515a4603ce58ff999dbb62 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 09:27:44 -0500 Subject: [PATCH 036/104] Embed bundled resources without compiling large C++ arrays [skip ci] (#7730) --- cockatrice/CMakeLists.txt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 9a3172aa8..e437f4d56 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -495,7 +495,15 @@ if(APPLE) set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +# The fonts and card artwork produce a large C++ byte array with qt_add_resources. +# Embed the data in the compiled resource object instead, keeping compiler memory +# independent of that payload. Qt's two-pass resource build is unsupported on iOS. +if(IOS) + qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +else() + qt_add_big_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +endif() +set_source_files_properties(${cockatrice_RESOURCES} PROPERTIES SKIP_AUTORCC ON) # Declare path variables set(ICONDIR @@ -521,6 +529,7 @@ qt_add_executable( WIN32 MACOSX_BUNDLE ${cockatrice_SOURCES} + ${cockatrice_RESOURCES} ${cockatrice_RESOURCES_RCC} ${cockatrice_MOC_SRCS} MANUAL_FINALIZATION From 2056043104a83d5bf9e8a45a5d03320e7cc69914 Mon Sep 17 00:00:00 2001 From: tooomm Date: Wed, 4 Nov 2026 15:35:45 +0100 Subject: [PATCH 037/104] [CI] Update macOS runner (keep same compile targets) (#7331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * update/add eol dates * use new macOS images * [CI] Retire the old macOS ccache keys from the live list prune-caches deletes any ccache-* entry whose key is not in LIVE_CCACHE_KEYS, so the three keys this PR renames would be dropped on the next master run and every macOS leg would start cold. Co-authored-by: Lukas Brübach --------- Co-authored-by: Lukas Brübach --- .github/workflows/desktop-build.yml | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index be11c3748..4ac28d3a5 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -526,8 +526,8 @@ jobs: matrix: include: - os: macOS - target: 14 # EOL 2026-?? - runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + target: 14 # EOL 2026-09-14 + runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja @@ -542,11 +542,10 @@ jobs: vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over - xcode: "26.6" - os: macOS - target: 15 - runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + target: 15 # EOL 2027-?? + runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja @@ -560,11 +559,10 @@ jobs: use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' - xcode: "26.6" - os: macOS - target: 26 - runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + target: 27 + runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja @@ -575,7 +573,6 @@ jobs: use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' - xcode: "26.6" - os: Windows target: 10 @@ -636,7 +633,7 @@ jobs: id: restore_ccache uses: actions/cache/restore@v6 with: - key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }} + key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }} path: ${{ env.CCACHE_DIR }} - name: "[macOS] Install aqtinstall" @@ -751,7 +748,6 @@ jobs: # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN # and the symbol upload below just like full releases. COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }} - DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} @@ -1112,7 +1108,6 @@ jobs: use_ccache: 1 vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over - xcode: "26.3" name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} needs: configure @@ -1166,7 +1161,7 @@ jobs: # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. # The nightly legs keep none, so their leftover entries are reclaimed here # instead of waiting out the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macos-26_14-Xcode26.6 ccache-macos-26_15-Xcode26.6 ccache-macos-26_-Xcode26.6' + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" From ca2301bc96cf0851d2292fb8927ca44ba2b9473a Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 09:43:38 -0500 Subject: [PATCH 038/104] Fix deck export and mana group ordering (#7729) --- .../src/interface/deck_loader/deck_loader.cpp | 9 ++++-- .../cards/deck_card_zone_display_widget.cpp | 4 +++ .../libcockatrice/deck_list/deck_list.cpp | 12 ++++++- .../models/deck_list/deck_list_model.cpp | 6 ++-- .../deck_list_model_custom_zones.cpp | 30 ++++++++++++++++-- .../deck_list/deck_list_model_custom_zones.h | 9 ++++-- .../deck_list_model_custom_zones_test.cpp | 31 +++++++++++++++++++ .../deck_list_zones/deck_list_zones_test.cpp | 22 +++++++++++++ 8 files changed, 113 insertions(+), 10 deletions(-) diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index daa5dc98a..c8fa2a6f8 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -372,8 +373,12 @@ bool DeckLoader::saveToStream_Plain(QTextStream &out, saveToStream_DeckHeader(out, deckList); } - // loop zones - for (auto zoneNode : deckList.getZoneNodes()) { + // Plain deck readers expect the main deck before the sideboard. Reorder a + // copy so native saves and the relative order of other zones stay intact. + auto zones = deckList.getZoneNodes(); + std::stable_partition(zones.begin(), zones.end(), + [](const auto *zone) { return zone->getName() == DECK_ZONE_MAIN; }); + for (auto zoneNode : zones) { saveToStream_DeckZone(out, zoneNode, addComments, addSetNameAndNumber); // end of zone diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index d7eef6c18..3ec2053b1 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -69,6 +69,7 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, updateZoneCardCount(); connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::onCategoryAddition); + connect(deckListModel, &QAbstractItemModel::layoutChanged, this, &DeckCardZoneDisplayWidget::displayCards); if (selectionModel) { connect(selectionModel, &QItemSelectionModel::selectionChanged, this, &DeckCardZoneDisplayWidget::onSelectionChanged); @@ -174,6 +175,9 @@ void DeckCardZoneDisplayWidget::displayCards() for (const QPersistentModelIndex &persistent : rows) { constructAppropriateWidget(persistent); + // Reuse existing widgets when a model sort moves their persistent rows. + // This preserves collapsed groups and card selection during live edits. + cardGroupLayout->insertWidget(persistent.row(), indexToWidgetMap.value(persistent)); } } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 2cd5ef914..b357b5801 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -340,7 +340,17 @@ bool DeckList::saveToStream_Plain(QTextStream &stream, bool prefixSideboardCards } }; - forEachCard(writeToStream); + // Export the main deck first without changing the saved tree's zone order. + forEachCard([&](const auto node, const auto card) { + if (node->getName() == DECK_ZONE_MAIN) { + writeToStream(node, card); + } + }); + forEachCard([&](const auto node, const auto card) { + if (node->getName() != DECK_ZONE_MAIN) { + writeToStream(node, card); + } + }); return true; } 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 617324fe3..f3c4545d7 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -746,7 +746,8 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const 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); + (void)DeckListModelCustomZones::sortWithCustomZonesLast( + root, node, order, activeGroupCriteria == DeckListModelGroupCriteria::MANA_COST && lastKnownColumn != 0); for (int i = node->size() - 1; i >= 0; --i) { if (auto *subNode = dynamic_cast(node->at(i))) { @@ -759,7 +760,8 @@ void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder 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); + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast( + root, node, order, activeGroupCriteria == DeckListModelGroupCriteria::MANA_COST && lastKnownColumn != 0); QModelIndexList from, to; int columns = columnCount(); 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 index 419c38b04..523708cbd 100644 --- 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 @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include namespace DeckListModelCustomZones @@ -111,7 +113,7 @@ QList> plainSort(InnerDecklistNode *node, Qt::SortOrder order) * * @return The (preSortRow, finalRow) mapping covering both the sort and the shift. */ -QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) +QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order, bool numericGroups) { const QVector> sortResult = node->sort(order); @@ -132,6 +134,27 @@ QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) } } + if (numericGroups) { + // Only criteria groups are numeric. Mixing numeric and lexical fallback + // comparisons with custom zone names would not define a strict ordering. + std::sort(groups.begin(), groups.end(), [order](const auto *left, const auto *right) { + const QString leftName = left->getName(); + const QString rightName = right->getName(); + bool leftIsNumber, rightIsNumber; + const double leftValue = leftName.toDouble(&leftIsNumber); + const double rightValue = rightName.toDouble(&rightIsNumber); + leftIsNumber = leftIsNumber && std::isfinite(leftValue); + rightIsNumber = rightIsNumber && std::isfinite(rightValue); + if (leftIsNumber != rightIsNumber) { + return leftIsNumber; + } + if (leftIsNumber && leftValue != rightValue) { + return order == Qt::AscendingOrder ? leftValue < rightValue : leftValue > rightValue; + } + return order == Qt::AscendingOrder ? leftName < rightName : leftName > rightName; + }); + } + QVector ordered = groups + customZones; for (int i = 0; i < ordered.size(); ++i) { node->replace(i, ordered[i]); @@ -147,10 +170,11 @@ QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) } // namespace -QList> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order) +QList> +sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order, bool numericGroups) { const bool isBoardZone = (node != root) && (node->getParent() == root); - return isBoardZone ? boardSort(node, order) : plainSort(node, order); + return isBoardZone ? boardSort(node, order, numericGroups) : 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 index 53bdeaa1e..d3f477450 100644 --- 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 @@ -91,10 +91,15 @@ void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode * @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. + * @param numericGroups Compare criteria-group names as mana values. Custom zone + * names and card names retain their normal comparison; nonnumeric groups follow + * numeric groups in either sort direction. * @return A list of (preSortRow, finalRow) pairs describing how each node moved. */ -[[nodiscard]] QList> -sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); +[[nodiscard]] QList> sortWithCustomZonesLast(InnerDecklistNode *root, + InnerDecklistNode *node, + Qt::SortOrder order, + bool numericGroups = false); } // namespace DeckListModelCustomZones 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 index 95df74702..d4e44125c 100644 --- a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -278,6 +278,37 @@ TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones) EXPECT_EQ(mapping[1].second, 1); } +TEST(DeckListModelCustomZones, ManaGroupsUseNumericOrderWithoutChangingOtherNodes) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + const QStringList original = {"10", "2", "0.5", "1000000", "1", "unknown", "nan"}; + for (const auto &name : original) { + new InnerDecklistNode(name, board); + } + auto *custom = new DecklistModelSubZoneNode("1x", board); + new InnerDecklistNode("10", custom); + new InnerDecklistNode("2", custom); + root.setSortMethod(DeckSortMethod::ByName); + + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder, true); + EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "2", "10", "1000000", "nan", "unknown", "1x"})); + QStringList before = original; + before.append("1x"); + for (const auto &move : mapping) { + EXPECT_EQ(board->at(move.second)->getName(), before.at(move.first)); + } + + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder, true); + EXPECT_EQ(childNames(board), (QStringList{"1000000", "10", "2", "1", "0.5", "unknown", "nan", "1x"})); + + // The numeric flag only affects groups directly under a board. + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, custom, Qt::AscendingOrder, true); + EXPECT_EQ(childNames(custom), (QStringList{"10", "2"})); + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "10", "1000000", "2", "nan", "unknown", "1x"})); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/deck_list_zones/deck_list_zones_test.cpp b/tests/deck_list_zones/deck_list_zones_test.cpp index 1903b70df..a67e689cf 100644 --- a/tests/deck_list_zones/deck_list_zones_test.cpp +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -393,6 +393,28 @@ TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash) EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash()); } +TEST(DeckListZones, PlainExportPutsMainFirstWithoutReorderingSavedZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Side Card", 2, "Side Tech", -1); + tree->addCard("Token Card", 1, DECK_ZONE_TOKENS, -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Main Card", 3, "Removal", -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + tree->addCard("Maybe Card", 4, "Candidates", -1); + + const QString native = deck.writeToString_Native(); + EXPECT_EQ(deck.writeToString_Plain(true, false), QString("3 Main Card\nSB: 2 Side Card\n1 Token Card\n")); + EXPECT_EQ(deck.writeToString_Plain(false, false), QString("3 Main Card\n2 Side Card\n1 Token Card\n")); + EXPECT_EQ(deck.writeToString_Native(), native); + + DeckList reopened(native); + EXPECT_EQ(reopened.getZoneNodes().first()->getName(), DECK_ZONE_SIDE); + EXPECT_EQ(reopened.writeToString_Plain(true, false), deck.writeToString_Plain(true, false)); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); From 5ea16c805f8c4ace3b59c47d19658f13032eea29 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:44:50 +0100 Subject: [PATCH 039/104] [Server/Client/Protocol] Shuffle or reorder player seats before a game starts (#7398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Protocol] Add shuffle players option and player order command/event Adds a 'Shuffle players at start' option to game creation plus a local player id on the game state so clients can reconcile seat changes, and a SET_PLAYER_ORDER command (1206) with a PLAYER_ORDER_CHANGED event (2023) so the game host can set or randomize the seating order. Took 6 minutes * [Server] Shuffle and reorder player seats before a game starts Auto-shuffles the seats at game start when enabled (now for games with two or more players instead of three or more) and handles the new SET_PLAYER_ORDER command so the game host or a moderator can reorder or randomize the seats before the game starts. Re-seating re-keys the player ids while keeping the per-user game/seat routing and persistent player records in sync, then broadcasts the new order to everyone. * [Client] Add shuffle players option and turn order dialog Adds the 'Shuffle players at start' checkbox to game creation and a host-only 'Set turn order...' action that opens a dialog to reorder or randomize the seats before the game starts. Seating changes are shown in the game's message log. * [Client] Move the turn order dialog strings into retranslateUi Follows the DlgInviteToGame convention so the dialog can be retranslated instead of freezing its strings at construction time. * [Server] Keep player ids stable across seat shuffles * [Client] Lay out table and turn order dialog in seat order * [Server] Send game-joined player list in seat order * [Client] Reword the shuffle option to match its behaviour * [Client] Address the seat-order review comments - Give the game setup checkboxes a row of their own so they no longer collide in the create-game dialog - Rename DlgTurnOrder::randomize() to isRandomizeRequested() - Document what the ids in GameState::seatOrder mean - Keep "Set turn order..." in sync with the game: hidden for non-hosts and once the game has started, re-shown when the game ends or hosting moves to the local player (new GameState::hostChanged signal) * [Client] Do not preview a randomized turn order Randomizing is a request to the server, not a local shuffle. Shuffling the list locally meant the dialog showed an order the server then discarded, and let the host re-open it until a favourable order appeared. The dialog now switches to a mode that says the server will randomize and toggles back to the manual list on request. * [Client] Let moderators set the turn order The server accepts SET_PLAYER_ORDER from the host or a moderator, but the client hid the action from everyone except the host, making the moderator branch unreachable. Visibility and the handler now share canSetTurnOrder(). * [Server] Rely on the player order event instead of a full state resend cmdSetPlayerOrder re-sent the whole game state after reseating, so every client reloaded its deck view mid-lobby for no reason. The new seating reaches clients through the Event_PlayerOrderChanged that reseatPlayers broadcasts. * [Client] Show random turn order in the game selector The restrictions column listed "open decklists" but not the new seating option, so a joining player had no way to see that seats will be randomized each game. Game info already shows the disabled checkbox. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + cockatrice/src/game_graphics/game_scene.cpp | 32 +++ .../game_graphics/log/message_log_widget.cpp | 14 + .../game_graphics/log/message_log_widget.h | 1 + .../widgets/dialogs/dlg_create_game.cpp | 13 +- .../widgets/dialogs/dlg_create_game.h | 1 + .../widgets/dialogs/dlg_turn_order.cpp | 136 +++++++++ .../widgets/dialogs/dlg_turn_order.h | 51 ++++ .../interface/widgets/server/games_model.cpp | 3 + .../src/interface/widgets/tabs/tab_game.cpp | 91 ++++++ .../src/interface/widgets/tabs/tab_game.h | 6 +- .../libcockatrice/game/game_event_handler.cpp | 77 +++++ .../libcockatrice/game/game_event_handler.h | 16 ++ .../libcockatrice/game/game_state.h | 22 ++ .../game/player/player_manager.h | 5 + .../interface_game_settings_provider.h | 1 + .../network/server/remote/game/game_config.h | 1 + .../game/server_abstract_participant.cpp | 31 ++ .../remote/game/server_abstract_participant.h | 7 + .../server/remote/game/server_game.cpp | 199 ++++++++++++- .../network/server/remote/game/server_game.h | 18 ++ .../server/remote/server_protocolhandler.cpp | 4 +- .../libcockatrice/protocol/pb/CMakeLists.txt | 2 + .../pb/command_set_player_order.proto | 15 + .../pb/event_game_state_changed.proto | 5 + .../pb/event_player_order_changed.proto | 13 + .../protocol/pb/game_commands.proto | 5 + .../protocol/pb/game_event.proto | 1 + .../protocol/pb/room_commands.proto | 3 + .../protocol/pb/serverinfo_game.proto | 3 + .../libcockatrice/settings/game_settings.cpp | 10 + .../libcockatrice/settings/game_settings.h | 2 + tests/movecard_tests/CMakeLists.txt | 12 + tests/movecard_tests/player_order_test.cpp | 268 ++++++++++++++++++ 34 files changed, 1054 insertions(+), 15 deletions(-) create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto create mode 100644 tests/movecard_tests/player_order_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index e437f4d56..b18846079 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -56,6 +56,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp + src/interface/widgets/dialogs/dlg_turn_order.cpp src/interface/widgets/dialogs/dlg_update.cpp src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 1f780b795..128899a13 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -441,6 +442,37 @@ QList GameScene::collectActivePlayers(int &firstPlayerIndex) cons } } } + + // Lay out the table in seat order rather than id order. Players missing + // from the tracked order keep id order at the end. + QList seatOrder; + for (auto *pgItem : playerViews.values()) { + if (PlayerLogic *p = pgItem->getLogic()) { + seatOrder = p->getGame()->getGameState()->getSeatOrder(); + break; + } + } + if (!seatOrder.isEmpty()) { + std::sort(activePlayers.begin(), activePlayers.end(), [&](PlayerLogic *a, PlayerLogic *b) { + int indexA = seatOrder.indexOf(a->getPlayerInfo()->getId()); + int indexB = seatOrder.indexOf(b->getPlayerInfo()->getId()); + if (indexA == -1) { + indexA = seatOrder.size() + a->getPlayerInfo()->getId(); + } + if (indexB == -1) { + indexB = seatOrder.size() + b->getPlayerInfo()->getId(); + } + return indexA < indexB; + }); + // Recompute the local-player index after sorting. + firstPlayerIndex = 0; + for (int i = 0; i < activePlayers.size(); ++i) { + if (activePlayers[i]->getPlayerInfo()->getLocal()) { + firstPlayerIndex = i; + break; + } + } + } return activePlayers; } diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 046a1ae90..8b351e022 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -605,6 +605,20 @@ void MessageLogWidget::logReverseTurn(PlayerLogic *player, bool reversed) .arg(reversed ? tr("reversed") : tr("normal"))); } +void MessageLogWidget::logTurnOrderChanged(QStringList playerNames, bool randomized) +{ + QStringList sanitizedNames; + for (const QString &name : playerNames) { + sanitizedNames.append(sanitizeHtml(name)); + } + const QString order = sanitizedNames.join(" -> "); + if (randomized) { + appendHtmlServerMessage(tr("Randomized turn order: %1.").arg(order)); + } else { + appendHtmlServerMessage(tr("New turn order set: %1.").arg(order)); + } +} + void MessageLogWidget::logRollDie(PlayerLogic *player, int sides, const QList &rolls) { if (rolls.length() == 1) { diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index 5642a76e9..5632f5af8 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -94,6 +94,7 @@ public slots: int amount, bool isLentToAnotherPlayer); void logReverseTurn(PlayerLogic *player, bool reversed); + void logTurnOrderChanged(QStringList playerNames, bool randomized); void logRollDie(PlayerLogic *player, int sides, const QList &rolls); void logSay(PlayerLogic *player, QString message); void logSetActivePhase(int phase); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 104b695e6..ada4237f7 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -116,14 +116,17 @@ void DlgCreateGame::sharedCtor() shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); + shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start")); + createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); auto *gameSetupOptionsLayout = new QGridLayout; gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); - gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0); + gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2); + gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2); if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { - gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0); + gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2); } else { createGameAsJudgeCheckBox->setChecked(false); createGameAsJudgeCheckBox->setHidden(true); @@ -184,6 +187,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameType createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator()); startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal()); shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad()); + shufflePlayersCheckBox->setChecked(SettingsCache::instance().game().getShufflePlayers()); if (!rememberGameSettings->isChecked()) { actReset(); @@ -217,6 +221,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetEnabled(false); startingLifeTotalEdit->setEnabled(false); shareDecklistsOnLoadCheckBox->setEnabled(false); + shufflePlayersCheckBox->setEnabled(false); descriptionEdit->setText(QString::fromStdString(gameInfo.description())); maxPlayersEdit->setValue(gameInfo.max_players()); @@ -227,6 +232,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetChecked(gameInfo.spectators_can_chat()); spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); + shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players()); QSet types; for (int i = 0; i < gameInfo.game_types_size(); ++i) { @@ -264,6 +270,7 @@ void DlgCreateGame::actReset() startingLifeTotalEdit->setValue(20); shareDecklistsOnLoadCheckBox->setChecked(false); + shufflePlayersCheckBox->setChecked(false); createGameAsJudgeCheckBox->setChecked(false); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); @@ -294,6 +301,7 @@ void DlgCreateGame::actOK() cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked()); cmd.set_starting_life_total(startingLifeTotalEdit->value()); cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked()); + cmd.set_shuffle_players(shufflePlayersCheckBox->isChecked()); auto _gameTypes = QString(); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); @@ -318,6 +326,7 @@ void DlgCreateGame::actOK() SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked()); SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value()); SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked()); + SettingsCache::instance().game().setShufflePlayers(shufflePlayersCheckBox->isChecked()); SettingsCache::instance().game().setGameTypes(_gameTypes); } PendingCommand *pend = room->prepareRoomCommand(cmd); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h index 71b09997c..58356c9b6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h @@ -51,6 +51,7 @@ private: QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox, *spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox; QCheckBox *shareDecklistsOnLoadCheckBox; + QCheckBox *shufflePlayersCheckBox; QDialogButtonBox *buttonBox; QPushButton *clearButton; QCheckBox *rememberGameSettings; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp new file mode 100644 index 000000000..8814f89db --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp @@ -0,0 +1,136 @@ +#include "dlg_turn_order.h" + +#include +#include +#include +#include +#include +#include +#include + +DlgTurnOrder::DlgTurnOrder(const QStringList &_playerNames, QWidget *parent) + : QDialog(parent), randomizeRequested(false) +{ + listWidget = new QListWidget(this); + listWidget->addItems(_playerNames); + listWidget->setCurrentRow(0); + connect(listWidget, &QListWidget::currentRowChanged, this, &DlgTurnOrder::selectionChanged); + + moveUpButton = new QPushButton(this); + connect(moveUpButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveUp); + moveDownButton = new QPushButton(this); + connect(moveDownButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveDown); + randomizeButton = new QPushButton(this); + connect(randomizeButton, &QPushButton::clicked, this, &DlgTurnOrder::actRandomize); + + // Shown in place of the list while the server is asked to randomize: the + // order is chosen server side, so there is nothing to preview here. + hintLabel = new QLabel(this); + hintLabel->setWordWrap(true); + hintLabel->setAlignment(Qt::AlignCenter); + + auto *buttonColumn = new QVBoxLayout; + buttonColumn->addWidget(moveUpButton); + buttonColumn->addWidget(moveDownButton); + buttonColumn->addWidget(randomizeButton); + buttonColumn->addStretch(); + + auto *listLayout = new QHBoxLayout; + listLayout->addWidget(listWidget); + listLayout->addWidget(hintLabel, 1); + listLayout->addLayout(buttonColumn); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTurnOrder::actOk); + connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgTurnOrder::reject); + + auto *mainLayout = new QVBoxLayout; + mainLayout->addLayout(listLayout); + mainLayout->addWidget(buttonBox); + setLayout(mainLayout); + + updateButtons(); + + updateMode(); + setMinimumWidth(300); +} + +void DlgTurnOrder::retranslateUi() +{ + setWindowTitle(tr("Set turn order")); + moveUpButton->setText(tr("Move up")); + moveDownButton->setText(tr("Move down")); + randomizeButton->setText(randomizeRequested ? tr("Set order manually") : tr("Randomize")); + hintLabel->setText(tr("The server will randomize the turn order.")); +} + +QStringList DlgTurnOrder::order() const +{ + QStringList playerNames; + for (int i = 0; i < listWidget->count(); ++i) { + playerNames.append(listWidget->item(i)->text()); + } + return playerNames; +} + +void DlgTurnOrder::moveSelected(int delta) +{ + const int row = listWidget->currentRow(); + const int newRow = row + delta; + if (row < 0 || newRow < 0 || newRow >= listWidget->count()) { + return; + } + + QListWidgetItem *item = listWidget->takeItem(row); + listWidget->insertItem(newRow, item); + listWidget->setCurrentRow(newRow); + updateButtons(); +} + +void DlgTurnOrder::updateMode() +{ + listWidget->setVisible(!randomizeRequested); + hintLabel->setVisible(randomizeRequested); + moveUpButton->setVisible(!randomizeRequested); + moveDownButton->setVisible(!randomizeRequested); + retranslateUi(); +} + +void DlgTurnOrder::updateButtons() +{ + const int row = listWidget->currentRow(); + const int count = listWidget->count(); + const bool manualOrder = !randomizeRequested; + moveUpButton->setEnabled(manualOrder && row > 0); + moveDownButton->setEnabled(manualOrder && row >= 0 && row < count - 1); +} + +void DlgTurnOrder::actMoveUp() +{ + moveSelected(-1); +} + +void DlgTurnOrder::actMoveDown() +{ + moveSelected(1); +} + +void DlgTurnOrder::actRandomize() +{ + // Randomizing is a request to the server, not a local shuffle: showing a + // preview would let the host re-open the dialog until the order looks + // favourable for them. + randomizeRequested = !randomizeRequested; + updateMode(); + updateButtons(); +} + +void DlgTurnOrder::actOk() +{ + accept(); +} + +void DlgTurnOrder::selectionChanged() +{ + updateButtons(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h new file mode 100644 index 000000000..44d8203b9 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h @@ -0,0 +1,51 @@ +/** + * @file dlg_turn_order.h + * @ingroup GameDialogs + */ +//! \todo Document this file. + +#ifndef DLG_TURN_ORDER_H +#define DLG_TURN_ORDER_H + +#include +#include + +class QLabel; +class QListWidget; +class QPushButton; + +class DlgTurnOrder : public QDialog +{ + Q_OBJECT +private: + QListWidget *listWidget; + QLabel *hintLabel; + QPushButton *moveUpButton; + QPushButton *moveDownButton; + QPushButton *randomizeButton; + bool randomizeRequested; + + void moveSelected(int delta); + void updateMode(); + void updateButtons(); + +private slots: + void actMoveUp(); + void actMoveDown(); + void actRandomize(); + void actOk(); + void selectionChanged(); + +public: + explicit DlgTurnOrder(const QStringList &_playerNames, QWidget *parent = nullptr); + + void retranslateUi(); + + [[nodiscard]] bool isRandomizeRequested() const + { + return randomizeRequested; + } + [[nodiscard]] QStringList order() const; +}; + +#endif diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 315d51d9e..8fed95833 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -185,6 +185,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const if (gameentry.share_decklists_on_load()) { result.append(tr("open decklists")); } + if (gameentry.shuffle_players()) { + result.append(tr("random turn order")); + } return result.join(", "); } case Qt::DecorationRole: { diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index cbff850a4..34d6f8a51 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -20,6 +20,7 @@ #include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_invite_to_game.h" +#include "../interface/widgets/dialogs/dlg_turn_order.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/utility/completer_utils.h" @@ -195,6 +196,7 @@ void TabGame::connectToGameState() { connect(game->getGameState(), &GameState::gameStarted, this, &TabGame::startGame); connect(game->getGameState(), &GameState::gameStopped, this, &TabGame::stopGame); + connect(game->getGameState(), &GameState::hostChanged, this, &TabGame::updateTurnOrderAction); connect(game->getGameState(), &GameState::activePhaseChanged, this, &TabGame::setActivePhase); connect(game->getGameState(), &GameState::activePlayerChanged, this, &TabGame::setActivePlayer); } @@ -263,6 +265,8 @@ void TabGame::connectMessageLogToGameEventHandler() connect(game->getGameEventHandler(), &GameEventHandler::logTurnReversed, messageLog, &MessageLogWidget::logReverseTurn); + connect(game->getGameEventHandler(), &GameEventHandler::logTurnOrderChanged, messageLog, + &MessageLogWidget::logTurnOrderChanged); connect(game->getGameEventHandler(), &GameEventHandler::logConcede, messageLog, &MessageLogWidget::logConcede); connect(game->getGameEventHandler(), &GameEventHandler::logUnconcede, messageLog, &MessageLogWidget::logUnconcede); @@ -373,6 +377,9 @@ void TabGame::retranslateUi() if (aReverseTurn) { aReverseTurn->setText(tr("Reverse turn order")); } + if (aTurnOrder) { + aTurnOrder->setText(tr("&Set turn order...")); + } if (aRemoveLocalArrows) { aRemoveLocalArrows->setText(tr("&Remove all local arrows")); } @@ -629,6 +636,73 @@ void TabGame::actConcede() } } +void TabGame::actTurnOrder() +{ + if (!canSetTurnOrder()) { + return; + } + + QStringList playerNames; + const QList seatOrder = game->getGameState()->getSeatOrder(); + const auto &players = game->getPlayerManager()->getPlayers(); + if (!seatOrder.isEmpty()) { + for (int playerId : seatOrder) { + if (PlayerLogic *player = players.value(playerId, nullptr)) { + playerNames.append(player->getPlayerInfo()->getName()); + } + } + // Include any players missing from the tracked order rather than dropping them. + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + const QString name = it.value()->getPlayerInfo()->getName(); + if (!playerNames.contains(name)) { + playerNames.append(name); + } + } + } else { + for (PlayerLogic *player : players) { + playerNames.append(player->getPlayerInfo()->getName()); + } + } + + DlgTurnOrder dlg(playerNames, this); + if (dlg.exec() != QDialog::Accepted) { + return; + } + + game->getGameEventHandler()->handleSetPlayerOrder(dlg.order(), dlg.isRandomizeRequested()); +} + +/** + * Whether the local user may change the seating order: only while the game has + * not started, and only as the host or a moderator - the server allows both + * (Server_AbstractParticipant::cmdSetPlayerOrder). + */ +bool TabGame::canSetTurnOrder() const +{ + if (game->getGameMetaInfo()->started()) { + return false; + } + if (game->isHost()) { + return true; + } + const ServerInfo_User *userInfo = tabSupervisor->getUserInfo(); + return userInfo != nullptr && (userInfo->user_level() & ServerInfo_User::IsModerator); +} + +/** + * Shows the turn order action only when it can actually change anything: the + * player must be allowed to set the order and the game must not have started. + * Called whenever either condition may have changed (game start/stop, host + * change). + */ +void TabGame::updateTurnOrderAction() +{ + if (aTurnOrder == nullptr) { + return; + } + aTurnOrder->setVisible(canSetTurnOrder()); +} + /** * Confirms the leave game and sends the leave game command, if applicable. * @@ -840,6 +914,15 @@ void TabGame::processPlayerLeave(PlayerLogic *leavingPlayer) } } + // A shuffled local player has their seat changed, so the deck view container + // keyed by their previous id must be cleaned up alongside the leaving player. + int leavingPlayerId = leavingPlayer->getPlayerInfo()->getId(); + if (deckViewContainers.contains(leavingPlayerId)) { + TabbedDeckViewContainer *deckView = deckViewContainers.take(leavingPlayerId); + deckViewContainerLayout->removeWidget(deckView); + deckView->deleteLater(); + } + scene->removePlayer(leavingPlayer); } @@ -948,6 +1031,7 @@ void TabGame::startGame(bool _resuming) aConcede->setText(tr("&Concede")); aConcede->setEnabled(true); } + updateTurnOrderAction(); } void TabGame::stopGame() @@ -970,6 +1054,7 @@ void TabGame::stopGame() aConcede->setText(tr("&Concede")); aConcede->setEnabled(false); } + updateTurnOrderAction(); } void TabGame::closeGame() @@ -1094,6 +1179,9 @@ void TabGame::createMenuItems() connect(aNextTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleNextTurn); aReverseTurn = new QAction(this); connect(aReverseTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleReverseTurn); + aTurnOrder = new QAction(this); + connect(aTurnOrder, &QAction::triggered, this, &TabGame::actTurnOrder); + updateTurnOrderAction(); aRemoveLocalArrows = new QAction(this); connect(aRemoveLocalArrows, &QAction::triggered, this, &TabGame::actRemoveLocalArrows); aRotateViewCW = new QAction(this); @@ -1144,6 +1232,7 @@ void TabGame::createMenuItems() gameMenu->addAction(aNextTurn); aNextTurn->setObjectName("aNextTurn"); gameMenu->addAction(aReverseTurn); + gameMenu->addAction(aTurnOrder); gameMenu->addSeparator(); gameMenu->addAction(aRemoveLocalArrows); gameMenu->addAction(aRotateViewCW); @@ -1172,6 +1261,7 @@ void TabGame::createReplayMenuItems() aNextPhaseAction = nullptr; aNextTurn = nullptr; aReverseTurn = nullptr; + aTurnOrder = nullptr; aRemoveLocalArrows = nullptr; aRotateViewCW = nullptr; aRotateViewCCW = nullptr; @@ -1302,6 +1392,7 @@ void TabGame::createPlayAreaWidget(bool bReplay) scene = new GameScene(phasesToolbar, this); connect(game->getPlayerManager(), &PlayerManager::playerConceded, scene, &GameScene::rearrange); connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, scene, &GameScene::rearrange); + connect(game->getGameState(), &GameState::seatOrderChanged, scene, &GameScene::rearrange); connect(scene, &GameScene::arrowDeletionRequested, game->getGameEventHandler(), &GameEventHandler::handleArrowDeletion); connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 7bc52202f..412529dc2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -94,7 +94,8 @@ private: QMenu *gameMenu, *viewMenu; TearOffMenu *phasesMenu; QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, - *aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; + *aReverseTurn, *aTurnOrder, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, + *aResetReplayLayout; QAction *aFocusChat; QAction *aInviteToGame = nullptr; QList phaseActions; @@ -129,6 +130,7 @@ private: PlayerLogic *setActivePlayer(int id); void setActivePhase(int phase); + bool canSetTurnOrder() const; void createMenuItems(); void createReplayMenuItems(); void createViewMenuItems(); @@ -162,6 +164,8 @@ private slots: void actInviteToGame(); void actConcede(); void actCopyGameLink(); + void actTurnOrder(); + void updateTurnOrderAction(); void actRemoveLocalArrows(); void actRotateViewCW(); void actRotateViewCCW(); diff --git a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp index 856475dfa..7ef502400 100644 --- a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -189,6 +192,9 @@ void GameEventHandler::processGameEventContainer(const GameEventContainer &cont, case GameEvent::REVERSE_TURN: eventReverseTurn(event.GetExtension(Event_ReverseTurn::ext), playerId, context); break; + case GameEvent::PLAYER_ORDER_CHANGED: + eventPlayerOrderChanged(event.GetExtension(Event_PlayerOrderChanged::ext), playerId, context); + break; default: { PlayerLogic *player = game->getPlayerManager()->getPlayers().value(playerId, 0); @@ -215,6 +221,16 @@ void GameEventHandler::handleReverseTurn() sendGameCommand(Command_ReverseTurn()); } +void GameEventHandler::handleSetPlayerOrder(const QList &playerNames, bool randomize) +{ + Command_SetPlayerOrder command; + command.set_randomize(randomize); + for (const QString &name : playerNames) { + command.add_player_names(name.toStdString()); + } + sendGameCommand(command); +} + void GameEventHandler::handleActiveLocalPlayerConceded() { sendGameCommand(Command_Concede()); @@ -377,6 +393,27 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event { const int playerListSize = event.player_list_size(); + if (event.has_local_player_id()) { + game->getPlayerManager()->setLocalPlayerId(event.local_player_id()); + } + + QSet authoritativePlayerIds; + for (int i = 0; i < playerListSize; ++i) { + const ServerInfo_PlayerProperties &prop = event.player_list(i).properties(); + if (!prop.spectator()) { + authoritativePlayerIds.insert(prop.player_id()); + } + } + + // Players whose seats were shuffled away from the previous game are no longer + // part of the authoritative list and must be removed. + for (int stalePlayerId : game->getPlayerManager()->getPlayers().keys()) { + if (!authoritativePlayerIds.contains(stalePlayerId)) { + game->getPlayerManager()->removePlayer(stalePlayerId); + emit playerLeft(stalePlayerId); + } + } + QVector>> opponentDecksToDisplay; for (int i = 0; i < playerListSize; ++i) { @@ -421,6 +458,19 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event emit remotePlayersDecksSelected(opponentDecksToDisplay); + // The server emits player_list in seat order, so record it to lay out the + // table by seating rather than by player id. + QList seatOrder; + for (int i = 0; i < playerListSize; ++i) { + const ServerInfo_PlayerProperties &prop = event.player_list(i).properties(); + if (!prop.spectator()) { + seatOrder.append(prop.player_id()); + } + } + if (!seatOrder.isEmpty()) { + game->getGameState()->setSeatOrder(seatOrder); + } + game->getGameState()->setGameTime(event.seconds_elapsed()); if (event.game_started() && !game->getGameMetaInfo()->started()) { @@ -625,6 +675,33 @@ void GameEventHandler::eventReverseTurn(const Event_ReverseTurn &event, emit logTurnReversed(player, event.reversed()); } +void GameEventHandler::eventPlayerOrderChanged(const Event_PlayerOrderChanged &event, + int /*eventPlayerId*/, + const GameEventContext & /*context*/) +{ + QStringList playerNames; + for (const auto &name : event.player_names()) { + playerNames.append(QString::fromStdString(name)); + } + + // Track the seating by stable player id so the table layout and the turn + // order dialog follow the shuffled order instead of id order. + QList seatOrder; + const auto &players = game->getPlayerManager()->getPlayers(); + for (const QString &name : playerNames) { + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + if (it.value() && it.value()->getPlayerInfo()->getName() == name) { + seatOrder.append(it.key()); + break; + } + } + } + if (!seatOrder.isEmpty()) { + game->getGameState()->setSeatOrder(seatOrder); + } + emit logTurnOrderChanged(playerNames, event.randomized()); +} + void GameEventHandler::eventGameHostChanged(const Event_GameHostChanged & /*event*/, int eventPlayerId, const GameEventContext & /*context*/) diff --git a/libcockatrice_game/libcockatrice/game/game_event_handler.h b/libcockatrice_game/libcockatrice/game/game_event_handler.h index f7b11780b..bfcf355b0 100644 --- a/libcockatrice_game/libcockatrice/game/game_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.h @@ -51,6 +51,7 @@ class Event_GameSay; class Event_Kicked; class Event_ReverseTurn; class Event_Ping; +class Event_PlayerOrderChanged; class ServerInfo_PlayerProperties; namespace google { @@ -113,6 +114,16 @@ public: /** @brief Request reversing the current turn order. */ void handleReverseTurn(); + /** + * @brief Request a new seating / turn order. + * + * Only the game host (or a moderator) may do this before the game starts. + * + * @param playerNames Ordered list of player names. Ignored when randomize is true. + * @param randomize Ask the server to shuffle the seats instead. + */ + void handleSetPlayerOrder(const QList &playerNames, bool randomize); + /** @brief Concede the game for the currently active local player. */ void handleActiveLocalPlayerConceded(); @@ -316,6 +327,10 @@ public: /** @brief Handle a turn reversal event. */ void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext &context); + /** @brief Handle a change of the seating / turn order. */ + void + eventPlayerOrderChanged(const Event_PlayerOrderChanged &event, int eventPlayerId, const GameEventContext &context); + /** @brief Handle ping / latency updates. */ void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context); @@ -443,6 +458,7 @@ signals: void logLeave(PlayerLogic *player, QString reason); void logKicked(); void logTurnReversed(PlayerLogic *player, bool reversed); + void logTurnOrderChanged(QStringList playerNames, bool randomized); void logGameClosed(); void logActivePlayer(PlayerLogic *activePlayer); void logActivePhaseChanged(int activePhase); diff --git a/libcockatrice_game/libcockatrice/game/game_state.h b/libcockatrice_game/libcockatrice/game/game_state.h index 55784fb91..4d02019ce 100644 --- a/libcockatrice_game/libcockatrice/game/game_state.h +++ b/libcockatrice_game/libcockatrice/game/game_state.h @@ -33,7 +33,11 @@ public: void setHostId(int _hostId) { + if (hostId == _hostId) { + return; + } hostId = _hostId; + emit hostChanged(hostId); } QList getClients() const @@ -112,6 +116,19 @@ public: return hostId; } + void setSeatOrder(const QList &order) + { + if (seatOrder != order) { + seatOrder = order; + emit seatOrderChanged(seatOrder); + } + } + + QList getSeatOrder() const + { + return seatOrder; + } + int getSecondsElapsed() const { return secondsElapsed; @@ -123,6 +140,8 @@ signals: void gameStopped(); void activePhaseChanged(int activePhase); void activePlayerChanged(int playerId); + void hostChanged(int hostId); + void seatOrderChanged(const QList &seatOrder); public slots: void incrementGameTime(); @@ -142,6 +161,9 @@ private: int currentPhase; int activePlayer; bool gameClosed; + //! Player ids in seating (turn) order, first to play first. Empty until the + //! server sends an order, in which case players are laid out in id order. + QList seatOrder; }; #endif // COCKATRICE_GAME_STATE_H diff --git a/libcockatrice_game/libcockatrice/game/player/player_manager.h b/libcockatrice_game/libcockatrice/game/player/player_manager.h index c95bbbb96..950574112 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_manager.h +++ b/libcockatrice_game/libcockatrice/game/player/player_manager.h @@ -57,6 +57,11 @@ public: return localPlayerId; } + void setLocalPlayerId(int _localPlayerId) + { + localPlayerId = _localPlayerId; + } + [[nodiscard]] const QMap &getPlayers() const { return players; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h index 2b1ddf8ec..00763d031 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h @@ -20,6 +20,7 @@ public: [[nodiscard]] virtual bool getCreateGameAsSpectator() const = 0; [[nodiscard]] virtual int getDefaultStartingLifeTotal() const = 0; [[nodiscard]] virtual bool getShareDecklistsOnLoad() const = 0; + [[nodiscard]] virtual bool getShufflePlayers() const = 0; [[nodiscard]] virtual bool getRememberGameSettings() const = 0; [[nodiscard]] virtual bool getLocalGameRememberSettings() const = 0; [[nodiscard]] virtual int getLocalGameMaxPlayers() const = 0; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h index baf946632..0661bd2aa 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h @@ -21,6 +21,7 @@ struct GameConfig bool spectatorsSeeEverything = true; int startingLifeTotal = 20; bool shareDecklistsOnLoad = true; + bool shufflePlayers = false; }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index ff8ea14f3..dcd14d14c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -434,6 +435,33 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_ return Response::RespOk; } +Response::ResponseCode Server_AbstractParticipant::cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + if ((game->getHostId() != playerId) && !(userInfo->user_level() & ServerInfo_User::IsModerator)) { + return Response::RespFunctionNotAllowed; + } + + if (game->getGameStarted()) { + return Response::RespContextError; + } + + if (cmd.randomize()) { + game->shufflePlayerSeats(); + } else { + QList playerNames; + for (int i = 0; i < cmd.player_names_size(); ++i) { + playerNames.append(QString::fromStdString(cmd.player_names(i))); + } + game->reorderPlayerSeats(playerNames); + } + + // Clients learn the new seating from the Event_PlayerOrderChanged that + // reseatPlayers broadcasts; no full game state resend is needed for it. + return Response::RespOk; +} + Response::ResponseCode Server_AbstractParticipant::processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges) { @@ -543,6 +571,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::SET_PLAYMAT: return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges); break; + case GameCommand::SET_PLAYER_ORDER: + return cmdSetPlayerOrder(command.GetExtension(Command_SetPlayerOrder::ext), rc, ges); + break; default: return Response::RespInvalidCommand; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index b6d4a8025..316b9727c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -53,6 +53,7 @@ class Command_DeckSelect; class Command_SetSideboardLock; class Command_ChangeZoneProperties; class Command_SetPlaymat; +class Command_SetPlayerOrder; class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container { @@ -89,6 +90,10 @@ public: { return playerId; } + void setPlayerId(int _playerId) + { + playerId = _playerId; + } bool isSpectator() const { return roleInfo.isSpectator; @@ -173,6 +178,8 @@ public: virtual Response::ResponseCode cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges); virtual Response::ResponseCode + cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges); Response::ResponseCode processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 49db999f6..e2724523e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -73,11 +73,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -91,8 +93,8 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room) spectatorsAllowed(config.spectatorsAllowed), spectatorsNeedPassword(config.spectatorsNeedPassword), spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything), startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad), - inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false), - turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), + shufflePlayers(config.shufflePlayers), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), + firstGameStarted(false), turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), deckValidationStrategy(new Server_DefaultDeckValidationStrategy), lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy), gameMutex() @@ -292,6 +294,32 @@ int Server_Game::getSpectatorCount() const return result; } +QList Server_Game::getParticipantsInSeatOrder() const +{ + // Players in seat order first (fall back to id order for seats predating + // seatOrder), then spectators. Player ids are stable; only the ordering + // changes on shuffle/reorder. + QList ordered; + ordered.reserve(participants.size()); + for (int id : seatOrder) { + auto *participant = participants.value(id, nullptr); + if (participant && !participant->isSpectator() && !ordered.contains(participant)) { + ordered.append(participant); + } + } + for (auto *participant : participants.values()) { + if (!participant->isSpectator() && !ordered.contains(participant)) { + ordered.append(participant); + } + } + for (auto *participant : participants.values()) { + if (participant->isSpectator()) { + ordered.append(participant); + } + } + return ordered; +} + void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, Server_AbstractParticipant *recipient, bool omniscient, @@ -306,16 +334,24 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, event->set_game_started(false); } - for (Server_AbstractParticipant *participant : participants.values()) { + // Emit the player list in seat order so every client (including old ones + // that ignore Event_PlayerOrderChanged) sees the current seating. Player + // ids are stable; only this ordering changes on shuffle/reorder. + QList ordered = getParticipantsInSeatOrder(); + for (Server_AbstractParticipant *participant : ordered) { participant->getInfo(event->add_player_list(), recipient, omniscient, withUserInfo); } + + if (recipient != nullptr) { + event->set_local_player_id(recipient->getPlayerId()); + } } void Server_Game::sendGameStateToPlayers() { // game state information for replay and omniscient spectators Event_GameStateChanged omniscientEvent; - createGameStateChangedEvent(&omniscientEvent, nullptr, true, false); + createGameStateChangedEvent(&omniscientEvent, nullptr, true, true); GameEventContainer *replayCont = prepareGameEvent(omniscientEvent, -1); replayCont->set_seconds_elapsed(secondsElapsed - startTimeOfThisGame); @@ -327,7 +363,7 @@ void Server_Game::sendGameStateToPlayers() // the data we used for the replay. All spectators are equal, so we don't need to make a createGameStateChangedEvent // call for each one. Event_GameStateChanged spectatorNormalEvent; - createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, false); + createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, true); // send game state info to clients according to their role in the game for (auto *participant : participants.values()) { @@ -340,7 +376,7 @@ void Server_Game::sendGameStateToPlayers() } } else { Event_GameStateChanged event; - createGameStateChangedEvent(&event, participant, participant->isJudge(), false); + createGameStateChangedEvent(&event, participant, participant->isJudge(), true); gec = prepareGameEvent(event, -1); } @@ -380,6 +416,11 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) return; } + if (shufflePlayers && players.size() > 1) { + shufflePlayerSeats(); + players = getPlayers(); + } + for (Server_AbstractPlayer *player : players.values()) { player->setupZones(); } @@ -433,6 +474,116 @@ void Server_Game::startGameIfReady(bool forceStartGame) emit sigStartGameIfReady(forceStartGame); } +void Server_Game::shufflePlayerSeats() +{ + QMutexLocker locker(&gameMutex); + + // Collect players in current seat order (fall back to id order for seats + // predating seatOrder). Spectators keep their seats. + QList players; + for (int id : seatOrder) { + auto *participant = participants.value(id, nullptr); + if (participant && !participant->isSpectator()) { + players.append(participant); + } + } + for (auto *participant : participants.values()) { + if (!participant->isSpectator() && !players.contains(participant)) { + players.append(participant); + } + } + QList spectators; + for (auto *participant : participants.values()) { + if (participant->isSpectator()) { + spectators.append(participant); + } + } + + // Fisher-Yates shuffle of the players. + QList shuffledPlayers; + while (!players.isEmpty()) { + const int index = rng->rand(0, players.size() - 1); + shuffledPlayers.append(players.takeAt(index)); + } + + reseatPlayers(shuffledPlayers + spectators, true); +} + +void Server_Game::reorderPlayerSeats(const QList &orderedNames) +{ + QMutexLocker locker(&gameMutex); + + // Collect players in current seat order (fall back to id order for seats + // predating seatOrder). Spectators keep their seats. + QList players; + for (int id : seatOrder) { + auto *participant = participants.value(id, nullptr); + if (participant && !participant->isSpectator()) { + players.append(participant); + } + } + for (auto *participant : participants.values()) { + if (!participant->isSpectator() && !players.contains(participant)) { + players.append(participant); + } + } + QList spectators; + for (auto *participant : participants.values()) { + if (participant->isSpectator()) { + spectators.append(participant); + } + } + + // Order the players according to the given names, appending any player that + // was not listed (should not happen) in their current seat order. + QList orderedPlayers; + QSet placed; + for (const QString &name : orderedNames) { + for (auto *participant : players) { + if (!placed.contains(participant) && (QString::fromStdString(participant->getUserInfo()->name()) == name)) { + orderedPlayers.append(participant); + placed.insert(participant); + break; + } + } + } + for (auto *participant : players) { + if (!placed.contains(participant)) { + orderedPlayers.append(participant); + } + } + + reseatPlayers(orderedPlayers + spectators, false); +} + +void Server_Game::reseatPlayers(const QList &orderedParticipants, bool randomized) +{ + QMutexLocker locker(&gameMutex); + + // Keep player ids stable and only reorder the seating/turn order. Changing + // ids would break old clients (stale localPlayerId from Event_GameJoined + // duplicates their own seat as an opponent), invalidate the home-server + // per-user game/seat mapping used for ISL command routing, and orphan the + // host id. All of those maps stay valid because ids never change here. + // The current host therefore keeps its id and needs no re-key tracking. + seatOrder.clear(); + for (auto *participant : orderedParticipants) { + if (!participant->isSpectator()) { + seatOrder.append(participant->getPlayerId()); + } + } + + // Broadcast the new seating order so that every client can display it. + Event_PlayerOrderChanged event; + for (auto *participant : orderedParticipants) { + if (!participant->isSpectator()) { + event.add_player_names(QString::fromStdString(participant->getUserInfo()->name()).toStdString()); + } + } + event.set_randomized(randomized); + sendGameEventContainer(prepareGameEvent(event, -1)); +} + void Server_Game::stopGameIfFinished() { QMutexLocker locker(&gameMutex); @@ -563,6 +714,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, allSpectatorsEver.insert(playerName); } else { allPlayersEver.insert(playerName); + seatOrder.append(newParticipant->getPlayerId()); // if the original creator of the game joins, give them host status back //! \todo Transferring host to spectators has side effects. @@ -592,6 +744,7 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve room->getServer()->removePersistentPlayer(QString::fromStdString(participant->getUserInfo()->name()), room->getId(), gameId, participant->getPlayerId()); participants.remove(participant->getPlayerId()); + seatOrder.removeAll(participant->getPlayerId()); bool spectator = participant->isSpectator(); GameEventStorage ges; @@ -611,10 +764,20 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve participant->prepareDestroy(); if (playerHost) { + // Pass host to the next player in seat order so the game always keeps + // a host while players remain. int newHostId = -1; - for (auto *otherPlayer : getPlayers().values()) { - newHostId = otherPlayer->getPlayerId(); - break; + for (int id : seatOrder) { + if (participants.contains(id)) { + newHostId = id; + break; + } + } + if (newHostId == -1) { + for (auto *otherPlayer : getPlayers().values()) { + newHostId = otherPlayer->getPlayerId(); + break; + } } if (newHostId != -1) { hostId = newHostId; @@ -771,7 +934,20 @@ void Server_Game::nextTurn() } auto players = getPlayers(); - const QList keys = players.keys(); + // Advance in seat order, not id order, so shuffles/reorders change the + // turn sequence while ids stay stable. + QList keys; + keys.reserve(players.size()); + for (int id : seatOrder) { + if (players.contains(id)) { + keys.append(id); + } + } + for (int id : players.keys()) { + if (!keys.contains(id)) { + keys.append(id); + } + } int listPos = -1; if (activePlayer != -1) { listPos = keys.indexOf(activePlayer); @@ -821,7 +997,7 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti event2.set_active_phase(activePhase); bool omniscient = (joiningParticipant->isSpectator() && spectatorsSeeEverything) || joiningParticipant->isJudge(); - for (auto *participant : participants.values()) { + for (auto *participant : getParticipantsInSeatOrder()) { participant->getInfo(event2.add_player_list(), joiningParticipant, omniscient, true); } @@ -950,6 +1126,7 @@ void Server_Game::getInfo(ServerInfo_Game &result) const result.set_spectators_can_chat(spectatorsCanTalk); result.set_spectators_omniscient(spectatorsSeeEverything); result.set_share_decklists_on_load(shareDecklistsOnLoad); + result.set_shuffle_players(shufflePlayers); result.set_spectators_count(getSpectatorCount()); result.set_start_time(startTime.toSecsSinceEpoch()); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index 363c0ca94..23ba96598 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -74,6 +74,11 @@ private: int hostId; ServerInfo_User *creatorInfo; QMap participants; + // Seating/turn order as an ordered list of player ids. Player ids themselves + // are stable for the lifetime of the participant; shuffling or reordering + // only permutes this list so old clients, ISL routes and persistent + // references (all keyed by player id) keep working. + QList seatOrder; QSet allPlayersEver, allSpectatorsEver; bool gameStarted; bool gameClosed; @@ -90,6 +95,7 @@ private: bool spectatorsSeeEverything; int startingLifeTotal; bool shareDecklistsOnLoad; + bool shufflePlayers; int inactivityCounter; int startTimeOfThisGame, secondsElapsed; bool firstGameStarted; @@ -109,6 +115,8 @@ private: Server_AbstractParticipant *recipient, bool omniscient, bool withUserInfo); + QList getParticipantsInSeatOrder() const; + void reseatPlayers(const QList &orderedParticipants, bool randomized); void storeGameInformation(); bool canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const; signals: @@ -149,6 +157,10 @@ public: { return participants; } + QList getSeatOrder() const + { + return seatOrder; + } int getGameId() const { return gameId; @@ -189,6 +201,10 @@ public: { return shareDecklistsOnLoad; } + bool getShufflePlayers() const + { + return shufflePlayers; + } Response::ResponseCode checkJoin(ServerInfo_User *user, const QString &_password, const RoleInfo &roleInfo, bool overrideRestrictions); bool containsUser(const QString &userName) const; @@ -202,6 +218,8 @@ public: bool kickParticipant(int playerId); void startGameIfReady(bool forceStartGame); void stopGameIfFinished(); + void shufflePlayerSeats(); + void reorderPlayerSeats(const QList &orderedNames); int getActivePlayer() const { return activePlayer; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index f9cadc9aa..78b9d955f 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1116,6 +1116,7 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room int startingLifeTotal = cmd.has_starting_life_total() ? cmd.starting_life_total() : 20; bool shareDecklistsOnLoad = cmd.has_share_decklists_on_load() ? cmd.share_decklists_on_load() : false; + bool shufflePlayers = cmd.has_shuffle_players() ? cmd.shuffle_players() : false; const int gameId = databaseInterface->getNextGameId(); if (gameId == -1) { @@ -1137,7 +1138,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room .spectatorsCanTalk = cmd.spectators_can_talk(), .spectatorsSeeEverything = cmd.spectators_see_everything(), .startingLifeTotal = startingLifeTotal, - .shareDecklistsOnLoad = shareDecklistsOnLoad}; + .shareDecklistsOnLoad = shareDecklistsOnLoad, + .shufflePlayers = shufflePlayers}; auto *game = new Server_Game(config, room); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 059e83096..5fba2631d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -66,6 +66,7 @@ set(PROTO_FILES command_set_card_attr.proto command_set_card_counter.proto command_set_counter.proto + command_set_player_order.proto command_set_playmat.proto command_set_sideboard_lock.proto command_set_sideboard_plan.proto @@ -110,6 +111,7 @@ set(PROTO_FILES event_list_rooms.proto event_move_card.proto event_notify_user.proto + event_player_order_changed.proto event_player_properties_changed.proto event_remove_from_list.proto event_remove_messages.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto new file mode 100644 index 000000000..97e27038f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto @@ -0,0 +1,15 @@ +syntax = "proto2"; +import "game_commands.proto"; +message Command_SetPlayerOrder { + extend GameCommand { + optional Command_SetPlayerOrder ext = 1206; + } + + // The desired seating order, expressed as player names. Only used when + // randomize is false. + repeated string player_names = 1; + + // When set, the server shuffles the seating order instead of using + // player_names. + optional bool randomize = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto index 5c8aee3fe..bbf2d0f98 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto @@ -24,4 +24,9 @@ message Event_GameStateChanged { // the amount of seconds since the game started optional uint32 seconds_elapsed = 5; + + // the id of the player this event is addressed to. Used to re-identify the + // local player after seats have been shuffled. Only set for events sent to + // a specific participant, never for omniscient/replay events. + optional sint32 local_player_id = 6; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto new file mode 100644 index 000000000..d119398b7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "game_event.proto"; +message Event_PlayerOrderChanged { + extend GameEvent { + optional Event_PlayerOrderChanged ext = 2023; + } + + // The new seating order, expressed as player names. + repeated string player_names = 1; + + // Whether the order was obtained by randomizing rather than set explicitly. + optional bool randomized = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 2e5b88978..eb399ffb9 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -180,6 +180,11 @@ message GameCommand { /// Server: Server_Player::cmdSetPlaymat /// Client: reflected via player properties changed event SET_PLAYMAT = 1035; + + /// Set the seating (turn) order of the players. + /// Server: Server_AbstractParticipant::cmdSetPlayerOrder + /// Client: reflected via Event_PlayerOrderChanged + SET_PLAYER_ORDER = 1206; } extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto index 7d3147701..573d26453 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto @@ -34,6 +34,7 @@ message GameEvent { CHANGE_ZONE_PROPERTIES = 2020; REVERSE_TURN = 2021; GAME_LOG_NOTICE = 2022; + PLAYER_ORDER_CHANGED = 2023; } optional sint32 player_id = 1 [default = -1]; extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto index 9d9c6d277..3be7e4e64 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto @@ -78,6 +78,9 @@ message Command_CreateGame { // share decklists with all players when selected optional bool share_decklists_on_load = 14; + + // shuffle the player order (seats) before each game starts + optional bool shuffle_players = 15; } message Command_JoinGame { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto index 9989ae18a..39ec79914 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto @@ -48,6 +48,9 @@ message ServerInfo_Game { // decklists are sent to all players when loaded optional bool share_decklists_on_load = 15; + // players are shuffled into new seats before each game starts + optional bool shuffle_players = 16; + // the current player count optional uint32 player_count = 30; diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.cpp b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp index c8c6463e3..89fe7b85e 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp @@ -71,6 +71,11 @@ bool GameSettings::getShareDecklistsOnLoad() const return getValue("shareDecklistsOnLoad", "game").toBool(); } +bool GameSettings::getShufflePlayers() const +{ + return getValue("shuffleplayers", "game").toBool(); +} + bool GameSettings::getRememberGameSettings() const { return getValue("rememberGameSettings", "game", QString(), true).toBool(); @@ -151,6 +156,11 @@ void GameSettings::setShareDecklistsOnLoad(bool _shareDecklistsOnLoad) setValue(_shareDecklistsOnLoad, "shareDecklistsOnLoad", "game"); } +void GameSettings::setShufflePlayers(bool _shufflePlayers) +{ + setValue(_shufflePlayers, "shuffleplayers", "game"); +} + void GameSettings::setRememberGameSettings(bool _rememberGameSettings) { setValue(_rememberGameSettings, "rememberGameSettings", "game"); diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.h b/libcockatrice_settings/libcockatrice/settings/game_settings.h index 3e64234fc..45ec8554b 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.h @@ -27,6 +27,7 @@ public: [[nodiscard]] bool getCreateGameAsSpectator() const override; [[nodiscard]] int getDefaultStartingLifeTotal() const override; [[nodiscard]] bool getShareDecklistsOnLoad() const override; + [[nodiscard]] bool getShufflePlayers() const override; [[nodiscard]] bool getRememberGameSettings() const override; [[nodiscard]] bool getLocalGameRememberSettings() const override; [[nodiscard]] int getLocalGameMaxPlayers() const override; @@ -44,6 +45,7 @@ public: void setCreateGameAsSpectator(bool _createGameAsSpectator); void setDefaultStartingLifeTotal(int _defaultStartingLifeTotal); void setShareDecklistsOnLoad(bool _shareDecklistsOnLoad); + void setShufflePlayers(bool _shufflePlayers); void setRememberGameSettings(bool _rememberGameSettings); void setLocalGameRememberSettings(bool value); void setLocalGameMaxPlayers(int value); diff --git a/tests/movecard_tests/CMakeLists.txt b/tests/movecard_tests/CMakeLists.txt index 68d8fc84c..35d7b44ed 100755 --- a/tests/movecard_tests/CMakeLists.txt +++ b/tests/movecard_tests/CMakeLists.txt @@ -9,3 +9,15 @@ cockatrice_add_test( LABELS server ) + +cockatrice_add_test( + NAME + player_order_test + SOURCES + player_order_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) diff --git a/tests/movecard_tests/player_order_test.cpp b/tests/movecard_tests/player_order_test.cpp new file mode 100644 index 000000000..6e398af24 --- /dev/null +++ b/tests/movecard_tests/player_order_test.cpp @@ -0,0 +1,268 @@ +#include "game/game_config.h" +#include "game/role_info.h" +#include "game/server_abstract_participant.h" +#include "game/server_game.h" +#include "server_abstractuserinterface.h" +#include "server_response_containers.h" +#include "server_room.h" +#include "server_test_helpers.h" + +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // this needs to be defined due to other functions in server + +class ScriptedRng : public RNG_Abstract +{ +public: + QList values; + + unsigned int rand(int min, int max) override + { + const unsigned int value = values.takeFirst(); + EXPECT_GE(value, static_cast(min)); + EXPECT_LE(value, static_cast(max)); + return value; + } +}; + +class FakeUserInterface : public Server_AbstractUserInterface +{ +public: + FakeUserInterface(Server *_server, const ServerInfo_User &_userInfo) : Server_AbstractUserInterface(_server) + { + setUserInfo(_userInfo); + } + + int getLastCommandTime() const override + { + return 0; + } + + bool addSaidMessageSize(int) override + { + return true; + } + + void sendProtocolItem(const Response &) override + { + } + + void sendProtocolItem(const SessionEvent &) override + { + } + + void sendProtocolItem(const GameEventContainer &) override + { + } + + void sendProtocolItem(const RoomEvent &) override + { + } +}; + +namespace +{ +QStringList participantNamesInOrder(const Server_Game &game) +{ + QStringList names; + for (int id : game.getSeatOrder()) { + const auto *participant = game.getParticipants().value(id, nullptr); + if (participant) { + names.append(QString::fromStdString(participant->getUserInfo()->name())); + } + } + return names; +} + +QList participantIdsInOrder(const Server_Game &game) +{ + return game.getSeatOrder(); +} +} // namespace + +class PlayerOrderTest : public ::testing::Test +{ +protected: + ServerInfo_User creator; + ServerInfo_User userB; + ServerInfo_User userC; + + PlayerOrderTest() + { + creator.set_name("A"); + creator.set_user_level(ServerInfo_User::IsRegistered); + userB.set_name("B"); + userB.set_user_level(ServerInfo_User::IsRegistered); + userC.set_name("C"); + userC.set_user_level(ServerInfo_User::IsRegistered); + } +}; + +TEST_F(PlayerOrderTest, JoinOrderIsSeatOrder) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + EXPECT_EQ(participantNamesInOrder(game), QStringList({"A", "B", "C"})); + EXPECT_EQ(participantIdsInOrder(game), QList({0, 1, 2})); + EXPECT_EQ(game.getHostId(), 0); + EXPECT_EQ(game.getPlayerCount(), 3); +} + +TEST_F(PlayerOrderTest, ReorderPlayerSeats) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + game.reorderPlayerSeats({"C", "A", "B"}); + + // player ids are stable; only the seat order changes + EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"})); + EXPECT_EQ(participantIdsInOrder(game), QList({2, 0, 1})); + EXPECT_EQ(game.getParticipants().size(), 3); + + // the host keeps its seat: creator A is still in the game, so host stays 0 + EXPECT_EQ(game.getHostId(), 0); + + // no re-keying means every mapping keyed by player id stays valid + const int gameId = game.getGameId(); + EXPECT_EQ(uiA.getGames().value(gameId).second, 0); + EXPECT_EQ(uiB.getGames().value(gameId).second, 1); + EXPECT_EQ(uiC.getGames().value(gameId).second, 2); +} + +TEST_F(PlayerOrderTest, ReorderPlayerSeatsKeepsPlayerCount) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + // listing only a subset appends the remaining players in their current order + game.reorderPlayerSeats({"C"}); + + EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"})); + EXPECT_EQ(participantIdsInOrder(game), QList({2, 0, 1})); + EXPECT_EQ(game.getPlayerCount(), 3); + EXPECT_EQ(game.getHostId(), 0); +} + +TEST_F(PlayerOrderTest, ShufflePlayerSeatsIsAPermutation) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + // fisher-yates on [A, B, C]: rand(0, 2) -> 1 (take B), rand(0, 1) -> 0 (take A), rand(0, 0) -> 0 (take C) + auto *scriptedRng = new ScriptedRng; + scriptedRng->values = {1, 0, 0}; + rng = scriptedRng; + + game.shufflePlayerSeats(); + + EXPECT_EQ(participantNamesInOrder(game), QStringList({"B", "A", "C"})); + EXPECT_EQ(participantIdsInOrder(game), QList({1, 0, 2})); + QSet idSet(participantIdsInOrder(game).cbegin(), participantIdsInOrder(game).cend()); + EXPECT_EQ(idSet.size(), 3); // ids are unique + // ids are stable, so the host (creator A, id 0) is unchanged + EXPECT_EQ(game.getHostId(), 0); +} From d9442733350e7d4034d48e385256316b1f39e109 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 11:06:58 -0500 Subject: [PATCH 040/104] [Home] Keep actions bounded and reachable at every window height (#7747) --- Testing/deck_editor_open_test.xml | 2 +- Testing/deck_editor_search_test.xml | 2 +- Testing/integration_test.xml | 2 +- .../widgets/general/home_styled_button.cpp | 4 +- .../interface/widgets/general/home_widget.cpp | 48 ++++++++++--------- .../interface/widgets/general/home_widget.h | 2 - 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Testing/deck_editor_open_test.xml b/Testing/deck_editor_open_test.xml index 437c4bd23..ab74b1c63 100644 --- a/Testing/deck_editor_open_test.xml +++ b/Testing/deck_editor_open_test.xml @@ -1,4 +1,4 @@ - + diff --git a/Testing/deck_editor_search_test.xml b/Testing/deck_editor_search_test.xml index 0b9f88fa1..6641ac3b2 100644 --- a/Testing/deck_editor_search_test.xml +++ b/Testing/deck_editor_search_test.xml @@ -1,6 +1,6 @@ - + diff --git a/Testing/integration_test.xml b/Testing/integration_test.xml index 5fc7a971a..33191d1ac 100644 --- a/Testing/integration_test.xml +++ b/Testing/integration_test.xml @@ -36,7 +36,7 @@ - + diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.cpp b/cockatrice/src/interface/widgets/general/home_styled_button.cpp index 9c31d3815..a2ac7912d 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.cpp +++ b/cockatrice/src/interface/widgets/general/home_styled_button.cpp @@ -21,8 +21,8 @@ class QWidget; HomeStyledButton::HomeStyledButton(const QString &text, QPair _gradientColors, QWidget *parent) : QPushButton(text, parent), gradientColors(_gradientColors) { - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - setMinimumHeight(25); + // Keep the font/style-derived height even when the home page has ample space. + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); setStyleSheet(generateButtonStylesheet(gradientColors)); applyInterfaceDefaultFont(); connect(themeManager, &ThemeManager::fontsChanged, this, &HomeStyledButton::applyInterfaceDefaultFont); diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index e6b5bf255..db4501efc 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -27,10 +27,11 @@ #include #include #include -#include +#include #include #include #include +#include #include #include #include @@ -44,9 +45,9 @@ #include #include #include +#include #include #include -#include #include #include #include @@ -60,6 +61,7 @@ #include #include #include +#include #include #include #include @@ -69,20 +71,25 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))) { setObjectName("homeWidget"); - layout = new QGridLayout(this); backgroundSourceCard = new CardInfoPictureArtCropWidget(this); gradientColors = determineButtonColor(); - layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter); + // The launcher keeps its natural size on a large display and stays centred in the + // viewport. On a small window it scrolls, instead of its combined minimum height + // becoming the minimum height of the entire application. + auto *scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); + scrollArea->setObjectName("homeActionsScrollArea"); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setWidgetResizable(false); + scrollArea->setAlignment(Qt::AlignCenter); + // The home background is painted by this widget, so let it show through the viewport. + scrollArea->viewport()->setAutoFillBackground(false); + scrollArea->setWidget(createButtons()); - layout->setRowStretch(0, 1); - layout->setRowStretch(2, 1); - layout->setColumnStretch(0, 1); - layout->setColumnStretch(2, 1); - - setLayout(layout); + auto *layout = new QVBoxLayout(this); + layout->addWidget(scrollArea); cardChangeTimer = new QTimer(this); connect(cardChangeTimer, &QTimer::timeout, this, &HomeWidget::updateRandomCard); @@ -289,18 +296,10 @@ QGroupBox *HomeWidget::createButtons() { QGroupBox *box = new QGroupBox(this); box->setObjectName("homeButtonsBox"); - box->setStyleSheet(R"( - QGroupBox { - color: white; /* Title text color */ - background: transparent; - } - - QGroupBox::title { - color: white; - subcontrol-origin: margin; - subcontrol-position: top center; /* or top left / right */ - } -)"); + // The box only stacks the logo and the actions: no frame, and no background of its + // own, so the home background art stays visible. It never shows a title. The rule is + // load-bearing: QScrollArea::setWidget() turns autoFillBackground on for its widget. + box->setStyleSheet("QGroupBox { background: transparent; border: none; }"); // Family comes from the InterfaceDefault theme role; the 20px size stays caller-side. auto applyBoxFont = [box]() { QFont boxFont = themeManager->appFont(AppFont::InterfaceDefault); @@ -309,9 +308,12 @@ QGroupBox *HomeWidget::createButtons() }; applyBoxFont(); connect(themeManager, &ThemeManager::fontsChanged, this, applyBoxFont); - box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); QVBoxLayout *boxLayout = new QVBoxLayout; boxLayout->setAlignment(Qt::AlignHCenter); + // The scroll area is not resizable, so the box has to size itself: SetFixedSize keeps + // it at its content size and re-fixes it whenever the labels or fonts change, which is + // what makes the scroll area's scroll bars follow the content. + boxLayout->setSizeConstraint(QLayout::SetFixedSize); logoLabel = new QLabel; logoLabel->setAlignment(Qt::AlignCenter); diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index aa4745c82..fec1db950 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -16,7 +16,6 @@ #include #include -class QGridLayout; class QLabel; class CardInfoPictureArtCropWidget; class ExactCard; @@ -46,7 +45,6 @@ public slots: void updateConnectButton(const ClientStatus status); private: - QGridLayout *layout; QTimer *cardChangeTimer; TabSupervisor *tabSupervisor; QPixmap background; From e558c0dffad615f5c8763a37bc04beeabf5e6035 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 11:08:54 -0500 Subject: [PATCH 041/104] [Banner] Keep horizontal user banner crops inside the artwork (#7742) --- .../widgets/cards/card_art_utils.cpp | 23 ++++- .../interface/widgets/cards/card_art_utils.h | 9 +- .../server/user/user_card_art_provider.cpp | 28 +----- .../server/user/user_card_art_provider.h | 3 - .../server/user/user_card_settings_dialog.cpp | 4 +- tests/CMakeLists.txt | 12 +++ tests/card_art_crop_test.cpp | 92 +++++++++++++++++++ 7 files changed, 139 insertions(+), 32 deletions(-) create mode 100644 tests/card_art_crop_test.cpp diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp index ebf79282e..a7e633ad2 100644 --- a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp @@ -2,6 +2,8 @@ #include "libcockatrice/card/card_info.h" +#include +#include #include #include #include @@ -18,4 +20,23 @@ QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card) transform.rotate(90); return art.transformed(transform, Qt::SmoothTransformation); } -} // namespace CardArtUtils \ No newline at end of file + +QPixmap cropCardArt(const QPixmap &art, const ExactCard &card) +{ + if (art.isNull()) { + return {}; + } + + const QPixmap source = rotateSidewaysLayoutArt(art, card); + const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation; + const QSize size = source.size(); + const int marginX = size.width() * 0.07; + // Landscape name boxes extend below 5% of the upright image. Remove the + // whole box here, rather than relying on the banner's narrow viewport to + // hide it: the same saved offset is also used by the taller profile popup. + const int topMargin = size.height() * (landscape ? 0.15 : 0.11); + const int bottomMargin = size.height() * 0.45; + const QRect crop(marginX, topMargin, size.width() - 2 * marginX, size.height() - topMargin - bottomMargin); + return source.copy(crop.intersected(source.rect())); +} +} // namespace CardArtUtils diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.h b/cockatrice/src/interface/widgets/cards/card_art_utils.h index 5c331a12c..ac3b82380 100644 --- a/cockatrice/src/interface/widgets/cards/card_art_utils.h +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.h @@ -20,6 +20,13 @@ namespace CardArtUtils * @return @p art rotated 90° clockwise for sideways-layout cards, else @p art. */ QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card); + +/** + * Return the upright artwork used by banner previews, user rows and profile + * popups. The crop excludes conventional name and rules boxes before callers apply + * their saved pan and zoom, so a taller popup cannot expose the card frame. + */ +QPixmap cropCardArt(const QPixmap &art, const ExactCard &card); } // namespace CardArtUtils -#endif // CARD_ART_UTILS_H \ No newline at end of file +#endif // CARD_ART_UTILS_H diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp index d84bd5804..6e8fd771a 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -66,27 +65,6 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString processQueue(); } -QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes, const ExactCard &card) -{ - QPixmap source = fullRes; - - // Sideways-layout cards (plane, siege/battle, split) store their landscape - // artwork rotated 90° inside a portrait frame. Rotate it upright first so - // the crop below lands on the horizontal art, mirroring the way - // CardInfoPictureWidget displays these cards. - const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation; - source = CardArtUtils::rotateSidewaysLayoutArt(source, card); - - const QSize sz = source.size(); - const int marginX = sz.width() * 0.07; - const int topMargin = landscape ? sz.height() * 0.05 : sz.height() * 0.11; - const int bottomMargin = landscape ? sz.height() * 0.42 : sz.height() * 0.45; - - const QRect artRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); - - return source.copy(artRect.intersected(source.rect())); -} - void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap) { if (pixmap.isNull()) { @@ -134,7 +112,7 @@ void UserCardArtProvider::processQueue() // Synchronous hit (already loaded/on disk) if (!fullRes.isNull()) { - insertIntoCache(key, cropCardArt(fullRes, card)); + insertIntoCache(key, CardArtUtils::cropCardArt(fullRes, card)); pending.remove(key); emit cardArtUpdated(userName); @@ -158,7 +136,7 @@ void UserCardArtProvider::processQueue() CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); if (!fullRes.isNull()) { - self->insertIntoCache(key, self->cropCardArt(fullRes, card)); + self->insertIntoCache(key, CardArtUtils::cropCardArt(fullRes, card)); } self->pending.remove(key); @@ -172,4 +150,4 @@ void UserCardArtProvider::processQueue() // Stop here. We'll continue when the async load finishes. return; } -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h index ce9225b00..a7844ca88 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h @@ -10,8 +10,6 @@ #include #include -class ExactCard; - class UserCardArtProvider : public QObject { Q_OBJECT @@ -21,7 +19,6 @@ public: void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId); const QMap &cache() const; - static QPixmap cropCardArt(const QPixmap &fullRes, const ExactCard &card); signals: void cardArtUpdated(const QString &userName); diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp index f8b57cfc2..67fb57e78 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -2,6 +2,7 @@ #include "../../../card_picture_loader/card_picture_loader.h" #include "../../cards/art_crop_attribution.h" +#include "../../cards/card_art_utils.h" #include "../../utility/completer_utils.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -10,7 +11,6 @@ #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/card/set/card_set.h" -#include "user_card_art_provider.h" #include "user_list_painter.h" #include @@ -580,7 +580,7 @@ void UserCardArtSettingsDialog::reloadPreview() return; } - currentPixmap = UserCardArtProvider::cropCardArt(fullRes, card); + currentPixmap = CardArtUtils::cropCardArt(fullRes, card); preview->setPixmap(currentPixmap); preview->setParams(currentParams); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c87bec627..e25390ba0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -220,6 +220,18 @@ cockatrice_add_test( libcockatrice_deck_list libcockatrice_utility ) +cockatrice_add_test( + NAME + card_art_crop_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/cards/card_art_utils.cpp + ${VERSION_STRING_CPP} + card_art_crop_test.cpp + LIBS + libcockatrice_card + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) cockatrice_add_test( NAME server_rate_limiter_test diff --git a/tests/card_art_crop_test.cpp b/tests/card_art_crop_test.cpp new file mode 100644 index 000000000..1c4a9ff8d --- /dev/null +++ b/tests/card_art_crop_test.cpp @@ -0,0 +1,92 @@ +#include "interface/widgets/cards/card_art_utils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +ExactCard makeCard(bool landscape) +{ + CardInfo::UiAttributes attributes; + attributes.landscapeOrientation = landscape; + return ExactCard(CardInfo::newInstance("Artwork", "", false, {}, {}, {}, {}, attributes)); +} + +QPixmap landscapeCardWithFrame() +{ + // Normalized landscape frame: the title reaches 14% down the upright + // image, and the Battle type line starts at 56%. Artwork has two colors + // so the test also detects a wrong or missing rotation. + QImage upright(1400, 1000, QImage::Format_RGB32); + upright.fill(Qt::red); + QPainter painter(&upright); + painter.fillRect(QRect(70, 140, 1260, 420), Qt::green); + painter.fillRect(QRect(700, 140, 630, 420), Qt::blue); + painter.end(); + QTransform storedOrientation; + storedOrientation.rotate(-90); + return QPixmap::fromImage(upright.transformed(storedOrientation)); +} + +TEST(CardArtCrop, LandscapeCropExcludesNameAndRulesBoxesAndRotatesUpright) +{ + const QImage crop = CardArtUtils::cropCardArt(landscapeCardWithFrame(), makeCard(true)).toImage(); + ASSERT_GT(crop.width(), crop.height()); + ASSERT_FALSE(crop.isNull()); + for (int y = 0; y < crop.height(); ++y) { + for (int x = 0; x < crop.width(); ++x) { + EXPECT_EQ(x < crop.width() / 2 ? QColor(Qt::green) : QColor(Qt::blue), crop.pixelColor(x, y)); + } + } +} + +TEST(CardArtCrop, PortraitCropRetainsItsExistingArtworkBounds) +{ + QImage source(745, 1040, QImage::Format_RGB32); + for (int y = 0; y < source.height(); ++y) { + for (int x = 0; x < source.width(); ++x) { + source.setPixelColor(x, y, QColor(x % 256, y % 256, (x + y) % 256)); + } + } + const QPixmap input = QPixmap::fromImage(source); + const QImage crop = CardArtUtils::cropCardArt(input, makeCard(false)).toImage(); + EXPECT_EQ(source.copy(QRect(52, 114, 641, 458)), crop); +} + +TEST(CardArtCrop, DevicePixelRatioDoesNotChangeTheArtworkPixels) +{ + QPixmap source = landscapeCardWithFrame(); + const QPixmap normal = CardArtUtils::cropCardArt(source, makeCard(true)); + source.setDevicePixelRatio(2.0); + const QPixmap highDpi = CardArtUtils::cropCardArt(source, makeCard(true)); + EXPECT_EQ(normal.size(), highDpi.size()); + EXPECT_EQ(2.0, highDpi.devicePixelRatio()); + QImage normalPixels = normal.toImage(); + QImage highDpiPixels = highDpi.toImage(); + normalPixels.setDevicePixelRatio(1.0); + highDpiPixels.setDevicePixelRatio(1.0); + EXPECT_EQ(normalPixels, highDpiPixels); +} + +TEST(CardArtCrop, EmptyAndSmallImagesStayWithinTheirBounds) +{ + EXPECT_TRUE(CardArtUtils::cropCardArt({}, makeCard(true)).isNull()); + QPixmap small(1, 1); + small.fill(Qt::green); + EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(true)).toImage()); + EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(false)).toImage()); +} +} // namespace + +int main(int argc, char **argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 3a656c9c4afaf6a231529ca902499ab7a5592d74 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 11:33:49 -0500 Subject: [PATCH 042/104] Preserve installed card databases when Oracle refresh fails (#7739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Preserve installed card databases when Oracle refresh fails * Fix the Oracle refresh verdict and cover the parser write path A background refresh that already installed cards.xml exited 1 when the token save afterwards failed, so the client kept its old in-memory database and reported the update as failed. Return the same verdict main() already computes: a reject after the database was written is a successful update. Cover the writer Oracle actually uses: add a short-write death test for CockatriceXml4Parser::saveToFile, which reverted to QFile would truncate an installed database without any test noticing. Route a failed background token download through reject() instead of a modal box on a hidden wizard, which left the client waiting with no timeout and blocked later updates. Test the first non-space byte in place rather than copying the payload, and report an import refusal with the importer's own message. --------- Co-authored-by: Lukas Brübach --- .../card/database/parser/cockatrice_xml_3.cpp | 5 +- .../card/database/parser/cockatrice_xml_4.cpp | 5 +- oracle/CMakeLists.txt | 1 + oracle/src/database_writer.cpp | 52 +++++ oracle/src/database_writer.h | 17 ++ oracle/src/oracleimporter.cpp | 69 ++++-- oracle/src/oracleimporter.h | 22 ++ oracle/src/oraclewizard.cpp | 21 +- oracle/src/pages.cpp | 76 +++++-- oracle/src/pages.h | 1 + oracle/src/pagetemplates.cpp | 40 ++-- tests/carddatabase/carddatabase_test.cpp | 64 ++++++ tests/oracle/CMakeLists.txt | 1 + tests/oracle/oracle_importer_test.cpp | 215 ++++++++++++++++++ 14 files changed, 517 insertions(+), 72 deletions(-) create mode 100644 oracle/src/database_writer.cpp create mode 100644 oracle/src/database_writer.h diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index d40c1c063..8d5735db7 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -499,7 +500,7 @@ bool CockatriceXml3Parser::saveToFile(FormatRulesNameMap _formats, { Q_UNUSED(_formats); - QFile file(fileName); + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { return false; } @@ -539,5 +540,5 @@ bool CockatriceXml3Parser::saveToFile(FormatRulesNameMap _formats, xml.writeEndElement(); // cockatrice_carddatabase xml.writeEndDocument(); - return true; + return !xml.hasError() && file.commit(); } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 3ee1e808e..3965b80f0 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -682,7 +683,7 @@ bool CockatriceXml4Parser::saveToFile(FormatRulesNameMap _formats, const QString &sourceUrl, const QString &sourceVersion) { - QFile file(fileName); + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { return false; } @@ -730,5 +731,5 @@ bool CockatriceXml4Parser::saveToFile(FormatRulesNameMap _formats, xml.writeEndElement(); // cockatrice_carddatabase xml.writeEndDocument(); - return true; + return !xml.hasError() && file.commit(); } diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 587463572..f68f51f23 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -17,6 +17,7 @@ set(ORACLE_WIN32_QM_INSTALL_DIR "translations") # Sources # ------------------------ set(oracle_SOURCES + src/database_writer.cpp src/main.cpp src/oraclewizard.cpp src/oracleimporter.cpp diff --git a/oracle/src/database_writer.cpp b/oracle/src/database_writer.cpp new file mode 100644 index 000000000..e80aa853a --- /dev/null +++ b/oracle/src/database_writer.cpp @@ -0,0 +1,52 @@ +#include "database_writer.h" + +#include +#include +#include +#include + +namespace OracleDatabase +{ +QString validateXml(const QByteArray &data) +{ + QXmlStreamReader xml(data); + if (!xml.readNextStartElement() || xml.name() != QLatin1String("cockatrice_carddatabase")) { + return QCoreApplication::translate("OracleDatabase", "The download is not a Cockatrice card database."); + } + const auto version = xml.attributes().value("version"); + if (version != QLatin1String("3") && version != QLatin1String("4")) { + return QCoreApplication::translate("OracleDatabase", "The downloaded card database version is not supported."); + } + while (!xml.atEnd()) { + xml.readNext(); + } + if (xml.hasError()) { + return QCoreApplication::translate("OracleDatabase", + "The downloaded card database is incomplete or invalid: %1") + .arg(xml.errorString()); + } + return {}; +} + +bool saveXml(const QString &fileName, const QByteArray &data, QString *error) +{ + const QString validationError = validateXml(data); + if (!validationError.isEmpty()) { + if (error) { + *error = validationError; + } + return false; + } + QSaveFile file(fileName); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) { + if (error) { + *error = file.errorString(); + } + return false; + } + if (error) { + error->clear(); + } + return true; +} +} // namespace OracleDatabase diff --git a/oracle/src/database_writer.h b/oracle/src/database_writer.h new file mode 100644 index 000000000..173fd23ab --- /dev/null +++ b/oracle/src/database_writer.h @@ -0,0 +1,17 @@ +#ifndef ORACLE_DATABASE_WRITER_H +#define ORACLE_DATABASE_WRITER_H + +#include +#include + +namespace OracleDatabase +{ +// Validate the complete downloaded document without allocating a card database. +// Empty string means valid; otherwise returns a diagnostic suitable for the wizard. +QString validateXml(const QByteArray &data); +// Never truncate the installed database: validate, write a sibling temporary file, +// check the complete write and atomically commit it. +bool saveXml(const QString &fileName, const QByteArray &data, QString *error = nullptr); +} // namespace OracleDatabase + +#endif diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 477c54d21..c83c962e3 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -100,6 +100,10 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s bool OracleImporter::readSetsFromByteArray(QByteArray data) { + importSuccessful = false; + importError.clear(); + allSets.clear(); + rawSetsData.clear(); const RawJson::ScanProgressCallback progress = progressReporting ? [this]( @@ -764,7 +768,15 @@ int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); - importCancelled.storeRelease(0); + importSuccessful = false; + importError.clear(); + sets.clear(); + cards.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); + if (allSets.isEmpty()) { + return failImport(tr("No set has been selected for import.")); + } // Pre-allocate the cards hash to avoid rehashing during import. Keys are // distinct card names while raw ranges only count printings (AllPrintings @@ -786,9 +798,7 @@ int OracleImporter::startImport() for (const SetToDownload &curSetToParse : allSets) { if (importCancelled.loadAcquire()) { - // The wizard was closed mid-import: stop at the next set boundary so - // the caller can wait for this future without processing every set. - break; + return failImport(tr("The card database import was cancelled.")); } CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), @@ -798,30 +808,20 @@ int OracleImporter::startImport() // parse only this set's slice of the raw document so the whole JSON tree is // never kept in memory at once const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange(); - const qsizetype rangeEnd = rawRange.start + rawRange.length; - if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) { - // rawSetsData is cleared by releaseSetData() while SetToDownload copies - // taken from getSets() keep their ranges, and nothing else enforces the - // pairing — so never index past the buffer on stale/mismatched ranges. - qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping"; - ++setIndex; - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; + if (rawRange.start < 0 || rawRange.length <= 0 || rawRange.start > rawSetsData.size() || + rawRange.length > rawSetsData.size() - rawRange.start) { + return failImport(tr("The source data for set %1 is unavailable. Load the sets file again.") + .arg(curSetToParse.getShortName())); } - // sliced() shares the buffer instead of deep-copying the slice; the largest - // sets in AllPrintings are tens of MB, so the copy is worth avoiding here. const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length); QJsonParseError parseError; const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError); if (parseError.error != QJsonParseError::NoError) { - qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":" - << parseError.errorString(); - ++setIndex; - // Keep the progress accounting honest: a set that failed to parse - // still advanced the index, so report it (with zero imported cards) - // rather than letting SaveSetsPage's bar stall per failed set. - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; + return failImport( + tr("Could not import set %1: %2").arg(curSetToParse.getShortName(), parseError.errorString())); + } + if (!setDoc.isObject() || !setDoc.object().value("cards").isArray()) { + return failImport(tr("Set %1 does not contain a card list.").arg(curSetToParse.getShortName())); } // Only add the set to the database once its slice parsed cleanly; @@ -838,7 +838,14 @@ int OracleImporter::startImport() emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName()); } + if (importCancelled.loadAcquire()) { + return failImport(tr("The card database import was cancelled.")); + } + if (cards.isEmpty()) { + return failImport(tr("No cards were imported. The existing card database has been kept.")); + } applyLocalizedData(); + importSuccessful = true; emit setIndexChanged(0, setIndex, QString()); @@ -846,8 +853,23 @@ int OracleImporter::startImport() return setIndex; } +int OracleImporter::failImport(const QString &message) +{ + importSuccessful = false; + importError = message; + sets.clear(); + cards.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); + qWarning() << message; + return 0; +} + bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion) { + if (!importSuccessful) { + return false; + } CockatriceXml4Parser parser(new NoopCardSetPriorityController()); return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion); @@ -861,6 +883,7 @@ void OracleImporter::releaseSetData() void OracleImporter::clear() { + prepareImport(); sets.clear(); cards.clear(); allSets.clear(); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index d872e184e..5ebe9cf36 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -198,6 +198,11 @@ private: * the whole import (or racing it). */ QAtomicInt importCancelled; + bool importSuccessful = false; + QString importError; + + // Discard the incomplete in-memory database; the installed file is untouched. + int failImport(const QString &message); /** * The ISO-639 language code whose foreignData is imported; "en" by default. @@ -278,7 +283,24 @@ public: * the wizard can hand over its decompressed buffer without copying it. */ bool readSetsFromByteArray(QByteArray data); + // Returns the complete selected-set count on success, zero on failure or cancellation. int startImport(); + bool hasSuccessfulImport() const + { + return importSuccessful; + } + const QString &getImportError() const + { + return importError; + } + // Call on the owning thread before scheduling a worker, so a cancellation + // arriving before that worker starts is not lost. + void prepareImport() + { + importCancelled.storeRelease(0); + importSuccessful = false; + importError.clear(); + } /** * @brief Requests an in-flight startImport() to stop at the next set boundary. * diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index c33d821a5..dc8c7ecb3 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -1,6 +1,7 @@ #include "oraclewizard.h" #include "client/settings/cache_settings.h" +#include "database_writer.h" #include "main.h" #include "oracleimporter.h" #include "pages.h" @@ -141,6 +142,13 @@ void OracleWizard::reject() } QWizard::reject(); + // A hidden wizard does not close the application's last visible window. + // Stop background refreshes explicitly so the hosting client receives a verdict, + // using the same rule as main(): a reject after the database was written is still + // a successful update, only a reject that wrote nothing counts as a failure. + if (backgroundMode) { + QCoreApplication::exit(hasSavedDatabase() ? 0 : 1); + } } void OracleWizard::runInBackground() @@ -212,17 +220,10 @@ void OracleWizard::disableButtons() bool OracleWizard::saveTokensToFile(const QString &fileName) { - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qDebug() << "File open (w) failed for" << fileName; + QString error; + if (!OracleDatabase::saveXml(fileName, tokensData, &error)) { + qWarning() << error; return false; } - - if (file.write(tokensData) == -1) { - qDebug() << "File write (w) failed for" << fileName; - return false; - } - - file.close(); return true; } diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 4c664a38c..f5c38be1c 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -1,6 +1,7 @@ #include "pages.h" #include "client/settings/cache_settings.h" +#include "database_writer.h" #include "main.h" #include "oracleimporter.h" #include "oraclewizard.h" @@ -43,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -169,7 +171,16 @@ LoadSetsResult loadSetsData(const QPointer &importer, QByteArray break; } - if (data.startsWith("<")) { + // Test the first non-space byte in place: trimmed() would duplicate the whole + // payload, which on the plain-XML path is the largest download we handle. + const auto firstNonSpace = std::find_if_not(data.cbegin(), data.cend(), + [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }); + const bool looksLikeXml = firstNonSpace != data.cend() && *firstNonSpace == '<'; + if (looksLikeXml) { + result.errorMessage = OracleDatabase::validateXml(data); + if (!result.errorMessage.isEmpty()) { + return result; + } result.ok = true; result.plainXml = true; result.xmlData = std::move(data); @@ -651,6 +662,10 @@ void LoadSetsPage::importFinished() } else if (!result.ok && !result.plainXml) { qWarning() << tr("The file was retrieved successfully, but it does not contain any sets data."); } + if (!result.ok) { + wizard()->reject(); + return; + } emit readyToContinue(); return; } @@ -717,7 +732,7 @@ SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) bool SaveSetsPage::isComplete() const { - return !importActive; + return !importActive && importReady; } void SaveSetsPage::cleanupPage() @@ -730,8 +745,11 @@ void SaveSetsPage::cleanupPage() void SaveSetsPage::initializePage() { + importReady = false; retranslateUi(); if (wizard()->downloadedPlainXml) { + importReady = true; + emit completeChanged(); messageLog->hide(); progressBar->hide(); if (wizard()->backgroundMode) { @@ -757,6 +775,9 @@ void SaveSetsPage::initializePage() emit completeChanged(); const QPointer importer = wizard()->importer; + if (auto *activeImporter = importer.data()) { + activeImporter->prepareImport(); + } importFuture = QtConcurrent::run([importer] { return importer ? importer->startImport() : 0; }); importWatcher.setFuture(importFuture); } @@ -788,22 +809,28 @@ void SaveSetsPage::importFinished() const int setsImported = importWatcher.result(); const QPointer importer = wizard()->importer; + importReady = importer && importer->hasSuccessfulImport() && setsImported == totalSets; + const QString error = importer ? importer->getImportError() : tr("The card database importer is unavailable."); if (importer) { importer->releaseSetData(); } + wizard()->button(QWizard::NextButton)->setEnabled(importReady); + emit completeChanged(); - if (wizard()->backgroundMode) { - if (setsImported == 0) { - qWarning() << tr("No set has been imported."); + if (!importReady) { + const QString message = error.isEmpty() ? tr("No set has been imported.") : error; + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->reject(); + } else { + QMessageBox::critical(this, tr("Error"), message); } - emit readyToContinue(); return; } progressBar->setValue(progressBar->maximum()); - - if (setsImported == 0) { - QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); + if (wizard()->backgroundMode) { + emit readyToContinue(); } } @@ -854,6 +881,18 @@ void SaveSetsPage::updateTotalProgress(int cardsImported, int setIndex, const QS bool SaveSetsPage::validatePage() { + if (!isComplete()) { + return false; + } + const auto failSave = [this](const QString &message) { + qWarning() << message; + if (wizard()->backgroundMode) { + wizard()->reject(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; QString defaultPath = SettingsCache::instance().getCardDatabasePath(); QString windowName = tr("Save card database"); QString fileType = tr("XML; card database (*.xml)"); @@ -872,24 +911,21 @@ bool SaveSetsPage::validatePage() QFileInfo fi(fileName); QDir fileDir(fi.path()); if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { - return false; + return failSave(tr("The database directory could not be created: %1").arg(fileDir.absolutePath())); } if (wizard()->downloadedPlainXml) { - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qDebug() << "File write (w) failed for" << fileName; - return false; - } - if (file.write(wizard()->xmlData) < 1) { - qDebug() << "File write (w) failed for" << fileName; - return false; + QString error; + if (!OracleDatabase::saveXml(fileName, wizard()->xmlData, &error)) { + return failSave(tr("The file could not be saved to %1: %2").arg(fileName, error)); } wizard()->xmlData.clear(); } else if (!wizard()->importer->saveToFile(fileName, wizard()->getCardSourceUrl(), wizard()->getCardSourceVersion())) { - QMessageBox::critical(this, tr("Error"), tr("The file could not be saved to %1").arg(fileName)); - return false; + // The importer refuses to write a database whose import did not finish, and + // reports the reason there; do not dress an import failure up as a disk failure. + const QString importError = wizard()->importer->getImportError(); + return failSave(importError.isEmpty() ? tr("The file could not be saved to %1").arg(fileName) : importError); } wizard()->markDatabaseSaved(); diff --git a/oracle/src/pages.h b/oracle/src/pages.h index 0530010ba..e29a8e84a 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -128,6 +128,7 @@ private: QFuture importFuture; int totalSets = 0; bool importActive = false; + bool importReady = false; protected: void initializePage() override; diff --git a/oracle/src/pagetemplates.cpp b/oracle/src/pagetemplates.cpp index dcc8e070c..7e5f30bb7 100644 --- a/oracle/src/pagetemplates.cpp +++ b/oracle/src/pagetemplates.cpp @@ -1,5 +1,6 @@ #include "pagetemplates.h" +#include "database_writer.h" #include "oraclewizard.h" #include @@ -184,9 +185,17 @@ void SimpleDownloadFilePage::actDownloadFinished() } QNetworkReply::NetworkError errorCode = reply->error(); if (errorCode != QNetworkReply::NoError) { - QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString())); - wizard()->enableButtons(); + const QString message = tr("Network error: %1.").arg(reply->errorString()); reply->deleteLater(); + // A hidden wizard cannot show a modal box: without an explicit stop it would sit + // here forever and the hosting client would wait with no timeout. + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->reject(); + return; + } + QMessageBox::critical(this, tr("Error"), message); + wizard()->enableButtons(); return; } @@ -218,6 +227,15 @@ void SimpleDownloadFilePage::actDownloadFinished() bool SimpleDownloadFilePage::saveToFile() { + const auto failSave = [this](const QString &message) { + qWarning() << message; + if (wizard()->backgroundMode) { + wizard()->reject(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; QString defaultPath = getDefaultSavePath(); QString windowName = getWindowTitle(); QString fileType = getFileType(); @@ -236,12 +254,11 @@ bool SimpleDownloadFilePage::saveToFile() QFileInfo fi(fileName); QDir fileDir(fi.path()); if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { - return false; + return failSave(tr("The database directory could not be created: %1").arg(fileDir.absolutePath())); } if (!internalSaveToFile(fileName)) { - QMessageBox::critical(this, tr("Error"), tr("The file could not be saved to %1").arg(fileName)); - return false; + return failSave(tr("The file could not be saved to %1").arg(fileName)); } // clean saved downloadData @@ -252,17 +269,10 @@ bool SimpleDownloadFilePage::saveToFile() bool SimpleDownloadFilePage::internalSaveToFile(const QString &fileName) { - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qDebug() << "File open (w) failed for" << fileName; + QString error; + if (!OracleDatabase::saveXml(fileName, downloadData, &error)) { + qWarning() << error; return false; } - - if (file.write(downloadData) == -1) { - qDebug() << "File write (w) failed for" << fileName; - return false; - } - - file.close(); return true; } diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 22cbe9074..b54205f30 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -36,6 +36,12 @@ #include #include +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + namespace { @@ -204,6 +210,64 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) ASSERT_EQ(printings.first().getProperty("manacost"), "1R"); } +#ifdef Q_OS_UNIX +/** + * @brief A failed write keeps the previously installed card database intact. + * + * This is the writer Oracle uses to install a refreshed database, so the truncation + * bug has to be covered here and not only through OracleDatabase::saveXml. + */ +TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile) +{ + NoopCardSetPriorityController controller; + CardSetPtr set = + CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary); + + QHash props; + props["manacost"] = "1R"; + PrintingInfo printing(set, LazyPropertiesHash(props)); + SetToPrintingsMap setsInfo; + setsInfo["TST"].append(printing); + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + CardInfoPtr card = + CardInfo::newInstance("Lightning Bolt", QString(1024, 'x'), false, {}, {}, {}, setsInfo, attributes); + + SetNameMap sets; + sets.insert("TST", set); + CardNameMap cards; + cards.insert("Lightning Bolt", card); + + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + const QString fileName = tempDir.filePath("cards.xml"); + + QFile installed(fileName); + ASSERT_TRUE(installed.open(QIODevice::WriteOnly)); + ASSERT_EQ(installed.write("installed database"), 18); + installed.close(); + + // A real filesystem write failure, isolated in the death-test child so the + // process-wide file limit never affects other cases or the parent runner. + ASSERT_EXIT( + { + std::signal(SIGXFSZ, SIG_IGN); + struct rlimit limit; + limit.rlim_cur = 128; + limit.rlim_max = 128; + if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(2); + } + CockatriceXml4Parser writer(&controller); + _exit(writer.saveToFile({}, sets, cards, fileName) ? 1 : 0); + }, + ::testing::ExitedWithCode(0), ""); + + ASSERT_TRUE(installed.open(QIODevice::ReadOnly)); + EXPECT_EQ(installed.readAll(), "installed database"); +} +#endif + /** * @brief Path provider serving a sandboxed copy of the test card database. */ diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 6958673f7..1cc1ad952 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -9,6 +9,7 @@ cockatrice_add_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/database_writer.cpp oracle_importer_test.cpp LIBS libcockatrice_card diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 83d77d465..259bae087 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,3 +1,4 @@ +#include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" #include "libcockatrice/card/card_info.h" @@ -6,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +33,12 @@ #include #include +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + class OracleImporterTest : public ::testing::Test { protected: @@ -1140,6 +1149,212 @@ TEST_F(OracleImporterTest, StartImportAppliesLocalizedData) ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo."); } +namespace +{ +QByteArray refreshSource(const QString &name = "New Card") +{ + QJsonObject card{{"name", name}, {"text", "Rules text."}, {"layout", "normal"}, + {"type", "Artifact"}, {"number", "1"}, {"rarity", "common"}}; + QJsonObject set{{"code", "TST"}, {"name", "Test Set"}, {"type", "expansion"}, {"cards", QJsonArray{card}}}; + return QJsonDocument(QJsonObject{{"data", QJsonObject{{"TST", set}}}}).toJson(QJsonDocument::Compact); +} +} // namespace + +TEST_F(OracleImporterTest, FailedLazyParseKeepsInstalledDatabase) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + QFile original(path); + ASSERT_TRUE(original.open(QIODevice::WriteOnly)); + ASSERT_EQ(original.write("existing database"), 17); + original.close(); + + // The metadata scanner skips numeric values without converting them. Qt's + // per-set parser rejects an overflowing number after selection succeeds. + auto badSet = QJsonDocument::fromJson(refreshSource()).object().value("data").toObject().value("TST").toObject(); + auto goodSet = badSet; + goodSet["code"] = "AAA"; + goodSet["name"] = "A successful first set"; + auto badCard = badSet.value("cards").toArray().first().toObject(); + badCard["number"] = "overflow"; + badSet["cards"] = QJsonArray{badCard}; + QByteArray data = QJsonDocument(QJsonObject{{"data", QJsonObject{{"AAA", goodSet}, {"TST", badSet}}}}) + .toJson(QJsonDocument::Compact); + data.replace("\"number\":\"overflow\"", "\"number\":1e9999"); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_TRUE(importer->getImportError().contains("TST")); + EXPECT_TRUE(importer->getCardList().isEmpty()); + EXPECT_FALSE(importer->saveToFile(path, {}, {})); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); + EXPECT_EQ(original.readAll(), "existing database"); +} + +TEST_F(OracleImporterTest, EmptyImportCannotReplaceInstalledCards) +{ + const QByteArray source = R"({"data":{"TST":{"code":"TST","name":"Test","type":"expansion","cards":[]}}})"; + ASSERT_TRUE(importer->readSetsFromByteArray(source)); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_FALSE(importer->getImportError().isEmpty()); +} + +TEST_F(OracleImporterTest, MissingSourceBytesCannotProduceSuccessfulImport) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + const auto selected = importer->getSets(); + importer->releaseSetData(); + importer->getSets() = selected; + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_TRUE(importer->getImportError().contains("TST")); +} + +TEST_F(OracleImporterTest, CancellationDuringFinalSetCannotSavePartialDatabase) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + QObject::connect( + importer, &OracleImporter::setIndexChanged, importer, + [this](int, int, const QString &name) { + if (!name.isEmpty()) { + importer->cancelImport(); + } + }, + Qt::DirectConnection); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_TRUE(importer->getCardList().isEmpty()); +} + +TEST_F(OracleImporterTest, CancellationBeforeWorkerStartsIsNotLost) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + importer->prepareImport(); + importer->cancelImport(); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + importer->prepareImport(); + EXPECT_EQ(importer->startImport(), 1); + EXPECT_TRUE(importer->hasSuccessfulImport()); +} + +TEST_F(OracleImporterTest, NewImportReplacesPreviousInMemoryCards) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("Old Card"))); + ASSERT_EQ(importer->startImport(), 1); + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("New Card"))); + EXPECT_FALSE(importer->hasSuccessfulImport()); + ASSERT_EQ(importer->startImport(), 1); + EXPECT_EQ(importer->getCardList().size(), 1); + EXPECT_FALSE(importer->getCardList().contains("Old Card")); + EXPECT_TRUE(importer->getCardList().contains("New Card")); +} + +TEST_F(OracleImporterTest, FailedSourceLoadCannotReusePreviousSuccessfulImport) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + ASSERT_EQ(importer->startImport(), 1); + ASSERT_FALSE(importer->readSetsFromByteArray("invalid JSON")); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_EQ(importer->startImport(), 0); +} + +TEST_F(OracleImporterTest, SuccessfulRefreshCommitsCompleteXml) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + ASSERT_EQ(importer->startImport(), 1); + importer->releaseSetData(); + ASSERT_TRUE(importer->saveToFile(path, "test-source", "v1")); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + const QByteArray xml = file.readAll(); + EXPECT_TRUE(xml.contains("New Card")); + EXPECT_TRUE(xml.contains("test-source")); + EXPECT_TRUE(xml.contains("")); +} + +TEST(OracleDatabaseWriter, AcceptsCompleteSupportedVersions) +{ + EXPECT_TRUE(OracleDatabase::validateXml( + "") + .isEmpty()); + EXPECT_TRUE(OracleDatabase::validateXml("") + .isEmpty()); + EXPECT_FALSE( + OracleDatabase::validateXml("") + .isEmpty()); +} + +TEST(OracleDatabaseWriter, InvalidDownloadPreservesInstalledBytes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("tokens.xml"); + QFile original(path); + ASSERT_TRUE(original.open(QIODevice::WriteOnly)); + ASSERT_EQ(original.write("installed tokens"), 16); + original.close(); + for (const QByteArray &payload : {QByteArray("server error"), + QByteArray(""), QByteArray()}) { + QString error; + EXPECT_FALSE(OracleDatabase::saveXml(path, payload, &error)); + EXPECT_FALSE(error.isEmpty()); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); + EXPECT_EQ(original.readAll(), "installed tokens"); + original.close(); + } +} + +TEST(OracleDatabaseWriter, CompleteDownloadReplacesInstalledBytes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + const QByteArray data(""); + QString error = "previous failure"; + ASSERT_TRUE(OracleDatabase::saveXml(path, data, &error)); + EXPECT_TRUE(error.isEmpty()); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + EXPECT_EQ(file.readAll(), data); +} + +#ifdef Q_OS_UNIX +TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + QFile original(path); + ASSERT_TRUE(original.open(QIODevice::WriteOnly)); + ASSERT_EQ(original.write("installed database"), 18); + original.close(); + const QByteArray data = ""; + // A real filesystem write failure, isolated in the death-test child so the + // process-wide file limit never affects other cases or the parent runner. + ASSERT_EXIT( + { + std::signal(SIGXFSZ, SIG_IGN); + struct rlimit limit; + limit.rlim_cur = 128; + limit.rlim_max = 128; + if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(2); + } + _exit(OracleDatabase::saveXml(path, data) ? 1 : 0); + }, + ::testing::ExitedWithCode(0), ""); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); + EXPECT_EQ(original.readAll(), "installed database"); +} +#endif + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); From 0de92c06f870d7e7175967db616657399c69bf49 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Wed, 4 Nov 2026 12:24:19 -0500 Subject: [PATCH 043/104] [Spoilers] Preserve spoiler files through update and reload failures (#7707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Preserve spoiler files and serialize update reloads * Surface spoiler removal reload failures and allow disabled-state retry * Address review feedback on spoiler update lifecycle Only remove the spoiler file when the user unchecks the setting; opening Settings no longer queues an unrequested reload for a leftover file. Remove the spoilersUpdatedSuccessfully signal, which had no receiver. * Fix spoiler settings state after reopen and while disabled Reopen Settings while an application-owned update is running: the new page now observes that updater, so the button reads Updating... and re-enables on completion instead of offering a second update that cancels the first. Observation does not take ownership: only updaters the page creates are retired with deleteLater, because the launch check is stack-allocated. When spoilers are off, the hint now describes what Reload Card Database actually does, stays readable, and wraps instead of clipping. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../spoiler_background_updater.cpp | 448 ++++++++++-------- .../card_spoiler/spoiler_background_updater.h | 46 +- .../update/card_spoiler/spoiler_file.cpp | 109 +++++ .../update/card_spoiler/spoiler_file.h | 24 + .../deck_editor_settings_page.cpp | 86 +++- .../settings_page/deck_editor_settings_page.h | 5 + tests/CMakeLists.txt | 9 + tests/spoiler_file_test.cpp | 149 ++++++ 9 files changed, 648 insertions(+), 229 deletions(-) create mode 100644 cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp create mode 100644 cockatrice/src/client/network/update/card_spoiler/spoiler_file.h create mode 100644 tests/spoiler_file_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b18846079..b76783167 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -15,6 +15,7 @@ set(cockatrice_SOURCES src/client/network/update/client/client_update_checker.cpp src/client/network/update/client/release_channel.cpp src/client/network/update/card_spoiler/spoiler_background_updater.cpp + src/client/network/update/card_spoiler/spoiler_file.cpp src/client/crash/crash_reporter.cpp src/client/latency_graph_widget.cpp src/client/latency_status_widget.cpp diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index 62668c4d9..d74414d4a 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -2,253 +2,305 @@ #include "../../../../main.h" #include "../../../settings/cache_settings.h" +#include "spoiler_file.h" -#include -#include -#include +#include #include -#include #include #include #include -#include -#include -#include -#include #include #include #include -#include #include -#include +#include #include #include -#include -#include +#include #include #include #include #include #include -#include -#include #include -#define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled" -#define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml" - -SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr) +namespace { - isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus(); - if (isSpoilerDownloadEnabled) { - // Start the process of checking if we're in spoiler season - // File exists means we're in spoiler season - startSpoilerDownloadProcess(SPOILERS_STATUS_URL, false); +const QUrl SPOILERS_STATUS_URL( + QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled")); +const QUrl SPOILERS_URL(QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml")); +// Request coordination stays on the GUI thread; reload workers never access it. +QPointer currentUpdater; +QPointer> activeReload; +quint64 newestOperation = 0; + +bool removeSpoilerFile(const QString &path) +{ + QFile file(path); + if (!file.exists()) { + return false; + } + if (!file.remove()) { + qCWarning(SpoilerBackgroundUpdaterLog) << "Could not remove spoiler database" << path << file.errorString(); + return false; + } + return true; +} +} // namespace + +SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *parent, bool forceReload) + : QObject(parent), enabledAtStart(SettingsCache::instance().downloads().getDownloadSpoilersStatus()), + forceReload(forceReload), targetPath(SettingsCache::instance().paths().getSpoilerCardDatabasePath()), + operationId(++newestOperation), manager(NetworkManagerFactory::create(this)), notificationIcon(trayIcon) +{ + connect(&SettingsCache::instance().downloads(), &DownloadSettings::downloadSpoilerStatusChanged, this, + &SpoilerBackgroundUpdater::cancelIfSettingsChanged); + connect(&SettingsCache::instance().paths(), &PathsSettings::cardDatabasePathChanged, this, + &SpoilerBackgroundUpdater::cancelIfSettingsChanged); + // Even a disabled/no-op operation completes after its caller can connect signals. + QTimer::singleShot(0, this, &SpoilerBackgroundUpdater::begin); +} + +void SpoilerBackgroundUpdater::begin() +{ + if (completed) { + return; + } + if (cancelled || operationId != newestOperation || + (reloadOnly ? SettingsCache::instance().downloads().getDownloadSpoilersStatus() + : (!enabledAtStart || !SettingsCache::instance().downloads().getDownloadSpoilersStatus())) || + targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) { + finish(); + return; + } + const auto previous = currentUpdater; + if (activeReload) { + // The existing database API stores its final load status after its parser + // mutex is released. Do not overlap updater-owned calls to that API. + connect(activeReload, &QFutureWatcher::finished, this, &SpoilerBackgroundUpdater::begin, + static_cast(Qt::QueuedConnection | Qt::SingleShotConnection)); + if (previous && previous != this) { + previous->cancel(); + } + return; + } + currentUpdater = this; + QPointer guard(this); + if (previous && previous != this) { + previous->cancel(); + } + if (!guard) { + return; + } + if (!isCurrent()) { + finish(); + } else if (reloadOnly) { + if (QFileInfo::exists(targetPath) && !removeSpoilerFile(targetPath)) { + finish(tr("The spoiler database could not be removed. Check its file permissions and use Reload Card " + "Database to retry.")); + return; + } + reloadDatabase(false); } else { - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Disabled"; + downloadFromURL(SPOILERS_STATUS_URL, false); } } -void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool saveResults) +bool SpoilerBackgroundUpdater::isCurrent() const { - auto spoilerURL = QUrl(url); - downloadFromURL(spoilerURL, saveResults); + return !completed && !cancelled && currentUpdater == this && operationId == newestOperation && + (reloadOnly != SettingsCache::instance().downloads().getDownloadSpoilersStatus()) && + targetPath == SettingsCache::instance().paths().getSpoilerCardDatabasePath(); } -void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults) +void SpoilerBackgroundUpdater::cancelIfSettingsChanged() { - auto *nam = NetworkManagerFactory::create(this); - auto request = QNetworkRequest(url); - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - QNetworkReply *reply = nam->get(request); + if (reloadOnly == SettingsCache::instance().downloads().getDownloadSpoilersStatus() || + targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) { + cancel(); + } +} +void SpoilerBackgroundUpdater::cancel() +{ + if (completed) { + return; + } + cancelled = true; + const auto reply = activeReply; + activeReply.clear(); + QPointer guard(this); + if (reply) { + reply->abort(); + if (reply) { + reply->deleteLater(); + } + } + // A running reload owns no updater state. Allow its completion to retire its + // watcher before a heap-owned updater is released by the caller. + if (guard && !reloading) { + finish(); + } +} + +void SpoilerBackgroundUpdater::downloadFromURL(const QUrl &url, bool saveResults) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + request.setTransferTimeout(60000); + activeReply = manager->get(request); if (saveResults) { - // This will write out to the file (used for spoiler.xml) - connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile); + connect(activeReply, &QNetworkReply::finished, this, + &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile); } else { - // This will check the status (used to see if we're in spoiler season or not) - connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled); + connect(activeReply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled); + } +} + +void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled() +{ + auto *reply = qobject_cast(sender()); + if (!reply || reply != activeReply) { + return; + } + activeReply.clear(); + reply->deleteLater(); + if (!isCurrent()) { + finish(); + return; + } + if (reply->error() == QNetworkReply::NoError) { + downloadFromURL(SPOILERS_URL, true); + } else if (reply->error() == QNetworkReply::ContentNotFoundError) { + const bool existed = QFileInfo::exists(targetPath); + seasonEnded = existed; + if (existed && !removeSpoilerFile(targetPath)) { + finish(tr("The spoiler season has ended, but the old spoiler database could not be removed.")); + return; + } + if (existed || forceReload) { + reloadDatabase(false); + } else { + finish(); + } + } else { + finish(tr("Could not check spoiler availability: %1").arg(reply->errorString())); } } void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile() { - // Check for server reply - auto *reply = dynamic_cast(sender()); - QNetworkReply::NetworkError errorCode = reply->error(); - - if (errorCode == QNetworkReply::NoError) { - spoilerData = reply->readAll(); - - // Save the spoiler.xml file to the disk - saveDownloadedFile(spoilerData); - - reply->deleteLater(); - emit spoilerCheckerDone(); + auto *reply = qobject_cast(sender()); + if (!reply || reply != activeReply) { + return; + } + activeReply.clear(); + reply->deleteLater(); + if (!isCurrent()) { + finish(); + return; + } + if (reply->error() != QNetworkReply::NoError) { + finish(tr("Could not download spoilers: %1").arg(reply->errorString())); + return; + } + spoilerData = reply->readAll(); + QString error; + const auto result = SpoilerFile::save(targetPath, spoilerData, [this] { return isCurrent(); }, error); + if (result == SpoilerFile::SaveResult::Failed) { + finish(isCurrent() ? error : QString()); + } else if (result == SpoilerFile::SaveResult::Written || forceReload) { + reloadDatabase(result == SpoilerFile::SaveResult::Written); } else { - qCWarning(SpoilerBackgroundUpdaterLog) << "Error downloading spoilers file" << errorCode; - emit spoilerCheckerDone(); + finish(); } } -bool SpoilerBackgroundUpdater::deleteSpoilerFile() +void SpoilerBackgroundUpdater::reloadDatabase(bool updated) { - QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); - QFileInfo fi(fileName); - QDir fileDir(fi.path()); - QFile file(fileName); - - // Delete the spoiler.xml file - if (file.exists() && file.remove()) { - qCInfo(SpoilerBackgroundUpdaterLog) << "Deleting spoiler.xml"; - return true; - } - - qCInfo(SpoilerBackgroundUpdaterLog) << "Error: Spoiler.xml not found or not deleted"; - return false; -} - -void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled() -{ - auto *response = dynamic_cast(sender()); - QNetworkReply::NetworkError errorCode = response->error(); - - if (errorCode == QNetworkReply::ContentNotFoundError) { - // Spoiler season is offline at this point, so the spoiler.xml file can be safely deleted - // The user should run Oracle to get the latest card information - if (deleteSpoilerFile() && trayIcon) { - trayIcon->showMessage(tr("Spoilers season has ended"), tr("Deleting spoiler.xml. Please run Oracle")); + reloading = true; + auto *database = CardDatabaseManager::getInstance(); + // The job outlives an individual request, so destroying that request cannot + // let a newer updater overlap the worker that is still completing. + auto *watcher = new QFutureWatcher(QCoreApplication::instance()); + activeReload = watcher; + connect(watcher, &QFutureWatcher::finished, watcher, [watcher] { + if (activeReload == watcher) { + activeReload.clear(); } - - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Season Offline"; - emit spoilerCheckerDone(); - } else if (errorCode == QNetworkReply::NoError) { - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Online"; - startSpoilerDownloadProcess(SPOILERS_URL, true); - } else if (errorCode == QNetworkReply::HostNotFoundError) { - if (trayIcon) { - trayIcon->showMessage(tr("Spoilers download failed"), tr("No internet connection")); - } - - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed due to no internet connection"; - emit spoilerCheckerDone(); - } else { - if (trayIcon) { - trayIcon->showMessage(tr("Spoilers download failed"), tr("Error") + " " + (short)errorCode); - } - - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed with reason" << errorCode; - emit spoilerCheckerDone(); - } -} - -bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data) -{ - QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); - QFileInfo fi(fileName); - QDir fileDir(fi.path()); - - if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { - return false; - } - - // Check if the data matches. If it does, then spoilers are up to date. - if (getHash(fileName) == getHash(data)) { - if (trayIcon) { - trayIcon->showMessage(tr("Spoilers already up to date"), tr("No new spoilers added")); - } - - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Up to Date"; - return false; - } - - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File open (w) failed for" << fileName; - file.close(); - return false; - } - - if (file.write(data) == -1) { - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File write (w) failed for" << fileName; - file.close(); - return false; - } - - file.close(); - - // Data written, so reload the card database - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written"; - // Spoilers can introduce sets the config has never seen; the loader never - // runs checkUnknownSets() itself, so run it once the finished snapshot has - // been swapped in. - auto *watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [watcher] { watcher->deleteLater(); - CardDatabaseManager::getInstance()->checkUnknownSets(); }); - watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); })); - - // If the user has notifications enabled, let them know - // when the database was last updated - if (trayIcon) { - QList lines = data.split('\n'); - - for (const QByteArray &line : lines) { - if (line.contains("Created At:")) { - QString timeStamp = QString(line).replace("Created At:", "").trimmed(); - timeStamp.chop(6); // Remove " (UTC)" - - auto utcTime = QLocale().toDateTime(timeStamp, "ddd, MMM dd yyyy, hh:mm:ss"); -#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) - utcTime.setTimeZone(QTimeZone::UTC); -#else - utcTime.setTimeSpec(Qt::UTC); -#endif - - QString localTime = utcTime.toLocalTime().toString("MMM d, hh:mm"); - - trayIcon->showMessage(tr("Spoilers have been updated!"), tr("Last change:") + " " + localTime); - emit spoilersUpdatedSuccessfully(); - return true; - } + connect(watcher, &QFutureWatcher::finished, this, [this, watcher, database, updated] { + bool loaded = false; + try { + loaded = watcher->result() && database->getLoadStatus() == Ok; + } catch (const std::exception &error) { + qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler database reload failed:" << error.what(); } - } - - return true; + reloading = false; + if (!isCurrent()) { + finish(); + } else if (!loaded) { + finish(reloadOnly + ? tr("Could not reload card data after disabling spoilers. Use Reload Card Database to retry.") + : tr("Could not reload card data after updating spoilers. Use Update Spoilers to retry the " + "reload.")); + } else { + finish({}, updated); + } + }); + watcher->setFuture(QtConcurrent::run([database] { + database->reloadCardDatabasesAndNotify(); + return true; + })); } -QByteArray SpoilerBackgroundUpdater::getHash(const QString fileName) +void SpoilerBackgroundUpdater::finish(const QString &error, bool updated) { - QFile file(fileName); - - if (file.open(QFile::ReadOnly)) { - // Only read the first 512 bytes (enough to get the "created" tag) - const QByteArray bytes = file.read(512); - - QCryptographicHash hash(QCryptographicHash::Algorithm::Md5); - hash.addData(bytes); - - qCInfo(SpoilerBackgroundUpdaterLog) << "File Hash =" << hash.result(); - - file.close(); - return hash.result(); - } else { - qCWarning(SpoilerBackgroundUpdaterLog) << "getHash ReadOnly failed!"; - file.close(); - return QByteArray(); + if (completed) { + return; + } + completed = true; + spoilerData = QByteArray(); + if (currentUpdater == this) { + currentUpdater.clear(); + } + QPointer guard(this); + if (!error.isEmpty() && !cancelled) { + qCWarning(SpoilerBackgroundUpdaterLog) << error; + if (notificationIcon) { + notificationIcon->showMessage(tr("Spoiler update failed"), error); + } + if (!guard) { + return; + } + emit spoilerUpdateFailed(error); + } else if (updated && !cancelled) { + if (notificationIcon) { + notificationIcon->showMessage(tr("Spoilers have been updated!"), + tr("The card database now includes the downloaded spoilers.")); + } + } else if (seasonEnded && !cancelled && notificationIcon) { + notificationIcon->showMessage(tr("Spoiler season has ended"), + tr("The spoiler database was removed. Please run Oracle to get released cards.")); + } + if (guard) { + emit spoilerCheckerDone(); } } -QByteArray SpoilerBackgroundUpdater::getHash(QByteArray data) +SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::current() { - // Only read the first 512 bytes (enough to get the "created" tag) - const QByteArray bytes = data.left(512); - - QCryptographicHash hash(QCryptographicHash::Algorithm::Md5); - hash.addData(bytes); - - qCInfo(SpoilerBackgroundUpdaterLog) << "Data Hash =" << hash.result(); - - return hash.result(); + return currentUpdater; +} + +SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::deleteSpoilerFile() +{ + // Deletion and the following reload share the operation lifetime. Callers can + // observe either failure before the queued operation starts, and an explicit + // retry still reloads when the file has already been removed. + auto *updater = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true); + updater->reloadOnly = true; + connect(updater, &SpoilerBackgroundUpdater::spoilerCheckerDone, updater, &QObject::deleteLater); + return updater; } diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h index a6c4ed00f..fd90eb688 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h @@ -2,7 +2,6 @@ * @file spoiler_background_updater.h * @ingroup Client */ -//! \todo Document this file. #ifndef COCKATRICE_SPOILER_DOWNLOADER_H #define COCKATRICE_SPOILER_DOWNLOADER_H @@ -10,10 +9,13 @@ #include #include #include +#include #include #include -class QProcess; +class QNetworkAccessManager; +class QNetworkReply; +class QSystemTrayIcon; class QUrl; inline Q_LOGGING_CATEGORY(SpoilerBackgroundUpdaterLog, "spoiler_background_updater"); @@ -22,30 +24,48 @@ class SpoilerBackgroundUpdater : public QObject { Q_OBJECT public: - explicit SpoilerBackgroundUpdater(QObject *apParent = nullptr); + explicit SpoilerBackgroundUpdater(QObject *parent = nullptr, bool forceReload = false); inline QString getCardUpdaterBinaryName() { return "oracle"; } - QByteArray getHash(const QString fileName); - QByteArray getHash(QByteArray data); - static bool deleteSpoilerFile(); + // Queues removal and reload; the application owns the returned updater and + // deletes it on completion. Callers can connect its failure/completion signals. + static SpoilerBackgroundUpdater *deleteSpoilerFile(); + // The operation currently running, if any. The updater outlives the page that + // started it, so a reopened page uses this to observe the in-flight update. + static SpoilerBackgroundUpdater *current(); private slots: + void begin(); void actDownloadFinishedSpoilersFile(); void actCheckIfSpoilerSeasonEnabled(); + void cancelIfSettingsChanged(); private: - bool isSpoilerDownloadEnabled; - QProcess *cardUpdateProcess; + const bool enabledAtStart; + const bool forceReload; + const QString targetPath; + const quint64 operationId; + QNetworkAccessManager *manager; + QPointer activeReply; + QPointer notificationIcon; QByteArray spoilerData; - void startSpoilerDownloadProcess(QString url, bool saveResults); - void downloadFromURL(QUrl url, bool saveResults); - bool saveDownloadedFile(QByteArray data); + bool completed = false; + bool cancelled = false; + bool reloading = false; + bool seasonEnded = false; + bool reloadOnly = false; + + bool isCurrent() const; + void cancel(); + void finish(const QString &error = {}, bool updated = false); + void downloadFromURL(const QUrl &url, bool saveResults); + void reloadDatabase(bool updated); signals: - void spoilersUpdatedSuccessfully(); void spoilerCheckerDone(); + void spoilerUpdateFailed(const QString &message); }; -#endif // COCKATRICE_SPOILER_DOWNLOADER_H \ No newline at end of file +#endif // COCKATRICE_SPOILER_DOWNLOADER_H diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp new file mode 100644 index 000000000..554ea1108 --- /dev/null +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp @@ -0,0 +1,109 @@ +#include "spoiler_file.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QByteArray hashFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + QCryptographicHash hash(QCryptographicHash::Sha256); + return hash.addData(&file) ? hash.result() : QByteArray(); +} +} // namespace + +namespace SpoilerFile +{ +QString validate(const QByteArray &data) +{ + QXmlStreamReader xml(data); + if (!xml.readNextStartElement() || xml.name() != QStringLiteral("cockatrice_carddatabase")) { + return QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "The download is not a Cockatrice card database. The previous spoiler file was preserved."); + } + bool versionOk = false; + const int version = xml.attributes().value(QStringLiteral("version")).toInt(&versionOk); + if (!versionOk || (version != 3 && version != 4)) { + return QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "The spoiler database uses an unsupported format. The previous spoiler file was preserved."); + } + bool hasCards = false; + int depth = 1; + while (!xml.atEnd()) { + const auto token = xml.readNext(); + if (token == QXmlStreamReader::StartElement) { + ++depth; + if (depth == 2 && xml.name() == QStringLiteral("cards")) { + hasCards = true; + } + } else if (token == QXmlStreamReader::EndElement) { + --depth; + } + } + if (xml.hasError() || !hasCards) { + return QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "The spoiler database is incomplete or invalid. The previous spoiler file was preserved."); + } + return {}; +} + +SaveResult +save(const QString &targetPath, const QByteArray &data, const std::function &isCurrent, QString &error) +{ + error = validate(data); + if (!error.isEmpty()) { + return SaveResult::Failed; + } + if (!isCurrent()) { + error = QCoreApplication::translate("SpoilerBackgroundUpdater", + "The spoiler update was cancelled because its settings changed."); + return SaveResult::Failed; + } + if (hashFile(targetPath) == QCryptographicHash::hash(data, QCryptographicHash::Sha256)) { + return SaveResult::Unchanged; + } + const QDir directory = QFileInfo(targetPath).dir(); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) { + error = QCoreApplication::translate("SpoilerBackgroundUpdater", + "Could not create the spoiler database directory: %1") + .arg(directory.absolutePath()); + return SaveResult::Failed; + } + QSaveFile file(targetPath); + file.setDirectWriteFallback(false); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush()) { + error = QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "Could not save the spoiler database to %1: %2. The previous file was preserved.") + .arg(targetPath, file.errorString()); + return SaveResult::Failed; + } + if (!isCurrent()) { + file.cancelWriting(); + error = QCoreApplication::translate("SpoilerBackgroundUpdater", + "The spoiler update was cancelled because its settings changed."); + return SaveResult::Failed; + } + if (!file.commit()) { + error = QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "Could not replace the spoiler database at %1: %2. The previous file was preserved.") + .arg(targetPath, file.errorString()); + return SaveResult::Failed; + } + return SaveResult::Written; +} + +} // namespace SpoilerFile diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h new file mode 100644 index 000000000..3a138cdea --- /dev/null +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h @@ -0,0 +1,24 @@ +#ifndef COCKATRICE_SPOILER_FILE_H +#define COCKATRICE_SPOILER_FILE_H + +#include +#include +#include + +namespace SpoilerFile +{ +enum class SaveResult +{ + Failed, + Unchanged, + Written +}; + +QString validate(const QByteArray &data); +// Validate before opening the destination, compare complete files, and replace +// atomically. Check operation ownership both before writing and before committing. +SaveResult +save(const QString &targetPath, const QByteArray &data, const std::function &isCurrent, QString &error); +} // namespace SpoilerFile + +#endif // COCKATRICE_SPOILER_FILE_H diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 31178ba4c..79370f512 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -61,7 +62,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() connect(mpSpoilerPathButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::spoilerPathButtonClicked); updateNowButton = new QPushButton; - updateNowButton->setFixedWidth(150); + updateNowButton->setMinimumWidth(150); connect(updateNowButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::updateSpoilers); // Update the GUI depending on if the box is ticked or not @@ -120,12 +121,19 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() lpSpoilerGrid->addWidget(&lastUpdatedLabel, 2, 0); lpSpoilerGrid->addWidget(updateNowButton, 2, 1); lpSpoilerGrid->addWidget(&infoOnSpoilersLabel, 3, 0, 1, 3, Qt::AlignTop); + infoOnSpoilersLabel.setWordWrap(true); // On a change to the checkbox, hide/un-hide the other fields connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, &SettingsCache::instance().downloads(), &DownloadSettings::setDownloadSpoilerStatus); connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, this, &DeckEditorSettingsPage::setSpoilersEnabled); + // The updater is application-owned and outlives this page, so a Settings page + // reopened mid-update must observe the operation that is still running. + if (auto *runningUpdater = SpoilerBackgroundUpdater::current()) { + observeSpoilerUpdater(runningUpdater); + } + mpGeneralGroupBox = new QGroupBox; mpGeneralGroupBox->setLayout(lpGeneralGrid); @@ -328,20 +336,60 @@ void DeckEditorSettingsPage::urlListChanged(const QModelIndex &, int, int, const void DeckEditorSettingsPage::updateSpoilers() { - // Disable the button so the user can only press it once at a time - updateNowButton->setDisabled(true); - updateNowButton->setText(tr("Updating...")); + if (spoilerUpdater) { + return; + } + // Application ownership lets either operation finish after settings closes. + // When disabled, the same button retries file removal and the local reload. + SpoilerBackgroundUpdater *sbu; + if (SettingsCache::instance().downloads().getDownloadSpoilersStatus()) { + sbu = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true); + // Only an updater this page creates is retired by deleteLater: observation + // may attach to the stack-allocated launch updater, which must not be deleted. + connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, sbu, &QObject::deleteLater); + } else { + // deleteSpoilerFile() wires its own deleteLater. + sbu = SpoilerBackgroundUpdater::deleteSpoilerFile(); + } + observeSpoilerUpdater(sbu); +} - // Create a new SBU that will act as if the client was just reloaded - auto *sbu = new SpoilerBackgroundUpdater(); - connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, this, &DeckEditorSettingsPage::unlockSettings); - connect(sbu, &SpoilerBackgroundUpdater::spoilersUpdatedSuccessfully, this, &DeckEditorSettingsPage::unlockSettings); +void DeckEditorSettingsPage::observeSpoilerUpdater(SpoilerBackgroundUpdater *sbu) +{ + spoilerUpdater = sbu; + unlockSettings(); + connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, this, [this, sbu] { + if (spoilerUpdater == sbu) { + spoilerUpdater.clear(); + unlockSettings(); + } + }); + connect(sbu, &SpoilerBackgroundUpdater::spoilerUpdateFailed, this, [this, sbu](const QString &message) { + if (spoilerUpdater != sbu) { + return; + } + auto *notice = new QMessageBox(QMessageBox::Warning, tr("Spoiler update"), message, QMessageBox::Ok, this); + notice->setTextFormat(Qt::PlainText); + notice->setAttribute(Qt::WA_DeleteOnClose); + notice->open(); + }); } void DeckEditorSettingsPage::unlockSettings() { - updateNowButton->setDisabled(false); - updateNowButton->setText(tr("Update Spoilers")); + updateNowButton->setEnabled(!spoilerUpdater); + updateNowButton->setText(spoilerUpdater ? tr("Updating...") + : SettingsCache::instance().downloads().getDownloadSpoilersStatus() + ? tr("Update Spoilers") + : tr("Reload Card Database")); + lastUpdatedLabel.setText(tr("Last Change") + ": " + getLastUpdateTime()); + infoOnSpoilersLabel.setText( + SettingsCache::instance().downloads().getDownloadSpoilersStatus() + ? tr("Spoilers download automatically on launch") + "\n" + + tr("Press the button to manually update without relaunching") + "\n\n" + + tr("Updates continue if you close settings") + : tr("Spoilers are off. Reload Card Database removes any downloaded spoiler file and reloads your " + "card data.")); } QString DeckEditorSettingsPage::getLastUpdateTime() @@ -379,12 +427,17 @@ void DeckEditorSettingsPage::setSpoilersEnabled(bool anInput) mpSpoilerSavePathLineEdit->setEnabled(anInput); mpSpoilerPathButton->setEnabled(anInput); lastUpdatedLabel.setEnabled(anInput); - updateNowButton->setEnabled(anInput); - infoOnSpoilersLabel.setEnabled(anInput); + // The hint explains the button in both states, especially the disabled-state + // Reload Card Database action, so keep it readable while spoilers are off. + infoOnSpoilersLabel.setEnabled(true); - if (!anInput) { - SpoilerBackgroundUpdater::deleteSpoilerFile(); + // Only react to the user disabling the setting. The constructor calls this to + // initialize the controls, and a leftover spoiler file is recovered through the + // Reload Card Database button rather than an unrequested reload on page open. + if (!anInput && sender() == &mcDownloadSpoilersCheckBox) { + observeSpoilerUpdater(SpoilerBackgroundUpdater::deleteSpoilerFile()); } + unlockSettings(); } void DeckEditorSettingsPage::retranslateUi() @@ -394,13 +447,10 @@ void DeckEditorSettingsPage::retranslateUi() mcDownloadSpoilersCheckBox.setText(tr("Download Spoilers Automatically")); mcSpoilerSaveLabel.setText(tr("Spoiler Location:")); lastUpdatedLabel.setText(tr("Last Change") + ": " + getLastUpdateTime()); - infoOnSpoilersLabel.setText(tr("Spoilers download automatically on launch") + "\n" + - tr("Press the button to manually update without relaunching") + "\n\n" + - tr("Do not close settings until manual update is complete")); picDownloadCheckBox.setText(tr("Download card pictures on the fly")); urlLinkLabel.setText(QString("%2").arg(WIKI_CUSTOM_PIC_URL).arg(tr("How to add a custom URL"))); resetDownloadURLs.setText(tr("Reset Download URLs")); - updateNowButton->setText(tr("Update Spoilers")); + unlockSettings(); aAdd->setText(tr("Add New URL")); aEdit->setText(tr("Edit URL")); aRemove->setText(tr("Remove URL")); diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h index 47f840191..8e33e697d 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ class QLineEdit; class QListWidget; class QListWidgetItem; class QModelIndex; +class SpoilerBackgroundUpdater; class DeckEditorSettingsPage : public AbstractSettingsPage { @@ -54,6 +56,7 @@ private: QLabel infoOnSpoilersLabel; QPushButton *mpSpoilerPathButton; QPushButton *updateNowButton; + QPointer spoilerUpdater; /** @brief Adds a list item for the given URL, storing the raw URL alongside its displayed label. */ QListWidgetItem *addUrlItem(const QString &url); @@ -66,6 +69,8 @@ private: /** @brief Refreshes the displayed label of every URL item after limits or settings change. */ void refreshUrlItems(); + + void observeSpoilerUpdater(SpoilerBackgroundUpdater *updater); }; #endif // COCKATRICE_DECK_EDITOR_SETTINGS_PAGE_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e25390ba0..5b7e06c20 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -190,6 +190,15 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/libcockatrice_network ) +cockatrice_add_test( + NAME + spoiler_file_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp + spoiler_file_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) # The installer's holder probe is Windows-only, but the wording it produces and the # question which programs are the payload's own are plain C++ and are what a stuck user # has to act on, so they are tested everywhere. diff --git a/tests/spoiler_file_test.cpp b/tests/spoiler_file_test.cpp new file mode 100644 index 000000000..9944f95bd --- /dev/null +++ b/tests/spoiler_file_test.cpp @@ -0,0 +1,149 @@ +#include "client/network/update/card_spoiler/spoiler_file.h" + +#include +#include +#include +#include +#include + +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + +namespace +{ +class SpoilerFileTest : public ::testing::Test +{ +protected: + QTemporaryDir directory; + QString path; + QString error; + const QByteArray original = ""; + + void SetUp() override + { + ASSERT_TRUE(directory.isValid()); + path = directory.filePath("spoiler.xml"); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + ASSERT_EQ(file.write(original), original.size()); + } + + QByteArray read() + { + QFile file(path); + EXPECT_TRUE(file.open(QIODevice::ReadOnly)); + return file.readAll(); + } + + SpoilerFile::SaveResult save(const QByteArray &data) + { + return SpoilerFile::save(path, data, [] { return true; }, error); + } +}; + +TEST_F(SpoilerFileTest, InvalidDocumentsPreserveExistingFile) +{ + for (const QByteArray &invalid : + {QByteArray(), QByteArray(""), + QByteArray(""), + QByteArray(""), + QByteArray(""), + QByteArray(""), + QByteArray("")}) { + EXPECT_EQ(save(invalid), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); + } +} + +TEST_F(SpoilerFileTest, SupportedVersionsAreAccepted) +{ + EXPECT_TRUE(SpoilerFile::validate(original).isEmpty()); + EXPECT_TRUE( + SpoilerFile::validate("").isEmpty()); +} + +TEST_F(SpoilerFileTest, IdenticalFileIsUnchanged) +{ + EXPECT_EQ(save(original), SpoilerFile::SaveResult::Unchanged); + EXPECT_TRUE(error.isEmpty()); + EXPECT_EQ(read(), original); +} + +TEST_F(SpoilerFileTest, CompleteFileHashDetectsChangesAfterLargeCommonPrefix) +{ + QByteArray changed = ""; + ASSERT_EQ(save(changed), SpoilerFile::SaveResult::Written); + changed.replace("", ""); + EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Written); + EXPECT_EQ(read(), changed); + EXPECT_TRUE(error.isEmpty()); +} + +TEST_F(SpoilerFileTest, CancellationBeforeWritePreservesExistingFile) +{ + EXPECT_EQ(SpoilerFile::save(path, original, [] { return false; }, error), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); +} + +TEST_F(SpoilerFileTest, CancellationBeforeCommitPreservesExistingFile) +{ + int checks = 0; + EXPECT_EQ(SpoilerFile::save( + path, original + "\n", [&checks] { return ++checks == 1; }, error), + SpoilerFile::SaveResult::Failed); + EXPECT_EQ(checks, 2); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); + EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); +} + +TEST_F(SpoilerFileTest, MissingParentIsCreated) +{ + path = directory.filePath("nested/spoiler.xml"); + EXPECT_EQ(save(original), SpoilerFile::SaveResult::Written); + EXPECT_EQ(read(), original); +} + +TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt) +{ + EXPECT_EQ(SpoilerFile::save( + QDir(path).filePath("spoiler.xml"), original, [] { return true; }, error), + SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); +} + +#ifdef Q_OS_UNIX +TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact) +{ + // Limit only the child process: a real short write exercises QSaveFile's + // buffered flush failure without depending on disk capacity or file ownership. + const QByteArray changed = ""; + EXPECT_EXIT( + { + struct rlimit limit; + if (getrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(2); + } + limit.rlim_cur = 128; + if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(3); + } + std::signal(SIGXFSZ, SIG_IGN); + const auto result = save(changed); + _exit(result == SpoilerFile::SaveResult::Failed && !error.isEmpty() ? 0 : 1); + }, + ::testing::ExitedWithCode(0), ""); + EXPECT_EQ(read(), original); + EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); +} +#endif +} // namespace From a23f0b43a698b3bb75dbc350b1bbfcde5e83523a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:26:50 +0100 Subject: [PATCH 044/104] [Replay] Build a compact event history index (#7611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn replay protocol events into short localized summaries with an actor, badge, and time so the history UI can render them without retaining the replay or duplicating the chat log. Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../widgets/replay/replay_event_index.cpp | 876 ++++++++++++++++++ .../widgets/replay/replay_event_index.h | 43 + 3 files changed, 920 insertions(+) create mode 100644 cockatrice/src/interface/widgets/replay/replay_event_index.cpp create mode 100644 cockatrice/src/interface/widgets/replay/replay_event_index.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b76783167..cf195fb5f 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -252,6 +252,7 @@ set(cockatrice_SOURCES src/interface/widgets/playmat/playmat_settings_dialog.cpp src/interface/widgets/quick_settings/settings_button_widget.cpp src/interface/widgets/quick_settings/settings_popup_widget.cpp + src/interface/widgets/replay/replay_event_index.cpp src/interface/widgets/replay/replay_manager.cpp src/interface/widgets/replay/replay_quick_settings_widget.cpp src/interface/widgets/replay/replay_timeline_widget.cpp diff --git a/cockatrice/src/interface/widgets/replay/replay_event_index.cpp b/cockatrice/src/interface/widgets/replay/replay_event_index.cpp new file mode 100644 index 000000000..d21671b5a --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_index.cpp @@ -0,0 +1,876 @@ +#include "replay_event_index.h" + +#include "libcockatrice/protocol/get_pb_extension.h" +#include "libcockatrice/protocol/pb/card_attributes.pb.h" +#include "libcockatrice/protocol/pb/context_concede.pb.h" +#include "libcockatrice/protocol/pb/context_connection_state_changed.pb.h" +#include "libcockatrice/protocol/pb/context_deck_select.pb.h" +#include "libcockatrice/protocol/pb/context_mulligan.pb.h" +#include "libcockatrice/protocol/pb/context_ready_start.pb.h" +#include "libcockatrice/protocol/pb/context_set_sideboard_lock.pb.h" +#include "libcockatrice/protocol/pb/context_undo_draw.pb.h" +#include "libcockatrice/protocol/pb/event_attach_card.pb.h" +#include "libcockatrice/protocol/pb/event_change_zone_properties.pb.h" +#include "libcockatrice/protocol/pb/event_create_counter.pb.h" +#include "libcockatrice/protocol/pb/event_create_token.pb.h" +#include "libcockatrice/protocol/pb/event_del_counter.pb.h" +#include "libcockatrice/protocol/pb/event_destroy_card.pb.h" +#include "libcockatrice/protocol/pb/event_draw_cards.pb.h" +#include "libcockatrice/protocol/pb/event_dump_zone.pb.h" +#include "libcockatrice/protocol/pb/event_flip_card.pb.h" +#include "libcockatrice/protocol/pb/event_game_closed.pb.h" +#include "libcockatrice/protocol/pb/event_game_host_changed.pb.h" +#include "libcockatrice/protocol/pb/event_game_log_notice.pb.h" +#include "libcockatrice/protocol/pb/event_game_say.pb.h" +#include "libcockatrice/protocol/pb/event_game_state_changed.pb.h" +#include "libcockatrice/protocol/pb/event_join.pb.h" +#include "libcockatrice/protocol/pb/event_kicked.pb.h" +#include "libcockatrice/protocol/pb/event_leave.pb.h" +#include "libcockatrice/protocol/pb/event_move_card.pb.h" +#include "libcockatrice/protocol/pb/event_player_properties_changed.pb.h" +#include "libcockatrice/protocol/pb/event_reveal_cards.pb.h" +#include "libcockatrice/protocol/pb/event_reverse_turn.pb.h" +#include "libcockatrice/protocol/pb/event_roll_die.pb.h" +#include "libcockatrice/protocol/pb/event_set_active_phase.pb.h" +#include "libcockatrice/protocol/pb/event_set_active_player.pb.h" +#include "libcockatrice/protocol/pb/event_set_card_attr.pb.h" +#include "libcockatrice/protocol/pb/event_set_card_counter.pb.h" +#include "libcockatrice/protocol/pb/event_set_counter.pb.h" +#include "libcockatrice/protocol/pb/event_shuffle.pb.h" +#include "libcockatrice/protocol/pb/game_event.pb.h" +#include "libcockatrice/protocol/pb/game_event_container.pb.h" +#include "libcockatrice/protocol/pb/game_event_context.pb.h" +#include "libcockatrice/protocol/pb/game_replay.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_card.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_counter.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_player.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" +#include "libcockatrice/utility/zone_names.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +struct PendingDetail +{ + QString text; + QString badge; + ReplayEventKind kind = ReplayEventKind::Action; + QString actor; +}; + +QString formatTime(int timeMs) +{ + const int totalSeconds = qMax(0, timeMs) / 1000; + const int hours = totalSeconds / 3600; + const int minutes = (totalSeconds / 60) % 60; + const int seconds = totalSeconds % 60; + + if (hours > 0) { + return QStringLiteral("%1:%2:%3") + .arg(hours) + .arg(minutes, 2, 10, QLatin1Char('0')) + .arg(seconds, 2, 10, QLatin1Char('0')); + } + + return QStringLiteral("%1:%2").arg(minutes, 2, 10, QLatin1Char('0')).arg(seconds, 2, 10, QLatin1Char('0')); +} + +class ReplayEventIndexBuilder +{ + Q_DECLARE_TR_FUNCTIONS(ReplayEventIndex) + +public: + ReplayEventIndexBuilder(const GameReplay *replay, const QList &timeline) : replay(replay), timeline(timeline) + { + } + + QList build() + { + QList entries; + + for (int i = 0; i < replay->event_list_size(); ++i) { + const GameEventContainer &container = replay->event_list(i); + const QList details = describeContainer(container); + if (details.isEmpty()) { + continue; + } + + ReplayEventEntry entry; + entry.eventIndex = i; + entry.timeMs = timeline.value(i, static_cast(container.seconds_elapsed()) * 1000); + entry.timeLabel = formatTime(entry.timeMs); + entry.kind = details.first().kind; + entry.badge = details.first().badge; + if (details.size() > 1) { + entry.badge += QStringLiteral(" +%1").arg(details.size() - 1); + } + + const QString sharedActor = details.first().actor; + const bool mixedActors = std::any_of(details.cbegin(), details.cend(), [&sharedActor](const auto &detail) { + return detail.actor != sharedActor; + }); + + QStringList detailTexts; + for (const PendingDetail &detail : details) { + if (mixedActors && !detail.actor.isEmpty()) { + detailTexts.append(tr("%1: %2").arg(detail.actor, detail.text)); + } else { + detailTexts.append(detail.text); + } + } + entry.actor = mixedActors ? QString() : sharedActor; + entry.detail = detailTexts.join(QStringLiteral(" • ")); + + entries.append(entry); + } + + return entries; + } + +private: + const GameReplay *replay; + const QList &timeline; + + QHash playerNames; + QHash spectators; + QHash cardNames; + QHash counterNames; + + int activePlayerId = -1; + bool gameStarted = false; + bool sawGameState = false; + + static QString cardKey(int playerId, const QString &zone, int cardId) + { + return QStringLiteral("%1/%2/%3").arg(playerId).arg(zone).arg(cardId); + } + + static QString counterKey(int playerId, int counterId) + { + return QStringLiteral("%1/%2").arg(playerId).arg(counterId); + } + + QString playerName(int playerId) const + { + if (playerId < 0) { + return tr("Game"); + } + return playerNames.value(playerId, tr("Player %1").arg(playerId)); + } + + QString actorName(int playerId) const + { + if (playerId < 0) { + return QString(); + } + return playerNames.value(playerId, tr("Player %1").arg(playerId)); + } + + bool isSpectator(int playerId) const + { + return spectators.value(playerId, false); + } + + void rememberPlayer(const ServerInfo_PlayerProperties &properties) + { + const int playerId = properties.player_id(); + if (playerId < 0) { + return; + } + + const QString name = QString::fromStdString(properties.user_info().name()); + if (!name.isEmpty()) { + playerNames.insert(playerId, name); + } + spectators.insert(playerId, properties.spectator()); + } + + void rememberCard(int playerId, const QString &zone, int cardId, const QString &name) + { + if (cardId >= 0 && !name.isEmpty()) { + cardNames.insert(cardKey(playerId, zone, cardId), name); + } + } + + void forgetCard(int playerId, const QString &zone, int cardId) + { + if (cardId >= 0) { + cardNames.remove(cardKey(playerId, zone, cardId)); + } + } + + QString cardName(int playerId, const QString &zone, int cardId) const + { + if (cardId < 0) { + return QString(); + } + return cardNames.value(cardKey(playerId, zone, cardId)); + } + + void rememberCounter(int playerId, const ServerInfo_Counter &counter) + { + const QString name = QString::fromStdString(counter.name()); + if (!name.isEmpty()) { + counterNames.insert(counterKey(playerId, counter.id()), name); + } + } + + QString counterName(int playerId, int counterId) const + { + return counterNames.value(counterKey(playerId, counterId)); + } + + QString zoneName(const QString &zone) const + { + if (zone == ZoneNames::TABLE) { + return tr("battlefield"); + } + if (zone == ZoneNames::GRAVE) { + return tr("graveyard"); + } + if (zone == ZoneNames::EXILE) { + return tr("exile"); + } + if (zone == ZoneNames::HAND) { + return tr("hand"); + } + if (zone == ZoneNames::DECK) { + return tr("library"); + } + if (zone == ZoneNames::SIDEBOARD) { + return tr("sideboard"); + } + if (zone == ZoneNames::STACK) { + return tr("stack"); + } + return zone; + } + + QString zonePhrase(int playerId, const QString &zone, int actorId) const + { + if (zone == ZoneNames::TABLE) { + return tr("the battlefield"); + } + if (zone == ZoneNames::EXILE) { + return tr("exile"); + } + if (zone == ZoneNames::STACK) { + return tr("the stack"); + } + + const QString name = zoneName(zone); + if (playerId < 0 || playerId == actorId) { + return tr("their %1").arg(name); + } + return tr("%1's %2").arg(playerName(playerId), name); + } + + QString formatCardList(const QStringList &names, int total) const + { + // Keep repeats: each entry is a drawn/revealed card, so the list length must agree with the count. + QStringList listedNames; + for (const QString &name : names) { + if (!name.isEmpty()) { + listedNames.append(name); + } + } + + if (listedNames.isEmpty()) { + if (total <= 0) { + return tr("no cards"); + } + return total == 1 ? tr("1 card") : tr("%1 cards").arg(total); + } + + if (total < listedNames.size()) { + total = listedNames.size(); + } + + if (listedNames.size() > 3) { + return tr("%1 and %2 more").arg(listedNames.mid(0, 3).join(QStringLiteral(", "))).arg(total - 3); + } + if (total > listedNames.size()) { + return tr("%1 and %2 more").arg(listedNames.join(QStringLiteral(", "))).arg(total - listedNames.size()); + } + + return listedNames.join(QStringLiteral(", ")); + } + + void updateGameState(const Event_GameStateChanged &event) + { + for (int i = 0; i < event.player_list_size(); ++i) { + const ServerInfo_Player &player = event.player_list(i); + const int playerId = player.properties().player_id(); + rememberPlayer(player.properties()); + + for (int j = 0; j < player.zone_list_size(); ++j) { + const ServerInfo_Zone &zone = player.zone_list(j); + for (int k = 0; k < zone.card_list_size(); ++k) { + const ServerInfo_Card &card = zone.card_list(k); + rememberCard(playerId, QString::fromStdString(zone.name()), card.id(), + QString::fromStdString(card.name())); + } + } + + for (int j = 0; j < player.counter_list_size(); ++j) { + rememberCounter(playerId, player.counter_list(j)); + } + } + + if (event.has_active_player_id()) { + activePlayerId = event.active_player_id(); + } + if (event.has_game_started()) { + gameStarted = event.game_started(); + } + } + + QString describeMoveCard(const Event_MoveCard &event, int actorId, const GameEventContext &context) + { + const int startPlayerId = event.start_player_id() >= 0 ? event.start_player_id() : actorId; + const int targetPlayerId = event.target_player_id() >= 0 ? event.target_player_id() : startPlayerId; + const QString startZone = QString::fromStdString(event.start_zone()); + const QString targetZone = event.has_target_zone() ? QString::fromStdString(event.target_zone()) : startZone; + + QString name; + if (event.has_card_name()) { + name = QString::fromStdString(event.card_name()); + } + if (name.isEmpty()) { + name = cardName(startPlayerId, startZone, event.card_id()); + } + + const bool knownName = !name.isEmpty(); + if (name.isEmpty()) { + name = event.face_down() ? tr("a face-down card") : tr("a card"); + } + + const int newCardId = event.has_new_card_id() ? event.new_card_id() : event.card_id(); + forgetCard(startPlayerId, startZone, event.card_id()); + if (knownName) { + rememberCard(targetPlayerId, targetZone, newCardId, name); + } + + if (startZone == targetZone) { + return tr("moved %1 within %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId)); + } + if (context.HasExtension(Context_UndoDraw::ext)) { + return tr("returned %1 to %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId)); + } + + return tr("moved %1 from %2 to %3.") + .arg(name, zonePhrase(startPlayerId, startZone, actorId), zonePhrase(targetPlayerId, targetZone, actorId)); + } + + QString describeSetCardAttribute(const Event_SetCardAttr &event, int actorId) + { + const QString zone = QString::fromStdString(event.zone_name()); + QString name; + if (event.has_card_id()) { + name = cardName(actorId, zone, event.card_id()); + } + if (name.isEmpty()) { + name = event.has_card_id() ? tr("a card") : tr("all cards"); + } + + const QString value = QString::fromStdString(event.attr_value()); + switch (event.attribute()) { + case AttrTapped: + return value == QLatin1String("1") ? tr("tapped %1.").arg(name) : tr("untapped %1.").arg(name); + case AttrAttacking: + return value == QLatin1String("1") ? tr("%1 is now attacking.").arg(name) + : tr("%1 is no longer attacking.").arg(name); + case AttrFaceDown: + return value == QLatin1String("1") ? tr("turned %1 face down.").arg(name) + : tr("turned %1 face up.").arg(name); + case AttrColor: + return tr("changed %1's color to %2.").arg(name, value); + case AttrPT: + return tr("set %1's power/toughness to %2.").arg(name, value); + case AttrAnnotation: + return value.isEmpty() ? tr("cleared %1's annotation.").arg(name) + : tr("annotated %1: %2").arg(name, value); + case AttrDoesntUntap: + return value == QLatin1String("1") ? tr("%1 will no longer untap.").arg(name) + : tr("%1 will untap normally again.").arg(name); + } + + return tr("changed %1.").arg(name); + } + + QString badgeForCardAttribute(CardAttribute attribute) const + { + switch (attribute) { + case AttrTapped: + return tr("TAP"); + case AttrAttacking: + return tr("ATTACK"); + case AttrFaceDown: + return tr("FLIP"); + case AttrColor: + return tr("COLOR"); + case AttrPT: + return tr("PT"); + case AttrAnnotation: + return tr("NOTE"); + case AttrDoesntUntap: + return tr("TAP"); + } + return tr("CARD"); + } + + QString describeLeaveReason(Event_Leave::LeaveReason reason) const + { + switch (reason) { + case Event_Leave::USER_KICKED: + return tr("kicked by game host or moderator"); + case Event_Leave::USER_LEFT: + return tr("left the game"); + case Event_Leave::USER_DISCONNECTED: + return tr("disconnected"); + case Event_Leave::OTHER: + default: + return tr("reason unknown"); + } + } + + void addDetail(QList &details, + const QString &text, + const QString &badge, + ReplayEventKind kind, + int actorId, + bool forcedByJudge) const + { + if (text.isEmpty()) { + return; + } + + const QString finalText = forcedByJudge ? tr("%1 (judge)").arg(text) : text; + details.append(PendingDetail{.text = finalText, .badge = badge, .kind = kind, .actor = actorName(actorId)}); + } + + QList describeContainer(const GameEventContainer &container) + { + QList details; + const GameEventContext &context = container.context(); + const bool forcedByJudge = container.has_forced_by_judge(); + const auto contextType = static_cast(getPbExtension(context)); + + for (int i = 0; i < container.event_list_size(); ++i) { + const GameEvent &event = container.event_list(i); + const int eventPlayerId = event.player_id(); + const auto eventType = static_cast(getPbExtension(event)); + + switch (eventType) { + case GameEvent::GAME_STATE_CHANGED: { + const Event_GameStateChanged &state = event.GetExtension(Event_GameStateChanged::ext); + const bool wasStarted = gameStarted; + updateGameState(state); + if (state.game_started() && !wasStarted) { + addDetail(details, tr("Game started."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge); + } else if (!sawGameState) { + addDetail(details, tr("Game state loaded."), tr("GAME"), ReplayEventKind::System, -1, + forcedByJudge); + } + sawGameState = true; + break; + } + case GameEvent::PLAYER_PROPERTIES_CHANGED: { + const Event_PlayerPropertiesChanged &propertiesEvent = + event.GetExtension(Event_PlayerPropertiesChanged::ext); + const bool hasProperties = propertiesEvent.has_player_properties(); + const ServerInfo_PlayerProperties &properties = propertiesEvent.player_properties(); + // The server only fills in the field that changed, so player_id is absent from these events + // unless they are about a specific player; fall back to the event's own player id. + const bool describesPlayer = hasProperties && properties.has_player_id(); + const int playerId = describesPlayer ? properties.player_id() : eventPlayerId; + if (describesPlayer) { + rememberPlayer(properties); + } + + switch (contextType) { + case GameEventContext::READY_START: + addDetail(details, + properties.ready_start() ? tr("is ready to start.") : tr("is no longer ready."), + tr("READY"), ReplayEventKind::System, playerId, forcedByJudge); + break; + case GameEventContext::CONCEDE: + addDetail(details, tr("conceded the game."), tr("CONCEDE"), ReplayEventKind::System, + playerId, forcedByJudge); + break; + case GameEventContext::UNCONCEDE: + addDetail(details, tr("unconceded the game."), tr("CONCEDE"), ReplayEventKind::System, + playerId, forcedByJudge); + break; + case GameEventContext::DECK_SELECT: { + const Context_DeckSelect &deckSelect = context.GetExtension(Context_DeckSelect::ext); + const int sideboardSize = deckSelect.sideboard_size(); + addDetail(details, + sideboardSize >= 0 + ? tr("selected a deck with %1 sideboard cards.").arg(sideboardSize) + : tr("selected a deck."), + tr("DECK"), ReplayEventKind::System, playerId, forcedByJudge); + break; + } + case GameEventContext::MULLIGAN: { + const Context_Mulligan &mulligan = context.GetExtension(Context_Mulligan::ext); + addDetail(details, + mulligan.number() > 0 ? tr("took a mulligan (%1).").arg(mulligan.number()) + : tr("took a mulligan."), + tr("MULLIGAN"), ReplayEventKind::System, playerId, forcedByJudge); + break; + } + case GameEventContext::SET_SIDEBOARD_LOCK: + addDetail(details, + properties.sideboard_locked() ? tr("locked their sideboard.") + : tr("unlocked their sideboard."), + tr("SIDEBOARD"), ReplayEventKind::System, playerId, forcedByJudge); + break; + case GameEventContext::CONNECTION_STATE_CHANGED: + addDetail(details, + properties.ping_seconds() != -1 ? tr("reconnected to the game.") + : tr("lost connection to the game."), + tr("CONNECTION"), ReplayEventKind::System, playerId, forcedByJudge); + break; + default: + break; + } + break; + } + case GameEvent::JOIN: { + const Event_Join &join = event.GetExtension(Event_Join::ext); + if (join.has_player_properties()) { + rememberPlayer(join.player_properties()); + } + const int playerId = join.player_properties().player_id() >= 0 + ? join.player_properties().player_id() + : eventPlayerId; + addDetail(details, isSpectator(playerId) ? tr("joined as a spectator.") : tr("joined the game."), + tr("JOIN"), ReplayEventKind::System, playerId, forcedByJudge); + break; + } + case GameEvent::LEAVE: { + const Event_Leave &leave = event.GetExtension(Event_Leave::ext); + const QString reason = describeLeaveReason(leave.reason()); + if (eventPlayerId < 0) { + addDetail(details, tr("A player left the game (%1).").arg(reason), tr("LEAVE"), + ReplayEventKind::System, -1, forcedByJudge); + } else { + addDetail(details, + isSpectator(eventPlayerId) ? tr("stopped spectating (%1).").arg(reason) + : tr("left the game (%1).").arg(reason), + tr("LEAVE"), ReplayEventKind::System, eventPlayerId, forcedByJudge); + } + break; + } + case GameEvent::KICKED: + addDetail(details, + eventPlayerId >= 0 ? tr("was kicked from the game.") + : tr("A player was kicked from the game."), + tr("KICK"), ReplayEventKind::System, eventPlayerId, forcedByJudge); + break; + case GameEvent::GAME_CLOSED: + addDetail(details, tr("Game closed."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge); + break; + case GameEvent::GAME_HOST_CHANGED: + addDetail(details, eventPlayerId >= 0 ? tr("is now the game host.") : tr("The game host changed."), + tr("HOST"), ReplayEventKind::System, eventPlayerId, forcedByJudge); + break; + case GameEvent::GAME_SAY: { + const Event_GameSay &say = event.GetExtension(Event_GameSay::ext); + addDetail(details, QString::fromStdString(say.message()), tr("CHAT"), ReplayEventKind::Chat, + eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SET_ACTIVE_PLAYER: { + const Event_SetActivePlayer &setActivePlayer = event.GetExtension(Event_SetActivePlayer::ext); + activePlayerId = setActivePlayer.active_player_id(); + if (activePlayerId >= 0) { + addDetail(details, tr("begins their turn."), tr("TURN"), ReplayEventKind::Turn, activePlayerId, + forcedByJudge); + } + break; + } + case GameEvent::SET_ACTIVE_PHASE: { + const Event_SetActivePhase &setActivePhase = event.GetExtension(Event_SetActivePhase::ext); + if (setActivePhase.phase() >= 0) { + const QString phaseName = Phases::getPhase(setActivePhase.phase()).getName(); + addDetail(details, tr("entered the %1 phase.").arg(phaseName), tr("PHASE"), + ReplayEventKind::Phase, activePlayerId >= 0 ? activePlayerId : eventPlayerId, + forcedByJudge); + } + break; + } + case GameEvent::REVERSE_TURN: { + const Event_ReverseTurn &reverseTurn = event.GetExtension(Event_ReverseTurn::ext); + addDetail(details, + reverseTurn.reversed() ? tr("reversed the turn order.") : tr("restored the turn order."), + tr("TURN"), ReplayEventKind::Turn, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::MOVE_CARD: { + const Event_MoveCard &moveCard = event.GetExtension(Event_MoveCard::ext); + const QString text = describeMoveCard(moveCard, eventPlayerId, context); + const QString startZone = QString::fromStdString(moveCard.start_zone()); + const QString targetZone = QString::fromStdString(moveCard.target_zone()); + QString badge = tr("MOVE"); + if (context.HasExtension(Context_UndoDraw::ext)) { + badge = tr("UNDO"); + } else if (startZone == ZoneNames::STACK) { + badge = tr("RESOLVE"); + } else if (startZone == ZoneNames::HAND && targetZone == ZoneNames::TABLE) { + badge = tr("PLAY"); + } + addDetail(details, text, badge, ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DRAW_CARDS: { + const Event_DrawCards &drawCards = event.GetExtension(Event_DrawCards::ext); + QStringList names; + for (int j = 0; j < drawCards.cards_size(); ++j) { + const ServerInfo_Card &card = drawCards.cards(j); + const QString name = QString::fromStdString(card.name()); + names.append(name); + rememberCard(eventPlayerId, ZoneNames::HAND, card.id(), name); + } + if (drawCards.number() > 0) { + addDetail(details, tr("drew %1.").arg(formatCardList(names, drawCards.number())), tr("DRAW"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + } + break; + } + case GameEvent::REVEAL_CARDS: { + const Event_RevealCards &revealCards = event.GetExtension(Event_RevealCards::ext); + const QString zone = QString::fromStdString(revealCards.zone_name()); + QStringList names; + for (int j = 0; j < revealCards.cards_size(); ++j) { + const ServerInfo_Card &card = revealCards.cards(j); + const QString name = QString::fromStdString(card.name()); + names.append(name); + rememberCard(eventPlayerId, zone, card.id(), name); + } + const int total = + revealCards.number_of_cards() > 0 ? revealCards.number_of_cards() : revealCards.cards_size(); + QString text = + tr("revealed %1 from %2.") + .arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId)); + if (revealCards.other_player_id() >= 0 && revealCards.other_player_id() != eventPlayerId) { + text = tr("revealed %1 from %2 to %3.") + .arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId), + playerName(revealCards.other_player_id())); + } + addDetail(details, text, tr("REVEAL"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SHUFFLE: { + const Event_Shuffle &shuffle = event.GetExtension(Event_Shuffle::ext); + addDetail( + details, + tr("shuffled %1.") + .arg(zonePhrase(eventPlayerId, QString::fromStdString(shuffle.zone_name()), eventPlayerId)), + tr("SHUFFLE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::ROLL_DIE: { + const Event_RollDie &rollDie = event.GetExtension(Event_RollDie::ext); + QStringList rolls; + for (int j = 0; j < rollDie.values_size(); ++j) { + rolls.append(QString::number(rollDie.values(j))); + } + if (rolls.isEmpty() && rollDie.value() > 0) { + rolls.append(QString::number(rollDie.value())); + } + const QString sides = rollDie.sides() > 0 ? QString::number(rollDie.sides()) : QStringLiteral("?"); + addDetail(details, tr("rolled a d%1: %2.").arg(sides, rolls.join(QStringLiteral(", "))), tr("DICE"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::FLIP_CARD: { + const Event_FlipCard &flipCard = event.GetExtension(Event_FlipCard::ext); + const QString zone = QString::fromStdString(flipCard.zone_name()); + if (flipCard.has_card_name()) { + rememberCard(eventPlayerId, zone, flipCard.card_id(), + QString::fromStdString(flipCard.card_name())); + } + QString name = flipCard.has_card_name() ? QString::fromStdString(flipCard.card_name()) + : cardName(eventPlayerId, zone, flipCard.card_id()); + if (name.isEmpty()) { + name = tr("a card"); + } + addDetail(details, + flipCard.face_down() ? tr("flipped %1 face down.").arg(name) + : tr("flipped %1 face up.").arg(name), + tr("FLIP"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DESTROY_CARD: { + const Event_DestroyCard &destroyCard = event.GetExtension(Event_DestroyCard::ext); + const QString zone = QString::fromStdString(destroyCard.zone_name()); + const QString name = cardName(eventPlayerId, zone, destroyCard.card_id()); + forgetCard(eventPlayerId, zone, destroyCard.card_id()); + addDetail(details, tr("destroyed %1.").arg(name.isEmpty() ? tr("a card") : name), tr("DESTROY"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::ATTACH_CARD: { + const Event_AttachCard &attachCard = event.GetExtension(Event_AttachCard::ext); + const QString startZone = QString::fromStdString(attachCard.start_zone()); + const QString source = cardName(eventPlayerId, startZone, attachCard.card_id()); + const QString sourceText = source.isEmpty() ? tr("a card") : source; + // An unattach event carries no target at all, so the target fields default to player 0 / no zone + // / card 0; only a present target_player_id means the card was attached to something. + if (!attachCard.has_target_player_id()) { + addDetail(details, tr("unattached %1.").arg(sourceText), tr("ATTACH"), ReplayEventKind::Action, + eventPlayerId, forcedByJudge); + break; + } + const QString targetZone = QString::fromStdString(attachCard.target_zone()); + const int targetPlayerId = + attachCard.target_player_id() >= 0 ? attachCard.target_player_id() : eventPlayerId; + const QString target = cardName(targetPlayerId, targetZone, attachCard.target_card_id()); + addDetail(details, + tr("attached %1 to %2.").arg(sourceText, target.isEmpty() ? tr("a card") : target), + tr("ATTACH"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::CREATE_TOKEN: { + const Event_CreateToken &createToken = event.GetExtension(Event_CreateToken::ext); + const QString name = QString::fromStdString(createToken.card_name()); + rememberCard(eventPlayerId, QString::fromStdString(createToken.zone_name()), createToken.card_id(), + name); + const QString pt = QString::fromStdString(createToken.pt()); + addDetail( + details, + tr("created a token: %1%2.") + .arg(name.isEmpty() ? tr("token") : name, pt.isEmpty() ? QString() : tr(" (%1)").arg(pt)), + tr("TOKEN"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SET_CARD_ATTR: { + const Event_SetCardAttr &setCardAttr = event.GetExtension(Event_SetCardAttr::ext); + addDetail(details, describeSetCardAttribute(setCardAttr, eventPlayerId), + badgeForCardAttribute(setCardAttr.attribute()), ReplayEventKind::Action, eventPlayerId, + forcedByJudge); + break; + } + case GameEvent::SET_CARD_COUNTER: { + const Event_SetCardCounter &setCardCounter = event.GetExtension(Event_SetCardCounter::ext); + const QString name = cardName(eventPlayerId, QString::fromStdString(setCardCounter.zone_name()), + setCardCounter.card_id()); + addDetail( + details, + tr("set a counter on %1 to %2.") + .arg(name.isEmpty() ? tr("a card") : name, QString::number(setCardCounter.counter_value())), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::CREATE_COUNTER: { + const Event_CreateCounter &createCounter = event.GetExtension(Event_CreateCounter::ext); + const ServerInfo_Counter &counter = createCounter.counter_info(); + rememberCounter(eventPlayerId, counter); + const QString name = QString::fromStdString(counter.name()); + addDetail(details, + tr("created the %1 counter (%2).") + .arg(name.isEmpty() ? tr("new") : name, QString::number(counter.count())), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SET_COUNTER: { + const Event_SetCounter &setCounter = event.GetExtension(Event_SetCounter::ext); + const QString name = counterName(eventPlayerId, setCounter.counter_id()); + addDetail(details, + tr("set %1 to %2.") + .arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name), + QString::number(setCounter.value())), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DEL_COUNTER: { + const Event_DelCounter &delCounter = event.GetExtension(Event_DelCounter::ext); + const QString name = counterName(eventPlayerId, delCounter.counter_id()); + counterNames.remove(counterKey(eventPlayerId, delCounter.counter_id())); + addDetail(details, + tr("removed %1.").arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name)), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DUMP_ZONE: { + const Event_DumpZone &dumpZone = event.GetExtension(Event_DumpZone::ext); + const QString zoneOwnerPhrase = zonePhrase( + dumpZone.zone_owner_id(), QString::fromStdString(dumpZone.zone_name()), eventPlayerId); + if (dumpZone.number_cards() < 0) { + addDetail(details, tr("looked at %1.").arg(zoneOwnerPhrase), tr("LOOK"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + } else { + addDetail(details, + tr("looked at %1 from %2.") + .arg(formatCardList(QStringList(), dumpZone.number_cards()), zoneOwnerPhrase), + tr("LOOK"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + } + break; + } + case GameEvent::CHANGE_ZONE_PROPERTIES: { + const Event_ChangeZoneProperties &changeZone = event.GetExtension(Event_ChangeZoneProperties::ext); + const QString zone = QString::fromStdString(changeZone.zone_name()); + QStringList changes; + if (changeZone.always_reveal_top_card()) { + changes.append(tr("top card revealed to all")); + } + if (changeZone.always_look_at_top_card()) { + changes.append(tr("top card visible to its owner")); + } + addDetail( + details, + changes.isEmpty() + ? tr("changed the properties of %1.").arg(zonePhrase(eventPlayerId, zone, eventPlayerId)) + : tr("changed %1: %2.") + .arg(zonePhrase(eventPlayerId, zone, eventPlayerId), + changes.join(QStringLiteral(", "))), + tr("ZONE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::GAME_LOG_NOTICE: { + const Event_GameLogNotice ¬ice = event.GetExtension(Event_GameLogNotice::ext); + switch (notice.notice_type()) { + case Event_GameLogNotice::UNDO_DRAW_FAILED: + addDetail(details, tr("failed to undo a draw."), tr("NOTICE"), ReplayEventKind::System, + eventPlayerId, forcedByJudge); + break; + default: + addDetail(details, tr("A game notice was logged."), tr("NOTICE"), ReplayEventKind::System, + -1, forcedByJudge); + break; + } + break; + } + case GameEvent::CREATE_ARROW: + case GameEvent::DELETE_ARROW: + // Arrows are transient visual annotations and would make the history very noisy. + break; + default: + addDetail(details, tr("Unrecognized game event."), tr("EVENT"), ReplayEventKind::Action, + eventPlayerId, forcedByJudge); + break; + } + } + + return details; + } +}; +} // namespace + +QList buildReplayEventEntries(const GameReplay *replay, const QList &timeline) +{ + if (!replay) { + return {}; + } + + return ReplayEventIndexBuilder(replay, timeline).build(); +} diff --git a/cockatrice/src/interface/widgets/replay/replay_event_index.h b/cockatrice/src/interface/widgets/replay/replay_event_index.h new file mode 100644 index 000000000..40f72a965 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_index.h @@ -0,0 +1,43 @@ +#ifndef COCKATRICE_REPLAY_EVENT_INDEX_H +#define COCKATRICE_REPLAY_EVENT_INDEX_H + +#include +#include + +class GameReplay; + +enum class ReplayEventKind +{ + Action, + Turn, + Phase, + Chat, + System +}; + +/** + * @brief One user-visible entry in the replay history sidebar. + * + * The event index refers to the corresponding GameEventContainer in the replay. It is used to seek the replay to that + * container. + */ +struct ReplayEventEntry +{ + int eventIndex = -1; + int timeMs = 0; + QString timeLabel; + QString actor; + QString badge; + QString detail; + ReplayEventKind kind = ReplayEventKind::Action; +}; + +/** + * @brief Builds a compact, human-readable index of the events in a replay. + * + * The returned entries copy all the data they need, so the caller does not need to keep the replay object alive after + * this function returns. + */ +QList buildReplayEventEntries(const GameReplay *replay, const QList &timeline); + +#endif // COCKATRICE_REPLAY_EVENT_INDEX_H From fc27cb7a59266f63463d3fd54b6d0fe25fc92c96 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:26:50 +0100 Subject: [PATCH 045/104] [Replay] Add a clickable replay history sidebar (#7612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the event index as a scrollable list of event boxes. Selecting an entry seeks the replay to that event and keeps the current event highlighted while playback advances. Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../widgets/replay/replay_event_sidebar.cpp | 284 ++++++++++++++++++ .../widgets/replay/replay_event_sidebar.h | 50 +++ 3 files changed, 335 insertions(+) create mode 100644 cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp create mode 100644 cockatrice/src/interface/widgets/replay/replay_event_sidebar.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index cf195fb5f..e99735bf7 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -253,6 +253,7 @@ set(cockatrice_SOURCES src/interface/widgets/quick_settings/settings_button_widget.cpp src/interface/widgets/quick_settings/settings_popup_widget.cpp src/interface/widgets/replay/replay_event_index.cpp + src/interface/widgets/replay/replay_event_sidebar.cpp src/interface/widgets/replay/replay_manager.cpp src/interface/widgets/replay/replay_quick_settings_widget.cpp src/interface/widgets/replay/replay_timeline_widget.cpp diff --git a/cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp new file mode 100644 index 000000000..b5fab2794 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp @@ -0,0 +1,284 @@ +#include "replay_event_sidebar.h" + +#include "replay_event_index.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +enum ItemDataRole +{ + EventIndexRole = Qt::UserRole + 1, + TimeLabelRole, + KindRole, + ActorRole, + BadgeRole, + DetailRole +}; + +QColor accentForKind(ReplayEventKind kind, const QPalette &palette) +{ + switch (kind) { + case ReplayEventKind::Turn: + return QColor(0x4c, 0xaf, 0x50); + case ReplayEventKind::Phase: + return QColor(0x42, 0xa5, 0xf5); + case ReplayEventKind::Chat: + return QColor(0xff, 0xb3, 0x00); + case ReplayEventKind::System: + return palette.mid().color(); + case ReplayEventKind::Action: + default: + return palette.highlight().color(); + } +} + +class ReplayEventDelegate : public QStyledItemDelegate +{ +public: + explicit ReplayEventDelegate(QObject *parent) : QStyledItemDelegate(parent) + { + } + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + + const QPalette &palette = option.palette; + const bool selected = option.state.testFlag(QStyle::State_Selected); + const bool hovered = option.state.testFlag(QStyle::State_MouseOver); + const auto kind = static_cast(index.data(KindRole).toInt()); + const QColor accent = accentForKind(kind, palette); + + const QRect card = option.rect.adjusted(4, 2, -4, -2); + QColor cardBackground = selected ? palette.highlight().color() : palette.base().color(); + if (!selected && hovered) { + cardBackground = palette.alternateBase().color(); + } + + painter->setPen(Qt::NoPen); + painter->setBrush(cardBackground); + painter->drawRoundedRect(card, 6, 6); + + QColor borderColor = selected ? palette.highlightedText().color() : palette.mid().color(); + borderColor.setAlpha(selected ? 100 : 130); + painter->setBrush(Qt::NoBrush); + painter->setPen(QPen(borderColor, 1)); + painter->drawRoundedRect(card.adjusted(0, 0, -1, -1), 6, 6); + + QColor accentColor = accent; + if (selected) { + accentColor = palette.highlightedText().color(); + accentColor.setAlpha(210); + } + painter->setPen(Qt::NoPen); + painter->setBrush(accentColor); + painter->drawRoundedRect(QRect(card.left() + 7, card.top() + 7, 3, card.height() - 14), 1.5, 1.5); + + const QColor textColor = selected ? palette.highlightedText().color() : palette.text().color(); + QColor mutedColor = textColor; + mutedColor.setAlpha(170); + + QFont timeFont = option.font; + timeFont.setBold(true); + timeFont.setPointSizeF(qMax(7.0, timeFont.pointSizeF() * 0.9)); + + QFont actorFont = option.font; + actorFont.setBold(true); + + QFont badgeFont = option.font; + badgeFont.setBold(true); + badgeFont.setPointSizeF(qMax(6.5, badgeFont.pointSizeF() * 0.72)); + + QFont detailFont = option.font; + detailFont.setPointSizeF(qMax(7.0, detailFont.pointSizeF() * 0.95)); + + const QFontMetrics timeMetrics(timeFont); + const QFontMetrics actorMetrics(actorFont); + const QFontMetrics badgeMetrics(badgeFont); + const QFontMetrics detailMetrics(detailFont); + + const int contentLeft = card.left() + 15; + const int contentRight = card.right() - 10; + const int topRowTop = card.top() + 6; + const int topRowHeight = qMax(timeMetrics.height(), badgeMetrics.height()) + 2; + + const QString timeText = index.data(TimeLabelRole).toString(); + const QString actorText = index.data(ActorRole).toString(); + const QString badgeText = index.data(BadgeRole).toString(); + const QString detailText = index.data(DetailRole).toString(); + + const int timeWidth = timeMetrics.horizontalAdvance(timeText); + painter->setFont(timeFont); + painter->setPen(mutedColor); + painter->drawText(QRect(contentLeft, topRowTop, timeWidth + 2, topRowHeight), Qt::AlignLeft | Qt::AlignVCenter, + timeText); + + int actorRight = contentRight; + if (!badgeText.isEmpty()) { + const int badgeWidth = badgeMetrics.horizontalAdvance(badgeText) + 10; + const int badgeHeight = badgeMetrics.height() + 2; + const QRect badgeRect(contentRight - badgeWidth, topRowTop + (topRowHeight - badgeHeight) / 2, badgeWidth, + badgeHeight); + + QColor badgeBackground = accentColor; + badgeBackground.setAlpha(selected ? 45 : 32); + painter->setPen(Qt::NoPen); + painter->setBrush(badgeBackground); + painter->drawRoundedRect(badgeRect, 3, 3); + + painter->setFont(badgeFont); + painter->setPen(accentColor); + painter->drawText(badgeRect, Qt::AlignCenter, badgeText); + + actorRight = badgeRect.left() - 8; + } + + const int actorLeft = contentLeft + timeWidth + 10; + if (actorRight > actorLeft) { + const QString elidedActor = + actorMetrics.elidedText(actorText, Qt::ElideRight, qMax(0, actorRight - actorLeft)); + painter->setFont(actorFont); + painter->setPen(textColor); + painter->drawText(QRect(actorLeft, topRowTop, actorRight - actorLeft, topRowHeight), + Qt::AlignLeft | Qt::AlignVCenter, elidedActor); + } + + const int detailTop = topRowTop + topRowHeight + 2; + const int detailHeight = qMax(detailMetrics.height(), card.bottom() - detailTop); + const QString elidedDetail = + detailMetrics.elidedText(detailText, Qt::ElideRight, qMax(0, contentRight - contentLeft)); + painter->setFont(detailFont); + painter->setPen(textColor); + painter->drawText(QRect(contentLeft, detailTop, contentRight - contentLeft, detailHeight), + Qt::AlignLeft | Qt::AlignVCenter, elidedDetail); + + painter->restore(); + } + + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex & /*index*/) const override + { + const QFontMetrics metrics(option.font); + return {0, qMax(58, metrics.height() * 3 + 10)}; + } +}; +} // namespace + +ReplayEventSidebar::ReplayEventSidebar(QWidget *parent, const GameReplay *replay, const QList &timeline) + : QWidget(parent), eventList(new QListWidget(this)), emptyLabel(new QLabel(this)) +{ + setObjectName("replayEventSidebar"); + + eventList->setObjectName("replayEventList"); + eventList->setItemDelegate(new ReplayEventDelegate(eventList)); + eventList->setFrameShape(QFrame::NoFrame); + eventList->setFocusPolicy(Qt::NoFocus); + eventList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + eventList->setSelectionMode(QAbstractItemView::SingleSelection); + eventList->setMouseTracking(true); + eventList->setUniformItemSizes(true); + eventList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + + emptyLabel->setAlignment(Qt::AlignCenter); + emptyLabel->setWordWrap(true); + emptyLabel->setMargin(16); + emptyLabel->setForegroundRole(QPalette::PlaceholderText); + + auto *layout = QtUtils::makeLayout(this); + layout->addWidget(eventList); + layout->addWidget(emptyLabel); + + const QList entries = buildReplayEventEntries(replay, timeline); + eventIndices.reserve(entries.size()); + + for (const ReplayEventEntry &entry : entries) { + auto *item = new QListWidgetItem(eventList); + item->setData(EventIndexRole, entry.eventIndex); + item->setData(TimeLabelRole, entry.timeLabel); + item->setData(KindRole, static_cast(entry.kind)); + item->setData(ActorRole, entry.actor); + item->setData(BadgeRole, entry.badge); + item->setData(DetailRole, entry.detail); + const QString tooltip = entry.actor.isEmpty() + ? tr("%1\n%2").arg(entry.timeLabel, entry.detail) + : tr("%1 · %2\n%3").arg(entry.timeLabel, entry.actor, entry.detail); + item->setToolTip(tooltip); + item->setData(Qt::AccessibleTextRole, entry.actor.isEmpty() + ? tr("%1: %2").arg(entry.timeLabel, entry.detail) + : tr("%1, %2: %3").arg(entry.timeLabel, entry.actor, entry.detail)); + eventIndices.append(entry.eventIndex); + } + + const bool hasEvents = !entries.isEmpty(); + eventList->setVisible(hasEvents); + emptyLabel->setVisible(!hasEvents); + emptyLabel->setText(tr("No game events in this replay.")); + + connect(eventList, &QListWidget::itemClicked, this, [this](QListWidgetItem *item) { + if (!item) { + return; + } + + setCurrentRow(eventList->row(item)); + emit eventSelected(item->data(EventIndexRole).toInt()); + }); + + if (hasEvents) { + setCurrentRow(-1); + } +} + +void ReplayEventSidebar::setCurrentEvent(int eventIndex) +{ + if (eventIndices.isEmpty()) { + setCurrentRow(-1); + return; + } + + // The rows hold a subset of the replay's events, in playback order, so the last row at or before eventIndex is the + // one the game state has reached. An eventIndex of -1 (nothing replayed yet) selects no row. + const auto it = std::upper_bound(eventIndices.cbegin(), eventIndices.cend(), eventIndex); + const int row = static_cast(it - eventIndices.cbegin()) - 1; + setCurrentRow(row); +} + +void ReplayEventSidebar::setCurrentRow(int row) +{ + if (row < 0 || row >= eventList->count()) { + row = -1; + } + + if (row >= 0) { + // The view's current index also moves on mouse press and can be deselected by a ctrl-click, + // so the cached row alone cannot tell whether the view is already in the desired state. + const QListWidgetItem *item = eventList->item(row); + if (row == eventList->currentRow() && item && item->isSelected()) { + return; + } + } else if (row == currentRow) { + return; + } + + currentRow = row; + eventList->setCurrentRow(currentRow); + + if (currentRow >= 0) { + QListWidgetItem *item = eventList->item(currentRow); + if (!eventList->viewport()->rect().contains(eventList->visualItemRect(item))) { + eventList->scrollToItem(item, QAbstractItemView::PositionAtCenter); + } + } +} diff --git a/cockatrice/src/interface/widgets/replay/replay_event_sidebar.h b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.h new file mode 100644 index 000000000..ee163356e --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.h @@ -0,0 +1,50 @@ +#ifndef COCKATRICE_REPLAY_EVENT_SIDEBAR_H +#define COCKATRICE_REPLAY_EVENT_SIDEBAR_H + +#include +#include +#include + +class GameReplay; +class QLabel; +class QListWidget; + +/** + * @brief A scrollable list of replay events that can be used to seek the replay. + * + * The sidebar copies the event data it needs at construction time. It does not keep a reference to the replay or to + * the replay manager. + */ +class ReplayEventSidebar : public QWidget +{ + Q_OBJECT + +public: + /** + * @param parent The parent widget + * @param replay The replay to build the event list from + * @param timeline Event index to time in milliseconds, as used by ReplayManager + */ + ReplayEventSidebar(QWidget *parent, const GameReplay *replay, const QList &timeline); + +public slots: + /** + * @brief Highlights the row of the event the game state has reached. + * + * @param eventIndex Index into the replay's event list, or -1 when nothing has been replayed yet + */ + void setCurrentEvent(int eventIndex); + +signals: + void eventSelected(int eventIndex); + +private: + void setCurrentRow(int row); + + QListWidget *eventList; + QLabel *emptyLabel; + QList eventIndices; ///< Replay event index of each row, ascending + int currentRow = -1; +}; + +#endif // COCKATRICE_REPLAY_EVENT_SIDEBAR_H From 7b4827dadd65c8b7a45c2cd721f232423bc8d537 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:26:51 +0100 Subject: [PATCH 046/104] [Replay] Show the history sidebar in replay tabs (#7613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Game History dock to the left of replay tabs, wire it to the replay timeline, and register it with the View menu and layout reset. Co-authored-by: Lukas Brübach --- .../interface/widgets/replay/replay_manager.h | 10 +++++ .../widgets/replay/replay_widget.cpp | 5 +++ .../interface/widgets/replay/replay_widget.h | 5 +++ .../src/interface/widgets/tabs/tab_game.cpp | 40 ++++++++++++++++++- .../src/interface/widgets/tabs/tab_game.h | 3 +- 5 files changed, 61 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index 34bae5f9a..e3745778e 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -66,6 +66,16 @@ public: return replayTimeline; } + /** + * @brief Index of the last event the game state has processed. + * + * @return The event index, or -1 while no event has been replayed yet + */ + [[nodiscard]] int getLastProcessedEventIndex() const + { + return currentEvent - 1; + } + void setTimeScaleFactor(qreal _timeScaleFactor); void setSkipEmptySections(bool value); diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp index 3f0bf35d9..e911bc0f9 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp @@ -19,6 +19,11 @@ class GameReplay; +int ReplayWidget::getLastProcessedEventIndex() const +{ + return replayManager->getLastProcessedEventIndex(); +} + ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) : QWidget(parent), replayPlayButton(nullptr), replayFastForwardButton(nullptr), aReplaySkipForward(nullptr), aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr), aReplaySkipBackwardBig(nullptr) diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h index 5d23b358d..cbb676288 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -38,6 +38,11 @@ public: [[nodiscard]] const QList &getReplayTimeline() const; + /** + * @brief Index of the last replay event the game state has processed, or -1 before the first event. + */ + [[nodiscard]] int getLastProcessedEventIndex() const; + public slots: void seekToEvent(int eventIndex); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 34d6f8a51..ac9edd1ba 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -27,6 +27,7 @@ #include "../interface/widgets/utility/line_edit_completer.h" #include "../main.h" #include "../menus/tearoff_menu.h" +#include "../replay/replay_event_sidebar.h" #include "../replay/replay_widget.h" #include "../utility/visibility_change_listener.h" #include "card/card_completer_proxy_model.h" @@ -98,7 +99,7 @@ class Message; } // namespace google TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) - : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr) + : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr), replayDock(nullptr), historyDock(nullptr) { setObjectName("TabGame"); // THIS CTOR IS USED ON REPLAY @@ -115,6 +116,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) addDockWidget(Qt::RightDockWidgetArea, cardInfoDock); addDockWidget(Qt::RightDockWidgetArea, playerListDock); addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock); + addDockWidget(Qt::LeftDockWidgetArea, historyDock); addDockWidget(Qt::BottomDockWidgetArea, replayDock); mainWidget = new QStackedWidget(this); @@ -158,6 +160,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, createPlayAreaWidget(); createDeckViewContainerWidget(); replayDock = nullptr; + historyDock = nullptr; addDockWidget(Qt::RightDockWidgetArea, cardInfoDock); addDockWidget(Qt::RightDockWidgetArea, playerListDock); @@ -353,6 +356,9 @@ void TabGame::retranslateUi() } cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString())); messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString())); + if (historyDock) { + historyDock->setWindowTitle(tr("Game History") + (historyDock->isWindow() ? tabText : QString())); + } if (replayDock) { replayDock->setWindowTitle(tr("Replay Timeline") + (replayDock->isWindow() ? tabText : QString())); } @@ -429,6 +435,9 @@ void TabGame::retranslateUi() dockToActions[messageLayoutDock].menu->setTitle(tr("Messages")); dockToActions[playerListDock].menu->setTitle(tr("Player List")); + if (historyDock) { + dockToActions[historyDock].menu->setTitle(tr("Game History")); + } if (replayDock) { dockToActions[replayDock].menu->setTitle(tr("Replay Timeline")); } @@ -1291,6 +1300,10 @@ void TabGame::createViewMenuItems() registerDockWidget(viewMenu, messageLayoutDock, {250, 200}); registerDockWidget(viewMenu, playerListDock, {250, 50}); + if (historyDock) { + registerDockWidget(viewMenu, historyDock, {280, 400}); + } + if (replayDock) { registerDockWidget(viewMenu, replayDock, {900, 100}); } @@ -1360,6 +1373,12 @@ void TabGame::actResetLayout() addDockWidget(Qt::RightDockWidgetArea, playerListDock); addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock); + if (historyDock) { + historyDock->setVisible(true); + historyDock->setFloating(false); + addDockWidget(Qt::LeftDockWidgetArea, historyDock); + } + if (replayDock) { replayDock->setVisible(true); replayDock->setFloating(false); @@ -1368,6 +1387,9 @@ void TabGame::actResetLayout() cardInfoDock->resize(250, 360); messageLayoutDock->resize(250, 200); playerListDock->resize(250, 50); + if (historyDock) { + historyDock->resize(280, 400); + } replayDock->resize(900, 100); } else { cardInfoDock->resize(250, 360); @@ -1422,6 +1444,22 @@ void TabGame::createReplayDock(GameReplay *replay) [this](const auto &event, auto options) { game->getGameEventHandler()->processGameEventContainer(event, nullptr, options); }); + + auto *historySidebar = new ReplayEventSidebar(nullptr, replay, replayWidget->getReplayTimeline()); + historyDock = new QDockWidget(this); + historyDock->setObjectName("historyDock"); + historyDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable | + QDockWidget::DockWidgetMovable); + historyDock->setWidget(historySidebar); + historyDock->setFloating(false); + + connect(historySidebar, &ReplayEventSidebar::eventSelected, replayWidget, &ReplayWidget::seekToEvent); + // The sidebar highlights events rather than times, so ask the manager which event the game state has reached. + connect(replayWidget, &ReplayWidget::timeChanged, historySidebar, + [this, historySidebar] { historySidebar->setCurrentEvent(replayWidget->getLastProcessedEventIndex()); }); + + // No event has been replayed yet when the sidebar is connected, so apply the initial state explicitly. + historySidebar->setCurrentEvent(replayWidget->getLastProcessedEventIndex()); } void TabGame::createDeckViewContainerWidget(bool bReplay) diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 412529dc2..142c02999 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -50,6 +50,7 @@ class AbstractClient; class GameView; class GameScene; class ReplayWidget; +class ReplayEventSidebar; class CardInfoFrameWidget; class QLabel; class QPushButton; @@ -89,7 +90,7 @@ private: QMap deckViewContainers; QVBoxLayout *deckViewContainerLayout; QWidget *gamePlayAreaWidget, *deckViewContainerWidget; - QDockWidget *cardInfoDock, *messageLayoutDock, *playerListDock, *replayDock; + QDockWidget *cardInfoDock, *messageLayoutDock, *playerListDock, *replayDock, *historyDock; QAction *playersSeparator; QMenu *gameMenu, *viewMenu; TearOffMenu *phasesMenu; From b04d8b88ac52849784efd9f5cdac8c466c531e22 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:40 +0100 Subject: [PATCH 047/104] [Icons] Add SVG currentColor sentinel resolver (#7593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qt's SVG renderer has no CSS cascade, so a `currentColor` fill has to be substituted before the bytes reach QSvgRenderer. - Match and replace on bytes: both the sentinel and the replacement are ASCII, so the document never goes through a UTF-16 round trip, and files that are not valid UTF-8 survive unchanged. - Document that the match is a plain case-insensitive substring, so an identifier that merely starts with the sentinel gets rewritten too. Co-authored-by: Lukas Brübach --- libcockatrice_utility/CMakeLists.txt | 2 + .../libcockatrice/utility/svg_recolor.cpp | 44 +++++++++++++ .../libcockatrice/utility/svg_recolor.h | 31 +++++++++ tests/CMakeLists.txt | 1 + tests/svg_recolor_test.cpp | 66 +++++++++++++++++++ 5 files changed, 144 insertions(+) create mode 100644 libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp create mode 100644 libcockatrice_utility/libcockatrice/utility/svg_recolor.h create mode 100644 tests/svg_recolor_test.cpp diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 8d2cb5b92..98ad40e69 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -8,6 +8,7 @@ set(UTILITY_SOURCES libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/report_categories.cpp libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/svg_recolor.cpp libcockatrice/utility/translation_loader.cpp libcockatrice/utility/user_data_backup.cpp libcockatrice/utility/warning_categories.cpp @@ -34,6 +35,7 @@ set(UTILITY_HEADERS libcockatrice/utility/zone_names.h libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h + libcockatrice/utility/svg_recolor.h libcockatrice/utility/translation_loader.h libcockatrice/utility/user_data_backup.h libcockatrice/utility/warning_categories.h diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp new file mode 100644 index 000000000..041e289fe --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp @@ -0,0 +1,44 @@ +#include "svg_recolor.h" + +#include + +namespace +{ +constexpr QByteArrayView SENTINEL = "currentColor"; + +/// Case-insensitive byte search for the sentinel, starting at @p from. +/// QByteArray::indexOf() only matches case-sensitively for QByteArrayView, and +/// its char overload cannot match a word. +qsizetype findSentinel(const QByteArray &bytes, qsizetype from) +{ + // QByteArrayView::sliced() is a view, so the scan does not allocate a copy + // at every byte position (QByteArray::sliced() would). + const QByteArrayView view(bytes); + for (qsizetype i = from; i + SENTINEL.size() <= view.size(); ++i) { + if (view.sliced(i, SENTINEL.size()).compare(SENTINEL, Qt::CaseInsensitive) == 0) { + return i; + } + } + return -1; +} +} // namespace + +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color) +{ + // The sentinel and the replacement are both ASCII, so stay in bytes: a + // QString round trip would decode the whole document (and mangle bytes that + // are not valid UTF-8) even on the no-op path. + qsizetype pos = findSentinel(svg, 0); + if (pos < 0) { + return svg; + } + + QByteArray resolved = svg; + while (pos >= 0) { + resolved.replace(pos, SENTINEL.size(), color); + // Skip past the inserted color: a caller's color never contains the + // sentinel, and this keeps the scan linear. + pos = findSentinel(resolved, pos + color.size()); + } + return resolved; +} diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h new file mode 100644 index 000000000..56d609b87 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h @@ -0,0 +1,31 @@ +/** + * @file svg_recolor.h + * @ingroup Core + */ + +#ifndef SVG_RECOLOR_H +#define SVG_RECOLOR_H + +#include + +/** + * @brief Resolves the `currentColor` sentinel in authored SVG data. + * + * Qt's SVG renderer has no CSS cascade or ambient color context, so the + * sentinel is not resolved by Qt. Callers pass the concrete color they want + * (for example a QPalette role serialized to #RRGGBB) and receive SVG bytes + * that are safe to hand to QSvgRenderer. + * + * Matching is a plain, case-insensitive substring search, not a CSS parse: an + * identifier that merely starts with the sentinel (for example + * `id="currentColorStop"`) is rewritten too. That is acceptable for the assets + * this project authors, and keeps the resolver dependency-free. + * + * @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved + * byte-for-byte, since the resolver never decodes the document. + * @param color Concrete SVG color (for example "#ff00ff"), as ASCII bytes. + * @return The recolored SVG source; the input is left untouched. + */ +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color); + +#endif // SVG_RECOLOR_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5b7e06c20..8b904d30a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -177,6 +177,7 @@ cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIB cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME svg_recolor_test SOURCES svg_recolor_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test( NAME diff --git a/tests/svg_recolor_test.cpp b/tests/svg_recolor_test.cpp new file mode 100644 index 000000000..abe1e8d1a --- /dev/null +++ b/tests/svg_recolor_test.cpp @@ -0,0 +1,66 @@ +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include + +namespace +{ + +TEST(SvgRecolor, ReplacesEverySentinel) +{ + const QByteArray svg = R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_EQ(result.count("#ff8800"), 2); + EXPECT_FALSE(result.contains("currentColor")); +} + +TEST(SvgRecolor, ReplacesSentinelRegardlessOfCase) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ff8800").count("#ff8800"), 2); +} + +TEST(SvgRecolor, LeavesTheRestOfTheDocumentIntact) +{ + const QByteArray svg = + R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#123456"); + + EXPECT_TRUE(result.contains(R"(viewBox="0 0 24 24")")); + EXPECT_TRUE(result.contains(R"(d="M0 0h24v24")")); + EXPECT_TRUE(result.contains("an ordinary comment")); +} + +TEST(SvgRecolor, DoesNotModifyTheInput) +{ + const QByteArray svg = R"()"; + const QByteArray original = svg; + + resolveSvgColorSentinel(svg, "#000000"); + + EXPECT_EQ(svg, original); +} + +TEST(SvgRecolor, PreservesBytesThatAreNotValidUtf8) +{ + QByteArray svg = ""; + svg.append("\xff\xfe"); + svg.append(R"()"); + + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_TRUE(result.contains("\xff\xfe")); + EXPECT_FALSE(result.contains("currentColor")); + EXPECT_EQ(result.count("#ff8800"), 1); +} + +TEST(SvgRecolor, NoSentinelIsANoOp) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg); +} + +} // namespace From 020d9e8dcb0da71a1588c51fbd947d7c45fb9ad3 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:41 +0100 Subject: [PATCH 048/104] [Icons] Add palette-aware themed icon engine (#7594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A QPixmap snapshot cannot follow palette changes, so expose a QIcon whose engine resolves the `currentColor` sentinel from the live palette on every pixmap request. Icons stay legible across light/dark switches without recreating actions or buttons. - Resolve the asset, its bytes and its intrinsic size once, and drop them on ThemeManager::themeChanged/paletteChanged. A repaint does no file I/O and no theme.cfg parse; the palette color stays part of the render cache key. - Centre the glyph on QRectF(rect).center(): QRect::center() floors and uses right = left + width - 1, which shifted every 16x16 item-view glyph up and left by a logical pixel and clipped its last row and column. - Take a raster override's intrinsic size from QImageReader instead of the 24x24 SVG default, so a non-square override reports the box it draws. - Set the device pixel ratio before inserting into the render cache, so a cache hit no longer detaches the pixmap on every paint. Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + cockatrice/src/interface/theme_icon.cpp | 300 ++++++++++++++++++++++++ cockatrice/src/interface/theme_icon.h | 50 ++++ oracle/CMakeLists.txt | 1 + 4 files changed, 352 insertions(+) create mode 100644 cockatrice/src/interface/theme_icon.cpp create mode 100644 cockatrice/src/interface/theme_icon.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index e99735bf7..f34961b0b 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -151,6 +151,7 @@ set(cockatrice_SOURCES src/interface/widgets/utility/reversed_completer_model.cpp src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp + src/interface/theme_icon.cpp src/interface/theme_manager.cpp src/interface/palette_editor/color_button.cpp src/interface/palette_editor/palette_generator.cpp diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp new file mode 100644 index 000000000..161c436ca --- /dev/null +++ b/cockatrice/src/interface/theme_icon.cpp @@ -0,0 +1,300 @@ +#include "theme_icon.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Palette color that replaces `currentColor`, following the icon mode. + */ +QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role) +{ + const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; + const QPalette::ColorRole effective = + mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role; + return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1(); +} + +/** + * @brief Reads an SVG through the `theme:` search path. + */ +QByteArray readSvg(const QString &resolvedPath) +{ + QFile file(QStringLiteral("theme:") + resolvedPath); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +/** + * @brief Renders SVG bytes, scaled to @p size when one is given. + * + * An empty size means the asset's intrinsic size; @p devicePixelRatio only + * applies in that case so HiDPI callers still get a crisp glyph. + */ +QPixmap renderSvg(const QByteArray &data, const QSize &size, qreal devicePixelRatio) +{ + QSvgRenderer renderer(data); + if (!renderer.isValid()) { + return {}; + } + + QSize renderSize = size; + if (renderSize.isEmpty()) { + renderSize = renderer.defaultSize(); + renderSize = + QSize(qRound(renderSize.width() * devicePixelRatio), qRound(renderSize.height() * devicePixelRatio)); + } + if (!renderSize.isValid() || renderSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(renderSize); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + renderer.render(&painter); + return pixmap; +} + +bool hasSentinel(const QByteArray &svg) +{ + return !svg.isEmpty() && QString::fromUtf8(svg).contains(QStringLiteral("currentColor"), Qt::CaseInsensitive); +} + +QString cacheKey(const QString &path, const QByteArray &variant, const QSize &size, qreal devicePixelRatio) +{ + return path + QLatin1Char('|') + QString::fromLatin1(variant) + QLatin1Char('|') + QString::number(size.width()) + + QLatin1Char('x') + QString::number(size.height()) + QLatin1Char('@') + QString::number(devicePixelRatio); +} + +/** + * @brief QIcon engine rendering a `theme:` SVG with `currentColor` resolved on demand. + * + * Qt's SVG renderer has no ambient color, so the sentinel is substituted from + * the current palette on every pixmap request. Asset resolution, the file bytes + * and the intrinsic size are cached until the theme changes: a repaint then + * does no file I/O at all. The render cache is keyed by resolved path and pixel + * size plus a variant: the palette color for sentinel SVGs, the icon mode for + * assets that carry their own colors. A palette change therefore produces new + * keys instead of reusing art built under the previous palette, and the + * non-sentinel path (raster overrides, plain SVGs) is cached like any other. + */ +class ThemeSvgIconEngine final : public QObject, public QIconEngine +{ +public: + ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role) + { + if (themeManager) { + // A theme switch changes the resolved asset (scheme-qualified + // variants, raster overrides) and a palette change invalidates + // every cached colour, so both drop the caches instead of + // re-reading theme.cfg and the assets on every repaint. + connect(themeManager, &ThemeManager::themeChanged, this, &ThemeSvgIconEngine::invalidate); + connect(themeManager, &ThemeManager::paletteChanged, this, &ThemeSvgIconEngine::invalidate); + } + } + + QIconEngine *clone() const override + { + return new ThemeSvgIconEngine(prefix, role); + } + + void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override + { + const qreal devicePixelRatio = painter->device() ? painter->device()->devicePixelRatioF() : 1.0; + const QSize logicalSize = fitToSize(rect.size()); + if (logicalSize.isEmpty()) { + return; + } + const QSize pixelSize(qRound(logicalSize.width() * devicePixelRatio), + qRound(logicalSize.height() * devicePixelRatio)); + const QPixmap pixmap = render(pixelSize, devicePixelRatio, mode); + if (!pixmap.isNull()) { + // QRect::center() would floor and drop the last row/column of a + // 16x16 glyph; the center has to be fractional so glyphs stay + // unshifted and unsclipped in item views. + const QSizeF contentSize = pixmap.deviceIndependentSize(); + const QRectF targetRect( + QRectF(rect).center() - QPointF(contentSize.width() / 2.0, contentSize.height() / 2.0), contentSize); + painter->drawPixmap(targetRect, pixmap, QRectF(pixmap.rect())); + } + } + + QPixmap pixmap(const QSize &size, QIcon::Mode mode, QIcon::State) override + { + return render(fitToSize(size), 1.0, mode); + } + + QPixmap scaledPixmap(const QSize &size, QIcon::Mode mode, QIcon::State, qreal scale) override + { + const QSize logicalSize = fitToSize(size); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + const QSize pixelSize(qRound(logicalSize.width() * scale), qRound(logicalSize.height() * scale)); +#else + // Qt < 6.8 passes the device-dependent size to the engine. + const QSize pixelSize = logicalSize; +#endif + return render(pixelSize, scale, mode); + } + + QSize actualSize(const QSize &size, QIcon::Mode, QIcon::State) override + { + return fitToSize(size); + } + +private: + void invalidate() + { + hasResolvedAsset = false; + hasIntrinsicSize = false; + cache.clear(); + } + + void ensureResolved() const + { + if (hasResolvedAsset && (!themeManager || themeManager->getCurrentThemePath() == resolvedThemePath)) { + return; + } + resolvedThemePath = themeManager ? themeManager->getCurrentThemePath() : QString(); + resolvedPath = themeManager ? themeManager->assetPath(prefix) : QString(); + resolvedAssetIsSvg = resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive); + resolvedSvg = resolvedAssetIsSvg ? readSvg(resolvedPath) : QByteArray(); + resolvedHasSentinel = hasSentinel(resolvedSvg); + hasResolvedAsset = true; + hasIntrinsicSize = false; + } + + QSize intrinsicSize() const + { + ensureResolved(); + if (!hasIntrinsicSize) { + if (resolvedAssetIsSvg) { + QSvgRenderer renderer(resolvedSvg); + intrinsicSizeCache = renderer.isValid() ? renderer.defaultSize() : QSize(); + } else { + // A theme may override a glyph with a raster; take its real + // aspect ratio instead of assuming the 24x24 SVG default, so + // actualSize() and the rendered pixmap agree. + QImageReader reader(QStringLiteral("theme:") + resolvedPath); + intrinsicSizeCache = reader.size(); + } + if (intrinsicSizeCache.isEmpty()) { + intrinsicSizeCache = QSize(24, 24); + } + hasIntrinsicSize = true; + } + return intrinsicSizeCache; + } + + QSize fitToSize(const QSize &size) const + { + if (size.isEmpty()) { + return size; + } + return intrinsicSize().scaled(size, Qt::KeepAspectRatio); + } + + QPixmap fromAsset(const QSize &pixelSize, QIcon::Mode mode) const + { + QPixmap pixmap(QStringLiteral("theme:") + resolvedPath); + if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) { + pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + if (mode == QIcon::Disabled && !pixmap.isNull()) { + pixmap = QApplication::style()->generatedIconPixmap(QIcon::Disabled, pixmap, nullptr); + } + return pixmap; + } + + QPixmap render(const QSize &pixelSize, qreal devicePixelRatio, QIcon::Mode mode) const + { + ensureResolved(); + + // Same cache for both paths: sentinel art varies by palette color, art + // with its own colors varies by mode (Disabled is generated). A color + // variant is never a bare digit, so the two key spaces cannot collide. + const QByteArray variant = + resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast(mode)); + const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio); + const auto cached = cache.constFind(key); + if (cached != cache.constEnd()) { + return cached.value(); + } + + QPixmap pixmap; + if (resolvedHasSentinel) { + pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio); + } else { + // Authored art carries its own colors: use Qt's normal loader so + // raster overrides and non-SVG assets keep working. + pixmap = fromAsset(pixelSize, mode); + } + if (!pixmap.isNull()) { + // Set the ratio before caching: assigning it to a cached pixmap + // afterwards detaches (deep-copies) it on every paint. + pixmap.setDevicePixelRatio(devicePixelRatio); + } + if (cache.size() >= 64) { + cache.clear(); + } + cache.insert(key, pixmap); + return pixmap; + } + + QString prefix; + QPalette::ColorRole role; + mutable QHash cache; + mutable QString resolvedPath; + mutable QString resolvedThemePath; + mutable QByteArray resolvedSvg; + mutable QSize intrinsicSizeCache; + mutable bool resolvedAssetIsSvg = false; + mutable bool resolvedHasSentinel = false; + mutable bool hasResolvedAsset = false; + mutable bool hasIntrinsicSize = false; +}; +} // namespace + +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role) +{ + return QIcon(new ThemeSvgIconEngine(prefix.toString(), role)); +} + +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size) +{ + if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) { + return {}; + } + + const QByteArray svg = readSvg(resolvedPath); + if (!hasSentinel(svg)) { + return {}; + } + + const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1(); + return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0); +} diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h new file mode 100644 index 000000000..e26015ea1 --- /dev/null +++ b/cockatrice/src/interface/theme_icon.h @@ -0,0 +1,50 @@ +/** + * @file theme_icon.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_H +#define THEME_ICON_H + +#include +#include +#include +#include +#include +#include + +/** + * @brief Builds a palette-aware icon from a `theme:` asset. + * + * SVG glyphs may use the `currentColor` sentinel. A plain QPixmap snapshot + * cannot follow palette changes, so this returns a QIcon whose engine resolves + * the sentinel from the current palette every time it is asked for a pixmap. + * Icons therefore stay legible when the user switches between light and dark + * themes without recreating any actions or buttons. + * + * @param prefix Asset prefix relative to the `theme:` search path, with no file + * extension (for example `icons/search`), matching themePixmap(). + * @param role Palette role whose color replaces the sentinel. Selected glyphs + * use HighlightedText unless a role other than WindowText is given. + * @return An icon that follows the application palette. + */ +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText); + +/** + * @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel. + * + * Non-SVG assets and SVGs without the sentinel return a null pixmap so callers + * can fall back to their usual loading path (including legacy inversion). + * + * @param resolvedPath Asset path relative to the `theme:` search path, as + * returned by ThemeManager::assetPath (includes extension). + * @param role Palette role whose color replaces the sentinel. + * @param size Pixel size to render at; an empty size uses the SVG's intrinsic size. + * @return The recolored pixmap, or a null pixmap when the asset is not a + * sentinel-carrying SVG. + */ +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, + QPalette::ColorRole role = QPalette::WindowText, + const QSize &size = {}); + +#endif // THEME_ICON_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index f68f51f23..4ca2acd07 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -31,6 +31,7 @@ set(oracle_SOURCES ../cockatrice/src/client/network/update/client/release_channel.cpp ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp + ../cockatrice/src/interface/theme_icon.cpp ../cockatrice/src/interface/theme_manager.cpp ${VERSION_STRING_CPP} ) From 627c7a83aafee46f51ca7f5e58affb55af561424 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:41 +0100 Subject: [PATCH 049/104] [Icons] Migrate UI glyphs to palette-token SVGs (#7595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Icons] Migrate UI glyphs to palette-token SVGs Runtime inversion is not a theming system: it rotates hue, breaks duotone art and mishandles alpha, and a QPixmap snapshot cannot follow a palette change. Redraw the top-level UI glyph set on a 24x24 grid with `currentColor` as the paint token, drop Inkscape/Sodipodi cruft, and delete the icons whose metaphor was duplicated (cogwheel/gear -> settings, scale_balanced -> scales, update.png -> reload). Load the glyphs through `loadThemedIcon` so the engine resolves the token from the palette on every request, and let `themePixmap` use the same resolution for one-shot pixmaps. `loadColorAdjustedPixmap` is gone; every caller now keeps a QIcon, which `SettingsButtonWidget::setButtonIcon` accepts instead of a QPixmap. Content art (mana, counters, countries) keeps its authored colors. Add a render test covering aspect ratio, palette flips, icon modes and high-DPI pixmaps. * [Tests] Keep the icon render test QApplication alive until exit Creating it as a function-local static tears it down before Qt's library statics at exit, and libQt6Network then runs QObject destructors against freed memory — a SEGV the sanitizer leg reports after the tests have already passed. Leak the application instead, the usual pattern for a test binary. * [Icons] Render lock and dropdown glyphs through the icon engine Both generators rendered the sentinel SVG to a fixed height x height pixmap at DPR 1. The pre-migration path rendered larger and let QIcon scale it, so on a 2x display the games-list lock (13px) and the banner dropdown arrow (24px) came back crisp; the new path hands the widget a DPR-1 bitmap that gets upscaled and blurred. - The games list asks the themed engine for `icons/lock` directly, so the item view paints it at the device pixel ratio and the lock also follows HighlightedText on a selected row. - The dropdown goes through `loadThemedIcon(...).pixmap(size, dpr)`, which keeps the glyph's aspect ratio and renders at the requested ratio. - LockPixmapGenerator has no consumer left, so it and its cache are gone. --------- Co-authored-by: Lukas Brübach --- cockatrice/cockatrice.qrc | 18 +- .../resources/icons/arrow_bottom_green.svg | 793 ------------------ cockatrice/resources/icons/arrow_down.svg | 4 + .../resources/icons/arrow_down_green.svg | 793 ------------------ cockatrice/resources/icons/arrow_history.svg | 13 +- cockatrice/resources/icons/arrow_left.svg | 4 + .../resources/icons/arrow_left_green.svg | 791 ----------------- cockatrice/resources/icons/arrow_redo.svg | 44 +- cockatrice/resources/icons/arrow_right.svg | 4 + .../resources/icons/arrow_right_blue.svg | 789 ----------------- .../resources/icons/arrow_right_green.svg | 789 ----------------- .../resources/icons/arrow_right_sideboard.svg | 5 + .../resources/icons/arrow_to_bottom.svg | 5 + cockatrice/resources/icons/arrow_to_top.svg | 5 + .../resources/icons/arrow_top_green.svg | 793 ------------------ cockatrice/resources/icons/arrow_undo.svg | 44 +- cockatrice/resources/icons/arrow_up.svg | 4 + cockatrice/resources/icons/arrow_up_green.svg | 792 ----------------- .../resources/icons/circle_half_stroke.svg | 5 +- cockatrice/resources/icons/clearsearch.svg | 278 +----- cockatrice/resources/icons/cogwheel.svg | 29 - cockatrice/resources/icons/conceded.svg | 105 +-- cockatrice/resources/icons/decrement.svg | 206 +---- cockatrice/resources/icons/delete.svg | 360 +------- cockatrice/resources/icons/dragon.svg | 7 +- .../resources/icons/dropdown_collapsed.svg | 11 +- .../resources/icons/dropdown_expanded.svg | 11 +- cockatrice/resources/icons/filter.svg | 15 +- cockatrice/resources/icons/floppy_disk.svg | 6 +- .../resources/icons/forgot_password.svg | 10 +- cockatrice/resources/icons/gear.svg | 1 - cockatrice/resources/icons/increment.svg | 201 +---- cockatrice/resources/icons/info.svg | 46 +- cockatrice/resources/icons/lock.svg | 752 +---------------- .../resources/icons/not_ready_start.svg | 370 +------- cockatrice/resources/icons/pen_to_square.svg | 5 +- cockatrice/resources/icons/pencil.svg | 125 +-- cockatrice/resources/icons/pin.svg | 26 +- cockatrice/resources/icons/player.svg | 148 +--- cockatrice/resources/icons/ready_start.svg | 82 +- cockatrice/resources/icons/reload.svg | 11 +- cockatrice/resources/icons/remove_row.svg | 358 +------- cockatrice/resources/icons/rename.svg | 15 +- cockatrice/resources/icons/scale_balanced.svg | 1 - cockatrice/resources/icons/scales.svg | 11 +- cockatrice/resources/icons/scroll.svg | 7 +- cockatrice/resources/icons/search.svg | 202 +---- cockatrice/resources/icons/settings.svg | 282 +------ cockatrice/resources/icons/share.svg | 30 +- .../resources/icons/sort_arrow_down.svg | 8 +- cockatrice/resources/icons/spectator.svg | 108 +-- cockatrice/resources/icons/swap.svg | 18 +- cockatrice/resources/icons/sync.svg | 10 +- cockatrice/resources/icons/tab_changed.svg | 118 +-- cockatrice/resources/icons/update.png | Bin 7262 -> 0 bytes cockatrice/resources/icons/view.svg | 33 +- cockatrice/src/filters/filter_builder.cpp | 4 +- .../player/player_list_widget.cpp | 15 +- .../game_graphics/zones/view_zone_widget.cpp | 5 +- .../src/interface/pixel_map_generator.cpp | 54 +- .../src/interface/pixel_map_generator.h | 23 +- .../abstract_analytics_panel_widget.cpp | 4 +- .../deck_editor_database_display_widget.cpp | 10 +- .../deck_editor_deck_dock_widget.cpp | 10 +- .../deck_editor_filter_dock_widget.cpp | 6 +- .../deck_list_history_manager_widget.cpp | 8 +- .../interface/widgets/dialogs/dlg_connect.cpp | 8 +- .../widgets/dialogs/dlg_edit_tokens.cpp | 6 +- .../widgets/dialogs/dlg_manage_sets.cpp | 12 +- .../widgets/dialogs/dlg_register.cpp | 6 +- .../widgets/dialogs/settings_view.cpp | 4 +- .../quick_settings/settings_button_widget.cpp | 10 +- .../quick_settings/settings_button_widget.h | 4 +- .../widgets/server/game_selector.cpp | 6 +- .../interface/widgets/server/games_model.cpp | 8 +- .../interface/widgets/server/games_model.h | 2 + .../remote/remote_replay_list_tree_widget.cpp | 4 +- .../deck_editor_settings_page.cpp | 10 +- .../settings_page/messages_settings_page.cpp | 8 +- .../settings_page/shortcut_settings_page.cpp | 6 +- .../tabs/api/archidekt/tab_archidekt.cpp | 4 +- .../commander_bracket_widget.cpp | 4 +- .../widgets/tabs/tab_deck_storage.cpp | 20 +- .../widgets/tabs/tab_public_decks.cpp | 7 +- .../interface/widgets/tabs/tab_replays.cpp | 20 +- .../src/interface/widgets/tabs/tab_room.cpp | 4 +- .../interface/widgets/tabs/tab_supervisor.cpp | 3 +- .../widgets/utility/sequence_edit.cpp | 6 +- ...database_display_filter_toolbar_widget.cpp | 20 +- .../visual_database_display_widget.cpp | 10 +- .../visual_deck_display_options_widget.cpp | 8 +- .../visual_deck_editor_widget.cpp | 4 +- .../visual_deck_storage_search_widget.cpp | 6 +- .../visual_deck_storage_widget.cpp | 6 +- cockatrice/src/interface/window_main.cpp | 5 +- tests/theme_migration/CMakeLists.txt | 4 + .../theme_icon_render_test.cpp | 189 +++++ 97 files changed, 543 insertions(+), 9711 deletions(-) delete mode 100644 cockatrice/resources/icons/arrow_bottom_green.svg create mode 100644 cockatrice/resources/icons/arrow_down.svg delete mode 100644 cockatrice/resources/icons/arrow_down_green.svg create mode 100644 cockatrice/resources/icons/arrow_left.svg delete mode 100644 cockatrice/resources/icons/arrow_left_green.svg create mode 100644 cockatrice/resources/icons/arrow_right.svg delete mode 100644 cockatrice/resources/icons/arrow_right_blue.svg delete mode 100644 cockatrice/resources/icons/arrow_right_green.svg create mode 100644 cockatrice/resources/icons/arrow_right_sideboard.svg create mode 100644 cockatrice/resources/icons/arrow_to_bottom.svg create mode 100644 cockatrice/resources/icons/arrow_to_top.svg delete mode 100644 cockatrice/resources/icons/arrow_top_green.svg create mode 100644 cockatrice/resources/icons/arrow_up.svg delete mode 100644 cockatrice/resources/icons/arrow_up_green.svg delete mode 100644 cockatrice/resources/icons/cogwheel.svg delete mode 100644 cockatrice/resources/icons/gear.svg delete mode 100644 cockatrice/resources/icons/scale_balanced.svg delete mode 100644 cockatrice/resources/icons/update.png create mode 100644 tests/theme_migration/theme_icon_render_test.cpp diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 4ec3d2385..229cdd125 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -6,19 +6,18 @@ resources/hand.svg resources/hr.jpg - resources/icons/arrow_bottom_green.svg - resources/icons/arrow_down_green.svg + resources/icons/arrow_down.svg resources/icons/arrow_history.svg - resources/icons/arrow_left_green.svg + resources/icons/arrow_left.svg resources/icons/arrow_redo.svg - resources/icons/arrow_right_blue.svg - resources/icons/arrow_right_green.svg - resources/icons/arrow_top_green.svg - resources/icons/arrow_up_green.svg + resources/icons/arrow_right.svg + resources/icons/arrow_right_sideboard.svg + resources/icons/arrow_to_bottom.svg + resources/icons/arrow_to_top.svg + resources/icons/arrow_up.svg resources/icons/arrow_undo.svg resources/icons/circle_half_stroke.svg resources/icons/clearsearch.svg - resources/icons/cogwheel.svg resources/icons/conceded.svg resources/icons/decrement.svg resources/icons/delete.svg @@ -28,7 +27,6 @@ resources/icons/filter.svg resources/icons/floppy_disk.svg resources/icons/forgot_password.svg - resources/icons/gear.svg resources/icons/increment.svg resources/icons/info.svg resources/icons/lock.svg @@ -41,7 +39,6 @@ resources/icons/reload.svg resources/icons/remove_row.svg resources/icons/rename.svg - resources/icons/scale_balanced.svg resources/icons/scales.svg resources/icons/scroll.svg resources/icons/search.svg @@ -52,7 +49,6 @@ resources/icons/swap.svg resources/icons/sync.svg resources/icons/tab_changed.svg - resources/icons/update.png resources/icons/view.svg resources/icons/mana/B.svg diff --git a/cockatrice/resources/icons/arrow_bottom_green.svg b/cockatrice/resources/icons/arrow_bottom_green.svg deleted file mode 100644 index 3a45053ad..000000000 --- a/cockatrice/resources/icons/arrow_bottom_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_down.svg b/cockatrice/resources/icons/arrow_down.svg new file mode 100644 index 000000000..0ff237f52 --- /dev/null +++ b/cockatrice/resources/icons/arrow_down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_down_green.svg b/cockatrice/resources/icons/arrow_down_green.svg deleted file mode 100644 index 574c12060..000000000 --- a/cockatrice/resources/icons/arrow_down_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_history.svg b/cockatrice/resources/icons/arrow_history.svg index d130758e6..29374d23a 100644 --- a/cockatrice/resources/icons/arrow_history.svg +++ b/cockatrice/resources/icons/arrow_history.svg @@ -1,8 +1,5 @@ - - - - history - - - - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/arrow_left.svg b/cockatrice/resources/icons/arrow_left.svg new file mode 100644 index 000000000..7a88deddb --- /dev/null +++ b/cockatrice/resources/icons/arrow_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_left_green.svg b/cockatrice/resources/icons/arrow_left_green.svg deleted file mode 100644 index 1b9eb0684..000000000 --- a/cockatrice/resources/icons/arrow_left_green.svg +++ /dev/null @@ -1,791 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_redo.svg b/cockatrice/resources/icons/arrow_redo.svg index dd89e3314..141260269 100644 --- a/cockatrice/resources/icons/arrow_redo.svg +++ b/cockatrice/resources/icons/arrow_redo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_right.svg b/cockatrice/resources/icons/arrow_right.svg new file mode 100644 index 000000000..f0ac5d5bf --- /dev/null +++ b/cockatrice/resources/icons/arrow_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_right_blue.svg b/cockatrice/resources/icons/arrow_right_blue.svg deleted file mode 100644 index 334cd68fd..000000000 --- a/cockatrice/resources/icons/arrow_right_blue.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_green.svg b/cockatrice/resources/icons/arrow_right_green.svg deleted file mode 100644 index db491985d..000000000 --- a/cockatrice/resources/icons/arrow_right_green.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_sideboard.svg b/cockatrice/resources/icons/arrow_right_sideboard.svg new file mode 100644 index 000000000..79a8cd5d3 --- /dev/null +++ b/cockatrice/resources/icons/arrow_right_sideboard.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_bottom.svg b/cockatrice/resources/icons/arrow_to_bottom.svg new file mode 100644 index 000000000..291a527d2 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_bottom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_top.svg b/cockatrice/resources/icons/arrow_to_top.svg new file mode 100644 index 000000000..aac38cc33 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_top.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_top_green.svg b/cockatrice/resources/icons/arrow_top_green.svg deleted file mode 100644 index c515c6a1d..000000000 --- a/cockatrice/resources/icons/arrow_top_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_undo.svg b/cockatrice/resources/icons/arrow_undo.svg index 2e182ffb2..c748b37b8 100644 --- a/cockatrice/resources/icons/arrow_undo.svg +++ b/cockatrice/resources/icons/arrow_undo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_up.svg b/cockatrice/resources/icons/arrow_up.svg new file mode 100644 index 000000000..ac32b5c92 --- /dev/null +++ b/cockatrice/resources/icons/arrow_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_up_green.svg b/cockatrice/resources/icons/arrow_up_green.svg deleted file mode 100644 index 1d0f61112..000000000 --- a/cockatrice/resources/icons/arrow_up_green.svg +++ /dev/null @@ -1,792 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/circle_half_stroke.svg b/cockatrice/resources/icons/circle_half_stroke.svg index 42e4dbca2..c16f84858 100644 --- a/cockatrice/resources/icons/circle_half_stroke.svg +++ b/cockatrice/resources/icons/circle_half_stroke.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/clearsearch.svg b/cockatrice/resources/icons/clearsearch.svg index be50d68d6..49d883c68 100644 --- a/cockatrice/resources/icons/clearsearch.svg +++ b/cockatrice/resources/icons/clearsearch.svg @@ -1,276 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/cogwheel.svg b/cockatrice/resources/icons/cogwheel.svg deleted file mode 100644 index 8c7aad617..000000000 --- a/cockatrice/resources/icons/cogwheel.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cockatrice/resources/icons/conceded.svg b/cockatrice/resources/icons/conceded.svg index a487f18bb..b9faf047c 100644 --- a/cockatrice/resources/icons/conceded.svg +++ b/cockatrice/resources/icons/conceded.svg @@ -1,103 +1,4 @@ - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/decrement.svg b/cockatrice/resources/icons/decrement.svg index 0ceddf69f..033245f85 100644 --- a/cockatrice/resources/icons/decrement.svg +++ b/cockatrice/resources/icons/decrement.svg @@ -1,205 +1,3 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + diff --git a/cockatrice/resources/icons/delete.svg b/cockatrice/resources/icons/delete.svg index 008d8d429..d79312f1e 100644 --- a/cockatrice/resources/icons/delete.svg +++ b/cockatrice/resources/icons/delete.svg @@ -1,357 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/dragon.svg b/cockatrice/resources/icons/dragon.svg index f45af39ef..cc48d1f5c 100644 --- a/cockatrice/resources/icons/dragon.svg +++ b/cockatrice/resources/icons/dragon.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/dropdown_collapsed.svg b/cockatrice/resources/icons/dropdown_collapsed.svg index 52e833e65..429c24974 100644 --- a/cockatrice/resources/icons/dropdown_collapsed.svg +++ b/cockatrice/resources/icons/dropdown_collapsed.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/dropdown_expanded.svg b/cockatrice/resources/icons/dropdown_expanded.svg index fa3ee205c..0b24aeb48 100644 --- a/cockatrice/resources/icons/dropdown_expanded.svg +++ b/cockatrice/resources/icons/dropdown_expanded.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/filter.svg b/cockatrice/resources/icons/filter.svg index d53d9ba29..d9cdf879c 100644 --- a/cockatrice/resources/icons/filter.svg +++ b/cockatrice/resources/icons/filter.svg @@ -1,12 +1,3 @@ - - - - - - - - \ No newline at end of file + + + diff --git a/cockatrice/resources/icons/floppy_disk.svg b/cockatrice/resources/icons/floppy_disk.svg index 3c5b5054d..10b9e8172 100644 --- a/cockatrice/resources/icons/floppy_disk.svg +++ b/cockatrice/resources/icons/floppy_disk.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/forgot_password.svg b/cockatrice/resources/icons/forgot_password.svg index f938ca3f4..3bfb3bdb1 100644 --- a/cockatrice/resources/icons/forgot_password.svg +++ b/cockatrice/resources/icons/forgot_password.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/gear.svg b/cockatrice/resources/icons/gear.svg deleted file mode 100644 index fdee3a297..000000000 --- a/cockatrice/resources/icons/gear.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/increment.svg b/cockatrice/resources/icons/increment.svg index 6d5f7a85a..52dbdb2aa 100644 --- a/cockatrice/resources/icons/increment.svg +++ b/cockatrice/resources/icons/increment.svg @@ -1,199 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/info.svg b/cockatrice/resources/icons/info.svg index 00b23cc2c..1305f6b3d 100644 --- a/cockatrice/resources/icons/info.svg +++ b/cockatrice/resources/icons/info.svg @@ -1,43 +1,5 @@ - - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/lock.svg b/cockatrice/resources/icons/lock.svg index b7ebf1a53..f150ece49 100644 --- a/cockatrice/resources/icons/lock.svg +++ b/cockatrice/resources/icons/lock.svg @@ -1,750 +1,4 @@ - - - - - - - - - padlock - - - - icon - - lock - security - secure - padlock - signs_and_symbols - - - - - AJ Ashton - - - - - AJ Ashton - - - - - AJ Ashton - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/not_ready_start.svg b/cockatrice/resources/icons/not_ready_start.svg index 73afda9db..c20a5cfe6 100644 --- a/cockatrice/resources/icons/not_ready_start.svg +++ b/cockatrice/resources/icons/not_ready_start.svg @@ -1,368 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pen_to_square.svg b/cockatrice/resources/icons/pen_to_square.svg index 60ff1d9c4..7d3e161ac 100644 --- a/cockatrice/resources/icons/pen_to_square.svg +++ b/cockatrice/resources/icons/pen_to_square.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/pencil.svg b/cockatrice/resources/icons/pencil.svg index c8b81f698..d274e98db 100644 --- a/cockatrice/resources/icons/pencil.svg +++ b/cockatrice/resources/icons/pencil.svg @@ -1,123 +1,4 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pin.svg b/cockatrice/resources/icons/pin.svg index 71d4c9c9c..9c173286b 100644 --- a/cockatrice/resources/icons/pin.svg +++ b/cockatrice/resources/icons/pin.svg @@ -1,24 +1,4 @@ - - - - - + + + - diff --git a/cockatrice/resources/icons/player.svg b/cockatrice/resources/icons/player.svg index e83b24654..9fbe39a11 100644 --- a/cockatrice/resources/icons/player.svg +++ b/cockatrice/resources/icons/player.svg @@ -1,142 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + + + + diff --git a/cockatrice/resources/icons/ready_start.svg b/cockatrice/resources/icons/ready_start.svg index 238d2aa46..4c4ea5d2d 100644 --- a/cockatrice/resources/icons/ready_start.svg +++ b/cockatrice/resources/icons/ready_start.svg @@ -1,81 +1,3 @@ - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + diff --git a/cockatrice/resources/icons/reload.svg b/cockatrice/resources/icons/reload.svg index 102995876..e09b1b7ef 100644 --- a/cockatrice/resources/icons/reload.svg +++ b/cockatrice/resources/icons/reload.svg @@ -1,7 +1,4 @@ - - - - Svg Vector Icons : http://www.onlinewebfonts.com/icon - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/remove_row.svg b/cockatrice/resources/icons/remove_row.svg index 6f133cdb6..5d00ed57c 100644 --- a/cockatrice/resources/icons/remove_row.svg +++ b/cockatrice/resources/icons/remove_row.svg @@ -1,356 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/rename.svg b/cockatrice/resources/icons/rename.svg index c68a00246..bab850808 100644 --- a/cockatrice/resources/icons/rename.svg +++ b/cockatrice/resources/icons/rename.svg @@ -1,10 +1,7 @@ - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scale_balanced.svg b/cockatrice/resources/icons/scale_balanced.svg deleted file mode 100644 index fae468d38..000000000 --- a/cockatrice/resources/icons/scale_balanced.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/scales.svg b/cockatrice/resources/icons/scales.svg index a410921dd..4b2e16fcc 100644 --- a/cockatrice/resources/icons/scales.svg +++ b/cockatrice/resources/icons/scales.svg @@ -1,6 +1,7 @@ - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scroll.svg b/cockatrice/resources/icons/scroll.svg index f4828e679..edcf29d11 100644 --- a/cockatrice/resources/icons/scroll.svg +++ b/cockatrice/resources/icons/scroll.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/search.svg b/cockatrice/resources/icons/search.svg index 05439a12e..be4a87f00 100644 --- a/cockatrice/resources/icons/search.svg +++ b/cockatrice/resources/icons/search.svg @@ -1,198 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/settings.svg b/cockatrice/resources/icons/settings.svg index 77a2ccfa9..3942a0916 100644 --- a/cockatrice/resources/icons/settings.svg +++ b/cockatrice/resources/icons/settings.svg @@ -1,280 +1,4 @@ - - - - - - - - Ruota dentata grigia - - - - unsorted - - - - - Open Clip Art Library - - - - - Architetto Francesco Rollandin - - - - - Architetto Francesco Rollandin - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/share.svg b/cockatrice/resources/icons/share.svg index a84f55847..9d6ff21a0 100644 --- a/cockatrice/resources/icons/share.svg +++ b/cockatrice/resources/icons/share.svg @@ -1,25 +1,7 @@ - - - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/sort_arrow_down.svg b/cockatrice/resources/icons/sort_arrow_down.svg index adf10f9f0..e10b401f2 100644 --- a/cockatrice/resources/icons/sort_arrow_down.svg +++ b/cockatrice/resources/icons/sort_arrow_down.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + diff --git a/cockatrice/resources/icons/spectator.svg b/cockatrice/resources/icons/spectator.svg index c580c448d..19242f058 100644 --- a/cockatrice/resources/icons/spectator.svg +++ b/cockatrice/resources/icons/spectator.svg @@ -1,106 +1,4 @@ - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/swap.svg b/cockatrice/resources/icons/swap.svg index a18ca2f06..4eddb8c08 100644 --- a/cockatrice/resources/icons/swap.svg +++ b/cockatrice/resources/icons/swap.svg @@ -1,12 +1,6 @@ - - - - - - - - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/sync.svg b/cockatrice/resources/icons/sync.svg index e287b1993..fa00b3024 100644 --- a/cockatrice/resources/icons/sync.svg +++ b/cockatrice/resources/icons/sync.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/tab_changed.svg b/cockatrice/resources/icons/tab_changed.svg index ae09ef8fd..1d9b8d09c 100644 --- a/cockatrice/resources/icons/tab_changed.svg +++ b/cockatrice/resources/icons/tab_changed.svg @@ -1,116 +1,4 @@ - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/update.png b/cockatrice/resources/icons/update.png deleted file mode 100644 index 825c0aee757322230b41bc9ef0be3ed08260a5d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7262 zcmV-k9HHZhP)004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00004XF*Lt006O$eEU(800001 zb5ch_0Itp)=>Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01m_e01m_fl`9S#000}c zNklkj_AbmvK$L*N79VLB$^l{SrNbeV4Yql9C0D@>IeUZ40 z6}OzmcjOHG1|yFLL^5D+BfVQBi%EbG1HORtWN{lCB5?hAEIl?zdOPVYVjnRH5O|9t zeF^DHNspgFaHm2rv<~&8H;~?A62O1ZQ$)~1F|tG98niwW$xwGSk|9_GKtZ;c^fc0w zrg>yf_h+J;s3g6Sbk}?*0Qzw!>8YYhpN%6sR0KlsYe+8>Ba3-OfJD->L_x>RB5*?i zzTWrdMb}VHdh69r01VSsh(11K;oA1?+mr6T`|jmgSy{;o7A#1Njg7V2Y&J(sOpGHe zEG!~AIvTg(%0DnPG}P%PZD3%av%kN;x390Sr@p?vskODW=lJpC4F?V!Xd4_HoCqOd z&@Egbz0>FkeRSV9a)0ySA9H6r0npT6D*|r`=E!~U!3USzb=O_XSFc{3m6n#aoB*B{ z5fNbz#g82yA0KINZ?7YysIIB0X?XL^H!mDHa-=H+772;)nGx6U=)MPeaC3RT*)cOD z&?5lG?%ATao#uOxZde>UKbx9zjP)|Ah8myRaI3N3kwS`KL7ml z=Ltc^g5++{HB^)SIe4;>)bAX7B=k--@0Kl#Z|R_@rb{``sEyLTTWuA(jot^vcx zGo*LO2=Ip-0e-;>2wN`i_tQuMC5v&?Un7QeQ&z^gxw$JJd+f1qq@<)=cQqtSh-N4z zL^wcPL}`Go0WH8e(u=*;00|&60E8XU9sK^G_j9L{0GPe|66jCHX#PktQf_(Rfd{_f za5&Q8Ds0`|-Oc; zN#woy>Z{j3{P4ryBno~x%!Ag|)z!FX&z|>w_`@H55r8#7vH9skd;hN73%cijaVuJa zy^s7kq$I$(^^Ia^=QsFIKKW$Ud+)vX1PSAEh>u?g^Q1*bN82}S*zlD{9(e=_-+JcE zneHjL1{`qd*FCWM9u@fBFoJ_kYwlZVz4Srp(NGfL8iU~bj$tVG(MKQU5I_GNV(!y8 zjkk z+qT4G$Btd@?Cdm@P+Cg};1hr1=lMt=6NRv8^*z^Giw~3^4TS~JWb31kKAQUOyYK$k z>2%&aU(G!WeC4qvOO|91L3|5|7|M%^ihA|V?YjF{ZU+hY>5I~#XRuckf<8WYx5OO; zmjp@zsJ14}S1;TwGj|3A-A6sTJ_Hh=_>jEnBwSx_G$!!jKvv{xi}Mi@c^&X2kxDJyL;QS!6bmuD8K}rGU92+3?|^@;7Z<}u{d2If*O3@ zs}FbH|Hig}a|Bl1FF4Oz%Rn<+>1%|>-a2RyXg4j%eL z&aY{iK3}p&A^N05LMwo~wJo)iVInUh-LCsz-x~JkfBx{9KnTE@;7>L;KLMrfC2+Cz z3n=d>6~I@IQpJ`5zRY(kf_!c8nVcc))il*k5=2TA9(LaQ`c~LKMG{#3 z%wAo!ZFp=5j@2E7(+$U=xxaZ5Fr^7rf-k|0mmw;34J!O6!RM8Pkh&3xC2#}OG}p*e z#o)^x?!5O`Ux)qwa`2fz2{7-#9~~csk1GEHg#`Q|*Pv>YD#2BYa>)T+GCHLOfab>) z$sReCBt#HjlDQ;@Gr$_?pd^P=UGNew_AA^r7{tDeAUDQ#^OGO?HD5(!u z=JKWPf|2RdvnZ3+LbL=+oWgE(Q;mA!Ncc_s&h5Ya`ov$JJZM-PTIQqa?;P%ce?R>* z*n8=>67bbPfntlWLry{h>^yA> z87+aE-?{BS?s(Q83ozx8-_z3rm6ervvBa!5`F}2Z7xonW7Yq@>r!`7>L!(p*B+c(@ z&hW9_iP8Wttb#MeDRf@(;>qvI?cDYYeG*s=CoD7?T7dfcdVvJV$;l8C6Eo|;|J{YR z;7sF5-BGFpH#$5995L}=kB*1vh)9UEMuWvS9U>KplRzj(t}JOJ6L)OUr}~$2+_^MU z_$}2}YCM8f;i^dBuygCHx50=1mjBFDM4;c+ps%k_=m`l4uz2xeh>D7u72v;7^h+VG z*Bhl?pyH$KkYrm7@dWe;YdEL^G+qg_pWs8_;Cl~dE_IUMNQ7ZywJOmhJm9#UTVJ_t z;=}Ld=T4gl?CR=*l9Cdrt*ynYYiAVvcP{=9Tx>fp!HuoX7sMvQ+NEn@_0l!4$hJrV zzS15m;N!K^#;SM7j)OM%DjBGD6IrR5LYG-JiLT%|*!A_7Z+q6L2n?Ru?(Bq1moCBO z%a@_Ix7VLsc~IJc^7r9X;|bkNUSwDVtX#4RvXisH7G& z`aRCnEaDQJD#7V{Imhq3^W{y?8qokjC4}4U7Qjcb7#SG}iHV7jl$0dwhg$6)sW~K1 zx=>QGsg9+jr-H>|(FU1k7#9Z1l9m&*zd(P4^J){UCHh$4)6%?J zieFm~$VrGApsJyoPI$op@OcNHD##7G2y{3cg76z3ANLESp`jrd92^v)hv?{Nu-olI z$IlDII-uWw`-?rEP>&({tVG3UC$A*t-mU>Mbxist`dHuxBKl~y1;MWt!B-L9b5f|_ zYlVS^M?Q{TtJMnq{rv){Q?(4(+uPgmLJFbB#l;Dqjby+c5fLFg#^=Jr!v&MAJn<+v zk2f5J%I*rf8N?q<-nS}wHN-~7DZpoV;b4e9@I&`^p(UY6emAy`-7E&0pS3QT%EO@lx;=0?{WN z1@QB8^%6lr^ZVYBik4ttV8Cy;5U}tYE`p(n0cx@b|4BV=xURI`Y)e$E>N)W(rtY(TnBj6hmfm3Se)6rZ{ClOrkQK%RxRpizu z@wZ3XVX0%Ov9M13XJ~8~itCGE*fkv33?F6gfLG4|%ZS5k1d2ZJ6%$O8uP_F_o(K$> z5J&{H3ZtW=g5bk#s9O1GDU9(h>O4ID2;;E?^DPfM%qAeJV0A6u&3V zuTlMNiN*07{Gf|Kzy2@@9gz&^CIkgfLO8S#NDO@JPpccPVUE&;wnakdry15sj(7BR zLEmtnT2=FGzi;G%5heJlh|X(9D5B2?KJY;WU&{haD;dx-AQ8Ob9gYNa5l9H!M?&CZ z&y**xvKXyn&CF4{#GY(e@#ChxvtIkMF8zD>WMLai^vO&=PszR*ivoh7 z8!*{}T`v#NIobi;&7DkjezxJ9-H%{3Q5PzaJFvu~}e_UlH6j+R470TM>8D)HxWqoEt~JQSd2N zK==Gg-{S$lTK*j*;B!F)-%Efw3qH2?p;omj_ai0C1Yg`b!V(0tZ*&gBU%4X<0eRo0 zUGzi~g8yLJ0&eZu~6#U#ULc$D+w?sUBSRu ze@O4)OOJN?mz6XQKBxNI%J1L>-_3^vd?^9u?43Zulxzs^;fu4{{07}9_#iVrJk{@q z5`0!Ba8428nJ>TKyB@%sFw%OgJQnDjgqGhIpYsoViQf-3_$&=z7KaFc2s+h5DCd@^ zi@s?9-$)c-k|8j^uNws@6HemcX#xML5P`E1dxn>< z5IXHl=ShF80M*jYB=}k)&;;KA_@9zK#fqV`=G@N9B2l3PpJ4@N?fJBrUE6Sqx}(>B zA*en2b2nLeH#{!?4hk+Zt|jz6sefg3kGW`+(&u-cpqqN3ENdpUI=2BK=(lCHSjYa6 zY}PC9SM=wycik%JJXdKk!S}S!rQlQiK7#LDlBo&4K`o$rZtr+@!yP;aFUcx@(dqz6 z{64#85xY`}n};$p3J}3RO#|P%dRK}Ud{C8U*RlePC_vNfGy$k0|^CO z)opQ<%i=A-6dl0PcNh z9$;RX{xj_-;ADFtvtSb)d|vI2DWg=Cl~XW6YmFjSrVm@Z-408VljR_5l$*s>O&c1o zFteB(5Br2@W4=WJr`nFgiMC_(DxKnjOqnT8W^G>9J$!zLX1uym2}|^ag<0WC>(+bf zCm8@ATUA9BjE#>`Y9I*zlzsm4g-f(d;8hL&CoRX|M9VQ|mCnqR3=w=r$tE7C_wdnD zvdjvhtk8^+>_PCaziz!yc~!l56i~q*7#xJ^+8UbQ7sEgP9q)VRO)B_=4}5x+PAT}n z0N>|CnN>O!eqR-u(NnUlWKVQ-3|znNI*5z2sfxzZtD1W@mx_ylce^Py5QKk6ED-TJ zjQ~>u-)6H3^?-v}`7`JvzqtSkn~nmjN~aHe)*>!4lg~=YD)IsvqL1qLCE$lyERdd> z0n40Dp@beq_o)=WcfW>={Em(eb`1nK|K{0Oi#k{q!0(ZdrU}cyVrh3Q13TN=aRU6Y zEBU~x(kTO<2fcN@bp1Yn@szChJ8+x@VxprUAwB^T5*(1UAPK^(VN?^ZwQNjBM>~|2 zl~ZaU_z2&_aS?ga^AnUP02gn-^#YI#NQ9YODNNc53GfA1jsUAlCnk6Vwa=){D+AM; zrv1uoGO%gu2CCK`FtmVFV5^AWsx4>FegSTwpe9gP6(u15yy%t5(eEWdP}d8VvZ zv2a8nt)THR$J_*{|n`LLOZio3w)>3*|}!Tn$={KTaBK4tni6TzM%g?YoJy8S70V%B@m;>q$Omr zAFV$ON9qqTFXoVguO<4x*wS(Ig~8a%#xT;wJkJj6RG%X5)YR3%`3o0d0?Xu|yaKw5-YTZ( zbHv4i!85Pr>*Mr*SH{Tb5>O-H@^Dxk93P`jm{h}o6Cl%`<*B&Y zQ$7&{g~lq;*9f3w&+Sr?2>XdV8R>v<%A1%VEsrqR#Ni3D5sA-nQ_` z+JaiQ76I1zlMFg>;zZ|LZ@pD;!wok;dV2aYGNQw@z-JKx!N;fp$$)wR*AKvsmcd`E zpws|J0+5hEd1X&`e>X0#4heRLrs8M&_vsRSJh7Hz(A?4jB_*X$QBeVX{rwu%PGxL5 zJUYyO>$8_W8_^o1N)f08oT>|m%6Gs0_S>aRO-)}AftZ+(kdVv*U#ohSbmXIDK&yac zz~qv5nHh8+NDOa6M7ds|mjKe|@b%X$%od)j?7U3Ng$pFWodkP=fn{V2iatIt(dTlF z!0_-0baZt>3jw{NvI2@rN}##5l^h>3Q03hB!S9a_yLS}-s(3)pN+ZC_NdL0IkdXTL zx#yl+`}pIJ=PX>fFkKJqK%evXGY(w(6MS&-eX1qE$GG6Ku;{Wu(f9EuhuPQVQzm(| zX82U||J1nyUVot}>FDR-b(^j4y9K$mBS!QY4l5iA4WuWL9_=sq*z)u9+g^I!&$xEcN?}{xT1pF#pu3M~0*(iVJ;9LTud1sK$_yW^Jgw2W z1I7&B0PlAH!`W9a{Z=2q;nS)Mtarf(rOG+twaXUU_pc{kC~uld z##Afqn=J~&sHx}z<*}!pdMZ07C+Dj~G~P%AU3e({zSgn*L=Wu0_$T`Hcc=+cosgC0 zWQaZ<4jn(Nd+|po?%`8tg%PrSljp5@%G`dS7C>nYszd@Lh`@4FaSiy-$&)8L_Uzen z?u|FzI7DLqYNGOoV`F1uNGKRPoo?aUB#$O2Yb#Mz>BMb!uNWf6Cr}l?&jlE~tV?F| z1GtAzbPcuS-`9Azbw|-F=l;}us(A>h}Tar|#VZb48NV)l3DFHv+089@M_X+5@V*^;zr)26g#%a$!6 z8p3X~+2TmIlZL}Gj988Y67yS>CwXviuzO-+!rkBB-`z)APft%@U0q#UYin!oxpU`Q z3knL_$uZvvyz#O1cQ0JEU@MDAS!r32g(Q#&!b8VCHV~SDf7c3nc3Q}`gFLDvPgHWe zyL{Z@KK=O%o@@6))uuZ2i;==AiA4w${D24(hy}XgvGrRQtxVji9Hm}Rv(lX+3A_Y2 zBzs-{)1Q1tbd?uKZ-hD4!lwAXLDFl{C5k6CUh!F6h`|rq2oA_CYx%jWuv$zzu}u??;`1aFb7*$fbVySVPlzi0wP4c5KN##@fot`o6fdY z*wbvBj06%BEs%h06C8<9g(6V$d~JTik6;eBupoU7&Ng+4$)p-Fu@p&q%q$q`hThYB zuC*dQRU`o=B=eAf{K%eenVu&E$TJBbZwP*iXcel($5CRMIn=HppwDbR*Ctwm=oPfk ztgEL>mFzJIV4Mu4Vs2qvv<{IoA^J2PqYJBVIoGz&`L*;u&H@{a+`~8531FSZzcnJd zhZ^y5v=~;Vh^c4nb}+t(MId1JiXDe^gSZ_MT^StNdHlzB{Pfl_;P_p-$(}jhtTXtx zheZK-MPZS=WC$0xQKFUz7fFKziKRpx6XIv`BH6qhf$bOf5p;Cox|b^-{!w8rApvq? zm+lOoNr0d=1hG@>Gv1H{w>`fB>9vbB!#CHP1lOK59&?$x`>0gr2qf`07*qoM6N<$f^ByXwEzGB diff --git a/cockatrice/resources/icons/view.svg b/cockatrice/resources/icons/view.svg index e5676d825..19242f058 100644 --- a/cockatrice/resources/icons/view.svg +++ b/cockatrice/resources/icons/view.svg @@ -1,29 +1,4 @@ - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 22679abb1..9c2a91a5b 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -1,6 +1,6 @@ #include "filter_builder.h" -#include "../interface/pixel_map_generator.h" +#include "../interface/theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include @@ -30,7 +30,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString()); + QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 7b358010f..6895e7db8 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -1,6 +1,7 @@ #include "player_list_widget.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../../interface/widgets/server/user/user_context_menu.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user_level.h" @@ -67,13 +68,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = themePixmap(QStringLiteral("icons/ready_start")); - notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start")); - concededIcon = themePixmap(QStringLiteral("icons/conceded")); - playerIcon = loadColorAdjustedPixmap("theme:icons/player"); - judgeIcon = loadColorAdjustedPixmap("theme:icons/scales"); - spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator"); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start")); + notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start")); + concededIcon = loadThemedIcon(QStringLiteral("icons/conceded")); + playerIcon = loadThemedIcon(QStringLiteral("icons/player")); + judgeIcon = loadThemedIcon(QStringLiteral("icons/scales")); + spectatorIcon = loadThemedIcon(QStringLiteral("icons/spectator")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); if (tabSupervisor) { itemDelegate = new PlayerListItemDelegate(this); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index b1ca42ee1..3c6cf9d02 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "../../filters/syntax_help.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../card_dimensions.h" #include "../z_values.h" #include "libcockatrice/utility/macros.h" @@ -93,8 +94,8 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, searchEdit.setFocusPolicy(Qt::ClickFocus); searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setClearButtonEnabled(true); - searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 2f5c4b6dd..037293af9 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,6 +1,7 @@ #include "pixel_map_generator.h" #include "libcockatrice/network/server/remote/user_level.h" +#include "theme_icon.h" #include "theme_manager.h" #include @@ -15,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -436,33 +436,19 @@ QIcon UserLevelPixmapGenerator::generateIconWithColorOverride(int height, QMap UserLevelPixmapGenerator::iconCache; -QPixmap LockPixmapGenerator::generatePixmap(int height) -{ - - int key = height; - if (pmCache.contains(key)) { - return pmCache.value(key); - } - - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), - QSize(height, height), true); - pmCache.insert(key, pixmap); - return pixmap; -} - -QMap LockPixmapGenerator::pmCache; - QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) { - QString key = QString::number(expanded) + ":" + QString::number(height); + const QString key = QString::number(expanded) + ":" + QString::number(height); if (pmCache.contains(key)) { return pmCache.value(key); } - QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed"; - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name), - QSize(height, height), true); - + // Render through the themed icon engine: it keeps the glyph's aspect ratio, + // follows the palette, and renders at the device pixel ratio instead of + // handing a DPR-1 bitmap to a label that would upscale and blur it on a 2x + // display. + const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed"); + const QPixmap pixmap = loadThemedIcon(prefix).pixmap(QSize(height, height), qApp->devicePixelRatio()); pmCache.insert(key, pixmap); return pixmap; } @@ -520,30 +506,11 @@ QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const Q QHash ManaSymbolPixmapGenerator::masterCache; QHash ManaSymbolPixmapGenerator::scaledCache; -QPixmap loadColorAdjustedPixmap(const QString &name) -{ - // Prefer an authored scheme-qualified variant when one exists for this asset. - const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size())); - if (!variant.isEmpty()) { - return QPixmap(QStringLiteral("theme:") + variant); - } - - // Legacy fallback: runtime-invert for dark mode when no authored variant. - if (qApp->palette().windowText().color().lightness() > 200) { - QImage img(name); - img.invertPixels(); - QPixmap result; - result.convertFromImage(img); - return result; - } else { - return QPixmap(name); - } -} - QPixmap themePixmap(QStringView prefix) { const QString resolved = themeManager->assetPath(prefix); - return QPixmap(QStringLiteral("theme:") + resolved); + const QPixmap sentinel = loadSentinelSvgPixmap(resolved); + return sentinel.isNull() ? QPixmap(QStringLiteral("theme:") + resolved) : sentinel; } void clearPixmapGeneratorCaches() @@ -553,7 +520,6 @@ void clearPixmapGeneratorCaches() PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); - LockPixmapGenerator::clear(); DropdownIconPixmapGenerator::clear(); ManaSymbolPixmapGenerator::clear(); } diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 7c01bb332..951f4231b 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -106,19 +106,6 @@ public: } }; -class LockPixmapGenerator -{ -private: - static QMap pmCache; - -public: - static QPixmap generatePixmap(int height); - static void clear() - { - pmCache.clear(); - } -}; - class DropdownIconPixmapGenerator { private: @@ -160,13 +147,11 @@ public: } }; -QPixmap loadColorAdjustedPixmap(const QString &name); - -// Loads a "theme:" asset (with no file extension in prefix), preferring the +// Returns a "theme:" asset (with no file extension in prefix), preferring the // scheme-qualified variant (prefix-dark / prefix-light, resolved via -// ThemeManager::assetPath) and falling back to the plain asset. Callers load -// the returned path directly. Use for scheme-sensitive pixmaps like -// backgrounds, the card back, and the app logo. +// ThemeManager::assetPath) and falling back to the plain asset. Token-driven +// SVGs resolve their `currentColor` sentinel from the palette. Use for +// scheme-sensitive pixmaps like backgrounds, the card back, and the app logo. QPixmap themePixmap(QStringView prefix); // Clears every PixmapGenerator's static cache so scheme variants are diff --git a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp index 730043d95..c8571ab08 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp @@ -1,6 +1,6 @@ #include "abstract_analytics_panel_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../general/display/banner_widget.h" #include "deck_list_statistics_analyzer.h" @@ -28,7 +28,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck // config button configureButton = new QPushButton(this); - configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + configureButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog); bannerAndSettingsLayout->addWidget(configureButton, 0); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 7ed398535..f25e6c0ff 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../utility/custom_line_edit.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -40,8 +40,8 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -83,13 +83,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); - aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue"))); + aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard"))); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); auto *tbAddCardToSideboard = new QToolButton(this); tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard); 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 30fde1595..47d2a1aae 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 @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../playmat/playmat_settings_dialog.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/abstract_tab_deck_editor.h" @@ -248,25 +248,25 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); auto *tbIncrement = new QToolButton(this); tbIncrement->setDefaultAction(aIncrement); aDecrement = new QAction(QString(), this); - aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); auto *tbDecrement = new QToolButton(this); tbDecrement->setDefaultAction(aDecrement); aRemoveCard = new QAction(QString(), this); - aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); auto *tbRemoveCard = new QToolButton(this); tbRemoveCard->setDefaultAction(aRemoveCard); aSwapCard = new QAction(QString(), this); - aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap"))); + aSwapCard->setIcon(loadThemedIcon(QStringLiteral("icons/swap"))); connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection); auto *tbSwapCard = new QToolButton(this); tbSwapCard->setDefaultAction(aSwapCard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index adeb5c3e1..8122d1847 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -5,7 +5,7 @@ #include "../../../filters/filter_builder.h" #include "../../../filters/filter_tree_model.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../tabs/abstract_tab_deck_editor.h" #include "libcockatrice/filters/filter_card.h" @@ -64,11 +64,11 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + aClearFilterAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp index 209dc7273..a1914e1b5 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp @@ -1,6 +1,6 @@ #include "deck_list_history_manager_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../quick_settings/settings_button_widget.h" #include "deck_state_manager.h" #include "libcockatrice/deck_list/deck_list_history_manager.h" @@ -32,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout = new QHBoxLayout(this); aUndo = new QAction(QString(), this); - aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo"))); + aUndo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_undo"))); aUndo->setShortcut(QKeySequence::Undo); aUndo->setShortcutContext(Qt::ApplicationShortcut); connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo); @@ -41,7 +41,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de undoButton->setDefaultAction(aUndo); aRedo = new QAction(QString(), this); - aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo"))); + aRedo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_redo"))); aRedo->setShortcut(QKeySequence::Redo); aRedo->setShortcutContext(Qt::ApplicationShortcut); connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo); @@ -53,7 +53,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout->addWidget(redoButton); historyButton = new SettingsButtonWidget(this); - historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history"))); + historyButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/arrow_history"))); historyLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 853a00828..daee5f1f9 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -1,7 +1,7 @@ #include "dlg_connect.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" #include "libcockatrice/utility/macros.h" @@ -38,7 +38,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -46,7 +46,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); @@ -121,7 +121,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) updateDisplayInfo(previousHosts->currentText()); btnForgotPassword = new QPushButton(this); - btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password"))); + btnForgotPassword->setIcon(loadThemedIcon(QStringLiteral("icons/forgot_password"))); btnForgotPassword->setToolTip(tr("Reset Password")); btnForgotPassword->setFixedWidth(30); connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index 3845029f7..b06481c4b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -1,6 +1,6 @@ #include "dlg_edit_tokens.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "card_database_display_model.h" #include "libcockatrice/card/card_info.h" @@ -108,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index caca5da92..c6e640a9f 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -1,7 +1,7 @@ #include "dlg_manage_sets.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "libcockatrice/card/database/card_database.h" @@ -58,28 +58,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); aTop = new QAction(QString(), this); - aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green"))); + aTop->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_top"))); aTop->setToolTip(tr("Move selected set to the top")); aTop->setEnabled(false); connect(aTop, &QAction::triggered, this, &WndSets::actTop); setsEditToolBar->addAction(aTop); aUp = new QAction(QString(), this); - aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green"))); + aUp->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_up"))); aUp->setToolTip(tr("Move selected set up")); aUp->setEnabled(false); connect(aUp, &QAction::triggered, this, &WndSets::actUp); setsEditToolBar->addAction(aUp); aDown = new QAction(QString(), this); - aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green"))); + aDown->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_down"))); aDown->setToolTip(tr("Move selected set down")); aDown->setEnabled(false); connect(aDown, &QAction::triggered, this, &WndSets::actDown); setsEditToolBar->addAction(aDown); aBottom = new QAction(QString(), this); - aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green"))); + aBottom->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_bottom"))); aBottom->setToolTip(tr("Move selected set to the bottom")); aBottom->setEnabled(false); connect(aBottom, &QAction::triggered, this, &WndSets::actBottom); @@ -89,7 +89,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) searchField = new LineEditUnfocusable; searchField->setObjectName("searchEdit"); searchField->setPlaceholderText(tr("Search by set name, code, type, or release date")); - searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); + searchField->addAction(loadThemedIcon(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 9e32e87d1..09afee32d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,7 +1,7 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" @@ -34,7 +34,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -42,7 +42,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 005824995..b1a80c86b 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -6,7 +6,7 @@ #include "settings_view.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/theme_manager.h" #include "../settings_page/abstract_settings_page.h" #include "../settings_page/appearance_settings_page.h" @@ -135,7 +135,7 @@ void SettingsView::setupUi() // Search bar searchEdit = new QLineEdit; searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); searchEdit->setObjectName("settingsSearchEdit"); diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp index ea0d2db5a..c6a6d989f 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -1,12 +1,12 @@ #include "settings_button_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "settings_popup_widget.h" #include #include +#include #include -#include #include #include #include @@ -21,7 +21,7 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) : QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr)) { - button->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + button->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); button->setCheckable(true); button->setFixedSize(32, 32); connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); @@ -49,9 +49,9 @@ void SettingsButtonWidget::removeSettingsWidget(QWidget *toRemove) const popup->removeSettingsWidget(toRemove); } -void SettingsButtonWidget::setButtonIcon(QPixmap iconMap) +void SettingsButtonWidget::setButtonIcon(const QIcon &icon) { - button->setIcon(iconMap); + button->setIcon(icon); } void SettingsButtonWidget::setButtonText(const QString &text) diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h index 015300bf8..56d542d37 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h @@ -13,7 +13,7 @@ #include class QHBoxLayout; -class QPixmap; +class QIcon; class QToolButton; class SettingsPopupWidget; @@ -28,7 +28,7 @@ public: void addSettingsWidget(QWidget *toAdd) const; void removeSettingsWidget(QWidget *toRemove) const; - void setButtonIcon(QPixmap iconMap); + void setButtonIcon(const QIcon &icon); void setButtonText(const QString &buttonText); void setCompact(bool compact); bool isCompact() const diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 2702a527d..2c0050afe 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,7 +1,7 @@ #include "game_selector.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" #include "../interface/widgets/tabs/tab_room.h" @@ -130,10 +130,10 @@ GameSelector::GameSelector(AbstractClient *_client, } filterButton = new QPushButton; - filterButton->setIcon(themePixmap(QStringLiteral("icons/search"))); + filterButton->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter); clearFilterButton = new QPushButton; - clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + clearFilterButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults(); clearFilterButton->setEnabled(!filtersSetToDefault); connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter); diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 8fed95833..d33526bd1 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -1,6 +1,7 @@ #include "games_model.h" #include "../../../client/settings/cache_settings.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user/user_list_proxy.h" @@ -85,7 +86,8 @@ const QString GamesModel::getGameCreatedString(const int secs) } GamesModel::GamesModel(const QMap &_rooms, const QMap &_gameTypes, QObject *parent) - : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes) + : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes), + lockIcon(loadThemedIcon(QStringLiteral("icons/lock"))) { } @@ -191,7 +193,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const return result.join(", "); } case Qt::DecorationRole: { - return gameentry.with_password() ? QIcon(LockPixmapGenerator::generatePixmap(13)) : QVariant(); + // One engine per model: it is palette- and theme-aware and + // invalidates itself, so a repaint must not build a new one. + return gameentry.with_password() ? lockIcon : QVariant(); case Qt::TextAlignmentRole: return Qt::AlignLeft; default: diff --git a/cockatrice/src/interface/widgets/server/games_model.h b/cockatrice/src/interface/widgets/server/games_model.h index 9bf54b83a..62862417d 100644 --- a/cockatrice/src/interface/widgets/server/games_model.h +++ b/cockatrice/src/interface/widgets/server/games_model.h @@ -5,6 +5,7 @@ #include "game_type_map.h" #include +#include #include #include #include @@ -32,6 +33,7 @@ private: QList gameList; /**< List of games currently displayed. */ QMap rooms; /**< Map of room IDs to room names. */ QMap gameTypes; /**< Map of room IDs to available game types. */ + QIcon lockIcon; /**< Themed lock for password-protected games. */ static const int NUM_COLS = 8; /**< Number of columns in the table. */ diff --git a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp index c131c3cbf..c1db6138d 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp @@ -1,6 +1,6 @@ #include "remote_replay_list_tree_widget.h" -#include "../../../pixel_map_generator.h" +#include "../../../theme_icon.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" @@ -50,7 +50,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client, QFileIconProvider fip; dirIcon = fip.icon(QFileIconProvider::Folder); fileIcon = fip.icon(QFileIconProvider::File); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); } RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel() diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 79370f512..e90603858 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -1,7 +1,7 @@ #include "deck_editor_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../dialogs/path_safety_warning.h" #include "update/card_spoiler/spoiler_background_updater.h" @@ -80,19 +80,19 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); aRateLimit = new QAction(this); - aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + aRateLimit->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit); auto *urlToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 6128a7830..09dd9e9b0 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -1,7 +1,7 @@ #include "messages_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "libcockatrice/utility/macros.h" @@ -146,15 +146,15 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp index a77c0c2c3..83c2ec585 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcut_treeview.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "../interface/widgets/utility/sequence_edit.h" @@ -58,8 +58,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() btnResetAll = new QPushButton(this); btnClearAll = new QPushButton(this); - btnResetAll->setIcon(themePixmap(QStringLiteral("icons/update"))); - btnClearAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + btnResetAll->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); + btnClearAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); // layout auto *_editLayout = new QGridLayout; diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 9f7369435..ae3a9baef 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -1,7 +1,7 @@ #include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "../../../cards/additional_info/mana_symbol_widget.h" #include "../../../cards/card_size_widget.h" #include "../../../quick_settings/settings_button_widget.h" @@ -282,7 +282,7 @@ void TabArchidekt::setupFilterWidgets() // Format filter (collapsible) formatButton = new SettingsButtonWidget(secondaryToolbar); formatButton->setButtonText(tr("Formats")); - formatButton->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + formatButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); QWidget *formatContainer = new QWidget(secondaryToolbar); QGridLayout *formatLayout = new QGridLayout(formatContainer); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 9adda008e..a0e3f2491 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -1,7 +1,7 @@ #include "commander_bracket_widget.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "commander_bracket_service.h" #include "libcockatrice/deck_list/deck_list.h" @@ -49,7 +49,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent bracketInfoButton->setEnabled(false); bracketRefreshButton = new QToolButton(this); - bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + bracketRefreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); bracketRefreshButton->setAutoRaise(true); connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 3dfdc956b..8af9d5e6e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" #include "../deck_share/share_bar_widget.h" @@ -187,19 +187,19 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Left side actions aOpenLocalDeck = new QAction(this); - aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal); aUpload = new QAction(this); - aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aUpload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); connect(aUpload, &QAction::triggered, this, &TabDeckStorage::actUpload); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); aDeleteLocalDeck = new QAction(this); - aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -208,24 +208,24 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Right side actions aOpenRemoteDeck = new QAction(this); - aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabDeckStorage::actDownload); aNewFolder = new QAction(this); aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); aDeleteRemoteDeck = new QAction(this); - aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); - aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); + aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); aPublishDeck = new QAction(this); - aPublishDeck->setIcon(QPixmap("theme:icons/lock")); + aPublishDeck->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index dc14b6e4a..dfde08f8e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include "../../theme_icon.h" #include "../cards/card_size_widget.h" #include "../general/layout_containers/flow_widget.h" #include "../interface/theme_manager.h" @@ -63,8 +64,8 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c headerLayout->addStretch(1); // Filter/toolbar row, matching the Visual Deck Storage: color identity filter - // first, the search bar stretching in the middle, and the quick settings - // cogwheel at the end. The card size slider lives inside the cogwheel popup. + // first, the search bar stretching in the middle, and the quick-settings button + // at the end. The card size slider lives inside its popup. emptyLabel = new QLabel(tr("This user has not published any decks."), this); emptyLabel->setAlignment(Qt::AlignCenter); emptyLabel->setVisible(false); @@ -79,7 +80,7 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this); searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); quickSettingsWidget->setPublicDecksMode(true); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index d37bff3d8..bd2a69017 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -1,7 +1,7 @@ #include "tab_replays.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/server/remote/remote_replay_list_tree_widget.h" #include "libcockatrice/protocol/pb/commands.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay.pb.h" @@ -145,17 +145,17 @@ QGroupBox *TabReplays::createLeftLayout() // Left side actions aOpenLocalReplay = new QAction(this); - aOpenLocalReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenLocalReplay, &QAction::triggered, this, &TabReplays::actOpenLocalReplay); connect(localDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenLocalReplay); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabReplays::actRenameLocal); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); aDeleteLocalReplay = new QAction(this); - aDeleteLocalReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -207,24 +207,24 @@ QGroupBox *TabReplays::createRightLayout() // Right side actions aOpenRemoteReplay = new QAction(this); - aOpenRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenRemoteReplay, &QAction::triggered, this, &TabReplays::actOpenRemoteReplay); connect(serverDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenRemoteReplay); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabReplays::actDownload); aKeep = new QAction(this); - aKeep->setIcon(themePixmap(QStringLiteral("icons/lock"))); + aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share"))); + aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); - aSubmitReplayCode->setIcon(themePixmap(QStringLiteral("icons/search"))); + aSubmitReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index f63b77445..329732c64 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" #include "../interface/widgets/server/game_link.h" @@ -198,7 +198,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(aOpenChatSettings, &QAction::triggered, this, &TabRoom::actOpenChatSettings); auto *chatSettingsButton = new QToolButton; - chatSettingsButton->setIcon(themePixmap(QStringLiteral("icons/settings"))); + chatSettingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); chatSettingsButton->setMenu(chatSettingsMenu); chatSettingsButton->setPopupMode(QToolButton::InstantPopup); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index cf71dd7ed..45c320852 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -7,6 +7,7 @@ #include "../../intents/contexts/context_join_room.h" #include "../../intents/intent.h" #include "../../intents/intent_join_server_game.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" @@ -1360,7 +1361,7 @@ void TabSupervisor::tabUserEvent(bool globalEvent) auto *tab = static_cast(sender()); if (tab != currentWidget()) { tab->setContentsChanged(true); - setTabIcon(indexOf(tab), themePixmap(QStringLiteral("icons/tab_changed"))); + setTabIcon(indexOf(tab), loadThemedIcon(QStringLiteral("icons/tab_changed"))); } if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) { QApplication::alert(this); diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index 44e6581e9..b09e6da1e 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -24,8 +24,8 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid defaultButton = new QPushButton("", this); lineEdit->setMinimumWidth(70); - clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); - defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); + clearButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); + defaultButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); auto *layout = QtUtils::makeLayout(this, 1); layout->addWidget(lineEdit); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 5063cce14..ecb949b67 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -1,6 +1,6 @@ #include "visual_database_display_filter_toolbar_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/card_database_view.h" #include "../quick_settings/settings_button_widget.h" #include "card_database_display_model.h" @@ -45,13 +45,13 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg sortGroupBox = new QGroupBox(this); filterGroupBox = new QGroupBox(this); - auto scalePixmap = [](const QString &fileName) { return QIcon(QPixmap(fileName)).pixmap({20, 20}); }; + auto scalePixmap = [](const QString &prefix) { return loadThemedIcon(prefix).pixmap({20, 20}); }; sortByLabel = new QLabel(this); - sortByLabel->setPixmap(scalePixmap("theme:icons/sort_arrow_down")); + sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down")); filterByLabel = new QLabel(this); - filterByLabel->setPixmap(scalePixmap("theme:icons/filter")); + filterByLabel->setPixmap(scalePixmap("icons/filter")); sortColumnCombo = new QComboBox(this); sortColumnCombo->setSizeAdjustPolicy(QComboBox::SizeAdjustPolicy::AdjustToContents); @@ -89,22 +89,22 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk"))); + quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"))); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square"))); + quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"))); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke"))); + quickFilterMainTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/circle_half_stroke"))); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon"))); + quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"))); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); retranslateUi(); } 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 6037418ad..af5c74969 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 @@ -4,7 +4,9 @@ #include "../../../filters/filter_tree_model.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" +#include "../cards/card_info_picture_widget.h" +#include "../cards/card_info_picture_with_text_overlay_widget.h" #include "../cards/card_size_widget.h" #include "../deck_editor/card_database_view.h" #include "../general/layout_containers/flow_widget.h" @@ -79,8 +81,8 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -154,7 +156,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete"))); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"))); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 63ef07835..f0c054c3a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -1,6 +1,6 @@ #include "visual_deck_display_options_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/deck_editor_deck_dock_widget.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" @@ -65,7 +65,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) sortByLabel = new QLabel(this); sortCriteriaButton = new SettingsButtonWidget(this); - sortCriteriaButton->setButtonIcon(themePixmap(QStringLiteral("icons/sort_arrow_down"))); + sortCriteriaButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/sort_arrow_down"))); sortLabel = new QLabel(sortCriteriaButton); sortLabel->setWordWrap(true); @@ -119,11 +119,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayTypeButton() switch (currentDisplayType) { case DisplayType::Flat: displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); break; } } diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 506ffa2b4..0e0b5e4b4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_editor_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/card_size_widget.h" #include "../cards/deck_card_zone_display_widget.h" #include "../general/layout_containers/flow_widget.h" @@ -144,7 +144,7 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); - searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); + searchPushButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(searchPushButton, &QPushButton::clicked, this, [this] { addCardFromSearch(); }); searchLayout->addWidget(searchPushButton); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index f2669f55d..10a3bf33a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_search_widget.h" #include "../../../filters/syntax_help.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -27,9 +27,9 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : searchBar = new QLineEdit(this); searchBar->setPlaceholderText(tr("Search by filename (or search expression)")); searchBar->setClearButtonEnabled(true); - searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); + searchBar->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 4e1d6293f..bbf68adb9 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "deck_preview/deck_preview_color_identity_filter_widget.h" #include "deck_preview/deck_preview_widget.h" #include "libcockatrice/card/database/card_database.h" @@ -62,12 +62,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); shareButton = new QToolButton(this); - shareButton->setIcon(themePixmap(QStringLiteral("icons/share"))); + shareButton->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); shareButton->setFixedSize(32, 32); shareButton->setVisible(false); connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 79a802234..715505926 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -50,6 +50,7 @@ #include "latency_tracker.h" #include "logger.h" #include "pixel_map_generator.h" +#include "theme_icon.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" #include "widgets/server/handle_public_servers.h" @@ -387,10 +388,10 @@ void MainWindow::retranslateUi() aRegister->setText(tr("&Register to server...")); aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); - aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); + aSettings->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); settingsButton->setToolTip(tr("Settings")); settingsButton->setAccessibleName(tr("Settings")); - settingsButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + settingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index 93493ee5f..818c1bb16 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -5,12 +5,14 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_manager.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp ${VERSION_STRING_CPP} theme_migration_test.cpp + theme_icon_render_test.cpp LIBS libcockatrice_settings libcockatrice_network @@ -18,4 +20,6 @@ cockatrice_add_test( libcockatrice_protocol INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src + DEFINITIONS + COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp new file mode 100644 index 000000000..f12db5b16 --- /dev/null +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -0,0 +1,189 @@ +#include "interface/theme_icon.h" +#include "interface/theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps its other + * tests free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "theme_icon_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) +{ + const QImage image = pixmap.toImage(); + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = image.pixelColor(x, y); + if (pixel.alpha() > 200 && pixel.red() == expected.red() && pixel.green() == expected.green() && + pixel.blue() == expected.blue()) { + ++count; + } + } + } + return count; +} + +QByteArray svgWith(const QByteArray &colorToken) +{ + return QByteArrayLiteral(R"()"); +} + +/** + * @brief Restores the global theme search paths and palette after each test. + * + * The icon engine reads both process-wide values, so a failing assertion must + * not leak a temporary theme directory or palette into the next test. + */ +class ThemeIconRenderTest : public ::testing::Test +{ +protected: + void SetUp() override + { + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + savedSearchPaths = QDir::searchPaths(QStringLiteral("theme")); + savedPalette = qApp->palette(); + themeManager = new ThemeManager; + QDir::setSearchPaths(QStringLiteral("theme"), {resourcesDir()}); + } + + void TearDown() override + { + delete themeManager; + themeManager = nullptr; + QDir::setSearchPaths(QStringLiteral("theme"), savedSearchPaths); + qApp->setPalette(savedPalette); + } + + QStringList savedSearchPaths; ///< Search paths captured before the test. + QPalette savedPalette; ///< Application palette captured before the test. +}; + +/** @brief A non-square request must letterbox, not squash, a 24×24 glyph. */ +TEST_F(ThemeIconRenderTest, NonSquareRequestPreservesAspectRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/player")); + ASSERT_FALSE(icon.isNull()); + + EXPECT_EQ(icon.actualSize(QSize(20, 15)), QSize(15, 15)); + + const QPixmap pixmap = icon.pixmap(QSize(20, 15)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15)); +} + +/** @brief The engine must follow palette flips, icon modes, and DPR. */ +TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) +{ + QPalette palette = qApp->palette(); + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/search")); + ASSERT_FALSE(icon.isNull()); + + const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0); + ASSERT_FALSE(normal.isNull()); + EXPECT_EQ(normal.size(), QSize(24, 24)); + EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0); + EXPECT_GT(countOpaquePixels(normal, QColor(QStringLiteral("#123456"))), 0); + + const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0); + ASSERT_FALSE(hidpi.isNull()); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(24, 24)); + + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef"))); + qApp->setPalette(palette); + const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0); + EXPECT_GT(countOpaquePixels(flipped, QColor(QStringLiteral("#abcdef"))), 0); + + QPalette selectedPalette = qApp->palette(); + selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00"))); + qApp->setPalette(selectedPalette); + const QPixmap selected = icon.pixmap(QSize(24, 24), 1.0, QIcon::Selected, QIcon::Off); + EXPECT_GT(countOpaquePixels(selected, QColor(QStringLiteral("#00ff00"))), 0); +} +} // namespace + +TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor) +{ + QPalette palette; + palette.setColor(QPalette::WindowText, QColor(Qt::white)); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(24, 24)); + EXPECT_GT(countOpaquePixels(pixmap, Qt::white), 0); +} + +TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#000000")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("plain.svg")).isNull()); + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("icons/search.png")).isNull()); +} + +TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("lower.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("currentcolor")); + file.close(); + + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + QPalette palette; + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0); +} From e22a75974a49b64beddf7189e35914c812facade Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:42 +0100 Subject: [PATCH 050/104] [Icons] Add an icon asset ratchet test (#7596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Icons] Add an icon asset ratchet test The icon rules were enforced by review only, so the next Inkscape export could reintroduce a baked fill or a physical size without anyone noticing. Add a test that rejects non-24 viewBoxes, physical sizes, editor metadata, hardcoded paints, missing tokens, color-coded filenames, and rasters. Keep the legacy baseline empty so the ratchet can only tighten. * [Tests] Drop the empty icon baseline The legacy icon set is fully paid down, so the ratchet had nothing left to ratchet: BaselineIsWellFormed required the baseline to be empty, which made the baseline file, the stale-entry diff and the entry-format regex unreachable. The rules are enforced directly now. - AssetsFollowTheRules fails on any violation of collectViolations(). - tests/icon_assets/icon-baseline.txt is gone with its reader. --------- Co-authored-by: Lukas Brübach --- tests/CMakeLists.txt | 1 + tests/icon_assets/CMakeLists.txt | 3 + tests/icon_assets/icon_assets_test.cpp | 241 +++++++++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 tests/icon_assets/CMakeLists.txt create mode 100644 tests/icon_assets/icon_assets_test.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8b904d30a..84b9f02bf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -566,6 +566,7 @@ add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) add_subdirectory(deck_list_model) add_subdirectory(deck_list_zones) +add_subdirectory(icon_assets) add_subdirectory(loading_from_clipboard) add_subdirectory(model_contract) add_subdirectory(movecard_tests) diff --git a/tests/icon_assets/CMakeLists.txt b/tests/icon_assets/CMakeLists.txt new file mode 100644 index 000000000..da9776f8e --- /dev/null +++ b/tests/icon_assets/CMakeLists.txt @@ -0,0 +1,3 @@ +cockatrice_add_test( + NAME icon_assets_test SOURCES icon_assets_test.cpp DEFINITIONS COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" +) diff --git a/tests/icon_assets/icon_assets_test.cpp b/tests/icon_assets/icon_assets_test.cpp new file mode 100644 index 000000000..f0b06d5cc --- /dev/null +++ b/tests/icon_assets/icon_assets_test.cpp @@ -0,0 +1,241 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons"); + +QString iconsDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH); +} + +/** @brief True for content art that is allowed to carry full color. */ +bool isContentArt(const QString &relativePath) +{ + return relativePath.startsWith(QStringLiteral("mana/")); +} + +QString violation(const QString &rule, const QString &relativePath) +{ + return rule + QLatin1Char(' ') + relativePath; +} + +/** + * @brief Reads one SVG and returns true when the root carries a usable viewBox. + * + * Also reports malformed XML and editor-only metadata through the out params. + */ +bool inspectSvg(const QString &absolutePath, + bool &validXml, + bool &hasViewBox, + bool &hasEditorMetadata, + bool &hasPhysicalSize) +{ + validXml = false; + hasViewBox = false; + hasEditorMetadata = false; + hasPhysicalSize = false; + + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + QXmlStreamReader reader(&file); + bool sawRoot = false; + + while (!reader.atEnd()) { + reader.readNext(); + + if (reader.hasError()) { + return false; + } + + if (!reader.isStartElement()) { + continue; + } + + if (!sawRoot) { + sawRoot = true; + if (reader.name() != QStringLiteral("svg")) { + return false; + } + const QString viewBox = reader.attributes().value(QStringLiteral("viewBox")).toString().simplified(); + hasViewBox = viewBox == QStringLiteral("0 0 24 24"); + hasPhysicalSize = !reader.attributes().value(QStringLiteral("width")).isEmpty() || + !reader.attributes().value(QStringLiteral("height")).isEmpty(); + } + + if (reader.name() == QStringLiteral("metadata") || reader.name() == QStringLiteral("namedview")) { + hasEditorMetadata = true; + } + + for (const QXmlStreamAttribute &attribute : reader.attributes()) { + const QString prefix = attribute.prefix().toString(); + if (prefix == QStringLiteral("inkscape") || prefix == QStringLiteral("sodipodi") || + prefix == QStringLiteral("dc") || prefix == QStringLiteral("cc") || prefix == QStringLiteral("rdf")) { + hasEditorMetadata = true; + } + } + } + + validXml = sawRoot && !reader.hasError(); + return validXml; +} + +bool isAllowedPaintValue(const QString &value) +{ + const QString normalized = value.trimmed().toLower(); + return normalized.isEmpty() || normalized == QStringLiteral("none") || + normalized == QStringLiteral("currentcolor") || normalized == QStringLiteral("inherit") || + normalized == QStringLiteral("context-fill") || normalized == QStringLiteral("context-stroke") || + normalized.startsWith(QStringLiteral("url(")); +} + +QRegularExpression paintValuePattern() +{ + static const QRegularExpression pattern( + QStringLiteral(R"re((?:fill|stroke|stop-color|color)\s*[:=]\s*["']?\s*([^"'\s;>]+))re"), + QRegularExpression::CaseInsensitiveOption); + return pattern; +} + +bool hasHardcodedColor(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + const QString value = matches.next().captured(1).trimmed(); + if (isAllowedPaintValue(value)) { + continue; + } + const QString normalized = value.toLower(); + if (normalized.startsWith(QLatin1Char('#')) || normalized.contains(QStringLiteral("rgb(")) || + normalized.contains(QStringLiteral("hsl(")) || QColor(value).isValid()) { + return true; + } + } + return false; +} + +bool hasCurrentColorToken(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + if (matches.next().captured(1).trimmed().compare(QStringLiteral("currentColor"), Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} + +bool isSchemeVariant(const QString &fileName) +{ + return fileName.contains(QStringLiteral("-dark."), Qt::CaseInsensitive) || + fileName.contains(QStringLiteral("-light."), Qt::CaseInsensitive); +} + +bool hasColorInFilename(const QString &fileName) +{ + static const QRegularExpression colorSuffix( + QStringLiteral(R"(_(green|blue|red|yellow|black|white|gray|grey)\.svg$)"), + QRegularExpression::CaseInsensitiveOption); + return colorSuffix.match(fileName).hasMatch(); +} + +/** @brief Collects every current violation as "rule relative/path.svg". */ +QSet collectViolations() +{ + QSet violations; + + QDirIterator it(iconsDir(), QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + const QFileInfo info = it.fileInfo(); + const QString relativePath = QDir(iconsDir()).relativeFilePath(info.absoluteFilePath()); + if (isContentArt(relativePath)) { + continue; + } + if (info.suffix().compare(QStringLiteral("svg"), Qt::CaseInsensitive) != 0) { + violations.insert(violation(QStringLiteral("raster"), relativePath)); + continue; + } + + bool validXml = false; + bool hasViewBox = false; + bool hasEditorMetadata = false; + bool hasPhysicalSize = false; + inspectSvg(info.absoluteFilePath(), validXml, hasViewBox, hasEditorMetadata, hasPhysicalSize); + + if (!validXml) { + violations.insert(violation(QStringLiteral("xml"), relativePath)); + } + if (validXml && !hasViewBox) { + violations.insert(violation(QStringLiteral("viewbox"), relativePath)); + } + if (hasPhysicalSize) { + violations.insert(violation(QStringLiteral("physical-size"), relativePath)); + } + if (hasEditorMetadata) { + violations.insert(violation(QStringLiteral("editor-metadata"), relativePath)); + } + if (hasHardcodedColor(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("hardcoded-color"), relativePath)); + } + if (!hasCurrentColorToken(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("no-token"), relativePath)); + } + if (hasColorInFilename(info.fileName())) { + violations.insert(violation(QStringLiteral("color-filename"), relativePath)); + } + } + + return violations; +} + +QString formatSet(const QSet &entries) +{ + QStringList lines(entries.begin(), entries.end()); + lines.sort(); + return lines.join(QLatin1Char('\n')); +} + +} // namespace + +/** + * @brief Every UI glyph follows the palette-token rules. + * + * The legacy set was paid down icon by icon, so no baseline grandfathers + * violations: a new violation fails right here. Scheme variants + * (`*-dark.svg`/`*-light.svg`) and mana art are exempt inside collectViolations(). + */ +TEST(IconAssets, AssetsFollowTheRules) +{ + ASSERT_TRUE(QDir::isAbsolutePath(iconsDir())); + ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString(); + + const QSet violations = collectViolations(); + EXPECT_TRUE(violations.isEmpty()) << "Icon rule violations. Fix the icon; do not add exceptions:\n" + << formatSet(violations).toStdString(); +} From ada0cccfbfdbd36dfc14f493eee0fbc959e5047a Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:42 +0100 Subject: [PATCH 051/104] [Game] Follow the palette in judge message tags (#7597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Settings] Delete update channels at shutdown SettingsCache owns the stable and beta update channels as raw pointers, and each owns a QNetworkAccessManager, so the process leaves QtNetwork objects alive past QApplication teardown. Delete them from a QCoreApplication::aboutToQuit handler instead of the destructor: SettingsCache is a Q_GLOBAL_STATIC, so ~SettingsCache() runs during static teardown, after QApplication is gone, and destroying the network managers at that point is the post-application window that ASan reports. The hook is installed on the first instance() call that has an application object, since the first access may predate it. * [Game] Follow the palette in judge message tags Judge tags hardcoded a black prefix and a `theme:icons/scales` image inside rich text, where `currentColor` cannot resolve and a palette change leaves the tag unreadable. Render the scales glyph through the themed icon engine, register it as a document image resource, and repaint the log when the palette or theme changes. --------- Co-authored-by: Lukas Brübach --- .../src/client/settings/cache_settings.cpp | 28 +++++++++++++++- .../src/client/settings/cache_settings.h | 5 +++ .../game_graphics/log/message_log_widget.cpp | 32 +++++++++++++++++-- .../game_graphics/log/message_log_widget.h | 2 ++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 08eccf1e4..b40e26377 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -6,6 +6,7 @@ #include "shortcuts_settings.h" #include +#include #include #include #include @@ -47,7 +48,32 @@ Q_GLOBAL_STATIC(SettingsCache, settingsCache) SettingsCache &SettingsCache::instance() { - return *settingsCache; // returns a QT managed singleton reference + SettingsCache &cache = *settingsCache; // returns a QT managed singleton reference + // The release channels own QNetworkAccessManagers, which must be destroyed + // while the application object is alive. ~SettingsCache() runs during + // static teardown, after QApplication is gone, so hook the application's + // shutdown instead. Retried here because the first access may predate the + // application object. + cache.installShutdownHook(); + return cache; +} + +void SettingsCache::installShutdownHook() +{ + if (shutdownHookInstalled) { + return; + } + QCoreApplication *application = QCoreApplication::instance(); + if (application == nullptr) { + return; + } + connect(application, &QCoreApplication::aboutToQuit, this, [this] { + for (ReleaseChannel *releaseChannel : std::as_const(releaseChannels)) { + delete releaseChannel; + } + releaseChannels.clear(); + }); + shutdownHookInstalled = true; } QString SettingsCache::getDataPath() diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 9aeb88a18..8ccf6f76b 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -90,8 +90,13 @@ private: QString themeName; QList releaseChannels; + bool shutdownHookInstalled = false; bool isPortableBuild; + // Connects the aboutToQuit handler that deletes the release channels while + // the application object is still alive; a no-op before it exists. + void installShutdownHook(); + [[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const; [[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const; void loadPaths(); diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 8b351e022..d526766af 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -4,6 +4,8 @@ #include "../../client/settings/card_counter_settings.h" #include "../../client/sound_engine.h" #include "../../interface/card_localization.h" +#include "../../interface/theme_icon.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/server/chat_view/chat_view.h" #include "../board/card_item.h" #include "../board/translate_counter_name.h" @@ -12,8 +14,12 @@ #include #include #include +#include +#include #include #include +#include +#include #include #include #include @@ -120,6 +126,7 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position void MessageLogWidget::containerProcessingDone() { currentContext = MessageContext_None; + contextJudgeName.clear(); messageSuffix = messagePrefix = QString(); } @@ -855,8 +862,27 @@ void MessageLogWidget::logUndoDrawFailed(PlayerLogic *player) void MessageLogWidget::setContextJudgeName(QString name) { - messagePrefix = QString(""); - messageSuffix = QString(" [ %1]").arg(sanitizeHtml(name)); + contextJudgeName = std::move(name); + refreshJudgeSuffix(); +} + +void MessageLogWidget::refreshJudgeSuffix() +{ + const QPixmap pixmap = loadThemedIcon(QStringLiteral("icons/scales")).pixmap(QSize(24, 24)); + if (!pixmap.isNull()) { + // Rich text cannot resolve `currentColor`; register a palette-rendered + // image so already-appended judge tags repaint on a theme switch. + document()->addResource(QTextDocument::ImageResource, QUrl(QStringLiteral("cockatrice:scales")), pixmap); + document()->markContentsDirty(0, document()->characterCount()); + } + + if (contextJudgeName.isEmpty()) { + return; + } + + messagePrefix = QStringLiteral("").arg(palette().color(QPalette::WindowText).name()); + messageSuffix = + QStringLiteral(" [ %1]").arg(sanitizeHtml(contextJudgeName)); } void MessageLogWidget::appendHtmlServerMessage(const QString &html, bool optionalIsBold, QString optionalFontColor) @@ -905,4 +931,6 @@ void MessageLogWidget::connectToPlayerEventHandler(PlayerEventHandler *playerEve MessageLogWidget::MessageLogWidget(TabSupervisor *_tabSupervisor, AbstractGame *_game, QWidget *parent) : ChatView(_tabSupervisor, _game, true, parent), currentContext(MessageContext_None) { + connect(themeManager, &ThemeManager::paletteChanged, this, &MessageLogWidget::refreshJudgeSuffix); + connect(themeManager, &ThemeManager::themeChanged, this, &MessageLogWidget::refreshJudgeSuffix); } diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index 5632f5af8..3ec5ff93c 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -39,8 +39,10 @@ private: MessageContext currentContext; QString messagePrefix, messageSuffix; + QString contextJudgeName; static QPair getFromStr(CardZoneLogic *zone, QString cardName, int position, bool ownerChange); + void refreshJudgeSuffix(); public: void connectToPlayerEventHandler(PlayerEventHandler *player); From df74917cb0d902371ce6eb4d334dbbc9c667ab66 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:39:43 +0100 Subject: [PATCH 052/104] [PrintingSelector] Load the pin badge through the themed icon engine (#7598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge rendered `theme:icons/pin` through the pixmap generator, which resolved `currentColor` once and cached the result, so the glyph stayed at its first palette and was rasterized at DPR 1. - Render the glyph through `loadThemedIcon`, at the label's device pixel ratio, and re-render it on palette and theme changes. - Compose it onto a rounded backdrop filled with `Window` and outlined with `WindowText`. The badge floats on card art rather than on a palette surface, so a bare outline vanishes into art of the same lightness; the backdrop keeps it readable on every printing, and both roles still follow the palette. Co-authored-by: Lukas Brübach --- .../printing_selector_card_overlay_widget.cpp | 90 ++++++++++++++----- 1 file changed, 67 insertions(+), 23 deletions(-) diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index 94acdbdce..a5177a6bc 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -2,6 +2,8 @@ #include "../../../client/settings/cache_settings.h" #include "../../card_picture_loader/card_picture_loader.h" +#include "../../theme_icon.h" +#include "../../theme_manager.h" #include "../cards/card_info_picture_widget.h" #include "../tabs/abstract_tab_deck_editor.h" #include "all_zones_card_amount_widget.h" @@ -12,16 +14,19 @@ #include #include +#include #include #include #include #include -#include #include #include #include #include #include +#include +#include +#include #include #include #include @@ -36,7 +41,6 @@ #include #include #include -#include #include #include #include @@ -120,6 +124,12 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa mainLayout->addWidget(cardInfoPicture); initializePinBadge(); + auto refreshPinBadge = [this] { + initializePinBadge(); + updatePinBadgeVisibility(); + }; + connect(themeManager, &ThemeManager::paletteChanged, this, refreshPinBadge); + connect(themeManager, &ThemeManager::themeChanged, this, refreshPinBadge); // Parent the preview to this overlay so it is destroyed with it (Qt::ToolTip keeps // it a frameless, non-activating top-level window despite the parent). @@ -401,10 +411,55 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) } /** - * @brief Initializes the pin badge overlay and loads its icon with DPI-aware rasterization. + * @brief Composes the pin badge: the glyph over a rounded palette backdrop. * - * The icon is rasterized once using the label's device pixel ratio so it stays crisp on HiDPI - * displays. The resulting pixmap is cached on the QLabel and simply shown/hidden when needed. + * The badge floats on card art rather than on a palette surface, so a bare glyph + * colored WindowText is not enough: a thin dark stroke disappears on dark art in a + * light palette, and a thin light stroke disappears on light art in a dark palette. + * A Window-filled rounded rect with a WindowText outline gives the glyph a solid + * backing, and the outline guarantees the badge edge contrasts with the art even when + * the fill matches it. Both roles follow the palette, like the glyph. + * + * @param logicalSize The badge's size in logical pixels. + * @param devicePixelRatio The label's device pixel ratio, applied to the pixmap so it + * stays crisp on HiDPI displays. + * @param palette The palette the glyph's WindowText resolves against. + */ +static QPixmap composePinBadgePixmap(const QSize &logicalSize, qreal devicePixelRatio, const QPalette &palette) +{ + // Inset the glyph so the backdrop reads as a badge around it, not as a border. + const int inset = qRound(logicalSize.width() * 0.16); + const QSize glyphSize = logicalSize - QSize(inset * 2, inset * 2); + const QPixmap glyph = loadThemedIcon(QStringLiteral("icons/pin")).pixmap(glyphSize, devicePixelRatio); + if (glyph.isNull()) { + // Let the caller fall back to the textual badge instead of drawing an empty panel. + return QPixmap(); + } + + QPixmap badge(logicalSize * devicePixelRatio); + badge.setDevicePixelRatio(devicePixelRatio); + badge.fill(Qt::transparent); + + QPainter painter(&badge); + painter.setRenderHint(QPainter::Antialiasing); + + // Half-pixel inset keeps the 1.5px outline inside the pixmap instead of clipping it. + const QRectF box(QPointF(0.5, 0.5), QSizeF(logicalSize) - QSizeF(1, 1)); + painter.setPen(QPen(palette.color(QPalette::WindowText), 1.5)); + painter.setBrush(palette.color(QPalette::Window)); + painter.drawRoundedRect(box, 8, 8); + painter.drawPixmap(QPointF(inset, inset), glyph); + + return badge; +} + +/** + * @brief Initializes the pin badge overlay and loads a DPI-aware icon pixmap. + * + * The glyph is drawn onto a rounded palette backdrop so the badge stays readable on + * card art, and it is rasterized at the label's device pixel ratio so it stays crisp on + * HiDPI displays. It is re-rendered when the palette or theme changes so the badge + * follows the surrounding UI instead of freezing at its first colors. * If the SVG cannot be read, a textual fallback badge is created instead. */ void PrintingSelectorCardOverlayWidget::initializePinBadge() @@ -420,24 +475,13 @@ void PrintingSelectorCardOverlayWidget::initializePinBadge() } bool pinLoaded = false; - QImageReader pinReader(QStringLiteral("theme:icons/pin")); - - if (pinReader.canRead()) { - // Rasterize a 64×64 logical icon so it has a consistent size regardless of card scaling. - const QSize targetSize(64, 64); - const qreal dpr = pinBadge->devicePixelRatioF(); - const QSize rasterSize(qMax(1, qCeil(targetSize.width() * dpr)), qMax(1, qCeil(targetSize.height() * dpr))); - pinReader.setScaledSize(rasterSize); - const QImage pinImage = pinReader.read(); - if (!pinImage.isNull()) { - // Tag the pixmap with the same DPR so Qt renders it at the correct physical size. - QPixmap pinPix = QPixmap::fromImage(pinImage); - pinPix.setDevicePixelRatio(dpr); - pinBadge->setPixmap(pinPix); - pinBadge->setFixedSize(targetSize); - pinBadge->setStyleSheet(QStringLiteral("background: transparent;")); - pinLoaded = true; - } + const QSize targetSize(64, 64); + const QPixmap pinPix = composePinBadgePixmap(targetSize, pinBadge->devicePixelRatioF(), pinBadge->palette()); + if (!pinPix.isNull()) { + pinBadge->setPixmap(pinPix); + pinBadge->setFixedSize(targetSize); + pinBadge->setStyleSheet(QStringLiteral("background: transparent;")); + pinLoaded = true; } if (!pinLoaded) { From 64c780933212c5a753013edaa09800af4a455cc1 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 07:49:20 +0100 Subject: [PATCH 053/104] [DlgCreateGame] Heap-allocate create game dialog to prevent crash on parent deletion (#7777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [DlgCreateGame] Heap-allocate create game dialog to prevent crash on parent deletion * [Tests] Run the short-write cases without forking a death test The three cases that force a write failure - CardDatabaseTest.Xml4ShortWriteKeepsInstalledFile, OracleDatabaseWriter.ShortWriteKeepsPreviouslyInstalledDatabase and SpoilerFileTest.ShortWriteLeavesExistingFileIntact - all forked a child, lowered RLIMIT_FSIZE and ignored SIGXFSZ. That child is killed by SIGSEGV on the macOS runners, so all three have been red on every macOS leg of master since they landed, while the same cases pass on Linux. Cap the soft limit in the test process instead: ctest already runs one case per process, and the guard restores the limit and the SIGXFSZ handler, so a whole binary keeps working. Same real EFBIG failure, no fork, and macOS keeps its coverage of the writers. * [CI] Derive DEBUG_TEST and RUN_TESTS in the nightly job The shared steps gate on both, but build-linux-nightly never set them, so its coverage leg skipped "Build debug and test" and then failed collecting a report from an empty build/ directory on every nightly and tag run. continue-on-error kept the run green, so the coverage artifact silently never existed. Derive both exactly as the pull request job does. --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- .github/workflows/desktop-build.yml | 6 ++ .../widgets/server/game_selector.cpp | 15 ++-- .../src/interface/widgets/tabs/tab_game.cpp | 6 +- tests/carddatabase/carddatabase_test.cpp | 30 ++----- tests/oracle/oracle_importer_test.cpp | 31 +++---- tests/spoiler_file_test.cpp | 32 ++----- tests/support/scoped_file_size_limit.h | 83 +++++++++++++++++++ 7 files changed, 132 insertions(+), 71 deletions(-) create mode 100644 tests/support/scoped_file_size_limit.h diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 4ac28d3a5..5a306e023 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -518,6 +518,12 @@ jobs: CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} + # The shared steps below gate on these, so they have to be derived here too. Left + # undefined they silently skipped the build the leg exists for: the coverage leg + # found build/ empty and its report collection failed on every nightly and tag run. + # Same derivation as the pull request job above. + DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} + RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} steps: *linux-steps build-vcpkg: diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 2c0050afe..468095adb 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -266,9 +266,13 @@ void GameSelector::actCreate() return; } - DlgCreateGame dlg(room, room->getGameTypes(), this); - dlg.exec(); - updateTitle(); + // Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the + // dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated + // child would then be deleted by Qt and crash. + auto *dlg = new DlgCreateGame(room, room->getGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + connect(dlg, &QDialog::finished, this, &GameSelector::updateTitle); + dlg->open(); } void GameSelector::checkResponse(const Response &response) @@ -362,8 +366,9 @@ void GameSelector::customContextMenu(const QPoint &point) const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); const QMap &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id()); - DlgCreateGame dlg(gameInfo, gameTypes, this); - dlg.exec(); + auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->open(); }); QAction copyLink(tr("Cop&y game link")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index ac9edd1ba..01d021907 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -570,8 +570,10 @@ void TabGame::adminLockChanged(bool lock) void TabGame::actGameInfo() { - DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); - dlg.exec(); + // Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object. + auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->open(); } void TabGame::actCopyGameLink() diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index b54205f30..350d9be7e 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -1,3 +1,4 @@ +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -36,12 +37,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { @@ -247,21 +242,14 @@ TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile) ASSERT_EQ(installed.write("installed database"), 18); installed.close(); - // A real filesystem write failure, isolated in the death-test child so the - // process-wide file limit never affects other cases or the parent runner. - ASSERT_EXIT( - { - std::signal(SIGXFSZ, SIG_IGN); - struct rlimit limit; - limit.rlim_cur = 128; - limit.rlim_max = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - CockatriceXml4Parser writer(&controller); - _exit(writer.saveToFile({}, sets, cards, fileName) ? 1 : 0); - }, - ::testing::ExitedWithCode(0), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + CockatriceXml4Parser writer(&controller); + EXPECT_FALSE(writer.saveToFile({}, sets, cards, fileName)); ASSERT_TRUE(installed.open(QIODevice::ReadOnly)); EXPECT_EQ(installed.readAll(), "installed database"); diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 259bae087..1b4e2d915 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,6 +1,7 @@ #include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/card_info.h" #include "gtest/gtest.h" @@ -33,12 +34,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - class OracleImporterTest : public ::testing::Test { protected: @@ -1336,20 +1331,16 @@ TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase) original.close(); const QByteArray data = ""; - // A real filesystem write failure, isolated in the death-test child so the - // process-wide file limit never affects other cases or the parent runner. - ASSERT_EXIT( - { - std::signal(SIGXFSZ, SIG_IGN); - struct rlimit limit; - limit.rlim_cur = 128; - limit.rlim_max = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - _exit(OracleDatabase::saveXml(path, data) ? 1 : 0); - }, - ::testing::ExitedWithCode(0), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + QString error; + EXPECT_FALSE(OracleDatabase::saveXml(path, data, &error)); + EXPECT_FALSE(error.isEmpty()); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); EXPECT_EQ(original.readAll(), "installed database"); } diff --git a/tests/spoiler_file_test.cpp b/tests/spoiler_file_test.cpp index 9944f95bd..ecdc3285f 100644 --- a/tests/spoiler_file_test.cpp +++ b/tests/spoiler_file_test.cpp @@ -1,4 +1,5 @@ #include "client/network/update/card_spoiler/spoiler_file.h" +#include "support/scoped_file_size_limit.h" #include #include @@ -6,12 +7,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { class SpoilerFileTest : public ::testing::Test @@ -123,25 +118,16 @@ TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt) #ifdef Q_OS_UNIX TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact) { - // Limit only the child process: a real short write exercises QSaveFile's - // buffered flush failure without depending on disk capacity or file ownership. const QByteArray changed = ""; - EXPECT_EXIT( - { - struct rlimit limit; - if (getrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - limit.rlim_cur = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(3); - } - std::signal(SIGXFSZ, SIG_IGN); - const auto result = save(changed); - _exit(result == SpoilerFile::SaveResult::Failed && !error.isEmpty() ? 0 : 1); - }, - ::testing::ExitedWithCode(0), ""); + // A real short write exercises QSaveFile's buffered flush failure without depending + // on disk capacity. The cap is scoped rather than forked, because a death-test child + // under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); EXPECT_EQ(read(), original); EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); } diff --git a/tests/support/scoped_file_size_limit.h b/tests/support/scoped_file_size_limit.h new file mode 100644 index 000000000..1c1f3d939 --- /dev/null +++ b/tests/support/scoped_file_size_limit.h @@ -0,0 +1,83 @@ +#ifndef COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H +#define COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H + +#include + +#ifdef Q_OS_UNIX + +#include +#include +#include + +namespace test_support +{ +/** + * @brief Caps how large a single file may grow, so a writer's short-write path is reachable. + * + * Lowering RLIMIT_FSIZE makes the next write past the cap fail with EFBIG instead of + * filling a filesystem, which is the only way to fail an install step mid-write without + * depending on free disk space or file ownership. Only the soft limit is lowered, so the + * destructor can hand the process back exactly as it found it: ctest runs one case per + * process, but a whole test binary must not carry the cap into its neighbours. + * + * Do not use this inside a gtest death test. A forked child under RLIMIT_FSIZE is killed + * by SIGSEGV on the macOS runners, while the same case passes on Linux, so the fork is + * what breaks - not the writer. The guard alone isolates the failure just as well. + */ +class ScopedFileSizeLimit +{ +public: + explicit ScopedFileSizeLimit(rlim_t maxBytes) + { + if (getrlimit(RLIMIT_FSIZE, &previousLimit) != 0) { + return; + } + + struct sigaction ignore = {}; + ignore.sa_handler = SIG_IGN; + sigemptyset(&ignore.sa_mask); + if (sigaction(SIGXFSZ, &ignore, &previousAction) != 0) { + return; + } + actionSaved = true; + + struct rlimit capped = previousLimit; + capped.rlim_cur = std::min(maxBytes, previousLimit.rlim_max); + if (setrlimit(RLIMIT_FSIZE, &capped) != 0) { + return; + } + active = true; + } + + ~ScopedFileSizeLimit() + { + if (active) { + setrlimit(RLIMIT_FSIZE, &previousLimit); + } + if (actionSaved) { + sigaction(SIGXFSZ, &previousAction, nullptr); + } + } + + ScopedFileSizeLimit(const ScopedFileSizeLimit &) = delete; + ScopedFileSizeLimit &operator=(const ScopedFileSizeLimit &) = delete; + + /** + * @brief Whether the cap is in force; a test that needs the failure must assert this. + */ + [[nodiscard]] bool isValid() const + { + return active; + } + +private: + bool active = false; + bool actionSaved = false; + struct rlimit previousLimit = {}; + struct sigaction previousAction = {}; +}; +} // namespace test_support + +#endif // Q_OS_UNIX + +#endif // COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H From 74686d173637a287895149f009a8c35dfd0366df Mon Sep 17 00:00:00 2001 From: Jurriaan Date: Thu, 5 Nov 2026 12:40:32 +0100 Subject: [PATCH 054/104] [Client] Added an exclude and color identity filter for the visual database display (#7587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Add exclude and color identity filters to the visual database display * [Filters] Extract the color filter mode derivation into libcockatrice_filters The mode table and the 'which mode produced this filter set' derivation are filter semantics, not widget code, so they belong next to CardFilter rather than in an anonymous namespace in the client. Moving them also makes them testable: filter_mode_test pins every mode, the Custom fallback and the 'keep the live mode when it still fits' rule. * [Tests] Cover the negated color identity query ci! read the camelCase colorIdentity property instead of the lowercase coloridentity key, so it matched the empty string and rejected every card. The new case uses a card whose colors and color identity differ, so it fails on the old lookup and passes on the fixed one. --------- Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- ...l_database_display_color_filter_widget.cpp | 206 +++++++++--------- ...ual_database_display_color_filter_widget.h | 13 +- .../visual_database_display_widget.cpp | 1 + .../libcockatrice/card/card_info.cpp | 4 + .../libcockatrice/card/card_info.h | 1 + libcockatrice_filters/CMakeLists.txt | 8 +- .../libcockatrice/filters/filter_card.cpp | 2 + .../libcockatrice/filters/filter_card.h | 1 + .../libcockatrice/filters/filter_mode.cpp | 77 +++++++ .../libcockatrice/filters/filter_mode.h | 62 ++++++ .../libcockatrice/filters/filter_string.cpp | 4 +- .../libcockatrice/filters/filter_tree.cpp | 60 +++-- .../libcockatrice/filters/filter_tree.h | 1 + tests/carddatabase/CMakeLists.txt | 34 ++- tests/carddatabase/filter_mode_test.cpp | 147 +++++++++++++ tests/carddatabase/filter_string_test.cpp | 19 ++ tests/carddatabase/filter_tree_test.cpp | 204 +++++++++++++++++ 17 files changed, 703 insertions(+), 141 deletions(-) create mode 100644 libcockatrice_filters/libcockatrice/filters/filter_mode.cpp create mode 100644 libcockatrice_filters/libcockatrice/filters/filter_mode.h create mode 100644 tests/carddatabase/filter_mode_test.cpp create mode 100644 tests/carddatabase/filter_tree_test.cpp diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp index 464d62e4a..46330ac46 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp @@ -15,9 +15,23 @@ #include #include +namespace +{ + +/** @brief Remove the filters that apply to the given color and attribute from the given model. */ +static void removeFilters(FilterTreeModel *filterModel, QChar color, CardFilter::Attr attr) +{ + for (const CardFilter *filter : filterModel->getFiltersOfType(attr)) { + if (filter->term() == color) { + filterModel->removeFilter(filter); + } + } +} +} // namespace + /** - * This widget provides a graphical control element for the CardFilter::Attr::AttrColor filters applied to the filter - * model. + * This widget provides a graphical control element for the CardFilter::Attr::AttrColor and + * CardFilter::Attr::AttrColorIdentity filters applied to the filter model. * @param parent The Qt Widget that this widget will parent to * @param _filterModel The filter model that this widget will manipulate */ @@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(true); modeComboBox->clear(); - modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes)); + modeComboBox->addItem(tr("Excludes"), QVariant::fromValue(FilterMode::Excludes)); modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude)); + modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); + modeComboBox->addItem(tr("Color identity"), QVariant::fromValue(FilterMode::ColorIdentity)); + + modeComboBox->setItemData(0, tr("Cards containing any of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(1, tr("Cards containing none of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(2, tr("Cards containing any of the selected colors and none of the unselected colors"), + Qt::ToolTipRole); + modeComboBox->setItemData(3, tr("Cards being exactly of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(4, tr("Cards whose color identity fits within the selected colors"), Qt::ToolTipRole); + + if (currentMode == FilterMode::Custom) { + addCustomFilterModeItem(); + } modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter")); + modeComboBox->setAccessibleName(tr("Color filter mode")); // Restore current mode const int index = modeComboBox->findData(QVariant::fromValue(currentMode)); @@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(false); } +void VisualDatabaseDisplayColorFilterWidget::setFilterMode(FilterMode filterMode) +{ + int index = modeComboBox->findData(QVariant::fromValue(filterMode)); + + if (index < 0 && filterMode == FilterMode::Custom) { + index = modeComboBox->count(); + addCustomFilterModeItem(); + } + + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } +} + +void VisualDatabaseDisplayColorFilterWidget::addCustomFilterModeItem() +{ + int customIndex = modeComboBox->count(); + modeComboBox->addItem(tr("Custom"), QVariant::fromValue(FilterMode::Custom)); + modeComboBox->setItemData(customIndex, tr("Cards matching an unknown combination of color filters"), + Qt::ToolTipRole); +} + void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active) { if (active) { @@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color) { - QString colorString = color; - QString typeStr; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove previous filters + removeFilters(filterModel, color, info.attr); - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; - - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add actual filter - - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeAnd, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; + if (info.includeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.includeType, info.attr)); } } void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color) { - QString colorString = color; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove inclusion filters - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; + removeFilters(filterModel, color, info.attr); - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add exclusion filters if the mode demands it - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - // No exclusion in Includes mode - break; + // Add exclusion filters if the mode demands it. + if (info.excludeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr)); } } @@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode() filterModel->filterTree()->blockSignals(true); filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity); const QList manaSymbolWidgets = findChildren(); @@ -211,52 +212,53 @@ QList VisualDatabaseDisplayColorFilterWidget::getActiveColors() void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel() { - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); + QList filters; + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor)); + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity)); - QList manaSymbolWidgets = findChildren(); + // Re-derive the mode from the filters in the model, so that a loaded filter set selects the mode that + // produced it (or Custom when none could have). + currentMode = deriveFilterMode(filters, currentMode); - for (ManaSymbolWidget *manaSymbolWidget : manaSymbolWidgets) { + // Update the combobox. Add a Custom item only if we have a custom filter mode. + int customIndex = modeComboBox->findData(QVariant::fromValue(FilterMode::Custom)); + modeComboBox->blockSignals(true); + if (currentMode == FilterMode::Custom) { + if (customIndex < 0) { + customIndex = modeComboBox->count(); + addCustomFilterModeItem(); + } + modeComboBox->setCurrentIndex(customIndex); + } else { + if (customIndex >= 0) { + modeComboBox->removeItem(customIndex); + } + int index = modeComboBox->findData(QVariant::fromValue(currentMode)); + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } + } + modeComboBox->blockSignals(false); + + // Update the mana symbol widgets by looking for the current mode's include or exclude filters. + const FilterModeInfo modeInfo = getFilterModeInfo(currentMode); + + const bool useExclude = + modeInfo.includeType == CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd; + const CardFilter::Type filterType = useExclude ? modeInfo.excludeType : modeInfo.includeType; + + filters = filterModel->getFiltersOfType(modeInfo.attr); + + for (ManaSymbolWidget *manaSymbolWidget : findChildren()) { bool found = false; - for (const CardFilter *filter : allColorFilters) { - if (manaSymbolWidget->getSymbolChar() == filter->term()) { - switch (currentMode) { - case FilterMode::ExactMatch: - switch (filter->type()) { - case CardFilter::Type::TypeAnd: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::Includes: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::IncludeExclude: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - } + for (const CardFilter *filter : filters) { + if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) { found = true; + break; } } - if (!found) { - setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false); - } + manaSymbolWidget->setColorActive(useExclude ? !found : found); + manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom); } } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h index 184f1f96c..561c92d09 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h @@ -10,8 +10,8 @@ #include #include #include -#include #include +#include #include #include @@ -38,15 +38,6 @@ private: int circleDiameter; }; -enum class FilterMode -{ - ExactMatch, // Only selected colors are included, all others are excluded. - Includes, // Include selected colors (OR condition). - IncludeExclude // Include selected colors (OR) and exclude unselected colors (AND NOT). -}; - -Q_DECLARE_METATYPE(FilterMode) - class VisualDatabaseDisplayColorFilterWidget : public QWidget { Q_OBJECT @@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget public: explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel); void retranslateUi(); + void setFilterMode(FilterMode filterMode); signals: void filterModeChanged(FilterMode filterMode); private slots: + void addCustomFilterModeItem(); void handleColorToggled(QChar color, bool active); void addFilter(QChar color); void removeFilter(QChar color); 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 af5c74969..6d9b28619 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 @@ -160,6 +160,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); + colorFilterWidget->setFilterMode(FilterMode::Includes); filterModel->clear(); filterModel->blockSignals(false); filterModel->filterTree()->blockSignals(false); diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 90bfd3f44..36f0cdeb8 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -316,6 +316,10 @@ void CardInfo::setColors(const QString &value) { setProperty(Mtg::Colors, value); } +QString CardInfo::getColorIdentity() const +{ + return getProperty(Mtg::ColorIdentity); +} QString CardInfo::getLoyalty() const { return getProperty(Mtg::Loyalty); diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 5452ef98b..075b5b264 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -469,6 +469,7 @@ public: [[nodiscard]] QString getCmc() const; [[nodiscard]] QString getColors() const; void setColors(const QString &value); + [[nodiscard]] QString getColorIdentity() const; [[nodiscard]] QString getLoyalty() const; [[nodiscard]] QString getMainCardType() const; [[nodiscard]] QString getManaCost() const; diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 070c04637..1b4303fc9 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -1,10 +1,10 @@ -set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h - libcockatrice/filters/filter_tree.h +set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_mode.h + libcockatrice/filters/filter_string.h libcockatrice/filters/filter_tree.h ) add_library( - libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_string.cpp - libcockatrice/filters/filter_tree.cpp + libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_mode.cpp + libcockatrice/filters/filter_string.cpp libcockatrice/filters/filter_tree.cpp ) add_dependencies(libcockatrice_filters libcockatrice_card) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 26cddd53c..8af39c098 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -69,6 +69,8 @@ const QString CardFilter::attrName(Attr a) return tr("Type"); case AttrColor: return tr("Color"); + case AttrColorIdentity: + return tr("Color Identity"); case AttrText: return tr("Text"); case AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index dbeffd18f..bc613fc9c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -32,6 +32,7 @@ public: { AttrCmc = 0, AttrColor, + AttrColorIdentity, AttrLoyalty, AttrManaCost, AttrName, diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp new file mode 100644 index 000000000..356ccf608 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp @@ -0,0 +1,77 @@ +/** + * @file filter_mode.cpp + * @ingroup Filters + */ + +#include "libcockatrice/filters/filter_mode.h" + +#include + +FilterModeInfo getFilterModeInfo(FilterMode filterMode) +{ + switch (filterMode) { + case FilterMode::Includes: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::Excludes: + return {CardFilter::Type::TypeAndNot, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::IncludeExclude: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ExactMatch: + return {CardFilter::Type::TypeAnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ColorIdentity: + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity}; + case FilterMode::Custom: + case FilterMode::End: + break; + } + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrEnd}; +} + +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode) +{ + constexpr int modeCount = static_cast(FilterMode::End); + bool modeImpossible[modeCount] = {}; + const QString colors = "WUBRG"; + + for (int i = 0; i < modeCount; ++i) { + const FilterModeInfo modeInfo = getFilterModeInfo(static_cast(i)); + + // Remove the modes that cannot create one of the filters. + for (const CardFilter *filter : filters) { + if (filter->attr() != modeInfo.attr || + (filter->type() != modeInfo.includeType && filter->type() != modeInfo.excludeType)) { + modeImpossible[i] = true; + break; + } + } + + // Modes that have both an include and an exclude filter should always have a filter for each color. + if (modeInfo.includeType != CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd && + !modeImpossible[i]) { + for (const QChar &color : colors) { + int count = 0; + for (const CardFilter *filter : filters) { + count += filter->term() == color; + } + if (count != 1) { + modeImpossible[i] = true; + break; + } + } + } + } + + // Keep the current mode if it can produce the filters, so that a live mode is never swapped for another. + // Switching to and from the custom mode is always possible. + if (!modeImpossible[static_cast(currentMode)] && currentMode != FilterMode::Custom) { + return currentMode; + } + + for (int i = 0; i < modeCount; ++i) { + if (!modeImpossible[i] || i == static_cast(FilterMode::Custom)) { + return static_cast(i); + } + } + + return FilterMode::Custom; +} diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.h b/libcockatrice_filters/libcockatrice/filters/filter_mode.h new file mode 100644 index 000000000..f7662d662 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.h @@ -0,0 +1,62 @@ +/** + * @file filter_mode.h + * @ingroup Filters + */ + +#ifndef LIBCOCKATRICE_FILTERS_FILTER_MODE_H +#define LIBCOCKATRICE_FILTERS_FILTER_MODE_H + +#include "libcockatrice/filters/filter_card.h" + +#include +#include +#include + +/** + * @brief The way selected and unselected colors are combined into card filters. + */ +enum class FilterMode +{ + Includes = 0, // Include selected colors (OR condition). + Excludes, // Exclude selected colors (AND NOT). + IncludeExclude, // Include selected colors (OR) and exclude unselected colors (AND NOT). + ExactMatch, // Only selected colors are included, all others are excluded. + ColorIdentity, // Keep cards whose color identity is a subset of the selected colors (stored as AND NOT for each + // unselected color). + Custom, // The color filters do not match any of the above modes. Not included in the combo box. + End +}; + +Q_DECLARE_METATYPE(FilterMode) + +/** + * @brief The filters a single color contributes in a given mode. + */ +struct FilterModeInfo +{ + // When a color symbol is enabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type includeType; + + // When a color symbol is disabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type excludeType; + + // The attribute to filter for when adding the filters. + CardFilter::Attr attr; +}; + +/** @brief Return the filters the given mode contributes for one color. */ +FilterModeInfo getFilterModeInfo(FilterMode filterMode); + +/** + * @brief Determine the mode that produced the given color filters. + * + * Returns @p currentMode unchanged when that mode can still produce @p filters, so that a live mode is never + * replaced by an equally valid one. Otherwise returns the first mode that can produce them, falling back to + * FilterMode::Custom when none can. + * + * @param filters The filters on CardFilter::Attr::AttrColor and CardFilter::Attr::AttrColorIdentity. + * @param currentMode The mode currently selected in the UI. + */ +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode); + +#endif // LIBCOCKATRICE_FILTERS_FILTER_MODE_H diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 081ada824..1bcb1646b 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -409,7 +409,7 @@ static void setupParserRules() const bool identity = sv.tokens[0].empty() || sv.tokens[0][0] != 'i'; if (sv.tokens[1][0] == ':') { return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("coloridentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } @@ -427,7 +427,7 @@ static void setupParserRules() } return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index f12f87181..817c7298d 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -176,23 +176,24 @@ FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(st lowercaseTerm = term.toLower(); switch (attr()) { - case CardFilter::AttrColor: { - QString converted = term.trimmed(); + case CardFilter::AttrColor: + case CardFilter::AttrColorIdentity: { + QString converted = term.trimmed().toLower(); - converted.replace("green", "g", Qt::CaseInsensitive); - converted.replace("grn", "g", Qt::CaseInsensitive); - converted.replace("blue", "u", Qt::CaseInsensitive); - converted.replace("blu", "u", Qt::CaseInsensitive); - converted.replace("black", "b", Qt::CaseInsensitive); - converted.replace("blk", "b", Qt::CaseInsensitive); - converted.replace("red", "r", Qt::CaseInsensitive); - converted.replace("white", "w", Qt::CaseInsensitive); - converted.replace("wht", "w", Qt::CaseInsensitive); - converted.replace("colorless", "c", Qt::CaseInsensitive); - converted.replace("colourless", "c", Qt::CaseInsensitive); - converted.replace("none", "c", Qt::CaseInsensitive); + converted.replace("green", "g"); + converted.replace("grn", "g"); + converted.replace("blue", "u"); + converted.replace("blu", "u"); + converted.replace("black", "b"); + converted.replace("blk", "b"); + converted.replace("red", "r"); + converted.replace("white", "w"); + converted.replace("wht", "w"); + converted.replace("colorless", "c"); + converted.replace("colourless", "c"); + converted.replace("none", "c"); - converted.replace(QString(" "), QString(""), Qt::CaseInsensitive); + converted.replace(QString(" "), QString("")); convertedColorTerm = converted; break; @@ -358,12 +359,17 @@ bool FilterItem::acceptSubType(const CardInfoPtr &info) const return subType.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptColor(const CardInfoPtr &info) const +/** + * @brief Return true when every letter of the given term appears in the given colors string. + * If the term is equal to "c", then only return true if the color string is empty. + * + * @param term The letters which should be included, or "c" + * @param colors The string to check for letters in + */ +static bool matchesColor(const QString &term, const QString &colors) { - const QString colors = info->getColors(); - // Colorless card filter - if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { + if (term == "c" && colors.length() < 1) { return true; } @@ -372,13 +378,23 @@ bool FilterItem::acceptColor(const CardInfoPtr &info) const * then we should match all of them to the card's colors */ int match_count = 0; - for (const QChar &color : convertedColorTerm) { + for (const QChar &color : term) { if (colors.contains(color, Qt::CaseInsensitive)) { match_count++; } } - return match_count == convertedColorTerm.length(); + return match_count == term.length(); +} + +bool FilterItem::acceptColor(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColors()); +} + +bool FilterItem::acceptColorIdentity(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColorIdentity()); } bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const @@ -540,6 +556,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptType(info); case CardFilter::AttrColor: return acceptColor(info); + case CardFilter::AttrColorIdentity: + return acceptColorIdentity(info); case CardFilter::AttrText: return acceptText(info, searchLanguage); case CardFilter::AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index 85a57304d..f1a5803e5 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -244,6 +244,7 @@ public: [[nodiscard]] bool acceptMainType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptSubType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptColor(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptColorIdentity(const CardInfoPtr &info) const; [[nodiscard]] bool acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptSet(const CardInfoPtr &info) const; [[nodiscard]] bool acceptManaCost(const CardInfoPtr &info) const; diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 7d83640a1..012081fd2 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -21,10 +21,12 @@ cockatrice_add_test( ) # ------------------------ -# Filter String Test -# (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) +# Guard to match condition for libcockatrice_filters in the root CMakeLists.txt # ------------------------ if(WITH_ORACLE OR WITH_CLIENT) + # ------------------------ + # Filter String Test + # ------------------------ cockatrice_add_test( NAME filter_string_test @@ -36,6 +38,34 @@ if(WITH_ORACLE OR WITH_CLIENT) libcockatrice_filters ) + # ------------------------ + # Filter Tree Test + # ------------------------ + cockatrice_add_test( + NAME + filter_tree_test + SOURCES + ${VERSION_STRING_CPP} + filter_tree_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + + # ------------------------ + # Filter Mode Test + # ------------------------ + cockatrice_add_test( + NAME + filter_mode_test + SOURCES + ${VERSION_STRING_CPP} + filter_mode_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + # ------------------------ # Visual Display Model Test # ------------------------ diff --git a/tests/carddatabase/filter_mode_test.cpp b/tests/carddatabase/filter_mode_test.cpp new file mode 100644 index 000000000..3fec7e25d --- /dev/null +++ b/tests/carddatabase/filter_mode_test.cpp @@ -0,0 +1,147 @@ +#include "libcockatrice/filters/filter_mode.h" + +#include + +class FilterModeTest : public ::testing::Test +{ +protected: + ~FilterModeTest() override + { + for (CardFilter *filter : owned) { + delete filter; + } + } + + /** @brief Create a filter owned by the fixture and append it to the set under test. */ + const CardFilter * + add(const QString &term, CardFilter::Type type, CardFilter::Attr attr = CardFilter::Attr::AttrColor) + { + owned.append(new CardFilter(term, type, attr)); + filters.append(owned.last()); + return owned.last(); + } + + QList owned; + QList filters; +}; + +TEST(FilterModeInfoTest, EachModeDescribesItsOwnFilters) +{ + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).excludeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).attr, CardFilter::Attr::AttrColor); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).includeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).excludeType, CardFilter::Type::TypeEnd); + + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).excludeType, CardFilter::Type::TypeAndNot); + + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).includeType, CardFilter::Type::TypeAnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).excludeType, CardFilter::Type::TypeAndNot); + + // Color identity only excludes: the selected colors must be the identity, so nothing is included positively. + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).includeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).excludeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).attr, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Custom).attr, CardFilter::Attr::AttrEnd); +} + +TEST_F(FilterModeTest, EmptyFilterSetKeepsACompatibleMode) +{ + // Includes and Color identity both allow an empty set, so the live mode is not swapped for another. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +// Include / Exclude and Exact match always hold one filter per color, so an empty set cannot have come from +// them and falls back to the first mode that can describe it. +TEST_F(FilterModeTest, EmptyFilterSetFallsBackWhenTheModeNeedsOneFilterPerColor) +{ + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::Includes); +} + +TEST_F(FilterModeTest, IncludesFromOrFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + // A single OR filter cannot come from Excludes, so the first mode that can produce it is selected. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Includes); +} + +TEST_F(FilterModeTest, ExcludesFromAndNotFilters) +{ + add("W", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Excludes); + // Includes needs an OR or an end filter, so an AND NOT filter selects Excludes. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} + +TEST_F(FilterModeTest, IncludeExcludeNeedsOneFilterPerColor) +{ + add("W", CardFilter::Type::TypeOr); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::IncludeExclude); +} + +TEST_F(FilterModeTest, IncludeExcludeRejectsAnIncompleteColorSet) +{ + add("W", CardFilter::Type::TypeOr); + add("U", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Custom); +} + +TEST_F(FilterModeTest, ExactMatchFromAndAndAndNotFilters) +{ + add("W", CardFilter::Type::TypeAnd); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); +} + +TEST_F(FilterModeTest, ColorIdentityFromIdentityAndNotFilters) +{ + for (const QChar color : QString("BRG")) { + add(QString(color), CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +TEST_F(FilterModeTest, MixedAttributesAreCustom) +{ + add("W", CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor); + add("U", CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Custom); +} + +TEST_F(FilterModeTest, CustomLeavesAsSoonAsAModeCanExplainTheFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Custom), FilterMode::Includes); +} + +// Exact match with no color selected stores one AND NOT filter per color. That is a legitimate Exact match +// state, so the live mode is kept; a mode that cannot produce the set re-derives to Excludes, which produces +// the identical filters. +TEST_F(FilterModeTest, FullyExcludedColorSetIsExactMatchOrExcludes) +{ + for (const QChar color : QString("WUBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index e561db590..931cb7e0c 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -123,6 +123,25 @@ CardInfoPtr greenIdentityCard() return CardInfo::newInstance("Identity Cat", "", false, {{"colors", "G"}, {"coloridentity", "G"}}, {}, {}, {}, {}); } +// A card whose color identity differs from its colors, so that a query reading the wrong one is visible. +CardInfoPtr splitIdentityCard() +{ + return CardInfo::newInstance("Split Identity", "", false, {{"colors", "G"}, {"coloridentity", "W"}}, {}, {}, {}, + {}); +} + +// "ci!" (exactly these color identities) used to read the camelCase "colorIdentity" property instead of the +// lowercase "coloridentity" key, so it matched the empty string and rejected every card. +TEST_F(CardQuery, ColorIdentityExactReadsTheColorIdentityProperty) +{ + const CardData card = splitIdentityCard(); + ASSERT_TRUE(FilterString("ci!w").check(card)); + ASSERT_FALSE(FilterString("ci!g").check(card)); + // "c!" still reads colors, not identity. + ASSERT_TRUE(FilterString("c!g").check(card)); + ASSERT_FALSE(FilterString("c!w").check(card)); +} + // Whitespace belongs around the separator, never inside an operator token: "c i: g" must not // be read as the color-identity operator "ci" (it is a generic "c" AND a field query on "i"). TEST_F(CardQuery, ColorIdentityOperatorIsNotSplitByWhitespace) diff --git a/tests/carddatabase/filter_tree_test.cpp b/tests/carddatabase/filter_tree_test.cpp new file mode 100644 index 000000000..8d3706071 --- /dev/null +++ b/tests/carddatabase/filter_tree_test.cpp @@ -0,0 +1,204 @@ + +#include "libcockatrice/filters/filter_tree.h" + +#include +#include +#include + +namespace +{ +class FilterTreeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + language = CardSearchLanguage{}; + + island = CardInfo::newInstance("Island", "", false, {{"colors", ""}, {"coloridentity", "U"}}, {}, {}, {}, {}); + artifact = + CardInfo::newInstance("Artifact", "", false, {{"colors", ""}, {"coloridentity", ""}}, {}, {}, {}, {}); + truth = CardInfo::newInstance("Truth", "", false, {{"colors", "U"}, {"coloridentity", "U"}}, {}, {}, {}, {}); + notDead = + CardInfo::newInstance("Not Dead", "", false, {{"colors", "B"}, {"coloridentity", "B"}}, {}, {}, {}, {}); + wallOfDenial = CardInfo::newInstance("Wall of Denial", "", false, {{"colors", "WU"}, {"coloridentity", "WU"}}, + {}, {}, {}, {}); + maelstromAngel = CardInfo::newInstance("Maelstrom Archangel", "", false, + {{"colors", "WUBRG"}, {"coloridentity", "WUBRG"}}, {}, {}, {}, {}); + } + + CardSearchLanguage language; + + CardData island; // c/u + CardData artifact; // c + CardData truth; // u + CardData notDead; // b + CardData wallOfDenial; // wu + CardData maelstromAngel; // wubrg +}; +} // namespace + +// ===================================================================================================================== +// acceptColor +// ===================================================================================================================== + +TEST_F(FilterTreeTest, Color) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "c"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorAliases) +{ + FilterTree filterTree; + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "green"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "grn"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blue"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blu"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "black"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blk"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "red"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "white"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wht"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colorless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colourless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "none"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); +} + +// ===================================================================================================================== +// acceptColorIdentity +// ===================================================================================================================== + +TEST_F(FilterTreeTest, ColorIdentity) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "c"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityMultiple) +{ + // Simulate a WU commander. + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "r"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "g"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 3339cc9d722d5d357328909453d2a56c450e366e Mon Sep 17 00:00:00 2001 From: SlightlyCircuitous <71394296+SlightlyCircuitous@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:04:50 -0500 Subject: [PATCH 055/104] Add Fedora 45 build and drop Fedora 43 build (#7774) * Create Dockerfile * Delete .ci/Fedora43 directory * Update release_template.md * Update desktop-build.yml --- .ci/{Fedora43 => Fedora45}/Dockerfile | 2 +- .ci/release_template.md | 2 +- .github/workflows/desktop-build.yml | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) rename .ci/{Fedora43 => Fedora45}/Dockerfile (96%) diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora45/Dockerfile similarity index 96% rename from .ci/Fedora43/Dockerfile rename to .ci/Fedora45/Dockerfile index 8aa345651..a7c49b249 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora45/Dockerfile @@ -1,4 +1,4 @@ -FROM fedora:43 +FROM fedora:45 RUN dnf install -y \ ca-certificates \ diff --git a/.ci/release_template.md b/.ci/release_template.md index 23b475150..8eb4c7ebd 100644 --- a/.ci/release_template.md +++ b/.ci/release_template.md @@ -21,8 +21,8 @@ Available pre-compiled binaries for installation: • Ubuntu 24.04 LTS Noble Numbat • Debian 13 Trixie • Debian 12 Bookworm + • Fedora 45 • Fedora 44 - • Fedora 43 General Linux support is available via a flatpak package hosted at Flathub (x86 & ARM)! Thanks to courtesy of @FFY00, the app is also available in Arch Linux's official extra repository. diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 5a306e023..002255fda 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -149,7 +149,7 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already - debug_test: yes # see the note on Fedora 44 below + debug_test: yes # see the note on Fedora 45 below - distro: Servatrice_Debian version: 12 @@ -166,10 +166,10 @@ jobs: ccache_size: 850M # the Release build plus the suite it runs - distro: Fedora - version: 44 + version: 45 package: RPM - # Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions + # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions # in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug: # only the Debug configuration passes -Werror, so these two are the legs where a # new warning in either compiler still fails the build. @@ -183,7 +183,7 @@ jobs: test: skip # The sanitizer leg below runs this distro's suite # The one leg that runs the suite under AddressSanitizer and - # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the + # UndefinedBehaviorSanitizer. Debian 13 and Fedora 45 keep covering the # uninstrumented suite, so nothing is lost by instrumenting this one. # # It packages nothing. A sanitized Debug build is not something to ship, and @@ -223,7 +223,7 @@ jobs: # A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat # below what a leg writes: ccache spent each build deleting the entries the next run # asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each - # leg states the size it needs instead: one configuration for most, two for Fedora 44 + # leg states the size it needs instead: one configuration for most, two for Fedora 45 # (Debug for the suite, Release for the package), the server alone for Servatrice. # Raising these further means caching fewer legs, since GitHub evicts a whole entry # when the total is exceeded. @@ -466,7 +466,7 @@ jobs: test: skip # Running tests on all distros is superfluous - distro: Fedora - version: 43 # previous release, still covered by the nightly + version: 44 # previous release, still covered by the nightly package: RPM test: skip # Running tests on all distros is superfluous @@ -1167,7 +1167,7 @@ jobs: # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. # The nightly legs keep none, so their leftover entries are reclaimed here # instead of waiting out the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora45 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" From 06e3d96308fd4c7f267b5fd2e3e9adcaf1cc14a3 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:41:53 +0100 Subject: [PATCH 056/104] [CI] Size compiler caches for one configuration and split the Fedora leg (#7772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounds in #7667 were set below what one build configuration writes, so ccache filled to its limit and evicted the entries the next build was about to ask for. The probe measured what that costs: a 6G cache on one leg served 1059 of 1059 calls and the 700M bound on the same leg served 14%. The quota also went to legs that finish under the Windows leg, which has no compiler cache. Debug information is most of an object's size. A measured Arch Debug configuration is 1.2 GB with the built-in flags and 660 MB at -g1; Debian 13 Release is 1.1 GB and 614 MB. CI therefore builds every non-tag leg at level 1 through a new COCKATRICE_DEBUG_INFO_LEVEL option, which stays empty by default so local, distro and release builds are unchanged. Level 1 keeps file and line, which is what a failing test or a sanitizer backtrace reports. Tags and the nightly legs keep full DWARF for the Sentry symbols. With one configuration at half the size, the quota goes to fewer legs at a size that holds one whole configuration, and the legs that duplicate another leg's coverage give their cache up: Servatrice, Ubuntu 26.04 behind the sanitize leg of the same distro, macOS 15 and macOS 27 Debug behind macOS 14. Their keys leave LIVE_CCACHE_KEYS, so prune-caches reclaims the existing entries. The single Fedora leg built Debug for the suite and Release for the package in one job (measured on Fedora 44: 30.8 min, the only leg above Windows), so the package compile queued behind the debug one while one 1.6 GB cache held two configurations. Fedora 45 now runs as two legs with one cache each, in parallel. The option reaches compile.sh inside the container through docker.sh, and compile.sh turns it into the CMake flag. Bounds: Arch 850M, Debian 13 800M, Fedora 45 debug 850M and release 800M, Ubuntu 26.04 sanitize 1200M, macOS 14 800M. That is 5.3 GB of compiler cache on top of roughly 4.5 GB of buildkit, vcpkg, Qt and CodeQL entries. Windows still sets the pull-request wall clock: 29.5 min, 23.7 of it compiling with no compiler cache, because ccache and sccache cannot store the 711 of 1054 translation units that use PCH under MSVC (#7381). That is a separate trial. Co-authored-by: Lukas Brübach --- .ci/compile.sh | 5 ++ .ci/docker.sh | 5 ++ .github/workflows/desktop-build.yml | 132 +++++++++++++++++++--------- CMakeLists.txt | 21 +++++ 4 files changed, 122 insertions(+), 41 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 569ae9f6b..7414b2dc3 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -203,6 +203,11 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then # developer builds never reach Sentry. flags+=("-DWITH_CRASH_REPORTING=ON") fi +if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + # CI lowers the DWARF level so one build configuration fits its compiler-cache bound; + # see the option in CMakeLists.txt. Empty keeps the built-in flags. + flags+=("-DCOCKATRICE_DEBUG_INFO_LEVEL=$COCKATRICE_DEBUG_INFO_LEVEL") +fi if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then # Release-only: Debug builds stay DSN-free so crashes from developer builds # never reach Sentry. Empty (local builds, forks without the secret, and non-tag diff --git a/.ci/docker.sh b/.ci/docker.sh index 64ea6baa7..c78ba3c31 100644 --- a/.ci/docker.sh +++ b/.ci/docker.sh @@ -181,6 +181,11 @@ function RUN () if [[ ${WITH_CRASH_REPORTING:-} ]]; then args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}") fi + # Same reason: the DWARF level reaches compile.sh only if it is handed to the container, + # and without it CI would cache objects twice the size its bounds were measured for. + if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + args+=(--env "COCKATRICE_DEBUG_INFO_LEVEL=${COCKATRICE_DEBUG_INFO_LEVEL}") + fi # shellcheck disable=2086 docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@" return $? diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 002255fda..b859556aa 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -150,6 +150,7 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already debug_test: yes # see the note on Fedora 45 below + ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1 - distro: Servatrice_Debian version: 12 @@ -157,30 +158,46 @@ jobs: package: DEB server_only: yes test: skip - ccache_size: 400M # server only, no client to compile + # 4.7 min and the server alone: nothing here for a compiler cache to save. + persist_ccache: 'false' - distro: Debian version: 13 package: DEB - ccache_size: 850M # the Release build plus the suite it runs + ccache_size: 800M # Release plus the suite: 614 MB of objects at -g1 + + # The single Fedora leg used to build Debug for the suite and Release for the + # package in one job (measured on Fedora 44: 30.8 min, the only leg slower than + # Windows), so the package compile waited behind the Debug one. Two legs run in + # parallel and each keeps a cache that holds exactly one configuration. + # + # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions + # in CMakeLists.txt exist for exactly their warnings. This leg compiles Debug + # because only Debug passes -Werror, so it is where a new warning still fails. + - distro: Fedora + version: 45 + variant: debug # separate image and ccache from the release leg below + + package: skip + debug_test: yes + ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1 - distro: Fedora version: 45 + variant: release # separate image and ccache from the debug leg above package: RPM - # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions - # in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug: - # only the Debug configuration passes -Werror, so these two are the legs where a - # new warning in either compiler still fails the build. - debug_test: yes - ccache_size: 900M # Debug for the suite and Release for the package + test: skip # The debug leg above runs this distro's suite + ccache_size: 800M # Release plus the package: 614 MB of objects at -g1 - distro: Ubuntu version: 26.04 package: DEB test: skip # The sanitizer leg below runs this distro's suite + # 18.1 min, and the same distro as the sanitize leg below, which runs this suite. + persist_ccache: 'false' # The one leg that runs the suite under AddressSanitizer and # UndefinedBehaviorSanitizer. Debian 13 and Fedora 45 keep covering the @@ -196,7 +213,7 @@ jobs: package: skip sanitize: yes - ccache_size: 800M # instrumented objects are larger + ccache_size: 1200M # instrumented objects are larger than the 614 MB measured; estimated name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure @@ -211,25 +228,47 @@ jobs: CCACHE_EVICTION_AGE: 7d # The bound is sized against the repository's 10 GB cache quota # (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy), - # not against what one leg would like. The quota currently holds about 1.1 GB of - # CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers - # (mode=min in docker-release.yml, added here, brings the last one down to roughly - # 0.3 GB - the one input here that is an expectation rather than a measurement, since - # mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five - # nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what - # makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache - # bounds + 3.2G of the fixed entries = 9.65G against the 10G quota. + # not against what one leg would like: the quota also holds about 0.9 GB of buildkit + # layers (mode=min in docker-release.yml), 0.8 GB of vcpkg, 0.7 GB of Qt and the CodeQL + # trap caches (about 1 GB each, which prune-caches below does not manage). # - # A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat - # below what a leg writes: ccache spent each build deleting the entries the next run - # asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each - # leg states the size it needs instead: one configuration for most, two for Fedora 45 - # (Debug for the suite, Release for the package), the server alone for Servatrice. - # Raising these further means caching fewer legs, since GitHub evicts a whole entry - # when the total is exceeded. + # Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with + # -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit + # rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls; + # the 700M bound on the same leg served 14%, because ccache fills to the limit and then + # evicts the entries the next build is about to ask for. A cache that cannot hold a + # whole configuration therefore buys almost nothing, and the quota goes to fewer legs + # at a size that holds one: + # + # Arch Debug + suite 660 MB working set 850M + # Debian 13 Release + suite 614 MB 800M + # Fedora 45 debug Debug + suite 660 MB 850M + # Fedora 45 release Release + package 614 MB 800M + # Ubuntu 26.04 sanitize ~1.0 GB 1200M + # + # That is about 5.3 GB of bounds, and with the fixed entries above it puts the quota at + # roughly 9.8 GB of 10 - so only legs worth their share get one. A leg keeps no cache + # when it duplicates another leg's coverage (Ubuntu 26.04 behind the sanitize leg of + # the same distro; macOS 15 and macOS 27 Debug behind macOS 14; Servatrice covers the + # server alone and finishes in 4.7 min). Those four run cold: 4.7, 18.1, 16.5 and + # 15.8 min. The five that stay compile the client on the toolchains a change can break + # and are the ones a cache shortens by the most. Measured job totals on master + # 2026-11-04 also show the ceiling this leaves: Windows 10 is 29.5 min and compiles for + # 23.7 of it with no compiler cache at all, so Windows still sets the pull-request + # wall clock no matter how warm these legs are (see build-vcpkg). + # + # Re-add a leg only with a bound that holds its whole configuration: a smaller one is + # worse than none. # # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix. CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + # Debug information dominates a cached object: with the built-in flags one + # configuration is 1.2 GB and no per-leg bound can hold it, so CI lowers the level to + # 1 (file and line, which is what a failing test or a sanitizer backtrace reports) and + # one configuration halves. Empty on tags and on the nightly legs, which ship binaries + # and keep full DWARF for the Sentry symbols; a tag restores a cache it cannot use and + # is slower, which is accepted because a tag is rare and only pull requests gate. + COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '1' || '' }} CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} @@ -242,11 +281,13 @@ jobs: # also package, so testing and packaging cost one compile instead of two. DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} - # Whether this leg restores and saves its compiler cache. Every leg a pull request - # runs keeps one; the nightly-only legs do not, so that the quota is spent on the - # builds that review a change. A tag build runs both sets, and the nightly legs are - # cold there - accepted, because a tag is rare and only the pull-request legs gate. - PERSIST_CCACHE: 'true' + # Whether this leg restores and saves its compiler cache. Not every pull-request leg + # keeps one: a cache only pays for itself when its bound holds a whole configuration + # (see the sizing comment above), and a leg that duplicates another leg's coverage + # spends quota without adding any. The nightly-only legs keep none either. Fedora 45 + # states it per leg: its two configurations have their own job and key, so neither + # bound is shared with a build whose objects it would evict. + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} # Shared with build-linux-nightly so the two leg sets run the same steps. steps: &linux-steps @@ -540,6 +581,7 @@ jobs: make_package: 1 override_target: 14 package_suffix: "-macOS14" + ccache_size: 800M # one Release configuration plus the suite at -g1 qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -558,6 +600,7 @@ jobs: make_package: 1 override_target: 15 package_suffix: "-macOS15" + persist_ccache: 'false' # 16.5 min: shorter than the Windows leg qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -572,6 +615,7 @@ jobs: ccache_eviction_age: 7d cmake_generator: Ninja + persist_ccache: 'false' # 15.8 min: shorter than the Windows leg qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -609,13 +653,17 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - # macOS configurations compile about twice the TUs of a Linux one, so each of these - # three legs gets a bound above what it has been measured to write. The four macOS - # legs used to be bounded at 600M from the same quota as the Linux ones; the three - # pull-request legs now get 700M each while the nightly Intel leg keeps none, and - # that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic). - CCACHE_SIZE: 700M - PERSIST_CCACHE: 'true' + # One macOS configuration is about 600 MB of objects at -g1, so its bound needs to be + # 800M to hold one whole; a smaller cache serves a few percent and is worthless (see + # the sizing comment in build-linux). macOS 14 keeps the one macOS cache: it runs the + # suite against the shipping Release build and is the longest of the three at 24.2 min. + # macOS 15 (16.5) and macOS 27 Debug (15.8) duplicate its coverage and are shorter, so + # they run cold. + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} + # MSVC has no -g level, so the Windows leg does not ask for one; it has no compiler + # cache to shrink anyway (see the sizing comment in build-linux). + COCKATRICE_DEBUG_INFO_LEVEL: ${{ matrix.os != 'Windows' && github.ref_type != 'tag' && '1' || '' }} # Shared with build-vcpkg-nightly so the two leg sets run the same steps. steps: &vcpkg-steps @@ -1165,9 +1213,10 @@ jobs: LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/' # Must list the ccache key of every leg that keeps a compiler cache # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. - # The nightly legs keep none, so their leftover entries are reclaimed here - # instead of waiting out the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora45 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' + # The nightly legs and the pull-request legs that finish under the Windows leg + # keep none, so their leftover entries are reclaimed here instead of waiting out + # the age rule. + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" @@ -1191,8 +1240,9 @@ jobs: if [[ $ref != refs/heads/master ]]; then reason="scoped to $ref" elif [[ $key == ccache-* ]]; then - # An exact token compare: ccache-Ubuntu26.04 is a prefix of - # ccache-Ubuntu26.04sanitize, and both keys are live + # An exact token compare, because one key can be a prefix of another: + # ccache-Ubuntu26.04 is a prefix of ccache-Ubuntu26.04sanitize, and only + # the sanitize entry is live. if [[ $live_ccache != *" $key "* ]]; then reason="no pull-request leg keeps it" fi diff --git a/CMakeLists.txt b/CMakeLists.txt index 9044bc005..53508fcd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -238,6 +238,27 @@ include(Sanitizers) include(Coverage) include(CrashReporting) +# Debug information is what makes a cached object large, and a build configuration needs more +# room than a per-leg cache bound can spare when it carries full DWARF: ccache then fills to +# its limit and evicts the entries the next build is about to ask for, so it serves a few +# percent of calls while holding most of a build. CI sets a lower level so one whole +# configuration fits; level 1 keeps file and line, which is what a failing test or a sanitizer +# backtrace reports. Empty (the default) leaves local, distro and release builds exactly as +# they were. This comes after CrashReporting, so the -g it adds for Sentry symbols cannot +# raise the level again. +set(COCKATRICE_DEBUG_INFO_LEVEL + "" + CACHE STRING "DWARF level for Debug and Release objects (empty keeps the built-in flags)" +) +if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "") + if(MSVC) + message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC") + else() + string(APPEND CMAKE_CXX_FLAGS_DEBUG " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + endif() +endif() + # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") add_definitions("-DSFMT_MEXP=19937") From daa8671b4d401646e6cef0337889737afabf0343 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:47:47 +0100 Subject: [PATCH 057/104] [Update] Make the update download atomic and its temp path unpredictable (#7627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The downloader wrote to a predictable /tmp/ path, ignored the result of write(), and leaked the previous reply when a check was retried. It also connected no error handler to the reply and had no transfer timeout. Create the file inside a private QTemporaryDir, write it with QSaveFile and commit it only after every byte was accepted, report reply errors, give the request a 30 s transfer timeout, and delete the superseded reply. Ignore a queued finished()/errorOccurred() from a reply that has already been replaced, and drop the manual redirect branch because Qt follows redirects by default. Co-authored-by: Lukas Brübach --- .../update/client/update_downloader.cpp | 111 ++++++++++++++---- .../network/update/client/update_downloader.h | 3 + 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/cockatrice/src/client/network/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp index 8868876fa..8b5ef3d6c 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.cpp +++ b/cockatrice/src/client/network/update/client/update_downloader.cpp @@ -1,15 +1,16 @@ #include "update_downloader.h" -#include -#include #include -#include +#include #include #include #include +#include +#include #include #include #include +#include UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) { @@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl) originalUrl = downloadUrl; } + if (auto *old = std::exchange(response, nullptr)) { + old->abort(); + old->deleteLater(); + } + + errorReported = false; + cancelRequested = false; + + // The transfer timeout comes from the manager (NetworkManagerFactory). response = netMan->get(QNetworkRequest(downloadUrl)); connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished); + connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError); connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress); - connect(this, &UpdateDownloader::stopDownload, response, &QNetworkReply::abort); + connect(this, &UpdateDownloader::stopDownload, this, &UpdateDownloader::cancelDownload); } -void UpdateDownloader::downloadError(QNetworkReply::NetworkError) +void UpdateDownloader::downloadError(QNetworkReply::NetworkError networkError) { - if (response == nullptr) { + // A reply that was superseded by beginDownload() can still deliver a + // queued error; it must not be reported against the current reply. + if (response == nullptr || sender() != response || errorReported) { return; } - emit error(response->errorString().toUtf8()); + // A transfer timeout aborts the reply, so it arrives as OperationCanceledError. + // Only an abort the user asked for is suppressed; every other cancel is a + // stalled transfer and has to be reported. + if (networkError == QNetworkReply::OperationCanceledError) { + if (cancelRequested) { + return; + } + + errorReported = true; + emit error(tr("The download timed out. Check your connection and try again.")); + return; + } + + errorReported = true; + emit error(response->errorString()); +} + +void UpdateDownloader::cancelDownload() +{ + cancelRequested = true; + if (response != nullptr) { + response->abort(); + } } void UpdateDownloader::fileFinished() { - // If we finished but there's a redirect, follow it - QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute); - if (!redirect.isNull()) { - beginDownload(redirect.toUrl()); + // An aborted, superseded reply also emits finished(). Only the reply + // currently stored in response may be consumed. + if (response == nullptr || sender() != response) { return; } - // Handle any errors we had - if (response->error()) { - emit error(response->errorString()); + // errorOccurred normally reports this first; keep the check for errors that + // only surface when the reply finishes. + if (response->error() != QNetworkReply::NoError) { + downloadError(response->error()); + response->deleteLater(); + response = nullptr; return; } - // Work out the file name of the download - QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1); + // Give the update its own private directory. The name is unpredictable, so a + // local user cannot pre-create or symlink the path, and the original file + // name (including the installer suffix) is preserved inside it. + QTemporaryDir downloadDir(QDir::temp().filePath(QStringLiteral("cockatrice-update-XXXXXX"))); + if (!downloadDir.isValid()) { + emit error(tr("Could not create a temporary directory for the download.")); + response->deleteLater(); + response = nullptr; + return; + } + downloadDir.setAutoRemove(false); - // Save the build in a temporary directory - QFile file(fileName); + const QString remoteName = QFileInfo(originalUrl.path()).fileName(); + const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName); + + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { - emit error(tr("Could not open the file for reading.")); + downloadDir.remove(); + emit error(tr("Could not open the file for writing: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; return; } - file.write(response->readAll()); - file.close(); + const QByteArray data = response->readAll(); + if (file.write(data) != data.size()) { + file.cancelWriting(); + downloadDir.remove(); + emit error(tr("Could not write the downloaded file to disk.")); + response->deleteLater(); + response = nullptr; + return; + } + + if (!file.commit()) { + downloadDir.remove(); + emit error(tr("Could not save the downloaded file: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; + return; + } + + response->deleteLater(); + response = nullptr; // Emit the success signal with a URL to the download file emit downloadSuccessful(QUrl::fromLocalFile(fileName)); diff --git a/cockatrice/src/client/network/update/client/update_downloader.h b/cockatrice/src/client/network/update/client/update_downloader.h index 8aeefb22a..7704eca38 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.h +++ b/cockatrice/src/client/network/update/client/update_downloader.h @@ -32,10 +32,13 @@ private: QUrl originalUrl; QNetworkAccessManager *netMan; QNetworkReply *response; + bool errorReported = false; + bool cancelRequested = false; private slots: void fileFinished(); void downloadProgress(qint64 bytesRead, qint64 totalBytes); void downloadError(QNetworkReply::NetworkError); + void cancelDownload(); }; #endif // COCKATRICE_UPDATEDOWNLOADER_H From 17f583013b2b225be37e4f96a5ec004f23d02b52 Mon Sep 17 00:00:00 2001 From: DawnFire42 Date: Fri, 6 Nov 2026 14:51:00 -0500 Subject: [PATCH 058/104] [CardMenu] Free the card menus and submenus rebuilt on each right-click (#7093) * Parent card menu submenus to the menu that owns them QMenu::addMenu(QMenu *) does not take ownership of the submenu, and these submenus were created without a parent. Since CardMenu is rebuilt from scratch on every right-click, each rebuild leaked the MoveMenu, PtMenu, card counters and related-cards submenus. Pass the owning CardMenu as the QWidget parent so Qt's parent-child mechanism frees them with it. * Delete the replaced card menu in TabGame to fix menu rebuild leak PlayerMenu creates a new CardMenu on every right-click and TabGame installs it with QAction::setMenu(), which does not take ownership, so the previous menu was never freed. TabGame now deletes the menu it replaces and reuses a single empty placeholder menu instead of allocating one per update. The installed menu is also released when the tab is destroyed. --- .../game_graphics/player/menu/card_menu.cpp | 16 ++++++------- .../game_graphics/player/menu/move_menu.cpp | 2 +- .../src/game_graphics/player/menu/move_menu.h | 2 +- .../src/game_graphics/player/menu/pt_menu.cpp | 2 +- .../src/game_graphics/player/menu/pt_menu.h | 2 +- .../src/interface/widgets/tabs/tab_game.cpp | 24 ++++++++++++++----- .../src/interface/widgets/tabs/tab_game.h | 2 ++ 7 files changed, 32 insertions(+), 18 deletions(-) diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 9f00c0cba..96835bcf5 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -123,7 +123,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts aRevealToAll = new QAction(this); - mCardCounters = new QMenu; + mCardCounters = new QMenu(this); // Card counters for (int i = 0; i < 6; ++i) { @@ -225,7 +225,7 @@ void CardMenu::createTableMenu(bool canModifyCard) } addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); addSeparator(); addAction(aAttach); if (card->getAttachedTo()) { @@ -233,7 +233,7 @@ void CardMenu::createTableMenu(bool canModifyCard) } addAction(aDrawArrow); addSeparator(); - addMenu(new PtMenu(player)); + addMenu(new PtMenu(player, this)); addAction(aSetAnnotation); addSeparator(); addAction(aReduceLifeByPower); @@ -275,7 +275,7 @@ void CardMenu::createStackMenu(bool canModifyCard) addAction(aPlayFacedown); addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); addSeparator(); addAction(aAttach); addAction(aDrawArrow); @@ -304,7 +304,7 @@ void CardMenu::createGraveyardOrExileMenu(bool canModifyCard) addAction(aPlayFacedown); addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); addSeparator(); addAction(aAttach); addAction(aDrawArrow); @@ -342,7 +342,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard) addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); // actions that are really wonky when done from deck or sideboard if (card->getZone()->getName() == ZoneNames::HAND) { @@ -366,7 +366,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard) void CardMenu::createZonelessMenu(bool canModifyCard) { if (canModifyCard) { - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); } } @@ -415,7 +415,7 @@ void CardMenu::addRelatedCardView() } addSeparator(); - auto viewRelatedCards = new QMenu(tr("View related cards")); + auto viewRelatedCards = new QMenu(tr("View related cards"), this); addMenu(viewRelatedCards); for (const CardRelation *relatedCard : relatedCards) { QString relatedCardName = relatedCard->getName(); diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index b81c4619b..24bfa3b0c 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -10,7 +10,7 @@ #include #include -MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) +MoveMenu::MoveMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Move to"), parent) { aMoveToTopLibrary = new QAction(this); aMoveToTopLibrary->setData(cmMoveToTopLibrary); diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.h b/cockatrice/src/game_graphics/player/menu/move_menu.h index f1f239abd..a2cb304aa 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.h +++ b/cockatrice/src/game_graphics/player/menu/move_menu.h @@ -17,7 +17,7 @@ class MoveMenu : public QMenu Q_OBJECT public: - explicit MoveMenu(PlayerGraphicsItem *player); + explicit MoveMenu(PlayerGraphicsItem *player, QWidget *parent); void setShortcutsActive(); void retranslateUi(); diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index e6c65fcfb..67e98bd76 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -9,7 +9,7 @@ #include #include -PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness")) +PtMenu::PtMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Power / toughness"), parent) { PlayerActions *playerActions = player->getLogic()->getPlayerActions(); diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.h b/cockatrice/src/game_graphics/player/menu/pt_menu.h index aaf17cead..987f033f8 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.h +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.h @@ -18,7 +18,7 @@ class PtMenu : public QMenu Q_OBJECT public: - explicit PtMenu(PlayerGraphicsItem *player); + explicit PtMenu(PlayerGraphicsItem *player, QWidget *parent); void retranslateUi(); void setShortcutsActive(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 01d021907..055ab321c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -330,6 +330,9 @@ void TabGame::emitUserEvent() TabGame::~TabGame() { + if (aCardMenu && aCardMenu->menu() && aCardMenu->menu() != emptyCardMenu) { + delete aCardMenu->menu(); + } for (auto &player : game->getPlayerManager()->getPlayers()) { player->clear(); } @@ -1158,10 +1161,16 @@ QString TabGame::getTabText() const /** * @param menu The menu to set. Pass in nullptr to set the menu to empty. + * + * The previous card menu is deleted here: QAction::setMenu() does not take ownership, so whoever installs the menu + * (this widget) has to dispose of the one it replaces. The empty placeholder menu is reused instead of deleted. */ void TabGame::setCardMenu(CardMenu *menu) { - if (!aCardMenu) { + if (!aCardMenu || !emptyCardMenu) { + if (menu) { + menu->deleteLater(); + } return; } @@ -1171,10 +1180,11 @@ void TabGame::setCardMenu(CardMenu *menu) gameView->viewport()->update(); } } - if (menu) { - aCardMenu->setMenu(menu); - } else { - aCardMenu->setMenu(new QMenu); + + QMenu *previousMenu = aCardMenu->menu(); + aCardMenu->setMenu(menu ? menu : emptyCardMenu); + if (previousMenu && previousMenu != emptyCardMenu && previousMenu != menu) { + previousMenu->deleteLater(); } } @@ -1258,7 +1268,8 @@ void TabGame::createMenuItems() gameMenu->addSeparator(); - aCardMenu = gameMenu->addMenu(new QMenu(this)); + emptyCardMenu = new QMenu(this); + aCardMenu = gameMenu->addMenu(emptyCardMenu); // Sync the new action with the same state the dock button already shows. updateInviteButtonState(); @@ -1290,6 +1301,7 @@ void TabGame::createReplayMenuItems() gameMenu->addAction(aLeaveGame); aCardMenu = nullptr; + emptyCardMenu = nullptr; addTabMenu(gameMenu); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 142c02999..5c2af82b4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -101,6 +101,8 @@ private: QAction *aInviteToGame = nullptr; QList phaseActions; QAction *aCardMenu; + /** @brief Placeholder submenu shown when no card is selected; reused instead of reallocated. */ + QMenu *emptyCardMenu = nullptr; /** * @brief The actions associated with managing a QDockWidget From 2d625575f68e8bfc337f6793dfc110b9b7c12e40 Mon Sep 17 00:00:00 2001 From: tooomm Date: Fri, 6 Nov 2026 21:12:07 +0100 Subject: [PATCH 059/104] [Translations] Give translators context for separator-bearing tr() strings (#6487) Build the card menu's related card actions from complete strings with %1 placeholders instead of appending translated fragments. This also fixes tr(cond ? "Transform into " : "Attach to "), which lupdate cannot extract: neither string appears in cockatrice_en@source.ts, so neither was ever translated. Give the EDHRec tab title a %1 placeholder so French can render "EDHRec : %1" instead of concatenating into "EDHRec :CardName". Keep the colon in tr("Token:") and the leading space in tr(" seconds"), since that separator is language-specific, and attach //: translator comments so they are no longer shown out of context on Transifex. --- .../src/game_graphics/player/menu/card_menu.cpp | 14 +++++++------- .../widgets/dialogs/dlg_forgot_password_reset.cpp | 1 + .../settings_page/appearance_settings_page.cpp | 1 + .../widgets/tabs/api/edhrec/tab_edhrec_main.h | 3 ++- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 96835bcf5..4f3520814 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -463,16 +463,16 @@ void CardMenu::addRelatedCardActions() relatedCardName = relatedCard.getName(); // "name" } - QString text = tr("Token: "); + QString text; if (cardRelation->getDoesAttach()) { - text += - tr(cardRelation->getDoesTransform() ? "Transform into " : "Attach to ") + "\"" + relatedCardName + "\""; + text = cardRelation->getDoesTransform() ? tr("Token: Transform into \"%1\"").arg(relatedCardName) + : tr("Token: Attach to \"%1\"").arg(relatedCardName); } else if (cardRelation->getIsVariable()) { - text += "X " + relatedCardName; + text = tr("Token: X %1").arg(relatedCardName); } else if (cardRelation->getDefaultCount() != 1) { - text += QString::number(cardRelation->getDefaultCount()) + "x " + relatedCardName; + text = tr("Token: %1x %2").arg(cardRelation->getDefaultCount()).arg(relatedCardName); } else { - text += relatedCardName; + text = tr("Token: %1").arg(relatedCardName); } if (createRelatedCards == nullptr) { @@ -587,4 +587,4 @@ void CardMenu::setShortcutsActive() aRemoveCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aRC" + colorWords[i])); aSetCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aSC" + colorWords[i])); } -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp index 6c1b95b43..ca42fbf40 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp @@ -56,6 +56,7 @@ DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent playernameEdit->setMaxLength(MAX_NAME_LENGTH); playernameLabel->setBuddy(playernameEdit); + //: Code mailed to the player so they can reset their password. Not a game token. tokenLabel = new QLabel(tr("Token:")); tokenEdit = new QLineEdit(); tokenEdit->setMaxLength(MAX_NAME_LENGTH); diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index de5f67b32..ad6b55333 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -146,6 +146,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() }); homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600); + //: Unit following a number of seconds. Keep the leading space unless your language writes number and unit flush. homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds")); homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency()); connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload(&QSpinBox::valueChanged), &settings.appearance(), diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h index 5d0245ed4..ce0861ef7 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h @@ -38,7 +38,8 @@ public: [[nodiscard]] QString getTabText() const override { auto cardName = cardToQuery.isNull() ? QString() : cardToQuery->getName(); - return tr("EDHRec: ") + cardName; + //: EDHRec is a website name, keep it as written. %1 is the card being looked up. + return tr("EDHRec: %1").arg(cardName); } CardSizeWidget *getCardSizeSlider() const From 0798d28dc98c4db90a0f215785babeed3cc9ebfa Mon Sep 17 00:00:00 2001 From: tooomm Date: Fri, 6 Nov 2026 21:38:47 +0100 Subject: [PATCH 060/104] UI: Settings dialog layout fixes (#4531) - Span chat and alert-word checkboxes across the full grid width - Add right-aligned Color labels and HEX placeholders to the mention, highlight and channel color fields - Reword the horizontal hand option to "requires more space" --- .../appearance_settings_page.cpp | 2 +- .../settings_page/messages_settings_page.cpp | 48 +++++++++++-------- .../settings_page/messages_settings_page.h | 6 +-- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index ad6b55333..21c0a79e1 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -676,7 +676,7 @@ void AppearanceSettingsPage::retranslateUi() } handGroupBox->setTitle(tr("Hand layout")); - horizontalHandCheckBox.setText(tr("Display hand horizontally (wastes space)")); + horizontalHandCheckBox.setText(tr("Display hand horizontally (requires more space)")); leftJustifiedHandCheckBox.setText(tr("Enable left justification")); tableGroupBox->setTitle(tr("Table grid layout")); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 09dd9e9b0..274e55866 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -88,18 +88,21 @@ MessagesSettingsPage::MessagesSettingsPage() auto *chatGrid = new QGridLayout; chatGrid->addWidget(&chatMentionCheckBox, 0, 0); - chatGrid->addWidget(&invertMentionForeground, 0, 1); + chatGrid->addWidget(&mentionColorStringLabel, 0, 1, Qt::AlignRight); chatGrid->addWidget(mentionColor, 0, 2); - chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0); - chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0); - chatGrid->addWidget(&hexLabel, 1, 2); - chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0); - chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0); - chatGrid->addWidget(&messagePopups, 5, 0); - chatGrid->addWidget(&mentionPopups, 6, 0); - chatGrid->addWidget(&roomHistory, 7, 0); - chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0); - chatGrid->addWidget(&useGameTimeCheckBox, 9, 0); + chatGrid->addWidget(&invertMentionForeground, 0, 3); + chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0, 1, -1); + chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0, 1, -1); + chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0, 1, -1); + chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0, 1, -1); + chatGrid->addWidget(&messagePopups, 5, 0, 1, -1); + chatGrid->addWidget(&mentionPopups, 6, 0, 1, -1); + chatGrid->addWidget(&roomHistory, 7, 0, 1, -1); + chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0, 1, -1); + chatGrid->addWidget(&useGameTimeCheckBox, 9, 0, 1, -1); + // Expand the first column more than the following ones + chatGrid->setColumnStretch(0, 3); + chatGrid->setColumnStretch(1, 1); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatGrid); @@ -109,11 +112,14 @@ MessagesSettingsPage::MessagesSettingsPage() connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor); auto *highlightNotice = new QGridLayout; - highlightNotice->addWidget(highlightColor, 0, 2); - highlightNotice->addWidget(&invertHighlightForeground, 0, 1); - highlightNotice->addWidget(&hexHighlightLabel, 1, 2); highlightNotice->addWidget(customAlertString, 0, 0); - highlightNotice->addWidget(&customAlertStringLabel, 1, 0); + highlightNotice->addWidget(&highlightColorStringLabel, 0, 1, Qt::AlignRight); + highlightNotice->addWidget(highlightColor, 0, 2); + highlightNotice->addWidget(&invertHighlightForeground, 0, 3); + highlightNotice->addWidget(&customAlertStringLabel, 1, 0, 1, -1); + // Expand the first column more than the following ones + highlightNotice->setColumnStretch(0, 3); + highlightNotice->setColumnStretch(1, 1); customAlertStringLabel.setBuddy(customAlertString); highlightGroupBox = new QGroupBox; highlightGroupBox->setLayout(highlightNotice); @@ -131,10 +137,8 @@ MessagesSettingsPage::MessagesSettingsPage() auto *channelGrid = new QGridLayout; channelGrid->addWidget(&channelUnreadColorLabel, 0, 0); channelGrid->addWidget(channelUnreadColor, 0, 1); - channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2); channelGrid->addWidget(&channelMentionColorLabel, 1, 0); channelGrid->addWidget(channelMentionColor, 1, 1); - channelGrid->addWidget(&hexChannelMentionLabel, 1, 2); channelGroupBox = new QGroupBox; channelGroupBox->setLayout(channelGrid); @@ -331,6 +335,8 @@ void MessagesSettingsPage::retranslateUi() channelGroupBox->setTitle(tr("Room channel tabs")); channelUnreadColorLabel.setText(tr("Unread messages color")); channelMentionColorLabel.setText(tr("Mentioned messages color")); + channelUnreadColor->setPlaceholderText(tr("Enter HEX code")); + channelMentionColor->setPlaceholderText(tr("Enter HEX code")); chatMentionCheckBox.setText(tr("Enable chat mentions")); chatMentionCompleterCheckbox.setText(tr("Enable mention completer")); messageGroupBox->setTitle(tr("In-game message macros")); @@ -341,15 +347,15 @@ void MessagesSettingsPage::retranslateUi() ignoreNonBuddyUserMessages.setText(tr("Ignore private messages sent by non-buddy users")); invertMentionForeground.setText(tr("Invert text color")); invertHighlightForeground.setText(tr("Invert text color")); + mentionColorStringLabel.setText(tr("Color")); + highlightColorStringLabel.setText(tr("Color")); + mentionColor->setPlaceholderText(tr("Enter HEX code")); + highlightColor->setPlaceholderText(tr("Enter HEX code")); messagePopups.setText(tr("Enable desktop notifications for private messages")); mentionPopups.setText(tr("Enable desktop notification for mentions")); roomHistory.setText(tr("Enable room message history on join")); ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages")); useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs")); - hexLabel.setText(tr("(Color is hexadecimal)")); - hexHighlightLabel.setText(tr("(Color is hexadecimal)")); - hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)")); - hexChannelMentionLabel.setText(tr("(Color is hexadecimal)")); customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only")); customAlertString->setPlaceholderText(tr("Word1 Word2 Word3")); aAdd->setText(tr("Add New Message")); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h index 112ff4e29..06fabcef2 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -58,13 +58,11 @@ private: QLineEdit *channelUnreadColor; QLineEdit *channelMentionColor; QLineEdit *customAlertString; - QLabel hexLabel; - QLabel hexHighlightLabel; QLabel channelUnreadColorLabel; QLabel channelMentionColorLabel; - QLabel hexChannelUnreadLabel; - QLabel hexChannelMentionLabel; QLabel customAlertStringLabel; + QLabel mentionColorStringLabel; + QLabel highlightColorStringLabel; QLabel explainMessagesLabel; void storeSettings(); From dec5e217a2288c5aa35851708cc82645c43d0606 Mon Sep 17 00:00:00 2001 From: tooomm Date: Fri, 6 Nov 2026 21:43:14 +0100 Subject: [PATCH 061/104] Remove leading spaces from message log zone strings (#4945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "from …" fragments built by getFromStr() carried a leading space so the sentence templates could concatenate them directly (%2%3). That blank is invisible on Transifex and most translations dropped it, merging the card name and the zone phrase in the log (#4943). Drop the blank from the tr() strings and add the separator in code where the fragment is built. The space is only prepended when the fragment is non-empty, so messages that put the zone in the card name instead — "the top card of their library." — keep their punctuation tight. The templates stay at %2%3, since %3 is empty on those paths. --- .../game_graphics/log/message_log_widget.cpp | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index d526766af..9fe89c04b 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -64,13 +64,13 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position QString zoneName = zone->getName(); if (zoneName == ZoneNames::TABLE) { - fromStr = tr(" from play"); + fromStr = tr("from play"); } else if (zoneName == ZoneNames::GRAVE) { - fromStr = tr(" from their graveyard"); + fromStr = tr("from their graveyard"); } else if (zoneName == ZoneNames::EXILE) { - fromStr = tr(" from exile"); + fromStr = tr("from exile"); } else if (zoneName == ZoneNames::HAND) { - fromStr = tr(" from their hand"); + fromStr = tr("from their hand"); } else if (zoneName == ZoneNames::DECK) { if (position == 0) { if (cardName.isEmpty()) { @@ -82,9 +82,9 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position cardNameContainsStartZone = true; } else { if (ownerChange) { - fromStr = tr(" from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); + fromStr = tr("from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); } else { - fromStr = tr(" from the top of their library"); + fromStr = tr("from the top of their library"); } } } else if (position == zone->getCards().size()) { @@ -97,29 +97,34 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position cardNameContainsStartZone = true; } else { if (ownerChange) { - fromStr = tr(" from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); + fromStr = tr("from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); } else { - fromStr = tr(" from the bottom of their library"); + fromStr = tr("from the bottom of their library"); } } } else { if (ownerChange) { - fromStr = tr(" from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); + fromStr = tr("from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); } else { - fromStr = tr(" from their library"); + fromStr = tr("from their library"); } } } else if (zoneName == ZoneNames::SIDEBOARD) { - fromStr = tr(" from sideboard"); + fromStr = tr("from sideboard"); } else if (zoneName == ZoneNames::STACK) { - fromStr = tr(" from the stack"); + fromStr = tr("from the stack"); } else { - fromStr = tr(" from custom zone '%1'").arg(zoneName); + fromStr = tr("from custom zone '%1'").arg(zoneName); } if (!cardNameContainsStartZone) { cardName.clear(); } + // The separator space is added here rather than stored in the tr() string so + // translators never have to preserve an invisible leading blank. + if (!fromStr.isEmpty()) { + fromStr.prepend(' '); + } return {cardName, fromStr}; } From 9fdbe45ffbe756ab43e0841b3df6151e2934b617 Mon Sep 17 00:00:00 2001 From: tuckerandrew21 <72102971+tuckerandrew21@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:19:02 -0600 Subject: [PATCH 062/104] [PictureLoader] Fix malformed MIME wildcards in Accept header (#6609) The Accept header added in ef87b54b was missing asterisks in two MIME type wildcards: "image/" and "/*" instead of "image/*" and "*/*". Servers may reject or mishandle requests with invalid MIME types, preventing card images from loading. Co-authored-by: Claude Opus 4.6 --- .../card_picture_loader/card_picture_loader_worker.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 3ab5eb684..54d2e5cf5 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -176,7 +176,7 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture QNetworkRequest req(url); req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8"); + req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"); // Cached entries are served straight from the disk cache even when picture downloads are // enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a From 818c0161ed09e78503f1d7573c6af707efa142db Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:31:27 +0100 Subject: [PATCH 063/104] [CI] Run the sanitizer leg nightly and stop caching it (#7780) The Ubuntu 26.04 sanitize leg was the largest single cache entry in the 10 GB quota (1.2G) and is not on the pull-request critical path: Windows sets that wall clock. Moving it to build-linux-nightly drops the cache, since nightly legs persist none, and frees the quota for the legs whose bounds still miss. Measured on master 2026-11-06, two runs: Fedora 45 debug/release served 99.5% of cacheable calls at 80% full, while Arch served 75% at 96% full, Debian 13 82% at 96% and macOS 14 42% at 99.96%. The sanitize cache was the largest of the six and is now nightly-only. Pull requests keep the uninstrumented suite on Debian 13 (Release) and Fedora 45 (Debug), so the only thing a PR loses is the instrumented run, which now happens nightly and on tags. The leg keeps a 1.2G scratch ccache so the larger instrumented objects do not thrash mid-build, and it is allow-failure so a sanitizer finding cannot stop vcpkg-cache-import from importing the nightly run's archives. prune-caches reclaims ccache-Ubuntu26.04sanitize on the next master push. --- .github/workflows/desktop-build.yml | 76 +++++++++++++++++------------ 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index b859556aa..82dc7c042 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -195,26 +195,11 @@ jobs: version: 26.04 package: DEB - test: skip # The sanitizer leg below runs this distro's suite - # 18.1 min, and the same distro as the sanitize leg below, which runs this suite. + test: skip # Debian 13 covers the uninstrumented suite on pull requests + # 18.1 min, shorter than the Windows leg. The sanitize leg in the nightly + # matrix runs this distro's instrumented suite. persist_ccache: 'false' - # The one leg that runs the suite under AddressSanitizer and - # UndefinedBehaviorSanitizer. Debian 13 and Fedora 45 keep covering the - # uninstrumented suite, so nothing is lost by instrumenting this one. - # - # It packages nothing. A sanitized Debug build is not something to ship, and - # carrying the package build here meant 18 minutes of Release compile queued - # behind the instrumented one in a single job. The leg above publishes this - # distro's DEB. - - distro: Ubuntu - version: 26.04 - variant: sanitize # separate image and ccache, same distro - - package: skip - sanitize: yes - ccache_size: 1200M # instrumented objects are larger than the 614 MB measured; estimated - name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure # The nightly legs live in build-linux-nightly below. A leg is in exactly one of the two @@ -244,18 +229,23 @@ jobs: # Debian 13 Release + suite 614 MB 800M # Fedora 45 debug Debug + suite 660 MB 850M # Fedora 45 release Release + package 614 MB 800M - # Ubuntu 26.04 sanitize ~1.0 GB 1200M # - # That is about 5.3 GB of bounds, and with the fixed entries above it puts the quota at - # roughly 9.8 GB of 10 - so only legs worth their share get one. A leg keeps no cache - # when it duplicates another leg's coverage (Ubuntu 26.04 behind the sanitize leg of - # the same distro; macOS 15 and macOS 27 Debug behind macOS 14; Servatrice covers the - # server alone and finishes in 4.7 min). Those four run cold: 4.7, 18.1, 16.5 and - # 15.8 min. The five that stay compile the client on the toolchains a change can break - # and are the ones a cache shortens by the most. Measured job totals on master - # 2026-11-04 also show the ceiling this leaves: Windows 10 is 29.5 min and compiles for - # 23.7 of it with no compiler cache at all, so Windows still sets the pull-request - # wall clock no matter how warm these legs are (see build-vcpkg). + # That is about 4.1 GB of bounds, and with the fixed entries above it puts the quota at + # roughly 8.6 GB of 10 - so only legs worth their share get one. A leg keeps no cache + # when it duplicates another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + + # suite; macOS 15 and macOS 27 Debug behind macOS 14; Servatrice covers the server + # alone and finishes in 4.7 min). Those four run cold: 4.7, 18.1, 16.5 and 15.8 min. + # The five that stay compile the client on the toolchains a change can break and are + # the ones a cache shortens by the most. Measured job totals on master 2026-11-04 also + # show the ceiling this leaves: Windows 10 is 29.5 min and compiles for 23.7 of it + # with no compiler cache at all, so Windows still sets the pull-request wall clock no + # matter how warm these legs are (see build-vcpkg). + # + # Measured on master 2026-11-06: Fedora 45 debug/release hit 99.5% at 80% full, while + # Arch (75% hits at 96% full), Debian 13 (82% at 96%) and macOS 14 (42% at 99.96%) + # still evict entries they need. The sanitize leg's 1.2G cache was the largest of the + # six and is now nightly-only; that is the budget a later re-sizing of those three + # spends. # # Re-add a leg only with a bound that holds its whole configuration: a smaller one is # worse than none. @@ -518,6 +508,28 @@ jobs: package: DEB test: skip # Running tests on all distros is superfluous + # The one leg that runs the suite under AddressSanitizer and + # UndefinedBehaviorSanitizer. It runs nightly rather than on pull requests: it is + # not on the pull-request critical path (Windows sets that), and its 1.2G cache + # was the largest single entry in the 10 GB quota. Debian 13 and Fedora 45 keep + # covering the uninstrumented suite on pull requests, so nothing is lost there. + # + # It packages nothing. A sanitized Debug build is not something to ship; the + # pull-request Ubuntu 26.04 leg publishes this distro's DEB. + - distro: Ubuntu + version: 26.04 + variant: sanitize # separate image, same distro as the coverage leg below + + package: skip + sanitize: yes + # A sanitizer finding must not redden the nightly run: vcpkg-cache-import only + # imports the archives of a run whose conclusion is success. The job itself + # still reports failure. + allow-failure: yes + # Scratch only (nightly legs persist no cache); sized so the instrumented + # objects, which are larger than the 614 MB measured, do not thrash mid-build. + ccache_size: 1200M + # The one leg that runs instrumented for line coverage and publishes the report as # a workflow artifact. It stays a separate leg rather than a second flag on the # sanitizer leg because the two instruments answer different questions: the @@ -542,7 +554,7 @@ jobs: name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure # A nightly and a tag build cover the legs a pull request or a merge does not wait for: - # the previous release of each distro, and the one instrument that only reports. + # the previous release of each distro, and the instrumented legs. if: github.event_name == 'schedule' || github.ref_type == 'tag' runs-on: ubuntu-latest # https://github.com/actions/runner-images continue-on-error: ${{ matrix.allow-failure == 'yes' }} @@ -555,7 +567,7 @@ jobs: # A cold nightly build costs minutes on a runner nobody waits for; a cold pull request # build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir. PERSIST_CCACHE: 'false' - CCACHE_SIZE: 600M + CCACHE_SIZE: ${{ matrix.ccache_size || '600M' }} CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} @@ -1216,7 +1228,7 @@ jobs: # The nightly legs and the pull-request legs that finish under the Windows leg # keep none, so their leftover entries are reclaimed here instead of waiting out # the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27' + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-macOS14-xcode-27' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" From d7dac8395b5b6225785d9ff6be91aa9695000edf Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:02:27 +0100 Subject: [PATCH 064/104] [CI] Spend the ccache quota on the legs that still thrash (#7781) * [CI] Spend the ccache quota on the legs that still thrash Arch and Debian 13 were the two bounds that still evicted entries they needed: on master 2026-11-06 Arch served 75% of calls at 96% full and Debian 13 82% at 96%, while Fedora 45 debug/release served 99.5% at 80%. Their working sets measure 820 MB and 770 MB, so 850M and 800M could not hold one configuration. Raise Arch to 1200M and Debian 13 to 1050M, and drop the macOS 14 cache: its 800M bound served 42% and saved about 32s on a 20 min leg that is already shorter than the Windows leg. That frees 800M and leaves the bounds at 3.9 GB, with the measured quota at about 7.8 GB of 10. Also document why pull requests, tags and nightlies never write: a PR save would evict the warm set, tags build full DWARF, and a schedule shares refs/heads/master with the push. No behavior change there. * [CI] Correct the quota estimate in the ccache sizing comment The 7.8 GB figure subtracted the dropped macOS bound without adding the 600 MB Arch/Debian increase. The fixed entries are about 4.4 GB, so 3.9 GB of bounds puts the estimate at about 8.3 GB of 10. * [CI] Size the Arch and Debian bounds to the measured working set * [CI] Keep the measured ccache bounds instead of raising them Steady-state master shows Arch at 90.1% and Debian 13 at 96.75% per-run hits on the existing 850M/800M bounds. The 75%/82% that motivated the increase were warming runs right after the -g1 switch invalidated the cache, not thrashing. Keep the bounds, keep the macOS 14 cache drop. --- .github/workflows/desktop-build.yml | 73 +++++++++++++++++------------ 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 82dc7c042..d33a8c2ea 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -150,7 +150,7 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already debug_test: yes # see the note on Fedora 45 below - ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1 + ccache_size: 850M # Debug plus the suite: 680 MB working set at -g1 - distro: Servatrice_Debian version: 12 @@ -165,7 +165,7 @@ jobs: version: 13 package: DEB - ccache_size: 800M # Release plus the suite: 614 MB of objects at -g1 + ccache_size: 800M # Release plus the suite: 625 MB working set at -g1 # The single Fedora leg used to build Debug for the suite and Release for the # package in one job (measured on Fedora 44: 30.8 min, the only leg slower than @@ -225,27 +225,34 @@ jobs: # whole configuration therefore buys almost nothing, and the quota goes to fewer legs # at a size that holds one: # - # Arch Debug + suite 660 MB working set 850M - # Debian 13 Release + suite 614 MB 800M + # Arch Debug + suite 680 MB working set 850M + # Debian 13 Release + suite 625 MB 800M # Fedora 45 debug Debug + suite 660 MB 850M # Fedora 45 release Release + package 614 MB 800M # - # That is about 4.1 GB of bounds, and with the fixed entries above it puts the quota at - # roughly 8.6 GB of 10 - so only legs worth their share get one. A leg keeps no cache - # when it duplicates another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + - # suite; macOS 15 and macOS 27 Debug behind macOS 14; Servatrice covers the server - # alone and finishes in 4.7 min). Those four run cold: 4.7, 18.1, 16.5 and 15.8 min. - # The five that stay compile the client on the toolchains a change can break and are - # the ones a cache shortens by the most. Measured job totals on master 2026-11-04 also - # show the ceiling this leaves: Windows 10 is 29.5 min and compiles for 23.7 of it - # with no compiler cache at all, so Windows still sets the pull-request wall clock no - # matter how warm these legs are (see build-vcpkg). + # That is 3.3 GB of bounds, and with the fixed entries above (about 4.4 GB) it puts + # the quota at roughly 7.7 GB of 10 - so only legs worth their share get one. A leg + # keeps no cache when it duplicates another leg's coverage (Ubuntu 26.04 behind + # Debian 13's Release + suite; the macOS legs behind the Linux ones) or finishes well + # inside the Windows leg (Servatrice covers the server alone in 4.7 min). A macOS + # cache served 42% and saved about 32s on a 20 min leg that is already shorter than + # Windows, so that bound is better spent here. The four Linux legs that stay compile + # the client on the toolchains a change can break and are the ones a cache shortens + # by the most. + # Measured job totals on master 2026-11-04 also show the ceiling this leaves: + # Windows 10 is 29.5 min and compiles for 23.7 of it with no compiler cache at all, so + # Windows still sets the pull-request wall clock no matter how warm these legs are + # (see build-vcpkg). # - # Measured on master 2026-11-06: Fedora 45 debug/release hit 99.5% at 80% full, while - # Arch (75% hits at 96% full), Debian 13 (82% at 96%) and macOS 14 (42% at 99.96%) - # still evict entries they need. The sanitize leg's 1.2G cache was the largest of the - # six and is now nightly-only; that is the budget a later re-sizing of those three - # spends. + # Steady-state master data corrected this on 2026-11-07: Arch 90.1% and Debian 13 + # 96.75% per-run hits at the 850M/800M bounds, Fedora 45 debug 99.18% and release + # 99.2%. The 75%/82% seen the day before were warming runs - the -g1 switch had + # invalidated the cache one commit earlier - not thrashing at the bound. A cold 3G + # run on the same day measured one configuration at 680 MB (Arch) and 625 MB + # (Debian 13), so those bounds already hold the working set with ~170 MB of slack for + # commit-to-commit churn, and raising them buys hit rate the numbers do not show a + # need for. macOS 14's 800M entry is dropped because persistence buys 32s there; the + # quota stays free for the legs whose bounds would actually miss. # # Re-add a leg only with a bound that holds its whole configuration: a smaller one is # worse than none. @@ -430,6 +437,14 @@ jobs: # comes from the client event itself (COCKATRICE_SENTRY_RELEASE). sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}" + # Pull requests and tags only restore; only a push to refs/heads/master writes. A PR save + # would replace the objects the next master build restores, and a PR that compiles a + # different configuration would evict the warm set for everyone. Tags build with full + # DWARF (COCKATRICE_DEBUG_INFO_LEVEL is empty), so a tag's cache would poison the -g1 + # set if it shared the master scope; it does not, because the save is master-only. The + # nightly legs persist nothing at all (PERSIST_CCACHE is false) for the same reason: + # a schedule shares refs/heads/master with the push. + # # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "Delete remote compiler cache (ccache)" if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit @@ -593,7 +608,7 @@ jobs: make_package: 1 override_target: 14 package_suffix: "-macOS14" - ccache_size: 800M # one Release configuration plus the suite at -g1 + persist_ccache: 'false' # 42% hits saved ~32s on a 20 min leg already under Windows qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -665,12 +680,12 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - # One macOS configuration is about 600 MB of objects at -g1, so its bound needs to be - # 800M to hold one whole; a smaller cache serves a few percent and is worthless (see - # the sizing comment in build-linux). macOS 14 keeps the one macOS cache: it runs the - # suite against the shipping Release build and is the longest of the three at 24.2 min. - # macOS 15 (16.5) and macOS 27 Debug (15.8) duplicate its coverage and are shorter, so - # they run cold. + # No macOS leg keeps a compiler cache. One configuration is about 600 MB of objects at + # -g1, so a useful bound is 800M, and the quota is better spent on the Linux legs whose + # bounds still miss (see the sizing comment in build-linux). Measured on master + # 2026-11-06, macOS 14's 800M cache served 42% of calls and saved about 32s on a 20 min + # job that is already shorter than the Windows leg; macOS 15 and macOS 27 Debug + # duplicate its coverage. All three run cold. CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} # MSVC has no -g level, so the Windows leg does not ask for one; it has no compiler @@ -1228,7 +1243,7 @@ jobs: # The nightly legs and the pull-request legs that finish under the Windows leg # keep none, so their leftover entries are reclaimed here instead of waiting out # the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-macOS14-xcode-27' + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" @@ -1253,8 +1268,8 @@ jobs: reason="scoped to $ref" elif [[ $key == ccache-* ]]; then # An exact token compare, because one key can be a prefix of another: - # ccache-Ubuntu26.04 is a prefix of ccache-Ubuntu26.04sanitize, and only - # the sanitize entry is live. + # ccache-Fedora45 is a prefix of ccache-Fedora45debug and + # ccache-Fedora45release, and only the latter two are live. if [[ $live_ccache != *" $key "* ]]; then reason="no pull-request leg keeps it" fi From eab217d1bf58008652b969ee3948f38d16c5efed Mon Sep 17 00:00:00 2001 From: RickyRister <42636155+RickyRister@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:44:40 -0700 Subject: [PATCH 065/104] [DlgCreateGame] Revert dlg to use exec instead of open (#7795) --- cockatrice/src/interface/widgets/server/game_selector.cpp | 4 ++-- cockatrice/src/interface/widgets/tabs/tab_game.cpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 468095adb..9dc7bf1e8 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -272,7 +272,7 @@ void GameSelector::actCreate() auto *dlg = new DlgCreateGame(room, room->getGameTypes(), this); dlg->setAttribute(Qt::WA_DeleteOnClose); connect(dlg, &QDialog::finished, this, &GameSelector::updateTitle); - dlg->open(); + dlg->exec(); } void GameSelector::checkResponse(const Response &response) @@ -368,7 +368,7 @@ void GameSelector::customContextMenu(const QPoint &point) auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this); dlg->setAttribute(Qt::WA_DeleteOnClose); - dlg->open(); + dlg->exec(); }); QAction copyLink(tr("Cop&y game link")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 055ab321c..5dba1fc51 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -576,7 +576,7 @@ void TabGame::actGameInfo() // Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object. auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); dlg->setAttribute(Qt::WA_DeleteOnClose); - dlg->open(); + dlg->exec(); } void TabGame::actCopyGameLink() From c6fadef00013bf7138bdead02bde94f3861b427e Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:54:10 +0100 Subject: [PATCH 066/104] [CI] Stop running ccache on macOS (#7792) Every macOS leg restores nothing (persist_ccache is false), so ccache only paid hashing on ~1160 compiles for the 3% of calls that repeat within one build: macOS 15 measured 37 hits out of 1163. The restored cache this replaces served 42% and saved about 32s on a 20 min leg. Drop the three matrix keys, the brew install, and the restore/delete/save steps, and leave the Linux docker path untouched. --- .github/workflows/desktop-build.yml | 68 ++++------------------------- 1 file changed, 8 insertions(+), 60 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index d33a8c2ea..b3c9b7391 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -603,17 +603,14 @@ jobs: target: 14 # EOL 2026-09-14 runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md - ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 override_target: 14 package_suffix: "-macOS14" - persist_ccache: 'false' # 42% hits saved ~32s on a 20 min leg already under Windows qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release - use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over @@ -622,17 +619,14 @@ jobs: target: 15 # EOL 2027-?? runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md - ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 override_target: 15 package_suffix: "-macOS15" - persist_ccache: 'false' # 16.5 min: shorter than the Windows leg qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release - use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' @@ -640,14 +634,11 @@ jobs: target: 27 runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md - ccache_eviction_age: 7d cmake_generator: Ninja - persist_ccache: 'false' # 15.8 min: shorter than the Windows leg qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Debug - use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' @@ -679,17 +670,9 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: - CCACHE_DIR: ${{ github.workspace }}/.cache/ - # No macOS leg keeps a compiler cache. One configuration is about 600 MB of objects at - # -g1, so a useful bound is 800M, and the quota is better spent on the Linux legs whose - # bounds still miss (see the sizing comment in build-linux). Measured on master - # 2026-11-06, macOS 14's 800M cache served 42% of calls and saved about 32s on a 20 min - # job that is already shorter than the Windows leg; macOS 15 and macOS 27 Debug - # duplicate its coverage. All three run cold. - CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} - PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} - # MSVC has no -g level, so the Windows leg does not ask for one; it has no compiler - # cache to shrink anyway (see the sizing comment in build-linux). + # No leg in this job keeps a compiler cache: neither the macOS legs nor the Windows + # leg can fill one across runs. See the note on the removed macOS ccache steps below. + # MSVC has no -g level, so the Windows leg does not ask for one. COCKATRICE_DEBUG_INFO_LEVEL: ${{ matrix.os != 'Windows' && github.ref_type != 'tag' && '1' || '' }} # Shared with build-vcpkg-nightly so the two leg sets run the same steps. @@ -705,17 +688,11 @@ jobs: with: msbuild-architecture: x64 - - name: "[macOS] Setup ccache" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 - run: brew install ccache - - - name: "[macOS] Restore compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' - id: restore_ccache - uses: actions/cache/restore@v6 - with: - key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }} - path: ${{ env.CCACHE_DIR }} + # No macOS leg runs ccache. A restored macOS cache served 42% of calls and saved + # about 32s on a 20 min leg that is already shorter than the Windows leg, which did + # not pay for its 800M slice of the quota; with nothing restored, the remaining + # intra-run hits are 3% and the hashing is pure overhead on ~1160 compiles, so the + # steps that installed and restored ccache are gone rather than merely disabled. - name: "[macOS] Install aqtinstall" if: matrix.os == 'macOS' @@ -822,7 +799,6 @@ jobs: shell: bash env: BUILDTYPE: '${{ matrix.type }}' - CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }} CMAKE_GENERATOR: ${{ matrix.cmake_generator }} # Tag builds only: artifacts from master pushes and PR branches must not report to # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way. @@ -837,7 +813,6 @@ jobs: MAKE_PACKAGE: '${{ matrix.make_package }}' PACKAGE_SUFFIX: '${{ matrix.package_suffix }}' TARGET_MACOS_VERSION: ${{ matrix.override_target }} - USE_CCACHE: ${{ matrix.use_ccache }} # Only pushes to the default branch may write the shared binary cache: the # archives a run compiles are only as trustworthy as the code they were # built from, and only merged code may populate the scope that master and @@ -1038,25 +1013,6 @@ jobs: path: ${{ runner.temp }}/vcpkg-new retention-days: 1 - # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - - name: "[macOS] Delete remote compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit - continue-on-error: true - env: - CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} - GH_TOKEN: ${{ github.token }} - run: | - if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then - echo "Cache deleted successfully" - fi - - - name: "[macOS] Cache updated compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' - uses: actions/cache/save@v6 - with: - key: ${{ steps.restore_ccache.outputs.cache-primary-key }} - path: ${{ env.CCACHE_DIR }} - - name: "[macOS] Sign app bundle" if: matrix.os == 'macOS' && matrix.make_package && needs.configure.outputs.tag != null id: sign_macos @@ -1177,7 +1133,6 @@ jobs: runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout - ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 override_target: 13 @@ -1186,7 +1141,6 @@ jobs: qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Intel type: Release - use_ccache: 1 vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over @@ -1197,12 +1151,6 @@ jobs: if: github.event_name == 'schedule' || github.ref_type == 'tag' runs-on: ${{ matrix.runner }} timeout-minutes: 100 - env: - CCACHE_DIR: ${{ github.workspace }}/.cache/ - # Like build-linux-nightly: this leg keeps no compiler cache, so the quota stays with - # the pull-request legs (see PERSIST_CCACHE there). - PERSIST_CCACHE: 'false' - CCACHE_SIZE: 600M steps: *vcpkg-steps prune-caches: From 7487a383a39cccd8660ed8697e29efa9eeae2fe6 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:56:38 +0100 Subject: [PATCH 067/104] [CI] Prune all but the newest CodeQL trap cache (#7796) The CodeQL job keeps one extracted database per analyzed commit, about 1.07 GB each, restores the newest by prefix and never deletes any. Four had accumulated by 2026-11-07: 4.26 GB, 40% of the repository's 10 GB cache quota, of which only the newest can be restored again. prune-caches skipped them deliberately, because their key shape belongs to codeql-action rather than to a workflow of ours. Add that shape and keep exactly the entry with the latest access date on master; a miss costs one re-extraction in a job that reports rather than gates. This is the room the Windows compiler cache needs: with the superseded entries retired the quota holds 7.1 GB instead of 10.3 GB. --- .github/workflows/desktop-build.yml | 35 ++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index b3c9b7391..3a31da5fc 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -213,9 +213,14 @@ jobs: CCACHE_EVICTION_AGE: 7d # The bound is sized against the repository's 10 GB cache quota # (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy), - # not against what one leg would like: the quota also holds about 0.9 GB of buildkit - # layers (mode=min in docker-release.yml), 0.8 GB of vcpkg, 0.7 GB of Qt and the CodeQL - # trap caches (about 1 GB each, which prune-caches below does not manage). + # not against what one leg would like. Measured 2026-11-07, the quota holds 2.97 GB + # of these four ccache entries, 0.92 GB of Qt, 1.24 GB of vcpkg, 0.91 GB of buildkit + # layers (mode=min in docker-release.yml) and 4.26 GB of CodeQL trap caches: one entry + # per analyzed commit, about 1.07 GB each, of which every one but the newest is dead. + # That is 10.30 GB against the 10 GB quota, so every save past this point costs some + # other entry, and least-recently-used order reaches the buildkit layers before the + # trap caches. prune-caches below keeps only the newest trap cache; the live total is + # then 7.1 GB, which is what leaves room for a Windows compiler cache. # # Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with # -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit @@ -1178,6 +1183,16 @@ jobs: # age is the right test. Dropping a blob an index still lists costs the next image build # one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the # entries forever. + # + # codeql.yml's analyze job keeps its extracted database as one entry per analyzed commit, + # keyed codeql-trap----, about 1.07 GB each. Each run + # restores the newest entry by prefix and saves its own, so every run that analyzes a new + # commit makes the previous entry dead, and the action never deletes them: four had + # accumulated by 2026-11-07 (4.26 GB, 40% of the quota). Retiring them cannot wait for + # the age rule, because they are not the oldest entries and so not what least-recently- + # used eviction reaches: the buildkit layers are. Only the newest can be restored again, + # and a miss costs one re-extraction in a job that reports rather than gates, so keeping + # exactly that one is safe. - name: "Delete unusable cache entries" shell: bash env: @@ -1199,15 +1214,19 @@ jobs: cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))" live=" ${LIVE_VCPKG_SCOPES} " live_ccache=" ${LIVE_CCACHE_KEYS} " + # The one trap cache a later run can still restore, by prefix. ISO-8601 sorts + # lexicographically, so this is a plain string compare. Entries scoped to another + # ref are deleted by the ref rule below whatever this says. + newest_trap="$(awk -F'\t' '$2 == "refs/heads/master" && $3 ~ /^codeql-trap-/ && $4 > m { m = $4; k = $3 } END { print k }' "$RUNNER_TEMP/caches.tsv")" deleted=0 while IFS=$'\t' read -r id ref key accessed; do [[ -n $id ]] || continue # Only ever touch the key shapes our workflows create (buildkit-* and index-* - # come from docker-release.yml), so that an entry of some other workflow can - # never be deleted by accident + # come from docker-release.yml, codeql-trap-* from codeql-action in codeql.yml), + # so that an entry of some other workflow can never be deleted by accident case "$key" in - vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;; + vcpkg/* | ccache-* | Qt* | buildkit-* | index-* | codeql-trap-*) ;; *) continue ;; esac @@ -1228,6 +1247,10 @@ jobs: if [[ $live != *" $scope "* ]]; then reason="no matrix leg uses $scope" fi + elif [[ $key == codeql-trap-* ]]; then + if [[ $key != "$newest_trap" ]]; then + reason="superseded by the newest trap cache" + fi fi if [[ -z $reason ]]; then stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)" From 4b224f7fe579a5a33060547a68b292dd55d75083 Mon Sep 17 00:00:00 2001 From: ebbit1q Date: Sat, 7 Nov 2026 16:26:31 +0100 Subject: [PATCH 068/104] Translate the filter string error at display time (#5283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the parser message untranslated so FilterStringLog stays in the source language, and format the user-facing text in error() instead. That gives both consumers what they need without localizing the log. Q_DECLARE_TR_FUNCTIONS provides the tr() context without deriving from QObject, which would have deleted copy/move assignment and broken the by-value uses in PlayerActions and ViewZone. Drop the unreachable line-number branch; peglib reports 1-based lines, so every message keeps master's "position" wording. Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- .../libcockatrice/filters/filter_string.cpp | 11 ++++++++++- .../libcockatrice/filters/filter_string.h | 13 ++++++++----- tests/carddatabase/filter_string_test.cpp | 16 ++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 1bcb1646b..f7ca82344 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -506,6 +506,14 @@ FilterString::FilterString() _error = "Not initialized"; } +QString FilterString::error() const +{ + if (_errorColumn == 0) { + return _error; + } + return tr("Error at position %1: %2").arg(_errorColumn).arg(_error); +} + FilterString::FilterString(const QString &expr, const CardSearchLanguage &searchLanguage) { QByteArray ba = expr.simplified().toUtf8(); @@ -522,7 +530,8 @@ FilterString::FilterString(const QString &expr, const CardSearchLanguage &search searchLanguageContext = searchLanguage; search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) { - _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg)); + _error = QString::fromStdString(msg); + _errorColumn = col; }); if (!search.parse(ba.data(), result)) { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index 2abe95201..34e2bfb35 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -7,6 +7,7 @@ #ifndef FILTER_STRING_H #define FILTER_STRING_H +#include #include #include #include @@ -33,6 +34,8 @@ typedef AstBase Ast; class FilterString { + Q_DECLARE_TR_FUNCTIONS(FilterString) + public: FilterString(); explicit FilterString(const QString &exp, const CardSearchLanguage &searchLanguage = {}); @@ -45,18 +48,18 @@ public: return result(card); } - bool valid() + [[nodiscard]] bool valid() const { return _error.isEmpty(); } - QString error() - { - return _error; - } + [[nodiscard]] QString error() const; private: + // Untranslated parser message; error() adds the translated prefix, logging uses it as-is. QString _error; + // Column reported by the parser, or 0 when the message carries no position. + size_t _errorColumn = 0; Filter result; }; diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index 931cb7e0c..88058278f 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -279,6 +279,22 @@ TEST_F(SetQuery, ComparisonMatchesByReleaseDate) EXPECT_FALSE(FilterString("set Date: Sat, 7 Nov 2026 16:51:27 +0100 Subject: [PATCH 069/104] [VisualDeckStorage] Replace QFutureWatcher tracking with QFuture continuations (#7691) * [VisualDeckStorage] Cancel in-flight deck loads and enforce teardown order on destruction * [VisualDeckStorage] Reformat activeWatchers comment * [VisualDeckStorage] Use QFuture continuations instead of QFutureWatcher tracking Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --------- Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> Co-authored-by: rickyrister <42636155+rickyrister@users.noreply.github.com> --- .../visual_deck_storage_model.cpp | 65 +++++++++---------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp index 3611a8c1f..f9409f5f2 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include #include #include @@ -244,15 +244,15 @@ void VisualDeckStorageModel::startScan() // The scan (directory walk + one stat per file) runs on a worker thread so that // constructing the widget never stalls the UI thread on a large deck folder. - auto *watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [this, watcher, generation] { - watcher->deleteLater(); - + // The continuation is bound to this model as its context object: it runs on the UI thread, + // and Qt drops it if the model is destroyed first, so no result is delivered to a dead model. + QFuture scan = + QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }); + scan.then(this, [this, generation](const DeckScanResult &result) { if (generation != scanGeneration) { return; // A newer scan started while this one was running; drop the stale result. } - const DeckScanResult result = watcher->result(); folderPaths = result.folderPaths; if (result.decks.isEmpty()) { @@ -268,9 +268,6 @@ void VisualDeckStorageModel::startScan() beginLoad(row); } }); - - watcher->setFuture( - QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); })); } void VisualDeckStorageModel::beginLoad(int row) @@ -286,32 +283,7 @@ void VisualDeckStorageModel::beginLoad(int row) const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath); const int generation = scanGeneration; - auto *watcher = new QFutureWatcher>(this); - connect(watcher, &QFutureWatcher>::finished, this, - [this, watcher, filePath, generation] { - watcher->deleteLater(); - - if (generation != scanGeneration) { - return; // The deck list was re-scanned while this load was running; drop the stale result. - } - - // Queue the result and apply a bounded number per event loop turn so that - // finishing hundreds of loads at once cannot stall the UI thread. - const std::optional result = watcher->result(); - PendingDeckLoad pending; - pending.filePath = filePath; - pending.generation = generation; - if (result) { - pending.ok = true; - pending.deck = std::move(result->deck); - pending.lastModified = result->lastModified; - pending.colorIdentity = std::move(result->colorIdentity); - } - pendingLoads.append(std::move(pending)); - schedulePendingLoadDrain(); - }); - - watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional { + auto load = QtConcurrent::run([filePath, fmt]() -> std::optional { std::optional deck = DeckLoader::loadFromFile(filePath, fmt, false); if (!deck) { return std::nullopt; @@ -320,7 +292,28 @@ void VisualDeckStorageModel::beginLoad(int row) // keep the completion handler on the UI thread cheap. const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query()); return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; - })); + }); + + // Bound to this model as context: runs on the UI thread and is dropped if the model is destroyed first. + load.then(this, [this, filePath, generation](std::optional result) { + if (generation != scanGeneration) { + return; // The deck list was re-scanned while this load was running; drop the stale result. + } + + // Queue the result and apply a bounded number per event loop turn so that + // finishing hundreds of loads at once cannot stall the UI thread. + PendingDeckLoad pending; + pending.filePath = filePath; + pending.generation = generation; + if (result) { + pending.ok = true; + pending.deck = std::move(result->deck); + pending.lastModified = result->lastModified; + pending.colorIdentity = std::move(result->colorIdentity); + } + pendingLoads.append(std::move(pending)); + schedulePendingLoadDrain(); + }); } void VisualDeckStorageModel::schedulePendingLoadDrain() From 80986266b9c0cd910d135bf9be9d4de1fd0188b9 Mon Sep 17 00:00:00 2001 From: unf0rce <37348972+unf0rce@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:53:53 +0100 Subject: [PATCH 070/104] Fix orphaned arrows when the draw shortcut repeats (#6789) Holding or spamming the draw-arrow shortcut fires the action again before the previous local arrow has been placed. Every new arrow takes the mouse grab from the previous one, so the previous arrow never receives the release that deletes it. If the cursor had passed over a card meanwhile, the orphan stayed visible on the board. Track the in-progress arrow on the CardItem that started it and cancel it, together with any child arrows, before starting a new one. The attach-arrow shortcut (Ctrl+Alt+A) uses the same path, so it no longer leaks arrows either. Fixes #4329. Co-authored-by: mb --- .../src/game_graphics/board/arrow_item.cpp | 76 ++++++++++++++----- .../src/game_graphics/board/arrow_item.h | 8 +- .../src/game_graphics/board/card_item.cpp | 14 ++++ .../src/game_graphics/board/card_item.h | 3 + 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index f7df98ab5..b07a156bd 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -88,7 +88,7 @@ void ArrowItem::onTargetDestroyed() // Drag and attach arrows are never inserted into the arrow registry and // have no server-side counterpart, so no deletion event can clean them // up. Delete them locally when either endpoint is destroyed. - delArrow(); + cancel(); return; } @@ -103,6 +103,12 @@ void ArrowItem::delArrow() deleteLater(); } +void ArrowItem::cancel() +{ + ungrabMouse(); + delArrow(); +} + void ArrowItem::updatePath() { if (!targetItem) { @@ -350,6 +356,17 @@ void ArrowDragItem::addChildArrow(ArrowDragItem *child) childArrows.append(child); } +void ArrowDragItem::cancel() +{ + for (const auto &child : childArrows) { + if (child) { + child->cancel(); + } + } + childArrows.clear(); + ArrowItem::cancel(); +} + void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (targetLocked || !startItem) { @@ -393,8 +410,10 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) targetItem ? updatePath() : updatePath(endPos); update(); - for (auto *child : childArrows) { - child->mouseMoveEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseMoveEvent(event); + } } } @@ -403,10 +422,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) if (!startItem) { // The source card was destroyed while the arrow was being drawn. // Clean up the arrow and its children instead of leaking them. - delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); - } + cancel(); return; } @@ -415,7 +431,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) // For now, we can safely assume that the start item is always a card. // The target item can be a player as well. if (!startCard) { - delArrow(); + cancel(); return; } @@ -435,7 +451,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } else if (auto *targetPlayer = qgraphicsitem_cast(targetItem)) { arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId(); } else { - delArrow(); + cancel(); return; } @@ -448,9 +464,12 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseReleaseEvent(event); + } } + childArrows.clear(); } // ArrowAttachItem @@ -471,6 +490,17 @@ void ArrowAttachItem::addChildArrow(ArrowAttachItem *child) childArrows.append(child); } +void ArrowAttachItem::cancel() +{ + for (const auto &child : childArrows) { + if (child) { + child->cancel(); + } + } + childArrows.clear(); + ArrowItem::cancel(); +} + void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (targetLocked || !startItem) { @@ -509,8 +539,10 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) targetItem ? updatePath() : updatePath(endPos); update(); - for (auto *child : childArrows) { - child->mouseMoveEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseMoveEvent(event); + } } } @@ -519,17 +551,16 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) if (!startItem) { // The source card was destroyed while the arrow was being drawn. // Clean up the arrow and its children instead of leaking them. - delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); - } + cancel(); return; } // Attaching could move startItem under the current cursor position, causing all children to retarget to it right // before they are processed. Prevent that. - for (auto *child : childArrows) { - child->setTargetLocked(true); + for (const auto &child : childArrows) { + if (child) { + child->setTargetLocked(true); + } } if (targetItem && targetItem != startItem) { @@ -541,7 +572,10 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseReleaseEvent(event); + } } + childArrows.clear(); } diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index 64a48cd14..c70a1030b 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -70,6 +70,8 @@ public: void onTargetDestroyed(); void delArrow(); + /// Abandons an in-progress drag arrow, discarding any child arrows with it. + virtual void cancel(); void updatePath(); void updatePath(const QPointF &endPoint); void startDrawAnimation(); @@ -112,12 +114,13 @@ class ArrowDragItem : public ArrowItem private: PlayerLogic *player; int deleteInPhase; - QList childArrows; + QList> childArrows; QMetaObject::Connection positionConnection; public: ArrowDragItem(PlayerLogic *_owner, ArrowTarget *_startItem, const QColor &_color, int _deleteInPhase); void addChildArrow(ArrowDragItem *child); + void cancel() override; protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; @@ -129,12 +132,13 @@ class ArrowAttachItem : public ArrowItem Q_OBJECT private: PlayerLogic *player; - QList childArrows; + QList> childArrows; QMetaObject::Connection positionConnection; public: explicit ArrowAttachItem(ArrowTarget *_startItem); void addChildArrow(ArrowAttachItem *child); + void cancel() override; protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index acd0764c9..02c0427d7 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -294,6 +294,13 @@ void CardItem::drawArrow(const QColor &arrowColor) return; } + // A held shortcut repeats the action before the previous arrow is placed. + // That arrow has lost the mouse grab to the new one, so it would never + // receive the release that deletes it; discard it (and its children). + if (pendingArrow) { + pendingArrow->cancel(); + } + auto *game = owner->getGame(); PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); int phase = 0; // 0 means to not set the phase @@ -303,6 +310,7 @@ void CardItem::drawArrow(const QColor &arrowColor) } ArrowDragItem *arrow = new ArrowDragItem(arrowOwner, this, arrowColor, phase); scene()->addItem(arrow); + pendingArrow = arrow; arrow->grabMouse(); for (const auto &item : scene()->selectedItems()) { @@ -326,8 +334,14 @@ void CardItem::drawAttachArrow() return; } + // See drawArrow(): a repeated trigger must not orphan the previous arrow. + if (pendingArrow) { + pendingArrow->cancel(); + } + auto *arrow = new ArrowAttachItem(this); scene()->addItem(arrow); + pendingArrow = arrow; arrow->grabMouse(); for (const auto &item : scene()->selectedItems()) { diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 01dacc021..ae22d9493 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -13,11 +13,13 @@ #include #include #include +#include #include #include #include #include +class ArrowItem; class CardDragItem; class ServerInfo_Card; class PlayerLogic; @@ -34,6 +36,7 @@ class CardItem : public AbstractCardItem Q_OBJECT private: CardDragItem *dragItem; + QPointer pendingArrow; void prepareDelete(); void handleClickedToPlay(bool shiftHeld); From d9cd308a2e3071b90abcffc44d25339233f5f964 Mon Sep 17 00:00:00 2001 From: scotland0208 Date: Sat, 7 Nov 2026 10:24:40 -0600 Subject: [PATCH 071/104] Show the library edge on the card in limited deck views (#6741) (#6741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Limited deck views (view top/bottom N cards, scry, surveil) made it ambiguous which card was the top or bottom of the library. Label the card itself with a small in-view marker instead of placing text above or below the whole zone, which became ambiguous once the grid used several columns or the window was resized. The marker stays on the part of its card that the column leaves visible: for a top view, the right end of the exposed strip of the first card; for a bottom view, the bottom-left corner of the last, fully visible card. The view logic now answers whether it is a limited library-end view and which edge it shows. The graphics zone draws and positions a marker on the first or last displayed card, and re-translates its tooltip with the rest of the zone view. Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- .../src/game_graphics/zones/view_zone.cpp | 154 +++++++++++++++++- .../src/game_graphics/zones/view_zone.h | 35 ++++ .../game_graphics/zones/view_zone_widget.cpp | 1 + .../game/zones/view_zone_logic.cpp | 6 + .../game/zones/view_zone_logic.h | 21 +++ tests/game/card_zone_logic_test.cpp | 28 ++++ 6 files changed, 243 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index be2e471d4..1716fc24d 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -1,21 +1,27 @@ #include "view_zone.h" #include "../../client/settings/cache_settings.h" +#include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" #include "../card_dimensions.h" +#include "../z_values.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/filters/filter_string.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" #include "libcockatrice/utility/card_ref.h" +#include #include #include #include +#include +#include #include #include #include +#include #include #include #include @@ -34,12 +40,85 @@ class QGraphicsItem; +namespace +{ +constexpr qreal MARKER_HORIZONTAL_PADDING = 4.0; +constexpr qreal MARKER_VERTICAL_PADDING = 2.0; +constexpr qreal MARKER_CORNER_RADIUS = 4.0; +constexpr qreal MARKER_INSET = 2.0; +constexpr int MARKER_FONT_SIZE = 9; +// Vertical pitch of a card column: each card shows this much before the next +// card overlaps it. Marker placement uses it to stay on the visible strip. +constexpr qreal CARD_STACK_OFFSET = CardDimensions::HEIGHT_F / 3.0; + +QFont markerFont() +{ + QFont font = themeManager != nullptr ? themeManager->appFont(AppFont::CardText) : QFont(); + font.setPixelSize(MARKER_FONT_SIZE); + font.setWeight(QFont::Bold); + return font; +} +} // namespace + +LibraryEdgeMarker::LibraryEdgeMarker(QGraphicsItem *parent) : QGraphicsItem(parent) +{ + setAcceptedMouseButtons(Qt::NoButton); + setAcceptHoverEvents(false); +} + +void LibraryEdgeMarker::setLabel(const QString &newLabel, const QString &toolTip) +{ + if (label == newLabel && this->toolTip() == toolTip) { + return; + } + + prepareGeometryChange(); + label = newLabel; + + const QFontMetrics metrics(markerFont()); + markerRect = QRectF(0, 0, metrics.horizontalAdvance(label) + 2 * MARKER_HORIZONTAL_PADDING, + metrics.height() + 2 * MARKER_VERTICAL_PADDING); + + setToolTip(toolTip); + update(); +} + +QRectF LibraryEdgeMarker::boundingRect() const +{ + return markerRect; +} + +void LibraryEdgeMarker::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) +{ + if (label.isEmpty()) { + return; + } + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setFont(markerFont()); + + // ToolTipBase/ToolTipText are the palette's designed background/text pair + // for small overlays, so the chip keeps its contrast in every theme. + const QPalette palette = qApp->palette(); + painter->setPen(QPen(palette.color(QPalette::Mid), 1)); + painter->setBrush(palette.color(QPalette::ToolTipBase)); + painter->drawRoundedRect(markerRect.adjusted(0.5, 0.5, -0.5, -0.5), MARKER_CORNER_RADIUS, MARKER_CORNER_RADIUS); + + painter->setPen(palette.color(QPalette::ToolTipText)); + painter->drawText(markerRect, Qt::AlignCenter, label); + painter->restore(); +} + /** * @param parent the parent QGraphicsWidget containing the reveal zone */ ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent) : SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort) { + libraryEdgeMarker = new LibraryEdgeMarker(this); + libraryEdgeMarker->setVisible(false); + auto *viewLogic = qobject_cast(getLogic()); if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) { viewLogic->getOriginalZone()->getViews().append(viewLogic); @@ -183,6 +262,8 @@ void ZoneViewZone::reorganizeCards() gridSize = positionCardsForDisplay(cardsToDisplay); } + updateLibraryEdgeMarker(cardsToDisplay); + // determine bounding rect qreal aleft = 0; qreal atop = 0; @@ -232,7 +313,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca lastColumnProp = columnProp; qreal x = col * CardDimensions::WIDTH_F; - qreal y = row * CardDimensions::HEIGHT_F / 3; + qreal y = row * CARD_STACK_OFFSET; cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setRealZValue(i); longestRow = qMax(row, longestRow); @@ -261,7 +342,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca for (int i = 0; i < cardCount; i++) { auto *cItem = qobject_cast(cards.at(i)->parent()); qreal x = (i / rows) * CardDimensions::WIDTH_F; - qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3; + qreal y = (i % rows) * CARD_STACK_OFFSET; cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setRealZValue(i); } @@ -270,6 +351,75 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca } } +void ZoneViewZone::updateLibraryEdgeMarker(const CardList &displayedCards) +{ + auto *viewLogic = qobject_cast(getLogic()); + if (viewLogic == nullptr || !viewLogic->isLibraryEdgeView() || displayedCards.isEmpty()) { + libraryEdgeMarkerAnchorPos = QPointF(); + libraryEdgeMarker->hide(); + return; + } + + const bool topEdge = viewLogic->showsTopOfLibrary(); + CardState *edgeState = topEdge ? displayedCards.first() : displayedCards.last(); + auto *edgeCard = edgeState == nullptr ? nullptr : qobject_cast(edgeState->parent()); + if (edgeCard == nullptr) { + libraryEdgeMarkerAnchorPos = QPointF(); + libraryEdgeMarker->hide(); + return; + } + + libraryEdgeMarkerAnchorPos = edgeCard->pos(); + applyLibraryEdgeMarkerText(); + positionLibraryEdgeMarker(); + + // Above the hovered-card layer: the chip sits inside its card, so hovering + // that card must not let the card repaint over the indicator. + libraryEdgeMarker->setZValue(ZValues::HOVERED_CARD + 0.5); + libraryEdgeMarker->show(); +} + +void ZoneViewZone::positionLibraryEdgeMarker() +{ + // The chip has to stay on the part of its card that is not covered by the + // next card in the column. The top card only shows its top strip, and its + // name is drawn top-left, so the chip goes to the right of that strip. The + // bottom card is fully visible, so the chip goes to its bottom-left, away + // from the bottom-right power/toughness. + const auto *viewLogic = qobject_cast(getLogic()); + const QRectF markerRect = libraryEdgeMarker->boundingRect(); + const bool topEdge = viewLogic != nullptr && viewLogic->showsTopOfLibrary(); + const QPointF offset = topEdge + ? QPointF(CardDimensions::WIDTH_F - markerRect.width() - MARKER_INSET, + CARD_STACK_OFFSET - markerRect.height() - MARKER_INSET) + : QPointF(MARKER_INSET, CardDimensions::HEIGHT_F - markerRect.height() - MARKER_INSET); + libraryEdgeMarker->setPos(libraryEdgeMarkerAnchorPos + offset); +} + +void ZoneViewZone::applyLibraryEdgeMarkerText() +{ + const auto *viewLogic = qobject_cast(getLogic()); + if (viewLogic == nullptr) { + return; + } + + const bool topEdge = viewLogic->showsTopOfLibrary(); + //: Short badge on the card at the top or bottom of a viewed library. The full phrase is in the tooltip. + libraryEdgeMarker->setLabel(topEdge ? tr("Top") : tr("Bottom"), + topEdge ? tr("This card is on top of the library") + : tr("This card is at the bottom of the library")); +} + +void ZoneViewZone::retranslateEdgeMarker() +{ + if (libraryEdgeMarker->isVisible()) { + // The translated label can be a different width, so reposition the chip + // to keep it inside its card. + applyLibraryEdgeMarkerText(); + positionLibraryEdgeMarker(); + } +} + void ZoneViewZone::setFilterString(const QString &_filterString) { const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); diff --git a/cockatrice/src/game_graphics/zones/view_zone.h b/cockatrice/src/game_graphics/zones/view_zone.h index f91b24834..9593ceeb9 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.h +++ b/cockatrice/src/game_graphics/zones/view_zone.h @@ -9,9 +9,11 @@ #include "select_zone.h" +#include #include #include #include +#include #include #include #include @@ -33,6 +35,30 @@ inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone"); class Response; class ServerInfo_Card; class QGraphicsSceneWheelEvent; +class QPainter; +class QStyleOptionGraphicsItem; +class QWidget; + +/** + * A small chip drawn on the card at the edge of a viewed library. + * + * It is a sibling of the card items rather than part of the zone widget, so it + * stays attached to its card when the view reflows into several columns or is + * resized. ZoneViewZone owns and positions it. + */ +class LibraryEdgeMarker : public QGraphicsItem +{ +public: + explicit LibraryEdgeMarker(QGraphicsItem *parent = nullptr); + + void setLabel(const QString &label, const QString &toolTip); + [[nodiscard]] QRectF boundingRect() const override; + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + +private: + QRectF markerRect; + QString label; +}; /** * A CardZone that is a view into another CardZone. @@ -56,6 +82,8 @@ private: FilterString filterString = FilterString(""); CardList::SortOption groupBy, sortBy; bool pileView; + LibraryEdgeMarker *libraryEdgeMarker; + QPointF libraryEdgeMarkerAnchorPos; struct GridSize { @@ -64,6 +92,9 @@ private: }; GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort); + void updateLibraryEdgeMarker(const CardList &displayedCards); + void applyLibraryEdgeMarkerText(); + void positionLibraryEdgeMarker(); void handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override; @@ -79,6 +110,10 @@ public: { return optimumRect; } + + /** @brief Re-applies the translated library edge marker text after a language change. */ + void retranslateEdgeMarker(); + public slots: void addToViews(); void removeFromViews(); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 3c6cf9d02..2efa2fd13 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -294,6 +294,7 @@ void ZoneViewWidget::retranslateUi() shuffleCheckBox.setText(tr("shuffle when closing")); pileViewCheckBox.setText(tr("pile view")); + zone->retranslateEdgeMarker(); } void ZoneViewWidget::stopWindowDrag() diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index db1fa32c3..f5fcd8786 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -7,6 +7,7 @@ #include #include #include +#include class PlayerLogic; class QObject; @@ -36,6 +37,11 @@ bool ZoneViewZoneLogic::getCloseEmptyCardView() const return interfaceSettings ? interfaceSettings->getCloseEmptyCardView() : true; } +bool ZoneViewZoneLogic::isLibraryEdgeView() const +{ + return origZone != nullptr && numberCards > 0 && origZone->getName() == ZoneNames::DECK; +} + /** * Checks if inserting a card at the given position requires an actual new card to be created and added to the view. * Also does any cardId updates that would be required if a card is inserted in that position. diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index a29e543a3..ee8156058 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -82,6 +82,27 @@ public: return isReversed; } + /** + * @brief Whether this view is a limited look at one end of a library. + * + * Only scry/surveil-style views of the top or bottom cards of a deck + * qualify. Full-zone views, revealed-card views and views of other zones + * do not, because their first and last cards have no library edge meaning. + */ + [[nodiscard]] bool isLibraryEdgeView() const; + + /** @brief Whether the first card in the view is the top card of the library. */ + [[nodiscard]] bool showsTopOfLibrary() const + { + return isLibraryEdgeView() && !isReversed; + } + + /** @brief Whether the last card in the view is the bottom card of the library. */ + [[nodiscard]] bool showsBottomOfLibrary() const + { + return isLibraryEdgeView() && isReversed; + } + CardZoneLogic *getOriginalZone() const { return origZone; diff --git a/tests/game/card_zone_logic_test.cpp b/tests/game/card_zone_logic_test.cpp index 7f0c39b47..e308d95a6 100644 --- a/tests/game/card_zone_logic_test.cpp +++ b/tests/game/card_zone_logic_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace { @@ -105,3 +106,30 @@ TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd) EXPECT_EQ(reorganizeSpy.count(), 1); } + +TEST(CardZoneLogicTest, LibraryEdgeViewIsLimitedToOneEndOfTheLibrary) +{ + HandZoneLogic deck(nullptr, QString::fromLatin1(ZoneNames::DECK), false, true, true); + HandZoneLogic grave(nullptr, QString::fromLatin1(ZoneNames::GRAVE), false, true, true); + + ZoneViewZoneLogic fullDeckView(nullptr, &deck, -1, false, false, false); + EXPECT_FALSE(fullDeckView.isLibraryEdgeView()); + EXPECT_FALSE(fullDeckView.showsTopOfLibrary()); + EXPECT_FALSE(fullDeckView.showsBottomOfLibrary()); + + ZoneViewZoneLogic emptyDeckView(nullptr, &deck, 0, false, false, false); + EXPECT_FALSE(emptyDeckView.isLibraryEdgeView()); + + ZoneViewZoneLogic topView(nullptr, &deck, 3, false, false, false); + EXPECT_TRUE(topView.isLibraryEdgeView()); + EXPECT_TRUE(topView.showsTopOfLibrary()); + EXPECT_FALSE(topView.showsBottomOfLibrary()); + + ZoneViewZoneLogic bottomView(nullptr, &deck, 3, false, false, true); + EXPECT_TRUE(bottomView.isLibraryEdgeView()); + EXPECT_FALSE(bottomView.showsTopOfLibrary()); + EXPECT_TRUE(bottomView.showsBottomOfLibrary()); + + ZoneViewZoneLogic graveView(nullptr, &grave, 3, false, false, false); + EXPECT_FALSE(graveView.isLibraryEdgeView()); +} From 257a584971ba9e8e2b1516244cf8fef75f15231a Mon Sep 17 00:00:00 2001 From: Mmonge123 Date: Sat, 7 Nov 2026 16:38:33 +0000 Subject: [PATCH 072/104] fix: keep only the roll-die shortcut active in the lobby (#6754) Player menu shortcuts were bound as soon as a local player was added in the deck-selection lobby. Defer full activation until GameMetaInfo reports the game has started, and bind only Player/aRollDie while in the lobby. Counters follow the deactivation signal, so they cannot re-enable shortcuts before the game starts. --- .../game_graphics/player/menu/player_menu.cpp | 31 +++++++++++++++++++ .../game_graphics/player/menu/player_menu.h | 11 +++++-- .../player/menu/utility_menu.cpp | 9 ++++++ .../game_graphics/player/menu/utility_menu.h | 3 ++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 582586ac2..27949356f 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -70,6 +71,11 @@ PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_ connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &PlayerMenu::refreshShortcuts); + + // Re-evaluate shortcuts when the game starts or ends, so the lobby only exposes the roll-die shortcut. + connect(player->getLogic()->getGame()->getGameMetaInfo(), &GameMetaInfo::startedChanged, this, + &PlayerMenu::refreshShortcuts); + refreshShortcuts(); retranslateUi(); @@ -144,6 +150,12 @@ void PlayerMenu::refreshShortcuts() void PlayerMenu::setShortcutsActive() { shortcutsActive = true; + + if (!isGameStarted()) { + applyLobbyShortcuts(); + return; + } + for (auto *c : managedComponents) { c->setShortcutsActive(); } @@ -156,5 +168,24 @@ void PlayerMenu::setShortcutsInactive() for (auto *c : managedComponents) { c->setShortcutsInactive(); } + emit shortcutsDeactivated(); +} + +bool PlayerMenu::isGameStarted() const +{ + return player->getLogic()->getGame()->getGameMetaInfo()->started(); +} + +void PlayerMenu::applyLobbyShortcuts() +{ + // Everything stays unbound until the game starts; only the roll-die shortcut is usable in the lobby. + for (auto *c : managedComponents) { + c->setShortcutsInactive(); + } + + if (utilityMenu) { + utilityMenu->setLobbyShortcutsActive(); + } + emit shortcutsDeactivated(); } \ No newline at end of file diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index e21a689de..56de3c918 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -73,17 +73,24 @@ public: return utilityMenu; } + /** @brief Whether this player currently holds its shortcuts and the game has started. */ [[nodiscard]] bool getShortcutsActive() const { - return shortcutsActive; + return shortcutsActive && isGameStarted(); } - /** @brief Bind keyboard shortcuts. Called when this player gains focus. */ + /** @brief Bind keyboard shortcuts. Only the roll-die shortcut is bound before the game starts. */ void setShortcutsActive(); /** @brief Unbind keyboard shortcuts. Called when this player loses focus. */ void setShortcutsInactive(); private: + /** @brief True once the game has started; false while players are still in the deck-selection lobby. */ + [[nodiscard]] bool isGameStarted() const; + + /** @brief Unbinds everything except the shortcuts that remain usable before the game starts. */ + void applyLobbyShortcuts(); + PlayerGraphicsItem *player; TearOffMenu *playerMenu; QMenu *countersMenu; diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index 3b8affff5..249fc57e6 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -150,4 +150,13 @@ void UtilityMenu::setShortcutsInactive() aCreateAnotherToken->setShortcut(QKeySequence()); aIncrementAllCardCounters->setShortcut(QKeySequence()); } +} + +void UtilityMenu::setLobbyShortcutsActive() +{ + if (!player->getLogic()->getPlayerInfo()->getLocalOrJudge()) { + return; + } + + aRollDie->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aRollDie")); } \ No newline at end of file diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.h b/cockatrice/src/game_graphics/player/menu/utility_menu.h index 009e4aca3..625c7aa62 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.h +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.h @@ -32,6 +32,9 @@ public slots: public: explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu); + /** @brief Binds only the shortcuts that remain usable before the game starts. */ + void setLobbyShortcutsActive(); + [[nodiscard]] bool createAnotherTokenActionExists() const { return aCreateAnotherToken != nullptr; From 0e9ba729244afd7a4aac988073b09602714fd935 Mon Sep 17 00:00:00 2001 From: tooomm Date: Sat, 7 Nov 2026 17:47:10 +0100 Subject: [PATCH 073/104] [Docs] Move documentation sources to docs/ and include CONTRIBUTING (#7584) Rename doc/ to docs/ so GitHub picks up docs/CONTRIBUTING.md and Doxygen keeps including it, and update all references to the old layout: - Doxygen output now goes to docs-output/, exposed by .gitignore - docs workflow triggers on docs/** and publishes docs-output/html - theme submodule moved to docs/doxygen/theme - schema URLs in both XML parsers point at docs/xsd/ - README, developer docs, desktop workflow filters, .dockerignore and gcovr config updated for the new paths The v3 card database schema is kept as docs/xsd/cards_v3.xsd. --- .dockerignore | 2 +- .github/workflows/desktop-build.yml | 4 +-- .github/workflows/desktop-lint.yml | 2 +- .github/workflows/documentation-build.yml | 4 +-- .gitignore | 2 +- .gitmodules | 2 +- Doxyfile | 24 +++++++++--------- README.md | 6 ++--- {.github => docs}/CONTRIBUTING.md | 0 {doc => docs}/doxygen/DoxygenLayout.xml | 0 {doc => docs}/doxygen/README.md | 6 ++--- .../doxygen/css/cockatrice_docs_style.css | 0 .../doxygen/css/doxygen_style_old.css | 0 {doc => docs}/doxygen/css/hide_nav_sync.css | 0 {doc => docs}/doxygen/filters/proto2cpp.py | 0 {doc => docs}/doxygen/groups/doc_groups.dox | 0 {doc => docs}/doxygen/html/footer.html | 0 {doc => docs}/doxygen/html/header.html | 0 .../doxygen/images/cockatrice_logo.svg | 0 .../screenshots/classic_database_display.png | Bin .../classic_database_display_add_buttons.png | Bin .../screenshots/classic_deck_editor.png | Bin .../screenshots/deck_dock_deck_list.png | Bin .../deck_dock_deck_list_buttons.png | Bin .../deck_dock_deck_list_group_by.png | Bin .../screenshots/deckeditordeckdockwidget.png | Bin .../images/screenshots/printing_selector.png | Bin .../screenshots/printing_selector_disable.png | Bin .../screenshots/printing_selector_enable.png | Bin .../printing_selector_navigation.png | Bin .../screenshots/printing_selector_options.png | Bin .../printing_selector_pre_providerid.png | Bin .../screenshots/release_channel_beta.png | Bin .../images/screenshots/vde_deck_analytics.png | Bin .../vde_flat_layout_color_grouped.png | Bin .../vde_flat_layout_type_grouped.png | Bin .../vde_overlap_layout_type_grouped.png | Bin .../images/screenshots/vde_sample_hand.png | Bin {doc => docs}/doxygen/js/graph_toggle.js | 0 .../card_database_schema_and_parsing.md | 4 +-- .../displaying_cards.md | 0 .../pages/developer_documentation/index.md | 0 .../loading_card_pictures.md | 0 .../pages/developer_documentation/logging.md | 0 .../network/client/game_event_handler.md | 0 .../network/client/index.md | 0 .../network/client/player_event_handler.md | 0 .../developer_documentation/network/index.md | 0 .../network/protocol/index.md | 0 .../protocol/protocol_command_container.md | 0 .../network/protocol/protocol_game_command.md | 0 .../network/protocol/protocol_overview.md | 0 .../network/protocol/protocol_response.md | 0 .../protocol/protocol_server_message.md | 0 .../developer_documentation/primer_cards.md | 0 .../querying_the_card_database.md | 0 {doc => docs}/doxygen/pages/index.md | 0 .../card_pictures/custom_card_pictures.md | 0 .../deck_management/creating_decks.md | 0 .../deck_management/editing_decks.md | 0 .../deck_management/editing_decks_classic.md | 0 .../editing_decks_printings.md | 0 .../deck_management/editing_decks_visual.md | 0 .../deck_management/exporting_decks.md | 0 .../deck_management/importing_decks.md | 0 .../visual_database_display.md | 0 .../doxygen/pages/user_documentation/index.md | 0 .../pages/user_documentation/settings/beta.md | 0 .../settings/settings_folder.md | 0 .../troubleshooting/enabling_debug_logs.md | 0 .../troubleshooting/fixing_card_pictures.md | 0 {doc => docs}/doxygen/theme | 0 {doc/carddatabase_v4 => docs/xsd}/cards.xsd | 0 .../cards.xsd => docs/xsd/cards_v3.xsd | 0 {doc => docs/xsd}/deck.xsd | 0 gcovr.cfg | 2 +- .../card/database/parser/cockatrice_xml_3.cpp | 2 +- .../card/database/parser/cockatrice_xml_4.cpp | 2 +- 78 files changed, 31 insertions(+), 31 deletions(-) rename {.github => docs}/CONTRIBUTING.md (100%) rename {doc => docs}/doxygen/DoxygenLayout.xml (100%) rename {doc => docs}/doxygen/README.md (80%) rename {doc => docs}/doxygen/css/cockatrice_docs_style.css (100%) rename {doc => docs}/doxygen/css/doxygen_style_old.css (100%) rename {doc => docs}/doxygen/css/hide_nav_sync.css (100%) rename {doc => docs}/doxygen/filters/proto2cpp.py (100%) rename {doc => docs}/doxygen/groups/doc_groups.dox (100%) rename {doc => docs}/doxygen/html/footer.html (100%) rename {doc => docs}/doxygen/html/header.html (100%) rename {doc => docs}/doxygen/images/cockatrice_logo.svg (100%) rename {doc => docs}/doxygen/images/screenshots/classic_database_display.png (100%) rename {doc => docs}/doxygen/images/screenshots/classic_database_display_add_buttons.png (100%) rename {doc => docs}/doxygen/images/screenshots/classic_deck_editor.png (100%) rename {doc => docs}/doxygen/images/screenshots/deck_dock_deck_list.png (100%) rename {doc => docs}/doxygen/images/screenshots/deck_dock_deck_list_buttons.png (100%) rename {doc => docs}/doxygen/images/screenshots/deck_dock_deck_list_group_by.png (100%) rename {doc => docs}/doxygen/images/screenshots/deckeditordeckdockwidget.png (100%) rename {doc => docs}/doxygen/images/screenshots/printing_selector.png (100%) rename {doc => docs}/doxygen/images/screenshots/printing_selector_disable.png (100%) rename {doc => docs}/doxygen/images/screenshots/printing_selector_enable.png (100%) rename {doc => docs}/doxygen/images/screenshots/printing_selector_navigation.png (100%) rename {doc => docs}/doxygen/images/screenshots/printing_selector_options.png (100%) rename {doc => docs}/doxygen/images/screenshots/printing_selector_pre_providerid.png (100%) rename {doc => docs}/doxygen/images/screenshots/release_channel_beta.png (100%) rename {doc => docs}/doxygen/images/screenshots/vde_deck_analytics.png (100%) rename {doc => docs}/doxygen/images/screenshots/vde_flat_layout_color_grouped.png (100%) rename {doc => docs}/doxygen/images/screenshots/vde_flat_layout_type_grouped.png (100%) rename {doc => docs}/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png (100%) rename {doc => docs}/doxygen/images/screenshots/vde_sample_hand.png (100%) rename {doc => docs}/doxygen/js/graph_toggle.js (100%) rename {doc => docs}/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md (81%) rename {doc => docs}/doxygen/pages/developer_documentation/displaying_cards.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/index.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/loading_card_pictures.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/logging.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/client/game_event_handler.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/client/index.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/client/player_event_handler.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/index.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/protocol/index.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/protocol/protocol_response.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/primer_cards.md (100%) rename {doc => docs}/doxygen/pages/developer_documentation/querying_the_card_database.md (100%) rename {doc => docs}/doxygen/pages/index.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/deck_management/creating_decks.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/deck_management/editing_decks.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/deck_management/exporting_decks.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/deck_management/importing_decks.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/deck_management/visual_database_display.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/index.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/settings/beta.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/settings/settings_folder.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md (100%) rename {doc => docs}/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md (100%) rename {doc => docs}/doxygen/theme (100%) rename {doc/carddatabase_v4 => docs/xsd}/cards.xsd (100%) rename doc/carddatabase_v3/cards.xsd => docs/xsd/cards_v3.xsd (100%) rename {doc => docs/xsd}/deck.xsd (100%) diff --git a/.dockerignore b/.dockerignore index 8e0b69332..fc07b2b9d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,6 @@ build/ .github/ .tx/ cockatrice/ -doc/ +docs/ oracle/ Dockerfile diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 3a31da5fc..9246c624b 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -15,7 +15,7 @@ on: - '!**.md' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.github/workflows/desktop-build.yml' - 'CMakeLists.txt' - 'vcpkg.json' @@ -28,7 +28,7 @@ on: - '!**.md' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.github/workflows/desktop-build.yml' - 'CMakeLists.txt' - 'vcpkg.json' diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml index 93ba79464..f42f26e6a 100644 --- a/.github/workflows/desktop-lint.yml +++ b/.github/workflows/desktop-lint.yml @@ -12,7 +12,7 @@ on: - '!.ci/**' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.ci/lint_cpp.sh' - '.github/workflows/desktop-lint.yml' - '.clang-format' diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index 91ee14e3e..b2f85e267 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -6,7 +6,7 @@ permissions: on: pull_request: paths: - - 'doc/doxygen/**' + - 'docs/**' - '.github/workflows/documentation-build.yml' - 'Doxyfile' release: @@ -71,7 +71,7 @@ jobs: destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/ external_repository: Cockatrice/cockatrice.github.io publish_branch: master - publish_dir: ./docs/html + publish_dir: ./docs-output/html - name: Link to Documentation Page if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' diff --git a/.gitignore b/.gitignore index 54939bdcf..ef88b1f58 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ compile_commands.json .cache .gdb_history cockatrice/resources/config/qtlogging.ini -docs/ +docs-output/ diff --git a/.gitmodules b/.gitmodules index e69c8e686..e6f115eb5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,7 +2,7 @@ path = vcpkg url = https://github.com/microsoft/vcpkg.git [submodule "doxygen-awesome-css"] - path = doc/doxygen/theme + path = docs/doxygen/theme url = https://github.com/jothepro/doxygen-awesome-css.git [submodule "external/qttesting"] path = external/qttesting diff --git a/Doxyfile b/Doxyfile index c12ff4a8a..0d508ca38 100644 --- a/Doxyfile +++ b/Doxyfile @@ -74,7 +74,7 @@ PROJECT_ICON = cockatrice/resources/cockatrice.png # entered, it will be relative to the location where Doxygen was started. If # left blank the current directory will be used. -OUTPUT_DIRECTORY = docs +OUTPUT_DIRECTORY = docs-output # If the CREATE_SUBDIRS tag is set to YES then Doxygen will create up to 4096 # sub-directories (in 2 levels) under the output directory of each output format @@ -874,7 +874,7 @@ FILE_VERSION_FILTER = # DoxygenLayout.xml, Doxygen will parse it automatically even if the LAYOUT_FILE # tag is left empty. -LAYOUT_FILE = doc/doxygen/DoxygenLayout.xml +LAYOUT_FILE = docs/doxygen/DoxygenLayout.xml # The CITE_BIB_FILES tag can be used to specify one or more bib files containing # the reference definitions. This must be a list of .bib files. The .bib @@ -1091,8 +1091,8 @@ RECURSIVE = YES EXCLUDE = build/ \ cmake/ \ cmake-build-debug/ \ - doc/doxygen/theme/docs/ \ - doc/doxygen/theme/include/ \ + docs/doxygen/theme/docs/ \ + docs/doxygen/theme/include/ \ vcpkg/ # The EXCLUDE_SYMLINKS tag can be used to select whether or not files or @@ -1144,7 +1144,7 @@ EXAMPLE_RECURSIVE = NO # that contain images that are to be included in the documentation (see the # \image command). -IMAGE_PATH = doc/doxygen/images +IMAGE_PATH = docs/doxygen/images # The INPUT_FILTER tag can be used to specify a program that Doxygen should # invoke to filter for each input file. Doxygen will invoke the filter program @@ -1183,7 +1183,7 @@ INPUT_FILTER = # need to set EXTENSION_MAPPING for the extension otherwise the files are not # properly processed by Doxygen. -FILTER_PATTERNS = "*.proto=python doc/doxygen/filters/proto2cpp.py" +FILTER_PATTERNS = "*.proto=python docs/doxygen/filters/proto2cpp.py" # If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using # INPUT_FILTER) will also be used to filter the input files that are used for @@ -1414,7 +1414,7 @@ HTML_FILE_EXTENSION = .html # of the possible markers and block names see the documentation. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_HEADER = doc/doxygen/html/header.html +HTML_HEADER = docs/doxygen/html/header.html # The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each # generated HTML page. If the tag is left blank Doxygen will generate a standard @@ -1424,7 +1424,7 @@ HTML_HEADER = doc/doxygen/html/header.html # that Doxygen normally uses. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_FOOTER = doc/doxygen/html/footer.html +HTML_FOOTER = docs/doxygen/html/footer.html # The HTML_STYLESHEET tag can be used to specify a user-defined cascading style # sheet that is used by each HTML page. It can be used to fine-tune the look of @@ -1454,9 +1454,9 @@ HTML_STYLESHEET = # documentation. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \ - doc/doxygen/css/hide_nav_sync.css \ - doc/doxygen/css/cockatrice_docs_style.css +HTML_EXTRA_STYLESHEET = docs/doxygen/theme/doxygen-awesome.css \ + docs/doxygen/css/hide_nav_sync.css \ + docs/doxygen/css/cockatrice_docs_style.css # The HTML_EXTRA_FILES tag can be used to specify one or more extra images or # other source files which should be copied to the HTML output directory. Note @@ -1466,7 +1466,7 @@ HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \ # files will be copied as-is; there are no commands or markers available. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_EXTRA_FILES = doc/doxygen/js/graph_toggle.js +HTML_EXTRA_FILES = docs/doxygen/js/graph_toggle.js # The HTML_COLORSTYLE tag can be used to specify if the generated HTML output # should be rendered with a dark or light theme. diff --git a/README.md b/README.md index fa9272c0f..03f02e540 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p ### Code -To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
    +To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md) first.
    We maintain two tags for contributors to easier find issues to potentially work on: - [![Good first issue](https://img.shields.io/github/issues/cockatrice/cockatrice/Good%20First%20Issue)](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)
    Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on. @@ -103,7 +103,7 @@ There are various places where useful information for different needs are mainta - [Official Wiki](https://github.com/Cockatrice/Cockatrice/wiki) `Community supported` - [Official Webpage](https://cockatrice.github.io/) - [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file` -- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) +- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md) Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage everybody to improve the documentation by suggesting edits based on this guide. @@ -163,7 +163,7 @@ The following flags (with their non-default values) can be passed to `cmake`: | `-DTEST=1` | Enable regression tests
    **Note:** `make test` to run tests, *googletest* will be downloaded if not available | > [!TIP] -> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs`: +> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs-output`: > ```bash > doxygen Doxyfile > ``` diff --git a/.github/CONTRIBUTING.md b/docs/CONTRIBUTING.md similarity index 100% rename from .github/CONTRIBUTING.md rename to docs/CONTRIBUTING.md diff --git a/doc/doxygen/DoxygenLayout.xml b/docs/doxygen/DoxygenLayout.xml similarity index 100% rename from doc/doxygen/DoxygenLayout.xml rename to docs/doxygen/DoxygenLayout.xml diff --git a/doc/doxygen/README.md b/docs/doxygen/README.md similarity index 80% rename from doc/doxygen/README.md rename to docs/doxygen/README.md index 230422303..ca4acc1fc 100644 --- a/doc/doxygen/README.md +++ b/docs/doxygen/README.md @@ -1,11 +1,11 @@ ## Building Doxygen Documentation We use [Doxygen](https://www.doxygen.nl) for our code documentation. -After installing the tool, you can build a HTML webpage into the `/docs` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): +After installing the tool, you can build a HTML webpage into the `/docs-output` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): ```bash doxygen Doxyfile ``` -Open the `index.html` file in the `/docs/html/` folder to browse the result locally. +Open the `index.html` file in the `/docs-output/html/` folder to browse the result locally. There is also an online version we host at the Cockatrice webpage: [cockatrice.github.io/docs](https://cockatrice.github.io/docs) Doxygen's own documenation can be found at [www.doxygen.nl/manual](https://www.doxygen.nl/manual). @@ -25,4 +25,4 @@ FULL_SIDEBAR = NO # Required for doxygen-awesome
    -Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/doc/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions. +Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/docs/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions. diff --git a/doc/doxygen/css/cockatrice_docs_style.css b/docs/doxygen/css/cockatrice_docs_style.css similarity index 100% rename from doc/doxygen/css/cockatrice_docs_style.css rename to docs/doxygen/css/cockatrice_docs_style.css diff --git a/doc/doxygen/css/doxygen_style_old.css b/docs/doxygen/css/doxygen_style_old.css similarity index 100% rename from doc/doxygen/css/doxygen_style_old.css rename to docs/doxygen/css/doxygen_style_old.css diff --git a/doc/doxygen/css/hide_nav_sync.css b/docs/doxygen/css/hide_nav_sync.css similarity index 100% rename from doc/doxygen/css/hide_nav_sync.css rename to docs/doxygen/css/hide_nav_sync.css diff --git a/doc/doxygen/filters/proto2cpp.py b/docs/doxygen/filters/proto2cpp.py similarity index 100% rename from doc/doxygen/filters/proto2cpp.py rename to docs/doxygen/filters/proto2cpp.py diff --git a/doc/doxygen/groups/doc_groups.dox b/docs/doxygen/groups/doc_groups.dox similarity index 100% rename from doc/doxygen/groups/doc_groups.dox rename to docs/doxygen/groups/doc_groups.dox diff --git a/doc/doxygen/html/footer.html b/docs/doxygen/html/footer.html similarity index 100% rename from doc/doxygen/html/footer.html rename to docs/doxygen/html/footer.html diff --git a/doc/doxygen/html/header.html b/docs/doxygen/html/header.html similarity index 100% rename from doc/doxygen/html/header.html rename to docs/doxygen/html/header.html diff --git a/doc/doxygen/images/cockatrice_logo.svg b/docs/doxygen/images/cockatrice_logo.svg similarity index 100% rename from doc/doxygen/images/cockatrice_logo.svg rename to docs/doxygen/images/cockatrice_logo.svg diff --git a/doc/doxygen/images/screenshots/classic_database_display.png b/docs/doxygen/images/screenshots/classic_database_display.png similarity index 100% rename from doc/doxygen/images/screenshots/classic_database_display.png rename to docs/doxygen/images/screenshots/classic_database_display.png diff --git a/doc/doxygen/images/screenshots/classic_database_display_add_buttons.png b/docs/doxygen/images/screenshots/classic_database_display_add_buttons.png similarity index 100% rename from doc/doxygen/images/screenshots/classic_database_display_add_buttons.png rename to docs/doxygen/images/screenshots/classic_database_display_add_buttons.png diff --git a/doc/doxygen/images/screenshots/classic_deck_editor.png b/docs/doxygen/images/screenshots/classic_deck_editor.png similarity index 100% rename from doc/doxygen/images/screenshots/classic_deck_editor.png rename to docs/doxygen/images/screenshots/classic_deck_editor.png diff --git a/doc/doxygen/images/screenshots/deck_dock_deck_list.png b/docs/doxygen/images/screenshots/deck_dock_deck_list.png similarity index 100% rename from doc/doxygen/images/screenshots/deck_dock_deck_list.png rename to docs/doxygen/images/screenshots/deck_dock_deck_list.png diff --git a/doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png b/docs/doxygen/images/screenshots/deck_dock_deck_list_buttons.png similarity index 100% rename from doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png rename to docs/doxygen/images/screenshots/deck_dock_deck_list_buttons.png diff --git a/doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png b/docs/doxygen/images/screenshots/deck_dock_deck_list_group_by.png similarity index 100% rename from doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png rename to docs/doxygen/images/screenshots/deck_dock_deck_list_group_by.png diff --git a/doc/doxygen/images/screenshots/deckeditordeckdockwidget.png b/docs/doxygen/images/screenshots/deckeditordeckdockwidget.png similarity index 100% rename from doc/doxygen/images/screenshots/deckeditordeckdockwidget.png rename to docs/doxygen/images/screenshots/deckeditordeckdockwidget.png diff --git a/doc/doxygen/images/screenshots/printing_selector.png b/docs/doxygen/images/screenshots/printing_selector.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector.png rename to docs/doxygen/images/screenshots/printing_selector.png diff --git a/doc/doxygen/images/screenshots/printing_selector_disable.png b/docs/doxygen/images/screenshots/printing_selector_disable.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_disable.png rename to docs/doxygen/images/screenshots/printing_selector_disable.png diff --git a/doc/doxygen/images/screenshots/printing_selector_enable.png b/docs/doxygen/images/screenshots/printing_selector_enable.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_enable.png rename to docs/doxygen/images/screenshots/printing_selector_enable.png diff --git a/doc/doxygen/images/screenshots/printing_selector_navigation.png b/docs/doxygen/images/screenshots/printing_selector_navigation.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_navigation.png rename to docs/doxygen/images/screenshots/printing_selector_navigation.png diff --git a/doc/doxygen/images/screenshots/printing_selector_options.png b/docs/doxygen/images/screenshots/printing_selector_options.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_options.png rename to docs/doxygen/images/screenshots/printing_selector_options.png diff --git a/doc/doxygen/images/screenshots/printing_selector_pre_providerid.png b/docs/doxygen/images/screenshots/printing_selector_pre_providerid.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_pre_providerid.png rename to docs/doxygen/images/screenshots/printing_selector_pre_providerid.png diff --git a/doc/doxygen/images/screenshots/release_channel_beta.png b/docs/doxygen/images/screenshots/release_channel_beta.png similarity index 100% rename from doc/doxygen/images/screenshots/release_channel_beta.png rename to docs/doxygen/images/screenshots/release_channel_beta.png diff --git a/doc/doxygen/images/screenshots/vde_deck_analytics.png b/docs/doxygen/images/screenshots/vde_deck_analytics.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_deck_analytics.png rename to docs/doxygen/images/screenshots/vde_deck_analytics.png diff --git a/doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png b/docs/doxygen/images/screenshots/vde_flat_layout_color_grouped.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png rename to docs/doxygen/images/screenshots/vde_flat_layout_color_grouped.png diff --git a/doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png b/docs/doxygen/images/screenshots/vde_flat_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png rename to docs/doxygen/images/screenshots/vde_flat_layout_type_grouped.png diff --git a/doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png b/docs/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png rename to docs/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png diff --git a/doc/doxygen/images/screenshots/vde_sample_hand.png b/docs/doxygen/images/screenshots/vde_sample_hand.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_sample_hand.png rename to docs/doxygen/images/screenshots/vde_sample_hand.png diff --git a/doc/doxygen/js/graph_toggle.js b/docs/doxygen/js/graph_toggle.js similarity index 100% rename from doc/doxygen/js/graph_toggle.js rename to docs/doxygen/js/graph_toggle.js diff --git a/doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md b/docs/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md similarity index 81% rename from doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md rename to docs/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md index 7b4bdafc3..8679b910b 100644 --- a/doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md +++ b/docs/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md @@ -7,8 +7,8 @@ The token file follows the schema of the card database (`tokens.xml`), too. Saved decks (`.xml`) use a different schema. -- [XSD Schema for `Card Databases`](https://github.com/Cockatrice/Cockatrice/blob/master/doc/carddatabase_v4/cards.xsd) v4 -- [XSD Schema for `Decks`](https://github.com/Cockatrice/Cockatrice/blob/master/doc/deck.xsd) v1 +- [XSD Schema for `Card Databases`](https://github.com/Cockatrice/Cockatrice/blob/master/docs/xsd/cards.xsd) v4 +- [XSD Schema for `Decks`](https://github.com/Cockatrice/Cockatrice/blob/master/docs/xsd/deck.xsd) v1 # Card Database Parsing diff --git a/doc/doxygen/pages/developer_documentation/displaying_cards.md b/docs/doxygen/pages/developer_documentation/displaying_cards.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/displaying_cards.md rename to docs/doxygen/pages/developer_documentation/displaying_cards.md diff --git a/doc/doxygen/pages/developer_documentation/index.md b/docs/doxygen/pages/developer_documentation/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/index.md rename to docs/doxygen/pages/developer_documentation/index.md diff --git a/doc/doxygen/pages/developer_documentation/loading_card_pictures.md b/docs/doxygen/pages/developer_documentation/loading_card_pictures.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/loading_card_pictures.md rename to docs/doxygen/pages/developer_documentation/loading_card_pictures.md diff --git a/doc/doxygen/pages/developer_documentation/logging.md b/docs/doxygen/pages/developer_documentation/logging.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/logging.md rename to docs/doxygen/pages/developer_documentation/logging.md diff --git a/doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md b/docs/doxygen/pages/developer_documentation/network/client/game_event_handler.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md rename to docs/doxygen/pages/developer_documentation/network/client/game_event_handler.md diff --git a/doc/doxygen/pages/developer_documentation/network/client/index.md b/docs/doxygen/pages/developer_documentation/network/client/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/client/index.md rename to docs/doxygen/pages/developer_documentation/network/client/index.md diff --git a/doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md b/docs/doxygen/pages/developer_documentation/network/client/player_event_handler.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md rename to docs/doxygen/pages/developer_documentation/network/client/player_event_handler.md diff --git a/doc/doxygen/pages/developer_documentation/network/index.md b/docs/doxygen/pages/developer_documentation/network/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/index.md rename to docs/doxygen/pages/developer_documentation/network/index.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/index.md b/docs/doxygen/pages/developer_documentation/network/protocol/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/index.md rename to docs/doxygen/pages/developer_documentation/network/protocol/index.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_response.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_response.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md diff --git a/doc/doxygen/pages/developer_documentation/primer_cards.md b/docs/doxygen/pages/developer_documentation/primer_cards.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/primer_cards.md rename to docs/doxygen/pages/developer_documentation/primer_cards.md diff --git a/doc/doxygen/pages/developer_documentation/querying_the_card_database.md b/docs/doxygen/pages/developer_documentation/querying_the_card_database.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/querying_the_card_database.md rename to docs/doxygen/pages/developer_documentation/querying_the_card_database.md diff --git a/doc/doxygen/pages/index.md b/docs/doxygen/pages/index.md similarity index 100% rename from doc/doxygen/pages/index.md rename to docs/doxygen/pages/index.md diff --git a/doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md b/docs/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md similarity index 100% rename from doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md rename to docs/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/creating_decks.md b/docs/doxygen/pages/user_documentation/deck_management/creating_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/creating_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/creating_decks.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md b/docs/doxygen/pages/user_documentation/deck_management/exporting_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/exporting_decks.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md b/docs/doxygen/pages/user_documentation/deck_management/importing_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/importing_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/importing_decks.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md b/docs/doxygen/pages/user_documentation/deck_management/visual_database_display.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md rename to docs/doxygen/pages/user_documentation/deck_management/visual_database_display.md diff --git a/doc/doxygen/pages/user_documentation/index.md b/docs/doxygen/pages/user_documentation/index.md similarity index 100% rename from doc/doxygen/pages/user_documentation/index.md rename to docs/doxygen/pages/user_documentation/index.md diff --git a/doc/doxygen/pages/user_documentation/settings/beta.md b/docs/doxygen/pages/user_documentation/settings/beta.md similarity index 100% rename from doc/doxygen/pages/user_documentation/settings/beta.md rename to docs/doxygen/pages/user_documentation/settings/beta.md diff --git a/doc/doxygen/pages/user_documentation/settings/settings_folder.md b/docs/doxygen/pages/user_documentation/settings/settings_folder.md similarity index 100% rename from doc/doxygen/pages/user_documentation/settings/settings_folder.md rename to docs/doxygen/pages/user_documentation/settings/settings_folder.md diff --git a/doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md b/docs/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md similarity index 100% rename from doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md rename to docs/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md diff --git a/doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md b/docs/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md similarity index 100% rename from doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md rename to docs/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md diff --git a/doc/doxygen/theme b/docs/doxygen/theme similarity index 100% rename from doc/doxygen/theme rename to docs/doxygen/theme diff --git a/doc/carddatabase_v4/cards.xsd b/docs/xsd/cards.xsd similarity index 100% rename from doc/carddatabase_v4/cards.xsd rename to docs/xsd/cards.xsd diff --git a/doc/carddatabase_v3/cards.xsd b/docs/xsd/cards_v3.xsd similarity index 100% rename from doc/carddatabase_v3/cards.xsd rename to docs/xsd/cards_v3.xsd diff --git a/doc/deck.xsd b/docs/xsd/deck.xsd similarity index 100% rename from doc/deck.xsd rename to docs/xsd/deck.xsd diff --git a/gcovr.cfg b/gcovr.cfg index ac893b6a4..7b25dbc8d 100644 --- a/gcovr.cfg +++ b/gcovr.cfg @@ -32,7 +32,7 @@ exclude = ^tests/ # Vendored third-party code is not ours to cover and is never going to move the number. exclude = ^vcpkg/ -exclude = ^doc/doxygen/theme/ +exclude = ^docs/doxygen/theme/ exclude = ^libcockatrice_rng/libcockatrice/rng/sfmt/ exclude = ^libcockatrice_utility/libcockatrice/utility/peglib\.h$ exclude = ^oracle/src/lzma/ diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index 8d5735db7..99440628a 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -33,7 +33,7 @@ class ICardSetPriorityController; #define COCKATRICE_XML3_TAGNAME "cockatrice_carddatabase" #define COCKATRICE_XML3_TAGVER 3 #define COCKATRICE_XML3_SCHEMALOCATION \ - "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/doc/carddatabase_v3/cards.xsd" + "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/docs/xsd/cards_v3.xsd" CockatriceXml3Parser::CockatriceXml3Parser(ICardSetPriorityController *_cardSetPriorityController) : ICardDatabaseParser(_cardSetPriorityController) diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 3965b80f0..84fc27155 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -36,7 +36,7 @@ class ICardSetPriorityController; #define COCKATRICE_XML4_TAGNAME "cockatrice_carddatabase" #define COCKATRICE_XML4_TAGVER 4 #define COCKATRICE_XML4_SCHEMALOCATION \ - "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/doc/carddatabase_v4/cards.xsd" + "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/docs/xsd/cards.xsd" CockatriceXml4Parser::CockatriceXml4Parser(ICardSetPriorityController *_cardSetPriorityController) : ICardDatabaseParser(_cardSetPriorityController) From d7d1872638a22820f2766cd55339a722596913de Mon Sep 17 00:00:00 2001 From: tooomm Date: Sat, 7 Nov 2026 18:34:33 +0100 Subject: [PATCH 074/104] [CI] Install only the Qt archives the desktop builds use (#7024) The online installer ships the base package as ~25 archives; aqt installs all of them unless `--archives` names the ones wanted. Restricting them to what cmake/FindQtRuntime.cmake requires cuts the Windows Qt cache from 440 MB to 180 MB and the macOS thin Qt download from >200 MB to 60 MB. The list lives once, in a workflow-level QT_ARCHIVES variable, because both install steps consume it and the macOS thin-Qt cache key has to change with it. The addons the build also needs (Multimedia, WebSockets, ShaderTools, ImageFormats) are not base archives and stay in each leg's qt_modules; qtdeclarative is required on Windows too, since the onboarding banner links Qt6::Quick/QuickWidgets. --- .github/workflows/desktop-build.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 9246c624b..164c55cfb 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -47,6 +47,20 @@ concurrency: group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})" cancel-in-progress: ${{ github.ref_type != 'tag' }} +# Qt's online installer ships its base package as separate archives. Installing only the +# ones Cockatrice loads drops the full 25+ archive download to this handful (Windows Qt +# cache: 440 MB → 180 MB, macOS thin Qt: ~200 MB → 60 MB). The addons Cockatrice also needs +# (Multimedia, WebSockets, ShaderTools, ImageFormats) are not base archives: they stay in +# each leg's qt_modules. +# +# The list mirrors what cmake/FindQtRuntime.cmake requires, so a component that is missing +# here fails configure with "No suitable version of Qt was found" instead of at runtime: +# qtbase supplies Core, Gui, Widgets, Network, Sql, Xml, Concurrent and PrintSupport; +# qtdeclarative supplies Qml, Quick and QuickWidgets; qtsvg supplies Svg; qttools supplies +# Linguist (lrelease/lupdate); qttranslations ships Qt's own catalogs. +env: + QT_ARCHIVES: qtbase qtdeclarative qtsvg qttools qttranslations + jobs: configure: name: Configure @@ -717,7 +731,7 @@ jobs: id: restore_qt uses: actions/cache/restore@v6 with: - key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-thin + key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-${{ env.QT_ARCHIVES }}-thin path: ${{ github.workspace }}/Qt # Using jurplel/install-qt-action to install Qt without using brew @@ -726,6 +740,7 @@ jobs: if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' uses: jurplel/install-qt-action@v4 with: + archives: ${{ env.QT_ARCHIVES }} cache: false # cache-key-prefix: Qt dir: ${{ github.workspace }} # thinning script depends on this location @@ -749,6 +764,7 @@ jobs: with: # Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released aqtsource: git+https://github.com/miurahr/aqtinstall.git + archives: ${{ env.QT_ARCHIVES }} cache: ${{ github.ref == 'refs/heads/master' }} cache-key-prefix: Qt modules: ${{ matrix.qt_modules }} From 393144772941b8eddbb307de65bac007b10c255d Mon Sep 17 00:00:00 2001 From: tooomm Date: Sat, 7 Nov 2026 18:37:09 +0100 Subject: [PATCH 075/104] [Build] Clean up CMake compiler flags and CI build script (#7219) Compiler flags are applied once and for the right compiler: - Detect the toolchain by CMAKE_CXX_COMPILER_ID (MSVC / GNU / Clang, warn on an unknown one). - MSVC: drop /std:c++20 (CMAKE_CXX_STANDARD already adds it) and the outdated C4251 suppression, add /utf-8, and use /O2 instead of /Ox. - GCC: drop the no-op -Wno-long-long and the macOS-only -std=gnu++20 override; CMAKE_CXX_EXTENSIONS=OFF now owns the dialect. - Use add_compile_definitions for SFMT_MEXP instead of add_definitions. CI build script: - Only run the schema version check for server builds. - Accept boolean env flags as 1 and say so in the header. - Print ccache, ctest and cpack versions next to their log groups. - Only build the macOS vcpkg triplet when vcpkg is actually used. - Drop the extra cmake --build verbosity. - Install NSIS without chocolatey progress noise. Co-authored-by: toomm --- .ci/compile.sh | 144 ++++++++++++++------------ .github/workflows/desktop-build.yml | 2 +- CMakeLists.txt | 152 +++++++++++++++++----------- servatrice/check_schema_version.sh | 2 +- 4 files changed, 173 insertions(+), 127 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 7414b2dc3..0307d630c 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -20,6 +20,7 @@ # --profile writes an MSBuild performance summary and binary log, Windows only # uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE # (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir --profile) +# boolean env flags are enabled with the value 1 (e.g. MAKE_SERVER=1) # exitcode: 1 for failure, 3 for invalid arguments # Read arguments @@ -138,17 +139,21 @@ done set -e -# Setup -./servatrice/check_schema_version.sh -if [[ ! $BUILDTYPE ]]; then +# Schema version consistency (only relevant to the server) +if [[ ${MAKE_SERVER:-} == "1" ]]; then + ./servatrice/check_schema_version.sh +fi + +# Use default values if unset +if [[ -z $BUILDTYPE ]]; then BUILDTYPE=Release fi -if [[ ! $BUILD_DIR ]]; then +if [[ -z $BUILD_DIR ]]; then BUILD_DIR="build" fi mkdir -p "$BUILD_DIR" -if [[ $MAKE_COVERAGE ]]; then +if [[ ${MAKE_COVERAGE:-} == "1" ]]; then # Resolved while the working directory is still the source root. $0 is relative to the # directory the script was invoked from, so after the cd below it no longer resolves, and # under `set -e` the failed subshell would abort the build rather than just yielding an empty @@ -163,16 +168,16 @@ export CMAKE_POLICY_VERSION_MINIMUM=3.10 # Add cmake flags flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE") -if [[ $MAKE_SERVER ]]; then +if [[ ${MAKE_SERVER:-} == "1" ]]; then flags+=("-DWITH_SERVER=1") fi -if [[ $MAKE_NO_CLIENT ]]; then +if [[ ${MAKE_NO_CLIENT:-} == "1" ]]; then flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0") fi -if [[ $MAKE_TEST ]]; then +if [[ ${MAKE_TEST:-} == "1" ]]; then flags+=("-DTEST=1") fi -if [[ $MAKE_SANITIZE ]]; then +if [[ ${MAKE_SANITIZE:-} == "1" ]]; then flags+=("-DENABLE_SANITIZERS=ON") else # Every leg states the flag explicitly rather than relying on the CMake @@ -180,20 +185,20 @@ else # never leak libasan/libubsan into a shipped binary. flags+=("-DENABLE_SANITIZERS=OFF") fi -if [[ $MAKE_COVERAGE ]]; then +if [[ ${MAKE_COVERAGE:-} == "1" ]]; then flags+=("-DENABLE_COVERAGE=ON") fi -if [[ $USE_CCACHE ]]; then +if [[ ${USE_CCACHE:-} == "1" ]]; then flags+=("-DUSE_CCACHE=1") - if [[ $CCACHE_SIZE ]]; then - # note, this setting persists after running the script + if [[ -n $CCACHE_SIZE ]]; then + # This setting persists after running the script ccache --max-size "$CCACHE_SIZE" fi fi -if [[ $PACKAGE_TYPE ]]; then +if [[ -n $PACKAGE_TYPE ]]; then flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE") fi -if [[ $USE_VCPKG ]]; then +if [[ ${USE_VCPKG:-} == "1" ]]; then flags+=("-DUSE_VCPKG=1") flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions") fi @@ -221,7 +226,8 @@ fi buildflags=(--config "$BUILDTYPE") function ccachestatsverbose() { - # note, verbose only works on newer ccache, discard the error + ccache --version + # Verbose only works on newer ccache, discard the error local got if got="$(ccache --show-stats --verbose 2>/dev/null)"; then echo "$got" @@ -261,42 +267,44 @@ if [[ $RUNNER_OS == macOS ]]; then # this works independent of the qt version as there should be only one version installed on the runner at a time export QTDIR - if [[ $TARGET_MACOS_VERSION ]]; then - # CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla cmake flag needed to compile to target macOS version + if [[ -n $TARGET_MACOS_VERSION ]]; then + # CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla CMake flag needed to compile to the target macOS version flags+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=$TARGET_MACOS_VERSION") - # vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version - # an easy way is to copy the x64-osx.cmake file and modify it - triplets_dir="/tmp/cmake/triplets" - triplet_version="custom-triplet" - triplet_file="$triplets_dir/$triplet_version.cmake" - arch=$(uname -m) - if [[ $arch == x86_64 ]]; then - arch="x64" + if [[ ${USE_VCPKG:-} == "1" ]]; then + # vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version + # An easy way is to copy the x64-osx.cmake file and modify it + triplets_dir="/tmp/cmake/triplets" + triplet_version="custom-triplet" + triplet_file="$triplets_dir/$triplet_version.cmake" + arch=$(uname -m) + if [[ $arch == x86_64 ]]; then + arch="x64" + fi + mkdir -p "$triplets_dir" + triplet_source="../vcpkg/triplets/$arch-osx.cmake" + if [[ ! -f "$triplet_source" ]]; then + triplet_source="../vcpkg/triplets/community/$arch-osx.cmake" + fi + cp "$triplet_source" "$triplet_file" + # The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that + # agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode + # produce identical binaries when they share the lowest deployment target, so they all + # build against 14 to keep one vcpkg cache scope instead of three. + # Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml. + vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}" + echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file" + echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file" + flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir") + flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version") + flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version") + echo "::group::Generated triplet $triplet_file" + cat "$triplet_file" + echo "::endgroup::" fi - mkdir -p "$triplets_dir" - triplet_source="../vcpkg/triplets/$arch-osx.cmake" - if [[ ! -f "$triplet_source" ]]; then - triplet_source="../vcpkg/triplets/community/$arch-osx.cmake" - fi - cp "$triplet_source" "$triplet_file" - # The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that - # agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode - # produce identical binaries when they share the lowest deployment target, so they all - # build against 14 to keep one vcpkg cache scope instead of three. - # Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml. - vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}" - echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file" - echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file" - flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir") - flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version") - flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version") - echo "::group::Generated triplet $triplet_file" - cat "$triplet_file" - echo "::endgroup::" fi - echo "::group::Signing Certificate" + echo "::group::Setup signing certificate" if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12" security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain @@ -311,7 +319,7 @@ if [[ $RUNNER_OS == macOS ]]; then fi echo "::endgroup::" - if [[ $MAKE_PACKAGE ]]; then + if [[ ${MAKE_PACKAGE:-} == "1" ]]; then # Workaround https://github.com/actions/runner-images/issues/7522 # have hdiutil repeat the command 10 times in hope of success. # The wrapper has to exit non-zero once it gives up: CPack reads that status to @@ -342,7 +350,7 @@ elif [[ $RUNNER_OS == Windows ]]; then # cmake only hands MSBuild a job count when one is asked for, and without it # MSBuild builds a single node: one project at a time, whatever MTT does inside # it. Ask for a node per core. - if [[ ! $BUILD_PARALLEL_LEVEL ]]; then + if [[ -z $BUILD_PARALLEL_LEVEL ]]; then BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}" fi buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL") @@ -368,19 +376,20 @@ elif [[ $RUNNER_OS == Windows ]]; then fi fi -if [[ $USE_CCACHE ]]; then +if [[ ${USE_CCACHE:-} == "1" ]]; then echo "::group::Show ccache stats" ccachestatsverbose echo "::endgroup::" fi -echo "::group::Configure cmake" +# Configure CMake +echo "::group::Generate build system" cmake --version -echo "Running cmake with flags: ${flags[*]}" +echo "Running CMake configuration with the following flags: ${flags[*]}" cmake .. "${flags[@]}" echo "::endgroup::" -if [[ $USE_CCACHE ]]; then +if [[ ${USE_CCACHE:-} == "1" ]]; then # PCH-aware caching is required or ccache refuses to cache any TU that consumes a # precompiled header, silently recompiling everything on every run. The mtime and ctime # entries cover files created during the build itself: the moc/uic/rcc output and the @@ -395,9 +404,10 @@ if [[ $USE_CCACHE ]]; then ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime fi +# Build echo "::group::Build project" cpuinfo -echo "Running cmake --build with flags: ${buildflags[*]}" +echo "Running CMake with the following build flags: ${buildflags[*]}" cmake --build . "${buildflags[@]}" echo "::endgroup::" @@ -409,16 +419,16 @@ if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then echo "::endgroup::" fi -if [[ $USE_CCACHE ]]; then - if [[ $CCACHE_EVICTION_AGE ]]; then - echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE" +if [[ ${USE_CCACHE:-} == "1" ]]; then + if [[ -n $CCACHE_EVICTION_AGE ]]; then + echo "::group::Evict ccache files older than $CCACHE_EVICTION_AGE" ccache --evict-older-than "$CCACHE_EVICTION_AGE" echo "::endgroup::" fi echo "::group::Show ccache stats again" ccachestatsverbose echo "::endgroup::" -elif [[ $CCACHE_EVICTION_AGE ]]; then +elif [[ -n $CCACHE_EVICTION_AGE ]]; then echo "::error file=$0::ccache eviction is enabled while ccache is disabled!" fi @@ -435,9 +445,9 @@ if [[ $RUNNER_OS == macOS ]]; then echo "::endgroup::" fi -if [[ $MAKE_TEST ]]; then +if [[ ${MAKE_TEST:-} == "1" ]]; then echo "::group::Run tests" - if [[ $MAKE_SANITIZE ]]; then + if [[ ${MAKE_SANITIZE:-} == "1" ]]; then # detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and # LeakSanitizer's default is aggressive enough to bury our real findings under hundreds # of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for @@ -455,11 +465,12 @@ if [[ $MAKE_TEST ]]; then # bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to # run concurrently: the only one that claims a fixed resource is single_instance_manager_test, # which already scopes its local socket name to its own pid so parallel copies cannot collide. + ctest --version ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}" echo "::endgroup::" fi -if [[ $MAKE_COVERAGE ]]; then +if [[ ${MAKE_COVERAGE:-} == "1" ]]; then # gcovr is not a build dependency and is not available everywhere, so a missing one is reported # and skipped rather than failing an otherwise green build. echo "::group::Coverage report" @@ -484,7 +495,7 @@ if [[ $MAKE_COVERAGE ]]; then echo "::endgroup::" fi -if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then +if [[ $RUNNER_OS == Windows && ${MAKE_NO_CLIENT:-} != "1" ]]; then echo "::group::Probe the installer's holder lookup" # The working directory is still the build dir here: the package steps below are what # leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name @@ -684,18 +695,21 @@ POWERSHELL echo "::endgroup::" fi -if [[ $MAKE_INSTALL ]]; then +# Install +if [[ ${MAKE_INSTALL:-} == "1" ]]; then echo "::group::Install" cmake --build . --target install --config "$BUILDTYPE" echo "::endgroup::" fi -if [[ $MAKE_PACKAGE ]]; then +# Package +if [[ ${MAKE_PACKAGE:-} == "1" ]]; then echo "::group::Create package" + cpack --version cmake --build . --target package --config "$BUILDTYPE" echo "::endgroup::" - if [[ $PACKAGE_SUFFIX ]]; then + if [[ -n $PACKAGE_SUFFIX ]]; then echo "::group::Update package name" cd .. BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 164c55cfb..90166ff03 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -773,7 +773,7 @@ jobs: - name: "[Windows] Install NSIS" if: matrix.os == 'Windows' shell: bash - run: choco install nsis + run: choco install nsis --no-progress # Ninja invokes cl.exe directly and needs the MSVC environment on PATH. - name: "[Windows] Set up MSVC environment" diff --git a/CMakeLists.txt b/CMakeLists.txt index 53508fcd1..b0f6a8db0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,11 @@ # Cockatrice's main CMakeLists.txt # -# This is basically a wrapper to enable/disable the compilation -# of the different projects: servatrice, cockatrice, test +# This is basically a wrapper to enable/disable the compilation of the different projects: +# Cockatrice, Oracle, Servatrice, Test # This file sets all the variables shared between the projects # like the installation path, compilation flags etc.. -# 3.16 required for Qt6 and target_precompile_headers() +# CMake 3.16 is required for Qt6 and target_precompile_headers() cmake_minimum_required(VERSION 3.16) # Use compiler cache (ccache) @@ -40,16 +40,11 @@ if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/ endif() # Default to "Release" build type -# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call -if(DEFINED CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE - ${CMAKE_BUILD_TYPE} - CACHE STRING "Type of build" - ) -else() +# A user-provided CMAKE_BUILD_TYPE must be checked before the PROJECT() call +if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release - CACHE STRING "Type of build" + CACHE STRING "Build type" ) endif() @@ -108,20 +103,21 @@ endif() # Version can be overriden by git tags, see cmake/getversion.cmake project("Cockatrice" VERSION 3.2.0) -# Set release name if not provided via env/cmake var +# Set release name if not provided via ENV/CMake var if(NOT DEFINED GIT_TAG_RELEASENAME) set(GIT_TAG_RELEASENAME "Reality Check") endif() -# Use c++20 for all targets +# Use C++20 for all targets set(CMAKE_CXX_STANDARD 20 CACHE STRING "C++ ISO Standard" ) -set(CMAKE_CXX_STANDARD_REQUIRED True) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) # Set conventional loops -set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS true) +set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS ON) # Search path for cmake modules set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake") @@ -134,31 +130,31 @@ include(createversionfile) # Define a proper install path if(UNIX) - if(APPLE) - # macOS - # Due to the special bundle structure ignore - # the prefix eventually set by the user. + if(APPLE) # macOS + # Due to the special bundle structure ignore the prefix eventually set by the user set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release) - # Force ccache usage if available - get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE) - if(RULE_LAUNCH_COMPILE) - message(STATUS "Force enabling CCache usage under macOS") - # Set up wrapper scripts - configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c) - configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx) - execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx") + # Force ccache usage if requested and available + if(USE_CCACHE) + get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE) + if(RULE_LAUNCH_COMPILE) + message(STATUS "Force enabling ccache usage under macOS") - # Set Xcode project attributes to route compilation through our scripts - set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c") - set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx") - set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c") - set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx") + # Set up wrapper scripts + configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c) + configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx) + execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx") + + # Set Xcode project attributes to route compilation through our scripts + set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c") + set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx") + set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c") + set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx") + endif() endif() - else() - # Linux / BSD + else() # Linux / BSD if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - #fix package build + # Fix package build if(PREFIX) set(CMAKE_INSTALL_PREFIX ${PREFIX}) else() @@ -166,47 +162,67 @@ if(UNIX) endif() endif() endif() -elseif(WIN32) +elseif(WIN32) # Windows (including 64-bit) set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE}) endif() -# Define proper compilation flags -if(MSVC) - # Disable Warning C4251, C++20 compatibility, Multi-threaded Builds, Warn Detection, Unwind Semantics, Debug Symbols - set(CMAKE_CXX_FLAGS "/wd4251 /Zc:__cplusplus /std:c++20 /permissive- /W4 /MP /EHsc /Zi") - # Visual Studio: Maximum Optimization, Multi-threaded DLL - set(CMAKE_CXX_FLAGS_RELEASE "/Ox /MD") - # Visual Studio: No Optimization, Multi-threaded Debug DLL +# Define compiler flags +if(MSVC) # Microsoft Visual C++ compiler + # /EHsc Enable standard C++ exception handling + # /MP Enable parallel compilation + # /permissive- Enable more standards-conforming behavior + # /utf-8 Set source file encoding and execution character set to UTF-8 + # /W4 Enable warning level 4 + # /Zc:__cplusplus Enable correct __cplusplus reporting + # /Zi Generate debugging information (Program Database, PDB) + set(CMAKE_CXX_FLAGS "/EHsc /MP /permissive- /utf-8 /W4 /Zc:__cplusplus /Zi") + # /O2 Balanced optimization + # /MD Link against the multi-threaded DLL runtime library (Release CRT) + set(CMAKE_CXX_FLAGS_RELEASE "/O2 /MD") + # /Od Disable optimization + # /MDd Link against the multi-threaded Debug DLL runtime library (Debug CRT) set(CMAKE_CXX_FLAGS_DEBUG "/Od /MDd") - # Generate PDB, even when in release (So developers can better analyze crash logs) + # Generate PDBs, even in release builds, so developers can better analyze crash logs + # /DEBUG Enable debug symbols and PDB generation + # /OPT:REF Remove unreferenced functions/data + # /OPT:ICF Fold identical COMDAT functions/data set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF") add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING) -elseif(CMAKE_COMPILER_IS_GNUCXX) - # linux/gcc, bsd/gcc, windows/mingw +elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler include(CheckCXXCompilerFlag) - set(CMAKE_CXX_FLAGS_RELEASE "-s -O2") + # -O2 Balanced optimization + # -s Remove symbols from the executable + set(CMAKE_CXX_FLAGS_RELEASE "-O2 -s") + + # -ggdb Produce GDB debugging symbols + # -O0 Disable optimization + # -Wall Enable a broad set of useful warnings + # -Wextra Enable a set of extra warnings + # -Werror Treat warnings as compilation errors if(WARNING_AS_ERROR) set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror") else() set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra") endif() - if(APPLE) - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20") - endif() - + # -Wcast-align Catch unsafe pointer casts + # -Wmissing-declarations Catch defined functions without a prior declaration + # -Wno-error=extra Downgrade some -Wextra warnings from errors to warnings + # -Wno-error=delete-non-virtual-dtor Downgrade delete-non-virtual-dtor from error to warning + # -Wno-error=sign-compare Downgrade signed/unsigned comparison from error to warning + # -Wno-error=missing-declarations Downgrade -Wmissing-declarations from error to warning + # -Wno-error=sfinae-incomplete GCC 16+: Qt MOC + protobuf forward decls trigger this set(ADDITIONAL_DEBUG_FLAGS -Wcast-align -Wmissing-declarations - -Wno-long-long -Wno-error=extra -Wno-error=delete-non-virtual-dtor -Wno-error=sign-compare -Wno-error=missing-declarations - -Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this + -Wno-error=sfinae-incomplete ) foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS}) @@ -218,9 +234,16 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) # Reduce compiler I/O by using pipes between stages instead of temp files set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") -else() - # other: osx/llvm, bsd/llvm +elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang and AppleClang compilers + # -O2 Balanced optimization set(CMAKE_CXX_FLAGS_RELEASE "-O2") + + # -g Include debug information + # -O0 Disable optimization + # -Wall Enable a broad set of useful warnings + # -Wextra Enable a set of extra warnings + # -Werror Treat warnings as compilation errors + # -Wno-unused-parameter Suppress warnings about unused function parameters (common in Qt callbacks) if(WARNING_AS_ERROR) set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter") else() @@ -229,6 +252,8 @@ else() # Reduce compiler I/O by using pipes between stages instead of temp files set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") +else() # Unknown compiler + message(WARNING "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}") endif() # Always included, because they define cockatrice_disable_sanitizers_for() and @@ -259,9 +284,9 @@ if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "") endif() endif() -# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning +# GNU systems need to define the Mersenne exponent for SFMT, so the RNG compiles without warnings if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - add_definitions("-DSFMT_MEXP=19937") + add_compile_definitions(SFMT_MEXP=19937) endif() find_package(Threads REQUIRED) @@ -314,15 +339,15 @@ if(${Protobuf_VERSION} VERSION_LESS "3.21.0.0" AND NOT EXISTS "${Protobuf_PROTOC message(FATAL_ERROR "No protoc command found!") endif() -#Find OpenSSL -if(WIN32) +# Find OpenSSL +if(WIN32) # Windows (including 64-bit) find_package(OpenSSL REQUIRED) if(OPENSSL_FOUND) include_directories(${OPENSSL_INCLUDE_DIRS}) endif() endif() -#Find VCredist +# Find Visual C++ Redistributable if(MSVC) find_package(VCredistRuntime) endif() @@ -535,3 +560,10 @@ if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3) # Qt6.3+ requires project finalization to support translations qt_finalize_project() endif() + +# Print compiler identification at configuration time +message(STATUS "C++ compiler: ${CMAKE_CXX_COMPILER_ID}") +message(STATUS "C++ compiler version: ${CMAKE_CXX_COMPILER_VERSION}") +message(STATUS "C++ compiler path: ${CMAKE_CXX_COMPILER}") +message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}") +message(STATUS "C++ extensions: ${CMAKE_CXX_EXTENSIONS}") diff --git a/servatrice/check_schema_version.sh b/servatrice/check_schema_version.sh index c4aadf356..8b645b5d2 100755 --- a/servatrice/check_schema_version.sh +++ b/servatrice/check_schema_version.sh @@ -10,7 +10,7 @@ declare -i schema_ver="${version_line%%)*}" latest_migration="$(ls -1 servatrice/migrations/ | tail -n1)" xtoysql="${latest_migration#servatrice_}" xtoy="${xtoysql%.sql}" -declare -i old_ver="10#${xtoy%_to_*}" #declare as integer with base 10, numbers with a leading 0 are normally interpreted as base 16 +declare -i old_ver="10#${xtoy%_to_*}" # declare as integer with base 10, numbers with a leading 0 are normally interpreted as base 16 declare -i new_ver="10#${xtoy#*_to_}" if ((old_ver >= new_ver)); then From ee66e73b2c253bf6df8e7c72b47cfb14444e353b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:58:28 +0100 Subject: [PATCH 076/104] [CI] Give the Windows leg a compiler cache (#7797) ccache drives MSVC since 4.7, but it rejects /Zi and /ZI because the debug information lives in a shared PDB it cannot restore. /Z7 puts it in the object file instead, which is what makes the compilation cacheable and also removes the shared PDB as a serialisation point on a 4-core runner. Allow ccache on Windows in CMake, install it with Chocolatey, and persist it under ccache-Windows10 the same way the Linux legs do. --- .github/workflows/desktop-build.yml | 69 ++++++++++++++++++++++++----- CMakeLists.txt | 22 ++++----- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 90166ff03..7e31fed66 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -248,20 +248,23 @@ jobs: # Debian 13 Release + suite 625 MB 800M # Fedora 45 debug Debug + suite 660 MB 850M # Fedora 45 release Release + package 614 MB 800M + # Windows 10 Release + package 1.2 GB working set 1300M # - # That is 3.3 GB of bounds, and with the fixed entries above (about 4.4 GB) it puts - # the quota at roughly 7.7 GB of 10 - so only legs worth their share get one. A leg - # keeps no cache when it duplicates another leg's coverage (Ubuntu 26.04 behind - # Debian 13's Release + suite; the macOS legs behind the Linux ones) or finishes well - # inside the Windows leg (Servatrice covers the server alone in 4.7 min). A macOS + # That is 4.6 GB of bounds; what lands in the quota is 4.3 GB of compiler cache, + # 2.97 GB stored by the Linux legs and 1.30 GB by Windows, for 8.4 GB of 10 in total. + # So only legs worth their share get one. A leg keeps no cache when it duplicates + # another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + suite; the macOS + # legs behind the Linux ones) or finishes well inside the Windows leg (Servatrice + # covers the server alone in 4.7 min). A macOS # cache served 42% and saved about 32s on a 20 min leg that is already shorter than # Windows, so that bound is better spent here. The four Linux legs that stay compile # the client on the toolchains a change can break and are the ones a cache shortens # by the most. # Measured job totals on master 2026-11-04 also show the ceiling this leaves: - # Windows 10 is 29.5 min and compiles for 23.7 of it with no compiler cache at all, so - # Windows still sets the pull-request wall clock no matter how warm these legs are - # (see build-vcpkg). + # Windows 10 is 29.5 min and compiles for 23.7 of it, so Windows sets the + # pull-request wall clock no matter how warm these legs are. That is why the Windows + # leg is the one macOS's share went to (see build-vcpkg): MSVC has no -g level to + # shrink, and with /Z7 the whole configuration is cacheable, PCH included. # # Steady-state master data corrected this on 2026-11-07: Arch 90.1% and Debian 13 # 96.75% per-run hits at the 850M/800M bounds, Fedora 45 debug 99.18% and release @@ -676,6 +679,9 @@ jobs: qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets type: Release + ccache_eviction_age: 7d + ccache_size: 1300M # Release plus the package: 1.2 GB working set at /Z7 + use_ccache: 1 # Scope is named after the vs2026 toolchain, not the generator: the # ABI hash covers cl.exe, which is identical, so this leg keeps # restoring the warm archives. Renaming would orphan them. @@ -689,8 +695,13 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: - # No leg in this job keeps a compiler cache: neither the macOS legs nor the Windows - # leg can fill one across runs. See the note on the removed macOS ccache steps below. + # Only the Windows leg keeps a compiler cache; the macOS legs build without one + # (see the note where their ccache steps used to be). ccache drives MSVC as long as + # the compiler emits /Z7, which CMakeLists.txt sets for exactly this reason, and it + # caches the PCH-backed targets with the sloppiness that .ci/compile.sh re-asserts. + CCACHE_DIR: ${{ github.workspace }}/.cache/ + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} # MSVC has no -g level, so the Windows leg does not ask for one. COCKATRICE_DEBUG_INFO_LEVEL: ${{ matrix.os != 'Windows' && github.ref_type != 'tag' && '1' || '' }} @@ -707,6 +718,19 @@ jobs: with: msbuild-architecture: x64 + - name: "[Windows] Setup ccache" + if: matrix.os == 'Windows' && matrix.use_ccache == 1 + shell: bash + run: choco install ccache --no-progress -y + + - name: "[Windows] Restore compiler cache (ccache)" + if: matrix.os == 'Windows' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' + id: restore_ccache + uses: actions/cache/restore@v6 + with: + key: ccache-${{ matrix.os }}${{ matrix.target }} + path: ${{ env.CCACHE_DIR }} + # No macOS leg runs ccache. A restored macOS cache served 42% of calls and saved # about 32s on a 20 min leg that is already shorter than the Windows leg, which did # not pay for its 800M slice of the quota; with nothing restored, the remaining @@ -820,6 +844,7 @@ jobs: shell: bash env: BUILDTYPE: '${{ matrix.type }}' + CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }} CMAKE_GENERATOR: ${{ matrix.cmake_generator }} # Tag builds only: artifacts from master pushes and PR branches must not report to # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way. @@ -834,6 +859,7 @@ jobs: MAKE_PACKAGE: '${{ matrix.make_package }}' PACKAGE_SUFFIX: '${{ matrix.package_suffix }}' TARGET_MACOS_VERSION: ${{ matrix.override_target }} + USE_CCACHE: ${{ matrix.use_ccache }} # Only pushes to the default branch may write the shared binary cache: the # archives a run compiles are only as trustworthy as the code they were # built from, and only merged code may populate the scope that master and @@ -1034,6 +1060,27 @@ jobs: path: ${{ runner.temp }}/vcpkg-new retention-days: 1 + # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 + - name: "[Windows] Delete remote compiler cache (ccache)" + if: matrix.os == 'Windows' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit + continue-on-error: true + # Bash, not the runner's default pwsh, which cannot parse this `if`. + shell: bash + env: + CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} + GH_TOKEN: ${{ github.token }} + run: | + if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then + echo "Cache deleted successfully" + fi + + - name: "[Windows] Cache updated compiler cache (ccache)" + if: matrix.os == 'Windows' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' + uses: actions/cache/save@v6 + with: + key: ${{ steps.restore_ccache.outputs.cache-primary-key }} + path: ${{ env.CCACHE_DIR }} + - name: "[macOS] Sign app bundle" if: matrix.os == 'macOS' && matrix.make_package && needs.configure.outputs.tag != null id: sign_macos @@ -1222,7 +1269,7 @@ jobs: # The nightly legs and the pull-request legs that finish under the Windows leg # keep none, so their leftover entries are reclaimed here instead of waiting out # the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release' + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Windows10' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" diff --git a/CMakeLists.txt b/CMakeLists.txt index b0f6a8db0..1edfef8e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,11 +48,12 @@ if(NOT CMAKE_BUILD_TYPE) ) endif() -# ccache does not support MSVC and must not auto-engage on Windows -# (it is installed unintentionally on the Windows CI runner). -# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows -# also opts out of ccache even though the GNUCXX branch below supports it. -if(USE_CCACHE AND NOT WIN32) +# ccache drives MSVC too (since 4.7), but only when the compiler writes debug +# information into the object file: /Zi and /ZI keep it in a shared PDB, and ccache +# reports them as unsupported rather than risk a stale one. The MSVC flags below +# therefore use /Z7. MSVC precompiled headers are cached as well, with the same +# sloppiness the other toolchains need. +if(USE_CCACHE) find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) # Support Unix Makefiles and Ninja @@ -66,10 +67,10 @@ if(USE_CCACHE AND NOT WIN32) # check for every project on the machine, not just this one. execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") + else() + # An explicit opt-in must not disappear silently. + message(STATUS "ccache requested but not found; building without a compiler cache") endif() -elseif(USE_CCACHE AND WIN32) - # An explicit opt-in must not disappear silently on Windows. - message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows") endif() if(WIN32 OR USE_VCPKG) @@ -174,8 +175,9 @@ if(MSVC) # Microsoft Visual C++ compiler # /utf-8 Set source file encoding and execution character set to UTF-8 # /W4 Enable warning level 4 # /Zc:__cplusplus Enable correct __cplusplus reporting - # /Zi Generate debugging information (Program Database, PDB) - set(CMAKE_CXX_FLAGS "/EHsc /MP /permissive- /utf-8 /W4 /Zc:__cplusplus /Zi") + # /Z7 Generate debugging information in each object file (ccache-cacheable) + # instead of a compiler-shared PDB, which ccache rejects + set(CMAKE_CXX_FLAGS "/EHsc /MP /permissive- /utf-8 /W4 /Zc:__cplusplus /Z7") # /O2 Balanced optimization # /MD Link against the multi-threaded DLL runtime library (Release CRT) set(CMAKE_CXX_FLAGS_RELEASE "/O2 /MD") From 1df8c8efcba93aefa15cf889f3b519f70763151d Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:58:28 +0100 Subject: [PATCH 077/104] [CI] Build the Windows and macOS legs without debug information (#7798) * [CI] Build the Windows and macOS legs without debug information Debug information is most of what a compiler cache stores: one Linux configuration measures 1.2 GB with full DWARF and 660 MB at -g1. The Windows leg cannot be lowered that way, because MSVC has no -g level and /Z7 is the only cacheable form, so it sits at full debug info and fills a 1.2 GB cache. Teach COCKATRICE_DEBUG_INFO_LEVEL=0 to mean something on MSVC by leaving /Z7 out, and build the vcpkg legs (Windows and macOS) at level 0 for every non-tag ref. A tag is the build that ships and is symbolicated, so it keeps the full set. The Linux legs keep -g1: a sanitizer report without file and line is not worth the quota it would save. Report the ccache directory and the object total at the end of each leg, so the working set a bound has to fit is a number in the log rather than an estimate. * [CI] Size the Windows cache for the no-debug footprint Measured on run 42377451921, one Windows configuration without /Z7 writes a 890 MB ccache directory, against 1.2 GB with it. A 950M bound holds that with 5.4% headroom, which is the same rule the Linux bounds follow. The comment block that splits the quota follows: 4.25 GB of bounds, 3.9 GB of stored compiler cache, 8.1 GB of 10 in total. --- .github/workflows/desktop-build.yml | 30 +++++++++++++++++++++++------ CMakeLists.txt | 9 ++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 7e31fed66..9aafedd29 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -248,10 +248,10 @@ jobs: # Debian 13 Release + suite 625 MB 800M # Fedora 45 debug Debug + suite 660 MB 850M # Fedora 45 release Release + package 614 MB 800M - # Windows 10 Release + package 1.2 GB working set 1300M + # Windows 10 Release + package 890 MB working set 950M # - # That is 4.6 GB of bounds; what lands in the quota is 4.3 GB of compiler cache, - # 2.97 GB stored by the Linux legs and 1.30 GB by Windows, for 8.4 GB of 10 in total. + # That is 4.25 GB of bounds; what lands in the quota is 3.9 GB of compiler cache, + # 2.97 GB stored by the Linux legs and 0.95 GB by Windows, for 8.1 GB of 10 in total. # So only legs worth their share get one. A leg keeps no cache when it duplicates # another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + suite; the macOS # legs behind the Linux ones) or finishes well inside the Windows leg (Servatrice @@ -680,7 +680,7 @@ jobs: qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets type: Release ccache_eviction_age: 7d - ccache_size: 1300M # Release plus the package: 1.2 GB working set at /Z7 + ccache_size: 950M # Release plus the package: 890 MB working set without /Z7 use_ccache: 1 # Scope is named after the vs2026 toolchain, not the generator: the # ABI hash covers cl.exe, which is identical, so this leg keeps @@ -702,8 +702,11 @@ jobs: CCACHE_DIR: ${{ github.workspace }}/.cache/ CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} - # MSVC has no -g level, so the Windows leg does not ask for one. - COCKATRICE_DEBUG_INFO_LEVEL: ${{ matrix.os != 'Windows' && github.ref_type != 'tag' && '1' || '' }} + # MSVC has no -g level, so there is nothing to ask for on Windows: level 0 is the one + # value with a meaning there, and it drops /Z7 rather than lowering a level. Pull-request + # builds carry no debug information, which is most of what a compiler cache stores; a + # tag is the build whose binary is shipped and symbolicated, so it keeps the full set. + COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '0' || '' }} # Shared with build-vcpkg-nightly so the two leg sets run the same steps. steps: &vcpkg-steps @@ -877,6 +880,21 @@ jobs: WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }} run: .ci/compile.sh --server --test --vcpkg + # What the compiler cache has to hold for this leg. ccache stores the objects it + # compiles, so their total is the working set a bound has to fit, and the cache + # directory shows what this build actually wrote. Debug information dominates both, + # which is why the level above is reported next to them. + - name: "Report compiler cache footprint" + if: always() + shell: bash + run: | + echo "=== ccache directory" + du -sh "$CCACHE_DIR" 2>/dev/null || echo "(none)" + echo "=== objects and archives" + find build -type f \( -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.lib' \) -exec du -k {} + 2>/dev/null \ + | awk '{n++; s+=$1} END {printf "%d files, %.2f GB\n", n, s/1048576}' + echo "=== debug info level in use: '${COCKATRICE_DEBUG_INFO_LEVEL}'" + - name: "Install sentry-cli" if: needs.configure.outputs.tag != null && matrix.make_package == '1' # Same as the Linux leg above: the install downloads from Sentry's registry, so a diff --git a/CMakeLists.txt b/CMakeLists.txt index 1edfef8e0..71f195385 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -279,7 +279,14 @@ set(COCKATRICE_DEBUG_INFO_LEVEL ) if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "") if(MSVC) - message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC") + # MSVC has no -g level, so /Z7 is either there or it is not. Level 0 is the only value + # that means something here: without /Z7 the objects carry no debug information, which is + # what a cache that has to fit a bound holds. Anything else is the full debug info. + if(COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "0") + string(REPLACE " /Z7" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") + else() + message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC except 0") + endif() else() string(APPEND CMAKE_CXX_FLAGS_DEBUG " -g${COCKATRICE_DEBUG_INFO_LEVEL}") string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g${COCKATRICE_DEBUG_INFO_LEVEL}") From bf2529d76809a1d45967b4fa6643444417af159c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:58:29 +0100 Subject: [PATCH 078/104] [CI] Cache the macOS legs again (#7805) Re-enable ccache on the three pull-request macOS legs (14, 15 and 27 Debug), which #7792 dropped because none of them persisted a cache. Every leg now restores and saves its own entry on master, keyed by target, and the eviction step keeps each entry inside its bound. The bounds are sized from the measured working set: one macOS configuration wrote a 0.5 GB ccache directory at -g1 with the old 700M bound, and the debug-information change builds the vcpkg legs at level 0, which shrinks it to ~0.4 GB. 700M holds the whole configuration for each leg, and the three entries add ~1.2 GB. That fits the quota once master's slimmed Qt archive set and the CodeQL prune are counted: the live total before the compiler caches is 7.1 GB, Windows adds 0.95 GB, and the three macOS entries add 1.23 GB, for ~9.3 GB of 10. --- .github/workflows/desktop-build.yml | 84 ++++++++++++++++++----------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 9aafedd29..b93d76b3d 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -234,7 +234,7 @@ jobs: # That is 10.30 GB against the 10 GB quota, so every save past this point costs some # other entry, and least-recently-used order reaches the buildkit layers before the # trap caches. prune-caches below keeps only the newest trap cache; the live total is - # then 7.1 GB, which is what leaves room for a Windows compiler cache. + # then 7.1 GB, which is what leaves room for the Windows and macOS compiler caches. # # Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with # -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit @@ -249,22 +249,25 @@ jobs: # Fedora 45 debug Debug + suite 660 MB 850M # Fedora 45 release Release + package 614 MB 800M # Windows 10 Release + package 890 MB working set 950M + # macOS 14/15 Release + package 0.5 GB ccache at -g1 700M + # macOS 27 Debug Debug + suite 0.5 GB ccache at -g1 700M # - # That is 4.25 GB of bounds; what lands in the quota is 3.9 GB of compiler cache, - # 2.97 GB stored by the Linux legs and 0.95 GB by Windows, for 8.1 GB of 10 in total. + # That is 6.35 GB of bounds; what lands in the quota is 5.15 GB of compiler cache, + # 2.97 GB stored by the Linux legs, 0.95 GB by Windows and 1.23 GB by the three macOS + # legs, for 9.3 GB of 10 in total. # So only legs worth their share get one. A leg keeps no cache when it duplicates - # another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + suite; the macOS - # legs behind the Linux ones) or finishes well inside the Windows leg (Servatrice - # covers the server alone in 4.7 min). A macOS - # cache served 42% and saved about 32s on a 20 min leg that is already shorter than - # Windows, so that bound is better spent here. The four Linux legs that stay compile - # the client on the toolchains a change can break and are the ones a cache shortens - # by the most. + # another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + suite) or finishes + # well inside the Windows leg (Servatrice covers the server alone in 4.7 min). The + # macOS legs compile the client with a different toolchain (Apple clang), and one + # configuration writes only ~0.5 GB, so all three fit beside the Windows entry even + # though a macOS cache served 42% and saved about 32s on a 20 min leg. The four Linux + # legs that stay compile the client on the toolchains a change can break and are the + # ones a cache shortens by the most. # Measured job totals on master 2026-11-04 also show the ceiling this leaves: # Windows 10 is 29.5 min and compiles for 23.7 of it, so Windows sets the # pull-request wall clock no matter how warm these legs are. That is why the Windows - # leg is the one macOS's share went to (see build-vcpkg): MSVC has no -g level to - # shrink, and with /Z7 the whole configuration is cacheable, PCH included. + # leg keeps the largest bound (see build-vcpkg): MSVC has no -g level to shrink, and + # with /Z7 the whole configuration is cacheable, PCH included. # # Steady-state master data corrected this on 2026-11-07: Arch 90.1% and Debian 13 # 96.75% per-run hits at the 850M/800M bounds, Fedora 45 debug 99.18% and release @@ -273,8 +276,9 @@ jobs: # run on the same day measured one configuration at 680 MB (Arch) and 625 MB # (Debian 13), so those bounds already hold the working set with ~170 MB of slack for # commit-to-commit churn, and raising them buys hit rate the numbers do not show a - # need for. macOS 14's 800M entry is dropped because persistence buys 32s there; the - # quota stays free for the legs whose bounds would actually miss. + # need for. The three macOS entries are 700M each because one configuration wrote a + # 0.5 GB ccache directory at -g1 and level 0 shrinks it further, so the bound holds the + # whole configuration with room to spare. # # Re-add a leg only with a bound that holds its whole configuration: a smaller one is # worse than none. @@ -625,6 +629,8 @@ jobs: target: 14 # EOL 2026-09-14 runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md + ccache_eviction_age: 7d + ccache_size: 700M # Release + package: ~0.4 GB at level 0 cmake_generator: Ninja make_package: 1 override_target: 14 @@ -633,6 +639,7 @@ jobs: qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release + use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over @@ -641,6 +648,8 @@ jobs: target: 15 # EOL 2027-?? runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md + ccache_eviction_age: 7d + ccache_size: 700M # Release + package: ~0.4 GB at level 0 cmake_generator: Ninja make_package: 1 override_target: 15 @@ -649,6 +658,7 @@ jobs: qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release + use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' @@ -656,11 +666,14 @@ jobs: target: 27 runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md + ccache_eviction_age: 7d + ccache_size: 700M # Debug + suite: ~0.5 GB at level 0 cmake_generator: Ninja qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Debug + use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' @@ -695,10 +708,13 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: - # Only the Windows leg keeps a compiler cache; the macOS legs build without one - # (see the note where their ccache steps used to be). ccache drives MSVC as long as - # the compiler emits /Z7, which CMakeLists.txt sets for exactly this reason, and it - # caches the PCH-backed targets with the sloppiness that .ci/compile.sh re-asserts. + # The Windows leg and all three pull-request macOS legs keep a compiler cache. ccache + # drives MSVC as long as the compiler emits /Z7, which CMakeLists.txt sets for exactly + # this reason, and it drives clang on macOS the same way; both cache the PCH-backed + # targets with the sloppiness that .ci/compile.sh re-asserts. Every leg builds without + # debug information on non-tag refs, so one macOS configuration writes ~0.4-0.5 GB and + # the 700M bound holds it with room for commit-to-commit churn (see build-linux for the + # quota arithmetic). CCACHE_DIR: ${{ github.workspace }}/.cache/ CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} @@ -726,19 +742,25 @@ jobs: shell: bash run: choco install ccache --no-progress -y - - name: "[Windows] Restore compiler cache (ccache)" - if: matrix.os == 'Windows' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' + - name: "[macOS] Setup ccache" + if: matrix.os == 'macOS' && matrix.use_ccache == 1 + run: brew install ccache + + - name: "Restore compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' id: restore_ccache uses: actions/cache/restore@v6 with: key: ccache-${{ matrix.os }}${{ matrix.target }} path: ${{ env.CCACHE_DIR }} - # No macOS leg runs ccache. A restored macOS cache served 42% of calls and saved - # about 32s on a 20 min leg that is already shorter than the Windows leg, which did - # not pay for its 800M slice of the quota; with nothing restored, the remaining - # intra-run hits are 3% and the hashing is pure overhead on ~1160 compiles, so the - # steps that installed and restored ccache are gone rather than merely disabled. + # Every macOS leg that keeps a cache restores the master entry for its own target. + # Measured on master 2026-11-07 (before the debug-information change), one macOS + # configuration wrote a 0.5 GB ccache directory at -g1 against the 700M bound, and the + # restored cache served 42% of calls. Level 0 shrinks that directory to ~0.4 GB, so the + # bound holds the whole configuration and the three entries fit the quota (see the + # sizing note in build-linux). macOS 14 and 15 build the same Release + package + # configuration for two deployment targets, but each is cheap enough to keep its own. - name: "[macOS] Install aqtinstall" if: matrix.os == 'macOS' @@ -1079,10 +1101,10 @@ jobs: retention-days: 1 # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - - name: "[Windows] Delete remote compiler cache (ccache)" - if: matrix.os == 'Windows' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit + - name: "Delete remote compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true - # Bash, not the runner's default pwsh, which cannot parse this `if`. + # Bash, not the Windows runner's default pwsh, which cannot parse this `if`. shell: bash env: CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -1092,8 +1114,8 @@ jobs: echo "Cache deleted successfully" fi - - name: "[Windows] Cache updated compiler cache (ccache)" - if: matrix.os == 'Windows' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' + - name: "Cache updated compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -1287,7 +1309,7 @@ jobs: # The nightly legs and the pull-request legs that finish under the Windows leg # keep none, so their leftover entries are reclaimed here instead of waiting out # the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Windows10' + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Windows10 ccache-macOS14 ccache-macOS15 ccache-macOS27' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" From 4e05aa0c54406801411715548f0abb30ea1c6921 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Sat, 7 Nov 2026 16:08:45 -0500 Subject: [PATCH 079/104] Refresh the Printing Selector after hidden card and deck changes (#7748) --- .../printing_selector/printing_selector.cpp | 50 +++++++++++++++---- .../printing_selector/printing_selector.h | 8 +++ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index b8438134c..e21bf8a4e 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -21,12 +21,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -54,6 +56,9 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck setLayout(layout); widgetLoadingBufferTimer = new QTimer(this); + deckChangeTimer = new QTimer(this); + deckChangeTimer->setSingleShot(true); + connect(deckChangeTimer, &QTimer::timeout, this, &PrintingSelector::updateDisplay); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); @@ -122,11 +127,36 @@ void PrintingSelector::retranslateUi() void PrintingSelector::printingsInDeckChanged() { if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) { - // Delay the update to avoid race conditions - QTimer::singleShot(100, this, &PrintingSelector::updateDisplay); + displayDirty = true; + if (isVisible()) { + // Coalesce deck changes while the model settles. + deckChangeTimer->start(100); + } else { + widgetLoadingBufferTimer->stop(); + } } } +void PrintingSelector::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + if (displayDirty) { + updateDisplay(); + } +} + +void PrintingSelector::hideEvent(QHideEvent *event) +{ + // A partially built batch must be rebuilt on reveal, not resumed with + // stale printing data after the selected card or deck changes. + if (widgetLoadingBufferTimer->isActive() || deckChangeTimer->isActive()) { + widgetLoadingBufferTimer->stop(); + deckChangeTimer->stop(); + displayDirty = true; + } + QWidget::hideEvent(event); +} + /** * @return A map of uuid to amounts (main, side, tokens). */ @@ -173,6 +203,8 @@ void PrintingSelector::updateCardAmounts() */ void PrintingSelector::updateDisplay() { + deckChangeTimer->stop(); + displayDirty = false; widgetLoadingBufferTimer->stop(); widgetLoadingBufferTimer->deleteLater(); widgetLoadingBufferTimer = new QTimer(this); @@ -199,20 +231,18 @@ void PrintingSelector::updateDisplay() */ void PrintingSelector::setCard(const CardInfoPtr &newCard) { - if (newCard.isNull()) { - selectedCard = newCard; - updateDisplay(); - return; - } - - // we don't need to redraw the widget if the card is the same - if (!selectedCard.isNull() && selectedCard->getName() == newCard->getName()) { + // Same-name cards may be different database snapshots after a reload. + if (selectedCard == newCard) { return; } + widgetLoadingBufferTimer->stop(); + deckChangeTimer->stop(); selectedCard = newCard; if (isVisible()) { updateDisplay(); + } else { + displayDirty = true; } flowWidget->setMinimumSizeToMaxSizeHint(); flowWidget->scrollArea->verticalScrollBar()->setValue(0); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector.h index b9cb27ecd..7b4af95e0 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.h @@ -18,6 +18,8 @@ class QCheckBox; class QHBoxLayout; class QString; class QTimer; +class QShowEvent; +class QHideEvent; class QVBoxLayout; class SettingsButtonWidget; template class QMap; @@ -47,6 +49,10 @@ public: void setCard(const CardInfoPtr &newCard); void getAllSetsForCurrentCard(); +protected: + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + public slots: void retranslateUi(); void updateDisplay(); @@ -88,6 +94,8 @@ private: DeckStateManager *deckStateManager; CardInfoPtr selectedCard; QTimer *widgetLoadingBufferTimer; + QTimer *deckChangeTimer; + bool displayDirty = false; int currentIndex = 0; }; From 6e8c7c806dc04f7b8ee110a67bff967f66844b20 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:12:58 +0100 Subject: [PATCH 080/104] [CI] Harden the hdiutil retry wrapper (#7806) The macOS packaging leg retries hdiutil for actions/runner-images#7522, but it gave up ten seconds after the first failure: shorter than an XProtect scan of the DMG it just wrote. It also re-ran a failed detach unchanged, so a busy mount kept every attempt busy, and a wedged DiskImages helper held the step until the job's 100 minute timeout. - Grow the delay between attempts from 1s to a 30s cap. - Retry a failed detach with -force, and force-detach a stale volume before a create or attach. - Kill a call that exceeds HDIUTIL_TIMEOUT (300s) and give up after two timeouts instead of spending the job budget on a wedged helper. The wrapper still exits non-zero when it gives up, so CPack reports the hdiutil failure where it happens. --- .ci/compile.sh | 85 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/.ci/compile.sh b/.ci/compile.sh index 0307d630c..c58563f61 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -320,8 +320,12 @@ if [[ $RUNNER_OS == macOS ]]; then echo "::endgroup::" if [[ ${MAKE_PACKAGE:-} == "1" ]]; then - # Workaround https://github.com/actions/runner-images/issues/7522 - # have hdiutil repeat the command 10 times in hope of success. + # Workaround https://github.com/actions/runner-images/issues/7522: XProtect holds a + # freshly written DMG for a moment ("Resource busy"), and a mount that outlived a + # failed detach stays busy for the rest of the job. Retry every hdiutil call through + # a wrapper that widens the delay so the scan can finish, retries a detach with + # -force, and kills a wedged DiskImages helper instead of letting it hold the job + # until the 100 minute timeout. # The wrapper has to exit non-zero once it gives up: CPack reads that status to # decide whether the disk image was created, so a zero exit turns a failed hdiutil # into a much later, misleading "Problem copying the package" error that hides @@ -330,17 +334,74 @@ if [[ $RUNNER_OS == macOS ]]; then # shellcheck disable=SC2016 echo '#!/bin/bash attempts=10 - i=0 - while true; do - hdiutil "$@" && exit 0 - rc=$? - if (( ++i >= attempts )); then - echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2 - exit "$rc" + timeout_seconds=${HDIUTIL_TIMEOUT:-300} + subcommand=$1 + # CPACK_DMG_VOLUME_NAME in CMakeLists.txt; a -volname argument wins when there is one. + volume_name=Cockatrice + arg_count=$# + for (( i = 1; i < arg_count; i++ )); do + if [[ ${!i} == -volname ]] && (( i + 1 <= arg_count )); then + (( i++ )) + volume_name=${!i} fi - echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2 - sleep 1 - done' >"$hdiutil_script" + done + + # macOS ships no timeout(1), so a watchdog kills the supervised call. It writes to + # /dev/null because a process holding the output pipe open would make CPack wait for + # the watchdog instead of for hdiutil; it is killed once the call returns. + run_supervised() { + "$@" & + pid=$! + ( sleep "$timeout_seconds" && kill -9 "$pid" ) >/dev/null 2>&1 & + watchdog=$! + wait "$pid" + rc=$? + kill "$watchdog" 2>/dev/null + wait "$watchdog" 2>/dev/null + return "$rc" + } + + force_detach_stale_mount() { + [[ -d /Volumes/$volume_name ]] || return 0 + echo "hdiutil $subcommand: force-detaching stale /Volumes/$volume_name" >&2 + run_supervised hdiutil detach -force "/Volumes/$volume_name" >/dev/null 2>&1 + return 0 + } + + timeouts=0 + attempt=0 + delay=1 + while (( attempt < attempts )); do + (( attempt++ )) + cmd=(hdiutil "$@") + # A busy volume is what a detach fails on, and -force is what clears it. + if (( attempt > 1 )) && [[ $subcommand == detach ]]; then + cmd=(hdiutil detach -force "${@:2}") + fi + run_supervised "${cmd[@]}" + rc=$? + (( rc == 0 )) && exit 0 + if (( rc == 137 )); then + (( timeouts++ )) + echo "hdiutil $subcommand attempt $attempt/$attempts timed out after ${timeout_seconds}s" >&2 + # A wedged DiskImages helper is not the busy-file flake: two kills are all the + # evidence the loop needs before failing is a better use of the job budget. + if (( timeouts >= 2 )); then + echo "::error::hdiutil $subcommand timed out $timeouts times, giving up" >&2 + exit "$rc" + fi + else + echo "hdiutil $subcommand attempt $attempt/$attempts failed (status $rc), retrying" >&2 + fi + (( attempt < attempts )) || break + if [[ $subcommand == create || $subcommand == attach ]]; then + force_detach_stale_mount + fi + sleep "$delay" + (( delay = delay * 2 > 30 ? 30 : delay * 2 )) + done + echo "::error::hdiutil $subcommand failed $attempt times (last status $rc), giving up" >&2 + exit "$rc"' >"$hdiutil_script" chmod +x "$hdiutil_script" flags+=(-DCPACK_COMMAND_HDIUTIL="$hdiutil_script") fi From a1ff047a43a9ff67300dda7960d945f3cf6cc088 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:05:29 +0100 Subject: [PATCH 081/104] [Protocol] Add alt match evidence and search depth (#7509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerInfo_UserAlt gains match_reason, depth, and shared_count so moderators can see why an account was flagged and how far it sits from the investigated user. Command_GetUserAlts gains a depth parameter (default 1) for transitive alt-of-alt searches. No behavior change: server still answers depth 1 and leaves the new fields empty until the follow-up server/client layers land. Co-authored-by: Lukas Brübach --- .../libcockatrice/protocol/pb/moderator_commands.proto | 1 + .../libcockatrice/protocol/pb/serverinfo_user_alt.proto | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 04cb31d98..19aec92c3 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -169,6 +169,7 @@ message Command_GetUserAlts { optional Command_GetUserAlts ext = 1014; } optional string user_name = 1; + optional uint32 depth = 2 [default = 1]; // BFS depth: 1 = direct matches only, up to 3 for transitive alts-of-alts } message Command_GetModeratorLastLogins { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto index 191dd5063..ce5e4fce2 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -13,4 +13,8 @@ message ServerInfo_UserAlt { optional uint32 warn_count = 6; // number of warnings on record optional uint32 ban_count = 7; // number of bans on record optional bool is_active = 8; // account is not deactivated/banned + optional string match_reason = 9; // why flagged, e.g. "email", "clientid:abc", "ip:1.2.3.4"; empty = seed/legacy + optional uint32 depth = 10; // BFS distance from investigated account; 1 = direct, 2+ = alt-of-alt + optional uint32 shared_count = + 11; // distinct users sharing the matched identifier; high values suggest VPN/shared network } From 933db4edd2f31127754d5e4c731efa9c9995eb32 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:14:03 +0100 Subject: [PATCH 082/104] [Server] Recursive alt search with match evidence (#7510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Server] Recursive alt search with match evidence getUserAlts() now walks a breadth-first graph to the requested depth (clamped 1-3): each level expands frontier accounts over registration email, current client id, recent session IPs (6-month window), and historic session client ids, so identifier changes no longer hide older alts. Every row carries match_reason (email / clientid: / ip:), depth, and shared_count fan-out so moderators can distinguish real alts from VPN/shared-network overlap. Command_GetUserAlts depth is plumbed through; unset depth defaults to 1, preserving previous single-hop behavior for old clients. * [Server] Fix alt search review findings - Seed the BFS with the stored user name so the searched account is not re-listed as its own alt at depth 1. - Normalise emails to lower case for the membership and fan-out checks; SQL compares them case-insensitively. - Widen the per-level row limit by the visited count so level 2+ cannot return only already-known accounts. - Prepare dynamic IN-list queries locally instead of through the shared prepared-statement cache, whose keys are query texts. - Count historic client-id fan-out from _sessions, not from the current _users ids. - Order capped identifiers by relevance (current email/client id first, then most recent session IPs) instead of alphabetically. * Apply suggestion from @ZeldaZach Co-authored-by: Zach H * Apply suggestion from @ZeldaZach Co-authored-by: Zach H --------- Co-authored-by: Lukas Brübach Co-authored-by: Zach H --- .../src/servatrice_database_interface.cpp | 443 ++++++++++++++++-- .../src/servatrice_database_interface.h | 2 +- servatrice/src/serversocketinterface.cpp | 3 +- 3 files changed, 396 insertions(+), 52 deletions(-) diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index a4f184e38..c003e5c9d 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -1977,7 +1978,65 @@ QList Servatrice_DatabaseInterface::getUserSessions(cons return results; } -QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName) +namespace +{ +constexpr int MAX_USER_ALT_DEPTH = 3; +constexpr int MAX_USER_ALTS_PER_LEVEL = 200; +// Bounds per-level work and the size of each dynamic IN-list. +// Identifier-heavy frontiers, e.g. VPN users with hundreds of recent IPs, are +// truncated: this tool is deliberately a first-pass sweep, not an audit. +constexpr int MAX_USER_ALT_IDENTIFIERS = 200; + +QString altInClause(const QString &prefix, const QList &values) +{ + QStringList placeholders; + placeholders.reserve(values.size()); + for (int i = 0; i < values.size(); ++i) { + placeholders << prefix + QString::number(i); + } + return QStringLiteral("(") + placeholders.join(QStringLiteral(",")) + QStringLiteral(")"); +} + +void altBindValues(QSqlQuery *query, const QString &prefix, const QList &values) +{ + for (int i = 0; i < values.size(); ++i) { + query->bindValue(prefix + QString::number(i), values.at(i)); + } +} + +struct AltCandidate +{ + QString name; + QString email; + QString clientId; + qint64 registrationTime = 0; + qint64 lastLogin = 0; + bool hasLastLogin = false; + int warnCount = 0; + int banCount = 0; + bool isActive = false; +}; + +// Keeps the first-seen order: callers add the strongest evidence first so the +// cap below drops the least relevant identifiers rather than an arbitrary slice. +void appendIdentifier(QList &list, QSet &seen, const QString &value) +{ + if (!value.isEmpty() && !seen.contains(value)) { + seen.insert(value); + list << value; + } +} + +QList cappedIdentifiers(const QList &values) +{ + if (values.size() <= MAX_USER_ALT_IDENTIFIERS) { + return values; + } + return values.mid(0, MAX_USER_ALT_IDENTIFIERS); +} +} // namespace + +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, int depth) { QList results; @@ -1985,63 +2044,347 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin return results; } - // Seed account identifiers used to find related accounts - QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name"); + if (depth < 1) { + depth = 1; + } else if (depth > MAX_USER_ALT_DEPTH) { + depth = MAX_USER_ALT_DEPTH; + } + + // Seed must exist; its row anchors the result at depth 0. + QSqlQuery *seedQuery = prepareQuery("SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE u.name = :user_name"); seedQuery->bindValue(":user_name", userName); if (!execSqlQuery(seedQuery) || !seedQuery->next()) { return results; } - const QString seedEmail = seedQuery->value(0).toString(); - const QString seedClientId = seedQuery->value(1).toString(); - QString queryString = "SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " - "UNIX_TIMESTAMP(a.last_login), " - "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " - "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " - "u.active " - "FROM {prefix}_users u " - "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " - "WHERE u.name = :user_name"; - if (!seedEmail.isEmpty()) { - queryString.append(" OR u.email = :seed_email"); - } - if (!seedClientId.isEmpty()) { - queryString.append(" OR u.clientid = :seed_clientid"); - } - queryString.append(" OR u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s " - "WHERE s.ip_address IN (SELECT DISTINCT s2.ip_address FROM {prefix}_sessions s2 " - "WHERE s2.user_name = :user_name" - " AND s2.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))" - " AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)) " - "ORDER BY u.name LIMIT 200"); - - QSqlQuery *query = prepareQuery(queryString); - query->bindValue(":user_name", userName); - if (!seedEmail.isEmpty()) { - query->bindValue(":seed_email", seedEmail); - } - if (!seedClientId.isEmpty()) { - query->bindValue(":seed_clientid", seedClientId); - } - - if (!execSqlQuery(query)) { - qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; - return results; - } - - while (query->next()) { + auto appendAlt = [&results](const AltCandidate &candidate, const QString &matchReason, int matchDepth, + int sharedCount) { ServerInfo_UserAlt altDetails; - altDetails.set_user_name(query->value(0).toString().toStdString()); - altDetails.set_email(query->value(1).toString().toStdString()); - altDetails.set_clientid(query->value(2).toString().toStdString()); - altDetails.set_registration_time(query->value(3).toLongLong()); - if (!query->value(4).isNull()) { - altDetails.set_last_login(query->value(4).toLongLong()); + altDetails.set_user_name(candidate.name.toStdString()); + altDetails.set_email(candidate.email.toStdString()); + altDetails.set_clientid(candidate.clientId.toStdString()); + altDetails.set_registration_time(candidate.registrationTime); + if (candidate.hasLastLogin) { + altDetails.set_last_login(candidate.lastLogin); } - altDetails.set_warn_count(query->value(5).toInt()); - altDetails.set_ban_count(query->value(6).toInt()); - altDetails.set_is_active(query->value(7).toBool()); + altDetails.set_warn_count(candidate.warnCount); + altDetails.set_ban_count(candidate.banCount); + altDetails.set_is_active(candidate.isActive); + altDetails.set_match_reason(matchReason.toStdString()); + altDetails.set_depth(static_cast(matchDepth)); + altDetails.set_shared_count(static_cast(sharedCount)); results << altDetails; + }; + + AltCandidate seed{.name = seedQuery->value(0).toString(), + .email = seedQuery->value(1).toString(), + .clientId = seedQuery->value(2).toString(), + .registrationTime = seedQuery->value(3).toLongLong(), + .lastLogin = seedQuery->value(4).toLongLong(), + .hasLastLogin = !seedQuery->value(4).isNull(), + .warnCount = seedQuery->value(5).toInt(), + .banCount = seedQuery->value(6).toInt(), + .isActive = seedQuery->value(7).toBool()}; + appendAlt(seed, QStringLiteral("self"), 0, 1); + + // Identifier lookups are case-insensitive in SQL, so the seed must be keyed by + // its stored casing: otherwise the level-1 query re-lists it as an alt. + QSet visited{seed.name}; + QList frontier{seed.name}; + + // Dynamic IN-lists bypass prepareQuery(): it caches by query text, so every + // distinct list size would leave a prepared statement in the long-lived cache + // and can exhaust MySQL's max_prepared_stmt_count. + const QString dbPrefix = server->getDbPrefix(); + auto prepareDynamicQuery = [&dbPrefix](QSqlQuery &query, const QString &queryText) { + QString text = queryText; + text.replace(QStringLiteral("{prefix}"), dbPrefix); + query.prepare(text); + }; + + for (int level = 1; level <= depth && !frontier.isEmpty(); ++level) { + const QString frontierClause = altInClause(QStringLiteral(":alt_name"), frontier); + + // Current identifiers of the frontier: registration email + client id. + // Kept strongest-first so cappedIdentifiers() drops the least useful ones. + QList emails; + QList clientIds; + QList ips; + QSet emailKeys; + QSet clientIdKeys; + QSet ipKeys; + + QSqlQuery frontierQuery(sqlDatabase); + prepareDynamicQuery(frontierQuery, QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + + frontierClause); + altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&frontierQuery)) { + while (frontierQuery.next()) { + // Email comparison is case-insensitive in SQL, so normalise the + // keys too or the membership tests below miss real matches. + appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower()); + appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString()); + } + } + + // Recent session identifiers: IPs plus historic client ids, most recent + // first so a capped list keeps what the account actually used lately. + QSqlQuery sessionQuery(sqlDatabase); + prepareDynamicQuery(sessionQuery, QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen " + "FROM {prefix}_sessions WHERE user_name IN ") + + frontierClause + + QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " + "GROUP BY ip_address, clientid ORDER BY last_seen DESC")); + altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&sessionQuery)) { + while (sessionQuery.next()) { + appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString()); + appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString()); + } + } + + if (emails.isEmpty() && clientIds.isEmpty() && ips.isEmpty()) { + break; + } + + // Accounts sharing any frontier identifier. Historic client ids are matched + // through past sessions so identifier changes do not hide older alts. + QString candidateString = + QStringLiteral("SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE "); + QStringList conditions; + const QList emailList = cappedIdentifiers(emails); + const QList clientIdList = cappedIdentifiers(clientIds); + const QList ipList = cappedIdentifiers(ips); + if (!emailList.isEmpty()) { + conditions << QStringLiteral("u.email IN ") + altInClause(QStringLiteral(":alt_email"), emailList); + } + if (!clientIdList.isEmpty()) { + conditions << QStringLiteral("u.clientid IN ") + altInClause(QStringLiteral(":alt_cid"), clientIdList); + } + if (!ipList.isEmpty()) { + conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " + "s.ip_address IN ") + + altInClause(QStringLiteral(":alt_ip"), ipList) + + QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"); + } + if (!clientIdList.isEmpty()) { + conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " + "s.clientid IN ") + + altInClause(QStringLiteral(":alt_hcid"), clientIdList) + + QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"); + } + candidateString += conditions.join(QStringLiteral(" OR ")); + // Already-visited accounts match their own identifiers at every level and + // are dropped below, so widen the row limit by that count: otherwise a + // second level can return only visited rows and no new candidates. + candidateString += + QStringLiteral(" ORDER BY u.name LIMIT ") + QString::number(MAX_USER_ALTS_PER_LEVEL + visited.size()); + + QSqlQuery candidateQuery(sqlDatabase); + prepareDynamicQuery(candidateQuery, candidateString); + altBindValues(&candidateQuery, QStringLiteral(":alt_email"), emailList); + altBindValues(&candidateQuery, QStringLiteral(":alt_cid"), clientIdList); + altBindValues(&candidateQuery, QStringLiteral(":alt_ip"), ipList); + altBindValues(&candidateQuery, QStringLiteral(":alt_hcid"), clientIdList); + if (!execSqlQuery(&candidateQuery)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; + return results; + } + + QList candidates; + while (candidateQuery.next()) { + AltCandidate candidate; + candidate.name = candidateQuery.value(0).toString(); + if (visited.contains(candidate.name)) { + continue; + } + candidate.email = candidateQuery.value(1).toString(); + candidate.clientId = candidateQuery.value(2).toString(); + candidate.registrationTime = candidateQuery.value(3).toLongLong(); + candidate.hasLastLogin = !candidateQuery.value(4).isNull(); + candidate.lastLogin = candidateQuery.value(4).toLongLong(); + candidate.warnCount = candidateQuery.value(5).toInt(); + candidate.banCount = candidateQuery.value(6).toInt(); + candidate.isActive = candidateQuery.value(7).toBool(); + candidates << candidate; + if (candidates.size() >= MAX_USER_ALTS_PER_LEVEL) { + break; + } + } + + // Resolve session-level evidence (shared IP or historic client id) for + // candidates that do not match on current email or client id. + QMap candidateIp; + QMap candidateHistoricClientId; + QList unexplained; + for (const AltCandidate &candidate : candidates) { + const bool emailMatch = !candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower()); + const bool clientIdMatch = !candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId); + if (!emailMatch && !clientIdMatch) { + unexplained << candidate.name; + } + } + if (!unexplained.isEmpty() && (!ipKeys.isEmpty() || !clientIdKeys.isEmpty())) { + QString evidenceString = + QStringLiteral("SELECT DISTINCT user_name, ip_address, clientid FROM {prefix}_sessions WHERE " + "user_name IN ") + + altInClause(QStringLiteral(":alt_ev"), unexplained) + QStringLiteral(" AND ("); + QStringList evidenceConditions; + if (!ipList.isEmpty()) { + evidenceConditions << QStringLiteral("ip_address IN ") + + altInClause(QStringLiteral(":alt_eip"), ipList); + } + if (!clientIdList.isEmpty()) { + evidenceConditions << QStringLiteral("clientid IN ") + + altInClause(QStringLiteral(":alt_ecid"), clientIdList); + } + evidenceString += evidenceConditions.join(QStringLiteral(" OR ")); + evidenceString += QStringLiteral(") AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)"); + + QSqlQuery evidenceQuery(sqlDatabase); + prepareDynamicQuery(evidenceQuery, evidenceString); + altBindValues(&evidenceQuery, QStringLiteral(":alt_ev"), unexplained); + altBindValues(&evidenceQuery, QStringLiteral(":alt_eip"), ipList); + altBindValues(&evidenceQuery, QStringLiteral(":alt_ecid"), clientIdList); + if (execSqlQuery(&evidenceQuery)) { + while (evidenceQuery.next()) { + const QString name = evidenceQuery.value(0).toString(); + const QString ip = evidenceQuery.value(1).toString(); + const QString clientId = evidenceQuery.value(2).toString(); + if (!ip.isEmpty() && ipKeys.contains(ip) && !candidateIp.contains(name)) { + candidateIp.insert(name, ip); + } + if (!clientId.isEmpty() && clientIdKeys.contains(clientId) && + !candidateHistoricClientId.contains(name)) { + candidateHistoricClientId.insert(name, clientId); + } + } + } + } + + // Fan-out counts so moderators can spot VPN/shared-network false positives. + QMap emailCounts; + QMap clientIdCounts; + QMap historicClientIdCounts; + QMap ipCounts; + QSet neededEmails; + QSet neededClientIds; + QSet neededHistoricClientIds; + QSet neededIps; + for (const AltCandidate &candidate : candidates) { + if (!candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower())) { + neededEmails << candidate.email.toLower(); + } else if (!candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId)) { + neededClientIds << candidate.clientId; + } else if (candidateHistoricClientId.contains(candidate.name)) { + neededHistoricClientIds << candidateHistoricClientId.value(candidate.name); + } else if (candidateIp.contains(candidate.name)) { + neededIps << candidateIp.value(candidate.name); + } + } + if (!neededEmails.isEmpty()) { + const QList neededEmailList = neededEmails.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, QStringLiteral("SELECT email, COUNT(*) FROM {prefix}_users WHERE " + "email IN ") + + altInClause(QStringLiteral(":alt_ce"), neededEmailList) + + QStringLiteral(" GROUP BY email")); + altBindValues(&countQuery, QStringLiteral(":alt_ce"), neededEmailList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + emailCounts.insert(countQuery.value(0).toString().toLower(), countQuery.value(1).toInt()); + } + } + } + if (!neededClientIds.isEmpty()) { + const QList neededClientIdList = neededClientIds.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, QStringLiteral("SELECT clientid, COUNT(*) FROM {prefix}_users WHERE " + "clientid IN ") + + altInClause(QStringLiteral(":alt_cc"), neededClientIdList) + + QStringLiteral(" GROUP BY clientid")); + altBindValues(&countQuery, QStringLiteral(":alt_cc"), neededClientIdList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + clientIdCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt()); + } + } + } + // Historic client ids are matched through sessions, so their fan-out must + // come from _sessions too: _users only knows each account's current id. + if (!neededHistoricClientIds.isEmpty()) { + const QList neededHistoricClientIdList = neededHistoricClientIds.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, + QStringLiteral("SELECT clientid, COUNT(DISTINCT user_name) FROM {prefix}_sessions " + "WHERE clientid IN ") + + altInClause(QStringLiteral(":alt_hcc"), neededHistoricClientIdList) + + QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " + "GROUP BY clientid")); + altBindValues(&countQuery, QStringLiteral(":alt_hcc"), neededHistoricClientIdList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + historicClientIdCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt()); + } + } + } + if (!neededIps.isEmpty()) { + const QList neededIpList = neededIps.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, + QStringLiteral("SELECT ip_address, COUNT(DISTINCT user_name) FROM {prefix}_sessions " + "WHERE ip_address IN ") + + altInClause(QStringLiteral(":alt_ci"), neededIpList) + + QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " + "GROUP BY ip_address")); + altBindValues(&countQuery, QStringLiteral(":alt_ci"), neededIpList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + ipCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt()); + } + } + } + + QList nextFrontier; + for (const AltCandidate &candidate : candidates) { + QString matchReason; + int sharedCount = 1; + if (!candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower())) { + matchReason = QStringLiteral("email"); + sharedCount = qMax(1, emailCounts.value(candidate.email.toLower(), 1)); + } else if (!candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId)) { + matchReason = QStringLiteral("clientid:") + candidate.clientId; + sharedCount = qMax(1, clientIdCounts.value(candidate.clientId, 1)); + } else if (candidateHistoricClientId.contains(candidate.name)) { + const QString historicId = candidateHistoricClientId.value(candidate.name); + matchReason = QStringLiteral("clientid:") + historicId; + sharedCount = qMax(1, historicClientIdCounts.value(historicId, 1)); + } else if (candidateIp.contains(candidate.name)) { + const QString ip = candidateIp.value(candidate.name); + matchReason = QStringLiteral("ip:") + ip; + sharedCount = qMax(1, ipCounts.value(ip, 1)); + } else { + continue; + } + appendAlt(candidate, matchReason, level, sharedCount); + visited.insert(candidate.name); + nextFrontier << candidate.name; + } + frontier = nextFrontier; } return results; diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index eb74063ac..327906b3c 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -224,7 +224,7 @@ public: const QString &beforeTime = QString(), bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); - QList getUserAlts(const QString &userName); + QList getUserAlts(const QString &userName, int depth = 1); QList getModeratorLastLogins(); // Uptime snapshot as recorded by Servatrice::statusUpdate() into the diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index c38d790b1..b49b94ec0 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2595,7 +2595,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Comma } Response_UserAlts *re = new Response_UserAlts; - const QList alts = sqlInterface->getUserAlts(userName); + const int depth = cmd.has_depth() ? static_cast(cmd.depth()) : 1; + const QList alts = sqlInterface->getUserAlts(userName, depth); for (const ServerInfo_UserAlt &alt : alts) { re->add_alts()->CopyFrom(alt); } From 88de1ff39b305c81c6245b49cae9e2fa07196248 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:15:12 +0100 Subject: [PATCH 083/104] [Client] Transient User Alts investigation tab (#7511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Transient User Alts investigation tab New TabAlts tab with user search, depth selector (1-3), and an alt table showing per-row match evidence (Via, Depth, Shared fan-out) alongside the account data. Sends Command_GetUserAlts with the requested depth and degrades gracefully against old servers (missing evidence fields render as dashes). Wired as a transient single-reuse tab via TabSupervisor::openTabAlts with no menu action or persisted setting; closed on disconnect like the other staff tabs. * Fix remaining review threads on the user alts tab - Switch to the User Alts tab when it is created, so the first investigate click does not open it in the background. - Treat an empty RespOk as "user not found" and use a generic message for error codes, matching what the server actually returns. - Exclude the depth-0 seed row from the related-account count. - Guard the reply with the requested user name so a stale response cannot overwrite a newer one. - Move the duplicated formatEpoch body into widgets/utility/epoch_format.h. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 2 + .../src/interface/widgets/tabs/tab_alts.cpp | 183 ++++++++++++++++++ .../src/interface/widgets/tabs/tab_alts.h | 54 ++++++ .../interface/widgets/tabs/tab_moderation.cpp | 6 +- .../interface/widgets/tabs/tab_supervisor.cpp | 22 ++- .../interface/widgets/tabs/tab_supervisor.h | 3 + .../interface/widgets/utility/epoch_format.h | 27 +++ 7 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 cockatrice/src/interface/widgets/tabs/tab_alts.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tab_alts.h create mode 100644 cockatrice/src/interface/widgets/utility/epoch_format.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index f34961b0b..ca63d1250 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -291,6 +291,7 @@ set(cockatrice_SOURCES src/interface/widgets/settings_page/storage_settings_page.cpp src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp + src/interface/widgets/utility/epoch_format.h src/interface/widgets/utility/get_text_with_max.cpp src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h @@ -384,6 +385,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp + src/interface/widgets/tabs/tab_alts.cpp src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp new file mode 100644 index 000000000..b3b31b8da --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -0,0 +1,183 @@ +#include "tab_alts.h" + +#include "../utility/epoch_format.h" +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/response_user_alts.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ALTS_USER = 0; +constexpr int COL_ALTS_VIA = 1; +constexpr int COL_ALTS_DEPTH = 2; +constexpr int COL_ALTS_SHARED = 3; +constexpr int COL_ALTS_EMAIL = 4; +constexpr int COL_ALTS_CLIENTID = 5; +constexpr int COL_ALTS_REGISTERED = 6; +constexpr int COL_ALTS_LAST_LOGIN = 7; +constexpr int COL_ALTS_WARNS = 8; +constexpr int COL_ALTS_BANS = 9; +constexpr int COL_ALTS_ACTIVE = 10; +constexpr int COL_ALTS_COUNT = 11; + +constexpr int MAX_ALT_DEPTH = 3; +} // namespace + +TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) + : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::returnPressed, this, &TabAlts::investigateUser); + + depthSpin = new QSpinBox; + depthSpin->setMinimum(1); + depthSpin->setMaximum(MAX_ALT_DEPTH); + depthSpin->setValue(1); + + investigateButton = new QPushButton; + connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); + + statusLabel = new QLabel; + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(depthSpin); + topBar->addWidget(investigateButton); + + altsTable = new QTableWidget(0, COL_ALTS_COUNT); + altsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + altsTable->setSelectionMode(QAbstractItemView::SingleSelection); + altsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + altsTable->verticalHeader()->setVisible(false); + altsTable->setAlternatingRowColors(true); + altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + altsTable->horizontalHeader()->setStretchLastSection(true); + + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->addLayout(topBar); + mainLayout->addWidget(statusLabel); + mainLayout->addWidget(altsTable); + + retranslateUi(); + investigate(initialUser); +} + +void TabAlts::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("User name")); + depthSpin->setPrefix(tr("Depth: ")); + depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts.")); + investigateButton->setText(tr("Investigate")); + if (currentUser.isEmpty()) { + statusLabel->setText(tr("Enter a user name to investigate.")); + } + + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), + tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), + tr("Active")}); +} + +QString TabAlts::formatEpoch(quint64 ts) const +{ + return formatEpochTimestamp(ts, tr("Unknown")); +} + +void TabAlts::investigate(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + searchEdit->setText(userName); + investigateUser(); +} + +void TabAlts::investigateUser() +{ + const QString userName = searchEdit->text().simplified(); + if (userName.isEmpty()) { + return; + } + currentUser = userName; + altsTable->setRowCount(0); + statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); + requestAlts(userName, depthSpin->value()); +} + +void TabAlts::requestAlts(const QString &userName, int depth) +{ + Command_GetUserAlts cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_depth(static_cast(depth)); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); + client->sendCommand(pend); +} + +void TabAlts::altsResponse(const Response &response) +{ + altsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser)); + return; + } + + const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + // The server answers RespOk with no rows when the user does not exist. + if (resp.alts_size() == 0) { + statusLabel->setText(tr("User %1 not found.").arg(currentUser)); + return; + } + + altsTable->setRowCount(resp.alts_size()); + int relatedCount = 0; + for (int i = 0; i < resp.alts_size(); ++i) { + const ServerInfo_UserAlt &alt = resp.alts(i); + // The server appends the frontier user as a depth-0 seed row. + if (!alt.has_depth() || alt.depth() > 0) { + ++relatedCount; + } + altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + altsTable->setItem(i, COL_ALTS_VIA, + new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() + ? QString::fromStdString(alt.match_reason()) + : QStringLiteral("-"))); + altsTable->setItem(i, COL_ALTS_DEPTH, + new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); + altsTable->setItem( + i, COL_ALTS_SHARED, + new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); + altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + } + statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser)); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h new file mode 100644 index 000000000..3b83cc4a9 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -0,0 +1,54 @@ +#ifndef TAB_ALTS_H +#define TAB_ALTS_H + +#include "tab.h" + +#include +#include +#include + +class AbstractClient; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QTableWidget; +class Response; +class TabSupervisor; + +/** + * Staff alt-graph investigation tool. Shows accounts related to the + * investigated user (shared email, client id, or recent IP address), + * including transitive alts-of-alts, with per-row match evidence. + */ +class TabAlts : public Tab +{ + Q_OBJECT +public: + explicit TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {}); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("User Alts"); + } + void investigate(const QString &userName); + +private slots: + void investigateUser(); + void altsResponse(const Response &response); + +private: + void requestAlts(const QString &userName, int depth); + [[nodiscard]] QString formatEpoch(quint64 ts) const; + + AbstractClient *client; + QString currentUser; + + QLineEdit *searchEdit; + QSpinBox *depthSpin; + QPushButton *investigateButton; + QLabel *statusLabel; + QTableWidget *altsTable; +}; + +#endif // TAB_ALTS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index be1adeef8..c28217f25 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,5 +1,6 @@ #include "tab_moderation.h" +#include "../utility/epoch_format.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/response.pb.h" @@ -215,10 +216,7 @@ void TabModeration::retranslateUi() QString TabModeration::formatEpoch(quint64 ts) const { - if (ts == 0) { - return tr("Unknown"); - } - return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); + return formatEpochTimestamp(ts, tr("Unknown")); } void TabModeration::clearUserData() diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 45c320852..7647b8ca8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -22,6 +22,7 @@ #include "tab.h" #include "tab_account.h" #include "tab_admin.h" +#include "tab_alts.h" #include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" @@ -157,7 +158,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), - tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) + tabModeration(nullptr), tabAlts(nullptr), tabDeveloper(nullptr), isLocalGame(false) { setObjectName("tabSupervisor"); @@ -575,6 +576,7 @@ void TabSupervisor::startLocal(const QList &_clients) tabLog = nullptr; tabReport = nullptr; tabModeration = nullptr; + tabAlts = nullptr; tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; @@ -623,6 +625,9 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } + if (tabAlts) { + tabAlts->close(); + } if (tabCardArtRules) { tabCardArtRules->close(); } @@ -931,6 +936,21 @@ void TabSupervisor::openTabModeration(const QString &userName) aTabModeration->setChecked(true); } +void TabSupervisor::openTabAlts(const QString &userName) +{ + if (tabAlts) { + setCurrentWidget(tabAlts); + if (!userName.isEmpty()) { + tabAlts->investigate(userName); + } + return; + } + tabAlts = new TabAlts(this, client, userName); + myAddTab(tabAlts); + setCurrentWidget(tabAlts); + connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; }); +} + void TabSupervisor::actTabDeveloper(bool checked) { if (checked && !tabDeveloper) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 9c7dc48d1..d529c5022 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -50,6 +50,7 @@ class TabAdmin; class TabMessage; class TabReport; class TabModeration; +class TabAlts; class TabAccount; class TabDeckEditor; class TabDeveloper; @@ -116,6 +117,7 @@ private: TabLog *tabLog; TabReport *tabReport; TabModeration *tabModeration; + TabAlts *tabAlts; TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; @@ -209,6 +211,7 @@ public slots: /** Joins or spectates a game listed on a user's profile hover card. */ void joinGameFromUserCard(int gameId, int roomId, bool asSpectator); void openTabModeration(const QString &userName = {}); + void openTabAlts(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); void actTabVisualDeckStorage(bool checked); diff --git a/cockatrice/src/interface/widgets/utility/epoch_format.h b/cockatrice/src/interface/widgets/utility/epoch_format.h new file mode 100644 index 000000000..977053997 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/epoch_format.h @@ -0,0 +1,27 @@ +/** + * @file epoch_format.h + * @brief Shared timestamp formatting for the moderation tables. + */ + +#ifndef EPOCH_FORMAT_H +#define EPOCH_FORMAT_H + +#include +#include + +/** + * @brief Format a unix timestamp the way the moderation tabs show dates. + * + * @param ts Seconds since the epoch; 0 means "unknown". + * @param unknownText Label returned for 0, translated by the caller so the + * translation context stays with the tab. + */ +inline QString formatEpochTimestamp(quint64 ts, const QString &unknownText) +{ + if (ts == 0) { + return unknownText; + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")); +} + +#endif // EPOCH_FORMAT_H From 9fda5c3c3fd1192f6afd453363623f720b9cb055 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:15:38 +0100 Subject: [PATCH 084/104] [Client] Alt investigation entry points and in-tab navigation (#7512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TabModeration gains an Open alt graph button plus alts-table double-click, both routing to the User Alts tab. UserContextMenu gains an Investigate alts action. TabAlts gains row double-click navigation with a Back history stack so moderators can walk the alt graph without retyping names. Co-authored-by: Lukas Brübach --- .../widgets/server/user/user_context_menu.cpp | 11 ++++++ .../widgets/server/user/user_context_menu.h | 2 ++ .../src/interface/widgets/tabs/tab_alts.cpp | 35 ++++++++++++++++++- .../src/interface/widgets/tabs/tab_alts.h | 4 +++ .../interface/widgets/tabs/tab_moderation.cpp | 25 +++++++++++++ .../interface/widgets/tabs/tab_moderation.h | 2 ++ 6 files changed, 78 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 447e63673..f61293013 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -83,6 +83,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDemoteFromDeveloper = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); aInvestigateUser = new QAction(QString(), this); + aInvestigateAlts = new QAction(QString(), this); retranslateUi(); } @@ -111,6 +112,7 @@ void UserContextMenu::retranslateUi() aDemoteFromDeveloper->setText(tr("Demote user from de&veloper")); aGetAdminNotes->setText(tr("View admin notes")); aInvestigateUser->setText(tr("Investigate user")); + aInvestigateAlts->setText(tr("Investigate &alts")); } void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer) @@ -454,6 +456,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addSeparator(); menu->addAction(aGetAdminNotes); menu->addAction(aInvestigateUser); + menu->addAction(aInvestigateAlts); menu->addSeparator(); if (userLevel.testFlag(ServerInfo_User::IsModerator) && @@ -499,6 +502,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aBanHistory->setEnabled(anotherUser); aGetAdminNotes->setEnabled(anotherUser); aInvestigateUser->setEnabled(anotherUser); + aInvestigateAlts->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); aPromoteToJudge->setEnabled(anotherUser); @@ -553,6 +557,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execAdminNotes(userName); } else if (actionClicked == aInvestigateUser) { execInvestigateUser(userName); + } else if (actionClicked == aInvestigateAlts) { + execInvestigateAlts(userName); } else if (actionClicked == aCopyToClipBoard) { QClipboard *clipboard = QGuiApplication::clipboard(); clipboard->setText(deckHash); @@ -745,6 +751,11 @@ void UserContextMenu::execInvestigateUser(const QString &userName) tabSupervisor->openTabModeration(userName); } +void UserContextMenu::execInvestigateAlts(const QString &userName) +{ + tabSupervisor->openTabAlts(userName); +} + void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod) { Command_AdjustMod cmd; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 2536819a4..86be5eff2 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -54,6 +54,7 @@ private: QAction *aGetAdminNotes; std::function()> gameInviteLinkProvider; QAction *aInvestigateUser; + QAction *aInvestigateAlts; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -127,6 +128,7 @@ public: void execWarnHistory(const QString &userName); void execAdminNotes(const QString &userName); void execInvestigateUser(const QString &userName); + void execInvestigateAlts(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper); diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index b3b31b8da..c8fa57343 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -58,9 +58,14 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q investigateButton = new QPushButton; connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); + backButton = new QPushButton; + backButton->setEnabled(false); + connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack); + statusLabel = new QLabel; auto *topBar = new QHBoxLayout; + topBar->addWidget(backButton); topBar->addWidget(searchEdit); topBar->addWidget(depthSpin); topBar->addWidget(investigateButton); @@ -73,6 +78,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable->setAlternatingRowColors(true); altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); altsTable->horizontalHeader()->setStretchLastSection(true); + connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { + auto *userItem = altsTable->item(row, COL_ALTS_USER); + if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { + investigate(userItem->text()); + } + }); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); @@ -89,6 +100,8 @@ void TabAlts::retranslateUi() depthSpin->setPrefix(tr("Depth: ")); depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts.")); investigateButton->setText(tr("Investigate")); + backButton->setText(tr("Back")); + backButton->setToolTip(tr("Back to the previously investigated user")); if (currentUser.isEmpty()) { statusLabel->setText(tr("Enter a user name to investigate.")); } @@ -105,9 +118,29 @@ QString TabAlts::formatEpoch(quint64 ts) const void TabAlts::investigate(const QString &userName) { - if (userName.isEmpty()) { + if (userName.isEmpty() || userName == currentUser) { return; } + if (!currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } + loadUser(userName); +} + +void TabAlts::goBack() +{ + if (backStack.isEmpty()) { + return; + } + const QString previousUser = backStack.takeLast(); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(previousUser); +} + +void TabAlts::loadUser(const QString &userName) +{ + currentUser = userName; searchEdit->setText(userName); investigateUser(); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 3b83cc4a9..de5a11ab8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -35,18 +35,22 @@ public: private slots: void investigateUser(); + void goBack(); void altsResponse(const Response &response); private: + void loadUser(const QString &userName); void requestAlts(const QString &userName, int depth); [[nodiscard]] QString formatEpoch(quint64 ts) const; AbstractClient *client; QString currentUser; + QList backStack; QLineEdit *searchEdit; QSpinBox *depthSpin; QPushButton *investigateButton; + QPushButton *backButton; QLabel *statusLabel; QTableWidget *altsTable; }; diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index c28217f25..73b87acaf 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -7,6 +7,7 @@ #include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" +#include "tab_supervisor.h" #include #include @@ -146,7 +147,19 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli altsGroup = new QGroupBox; altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureTable(altsTable); + connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { + auto *userItem = altsTable->item(row, COL_ALTS_USER); + if (userItem != nullptr && !userItem->text().isEmpty()) { + getTabSupervisor()->openTabAlts(userItem->text()); + } + }); + openAltsButton = new QPushButton; + connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph); + auto *altsHeader = new QHBoxLayout; + altsHeader->addStretch(); + altsHeader->addWidget(openAltsButton); auto *altsLayout = new QVBoxLayout(altsGroup); + altsLayout->addLayout(altsHeader); altsLayout->addWidget(altsTable); sessionsGroup = new QGroupBox; @@ -205,6 +218,8 @@ void TabModeration::retranslateUi() userInfoNotesLabel->setText(tr("Admin notes:")); altsGroup->setTitle(tr("Alts")); + openAltsButton->setText(tr("Open alt graph")); + openAltsButton->setToolTip(tr("Open this user in the User Alts tab")); sessionsGroup->setTitle(tr("Sessions")); staffGroup->setTitle(tr("Staff Last Logins")); @@ -233,6 +248,7 @@ void TabModeration::clearUserData() resetPasswordButton->setEnabled(false); removeAvatarButton->setEnabled(false); removeBannerButton->setEnabled(false); + openAltsButton->setEnabled(false); } void TabModeration::investigate(const QString &userName) @@ -254,6 +270,7 @@ void TabModeration::investigateUser() resetPasswordButton->setEnabled(true); removeAvatarButton->setEnabled(true); removeBannerButton->setEnabled(true); + openAltsButton->setEnabled(true); altsTable->setRowCount(0); sessionsTable->setRowCount(0); requestUserInfo(userName); @@ -261,6 +278,14 @@ void TabModeration::investigateUser() requestAlts(userName); } +void TabModeration::openAltsGraph() +{ + if (currentUser.isEmpty()) { + return; + } + getTabSupervisor()->openTabAlts(currentUser); +} + void TabModeration::requestUserInfo(const QString &userName) { userInfoNameValue->setText(userName); diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h index ce23b5a7a..3bd306d18 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.h +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -36,6 +36,7 @@ public: private slots: void investigateUser(); + void openAltsGraph(); void userInfoResponse(const Response &response); void sessionsResponse(const Response &response); void altsResponse(const Response &response); @@ -80,6 +81,7 @@ private: QGroupBox *altsGroup; QTableWidget *altsTable; + QPushButton *openAltsButton; QGroupBox *sessionsGroup; QTableWidget *sessionsTable; QGroupBox *staffGroup; From e603257e085b19daf7da5a8c406e2f9e6b13406e Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:18:01 +0100 Subject: [PATCH 085/104] [Client] Alt tab filters and unified user timeline (#7513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TabAlts gains client-side match filters (Email / Client ID / IP toggles plus a hide-IPs-shared-by-more-than-N-users threshold for VPN noise) with a shown/hidden status count, and a Selected user panel that loads the row account's counts, admin notes, warning history, and ban history in one place. Detail requests are guarded against stale selections. Co-authored-by: Lukas Brübach --- .../src/interface/widgets/tabs/tab_alts.cpp | 326 ++++++++++++++++-- .../src/interface/widgets/tabs/tab_alts.h | 33 ++ 2 files changed, 331 insertions(+), 28 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index c8fa57343..a72d5885e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -4,21 +4,31 @@ #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" -#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include +#include #include +#include +#include #include #include #include #include +#include #include #include #include #include +#include #include #include +#include #include +#include +#include +#include +#include +#include #include #include @@ -38,6 +48,21 @@ constexpr int COL_ALTS_ACTIVE = 10; constexpr int COL_ALTS_COUNT = 11; constexpr int MAX_ALT_DEPTH = 3; +// Starting default only (adjustable 1-100000): shared VPN exits routinely +// cover dozens of users, so 10 hides the obvious VPN noise but keeps +// small shared networks visible. +constexpr int DEFAULT_VPN_THRESHOLD = 10; + +void configureAltTable(QTableWidget *table) +{ + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + table->horizontalHeader()->setStretchLastSection(true); +} } // namespace TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) @@ -70,27 +95,80 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q topBar->addWidget(depthSpin); topBar->addWidget(investigateButton); + emailCheck = new QCheckBox; + emailCheck->setChecked(true); + connect(emailCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + clientIdCheck = new QCheckBox; + clientIdCheck->setChecked(true); + connect(clientIdCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + ipCheck = new QCheckBox; + ipCheck->setChecked(true); + connect(ipCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + vpnLabel = new QLabel; + vpnSpin = new QSpinBox; + vpnSpin->setMinimum(1); + vpnSpin->setMaximum(100000); + vpnSpin->setValue(DEFAULT_VPN_THRESHOLD); + connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onFilterChanged); + + auto *filterBar = new QHBoxLayout; + filterBar->addWidget(emailCheck); + filterBar->addWidget(clientIdCheck); + filterBar->addWidget(ipCheck); + filterBar->addWidget(vpnLabel); + filterBar->addWidget(vpnSpin); + filterBar->addStretch(); + altsTable = new QTableWidget(0, COL_ALTS_COUNT); - altsTable->setSelectionBehavior(QAbstractItemView::SelectRows); - altsTable->setSelectionMode(QAbstractItemView::SingleSelection); - altsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); - altsTable->verticalHeader()->setVisible(false); - altsTable->setAlternatingRowColors(true); - altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - altsTable->horizontalHeader()->setStretchLastSection(true); + configureAltTable(altsTable); connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { auto *userItem = altsTable->item(row, COL_ALTS_USER); if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { investigate(userItem->text()); } }); + connect(altsTable, &QTableWidget::itemSelectionChanged, this, &TabAlts::onAltSelected); + + detailNameValue = new QLabel; + detailCountsValue = new QLabel; + detailNotesEdit = new QTextEdit; + detailNotesEdit->setReadOnly(true); + // Compact cap so the warn/ban tables stay visible without scrolling the tab. + detailNotesEdit->setMaximumHeight(60); + warnsTable = new QTableWidget(0, 3); + configureAltTable(warnsTable); + bansTable = new QTableWidget(0, 4); + configureAltTable(bansTable); + + auto *detailGrid = new QGridLayout; + detailNameLabel = new QLabel; + detailCountsLabel = new QLabel; + detailNotesLabel = new QLabel; + detailGrid->addWidget(detailNameLabel, 0, 0); + detailGrid->addWidget(detailNameValue, 0, 1); + detailGrid->addWidget(detailCountsLabel, 0, 2); + detailGrid->addWidget(detailCountsValue, 0, 3); + detailGrid->addWidget(detailNotesLabel, 1, 0); + detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3); + + auto *detailTables = new QHBoxLayout; + detailTables->addWidget(warnsTable); + detailTables->addWidget(bansTable); + + detailsGroup = new QGroupBox; + auto *detailsLayout = new QVBoxLayout(detailsGroup); + detailsLayout->addLayout(detailGrid); + detailsLayout->addLayout(detailTables); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); + mainLayout->addLayout(filterBar); mainLayout->addWidget(statusLabel); - mainLayout->addWidget(altsTable); + mainLayout->addWidget(altsTable, 3); + mainLayout->addWidget(detailsGroup, 2); retranslateUi(); + clearDetails(); investigate(initialUser); } @@ -106,9 +184,22 @@ void TabAlts::retranslateUi() statusLabel->setText(tr("Enter a user name to investigate.")); } + emailCheck->setText(tr("Email")); + clientIdCheck->setText(tr("Client ID")); + ipCheck->setText(tr("IP")); + vpnLabel->setText(tr("Hide IPs shared by more than")); + vpnSpin->setSuffix(tr(" users")); + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); + warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")}); + bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")}); + + detailsGroup->setTitle(tr("Selected user")); + detailNameLabel->setText(tr("Name:")); + detailCountsLabel->setText(tr("Counts:")); + detailNotesLabel->setText(tr("Admin notes:")); } QString TabAlts::formatEpoch(quint64 ts) const @@ -152,6 +243,9 @@ void TabAlts::investigateUser() return; } currentUser = userName; + lastAlts.clear(); + selectedUser.clear(); + clearDetails(); altsTable->setRowCount(0); statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); requestAlts(userName, depthSpin->value()); @@ -173,7 +267,7 @@ void TabAlts::requestAlts(const QString &userName, int depth) void TabAlts::altsResponse(const Response &response) { - altsTable->setRowCount(0); + lastAlts.clear(); if (response.response_code() != Response::RespOk) { statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser)); return; @@ -186,31 +280,207 @@ void TabAlts::altsResponse(const Response &response) return; } - altsTable->setRowCount(resp.alts_size()); - int relatedCount = 0; for (int i = 0; i < resp.alts_size(); ++i) { - const ServerInfo_UserAlt &alt = resp.alts(i); - // The server appends the frontier user as a depth-0 seed row. - if (!alt.has_depth() || alt.depth() > 0) { - ++relatedCount; + lastAlts << resp.alts(i); + } + renderTable(); +} + +bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const +{ + const QString name = QString::fromStdString(alt.user_name()); + if (name == currentUser) { + return true; + } + if (!alt.has_match_reason() || alt.match_reason().empty()) { + return true; + } + const QString reason = QString::fromStdString(alt.match_reason()); + if (reason == QStringLiteral("email")) { + return emailCheck->isChecked(); + } + if (reason.startsWith(QStringLiteral("clientid:"))) { + return clientIdCheck->isChecked(); + } + if (reason.startsWith(QStringLiteral("ip:"))) { + return ipCheck->isChecked() && + (!alt.has_shared_count() || alt.shared_count() <= static_cast(vpnSpin->value())); + } + return true; +} + +void TabAlts::renderTable() +{ + altsTable->setRowCount(0); + int hidden = 0; + int row = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + if (!rowVisible(alt)) { + ++hidden; + continue; } - altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); - altsTable->setItem(i, COL_ALTS_VIA, + altsTable->insertRow(row); + altsTable->setItem(row, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + altsTable->setItem(row, COL_ALTS_VIA, new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() ? QString::fromStdString(alt.match_reason()) : QStringLiteral("-"))); - altsTable->setItem(i, COL_ALTS_DEPTH, + altsTable->setItem(row, COL_ALTS_DEPTH, new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); altsTable->setItem( - i, COL_ALTS_SHARED, + row, COL_ALTS_SHARED, new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); - altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); - altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); - altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); - altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); - altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); - altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); - altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + ++row; + } + // The server appends the frontier user as a depth-0 seed row; it is the + // investigated user, not one of their related accounts. + int relatedCount = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + if (!alt.has_depth() || alt.depth() > 0) { + ++relatedCount; + } + } + if (hidden == 0) { + statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser)); + } else { + statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.") + .arg(row) + .arg(hidden) + .arg(relatedCount) + .arg(currentUser)); + } +} + +void TabAlts::onFilterChanged() +{ + if (lastAlts.isEmpty()) { + return; + } + renderTable(); +} + +void TabAlts::onAltSelected() +{ + const auto selected = altsTable->selectedItems(); + if (selected.isEmpty()) { + selectedUser.clear(); + clearDetails(); + return; + } + auto *userItem = altsTable->item(selected.first()->row(), COL_ALTS_USER); + if (userItem == nullptr || userItem->text().isEmpty() || userItem->text() == selectedUser) { + return; + } + selectedUser = userItem->text(); + requestDetails(selectedUser); +} + +void TabAlts::requestDetails(const QString &userName) +{ + detailNameValue->setText(userName); + detailCountsValue->setText(tr("Loading...")); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); + + Command_ReportUserInfo infoCmd; + infoCmd.set_user_name(userName.toStdString()); + PendingCommand *infoPend = client->prepareModeratorCommand(infoCmd); + connect(infoPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + userDetailsResponse(response); + } + }); + client->sendCommand(infoPend); + + Command_GetWarnHistory warnCmd; + warnCmd.set_user_name(userName.toStdString()); + PendingCommand *warnPend = client->prepareModeratorCommand(warnCmd); + connect(warnPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + warnHistoryResponse(response); + } + }); + client->sendCommand(warnPend); + + Command_GetBanHistory banCmd; + banCmd.set_user_name(userName.toStdString()); + PendingCommand *banPend = client->prepareModeratorCommand(banCmd); + connect(banPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + banHistoryResponse(response); + } + }); + client->sendCommand(banPend); +} + +void TabAlts::clearDetails() +{ + detailNameValue->clear(); + detailCountsValue->clear(); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); +} + +void TabAlts::userDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + detailCountsValue->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + detailCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") + .arg(resp.total_reports()) + .arg(resp.total_bans()) + .arg(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + detailNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + detailNotesEdit->setPlainText(tr("(none)")); + } +} + +void TabAlts::warnHistoryResponse(const Response &response) +{ + warnsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_WarnHistory &resp = response.GetExtension(Response_WarnHistory::ext); + warnsTable->setRowCount(resp.warn_list_size()); + for (int i = 0; i < resp.warn_list_size(); ++i) { + const ServerInfo_Warning &warn = resp.warn_list(i); + warnsTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(warn.time_of()))); + warnsTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(warn.admin_name()))); + warnsTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(warn.reason()))); + } +} + +void TabAlts::banHistoryResponse(const Response &response) +{ + bansTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_BanHistory &resp = response.GetExtension(Response_BanHistory::ext); + bansTable->setRowCount(resp.ban_list_size()); + for (int i = 0; i < resp.ban_list_size(); ++i) { + const ServerInfo_Ban &ban = resp.ban_list(i); + bansTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(ban.ban_time()))); + bansTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(ban.admin_name()))); + bansTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(ban.ban_length()))); + bansTable->setItem(i, 3, new QTableWidgetItem(QString::fromStdString(ban.ban_reason()))); } - statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser)); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index de5a11ab8..65833f90d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -3,16 +3,20 @@ #include "tab.h" +#include #include #include +#include #include class AbstractClient; +class QCheckBox; class QLabel; class QLineEdit; class QPushButton; class QSpinBox; class QTableWidget; +class QTextEdit; class Response; class TabSupervisor; @@ -20,6 +24,7 @@ class TabSupervisor; * Staff alt-graph investigation tool. Shows accounts related to the * investigated user (shared email, client id, or recent IP address), * including transitive alts-of-alts, with per-row match evidence. + * Selecting a row loads that account's warnings, bans, and admin notes. */ class TabAlts : public Tab { @@ -37,15 +42,26 @@ private slots: void investigateUser(); void goBack(); void altsResponse(const Response &response); + void onFilterChanged(); + void onAltSelected(); + void userDetailsResponse(const Response &response); + void warnHistoryResponse(const Response &response); + void banHistoryResponse(const Response &response); private: void loadUser(const QString &userName); void requestAlts(const QString &userName, int depth); + void requestDetails(const QString &userName); + void clearDetails(); + void renderTable(); + [[nodiscard]] bool rowVisible(const ServerInfo_UserAlt &alt) const; [[nodiscard]] QString formatEpoch(quint64 ts) const; AbstractClient *client; QString currentUser; + QString selectedUser; QList backStack; + QList lastAlts; QLineEdit *searchEdit; QSpinBox *depthSpin; @@ -53,6 +69,23 @@ private: QPushButton *backButton; QLabel *statusLabel; QTableWidget *altsTable; + + QCheckBox *emailCheck; + QCheckBox *clientIdCheck; + QCheckBox *ipCheck; + QLabel *vpnLabel; + QSpinBox *vpnSpin; + + QLabel *detailNameValue; + QLabel *detailCountsValue; + QTextEdit *detailNotesEdit; + QTableWidget *warnsTable; + QTableWidget *bansTable; + + QGroupBox *detailsGroup; + QLabel *detailNameLabel; + QLabel *detailCountsLabel; + QLabel *detailNotesLabel; }; #endif // TAB_ALTS_H From 18f9b26a3d9ec285c90df7952f2b105f051de7b1 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:59:50 +0100 Subject: [PATCH 086/104] [Client] Report queue dashboard and status pills (#7515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Moderation and alts dashboards Turn the two staff tables into dashboards without behavior changes: TabModeration gets an identity header (display-size name, status, registered/last-login subline), Reports/Warnings/Bans/Alts count cards with an Open-graph deep link, full-width admin notes, and Alts/Sessions/Staff tabbed sections with tab memory. TabAlts gets toggle chips with match counts, bold usernames, palette-token dimming of shared-IP rows, and a resizable side card for the selected-user timeline with selection restore. Row activation works via Enter as well as double-click; async responses are guarded against stale investigations and retranslation re-applies cached dashboard data. * [Client] Report queue dashboard and status pills TabReport becomes a dashboard: selected-report identity header (display-size user, bold status, category/time/room subline), Description/Chat/Comments/Reported-user tabbed sections with tab memory, always-visible user context with empty cues, and bold reported users. ReportStatusDelegate paints palette-tinted status pills with paint-through selection, translated status strings, and font/elision handling. report_utils gains formatReportStatus plus an opt-out status-color flag so the delegate owns color in the mod queue while the user dialog keeps its colors. Async responses stay guarded; retranslation re-applies header and comment states. * Fix remaining review threads on the report queue dashboard - Read the raw report status from Qt::UserRole in DlgMyReports, so regular users can comment on their own open reports again. - Exclude the server's depth-0 seed row from the related-alt count. - Dim only IP matches shared by more than the pair, not every IP match. - Activate alts rows on double-click or Enter instead of `activated`, which fires on a single click on single-click platforms. - Fold the reported-user bolding pass into the fill loop. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 3 + .../widgets/dialogs/dlg_my_reports.cpp | 3 +- .../widgets/tabs/report_status_delegate.cpp | 77 ++++++ .../widgets/tabs/report_status_delegate.h | 22 ++ .../src/interface/widgets/tabs/tab_alts.cpp | 158 +++++++++---- .../src/interface/widgets/tabs/tab_alts.h | 8 +- .../interface/widgets/tabs/tab_moderation.cpp | 219 ++++++++++++------ .../interface/widgets/tabs/tab_moderation.h | 30 ++- .../src/interface/widgets/tabs/tab_report.cpp | 159 ++++++++++--- .../src/interface/widgets/tabs/tab_report.h | 15 +- .../widgets/utility/report_utils.cpp | 28 ++- .../interface/widgets/utility/report_utils.h | 4 +- .../widgets/utility/table_activation.cpp | 28 +++ .../widgets/utility/table_activation.h | 32 +++ 14 files changed, 615 insertions(+), 171 deletions(-) create mode 100644 cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/report_status_delegate.h create mode 100644 cockatrice/src/interface/widgets/utility/table_activation.cpp create mode 100644 cockatrice/src/interface/widgets/utility/table_activation.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ca63d1250..c68f82982 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -295,6 +295,8 @@ set(cockatrice_SOURCES src/interface/widgets/utility/get_text_with_max.cpp src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h + src/interface/widgets/utility/table_activation.cpp + src/interface/widgets/utility/table_activation.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h @@ -382,6 +384,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp + src/interface/widgets/tabs/report_status_delegate.cpp src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index a7387a068..0f429677a 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -230,7 +230,8 @@ void DlgMyReports::onSelectionChanged() connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); client->sendCommand(pend); - QString status = table->item(row, COL_STATUS)->text(); + // The cell text is the translated label; the raw status lives in UserRole. + const QString status = table->item(row, COL_STATUS)->data(Qt::UserRole).toString(); bool canComment = (status == "open" || status == "assigned"); commentButton->setEnabled(canComment); commentInput->setEnabled(canComment); diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp new file mode 100644 index 000000000..fe59702c4 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -0,0 +1,77 @@ +#include "report_status_delegate.h" + +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +constexpr int PILL_PAD_X = 10; +constexpr int PILL_PAD_Y = 4; +} // namespace + +ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent) +{ +} + +void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + QString status = index.data(Qt::UserRole).toString(); + if (status.isEmpty()) { + status = index.data(Qt::DisplayRole).toString(); + } + + QPalette::ColorRole tintRole = QPalette::NoRole; + if (status == QStringLiteral("open")) { + tintRole = QPalette::Highlight; + } else if (status == QStringLiteral("assigned")) { + tintRole = QPalette::Link; + } else if (status == QStringLiteral("resolved")) { + tintRole = QPalette::PlaceholderText; + } + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + + if (tintRole == QPalette::NoRole) { + const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::PlaceholderText; + QStyle *fallbackStyle = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + fallbackStyle->drawItemText(painter, opt.rect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, opt.text, + textRole); + return; + } + + const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + QStyledItemDelegate::paint(painter, opt, index); + return; + } + + if (!selected) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = opt.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + const QString elided = + opt.fontMetrics.elidedText(opt.text.isEmpty() ? status : opt.text, opt.textElideMode, pillRect.width()); + const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText; + QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + style->drawItemText(painter, pillRect, Qt::AlignCenter, opt.palette, true, elided, textRole); +} + +QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const QSize hint = QStyledItemDelegate::sizeHint(option, index); + return hint + QSize(PILL_PAD_X, PILL_PAD_Y); +} diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.h b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h new file mode 100644 index 000000000..5c168bcd6 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h @@ -0,0 +1,22 @@ +#ifndef REPORT_STATUS_DELEGATE_H +#define REPORT_STATUS_DELEGATE_H + +#include +#include + +/** + * Status pill for the report queue table. Paints the status text on a + * translucent badge tinted with a palette role per status, so the colors + * always follow the active theme. Falls back to default painting for + * selected rows and unknown statuses. + */ +class ReportStatusDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit ReportStatusDelegate(QObject *parent = nullptr); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; +}; + +#endif // REPORT_STATUS_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index a72d5885e..da08a0b56 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -1,22 +1,25 @@ #include "tab_alts.h" #include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" #include -#include #include #include #include #include #include +#include #include #include #include #include +#include #include +#include #include #include #include @@ -47,6 +50,17 @@ constexpr int COL_ALTS_BANS = 9; constexpr int COL_ALTS_ACTIVE = 10; constexpr int COL_ALTS_COUNT = 11; +constexpr int COL_WARNS_TIME = 0; +constexpr int COL_WARNS_MODERATOR = 1; +constexpr int COL_WARNS_REASON = 2; +constexpr int COL_WARNS_COUNT = 3; + +constexpr int COL_BANS_TIME = 0; +constexpr int COL_BANS_MODERATOR = 1; +constexpr int COL_BANS_LENGTH = 2; +constexpr int COL_BANS_REASON = 3; +constexpr int COL_BANS_COUNT = 4; + constexpr int MAX_ALT_DEPTH = 3; // Starting default only (adjustable 1-100000): shared VPN exits routinely // cover dozens of users, so 10 hides the obvious VPN noise but keeps @@ -95,15 +109,16 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q topBar->addWidget(depthSpin); topBar->addWidget(investigateButton); - emailCheck = new QCheckBox; - emailCheck->setChecked(true); - connect(emailCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); - clientIdCheck = new QCheckBox; - clientIdCheck->setChecked(true); - connect(clientIdCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); - ipCheck = new QCheckBox; - ipCheck->setChecked(true); - connect(ipCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + auto makeChip = [this](const QString &text) { + auto *chip = new QPushButton(text); + chip->setCheckable(true); + chip->setChecked(true); + connect(chip, &QPushButton::toggled, this, &TabAlts::onFilterChanged); + return chip; + }; + emailChip = makeChip(QString()); + clientIdChip = makeChip(QString()); + ipChip = makeChip(QString()); vpnLabel = new QLabel; vpnSpin = new QSpinBox; vpnSpin->setMinimum(1); @@ -112,17 +127,20 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onFilterChanged); auto *filterBar = new QHBoxLayout; - filterBar->addWidget(emailCheck); - filterBar->addWidget(clientIdCheck); - filterBar->addWidget(ipCheck); + filterBar->addWidget(emailChip); + filterBar->addWidget(clientIdChip); + filterBar->addWidget(ipChip); filterBar->addWidget(vpnLabel); filterBar->addWidget(vpnSpin); filterBar->addStretch(); altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); - connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { - auto *userItem = altsTable->item(row, COL_ALTS_USER); + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { + if (!index.isValid()) { + return; + } + auto *userItem = altsTable->item(index.row(), COL_ALTS_USER); if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { investigate(userItem->text()); } @@ -135,9 +153,9 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q detailNotesEdit->setReadOnly(true); // Compact cap so the warn/ban tables stay visible without scrolling the tab. detailNotesEdit->setMaximumHeight(60); - warnsTable = new QTableWidget(0, 3); + warnsTable = new QTableWidget(0, COL_WARNS_COUNT); configureAltTable(warnsTable); - bansTable = new QTableWidget(0, 4); + bansTable = new QTableWidget(0, COL_BANS_COUNT); configureAltTable(bansTable); auto *detailGrid = new QGridLayout; @@ -151,21 +169,24 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q detailGrid->addWidget(detailNotesLabel, 1, 0); detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3); - auto *detailTables = new QHBoxLayout; - detailTables->addWidget(warnsTable); - detailTables->addWidget(bansTable); - detailsGroup = new QGroupBox; auto *detailsLayout = new QVBoxLayout(detailsGroup); detailsLayout->addLayout(detailGrid); - detailsLayout->addLayout(detailTables); + detailsLayout->addWidget(warnsTable, 1); + detailsLayout->addWidget(bansTable, 1); + + auto *contentSplitter = new QSplitter(Qt::Horizontal); + contentSplitter->addWidget(altsTable); + contentSplitter->addWidget(detailsGroup); + contentSplitter->setStretchFactor(0, 3); + contentSplitter->setStretchFactor(1, 2); + detailsGroup->setMinimumWidth(260); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); mainLayout->addLayout(filterBar); mainLayout->addWidget(statusLabel); - mainLayout->addWidget(altsTable, 3); - mainLayout->addWidget(detailsGroup, 2); + mainLayout->addWidget(contentSplitter, 1); retranslateUi(); clearDetails(); @@ -184,15 +205,19 @@ void TabAlts::retranslateUi() statusLabel->setText(tr("Enter a user name to investigate.")); } - emailCheck->setText(tr("Email")); - clientIdCheck->setText(tr("Client ID")); - ipCheck->setText(tr("IP")); + emailChip->setText(tr("Email")); + emailChip->setToolTip(tr("Show or hide email matches")); + clientIdChip->setText(tr("Client ID")); + clientIdChip->setToolTip(tr("Show or hide client ID matches")); + ipChip->setText(tr("IP")); + ipChip->setToolTip(tr("Show or hide IP address matches")); vpnLabel->setText(tr("Hide IPs shared by more than")); vpnSpin->setSuffix(tr(" users")); altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); + altsTable->setToolTip(tr("Double-click or press Enter on a row to investigate that account")); warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")}); bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")}); @@ -200,6 +225,10 @@ void TabAlts::retranslateUi() detailNameLabel->setText(tr("Name:")); detailCountsLabel->setText(tr("Counts:")); detailNotesLabel->setText(tr("Admin notes:")); + + if (!lastAlts.isEmpty()) { + renderTable(); + } } QString TabAlts::formatEpoch(quint64 ts) const @@ -297,13 +326,13 @@ bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const } const QString reason = QString::fromStdString(alt.match_reason()); if (reason == QStringLiteral("email")) { - return emailCheck->isChecked(); + return emailChip->isChecked(); } if (reason.startsWith(QStringLiteral("clientid:"))) { - return clientIdCheck->isChecked(); + return clientIdChip->isChecked(); } if (reason.startsWith(QStringLiteral("ip:"))) { - return ipCheck->isChecked() && + return ipChip->isChecked() && (!alt.has_shared_count() || alt.shared_count() <= static_cast(vpnSpin->value())); } return true; @@ -311,7 +340,32 @@ bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const void TabAlts::renderTable() { + // Blocked so the programmatic refill neither wipes the details panel + // (via the selection-cleared path) nor refetches them on restore. + const QSignalBlocker blocker(altsTable); altsTable->setRowCount(0); + + int emailCount = 0; + int clientIdCount = 0; + int ipCount = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + const QString reason = QString::fromStdString(alt.match_reason()); + if (reason == QStringLiteral("email")) { + ++emailCount; + } else if (reason.startsWith(QStringLiteral("clientid:"))) { + ++clientIdCount; + } else if (reason.startsWith(QStringLiteral("ip:"))) { + ++ipCount; + } + } + emailChip->setText(tr("Email (%1)").arg(emailCount)); + clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount)); + ipChip->setText(tr("IP (%1)").arg(ipCount)); + + QFont userFont = altsTable->font(); + userFont.setBold(true); + const QColor dimColor = palette().color(QPalette::Disabled, QPalette::Text); + int hidden = 0; int row = 0; for (const ServerInfo_UserAlt &alt : lastAlts) { @@ -320,7 +374,9 @@ void TabAlts::renderTable() continue; } altsTable->insertRow(row); - altsTable->setItem(row, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + auto *userItem = new QTableWidgetItem(QString::fromStdString(alt.user_name())); + userItem->setFont(userFont); + altsTable->setItem(row, COL_ALTS_USER, userItem); altsTable->setItem(row, COL_ALTS_VIA, new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() ? QString::fromStdString(alt.match_reason()) @@ -337,6 +393,14 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && + alt.shared_count() > 2) { + for (int col = 0; col < COL_ALTS_COUNT; ++col) { + if (auto *item = altsTable->item(row, col)) { + item->setForeground(dimColor); + } + } + } ++row; } // The server appends the frontier user as a depth-0 seed row; it is the @@ -356,6 +420,22 @@ void TabAlts::renderTable() .arg(relatedCount) .arg(currentUser)); } + + if (!selectedUser.isEmpty()) { + bool restored = false; + for (int restoreRow = 0; restoreRow < altsTable->rowCount(); ++restoreRow) { + auto *userItem = altsTable->item(restoreRow, COL_ALTS_USER); + if (userItem != nullptr && userItem->text() == selectedUser) { + altsTable->setCurrentItem(userItem, QItemSelectionModel::Select | QItemSelectionModel::Rows); + restored = true; + break; + } + } + if (!restored) { + selectedUser.clear(); + clearDetails(); + } + } } void TabAlts::onFilterChanged() @@ -424,7 +504,7 @@ void TabAlts::requestDetails(const QString &userName) void TabAlts::clearDetails() { detailNameValue->clear(); - detailCountsValue->clear(); + detailCountsValue->setText(tr("Select a row to inspect its history.")); detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); @@ -461,9 +541,9 @@ void TabAlts::warnHistoryResponse(const Response &response) warnsTable->setRowCount(resp.warn_list_size()); for (int i = 0; i < resp.warn_list_size(); ++i) { const ServerInfo_Warning &warn = resp.warn_list(i); - warnsTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(warn.time_of()))); - warnsTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(warn.admin_name()))); - warnsTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(warn.reason()))); + warnsTable->setItem(i, COL_WARNS_TIME, new QTableWidgetItem(QString::fromStdString(warn.time_of()))); + warnsTable->setItem(i, COL_WARNS_MODERATOR, new QTableWidgetItem(QString::fromStdString(warn.admin_name()))); + warnsTable->setItem(i, COL_WARNS_REASON, new QTableWidgetItem(QString::fromStdString(warn.reason()))); } } @@ -478,9 +558,9 @@ void TabAlts::banHistoryResponse(const Response &response) bansTable->setRowCount(resp.ban_list_size()); for (int i = 0; i < resp.ban_list_size(); ++i) { const ServerInfo_Ban &ban = resp.ban_list(i); - bansTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(ban.ban_time()))); - bansTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(ban.admin_name()))); - bansTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(ban.ban_length()))); - bansTable->setItem(i, 3, new QTableWidgetItem(QString::fromStdString(ban.ban_reason()))); + bansTable->setItem(i, COL_BANS_TIME, new QTableWidgetItem(QString::fromStdString(ban.ban_time()))); + bansTable->setItem(i, COL_BANS_MODERATOR, new QTableWidgetItem(QString::fromStdString(ban.admin_name()))); + bansTable->setItem(i, COL_BANS_LENGTH, new QTableWidgetItem(QString::fromStdString(ban.ban_length()))); + bansTable->setItem(i, COL_BANS_REASON, new QTableWidgetItem(QString::fromStdString(ban.ban_reason()))); } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 65833f90d..8c0086f25 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -10,7 +10,7 @@ #include class AbstractClient; -class QCheckBox; +class QGroupBox; class QLabel; class QLineEdit; class QPushButton; @@ -70,9 +70,9 @@ private: QLabel *statusLabel; QTableWidget *altsTable; - QCheckBox *emailCheck; - QCheckBox *clientIdCheck; - QCheckBox *ipCheck; + QPushButton *emailChip; + QPushButton *clientIdChip; + QPushButton *ipChip; QLabel *vpnLabel; QSpinBox *vpnSpin; diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 73b87acaf..8568b0d2e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,6 +1,7 @@ #include "tab_moderation.h" #include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/response.pb.h" @@ -23,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -101,38 +103,67 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli topBar->addWidget(removeBannerButton); userInfoGroup = new QGroupBox; - userInfoNameLabel = new QLabel; userInfoNameValue = new QLabel; - userInfoRegisteredLabel = new QLabel; - userInfoRegisteredValue = new QLabel; - userInfoLastLoginLabel = new QLabel; - userInfoLastLoginValue = new QLabel; - userInfoStatusLabel = new QLabel; + QFont displayFont = userInfoNameValue->font(); + displayFont.setPointSize(displayFont.pointSize() + 6); + displayFont.setBold(true); + userInfoNameValue->setFont(displayFont); userInfoStatusValue = new QLabel; - userInfoCountsLabel = new QLabel; - userInfoCountsValue = new QLabel; + QFont statusFont = userInfoStatusValue->font(); + statusFont.setBold(true); + userInfoStatusValue->setFont(statusFont); + sublineLabel = new QLabel; + + auto makeCountCard = [](const QString &title, QLabel *&value) { + auto *card = new QGroupBox(title); + value = new QLabel(QStringLiteral("–"), card); + QFont cardFont = value->font(); + cardFont.setPointSize(cardFont.pointSize() + 6); + cardFont.setBold(true); + value->setFont(cardFont); + value->setAlignment(Qt::AlignCenter); + auto *cardLayout = new QVBoxLayout(card); + cardLayout->addWidget(value); + return card; + }; + cardReports = makeCountCard(QString(), cardReportsValue); + cardWarns = makeCountCard(QString(), cardWarnsValue); + cardBans = makeCountCard(QString(), cardBansValue); + + altsCard = new QGroupBox; + altsCountValue = new QLabel(QStringLiteral("–"), altsCard); + QFont altsFont = altsCountValue->font(); + altsFont.setPointSize(altsFont.pointSize() + 6); + altsFont.setBold(true); + altsCountValue->setFont(altsFont); + altsCountValue->setAlignment(Qt::AlignCenter); + openAltsButton = new QPushButton; + connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph); + auto *altsCardLayout = new QVBoxLayout(altsCard); + altsCardLayout->addWidget(altsCountValue); + altsCardLayout->addWidget(openAltsButton); + + auto *cardsRow = new QHBoxLayout; + cardsRow->addWidget(cardReports); + cardsRow->addWidget(cardWarns); + cardsRow->addWidget(cardBans); + cardsRow->addWidget(altsCard); + userInfoNotesLabel = new QLabel; userInfoNotesEdit = new QTextEdit; userInfoNotesEdit->setReadOnly(true); - auto *infoGrid = new QGridLayout; - infoGrid->addWidget(userInfoNameLabel, 0, 0); - infoGrid->addWidget(userInfoNameValue, 0, 1); - infoGrid->addWidget(userInfoRegisteredLabel, 0, 2); - infoGrid->addWidget(userInfoRegisteredValue, 0, 3); - infoGrid->addWidget(userInfoLastLoginLabel, 1, 0); - infoGrid->addWidget(userInfoLastLoginValue, 1, 1); - infoGrid->addWidget(userInfoStatusLabel, 1, 2); - infoGrid->addWidget(userInfoStatusValue, 1, 3); - infoGrid->addWidget(userInfoCountsLabel, 2, 0); - infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3); - infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop); - infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3); - infoGrid->setColumnStretch(1, 1); - infoGrid->setColumnStretch(3, 1); + auto *identityRow = new QHBoxLayout; + identityRow->addWidget(userInfoNameValue); + identityRow->addStretch(); + identityRow->addWidget(userInfoStatusValue); auto *infoLayout = new QVBoxLayout(userInfoGroup); - infoLayout->addLayout(infoGrid); + infoLayout->addLayout(identityRow); + infoLayout->addWidget(sublineLabel); + infoLayout->addLayout(cardsRow); + infoLayout->addWidget(userInfoNotesLabel); + infoLayout->addWidget(userInfoNotesEdit); auto configureTable = [](QTableWidget *table) { table->setSelectionBehavior(QAbstractItemView::SelectRows); @@ -144,31 +175,27 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli table->horizontalHeader()->setStretchLastSection(true); }; - altsGroup = new QGroupBox; altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureTable(altsTable); - connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { - auto *userItem = altsTable->item(row, COL_ALTS_USER); + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { + if (!index.isValid()) { + return; + } + auto *userItem = altsTable->item(index.row(), COL_ALTS_USER); if (userItem != nullptr && !userItem->text().isEmpty()) { getTabSupervisor()->openTabAlts(userItem->text()); } }); - openAltsButton = new QPushButton; - connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph); - auto *altsHeader = new QHBoxLayout; - altsHeader->addStretch(); - altsHeader->addWidget(openAltsButton); - auto *altsLayout = new QVBoxLayout(altsGroup); - altsLayout->addLayout(altsHeader); + auto *altsPage = new QWidget; + auto *altsLayout = new QVBoxLayout(altsPage); altsLayout->addWidget(altsTable); - sessionsGroup = new QGroupBox; sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT); configureTable(sessionsTable); - auto *sessionsLayout = new QVBoxLayout(sessionsGroup); + auto *sessionsPage = new QWidget; + auto *sessionsLayout = new QVBoxLayout(sessionsPage); sessionsLayout->addWidget(sessionsTable); - staffGroup = new QGroupBox; staffTable = new QTableWidget(0, COL_STAFF_COUNT); configureTable(staffTable); refreshStaffButton = new QPushButton; @@ -176,19 +203,21 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli auto *staffHeader = new QHBoxLayout; staffHeader->addStretch(); staffHeader->addWidget(refreshStaffButton); - auto *staffLayout = new QVBoxLayout(staffGroup); + auto *staffPage = new QWidget; + auto *staffLayout = new QVBoxLayout(staffPage); staffLayout->addWidget(staffTable); staffLayout->addLayout(staffHeader); + sectionTabs = new QTabWidget; + sectionTabs->addTab(altsPage, QString()); + sectionTabs->addTab(sessionsPage, QString()); + sectionTabs->addTab(staffPage, QString()); + auto *splitter = new QSplitter(Qt::Vertical); splitter->addWidget(userInfoGroup); - splitter->addWidget(altsGroup); - splitter->addWidget(sessionsGroup); - splitter->addWidget(staffGroup); + splitter->addWidget(sectionTabs); splitter->setStretchFactor(0, 1); splitter->setStretchFactor(1, 2); - splitter->setStretchFactor(2, 2); - splitter->setStretchFactor(3, 1); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); @@ -210,21 +239,28 @@ void TabModeration::retranslateUi() refreshStaffButton->setText(tr("Refresh")); userInfoGroup->setTitle(tr("User Info")); - userInfoNameLabel->setText(tr("Name:")); - userInfoRegisteredLabel->setText(tr("Registered:")); - userInfoLastLoginLabel->setText(tr("Last login:")); - userInfoStatusLabel->setText(tr("Status:")); - userInfoCountsLabel->setText(tr("Counts:")); userInfoNotesLabel->setText(tr("Admin notes:")); - - altsGroup->setTitle(tr("Alts")); - openAltsButton->setText(tr("Open alt graph")); + cardReports->setTitle(tr("Reports")); + cardWarns->setTitle(tr("Warnings")); + cardBans->setTitle(tr("Bans")); + altsCard->setTitle(tr("Alts")); + openAltsButton->setText(tr("Open graph")); openAltsButton->setToolTip(tr("Open this user in the User Alts tab")); - sessionsGroup->setTitle(tr("Sessions")); - staffGroup->setTitle(tr("Staff Last Logins")); + + sectionTabs->setTabText(0, tr("Alts")); + sectionTabs->setTabText(1, tr("Sessions")); + sectionTabs->setTabText(2, tr("Staff Last Logins")); + + if (cachedUserInfoValid) { + applyUserInfo(); + } + if (cachedAltsCount >= 0) { + applyAltsCount(); + } altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); + altsTable->setToolTip(tr("Double-click or press Enter on a row to open it in the User Alts tab")); sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")}); staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")}); } @@ -237,11 +273,15 @@ QString TabModeration::formatEpoch(quint64 ts) const void TabModeration::clearUserData() { currentUser.clear(); + cachedUserInfoValid = false; + cachedAltsCount = -1; userInfoNameValue->clear(); - userInfoRegisteredValue->clear(); - userInfoLastLoginValue->clear(); userInfoStatusValue->clear(); - userInfoCountsValue->clear(); + sublineLabel->clear(); + cardReportsValue->setText(QStringLiteral("–")); + cardWarnsValue->setText(QStringLiteral("–")); + cardBansValue->setText(QStringLiteral("–")); + altsCountValue->setText(QStringLiteral("–")); userInfoNotesEdit->clear(); altsTable->setRowCount(0); sessionsTable->setRowCount(0); @@ -267,10 +307,13 @@ void TabModeration::investigateUser() return; } currentUser = userName; + cachedUserInfoValid = false; + cachedAltsCount = -1; resetPasswordButton->setEnabled(true); removeAvatarButton->setEnabled(true); removeBannerButton->setEnabled(true); openAltsButton->setEnabled(true); + altsCountValue->setText(QStringLiteral("…")); altsTable->setRowCount(0); sessionsTable->setRowCount(0); requestUserInfo(userName); @@ -289,10 +332,11 @@ void TabModeration::openAltsGraph() void TabModeration::requestUserInfo(const QString &userName) { userInfoNameValue->setText(userName); - userInfoRegisteredValue->setText(tr("Loading...")); - userInfoLastLoginValue->setText(tr("Loading...")); + sublineLabel->setText(tr("Loading...")); userInfoStatusValue->clear(); - userInfoCountsValue->clear(); + cardReportsValue->setText(QStringLiteral("…")); + cardWarnsValue->setText(QStringLiteral("…")); + cardBansValue->setText(QStringLiteral("…")); userInfoNotesEdit->clear(); Command_ReportUserInfo cmd; @@ -305,9 +349,12 @@ void TabModeration::requestUserInfo(const QString &userName) void TabModeration::userInfoResponse(const Response &response) { if (response.response_code() != Response::RespOk) { - userInfoRegisteredValue->clear(); - userInfoLastLoginValue->clear(); + cachedUserInfoValid = false; + sublineLabel->clear(); userInfoStatusValue->setText(tr("Error loading user info.")); + cardReportsValue->setText(QStringLiteral("–")); + cardWarnsValue->setText(QStringLiteral("–")); + cardBansValue->setText(QStringLiteral("–")); return; } @@ -316,20 +363,28 @@ void TabModeration::userInfoResponse(const Response &response) return; } - userInfoRegisteredValue->setText(formatEpoch(resp.registration_time())); - userInfoLastLoginValue->setText(formatEpoch(resp.last_login())); + cachedUserInfo.CopyFrom(resp); + cachedUserInfoValid = true; + applyUserInfo(); +} + +void TabModeration::applyUserInfo() +{ + const Response_ReportUserInfo &resp = cachedUserInfo; + sublineLabel->setText(tr("Registered %1 · Last login %2") + .arg(formatEpoch(resp.registration_time())) + .arg(formatEpoch(resp.last_login()))); QStringList statusParts; statusParts << (resp.is_active() ? tr("active") : tr("inactive")); if (resp.has_is_admin() && resp.is_admin()) { statusParts << tr("admin"); } - userInfoStatusValue->setText(statusParts.join(", ")); + userInfoStatusValue->setText(statusParts.join(QStringLiteral(", "))); - userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") - .arg(resp.total_reports()) - .arg(resp.total_bans()) - .arg(resp.total_warns())); + cardReportsValue->setText(QString::number(resp.total_reports())); + cardWarnsValue->setText(QString::number(resp.total_warns())); + cardBansValue->setText(QString::number(resp.total_bans())); if (resp.has_admin_notes() && !resp.admin_notes().empty()) { userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); @@ -338,12 +393,21 @@ void TabModeration::userInfoResponse(const Response &response) } } +void TabModeration::applyAltsCount() +{ + altsCountValue->setText(tr("%1 related").arg(cachedAltsCount)); +} + void TabModeration::requestSessions(const QString &userName) { Command_GetUserSessions cmd; cmd.set_user_name(userName.toStdString()); PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + sessionsResponse(response); + } + }); client->sendCommand(pend); } @@ -375,7 +439,11 @@ void TabModeration::requestAlts(const QString &userName) Command_GetUserAlts cmd; cmd.set_user_name(userName.toStdString()); PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); client->sendCommand(pend); } @@ -383,10 +451,21 @@ void TabModeration::altsResponse(const Response &response) { altsTable->setRowCount(0); if (response.response_code() != Response::RespOk) { + cachedAltsCount = -1; + altsCountValue->setText(QStringLiteral("–")); return; } const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + // The server always includes the frontier user as a depth-0 seed row. + int relatedCount = 0; + for (int i = 0; i < resp.alts_size(); ++i) { + if (!resp.alts(i).has_depth() || resp.alts(i).depth() > 0) { + ++relatedCount; + } + } + cachedAltsCount = relatedCount; + applyAltsCount(); altsTable->setRowCount(resp.alts_size()); for (int i = 0; i < resp.alts_size(); ++i) { const ServerInfo_UserAlt &alt = resp.alts(i); diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h index 3bd306d18..9ba123493 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.h +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -5,6 +5,7 @@ #include #include +#include #include class AbstractClient; @@ -13,6 +14,7 @@ class QLabel; class QLineEdit; class QPushButton; class QTableWidget; +class QTabWidget; class QTextEdit; class Response; class TabSupervisor; @@ -54,10 +56,15 @@ private: void requestAlts(const QString &userName); void requestModeratorLogins(); void clearUserData(); + void applyUserInfo(); + void applyAltsCount(); [[nodiscard]] QString formatEpoch(quint64 ts) const; AbstractClient *client; QString currentUser; + Response_ReportUserInfo cachedUserInfo; + bool cachedUserInfoValid = false; + int cachedAltsCount = -1; QLineEdit *searchEdit; QPushButton *investigateButton; @@ -66,26 +73,25 @@ private: QPushButton *removeBannerButton; QGroupBox *userInfoGroup; - QLabel *userInfoNameLabel; QLabel *userInfoNameValue; - QLabel *userInfoRegisteredLabel; - QLabel *userInfoRegisteredValue; - QLabel *userInfoLastLoginLabel; - QLabel *userInfoLastLoginValue; - QLabel *userInfoStatusLabel; QLabel *userInfoStatusValue; - QLabel *userInfoCountsLabel; - QLabel *userInfoCountsValue; + QLabel *sublineLabel; + QGroupBox *cardReports; + QGroupBox *cardWarns; + QGroupBox *cardBans; + QGroupBox *altsCard; + QLabel *cardReportsValue; + QLabel *cardWarnsValue; + QLabel *cardBansValue; + QLabel *altsCountValue; QLabel *userInfoNotesLabel; QTextEdit *userInfoNotesEdit; - QGroupBox *altsGroup; + QTabWidget *sectionTabs; QTableWidget *altsTable; - QPushButton *openAltsButton; - QGroupBox *sessionsGroup; QTableWidget *sessionsTable; - QGroupBox *staffGroup; QTableWidget *staffTable; + QPushButton *openAltsButton; QPushButton *refreshStaffButton; }; diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 7986286b3..d934a340b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -5,6 +5,7 @@ #include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" +#include "report_status_delegate.h" #include "tab_supervisor.h" #include @@ -24,7 +25,7 @@ #include #include #include -#include +#include #include #include #include @@ -126,26 +127,43 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); table->setContextMenuPolicy(Qt::CustomContextMenu); connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu); + table->setItemDelegateForColumn(COL_STATUS, new ReportStatusDelegate(table)); userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog); - descGroup = new QGroupBox; + reportHeaderName = new QLabel; + QFont displayFont = reportHeaderName->font(); + displayFont.setPointSize(displayFont.pointSize() + 6); + displayFont.setBold(true); + reportHeaderName->setFont(displayFont); + reportHeaderStatus = new QLabel; + QFont statusFont = reportHeaderStatus->font(); + statusFont.setBold(true); + reportHeaderStatus->setFont(statusFont); + reportHeaderSubline = new QLabel; + + auto *reportIdentityRow = new QHBoxLayout; + reportIdentityRow->addWidget(reportHeaderName); + reportIdentityRow->addStretch(); + reportIdentityRow->addWidget(reportHeaderStatus); + + auto *descPage = new QWidget; descriptionEdit = new QTextEdit; descriptionEdit->setReadOnly(true); - auto *descLayout = new QVBoxLayout(descGroup); + auto *descLayout = new QVBoxLayout(descPage); descLayout->setContentsMargins(4, 4, 4, 4); descLayout->addWidget(descriptionEdit); - chatGroup = new QGroupBox; + auto *chatPage = new QWidget; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); applyChatLogFont(); connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont); - auto *chatLayout = new QVBoxLayout(chatGroup); + auto *chatLayout = new QVBoxLayout(chatPage); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); - commentsGroup = new QGroupBox; + auto *commentsPage = new QWidget; commentsEdit = new QTextEdit; commentsEdit->setReadOnly(true); auto *commentInputRow = new QHBoxLayout; @@ -156,12 +174,12 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment); commentInputRow->addWidget(commentInput); commentInputRow->addWidget(commentButton); - auto *commentsLayout = new QVBoxLayout(commentsGroup); + auto *commentsLayout = new QVBoxLayout(commentsPage); commentsLayout->setContentsMargins(4, 4, 4, 4); commentsLayout->addWidget(commentsEdit); commentsLayout->addLayout(commentInputRow); - userContextGroup = new QGroupBox; + auto *userPage = new QWidget; userContextName = new QLabel; userContextAccountAge = new QLabel; userContextReports = new QLabel; @@ -180,7 +198,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T userContextWarnsLabel = new QLabel; userContextNotesLabel = new QLabel; userContextRecentReportsLabel = new QLabel; - auto *ucLayout = new QGridLayout(userContextGroup); + auto *ucLayout = new QGridLayout(userPage); ucLayout->setContentsMargins(8, 8, 8, 8); ucLayout->addWidget(userContextUserLabel, 0, 0); ucLayout->addWidget(userContextName, 0, 1, 1, 3); @@ -196,7 +214,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T ucLayout->addWidget(userContextNotes, 3, 1, 1, 3); ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4); ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4); - userContextGroup->setVisible(false); statsGroup = new QGroupBox; statsGroup->setCheckable(true); @@ -223,13 +240,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T } }); - detailSplitter = new QSplitter(Qt::Vertical); - detailSplitter->addWidget(descGroup); - detailSplitter->addWidget(chatGroup); - detailSplitter->addWidget(commentsGroup); - detailSplitter->setStretchFactor(0, 1); - detailSplitter->setStretchFactor(1, 1); - detailSplitter->setStretchFactor(2, 2); + detailTabs = new QTabWidget; + detailTabs->addTab(descPage, QString()); + detailTabs->addTab(chatPage, QString()); + detailTabs->addTab(commentsPage, QString()); + detailTabs->addTab(userPage, QString()); assignButton = new QPushButton; resolveButton = new QPushButton; @@ -261,12 +276,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T layout->addLayout(topBar); layout->addWidget(statsLabel); layout->addWidget(table, 2); - layout->addWidget(detailSplitter, 1); - layout->addWidget(userContextGroup); + layout->addLayout(reportIdentityRow); + layout->addWidget(reportHeaderSubline); + layout->addWidget(detailTabs, 2); layout->addWidget(statsGroup); layout->addLayout(actionBar); retranslateUi(); + clearReportHeader(); setActionsEnabled(false); refreshList(); @@ -293,18 +310,18 @@ void TabReport::retranslateUi() table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"), tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")}); - descGroup->setTitle(tr("Description")); - chatGroup->setTitle(tr("Chat Log Context")); - chatGroup->setToolTip( - tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited.")); - commentsGroup->setTitle(tr("Comments / Thread")); + detailTabs->setTabText(0, tr("Description")); + detailTabs->setTabText(1, tr("Chat Log Context")); + detailTabs->setTabToolTip(1, tr("Chat log attached by the reporter. It is captured from their client and may be " + "incomplete or edited.")); + detailTabs->setTabText(2, tr("Comments / Thread")); + detailTabs->setTabText(3, tr("Reported User")); descriptionEdit->setPlaceholderText(tr("No description.")); chatLogEdit->setPlaceholderText(tr("No chat log attached.")); commentsEdit->setPlaceholderText(tr("No comments yet.")); commentInput->setPlaceholderText(tr("Type a reply...")); commentButton->setText(tr("Send")); - userContextGroup->setTitle(tr("Reported User Context")); userContextUserLabel->setText(tr("User:")); userContextAgeLabel->setText(tr("Account Age:")); userContextReportsLabel->setText(tr("Reports:")); @@ -312,10 +329,18 @@ void TabReport::retranslateUi() userContextWarnsLabel->setText(tr("Warns:")); userContextNotesLabel->setText(tr("Admin Notes:")); userContextRecentReportsLabel->setText(tr("Recent Reports Against User:")); - userContextAccountAge->setText(QString()); - userContextReports->setText(QString()); - userContextBans->setText(QString()); - userContextWarns->setText(QString()); + + if (headerReportId >= 0) { + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == headerReportId) { + updateReportHeader(r); + const QString status = QString::fromStdString(r.status()); + const bool canComment = (status == "open" || status == "assigned"); + commentInput->setPlaceholderText(canComment ? tr("Type a reply...") : tr("This report is closed.")); + break; + } + } + } statsGroup->setTitle(tr("Report Statistics")); @@ -408,7 +433,8 @@ void TabReport::reportListResponse(const Response &response) chatLogEdit->clear(); commentsEdit->clear(); setActionsEnabled(false); - userContextGroup->setVisible(false); + clearReportHeader(); + clearUserContext(); } } @@ -445,11 +471,14 @@ void TabReport::applyFilters() table->setSortingEnabled(false); table->setRowCount(filteredReports.size()); + QFont reportedFont = table->font(); + reportedFont.setBold(true); + for (int row = 0; row < filteredReports.size(); ++row) { const ServerInfo_Report &r = filteredReports[row]; report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, - COL_STATUS, COL_ASSIGNED); + COL_STATUS, COL_ASSIGNED, false); table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name()))); @@ -458,12 +487,30 @@ void TabReport::applyFilters() table->setItem(row, COL_ROOM, new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); + + if (auto *reportedItem = table->item(row, COL_REPORTED)) { + reportedItem->setFont(reportedFont); + } } table->setSortingEnabled(true); table->resizeColumnsToContents(); table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + if (headerReportId >= 0) { + bool stillVisible = false; + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == headerReportId) { + stillVisible = true; + break; + } + } + if (!stillVisible) { + clearReportHeader(); + clearUserContext(); + } + } + statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size())); } @@ -490,6 +537,31 @@ void TabReport::updateStats() .arg(dismissed)); } +void TabReport::updateReportHeader(const ServerInfo_Report &report) +{ + reportHeaderName->setText(QString::fromStdString(report.reported_user_name())); + reportHeaderStatus->setText(report_utils::formatReportStatus(QString::fromStdString(report.status()))); + + QStringList parts; + parts << QString::fromStdString(report.category()); + parts << QDateTime::fromSecsSinceEpoch(report.report_time()).toLocalTime().toString("yyyy-MM-dd HH:mm"); + if (report.has_room_id() && report.room_id() > 0) { + parts << tr("Room %1").arg(report.room_id()); + } + if (report.game_id() > 0) { + parts << tr("Game %1").arg(report.game_id()); + } + reportHeaderSubline->setText(parts.join(QStringLiteral(" · "))); +} + +void TabReport::clearReportHeader() +{ + headerReportId = -1; + reportHeaderName->setText(tr("Select a report to inspect.")); + reportHeaderStatus->clear(); + reportHeaderSubline->clear(); +} + void TabReport::onSelectionChanged() { const int row = table->currentRow(); @@ -500,7 +572,8 @@ void TabReport::onSelectionChanged() commentInput->clear(); commentButton->setEnabled(false); setActionsEnabled(false); - userContextGroup->setVisible(false); + clearReportHeader(); + clearUserContext(); return; } @@ -509,6 +582,18 @@ void TabReport::onSelectionChanged() updateActionStates(); } +void TabReport::clearUserContext() +{ + lastRequestedUser.clear(); + userContextName->setText(tr("—")); + userContextAccountAge->clear(); + userContextReports->clear(); + userContextBans->clear(); + userContextWarns->clear(); + userContextNotes->setPlainText(tr("Select a report to inspect.")); + userContextRecentReports->clear(); +} + void TabReport::showTableContextMenu(const QPoint &pos) { QTableWidgetItem *clickedItem = table->itemAt(pos); @@ -546,12 +631,17 @@ void TabReport::loadReportDetails(int reportId) for (const ServerInfo_Report &r : filteredReports) { if (r.report_id() == reportId) { descriptionEdit->setPlainText(QString::fromStdString(r.description())); + updateReportHeader(r); + if (headerReportId != reportId) { + headerReportId = reportId; + detailTabs->setCurrentIndex(0); + } QString reportedUser = QString::fromStdString(r.reported_user_name()); if (!reportedUser.isEmpty()) { requestUserInfo(reportedUser); } else { - userContextGroup->setVisible(false); + clearUserContext(); } QString status = QString::fromStdString(r.status()); @@ -871,12 +961,11 @@ void TabReport::addCommentResponse(const Response &response) void TabReport::requestUserInfo(const QString &userName) { if (userName.isEmpty()) { - userContextGroup->setVisible(false); + clearUserContext(); return; } lastRequestedUser = userName; - userContextGroup->setVisible(true); userContextName->setText(userName); userContextAccountAge->setText(tr("Loading...")); userContextReports->clear(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 43e778f4b..aea42648e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -16,8 +16,8 @@ class QGroupBox; class QLabel; class QLineEdit; class QPushButton; -class QSplitter; class QTableWidget; +class QTabWidget; class QTextEdit; class QTimer; class GameReplay; @@ -72,6 +72,9 @@ private: void applyFilters(); void updateStats(); void loadReportDetails(int reportId); + void updateReportHeader(const ServerInfo_Report &report); + void clearReportHeader(); + void clearUserContext(); AbstractClient *client; @@ -81,10 +84,10 @@ private: QPushButton *refreshButton; QLabel *statsLabel; QTableWidget *table; - QSplitter *detailSplitter; - QGroupBox *descGroup; - QGroupBox *chatGroup; - QGroupBox *commentsGroup; + QLabel *reportHeaderName; + QLabel *reportHeaderStatus; + QLabel *reportHeaderSubline; + QTabWidget *detailTabs; QTextEdit *descriptionEdit; QTextEdit *chatLogEdit; QTextEdit *commentsEdit; @@ -100,7 +103,6 @@ private: QTimer *refreshTimer; UserContextMenu *userContextMenu; - QGroupBox *userContextGroup; QLabel *userContextName; QLabel *userContextAccountAge; QLabel *userContextReports; @@ -125,6 +127,7 @@ private: QList allReports; QList filteredReports; + int headerReportId = -1; int detailsRequestedReportId = -1; int detailsRequestSeq = 0; int selectedReportIdBeforeRefresh = -1; diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp index df2400b14..bdbc24f32 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.cpp +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -80,6 +80,23 @@ QString formatReportTime(qint64 secondsSinceEpoch) return dt.toString("yyyy-MM-dd hh:mm"); } +QString formatReportStatus(const QString &status) +{ + if (status == QStringLiteral("open")) { + return QObject::tr("Open"); + } + if (status == QStringLiteral("assigned")) { + return QObject::tr("Assigned"); + } + if (status == QStringLiteral("resolved")) { + return QObject::tr("Resolved"); + } + if (status == QStringLiteral("dismissed")) { + return QObject::tr("Dismissed"); + } + return status; +} + void fillReportTableRow(QTableWidget *table, int row, const ServerInfo_Report &report, @@ -89,7 +106,8 @@ void fillReportTableRow(QTableWidget *table, int categoryColumn, int gameIdColumn, int statusColumn, - int assignedColumn) + int assignedColumn, + bool colorizeStatus) { auto *idItem = new QTableWidgetItem(QString::number(report.report_id())); idItem->setData(Qt::UserRole, report.report_id()); @@ -102,8 +120,12 @@ void fillReportTableRow(QTableWidget *table, table->setItem(row, gameIdColumn, new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString())); - auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status())); - setReportStatusItemColor(statusItem, QString::fromStdString(report.status())); + const QString rawStatus = QString::fromStdString(report.status()); + auto *statusItem = new QTableWidgetItem(formatReportStatus(rawStatus)); + statusItem->setData(Qt::UserRole, rawStatus); + if (colorizeStatus) { + setReportStatusItemColor(statusItem, rawStatus); + } table->setItem(row, statusColumn, statusItem); table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name()))); diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h index 32cc19eac..f81e01369 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.h +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -20,6 +20,7 @@ QFont reportLogFont(); QString formatReportCategory(const QString &category); QString formatReportTime(qint64 secondsSinceEpoch); +QString formatReportStatus(const QString &status); void fillReportTableRow(QTableWidget *table, int row, @@ -30,7 +31,8 @@ void fillReportTableRow(QTableWidget *table, int categoryColumn, int gameIdColumn, int statusColumn, - int assignedColumn); + int assignedColumn, + bool colorizeStatus = true); void renderReportDetails(QTextEdit *chatLogEdit, QTextEdit *commentsEdit, diff --git a/cockatrice/src/interface/widgets/utility/table_activation.cpp b/cockatrice/src/interface/widgets/utility/table_activation.cpp new file mode 100644 index 000000000..5633b9f58 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.cpp @@ -0,0 +1,28 @@ +#include "table_activation.h" + +#include +#include +#include + +namespace +{ +void addActivationShortcut(QTableWidget *table, + QObject *context, + int key, + const std::function &handler) +{ + auto *shortcut = new QShortcut(QKeySequence(key), table); + // The table's viewport takes focus, so match children as well. + shortcut->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(shortcut, &QShortcut::activated, context, [table, handler] { handler(table->currentIndex()); }); +} +} // namespace + +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler) +{ + QObject::connect(table, &QTableWidget::doubleClicked, context, handler); + addActivationShortcut(table, context, Qt::Key_Return, handler); + addActivationShortcut(table, context, Qt::Key_Enter, handler); +} diff --git a/cockatrice/src/interface/widgets/utility/table_activation.h b/cockatrice/src/interface/widgets/utility/table_activation.h new file mode 100644 index 000000000..f5038bdb0 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.h @@ -0,0 +1,32 @@ +/** + * @file table_activation.h + * @brief Row activation for tables that must not navigate on a single click. + */ + +#ifndef TABLE_ACTIVATION_H +#define TABLE_ACTIVATION_H + +#include + +class QModelIndex; +class QObject; +class QTableWidget; + +/** + * @brief Call @p handler on double-click or Enter, never on a single click. + * + * QAbstractItemView::activated fires on a single click on platforms where + * SH_ItemView_ActivateItemOnSingleClick is set, so navigation actions must not + * use it. This connects doubleClicked and installs Return/Enter shortcuts that + * forward the current index. + * + * @param table The table whose rows activate. + * @param context Object that owns the connections; the handler is disconnected + * with it. + * @param handler Receives the activated row's index, which may be invalid. + */ +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler); + +#endif // TABLE_ACTIVATION_H From 2fba13753b5a84cad8b98af0bea8e6bc7cf582de Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:59:50 +0100 Subject: [PATCH 087/104] [Client] Alt badges, breadcrumb trail, investigate button (#7516) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Alt badges, breadcrumb trail, investigate button AltTableDelegate paints palette-tinted match badges with paint-through selection, style warning/critical icons on nonzero counts, and font/elision handling. TabAlts gains a clickable breadcrumb trail, an Investigate-this-user button, and bold usernames. Async alts responses are guarded against stale investigations. * [Client] Fix alt badge and trail review findings - Drop the dead VPN warning branch: rowVisible() already hides IP rows above the threshold, so setVpnThreshold(), its member and the spin-box connect never took effect. - Dim IP rows at shared_count > 2 instead of > 1: the count already includes the investigated account, so every IP row was dimmed. - Truncate backStack when a breadcrumb is clicked instead of pushing the current user again, so the trail keeps a single path and Back works. - Word-wrap the trail label so a long chain cannot widen the tab. - Extract the status/match pill painting into widgets/utility/pill_painter and use it from both delegates instead of duplicating it. * [Client] Route investigate() through the trail helper --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 3 + .../widgets/tabs/alt_table_delegate.cpp | 95 +++++++++++++++++++ .../widgets/tabs/alt_table_delegate.h | 33 +++++++ .../widgets/tabs/report_status_delegate.cpp | 35 ++----- .../src/interface/widgets/tabs/tab_alts.cpp | 84 ++++++++++++++++ .../src/interface/widgets/tabs/tab_alts.h | 7 ++ .../widgets/utility/pill_painter.cpp | 41 ++++++++ .../interface/widgets/utility/pill_painter.h | 29 ++++++ 8 files changed, 298 insertions(+), 29 deletions(-) create mode 100644 cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/alt_table_delegate.h create mode 100644 cockatrice/src/interface/widgets/utility/pill_painter.cpp create mode 100644 cockatrice/src/interface/widgets/utility/pill_painter.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index c68f82982..984784161 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -293,6 +293,8 @@ set(cockatrice_SOURCES src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/epoch_format.h src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/pill_painter.cpp + src/interface/widgets/utility/pill_painter.h src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h src/interface/widgets/utility/table_activation.cpp @@ -384,6 +386,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp + src/interface/widgets/tabs/alt_table_delegate.cpp src/interface/widgets/tabs/report_status_delegate.cpp src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp new file mode 100644 index 000000000..3cc412a7e --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -0,0 +1,95 @@ +#include "alt_table_delegate.h" + +#include "../utility/pill_painter.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int ICON_TEXT_GAP = 4; +} // namespace + +AltTableDelegate::AltTableDelegate(int viaColumn, int warnsColumn, int bansColumn, int sharedColumn, QObject *parent) + : QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn), + sharedColumn(sharedColumn) +{ +} + +void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + const QString text = index.data(Qt::DisplayRole).toString(); + const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText; + + if (index.column() == viaColumn) { + bool sharedOk = false; + const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk); + const bool ipMatch = text.startsWith(QStringLiteral("ip:")); + // shared_count for an IP match already includes the investigated account, + // so anything above 2 means the address is shared beyond that pair. + const bool dimmed = sharedOk && ipMatch && shared > 2; + const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole; + + QPalette::ColorRole tintRole = QPalette::NoRole; + if (text == QStringLiteral("email")) { + tintRole = QPalette::Link; + } else if (text.startsWith(QStringLiteral("clientid:"))) { + tintRole = QPalette::Highlight; + } else if (ipMatch) { + tintRole = QPalette::PlaceholderText; + } + if (tintRole != QPalette::NoRole) { + if (!pill_painter::paint(painter, opt, tintRole, textRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); + } + return; + } + QStyledItemDelegate::paint(painter, opt, index); + return; + } + + if (index.column() == warnsColumn || index.column() == bansColumn) { + bool countOk = false; + const int count = text.toInt(&countOk); + if (countOk && count > 0 && !selected) { + const QIcon icon = style->standardIcon(index.column() == bansColumn ? QStyle::SP_MessageBoxCritical + : QStyle::SP_MessageBoxWarning); + const int iconSize = qMax(0, opt.rect.height() - 2 * ICON_TEXT_GAP); + QRect textRect = opt.rect; + if (!icon.isNull() && iconSize > 0) { + const QRect iconRect(opt.rect.left() + ICON_TEXT_GAP, opt.rect.top() + ICON_TEXT_GAP, iconSize, + iconSize); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); + } + const QString elided = opt.fontMetrics.elidedText(text, opt.textElideMode, textRect.width()); + style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, elided, + emphasisRole); + return; + } + } + + QStyledItemDelegate::paint(painter, opt, index); +} + +QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const QSize hint = QStyledItemDelegate::sizeHint(option, index); + if (index.column() == viaColumn) { + return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y); + } + if (index.column() == warnsColumn || index.column() == bansColumn) { + return hint + QSize(2 * ICON_TEXT_GAP, 0); + } + return hint; +} diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h new file mode 100644 index 000000000..73386b00d --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -0,0 +1,33 @@ +#ifndef ALT_TABLE_DELEGATE_H +#define ALT_TABLE_DELEGATE_H + +#include +#include + +/** + * Badge + icon painting for the User Alts table. The match-evidence column + * renders as a palette-tinted pill per match type (email / client id / IP), and + * an IP shared by more than one other account is dimmed. Warn/ban counts gain + * the style's warning/critical icons when nonzero. Selected rows and unknown + * content fall back to default painting. + */ +class AltTableDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + QObject *parent = nullptr); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + int viaColumn; + int warnsColumn; + int bansColumn; + int sharedColumn; +}; + +#endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp index fe59702c4..62e427e0e 100644 --- a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -1,18 +1,13 @@ #include "report_status_delegate.h" +#include "../utility/pill_painter.h" + #include #include #include #include #include -namespace -{ -constexpr qreal PILL_ALPHA = 0.30; -constexpr int PILL_PAD_X = 10; -constexpr int PILL_PAD_Y = 4; -} // namespace - ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent) { } @@ -46,32 +41,14 @@ void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem & return; } - const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); - if (pillRect.width() <= 0 || pillRect.height() <= 0) { - QStyledItemDelegate::paint(painter, opt, index); - return; - } - - if (!selected) { - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - QColor pill = opt.palette.color(tintRole); - pill.setAlphaF(PILL_ALPHA); - painter->setPen(Qt::NoPen); - painter->setBrush(pill); - painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); - painter->restore(); - } - - const QString elided = - opt.fontMetrics.elidedText(opt.text.isEmpty() ? status : opt.text, opt.textElideMode, pillRect.width()); const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText; - QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); - style->drawItemText(painter, pillRect, Qt::AlignCenter, opt.palette, true, elided, textRole); + if (!pill_painter::paint(painter, opt, tintRole, textRole, opt.text.isEmpty() ? status : opt.text)) { + QStyledItemDelegate::paint(painter, opt, index); + } } QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { const QSize hint = QStyledItemDelegate::sizeHint(option, index); - return hint + QSize(PILL_PAD_X, PILL_PAD_Y); + return hint + QSize(pill_painter::PAD_X, pill_painter::PAD_Y); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index da08a0b56..833584a39 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -3,6 +3,7 @@ #include "../utility/epoch_format.h" #include "../utility/table_activation.h" #include "abstract_client.h" +#include "alt_table_delegate.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" @@ -102,6 +103,18 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack); statusLabel = new QLabel; + trailLabel = new QLabel; + trailLabel->setTextFormat(Qt::RichText); + trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); + trailLabel->setOpenExternalLinks(false); + // The trail grows with every hop, so let it wrap instead of forcing the tab + // wider than the window. + trailLabel->setWordWrap(true); + connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) { + if (!userName.isEmpty() && userName != currentUser) { + goToTrailEntry(userName); + } + }); auto *topBar = new QHBoxLayout; topBar->addWidget(backButton); @@ -136,6 +149,10 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); + altsDelegate = new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, altsTable); + altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; @@ -174,6 +191,10 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q detailsLayout->addLayout(detailGrid); detailsLayout->addWidget(warnsTable, 1); detailsLayout->addWidget(bansTable, 1); + investigateSelectedButton = new QPushButton; + investigateSelectedButton->setEnabled(false); + connect(investigateSelectedButton, &QPushButton::clicked, this, &TabAlts::investigateSelected); + detailsLayout->addWidget(investigateSelectedButton); auto *contentSplitter = new QSplitter(Qt::Horizontal); contentSplitter->addWidget(altsTable); @@ -186,6 +207,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q mainLayout->addLayout(topBar); mainLayout->addLayout(filterBar); mainLayout->addWidget(statusLabel); + mainLayout->addWidget(trailLabel); mainLayout->addWidget(contentSplitter, 1); retranslateUi(); @@ -225,6 +247,8 @@ void TabAlts::retranslateUi() detailNameLabel->setText(tr("Name:")); detailCountsLabel->setText(tr("Counts:")); detailNotesLabel->setText(tr("Admin notes:")); + investigateSelectedButton->setText(tr("Investigate this user")); + investigateSelectedButton->setToolTip(tr("Open the selected account as a new investigation")); if (!lastAlts.isEmpty()) { renderTable(); @@ -241,6 +265,13 @@ void TabAlts::investigate(const QString &userName) if (userName.isEmpty() || userName == currentUser) { return; } + // Alt matches are symmetric, so revisiting an earlier account is a normal + // double-click. Truncate there instead of pushing it again, so the trail + // stays a single path. + if (backStack.contains(userName)) { + goToTrailEntry(userName); + return; + } if (!currentUser.isEmpty()) { backStack << currentUser; backButton->setEnabled(true); @@ -248,6 +279,20 @@ void TabAlts::investigate(const QString &userName) loadUser(userName); } +void TabAlts::goToTrailEntry(const QString &userName) +{ + const int index = backStack.indexOf(userName); + if (index < 0) { + investigate(userName); + return; + } + // Clicking an earlier crumb truncates the history there instead of pushing + // another entry, so the trail keeps a single path and Back still works. + backStack.erase(backStack.begin() + index, backStack.end()); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(userName); +} + void TabAlts::goBack() { if (backStack.isEmpty()) { @@ -262,16 +307,41 @@ void TabAlts::loadUser(const QString &userName) { currentUser = userName; searchEdit->setText(userName); + updateTrail(); investigateUser(); } +void TabAlts::updateTrail() +{ + QStringList trail = backStack; + trail << currentUser; + trail.removeAll(QString()); + QStringList links; + for (const QString &name : trail) { + const QString safeName = name.toHtmlEscaped(); + if (name == currentUser) { + links << QStringLiteral("") + safeName + QStringLiteral(""); + } else { + links << QStringLiteral("") + safeName + + QStringLiteral(""); + } + } + trailLabel->setText(links.join(QStringLiteral(" › "))); + trailLabel->setVisible(!links.isEmpty()); +} + void TabAlts::investigateUser() { const QString userName = searchEdit->text().simplified(); if (userName.isEmpty()) { return; } + if (userName != currentUser && !currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } currentUser = userName; + updateTrail(); lastAlts.clear(); selectedUser.clear(); clearDetails(); @@ -393,9 +463,14 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + // shared_count already includes the investigated account, so anything + // above 2 means the address is shared beyond that pair. if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { + if (col == COL_ALTS_VIA) { + continue; + } if (auto *item = altsTable->item(row, col)) { item->setForeground(dimColor); } @@ -446,6 +521,13 @@ void TabAlts::onFilterChanged() renderTable(); } +void TabAlts::investigateSelected() +{ + if (!selectedUser.isEmpty() && selectedUser != currentUser) { + investigate(selectedUser); + } +} + void TabAlts::onAltSelected() { const auto selected = altsTable->selectedItems(); @@ -469,6 +551,7 @@ void TabAlts::requestDetails(const QString &userName) detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(userName != currentUser); Command_ReportUserInfo infoCmd; infoCmd.set_user_name(userName.toStdString()); @@ -508,6 +591,7 @@ void TabAlts::clearDetails() detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(false); } void TabAlts::userDetailsResponse(const Response &response) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 8c0086f25..50aabf8f5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -10,6 +10,7 @@ #include class AbstractClient; +class AltTableDelegate; class QGroupBox; class QLabel; class QLineEdit; @@ -41,6 +42,7 @@ public: private slots: void investigateUser(); void goBack(); + void investigateSelected(); void altsResponse(const Response &response); void onFilterChanged(); void onAltSelected(); @@ -50,6 +52,8 @@ private slots: private: void loadUser(const QString &userName); + void goToTrailEntry(const QString &userName); + void updateTrail(); void requestAlts(const QString &userName, int depth); void requestDetails(const QString &userName); void clearDetails(); @@ -67,8 +71,11 @@ private: QSpinBox *depthSpin; QPushButton *investigateButton; QPushButton *backButton; + QPushButton *investigateSelectedButton; + QLabel *trailLabel; QLabel *statusLabel; QTableWidget *altsTable; + AltTableDelegate *altsDelegate; QPushButton *emailChip; QPushButton *clientIdChip; diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.cpp b/cockatrice/src/interface/widgets/utility/pill_painter.cpp new file mode 100644 index 000000000..6f317b951 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.cpp @@ -0,0 +1,41 @@ +#include "pill_painter.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +} // namespace + +bool pill_painter::paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text) +{ + const QRect pillRect = option.rect.adjusted(PAD_X / 2, PAD_Y / 2, -PAD_X / 2, -PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + return false; + } + + if ((option.state & QStyle::State_Selected) == 0) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = option.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + QStyle *style = option.widget != nullptr ? option.widget->style() : QApplication::style(); + const QString elided = option.fontMetrics.elidedText(text, option.textElideMode, pillRect.width()); + style->drawItemText(painter, pillRect, Qt::AlignCenter, option.palette, true, elided, textRole); + return true; +} diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.h b/cockatrice/src/interface/widgets/utility/pill_painter.h new file mode 100644 index 000000000..72e3fe0b4 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.h @@ -0,0 +1,29 @@ +#ifndef PILL_PAINTER_H +#define PILL_PAINTER_H + +#include +#include + +class QPainter; +class QStyleOptionViewItem; + +namespace pill_painter +{ +constexpr int PAD_X = 10; +constexpr int PAD_Y = 4; + +/** + * Draws a translucent, palette-tinted rounded pill inside the option's rect and + * paints the text elided and centred on it, so badges follow the active theme. + * Returns false when the cell is too small to hold a pill, leaving the caller to + * fall back to default painting. + */ +bool paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text); + +} // namespace pill_painter + +#endif // PILL_PAINTER_H From 53f69c9216c87ab3aba344583d07e92973ae4195 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:59:51 +0100 Subject: [PATCH 088/104] [Alts] Server-side search controls, IP counts, adjustable window (#7517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Alts] Server-side search controls, IP counts, adjustable window Plumbs the match-type chips through to the server: include_email/include_clientid/include_ip skip disabled identifier types in both matching and frontier expansion, so excluded types (e.g. throwaway domains) never enter the graph at any depth. Session lookback becomes adjustable (session_months, clamped 1-24, default 6). Each alt row carries ip_count, the distinct recent session IP count shown in a new IPs column. All proto additions are optional with old defaults, so old clients/servers interoperate. * Fix remaining review threads on the alts search controls - Disable keyboard tracking on the depth and window spin boxes so typing a two-digit value sends one search, not one per digit. - Reload the user under investigation on a filter change instead of reading the search box, which could switch to a name never submitted. - Keep a chip's last match count while it is unchecked; the server omits unchecked match types, so the label used to drop to "(0)". --------- Co-authored-by: Lukas Brübach --- .../src/interface/widgets/tabs/tab_alts.cpp | 77 +++++++++--- .../src/interface/widgets/tabs/tab_alts.h | 6 +- .../protocol/pb/moderator_commands.proto | 4 + .../protocol/pb/serverinfo_user_alt.proto | 1 + .../src/servatrice_database_interface.cpp | 114 +++++++++++++----- .../src/servatrice_database_interface.h | 7 +- servatrice/src/serversocketinterface.cpp | 4 +- 7 files changed, 164 insertions(+), 49 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index 833584a39..d6b13b40e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -42,14 +42,15 @@ constexpr int COL_ALTS_USER = 0; constexpr int COL_ALTS_VIA = 1; constexpr int COL_ALTS_DEPTH = 2; constexpr int COL_ALTS_SHARED = 3; -constexpr int COL_ALTS_EMAIL = 4; -constexpr int COL_ALTS_CLIENTID = 5; -constexpr int COL_ALTS_REGISTERED = 6; -constexpr int COL_ALTS_LAST_LOGIN = 7; -constexpr int COL_ALTS_WARNS = 8; -constexpr int COL_ALTS_BANS = 9; -constexpr int COL_ALTS_ACTIVE = 10; -constexpr int COL_ALTS_COUNT = 11; +constexpr int COL_ALTS_IPS = 4; +constexpr int COL_ALTS_EMAIL = 5; +constexpr int COL_ALTS_CLIENTID = 6; +constexpr int COL_ALTS_REGISTERED = 7; +constexpr int COL_ALTS_LAST_LOGIN = 8; +constexpr int COL_ALTS_WARNS = 9; +constexpr int COL_ALTS_BANS = 10; +constexpr int COL_ALTS_ACTIVE = 11; +constexpr int COL_ALTS_COUNT = 12; constexpr int COL_WARNS_TIME = 0; constexpr int COL_WARNS_MODERATOR = 1; @@ -94,6 +95,8 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q depthSpin->setMinimum(1); depthSpin->setMaximum(MAX_ALT_DEPTH); depthSpin->setValue(1); + depthSpin->setKeyboardTracking(false); + connect(depthSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); investigateButton = new QPushButton; connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); @@ -126,7 +129,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q auto *chip = new QPushButton(text); chip->setCheckable(true); chip->setChecked(true); - connect(chip, &QPushButton::toggled, this, &TabAlts::onFilterChanged); + connect(chip, &QPushButton::toggled, this, &TabAlts::onSearchFilterChanged); return chip; }; emailChip = makeChip(QString()); @@ -137,7 +140,14 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q vpnSpin->setMinimum(1); vpnSpin->setMaximum(100000); vpnSpin->setValue(DEFAULT_VPN_THRESHOLD); - connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onFilterChanged); + connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onDisplayFilterChanged); + windowLabel = new QLabel; + monthsSpin = new QSpinBox; + monthsSpin->setMinimum(1); + monthsSpin->setMaximum(24); + monthsSpin->setValue(6); + monthsSpin->setKeyboardTracking(false); + connect(monthsSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); auto *filterBar = new QHBoxLayout; filterBar->addWidget(emailChip); @@ -145,6 +155,8 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q filterBar->addWidget(ipChip); filterBar->addWidget(vpnLabel); filterBar->addWidget(vpnSpin); + filterBar->addWidget(windowLabel); + filterBar->addWidget(monthsSpin); filterBar->addStretch(); altsTable = new QTableWidget(0, COL_ALTS_COUNT); @@ -235,8 +247,11 @@ void TabAlts::retranslateUi() ipChip->setToolTip(tr("Show or hide IP address matches")); vpnLabel->setText(tr("Hide IPs shared by more than")); vpnSpin->setSuffix(tr(" users")); + windowLabel->setText(tr("Session window")); + monthsSpin->setSuffix(tr(" months")); + monthsSpin->setToolTip(tr("How far back session IPs and client IDs are considered.")); - altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("IPs"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); altsTable->setToolTip(tr("Double-click or press Enter on a row to investigate that account")); @@ -342,12 +357,17 @@ void TabAlts::investigateUser() } currentUser = userName; updateTrail(); + reloadCurrentUser(); +} + +void TabAlts::reloadCurrentUser() +{ lastAlts.clear(); selectedUser.clear(); clearDetails(); altsTable->setRowCount(0); statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); - requestAlts(userName, depthSpin->value()); + requestAlts(currentUser, depthSpin->value()); } void TabAlts::requestAlts(const QString &userName, int depth) @@ -355,6 +375,10 @@ void TabAlts::requestAlts(const QString &userName, int depth) Command_GetUserAlts cmd; cmd.set_user_name(userName.toStdString()); cmd.set_depth(static_cast(depth)); + cmd.set_include_email(emailChip->isChecked()); + cmd.set_include_clientid(clientIdChip->isChecked()); + cmd.set_include_ip(ipChip->isChecked()); + cmd.set_session_months(static_cast(monthsSpin->value())); PendingCommand *pend = client->prepareModeratorCommand(cmd); connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { if (userName == currentUser) { @@ -428,9 +452,17 @@ void TabAlts::renderTable() ++ipCount; } } - emailChip->setText(tr("Email (%1)").arg(emailCount)); - clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount)); - ipChip->setText(tr("IP (%1)").arg(ipCount)); + // The server omits unchecked match types, so keep the last count while a + // chip is off instead of relabelling it "(0)". + if (emailChip->isChecked()) { + emailChip->setText(tr("Email (%1)").arg(emailCount)); + } + if (clientIdChip->isChecked()) { + clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount)); + } + if (ipChip->isChecked()) { + ipChip->setText(tr("IP (%1)").arg(ipCount)); + } QFont userFont = altsTable->font(); userFont.setBold(true); @@ -456,6 +488,9 @@ void TabAlts::renderTable() altsTable->setItem( row, COL_ALTS_SHARED, new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); + altsTable->setItem( + row, COL_ALTS_IPS, + new QTableWidgetItem(alt.has_ip_count() ? QString::number(alt.ip_count()) : QStringLiteral("-"))); altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); @@ -513,7 +548,17 @@ void TabAlts::renderTable() } } -void TabAlts::onFilterChanged() +void TabAlts::onSearchFilterChanged() +{ + if (currentUser.isEmpty()) { + return; + } + // A filter change reloads the user being investigated; it must not switch + // to a name that was typed into the search box but never submitted. + reloadCurrentUser(); +} + +void TabAlts::onDisplayFilterChanged() { if (lastAlts.isEmpty()) { return; diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 50aabf8f5..7f6dd4f42 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -44,7 +44,8 @@ private slots: void goBack(); void investigateSelected(); void altsResponse(const Response &response); - void onFilterChanged(); + void onSearchFilterChanged(); + void onDisplayFilterChanged(); void onAltSelected(); void userDetailsResponse(const Response &response); void warnHistoryResponse(const Response &response); @@ -54,6 +55,7 @@ private: void loadUser(const QString &userName); void goToTrailEntry(const QString &userName); void updateTrail(); + void reloadCurrentUser(); void requestAlts(const QString &userName, int depth); void requestDetails(const QString &userName); void clearDetails(); @@ -82,6 +84,8 @@ private: QPushButton *ipChip; QLabel *vpnLabel; QSpinBox *vpnSpin; + QLabel *windowLabel; + QSpinBox *monthsSpin; QLabel *detailNameValue; QLabel *detailCountsValue; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 19aec92c3..d03f93667 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -170,6 +170,10 @@ message Command_GetUserAlts { } optional string user_name = 1; optional uint32 depth = 2 [default = 1]; // BFS depth: 1 = direct matches only, up to 3 for transitive alts-of-alts + optional bool include_email = 3 [default = true]; // match and expand over registration email + optional bool include_clientid = 4 [default = true]; // match and expand over current and historic client ids + optional bool include_ip = 5 [default = true]; // match and expand over recent session IPs + optional uint32 session_months = 6 [default = 6]; // session lookback window in months, 1-24 } message Command_GetModeratorLastLogins { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto index ce5e4fce2..c9ec9aa9a 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -17,4 +17,5 @@ message ServerInfo_UserAlt { optional uint32 depth = 10; // BFS distance from investigated account; 1 = direct, 2+ = alt-of-alt optional uint32 shared_count = 11; // distinct users sharing the matched identifier; high values suggest VPN/shared network + optional uint32 ip_count = 12; // distinct recent session IPs of this account in the search window } diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index c003e5c9d..1327c85d5 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1986,6 +1986,8 @@ constexpr int MAX_USER_ALTS_PER_LEVEL = 200; // Identifier-heavy frontiers, e.g. VPN users with hundreds of recent IPs, are // truncated: this tool is deliberately a first-pass sweep, not an audit. constexpr int MAX_USER_ALT_IDENTIFIERS = 200; +constexpr int MIN_USER_ALT_MONTHS = 1; +constexpr int MAX_USER_ALT_MONTHS = 24; QString altInClause(const QString &prefix, const QList &values) { @@ -2036,7 +2038,12 @@ QList cappedIdentifiers(const QList &values) } } // namespace -QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, int depth) +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, + int depth, + bool useEmail, + bool useClientId, + bool useIp, + int sessionMonths) { QList results; @@ -2049,6 +2056,16 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin } else if (depth > MAX_USER_ALT_DEPTH) { depth = MAX_USER_ALT_DEPTH; } + if (sessionMonths < MIN_USER_ALT_MONTHS) { + sessionMonths = MIN_USER_ALT_MONTHS; + } else if (sessionMonths > MAX_USER_ALT_MONTHS) { + sessionMonths = MAX_USER_ALT_MONTHS; + } + + // Interpolated, not bound: placeholders are not allowed in INTERVAL + // expressions, and the clamped integer cannot inject SQL. + const QString sessionWindow = QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL ") + + QString::number(sessionMonths) + QStringLiteral(" MONTH)"); // Seed must exist; its row anchors the result at depth 0. QSqlQuery *seedQuery = prepareQuery("SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " @@ -2121,32 +2138,46 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin QSet clientIdKeys; QSet ipKeys; - QSqlQuery frontierQuery(sqlDatabase); - prepareDynamicQuery(frontierQuery, QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + - frontierClause); - altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier); - if (execSqlQuery(&frontierQuery)) { - while (frontierQuery.next()) { - // Email comparison is case-insensitive in SQL, so normalise the - // keys too or the membership tests below miss real matches. - appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower()); - appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString()); + if (useEmail || useClientId) { + QSqlQuery frontierQuery(sqlDatabase); + prepareDynamicQuery(frontierQuery, + QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + + frontierClause); + altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&frontierQuery)) { + while (frontierQuery.next()) { + // Email comparison is case-insensitive in SQL, so normalise the + // keys too or the membership tests below miss real matches. + if (useEmail) { + appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString()); + } + } } } // Recent session identifiers: IPs plus historic client ids, most recent // first so a capped list keeps what the account actually used lately. - QSqlQuery sessionQuery(sqlDatabase); - prepareDynamicQuery(sessionQuery, QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen " - "FROM {prefix}_sessions WHERE user_name IN ") + - frontierClause + - QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " - "GROUP BY ip_address, clientid ORDER BY last_seen DESC")); - altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier); - if (execSqlQuery(&sessionQuery)) { - while (sessionQuery.next()) { - appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString()); - appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString()); + if (useIp || useClientId) { + QSqlQuery sessionQuery(sqlDatabase); + prepareDynamicQuery(sessionQuery, + QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen " + "FROM {prefix}_sessions WHERE user_name IN ") + + frontierClause + sessionWindow + + QStringLiteral(" GROUP BY ip_address, clientid " + "ORDER BY last_seen DESC")); + altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&sessionQuery)) { + while (sessionQuery.next()) { + if (useIp) { + appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString()); + } + } } } @@ -2178,14 +2209,13 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin if (!ipList.isEmpty()) { conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " "s.ip_address IN ") + - altInClause(QStringLiteral(":alt_ip"), ipList) + - QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"); + altInClause(QStringLiteral(":alt_ip"), ipList) + sessionWindow + QStringLiteral(")"); } if (!clientIdList.isEmpty()) { conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " "s.clientid IN ") + - altInClause(QStringLiteral(":alt_hcid"), clientIdList) + - QStringLiteral(" AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))"); + altInClause(QStringLiteral(":alt_hcid"), clientIdList) + sessionWindow + + QStringLiteral(")"); } candidateString += conditions.join(QStringLiteral(" OR ")); // Already-visited accounts match their own identifiers at every level and @@ -2253,7 +2283,7 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin altInClause(QStringLiteral(":alt_ecid"), clientIdList); } evidenceString += evidenceConditions.join(QStringLiteral(" OR ")); - evidenceString += QStringLiteral(") AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)"); + evidenceString += QStringLiteral(")") + sessionWindow; QSqlQuery evidenceQuery(sqlDatabase); prepareDynamicQuery(evidenceQuery, evidenceString); @@ -2348,9 +2378,8 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin prepareDynamicQuery(countQuery, QStringLiteral("SELECT ip_address, COUNT(DISTINCT user_name) FROM {prefix}_sessions " "WHERE ip_address IN ") + - altInClause(QStringLiteral(":alt_ci"), neededIpList) + - QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " - "GROUP BY ip_address")); + altInClause(QStringLiteral(":alt_ci"), neededIpList) + sessionWindow + + QStringLiteral(" GROUP BY ip_address")); altBindValues(&countQuery, QStringLiteral(":alt_ci"), neededIpList); if (execSqlQuery(&countQuery)) { while (countQuery.next()) { @@ -2387,6 +2416,31 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin frontier = nextFrontier; } + // Distinct recent IP counts per account: cheap group signal without + // shipping full address histories to the client. + if (!results.isEmpty()) { + QList resultNames; + resultNames.reserve(results.size()); + for (const ServerInfo_UserAlt &alt : results) { + resultNames << QString::fromStdString(alt.user_name()); + } + QSqlQuery *ipCountQuery = prepareQuery( + QStringLiteral("SELECT user_name, COUNT(DISTINCT ip_address) FROM {prefix}_sessions WHERE user_name IN ") + + altInClause(QStringLiteral(":alt_ic"), resultNames) + sessionWindow + + QStringLiteral(" GROUP BY user_name")); + altBindValues(ipCountQuery, QStringLiteral(":alt_ic"), resultNames); + QMap ipCountsByUser; + if (execSqlQuery(ipCountQuery)) { + while (ipCountQuery->next()) { + ipCountsByUser.insert(ipCountQuery->value(0).toString(), ipCountQuery->value(1).toInt()); + } + } + for (ServerInfo_UserAlt &alt : results) { + alt.set_ip_count(static_cast( + ipCountsByUser.value(QString::fromStdString(alt.user_name()), 0))); + } + } + return results; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 327906b3c..c3cc57ab2 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -224,7 +224,12 @@ public: const QString &beforeTime = QString(), bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); - QList getUserAlts(const QString &userName, int depth = 1); + QList getUserAlts(const QString &userName, + int depth = 1, + bool useEmail = true, + bool useClientId = true, + bool useIp = true, + int sessionMonths = 6); QList getModeratorLastLogins(); // Uptime snapshot as recorded by Servatrice::statusUpdate() into the diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index b49b94ec0..c32ccad49 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2596,7 +2596,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Comma Response_UserAlts *re = new Response_UserAlts; const int depth = cmd.has_depth() ? static_cast(cmd.depth()) : 1; - const QList alts = sqlInterface->getUserAlts(userName, depth); + const int sessionMonths = cmd.has_session_months() ? static_cast(cmd.session_months()) : 6; + const QList alts = sqlInterface->getUserAlts( + userName, depth, cmd.include_email(), cmd.include_clientid(), cmd.include_ip(), sessionMonths); for (const ServerInfo_UserAlt &alt : alts) { re->add_alts()->CopyFrom(alt); } From 2b4019ccaf39cb83fe6f83428b841049165ce46f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:01:11 +0100 Subject: [PATCH 089/104] [Client] Active pill for alts table, dismissed pill for report queue (#7519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Client] Active pill for alts table, dismissed pill for report queue * Apply suggestion from @ZeldaZach Co-authored-by: Zach H --------- Co-authored-by: Lukas Brübach Co-authored-by: Zach H --- .../widgets/tabs/alt_table_delegate.cpp | 20 ++++++++++++++++--- .../widgets/tabs/alt_table_delegate.h | 8 ++++++-- .../widgets/tabs/report_status_delegate.cpp | 2 +- .../widgets/tabs/report_status_delegate.h | 6 +++--- .../src/interface/widgets/tabs/tab_alts.cpp | 8 ++++++-- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp index 3cc412a7e..9fb6a7ffe 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -14,9 +14,14 @@ namespace constexpr int ICON_TEXT_GAP = 4; } // namespace -AltTableDelegate::AltTableDelegate(int viaColumn, int warnsColumn, int bansColumn, int sharedColumn, QObject *parent) +AltTableDelegate::AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + int activeColumn, + QObject *parent) : QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn), - sharedColumn(sharedColumn) + sharedColumn(sharedColumn), activeColumn(activeColumn) { } @@ -79,13 +84,22 @@ void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opti } } + if (index.column() == activeColumn) { + const bool active = index.data(Qt::UserRole).toBool(); + const QPalette::ColorRole tintRole = active ? QPalette::Highlight : QPalette::PlaceholderText; + if (!pill_painter::paint(painter, opt, tintRole, emphasisRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); + } + return; + } + QStyledItemDelegate::paint(painter, opt, index); } QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { const QSize hint = QStyledItemDelegate::sizeHint(option, index); - if (index.column() == viaColumn) { + if (index.column() == viaColumn || index.column() == activeColumn) { return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y); } if (index.column() == warnsColumn || index.column() == bansColumn) { diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h index 73386b00d..6cd5c7fad 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -8,8 +8,10 @@ * Badge + icon painting for the User Alts table. The match-evidence column * renders as a palette-tinted pill per match type (email / client id / IP), and * an IP shared by more than one other account is dimmed. Warn/ban counts gain - * the style's warning/critical icons when nonzero. Selected rows and unknown - * content fall back to default painting. + * the style's warning/critical icons when nonzero. The active column renders as + * a palette-tinted pill (Highlight when active, PlaceholderText when inactive, + * from Qt::UserRole). Selected rows and unknown content fall back to default + * painting. */ class AltTableDelegate : public QStyledItemDelegate { @@ -19,6 +21,7 @@ public: int warnsColumn, int bansColumn, int sharedColumn, + int activeColumn, QObject *parent = nullptr); void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; @@ -28,6 +31,7 @@ private: int warnsColumn; int bansColumn; int sharedColumn; + int activeColumn; }; #endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp index 62e427e0e..361e89605 100644 --- a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -25,7 +25,7 @@ void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem & tintRole = QPalette::Highlight; } else if (status == QStringLiteral("assigned")) { tintRole = QPalette::Link; - } else if (status == QStringLiteral("resolved")) { + } else if (status == QStringLiteral("resolved") || status == QStringLiteral("dismissed")) { tintRole = QPalette::PlaceholderText; } diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.h b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h index 5c168bcd6..f01f6d3e3 100644 --- a/cockatrice/src/interface/widgets/tabs/report_status_delegate.h +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h @@ -6,9 +6,9 @@ /** * Status pill for the report queue table. Paints the status text on a - * translucent badge tinted with a palette role per status, so the colors - * always follow the active theme. Falls back to default painting for - * selected rows and unknown statuses. + * translucent badge tinted with a palette role per status (open, assigned, + * resolved, dismissed), so the colors always follow the active theme. + * Falls back to default painting for selected rows and unknown statuses. */ class ReportStatusDelegate : public QStyledItemDelegate { diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index d6b13b40e..d2cec3855 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -161,10 +161,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); - altsDelegate = new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, altsTable); + altsDelegate = + new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, COL_ALTS_ACTIVE, altsTable); altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_ACTIVE, altsDelegate); connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; @@ -497,7 +499,9 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); - altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + auto *activeItem = new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no")); + activeItem->setData(Qt::UserRole, alt.is_active()); + altsTable->setItem(row, COL_ALTS_ACTIVE, activeItem); // shared_count already includes the investigated account, so anything // above 2 means the address is shared beyond that pair. if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && From fb3f895cfd4a3427b61568ebd9425c00720d8035 Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 8 Nov 2026 09:01:56 +0100 Subject: [PATCH 090/104] [CI] Update Linux Dockerfiles (#6894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Install the distribution's gtest (libgtest-dev / gtest-devel) in the Debian, Ubuntu and Fedora images, so configuring the test suite no longer downloads googletest; Arch already had it. - Drop the toolchain meta packages. g++, Ninja and cmake are all that is used, and make is not: Ninja is the generator on every Linux leg. The DEB legs keep dpkg-dev, which CPack needs for dpkg-shlibdeps when CPACK_DEBIAN_PACKAGE_SHLIBDEPS is on. - Use the slim Debian bases and install Fedora packages with --nodocs. - Define the global rng in the tests that pull the server game classes out of server_remote, as the other server tests already do. Debian's libgtest.a leaves a std::string reference undefined, so the linker drags server_protocolhandler.cpp and the classes behind it into any test that links the library, and those need the symbol. git, ca-certificates, libcurl and OpenSSL stay: sentry-native is cloned over git at configure time on every packaging leg, and OpenSSL also backs CryptoUtil. mariadb-devel stays on Fedora for the same reason it is installed elsewhere: without it Servatrice configures successfully but silently loses its MySQL connector. Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- .ci/Arch/Dockerfile | 4 ++-- .ci/Debian12/Dockerfile | 5 +++-- .ci/Debian13/Dockerfile | 5 +++-- .ci/Fedora44/Dockerfile | 3 ++- .ci/Fedora45/Dockerfile | 3 ++- .ci/Servatrice_Debian12/Dockerfile | 4 ++-- .ci/Ubuntu24.04/Dockerfile | 3 ++- .ci/Ubuntu26.04/Dockerfile | 3 ++- tests/latency_tracker_test.cpp | 4 ++++ tests/loader_local_matching_test.cpp | 4 ++++ tests/server_counter_test.cpp | 4 ++++ 11 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index b08e568f3..4dc2f5e90 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -1,9 +1,9 @@ -from archlinux:latest +FROM archlinux:latest RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ - base-devel \ ccache \ cmake \ + gcc \ git \ gtest \ mariadb-libs \ diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index 4da910dc0..03f33b38e 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -1,16 +1,17 @@ -FROM debian:12 +FROM debian:12-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index 94f711398..8ee8cccff 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -1,16 +1,17 @@ -FROM debian:13 +FROM debian:13-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index 15204fa1a..d02649b4d 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -1,11 +1,12 @@ FROM fedora:44 -RUN dnf install -y \ +RUN dnf install -y --nodocs \ ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + gtest-devel \ libcurl-devel \ mariadb-devel \ ninja-build \ diff --git a/.ci/Fedora45/Dockerfile b/.ci/Fedora45/Dockerfile index a7c49b249..5527aac87 100644 --- a/.ci/Fedora45/Dockerfile +++ b/.ci/Fedora45/Dockerfile @@ -1,11 +1,12 @@ FROM fedora:45 -RUN dnf install -y \ +RUN dnf install -y --nodocs \ ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + gtest-devel \ libcurl-devel \ mariadb-devel \ ninja-build \ diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile index f1a16843a..8c166a85b 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -1,10 +1,10 @@ -FROM debian:12 +FROM debian:12-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 43497dd3b..47f927f41 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -2,15 +2,16 @@ FROM ubuntu:24.04 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 4dc861ae9..f50a3cec2 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -2,16 +2,17 @@ FROM ubuntu:26.04 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ gcovr \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/tests/latency_tracker_test.cpp b/tests/latency_tracker_test.cpp index a77051799..a8ecf9e8d 100644 --- a/tests/latency_tracker_test.cpp +++ b/tests/latency_tracker_test.cpp @@ -3,8 +3,12 @@ #include #include #include +#include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + TEST(LatencyTrackerTest, EmptyTrackerYieldsZeroedStats) { LatencyTracker tracker; diff --git a/tests/loader_local_matching_test.cpp b/tests/loader_local_matching_test.cpp index 7632e60d4..ef6018790 100644 --- a/tests/loader_local_matching_test.cpp +++ b/tests/loader_local_matching_test.cpp @@ -23,10 +23,14 @@ #include #include #include +#include #include #include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + namespace { diff --git a/tests/server_counter_test.cpp b/tests/server_counter_test.cpp index 492aecf66..f894abdbf 100644 --- a/tests/server_counter_test.cpp +++ b/tests/server_counter_test.cpp @@ -8,9 +8,13 @@ #include #include #include +#include #include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + TEST(ServerCounter, IncrementDoesNotOverflow) { Server_Counter c(1, "test", color(), 10, std::numeric_limits::max()); From 1ca67ebb0f126f3589323893dacd00757242bfd7 Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 8 Nov 2026 09:05:51 +0100 Subject: [PATCH 091/104] [CI] Fill in empty draft releases, never rewrite published ones (#7360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Check if release tag exists before blindly creating/overriding * cleanup diff * [CI] Fill in empty draft releases without touching published ones The existence check skipped release creation for any existing release, which left a draft saved from the GitHub UI empty and silently reused a published release. Inspect the release instead: create it when missing, fill in the title, notes and prerelease flag of an empty draft, and leave a draft that already has a body or a published release untouched. --------- Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- .github/workflows/desktop-build.yml | 36 +++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index b93d76b3d..861b28fbb 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -103,7 +103,7 @@ jobs: TAG: ${{ steps.configure.outputs.tag }} run: .ci/prep_release.sh - - name: "Create release" + - name: "Create or update draft release" if: steps.configure.outputs.tag != null id: create_release shell: bash @@ -115,13 +115,35 @@ jobs: body_path: ${{ steps.prepare.outputs.body_path }} prerelease: ${{ steps.prepare.outputs.is_beta }} run: | - args=() - [[ $prerelease == yes ]] && args+=(--prerelease) + # A release for this tag may already exist: a maintainer can save a draft in + # the GitHub UI before pushing the tag, or an earlier run may have left one. + # Never touch a published release, and never overwrite a draft that already + # has a body (generated notes or a maintainer's edits) - only fill in an + # empty draft. Prints " "; empty when there is none. + release_state="$(gh release view "$tag_name" --json isDraft,body \ + --template '{{.isDraft}} {{len (or .body "")}}' 2>/dev/null || true)" - gh release create "$tag_name" --verify-tag --draft "${args[@]}" \ - --target "$target" \ - --title "$release_name" \ - --notes-file "$body_path" + if [[ -z "$release_state" ]]; then + args=() + [[ $prerelease == yes ]] && args+=(--prerelease) + + gh release create "$tag_name" --verify-tag --draft "${args[@]}" \ + --target "$target" \ + --title "$release_name" \ + --notes-file "$body_path" + elif [[ "$release_state" == "true 0" ]]; then + echo "Filling in the empty draft release $tag_name" + args=(--prerelease=false) + [[ $prerelease == yes ]] && args=(--prerelease) + + gh release edit "$tag_name" "${args[@]}" \ + --title "$release_name" \ + --notes-file "$body_path" + elif [[ "$release_state" == true* ]]; then + echo "::warning::Draft release $tag_name already has a body, leaving it untouched" + else + echo "::warning::Release $tag_name is already published, leaving it untouched" + fi vcpkg-baseline: name: vcpkg baseline From aa0c8dc7cb9e573e14fa1b319b359007048caf28 Mon Sep 17 00:00:00 2001 From: tooomm Date: Sun, 8 Nov 2026 15:45:54 +0100 Subject: [PATCH 092/104] [CI] Pin used GH Actions (#7716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pin used actions plus some reordering and harmonization across files * [CI] Link the version string and RNG stub into the test targets The distribution libgtest.a now used by the Linux images makes the linker pull more translation units out of libcockatrice_network than the fetched googletest did. Those units reference VERSION_STRING and the global rng, which several test targets never defined, so the instrumented Ubuntu 26.04 legs failed at link: undefined reference to `VERSION_STRING' undefined reference to `rng' Add the generated version string source wherever a test links the network umbrella, and define the rng stub in the tests that pull the server game classes without declaring it. This matches what the neighbouring server tests already do. A full Debug build and ctest suite in the ci-ubuntu2604 image (system libgtest.a) is green: 841 tests pass. Fixes the nightly failures introduced by #6894. --------- Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- .github/dependabot.yml | 13 +++++- .github/workflows/codeql.yml | 6 +-- .github/workflows/desktop-build.yml | 42 +++++++++---------- .github/workflows/desktop-lint.yml | 7 ++-- .github/workflows/docker-release.yml | 22 +++++----- .github/workflows/documentation-build.yml | 14 +++---- .github/workflows/translations-pull.yml | 11 +++-- .github/workflows/translations-push.yml | 19 +++++---- .github/workflows/vcpkg-cache-import.yml | 27 +++++++----- tests/CMakeLists.txt | 7 ++++ tests/client_login_status_test.cpp | 4 ++ tests/server_dump_zone_test.cpp | 4 ++ .../theme_migration/theme_migration_test.cpp | 4 ++ 13 files changed, 107 insertions(+), 73 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 404cac62e..3085bde24 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,8 +15,11 @@ updates: # Check for updates once a month schedule: interval: "monthly" + # A new version is not considered for a version update until x days after its release (default = 3) + cooldown: + default-days: 3 # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) - open-pull-requests-limit: 2 + open-pull-requests-limit: 3 # # Enable version updates for Docker # Not yet possible to bump from one LTS version to the next and skip others, see: @@ -27,6 +30,9 @@ updates: # # Check for updates once a week # schedule: # interval: "weekly" + # # A new version is not considered for a version update until x days after its release (default = 3) + # cooldown: + # default-days: 3 # # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) # open-pull-requests-limit: 1 @@ -37,5 +43,8 @@ updates: # Check for updates to GitHub Actions once a week schedule: interval: "weekly" + # A new version is not considered for a version update until x days after its release (default = 3) + cooldown: + default-days: 3 # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) - open-pull-requests-limit: 2 + open-pull-requests-limit: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 75494dc3e..a2c460d62 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,10 +40,10 @@ jobs: steps: - name: "Checkout repository" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Initialize CodeQL" - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -92,6 +92,6 @@ jobs: run: cmake --build build - name: "Perform CodeQL Analysis" - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 861b28fbb..731bff41b 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -91,7 +91,7 @@ jobs: - name: "Checkout" if: steps.configure.outputs.tag != null - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # fetch all history for all branches and tags @@ -150,7 +150,7 @@ jobs: runs-on: ubuntu-slim steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # vcpkg-tool reads versions/baseline.json out of the commit named by # `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule @@ -337,12 +337,12 @@ jobs: # Shared with build-linux-nightly so the two leg sets run the same steps. steps: &linux-steps - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Restore compiler cache (ccache)" id: restore_ccache if: env.PERSIST_CCACHE == 'true' - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} path: ${{ env.CACHE }}/.ccache @@ -389,7 +389,7 @@ jobs: - name: "Upload coverage artifact" if: matrix.coverage == 'yes' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-report path: coverage/ @@ -507,7 +507,7 @@ jobs: - name: "Save updated compiler cache (ccache)" if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CACHE }}/.ccache @@ -515,7 +515,7 @@ jobs: - name: "Upload artifact" id: upload_artifact if: matrix.package != 'skip' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: archive: false if-no-files-found: error @@ -535,7 +535,7 @@ jobs: - name: "Attest binary provenance" id: attestation if: steps.upload_release.outcome == 'success' - uses: actions/attest@v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: show-summary: false subject-path: ${{ steps.build.outputs.path }} @@ -749,13 +749,13 @@ jobs: # Shared with build-vcpkg-nightly so the two leg sets run the same steps. steps: &vcpkg-steps - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - name: "[Windows] Add msbuild to PATH" if: matrix.os == 'Windows' - uses: microsoft/setup-msbuild@v3 + uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0 with: msbuild-architecture: x64 @@ -771,7 +771,7 @@ jobs: - name: "Restore compiler cache (ccache)" if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' id: restore_ccache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ccache-${{ matrix.os }}${{ matrix.target }} path: ${{ env.CCACHE_DIR }} @@ -800,7 +800,7 @@ jobs: - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}" if: matrix.os == 'macOS' id: restore_qt - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-${{ env.QT_ARCHIVES }}-thin path: ${{ github.workspace }}/Qt @@ -809,7 +809,7 @@ jobs: # Qt build using vcpkg either just fails or takes too long to build - name: "[macOS] Install fat Qt ${{ matrix.qt_version }}" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0 with: archives: ${{ env.QT_ARCHIVES }} cache: false @@ -824,14 +824,14 @@ jobs: - name: "[macOS] Cache thin Qt libraries" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.restore_qt.outputs.cache-primary-key }} path: ${{ github.workspace }}/Qt - name: "[Windows] Install Qt ${{ matrix.qt_version }}" if: matrix.os == 'Windows' - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0 with: # Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released aqtsource: git+https://github.com/miurahr/aqtinstall.git @@ -855,7 +855,7 @@ jobs: - name: "Setup vcpkg cache" id: vcpkg-cache - uses: TAServers/vcpkg-cache@v3 + uses: TAServers/vcpkg-cache@4f07be616a4604dd29f3c6885975cf2c04abee71 # v3.4.2 with: # Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform, # target and toolchain. The action restores every entry it finds under the @@ -1115,7 +1115,7 @@ jobs: - name: "Upload new vcpkg entries" # An empty count means the step above did not run at all, on master and on forks if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: if-no-files-found: error # a count above zero with nothing staged is a bug name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }} @@ -1138,7 +1138,7 @@ jobs: - name: "Cache updated compiler cache (ccache)" if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CCACHE_DIR }} @@ -1193,7 +1193,7 @@ jobs: - name: "Upload artifact" if: matrix.make_package id: upload_artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: archive: false if-no-files-found: error @@ -1225,7 +1225,7 @@ jobs: # debug identifier, and the PE is what carries the unwind information. - name: "[Windows] Upload debug symbols (exe + pdb)" if: matrix.os == 'Windows' && needs.configure.outputs.tag != null - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: if-no-files-found: error name: ${{ steps.build.outputs.name }}-Symbols @@ -1240,7 +1240,7 @@ jobs: - name: "Attest binary provenance" if: steps.upload_release.outcome == 'success' id: attestation - uses: actions/attest@v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: show-summary: false subject-path: ${{ steps.build.outputs.path }} diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml index f42f26e6a..be08420ca 100644 --- a/.github/workflows/desktop-lint.yml +++ b/.github/workflows/desktop-lint.yml @@ -1,7 +1,7 @@ name: Code Style (C++) permissions: - contents: read + contents: read # needed to checkout repo on: # Push trigger not needed for linting, we do not allow direct pushes to master @@ -21,11 +21,12 @@ on: jobs: format: - runs-on: ubuntu-slim + name: "Check formatting" + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 20 # should be enough to find merge base diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 1537d6d4d..26d61c181 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -56,7 +56,7 @@ jobs: include: - label: x86 platform: linux/amd64 - runner: ubuntu-latest # https://github.com/actions/runner-images + runner: ubuntu-latest # https://github.com/actions/runner-images#available-images - label: arm platform: linux/arm64 @@ -67,17 +67,17 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ case(github.event_name == 'workflow_dispatch', format('refs/tags/{0}', inputs.tag), '') }} - name: "Set up Docker buildx" - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: "Login to GitHub Container Registry (GHCR)" if: (github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch' id: login - uses: docker/login-action@v4 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -92,7 +92,7 @@ jobs: # cache held. The final runtime stage is the part that still pays off. - name: "Build image" if: steps.login.outcome != 'success' - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }} @@ -104,7 +104,7 @@ jobs: - name: "Build image and push by digest" if: steps.login.outcome == 'success' id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }} @@ -115,7 +115,7 @@ jobs: org.opencontainers.image.url=${{ env.OCI_URL }} outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true platforms: ${{ matrix.platform }} - provenance: mode=max # Do not pass secrets as build arguments with this option + provenance: mode=max # do not pass secrets as build arguments with this option sbom: true - name: "Export digest" @@ -128,7 +128,7 @@ jobs: - name: "Upload digest" if: steps.login.outcome == 'success' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: if-no-files-found: error name: digest-${{ matrix.label }} @@ -145,14 +145,14 @@ jobs: steps: - name: "Download digests" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: ${{ runner.temp }}/digests pattern: digest-* merge-multiple: true - name: "Login to GitHub Container Registry (GHCR)" - uses: docker/login-action@v4 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -160,7 +160,7 @@ jobs: - name: "Docker metadata" id: metadata - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.GHCR_IMAGE }} flavor: | diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index b2f85e267..fbc9db61e 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -1,4 +1,4 @@ -name: Generate Docs +name: Build Documentation permissions: contents: read # write permission to the destination repo come from 'deploy_key' @@ -24,12 +24,12 @@ env: jobs: docs: - name: Doxygen - runs-on: ubuntu-latest + name: "Doxygen" + runs-on: ubuntu-latest # https://github.com/actions/runner-images#available-images steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive @@ -40,9 +40,9 @@ jobs: dot -V - name: "Install Doxygen" - uses: ssciwr/doxygen-install@v2 + uses: ssciwr/doxygen-install@fa3e94d34131d3990b6b8ba939ab982964aa3758 # v2.0.3 with: - version: "1.18.0" + version: "1.18.0" # https://www.doxygen.nl/manual/changelog.html - name: "Update Doxygen Configuration" shell: bash @@ -65,7 +65,7 @@ jobs: - name: "Deploy to cockatrice.github.io/docs" if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: peaceiris/actions-gh-pages@v4 + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 with: deploy_key: ${{ secrets.DOCS_DEPLOY_KEY }} destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/ diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml index da3df1714..e2da19a9e 100644 --- a/.github/workflows/translations-pull.yml +++ b/.github/workflows/translations-pull.yml @@ -16,20 +16,19 @@ on: jobs: translations: + name: "Pull languages" # Do not run the scheduled workflow on forks if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice' - - name: Pull languages - runs-on: ubuntu-slim + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md steps: - name: "Checkout repo" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Pull translated strings from Transifex" # Do not run this step for PR's from forks, they don't have access to the secret if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false - uses: transifex/cli-action@v2 + uses: transifex/cli-action@584fd205cbe598773b5a81ce711fa44842678189 # v2 with: # Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config # Docs: https://github.com/transifex/cli#pulling-files-from-transifex @@ -39,7 +38,7 @@ jobs: - name: "Create pull request" if: github.event_name != 'pull_request' id: create_pr - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: add-paths: | cockatrice/translations/*.ts diff --git a/.github/workflows/translations-push.yml b/.github/workflows/translations-push.yml index 17645cab2..9f59fbdfc 100644 --- a/.github/workflows/translations-push.yml +++ b/.github/workflows/translations-push.yml @@ -16,15 +16,14 @@ on: jobs: translations: + name: "Push strings" # Do not run the scheduled workflow on forks if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice' - - name: Push strings - runs-on: ubuntu-slim + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md steps: - name: "Checkout repo" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Install lupdate" shell: bash @@ -37,20 +36,22 @@ jobs: FILE: cockatrice/cockatrice_en@source.ts id: cockatrice shell: bash - run: > - DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')" + run: | + export DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')" .ci/update_translation_source_strings.sh - name: "Update Oracle translation source" id: oracle shell: bash env: - DIRS: 'oracle/src' - FILE: 'oracle/oracle_en@source.ts' + DIRS: oracle/src + FILE: oracle/oracle_en@source.ts run: .ci/update_translation_source_strings.sh - name: "Render template" id: template + # Not pinnable to a SHA: the action downloads its release binary by ref name, + # so a commit ref matches no release tag. uses: chuhlomin/render-template/binary@v1 with: template: .ci/update_translation_source_strings_template.md @@ -62,7 +63,7 @@ jobs: - name: "Create pull request" if: github.event_name != 'pull_request' id: create_pr - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: add-paths: | cockatrice/cockatrice_en@source.ts diff --git a/.github/workflows/vcpkg-cache-import.yml b/.github/workflows/vcpkg-cache-import.yml index 0a377a8fd..699d8324b 100644 --- a/.github/workflows/vcpkg-cache-import.yml +++ b/.github/workflows/vcpkg-cache-import.yml @@ -23,8 +23,10 @@ permissions: on: workflow_run: - workflows: ["Build Desktop"] - types: [completed] + workflows: + - 'Build Desktop' + types: + - completed # Every import writes the same scopes, so let them queue instead of racing each other concurrency: @@ -33,12 +35,12 @@ concurrency: jobs: list: - name: List new vcpkg entries + name: "List new vcpkg entries" # Only a successful push to the default branch has archives worth importing. # Only code that landed on master may populate the scope master runs restore. # Bot actors are excluded on top: their runs must never promote binaries into # the shared scope even if they somehow target the default branch. - if: >- + if: | github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') && github.event.workflow_run.head_branch == 'master' && @@ -46,6 +48,7 @@ jobs: !endsWith(github.event.workflow_run.actor.login, '[bot]') && !endsWith(github.event.workflow_run.triggering_actor.login, '[bot]') runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md + # workflow_run is a low-trust trigger, so cache access is read-only by default even # though this run resolves to the default branch. Assert below that this took effect, # because a save under read-only access only warns and would import nothing silently. @@ -55,7 +58,7 @@ jobs: steps: - name: "Download new vcpkg entries" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: vcpkg-new-* path: incoming @@ -66,7 +69,7 @@ jobs: shell: bash run: | if [[ $ACTIONS_CACHE_MODE != *write* ]]; then - echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'" + echo "::error::Expected a write-capable cache, got '$ACTIONS_CACHE_MODE'" exit 1 fi @@ -88,14 +91,16 @@ jobs: # Compact, because a multi line value cannot be written to GITHUB_OUTPUT entries="$(jq -cs '.' <<<"$entries")" echo "entries=$entries" >>"$GITHUB_OUTPUT" - echo "::notice::importing $(jq length <<<"$entries") vcpkg entries" + echo "::notice::Importing $(jq length <<<"$entries") vcpkg entries" save: name: "Save ${{ matrix.entry.key }}" - needs: list if: needs.list.outputs.entries - runs-on: ubuntu-slim + needs: list + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md + cache-mode: write + strategy: # One job per archive, they run in parallel and each only uploads a single file fail-fast: false @@ -105,7 +110,7 @@ jobs: steps: - name: "Download new vcpkg entries" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: ${{ matrix.entry.artifact }} path: incoming @@ -113,7 +118,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: "Save vcpkg entry" - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: # The payload is a Windows or macOS archive while the upload runs on Linux, and # without this the entry would be stamped as a Linux cache that neither of them diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 84b9f02bf..0450e5b8a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -300,6 +300,7 @@ cockatrice_add_test( NAME server_card_counter_test SOURCES + ${VERSION_STRING_CPP} server_card_counter_test.cpp LIBS libcockatrice_network @@ -310,6 +311,7 @@ cockatrice_add_test( NAME server_dump_zone_test SOURCES + ${VERSION_STRING_CPP} server_dump_zone_test.cpp LIBS libcockatrice_rng @@ -321,6 +323,7 @@ cockatrice_add_test( NAME server_counter_test SOURCES + ${VERSION_STRING_CPP} server_counter_test.cpp LIBS libcockatrice_network @@ -332,6 +335,7 @@ cockatrice_add_test( server_developer_role_test NO_SANITIZE SOURCES + ${VERSION_STRING_CPP} server_developer_role_test.cpp LIBS libcockatrice_network @@ -344,6 +348,7 @@ cockatrice_add_test( server_moderator_role_test NO_SANITIZE SOURCES + ${VERSION_STRING_CPP} server_moderator_role_test.cpp LIBS libcockatrice_network @@ -390,6 +395,7 @@ cockatrice_add_test( NAME local_client_lifetime_test SOURCES + ${VERSION_STRING_CPP} local_client_lifetime_test.cpp LIBS libcockatrice_network @@ -411,6 +417,7 @@ cockatrice_add_test( NAME latency_tracker_test SOURCES + ${VERSION_STRING_CPP} latency_tracker_test.cpp LIBS libcockatrice_network diff --git a/tests/client_login_status_test.cpp b/tests/client_login_status_test.cpp index d8ea63550..a2e073973 100644 --- a/tests/client_login_status_test.cpp +++ b/tests/client_login_status_test.cpp @@ -13,6 +13,10 @@ #include #include #include +#include + +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; namespace { diff --git a/tests/server_dump_zone_test.cpp b/tests/server_dump_zone_test.cpp index 71c410655..e3284efbb 100644 --- a/tests/server_dump_zone_test.cpp +++ b/tests/server_dump_zone_test.cpp @@ -7,8 +7,12 @@ #include #include #include +#include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + namespace { /// Indices a dump request resolves to, as the handler would walk them. diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index d77c2f62d..a953c9f0b 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -9,12 +9,16 @@ #include #include #include +#include #include // Owned by main.cpp in the real application; pixel_map_generator.cpp references // it, so the test binary needs a definition even though it never renders. ThemeManager *themeManager = nullptr; +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + namespace { const QString LEGACY = QStringLiteral("Default"); From 870e4be310ad3ed03e775cdccd1dab5962e3e37d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:21:03 +0100 Subject: [PATCH 093/104] Bump docker/login-action from 4.7.0 to 4.8.0 (#7812) Bumps [docker/login-action](https://github.com/docker/login-action) from 4.7.0 to 4.8.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/81c6f93d4bc991e510b342a00b419b3bb4efc5c6...8aa5831fb8d48d8f76594c7ddde61ae58019f550) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 26d61c181..3b8515bff 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -77,7 +77,7 @@ jobs: - name: "Login to GitHub Container Registry (GHCR)" if: (github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch' id: login - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -152,7 +152,7 @@ jobs: merge-multiple: true - name: "Login to GitHub Container Registry (GHCR)" - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: registry: ghcr.io username: ${{ github.actor }} From 554368967d3aeac77c6d0b033db0b960bf68e87c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:22:30 +0100 Subject: [PATCH 094/104] [Refactor] Extract GameCreationOptions from DlgCreateGame (#7800) * Extract GameCreationOptions from DlgCreateGame * Address review findings --- cockatrice/CMakeLists.txt | 1 + .../widgets/dialogs/dlg_create_game.cpp | 41 ++++---- .../game_hosting/game_creation_options.cpp | 24 +++++ .../game_hosting/game_creation_options.h | 44 +++++++++ tests/CMakeLists.txt | 11 +++ tests/game_creation_options_test.cpp | 93 +++++++++++++++++++ 6 files changed, 197 insertions(+), 17 deletions(-) create mode 100644 cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp create mode 100644 cockatrice/src/interface/widgets/game_hosting/game_creation_options.h create mode 100644 tests/game_creation_options_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 984784161..46e6702d9 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -216,6 +216,7 @@ set(cockatrice_SOURCES 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/game_hosting/game_creation_options.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp src/interface/widgets/general/display/banner_widget.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index ada4237f7..2030882fd 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -1,6 +1,7 @@ #include "dlg_create_game.h" #include "../../../client/settings/cache_settings.h" +#include "../interface/widgets/game_hosting/game_creation_options.h" #include "../interface/widgets/tabs/tab_room.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/room_commands.pb.h" @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -287,32 +289,37 @@ void DlgCreateGame::actReset() void DlgCreateGame::actOK() { - Command_CreateGame cmd; - cmd.set_description(descriptionEdit->text().simplified().toStdString()); - cmd.set_password(passwordEdit->text().toStdString()); - cmd.set_max_players(maxPlayersEdit->value()); - cmd.set_only_buddies(onlyBuddiesCheckBox->isChecked()); - cmd.set_only_registered(onlyRegisteredCheckBox->isChecked()); - cmd.set_spectators_allowed(spectatorsAllowedCheckBox->isChecked()); - cmd.set_spectators_need_password(spectatorsNeedPasswordCheckBox->isChecked()); - cmd.set_spectators_can_talk(spectatorsCanTalkCheckBox->isChecked()); - cmd.set_spectators_see_everything(spectatorsSeeEverythingCheckBox->isChecked()); - cmd.set_join_as_judge(createGameAsJudgeCheckBox->isChecked()); - cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked()); - cmd.set_starting_life_total(startingLifeTotalEdit->value()); - cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked()); - cmd.set_shuffle_players(shufflePlayersCheckBox->isChecked()); - auto _gameTypes = QString(); + QList gameTypeIds; QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); while (gameTypeCheckBoxIterator.hasNext()) { gameTypeCheckBoxIterator.next(); if (gameTypeCheckBoxIterator.value()->isChecked()) { - cmd.add_game_type_ids(gameTypeCheckBoxIterator.key()); + gameTypeIds.append(gameTypeCheckBoxIterator.key()); _gameTypes += gameTypeCheckBoxIterator.value()->text() + ", "; } } + GameCreationOptions options{ + .description = descriptionEdit->text(), + .password = passwordEdit->text(), + .maxPlayers = maxPlayersEdit->value(), + .onlyBuddies = onlyBuddiesCheckBox->isChecked(), + .onlyRegistered = onlyRegisteredCheckBox->isChecked(), + .spectatorsAllowed = spectatorsAllowedCheckBox->isChecked(), + .spectatorsNeedPassword = spectatorsNeedPasswordCheckBox->isChecked(), + .spectatorsCanTalk = spectatorsCanTalkCheckBox->isChecked(), + .spectatorsSeeEverything = spectatorsSeeEverythingCheckBox->isChecked(), + .joinAsJudge = createGameAsJudgeCheckBox->isChecked(), + .joinAsSpectator = createGameAsSpectatorCheckBox->isChecked(), + .startingLifeTotal = startingLifeTotalEdit->value(), + .shareDecklistsOnLoad = shareDecklistsOnLoadCheckBox->isChecked(), + .shufflePlayers = shufflePlayersCheckBox->isChecked(), + .gameTypeIds = gameTypeIds, + }; + + Command_CreateGame cmd = options.toCreateGameCommand(); + SettingsCache::instance().game().setRememberGameSettings(rememberGameSettings->isChecked()); if (rememberGameSettings->isChecked()) { SettingsCache::instance().game().setGameDescription(descriptionEdit->text()); diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp new file mode 100644 index 000000000..33e30adb1 --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp @@ -0,0 +1,24 @@ +#include "game_creation_options.h" + +Command_CreateGame GameCreationOptions::toCreateGameCommand() const +{ + Command_CreateGame cmd; + cmd.set_description(description.simplified().toStdString()); + cmd.set_password(password.toStdString()); + cmd.set_max_players(maxPlayers); + cmd.set_only_buddies(onlyBuddies); + cmd.set_only_registered(onlyRegistered); + cmd.set_spectators_allowed(spectatorsAllowed); + cmd.set_spectators_need_password(spectatorsNeedPassword); + cmd.set_spectators_can_talk(spectatorsCanTalk); + cmd.set_spectators_see_everything(spectatorsSeeEverything); + cmd.set_join_as_judge(joinAsJudge); + cmd.set_join_as_spectator(joinAsSpectator); + cmd.set_starting_life_total(startingLifeTotal); + cmd.set_share_decklists_on_load(shareDecklistsOnLoad); + cmd.set_shuffle_players(shufflePlayers); + for (int gameTypeId : gameTypeIds) { + cmd.add_game_type_ids(gameTypeId); + } + return cmd; +} diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h new file mode 100644 index 000000000..15284eee0 --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h @@ -0,0 +1,44 @@ +/** + * @file game_creation_options.h + * @ingroup RoomWidgets + */ + +#ifndef GAME_CREATION_OPTIONS_H +#define GAME_CREATION_OPTIONS_H + +#include "libcockatrice/protocol/pb/room_commands.pb.h" + +#include +#include + +/** + * @brief The values needed to create a game, independent of the widgets that edit them. + * + * Pure value object: it holds no widget or settings dependencies, so every host entry + * point can build a @ref Command_CreateGame through the same conversion. + */ +struct GameCreationOptions +{ + QString description; + QString password; + int maxPlayers = 2; + bool onlyBuddies = false; + bool onlyRegistered = false; + bool spectatorsAllowed = true; + bool spectatorsNeedPassword = false; + bool spectatorsCanTalk = false; + bool spectatorsSeeEverything = false; + bool joinAsJudge = false; + bool joinAsSpectator = false; + int startingLifeTotal = 20; + bool shareDecklistsOnLoad = false; + bool shufflePlayers = false; + QList gameTypeIds; + + /** + * @brief Builds the room command, simplifying the description's whitespace. + */ + [[nodiscard]] Command_CreateGame toCreateGameCommand() const; +}; + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0450e5b8a..7595706b2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -462,6 +462,17 @@ cockatrice_add_test( INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src ) +cockatrice_add_test( + NAME + game_creation_options_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp + game_creation_options_test.cpp + LIBS + libcockatrice_protocol + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) cockatrice_add_test( NAME deck_hash_performance_test diff --git a/tests/game_creation_options_test.cpp b/tests/game_creation_options_test.cpp new file mode 100644 index 000000000..e182e4e91 --- /dev/null +++ b/tests/game_creation_options_test.cpp @@ -0,0 +1,93 @@ +#include "interface/widgets/game_hosting/game_creation_options.h" + +#include +#include + +TEST(GameCreationOptions, DefaultsMatchTheDialog) +{ + const GameCreationOptions options; + + EXPECT_TRUE(options.description.isEmpty()); + EXPECT_TRUE(options.password.isEmpty()); + EXPECT_EQ(options.maxPlayers, 2); + EXPECT_FALSE(options.onlyBuddies); + EXPECT_FALSE(options.onlyRegistered); + EXPECT_TRUE(options.spectatorsAllowed); + EXPECT_FALSE(options.spectatorsNeedPassword); + EXPECT_FALSE(options.spectatorsCanTalk); + EXPECT_FALSE(options.spectatorsSeeEverything); + EXPECT_FALSE(options.joinAsJudge); + EXPECT_FALSE(options.joinAsSpectator); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_FALSE(options.shareDecklistsOnLoad); + EXPECT_FALSE(options.shufflePlayers); + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(GameCreationOptions, EveryFieldMapsToTheCommand) +{ + GameCreationOptions options; + options.description = "Some game"; + options.password = "hunter2"; + options.maxPlayers = 4; + options.onlyBuddies = true; + options.onlyRegistered = true; + options.spectatorsAllowed = false; + options.spectatorsNeedPassword = true; + options.spectatorsCanTalk = true; + options.spectatorsSeeEverything = true; + options.joinAsJudge = true; + options.joinAsSpectator = true; + options.startingLifeTotal = 40; + options.shareDecklistsOnLoad = true; + options.shufflePlayers = true; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + EXPECT_EQ(cmd.description(), "Some game"); + EXPECT_EQ(cmd.password(), "hunter2"); + EXPECT_EQ(cmd.max_players(), 4); + EXPECT_TRUE(cmd.only_buddies()); + EXPECT_TRUE(cmd.only_registered()); + EXPECT_FALSE(cmd.spectators_allowed()); + EXPECT_TRUE(cmd.spectators_need_password()); + EXPECT_TRUE(cmd.spectators_can_talk()); + EXPECT_TRUE(cmd.spectators_see_everything()); + EXPECT_TRUE(cmd.join_as_judge()); + EXPECT_TRUE(cmd.join_as_spectator()); + EXPECT_EQ(cmd.starting_life_total(), 40); + EXPECT_TRUE(cmd.share_decklists_on_load()); + EXPECT_TRUE(cmd.shuffle_players()); +} + +TEST(GameCreationOptions, GameTypeIdsKeepTheirOrder) +{ + GameCreationOptions options; + options.gameTypeIds = {7, 2, 5}; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + ASSERT_EQ(cmd.game_type_ids_size(), 3); + EXPECT_EQ(cmd.game_type_ids(0), 7); + EXPECT_EQ(cmd.game_type_ids(1), 2); + EXPECT_EQ(cmd.game_type_ids(2), 5); +} + +TEST(GameCreationOptions, EmptyGameTypeIdsProduceAnEmptyRepeatedField) +{ + GameCreationOptions options; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + EXPECT_EQ(cmd.game_type_ids_size(), 0); +} + +TEST(GameCreationOptions, DescriptionWhitespaceIsSimplified) +{ + GameCreationOptions options; + options.description = " a b "; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + EXPECT_EQ(cmd.description(), "a b"); +} From 39220a2ea9f163f194fe0506732c609b26528042 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:22:31 +0100 Subject: [PATCH 095/104] [Refactor] Split CreateGameFormWidget out of DlgCreateGame (#7801) * Extract CreateGameFormWidget from DlgCreateGame * Address review findings --- cockatrice/CMakeLists.txt | 1 + .../widgets/dialogs/dlg_create_game.cpp | 378 ++++-------------- .../widgets/dialogs/dlg_create_game.h | 34 +- .../game_hosting/create_game_form_widget.cpp | 260 ++++++++++++ .../game_hosting/create_game_form_widget.h | 69 ++++ .../game_hosting/game_creation_options.cpp | 64 +++ .../game_hosting/game_creation_options.h | 20 + tests/CMakeLists.txt | 14 + tests/create_game_form_widget_test.cpp | 132 ++++++ tests/game_creation_options_test.cpp | 217 ++++++++++ 10 files changed, 864 insertions(+), 325 deletions(-) create mode 100644 cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp create mode 100644 cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h create mode 100644 tests/create_game_form_widget_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 46e6702d9..77cb7f014 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -216,6 +216,7 @@ set(cockatrice_SOURCES 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/game_hosting/create_game_form_widget.cpp src/interface/widgets/game_hosting/game_creation_options.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 2030882fd..f5ba5458c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -1,341 +1,109 @@ #include "dlg_create_game.h" #include "../../../client/settings/cache_settings.h" -#include "../interface/widgets/game_hosting/game_creation_options.h" -#include "../interface/widgets/tabs/tab_room.h" +#include "../game_hosting/create_game_form_widget.h" +#include "../game_hosting/game_creation_options.h" +#include "../tabs/tab_room.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/room_commands.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" -#include "libcockatrice/utility/macros.h" +#include "libcockatrice/protocol/pending_command.h" +#include "libcockatrice/settings/game_settings.h" -#include -#include #include -#include -#include #include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include -#include - -class QWidget; - -void DlgCreateGame::sharedCtor() -{ - rememberGameSettings = new QCheckBox(tr("Re&member settings")); - descriptionLabel = new QLabel(tr("&Description:")); - descriptionEdit = new QLineEdit; - descriptionEdit->setMaxLength(MAX_NAME_LENGTH); - descriptionLabel->setBuddy(descriptionEdit); - - maxPlayersLabel = new QLabel(tr("P&layers:")); - maxPlayersEdit = new QSpinBox(); - maxPlayersEdit->setMinimum(1); - maxPlayersEdit->setMaximum(100); - maxPlayersEdit->setValue(2); - maxPlayersLabel->setBuddy(maxPlayersEdit); - - auto *generalGrid = new QGridLayout; - generalGrid->addWidget(descriptionLabel, 0, 0); - generalGrid->addWidget(descriptionEdit, 0, 1); - generalGrid->addWidget(maxPlayersLabel, 1, 0); - generalGrid->addWidget(maxPlayersEdit, 1, 1); - generalGroupBox = new QGroupBox(tr("General")); - generalGroupBox->setLayout(generalGrid); - - auto *gameTypeLayout = new QVBoxLayout; - QMapIterator gameTypeIterator(gameTypes); - while (gameTypeIterator.hasNext()) { - gameTypeIterator.next(); - auto *gameTypeRadioButton = new QRadioButton(gameTypeIterator.value(), this); - gameTypeLayout->addWidget(gameTypeRadioButton); - gameTypeCheckBoxes.insert(gameTypeIterator.key(), gameTypeRadioButton); - bool isChecked = SettingsCache::instance().game().getGameTypes().contains(gameTypeIterator.value() + ", "); - gameTypeCheckBoxes[gameTypeIterator.key()]->setChecked(isChecked); - } - auto *gameTypeGroupBox = new QGroupBox(tr("Game type")); - gameTypeGroupBox->setLayout(gameTypeLayout); - - passwordLabel = new QLabel(tr("&Password:")); - passwordEdit = new QLineEdit; - passwordEdit->setMaxLength(MAX_NAME_LENGTH); - passwordLabel->setBuddy(passwordEdit); - - onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join")); - onlyRegisteredCheckBox = new QCheckBox(tr("Only ®istered users can join")); - if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) { - onlyRegisteredCheckBox->setChecked(true); - } else { - onlyBuddiesCheckBox->setEnabled(false); - onlyRegisteredCheckBox->setEnabled(false); - } - - auto *joinRestrictionsLayout = new QGridLayout; - joinRestrictionsLayout->addWidget(passwordLabel, 0, 0); - joinRestrictionsLayout->addWidget(passwordEdit, 0, 1); - joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2); - joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2); - - auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions")); - joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout); - - spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch")); - spectatorsAllowedCheckBox->setChecked(true); - connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &DlgCreateGame::spectatorsAllowedChanged); - spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch")); - spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat")); - spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands")); - createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator")); - auto *spectatorsLayout = new QVBoxLayout; - spectatorsLayout->addWidget(spectatorsAllowedCheckBox); - spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox); - spectatorsLayout->addWidget(spectatorsCanTalkCheckBox); - spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox); - spectatorsLayout->addWidget(createGameAsSpectatorCheckBox); - spectatorsGroupBox = new QGroupBox(tr("Spectators")); - spectatorsGroupBox->setLayout(spectatorsLayout); - - startingLifeTotalLabel = new QLabel(tr("Starting life total:")); - startingLifeTotalEdit = new QSpinBox(); - startingLifeTotalEdit->setMinimum(1); - startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining. - startingLifeTotalEdit->setValue(20); - startingLifeTotalLabel->setBuddy(startingLifeTotalEdit); - - shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); - - shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start")); - - createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); - - auto *gameSetupOptionsLayout = new QGridLayout; - gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); - gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); - gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2); - gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2); - if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { - gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2); - } else { - createGameAsJudgeCheckBox->setChecked(false); - createGameAsJudgeCheckBox->setHidden(true); - } - gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options")); - gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout); - - auto *grid = new QGridLayout; - - // Top row - grid->addWidget(generalGroupBox, 0, 0); - grid->addWidget(joinRestrictionsGroupBox, 0, 1); - - // Middle row: left column - grid->addWidget(gameTypeGroupBox, 1, 0); - - // Middle row: right column (game setup + spectators) - auto *rightLayout = new QVBoxLayout; - rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top - rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom - - grid->addLayout(rightLayout, 1, 1); - - // Bottom row - grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed - - buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok); - connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject); - - auto *mainLayout = new QVBoxLayout; - mainLayout->addLayout(grid); - mainLayout->addWidget(buttonBox); - - setLayout(mainLayout); - - setFixedHeight(sizeHint().height()); -} DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameTypes, QWidget *parent) - : QDialog(parent), room(_room), gameTypes(_gameTypes) + : QDialog(parent), room(_room) { - sharedCtor(); + const bool registered = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered); + const bool judge = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsJudge); - rememberGameSettings->setChecked(SettingsCache::instance().game().getRememberGameSettings()); - descriptionEdit->setText(SettingsCache::instance().game().getGameDescription()); - maxPlayersEdit->setValue(SettingsCache::instance().game().getMaxPlayers()); - if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) { - onlyBuddiesCheckBox->setChecked(SettingsCache::instance().game().getOnlyBuddies()); - onlyRegisteredCheckBox->setChecked(SettingsCache::instance().game().getOnlyRegistered()); + form = new CreateGameFormWidget(_gameTypes, registered, judge, this); + const bool rememberSettings = SettingsCache::instance().game().getRememberGameSettings(); + form->setRememberSettingsChecked(rememberSettings); + if (rememberSettings) { + form->setOptions(GameCreationOptions::fromSettings(SettingsCache::instance().game(), _gameTypes, registered)); } else { - onlyBuddiesCheckBox->setEnabled(false); - onlyRegisteredCheckBox->setEnabled(false); + form->resetToDefaults(); } - spectatorsAllowedCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsAllowed()); - spectatorsNeedPasswordCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsNeedPassword()); - spectatorsCanTalkCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanTalk()); - spectatorsSeeEverythingCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanSeeEverything()); - createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator()); - startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal()); - shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad()); - shufflePlayersCheckBox->setChecked(SettingsCache::instance().game().getShufflePlayers()); + form->focusDescription(); - if (!rememberGameSettings->isChecked()) { - actReset(); - } + errorLabel = new QLabel(this); + errorLabel->setObjectName("createGameErrorLabel"); + errorLabel->setWordWrap(true); + errorLabel->setVisible(false); - descriptionEdit->setFocus(); - clearButton = new QPushButton(tr("&Clear")); + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this); + connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject); + clearButton = new QPushButton(tr("&Clear"), this); buttonBox->addButton(QDialogButtonBox::Cancel); buttonBox->addButton(clearButton, QDialogButtonBox::ActionRole); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::actOK); connect(clearButton, &QPushButton::clicked, this, &DlgCreateGame::actReset); + auto *mainLayout = new QVBoxLayout; + mainLayout->addWidget(form); + mainLayout->addWidget(errorLabel); + mainLayout->addWidget(buttonBox); + + setLayout(mainLayout); setWindowTitle(tr("Create game")); } DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap &_gameTypes, QWidget *parent) - : QDialog(parent), room(0), gameTypes(_gameTypes) + : QDialog(parent), room(nullptr) { - sharedCtor(); - - rememberGameSettings->setEnabled(false); - descriptionEdit->setEnabled(false); - maxPlayersEdit->setEnabled(false); - passwordEdit->setEnabled(false); - onlyBuddiesCheckBox->setEnabled(false); - onlyRegisteredCheckBox->setEnabled(false); - spectatorsAllowedCheckBox->setEnabled(false); - spectatorsNeedPasswordCheckBox->setEnabled(false); - spectatorsCanTalkCheckBox->setEnabled(false); - spectatorsSeeEverythingCheckBox->setEnabled(false); - createGameAsSpectatorCheckBox->setEnabled(false); - startingLifeTotalEdit->setEnabled(false); - shareDecklistsOnLoadCheckBox->setEnabled(false); - shufflePlayersCheckBox->setEnabled(false); - - descriptionEdit->setText(QString::fromStdString(gameInfo.description())); - maxPlayersEdit->setValue(gameInfo.max_players()); - onlyBuddiesCheckBox->setChecked(gameInfo.only_buddies()); - onlyRegisteredCheckBox->setChecked(gameInfo.only_registered()); - spectatorsAllowedCheckBox->setChecked(gameInfo.spectators_allowed()); - spectatorsNeedPasswordCheckBox->setChecked(gameInfo.spectators_need_password()); - spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat()); - spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); - shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); - shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players()); - - QSet types; - for (int i = 0; i < gameInfo.game_types_size(); ++i) { - types.insert(gameInfo.game_types(i)); - } - - QMapIterator gameTypeIterator(gameTypes); - while (gameTypeIterator.hasNext()) { - gameTypeIterator.next(); - - QRadioButton *gameTypeCheckBox = gameTypeCheckBoxes.value(gameTypeIterator.key()); - gameTypeCheckBox->setEnabled(false); - gameTypeCheckBox->setChecked(types.contains(gameTypeIterator.key())); - } + form = new CreateGameFormWidget(_gameTypes, false, false, this); + form->setOptions(GameCreationOptions::fromGameInfo(gameInfo, _gameTypes)); + form->setReadOnly(true); + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::accept); + auto *mainLayout = new QVBoxLayout; + mainLayout->addWidget(form); + mainLayout->addWidget(buttonBox); + + setLayout(mainLayout); setWindowTitle(tr("Game information")); } void DlgCreateGame::actReset() { - descriptionEdit->setText(""); - maxPlayersEdit->setValue(2); - - passwordEdit->setText(""); - onlyBuddiesCheckBox->setChecked(false); - onlyRegisteredCheckBox->setChecked(room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered); - - spectatorsAllowedCheckBox->setChecked(true); - spectatorsNeedPasswordCheckBox->setChecked(false); - spectatorsCanTalkCheckBox->setChecked(false); - spectatorsSeeEverythingCheckBox->setChecked(false); - createGameAsSpectatorCheckBox->setChecked(false); - - startingLifeTotalEdit->setValue(20); - shareDecklistsOnLoadCheckBox->setChecked(false); - shufflePlayersCheckBox->setChecked(false); - createGameAsJudgeCheckBox->setChecked(false); - - QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); - while (gameTypeCheckBoxIterator.hasNext()) { - gameTypeCheckBoxIterator.next(); - // must set auto enclusive to false to be able to set the check to false - gameTypeCheckBoxIterator.value()->setAutoExclusive(false); - gameTypeCheckBoxIterator.value()->setChecked(false); - gameTypeCheckBoxIterator.value()->setAutoExclusive(true); - } - - descriptionEdit->setFocus(); + form->resetToDefaults(); + clearError(); } void DlgCreateGame::actOK() { - auto _gameTypes = QString(); - QList gameTypeIds; - QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); - while (gameTypeCheckBoxIterator.hasNext()) { - gameTypeCheckBoxIterator.next(); - if (gameTypeCheckBoxIterator.value()->isChecked()) { - gameTypeIds.append(gameTypeCheckBoxIterator.key()); - _gameTypes += gameTypeCheckBoxIterator.value()->text() + ", "; - } - } - - GameCreationOptions options{ - .description = descriptionEdit->text(), - .password = passwordEdit->text(), - .maxPlayers = maxPlayersEdit->value(), - .onlyBuddies = onlyBuddiesCheckBox->isChecked(), - .onlyRegistered = onlyRegisteredCheckBox->isChecked(), - .spectatorsAllowed = spectatorsAllowedCheckBox->isChecked(), - .spectatorsNeedPassword = spectatorsNeedPasswordCheckBox->isChecked(), - .spectatorsCanTalk = spectatorsCanTalkCheckBox->isChecked(), - .spectatorsSeeEverything = spectatorsSeeEverythingCheckBox->isChecked(), - .joinAsJudge = createGameAsJudgeCheckBox->isChecked(), - .joinAsSpectator = createGameAsSpectatorCheckBox->isChecked(), - .startingLifeTotal = startingLifeTotalEdit->value(), - .shareDecklistsOnLoad = shareDecklistsOnLoadCheckBox->isChecked(), - .shufflePlayers = shufflePlayersCheckBox->isChecked(), - .gameTypeIds = gameTypeIds, - }; + clearError(); + const GameCreationOptions options = form->options(); Command_CreateGame cmd = options.toCreateGameCommand(); - SettingsCache::instance().game().setRememberGameSettings(rememberGameSettings->isChecked()); - if (rememberGameSettings->isChecked()) { - SettingsCache::instance().game().setGameDescription(descriptionEdit->text()); - SettingsCache::instance().game().setMaxPlayers(maxPlayersEdit->value()); - SettingsCache::instance().game().setOnlyBuddies(onlyBuddiesCheckBox->isChecked()); - SettingsCache::instance().game().setOnlyRegistered(onlyRegisteredCheckBox->isChecked()); - SettingsCache::instance().game().setSpectatorsAllowed(spectatorsAllowedCheckBox->isChecked()); - SettingsCache::instance().game().setSpectatorsNeedPassword(spectatorsNeedPasswordCheckBox->isChecked()); - SettingsCache::instance().game().setSpectatorsCanTalk(spectatorsCanTalkCheckBox->isChecked()); - SettingsCache::instance().game().setSpectatorsCanSeeEverything(spectatorsSeeEverythingCheckBox->isChecked()); - SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked()); - SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value()); - SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked()); - SettingsCache::instance().game().setShufflePlayers(shufflePlayersCheckBox->isChecked()); - SettingsCache::instance().game().setGameTypes(_gameTypes); + const bool rememberSettings = form->isRememberSettingsChecked(); + auto &gameSettings = SettingsCache::instance().game(); + gameSettings.setRememberGameSettings(rememberSettings); + if (rememberSettings) { + gameSettings.setGameDescription(options.description); + gameSettings.setMaxPlayers(options.maxPlayers); + gameSettings.setOnlyBuddies(options.onlyBuddies); + gameSettings.setOnlyRegistered(options.onlyRegistered); + gameSettings.setSpectatorsAllowed(options.spectatorsAllowed); + gameSettings.setSpectatorsNeedPassword(options.spectatorsNeedPassword); + gameSettings.setSpectatorsCanTalk(options.spectatorsCanTalk); + gameSettings.setSpectatorsCanSeeEverything(options.spectatorsSeeEverything); + gameSettings.setCreateGameAsSpectator(options.joinAsSpectator); + gameSettings.setDefaultStartingLifeTotal(options.startingLifeTotal); + gameSettings.setShareDecklistsOnLoad(options.shareDecklistsOnLoad); + gameSettings.setShufflePlayers(options.shufflePlayers); + gameSettings.setGameTypes(form->selectedGameTypesSettingString()); } + PendingCommand *pend = room->prepareRoomCommand(cmd); connect(pend, &PendingCommand::finished, this, &DlgCreateGame::checkResponse); room->sendRoomCommand(pend); @@ -350,14 +118,26 @@ void DlgCreateGame::checkResponse(const Response &response) if (response.response_code() == Response::RespOk) { accept(); } else { - QMessageBox::critical(this, tr("Error"), tr("Server error.")); - return; + showError(tr("Server error.")); } } -void DlgCreateGame::spectatorsAllowedChanged(QT_STATE_CHANGED_T state) +void DlgCreateGame::showError(const QString &message) { - spectatorsNeedPasswordCheckBox->setEnabled(state); - spectatorsCanTalkCheckBox->setEnabled(state); - spectatorsSeeEverythingCheckBox->setEnabled(state); + if (!errorLabel) { + return; + } + errorLabel->setText(message); + errorLabel->setVisible(true); + adjustSize(); +} + +void DlgCreateGame::clearError() +{ + if (!errorLabel) { + return; + } + errorLabel->clear(); + errorLabel->setVisible(false); + adjustSize(); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h index 58356c9b6..414c9398d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h @@ -2,7 +2,6 @@ * @file dlg_create_game.h * @ingroup RoomDialogs */ -//! \todo Document this file. #ifndef DLG_CREATEGAME_H #define DLG_CREATEGAME_H @@ -10,21 +9,15 @@ #include #include #include -#include #include -class QCheckBox; +class CreateGameFormWidget; class QDialogButtonBox; -class QGroupBox; class QLabel; -class QLineEdit; class QPushButton; -class QRadioButton; -class QSpinBox; class Response; class ServerInfo_Game; class TabRoom; -class QWidget; class DlgCreateGame : public QDialog { @@ -36,27 +29,16 @@ private slots: void actOK(); void actReset(); void checkResponse(const Response &response); - void spectatorsAllowedChanged(QT_STATE_CHANGED_T state); private: TabRoom *room; - QMap gameTypes; - QMap gameTypeCheckBoxes; + CreateGameFormWidget *form = nullptr; + QDialogButtonBox *buttonBox = nullptr; + QPushButton *clearButton = nullptr; + QLabel *errorLabel = nullptr; - QGroupBox *generalGroupBox, *spectatorsGroupBox, *gameSetupOptionsGroupBox; - QLabel *descriptionLabel, *passwordLabel, *maxPlayersLabel, *startingLifeTotalLabel, *shareDecklistsOnLoadLabel; - QLineEdit *descriptionEdit, *passwordEdit; - QSpinBox *maxPlayersEdit, *startingLifeTotalEdit; - QCheckBox *onlyBuddiesCheckBox, *onlyRegisteredCheckBox; - QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox, - *spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox; - QCheckBox *shareDecklistsOnLoadCheckBox; - QCheckBox *shufflePlayersCheckBox; - QDialogButtonBox *buttonBox; - QPushButton *clearButton; - QCheckBox *rememberGameSettings; - - void sharedCtor(); + void showError(const QString &message); + void clearError(); }; -#endif +#endif // DLG_CREATEGAME_H diff --git a/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp new file mode 100644 index 000000000..7d9e8e694 --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp @@ -0,0 +1,260 @@ +#include "create_game_form_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CreateGameFormWidget::CreateGameFormWidget(const QMap &gameTypes, + bool _registeredUser, + bool judge, + QWidget *parent) + : QWidget(parent), registeredUser(_registeredUser) +{ + setObjectName("createGameForm"); + + rememberGameSettings = new QCheckBox(tr("Re&member settings")); + auto *descriptionLabel = new QLabel(tr("&Description:")); + descriptionEdit = new QLineEdit; + descriptionEdit->setMaxLength(MAX_NAME_LENGTH); + descriptionLabel->setBuddy(descriptionEdit); + + auto *maxPlayersLabel = new QLabel(tr("P&layers:")); + maxPlayersEdit = new QSpinBox(); + maxPlayersEdit->setMinimum(1); + maxPlayersEdit->setMaximum(100); + maxPlayersEdit->setValue(2); + maxPlayersLabel->setBuddy(maxPlayersEdit); + + auto *generalGrid = new QGridLayout; + generalGrid->addWidget(descriptionLabel, 0, 0); + generalGrid->addWidget(descriptionEdit, 0, 1); + generalGrid->addWidget(maxPlayersLabel, 1, 0); + generalGrid->addWidget(maxPlayersEdit, 1, 1); + auto *generalGroupBox = new QGroupBox(tr("General")); + generalGroupBox->setLayout(generalGrid); + + auto *gameTypeLayout = new QVBoxLayout; + for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) { + auto *gameTypeRadioButton = new QRadioButton(it.value(), this); + gameTypeLayout->addWidget(gameTypeRadioButton); + gameTypeCheckBoxes.insert(it.key(), gameTypeRadioButton); + } + auto *gameTypeGroupBox = new QGroupBox(tr("Game type")); + gameTypeGroupBox->setLayout(gameTypeLayout); + + auto *passwordLabel = new QLabel(tr("&Password:")); + passwordEdit = new QLineEdit; + passwordEdit->setMaxLength(MAX_NAME_LENGTH); + passwordLabel->setBuddy(passwordEdit); + + onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join")); + onlyRegisteredCheckBox = new QCheckBox(tr("Only ®istered users can join")); + if (registeredUser) { + onlyRegisteredCheckBox->setChecked(true); + } else { + onlyBuddiesCheckBox->setEnabled(false); + onlyRegisteredCheckBox->setEnabled(false); + } + + auto *joinRestrictionsLayout = new QGridLayout; + joinRestrictionsLayout->addWidget(passwordLabel, 0, 0); + joinRestrictionsLayout->addWidget(passwordEdit, 0, 1); + joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2); + joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2); + + auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions")); + joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout); + + spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch")); + spectatorsAllowedCheckBox->setChecked(true); + connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &CreateGameFormWidget::spectatorsAllowedChanged); + spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch")); + spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat")); + spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands")); + createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator")); + auto *spectatorsLayout = new QVBoxLayout; + spectatorsLayout->addWidget(spectatorsAllowedCheckBox); + spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox); + spectatorsLayout->addWidget(spectatorsCanTalkCheckBox); + spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox); + spectatorsLayout->addWidget(createGameAsSpectatorCheckBox); + auto *spectatorsGroupBox = new QGroupBox(tr("Spectators")); + spectatorsGroupBox->setLayout(spectatorsLayout); + + auto *startingLifeTotalLabel = new QLabel(tr("Starting life total:")); + startingLifeTotalEdit = new QSpinBox(); + startingLifeTotalEdit->setMinimum(1); + startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining. + startingLifeTotalEdit->setValue(20); + startingLifeTotalLabel->setBuddy(startingLifeTotalEdit); + + shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); + + shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start")); + + createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); + + auto *gameSetupOptionsLayout = new QGridLayout; + gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); + gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); + gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2); + gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2); + if (judge) { + gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2); + } else { + createGameAsJudgeCheckBox->setChecked(false); + createGameAsJudgeCheckBox->setHidden(true); + } + auto *gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options")); + gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout); + + auto *grid = new QGridLayout; + + // Top row + grid->addWidget(generalGroupBox, 0, 0); + grid->addWidget(joinRestrictionsGroupBox, 0, 1); + + // Middle row: left column + grid->addWidget(gameTypeGroupBox, 1, 0); + + // Middle row: right column (game setup + spectators) + auto *rightLayout = new QVBoxLayout; + rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top + rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom + + grid->addLayout(rightLayout, 1, 1); + + // Bottom row + grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed + + setLayout(grid); +} + +void CreateGameFormWidget::setOptions(const GameCreationOptions &options) +{ + descriptionEdit->setText(options.description); + maxPlayersEdit->setValue(options.maxPlayers); + passwordEdit->setText(options.password); + onlyBuddiesCheckBox->setChecked(options.onlyBuddies); + onlyRegisteredCheckBox->setChecked(options.onlyRegistered); + spectatorsAllowedCheckBox->setChecked(options.spectatorsAllowed); + spectatorsNeedPasswordCheckBox->setChecked(options.spectatorsNeedPassword); + spectatorsCanTalkCheckBox->setChecked(options.spectatorsCanTalk); + spectatorsSeeEverythingCheckBox->setChecked(options.spectatorsSeeEverything); + createGameAsJudgeCheckBox->setChecked(options.joinAsJudge); + createGameAsSpectatorCheckBox->setChecked(options.joinAsSpectator); + startingLifeTotalEdit->setValue(options.startingLifeTotal); + shareDecklistsOnLoadCheckBox->setChecked(options.shareDecklistsOnLoad); + shufflePlayersCheckBox->setChecked(options.shufflePlayers); + + for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) { + it.value()->setChecked(options.gameTypeIds.contains(it.key())); + } +} + +GameCreationOptions CreateGameFormWidget::options() const +{ + GameCreationOptions options; + options.description = descriptionEdit->text(); + options.password = passwordEdit->text(); + options.maxPlayers = maxPlayersEdit->value(); + options.onlyBuddies = onlyBuddiesCheckBox->isChecked(); + options.onlyRegistered = onlyRegisteredCheckBox->isChecked(); + options.spectatorsAllowed = spectatorsAllowedCheckBox->isChecked(); + options.spectatorsNeedPassword = spectatorsNeedPasswordCheckBox->isChecked(); + options.spectatorsCanTalk = spectatorsCanTalkCheckBox->isChecked(); + options.spectatorsSeeEverything = spectatorsSeeEverythingCheckBox->isChecked(); + options.joinAsJudge = createGameAsJudgeCheckBox->isChecked(); + options.joinAsSpectator = createGameAsSpectatorCheckBox->isChecked(); + options.startingLifeTotal = startingLifeTotalEdit->value(); + options.shareDecklistsOnLoad = shareDecklistsOnLoadCheckBox->isChecked(); + options.shufflePlayers = shufflePlayersCheckBox->isChecked(); + + for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) { + if (it.value()->isChecked()) { + options.gameTypeIds.append(it.key()); + } + } + + return options; +} + +QString CreateGameFormWidget::selectedGameTypesSettingString() const +{ + QString gameTypes; + for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) { + if (it.value()->isChecked()) { + gameTypes += it.value()->text() + ", "; + } + } + return gameTypes; +} + +void CreateGameFormWidget::resetToDefaults() +{ + GameCreationOptions defaults; + defaults.onlyRegistered = registeredUser; + setOptions(defaults); + + // An exclusive button group refuses to uncheck its selected button, which setOptions + // cannot do, so clear the exclusive flag while resetting the game type selection. + for (auto *gameTypeCheckBox : gameTypeCheckBoxes) { + gameTypeCheckBox->setAutoExclusive(false); + gameTypeCheckBox->setChecked(false); + gameTypeCheckBox->setAutoExclusive(true); + } + + descriptionEdit->setFocus(); +} + +void CreateGameFormWidget::setReadOnly(bool readOnly) +{ + const bool editable = !readOnly; + rememberGameSettings->setEnabled(editable); + descriptionEdit->setEnabled(editable); + maxPlayersEdit->setEnabled(editable); + passwordEdit->setEnabled(editable); + onlyBuddiesCheckBox->setEnabled(editable && registeredUser); + onlyRegisteredCheckBox->setEnabled(editable && registeredUser); + spectatorsAllowedCheckBox->setEnabled(editable); + spectatorsNeedPasswordCheckBox->setEnabled(editable); + spectatorsCanTalkCheckBox->setEnabled(editable); + spectatorsSeeEverythingCheckBox->setEnabled(editable); + createGameAsSpectatorCheckBox->setEnabled(editable); + startingLifeTotalEdit->setEnabled(editable); + shareDecklistsOnLoadCheckBox->setEnabled(editable); + shufflePlayersCheckBox->setEnabled(editable); + for (auto *gameTypeCheckBox : gameTypeCheckBoxes) { + gameTypeCheckBox->setEnabled(editable); + } +} + +void CreateGameFormWidget::focusDescription() +{ + descriptionEdit->setFocus(); +} + +bool CreateGameFormWidget::isRememberSettingsChecked() const +{ + return rememberGameSettings->isChecked(); +} + +void CreateGameFormWidget::setRememberSettingsChecked(bool checked) +{ + rememberGameSettings->setChecked(checked); +} + +void CreateGameFormWidget::spectatorsAllowedChanged(QT_STATE_CHANGED_T state) +{ + spectatorsNeedPasswordCheckBox->setEnabled(state); + spectatorsCanTalkCheckBox->setEnabled(state); + spectatorsSeeEverythingCheckBox->setEnabled(state); +} diff --git a/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h new file mode 100644 index 000000000..43e56006e --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h @@ -0,0 +1,69 @@ +/** + * @file create_game_form_widget.h + * @ingroup RoomWidgets + */ + +#ifndef CREATE_GAME_FORM_WIDGET_H +#define CREATE_GAME_FORM_WIDGET_H + +#include "game_creation_options.h" + +#include +#include +#include +#include + +class QCheckBox; +class QLineEdit; +class QRadioButton; +class QSpinBox; + +/** + * @brief The editable create-game form, independent of settings, the network, and the room. + * + * The dialog loads @ref GameCreationOptions into it and reads them back; a future host + * activity can do the same without duplicating the widget layout. + */ +class CreateGameFormWidget : public QWidget +{ + Q_OBJECT +public: + CreateGameFormWidget(const QMap &gameTypes, + bool registeredUser, + bool judge, + QWidget *parent = nullptr); + + void setOptions(const GameCreationOptions &options); + [[nodiscard]] GameCreationOptions options() const; + [[nodiscard]] QString selectedGameTypesSettingString() const; + void resetToDefaults(); + void setReadOnly(bool readOnly); + void focusDescription(); + [[nodiscard]] bool isRememberSettingsChecked() const; + void setRememberSettingsChecked(bool checked); + +private slots: + void spectatorsAllowedChanged(QT_STATE_CHANGED_T state); + +private: + bool registeredUser; + QMap gameTypeCheckBoxes; + + QLineEdit *descriptionEdit; + QLineEdit *passwordEdit; + QSpinBox *maxPlayersEdit; + QSpinBox *startingLifeTotalEdit; + QCheckBox *onlyBuddiesCheckBox; + QCheckBox *onlyRegisteredCheckBox; + QCheckBox *spectatorsAllowedCheckBox; + QCheckBox *spectatorsNeedPasswordCheckBox; + QCheckBox *spectatorsCanTalkCheckBox; + QCheckBox *spectatorsSeeEverythingCheckBox; + QCheckBox *createGameAsJudgeCheckBox; + QCheckBox *createGameAsSpectatorCheckBox; + QCheckBox *shareDecklistsOnLoadCheckBox; + QCheckBox *shufflePlayersCheckBox; + QCheckBox *rememberGameSettings; +}; + +#endif // CREATE_GAME_FORM_WIDGET_H diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp index 33e30adb1..048899266 100644 --- a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp @@ -1,5 +1,69 @@ #include "game_creation_options.h" +#include +#include +#include + +GameCreationOptions GameCreationOptions::fromSettings(const IGameSettingsProvider &settings, + const QMap &gameTypes, + bool registeredUser) +{ + GameCreationOptions options; + options.description = settings.getGameDescription(); + options.maxPlayers = settings.getMaxPlayers(); + if (registeredUser) { + options.onlyBuddies = settings.getOnlyBuddies(); + options.onlyRegistered = settings.getOnlyRegistered(); + } + options.spectatorsAllowed = settings.getSpectatorsAllowed(); + options.spectatorsNeedPassword = settings.getSpectatorsNeedPassword(); + options.spectatorsCanTalk = settings.getSpectatorsCanTalk(); + options.spectatorsSeeEverything = settings.getSpectatorsCanSeeEverything(); + options.joinAsSpectator = settings.getCreateGameAsSpectator(); + options.startingLifeTotal = settings.getDefaultStartingLifeTotal(); + options.shareDecklistsOnLoad = settings.getShareDecklistsOnLoad(); + options.shufflePlayers = settings.getShufflePlayers(); + + // The old dialog stored game type names as "Name, " in one string; keep the exact + // substring test so an existing game.ini selects the same types after the split. + const QString savedGameTypes = settings.getGameTypes(); + for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) { + if (savedGameTypes.contains(it.value() + ", ")) { + options.gameTypeIds.append(it.key()); + } + } + + return options; +} + +GameCreationOptions GameCreationOptions::fromGameInfo(const ServerInfo_Game &gameInfo, + const QMap &gameTypes) +{ + GameCreationOptions options; + options.description = QString::fromStdString(gameInfo.description()); + options.maxPlayers = gameInfo.max_players(); + options.onlyBuddies = gameInfo.only_buddies(); + options.onlyRegistered = gameInfo.only_registered(); + options.spectatorsAllowed = gameInfo.spectators_allowed(); + options.spectatorsNeedPassword = gameInfo.spectators_need_password(); + options.spectatorsCanTalk = gameInfo.spectators_can_chat(); + options.spectatorsSeeEverything = gameInfo.spectators_omniscient(); + options.shareDecklistsOnLoad = gameInfo.share_decklists_on_load(); + options.shufflePlayers = gameInfo.shuffle_players(); + + QSet selectedTypes; + for (int i = 0; i < gameInfo.game_types_size(); ++i) { + selectedTypes.insert(gameInfo.game_types(i)); + } + for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) { + if (selectedTypes.contains(it.key())) { + options.gameTypeIds.append(it.key()); + } + } + + return options; +} + Command_CreateGame GameCreationOptions::toCreateGameCommand() const { Command_CreateGame cmd; diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h index 15284eee0..cbc941815 100644 --- a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h @@ -9,8 +9,12 @@ #include "libcockatrice/protocol/pb/room_commands.pb.h" #include +#include #include +class IGameSettingsProvider; +class ServerInfo_Game; + /** * @brief The values needed to create a game, independent of the widgets that edit them. * @@ -35,6 +39,22 @@ struct GameCreationOptions bool shufflePlayers = false; QList gameTypeIds; + /** + * @brief Reads the remembered create-game settings. + * + * Join restrictions are only meaningful for registered users, so they are left + * unset for an unregistered host. Game type ids are resolved from the room's map + * by matching the remembered names. + */ + [[nodiscard]] static GameCreationOptions + fromSettings(const IGameSettingsProvider &settings, const QMap &gameTypes, bool registeredUser); + + /** + * @brief Reads the read-only fields shown in the game information view. + */ + [[nodiscard]] static GameCreationOptions fromGameInfo(const ServerInfo_Game &gameInfo, + const QMap &gameTypes); + /** * @brief Builds the room command, simplifying the description's whitespace. */ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7595706b2..62523f456 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -469,6 +469,20 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp game_creation_options_test.cpp LIBS + libcockatrice_interfaces + libcockatrice_protocol + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + create_game_form_widget_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp + create_game_form_widget_test.cpp + LIBS + libcockatrice_interfaces libcockatrice_protocol INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src diff --git a/tests/create_game_form_widget_test.cpp b/tests/create_game_form_widget_test.cpp new file mode 100644 index 000000000..f919e3443 --- /dev/null +++ b/tests/create_game_form_widget_test.cpp @@ -0,0 +1,132 @@ +#include "interface/widgets/game_hosting/create_game_form_widget.h" + +#include +#include +#include + +namespace +{ +const QMap gameTypes{{1, "Draft"}, {2, "Commander"}}; + +void expectSameOptions(const GameCreationOptions &actual, const GameCreationOptions &expected) +{ + EXPECT_EQ(actual.description, expected.description); + EXPECT_EQ(actual.password, expected.password); + EXPECT_EQ(actual.maxPlayers, expected.maxPlayers); + EXPECT_EQ(actual.onlyBuddies, expected.onlyBuddies); + EXPECT_EQ(actual.onlyRegistered, expected.onlyRegistered); + EXPECT_EQ(actual.spectatorsAllowed, expected.spectatorsAllowed); + EXPECT_EQ(actual.spectatorsNeedPassword, expected.spectatorsNeedPassword); + EXPECT_EQ(actual.spectatorsCanTalk, expected.spectatorsCanTalk); + EXPECT_EQ(actual.spectatorsSeeEverything, expected.spectatorsSeeEverything); + EXPECT_EQ(actual.joinAsJudge, expected.joinAsJudge); + EXPECT_EQ(actual.joinAsSpectator, expected.joinAsSpectator); + EXPECT_EQ(actual.startingLifeTotal, expected.startingLifeTotal); + EXPECT_EQ(actual.shareDecklistsOnLoad, expected.shareDecklistsOnLoad); + EXPECT_EQ(actual.shufflePlayers, expected.shufflePlayers); + EXPECT_EQ(actual.gameTypeIds, expected.gameTypeIds); +} +} // namespace + +TEST(CreateGameFormWidget, RegisteredHostDefaultsToRegisteredOnly) +{ + const CreateGameFormWidget form(gameTypes, true, false); + + const GameCreationOptions options = form.options(); + + EXPECT_TRUE(options.onlyRegistered); + EXPECT_FALSE(options.onlyBuddies); + EXPECT_EQ(options.maxPlayers, 2); + EXPECT_TRUE(options.spectatorsAllowed); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(CreateGameFormWidget, UnregisteredHostDefaultsToOpenJoin) +{ + const CreateGameFormWidget form(gameTypes, false, false); + + const GameCreationOptions options = form.options(); + + EXPECT_FALSE(options.onlyRegistered); + EXPECT_FALSE(options.onlyBuddies); +} + +TEST(CreateGameFormWidget, SetOptionsRoundTripsEveryField) +{ + CreateGameFormWidget form(gameTypes, true, true); + + GameCreationOptions expected; + expected.description = "Some game"; + expected.password = "hunter2"; + expected.maxPlayers = 5; + expected.onlyBuddies = true; + expected.onlyRegistered = false; + expected.spectatorsAllowed = false; + expected.spectatorsNeedPassword = true; + expected.spectatorsCanTalk = true; + expected.spectatorsSeeEverything = true; + expected.joinAsJudge = true; + expected.joinAsSpectator = true; + expected.startingLifeTotal = 40; + expected.shareDecklistsOnLoad = true; + expected.shufflePlayers = true; + expected.gameTypeIds = {2}; + + form.setOptions(expected); + + expectSameOptions(form.options(), expected); +} + +TEST(CreateGameFormWidget, ResetRestoresDefaultsAndClearsTheGameType) +{ + CreateGameFormWidget form(gameTypes, true, false); + GameCreationOptions configured; + configured.description = "Configured"; + configured.maxPlayers = 8; + configured.onlyBuddies = true; + configured.joinAsJudge = true; + configured.gameTypeIds = {2}; + form.setOptions(configured); + + form.resetToDefaults(); + + const GameCreationOptions options = form.options(); + EXPECT_TRUE(options.description.isEmpty()); + EXPECT_EQ(options.maxPlayers, 2); + EXPECT_FALSE(options.onlyBuddies); + EXPECT_TRUE(options.onlyRegistered); + EXPECT_TRUE(options.spectatorsAllowed); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_FALSE(options.joinAsJudge); + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(CreateGameFormWidget, RememberSettingsFlagRoundTrips) +{ + CreateGameFormWidget form(gameTypes, true, false); + + EXPECT_FALSE(form.isRememberSettingsChecked()); + + form.setRememberSettingsChecked(true); + + EXPECT_TRUE(form.isRememberSettingsChecked()); +} + +TEST(CreateGameFormWidget, SelectedGameTypesSettingStringUsesTheLegacyEncoding) +{ + CreateGameFormWidget form(gameTypes, true, false); + GameCreationOptions configured; + configured.gameTypeIds = {1}; + form.setOptions(configured); + + EXPECT_EQ(form.selectedGameTypesSettingString(), "Draft, "); +} + +int main(int argc, char **argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/game_creation_options_test.cpp b/tests/game_creation_options_test.cpp index e182e4e91..e4a7996cb 100644 --- a/tests/game_creation_options_test.cpp +++ b/tests/game_creation_options_test.cpp @@ -1,7 +1,100 @@ #include "interface/widgets/game_hosting/game_creation_options.h" #include +#include #include +#include + +namespace +{ +/** + * @brief The remembered settings a test hands to fromSettings. + * + * The fake cannot itself be an aggregate (it derives from a polymorphic interface), so the + * remembered values live in a GameCreationOptions member and the getters read them from + * there. That keeps the test data in one designated initializer. + */ +class FakeGameSettings : public IGameSettingsProvider +{ +public: + explicit FakeGameSettings(const GameCreationOptions &_options = {}) : options(_options) + { + } + + [[nodiscard]] QString getGameDescription() const override + { + return options.description; + } + [[nodiscard]] int getMaxPlayers() const override + { + return options.maxPlayers; + } + [[nodiscard]] QString getGameTypes() const override + { + return gameTypes; + } + [[nodiscard]] bool getOnlyBuddies() const override + { + return options.onlyBuddies; + } + [[nodiscard]] bool getOnlyRegistered() const override + { + return options.onlyRegistered; + } + [[nodiscard]] bool getSpectatorsAllowed() const override + { + return options.spectatorsAllowed; + } + [[nodiscard]] bool getSpectatorsNeedPassword() const override + { + return options.spectatorsNeedPassword; + } + [[nodiscard]] bool getSpectatorsCanTalk() const override + { + return options.spectatorsCanTalk; + } + [[nodiscard]] bool getSpectatorsCanSeeEverything() const override + { + return options.spectatorsSeeEverything; + } + [[nodiscard]] bool getCreateGameAsSpectator() const override + { + return options.joinAsSpectator; + } + [[nodiscard]] int getDefaultStartingLifeTotal() const override + { + return options.startingLifeTotal; + } + [[nodiscard]] bool getShareDecklistsOnLoad() const override + { + return options.shareDecklistsOnLoad; + } + [[nodiscard]] bool getShufflePlayers() const override + { + return options.shufflePlayers; + } + [[nodiscard]] bool getRememberGameSettings() const override + { + return false; + } + [[nodiscard]] bool getLocalGameRememberSettings() const override + { + return false; + } + [[nodiscard]] int getLocalGameMaxPlayers() const override + { + return 0; + } + [[nodiscard]] int getLocalGameStartingLifeTotal() const override + { + return 0; + } + + GameCreationOptions options; + /// The legacy "Name, " list; it has no GameCreationOptions counterpart. + QString gameTypes; +}; +} // namespace TEST(GameCreationOptions, DefaultsMatchTheDialog) { @@ -91,3 +184,127 @@ TEST(GameCreationOptions, DescriptionWhitespaceIsSimplified) EXPECT_EQ(cmd.description(), "a b"); } + +TEST(GameCreationOptions, FromSettingsCopiesEveryRememberedField) +{ + const FakeGameSettings settings{GameCreationOptions{.description = "Remembered game", + .password = {}, + .maxPlayers = 6, + .onlyBuddies = true, + .onlyRegistered = true, + .spectatorsAllowed = false, + .spectatorsNeedPassword = true, + .spectatorsCanTalk = true, + .spectatorsSeeEverything = true, + .joinAsJudge = false, + .joinAsSpectator = true, + .startingLifeTotal = 40, + .shareDecklistsOnLoad = true, + .shufflePlayers = true, + .gameTypeIds = {}}}; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, {}, true); + + EXPECT_EQ(options.description, "Remembered game"); + EXPECT_EQ(options.maxPlayers, 6); + EXPECT_TRUE(options.onlyBuddies); + EXPECT_TRUE(options.onlyRegistered); + EXPECT_FALSE(options.spectatorsAllowed); + EXPECT_TRUE(options.spectatorsNeedPassword); + EXPECT_TRUE(options.spectatorsCanTalk); + EXPECT_TRUE(options.spectatorsSeeEverything); + EXPECT_TRUE(options.joinAsSpectator); + EXPECT_EQ(options.startingLifeTotal, 40); + EXPECT_TRUE(options.shareDecklistsOnLoad); + EXPECT_TRUE(options.shufflePlayers); +} + +TEST(GameCreationOptions, FromSettingsDropsJoinRestrictionsForUnregisteredHosts) +{ + FakeGameSettings settings; + settings.options.onlyBuddies = true; + settings.options.onlyRegistered = true; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, {}, false); + + EXPECT_FALSE(options.onlyBuddies); + EXPECT_FALSE(options.onlyRegistered); +} + +TEST(GameCreationOptions, FromSettingsMatchesGameTypesByTheLegacyNameList) +{ + FakeGameSettings settings; + settings.gameTypes = "Draft, Commander, "; + const QMap gameTypes{{1, "Draft"}, {2, "Commander"}, {3, "Standard"}}; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, gameTypes, true); + + EXPECT_EQ(options.gameTypeIds, QList({1, 2})); +} + +TEST(GameCreationOptions, FromSettingsDoesNotMatchGameTypesWithoutTheLegacySeparator) +{ + FakeGameSettings settings; + settings.gameTypes = "Draft Commander"; + const QMap gameTypes{{1, "Draft"}, {2, "Commander"}}; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, gameTypes, true); + + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(GameCreationOptions, FromGameInfoCopiesTheVisibleFields) +{ + ServerInfo_Game gameInfo; + gameInfo.set_description("Some game"); + gameInfo.set_max_players(4); + gameInfo.set_only_buddies(true); + gameInfo.set_only_registered(true); + gameInfo.set_spectators_allowed(false); + gameInfo.set_spectators_need_password(true); + gameInfo.set_spectators_can_chat(true); + gameInfo.set_spectators_omniscient(true); + gameInfo.set_share_decklists_on_load(true); + gameInfo.set_shuffle_players(true); + gameInfo.add_game_types(2); + + const QMap gameTypes{{1, "Draft"}, {2, "Commander"}}; + const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, gameTypes); + + EXPECT_EQ(options.description, "Some game"); + EXPECT_EQ(options.maxPlayers, 4); + EXPECT_TRUE(options.onlyBuddies); + EXPECT_TRUE(options.onlyRegistered); + EXPECT_FALSE(options.spectatorsAllowed); + EXPECT_TRUE(options.spectatorsNeedPassword); + EXPECT_TRUE(options.spectatorsCanTalk); + EXPECT_TRUE(options.spectatorsSeeEverything); + EXPECT_TRUE(options.shareDecklistsOnLoad); + EXPECT_TRUE(options.shufflePlayers); + EXPECT_EQ(options.gameTypeIds, QList({2})); +} + +TEST(GameCreationOptions, FromGameInfoLeavesUnshownFieldsAtDefaults) +{ + ServerInfo_Game gameInfo; + gameInfo.set_max_players(3); + + const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, {}); + + EXPECT_TRUE(options.password.isEmpty()); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_FALSE(options.joinAsJudge); + EXPECT_FALSE(options.joinAsSpectator); +} + +TEST(GameCreationOptions, FromGameInfoIgnoresUnknownGameTypeIds) +{ + ServerInfo_Game gameInfo; + gameInfo.add_game_types(7); + gameInfo.add_game_types(9); + + const QMap gameTypes{{1, "Draft"}, {7, "Commander"}}; + const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, gameTypes); + + EXPECT_EQ(options.gameTypeIds, QList({7})); +} From 078d0858a352c2a135586add7cdff75f4f6d778c Mon Sep 17 00:00:00 2001 From: Bryce! Date: Sun, 8 Nov 2026 10:41:14 -0500 Subject: [PATCH 096/104] [Game] Reveal multiple distinct random hand cards to a recipient (#7753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add distinct random hand reveals with private recipient delivery * Route random hand reveals through PlayerActions Keep command creation, dispatch and response handling in the game action layer. HandMenu requests the typed operation and presents the count and failure dialogs. Show the chosen recipient, distinguish the count action from the immediate single-card reveal, and use the game window as parent. * Apply project formatting to random-reveal warning construction * Simplify random reveal to follow existing command patterns Forward server dispatch through cmdRevealRandomCards and use ordinary PlayerActions command sending. Remove the bespoke server-version error UI and long-lived-object guards. Reduce the test from 246 to 150 lines with two focused cases using the current RoleInfo player API, preserving distinct-card, private-event and rejected-count coverage. * Extract the shared reveal helper and name the test's CardRef fields RickyRister asked for cmdRevealCards and cmdRevealRandomCards to share a helper instead of threading a randomCount parameter through the reveal virtual. Restore the three-argument cmdRevealCards signature, resolve the reveal preconditions and zone in resolveRevealZone, and let both commands build their card list and hand it to revealCards, which owns the private and public Event_RevealCards construction and the enqueue. The random command no longer fabricates a Command_RevealCards with the -2 sentinel to re-enter the legacy path; its behaviour is unchanged. Name the CardRef fields in random_reveal_test instead of relying on declaration order, and state in the user documentation that a rejected reveal is not reported in the client, matching what the client does. --------- Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- .../game_graphics/player/menu/hand_menu.cpp | 62 +++++++ .../src/game_graphics/player/menu/hand_menu.h | 6 + .../gameplay/revealing_cards.md | 11 ++ .../doxygen/pages/user_documentation/index.md | 4 + .../game/player/player_actions.cpp | 11 ++ .../game/player/player_actions.h | 1 + .../game/server_abstract_participant.cpp | 10 ++ .../remote/game/server_abstract_participant.h | 3 + .../remote/game/server_abstract_player.cpp | 93 +++++++++-- .../remote/game/server_abstract_player.h | 14 ++ .../protocol/pb/command_reveal_cards.proto | 10 ++ .../protocol/pb/game_commands.proto | 5 + tests/CMakeLists.txt | 11 ++ tests/random_reveal_test.cpp | 151 ++++++++++++++++++ 14 files changed, 375 insertions(+), 17 deletions(-) create mode 100644 docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md create mode 100644 tests/random_reveal_test.cpp diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp index e992f3ff5..8ef47d4cb 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp @@ -6,6 +6,9 @@ #include "../player_graphics_item.h" #include +#include +#include +#include #include #include #include @@ -67,6 +70,15 @@ HandMenu::HandMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(p aRevealRandomHandCardToAll->setData(-1); connect(aRevealRandomHandCardToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardTriggered); + mRevealRandomHandCards = addMenu(QString()); + mRevealRandomHandCards->setObjectName("revealRandomHandCardsMenu"); + connect(mRevealRandomHandCards, &QMenu::aboutToShow, this, + &HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers); + aRevealRandomHandCardsToAll = new QAction(this); + aRevealRandomHandCardsToAll->setData(-1); + connect(aRevealRandomHandCardsToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered); + mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll); + // We still need to add these actions to menu here so that the shortcuts are active right away mRevealHand->addAction(aRevealHandToAll); mRevealRandomHandCard->addAction(aRevealRandomHandCardToAll); @@ -146,6 +158,8 @@ void HandMenu::retranslateUi() mRevealRandomHandCard->setTitle(tr("Reveal r&andom card to...")); aRevealRandomHandCardToAll->setText(tr("All players")); + mRevealRandomHandCards->setTitle(tr("Reveal a chosen number of random cards to...")); + aRevealRandomHandCardsToAll->setText(tr("All players")); } } @@ -233,3 +247,51 @@ void HandMenu::onRevealRandomHandCardTriggered() const int targetId = action->data().toInt(); player->getLogic()->getPlayerActions()->actRevealRandomHandCard(targetId); } + +void HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers() +{ + const bool hasCards = !player->getLogic()->getHandZone()->getCards().isEmpty(); + mRevealRandomHandCards->clear(); + aRevealRandomHandCardsToAll->setEnabled(hasCards); + mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll); + mRevealRandomHandCards->addSeparator(); + const auto &players = player->getLogic()->getGame()->getPlayerManager()->getPlayers().values(); + for (auto *other : players) { + if (other == player->getLogic()) { + continue; + } + auto *action = mRevealRandomHandCards->addAction(other->getPlayerInfo()->getName()); + action->setData(other->getPlayerInfo()->getId()); + action->setEnabled(hasCards); + connect(action, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered); + } +} + +void HandMenu::onRevealRandomHandCardsTriggered() +{ + const auto *action = qobject_cast(sender()); + const int handSize = player->getLogic()->getHandZone()->getCards().size(); + if (!action || handSize == 0) { + return; + } + const int targetId = action->data().toInt(); + auto *actions = player->getLogic()->getPlayerActions(); + auto *dialog = new QInputDialog(dialogParent()); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setWindowTitle(tr("Reveal Random Hand Cards")); + dialog->setLabelText(tr("Number of distinct random cards to reveal to %1:").arg(action->text())); + dialog->setInputMode(QInputDialog::IntInput); + dialog->setIntRange(1, handSize); + dialog->setIntValue(qMin(2, handSize)); + connect(dialog, &QDialog::accepted, actions, + [dialog, actions, targetId] { actions->actRevealRandomHandCards(targetId, dialog->intValue()); }); + dialog->open(); +} + +QWidget *HandMenu::dialogParent() const +{ + if (auto *view = player->scene() ? player->scene()->views().value(0) : nullptr) { + return view->window(); + } + return nullptr; +} diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.h b/cockatrice/src/game_graphics/player/menu/hand_menu.h index de7726064..03ecd7544 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.h +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.h @@ -42,8 +42,11 @@ private slots: void populateRevealRandomHandCardMenuWithActivePlayers(); void onRevealHandTriggered(); void onRevealRandomHandCardTriggered(); + void populateRevealRandomHandCardsMenuWithActivePlayers(); + void onRevealRandomHandCardsTriggered(); private: + QWidget *dialogParent() const; PlayerGraphicsItem *player; QAction *aViewHand = nullptr; @@ -62,6 +65,9 @@ private: QMenu *mRevealRandomHandCard = nullptr; QAction *aRevealRandomHandCardToAll = nullptr; + QMenu *mRevealRandomHandCards = nullptr; + QAction *aRevealRandomHandCardsToAll = nullptr; + QMenu *mMoveHandMenu = nullptr; QAction *aMoveHandToTopLibrary = nullptr; QAction *aMoveHandToBottomLibrary = nullptr; diff --git a/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md b/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md new file mode 100644 index 000000000..5d47bd3b4 --- /dev/null +++ b/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md @@ -0,0 +1,11 @@ +@page revealing_cards Revealing Cards + +The **Hand** menu offers **Reveal a chosen number of random cards to...**. Choose **All players** or a specific player, then choose how many cards to reveal. The count dialog displays the chosen recipient. + +The server chooses distinct cards without replacement. Two copies of the same card are separate physical cards and can both be chosen. The operation does not reorder, move, or otherwise change the hand. + +When you choose a specific player, that player receives the selected card identities. Judges and spectators configured to see all hidden information retain their usual access. Ordinary participants other than the chosen player, including the hand's owner, receive a notice with the count and recipient, but do not learn which cards were chosen. Revealing to all players sends the selected identities to everyone, using the ordinary reveal window. + +If the hand becomes too small or the recipient leaves before the server processes the request, the server rejects the request and no cards are revealed. Cancelling the count dialog sends nothing. + +This operation requires a server that supports multiple random reveals. An older server rejects it without revealing any cards. Neither rejection is reported in the client; the reveal simply does not happen. The existing **Reveal random card to...** action and its shortcut remain available for single-card reveals. diff --git a/docs/doxygen/pages/user_documentation/index.md b/docs/doxygen/pages/user_documentation/index.md index f4ecbe8a1..e95706727 100644 --- a/docs/doxygen/pages/user_documentation/index.md +++ b/docs/doxygen/pages/user_documentation/index.md @@ -7,6 +7,10 @@ - @subpage editing_decks - @subpage exporting_decks +## Gameplay + +- @subpage revealing_cards + ## Release Channels - @subpage beta_release diff --git a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp index 90823fec6..1e854cbd4 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp @@ -1756,6 +1756,17 @@ void PlayerActions::actRevealRandomHandCard(int revealToPlayerId) sendGameCommand(cmd); } +void PlayerActions::actRevealRandomHandCards(int revealToPlayerId, int amount) +{ + Command_RevealRandomCards cmd; + if (revealToPlayerId != -1) { + cmd.set_player_id(revealToPlayerId); + } + cmd.set_zone_name(ZoneNames::HAND); + cmd.set_number_of_cards(amount); + sendGameCommand(cmd); +} + void PlayerActions::actRevealLibrary(int revealToPlayerId) { Command_RevealCards cmd; diff --git a/libcockatrice_game/libcockatrice/game/player/player_actions.h b/libcockatrice_game/libcockatrice/game/player/player_actions.h index 7ed52b9a1..81d18315c 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_actions.h +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.h @@ -279,6 +279,7 @@ public slots: void actReveal(QList selectedCards, QAction *action); void actRevealHand(int revealToPlayerId); void actRevealRandomHandCard(int revealToPlayerId); + void actRevealRandomHandCards(int revealToPlayerId, int amount); void actRevealLibrary(int revealToPlayerId); void actSortHand(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index dcd14d14c..f7739bc85 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -405,6 +405,13 @@ Response::ResponseCode Server_AbstractParticipant::cmdRevealCards(const Command_ return Response::RespFunctionNotAllowed; } +Response::ResponseCode Server_AbstractParticipant::cmdRevealRandomCards(const Command_RevealRandomCards & /*cmd*/, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + return Response::RespFunctionNotAllowed; +} + Response::ResponseCode Server_AbstractParticipant::cmdChangeZoneProperties(const Command_ChangeZoneProperties & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage & /*ges*/) @@ -544,6 +551,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::REVEAL_CARDS: return cmdRevealCards(command.GetExtension(Command_RevealCards::ext), rc, ges); break; + case GameCommand::REVEAL_RANDOM_CARDS: + return cmdRevealRandomCards(command.GetExtension(Command_RevealRandomCards::ext), rc, ges); + break; case GameCommand::MOVE_CARD: return cmdMoveCard(command.GetExtension(Command_MoveCard::ext), rc, ges); break; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index 316b9727c..6b2a93e48 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -46,6 +46,7 @@ class Command_NextTurn; class Command_SetActivePhase; class Command_DumpZone; class Command_RevealCards; +class Command_RevealRandomCards; class Command_ReverseTurn; class Command_MoveCard; class Command_SetSideboardPlan; @@ -176,6 +177,8 @@ public: virtual Response::ResponseCode cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode + cmdRevealRandomCards(const Command_RevealRandomCards &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges); virtual Response::ResponseCode cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, ResponseContainer &rc, GameEventStorage &ges); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index b5a620798..b9617b547 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -1494,8 +1494,10 @@ Server_AbstractPlayer::cmdDumpZone(const Command_DumpZone &cmd, ResponseContaine return Response::RespOk; } -Response::ResponseCode -Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges) +Response::ResponseCode Server_AbstractPlayer::resolveRevealZone(const std::string &zoneName, + bool hasRecipient, + int recipientId, + Server_CardZone *&zone) { if (!game->getGameStarted()) { return Response::RespGameNotStarted; @@ -1504,17 +1506,61 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo return Response::RespContextError; } - if (cmd.has_player_id()) { - Server_AbstractPlayer *otherPlayer = game->getPlayer(cmd.player_id()); - if (!otherPlayer) { - return Response::RespNameNotFound; - } + if (hasRecipient && !game->getPlayer(recipientId)) { + return Response::RespNameNotFound; } - Server_CardZone *zone = zones.value(nameFromStdString(cmd.zone_name())); + + zone = zones.value(nameFromStdString(zoneName)); if (!zone) { return Response::RespNameNotFound; } + return Response::RespOk; +} + +Response::ResponseCode Server_AbstractPlayer::cmdRevealRandomCards(const Command_RevealRandomCards &cmd, + ResponseContainer & /*rc*/, + GameEventStorage &ges) +{ + if (cmd.number_of_cards() <= 0) { + return Response::RespInvalidData; + } + + Server_CardZone *zone = nullptr; + const Response::ResponseCode result = + resolveRevealZone(cmd.zone_name(), cmd.has_player_id(), cmd.player_id(), zone); + if (result != Response::RespOk) { + return result; + } + + auto candidates = zone->getCards(); + if (cmd.number_of_cards() > candidates.size()) { + return Response::RespContextError; + } + + // Partial Fisher-Yates on a copy: each physical card can appear only once, + // and the hand's order and contents stay unchanged. + QList cardsToReveal; + for (int i = 0; i < cmd.number_of_cards(); ++i) { + const int selected = rng->rand(i, candidates.size() - 1); + candidates.swapItemsAt(i, selected); + cardsToReveal.append(candidates.at(i)); + } + + // -2 in the public event tells other participants only how many cards were revealed. + return revealCards(zone, cardsToReveal, {-2}, cmd.has_player_id(), cmd.player_id(), false, ges); +} + +Response::ResponseCode +Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges) +{ + Server_CardZone *zone = nullptr; + const Response::ResponseCode result = + resolveRevealZone(cmd.zone_name(), cmd.has_player_id(), cmd.player_id(), zone); + if (result != Response::RespOk) { + return result; + } + QList cardsToReveal; if (cmd.top_cards() != -1) { for (int i = 0; i < cmd.top_cards(); i++) { @@ -1547,15 +1593,28 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo } } + const QList cardIdsForOthers(cmd.card_id().begin(), cmd.card_id().end()); + return revealCards(zone, cardsToReveal, cardIdsForOthers, cmd.has_player_id(), cmd.player_id(), + cmd.grant_write_access(), ges); +} + +Response::ResponseCode Server_AbstractPlayer::revealCards(Server_CardZone *zone, + const QList &cardsToReveal, + const QList &cardIdsForOthers, + bool hasRecipient, + int recipientId, + bool grantWriteAccess, + GameEventStorage &ges) +{ Event_RevealCards eventOthers; - eventOthers.set_grant_write_access(cmd.grant_write_access()); + eventOthers.set_grant_write_access(grantWriteAccess); eventOthers.set_zone_name(zone->getName().toStdString()); eventOthers.set_number_of_cards(cardsToReveal.size()); - for (auto cardId : cmd.card_id()) { + for (int cardId : cardIdsForOthers) { eventOthers.add_card_id(cardId); } - if (cmd.has_player_id()) { - eventOthers.set_other_player_id(cmd.player_id()); + if (hasRecipient) { + eventOthers.set_other_player_id(recipientId); } Event_RevealCards eventPrivate(eventOthers); @@ -1592,19 +1651,19 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo } } - if (cmd.has_player_id()) { - if (cmd.grant_write_access()) { - zone->addWritePermission(cmd.player_id()); + if (hasRecipient) { + if (grantWriteAccess) { + zone->addWritePermission(recipientId); } if (isJudge()) { ges.setOverwriteOwnership(true); } - ges.enqueueGameEvent(eventPrivate, playerId, GameEventStorageItem::SendToPrivate, cmd.player_id()); + ges.enqueueGameEvent(eventPrivate, playerId, GameEventStorageItem::SendToPrivate, recipientId); ges.enqueueGameEvent(eventOthers, playerId, GameEventStorageItem::SendToOthers); } else { - if (cmd.grant_write_access()) { + if (grantWriteAccess) { const QList &participantIds = game->getParticipants().keys(); for (int anyParticipantId : participantIds) { zone->addWritePermission(anyParticipantId); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h index ae20955bf..c92c4c3dd 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h @@ -32,6 +32,18 @@ private: void sendCreateTokenEvents(Server_CardZone *zone, Server_Card *card, int xCoord, int yCoord, GameEventStorage &ges); void getPlayerProperties(ServerInfo_PlayerProperties &result) override; + /// Validates the preconditions shared by every reveal and resolves the source zone. + Response::ResponseCode + resolveRevealZone(const std::string &zoneName, bool hasRecipient, int recipientId, Server_CardZone *&zone); + /// Builds the private and public reveal events for an already chosen set of cards and enqueues them. + Response::ResponseCode revealCards(Server_CardZone *zone, + const QList &cardsToReveal, + const QList &cardIdsForOthers, + bool hasRecipient, + int recipientId, + bool grantWriteAccess, + GameEventStorage &ges); + protected: bool conceded; DeckList *deck; @@ -163,6 +175,8 @@ public: cmdDumpZone(const Command_DumpZone &cmd, ResponseContainer &rc, GameEventStorage &ges) override; virtual Response::ResponseCode cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer &rc, GameEventStorage &ges) override; + Response::ResponseCode + cmdRevealRandomCards(const Command_RevealRandomCards &cmd, ResponseContainer &rc, GameEventStorage &ges) override; virtual Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges) override; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto index 82216911c..6bca5a69e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto @@ -10,3 +10,13 @@ message Command_RevealCards { optional bool grant_write_access = 4; optional sint32 top_cards = 5 [default = -1]; } + +// Separate extension so older servers reject the operation without revealing any cards. +message Command_RevealRandomCards { + extend GameCommand { + optional Command_RevealRandomCards ext = 1036; + } + optional string zone_name = 1; + optional sint32 number_of_cards = 2; + optional sint32 player_id = 3 [default = -1]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index eb399ffb9..10ceb33ba 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -185,6 +185,11 @@ message GameCommand { /// Server: Server_AbstractParticipant::cmdSetPlayerOrder /// Client: reflected via Event_PlayerOrderChanged SET_PLAYER_ORDER = 1206; + + /// Reveal a distinct random sample without exposing identities to other recipients. + /// Server: Server_AbstractPlayer::cmdRevealCards + /// Client: PlayerEventHandler::eventRevealCards + REVEAL_RANDOM_CARDS = 1036; } extensions 100 to max; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 62523f456..04b3575aa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -356,6 +356,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + random_reveal_test + SOURCES + random_reveal_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) cockatrice_add_test( NAME server_game_join_test diff --git a/tests/random_reveal_test.cpp b/tests/random_reveal_test.cpp new file mode 100644 index 000000000..62defe183 --- /dev/null +++ b/tests/random_reveal_test.cpp @@ -0,0 +1,151 @@ +#include "game/game_config.h" +#include "game/server_abstract_player.h" +#include "game/server_card.h" +#include "game/server_cardzone.h" +#include "game/server_game.h" +#include "movecard_tests/server_test_helpers.h" +#include "server_abstractuserinterface.h" +#include "server_response_containers.h" +#include "server_room.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +class FirstRng : public RNG_Abstract +{ + unsigned int rand(int min, int) override + { + return min; + } +} firstRng; + +class User : public Server_AbstractUserInterface +{ +public: + User(Server *server, const char *name) : Server_AbstractUserInterface(server) + { + ServerInfo_User info; + info.set_name(name); + setUserInfo(info); + } + int getLastCommandTime() const override + { + return 0; + } + bool addSaidMessageSize(int) override + { + return true; + } + void sendProtocolItem(const Response &) override + { + } + void sendProtocolItem(const SessionEvent &) override + { + } + void sendProtocolItem(const RoomEvent &) override + { + } + void sendProtocolItem(const GameEventContainer &) override + { + } +}; + +class RandomReveal : public ::testing::Test +{ +protected: + FakeServer server; + User owner{&server, "Owner"}, target{&server, "Target"}; + Server_Room room{0, 0, "", "", "", "", false, "", {}, &server}; + std::unique_ptr game; + Server_AbstractPlayer *player; + Server_CardZone *hand; + ResponseContainer response{1}; + + void SetUp() override + { + GameConfig config; + config.creatorInfo = *owner.getUserInfo(); + config.gameId = 1; + config.maxPlayers = 2; + config.shareDecklistsOnLoad = false; + game = std::make_unique(config, &room); + game->addPlayer(&owner, response, {}); + game->addPlayer(&target, response, {}); + for (auto *participant : game->getPlayers()) { + GameCommand command; + command.MutableExtension(Command_DeckSelect::ext) + ->set_deck(DeckList().writeToString_Native().toStdString()); + GameEventStorage events; + ASSERT_EQ(participant->processGameCommand(command, response, events), Response::RespOk); + participant->setReadyStart(true); + } + game->startGameIfReady(false); + QCoreApplication::processEvents(); + ASSERT_TRUE(game->getGameStarted()); + player = game->getPlayer(0); + hand = player->getZones().value(ZoneNames::HAND); + for (int i = 0; i < 4; ++i) { + hand->insertCard( + new Server_Card({.name = "Twin", .providerId = QString::number(i)}, player->newCardId(), i, 0), -1, 0); + } + } + + Response::ResponseCode reveal(int count, GameEventStorage &events) + { + GameCommand command; + auto *request = command.MutableExtension(Command_RevealRandomCards::ext); + request->set_zone_name(ZoneNames::HAND); + request->set_player_id(1); + request->set_number_of_cards(count); + return player->processGameCommand(command, response, events); + } +}; + +TEST_F(RandomReveal, DistinctPhysicalCardsArePrivateAndLeaveTheHandUnchanged) +{ + const auto original = hand->getCards(); + GameEventStorage events; + ASSERT_EQ(reveal(2, events), Response::RespOk); + ASSERT_EQ(events.getGameEventList().size(), 2); + EXPECT_EQ(events.getPrivatePlayerId(), 1); + const auto *privateEvent = events.getGameEventList().at(0); + EXPECT_EQ(privateEvent->getRecipients(), GameEventStorageItem::SendToPrivate); + const auto &cards = privateEvent->getGameEvent().GetExtension(Event_RevealCards::ext); + ASSERT_EQ(cards.cards_size(), 2); + EXPECT_NE(cards.cards(0).id(), cards.cards(1).id()); + const auto *publicEvent = events.getGameEventList().at(1); + EXPECT_EQ(publicEvent->getRecipients(), GameEventStorageItem::SendToOthers); + const auto ¬ice = publicEvent->getGameEvent().GetExtension(Event_RevealCards::ext); + EXPECT_EQ(notice.cards_size(), 0); + EXPECT_EQ(notice.number_of_cards(), 2); + EXPECT_EQ(hand->getCards(), original); +} + +TEST_F(RandomReveal, InvalidCountsDoNotQueueAnyReveal) +{ + for (int count : {0, -1, 5}) { + GameEventStorage events; + EXPECT_NE(reveal(count, events), Response::RespOk); + EXPECT_TRUE(events.getGameEventList().isEmpty()); + } +} +} // namespace + +RNG_Abstract *rng = &firstRng; + +int main(int argc, char **argv) +{ + QCoreApplication application(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 1ed7e453576d8d29e1876da0eb4ba85e34c9d562 Mon Sep 17 00:00:00 2001 From: Bryce! Date: Sun, 8 Nov 2026 13:02:07 -0500 Subject: [PATCH 097/104] [PictureLoader] Improve image negotiation, cache recovery and redirects (#7703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix picture-loader cache formats, redirects and offline queue handling * Format picture-loader changes using project style * Address review: name decoded image fields, test cancelled requests Use designated initializers for DecodedImage so a field reorder cannot silently swap the image and its error string. Extract the cancelled-request sweep shared by the dispatcher and the single-request path into takeCancelledRequests(), and cover it: one status retirement per vanished work item, in queue order, with live entries kept. --------- Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> --- .../card_picture_loader_image_formats.h | 69 +++++++++ .../card_picture_loader_request_queue.h | 40 +++++ .../card_picture_loader_worker.cpp | 141 +++++++++++------- .../card_picture_loader_worker.h | 15 +- .../card_picture_loader_worker_work.cpp | 134 +++++++++++------ .../card_picture_loader_worker_work.h | 26 ++-- .../card_picture_loader/redirect_chain.h | 37 +++++ tests/CMakeLists.txt | 2 + tests/image_redirect_chain_test.cpp | 82 ++++++++++ tests/image_request_queue_test.cpp | 85 +++++++++++ 10 files changed, 509 insertions(+), 122 deletions(-) create mode 100644 cockatrice/src/interface/card_picture_loader/card_picture_loader_image_formats.h create mode 100644 cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h create mode 100644 cockatrice/src/interface/card_picture_loader/redirect_chain.h create mode 100644 tests/image_redirect_chain_test.cpp create mode 100644 tests/image_request_queue_test.cpp diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_image_formats.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_image_formats.h new file mode 100644 index 000000000..8c91a9592 --- /dev/null +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_image_formats.h @@ -0,0 +1,69 @@ +#ifndef COCKATRICE_CARD_PICTURE_LOADER_IMAGE_FORMATS_H +#define COCKATRICE_CARD_PICTURE_LOADER_IMAGE_FORMATS_H + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace CardPictureLoaderImageFormats +{ +inline QByteArray acceptHeader() +{ + // Decoder plugins differ across distributions. Prefer only formats this + // runtime can read, then retain valid wildcards for other image endpoints. + static const QByteArray header = [] { + const auto supported = QImageReader::supportedMimeTypes(); + QByteArrayList types; + for (const QByteArray &preferred : + {QByteArray("image/avif"), QByteArray("image/webp"), QByteArray("image/apng"), QByteArray("image/png"), + QByteArray("image/jpeg")}) { + if (supported.contains(preferred)) { + types.append(preferred); + } + } + for (const QByteArray &type : supported) { + if (type.startsWith("image/") && !types.contains(type)) { + types.append(type); + } + } + types.append("image/*;q=0.5"); + types.append("*/*;q=0.1"); + return types.join(','); + }(); + return header; +} + +struct DecodedImage +{ + QImage image; + QString error; +}; + +inline DecodedImage readImage(QIODevice *device) +{ + static constexpr int riffHeaderSize = 12; // RIFF_HEADER_SIZE from webp/format_constants.h + const auto header = device->peek(riffHeaderSize); + if (header.startsWith("RIFF") && header.endsWith("WEBP")) { + // Preserve the existing first-frame behavior for animated WebP files. + QBuffer buffer; + buffer.setData(device->readAll()); + QMovie movie(&buffer); + movie.start(); + movie.stop(); + return DecodedImage{.image = movie.currentImage(), .error = movie.lastErrorString()}; + } + + QImageReader reader; + reader.setDecideFormatFromContent(true); + reader.setDevice(device); + QImage image = reader.read(); + return DecodedImage{.image = image, .error = image.isNull() ? reader.errorString() : QString()}; +} +} // namespace CardPictureLoaderImageFormats + +#endif // COCKATRICE_CARD_PICTURE_LOADER_IMAGE_FORMATS_H diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h new file mode 100644 index 000000000..e00f43002 --- /dev/null +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h @@ -0,0 +1,40 @@ +#ifndef COCKATRICE_CARD_PICTURE_LOADER_REQUEST_QUEUE_H +#define COCKATRICE_CARD_PICTURE_LOADER_REQUEST_QUEUE_H + +#include +#include +#include +#include +#include + +class CardPictureLoaderWorkerWork; + +/** + * @brief One queued picture request. + * + * The work pointer is null once the owning work item was destroyed; the URL still + * owes its queued status entry a completion, so it must be retired from the queue. + */ +using CardPictureLoaderRequest = QPair>; + +/** + * @brief Removes entry.first for every entry whose second (a QPointer) is null. + * + * The removed URLs are returned in queue order and the surviving entries keep + * theirs. The caller emits one completion per returned URL: every queued request + * retires exactly one status entry, including the ones whose work item vanished. + */ +template QList takeCancelledRequests(QQueue &queue) +{ + QList cancelled; + for (int i = 0; i < queue.size();) { + if (queue.at(i).second) { + ++i; + } else { + cancelled.append(queue.takeAt(i).first); + } + } + return cancelled; +} + +#endif // COCKATRICE_CARD_PICTURE_LOADER_REQUEST_QUEUE_H diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 54d2e5cf5..d376d8204 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -2,11 +2,13 @@ #include "../../client/settings/cache_settings.h" #include "card_picture_loader_cache_method.h" +#include "card_picture_loader_image_formats.h" #include "card_picture_loader_local.h" #include "card_picture_loader_worker_work.h" #include "card_picture_to_load.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/utility/server_rate_limiter.h" +#include "redirect_chain.h" #include #include @@ -100,6 +102,22 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() connect(&SettingsCache::instance().downloads(), &DownloadSettings::hostRequestLimitsChanged, this, [this] { hostRequestLimits = SettingsCache::instance().downloads().getHostRequestLimits(); }); + connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this, [this] { + picDownload = SettingsCache::instance().downloads().getPicDownload(); + if (!picDownload) { + // Waiting for network quota or 429 recovery cannot help a cache-only lookup. + // Retire cancelled status entries just as the normal dispatcher does. + while (!requestLoadQueue.isEmpty()) { + const auto entry = requestLoadQueue.takeFirst(); + if (entry.second) { + makeRequest(entry.first, entry.second); + } else { + emit imageRequestSucceeded(entry.first); + } + } + updateTimerState(); + } + }); } CardPictureLoaderWorker::~CardPictureLoaderWorker() @@ -136,52 +154,49 @@ QThread *CardPictureLoaderWorker::workerThread() const void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker) { - QUrl cachedRedirect = getCachedRedirect(url); - if (!cachedRedirect.isEmpty()) { - queueRequest(cachedRedirect, worker); - return; - } - if (static_cast( - SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == - CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && - cache->metaData(url).isValid()) { + const QUrl resolvedUrl = resolveCachedRedirect(url); + if (!picDownload || (static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == + CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && + cache->metaData(resolvedUrl).isValid() && !worker->shouldBypassCache(resolvedUrl))) { // A request that will be served from the disk cache never touches the network and therefore // doesn't use up any of the rate limit, so it gets to skip the queue. - makeRequest(url, worker); + // Every reply retires a status, including online cache hits and deferred + // 429 retries. Pair that completion with this request before dispatch. + emit imageRequestQueued(resolvedUrl, worker->cardToDownload.getCard(), worker->cardToDownload.getSetName()); + makeRequest(resolvedUrl, worker); return; } - requestLoadQueue.append(qMakePair(url, worker)); - emit imageRequestQueued(url, worker->cardToDownload.getCard(), worker->cardToDownload.getSetName()); + requestLoadQueue.append(qMakePair(resolvedUrl, QPointer(worker))); + emit imageRequestQueued(resolvedUrl, worker->cardToDownload.getCard(), worker->cardToDownload.getSetName()); processQueuedRequests(); } QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker) { - // Check for cached redirects - QUrl cachedRedirect = getCachedRedirect(url); - if (!cachedRedirect.isEmpty()) { - // The status bar still needs to reclaim this URL's widget even when we hand the request back - // for a deferred retry instead of dispatching it onto the network. + // Redirect mappings can be learned while a request waits in the queue. + // Resolve again without recursion, and honor the target host's backoff. + const QUrl resolvedUrl = resolveCachedRedirect(url); + if (resolvedUrl != url) { emit imageRequestSucceeded(url); - // The redirect target is a different host, which may itself be in 429 backoff; hand the - // entry back to its worker so it waits the backoff out instead of dispatching straight - // onto the backed-off host. - if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(cachedRedirect.host(), - QDateTime::currentDateTime())) { - worker->scheduleDeferredRetry(cachedRedirect.host()); + if (requestTouchesNetwork(resolvedUrl, worker) && CardPictureLoaderWorkerWork::rateLimiter().isRateLimited( + resolvedUrl.host(), QDateTime::currentDateTime())) { + worker->scheduleDeferredRetry(resolvedUrl.host()); return nullptr; } - return makeRequest(cachedRedirect, worker); + // The original status was retired above; track the resolved request + // separately so its eventual completion has a matching queued entry. + emit imageRequestQueued(resolvedUrl, worker->cardToDownload.getCard(), worker->cardToDownload.getSetName()); } - QNetworkRequest req(url); + QNetworkRequest req(resolvedUrl); req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"); + req.setRawHeader("Accept", CardPictureLoaderImageFormats::acceptHeader()); // Cached entries are served straight from the disk cache even when picture downloads are // enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a // genuine cache miss goes to the network, and only when downloads are enabled. - bool useNetworkCache = !requestTouchesNetwork(url); + bool useNetworkCache = !requestTouchesNetwork(resolvedUrl, worker); req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork); @@ -190,7 +205,7 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture // Track in-flight replies per host so the unlocked fast path can bound how many requests it // issues at once, instead of creating replies that time out before Qt opens a connection. - const QString host = url.host(); + const QString host = resolvedUrl.host(); hostInFlight.insert(host, hostInFlight.value(host) + 1); // Release the in-flight slot when the reply is destroyed, not when it emits `finished`, and use @@ -201,8 +216,22 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture connect(reply, &QObject::destroyed, this, [this, host] { hostInFlight.insert(host, qMax(0, hostInFlight.value(host) - 1)); }); + // Retire the status even if the work object is destroyed before the reply + // finishes. Connect this before the work callback, which may enqueue a retry. + connect(reply, &QNetworkReply::finished, this, [this, reply] { emit imageRequestSucceeded(reply->url()); }); + // Connect reply handling; the work object is the context so its handler dies with it. - connect(reply, &QNetworkReply::finished, worker, [worker, reply] { worker->handleNetworkReply(reply); }); + const QPointer guardedWorker(worker); + connect(reply, &QNetworkReply::finished, worker, [guardedWorker, reply] { + if (guardedWorker) { + guardedWorker->handleNetworkReply(reply); + } + }); + // A work item may be cancelled before the reply finishes. In that case its + // context-bound handler is disconnected, but the reply must still release + // the host's in-flight slot and any network resources. + connect(worker, &QObject::destroyed, reply, [reply] { reply->abort(); }); + connect(reply, &QNetworkReply::finished, reply, &QObject::deleteLater); return reply; } @@ -267,6 +296,9 @@ void CardPictureLoaderWorker::dispatchQueuedRequest() // allowance: dispatch their queued requests back-to-back, bounded by their 429 backoff and the // per-host in-flight cap so a large burst can't queue replies that time out before Qt opens a // connection for them. + for (const QUrl &cancelled : takeCancelledRequests(requestLoadQueue)) { + emit imageRequestSucceeded(cancelled); + } for (int i = 0; i < requestLoadQueue.size();) { const auto &request = requestLoadQueue.at(i); // Dispatch decisions must key on the host the request will actually go to, not the URL that @@ -275,13 +307,14 @@ void CardPictureLoaderWorker::dispatchQueuedRequest() const QUrl resolvedUrl = resolveCachedRedirect(request.first); const QString host = resolvedUrl.host(); if (isUnlockedHost(host)) { - if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + if (requestTouchesNetwork(resolvedUrl, request.second) && + CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { ++i; continue; } if (hostInFlight.value(host) < MAX_IN_FLIGHT_PER_HOST) { - makeRequest(resolvedUrl, request.second); - requestLoadQueue.removeAt(i); + const auto entry = requestLoadQueue.takeAt(i); + makeRequest(entry.first, entry.second); dispatched = true; continue; } @@ -344,6 +377,9 @@ void CardPictureLoaderWorker::updateTimerState() bool CardPictureLoaderWorker::processSingleRequest() { QDateTime now = QDateTime::currentDateTime(); + for (const QUrl &cancelled : takeCancelledRequests(requestLoadQueue)) { + emit imageRequestSucceeded(cancelled); + } for (int i = 0; i < requestLoadQueue.size(); ++i) { // Copy the entry: takeAt(i) below erases within the list this reference points into. const auto request = requestLoadQueue.at(i); @@ -356,13 +392,14 @@ bool CardPictureLoaderWorker::processSingleRequest() // instead of leaving it parked in the queue with no reply pending. Only applies to // requests that will actually touch the network: one that will be served from the disk // cache costs nothing and shouldn't wait out the 429. - if (requestTouchesNetwork(request.first) && + if (requestTouchesNetwork(resolvedUrl, request.second) && CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { // The queued URL is usually a cached-redirect target whose host differs from // cardToDownload.getCurrentUrl(), so scheduleDeferredRetry() (which waits out the // blocked host's deadline) is used instead of startNextPicDownload() looping on the // original host. auto entry = requestLoadQueue.takeAt(i); + emit imageRequestSucceeded(entry.first); if (host != entry.first.host()) { // A cached redirect target is what is blocked, which the work object would not // discover from its own URL; wait out that specific host (with jitter) instead. @@ -393,15 +430,21 @@ bool CardPictureLoaderWorker::processSingleRequest() if (allowance > 0) { hostQuotaRemaining.insert(host, allowance - 1); auto entry = requestLoadQueue.takeAt(i); - makeRequest(resolvedUrl, entry.second); + makeRequest(entry.first, entry.second); return true; } } return false; } -bool CardPictureLoaderWorker::requestTouchesNetwork(const QUrl &url) const +bool CardPictureLoaderWorker::requestTouchesNetwork(const QUrl &url, const CardPictureLoaderWorkerWork *worker) const { + if (!picDownload) { + return false; + } + if (worker->shouldBypassCache(url)) { + return true; + } bool useNetworkCache = static_cast( SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && @@ -477,33 +520,19 @@ void CardPictureLoaderWorker::cacheRedirect(const QUrl &originalUrl, const QUrl // saveRedirectCache(); } +void CardPictureLoaderWorker::invalidateRedirect(const QUrl &url) +{ + redirectCache.remove(url); +} + void CardPictureLoaderWorker::removedCachedUrl(const QUrl &url) { networkManager->cache()->remove(url); } -QUrl CardPictureLoaderWorker::getCachedRedirect(const QUrl &originalUrl) const +QUrl CardPictureLoaderWorker::resolveCachedRedirect(const QUrl &url) { - if (redirectCache.contains(originalUrl)) { - return redirectCache[originalUrl].first; - } - return {}; -} - -QUrl CardPictureLoaderWorker::resolveCachedRedirect(const QUrl &url) const -{ - // Follow the whole cached-redirect chain so dispatch keys on the host that is really hit. The - // depth bound keeps a corrupt or self-referencing cache entry from spinning us forever. - QUrl resolved = url; - int depth = 0; - while (depth++ < MAX_REDIRECT_CHAIN_DEPTH) { - QUrl target = getCachedRedirect(resolved); - if (target.isEmpty() || target == resolved) { - break; - } - resolved = target; - } - return resolved; + return resolveImageRedirects(url, redirectCache, MAX_REDIRECT_CHAIN_DEPTH); } void CardPictureLoaderWorker::loadRedirectCache() diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h index 4b42c6e59..f7bbbff02 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h @@ -1,10 +1,13 @@ #ifndef PICTURE_LOADER_WORKER_H #define PICTURE_LOADER_WORKER_H +#include "card_picture_loader_request_queue.h" + #include #include #include #include +#include #include #include #include @@ -139,6 +142,9 @@ public slots: /** @brief Caches a redirect mapping between original and redirected URL. */ void cacheRedirect(const QUrl &originalUrl, const QUrl &redirectUrl); + /** @brief Removes a stale redirect mapping. */ + void invalidateRedirect(const QUrl &url); + /** @brief Removes a URL from the network cache. */ void removedCachedUrl(const QUrl &url); @@ -150,7 +156,7 @@ private: QString cacheFilePath; ///< Path to persistent redirect cache file static constexpr int CacheTTLInDays = 30; ///< Time-to-live for redirect cache entries (days) bool picDownload; ///< Whether downloading images from network is enabled - QQueue> requestLoadQueue; ///< Queue of pending network requests + QQueue requestLoadQueue; ///< Queue of pending network requests QTimer requestTimer; ///< Timer to reset the request quota QTimer dispatchTimer; ///< Timer pacing individual network requests @@ -185,12 +191,9 @@ private: */ [[nodiscard]] bool isUnlockedHost(const QString &host) const; - /** @brief Returns cached redirect URL for the given original URL, if available. */ - [[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const; - /** @brief Whether a request for this URL would actually touch the network, rather than being served from the disk * cache. */ - [[nodiscard]] bool requestTouchesNetwork(const QUrl &url) const; + [[nodiscard]] bool requestTouchesNetwork(const QUrl &url, const CardPictureLoaderWorkerWork *worker) const; /** * @brief Follows the cached-redirect chain to the URL that will actually be requested. @@ -200,7 +203,7 @@ private: * Dispatch decisions (unlocked-host fast path, 429 backoff, in-flight cap) must key on the host * a request really goes to, not the URL that merely redirects to it. */ - [[nodiscard]] QUrl resolveCachedRedirect(const QUrl &url) const; + [[nodiscard]] QUrl resolveCachedRedirect(const QUrl &url); /** @brief Loads redirect cache from disk. */ void loadRedirectCache(); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp index 92524384a..f0f2dc86a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp @@ -1,22 +1,20 @@ #include "card_picture_loader_worker_work.h" #include "../../client/settings/cache_settings.h" +#include "card_picture_loader_image_formats.h" #include "card_picture_loader_worker.h" #include "card_picture_to_load.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/utility/server_rate_limiter.h" -#include #include #include #include #include -#include #include #include #include -#include #include #include #include @@ -25,6 +23,8 @@ #include #include #include +#include +#include ServerRateLimiter CardPictureLoaderWorkerWork::s_rateLimiter; #include @@ -50,23 +50,32 @@ CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(CardPictureLoaderWorker // Hook up signals to the orchestrator connect(this, &CardPictureLoaderWorkerWork::requestImageDownload, worker, &CardPictureLoaderWorker::queueRequest); connect(this, &CardPictureLoaderWorkerWork::urlRedirected, worker, &CardPictureLoaderWorker::cacheRedirect); + connect(this, &CardPictureLoaderWorkerWork::redirectInvalidated, worker, + &CardPictureLoaderWorker::invalidateRedirect); connect(this, &CardPictureLoaderWorkerWork::cachedUrlInvalidated, worker, &CardPictureLoaderWorker::removedCachedUrl); connect(this, &CardPictureLoaderWorkerWork::imageLoaded, worker, &CardPictureLoaderWorker::handleImageLoaded); - connect(this, &CardPictureLoaderWorkerWork::requestSucceeded, worker, - &CardPictureLoaderWorker::imageRequestSucceeded); connect(this, &CardPictureLoaderWorkerWork::rateLimited, worker, &CardPictureLoaderWorker::onHostRateLimited); // Hook up signals to settings connect(&SettingsCache::instance().downloads(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged())); + deferredRetry = new QTimer(this); + deferredRetry->setSingleShot(true); + connect(deferredRetry, &QTimer::timeout, this, [this] { + s_rateLimiter.clearExpired(QDateTime::currentDateTime()); + cardToDownload.resetIndices(); + startNextPicDownload(); + }); + startNextPicDownload(); } void CardPictureLoaderWorkerWork::startNextPicDownload() { + redirectVisited.clear(); QDateTime now = QDateTime::currentDateTime(); - while (!cardToDownload.getCurrentUrl().isEmpty() && + while (picDownload && !cardToDownload.getCurrentUrl().isEmpty() && s_rateLimiter.isRateLimited(QUrl(cardToDownload.getCurrentUrl()).host(), now)) { QString host = QUrl(cardToDownload.getCurrentUrl()).host(); if (s_rateLimiter.rounds(host) == 1) { @@ -96,7 +105,11 @@ void CardPictureLoaderWorkerWork::startNextPicDownload() QString picUrl = cardToDownload.getCurrentUrl(); if (picUrl.isEmpty()) { - scheduleDeferredRetry(); + if (picDownload) { + scheduleDeferredRetry(); + } else { + concludeImageLoad(QImage()); + } } else { QUrl url(picUrl); qCDebug(CardPictureLoaderWorkerWorkLog).nospace() @@ -127,27 +140,38 @@ void CardPictureLoaderWorkerWork::picDownloadFailed() void CardPictureLoaderWorkerWork::handleNetworkReply(QNetworkReply *reply) { - QVariant redirectTarget = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); - bool redirectFailure = false; + const QVariant redirectTarget = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); + const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool isRedirect = + statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308; + bool redirectFailure = isRedirect && !redirectTarget.isValid(); if (redirectTarget.isValid()) { - QUrl url = reply->request().url(); - QUrl redirectUrl = redirectTarget.toUrl(); - if (redirectUrl.isRelative()) { - redirectUrl = url.resolved(redirectUrl); - } - if (url == redirectUrl) { - qCWarning(CardPictureLoaderWorkerWorkLog) << "recursive redirect detected!"; + const QUrl url = reply->url(); + const QUrl redirectUrl = url.resolved(redirectTarget.toUrl()); + redirectVisited.insert(url); + if (!redirectUrl.isValid() || redirectUrl.isEmpty() || redirectVisited.contains(redirectUrl) || + redirectVisited.size() > 10) { + qCWarning(CardPictureLoaderWorkerWorkLog) << "Invalid, cyclic or overlong redirect from" << url; redirectFailure = true; } else { emit urlRedirected(url, redirectUrl); } } - if (redirectFailure || reply->error()) { + if (redirectFailure) { + for (const QUrl &visited : std::as_const(redirectVisited)) { + emit redirectInvalidated(visited); + } + // Do not retry a cached cyclic redirect as a corrupt image: advance to + // the next configured source, just as for an ordinary failed response. + if (reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool()) { + emit cachedUrlInvalidated(reply->url()); + } + picDownloadFailed(); + } else if (reply->error()) { handleFailedReply(reply); } else { handleSuccessfulReply(reply); - emit requestSucceeded(reply->url()); } reply->deleteLater(); @@ -184,6 +208,15 @@ void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply) QDateTime backoffUntil = s_rateLimiter.on429(host, now, retryAfterMs); emit rateLimited(host); + // Downloads may have been disabled while this request was in flight. + // Record the server's limit for future online requests, but resume the + // cache-only search now instead of scheduling a new network backoff. + if (!picDownload) { + cardToDownload.resetIndices(); + startNextPicDownload(); + return; + } + if (s_rateLimiter.rounds(host) == 1) { qCWarning(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() @@ -208,7 +241,9 @@ void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply) emit cachedUrlInvalidated(reply->url()); - emit requestImageDownload(reply->url(), this); + if (!retryCachedUrl(reply->url())) { + picDownloadFailed(); + } } else { qCDebug(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() @@ -226,9 +261,8 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply) // List of status codes from https://doc.qt.io/qt-6/qnetworkreply.html#redirected int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 305 || statusCode == 307 || - statusCode == 308) { - QUrl redirectUrl = reply->header(QNetworkRequest::LocationHeader).toUrl(); + if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308) { + QUrl redirectUrl = reply->url().resolved(reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl()); qCDebug(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName() << "]: following " << (isFromCache ? "cached redirect" : "redirect") << " to " @@ -252,15 +286,23 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply) return; } - QImage image = tryLoadImageFromReply(reply); + const auto decoded = CardPictureLoaderImageFormats::readImage(reply); + const QImage &image = decoded.image; if (image.isNull()) { qCDebug(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName() << "]: Possible " << (isFromCache ? "cached" : "downloaded") << " picture at " - << reply->url().toDisplayString() << " could not be loaded: " << reply->errorString(); + << reply->url().toDisplayString() << " could not be loaded: " << decoded.error; - picDownloadFailed(); + // HTTP success can still contain an invalid or unsupported image. Remove + // that cache entry so future loads do not keep selecting the same bytes. + emit cachedUrlInvalidated(reply->url()); + if (!isFromCache || !retryCachedUrl(reply->url())) { + // Fresh bad responses advance to the next source; cached-only mode + // must never turn a decoder failure into a network download. + picDownloadFailed(); + } } else { qCDebug(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName() @@ -271,27 +313,21 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply) } } -QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply) +bool CardPictureLoaderWorkerWork::shouldBypassCache(const QUrl &url) const { - static constexpr int riffHeaderSize = 12; // RIFF_HEADER_SIZE from webp/format_constants.h - auto replyHeader = reply->peek(riffHeaderSize); + return picDownload && retriedCacheUrls.contains(url); +} - if (replyHeader.startsWith("RIFF") && replyHeader.endsWith("WEBP")) { - auto imgBuf = QBuffer(this); - imgBuf.setData(reply->readAll()); - - auto movie = QMovie(&imgBuf); - movie.start(); - movie.stop(); - - return movie.currentImage(); +bool CardPictureLoaderWorkerWork::retryCachedUrl(const QUrl &url) +{ + if (!picDownload || retriedCacheUrls.contains(url)) { + return false; } - - QImageReader imgReader; - imgReader.setDecideFormatFromContent(true); - imgReader.setDevice(reply); - - return imgReader.read(); + // Record before emitting: the queue can dispatch synchronously, and removing + // an unreadable cache entry can fail. The retry must bypass that entry. + retriedCacheUrls.insert(url); + emit requestImageDownload(url, this); + return true; } void CardPictureLoaderWorkerWork::scheduleDeferredRetry(const QString &preferredHost) @@ -324,12 +360,7 @@ void CardPictureLoaderWorkerWork::scheduleDeferredRetry(const QString &preferred << " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, scheduling deferred retry in " << waitMs << "ms"; - QTimer::singleShot(waitMs, this, [this] { - s_rateLimiter.clearExpired(QDateTime::currentDateTime()); - - cardToDownload.resetIndices(); - startNextPicDownload(); - }); + deferredRetry->start(static_cast(qMin(waitMs, std::numeric_limits::max()))); } void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image) @@ -341,4 +372,9 @@ void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image) void CardPictureLoaderWorkerWork::picDownloadChanged() { picDownload = SettingsCache::instance().downloads().getPicDownload(); + if (!picDownload && deferredRetry->isActive()) { + deferredRetry->stop(); + cardToDownload.resetIndices(); + startNextPicDownload(); + } } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h index c3dbcc6e8..da9420773 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h @@ -6,7 +6,10 @@ #include #include #include +#include #include +#include +#include #include class ExactCard; @@ -47,6 +50,9 @@ public: /** @brief Shared per-server 429 backoff state. */ static const ServerRateLimiter &rateLimiter(); + /** @brief A corrupt cached response gets one network retry per URL and work item. */ + [[nodiscard]] bool shouldBypassCache(const QUrl &url) const; + /** * @brief Starts downloading the next URL for this card. * @@ -74,7 +80,12 @@ public slots: void handleNetworkReply(QNetworkReply *reply); private: - bool picDownload; ///< Whether network downloading is enabled + bool picDownload; ///< Whether network downloading is enabled + QTimer *deferredRetry = nullptr; ///< Owned retry, cancellable when downloads are disabled + QSet retriedCacheUrls; + + bool retryCachedUrl(const QUrl &url); + QSet redirectVisited; ///< Redirect chain for the current source URL static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state @@ -87,13 +98,6 @@ private: /** @brief Processes a successful network reply. */ void handleSuccessfulReply(QNetworkReply *reply); - /** - * @brief Attempts to read an image from a network reply. - * @param reply The network reply - * @return The loaded QImage or an empty image if reading failed - */ - QImage tryLoadImageFromReply(QNetworkReply *reply); - /** * @brief Finalizes the image loading process. * @param image The loaded image (empty if failed) @@ -116,15 +120,15 @@ signals: */ void imageLoaded(const ExactCard &card, const QImage &image); - /** @brief Emitted when a network request completes successfully. */ - void requestSucceeded(const QUrl &url); - /** @brief Request that a URL be downloaded. */ void requestImageDownload(const QUrl &url, CardPictureLoaderWorkerWork *instance); /** @brief Emitted when a URL has been redirected. */ void urlRedirected(const QUrl &originalUrl, const QUrl &redirectUrl); + /** @brief A redirect mapping is unsafe and should be removed. */ + void redirectInvalidated(const QUrl &url); + /** @brief Emitted when a server returned HTTP 429. */ void rateLimited(const QString &host); diff --git a/cockatrice/src/interface/card_picture_loader/redirect_chain.h b/cockatrice/src/interface/card_picture_loader/redirect_chain.h new file mode 100644 index 000000000..ffcfe6b10 --- /dev/null +++ b/cockatrice/src/interface/card_picture_loader/redirect_chain.h @@ -0,0 +1,37 @@ +#ifndef COCKATRICE_REDIRECT_CHAIN_H +#define COCKATRICE_REDIRECT_CHAIN_H + +#include +#include +#include +#include +#include +#include + +// Resolve previously accepted redirects without recursion. Invalid chains are +// removed so subsequent requests can rediscover them from the source server. +inline QUrl resolveImageRedirects(const QUrl &url, QHash> &cache, int maxHops) +{ + QUrl resolved = url; + QSet visited; + for (int depth = 0; depth <= maxHops; ++depth) { + if (visited.contains(resolved)) { + break; + } + const auto it = cache.constFind(resolved); + if (it == cache.cend() || it.value().first.isEmpty()) { + return resolved; + } + if (depth == maxHops) { + break; + } + visited.insert(resolved); + resolved = it.value().first; + } + for (const QUrl &entry : std::as_const(visited)) { + cache.remove(entry); + } + return url; +} + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 04b3575aa..ae90ee896 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -171,6 +171,8 @@ endfunction() # --- Unit tests ------------------------------------------------------------------ +cockatrice_add_test(NAME image_request_queue_test SOURCES image_request_queue_test.cpp) +cockatrice_add_test(NAME image_redirect_chain_test SOURCES image_redirect_chain_test.cpp) cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility) diff --git a/tests/image_redirect_chain_test.cpp b/tests/image_redirect_chain_test.cpp new file mode 100644 index 000000000..072512d96 --- /dev/null +++ b/tests/image_redirect_chain_test.cpp @@ -0,0 +1,82 @@ +#include "../cockatrice/src/interface/card_picture_loader/redirect_chain.h" + +#include + +namespace +{ +using RedirectCache = QHash>; + +QUrl url(int index) +{ + return QUrl(QStringLiteral("https://cards.example/%1").arg(index)); +} + +RedirectCache chain(int hops) +{ + RedirectCache cache; + for (int index = 0; index < hops; ++index) { + cache.insert(url(index), {url(index + 1), QDateTime()}); + } + return cache; +} +} // namespace + +TEST(ImageRedirectChain, MissingHopReturnsLastKnownUrlWithoutRemovingMappings) +{ + auto cache = chain(2); + const auto original = cache; + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(2)); + EXPECT_EQ(cache, original); + EXPECT_EQ(resolveImageRedirects(url(9), cache, 10), url(9)); +} + +TEST(ImageRedirectChain, ExactlyMaxHopsResolvesAndKeepsCache) +{ + auto cache = chain(10); + const auto original = cache; + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(10)); + EXPECT_EQ(cache, original); +} + +TEST(ImageRedirectChain, CycleReturnsOriginalAndPurgesVisitedMappings) +{ + auto cache = chain(2); + cache.insert(url(2), {url(1), QDateTime()}); + cache.insert(url(9), {url(10), QDateTime()}); + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(0)); + EXPECT_FALSE(cache.contains(url(0))); + EXPECT_FALSE(cache.contains(url(1))); + EXPECT_FALSE(cache.contains(url(2))); + EXPECT_TRUE(cache.contains(url(9))); +} + +TEST(ImageRedirectChain, OverlongChainReturnsOriginalAndPurgesVisitedMappings) +{ + auto cache = chain(12); + cache.insert(url(20), {url(21), QDateTime()}); + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(0)); + for (int index = 0; index < 10; ++index) { + EXPECT_FALSE(cache.contains(url(index))); + } + EXPECT_TRUE(cache.contains(url(10))); + EXPECT_TRUE(cache.contains(url(11))); + EXPECT_TRUE(cache.contains(url(20))); +} + +TEST(ImageRedirectChain, EmptyTargetStopsAtItsSource) +{ + auto cache = chain(1); + cache.insert(url(1), {QUrl(), QDateTime()}); + const auto original = cache; + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(1)); + EXPECT_EQ(cache, original); +} + +TEST(ImageRedirectChain, ZeroHopsDoesNotFollowOrRemoveMappings) +{ + auto cache = chain(1); + EXPECT_EQ(resolveImageRedirects(url(5), cache, 0), url(5)); + EXPECT_TRUE(cache.contains(url(0))); + EXPECT_EQ(resolveImageRedirects(url(0), cache, 0), url(0)); + EXPECT_TRUE(cache.contains(url(0))); +} diff --git a/tests/image_request_queue_test.cpp b/tests/image_request_queue_test.cpp new file mode 100644 index 000000000..b911483c1 --- /dev/null +++ b/tests/image_request_queue_test.cpp @@ -0,0 +1,85 @@ +#include "../cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h" + +#include +#include +#include +#include +#include + +namespace +{ +using TestRequest = QPair>; + +QUrl url(int index) +{ + return QUrl(QStringLiteral("https://cards.example/%1").arg(index)); +} + +TestRequest live(int index, QObject &owner) +{ + return qMakePair(url(index), QPointer(&owner)); +} + +TestRequest cancelled(int index) +{ + return qMakePair(url(index), QPointer()); +} + +QList queuedUrls(const QQueue &queue) +{ + QList urls; + for (const auto &entry : queue) { + urls.append(entry.first); + } + return urls; +} +} // namespace + +TEST(PictureLoaderRequestQueue, EmptyQueueHasNothingToRetire) +{ + QQueue queue; + EXPECT_TRUE(takeCancelledRequests(queue).isEmpty()); + EXPECT_TRUE(queue.isEmpty()); +} + +TEST(PictureLoaderRequestQueue, LiveRequestsStayQueuedInOrder) +{ + QObject first; + QObject second; + QQueue queue; + queue.enqueue(live(1, first)); + queue.enqueue(live(2, second)); + + EXPECT_TRUE(takeCancelledRequests(queue).isEmpty()); + EXPECT_EQ(queuedUrls(queue), (QList{url(1), url(2)})); +} + +TEST(PictureLoaderRequestQueue, CancelledRequestsAreRetiredInQueueOrder) +{ + QObject owner; + QQueue queue; + queue.enqueue(live(1, owner)); + queue.enqueue(cancelled(2)); + queue.enqueue(live(3, owner)); + queue.enqueue(cancelled(4)); + queue.enqueue(cancelled(5)); + + // Exactly one status entry is retired per vanished work item, in queue order. + EXPECT_EQ(takeCancelledRequests(queue), (QList{url(2), url(4), url(5)})); + // The surviving requests keep their relative order. + EXPECT_EQ(queuedUrls(queue), (QList{url(1), url(3)})); + + // Nothing is retired twice on a later sweep. + EXPECT_TRUE(takeCancelledRequests(queue).isEmpty()); + EXPECT_EQ(queuedUrls(queue), (QList{url(1), url(3)})); +} + +TEST(PictureLoaderRequestQueue, AllCancelledEmptiesTheQueue) +{ + QQueue queue; + queue.enqueue(cancelled(1)); + queue.enqueue(cancelled(2)); + + EXPECT_EQ(takeCancelledRequests(queue), (QList{url(1), url(2)})); + EXPECT_TRUE(queue.isEmpty()); +} From e81ee02c24950ba93739e58313196da54007a380 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:09:01 +0100 Subject: [PATCH 098/104] [Icons] Tint semantic glyphs and add badge and sticker treatments (#7775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Icons] Keep semantic red and green on themed glyphs The one-glyph cutover recolored every icon from a single palette role, so a disabled deck, a not-ready player and a remove button all lost the red or green that carried their meaning. Add Positive and Negative application colors beside the accents, ship scheme-appropriate defaults for every theme, and resolve them through ThemeManager. Icons now take an IconTint: a palette role for structural glyphs, or a semantic role for states and add/remove actions. A disabled semantic icon falls back to the disabled foreground; on a selected row a semantic tint keeps its color only while it stays legible on the highlight, and a palette tint keeps the existing WindowText -> HighlightedText swap. * [Icons] Render a semantic-tint sheet for every shipped theme The PR showed two sheets for one theme, which cannot show that the shipped reds and greens stay legible on every palette. Each shipped theme and scheme now gets its own sheet: both families, three glyphs, at 16 and 24 px, on the window and button surfaces the contrast is measured against, with the disabled tone beside each family. The generator lives in its own test file so the preview work does not have to move the branches above it. * [Icons] Add a badge icon treatment Restore the filled-disc look the ready/not-ready icons had before the one-glyph cutover, without bringing back palette-locked art. IconStyle::Badge composes the existing sentinel SVG at paint time: a gradient disc, a dark ring, and the glyph re-weighted and inked for contrast against the tone. Line stays the default and is unchanged. Only the two state icons opt in. The add/remove icons keep the line treatment because their old art was a filled glyph with an outline and no disc, so badging them would invent a look the UI never had. Disabled badges fall back to the line glyph so an unavailable control still reads as unavailable. * [Icons] Add a sticker icon treatment The pre-#7595 add, remove and delete icons were filled shapes with a black outline and no disc. A tint cannot bring that back, and the badge disc is a different icon language, so add a third value to the existing style axis. IconStyle::Sticker draws the sentinel glyph twice: a near-black rim at the band width plus one rim on each side, then the tone pass clipped to the glyph and filled with a vertical gradient. Both treatments keep the disabled fallback to the line glyph. The fill rule moves into theme_icon_fill: the semantic tones are tuned for text, so a filled shape gives its tone a saturation floor, and a dark-scheme tone is pulled down rather than up. IconTint::resolve() applies the same floor to line glyphs, and the badge disc uses the same fill, so a line, a badge and a sticker next to each other carry the same green or red instead of a pale line beside a saturated glyph. The badge's glyph ink follows the scheme: white on light-scheme tones, as in the pre-#7595 art, and the saturated tone darkened to an outline-dark ink on dark-scheme tones, where the shared saturated disc is light enough that white would sit near 2.7:1. The new test pins both cases so the glyph cannot silently flip. resolveSvgStrokeWidth() moves the stroke-width rewrite into libcockatrice_utility so the badge and the sticker share it; the badge's local copy is gone. The sticker is opted into at 17 call sites: increment, decrement and remove_row in the deck editor, settings pages, token dialog, server dialogs and replay/deck storage tabs. The visual database's clear-filter button keeps its trash can (icons/delete) in the line treatment: that glyph is new in #7595, but it is the clearer metaphor, and a sticker rim closes its lid gap at toolbar sizes. The preview generators also gain a semantic-tint sheet for #7775 and a line/badge/sticker family strip, so the three PR descriptions can show the same rendered colors. * [Icons] Render authored theme SVG art without the SVG image plugin `QPixmap("theme:file.svg")` needs Qt's SVG *image-format* plugin. Debian builds that as the separate `qt6-svg-plugins` package, which is only a Recommends of `qt6-svg-dev`, so a `--no-install-recommends` install (CI, minimal user installs) does not have it. Authored theme art then loaded as a null pixmap: the two authored-art render tests failed on Debian 13 and on the Ubuntu sanitize leg while passing on a developer machine whose Qt bundles the format. Render authored SVG assets through QSvgRenderer instead, which is already linked for the sentinel path and needs no plugin. Raster assets keep the QPixmap loader. The test suite now hides the plugin directories, so the dependency cannot come back unnoticed on platforms that ship the plugin. * [Icons] Render a badge/sticker sheet for every shipped theme The PR showed one theme's badge and sticker sheets, which cannot show that the filled treatments read on every palette. Each shipped theme and scheme now gets its own sheet: both semantic families, three glyphs, as line glyphs, stickers and badges, at 16 and 24 px, on the window and button surfaces the contrast is measured against, with the disabled fallback under each family. The generator lives in its own test file so the preview work does not have to move the branches above it. * [Icons] Show the trash can with the sticker treatment again The sticker sheet kept the delete glyph out of the sticker rows and explained why: at toolbar sizes the rim closed the lid gap. The icon is used with the sticker treatment again for destructive actions, so the sheet now shows it and the exception note is gone. * [Icons] Apply the CI clang-format output to the treatment preview clang-format 18 wraps the family label and the glyph x offset differently from the local clang-format 23. The preview test takes the CI output so the bottom of the stack passes the style job on its own. * [Icons] Rewrite the icon role docs in plain language Address review: drop the AI-cadence prose from the IconStyle/IconTint and treatment headers, use ///< for the enum member docs, and move theme_icon.cpp's anonymous namespace above the IconTint definitions. No behavior change. * Address review findings --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 3 + cockatrice/src/filters/filter_builder.cpp | 3 +- .../player/player_list_widget.cpp | 4 +- .../palette_editor/palette_generator.cpp | 4 + .../palette_editor/palette_grid_widget.cpp | 13 +- cockatrice/src/interface/theme_config.cpp | 17 + cockatrice/src/interface/theme_config.h | 12 + cockatrice/src/interface/theme_icon.cpp | 148 ++- cockatrice/src/interface/theme_icon.h | 87 +- cockatrice/src/interface/theme_icon_badge.cpp | 137 +++ cockatrice/src/interface/theme_icon_badge.h | 81 ++ cockatrice/src/interface/theme_icon_fill.cpp | 61 ++ cockatrice/src/interface/theme_icon_fill.h | 63 ++ .../src/interface/theme_icon_sticker.cpp | 87 ++ cockatrice/src/interface/theme_icon_sticker.h | 61 ++ cockatrice/src/interface/theme_manager.cpp | 23 +- cockatrice/src/interface/theme_manager.h | 3 +- .../deck_editor_database_display_widget.cpp | 4 +- .../deck_editor_deck_dock_widget.cpp | 6 +- .../deck_editor_filter_dock_widget.cpp | 2 +- .../interface/widgets/dialogs/dlg_connect.cpp | 3 +- .../widgets/dialogs/dlg_edit_tokens.cpp | 4 +- .../widgets/dialogs/dlg_register.cpp | 3 +- .../deck_editor_settings_page.cpp | 4 +- .../settings_page/messages_settings_page.cpp | 4 +- .../widgets/tabs/tab_deck_storage.cpp | 6 +- .../interface/widgets/tabs/tab_replays.cpp | 6 +- .../visual_database_display_widget.cpp | 2 +- .../themes/Fabric/palette-default-dark.toml | 2 + .../themes/Fabric/palette-default-light.toml | 2 + .../themes/Fusion/palette-default-dark.toml | 2 + .../themes/Fusion/palette-default-light.toml | 2 + .../themes/Leather/palette-default-dark.toml | 2 + .../themes/Leather/palette-default-light.toml | 2 + .../themes/Plasma/palette-default-dark.toml | 2 + .../themes/Plasma/palette-default-light.toml | 2 + .../themes/System/palette-default-dark.toml | 7 +- .../themes/System/palette-default-light.toml | 7 +- .../VelvetMarble/palette-default-dark.toml | 2 + .../VelvetMarble/palette-default-light.toml | 2 + .../libcockatrice/utility/svg_recolor.cpp | 43 + .../libcockatrice/utility/svg_recolor.h | 17 + oracle/CMakeLists.txt | 3 + tests/svg_recolor_test.cpp | 53 + tests/theme_migration/CMakeLists.txt | 5 + .../icon_tint_preview_test.cpp | 227 +++++ .../icon_treatment_preview_test.cpp | 245 +++++ .../theme_icon_render_test.cpp | 910 ++++++++++++++++++ .../theme_migration/theme_migration_test.cpp | 35 + 49 files changed, 2351 insertions(+), 72 deletions(-) create mode 100644 cockatrice/src/interface/theme_icon_badge.cpp create mode 100644 cockatrice/src/interface/theme_icon_badge.h create mode 100644 cockatrice/src/interface/theme_icon_fill.cpp create mode 100644 cockatrice/src/interface/theme_icon_fill.h create mode 100644 cockatrice/src/interface/theme_icon_sticker.cpp create mode 100644 cockatrice/src/interface/theme_icon_sticker.h create mode 100644 tests/theme_migration/icon_tint_preview_test.cpp create mode 100644 tests/theme_migration/icon_treatment_preview_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 77cb7f014..564e19891 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -152,6 +152,9 @@ set(cockatrice_SOURCES src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp src/interface/theme_icon.cpp + src/interface/theme_icon_badge.cpp + src/interface/theme_icon_fill.cpp + src/interface/theme_icon_sticker.cpp src/interface/theme_manager.cpp src/interface/palette_editor/color_button.cpp src/interface/palette_editor/palette_generator.cpp diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 9c2a91a5b..890da0f62 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -30,7 +30,8 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString()); + QPushButton *ok = new QPushButton( + loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 6895e7db8..bc27d85f9 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -68,8 +68,8 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start")); - notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start")); + readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"), AppColor::Negative, IconStyle::Badge); concededIcon = loadThemedIcon(QStringLiteral("icons/conceded")); playerIcon = loadThemedIcon(QStringLiteral("icons/player")); judgeIcon = loadThemedIcon(QStringLiteral("icons/scales")); diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index c98dcbe03..6bcfdc713 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -167,6 +167,10 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch // the lightened, desaturated companion used for button-gradient highlights. cfg.appColors[AppColor::AccentStrong] = hl; cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); + // Semantic roles stay independent of the accent: a generated palette must + // still paint add/ready green and remove/not-ready red. + cfg.appColors[AppColor::Positive] = defaultAppColor(AppColor::Positive, dark); + cfg.appColors[AppColor::Negative] = defaultAppColor(AppColor::Negative, dark); // BrightText QColor bright; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 392f01d0d..3fec389da 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -60,8 +61,14 @@ static const QMap ROLE_DESCRIPTIONS = { }; static const QMap APP_ROLE_DESCRIPTIONS = { - {AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")}, - {AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")}, + {AppColor::AccentStrong, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Vivid primary accent (e.g. home-tab button gradient start)")}, + {AppColor::AccentSoft, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Lightened, desaturated accent (e.g. home-tab button gradient end)")}, + {AppColor::Positive, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")}, + {AppColor::Negative, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")}, }; PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) @@ -179,7 +186,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) } auto *label = new QLabel(QString(appEnum.valueToKey(role)), host); - label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {})); + label->setToolTip(QCoreApplication::translate("PaletteGridWidget", APP_ROLE_DESCRIPTIONS.value(role, {}))); label->setContentsMargins(4, 2, 8, 2); grid->addWidget(label, row, 0); diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 54143d7d3..42d7a4691 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -451,3 +452,19 @@ FontConfig FontConfig::fromThemeDir(const QString &themeDirPath) return fromFile(QDir(themeDirPath).absoluteFilePath(fileName())); } + +QColor defaultAppColor(AppColor::Role role, bool dark) +{ + switch (role) { + case AppColor::AccentStrong: + case AppColor::AccentSoft: + // Derived from the palette highlight by ThemeManager::appColor(). + return {}; + case AppColor::Positive: + return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b")); + case AppColor::Negative: + return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e")); + } + + return {}; +} diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 84601a2bf..7d05e4bf0 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -20,6 +20,8 @@ enum Role { AccentStrong, AccentSoft, + Positive, + Negative, }; Q_ENUM_NS(Role) } // namespace AppColor @@ -41,6 +43,16 @@ enum Role Q_ENUM_NS(Role) } // namespace AppFont +/** + * @brief Fallback for an application color role when the active theme does not + * define one. + * + * Accent roles return an invalid QColor because ThemeManager derives them from + * the palette highlight. Semantic roles return an opaque default that suits the + * scheme, so themes written before the role existed still render it. + */ +QColor defaultAppColor(AppColor::Role role, bool dark); + struct ThemeConfig { QString colorScheme; diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 161c436ca..622534d8c 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -1,6 +1,9 @@ #include "theme_icon.h" #include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_badge.h" +#include "theme_icon_fill.h" +#include "theme_icon_sticker.h" #include "theme_manager.h" #include @@ -27,17 +30,6 @@ namespace { -/** - * @brief Palette color that replaces `currentColor`, following the icon mode. - */ -QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role) -{ - const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; - const QPalette::ColorRole effective = - mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role; - return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1(); -} - /** * @brief Reads an SVG through the `theme:` search path. */ @@ -99,14 +91,19 @@ QString cacheKey(const QString &path, const QByteArray &variant, const QSize &si * and the intrinsic size are cached until the theme changes: a repaint then * does no file I/O at all. The render cache is keyed by resolved path and pixel * size plus a variant: the palette color for sentinel SVGs, the icon mode for - * assets that carry their own colors. A palette change therefore produces new - * keys instead of reusing art built under the previous palette, and the - * non-sentinel path (raster overrides, plain SVGs) is cached like any other. + * assets that carry their own colors, and the tone plus glyph color for a badge. + * A palette change therefore produces new keys instead of reusing art built + * under the previous palette, and the non-sentinel path (raster overrides, plain + * SVGs) is cached like any other. + * + * The engine only resolves and caches. The badge shape itself is painted by + * ThemeIconBadge so the compositing stays out of the resolution path. */ class ThemeSvgIconEngine final : public QObject, public QIconEngine { public: - ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role) + ThemeSvgIconEngine(QString _prefix, IconTint _tint, IconStyle _style) + : prefix(std::move(_prefix)), tint(_tint), style(_style) { if (themeManager) { // A theme switch changes the resolved asset (scheme-qualified @@ -120,7 +117,7 @@ public: QIconEngine *clone() const override { - return new ThemeSvgIconEngine(prefix, role); + return new ThemeSvgIconEngine(prefix, tint, style); } void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override @@ -220,9 +217,21 @@ private: QPixmap fromAsset(const QSize &pixelSize, QIcon::Mode mode) const { - QPixmap pixmap(QStringLiteral("theme:") + resolvedPath); - if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) { - pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + QPixmap pixmap; + if (resolvedAssetIsSvg) { + // Render authored SVG art through QSvgRenderer rather than QPixmap. + // Loading an `.svg` through QPixmap needs Qt's SVG *image-format* + // plugin, which is a separate package on Linux distributions and is + // absent from minimal installs and from CI (it is a Recommends of + // qt6-svg-dev, so `--no-install-recommends` drops it). Art a theme + // ships must not disappear because of a packaging choice, and the + // renderer is already linked for the sentinel path. + pixmap = renderSvg(resolvedSvg, pixelSize.isEmpty() ? intrinsicSize() : pixelSize, 1.0); + } else { + pixmap = QPixmap(QStringLiteral("theme:") + resolvedPath); + if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) { + pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } } if (mode == QIcon::Disabled && !pixmap.isNull()) { pixmap = QApplication::style()->generatedIconPixmap(QIcon::Disabled, pixmap, nullptr); @@ -234,11 +243,35 @@ private: { ensureResolved(); - // Same cache for both paths: sentinel art varies by palette color, art + // Badge and Sticker are shape treatments, so they only apply to sentinel + // art: an authored asset keeps its own drawing and follows the line/asset + // path. Disabled also drops back to the line glyph: a filled disc or + // sticker at full mass does not read as unavailable next to the thin line + // glyphs around it, and the tint already removes the semantic tone. + const bool treated = style != IconStyle::Line && resolvedHasSentinel && mode != QIcon::Disabled; + const bool badge = treated && style == IconStyle::Badge; + const bool sticker = treated && style == IconStyle::Sticker; + // Same cache for all paths: sentinel art varies by palette color, art // with its own colors varies by mode (Disabled is generated). A color - // variant is never a bare digit, so the two key spaces cannot collide. - const QByteArray variant = - resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast(mode)); + // variant is never a bare digit, so the key spaces cannot collide. A + // badge also varies by glyph color, because the same tone can switch + // between a white and a near-black glyph at different lightnesses. + // The selected-row contrast fallback exists for a thin glyph painted + // directly on the highlight. A badge or sticker brings its own ring and + // contrast glyph, so repainting its whole tone with HighlightedText + // would collapse every badge on a selected row into one gray disc. + const QColor tone = resolvedHasSentinel ? tint.resolve(treated ? QIcon::Normal : mode) : QColor(); + QByteArray variant; + if (badge) { + variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' + + ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1(); + } else if (sticker) { + variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1(); + } else if (resolvedHasSentinel) { + variant = tone.name(QColor::HexRgb).toLatin1(); + } else { + variant = QByteArray::number(static_cast(mode)); + } const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio); const auto cached = cache.constFind(key); if (cached != cache.constEnd()) { @@ -246,7 +279,13 @@ private: } QPixmap pixmap; - if (resolvedHasSentinel) { + if (badge) { + // Geometry is computed from the device-pixel size, and the ratio is + // attached below like every other path. + pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize); + } else if (sticker) { + pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize); + } else if (resolvedHasSentinel) { pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio); } else { // Authored art carries its own colors: use Qt's normal loader so @@ -266,7 +305,8 @@ private: } QString prefix; - QPalette::ColorRole role; + IconTint tint; + IconStyle style; mutable QHash cache; mutable QString resolvedPath; mutable QString resolvedThemePath; @@ -279,12 +319,51 @@ private: }; } // namespace -QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role) +IconTint::IconTint(QPalette::ColorRole role) : paletteRole(role) { - return QIcon(new ThemeSvgIconEngine(prefix.toString(), role)); } -QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size) +IconTint::IconTint(AppColor::Role role) : source(Source::AppColor), appRole(role) +{ +} + +QColor IconTint::resolve(QIcon::Mode mode) const +{ + if (source == Source::AppColor) { + // A disabled control must not keep signalling its semantic color. + if (mode == QIcon::Disabled) { + return qApp->palette().color(QPalette::Disabled, QPalette::WindowText); + } + // The three treatments paint the same tone: a line glyph, a badge disc + // and a sticker fill must not disagree on how saturated the role is. + // The fill treatments apply this floor themselves, so this only changes + // the line path, and only for the desaturated dark-scheme tones. + const QColor color = ThemeIconFill::saturated(themeManager->appColor(appRole)); + if (mode != QIcon::Selected) { + return color; + } + // Selected rows paint their icon on the highlight colour. Keep the + // semantic tint when it stays legible there; otherwise the glyph would + // vanish into the selection. + constexpr double MIN_SELECTED_CONTRAST = 3.0; + const QColor highlight = qApp->palette().color(QPalette::Active, QPalette::Highlight); + return ThemeIconBadge::contrastRatio(color, highlight) >= MIN_SELECTED_CONTRAST + ? color + : qApp->palette().color(QPalette::Active, QPalette::HighlightedText); + } + + const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; + const QPalette::ColorRole effective = + mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole; + return qApp->palette().color(group, effective); +} + +QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style) +{ + return QIcon(new ThemeSvgIconEngine(prefix.toString(), tint, style)); +} + +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const QSize &size, IconStyle style) { if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) { return {}; @@ -295,6 +374,15 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole r return {}; } - const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1(); - return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0); + const QColor tone = tint.resolve(QIcon::Normal); + if (style == IconStyle::Badge || style == IconStyle::Sticker) { + QSize canvas = size; + if (canvas.isEmpty()) { + canvas = QSvgRenderer(svg).defaultSize(); + } + return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas) + : ThemeIconSticker::renderSticker(svg, tone, canvas); + } + + return renderSvg(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), size, 1.0); } diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index e26015ea1..e1a867fc0 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -6,6 +6,9 @@ #ifndef THEME_ICON_H #define THEME_ICON_H +#include "theme_config.h" + +#include #include #include #include @@ -13,6 +16,73 @@ #include #include +/** + * @brief Selects how a `currentColor` glyph is painted. + * + * The tint decides the tone. The style decides how much shape carries it. + * + * Line is the default and matches what every caller did before this axis + * existed: the recolored glyph alone, at the author's stroke weight. Use it for + * navigation, menu and toolbar glyphs, where a row of badges would compete with + * the content. + * + * Badge reproduces the pre-#7595 ready/not-ready art: a filled disc, a dark ring + * and a contrast glyph derived from the tone. A filled shape draws more + * attention than a line, so it is opt-in per call site. It was made for the + * ready/not-ready icons. The add/remove/delete icons were filled glyphs with an + * outline and no disc, so badging them would produce art the UI never had. + * Authored art that carries its own colors is never badged and falls back to the + * asset path. Disabled icons fall back to the line glyph too, because a filled + * disc at full mass does not read as unavailable. + * + * Sticker reproduces the other half of the pre-#7595 set: the filled add, remove + * and delete glyphs, painted with a tone gradient and a dark outline and no + * disc. It shares the disabled fallback for the same reason. + */ +enum class IconStyle +{ + Line, ///< the recolored glyph alone + Badge, ///< filled disc + dark ring + contrast glyph + Sticker, ///< filled glyph + dark outline, no disc +}; + +/** + * @brief Selects the color that replaces the `currentColor` sentinel. + * + * A palette role follows Qt's structural palette. An application color carries + * meaning that QPalette cannot express, such as positive and negative states. + * Callers choose the role. The active theme supplies the value. + */ +class IconTint +{ +public: + IconTint() = default; + IconTint(QPalette::ColorRole role); // implicit: existing palette-role callers + IconTint(AppColor::Role role); // implicit: semantic callers + + /** + * @brief Resolves the concrete color for an icon mode. + * + * A disabled application color resolves to the disabled foreground, so an + * unavailable control does not keep its semantic color. A selected palette + * color keeps the WindowText -> HighlightedText swap. Application colors go + * through the saturation floor shared by the filled treatments, so a line + * glyph, a badge disc and a sticker fill carry the same tone. + */ + QColor resolve(QIcon::Mode mode) const; + +private: + enum class Source + { + Palette, + AppColor, + }; + + Source source = Source::Palette; + QPalette::ColorRole paletteRole = QPalette::WindowText; + AppColor::Role appRole = AppColor::AccentStrong; +}; + /** * @brief Builds a palette-aware icon from a `theme:` asset. * @@ -24,11 +94,12 @@ * * @param prefix Asset prefix relative to the `theme:` search path, with no file * extension (for example `icons/search`), matching themePixmap(). - * @param role Palette role whose color replaces the sentinel. Selected glyphs - * use HighlightedText unless a role other than WindowText is given. + * @param tint Color that replaces the sentinel: a palette role or a semantic + * application color. Defaults to WindowText. + * @param style Treatment of the glyph: Line, Badge or Sticker. Defaults to Line. * @return An icon that follows the application palette. */ -QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText); +QIcon loadThemedIcon(QStringView prefix, IconTint tint = {}, IconStyle style = IconStyle::Line); /** * @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel. @@ -38,13 +109,17 @@ QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::Wi * * @param resolvedPath Asset path relative to the `theme:` search path, as * returned by ThemeManager::assetPath (includes extension). - * @param role Palette role whose color replaces the sentinel. + * @param tint Color that replaces the sentinel: a palette role or a semantic + * application color. Defaults to WindowText. * @param size Pixel size to render at; an empty size uses the SVG's intrinsic size. + * @param style Treatment of the glyph: Line, Badge or Sticker. A treatment on a + * non-sentinel asset stays null, like the line path. * @return The recolored pixmap, or a null pixmap when the asset is not a * sentinel-carrying SVG. */ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, - QPalette::ColorRole role = QPalette::WindowText, - const QSize &size = {}); + IconTint tint = {}, + const QSize &size = {}, + IconStyle style = IconStyle::Line); #endif // THEME_ICON_H diff --git a/cockatrice/src/interface/theme_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp new file mode 100644 index 000000000..e9ec057f7 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -0,0 +1,137 @@ +#include "theme_icon_badge.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_fill.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// The pre-#7595 badge art filled almost the whole canvas: a 24px icon carried a +// 23px disc with a hairline black outline. Keep the disc large and the ring +// thin so the glyph still has room at 16px. +constexpr double RING_WIDTH_RATIO = 0.04; +// The sentinel paths sit inside a padded 24-unit viewBox (the check spans 4..20), +// so the glyph box has to be nearly the whole disc for the engraved mark to +// match the old art's weight. At half the disc it read as a smudge at 15px. +constexpr double GLYPH_DIAMETER_RATIO = 0.9; +// The sentinel set is drawn at 2 units in a 24-unit viewBox, which turns into a +// hairline once the glyph is scaled into a 15px disc. The old badge carried a +// band near a tenth of the disc diameter, so the badge decides the glyph weight +// instead of inheriting the authored one. +constexpr qreal GLYPH_STROKE_WIDTH = 3.0; +constexpr int RING_DARKER = 300; +constexpr int GLYPH_DARKER = 400; +} // namespace + +namespace +{ +struct DiscFill +{ + QColor top; + QColor bottom; +}; + +DiscFill discFill(const QColor &tone) +{ + return {ThemeIconFill::topColor(tone), ThemeIconFill::bottomColor(tone)}; +} +} // namespace + +namespace ThemeIconBadge +{ +double relativeLuminance(const QColor &color) +{ + const auto linear = [](double channel) { + channel /= 255.0; + return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4); + }; + return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue()); +} + +double contrastRatio(const QColor &first, const QColor &second) +{ + const double a = relativeLuminance(first); + const double b = relativeLuminance(second); + return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05); +} + +QColor glyphColor(const QColor &tone) +{ + // The badge carries the ink the pre-sticker badge did: white on the light + // scheme, and the tone darkened to an outline-dark ink on the dark scheme. + // The shared saturated disc is light enough there that white sits near + // 2.7:1 at the top, so the dark ink is the deliberate choice rather than a + // contrast measurement that a palette tweak could flip. Deriving it from + // the tone keeps themes in control. + return ThemeIconFill::isDarkSchemeTone(tone) ? ThemeIconFill::saturated(tone).darker(GLYPH_DARKER) + : QColor(Qt::white); +} + +QSize glyphSize(const QSize &pixelSize) +{ + if (pixelSize.isEmpty()) { + return {}; + } + const qreal side = qMin(pixelSize.width(), pixelSize.height()); + const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO); + const qreal discDiameter = side - 2.0 * ringWidth; + const int glyphSide = qMax(1, qRound(discDiameter * GLYPH_DIAMETER_RATIO)); + return QSize(glyphSide, glyphSide); +} + +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) +{ + if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(pixelSize); + pixmap.fill(Qt::transparent); + + const qreal side = qMin(pixelSize.width(), pixelSize.height()); + const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO); + const qreal discDiameter = side - 2.0 * ringWidth; + + QRectF discRect(0, 0, discDiameter, discDiameter); + discRect.moveCenter(QRectF(QPointF(0, 0), QSizeF(pixelSize)).center()); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + + QLinearGradient gradient(discRect.topLeft(), discRect.bottomLeft()); + const DiscFill fill = discFill(tone); + gradient.setColorAt(0.0, fill.top); + gradient.setColorAt(1.0, fill.bottom); + painter.setPen(Qt::NoPen); + painter.setBrush(gradient); + painter.drawEllipse(discRect); + + // The ring closes the disc: its outer edge touches the canvas, its inner + // edge is the disc boundary, so the two meet without a seam. + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(ThemeIconFill::saturated(tone).darker(RING_DARKER), ringWidth)); + const qreal ringRadius = (side - ringWidth) / 2.0; + painter.drawEllipse(discRect.center(), ringRadius, ringRadius); + + const QByteArray glyphSvg = resolveSvgStrokeWidth( + resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH); + QSvgRenderer renderer(glyphSvg); + if (renderer.isValid()) { + const QSize size = glyphSize(pixelSize); + const QRectF glyphRect( + QPointF(discRect.center().x() - size.width() / 2.0, discRect.center().y() - size.height() / 2.0), + QSizeF(size)); + renderer.render(&painter, glyphRect); + } + + return pixmap; +} +} // namespace ThemeIconBadge diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h new file mode 100644 index 000000000..cdb43cbd9 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -0,0 +1,81 @@ +/** + * @file theme_icon_badge.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_BADGE_H +#define THEME_ICON_BADGE_H + +#include +#include +#include +#include + +/** + * @brief Paints the badge treatment of a `currentColor` glyph. + * + * A tint alone changes the color of a 2px line, but the UI drew ready and + * not-ready states as a filled badge before the one-glyph cutover. The badge + * treatment keeps that shape: a filled disc, a dark ring and a contrast glyph, + * all derived from the tone the tint resolves to. The theme still owns the + * color. Callers only choose between a line and a badge. + * + * A badge survives a busy toolbar and a tinted row better than a hairline, and + * its glyph can be light or dark regardless of the surrounding text. The cost is + * more attention per control, and the glyph color follows the scheme it is drawn + * for instead of the window's text color. + * + * Everything here works in device pixels. The icon engine renders at the + * device-pixel size it already computed and sets the pixmap's device pixel ratio + * afterwards, so badge geometry must not assume logical pixels. + */ +namespace ThemeIconBadge +{ +/** + * @brief WCAG relative luminance, used to test a color against a background. + * + * Used by IconTint::resolve()'s selected-row contrast test. + */ +double relativeLuminance(const QColor &color); + +/** @brief WCAG contrast ratio between two opaque colors, from 1.0 to 21.0. */ +double contrastRatio(const QColor &first, const QColor &second); + +/** + * @brief Glyph color for a badge disc. + * + * White on the light scheme, the saturated tone darkened to an outline-dark ink + * on the dark scheme. The dark-scheme tones fill the disc light enough that + * white would sit near 2.7:1 at its top, so that scheme keeps the darkened ink + * the badge had before the filled treatments shared a fill. The dark ink is + * derived from the tone, so the theme keeps control of both ends. + */ +QColor glyphColor(const QColor &tone); + +/** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */ +QSize glyphSize(const QSize &pixelSize); + +/** + * @brief Renders a sentinel-carrying SVG as a badge. + * + * The disc fills the square canvas minus the ring with the same gradient the + * sticker fill uses, the ring darkens the tone, and the sentinel is resolved to + * the contrast glyph. The SVG is scaled to nearly the whole disc, because the + * sentinel paths are padded inside their viewBox and a smaller box loses the + * stroke at 16px. + * + * This treatment reproduces the pre-#7595 ready/not-ready art, which was a + * filled disc. It is not a generic replacement for every semantic glyph: the + * old add/remove icons were filled glyphs with an outline and no disc, so + * badging them would invent a look the UI never had. + * + * @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally. + * @param tone Disc color, already resolved for the icon mode. + * @param pixelSize Canvas size in device pixels; a non-square canvas centers + * the badge and leaves the remaining area transparent. + * @return The composited badge, or a null pixmap for empty or invalid input. + */ +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize); +} // namespace ThemeIconBadge + +#endif // THEME_ICON_BADGE_H diff --git a/cockatrice/src/interface/theme_icon_fill.cpp b/cockatrice/src/interface/theme_icon_fill.cpp new file mode 100644 index 000000000..7615fb4fa --- /dev/null +++ b/cockatrice/src/interface/theme_icon_fill.cpp @@ -0,0 +1,61 @@ +#include "theme_icon_fill.h" + +#include + +namespace +{ +// Semantic tones are tuned for text. The dark-scheme ones are deliberately +// desaturated, which reads washed out when the same color fills a whole shape, +// so the fill gives its tone a saturation floor. +constexpr int MIN_SATURATION = 170; +// A tone this light belongs to the dark scheme; filling a glyph with it at full +// value is what looks pale, so its gradient is pulled down instead of up. +constexpr int LIGHT_TONE_VALUE = 200; +constexpr int MIN_VALUE = 200; +constexpr int GRADIENT_BOTTOM_DARKER = 140; +constexpr int DARK_SCHEME_TOP_DARKER = 120; +constexpr int DARK_SCHEME_BOTTOM_DARKER = 170; +} // namespace + +namespace +{ +/** @brief Raises a tone's saturation, and optionally its value, for a filled shape. */ +QColor boost(const QColor &color, int minValue) +{ + if (color.hue() < 0 || (color.hsvSaturation() >= MIN_SATURATION && color.value() >= minValue)) { + return color; + } + QColor result = color; + result.setHsv(color.hue(), qMax(color.hsvSaturation(), MIN_SATURATION), qMax(color.value(), minValue)); + // Return RGB so callers comparing or caching the tone do not see a spec + // change for an unchanged color. + return result.toRgb(); +} +} // namespace + +namespace ThemeIconFill +{ +QColor saturated(const QColor &tone) +{ + return boost(tone, 0); +} + +bool isDarkSchemeTone(const QColor &tone) +{ + return tone.value() >= LIGHT_TONE_VALUE; +} + +QColor topColor(const QColor &tone) +{ + if (isDarkSchemeTone(tone)) { + return boost(tone, 0).darker(DARK_SCHEME_TOP_DARKER); + } + return boost(tone, MIN_VALUE); +} + +QColor bottomColor(const QColor &tone) +{ + const int darker = isDarkSchemeTone(tone) ? DARK_SCHEME_BOTTOM_DARKER : GRADIENT_BOTTOM_DARKER; + return boost(tone, 0).darker(darker); +} +} // namespace ThemeIconFill diff --git a/cockatrice/src/interface/theme_icon_fill.h b/cockatrice/src/interface/theme_icon_fill.h new file mode 100644 index 000000000..4efcc136e --- /dev/null +++ b/cockatrice/src/interface/theme_icon_fill.h @@ -0,0 +1,63 @@ +/** + * @file theme_icon_fill.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_FILL_H +#define THEME_ICON_FILL_H + +#include + +/** + * @brief Picks the two colors of a filled treatment's vertical gradient. + * + * The pre-#7595 icons were filled with a lighter top over a darker bottom. The + * semantic tones are tuned for text, not for filling a whole shape; the + * dark-scheme tones in particular are desaturated and read washed out once a + * disc or a glyph carries them. The badge and the sticker share one fill rule so + * they sit next to each other with the same green or red. + * + * The rule needs no scheme switch: a tone whose value is already high is a + * dark-scheme tone, so its gradient is pulled down. Filling a shape with a tone + * that light is what reads pale. + */ +namespace ThemeIconFill +{ +/** + * @brief Returns the tone with a saturation floor, for a filled shape. + * + * The hue and value are left alone, so a light-scheme tone stays as dark as the + * line glyph next to it, and an achromatic tone stays gray. + * + * @param tone Glyph color the treatment is rendered with. + * @return The tone, saturated enough to carry a whole shape. + */ +QColor saturated(const QColor &tone); + +/** + * @brief True when @p tone is a light tone, as the dark scheme's palette is. + * + * The fill branches on this: a tone this light is darkened for a filled shape, + * and the badge keeps its darkened ink on it instead of the light scheme's + * white glyph. + * + * @param tone Glyph color the treatment is rendered with. + */ +bool isDarkSchemeTone(const QColor &tone); + +/** + * @brief Returns the top color of the fill gradient. + * @param tone Glyph color the treatment is rendered with. + * @return The lighter end of the gradient. + */ +QColor topColor(const QColor &tone); + +/** + * @brief Returns the bottom color of the fill gradient. + * @param tone Glyph color the treatment is rendered with. + * @return The darker end of the gradient. + */ +QColor bottomColor(const QColor &tone); +} // namespace ThemeIconFill + +#endif // THEME_ICON_FILL_H diff --git a/cockatrice/src/interface/theme_icon_sticker.cpp b/cockatrice/src/interface/theme_icon_sticker.cpp new file mode 100644 index 000000000..c5f86c7ac --- /dev/null +++ b/cockatrice/src/interface/theme_icon_sticker.cpp @@ -0,0 +1,87 @@ +#include "theme_icon_sticker.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_fill.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +// The pre-#7595 add/remove art filled about 80% of the canvas: the sentinel +// paths are padded inside their 24-unit viewBox, so the drawing box has to be +// slightly larger than the canvas for the ink to reach that far. +constexpr qreal STICKER_SCALE = 1.06; +// Old arm thickness was about a fifth of the canvas, with a rim near 5%. In the +// 24-unit viewBox that is a 4.5-unit band with a 0.9-unit rim on each side. +constexpr qreal STICKER_STROKE_WIDTH = 4.5; +constexpr qreal STICKER_OUTLINE_WIDTH = 0.9; +// The old art's outline was black. Darkening by 300 reads as black on a light +// scheme (the shipped tones are already dark there) while staying a visible dark +// rim on a dark one, where a neutral black would vanish into the window. +constexpr int OUTLINE_DARKER = 300; +} // namespace + +namespace ThemeIconSticker +{ +QColor outlineColor(const QColor &tone) +{ + return ThemeIconFill::saturated(tone).darker(OUTLINE_DARKER); +} + +QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) +{ + if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(pixelSize); + pixmap.fill(Qt::transparent); + + const qreal side = qMin(pixelSize.width(), pixelSize.height()); + const qreal box = side * STICKER_SCALE; + const QRectF glyphRect(QPointF((pixelSize.width() - box) / 2.0, (pixelSize.height() - box) / 2.0), + QSizeF(box, box)); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + + // Outline first, at the band width plus one rim on each side; the fill pass + // then covers the middle and leaves only the rim visible. + const QByteArray outlineSvg = + resolveSvgStrokeWidth(resolveSvgColorSentinel(svg, outlineColor(tone).name(QColor::HexRgb).toLatin1()), + STICKER_STROKE_WIDTH + 2.0 * STICKER_OUTLINE_WIDTH); + QSvgRenderer outlineRenderer(outlineSvg); + if (outlineRenderer.isValid()) { + outlineRenderer.render(&painter, glyphRect); + } + + // The fill needs its own pass so the gradient can be clipped to the glyph: + // painting it onto the composited pixmap would tint the outline as well. + QPixmap fillPixmap(pixelSize); + fillPixmap.fill(Qt::transparent); + { + QPainter fillPainter(&fillPixmap); + fillPainter.setRenderHint(QPainter::Antialiasing, true); + + const QByteArray fillSvg = resolveSvgStrokeWidth( + resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), STICKER_STROKE_WIDTH); + QSvgRenderer fillRenderer(fillSvg); + if (fillRenderer.isValid()) { + fillRenderer.render(&fillPainter, glyphRect); + fillPainter.setCompositionMode(QPainter::CompositionMode_SourceIn); + QLinearGradient gradient(glyphRect.topLeft(), glyphRect.bottomLeft()); + gradient.setColorAt(0.0, ThemeIconFill::topColor(tone)); + gradient.setColorAt(1.0, ThemeIconFill::bottomColor(tone)); + fillPainter.fillRect(QRectF(QPointF(0, 0), QSizeF(pixelSize)), gradient); + } + } + painter.drawPixmap(0, 0, fillPixmap); + + return pixmap; +} +} // namespace ThemeIconSticker diff --git a/cockatrice/src/interface/theme_icon_sticker.h b/cockatrice/src/interface/theme_icon_sticker.h new file mode 100644 index 000000000..d9e5ec3ba --- /dev/null +++ b/cockatrice/src/interface/theme_icon_sticker.h @@ -0,0 +1,61 @@ +/** + * @file theme_icon_sticker.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_STICKER_H +#define THEME_ICON_STICKER_H + +#include +#include +#include +#include + +/** + * @brief Paints the sticker treatment of a `currentColor` glyph. + * + * The pre-#7595 add, remove and delete icons were filled shapes with a black + * outline and no disc. A tint alone cannot reproduce that: it produces a + * hairline, and a badge disc is a different icon language. The sticker + * treatment rebuilds the look from the same sentinel art by drawing the glyph + * twice, once with a dark outline and once with a vertical tone gradient on top. + * + * A sticker is louder than a line, which suits an add or a destructive action. + * It needs a size where the tone band is at least two device pixels wide. + * Callers opt in. Navigation and menus keep the line treatment. + * + * Everything here works in device pixels, like the badge treatment: the engine + * renders at the size it already computed and attaches the device pixel ratio + * afterwards. + */ +namespace ThemeIconSticker +{ +/** + * @brief Returns the dark rim color the sticker is painted with. + * + * The old art had a black outline. Deriving it from the tone reads as black on a + * light scheme without a literal, and stays visible on a dark one, where a pure + * black rim would disappear into the window. + * + * @param tone Glyph color the sticker is rendered with. + * @return A much darker version of the tone. + */ +QColor outlineColor(const QColor &tone); + +/** + * @brief Renders a sentinel-carrying SVG as a filled glyph with a black outline. + * + * The glyph is drawn larger than the canvas so the padded sentinel paths fill + * the icon the way the old art did, with a vertical tone gradient as the fill + * and a near-black rim. Authored art is never drawn through this path. + * + * @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally. + * @param tone Glyph color, already resolved for the icon mode. + * @param pixelSize Canvas size in device pixels; a non-square canvas centers + * the glyph and leaves the remaining area transparent. + * @return The composited sticker, or a null pixmap for empty or invalid input. + */ +QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize); +} // namespace ThemeIconSticker + +#endif // THEME_ICON_STICKER_H diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index f120af8e4..1cf62ae8b 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -639,14 +639,23 @@ QColor ThemeManager::appColor(AppColor::Role role) const // unconditionally keeps every Qt version rendering identically. const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight); - if (role == AppColor::AccentSoft) { - constexpr int SOFT_SATURATION_PERCENT = 70; - constexpr int SOFT_LIGHTNESS_OFFSET = 60; + switch (role) { + case AppColor::AccentStrong: + return accent; + case AppColor::AccentSoft: { + constexpr int SOFT_SATURATION_PERCENT = 70; + constexpr int SOFT_LIGHTNESS_OFFSET = 60; - // Light end of the gradient: same hue, softened and lightened - return QColor::fromHsl(qMax(0, accent.hslHue()), - qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), - qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + // Light end of the gradient: same hue, softened and lightened + return QColor::fromHsl(qMax(0, accent.hslHue()), + qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), + qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + } + case AppColor::Positive: + case AppColor::Negative: + // Semantic colors are independent of the accent: a missing role must + // not silently paint "remove" in the theme's highlight color. + return defaultAppColor(role, isDarkModeActive()); } return accent; diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index ffe1043b5..3bcfd7b3c 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -147,7 +147,8 @@ public: void previewFonts(const FontConfig &cfg); // Resolves an application color role: the theme's stored [AppColors] value - // when present, otherwise a palette-accent-derived fallback. + // when present, otherwise a palette-accent-derived fallback for accents and + // a scheme-appropriate semantic default for positive / negative. QColor appColor(AppColor::Role role) const; /** @brief Resolves an application font role: the theme's stored [Fonts] * value when present, otherwise the application font. Roles that diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index f25e6c0ff..128597962 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -83,12 +83,14 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); + aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::Positive)); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); + // Sideboard is a destination, not a positive state: its distinct glyph + // keeps the target legible without competing with the green main-deck add. aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard"))); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); auto *tbAddCardToSideboard = new QToolButton(this); 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 47d2a1aae..5df8db7cc 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 @@ -248,19 +248,19 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); + aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); auto *tbIncrement = new QToolButton(this); tbIncrement->setDefaultAction(aIncrement); aDecrement = new QAction(QString(), this); - aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); + aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); auto *tbDecrement = new QToolButton(this); tbDecrement->setDefaultAction(aDecrement); aRemoveCard = new QAction(QString(), this); - aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); + aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); auto *tbRemoveCard = new QToolButton(this); tbRemoveCard->setDefaultAction(aRemoveCard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 8122d1847..33a9cd63c 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -64,7 +64,7 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); + aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index daee5f1f9..a30076519 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -38,7 +38,8 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index b06481c4b..a6d337a56 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -108,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); + aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); + aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 09afee32d..0b6a9585d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -34,7 +34,8 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index e90603858..6e104d617 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -80,7 +80,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); @@ -88,7 +88,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); aRateLimit = new QAction(this); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 274e55866..ad6651ffa 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -150,7 +150,7 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); @@ -158,7 +158,7 @@ MessagesSettingsPage::MessagesSettingsPage() connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 8af9d5e6e..9e031c79a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -199,7 +199,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); aDeleteLocalDeck = new QAction(this); - aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); + aDeleteLocalDeck->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -217,7 +218,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); aDeleteRemoteDeck = new QAction(this); - aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); + aDeleteRemoteDeck->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index bd2a69017..3868ee75f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -155,7 +155,8 @@ QGroupBox *TabReplays::createLeftLayout() aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); aDeleteLocalReplay = new QAction(this); - aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); + aDeleteLocalReplay->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -217,7 +218,8 @@ QGroupBox *TabReplays::createRightLayout() aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); + aDeleteRemoteReplay->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); 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 6d9b28619..7f516839a 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 @@ -156,7 +156,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"))); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"), AppColor::Negative)); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index cad18921a..5a961bd5d 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index f5151af3e..a958b39d1 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index b180e9b63..8be52dc5f 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -71,3 +71,5 @@ Accent = #ff1e1e1e [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 5a1163a2b..9548af916 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -71,3 +71,5 @@ Accent = #fff0f0f0 [AppColors] AccentStrong = #ff139740 AccentSoft = #ffc9fd62 +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index b0db0e90a..26bee5be3 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index ff122db76..0219fce8e 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index 222456f4f..e013e3602 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index d54db31d2..71d86917c 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 +Positive = #1b7f3b +Negative = #b3261e diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml index 9dfe6e19f..9f945fa30 100644 --- a/cockatrice/themes/System/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -1,7 +1,10 @@ # The System theme derives all of its colors from the platform palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette -# overrides. Only the identity app colors are provided here, so buttons keep -# their classic greens while the actual palette stays native to the OS. +# overrides. Only the identity and semantic app colors are provided here, so +# buttons keep their classic greens while the actual palette stays native to +# the OS. [AppColors] AccentStrong = #ff148c3c AccentSoft = #ff78c850 +Positive = #ff6dd58c +Negative = #ffff8a80 diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml index 9dfe6e19f..32cf7845c 100644 --- a/cockatrice/themes/System/palette-default-light.toml +++ b/cockatrice/themes/System/palette-default-light.toml @@ -1,7 +1,10 @@ # The System theme derives all of its colors from the platform palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette -# overrides. Only the identity app colors are provided here, so buttons keep -# their classic greens while the actual palette stays native to the OS. +# overrides. Only the identity and semantic app colors are provided here, so +# buttons keep their classic greens while the actual palette stays native to +# the OS. [AppColors] AccentStrong = #ff148c3c AccentSoft = #ff78c850 +Positive = #ff1b7f3b +Negative = #ffb3261e diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 496943931..56d3e2c4c 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,4 +71,6 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 +Positive = #6dd58c +Negative = #ff8a80 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index 5323fd320..04461013b 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,4 +71,6 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 +Positive = #1b7f3b +Negative = #b3261e diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp index 041e289fe..ad51388d2 100644 --- a/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp @@ -42,3 +42,46 @@ QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &colo } return resolved; } + +QByteArray resolveSvgStrokeWidth(const QByteArray &svg, double width) +{ + constexpr QByteArrayView ATTRIBUTE = "stroke-width"; + constexpr QByteArrayView DOUBLE_QUOTE = "\""; + constexpr QByteArrayView SINGLE_QUOTE = "'"; + const QByteArray value = QByteArray::number(width, 'f', 2); + + QByteArray resolved; + resolved.reserve(svg.size()); + + qsizetype cursor = 0; + while (true) { + const qsizetype start = svg.indexOf(ATTRIBUTE, cursor); + if (start < 0) { + resolved.append(svg.sliced(cursor)); + return resolved; + } + + // Only an XML attribute is rewritten. A CSS declaration such as + // `style="stroke-width:2"` has no `=` right after the name, so it stays + // untouched and the scan moves past it. + qsizetype nameEnd = start + ATTRIBUTE.size(); + while (nameEnd < svg.size() && svg.at(nameEnd) == ' ') { + ++nameEnd; + } + const bool isAttribute = nameEnd < svg.size() && svg.at(nameEnd) == '=' && nameEnd + 1 < svg.size(); + const char quoteChar = isAttribute ? svg.at(nameEnd + 1) : '\0'; + const bool quoted = quoteChar == '"' || quoteChar == '\''; + const qsizetype close = quoted ? svg.indexOf(quoteChar == '"' ? DOUBLE_QUOTE : SINGLE_QUOTE, nameEnd + 2) : -1; + if (close < 0) { + resolved.append(svg.sliced(cursor, start + ATTRIBUTE.size() - cursor)); + cursor = start + ATTRIBUTE.size(); + continue; + } + + // Keep the opening quote, replace the value, and resume at the closing + // quote so the rest of the document is copied verbatim. + resolved.append(svg.sliced(cursor, nameEnd + 2 - cursor)); + resolved.append(value); + cursor = close; + } +} diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h index 56d609b87..e55f1c775 100644 --- a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h @@ -28,4 +28,21 @@ */ QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color); +/** + * @brief Rewrites the value of every `stroke-width` attribute in authored SVG. + * + * A treatment can need a heavier or lighter stroke than the one authored for a + * line icon at text size. Like the sentinel resolver, this rewriter works on the + * bytes and understands only the XML attribute form, in either quote style + * (`stroke-width="2"` or `stroke-width='2'`): a CSS declaration such as + * `style="stroke-width:2"` is left untouched. Documents without the attribute + * are returned unchanged. + * + * @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved + * byte-for-byte. + * @param width Replacement width in the document's user units. + * @return The rewritten SVG source; the input is left untouched. + */ +QByteArray resolveSvgStrokeWidth(const QByteArray &svg, double width); + #endif // SVG_RECOLOR_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 4ca2acd07..50799d496 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -32,6 +32,9 @@ set(oracle_SOURCES ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp ../cockatrice/src/interface/theme_icon.cpp + ../cockatrice/src/interface/theme_icon_badge.cpp + ../cockatrice/src/interface/theme_icon_fill.cpp + ../cockatrice/src/interface/theme_icon_sticker.cpp ../cockatrice/src/interface/theme_manager.cpp ${VERSION_STRING_CPP} ) diff --git a/tests/svg_recolor_test.cpp b/tests/svg_recolor_test.cpp index abe1e8d1a..ada58a7a7 100644 --- a/tests/svg_recolor_test.cpp +++ b/tests/svg_recolor_test.cpp @@ -63,4 +63,57 @@ TEST(SvgRecolor, NoSentinelIsANoOp) EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg); } +TEST(SvgStrokeWidth, RewritesEveryAttribute) +{ + const QByteArray svg = R"()"; + + const QByteArray result = resolveSvgStrokeWidth(svg, 4.25); + + EXPECT_EQ(result.count(R"(stroke-width="4.25")"), 2); + EXPECT_EQ(result.count(R"(stroke-width='4.25')"), 1); + EXPECT_FALSE(result.contains("1.5")); + EXPECT_FALSE(result.contains("3'/")); +} + +TEST(SvgStrokeWidth, LeavesCssDeclarationsAlone) +{ + const QByteArray svg = R"()"; + + const QByteArray result = resolveSvgStrokeWidth(svg, 3.0); + + EXPECT_TRUE(result.contains(R"(style="stroke-width:2")")); + EXPECT_EQ(result.count(R"(stroke-width="3.00")"), 1); +} + +TEST(SvgStrokeWidth, NoAttributeIsANoOp) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgStrokeWidth(svg, 3.0), svg); +} + +TEST(SvgStrokeWidth, DoesNotModifyTheInputAndPreservesBytes) +{ + QByteArray svg = ""; + svg.append("\xff\xfe"); + svg.append(R"()"); + const QByteArray original = svg; + + const QByteArray result = resolveSvgStrokeWidth(svg, 3.5); + + EXPECT_EQ(svg, original); + EXPECT_TRUE(result.contains("\xff\xfe")); + EXPECT_TRUE(result.contains(R"(stroke-width="3.50")")); +} + +TEST(SvgStrokeWidth, TruncatedAttributeTerminates) +{ + // A quote that is never closed must not over-read or hang. + const QByteArray svg = R"(")); +} + } // namespace diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index 818c1bb16..d0bd357b3 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -6,6 +6,9 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_badge.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_fill.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_sticker.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp @@ -13,6 +16,8 @@ cockatrice_add_test( ${VERSION_STRING_CPP} theme_migration_test.cpp theme_icon_render_test.cpp + icon_tint_preview_test.cpp + icon_treatment_preview_test.cpp LIBS libcockatrice_settings libcockatrice_network diff --git a/tests/theme_migration/icon_tint_preview_test.cpp b/tests/theme_migration/icon_tint_preview_test.cpp new file mode 100644 index 000000000..b4244cef0 --- /dev/null +++ b/tests/theme_migration/icon_tint_preview_test.cpp @@ -0,0 +1,227 @@ +#include "interface/theme_config.h" +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps the other + * test files free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "icon_tint_preview_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +/** @brief Reads an authored icon from the theme resource directory. */ +QByteArray readIconSvg(const char *name) +{ + QFile file(QDir(resourcesDir()).absoluteFilePath(QStringLiteral("icons/%1.svg").arg(QString::fromLatin1(name)))); + if (!file.open(QFile::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +/** @brief Renders a pixmap at device size and tags it with the ratio. */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; +} +} // namespace + +/** + * @brief Writes one semantic-tint sheet per shipped palette theme. + * + * The theme files are the only place the shipped tones live, so each sheet is + * rendered from the palette the theme actually ships: both semantic families, + * three glyphs each, at the two sizes the UI uses, on the window and button + * surfaces the contrast is measured against. A disabled row closes each family + * so the enabled/disabled order is visible next to the tone that carries it. + */ +TEST(IconTintPreview, WritesOneSheetPerShippedTheme) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ensureGuiApplication(); + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 190; + constexpr int SLOT = 36; + constexpr int ROW_HEIGHT = 56; + constexpr int PANEL_TITLE = 26; + constexpr int PANEL_PADDING = 12; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + + struct Family + { + const char *name; + AppColor::Role role; + const char *glyphs[3]; + }; + const QList families = { + {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, + {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int glyphCount = static_cast(std::size(families.first().glyphs)); + const int columnCount = glyphCount * static_cast(std::size(SIZES)); + const int panelWidth = LABEL_WIDTH + columnCount * SLOT + PANEL_PADDING; + const int panelHeight = PANEL_TITLE + static_cast(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + ASSERT_TRUE(config.hasPalette()) << theme.toStdString(); + + const QPalette palette = config.apply(QPalette()); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor button = palette.color(QPalette::Active, QPalette::Button); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, semantic tints") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + }; + const QList surfaces = { + {"Window surface", window}, + {"Button surface", button}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(sizeIndex) * glyphCount * SLOT; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QStringLiteral("%1 px").arg(SIZES[sizeIndex])); + } + + qreal y = panelTop + PANEL_TITLE + 20; + for (const Family &family : families) { + const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark)); + ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name; + for (int state = 0; state < 2; ++state) { + const bool isDisabled = state == 1; + const QColor ink = isDisabled ? disabled : tone; + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText( + QPointF(contentLeft, y + 14), + QStringLiteral("%1%2").arg(QString::fromLatin1(family.name), + isDisabled ? QStringLiteral(" / disabled") : QString())); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + for (int glyphIndex = 0; glyphIndex < glyphCount; ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + const QPixmap pixmap = renderAtDpr( + size, DPR, [&](const QSize &pixelSize) { return renderLine(svg, ink, pixelSize); }); + const qreal x = contentLeft + LABEL_WIDTH + + static_cast(sizeIndex * glyphCount + glyphIndex) * SLOT + + (SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, y + (ROW_HEIGHT - side) / 2.0 - 2), pixmap); + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("semantic-tint-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} diff --git a/tests/theme_migration/icon_treatment_preview_test.cpp b/tests/theme_migration/icon_treatment_preview_test.cpp new file mode 100644 index 000000000..212987ec0 --- /dev/null +++ b/tests/theme_migration/icon_treatment_preview_test.cpp @@ -0,0 +1,245 @@ +#include "interface/theme_config.h" +#include "interface/theme_icon_badge.h" +#include "interface/theme_icon_fill.h" +#include "interface/theme_icon_sticker.h" +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps the other + * test files free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "icon_treatment_preview_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +/** @brief Reads a sentinel SVG from the shipped icon set. */ +QByteArray readIconSvg(const char *name) +{ + QFile file(QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QFile::ReadOnly) ? file.readAll() : QByteArray(); +} + +/** @brief Renders a pixmap at device size and tags it with the ratio. */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; +} +} // namespace + +/** + * @brief Writes one badge/sticker sheet per shipped palette theme. + * + * The treatments are only useful if they read on every palette, so each sheet + * is rendered from the palette the theme actually ships: both semantic + * families, three glyphs each, as line glyphs, stickers and badges, at the two + * sizes the UI uses, on the window and button surfaces the contrast is measured + * against. A disabled row closes each family: every treatment falls back to the + * line glyph there, so an unavailable control still reads as unavailable. + */ +TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ensureGuiApplication(); + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 190; + constexpr int SLOT = 36; + constexpr int TREATMENT_WIDTH = 120; + constexpr int ROW_HEIGHT = 56; + constexpr int PANEL_TITLE = 26; + constexpr int COLUMN_HEADER = 20; + constexpr int PANEL_PADDING = 12; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; + + struct Family + { + const char *name; + AppColor::Role role; + const char *glyphs[3]; + }; + const QList families = { + {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, + {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int treatmentCount = static_cast(std::size(TREATMENTS)); + const int panelWidth = LABEL_WIDTH + treatmentCount * TREATMENT_WIDTH + PANEL_PADDING; + const int panelHeight = + PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + ASSERT_TRUE(config.hasPalette()) << theme.toStdString(); + + const QPalette palette = config.apply(QPalette()); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor button = palette.color(QPalette::Active, QPalette::Button); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, treatments across glyphs") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + }; + const QList surfaces = { + {"Window surface", window}, + {"Button surface", button}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (int treatment = 0; treatment < treatmentCount; ++treatment) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(treatment) * TREATMENT_WIDTH; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24")); + } + + qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; + for (const Family &family : families) { + const QColor raw = config.appColors.value(family.role, defaultAppColor(family.role, dark)); + ASSERT_TRUE(raw.isValid()) << theme.toStdString() << " " << family.name; + const QColor tone = ThemeIconFill::saturated(raw); + + for (int state = 0; state < 2; ++state) { + const bool isDisabled = state == 1; + const QColor ink = isDisabled ? disabled : tone; + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText( + QPointF(contentLeft, y + 14), + QStringLiteral("%1%2").arg(QString::fromLatin1(family.name), + isDisabled ? QStringLiteral(" / disabled") : QString())); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (int treatment = 0; treatment < treatmentCount; ++treatment) { + const qreal cellX = + contentLeft + LABEL_WIDTH + static_cast(treatment) * TREATMENT_WIDTH; + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); + for (int glyphIndex = 0; glyphIndex < 3; ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (isDisabled || treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize); + }); + const qreal x = + cellX + 6 + static_cast(glyphIndex) * SLOT + (SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, rowTop + (SLOT - side) / 2.0), pixmap); + } + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("treatment-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index f12db5b16..ee078555d 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1,19 +1,30 @@ #include "interface/theme_icon.h" +#include "interface/theme_icon_badge.h" +#include "interface/theme_icon_fill.h" +#include "interface/theme_icon_sticker.h" #include "interface/theme_manager.h" +#include "libcockatrice/utility/svg_recolor.h" #include +#include #include +#include #include #include +#include #include #include +#include #include #include +#include #include #include #include +#include #include #include +#include namespace { @@ -40,6 +51,14 @@ QString resourcesDir() return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); } +/** @brief Reads a sentinel SVG from the shipped icon set. */ +QByteArray readIconSvg(const char *name) +{ + QFile file(QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); +} + int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) { const QImage image = pixmap.toImage(); @@ -56,6 +75,23 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) return count; } +/** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ +int countOpaquePixelsOtherThan(const QPixmap &pixmap, const QColor &excluded) +{ + const QImage image = pixmap.toImage(); + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = image.pixelColor(x, y); + if (pixel.alpha() > 200 && (pixel.red() != excluded.red() || pixel.green() != excluded.green() || + pixel.blue() != excluded.blue())) { + ++count; + } + } + } + return count; +} + QByteArray svgWith(const QByteArray &colorToken) { return QByteArrayLiteral(R"(palette(); + const QColor accent(QStringLiteral("#ff00ff")); + palette.setColor(QPalette::Active, QPalette::Highlight, accent); + qApp->setPalette(palette); + + const QColor positive = themeManager->appColor(AppColor::Positive); + const QColor negative = themeManager->appColor(AppColor::Negative); + ASSERT_TRUE(positive.isValid()); + ASSERT_TRUE(negative.isValid()); + EXPECT_NE(positive, accent); + EXPECT_NE(negative, accent); + + const QPixmap positivePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Positive); + ASSERT_FALSE(positivePixmap.isNull()); + EXPECT_GT(countOpaquePixels(positivePixmap, positive), 0); + EXPECT_EQ(countOpaquePixels(positivePixmap, accent), 0); + + const QPixmap negativePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Negative); + ASSERT_FALSE(negativePixmap.isNull()); + EXPECT_GT(countOpaquePixels(negativePixmap, negative), 0); + EXPECT_EQ(countOpaquePixels(negativePixmap, accent), 0); +} + +/** @brief A disabled control must not keep signalling its semantic color. */ +TEST_F(ThemeIconRenderTest, DisabledAppColorUsesDisabledForeground) +{ + QPalette palette = qApp->palette(); + const QColor disabledText(QStringLiteral("#fedcba")); + palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); + qApp->setPalette(palette); + + const IconTint tint(AppColor::Negative); + EXPECT_EQ(tint.resolve(QIcon::Disabled), disabledText); + EXPECT_NE(tint.resolve(QIcon::Normal), disabledText); +} + +/** @brief Palette tints keep the WindowText -> HighlightedText selected swap. */ +TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText) +{ + QPalette palette = qApp->palette(); + const QColor activeText(QStringLiteral("#111111")); + const QColor selectedText(QStringLiteral("#222222")); + palette.setColor(QPalette::Active, QPalette::WindowText, activeText); + palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); + qApp->setPalette(palette); + + const IconTint tint(QPalette::WindowText); + EXPECT_EQ(tint.resolve(QIcon::Normal), activeText); + EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText); +} + +/** @brief A selected row falls back to HighlightedText when the tint vanishes. */ +TEST_F(ThemeIconRenderTest, SelectedAppColorFallsBackWhenItMatchesTheHighlight) +{ + const QColor semantic = themeManager->appColor(AppColor::Positive); + const QColor selectedText(QStringLiteral("#fedcba")); + QPalette palette = qApp->palette(); + palette.setColor(QPalette::Active, QPalette::Highlight, semantic); + palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); + qApp->setPalette(palette); + + EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), selectedText); +} + +/** @brief A selected row keeps a semantic tint that still contrasts enough. */ +TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint) +{ + QPalette palette = qApp->palette(); + palette.setColor(QPalette::Active, QPalette::Highlight, QColor(Qt::black)); + palette.setColor(QPalette::Active, QPalette::HighlightedText, QColor(Qt::white)); + qApp->setPalette(palette); + + EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), themeManager->appColor(AppColor::Positive)); +} + +/** + * @brief Line glyphs share the filled treatments' saturation floor. + * + * The dark-scheme semantic tones are the visible case, but `AccentSoft` is below + * the floor in every shipped light palette, so the resolve path is exercised + * without swapping the theme. A line glyph next to a badge or a sticker has to + * carry the same tone; reverting the floor here would leave it pale beside them. + */ +TEST_F(ThemeIconRenderTest, LineTintAppliesTheFillSaturationFloor) +{ + const QColor raw = themeManager->appColor(AppColor::AccentSoft); + ASSERT_TRUE(raw.isValid()); + ASSERT_LT(raw.hsvSaturation(), 170); + + const QColor saturated = ThemeIconFill::saturated(raw); + EXPECT_NE(saturated, raw); + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), saturated); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::AccentSoft); + const QPixmap pixmap = icon.pixmap(QSize(24, 24)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_GT(countOpaquePixels(pixmap, saturated), 0); + EXPECT_EQ(countOpaquePixels(pixmap, raw), 0); +} + +/** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */ +TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + ASSERT_TRUE(tone.isValid()); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(64, 64)); + + const QImage image = pixmap.toImage(); + // The disc fills the circle; the square corners stay transparent. + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_GT(image.pixelColor(32, 32).alpha(), 200); + + // The ring is the saturated tone darkened, and it must actually be painted. + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + + // The fill runs from a lighter top to a darker bottom. Sample left of the + // check, which now reaches almost to the disc edge. + const int fillX = 10; + const QColor top = image.pixelColor(fillX, 16); + const QColor bottom = image.pixelColor(fillX, 48); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + EXPECT_GT(top.lightness(), bottom.lightness()); + + // The glyph uses the contrast color chosen for this tone. + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); +} + +/** + * @brief The dark-scheme disc carries the same saturated fill as the sticker. + * + * The dark tones are tuned for text and read as pale mint or salmon once they + * fill a disc, so the badge saturates its fill. The dark scheme keeps the + * darkened ink the badge had before the fill moved to the sticker ramp. + */ +TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill) +{ + const QColor tone = defaultAppColor(AppColor::Positive, true); + ASSERT_TRUE(tone.isValid()); + ASSERT_GE(tone.value(), 200); + + const QPixmap pixmap = ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + const QImage image = pixmap.toImage(); + + // The disc neither keeps the pale raw tone nor the old unsaturating bottom. + EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(104)), 0); + const QColor top = image.pixelColor(10, 16); + const QColor bottom = image.pixelColor(10, 48); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + // Antialiasing costs a point or two of saturation at the sample points. + EXPECT_GE(top.hsvSaturation(), 160); + EXPECT_GE(bottom.hsvSaturation(), 160); + EXPECT_GT(top.lightness(), bottom.lightness()); + + // The dark scheme keeps the darkened ink, and it is actually painted. + const QColor ink = ThemeIconBadge::glyphColor(tone); + ASSERT_NE(ink, QColor(Qt::white)); + EXPECT_GT(countOpaquePixels(pixmap, ink), 0); +} + +/** + * @brief The badge ink follows the scheme. + * + * Light-scheme tones keep the white glyph of the old art; dark-scheme tones + * keep the darkened ink, because the shared saturated disc is light enough that + * white would sit near 2.7:1 at its top. Pinning both cases stops the glyph from + * silently flipping back to white when the fill or the palette changes. + */ +TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme) +{ + const struct + { + AppColor::Role role; + bool dark; + } cases[] = {{AppColor::Positive, false}, + {AppColor::Negative, false}, + {AppColor::Positive, true}, + {AppColor::Negative, true}}; + + for (const auto &testCase : cases) { + const QColor tone = defaultAppColor(testCase.role, testCase.dark); + ASSERT_TRUE(tone.isValid()); + const QColor ink = ThemeIconBadge::glyphColor(tone); + if (testCase.dark) { + EXPECT_NE(ink, QColor(Qt::white)); + EXPECT_LT(ink.value(), 100); + } else { + EXPECT_EQ(ink, QColor(Qt::white)); + } + } +} + +/** @brief The badge is rendered at the device-pixel size, not upscaled later. */ +TEST_F(ThemeIconRenderTest, BadgeHonoursDevicePixelRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap hidpi = icon.pixmap(QSize(32, 32), 2.0); + + ASSERT_FALSE(hidpi.isNull()); + EXPECT_EQ(hidpi.size(), QSize(64, 64)); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(32, 32)); +} + +/** + * @brief The player list is the one place the badge has to work, at 15px. + * + * A 20x15 icon box yields a 15px canvas. The art this replaces kept its check + * legible there, so the badge must still paint solid glyph ink rather than an + * antialiased smudge. + */ +TEST_F(ThemeIconRenderTest, BadgeStaysLegibleAtPlayerListSize) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(20, 15)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15)); + + // The disc survives: the corners stay transparent and the center is solid. + const QImage image = pixmap.toImage(); + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_GT(image.pixelColor(7, 7).alpha(), 200); + // A 1px ring on a 15px canvas antialiases into the disc and has no exact + // color, so the glyph is what has to stay legible. + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 8); +} + +/** @brief The pixmap API's badge path agrees with the engine's. */ +TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/ready_start.svg"), AppColor::Positive, + QSize(48, 48), IconStyle::Badge); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(48, 48)); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); +} + +/** + * @brief Hides Qt's plugin directories while it lives, then restores them. + * + * Only the image-format plugins matter here: the platform plugin the fixture + * needs is already loaded by the time a test runs. + */ +class ScopedPluginPathHiding +{ +public: + ScopedPluginPathHiding() : saved(QCoreApplication::libraryPaths()) + { + QCoreApplication::setLibraryPaths({}); + } + ~ScopedPluginPathHiding() + { + QCoreApplication::setLibraryPaths(saved); + } + ScopedPluginPathHiding(const ScopedPluginPathHiding &) = delete; + ScopedPluginPathHiding &operator=(const ScopedPluginPathHiding &) = delete; + +private: + QStringList saved; +}; + +/** + * @brief Authored SVG art must not depend on Qt's SVG image-format plugin. + * + * Loading an `.svg` through QPixmap needs the separate `qsvg` image plugin. + * Distributions that build Qt with the plugin split out (Debian, and therefore + * CI) ship it in its own package, and a `--no-install-recommends` install does + * not have it, so authored theme art rendered blank there while working on a + * developer machine whose Qt bundles the format. Hiding the plugin directories + * reproduces that install. + */ +TEST_F(ThemeIconRenderTest, AuthoredArtDoesNotNeedTheSvgImagePlugin) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#336699")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + // The offscreen platform plugin is already loaded by the fixture, so an + // empty library path only removes the image-format plugins. + const ScopedPluginPathHiding hidePlugins; + + const QIcon icon = loadThemedIcon(QStringLiteral("plain")); + const QPixmap pixmap = icon.pixmap(QSize(32, 32)); + + ASSERT_FALSE(pixmap.isNull()) << "authored SVG art went through the QPixmap loader"; + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); +} + +/** @brief Badge never paints authored art: the asset keeps its own colors. */ +TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#336699")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("plain"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(32, 32)); + ASSERT_FALSE(pixmap.isNull()); + + // The authored color survives; no disc tone and no ring are painted. + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); +} + +/** @brief Disabled keeps the shape language but drops back to the line glyph. */ +TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) +{ + QPalette palette = qApp->palette(); + const QColor disabledText(QStringLiteral("#fedcba")); + palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); + qApp->setPalette(palette); + + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled); + ASSERT_FALSE(pixmap.isNull()); + + // A filled disc at full mass would not read as unavailable, so Disabled + // drops the badge: no ring, no disc, just the disabled line glyph. + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, disabledText.darker(300)), 0); + EXPECT_EQ(pixmap.toImage().pixelColor(0, 0).alpha(), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); +} + +/** + * @brief Sticker fills the glyph with the tone and rims it with a dark outline. + * + * The old add/remove art was a filled shape with a black outline and no disc, so + * the sticker must show a solid tone interior, a dark rim, and transparent + * corners. + */ +TEST_F(ThemeIconRenderTest, StickerPaintsToneFillWithDarkOutline) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(64, 64)); + + const QImage image = pixmap.toImage(); + // No disc: the corners stay transparent, unlike the badge. + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_EQ(image.pixelColor(63, 63).alpha(), 0); + + // The glyph is filled: the vertical arm carries a gradient, lighter at the + // top and darker at the bottom, with the rim painted in the darkened tone. + const QColor top = image.pixelColor(32, 12); + const QColor bottom = image.pixelColor(32, 52); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + EXPECT_GT(top.lightness(), bottom.lightness()); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 20); +} + +/** + * @brief The dark-scheme fill is the branch the treatment exists for. + * + * That tone is light and deliberately desaturated, so a plain gradient paints a + * pale mint shape. The fill has to raise its saturation and pull its value down + * instead of up. + */ +TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone) +{ + const QColor tone = defaultAppColor(AppColor::Positive, true); + ASSERT_TRUE(tone.isValid()); + ASSERT_GE(tone.value(), 200); + + const QPixmap pixmap = ThemeIconSticker::renderSticker(readIconSvg("increment"), tone, QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + const QImage image = pixmap.toImage(); + + const QColor top = image.pixelColor(32, 14); + const QColor bottom = image.pixelColor(32, 50); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + EXPECT_LT(top.value(), tone.value()); + EXPECT_LT(bottom.value(), tone.value()); + EXPECT_GT(top.lightness(), bottom.lightness()); + EXPECT_GE(top.hsvSaturation(), 170); +} + +/** @brief The sticker is rendered at the device-pixel size, not upscaled later. */ +TEST_F(ThemeIconRenderTest, StickerHonoursDevicePixelRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); + const QPixmap hidpi = icon.pixmap(QSize(32, 32), 2.0); + + ASSERT_FALSE(hidpi.isNull()); + EXPECT_EQ(hidpi.size(), QSize(64, 64)); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(32, 32)); +} + +/** + * @brief A sticker has to survive the 16px toolbar slot. + * + * A 15/16px canvas leaves a three-pixel band. The tone interior must still be + * solid ink rather than an antialiased smudge, and the icon must not turn into a + * disc. + */ +TEST_F(ThemeIconRenderTest, StickerStaysLegibleAtToolbarSize) +{ + const QColor tone = themeManager->appColor(AppColor::Negative); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(16, 16)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(16, 16)); + + const QImage image = pixmap.toImage(); + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_GT(countOpaquePixelsOtherThan(pixmap, ThemeIconSticker::outlineColor(tone)), 8); +} + +/** @brief The pixmap API's sticker path agrees with the engine's. */ +TEST_F(ThemeIconRenderTest, SentinelSvgStickerMatchesTheEngine) +{ + const QColor tone = themeManager->appColor(AppColor::Negative); + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/remove_row.svg"), AppColor::Negative, + QSize(48, 48), IconStyle::Sticker); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(48, 48)); + EXPECT_GT(countOpaquePixelsOtherThan(pixmap, ThemeIconSticker::outlineColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); +} + +/** @brief Sticker never paints authored art: the asset keeps its own colors. */ +TEST_F(ThemeIconRenderTest, StickerOnAuthoredArtUsesTheAssetPath) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#336699")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + const QColor tone = themeManager->appColor(AppColor::Negative); + const QIcon icon = loadThemedIcon(QStringLiteral("plain"), AppColor::Negative, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(32, 32)); + ASSERT_FALSE(pixmap.isNull()); + + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); +} + +/** @brief Disabled keeps the shape language but drops back to the line glyph. */ +TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph) +{ + QPalette palette = qApp->palette(); + const QColor disabledText(QStringLiteral("#fedcba")); + palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); + qApp->setPalette(palette); + + const QColor tone = themeManager->appColor(AppColor::Negative); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled); + ASSERT_FALSE(pixmap.isNull()); + + // No rim in the sticker tone: Disabled drops the treatment and paints the + // thin disabled line glyph instead. + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); +} + +/** @brief Line stays the default: no disc, no ring, just the recolored glyph. */ +TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QPixmap pixmap = + loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_GT(countOpaquePixels(pixmap, tone), 0); +} + +/** + * @brief Writes the semantic-tint preview sheet when asked; skipped otherwise. + * + * #7775 put the semantic tone back on line glyphs, before any filled treatment + * existed. The sheet renders the affected glyphs with the raw per-scheme + * `AppColor` value, which is what that PR paints, at the sizes the UI uses. + */ +TEST(IconTintPreview, WritesPngsWhenRequested) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + struct Glyph + { + const char *name; + AppColor::Role role; + }; + constexpr Glyph tinted[] = { + {"ready_start", AppColor::Positive}, {"not_ready_start", AppColor::Negative}, {"increment", AppColor::Positive}, + {"decrement", AppColor::Negative}, {"remove_row", AppColor::Negative}, {"delete", AppColor::Negative}, + {"arrow_right", AppColor::Positive}, + }; + constexpr int SIZES[] = {15, 16, 24, 48}; + + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 130; + constexpr int CELL_WIDTH = 120; + constexpr int ROW_HEIGHT = 70; + constexpr qreal DPR = 2.0; + + const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast(std::size(SIZES)) * CELL_WIDTH + MARGIN; + const int logicalHeight = MARGIN + 24 + 34 + static_cast(std::size(tinted)) * ROW_HEIGHT + 40 + MARGIN; + const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)); + + for (const bool dark : {false, true}) { + const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff")); + const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e")); + + QImage image(imageSize, QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9)); + + qreal y = MARGIN + 4; + painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")); + + y += 24; + painter.drawText(QPointF(MARGIN, y), QStringLiteral("Semantic tints on line glyphs (#7775)")); + y += 10; + + for (const Glyph &glyph : tinted) { + y += ROW_HEIGHT; + const qreal rowTop = y - ROW_HEIGHT; + QFile file(QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(glyph.name) + + QStringLiteral(".svg"))); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)) << glyph.name; + const QByteArray svg = file.readAll(); + const QColor tone = defaultAppColor(glyph.role, dark); + painter.drawText(QPointF(MARGIN, rowTop + 16), QString::fromLatin1(glyph.name)); + for (std::size_t i = 0; i < std::size(SIZES); ++i) { + const int side = SIZES[i]; + const QPixmap pixmap = renderLine(svg, tone, QSize(side, side)); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + // Bottom-align the sizes and put the caption below the 48px row. + painter.drawPixmap(QPointF(x, rowTop + 54 - side), pixmap); + painter.drawText(QPointF(x, rowTop + 66), QStringLiteral("%1px").arg(side)); + } + } + + y += 20; + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Positive: ready and add. Negative: not ready, remove and delete.")); + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("tint-preview-dark.png") + : QStringLiteral("tint-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} + +/** + * @brief Writes the sticker preview sheet when asked; skipped otherwise. + * + * The sheet shows each add/remove glyph as line and as sticker at the + * sizes the UI uses (15px in the player list and menus, 16px in toolbars, 24 and + * 48 for inspection), with the ready/not-ready badge for context. It lives in the + * test binary so the colors come from the same place the engine uses. + */ +TEST(IconStickerPreview, WritesPngsWhenRequested) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + struct Glyph + { + const char *name; + AppColor::Role role; + }; + constexpr Glyph stickered[] = { + {"increment", AppColor::Positive}, + {"decrement", AppColor::Negative}, + {"remove_row", AppColor::Negative}, + {"delete", AppColor::Negative}, + }; + constexpr int SIZES[] = {15, 16, 24, 48}; + constexpr int CONTEXT_SIZE = 32; + + const auto readSvg = [](const char *name) { + QFile file( + QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); + }; + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 110; + constexpr int CELL_WIDTH = 120; + constexpr int ROW_HEIGHT = 58; + constexpr int GLYPH_BLOCK_HEIGHT = 22 + 2 * ROW_HEIGHT; + constexpr qreal DPR = 2.0; + + const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast(std::size(SIZES)) * CELL_WIDTH + MARGIN; + const int logicalHeight = MARGIN + 24 + 24 + static_cast(std::size(stickered)) * GLYPH_BLOCK_HEIGHT + 24 + 24 + + CONTEXT_SIZE + 46 + CONTEXT_SIZE + 46 + MARGIN; + const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)); + + for (const bool dark : {false, true}) { + const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff")); + const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e")); + + QImage image(imageSize, QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9)); + + qreal y = MARGIN + 4; + painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")); + + y += 24; + painter.drawText(QPointF(MARGIN, y), QStringLiteral("Add / remove (line above, sticker below)")); + for (const Glyph &glyph : stickered) { + y += GLYPH_BLOCK_HEIGHT; + const QByteArray svg = readSvg(glyph.name); + ASSERT_FALSE(svg.isEmpty()) << glyph.name; + // The preview must show the line glyph the engine paints, and the + // engine saturates AppColor tones before drawing them. + const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); + + for (int row = 0; row < 2; ++row) { + const bool sticker = row == 1; + const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT; + painter.drawText(QPointF(MARGIN, rowTop + 16), + (sticker ? QStringLiteral("sticker ") : QStringLiteral("line ")) + + QString::fromLatin1(glyph.name)); + for (std::size_t i = 0; i < std::size(SIZES); ++i) { + const int side = SIZES[i]; + const QPixmap pixmap = sticker ? ThemeIconSticker::renderSticker(svg, tone, QSize(side, side)) + : renderLine(svg, tone, QSize(side, side)); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap); + if (row == 1) { + painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side)); + } + } + } + } + + y += 24; + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Ready / not ready keep the badge (%1px)").arg(CONTEXT_SIZE)); + constexpr Glyph badgeContext[] = { + {"ready_start", AppColor::Positive}, + {"not_ready_start", AppColor::Negative}, + }; + for (std::size_t i = 0; i < std::size(badgeContext); ++i) { + const QByteArray svg = readSvg(badgeContext[i].name); + ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; + const QColor tone = defaultAppColor(badgeContext[i].role, dark); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + painter.drawPixmap(QPointF(x, y + 8), + ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE))); + painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name)); + } + + y += CONTEXT_SIZE + 46; + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Line / badge / sticker share one tone (%1px)").arg(CONTEXT_SIZE)); + constexpr const char *FAMILY_LABELS[] = {"line", "badge", "sticker"}; + for (std::size_t i = 0; i < std::size(badgeContext); ++i) { + const QByteArray svg = readSvg(badgeContext[i].name); + ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; + const QColor tone = ThemeIconFill::saturated(defaultAppColor(badgeContext[i].role, dark)); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH * 1.5; + for (int j = 0; j < 3; ++j) { + const QSize side(CONTEXT_SIZE, CONTEXT_SIZE); + QPixmap pixmap; + if (j == 0) { + pixmap = renderLine(svg, tone, side); + } else if (j == 1) { + pixmap = ThemeIconBadge::renderBadge(svg, tone, side); + } else { + pixmap = ThemeIconSticker::renderSticker(svg, tone, side); + } + const qreal px = x + j * (CONTEXT_SIZE + 16); + painter.drawPixmap(QPointF(px, y + 8), pixmap); + painter.drawText(QPointF(px, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(FAMILY_LABELS[j])); + } + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("sticker-preview-dark.png") + : QStringLiteral("sticker-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} + +/** + * @brief Writes the PR preview sheets when asked; skipped otherwise. + * + * Two panels per scheme: ready/not-ready as line and badge at the sizes the UI + * actually uses (15px in the player list, 16px in toolbars, 24 and 48 for + * inspection). It lives in the test binary so the colors come from the same + * place the engine uses. The add/remove/delete glyphs and their sticker + * treatment have their own sheet (IconStickerPreview). + */ +TEST(IconBadgePreview, WritesPngsWhenRequested) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + struct Glyph + { + const char *name; + AppColor::Role role; + }; + constexpr Glyph badged[] = { + {"ready_start", AppColor::Positive}, + {"not_ready_start", AppColor::Negative}, + }; + constexpr int SIZES[] = {15, 16, 24, 48}; + + const auto readSvg = [](const char *name) { + QFile file( + QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); + }; + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 90; + constexpr int CELL_WIDTH = 120; + constexpr int ROW_HEIGHT = 58; + constexpr int GLYPH_BLOCK_HEIGHT = 22 + 2 * ROW_HEIGHT; + constexpr qreal DPR = 2.0; + + const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast(std::size(SIZES)) * CELL_WIDTH + MARGIN; + const int logicalHeight = MARGIN + 24 + 24 + static_cast(std::size(badged)) * GLYPH_BLOCK_HEIGHT + 30 + MARGIN; + const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)); + + for (const bool dark : {false, true}) { + const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff")); + const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e")); + + QImage image(imageSize, QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9)); + + qreal y = MARGIN + 4; + painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")); + + y += 24; + painter.drawText(QPointF(MARGIN, y), QStringLiteral("Ready / not ready (line above, badge below)")); + for (const Glyph &glyph : badged) { + y += GLYPH_BLOCK_HEIGHT; + const QByteArray svg = readSvg(glyph.name); + ASSERT_FALSE(svg.isEmpty()) << glyph.name; + // The preview must show the line glyph the engine paints, and the + // engine saturates AppColor tones before drawing them. + const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); + + for (int row = 0; row < 2; ++row) { + const bool badge = row == 1; + const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT; + painter.drawText(QPointF(MARGIN, rowTop + 16), + (badge ? QStringLiteral("badge ") : QStringLiteral("line ")) + + QString::fromLatin1(glyph.name)); + for (std::size_t i = 0; i < std::size(SIZES); ++i) { + const int side = SIZES[i]; + const QPixmap pixmap = badge ? ThemeIconBadge::renderBadge(svg, tone, QSize(side, side)) + : renderLine(svg, tone, QSize(side, side)); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + // Align all sizes on a common baseline so the weight + // difference between 15px and 48px is visible. + painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap); + if (row == 1) { + painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side)); + } + } + } + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("badge-preview-dark.png") + : QStringLiteral("badge-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index a953c9f0b..7e769b1bd 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -153,6 +153,41 @@ TEST_F(ThemeMigrationTest, IsIdempotent) EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css")); } +TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) +{ + EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1b7f3b"))); + EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c"))); + EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e"))); + EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80"))); + // Accent roles are derived from the palette, so they have no static default. + EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, false).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, true).isValid()); +} + +TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + PaletteConfig config; + const QColor positive(QStringLiteral("#1b7f3b")); + const QColor negative(QStringLiteral("#b3261e")); + config.appColors[AppColor::Positive] = positive; + config.appColors[AppColor::Negative] = negative; + + const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml")); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(config.toToml().toUtf8()); + file.close(); + + const PaletteConfig parsed = PaletteConfig::fromFile(path); + ASSERT_TRUE(parsed.appColors.contains(AppColor::Positive)); + ASSERT_TRUE(parsed.appColors.contains(AppColor::Negative)); + EXPECT_EQ(parsed.appColors.value(AppColor::Positive), positive); + EXPECT_EQ(parsed.appColors.value(AppColor::Negative), negative); +} + // Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and // the fallback then links the bare gtest library without gtest_main. int main(int argc, char **argv) From 3e2884e6e12bdceea8b918dc12f55d5e7f410b08 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:09:01 +0100 Subject: [PATCH 099/104] [Icons] Paint neutral glyphs with icon ink and add an emphasis axis (#7788) * [Icons] Paint neutral glyphs with icon ink instead of body-text ink Neutral icons resolved `currentColor` straight to `QPalette::WindowText`. A glyph is not a word: it is a 16-24px mark drawn with a 2-unit stroke, so antialiasing thins it until it reads lighter than the text beside it, in the one color the palette reserves for reading. The result is that icons recede into the chrome exactly where they should anchor it. Add `theme_icon_ink`, one rule with one job: mix a share of the theme accent into the neutral text color, then hold the result to a contrast floor. The accent share gives the glyph chroma the text does not have, so it reads as an icon rather than as leftover text, and it is derived from `QPalette::Highlight` rather than a new token. The floor keeps a theme with a weak text color from producing an illegible glyph. Only the neutral label roles (`WindowText`, `Text`) are inked. Semantic tints are hues the call site chose on purpose, disabled uses the disabled group, and the selected-row swap to `HighlightedText` is unchanged. The WCAG contrast math moves from `theme_icon_badge` to the new unit, so badge ink decisions and the floor share one implementation. Tests: the ink rule against every shipped palette, the weak-text floor, and the palette-following render tests now ask the rule instead of restating its output. A preview sheet sweeps the accent share at 0-35% over window and button surfaces for both schemes. * [Icons] Add an emphasis axis for icon ink Tint says which hue, style says how much shape, and nothing said how loud. Call sites that have a hierarchy (a card hover panel's nav strip, the visual database filter toolbar) could only pick between "the theme color" and "another theme color", so "this is the important one" had no way to be expressed and every glyph in a surface arrived with the same weight. Add `IconEmphasis` as the third axis: Quiet, Secondary, Primary. It travels inside `IconTint` instead of becoming a fourth `loadThemedIcon` parameter, so every existing call site keeps working and its look, and emphasis reads as part of the same "how should this glyph be inked" decision as the tint. - Primary lifts a neutral glyph to `QPalette::Highlight`, so the surface's own action color names the primary action instead of a second gray token, and pushes both neutral and tinted glyphs to a 7:1 floor. - Quiet blends toward the surface and stops at the 3:1 WCAG non-text floor, for glyphs that label a control rather than act (the magnifier in a search field, a section marker). - Secondary is the previous color verbatim. The default ink borrows enough accent to sit off the palette's pole (5.4:1 on light, 6.4:1 on dark), so the 7:1 primary floor is measurably above it in both schemes instead of being lost against a near-black or near-white default. The tests pin that order where it exists, and the hue shift still separates the levels at a glance. Disabled glyphs and the Selected -> HighlightedText swap are unchanged at every emphasis level. * [Icons] Render the emphasis sheet for every shipped theme The preview only rendered one theme, so a reviewer could not see that the emphasis levels behave the same way across Fabric, Fusion, Leather, Plasma and VelvetMarble. It now writes one sheet per theme and scheme: the neutral, positive and negative families at all three levels, three glyphs per family, as line, sticker and badge at 16 and 24 px, on the window and button surfaces. * Address review findings --- cockatrice/CMakeLists.txt | 1 + cockatrice/src/interface/theme_icon.cpp | 45 +- cockatrice/src/interface/theme_icon.h | 45 +- cockatrice/src/interface/theme_icon_badge.cpp | 17 - cockatrice/src/interface/theme_icon_badge.h | 10 - cockatrice/src/interface/theme_icon_ink.cpp | 135 +++++ cockatrice/src/interface/theme_icon_ink.h | 127 ++++ oracle/CMakeLists.txt | 1 + tests/theme_migration/CMakeLists.txt | 1 + .../theme_icon_render_test.cpp | 550 +++++++++++++++++- 10 files changed, 888 insertions(+), 44 deletions(-) create mode 100644 cockatrice/src/interface/theme_icon_ink.cpp create mode 100644 cockatrice/src/interface/theme_icon_ink.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 564e19891..cc6debdf0 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -154,6 +154,7 @@ set(cockatrice_SOURCES src/interface/theme_icon.cpp src/interface/theme_icon_badge.cpp src/interface/theme_icon_fill.cpp + src/interface/theme_icon_ink.cpp src/interface/theme_icon_sticker.cpp src/interface/theme_manager.cpp src/interface/palette_editor/color_button.cpp diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 622534d8c..3557a9441 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -3,6 +3,7 @@ #include "libcockatrice/utility/svg_recolor.h" #include "theme_icon_badge.h" #include "theme_icon_fill.h" +#include "theme_icon_ink.h" #include "theme_icon_sticker.h" #include "theme_manager.h" @@ -319,26 +320,43 @@ private: }; } // namespace -IconTint::IconTint(QPalette::ColorRole role) : paletteRole(role) +IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis) { } -IconTint::IconTint(AppColor::Role role) : source(Source::AppColor), appRole(role) +IconTint::IconTint(AppColor::Role role, IconEmphasis _emphasis) + : source(Source::AppColor), appRole(role), emphasis(_emphasis) { } +IconTint IconTint::primary() +{ + return IconTint(QPalette::WindowText, IconEmphasis::Primary); +} + +IconTint IconTint::quiet() +{ + return IconTint(QPalette::WindowText, IconEmphasis::Quiet); +} + QColor IconTint::resolve(QIcon::Mode mode) const { + const QPalette &palette = qApp->palette(); + const QColor surface = palette.color(QPalette::Active, QPalette::Window); + const QColor accent = palette.color(QPalette::Active, QPalette::Highlight); + if (source == Source::AppColor) { // A disabled control must not keep signalling its semantic color. if (mode == QIcon::Disabled) { - return qApp->palette().color(QPalette::Disabled, QPalette::WindowText); + return palette.color(QPalette::Disabled, QPalette::WindowText); } // The three treatments paint the same tone: a line glyph, a badge disc // and a sticker fill must not disagree on how saturated the role is. // The fill treatments apply this floor themselves, so this only changes - // the line path, and only for the desaturated dark-scheme tones. - const QColor color = ThemeIconFill::saturated(themeManager->appColor(appRole)); + // the line path, and only for the desaturated dark-scheme tones. The + // emphasis then decides how loud that tone is, without changing its hue. + const QColor color = + ThemeIconInk::forEmphasis(ThemeIconFill::saturated(themeManager->appColor(appRole)), surface, emphasis); if (mode != QIcon::Selected) { return color; } @@ -346,16 +364,25 @@ QColor IconTint::resolve(QIcon::Mode mode) const // semantic tint when it stays legible there; otherwise the glyph would // vanish into the selection. constexpr double MIN_SELECTED_CONTRAST = 3.0; - const QColor highlight = qApp->palette().color(QPalette::Active, QPalette::Highlight); - return ThemeIconBadge::contrastRatio(color, highlight) >= MIN_SELECTED_CONTRAST + return ThemeIconInk::contrastRatio(color, accent) >= MIN_SELECTED_CONTRAST ? color - : qApp->palette().color(QPalette::Active, QPalette::HighlightedText); + : palette.color(QPalette::Active, QPalette::HighlightedText); } const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; const QPalette::ColorRole effective = mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole; - return qApp->palette().color(group, effective); + const QColor color = palette.color(group, effective); + // A neutral label role is inked as an icon, not as body text: it borrows a + // little accent chroma and is held to a contrast floor. Semantic roles and + // the selected-row swap keep the color the palette gave them. + if (group == QPalette::Active && (effective == QPalette::WindowText || effective == QPalette::Text)) { + // The primary action of a surface takes the accent itself; the other + // levels stay neutral. Emphasis then sets the contrast the glyph pays. + const QColor base = emphasis == IconEmphasis::Primary ? accent : ThemeIconInk::glyphInk(color, surface, accent); + return ThemeIconInk::forEmphasis(base, surface, emphasis); + } + return color; } QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style) diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index e1a867fc0..231221c93 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -46,19 +46,47 @@ enum class IconStyle Sticker, ///< filled glyph + dark outline, no disc }; +/** + * @brief Selects how much attention a glyph claims inside its surface. + * + * Tint says which hue, style says how much shape, emphasis says how loud. The + * three are independent: a primary action can be a line glyph in the theme + * accent, and a quiet one can be a sticker in a dimmed tone. + * + * Secondary is the default and the color every icon had before the axis + * existed, so existing call sites keep their look. Primary is for the one + * action a surface exists for: a neutral primary glyph takes the accent, and a + * tinted one is pushed to full contrast. Quiet is for glyphs that label rather + * than act, such as the magnifier inside a search field: legible, but never + * competing with the buttons next to it. + */ +enum class IconEmphasis +{ + Quiet, // labels rather than acts + Secondary, // the default action weight + Primary, // the action the surface exists for +}; + /** * @brief Selects the color that replaces the `currentColor` sentinel. * * A palette role follows Qt's structural palette. An application color carries * meaning that QPalette cannot express, such as positive and negative states. - * Callers choose the role. The active theme supplies the value. + * Callers pick the role by intent; the theme owns the concrete value. Emphasis + * is part of the same decision: both answer how the glyph is inked. */ class IconTint { public: IconTint() = default; - IconTint(QPalette::ColorRole role); // implicit: existing palette-role callers - IconTint(AppColor::Role role); // implicit: semantic callers + IconTint(QPalette::ColorRole role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for role callers + IconTint(AppColor::Role role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for semantic callers + + /** @brief A neutral glyph in the accent tone, for the surface's primary action. */ + static IconTint primary(); + + /** @brief A dimmed neutral glyph, for a control that labels rather than acts. */ + static IconTint quiet(); /** * @brief Resolves the concrete color for an icon mode. @@ -68,6 +96,16 @@ public: * color keeps the WindowText -> HighlightedText swap. Application colors go * through the saturation floor shared by the filled treatments, so a line * glyph, a badge disc and a sticker fill carry the same tone. + * + * Neutral label roles (WindowText, Text) resolve to icon ink rather than the + * raw palette color: ThemeIconInk::glyphInk borrows a little accent chroma + * and holds the glyph to a contrast floor, because a 2px glyph in body-text + * ink reads thinner than the text beside it. Semantic roles keep the hue the + * call site asked for. + * + * Emphasis is applied after the hue: Primary lifts a neutral glyph to the + * accent and pushes a tinted one to full contrast; Quiet blends either + * toward the surface down to the non-text legibility floor. */ QColor resolve(QIcon::Mode mode) const; @@ -81,6 +119,7 @@ private: Source source = Source::Palette; QPalette::ColorRole paletteRole = QPalette::WindowText; AppColor::Role appRole = AppColor::AccentStrong; + IconEmphasis emphasis = IconEmphasis::Secondary; }; /** diff --git a/cockatrice/src/interface/theme_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp index e9ec057f7..086d2be43 100644 --- a/cockatrice/src/interface/theme_icon_badge.cpp +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -10,7 +10,6 @@ #include #include #include -#include namespace { @@ -47,22 +46,6 @@ DiscFill discFill(const QColor &tone) namespace ThemeIconBadge { -double relativeLuminance(const QColor &color) -{ - const auto linear = [](double channel) { - channel /= 255.0; - return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4); - }; - return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue()); -} - -double contrastRatio(const QColor &first, const QColor &second) -{ - const double a = relativeLuminance(first); - const double b = relativeLuminance(second); - return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05); -} - QColor glyphColor(const QColor &tone) { // The badge carries the ink the pre-sticker badge did: white on the light diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h index cdb43cbd9..412082377 100644 --- a/cockatrice/src/interface/theme_icon_badge.h +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -31,16 +31,6 @@ */ namespace ThemeIconBadge { -/** - * @brief WCAG relative luminance, used to test a color against a background. - * - * Used by IconTint::resolve()'s selected-row contrast test. - */ -double relativeLuminance(const QColor &color); - -/** @brief WCAG contrast ratio between two opaque colors, from 1.0 to 21.0. */ -double contrastRatio(const QColor &first, const QColor &second); - /** * @brief Glyph color for a badge disc. * diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp new file mode 100644 index 000000000..1b0748091 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -0,0 +1,135 @@ +#include "theme_icon_ink.h" + +#include +#include + +namespace +{ +// How much of the theme accent a neutral glyph borrows. A quarter still reads as +// near-black at 16-24px, which is what made the first cut look unchanged, so the +// glyph takes most of the accent's hue. It stops short of the accent itself: the +// glyph keeps its own contrast against the surface instead of becoming a second +// accent fill. Every shipped theme accents in blue or violet, so the borrowed hue +// stays neutral chrome instead of implying a state. +constexpr double ACCENT_MIX = 0.70; +// An icon that labels a control is carrying text; see ui-assets.md. +constexpr double LABEL_CONTRAST_FLOOR = 4.5; +// A primary glyph has to out-read the default one on both schemes. The accent is +// a mid-tone: on a light surface it is *lighter* than the neutral ink, and on a +// dark surface it is *darker*, so neither "the accent as-is" nor "a contrast +// floor shared with the default" keeps the primary above the secondary. A 7:1 +// target does: it darkens the accent on light schemes and lightens it on dark +// ones, so the primary level always out-contrasts the default level. +constexpr double PRIMARY_CONTRAST = 7.0; +// A quiet glyph is blended this far into its surface. It has to stay findable +// without competing with the actions beside it. +constexpr double QUIET_BLEND = 0.55; +// WCAG 2.2 SC 1.4.11, the floor for a non-text element that carries meaning. +constexpr double QUIET_CONTRAST_FLOOR = 3.0; +// Binary-search depth for reachContrast(). 24 halvings resolve to well under +// one 8-bit channel step, so the result is stable across platforms. +constexpr int CONTRAST_SEARCH_STEPS = 24; +} // namespace + +namespace ThemeIconInk +{ +double relativeLuminance(const QColor &color) +{ + const auto linear = [](double channel) { + channel /= 255.0; + return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4); + }; + return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue()); +} + +double contrastRatio(const QColor &first, const QColor &second) +{ + const double a = relativeLuminance(first); + const double b = relativeLuminance(second); + return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05); +} + +QColor blend(const QColor &color, const QColor &target, double amount) +{ + const double clamped = qBound(0.0, amount, 1.0); + const auto channel = [clamped](int from, int to) { return qRound(from + (to - from) * clamped); }; + return QColor(channel(color.red(), target.red()), channel(color.green(), target.green()), + channel(color.blue(), target.blue())); +} + +QColor reachContrast(const QColor &color, const QColor &background, double minRatio) +{ + if (contrastRatio(color, background) >= minRatio) { + return color; + } + // Move away from the background: toward whichever pole actually reaches + // further. Black and white cross at a luminance of about 0.18, not 0.5, so + // the midpoint test would pick white on a surface where black is far more + // legible -- and could drag an ink that already cleared the floor to white. + // The hue is kept by interpolating, not by replacing. + const QColor black(Qt::black); + const QColor white(Qt::white); + const QColor pole = contrastRatio(black, background) >= contrastRatio(white, background) ? black : white; + if (contrastRatio(pole, background) < minRatio) { + return pole; + } + + // The ratio is monotone along the path from color to pole, so a bisection + // finds the first point that clears the floor. Stepping the opposite way + // from the pole keeps the glyph as close to its palette color as possible. + double low = 0.0; + double high = 1.0; + for (int step = 0; step < CONTRAST_SEARCH_STEPS; ++step) { + const double middle = (low + high) / 2.0; + if (contrastRatio(blend(color, pole, middle), background) >= minRatio) { + high = middle; + } else { + low = middle; + } + } + return blend(color, pole, high); +} + +QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix) +{ + return reachContrast(blend(text, accent, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb(); +} + +double defaultAccentMix() +{ + return ACCENT_MIX; +} + +double labelContrastFloor() +{ + return LABEL_CONTRAST_FLOOR; +} + +QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis) +{ + switch (emphasis) { + case IconEmphasis::Primary: + return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb(); + case IconEmphasis::Quiet: + return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb(); + case IconEmphasis::Secondary: + break; + } + return color; +} + +double primaryContrastFloor() +{ + return PRIMARY_CONTRAST; +} + +double quietBlend() +{ + return QUIET_BLEND; +} + +double quietContrastFloor() +{ + return QUIET_CONTRAST_FLOOR; +} +} // namespace ThemeIconInk diff --git a/cockatrice/src/interface/theme_icon_ink.h b/cockatrice/src/interface/theme_icon_ink.h new file mode 100644 index 000000000..6ba90a239 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -0,0 +1,127 @@ +/** + * @file theme_icon_ink.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_INK_H +#define THEME_ICON_INK_H + +#include "theme_icon.h" + +#include + +/** + * @brief Turns a text color into the ink a glyph is painted with. + * + * A glyph is not a word. Body text is read in sentences, at a size where a 2px + * stroke would be unusually heavy; a glyph is a 16px mark of the same stroke, + * and antialiasing eats its edges, so the same color that reads as solid type + * reads as a thin gray line beside it. Painting icons with `WindowText` verbatim + * is what makes them recede. + * + * The rule here is deliberately small: give the glyph a little of the theme's + * accent chroma, then hold it to a contrast floor against the surface it sits + * on. The chroma is what makes it read as an icon rather than as leftover text; + * the floor keeps a theme with a weak text color from producing a glyph that is + * not legible. Neither step invents a color: both are derived from the palette + * the theme already ships. + * + * The rule only applies to the neutral label roles. A semantic tint + * (`AppColor::Positive`, `QPalette::Highlight`, `QPalette::Link`) is a hue the + * call site chose on purpose, so it is left alone. + */ +namespace ThemeIconInk +{ +/** + * @brief WCAG relative luminance of an opaque color. + */ +double relativeLuminance(const QColor &color); + +/** + * @brief WCAG contrast ratio between two opaque colors, from 1.0 to 21.0. + */ +double contrastRatio(const QColor &first, const QColor &second); + +/** + * @brief Mixes two colors, @p amount = 0 returning @p color and 1 returning @p target. + * + * RGB interpolation, with no gamma or hue-space correction: the inputs are + * palette colors that already sit in sRGB, and the result has to be predictable + * enough for a theme author to reason about it. + */ +QColor blend(const QColor &color, const QColor &target, double amount); + +/** + * @brief Pushes @p color away from @p background until it reaches @p minRatio. + * + * Moves @p color toward black on a light background and toward white on a dark + * one, so the hue survives the adjustment. Returns @p color unchanged when it + * already meets the floor, which is the common case for a shipped palette. + * + * @param color Color to adjust. + * @param background Surface the color is drawn on. + * @param minRatio Contrast floor, as a WCAG ratio. + * @return The adjusted color; the nearer pole when even that cannot reach it. + */ +QColor reachContrast(const QColor &color, const QColor &background, double minRatio); + +/** + * @brief Accent share used by production callers of glyphInk(). + * + * Exposed so the preview sheet and the tests name the value they pin instead of + * repeating the literal, and so the default argument below has exactly one + * source: the constant in theme_icon_ink.cpp. + */ +double defaultAccentMix(); + +/** + * @brief Icon ink for a neutral glyph, given the palette it is drawn from. + * + * @param text Neutral text color, normally `QPalette::WindowText`. + * @param surface Surface the glyph is drawn on, normally `QPalette::Window`. + * @param accent The theme accent, normally `QPalette::Highlight`. + * @param accentMix How much of the accent color to borrow. Defaults to + * defaultAccentMix(); previews and tests sweep it to compare + * strengths, production callers do not pass it. + * @return The ink to resolve `currentColor` to, as an RGB color. + */ +QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix = defaultAccentMix()); + +/** + * @brief Contrast floor a glyph that labels a control has to clear. + * + * 4.5:1, the Text-level WCAG ratio: an icon that is the only label for a button + * is carrying text, and the icon guidelines in `.opencode/docs/ui-assets.md` + * already ask for that when there is no visible label. + */ +double labelContrastFloor(); + +/** + * @brief Applies an emphasis level to an already-hued glyph color. + * + * Secondary keeps the hue untouched, so it is the no-op the default needs. + * Primary pushes the color to a higher floor than the default, because the + * accent is a mid-tone: on a light scheme it is lighter than the neutral ink and + * on a dark one darker, so only a stronger contrast target keeps the primary + * level above the secondary on both. Quiet + * blends the color toward the surface it sits on and stops at the non-text + * legibility floor, so a labelling glyph recedes without disappearing. + * + * @param color Hue-resolved glyph color. + * @param surface Surface the glyph is drawn on. + * @param emphasis How much attention the glyph claims. + * @return The emphasized color, as an RGB color. + */ +QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis); + +/** @brief Contrast floor the primary level reaches, so it out-reads the default. */ +double primaryContrastFloor(); + +/** @brief How far a quiet glyph is blended into its surface. */ +double quietBlend(); + +/** @brief Contrast floor a quiet glyph keeps, the WCAG non-text minimum. */ +double quietContrastFloor(); +} // namespace ThemeIconInk + +#endif // THEME_ICON_INK_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 50799d496..b500f79ab 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -34,6 +34,7 @@ set(oracle_SOURCES ../cockatrice/src/interface/theme_icon.cpp ../cockatrice/src/interface/theme_icon_badge.cpp ../cockatrice/src/interface/theme_icon_fill.cpp + ../cockatrice/src/interface/theme_icon_ink.cpp ../cockatrice/src/interface/theme_icon_sticker.cpp ../cockatrice/src/interface/theme_manager.cpp ${VERSION_STRING_CPP} diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index d0bd357b3..4f9d6dce4 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -8,6 +8,7 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_badge.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_fill.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_ink.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_sticker.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index ee078555d..f51282e0a 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1,6 +1,7 @@ #include "interface/theme_icon.h" #include "interface/theme_icon_badge.h" #include "interface/theme_icon_fill.h" +#include "interface/theme_icon_ink.h" #include "interface/theme_icon_sticker.h" #include "interface/theme_manager.h" #include "libcockatrice/utility/svg_recolor.h" @@ -75,6 +76,20 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) return count; } +/** + * @brief The ink the engine resolves a neutral glyph to, for @p palette. + * + * Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink + * borrows accent chroma and holds the result to a contrast floor. Tests that + * pin the rendered color ask the rule instead of restating it. + */ +QColor expectedNeutralInk(const QPalette &palette) +{ + return ThemeIconInk::glyphInk(palette.color(QPalette::Active, QPalette::WindowText), + palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Highlight)); +} + /** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ int countOpaquePixelsOtherThan(const QPixmap &pixmap, const QColor &excluded) { @@ -152,11 +167,12 @@ TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) const QIcon icon = loadThemedIcon(QStringLiteral("icons/search")); ASSERT_FALSE(icon.isNull()); + const QColor firstInk = expectedNeutralInk(qApp->palette()); const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0); ASSERT_FALSE(normal.isNull()); EXPECT_EQ(normal.size(), QSize(24, 24)); EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0); - EXPECT_GT(countOpaquePixels(normal, QColor(QStringLiteral("#123456"))), 0); + EXPECT_GT(countOpaquePixels(normal, firstInk), 0); const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0); ASSERT_FALSE(hidpi.isNull()); @@ -165,8 +181,10 @@ TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef"))); qApp->setPalette(palette); + const QColor secondInk = expectedNeutralInk(qApp->palette()); + EXPECT_NE(firstInk, secondInk) << "the icon did not follow the palette"; const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0); - EXPECT_GT(countOpaquePixels(flipped, QColor(QStringLiteral("#abcdef"))), 0); + EXPECT_GT(countOpaquePixels(flipped, secondInk), 0); QPalette selectedPalette = qApp->palette(); selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00"))); @@ -179,6 +197,7 @@ TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor) { QPalette palette; + palette.setColor(QPalette::Window, QColor(Qt::black)); palette.setColor(QPalette::WindowText, QColor(Qt::white)); qApp->setPalette(palette); @@ -186,7 +205,7 @@ TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor) ASSERT_FALSE(pixmap.isNull()); EXPECT_EQ(pixmap.size(), QSize(24, 24)); - EXPECT_GT(countOpaquePixels(pixmap, Qt::white), 0); + EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 0); } TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull) @@ -221,7 +240,7 @@ TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved) const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg")); ASSERT_FALSE(pixmap.isNull()); - EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0); + EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 0); } /** @@ -279,7 +298,12 @@ TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText) qApp->setPalette(palette); const IconTint tint(QPalette::WindowText); - EXPECT_EQ(tint.resolve(QIcon::Normal), activeText); + // The normal ink is derived from the active text color, but it is icon ink + // rather than the text color itself. + EXPECT_NE(tint.resolve(QIcon::Normal), activeText); + EXPECT_EQ(tint.resolve(QIcon::Normal), expectedNeutralInk(qApp->palette())); + // A selected row paints on the highlight, so the ink is the palette's + // highlighted text rather than the icon ink. EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText); } @@ -1097,3 +1121,519 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) ASSERT_TRUE(image.save(path)) << path.toStdString(); } } + +namespace +{ +/** @brief Palette roles the ink rule reads, from one shipped theme file. */ +struct InkPalette +{ + QColor text; + QColor window; + QColor button; + QColor accent; +}; + +/** + * @brief Loads a shipped palette file, or returns a default-constructed palette. + * + * The theme files are the only place the shipped values live, so the rule is + * tested against them rather than against a copy of them. + */ +InkPalette shippedPalette(const QString &theme, bool dark) +{ + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + if (!config.hasPalette()) { + return {}; + } + const QPalette palette = config.apply(QPalette()); + return {palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), palette.color(QPalette::Active, QPalette::Highlight)}; +} +} // namespace + +TEST(IconInk, GivesNeutralGlyphsAccentChroma) +{ + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), false); + ASSERT_TRUE(palette.text.isValid()); + + const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + EXPECT_NE(ink, palette.text) << "a glyph must not be painted with body-text ink verbatim"; + // The borrowed accent is a lighter color than the near-black text ink, so + // the glyph gets a little lighter as well as more chromatic. What must not + // change is its legibility, its blue cast, and the fact that it reads as a + // richer color than plain text. + EXPECT_GT(ink.hsvSaturation(), palette.text.hsvSaturation()) << "the glyph gained no chroma"; + EXPECT_GT(ink.blue(), ink.red()) << "accent chroma did not reach the glyph"; + EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor()); +} + +TEST(IconInk, MeetsTheLabelFloorOnEveryShippedPalette) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; + } + ++checked; + const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +TEST(IconInk, KeepsADeliberateHueWhenTheTextColorIsWeak) +{ + // A theme whose text color is deliberately low-contrast still gets a glyph + // that clears the floor, and the hue survives the adjustment. + const QColor surface(QStringLiteral("#f4f4f4")); + const QColor weakText(QStringLiteral("#c9c9c9")); + const QColor ink = ThemeIconInk::glyphInk(weakText, surface, QColor(QStringLiteral("#c9c9c9"))); + EXPECT_GE(ThemeIconInk::contrastRatio(ink, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_LT(ink.value(), weakText.value()); +} + +/** + * @brief Writes the glyph-ink comparison sheet when asked; skipped otherwise. + * + * One row per accent share, so the trade-off between "still neutral chrome" and + * "clearly an icon" can be judged against the body text drawn beside the glyph. + * Lives in the test binary so the colors come from the same rule the engine uses. + */ +TEST(IconInkPreview, WritesPngsWhenRequested) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr const char *GLYPHS[] = {"search", "info", "settings", "filter", "increment"}; + constexpr int SIZES[] = {15, 16, 24, 48}; + constexpr int LAST_SIZE = SIZES[std::size(SIZES) - 1]; + constexpr int CELL_GAP = 10; + + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 20; + constexpr int SIZE_LABEL_WIDTH = 46; + constexpr int BODY_WIDTH = 46; + constexpr int COLUMN_GAP = 28; + constexpr int ROW_HEIGHT = 58; + constexpr qreal DPR = 2.0; + + // The same five glyphs at the sizes the client actually draws them, so the + // comparison is one the reviewer can read rather than a color swatch. + const int glyphsWidth = + static_cast(std::size(GLYPHS)) * LAST_SIZE + (static_cast(std::size(GLYPHS)) - 1) * CELL_GAP; + const int oldX = MARGIN + SIZE_LABEL_WIDTH + BODY_WIDTH; + const int newX = oldX + glyphsWidth + COLUMN_GAP; + const int logicalWidth = newX + glyphsWidth + MARGIN; + const int blockHeight = 46 + static_cast(std::size(SIZES)) * ROW_HEIGHT + 10; + const int logicalHeight = MARGIN + 24 + 20 + 26 + 2 * blockHeight + MARGIN; + + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + const QFont bodyFont(QStringLiteral("Sans"), 10); + const QFont labelFont(QStringLiteral("Sans"), 8); + const QFont captionFont(QStringLiteral("Sans"), 9, QFont::DemiBold); + + qreal y = MARGIN; + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 11, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, y), (dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")) + + QStringLiteral(" — neutral glyph ink")); + y += 18; + painter.setFont(labelFont); + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Body text is the same in both columns; only the glyph ink changes.")); + y += 20; + + // The two colors as filled swatches: at 15-24px the glyph difference is + // a hue shift, and the hex pair makes it measurable at a glance. + const QColor windowOld = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent, 0.0); + const QColor windowNew = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + qreal swatchX = MARGIN; + for (const auto &[ink, label] : {std::pair(windowOld, QStringLiteral("before")), + std::pair(windowNew, QStringLiteral("after"))}) { + painter.setPen(Qt::NoPen); + painter.setBrush(ink); + painter.drawRoundedRect(QRectF(swatchX, y - 10, 14, 14), 3, 3); + painter.setBrush(Qt::NoBrush); + painter.setPen(palette.text); + painter.drawText(QPointF(swatchX + 20, y), QStringLiteral("%1 %2").arg(label, ink.name(QColor::HexRgb))); + swatchX += 150; + } + y += 24; + + for (const bool onButton : {false, true}) { + const QColor surface = onButton ? palette.button : palette.window; + const QColor oldInk = ThemeIconInk::glyphInk(palette.text, surface, palette.accent, 0.0); + const QColor newInk = ThemeIconInk::glyphInk(palette.text, surface, palette.accent); + + const qreal blockTop = y; + if (onButton) { + painter.fillRect(QRectF(0, blockTop, logicalWidth, blockHeight), surface); + } + + painter.setPen(palette.text); + painter.setFont(captionFont); + painter.drawText(QPointF(MARGIN, blockTop + 14), + onButton ? QStringLiteral("Button surface") : QStringLiteral("Window surface")); + painter.setFont(labelFont); + painter.drawText(QPointF(MARGIN, blockTop + 34), QStringLiteral("size")); + painter.drawText(QPointF(MARGIN + SIZE_LABEL_WIDTH, blockTop + 34), QStringLiteral("body")); + painter.drawText(QPointF(oldX, blockTop + 34), QStringLiteral("before: WindowText")); + painter.drawText( + QPointF(newX, blockTop + 34), + QStringLiteral("after: %1% accent ink").arg(qRound(ThemeIconInk::defaultAccentMix() * 100))); + + qreal rowY = blockTop + 46; + for (const int side : SIZES) { + const qreal baseline = rowY + ROW_HEIGHT / 2.0 + 3.5; + painter.setFont(labelFont); + painter.drawText(QPointF(MARGIN, baseline), QStringLiteral("%1 px").arg(side)); + painter.setFont(bodyFont); + painter.drawText(QPointF(MARGIN + SIZE_LABEL_WIDTH, baseline), QStringLiteral("Aa")); + + for (std::size_t i = 0; i < std::size(GLYPHS); ++i) { + const QByteArray svg = readIconSvg(GLYPHS[i]); + ASSERT_FALSE(svg.isEmpty()) << GLYPHS[i]; + const qreal cellOffset = static_cast(i) * (side + CELL_GAP); + const qreal glyphY = rowY + (ROW_HEIGHT - side) / 2.0; + painter.drawPixmap(QPointF(oldX + cellOffset, glyphY), renderLine(svg, oldInk, QSize(side, side))); + painter.drawPixmap(QPointF(newX + cellOffset, glyphY), renderLine(svg, newInk, QSize(side, side))); + } + rowY += ROW_HEIGHT; + } + y = blockTop + blockHeight; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("ink-preview-dark.png") + : QStringLiteral("ink-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} + +namespace +{ +/** @brief Applies a shipped palette file to the application for a test. */ +bool applyShippedPalette(const QString &theme, bool dark) +{ + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + if (!config.hasPalette()) { + return false; + } + qApp->setPalette(config.apply(QPalette())); + return true; +} + +QColor surfaceColor() +{ + return qApp->palette().color(QPalette::Active, QPalette::Window); +} +} // namespace + +/** + * @brief The primary action of a surface takes the accent, not a darker gray. + * + * A glyph hierarchy cannot be built out of "dark" and "darker": at any usable + * contrast a neutral ink is already near the palette's pole, so the top level + * has to be a different color. The accent is the palette's own answer to "which + * color means action", so primary uses it instead of inventing a token. + */ +TEST_F(ThemeIconRenderTest, PrimaryEmphasisUsesTheAccent) +{ + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + + const QColor secondary = IconTint().resolve(QIcon::Normal); + const QColor primary = IconTint::primary().resolve(QIcon::Normal); + + EXPECT_NE(primary, secondary); + EXPECT_GT(primary.blue(), primary.red()) << "the primary glyph did not take the accent's blue"; + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()), ThemeIconInk::primaryContrastFloor()); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()), + ThemeIconInk::contrastRatio(secondary, surfaceColor())); +} + +/** + * @brief The primary level out-reads the default one on both schemes. + * + * The default ink borrows enough accent to sit off the palette's pole (5.4:1 on + * light, 6.4:1 on dark), so the 7:1 primary floor is measurably above it in both + * schemes instead of being lost against a near-black or near-white default. + * Hue alone would not keep the order; the contrast target does. + */ +TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes) +{ + for (const bool dark : {false, true}) { + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark)); + const QColor surface = surfaceColor(); + const QColor secondary = IconTint().resolve(QIcon::Normal); + const QColor primary = IconTint::primary().resolve(QIcon::Normal); + EXPECT_NE(primary, secondary) << (dark ? "dark" : "light"); + EXPECT_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface)) + << (dark ? "dark" : "light"); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) + << (dark ? "dark" : "light"); + } +} + +/** @brief A quiet glyph recedes toward its surface without dropping below 3:1. */ +TEST_F(ThemeIconRenderTest, QuietEmphasisRecedesButStaysLegible) +{ + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + + const QColor secondary = IconTint().resolve(QIcon::Normal); + const QColor quiet = IconTint::quiet().resolve(QIcon::Normal); + + EXPECT_LT(ThemeIconInk::contrastRatio(quiet, surfaceColor()), + ThemeIconInk::contrastRatio(secondary, surfaceColor())) + << "the quiet glyph does not recede"; + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surfaceColor()), ThemeIconInk::quietContrastFloor()); +} + +/** @brief Emphasis changes how loud a semantic color is, never which color it is. */ +TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue) +{ + ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + + for (const IconEmphasis emphasis : {IconEmphasis::Primary, IconEmphasis::Secondary, IconEmphasis::Quiet}) { + const QColor ink = IconTint(AppColor::Positive, emphasis).resolve(QIcon::Normal); + EXPECT_GT(ink.green(), ink.red()) << static_cast(emphasis); + EXPECT_GT(ink.green(), ink.blue()) << static_cast(emphasis); + } +} + +/** @brief Every emphasis keeps the selected-row swap to HighlightedText. */ +TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText) +{ + QPalette palette = qApp->palette(); + const QColor selectedText(QStringLiteral("#222222")); + palette.setColor(QPalette::Active, QPalette::Highlight, QColor(QStringLiteral("#000000"))); + palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); + qApp->setPalette(palette); + + // Black ink on a black highlight is illegible, so the selection wins for + // both the default and the quiet level. + EXPECT_EQ(IconTint().resolve(QIcon::Selected), selectedText); + EXPECT_EQ(IconTint::quiet().resolve(QIcon::Selected), selectedText); +} + +/** + * @brief Writes the emphasis ladder sheets for every shipped palette theme. + * + * One sheet per theme and scheme: the neutral, positive and negative families at + * their quiet, secondary and primary levels, each as a line glyph, a sticker and + * a badge, three glyphs per family, at 16 and 24 px, on the window and button + * surfaces. The tone is the one the emphasis rule resolves for that surface, so + * the sheet is what the engine paints rather than a hand-picked sample. + */ +TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 230; + constexpr int CELL_WIDTH = 166; + constexpr int ROW_HEIGHT = 58; + constexpr int PANEL_TITLE = 26; + constexpr int COLUMN_HEADER = 20; + constexpr int PANEL_PADDING = 12; + constexpr int GLYPH_SLOT = 32; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; + constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"}; + constexpr IconEmphasis LEVELS[] = {IconEmphasis::Quiet, IconEmphasis::Secondary, IconEmphasis::Primary}; + + struct Family + { + const char *name; + const char *glyphs[3]; + AppColor::Role role; + bool neutral; + }; + const QList families = { + {"Neutral", {"settings", "info", "player"}, AppColor::Positive, true}, + {"Positive", {"increment", "ready_start", "arrow_up"}, AppColor::Positive, false}, + {"Negative", {"decrement", "not_ready_start", "remove_row"}, AppColor::Negative, false}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int panelWidth = LABEL_WIDTH + static_cast(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING; + const int panelHeight = + PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 3 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + ASSERT_TRUE(palette.text.isValid()) << theme.toStdString(); + + // The emphasis rule reads the live palette, so apply the theme + // before resolving tones for the sheet. + ASSERT_TRUE(applyShippedPalette(theme, dark)); + + const auto toneFor = [&](const Family &family, int level) { + QColor base; + if (family.neutral) { + base = level == 2 ? palette.accent + : ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + } else { + base = ThemeIconFill::saturated(defaultAppColor(family.role, dark)); + } + return ThemeIconInk::forEmphasis(base, palette.window, LEVELS[level]); + }; + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, emphasis across treatments") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + QColor text; + }; + const QList surfaces = { + {"Window surface", palette.window, palette.text}, + {"Button surface", palette.button, palette.text}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24")); + } + + qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; + for (const Family &family : families) { + for (int level = 0; level < 3; ++level) { + const QColor ink = toneFor(family, level); + ASSERT_TRUE(ink.isValid()) + << theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level]; + + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, y + 14), + QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), + QString::fromLatin1(LEVEL_NAMES[level]))); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal cellX = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); + for (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + QPixmap pixmap; + if (treatment == 0) { + pixmap = renderLine(svg, ink, size); + } else if (treatment == 1) { + pixmap = ThemeIconSticker::renderSticker(svg, ink, size); + } else { + pixmap = ThemeIconBadge::renderBadge(svg, ink, size); + } + const qreal x = cellX + 6 + static_cast(glyphIndex) * GLYPH_SLOT + + (GLYPH_SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap); + } + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("emphasis-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} From c7e842280887f7c7b89aa1c95164a4c3940916fc Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:09:02 +0100 Subject: [PATCH 100/104] [Icons] Author icon emphasis colors as palette roles (#7789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Icons] Author icon emphasis colors as palette roles The emphasis levels were computed from the accent at paint time, so a theme could not vary them: every theme got the same derived strong and soft tones, and a semantic hue was pushed toward the accent whether the author wanted that or not. Give each family three authored roles instead. Icon/IconStrong/IconSoft carry the neutral glyph ink and Positive/Negative gain the same Strong and Soft companions. Emphasis becomes a lookup inside the family, and a caller that names a variant pins that level at every emphasis. The paint path returns the authored color verbatim; only the filled treatments keep their saturation floor, because carrying a disc or a fill is a treatment decision. Shipped palettes author the full ladder for both schemes; the palette editor exposes the new rows and the generator writes them. System keeps deriving its neutral ink from the platform palette, since it ships no palette of its own. * [Icons] Keep the authored emphasis in filled treatments The saturation floor was added while the emphasis levels were still computed: it rescued the pale dark-scheme tones once a whole shape carried them. With the levels authored, the floor overrides the author instead: a quiet level jumped to the secondary saturation as soon as a sticker or a badge painted it, so the ladder only existed for line glyphs. The authored tone is now the top of the fill gradient and the treatment only darkens the bottom for depth, so every treatment renders exactly the level the palette stores. The badge glyph ink no longer guesses the scheme from the tone's value: an authored quiet level is a dark color on the dark scheme too, so the scheme is passed in from the surface palette. The preview sheet renders every shipped palette theme, three glyphs per family, and each row prints the authored tone and the darker bottom. * [Icons] Order the emphasis ladder by chroma, not contrast Contrast cannot order the ladder. On a dark surface the highest-contrast tone is the palest one, so a 7:1 primary came out near-white while the secondary kept the family's color; the ladder read inverted, and for the shipped semantic greens and reds the primary level was already satisfied by the secondary, so the two levels resolved to the same hex. Emphasis is chromatic intensity now. Quiet recedes toward the surface at the 3:1 non-text floor, secondary keeps the family color at the 4.5:1 text floor, and primary is the most chromatic tone the same floor allows. Every level stays legible on the window and the button surface, and the primary is a strictly more saturated member of its family on both schemes. The reformatted palettes move the two upper levels onto the shared floor and leave the quiet level alone. * [Icons] Make the emphasis ladder structural on every theme The chroma ladder landed with four P1s from review: reachContrast picked its pole by background lightness instead of by what each pole can reach, the generated neutral ink was solved on the window surface only, Fusion's neutral family was still the green accent, and quiet sat below the text floor while disabled reused the palette gray. Pick the pole by achievable contrast, solve fallback and preview inks on the harder of the window and button surfaces, re-author Fusion's neutral trio in slate blue, and keep quiet on the shared text floor by muting its saturation to 45% instead of blending it into the surface. Disabled is now derived from the quiet ink, so it is structurally dimmer than every enabled level on every theme. Re-author all soft roles at a 4.60 target with a rounding margin, rebuild the per-theme preview generator the rebase dropped, and add a Disabled row to every treatment sheet. * [Icons] Render preview glyphs at the sheet's device pixel ratio The preview sheets paint on a 2x painter but rasterized each glyph at its logical size, so every icon was upscaled on the way into the file and the sheets read as blurry. Render each glyph at device size and tag the pixmap with the ratio, so one source pixel lands on one file pixel. * [Icons] Keep the System semantic ladder on the text floor System authors no palette of its own, so its semantic family is fixed data measured against whatever surfaces the platform provides. The ladder that landed with the authored roles reused the old contrast-ordered values: PositiveSoft and NegativeSoft sat at 2.76–3.90:1 on common light and dark platform surfaces, and the strong level was less saturated than the secondary, which is the inversion the chroma rule removes. Re-author the six default semantic colors per scheme at the same 4.60 target the shipped themes use, against representative platform surfaces, and mirror them in System's palette files. The new DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces test holds the floor and the saturation order there, and shippedPalette's early return now says why System is outside the authored-palette assertions. * [Icons] Apply the CI clang-format output to the new tests * [Icons] Adapt the treatment preview to the authored roles The preview file is inherited from the treatment PR. Here the palette already authors the fill tone and the badge takes an explicit scheme, so the generator stops re-saturating the role and passes the scheme the sheet renders. * [Icons] Author neutral System icon ink The System theme derived icon ink from the platform palette, which rendered blue under the default palette. Author Icon/IconStrong/IconSoft explicitly: a black ladder in light mode, a white ladder in dark mode. Pin authorship, achromaticity and ordering in the render test. * Address review findings --- .../palette_editor/palette_generator.cpp | 16 +- .../palette_editor/palette_grid_widget.cpp | 9 + cockatrice/src/interface/theme_config.cpp | 22 +- cockatrice/src/interface/theme_config.h | 26 +- cockatrice/src/interface/theme_icon.cpp | 108 +- cockatrice/src/interface/theme_icon.h | 53 +- cockatrice/src/interface/theme_icon_badge.cpp | 20 +- cockatrice/src/interface/theme_icon_badge.h | 17 +- cockatrice/src/interface/theme_icon_fill.cpp | 49 +- cockatrice/src/interface/theme_icon_fill.h | 45 +- cockatrice/src/interface/theme_icon_ink.cpp | 222 +++- cockatrice/src/interface/theme_icon_ink.h | 73 +- .../src/interface/theme_icon_sticker.cpp | 2 +- cockatrice/src/interface/theme_manager.cpp | 30 + .../themes/Fabric/palette-default-dark.toml | 12 +- .../themes/Fabric/palette-default-light.toml | 12 +- .../themes/Fusion/palette-default-dark.toml | 15 +- .../themes/Fusion/palette-default-light.toml | 15 +- .../themes/Leather/palette-default-dark.toml | 12 +- .../themes/Leather/palette-default-light.toml | 12 +- .../themes/Plasma/palette-default-dark.toml | 12 +- .../themes/Plasma/palette-default-light.toml | 12 +- .../themes/System/palette-default-dark.toml | 17 +- .../themes/System/palette-default-light.toml | 17 +- .../VelvetMarble/palette-default-dark.toml | 12 +- .../VelvetMarble/palette-default-light.toml | 12 +- .../icon_treatment_preview_test.cpp | 11 +- .../theme_icon_render_test.cpp | 953 ++++++++++++++---- .../theme_migration/theme_migration_test.cpp | 49 +- 29 files changed, 1448 insertions(+), 417 deletions(-) diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index 6bcfdc713..d5d70c4ef 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -1,6 +1,7 @@ #include "palette_generator.h" #include "../theme_config.h" +#include "../theme_icon_ink.h" #include #include @@ -167,10 +168,21 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch // the lightened, desaturated companion used for button-gradient highlights. cfg.appColors[AppColor::AccentStrong] = hl; cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); + // Neutral icon ink follows the generated accent at the same three emphasis + // levels a hand-authored theme stores, so the preview matches what the icon + // engine renders before the palette is saved. The harder of the two surfaces + // is the one the floor has to be solved against. + const QColor iconSurface = ThemeIconInk::harderSurface(bg_win, bg_btn, winText); + const QColor iconInk = ThemeIconInk::glyphInk(winText, iconSurface, hl); + cfg.appColors[AppColor::Icon] = iconInk; + cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary); + cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Quiet); // Semantic roles stay independent of the accent: a generated palette must // still paint add/ready green and remove/not-ready red. - cfg.appColors[AppColor::Positive] = defaultAppColor(AppColor::Positive, dark); - cfg.appColors[AppColor::Negative] = defaultAppColor(AppColor::Negative, dark); + for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, + AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + cfg.appColors[role] = defaultAppColor(role, dark); + } // BrightText QColor bright; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 3fec389da..73e821b90 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -65,10 +65,19 @@ static const QMap APP_ROLE_DESCRIPTIONS = { QT_TRANSLATE_NOOP("PaletteGridWidget", "Vivid primary accent (e.g. home-tab button gradient start)")}, {AppColor::AccentSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Lightened, desaturated accent (e.g. home-tab button gradient end)")}, + {AppColor::Icon, QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink at the default action weight")}, + {AppColor::IconStrong, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for the one action a surface exists for")}, + {AppColor::IconSoft, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for a glyph that labels rather than acts")}, {AppColor::Positive, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")}, + {AppColor::PositiveStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at primary emphasis")}, + {AppColor::PositiveSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at quiet emphasis")}, {AppColor::Negative, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")}, + {AppColor::NegativeStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at primary emphasis")}, + {AppColor::NegativeSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at quiet emphasis")}, }; PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 42d7a4691..848be2231 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -458,12 +458,30 @@ QColor defaultAppColor(AppColor::Role role, bool dark) switch (role) { case AppColor::AccentStrong: case AppColor::AccentSoft: - // Derived from the palette highlight by ThemeManager::appColor(). + // The neutral icon family is palette-derived like the accent: a theme + // that leaves it unauthored (System, and every theme written before the + // roles existed) keeps the live platform palette's WindowText instead + // of pinning one scheme's ink. + case AppColor::Icon: + case AppColor::IconStrong: + case AppColor::IconSoft: return {}; + // The semantic family follows the same chroma ladder as the authored + // themes. A theme that authors none of it has no per-surface palette to + // measure against, so these are authored against the representative + // platform surfaces the System theme runs on. case AppColor::Positive: - return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1b7f3b")); + return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1a7d3a")); + case AppColor::PositiveStrong: + return QColor(dark ? QStringLiteral("#32c05c") : QStringLiteral("#007e28")); + case AppColor::PositiveSoft: + return QColor(dark ? QStringLiteral("#8ab196") : QStringLiteral("#4c7659")); case AppColor::Negative: return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e")); + case AppColor::NegativeStrong: + return QColor(dark ? QStringLiteral("#ff8378") : QStringLiteral("#da0b00")); + case AppColor::NegativeSoft: + return QColor(dark ? QStringLiteral("#c79e9b") : QStringLiteral("#935f5c")); } return {}; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 7d05e4bf0..68734e762 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -13,6 +13,13 @@ // set. Stored in the same palette-.toml under an [AppColors] section // and editable from the palette editor, so theme authors can control colors // beyond what Qt's palette can express. +// +// The icon families carry one role per emphasis level. Icon is the neutral +// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive +// and Negative follow the same pattern, so an emphasis never has to be computed +// from a hue at paint time: the theme authors all three levels. Naming a variant +// explicitly (`IconStrong`) pins that level; naming the family (`Icon`) lets the +// caller's emphasis choose. namespace AppColor { Q_NAMESPACE @@ -20,8 +27,15 @@ enum Role { AccentStrong, AccentSoft, + Icon, + IconStrong, + IconSoft, Positive, + PositiveStrong, + PositiveSoft, Negative, + NegativeStrong, + NegativeSoft, }; Q_ENUM_NS(Role) } // namespace AppColor @@ -47,9 +61,15 @@ Q_ENUM_NS(Role) * @brief Fallback for an application color role when the active theme does not * define one. * - * Accent roles return an invalid QColor because ThemeManager derives them from - * the palette highlight. Semantic roles return an opaque default that suits the - * scheme, so themes written before the role existed still render it. + * Accent roles and the neutral Icon family return an invalid QColor because + * ThemeManager derives them from the palette: the accent from the highlight, + * the icon ink from the live WindowText held to the label floor. Semantic roles + * return an opaque default that suits the scheme, so themes written before the + * role existed still render it. The System theme deliberately leaves the + * neutral family unauthored: the palette file it picks cannot know the platform + * scheme, so a fixed ladder would be invisible on the other one. It derives + * neutral ink from the live platform palette instead of pinning one scheme's + * colors. */ QColor defaultAppColor(AppColor::Role role, bool dark); diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index 3557a9441..f0c9ef84f 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -2,7 +2,6 @@ #include "libcockatrice/utility/svg_recolor.h" #include "theme_icon_badge.h" -#include "theme_icon_fill.h" #include "theme_icon_ink.h" #include "theme_icon_sticker.h" #include "theme_manager.h" @@ -31,6 +30,74 @@ namespace { +/** + * @brief The role an emphasis level selects inside a base role's family. + * + * The theme authors all three levels, so emphasis is a lookup instead of a + * color computation. Naming a variant (`IconStrong`, `PositiveSoft`) is an + * explicit request and keeps that role at every emphasis; only the base roles + * take part in the mapping. Accent roles have no neutral middle member, so they + * are always used as named. + */ +AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis) +{ + switch (role) { + case AppColor::Icon: + return emphasis == IconEmphasis::Primary ? AppColor::IconStrong + : emphasis == IconEmphasis::Quiet ? AppColor::IconSoft + : AppColor::Icon; + case AppColor::Positive: + return emphasis == IconEmphasis::Primary ? AppColor::PositiveStrong + : emphasis == IconEmphasis::Quiet ? AppColor::PositiveSoft + : AppColor::Positive; + case AppColor::Negative: + return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong + : emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft + : AppColor::Negative; + default: + return role; + } +} + +/** + * @brief The scheme the active application palette is painting for. + * + * The fill needs the surface scheme to choose the badge glyph ink, and it cannot + * read it from the tone: an authored quiet level is a dark color on the dark + * scheme too. The theme's resolved scheme is the same source the palette file + * and the default app colors are chosen from, so the fill follows it instead of + * re-deriving the scheme from the live Window lightness, which can disagree + * when a theme pins a scheme the platform palette does not match. + */ +ThemeIconFill::Scheme activeScheme() +{ + if (themeManager != nullptr) { + return themeManager->isDarkModeActive() ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; + } + + // No manager (test binaries link the icon engine without the app): fall + // back to the live palette, which is the only scheme signal left. + const QPalette &palette = qApp->palette(); + return palette.color(QPalette::Active, QPalette::Window).lightness() < 128 ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light; +} + +/** + * @brief The ink a disabled glyph is painted with. + * + * A disabled icon used to be the palette's disabled text, a fixed gray whose + * contrast swings with the surface: on the darkest themes it out-reads the quiet + * level, so an enabled quiet glyph looks more disabled than a disabled one. + * Deriving it from the theme's own quiet ink keeps the order structural. + */ +QColor disabledGlyphInk(const QPalette &palette) +{ + const QColor quiet = themeManager->appColor(AppColor::IconSoft); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), quiet); + return ThemeIconInk::disabledInk(quiet, surface); +} + /** * @brief Reads an SVG through the `theme:` search path. */ @@ -265,7 +332,7 @@ private: QByteArray variant; if (badge) { variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' + - ThemeIconBadge::glyphColor(tone).name(QColor::HexRgb).toLatin1(); + ThemeIconBadge::glyphColor(tone, activeScheme()).name(QColor::HexRgb).toLatin1(); } else if (sticker) { variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1(); } else if (resolvedHasSentinel) { @@ -283,7 +350,7 @@ private: if (badge) { // Geometry is computed from the device-pixel size, and the ratio is // attached below like every other path. - pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize); + pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize, activeScheme()); } else if (sticker) { pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize); } else if (resolvedHasSentinel) { @@ -318,6 +385,7 @@ private: mutable bool hasResolvedAsset = false; mutable bool hasIntrinsicSize = false; }; + } // namespace IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis) @@ -342,21 +410,18 @@ IconTint IconTint::quiet() QColor IconTint::resolve(QIcon::Mode mode) const { const QPalette &palette = qApp->palette(); - const QColor surface = palette.color(QPalette::Active, QPalette::Window); const QColor accent = palette.color(QPalette::Active, QPalette::Highlight); if (source == Source::AppColor) { // A disabled control must not keep signalling its semantic color. if (mode == QIcon::Disabled) { - return palette.color(QPalette::Disabled, QPalette::WindowText); + return disabledGlyphInk(palette); } - // The three treatments paint the same tone: a line glyph, a badge disc - // and a sticker fill must not disagree on how saturated the role is. - // The fill treatments apply this floor themselves, so this only changes - // the line path, and only for the desaturated dark-scheme tones. The - // emphasis then decides how loud that tone is, without changing its hue. - const QColor color = - ThemeIconInk::forEmphasis(ThemeIconFill::saturated(themeManager->appColor(appRole)), surface, emphasis); + // The theme authors one color per emphasis level, so the paint path + // uses the resolved role verbatim. The filled treatments only shape the + // tone's value to give a disc or a fill depth; hue and saturation stay + // the author's. + const QColor color = themeManager->appColor(emphasisRole(appRole, emphasis)); if (mode != QIcon::Selected) { return color; } @@ -372,17 +437,14 @@ QColor IconTint::resolve(QIcon::Mode mode) const const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; const QPalette::ColorRole effective = mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole; - const QColor color = palette.color(group, effective); - // A neutral label role is inked as an icon, not as body text: it borrows a - // little accent chroma and is held to a contrast floor. Semantic roles and - // the selected-row swap keep the color the palette gave them. - if (group == QPalette::Active && (effective == QPalette::WindowText || effective == QPalette::Text)) { - // The primary action of a surface takes the accent itself; the other - // levels stay neutral. Emphasis then sets the contrast the glyph pays. - const QColor base = emphasis == IconEmphasis::Primary ? accent : ThemeIconInk::glyphInk(color, surface, accent); - return ThemeIconInk::forEmphasis(base, surface, emphasis); + // A neutral label role resolves to the theme's authored icon ink. A disabled + // one uses the dimmed quiet ink so it always sits below the quiet level; + // semantic roles keep the color the palette gave them. + if (effective == QPalette::WindowText || effective == QPalette::Text) { + return group == QPalette::Disabled ? disabledGlyphInk(palette) + : themeManager->appColor(emphasisRole(AppColor::Icon, emphasis)); } - return color; + return palette.color(group, effective); } QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style) @@ -407,7 +469,7 @@ QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const if (canvas.isEmpty()) { canvas = QSvgRenderer(svg).defaultSize(); } - return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas) + return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas, activeScheme()) : ThemeIconSticker::renderSticker(svg, tone, canvas); } diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h index 231221c93..82500292a 100644 --- a/cockatrice/src/interface/theme_icon.h +++ b/cockatrice/src/interface/theme_icon.h @@ -47,18 +47,20 @@ enum class IconStyle }; /** - * @brief Selects how much attention a glyph claims inside its surface. + * @brief Selects which authored level of a color family a glyph uses. * - * Tint says which hue, style says how much shape, emphasis says how loud. The - * three are independent: a primary action can be a line glyph in the theme - * accent, and a quiet one can be a sticker in a dimmed tone. + * Tint says which family, style says how much shape, emphasis says how loud. + * The three are independent: a primary action can be a line glyph in the + * theme's primary ink, and a quiet one can be a sticker in that family's quiet + * level. * - * Secondary is the default and the color every icon had before the axis - * existed, so existing call sites keep their look. Primary is for the one - * action a surface exists for: a neutral primary glyph takes the accent, and a - * tinted one is pushed to full contrast. Quiet is for glyphs that label rather - * than act, such as the magnifier inside a search field: legible, but never - * competing with the buttons next to it. + * Each family (neutral icon, positive, negative) stores one color per level, so + * emphasis is a lookup rather than a computation: the palette editor's rows are + * the whole source of truth. Secondary is the default and the color every icon + * had before the axis existed, so existing call sites keep their look. Primary + * is for the one action a surface exists for. Quiet is for glyphs that label + * rather than act, such as the magnifier inside a search field: legible, but + * never competing with the buttons next to it. */ enum class IconEmphasis { @@ -82,7 +84,7 @@ public: IconTint(QPalette::ColorRole role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for role callers IconTint(AppColor::Role role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for semantic callers - /** @brief A neutral glyph in the accent tone, for the surface's primary action. */ + /** @brief A neutral glyph at the family's primary level, for the surface's primary action. */ static IconTint primary(); /** @brief A dimmed neutral glyph, for a control that labels rather than acts. */ @@ -91,21 +93,26 @@ public: /** * @brief Resolves the concrete color for an icon mode. * - * A disabled application color resolves to the disabled foreground, so an - * unavailable control does not keep its semantic color. A selected palette - * color keeps the WindowText -> HighlightedText swap. Application colors go - * through the saturation floor shared by the filled treatments, so a line - * glyph, a badge disc and a sticker fill carry the same tone. + * A disabled application color resolves to the derived disabled ink, the + * quiet level receded toward the surface, so an unavailable control does not + * keep its semantic color. A selected palette color keeps the + * WindowText -> HighlightedText swap. + * + * Application colors are returned verbatim: the theme author stores one + * value per emphasis level, so the paint path does not rewrite it. The + * filled treatments only shape that tone's value to give a shape depth; the + * hue and saturation stay the author's. * * Neutral label roles (WindowText, Text) resolve to icon ink rather than the - * raw palette color: ThemeIconInk::glyphInk borrows a little accent chroma - * and holds the glyph to a contrast floor, because a 2px glyph in body-text - * ink reads thinner than the text beside it. Semantic roles keep the hue the - * call site asked for. + * raw palette color: an unauthored neutral family derives it from the live + * WindowText and holds the glyph to a contrast floor, because a 2px glyph in + * body-text ink reads thinner than the text beside it. A theme that authors + * the neutral icon roles overrides that derivation. Semantic roles keep the + * hue the call site asked for. * - * Emphasis is applied after the hue: Primary lifts a neutral glyph to the - * accent and pushes a tinted one to full contrast; Quiet blends either - * toward the surface down to the non-text legibility floor. + * Emphasis selects the family's authored variant, so the palette editor's + * rows are the whole source of truth. A caller that names a variant + * explicitly (a strong or soft role) pins that level at every emphasis. */ QColor resolve(QIcon::Mode mode) const; diff --git a/cockatrice/src/interface/theme_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp index 086d2be43..d5b07b3c6 100644 --- a/cockatrice/src/interface/theme_icon_badge.cpp +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -46,16 +46,16 @@ DiscFill discFill(const QColor &tone) namespace ThemeIconBadge { -QColor glyphColor(const QColor &tone) +QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme) { // The badge carries the ink the pre-sticker badge did: white on the light // scheme, and the tone darkened to an outline-dark ink on the dark scheme. - // The shared saturated disc is light enough there that white sits near - // 2.7:1 at the top, so the dark ink is the deliberate choice rather than a - // contrast measurement that a palette tweak could flip. Deriving it from - // the tone keeps themes in control. - return ThemeIconFill::isDarkSchemeTone(tone) ? ThemeIconFill::saturated(tone).darker(GLYPH_DARKER) - : QColor(Qt::white); + // The lifted disc is light enough there that white sits near 2.7:1 at the + // top, so the dark ink is the deliberate choice rather than a contrast + // measurement that a palette tweak could flip. Deriving it from the tone + // keeps themes in control, and the scheme is explicit because an authored + // quiet level is a dark tone on the dark scheme too. + return scheme == ThemeIconFill::Scheme::Dark ? tone.darker(GLYPH_DARKER) : QColor(Qt::white); } QSize glyphSize(const QSize &pixelSize) @@ -70,7 +70,7 @@ QSize glyphSize(const QSize &pixelSize) return QSize(glyphSide, glyphSide); } -QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme) { if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) { return {}; @@ -100,12 +100,12 @@ QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixe // The ring closes the disc: its outer edge touches the canvas, its inner // edge is the disc boundary, so the two meet without a seam. painter.setBrush(Qt::NoBrush); - painter.setPen(QPen(ThemeIconFill::saturated(tone).darker(RING_DARKER), ringWidth)); + painter.setPen(QPen(tone.darker(RING_DARKER), ringWidth)); const qreal ringRadius = (side - ringWidth) / 2.0; painter.drawEllipse(discRect.center(), ringRadius, ringRadius); const QByteArray glyphSvg = resolveSvgStrokeWidth( - resolveSvgColorSentinel(svg, glyphColor(tone).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH); + resolveSvgColorSentinel(svg, glyphColor(tone, scheme).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH); QSvgRenderer renderer(glyphSvg); if (renderer.isValid()) { const QSize size = glyphSize(pixelSize); diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h index 412082377..f1c392d02 100644 --- a/cockatrice/src/interface/theme_icon_badge.h +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -6,6 +6,8 @@ #ifndef THEME_ICON_BADGE_H #define THEME_ICON_BADGE_H +#include "theme_icon_fill.h" + #include #include #include @@ -34,13 +36,17 @@ namespace ThemeIconBadge /** * @brief Glyph color for a badge disc. * - * White on the light scheme, the saturated tone darkened to an outline-dark ink + * White on the light scheme, the authored tone darkened to an outline-dark ink * on the dark scheme. The dark-scheme tones fill the disc light enough that * white would sit near 2.7:1 at its top, so that scheme keeps the darkened ink - * the badge had before the filled treatments shared a fill. The dark ink is - * derived from the tone, so the theme keeps control of both ends. + * the badge had before the filled treatments shared a fill. The scheme is passed + * in rather than inferred from the tone, because an authored quiet level is a + * dark color on the dark scheme as well. + * + * @param tone Disc color, already resolved for the icon mode. + * @param scheme Scheme the badge is painted for. */ -QColor glyphColor(const QColor &tone); +QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme); /** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */ QSize glyphSize(const QSize &pixelSize); @@ -63,9 +69,10 @@ QSize glyphSize(const QSize &pixelSize); * @param tone Disc color, already resolved for the icon mode. * @param pixelSize Canvas size in device pixels; a non-square canvas centers * the badge and leaves the remaining area transparent. + * @param scheme Scheme the badge is painted for; selects the glyph ink. * @return The composited badge, or a null pixmap for empty or invalid input. */ -QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize); +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme); } // namespace ThemeIconBadge #endif // THEME_ICON_BADGE_H diff --git a/cockatrice/src/interface/theme_icon_fill.cpp b/cockatrice/src/interface/theme_icon_fill.cpp index 7615fb4fa..79690acea 100644 --- a/cockatrice/src/interface/theme_icon_fill.cpp +++ b/cockatrice/src/interface/theme_icon_fill.cpp @@ -1,61 +1,22 @@ #include "theme_icon_fill.h" -#include - namespace { -// Semantic tones are tuned for text. The dark-scheme ones are deliberately -// desaturated, which reads washed out when the same color fills a whole shape, -// so the fill gives its tone a saturation floor. -constexpr int MIN_SATURATION = 170; -// A tone this light belongs to the dark scheme; filling a glyph with it at full -// value is what looks pale, so its gradient is pulled down instead of up. -constexpr int LIGHT_TONE_VALUE = 200; -constexpr int MIN_VALUE = 200; +// The bottom end of every filled treatment. The authored tone is the top, so a +// single darkening keeps the emphasis level the palette author picked and still +// gives the shape the lit-from-above depth the pre-#7595 art had. constexpr int GRADIENT_BOTTOM_DARKER = 140; -constexpr int DARK_SCHEME_TOP_DARKER = 120; -constexpr int DARK_SCHEME_BOTTOM_DARKER = 170; -} // namespace - -namespace -{ -/** @brief Raises a tone's saturation, and optionally its value, for a filled shape. */ -QColor boost(const QColor &color, int minValue) -{ - if (color.hue() < 0 || (color.hsvSaturation() >= MIN_SATURATION && color.value() >= minValue)) { - return color; - } - QColor result = color; - result.setHsv(color.hue(), qMax(color.hsvSaturation(), MIN_SATURATION), qMax(color.value(), minValue)); - // Return RGB so callers comparing or caching the tone do not see a spec - // change for an unchanged color. - return result.toRgb(); -} } // namespace namespace ThemeIconFill { -QColor saturated(const QColor &tone) -{ - return boost(tone, 0); -} - -bool isDarkSchemeTone(const QColor &tone) -{ - return tone.value() >= LIGHT_TONE_VALUE; -} - QColor topColor(const QColor &tone) { - if (isDarkSchemeTone(tone)) { - return boost(tone, 0).darker(DARK_SCHEME_TOP_DARKER); - } - return boost(tone, MIN_VALUE); + return tone; } QColor bottomColor(const QColor &tone) { - const int darker = isDarkSchemeTone(tone) ? DARK_SCHEME_BOTTOM_DARKER : GRADIENT_BOTTOM_DARKER; - return boost(tone, 0).darker(darker); + return tone.darker(GRADIENT_BOTTOM_DARKER); } } // namespace ThemeIconFill diff --git a/cockatrice/src/interface/theme_icon_fill.h b/cockatrice/src/interface/theme_icon_fill.h index 4efcc136e..c0e8ee6eb 100644 --- a/cockatrice/src/interface/theme_icon_fill.h +++ b/cockatrice/src/interface/theme_icon_fill.h @@ -12,50 +12,41 @@ * @brief Picks the two colors of a filled treatment's vertical gradient. * * The pre-#7595 icons were filled with a lighter top over a darker bottom. The - * semantic tones are tuned for text, not for filling a whole shape; the - * dark-scheme tones in particular are desaturated and read washed out once a - * disc or a glyph carries them. The badge and the sticker share one fill rule so - * they sit next to each other with the same green or red. + * badge and the sticker share one fill rule so they sit next to each other with + * the same green or red. * - * The rule needs no scheme switch: a tone whose value is already high is a - * dark-scheme tone, so its gradient is pulled down. Filling a shape with a tone - * that light is what reads pale. + * The authored tone is the top of that gradient: the palette author stores one + * color per emphasis level, so the treatment only adds depth by darkening the + * bottom. Hue, saturation and value all stay the author's, so the rendered + * ladder is the authored ladder on every scheme. */ namespace ThemeIconFill { /** - * @brief Returns the tone with a saturation floor, for a filled shape. + * @brief Which scheme a treatment is painted for. * - * The hue and value are left alone, so a light-scheme tone stays as dark as the - * line glyph next to it, and an achromatic tone stays gray. - * - * @param tone Glyph color the treatment is rendered with. - * @return The tone, saturated enough to carry a whole shape. + * The scheme is a property of the surface, not of the tone: an authored quiet + * level is deliberately darker than its secondary level on the dark scheme too, + * so it cannot be inferred from the tone's value. Callers know the surface + * palette and pass it explicitly. */ -QColor saturated(const QColor &tone); - -/** - * @brief True when @p tone is a light tone, as the dark scheme's palette is. - * - * The fill branches on this: a tone this light is darkened for a filled shape, - * and the badge keeps its darkened ink on it instead of the light scheme's - * white glyph. - * - * @param tone Glyph color the treatment is rendered with. - */ -bool isDarkSchemeTone(const QColor &tone); +enum class Scheme +{ + Light, + Dark, +}; /** * @brief Returns the top color of the fill gradient. * @param tone Glyph color the treatment is rendered with. - * @return The lighter end of the gradient. + * @return The authored tone, unchanged. */ QColor topColor(const QColor &tone); /** * @brief Returns the bottom color of the fill gradient. * @param tone Glyph color the treatment is rendered with. - * @return The darker end of the gradient. + * @return The authored tone, darkened for depth. */ QColor bottomColor(const QColor &tone); } // namespace ThemeIconFill diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp index 1b0748091..7ed458f70 100644 --- a/cockatrice/src/interface/theme_icon_ink.cpp +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -12,20 +12,40 @@ namespace // accent fill. Every shipped theme accents in blue or violet, so the borrowed hue // stays neutral chrome instead of implying a state. constexpr double ACCENT_MIX = 0.70; +// The Positive family owns green and the Negative family owns red. An accent in +// either band is a semantic color the theme did not mean as chrome, so the +// neutral glyph keeps the accent's intensity but not its hue. +constexpr int SEMANTIC_GREEN_START = 80; +constexpr int SEMANTIC_GREEN_END = 165; +constexpr int SEMANTIC_RED_START = 340; +constexpr int SEMANTIC_RED_END = 25; +// Slate blue: the chrome hue every shipped theme already leans toward, and the +// least likely to be mistaken for a state. +constexpr int NEUTRAL_CHROME_HUE = 210; // An icon that labels a control is carrying text; see ui-assets.md. constexpr double LABEL_CONTRAST_FLOOR = 4.5; -// A primary glyph has to out-read the default one on both schemes. The accent is -// a mid-tone: on a light surface it is *lighter* than the neutral ink, and on a -// dark surface it is *darker*, so neither "the accent as-is" nor "a contrast -// floor shared with the default" keeps the primary above the secondary. A 7:1 -// target does: it darkens the accent on light schemes and lightens it on dark -// ones, so the primary level always out-contrasts the default level. -constexpr double PRIMARY_CONTRAST = 7.0; -// A quiet glyph is blended this far into its surface. It has to stay findable -// without competing with the actions beside it. -constexpr double QUIET_BLEND = 0.55; -// WCAG 2.2 SC 1.4.11, the floor for a non-text element that carries meaning. -constexpr double QUIET_CONTRAST_FLOOR = 3.0; +// A primary glyph is the most chromatic member of its family. Contrast cannot +// order the ladder: on a dark surface the highest-contrast tone is the palest +// one, so a contrast target makes the primary look like a washed-out secondary +// and the ladder reads inverted. Primary instead raises the saturation by this +// many HSV points over the secondary and then solves the value back to the text +// floor, so it is louder in color at the same legibility. +constexpr int PRIMARY_SATURATION_STEP = 64; +// Quiet recedes by carrying less of the family's chroma, not by dropping closer +// to the surface: the disabled level owns the low-contrast end. It keeps this +// share of the secondary level's saturation. +constexpr int QUIET_SATURATION_PERCENT = 45; +// The quiet level shares the text floor with the other two levels. +constexpr double QUIET_CONTRAST_FLOOR = LABEL_CONTRAST_FLOOR; +// A disabled glyph recedes past the quiet level, so it is blended this much +// further into the surface... +constexpr double DISABLED_BLEND = 0.55; +// ...and then held to a floor below the quiet one so it stays findable. +constexpr double DISABLED_CONTRAST_FLOOR = 2.5; +// 8-bit quantization can drop a solved color just under the ratio it was solved +// for, so every search aims this much above the floor. Without it a palette +// value generated at exactly 4.5:1 measures 4.45:1 after the round trip. +constexpr double CONTRAST_ROUNDING_MARGIN = 0.10; // Binary-search depth for reachContrast(). 24 halvings resolve to well under // one 8-bit channel step, so the result is stable across platforms. constexpr int CONTRAST_SEARCH_STEPS = 24; @@ -59,18 +79,18 @@ QColor blend(const QColor &color, const QColor &target, double amount) QColor reachContrast(const QColor &color, const QColor &background, double minRatio) { - if (contrastRatio(color, background) >= minRatio) { + const double target = minRatio + CONTRAST_ROUNDING_MARGIN; + if (contrastRatio(color, background) >= target) { return color; } - // Move away from the background: toward whichever pole actually reaches - // further. Black and white cross at a luminance of about 0.18, not 0.5, so - // the midpoint test would pick white on a surface where black is far more - // legible -- and could drag an ink that already cleared the floor to white. - // The hue is kept by interpolating, not by replacing. - const QColor black(Qt::black); - const QColor white(Qt::white); - const QColor pole = contrastRatio(black, background) >= contrastRatio(white, background) ? black : white; - if (contrastRatio(pole, background) < minRatio) { + // Move toward whichever pole the background leaves room for. A medium-light + // surface is lighter than mid-gray but still too light to reach a text floor + // with white, so "light background means move toward black" is the wrong + // test: compare what each pole can actually reach. + const double blackRatio = contrastRatio(QColor(Qt::black), background); + const double whiteRatio = contrastRatio(QColor(Qt::white), background); + const QColor pole = blackRatio >= whiteRatio ? QColor(Qt::black) : QColor(Qt::white); + if (qMax(blackRatio, whiteRatio) < target) { return pole; } @@ -90,9 +110,39 @@ QColor reachContrast(const QColor &color, const QColor &background, double minRa return blend(color, pole, high); } +bool isSemanticHue(int hue) +{ + if (hue < 0) { + return false; + } + return (hue >= SEMANTIC_GREEN_START && hue <= SEMANTIC_GREEN_END) || hue >= SEMANTIC_RED_START || + hue <= SEMANTIC_RED_END; +} + +int neutralChromeHue() +{ + return NEUTRAL_CHROME_HUE; +} + +QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink) +{ + const double inkLuminance = relativeLuminance(ink); + const auto distance = [inkLuminance](const QColor &surface) { + return std::abs(relativeLuminance(surface) - inkLuminance); + }; + return distance(first) <= distance(second) ? first : second; +} + QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix) { - return reachContrast(blend(text, accent, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb(); + // A green or red accent is a Positive or Negative color, so borrowing its + // hue would paint neutral chrome the same color as a semantic state. Keep + // the accent's intensity, replace its hue with the chrome hue. + QColor source = accent; + if (isSemanticHue(accent.hue())) { + source = QColor::fromHsv(NEUTRAL_CHROME_HUE, accent.hsvSaturation(), qMax(accent.value(), 128)); + } + return reachContrast(blend(text, source, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb(); } double defaultAccentMix() @@ -105,27 +155,141 @@ double labelContrastFloor() return LABEL_CONTRAST_FLOOR; } +namespace +{ +/** @brief Whether black reaches a higher contrast against @p surface than white. */ +bool darkerPoleWins(const QColor &surface) +{ + return ThemeIconInk::contrastRatio(QColor(Qt::black), surface) >= + ThemeIconInk::contrastRatio(QColor(Qt::white), surface); +} + +/** @brief The luminance a color needs to reach @p ratio against @p surface. */ +double targetLuminance(const QColor &surface, double ratio) +{ + const double surfaceLuminance = ThemeIconInk::relativeLuminance(surface); + return darkerPoleWins(surface) ? (surfaceLuminance + 0.05) / ratio - 0.05 + : (surfaceLuminance + 0.05) * ratio - 0.05; +} + +/** + * @brief The value that puts @p hue at @p saturation on @p surface's floor. + * + * Luminance is monotone in HSV value at a fixed hue and saturation, so a + * bisection finds the boundary: the closest tone to the surface that still + * clears the floor, on either scheme. The direction depends on the surface: + * toward black (a light surface) the floor is a luminance ceiling, so the + * search keeps the highest passing value; toward white (a dark surface) it is a + * floor, so the search keeps the lowest passing value. Returns an invalid color + * when the tone cannot reach the floor at that saturation at all. + */ +QColor solveValue(int hue, int saturation, const QColor &surface, double floor) +{ + const bool towardBlack = darkerPoleWins(surface); + const double target = targetLuminance(surface, floor + CONTRAST_ROUNDING_MARGIN); + // Toward black a tone is legible at or below the target luminance, toward + // white at or above it. Testing the wrong side is what let a saturated + // violet at full value look "too dark" on a light surface and fall back to + // the unchanged secondary. + const auto passes = [hue, saturation, target, towardBlack](int value) { + const double luminance = ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, value)); + return towardBlack ? luminance <= target : luminance >= target; + }; + + if (passes(255)) { + // Toward black, full value is the palest tone: if even that clears the + // floor, the whole ramp does and the surface wants the authored tone. + // Toward white it is the palest tone but also the most legible one, so + // the search still starts from the failing end. + if (towardBlack) { + return QColor::fromHsv(hue, saturation, 255).toRgb(); + } + } else if (!towardBlack) { + // Toward white the floor needs the palest tone; no darker value can + // reach it at this saturation. + return {}; + } + + int passing = towardBlack ? 0 : 255; + int failing = towardBlack ? 255 : 0; + for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) { + const int middle = (passing + failing) / 2; + if (passes(middle)) { + passing = middle; + } else { + failing = middle; + } + } + return QColor::fromHsv(hue, saturation, passing).toRgb(); +} + +/** + * @brief Raises a color's saturation, then solves its value for the floor. + * + * Saturation is raised before the value is solved, because reaching a contrast + * floor by adding white is what desaturates a tone in the first place. If the + * hue cannot reach the floor at the raised saturation inside the gamut, the step + * shrinks until it can, so an already-vivid tone keeps what it has. A gray has + * no saturation to raise and falls back to the contrast search. + */ +QColor chromaticStep(const QColor &color, const QColor &surface, double floor, int stepPoints) +{ + if (color.hue() < 0) { + return ThemeIconInk::reachContrast(color, surface, floor); + } + + const int hue = color.hue(); + const int secondary = color.hsvSaturation(); + for (int saturation = qMin(255, secondary + stepPoints); saturation > secondary; --saturation) { + const QColor solved = solveValue(hue, saturation, surface, floor); + if (solved.isValid()) { + return solved; + } + } + return ThemeIconInk::reachContrast(color, surface, floor); +} + +/** + * @brief Mutes a color's saturation, then solves its value for the floor. + * + * The mirror of chromaticStep: quiet stays on the same floor as the other + * levels and recedes by carrying less of the family's chroma, so it cannot drop + * below the disabled level's contrast by construction. A gray has no saturation + * to mute and falls back to the contrast search. + */ +QColor mutedStep(const QColor &color, const QColor &surface, double floor, int percent) +{ + if (color.hue() < 0) { + return ThemeIconInk::reachContrast(color, surface, floor); + } + + const int saturation = qBound(0, qRound(color.hsvSaturation() * percent / 100.0), 255); + const QColor solved = solveValue(color.hue(), saturation, surface, floor); + return solved.isValid() ? solved : ThemeIconInk::reachContrast(color, surface, floor); +} +} // namespace + QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis) { switch (emphasis) { case IconEmphasis::Primary: - return reachContrast(color, surface, PRIMARY_CONTRAST).toRgb(); + return chromaticStep(color, surface, LABEL_CONTRAST_FLOOR, PRIMARY_SATURATION_STEP).toRgb(); case IconEmphasis::Quiet: - return reachContrast(blend(color, surface, QUIET_BLEND), surface, QUIET_CONTRAST_FLOOR).toRgb(); + return mutedStep(color, surface, QUIET_CONTRAST_FLOOR, QUIET_SATURATION_PERCENT).toRgb(); case IconEmphasis::Secondary: break; } return color; } -double primaryContrastFloor() +QColor disabledInk(const QColor &quiet, const QColor &surface) { - return PRIMARY_CONTRAST; + return reachContrast(blend(quiet, surface, DISABLED_BLEND), surface, DISABLED_CONTRAST_FLOOR).toRgb(); } -double quietBlend() +double primaryContrastFloor() { - return QUIET_BLEND; + return LABEL_CONTRAST_FLOOR; } double quietContrastFloor() diff --git a/cockatrice/src/interface/theme_icon_ink.h b/cockatrice/src/interface/theme_icon_ink.h index 6ba90a239..dfa585722 100644 --- a/cockatrice/src/interface/theme_icon_ink.h +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -24,7 +24,9 @@ * on. The chroma is what makes it read as an icon rather than as leftover text; * the floor keeps a theme with a weak text color from producing a glyph that is * not legible. Neither step invents a color: both are derived from the palette - * the theme already ships. + * the theme already ships. An unauthored neutral family stops at the floor: the + * live platform palette is the only source that knows the actual scheme, so a + * chroma pinned in a palette file could belong to the other one. * * The rule only applies to the neutral label roles. A semantic tint * (`AppColor::Positive`, `QPalette::Highlight`, `QPalette::Link`) is a hue the @@ -32,6 +34,27 @@ */ namespace ThemeIconInk { +/** + * @brief Whether @p hue belongs to the band the Positive or Negative family owns. + * + * A green accent is a Positive color and a red accent is a Negative one, whether + * or not the theme means it that way. Neutral chrome must not borrow either hue, + * or "settings" and "add" are painted the same color. + */ +bool isSemanticHue(int hue); + +/** @brief The hue neutral chrome falls back to when the accent is semantic. */ +int neutralChromeHue(); + +/** + * @brief The harder of two surfaces to keep an ink legible on. + * + * Contrast is monotone in the luminance distance between an ink and its + * surface, so the surface nearest @p ink is the one it reads worst on. Choosing + * that one keeps a single authored ink above the floor on both. + */ +QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink); + /** * @brief WCAG relative luminance of an opaque color. */ @@ -54,9 +77,10 @@ QColor blend(const QColor &color, const QColor &target, double amount); /** * @brief Pushes @p color away from @p background until it reaches @p minRatio. * - * Moves @p color toward black on a light background and toward white on a dark - * one, so the hue survives the adjustment. Returns @p color unchanged when it - * already meets the floor, which is the common case for a shipped palette. + * Moves @p color toward whichever pole, black or white, actually reaches + * @p minRatio against @p background, so the hue survives the adjustment. + * Returns @p color unchanged when it already meets the floor, which is the + * common case for a shipped palette. * * @param color Color to adjust. * @param background Surface the color is drawn on. @@ -82,7 +106,9 @@ double defaultAccentMix(); * @param accent The theme accent, normally `QPalette::Highlight`. * @param accentMix How much of the accent color to borrow. Defaults to * defaultAccentMix(); previews and tests sweep it to compare - * strengths, production callers do not pass it. + * strengths. Production passes 0 for an unauthored neutral + * family, where the live platform palette is the only source + * that knows the actual scheme. * @return The ink to resolve `currentColor` to, as an RGB color. */ QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix = defaultAccentMix()); @@ -99,13 +125,12 @@ double labelContrastFloor(); /** * @brief Applies an emphasis level to an already-hued glyph color. * - * Secondary keeps the hue untouched, so it is the no-op the default needs. - * Primary pushes the color to a higher floor than the default, because the - * accent is a mid-tone: on a light scheme it is lighter than the neutral ink and - * on a dark one darker, so only a stronger contrast target keeps the primary - * level above the secondary on both. Quiet - * blends the color toward the surface it sits on and stops at the non-text - * legibility floor, so a labelling glyph recedes without disappearing. + * The three levels share one legibility floor and differ by chroma, because + * contrast cannot order them: on a dark surface the highest-contrast tone is the + * palest one, so a contrast target inverts the ladder. Primary is the most + * chromatic member of its family, secondary keeps the color untouched (the + * no-op the default needs), and quiet mutes the color to a fraction of its + * saturation before solving it back to the same floor. * * @param color Hue-resolved glyph color. * @param surface Surface the glyph is drawn on. @@ -114,13 +139,27 @@ double labelContrastFloor(); */ QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis); -/** @brief Contrast floor the primary level reaches, so it out-reads the default. */ +/** + * @brief The ink a disabled glyph is painted with, given the quiet level. + * + * A disabled glyph is the quiet ink receded further toward the surface and held + * to a floor below the quiet one. The palette's own disabled text is a fixed + * gray whose contrast varies with the surface, so on the darkest themes it + * out-reads the quiet level and an enabled glyph looks more disabled than a + * disabled one. Deriving the disabled ink from the quiet ink makes that order + * structural instead of incidental. + */ +QColor disabledInk(const QColor &quiet, const QColor &surface); + +/** + * @brief Contrast floor the primary level reaches, so it stays legible. + * + * Primary is ordered by chroma, not contrast, so it shares the text floor + * rather than clearing a higher one. + */ double primaryContrastFloor(); -/** @brief How far a quiet glyph is blended into its surface. */ -double quietBlend(); - -/** @brief Contrast floor a quiet glyph keeps, the WCAG non-text minimum. */ +/** @brief Contrast floor the quiet level reaches, shared with the other levels. */ double quietContrastFloor(); } // namespace ThemeIconInk diff --git a/cockatrice/src/interface/theme_icon_sticker.cpp b/cockatrice/src/interface/theme_icon_sticker.cpp index c5f86c7ac..135ba71b8 100644 --- a/cockatrice/src/interface/theme_icon_sticker.cpp +++ b/cockatrice/src/interface/theme_icon_sticker.cpp @@ -30,7 +30,7 @@ namespace ThemeIconSticker { QColor outlineColor(const QColor &tone) { - return ThemeIconFill::saturated(tone).darker(OUTLINE_DARKER); + return tone.darker(OUTLINE_DARKER); } QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 1cf62ae8b..1ad0309b2 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "pixel_map_generator.h" #include "theme_config.h" +#include "theme_icon_ink.h" #include #include @@ -651,8 +652,37 @@ QColor ThemeManager::appColor(AppColor::Role role) const qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); } + case AppColor::Icon: + case AppColor::IconStrong: + case AppColor::IconSoft: { + // Legacy fallback for a theme written before the icon roles + // existed, or one that deliberately leaves the neutral family + // unauthored (System). No accent chroma is borrowed here: the + // palette file that would carry it cannot know the platform scheme, + // so neutral ink is the live WindowText held to the label floor. + // The three levels are the ink rule this series shipped, applied + // once here so authored roles never get recomputed at paint time. + const QPalette &palette = qApp->palette(); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), text); + const QColor ink = ThemeIconInk::glyphInk(text, surface, accent, 0.0); + switch (role) { + case AppColor::IconStrong: + return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Primary); + case AppColor::IconSoft: + return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Quiet); + case AppColor::Icon: + default: + return ink; + } + } case AppColor::Positive: + case AppColor::PositiveStrong: + case AppColor::PositiveSoft: case AppColor::Negative: + case AppColor::NegativeStrong: + case AppColor::NegativeSoft: // Semantic colors are independent of the accent: a missing role must // not silently paint "remove" in the theme's highlight color. return defaultAppColor(role, isDarkModeActive()); diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index 5a961bd5d..419859844 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #7ca0e5 +IconStrong = #6a9dff +IconSoft = #93a1ba +Positive = #5bb174 +PositiveStrong = #30b758 +PositiveSoft = #84a98f +Negative = #ed8077 +NegativeStrong = #ff7367 +NegativeSoft = #be9794 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index a958b39d1..95973457f 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 -Positive = #1b7f3b -Negative = #b3261e - +Icon = #2f58ac +IconStrong = #054ddf +IconSoft = #505c76 +Positive = #166931 +PositiveStrong = #006b22 +PositiveSoft = #40634b +Negative = #b0251d +NegativeStrong = #ba0a00 +NegativeSoft = #7c504d diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index 8be52dc5f..adc992a89 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -69,7 +69,14 @@ Accent = #ff1e1e1e [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 -Positive = #6dd58c -Negative = #ff8a80 +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #74ace4 +IconStrong = #58acff +IconSoft = #95aabf +Positive = #5fb97a +PositiveStrong = #32bf5c +PositiveSoft = #8ab196 +Negative = #f8867c +NegativeStrong = #ff8075 +NegativeSoft = #c79e9b diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 9548af916..5e2caa4b8 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -69,7 +69,14 @@ Accent = #fff0f0f0 [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 -Positive = #1b7f3b -Negative = #b3261e +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #196dc2 +IconStrong = #006ad4 +IconSoft = #546f8a +Positive = #1b7d3a +PositiveStrong = #007e28 +PositiveSoft = #4c765a +Negative = #cf2c23 +NegativeStrong = #da0c00 +NegativeSoft = #935f5c diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index 26bee5be3..6782bb7c4 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #bd8d4a +IconStrong = #d0851e +IconSoft = #a79479 +Positive = #55a66d +PositiveStrong = #2dab52 +PositiveSoft = #7c9f86 +Negative = #df7970 +NegativeStrong = #ff5e50 +NegativeSoft = #b38e8b diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index 0219fce8e..e405881cd 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f -Positive = #1b7f3b -Negative = #b3261e - +Icon = #725025 +IconStrong = #7e4b09 +IconSoft = #625544 +Positive = #15642e +PositiveStrong = #006621 +PositiveSoft = #3d5f48 +Negative = #a7231c +NegativeStrong = #b10a00 +NegativeSoft = #764c4a diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index e013e3602..de36f449a 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #9380eb +IconStrong = #927aff +IconSoft = #928cb0 +Positive = #519f69 +PositiveStrong = #2ba44f +PositiveSoft = #779881 +Negative = #d5736b +NegativeStrong = #ff4f40 +NegativeSoft = #ab8885 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index 71d86917c..b93002e25 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 -Positive = #1b7f3b -Negative = #b3261e - +Icon = #5338ce +IconStrong = #4118fe +IconSoft = #585078 +Positive = #15622e +PositiveStrong = #006420 +PositiveSoft = #3c5d47 +Negative = #a4231b +NegativeStrong = #ae0900 +NegativeSoft = #744b48 diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml index 9f945fa30..b26bbd268 100644 --- a/cockatrice/themes/System/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -3,8 +3,17 @@ # overrides. Only the identity and semantic app colors are provided here, so # buttons keep their classic greens while the actual palette stays native to # the OS. +# The neutral icon family is deliberately not authored: the palette file is +# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer +# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a +# platform palette of the other scheme. Neutral ink derives from the live +# WindowText instead. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 -Positive = #ff6dd58c -Negative = #ffff8a80 +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff6dd58c +PositiveStrong = #ff32c05c +PositiveSoft = #ff8ab196 +Negative = #ffff8a80 +NegativeStrong = #ffff8378 +NegativeSoft = #ffc79e9b diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml index 32cf7845c..6d6d7ebf4 100644 --- a/cockatrice/themes/System/palette-default-light.toml +++ b/cockatrice/themes/System/palette-default-light.toml @@ -3,8 +3,17 @@ # overrides. Only the identity and semantic app colors are provided here, so # buttons keep their classic greens while the actual palette stays native to # the OS. +# The neutral icon family is deliberately not authored: the palette file is +# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer +# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a +# platform palette of the other scheme. Neutral ink derives from the live +# WindowText instead. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 -Positive = #ff1b7f3b -Negative = #ffb3261e +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff1a7d3a +PositiveStrong = #ff007e28 +PositiveSoft = #ff4c7659 +Negative = #ffb3261e +NegativeStrong = #ffda0b00 +NegativeSoft = #ff935f5c diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 56d3e2c4c..1fb24eb65 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,6 +71,12 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Positive = #6dd58c -Negative = #ff8a80 - +Icon = #8493a2 +IconStrong = #6b95bf +IconSoft = #8c9399 +Positive = #52a16a +PositiveStrong = #2ba650 +PositiveSoft = #789a82 +Negative = #d8756c +NegativeStrong = #ff5344 +NegativeSoft = #ad8a86 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index 04461013b..1599d71fc 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,6 +71,12 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 -Positive = #1b7f3b -Negative = #b3261e - +Icon = #495a6e +IconStrong = #375a84 +IconSoft = #525961 +Positive = #16662f +PositiveStrong = #006721 +PositiveSoft = #3e6049 +Negative = #aa241c +NegativeStrong = #b40a00 +NegativeSoft = #784d4b diff --git a/tests/theme_migration/icon_treatment_preview_test.cpp b/tests/theme_migration/icon_treatment_preview_test.cpp index 212987ec0..1d027a9e7 100644 --- a/tests/theme_migration/icon_treatment_preview_test.cpp +++ b/tests/theme_migration/icon_treatment_preview_test.cpp @@ -187,9 +187,10 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; for (const Family &family : families) { - const QColor raw = config.appColors.value(family.role, defaultAppColor(family.role, dark)); - ASSERT_TRUE(raw.isValid()) << theme.toStdString() << " " << family.name; - const QColor tone = ThemeIconFill::saturated(raw); + // The authored role is the tone the filled shape carries; the + // treatments apply their own fill rule from it. + const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark)); + ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name; for (int state = 0; state < 2; ++state) { const bool isDisabled = state == 1; @@ -221,7 +222,9 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) if (treatment == 1) { return ThemeIconSticker::renderSticker(svg, ink, pixelSize); } - return ThemeIconBadge::renderBadge(svg, ink, pixelSize); + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, + dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light); }); const qreal x = cellX + 6 + static_cast(glyphIndex) * SLOT + (SLOT - side) / 2.0; diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index f51282e0a..93203be6c 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include namespace { @@ -79,15 +80,33 @@ int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) /** * @brief The ink the engine resolves a neutral glyph to, for @p palette. * - * Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink - * borrows accent chroma and holds the result to a contrast floor. Tests that - * pin the rendered color ask the rule instead of restating it. + * Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink holds + * the platform text color to a contrast floor. Tests that pin the rendered color + * ask the rule instead of restating it. No accent chroma is borrowed: this is + * the fallback an unauthored neutral family takes, so it works whatever scheme + * the platform palette actually is. */ QColor expectedNeutralInk(const QPalette &palette) { - return ThemeIconInk::glyphInk(palette.color(QPalette::Active, QPalette::WindowText), - palette.color(QPalette::Active, QPalette::Window), - palette.color(QPalette::Active, QPalette::Highlight)); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), text); + return ThemeIconInk::glyphInk(text, surface, palette.color(QPalette::Active, QPalette::Highlight), 0.0); +} + +/** + * @brief Renders a glyph at the sheet's device pixel ratio. + * + * The preview sheets paint through a painter scaled by @p dpr, so a pixmap + * rendered at logical size would be upscaled on the way into the file and read + * as blurry. Rendering at device size and tagging the pixmap keeps one source + * pixel per device pixel. + */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; } /** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ @@ -274,17 +293,29 @@ TEST_F(ThemeIconRenderTest, AppColorTintUsesSemanticColorNotTheAccent) EXPECT_EQ(countOpaquePixels(negativePixmap, accent), 0); } -/** @brief A disabled control must not keep signalling its semantic color. */ -TEST_F(ThemeIconRenderTest, DisabledAppColorUsesDisabledForeground) +/** + * @brief A disabled glyph recedes past the quiet level, whatever the family. + * + * The palette's own disabled text is a fixed gray, so on the darkest themes it + * out-reads the quiet level and an enabled glyph looks more disabled than a + * disabled one. The disabled ink is derived from the quiet ink instead, and a + * semantic family loses its hue when disabled. + */ +TEST_F(ThemeIconRenderTest, DisabledGlyphRecedesPastTheQuietLevel) { - QPalette palette = qApp->palette(); - const QColor disabledText(QStringLiteral("#fedcba")); - palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); - qApp->setPalette(palette); + const QColor quiet = themeManager->appColor(AppColor::IconSoft); + ASSERT_TRUE(quiet.isValid()); + const QPalette palette = qApp->palette(); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), quiet); + const QColor expected = ThemeIconInk::disabledInk(quiet, surface); - const IconTint tint(AppColor::Negative); - EXPECT_EQ(tint.resolve(QIcon::Disabled), disabledText); - EXPECT_NE(tint.resolve(QIcon::Normal), disabledText); + for (const AppColor::Role role : {AppColor::Icon, AppColor::Positive, AppColor::Negative}) { + const QColor disabled = IconTint(role).resolve(QIcon::Disabled); + EXPECT_EQ(disabled, expected) << static_cast(role); + EXPECT_NE(disabled, themeManager->appColor(role)) << static_cast(role); + } + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - ThemeIconInk::contrastRatio(expected, surface), 1.0); } /** @brief Palette tints keep the WindowText -> HighlightedText selected swap. */ @@ -298,10 +329,16 @@ TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText) qApp->setPalette(palette); const IconTint tint(QPalette::WindowText); - // The normal ink is derived from the active text color, but it is icon ink - // rather than the text color itself. - EXPECT_NE(tint.resolve(QIcon::Normal), activeText); - EXPECT_EQ(tint.resolve(QIcon::Normal), expectedNeutralInk(qApp->palette())); + // The normal ink is the accent-free neutral fallback: the platform text + // color held to the label floor. A text color that already clears the floor + // is the ink verbatim, so this pins the rule and the floor, not a shift. + const QColor normal = tint.resolve(QIcon::Normal); + EXPECT_EQ(normal, expectedNeutralInk(qApp->palette())); + const QPalette resolved = qApp->palette(); + const QColor surface = ThemeIconInk::harderSurface(resolved.color(QPalette::Active, QPalette::Window), + resolved.color(QPalette::Active, QPalette::Button), + resolved.color(QPalette::Active, QPalette::WindowText)); + EXPECT_GE(ThemeIconInk::contrastRatio(normal, surface), ThemeIconInk::labelContrastFloor()); // A selected row paints on the highlight, so the ink is the palette's // highlighted text rather than the icon ink. EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText); @@ -332,28 +369,24 @@ TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint) } /** - * @brief Line glyphs share the filled treatments' saturation floor. + * @brief A line glyph renders the authored role verbatim. * - * The dark-scheme semantic tones are the visible case, but `AccentSoft` is below - * the floor in every shipped light palette, so the resolve path is exercised - * without swapping the theme. A line glyph next to a badge or a sticker has to - * carry the same tone; reverting the floor here would leave it pale beside them. + * The emphasis colors are authored data now, so the paint path must not + * rewrite them. A muted quiet role stays muted on a line glyph, and the badge + * and sticker only shape the value of that tone to give the shape depth. */ -TEST_F(ThemeIconRenderTest, LineTintAppliesTheFillSaturationFloor) +TEST_F(ThemeIconRenderTest, LineTintRendersTheAuthoredRoleVerbatim) { const QColor raw = themeManager->appColor(AppColor::AccentSoft); ASSERT_TRUE(raw.isValid()); ASSERT_LT(raw.hsvSaturation(), 170); - const QColor saturated = ThemeIconFill::saturated(raw); - EXPECT_NE(saturated, raw); - EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), saturated); + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), raw); const QIcon icon = loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::AccentSoft); const QPixmap pixmap = icon.pixmap(QSize(24, 24)); ASSERT_FALSE(pixmap.isNull()); - EXPECT_GT(countOpaquePixels(pixmap, saturated), 0); - EXPECT_EQ(countOpaquePixels(pixmap, raw), 0); + EXPECT_GT(countOpaquePixels(pixmap, raw), 0); } /** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */ @@ -372,8 +405,8 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); EXPECT_GT(image.pixelColor(32, 32).alpha(), 200); - // The ring is the saturated tone darkened, and it must actually be painted. - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + // The ring is the authored tone darkened, and it must actually be painted. + EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0); // The fill runs from a lighter top to a darker bottom. Sample left of the // check, which now reaches almost to the disc edge. @@ -385,40 +418,45 @@ TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) EXPECT_GT(top.lightness(), bottom.lightness()); // The glyph uses the contrast color chosen for this tone. - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0); } /** - * @brief The dark-scheme disc carries the same saturated fill as the sticker. + * @brief The dark-scheme disc keeps the authored tone and darkens it. * - * The dark tones are tuned for text and read as pale mint or salmon once they - * fill a disc, so the badge saturates its fill. The dark scheme keeps the - * darkened ink the badge had before the fill moved to the sticker ramp. + * The authored tone is the top of the gradient, so the level the palette author + * picked survives the treatment; the shape only adds depth by darkening the + * bottom. It must not re-saturate the tone: the authored saturation is the theme + * author's choice. The dark scheme keeps the darkened ink the badge had before + * the fill moved to the sticker ramp. */ -TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill) +TEST_F(ThemeIconRenderTest, BadgeDarkSchemeKeepsTheAuthoredTone) { const QColor tone = defaultAppColor(AppColor::Positive, true); ASSERT_TRUE(tone.isValid()); ASSERT_GE(tone.value(), 200); - const QPixmap pixmap = ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64)); + const QPixmap pixmap = + ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64), ThemeIconFill::Scheme::Dark); ASSERT_FALSE(pixmap.isNull()); const QImage image = pixmap.toImage(); - // The disc neither keeps the pale raw tone nor the old unsaturating bottom. - EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); - EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(104)), 0); const QColor top = image.pixelColor(10, 16); const QColor bottom = image.pixelColor(10, 48); ASSERT_GT(top.alpha(), 200); ASSERT_GT(bottom.alpha(), 200); - // Antialiasing costs a point or two of saturation at the sample points. - EXPECT_GE(top.hsvSaturation(), 160); - EXPECT_GE(bottom.hsvSaturation(), 160); + // The top is a darkening of the authored tone, never a lift, and it keeps + // the authored saturation; the sample sits a little down the gradient. + EXPECT_LE(top.value(), tone.value()); + EXPECT_GT(top.value(), bottom.value()); + EXPECT_LT(bottom.value(), tone.value()); EXPECT_GT(top.lightness(), bottom.lightness()); + // Antialiasing costs a point or two of saturation at the sample points. + EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24); + EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24); // The dark scheme keeps the darkened ink, and it is actually painted. - const QColor ink = ThemeIconBadge::glyphColor(tone); + const QColor ink = ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Dark); ASSERT_NE(ink, QColor(Qt::white)); EXPECT_GT(countOpaquePixels(pixmap, ink), 0); } @@ -427,8 +465,8 @@ TEST_F(ThemeIconRenderTest, BadgeDarkSchemeDiscUsesTheSaturatedFill) * @brief The badge ink follows the scheme. * * Light-scheme tones keep the white glyph of the old art; dark-scheme tones - * keep the darkened ink, because the shared saturated disc is light enough that - * white would sit near 2.7:1 at its top. Pinning both cases stops the glyph from + * keep the darkened ink, because the lifted disc is light enough that white + * would sit near 2.7:1 at its top. Pinning both cases stops the glyph from * silently flipping back to white when the fill or the palette changes. */ TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme) @@ -445,7 +483,8 @@ TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme) for (const auto &testCase : cases) { const QColor tone = defaultAppColor(testCase.role, testCase.dark); ASSERT_TRUE(tone.isValid()); - const QColor ink = ThemeIconBadge::glyphColor(tone); + const QColor ink = ThemeIconBadge::glyphColor(tone, testCase.dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light); if (testCase.dark) { EXPECT_NE(ink, QColor(Qt::white)); EXPECT_LT(ink.value(), 100); @@ -488,7 +527,7 @@ TEST_F(ThemeIconRenderTest, BadgeStaysLegibleAtPlayerListSize) EXPECT_GT(image.pixelColor(7, 7).alpha(), 200); // A 1px ring on a 15px canvas antialiases into the disc and has no exact // color, so the glyph is what has to stay legible. - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 8); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 8); } /** @brief The pixmap API's badge path agrees with the engine's. */ @@ -499,8 +538,8 @@ TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine) QSize(48, 48), IconStyle::Badge); ASSERT_FALSE(pixmap.isNull()); EXPECT_EQ(pixmap.size(), QSize(48, 48)); - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); - EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0); } /** @@ -577,16 +616,14 @@ TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath) // The authored color survives; no disc tone and no ring are painted. EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); - EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); } /** @brief Disabled keeps the shape language but drops back to the line glyph. */ TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) { - QPalette palette = qApp->palette(); - const QColor disabledText(QStringLiteral("#fedcba")); - palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); - qApp->setPalette(palette); + const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled); + ASSERT_TRUE(disabled.isValid()); const QColor tone = themeManager->appColor(AppColor::Positive); const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); @@ -595,10 +632,10 @@ TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) // A filled disc at full mass would not read as unavailable, so Disabled // drops the badge: no ring, no disc, just the disabled line glyph. - EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); - EXPECT_EQ(countOpaquePixels(pixmap, disabledText.darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, disabled.darker(300)), 0); EXPECT_EQ(pixmap.toImage().pixelColor(0, 0).alpha(), 0); - EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabled), 0); } /** @@ -632,13 +669,13 @@ TEST_F(ThemeIconRenderTest, StickerPaintsToneFillWithDarkOutline) } /** - * @brief The dark-scheme fill is the branch the treatment exists for. + * @brief The dark-scheme fill keeps the authored tone and darkens it. * - * That tone is light and deliberately desaturated, so a plain gradient paints a - * pale mint shape. The fill has to raise its saturation and pull its value down - * instead of up. + * The authored tone is the top of the gradient, so the level the palette author + * picked survives the treatment. The treatment only adds depth by darkening the + * bottom, without re-saturating the tone. */ -TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone) +TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillKeepsTheAuthoredTone) { const QColor tone = defaultAppColor(AppColor::Positive, true); ASSERT_TRUE(tone.isValid()); @@ -652,10 +689,14 @@ TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillIsSaturatedAndDarkerThanTheTone const QColor bottom = image.pixelColor(32, 50); ASSERT_GT(top.alpha(), 200); ASSERT_GT(bottom.alpha(), 200); - EXPECT_LT(top.value(), tone.value()); + // The top is a darkening of the authored tone, never a lift, and it keeps + // the authored saturation; the sample sits a little down the gradient. + EXPECT_LE(top.value(), tone.value()); + EXPECT_GT(top.value(), bottom.value()); EXPECT_LT(bottom.value(), tone.value()); EXPECT_GT(top.lightness(), bottom.lightness()); - EXPECT_GE(top.hsvSaturation(), 170); + EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24); + EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24); } /** @brief The sticker is rendered at the device-pixel size, not upscaled later. */ @@ -726,10 +767,8 @@ TEST_F(ThemeIconRenderTest, StickerOnAuthoredArtUsesTheAssetPath) /** @brief Disabled keeps the shape language but drops back to the line glyph. */ TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph) { - QPalette palette = qApp->palette(); - const QColor disabledText(QStringLiteral("#fedcba")); - palette.setColor(QPalette::Disabled, QPalette::WindowText, disabledText); - qApp->setPalette(palette); + const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled); + ASSERT_TRUE(disabled.isValid()); const QColor tone = themeManager->appColor(AppColor::Negative); const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); @@ -739,7 +778,7 @@ TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph) // No rim in the sticker tone: Disabled drops the treatment and paints the // thin disabled line glyph instead. EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); - EXPECT_GT(countOpaquePixels(pixmap, disabledText), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabled), 0); } /** @brief Line stays the default: no disc, no ring, just the recolored glyph. */ @@ -750,7 +789,7 @@ TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone) loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64)); ASSERT_FALSE(pixmap.isNull()); - EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconFill::saturated(tone).darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); EXPECT_GT(countOpaquePixels(pixmap, tone), 0); } @@ -836,7 +875,8 @@ TEST(IconTintPreview, WritesPngsWhenRequested) painter.drawText(QPointF(MARGIN, rowTop + 16), QString::fromLatin1(glyph.name)); for (std::size_t i = 0; i < std::size(SIZES); ++i) { const int side = SIZES[i]; - const QPixmap pixmap = renderLine(svg, tone, QSize(side, side)); + const QPixmap pixmap = renderAtDpr( + QSize(side, side), DPR, [&](const QSize &pixelSize) { return renderLine(svg, tone, pixelSize); }); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; // Bottom-align the sizes and put the caption below the 48px row. painter.drawPixmap(QPointF(x, rowTop + 54 - side), pixmap); @@ -939,8 +979,8 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) const QByteArray svg = readSvg(glyph.name); ASSERT_FALSE(svg.isEmpty()) << glyph.name; // The preview must show the line glyph the engine paints, and the - // engine saturates AppColor tones before drawing them. - const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); + // engine renders an authored tone verbatim. + const QColor tone = defaultAppColor(glyph.role, dark); for (int row = 0; row < 2; ++row) { const bool sticker = row == 1; @@ -950,8 +990,10 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) QString::fromLatin1(glyph.name)); for (std::size_t i = 0; i < std::size(SIZES); ++i) { const int side = SIZES[i]; - const QPixmap pixmap = sticker ? ThemeIconSticker::renderSticker(svg, tone, QSize(side, side)) - : renderLine(svg, tone, QSize(side, side)); + const QPixmap pixmap = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return sticker ? ThemeIconSticker::renderSticker(svg, tone, pixelSize) + : renderLine(svg, tone, pixelSize); + }); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap); if (row == 1) { @@ -973,8 +1015,11 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; const QColor tone = defaultAppColor(badgeContext[i].role, dark); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; - painter.drawPixmap(QPointF(x, y + 8), - ThemeIconBadge::renderBadge(svg, tone, QSize(CONTEXT_SIZE, CONTEXT_SIZE))); + painter.drawPixmap( + QPointF(x, y + 8), renderAtDpr(QSize(CONTEXT_SIZE, CONTEXT_SIZE), DPR, [&](const QSize &pixelSize) { + return ThemeIconBadge::renderBadge( + svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light); + })); painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name)); } @@ -985,18 +1030,20 @@ TEST(IconStickerPreview, WritesPngsWhenRequested) for (std::size_t i = 0; i < std::size(badgeContext); ++i) { const QByteArray svg = readSvg(badgeContext[i].name); ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; - const QColor tone = ThemeIconFill::saturated(defaultAppColor(badgeContext[i].role, dark)); + const QColor tone = defaultAppColor(badgeContext[i].role, dark); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH * 1.5; for (int j = 0; j < 3; ++j) { const QSize side(CONTEXT_SIZE, CONTEXT_SIZE); - QPixmap pixmap; - if (j == 0) { - pixmap = renderLine(svg, tone, side); - } else if (j == 1) { - pixmap = ThemeIconBadge::renderBadge(svg, tone, side); - } else { - pixmap = ThemeIconSticker::renderSticker(svg, tone, side); - } + const QPixmap pixmap = renderAtDpr(side, DPR, [&](const QSize &pixelSize) { + if (j == 0) { + return renderLine(svg, tone, pixelSize); + } + if (j == 1) { + return ThemeIconBadge::renderBadge( + svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light); + } + return ThemeIconSticker::renderSticker(svg, tone, pixelSize); + }); const qreal px = x + j * (CONTEXT_SIZE + 16); painter.drawPixmap(QPointF(px, y + 8), pixmap); painter.drawText(QPointF(px, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(FAMILY_LABELS[j])); @@ -1091,8 +1138,8 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) const QByteArray svg = readSvg(glyph.name); ASSERT_FALSE(svg.isEmpty()) << glyph.name; // The preview must show the line glyph the engine paints, and the - // engine saturates AppColor tones before drawing them. - const QColor tone = ThemeIconFill::saturated(defaultAppColor(glyph.role, dark)); + // engine renders an authored tone verbatim. + const QColor tone = defaultAppColor(glyph.role, dark); for (int row = 0; row < 2; ++row) { const bool badge = row == 1; @@ -1102,8 +1149,12 @@ TEST(IconBadgePreview, WritesPngsWhenRequested) QString::fromLatin1(glyph.name)); for (std::size_t i = 0; i < std::size(SIZES); ++i) { const int side = SIZES[i]; - const QPixmap pixmap = badge ? ThemeIconBadge::renderBadge(svg, tone, QSize(side, side)) - : renderLine(svg, tone, QSize(side, side)); + const QPixmap pixmap = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return badge ? ThemeIconBadge::renderBadge(svg, tone, pixelSize, + dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light) + : renderLine(svg, tone, pixelSize); + }); const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; // Align all sizes on a common baseline so the weight // difference between 15px and 48px is visible. @@ -1128,9 +1179,11 @@ namespace struct InkPalette { QColor text; + QColor buttonText; QColor window; QColor button; QColor accent; + QMap roles; }; /** @@ -1146,12 +1199,37 @@ InkPalette shippedPalette(const QString &theme, bool dark) .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); const PaletteConfig config = PaletteConfig::fromFile(file); - if (!config.hasPalette()) { + // A theme may author only [AppColors] (System does), so appColors alone do + // not mean it ships a palette: the keys have to be present. The authored + // neutral-family assertions therefore cover only themes with a palette; + // System deliberately leaves its neutral family unauthored, which + // ShippedPalettesAuthorTheEmphasisLadder pins, and its semantic fallbacks + // are measured against representative platform surfaces by + // DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces. + if (config.colors.isEmpty()) { return {}; } const QPalette palette = config.apply(QPalette()); - return {palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::Window), - palette.color(QPalette::Active, QPalette::Button), palette.color(QPalette::Active, QPalette::Highlight)}; + return { + palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::ButtonText), + palette.color(QPalette::Active, QPalette::Window), palette.color(QPalette::Active, QPalette::Button), + palette.color(QPalette::Active, QPalette::Highlight), config.appColors}; +} + +/** + * @brief The [AppColors] a shipped theme authors, even when it ships no palette. + * + * System deliberately has no palette of its own, but it still authors its + * semantic roles. The neutral icon family stays unauthored there so it follows + * the live platform palette; see ShippedPalettesAuthorTheEmphasisLadder. + */ +QMap shippedAppColors(const QString &theme, bool dark) +{ + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + return PaletteConfig::fromFile(file).appColors; } } // namespace @@ -1171,6 +1249,67 @@ TEST(IconInk, GivesNeutralGlyphsAccentChroma) EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor()); } +/** + * @brief A medium-light surface needs the pole that can actually reach the floor. + * + * #9a9a9a is lighter than mid-gray but still too light for white to reach a text + * ratio, so "light background means move toward black" is the wrong test. + */ +TEST(IconInk, ContrastFloorPicksThePoleItCanReach) +{ + const QColor background(QStringLiteral("#9a9a9a")); + ASSERT_LT(ThemeIconInk::contrastRatio(QColor(Qt::white), background), 4.5); + ASSERT_GE(ThemeIconInk::contrastRatio(QColor(Qt::black), background), 4.5); + + const QColor solved = ThemeIconInk::reachContrast(QColor(QStringLiteral("#d0d0d0")), background, 4.5); + EXPECT_GE(ThemeIconInk::contrastRatio(solved, background), 4.5); +} + +/** + * @brief Primary stays louder than secondary on a light surface. + * + * A saturated blue or violet at full value is already dark enough to clear the + * label floor on a light surface. When solveValue() read the target as a + * minimum in both directions it rejected that tone, walked the whole saturation + * ladder and fell back to the unchanged secondary, so primary was never + * distinguishable. #5a3ee2 on #efeff4 is the shipped Plasma accent's shape. + */ +TEST(IconInk, PrimaryRaisesAChromaticToneOnALightSurface) +{ + const QColor surface(QStringLiteral("#efeff4")); + const QColor violet(QStringLiteral("#5a3ee2")); + ASSERT_GE(ThemeIconInk::contrastRatio(violet, surface), ThemeIconInk::labelContrastFloor()) + << "the fixture must already be legible, so the fallback would be a no-op"; + + const QColor primary = ThemeIconInk::forEmphasis(violet, surface, IconEmphasis::Primary); + ASSERT_TRUE(primary.isValid()); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GT(primary.hsvSaturation(), violet.hsvSaturation()) << "primary fell back to the unchanged secondary"; + EXPECT_NEAR(primary.hue(), violet.hue(), 2); +} + +/** + * @brief A green or red accent must not tint neutral chrome. + * + * Positive owns green and Negative owns red, so a theme whose accent sits in + * either band would otherwise paint "settings" the same color as "add". + */ +TEST(IconInk, SemanticAccentDoesNotTintNeutralChrome) +{ + const QColor surface(QStringLiteral("#18202e")); + const QColor text = Qt::white; + const QColor green(QStringLiteral("#149740")); + const QColor blue(QStringLiteral("#2f6fd6")); + + ASSERT_TRUE(ThemeIconInk::isSemanticHue(green.hue())); + ASSERT_FALSE(ThemeIconInk::isSemanticHue(blue.hue())); + + const QColor greenInk = ThemeIconInk::glyphInk(text, surface, green); + EXPECT_NEAR(greenInk.hue(), ThemeIconInk::neutralChromeHue(), 2); + EXPECT_FALSE(ThemeIconInk::isSemanticHue(greenInk.hue())); + EXPECT_NEAR(ThemeIconInk::glyphInk(text, surface, blue).hue(), blue.hue(), 1); +} + TEST(IconInk, MeetsTheLabelFloorOnEveryShippedPalette) { const QString themesRoot = @@ -1334,8 +1473,14 @@ TEST(IconInkPreview, WritesPngsWhenRequested) ASSERT_FALSE(svg.isEmpty()) << GLYPHS[i]; const qreal cellOffset = static_cast(i) * (side + CELL_GAP); const qreal glyphY = rowY + (ROW_HEIGHT - side) / 2.0; - painter.drawPixmap(QPointF(oldX + cellOffset, glyphY), renderLine(svg, oldInk, QSize(side, side))); - painter.drawPixmap(QPointF(newX + cellOffset, glyphY), renderLine(svg, newInk, QSize(side, side))); + painter.drawPixmap(QPointF(oldX + cellOffset, glyphY), + renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return renderLine(svg, oldInk, pixelSize); + })); + painter.drawPixmap(QPointF(newX + cellOffset, glyphY), + renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return renderLine(svg, newInk, pixelSize); + })); } rowY += ROW_HEIGHT; } @@ -1365,97 +1510,352 @@ bool applyShippedPalette(const QString &theme, bool dark) qApp->setPalette(config.apply(QPalette())); return true; } - -QColor surfaceColor() -{ - return qApp->palette().color(QPalette::Active, QPalette::Window); -} } // namespace /** - * @brief The primary action of a surface takes the accent, not a darker gray. + * @brief Emphasis selects the authored variant of a family, not a computed color. * - * A glyph hierarchy cannot be built out of "dark" and "darker": at any usable - * contrast a neutral ink is already near the palette's pole, so the top level - * has to be a different color. The accent is the palette's own answer to "which - * color means action", so primary uses it instead of inventing a token. + * The theme stores one color per level, so the mapping is a lookup: a base role + * follows the caller's emphasis, while naming a variant pins that level at every + * emphasis. This is the contract that makes the palette editor's rows the whole + * source of truth. */ -TEST_F(ThemeIconRenderTest, PrimaryEmphasisUsesTheAccent) +TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + EXPECT_EQ(IconTint().resolve(QIcon::Normal), themeManager->appColor(AppColor::Icon)); + EXPECT_EQ(IconTint::primary().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconStrong)); + EXPECT_EQ(IconTint::quiet().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconSoft)); + EXPECT_EQ(IconTint(QPalette::WindowText, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(AppColor::IconStrong)); - const QColor secondary = IconTint().resolve(QIcon::Normal); - const QColor primary = IconTint::primary().resolve(QIcon::Normal); + struct Family + { + AppColor::Role base; + AppColor::Role strong; + AppColor::Role soft; + }; - EXPECT_NE(primary, secondary); - EXPECT_GT(primary.blue(), primary.red()) << "the primary glyph did not take the accent's blue"; - EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()), ThemeIconInk::primaryContrastFloor()); - EXPECT_GE(ThemeIconInk::contrastRatio(primary, surfaceColor()), - ThemeIconInk::contrastRatio(secondary, surfaceColor())); + for (const Family &family : {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, + Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(family.strong)); + EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base)); + EXPECT_EQ(IconTint(family.base, IconEmphasis::Quiet).resolve(QIcon::Normal), + themeManager->appColor(family.soft)); + // Naming a variant is explicit: emphasis does not rewrite it. + EXPECT_EQ(IconTint(family.strong, IconEmphasis::Quiet).resolve(QIcon::Normal), + themeManager->appColor(family.strong)); + EXPECT_EQ(IconTint(family.soft, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(family.soft)); + } } /** - * @brief The primary level out-reads the default one on both schemes. + * @brief The authored primary level is the most chromatic member of its family. * - * The default ink borrows enough accent to sit off the palette's pole (5.4:1 on - * light, 6.4:1 on dark), so the 7:1 primary floor is measurably above it in both - * schemes instead of being lost against a near-black or near-white default. - * Hue alone would not keep the order; the contrast target does. + * Contrast cannot order the ladder: on a dark surface the highest-contrast tone + * is the palest one, so a contrast target makes the primary read as a washed-out + * secondary. Chroma orders the levels instead; contrast only has to stay above + * the legibility floor on every surface the icon is drawn on. */ -TEST_F(ThemeIconRenderTest, PrimaryOutReadsSecondaryOnBothSchemes) +TEST_F(ThemeIconRenderTest, PrimaryIsTheMostChromaticLevelOnBothSchemes) { for (const bool dark : {false, true}) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), dark)); - const QColor surface = surfaceColor(); - const QColor secondary = IconTint().resolve(QIcon::Normal); - const QColor primary = IconTint::primary().resolve(QIcon::Normal); - EXPECT_NE(primary, secondary) << (dark ? "dark" : "light"); - EXPECT_GT(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::contrastRatio(secondary, surface)) - << (dark ? "dark" : "light"); - EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) - << (dark ? "dark" : "light"); + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light"); + const QColor secondary = palette.roles.value(AppColor::Icon); + const QColor primary = palette.roles.value(AppColor::IconStrong); + ASSERT_TRUE(secondary.isValid() && primary.isValid()) << (dark ? "dark" : "light"); + + EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); + for (const QColor &surface : {palette.window, palette.button}) { + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) + << (dark ? "dark" : "light"); + } } } -/** @brief A quiet glyph recedes toward its surface without dropping below 3:1. */ -TEST_F(ThemeIconRenderTest, QuietEmphasisRecedesButStaysLegible) +/** + * @brief Quiet is the least chromatic level; it shares the legibility floor. + * + * Ordering the ladder by contrast inverted it on dark schemes and pushed quiet + * under the disabled ink. Quiet now keeps the same floor as the other levels and + * recedes by carrying less of the family's chroma, which also leaves the + * disabled level the only one below the floor. + */ +TEST_F(ThemeIconRenderTest, QuietIsTheLeastChromaticLevel) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light"); + const QColor secondary = palette.roles.value(AppColor::Icon); + const QColor quiet = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(secondary.isValid() && quiet.isValid()) << (dark ? "dark" : "light"); - const QColor secondary = IconTint().resolve(QIcon::Normal); - const QColor quiet = IconTint::quiet().resolve(QIcon::Normal); - - EXPECT_LT(ThemeIconInk::contrastRatio(quiet, surfaceColor()), - ThemeIconInk::contrastRatio(secondary, surfaceColor())) - << "the quiet glyph does not recede"; - EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surfaceColor()), ThemeIconInk::quietContrastFloor()); + EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); + for (const QColor &surface : {palette.window, palette.button}) { + const double quietContrast = ThemeIconInk::contrastRatio(quiet, surface); + EXPECT_GE(quietContrast, ThemeIconInk::quietContrastFloor()) << (dark ? "dark" : "light"); + EXPECT_GE(quietContrast - ThemeIconInk::contrastRatio(ThemeIconInk::disabledInk(quiet, surface), surface), + 1.0) + << (dark ? "dark" : "light"); + } + } } -/** @brief Emphasis changes how loud a semantic color is, never which color it is. */ +/** @brief Every authored emphasis level keeps a semantic family's hue. */ TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue) { - ASSERT_TRUE(applyShippedPalette(QStringLiteral("Fabric"), false)); + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); - for (const IconEmphasis emphasis : {IconEmphasis::Primary, IconEmphasis::Secondary, IconEmphasis::Quiet}) { - const QColor ink = IconTint(AppColor::Positive, emphasis).resolve(QIcon::Normal); - EXPECT_GT(ink.green(), ink.red()) << static_cast(emphasis); - EXPECT_GT(ink.green(), ink.blue()) << static_cast(emphasis); + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QMap roles = shippedAppColors(theme, dark); + + for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}) { + const QColor ink = roles.value(role); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.green(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + const QColor ink = roles.value(role); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.red(), ink.blue()) << theme.toStdString() << static_cast(role); + } + } + } + EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level"; +} + +/** + * @brief Every palette-shipping theme authors the whole emphasis ladder. + * + * The palette editor treats an unset role as "derive it", so a missing key is + * not a crash: it silently reintroduces the computed colors this change exists + * to remove. Treat a missing role as a test failure instead, and keep the same + * contrast contract the old computed levels had. + */ +TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; // System ships no palette, so it has no surfaces to measure. + } + ++checked; + + for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft, + AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, + AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + ASSERT_TRUE(palette.roles.contains(role)) + << theme.toStdString() << (dark ? " dark" : " light") << static_cast(role); + } + + for (const QColor &surface : {palette.window, palette.button}) { + const double secondary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::Icon), surface); + const double primary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconStrong), surface); + const double quiet = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconSoft), surface); + EXPECT_GE(secondary, ThemeIconInk::labelContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GT(palette.roles.value(AppColor::IconStrong).hsvSaturation(), + palette.roles.value(AppColor::Icon).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_LT(palette.roles.value(AppColor::IconSoft).hsvSaturation(), + palette.roles.value(AppColor::Icon).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + + for (const auto &[base, strong, soft] : + {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + EXPECT_GE(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), + ThemeIconInk::quietContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_LT(palette.roles.value(soft).hsvSaturation(), palette.roles.value(base).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GT(palette.roles.value(strong).hsvSaturation(), palette.roles.value(base).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + } + } + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; + + // The System theme ships no palette of its own, so its families have no + // authored surfaces to measure against. It nevertheless authors the + // semantic family, while the neutral family stays unauthored on purpose: + // the palette file is chosen from the theme's scheme, which is always light + // on Qt < 6.5 and can be unknown on newer Qt, so one fixed ladder would be + // invisible against a platform palette of the other scheme. + for (const bool dark : {false, true}) { + const QMap system = shippedAppColors(QStringLiteral("System"), dark); + EXPECT_TRUE(system.contains(AppColor::PositiveStrong)); + EXPECT_TRUE(system.contains(AppColor::NegativeSoft)); + for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}) { + EXPECT_FALSE(system.contains(role)) << (dark ? "dark" : "light") << static_cast(role); + } } } -/** @brief Every emphasis keeps the selected-row swap to HighlightedText. */ +/** + * @brief Neutral chrome keeps a hue distance from the semantic families. + * + * Fusion's accent is green, so deriving neutral ink from it painted every menu + * and toolbar glyph the same color as the Positive family. The authored neutral + * family has to stay a hue a reader can tell apart from "add" and "remove". + */ +TEST(IconInk, NeutralFamilyKeepsItsDistanceFromTheSemanticFamilies) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; + } + const QColor neutral = palette.roles.value(AppColor::Icon); + for (const AppColor::Role role : {AppColor::Positive, AppColor::Negative}) { + const QColor semantic = palette.roles.value(role); + ASSERT_TRUE(neutral.isValid() && semantic.isValid()) + << theme.toStdString() << (dark ? " dark" : " light"); + const int distance = qAbs(neutral.hue() - semantic.hue()); + EXPECT_GE(qMin(distance, 360 - distance), 30) + << theme.toStdString() << (dark ? " dark" : " light") << static_cast(role); + ++checked; + } + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +/** + * @brief Every shipped theme keeps the disabled level below the quiet level. + * + * The palette's disabled text is a fixed gray, so this is the invariant the + * derived disabled ink exists to hold; a regression here makes an enabled quiet + * glyph look unavailable. + */ +TEST(IconInk, DisabledStaysBelowQuietOnEveryShippedTheme) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; + } + const QColor quiet = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(quiet.isValid()) << theme.toStdString() << (dark ? " dark" : " light"); + const QColor surface = ThemeIconInk::harderSurface(palette.window, palette.button, quiet); + const QColor disabled = ThemeIconInk::disabledInk(quiet, surface); + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - ThemeIconInk::contrastRatio(disabled, surface), 1.0) + << theme.toStdString() << (dark ? " dark" : " light"); + + for (const QColor &anySurface : {palette.window, palette.button}) { + EXPECT_LT(ThemeIconInk::contrastRatio(disabled, anySurface), + ThemeIconInk::contrastRatio(quiet, anySurface)) + << theme.toStdString() << (dark ? " dark" : " light"); + } + ++checked; + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +/** + * @brief The System fallback family keeps the ladder on a platform palette. + * + * System authors no palette and follows the OS surfaces, so its families + * cannot be measured against its own window and button. These are the + * representative extremes of a platform palette, and the fixed values have to + * clear the same floor there that the five authored themes clear on their own + * surfaces. + */ +TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces) +{ + struct Scheme + { + bool dark; + QColor window; + QColor button; + }; + const QList schemes = { + {false, QColor(QStringLiteral("#f0f0f0")), QColor(QStringLiteral("#f5f5f5"))}, + {true, QColor(QStringLiteral("#2b2b2b")), QColor(QStringLiteral("#3c3c3c"))}, + }; + + for (const Scheme &scheme : schemes) { + for (const auto &[base, strong, soft] : + {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + const QColor secondary = defaultAppColor(base, scheme.dark); + const QColor primary = defaultAppColor(strong, scheme.dark); + const QColor quiet = defaultAppColor(soft, scheme.dark); + ASSERT_TRUE(secondary.isValid() && primary.isValid() && quiet.isValid()); + + for (const QColor &surface : {scheme.window, scheme.button}) { + EXPECT_GE(ThemeIconInk::contrastRatio(secondary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::labelContrastFloor()); + } + EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()); + EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()); + } + } +} + +/** + * @brief Every emphasis keeps the selected-row swap to HighlightedText. + */ TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText) { + const QColor tone = IconTint(AppColor::AccentSoft).resolve(QIcon::Normal); + ASSERT_TRUE(tone.isValid()); + QPalette palette = qApp->palette(); const QColor selectedText(QStringLiteral("#222222")); - palette.setColor(QPalette::Active, QPalette::Highlight, QColor(QStringLiteral("#000000"))); + palette.setColor(QPalette::Active, QPalette::Highlight, tone); palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); qApp->setPalette(palette); - // Black ink on a black highlight is illegible, so the selection wins for - // both the default and the quiet level. + // Tone on a highlight of the same color is illegible, so the selection wins + // for both the default and an explicitly tinted glyph. EXPECT_EQ(IconTint().resolve(QIcon::Selected), selectedText); - EXPECT_EQ(IconTint::quiet().resolve(QIcon::Selected), selectedText); + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Selected), selectedText); } /** @@ -1487,19 +1887,27 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) constexpr int SIZES[] = {16, 24}; constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"}; - constexpr IconEmphasis LEVELS[] = {IconEmphasis::Quiet, IconEmphasis::Secondary, IconEmphasis::Primary}; struct Family { const char *name; const char *glyphs[3]; - AppColor::Role role; - bool neutral; + AppColor::Role quiet; + AppColor::Role secondary; + AppColor::Role primary; }; const QList families = { - {"Neutral", {"settings", "info", "player"}, AppColor::Positive, true}, - {"Positive", {"increment", "ready_start", "arrow_up"}, AppColor::Positive, false}, - {"Negative", {"decrement", "not_ready_start", "remove_row"}, AppColor::Negative, false}, + {"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong}, + {"Positive", + {"increment", "ready_start", "arrow_up"}, + AppColor::PositiveSoft, + AppColor::Positive, + AppColor::PositiveStrong}, + {"Negative", + {"decrement", "not_ready_start", "remove_row"}, + AppColor::NegativeSoft, + AppColor::Negative, + AppColor::NegativeStrong}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -1531,16 +1939,14 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) // before resolving tones for the sheet. ASSERT_TRUE(applyShippedPalette(theme, dark)); + // The paint path is a lookup now, so the sheet shows the authored + // rows verbatim rather than a color recomputed from the accent. const auto toneFor = [&](const Family &family, int level) { - QColor base; - if (family.neutral) { - base = level == 2 ? palette.accent - : ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); - } else { - base = ThemeIconFill::saturated(defaultAppColor(family.role, dark)); - } - return ThemeIconInk::forEmphasis(base, palette.window, LEVELS[level]); + return level == 0 ? palette.roles.value(family.quiet) + : level == 1 ? palette.roles.value(family.secondary) + : palette.roles.value(family.primary); }; + const auto scheme = dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), QImage::Format_ARGB32_Premultiplied); @@ -1609,14 +2015,15 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) for (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++glyphIndex) { const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; - QPixmap pixmap; - if (treatment == 0) { - pixmap = renderLine(svg, ink, size); - } else if (treatment == 1) { - pixmap = ThemeIconSticker::renderSticker(svg, ink, size); - } else { - pixmap = ThemeIconBadge::renderBadge(svg, ink, size); - } + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, scheme); + }); const qreal x = cellX + 6 + static_cast(glyphIndex) * GLYPH_SLOT + (GLYPH_SLOT - side) / 2.0; painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap); @@ -1637,3 +2044,199 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) } } } + +/** + * @brief Writes the authored emphasis sheets for every shipped palette theme. + * + * The ladder is only useful if it survives the treatment a call site chooses, so + * each sheet renders the same three authored levels as line glyphs, as sticker + * fills and as badges, at the two sizes the UI uses, on the two surfaces the + * contrast targets are measured against. Each family carries three glyphs, so + * the sheet also proves the level, not just one lucky shape. A disabled row + * closes each family: it must read dimmer than quiet, and every treatment falls + * back to the line glyph there. + * + * The authored tone is the top of every filled gradient, so the sheet prints that + * tone and the darker bottom the shape actually carries. + */ +TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 230; + constexpr int CELL_WIDTH = 166; + constexpr int ROW_HEIGHT = 58; + constexpr int PANEL_TITLE = 26; + constexpr int COLUMN_HEADER = 20; + constexpr int PANEL_PADDING = 12; + constexpr int GLYPH_SLOT = 32; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; + constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"}; + + struct Family + { + const char *name; + const char *glyphs[3]; + AppColor::Role quiet; + AppColor::Role secondary; + AppColor::Role primary; + }; + const QList families = { + {"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong}, + {"Positive", + {"increment", "ready_start", "arrow_up"}, + AppColor::PositiveSoft, + AppColor::Positive, + AppColor::PositiveStrong}, + {"Negative", + {"decrement", "not_ready_start", "remove_row"}, + AppColor::NegativeSoft, + AppColor::Negative, + AppColor::NegativeStrong}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto roleAt = [](const Family &family, int level) { + return level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary; + }; + const auto schemeFor = [](bool dark) { return dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; }; + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int panelWidth = LABEL_WIDTH + static_cast(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING; + const int panelHeight = + PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 4 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + ASSERT_TRUE(palette.text.isValid()) << theme.toStdString(); + const QColor quietTone = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(quietTone.isValid()) << theme.toStdString(); + // What the engine paints for `QIcon::Disabled`: the authored quiet + // level receded past its floor, measured on the harder surface. + const QColor disabledTone = ThemeIconInk::disabledInk( + quietTone, ThemeIconInk::harderSurface(palette.window, palette.button, quietTone)); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, authored emphasis across treatments") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + QColor text; + }; + const QList surfaces = { + {"Window surface", palette.window, palette.text}, + {"Button surface", palette.button, palette.buttonText}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24")); + } + + qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; + for (const Family &family : families) { + for (int level = 0; level < 4; ++level) { + const bool disabled = level == 3; + const QColor ink = disabled ? disabledTone : palette.roles.value(roleAt(family, level)); + const QString levelName = + disabled ? QStringLiteral("Disabled") : QString::fromLatin1(LEVEL_NAMES[level]); + ASSERT_TRUE(ink.isValid()) + << theme.toStdString() << " " << family.name << " " << levelName.toStdString(); + const QColor bottom = disabled ? ink : ThemeIconFill::bottomColor(ink); + + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, y + 14), + QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), levelName)); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + if (!disabled && bottom != ink) { + painter.drawText(QPointF(contentLeft + 82, y + 28), + QStringLiteral("bottom ") + bottom.name(QColor::HexRgb)); + } + + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal cellX = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); + for (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (disabled || treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, schemeFor(dark)); + }); + const qreal x = cellX + 6 + static_cast(glyphIndex) * GLYPH_SLOT + + (GLYPH_SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap); + } + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("emphasis-treatments-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index 7e769b1bd..3bd8be3a2 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -155,13 +155,28 @@ TEST_F(ThemeMigrationTest, IsIdempotent) TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) { - EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1b7f3b"))); + EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1a7d3a"))); EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, false), QColor(QStringLiteral("#007e28"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#32c05c"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#4c7659"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#8ab196"))); EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e"))); EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80"))); - // Accent roles are derived from the palette, so they have no static default. - EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, false).isValid()); - EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, true).isValid()); + EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#da0b00"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b"))); + // Accent and neutral icon roles have no static default: an older theme + // written before the roles existed falls back to deriving them from its + // palette rather than pinning one scheme's ink. + for (const bool dark : {false, true}) { + EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::Icon, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::IconStrong, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::IconSoft, dark).isValid()); + } } TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) @@ -170,10 +185,20 @@ TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) ASSERT_TRUE(dir.isValid()); PaletteConfig config; - const QColor positive(QStringLiteral("#1b7f3b")); - const QColor negative(QStringLiteral("#b3261e")); - config.appColors[AppColor::Positive] = positive; - config.appColors[AppColor::Negative] = negative; + const QMap roles = { + {AppColor::Icon, QColor(QStringLiteral("#2f59ad"))}, + {AppColor::IconStrong, QColor(QStringLiteral("#213e79"))}, + {AppColor::IconSoft, QColor(QStringLiteral("#687899"))}, + {AppColor::Positive, QColor(QStringLiteral("#1b7f3b"))}, + {AppColor::PositiveStrong, QColor(QStringLiteral("#104a22"))}, + {AppColor::PositiveSoft, QColor(QStringLiteral("#5d7f6f"))}, + {AppColor::Negative, QColor(QStringLiteral("#b3261e"))}, + {AppColor::NegativeStrong, QColor(QStringLiteral("#7c1b15"))}, + {AppColor::NegativeSoft, QColor(QStringLiteral("#9a6c6f"))}, + }; + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + config.appColors[it.key()] = it.value(); + } const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml")); QFile file(path); @@ -182,10 +207,10 @@ TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) file.close(); const PaletteConfig parsed = PaletteConfig::fromFile(path); - ASSERT_TRUE(parsed.appColors.contains(AppColor::Positive)); - ASSERT_TRUE(parsed.appColors.contains(AppColor::Negative)); - EXPECT_EQ(parsed.appColors.value(AppColor::Positive), positive); - EXPECT_EQ(parsed.appColors.value(AppColor::Negative), negative); + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + ASSERT_TRUE(parsed.appColors.contains(it.key())); + EXPECT_EQ(parsed.appColors.value(it.key()), it.value()); + } } // Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and From 6138fbd0a09395aaaa18f4f943ba67c2737c93ee Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:57:45 +0100 Subject: [PATCH 101/104] [Card] Add Scryfall Tagger tag search and filtering (#7337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Card] Import Scryfall Tagger tags from MTGJson * [Card] Search cards by Scryfall Tagger tag * [Card] Add a Tag attribute to the card filter tree * [Card] Add a Tags filter to the visual card database * [Oracle] Add a script to inject Scryfall tags into AllPrintings * [Card] Elide long tag lists in the card info and drop the Scryfall label from the tag filter tooltip * [Oracle] Load card tags from a tag database during import Add a TagDatabase loader that understands Scryfall's Oracle Tags bulk data and user-supplied tag files (tag objects, flat oracle-id or name maps, optional manifest, JSON or JSONL, gzip/xz/zip). The importer joins the slugs on identifiers.scryfallOracleId while the identifiers are still available, falls back to the card name, and unions them with any tags the source already provides. Split-card faces merge through the existing tag union path. This replaces oracle/scripts/inject_scryfall_tags.py, which patched AllPrintings.json before import. Loading during import keeps tags on by default for every source and lets users pair Oracle with any tag database. * [Oracle] Add a tag data step to the import wizard Insert a Tags page between set selection and the import. It downloads Scryfall's Oracle Tags by default (or a user-supplied file/URL), can include parent tags, and persists tagsenabled, tagssource and tagsparents in oracle.ini. The page loads asynchronously and soft-fails in background mode, so a tag download or parse error never aborts the card database import. * [Help] Describe tag search without naming Scryfall Tags can come from Scryfall's Oracle Tags or any user-supplied tag database, so the search help should not claim a single source. * [Oracle] Fix Scryfall tag requests and the retry loop api.scryfall.com rejects requests that lack an Accept header with HTTP 400, and QNetworkAccessManager does not add one. Send application/json for the bulk metadata request and */* for the tag file, and surface the URL plus Scryfall's JSON error body when a request fails. A failed tag load used to call next(), which re-ran validatePage() and started the same failing download again, trapping the user in an error loop. Stay on the page instead, drop the unusable database, and tell the user they can retry, change the source, or untick the option to import without tags. Also explain parent tags inline with a real Scryfall hierarchy example. * [Oracle] Give the parent-tag explainer room to breathe The wrapped explainer sat flush under the checkbox with no separation from the status label, which made the paragraph look cramped. Indent it under the checkbox and add a little vertical margin. * [Oracle] Stop the parent-tag explainer from being clipped A word-wrapped QLabel reports a one-line sizeHint, so the wizard page reserved too little height and the explanation lost its top and bottom lines. Pin the vertical size policy to Minimum on the explainer and on the status label, so the wrapped height becomes part of the page minimum. Verified with a layout probe: the label went from 28 px tall (needed 56) to its full height. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + cockatrice/cockatrice.qrc | 1 + cockatrice/resources/help/search.md | 5 + cockatrice/resources/icons/tags.svg | 4 + .../widgets/cards/card_info_text_widget.cpp | 42 +- .../widgets/cards/card_info_text_widget.h | 3 +- ...database_display_filter_toolbar_widget.cpp | 13 +- ...l_database_display_filter_toolbar_widget.h | 3 + ...ual_database_display_tag_filter_widget.cpp | 198 ++++++ ...isual_database_display_tag_filter_widget.h | 47 ++ .../card/database/card_database_querier.cpp | 17 + .../card/database/card_database_querier.h | 11 + .../libcockatrice/card/game_specific_terms.h | 4 + .../libcockatrice/filters/filter_card.cpp | 2 + .../libcockatrice/filters/filter_card.h | 1 + .../libcockatrice/filters/filter_string.cpp | 18 +- .../libcockatrice/filters/filter_tree.cpp | 13 + .../libcockatrice/filters/filter_tree.h | 1 + oracle/CMakeLists.txt | 1 + oracle/src/oracleimporter.cpp | 34 + oracle/src/oracleimporter.h | 18 + oracle/src/oraclewizard.cpp | 3 +- oracle/src/oraclewizard.h | 4 + oracle/src/pages.cpp | 505 ++++++++++++++ oracle/src/pages.h | 70 ++ oracle/src/tag_database.cpp | 643 ++++++++++++++++++ oracle/src/tag_database.h | 69 ++ tests/carddatabase/filter_string_test.cpp | 59 ++ tests/oracle/CMakeLists.txt | 82 ++- tests/oracle/oracle_importer_test.cpp | 153 +++++ tests/oracle/tag_database_test.cpp | 254 +++++++ 31 files changed, 2242 insertions(+), 37 deletions(-) create mode 100644 cockatrice/resources/icons/tags.svg create mode 100644 cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp create mode 100644 cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h create mode 100644 oracle/src/tag_database.cpp create mode 100644 oracle/src/tag_database.h create mode 100644 tests/oracle/tag_database_test.cpp diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index cc6debdf0..a09a81ac9 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -317,6 +317,7 @@ set(cockatrice_SOURCES src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp + src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_widget.cpp src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 229cdd125..e398033e9 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -49,6 +49,7 @@ resources/icons/swap.svg resources/icons/sync.svg resources/icons/tab_changed.svg + resources/icons/tags.svg resources/icons/view.svg resources/icons/mana/B.svg diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index fd0a12507..f29e57bec 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -22,6 +22,11 @@ In this list of examples below, each entry has an explanation and can be clicked
    [t:basic](#t:basic) (Any card with the type basic)
    [t:arcane t:instant](#t:arcane t:instant) (Any card with the types arcane and instant)
    +
    Tags:
    +
    [tags:ramp](#tags:ramp) (Any card tagged "ramp")
    +
    [tags:draw tags:ramp](#tags:draw tags:ramp) (Any card tagged both "draw" and "ramp")
    +
    [tags:draw OR tags:ramp](#tags:draw OR tags:ramp) (Any card tagged "draw" or "ramp")
    +
    Colors:
    [c:w](#c:w) (Any card that is white)
    [c:wu](#c:wu) (Any card that is white or blue)
    diff --git a/cockatrice/resources/icons/tags.svg b/cockatrice/resources/icons/tags.svg new file mode 100644 index 000000000..e3defcd0d --- /dev/null +++ b/cockatrice/resources/icons/tags.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index 218d0e964..a94f01085 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -23,12 +23,25 @@ #include #include +namespace +{ +constexpr int TAGS_PREVIEW_LIMIT = 3; +const QString SHOW_ALL_TAGS_LINK = QStringLiteral("cockatrice://show-all-tags"); +} // namespace + CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) { propsLabel = new QLabel; propsLabel->setOpenExternalLinks(false); propsLabel->setWordWrap(true); - connect(propsLabel, SIGNAL(linkActivated(const QString &)), this, SIGNAL(linkActivated(const QString &))); + connect(propsLabel, &QLabel::linkActivated, this, [this](const QString &link) { + if (link == SHOW_ALL_TAGS_LINK) { + showAllTags = true; + setCard(currentCard); + return; + } + emit linkActivated(link); + }); textLabel = new QTextEdit(); textLabel->setReadOnly(true); @@ -75,6 +88,12 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) return; } + // Expanding the tag list re-renders the same card, so only reset the + // collapsed state when a genuinely different card is shown. + if (exactCard != currentCard) { + showAllTags = false; + } + QString text = ""; text += QString("") .arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped()); @@ -94,6 +113,27 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) continue; } QString keyText = Mtg::getNicePropertyName(key).toHtmlEscaped() + ":"; + + if (key == Mtg::Tags) { + const QStringList tags = card->getProperty(Mtg::Tags).split(" ", Qt::SkipEmptyParts); + + QString tagsText; + if (!showAllTags && tags.size() > TAGS_PREVIEW_LIMIT) { + const QStringList preview = tags.mid(0, TAGS_PREVIEW_LIMIT); + const int remaining = tags.size() - preview.size(); + // Join with non-breaking spaces so "… and N more" never wraps mid-phrase. + const QString moreText = + tr("and %n more", nullptr, remaining).toHtmlEscaped().split(" ").join(" "); + tagsText = preview.join(", ").toHtmlEscaped() + + QString(" … %2").arg(SHOW_ALL_TAGS_LINK, moreText); + } else { + tagsText = tags.join(", ").toHtmlEscaped(); + } + + text += QString("").arg(keyText, tagsText); + continue; + } + text += QString("").arg(keyText, card->getProperty(key).toHtmlEscaped()); } diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h index 8223093fe..eae2fb8e3 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -26,7 +26,8 @@ private: QLabel *propsLabel; QScrollArea *propsScroll; QTextEdit *textLabel; - ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. + ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. + bool showAllTags = false; ///< Whether the potentially long tag list is fully expanded. void setTexts(const QString &propsText, const QString &textText); public: diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index ecb949b67..f109b25c1 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -10,6 +10,7 @@ #include "visual_database_display_name_filter_widget.h" #include "visual_database_display_set_filter_widget.h" #include "visual_database_display_sub_type_filter_widget.h" +#include "visual_database_display_tag_filter_widget.h" #include "visual_database_display_widget.h" #include @@ -106,6 +107,9 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); + quickFilterTagWidget = new SettingsButtonWidget(this); + quickFilterTagWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/tags"))); + retranslateUi(); } @@ -126,6 +130,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize() formatLegalityWidget = new VisualDatabaseDisplayFormatLegalityFilterWidget(this, filterModel); subTypeFilterWidget = new VisualDatabaseDisplaySubTypeFilterWidget(this, filterModel); setFilterWidget = new VisualDatabaseDisplaySetFilterWidget(this, filterModel); + tagFilterWidget = new VisualDatabaseDisplayTagFilterWidget(this, filterModel); quickFilterSaveLoadWidget->addSettingsWidget(saveLoadWidget); quickFilterNameWidget->addSettingsWidget(nameFilterWidget); @@ -133,6 +138,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize() quickFilterSubTypeWidget->addSettingsWidget(subTypeFilterWidget); quickFilterSetWidget->addSettingsWidget(setFilterWidget); quickFilterFormatLegalityWidget->addSettingsWidget(formatLegalityWidget); + quickFilterTagWidget->addSettingsWidget(tagFilterWidget); // fill groupbox layouts sortLayout->addWidget(sortByLabel); @@ -145,6 +151,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize() filterLayout->addWidget(quickFilterSubTypeWidget); filterLayout->addWidget(quickFilterSetWidget); filterLayout->addWidget(quickFilterFormatLegalityWidget); + filterLayout->addWidget(quickFilterTagWidget); // put everything into main layout addWidget(sortGroupBox); @@ -172,6 +179,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi() quickFilterSubTypeWidget->setToolTip(tr("Filter by card sub-type")); quickFilterSetWidget->setToolTip(tr("Filter by set")); quickFilterFormatLegalityWidget->setToolTip(tr("Filter by format legality")); + quickFilterTagWidget->setToolTip(tr("Filter by card tag")); quickFilterSaveLoadWidget->setButtonText(tr("Save/Load")); quickFilterNameWidget->setButtonText(tr("Name")); @@ -179,6 +187,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi() quickFilterSubTypeWidget->setButtonText(tr("Sub Type")); quickFilterSetWidget->setButtonText(tr("Sets")); quickFilterFormatLegalityWidget->setButtonText(tr("Formats")); + quickFilterTagWidget->setButtonText(tr("Tags")); } void VisualDatabaseDisplayFilterToolbarWidget::resizeEvent(QResizeEvent *event) @@ -192,8 +201,8 @@ void VisualDatabaseDisplayFilterToolbarWidget::updateCompactMode(int availableWi const bool compact = availableWidth < fullWidthHint; const QList filterButtons = { - quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, - quickFilterSubTypeWidget, quickFilterSetWidget, quickFilterFormatLegalityWidget, + quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, quickFilterSubTypeWidget, + quickFilterSetWidget, quickFilterFormatLegalityWidget, quickFilterTagWidget, }; for (auto *btn : filterButtons) { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h index f97310c1b..9f36f3cd1 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h @@ -17,6 +17,7 @@ class VisualDatabaseDisplayMainTypeFilterWidget; class VisualDatabaseDisplayNameFilterWidget; class VisualDatabaseDisplaySetFilterWidget; class VisualDatabaseDisplaySubTypeFilterWidget; +class VisualDatabaseDisplayTagFilterWidget; class VisualDatabaseDisplayFilterToolbarWidget : public FlowWidget { @@ -54,6 +55,8 @@ private: VisualDatabaseDisplaySetFilterWidget *setFilterWidget; SettingsButtonWidget *quickFilterFormatLegalityWidget; VisualDatabaseDisplayFormatLegalityFilterWidget *formatLegalityWidget; + SettingsButtonWidget *quickFilterTagWidget; + VisualDatabaseDisplayTagFilterWidget *tagFilterWidget; int fullWidthHint = 0; void updateCompactMode(int availableWidth); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp new file mode 100644 index 000000000..ab7bb27a4 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp @@ -0,0 +1,198 @@ +#include "visual_database_display_tag_filter_widget.h" + +#include "../../../filters/filter_tree_model.h" +#include "visual_database_display_filter_button.h" + +#include +#include +#include +#include +#include +#include +#include + +VisualDatabaseDisplayTagFilterWidget::VisualDatabaseDisplayTagFilterWidget(QWidget *parent, + FilterTreeModel *_filterModel) + : QWidget(parent), filterModel(_filterModel) +{ + allTagsWithCount = CardDatabaseManager::query()->getAllTagsWithCount(); + + setMinimumWidth(300); + setMaximumHeight(300); + + layout = new QVBoxLayout(this); + setLayout(layout); + layout->setContentsMargins(0, 1, 0, 1); + layout->setSpacing(1); + layout->setAlignment(Qt::AlignTop); + + // Create search box + searchBox = new QLineEdit(this); + layout->addWidget(searchBox); + connect(searchBox, &QLineEdit::textChanged, this, + &VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + layout->addWidget(flowWidget); + + // Create a container for the threshold control + auto *thresholdLayout = new QHBoxLayout(); + thresholdLayout->setContentsMargins(0, 0, 0, 0); + + thresholdLabel = new QLabel(this); + thresholdLayout->addWidget(thresholdLabel); + + // Create the spinbox + spinBox = new QSpinBox(this); + spinBox->setMinimum(1); + spinBox->setMaximum(getMaxTagCount()); + spinBox->setValue(150); + thresholdLayout->addWidget(spinBox); + thresholdLayout->addStretch(); + + layout->addLayout(thresholdLayout); + + connect(spinBox, qOverload(&QSpinBox::valueChanged), this, + &VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility); + + // Create the toggle button for Exact Match/Includes mode + toggleButton = new QPushButton(this); + layout->addWidget(toggleButton); + connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayTagFilterWidget::updateFilterMode); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayTagFilterWidget::syncWithFilterModel); }); + + createTagButtons(); // Populate buttons initially + updateFilterMode(); // Initialize toggle button text + + retranslateUi(); +} + +void VisualDatabaseDisplayTagFilterWidget::retranslateUi() +{ + searchBox->setPlaceholderText(tr("Search tags...")); + thresholdLabel->setText(tr("Show tags with at least:")); + spinBox->setSuffix(tr(" cards")); + spinBox->setToolTip(tr("Do not display tags with less than this amount of cards in the database")); + toggleButton->setToolTip(tr("Filter mode (require all selected tags, or any of them)")); +} + +void VisualDatabaseDisplayTagFilterWidget::createTagButtons() +{ + // Iterate through tags and create buttons + for (auto it = allTagsWithCount.begin(); it != allTagsWithCount.end(); ++it) { + auto *button = new QPushButton(it.key(), flowWidget); + button->setCheckable(true); + button->setStyleSheet(visualDatabaseDisplayFilterButtonStyle); + + flowWidget->addWidget(button); + tagButtons[it.key()] = button; + + // Connect toggle signal for each button + connect(button, &QPushButton::toggled, this, + [this, tag = it.key()](bool checked) { handleTagToggled(tag, checked); }); + } + updateTagButtonsVisibility(); // Ensure visibility is updated initially +} + +void VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility() +{ + const int threshold = spinBox->value(); + const QString filterText = searchBox->text().trimmed().toLower(); + + // Iterate through buttons and hide/disable those below the threshold. An + // active search overrides the threshold so rare tags stay reachable. + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + const bool isActive = activeTags.value(it.key(), false); + const bool visible = isActive || (filterText.isEmpty() ? allTagsWithCount[it.key()] >= threshold + : it.key().toLower().contains(filterText)); + + it.value()->setVisible(visible); + it.value()->setEnabled(visible); + } +} + +int VisualDatabaseDisplayTagFilterWidget::getMaxTagCount() const +{ + int maxCount = 1; + for (auto it = allTagsWithCount.begin(); it != allTagsWithCount.end(); ++it) { + maxCount = qMax(maxCount, it.value()); + } + return maxCount; +} + +void VisualDatabaseDisplayTagFilterWidget::handleTagToggled(const QString &tag, bool active) +{ + activeTags[tag] = active; + + if (tagButtons.contains(tag)) { + tagButtons[tag]->setChecked(active); + } + + updateTagFilter(); +} + +void VisualDatabaseDisplayTagFilterWidget::updateTagFilter() +{ + filterModel->blockSignals(true); + filterModel->filterTree()->blockSignals(true); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrTag); + + for (const auto &activeTag : activeTags.keys()) { + if (activeTags[activeTag]) { + QString tag = activeTag; + filterModel->addFilter(new CardFilter( + tag, exactMatchMode ? CardFilter::Type::TypeAnd : CardFilter::Type::TypeOr, CardFilter::Attr::AttrTag)); + } + } + + filterModel->blockSignals(false); + filterModel->filterTree()->blockSignals(false); + + emit filterModel->filterTree()->changed(); + emit filterModel->layoutChanged(); +} + +void VisualDatabaseDisplayTagFilterWidget::updateFilterMode() +{ + exactMatchMode = !exactMatchMode; + toggleButton->setText(exactMatchMode ? tr("Mode: Exact Match") : tr("Mode: Includes")); + updateTagFilter(); +} + +void VisualDatabaseDisplayTagFilterWidget::syncWithFilterModel() +{ + // Temporarily block signals for each button to prevent toggling while updating button states + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->blockSignals(true); + } + + // Uncheck all buttons + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->setChecked(false); + } + + // Get active filters for tags + QSet activeTypes; + for (const auto &filter : filterModel->getFiltersOfType(CardFilter::AttrTag)) { + if (filter->type() == CardFilter::Type::TypeAnd || filter->type() == CardFilter::Type::TypeOr) { + activeTypes.insert(filter->term()); + } + } + + // Check the buttons for active tags + for (const auto &tag : activeTypes) { + activeTags[tag] = true; + if (tagButtons.contains(tag)) { + tagButtons[tag]->setChecked(true); + } + } + + // Re-enable signal emissions for each button + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->blockSignals(false); + } + + // Update the visibility of buttons + updateTagButtonsVisibility(); +} diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h new file mode 100644 index 000000000..092a10587 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h @@ -0,0 +1,47 @@ +#ifndef COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H +#define COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H + +#include "../../../filters/filter_tree_model.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +class VisualDatabaseDisplayTagFilterWidget : public QWidget +{ + Q_OBJECT +public: + explicit VisualDatabaseDisplayTagFilterWidget(QWidget *parent, FilterTreeModel *filterModel); + void retranslateUi(); + void createTagButtons(); + void updateTagButtonsVisibility(); + [[nodiscard]] int getMaxTagCount() const; + + void handleTagToggled(const QString &tag, bool active); + void updateTagFilter(); + void updateFilterMode(); + void syncWithFilterModel(); + +private: + FilterTreeModel *filterModel; + QMap allTagsWithCount; + + QVBoxLayout *layout; + QLineEdit *searchBox; + FlowWidget *flowWidget; + QLabel *thresholdLabel; + QSpinBox *spinBox; + QPushButton *toggleButton; // Mode switch button + + QMap activeTags; // Track active filters + QMap tagButtons; // Store toggle buttons + + bool exactMatchMode = true; // Toggle between "Exact Match" (all) and "Includes" (any) +}; + +#endif // COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 8df0d2e20..fb8542808 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -1,6 +1,7 @@ #include "card_database_querier.h" #include "../card_info.h" +#include "../game_specific_terms.h" #include "../printing/exact_card.h" #include "../set/card_set_comparator.h" #include "card_database.h" @@ -31,6 +32,7 @@ void CardDatabaseQuerier::invalidateCaches() mainCardTypeCountsCache.clear(); subCardTypeCountsCache.clear(); formatsCountCache.clear(); + tagCountsCache.clear(); } /** @@ -381,6 +383,21 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const return db->formats.value(formatName.toLower()); } +QMap CardDatabaseQuerier::getAllTagsWithCount() const +{ + if (tagCountsCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + const QStringList tags = card->getProperty(Mtg::Tags).split(" ", Qt::SkipEmptyParts); + + for (const QString &tag : tags) { + tagCountsCache[tag]++; + } + } + } + + return tagCountsCache; +} + QMap CardDatabaseQuerier::getAllFormatsWithCount() const { if (formatsCountCache.isEmpty()) { diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h index a1409b3cc..8508e4245 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h @@ -220,6 +220,16 @@ public: * @return Map of subtype string to count. */ [[nodiscard]] QMap getAllSubCardTypesWithCount() const; + + /** + * @brief Returns a mapping of Scryfall Tagger tag slugs to their occurrence counts. + * + * Tags are stored space-separated in the `tags` card property, so a card + * tagged "ramp" and "removal" contributes one to each slug's count. + * + * @return Map of tag slug to count. + */ + [[nodiscard]] QMap getAllTagsWithCount() const; FormatRulesPtr getFormat(const QString &formatName) const; QMap getAllFormatsWithCount() const; @@ -233,6 +243,7 @@ private: mutable QMap mainCardTypeCountsCache; mutable QMap subCardTypeCountsCache; mutable QMap formatsCountCache; + mutable QMap tagCountsCache; private slots: void invalidateCaches(); diff --git a/libcockatrice_card/libcockatrice/card/game_specific_terms.h b/libcockatrice_card/libcockatrice/card/game_specific_terms.h index e9160e514..5be8ec6d5 100644 --- a/libcockatrice_card/libcockatrice/card/game_specific_terms.h +++ b/libcockatrice_card/libcockatrice/card/game_specific_terms.h @@ -28,6 +28,7 @@ QString const PowTough("pt"); QString const Side("side"); QString const Layout("layout"); QString const ColorIdentity("coloridentity"); +QString const Tags("tags"); inline static const QString getNicePropertyName(QString key) { @@ -61,6 +62,9 @@ inline static const QString getNicePropertyName(QString key) if (key == ColorIdentity) { return QCoreApplication::translate("Mtg", "Color Identity"); } + if (key == Tags) { + return QCoreApplication::translate("Mtg", "Tags"); + } return key; } } // namespace Mtg diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 8af39c098..041ab9c0c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -89,6 +89,8 @@ const QString CardFilter::attrName(Attr a) return tr("Loyalty"); case AttrFormat: return tr("Format"); + case AttrTag: + return tr("Tag"); case AttrMainType: return tr("Main Type"); case AttrSubType: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index bc613fc9c..dee9e14bc 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -46,6 +46,7 @@ public: AttrMainType, AttrSubType, AttrFormat, + AttrTag, AttrEnd, }; diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index f7ca82344..e91817967 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -41,7 +42,7 @@ ComplexQueryPart <- SomewhatComplexQueryPart ws "OR" ws ComplexQueryPart / Somew SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart -QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery +QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TagQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart SetQuery <- ('e'/'set') ws? SetQueryValue @@ -63,6 +64,8 @@ Legality <- [Ll] 'egal'? / [Bb] 'anned'? / [Rr] 'estricted' TypeQuery <- [tT] 'ype'? ws? [:] ws? StringValue +TagQuery <- [tT]('ags'/'ag') [:] String + Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? > ColorEx <- Color / [mc] @@ -158,6 +161,19 @@ static void setupParserRules() const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; + search["TagQuery"] = [](const peg::SemanticValues &sv) -> Filter { + // Tags are stored space-separated, so matching whole tokens keeps + // `tags:ram` from matching `ramp`. Combine tags with AND: + // `tags:draw tags:ramp`. + const auto tag = std::any_cast(sv[0]).trimmed(); + return [=](const CardData &x) -> bool { + const QString stored = x->getProperty(Mtg::Tags); + if (stored.isEmpty() || tag.isEmpty()) { + return false; + } + return stored.split(" ", Qt::SkipEmptyParts).contains(tag, Qt::CaseInsensitive); + }; + }; search["SetQueryValue"] = [](const peg::SemanticValues &sv) -> Filter { if (sv.choice() == 0) { auto matcher = std::any_cast(sv[0]); diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index 817c7298d..13c78e1f7 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -2,6 +2,7 @@ #include "filter_card.h" #include "libcockatrice/card/card_localization.h" +#include "libcockatrice/card/game_specific_terms.h" #include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/card/set/card_set.h" @@ -477,6 +478,16 @@ bool FilterItem::acceptFormat(const CardInfoPtr &info) const return info->getLegalityProp(lowercaseTerm) == "legal"; } +bool FilterItem::acceptTag(const CardInfoPtr &info) const +{ + const QString stored = info->getProperty(Mtg::Tags); + if (stored.isEmpty()) { + return false; + } + // Tags are stored space-separated; match whole slugs, not substrings. + return stored.split(" ", Qt::SkipEmptyParts).contains(term.trimmed(), Qt::CaseInsensitive); +} + bool FilterItem::acceptLoyalty(const CardInfoPtr &info) const { const QString loyaltyString = info->getLoyalty(); @@ -576,6 +587,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptLoyalty(info); case CardFilter::AttrFormat: return acceptFormat(info); + case CardFilter::AttrTag: + return acceptTag(info); case CardFilter::AttrMainType: return acceptMainType(info); case CardFilter::AttrSubType: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index f1a5803e5..1b899fed7 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -255,6 +255,7 @@ public: [[nodiscard]] bool acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptFormat(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptTag(const CardInfoPtr &info) const; [[nodiscard]] bool relationCheck(int cardInfo) const; }; diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index b500f79ab..6c61a7e27 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -25,6 +25,7 @@ set(oracle_SOURCES src/pagetemplates.cpp src/parsehelpers.cpp src/raw_json_scanner.cpp + src/tag_database.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index c83c962e3..4657bf055 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -6,6 +6,7 @@ #include "libcockatrice/card/relation/card_relation_type.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "parsehelpers.h" +#include "tag_database.h" #include #include @@ -521,6 +522,31 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson properties.insert("coloridentity", colorIdentity); } + // Card tags: the source's own `tags` array is used when present (MTGJson + // may ship one eventually), then the configured tag database is joined in + // while the card's identifiers are still available. Stored space-separated + // so `tags:` can match each slug as a discrete token; slugs never contain + // whitespace. + QStringList tags; + for (const QJsonValue &tag : card.value("tags").toArray()) { + const QString tagSlug = tag.toString().trimmed().toLower(); + if (!tagSlug.isEmpty() && !tags.contains(tagSlug)) { + tags.append(tagSlug); + } + } + if (tagDatabase) { + const QString oracleId = getJsonString(identifiers, "scryfallOracleId"); + for (const QString &tagSlug : tagDatabase->tagsForCard(oracleId, name)) { + if (!tags.contains(tagSlug)) { + tags.append(tagSlug); + } + } + } + if (!tags.isEmpty()) { + tags.sort(); + properties.insert("tags", tags.join(" ")); + } + const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; @@ -667,6 +693,14 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) { properties.insert(prop, thisCardPropertyValue); } + } else if (prop == "tags") { + // Tags are oracle-level: union both faces instead of + // concatenating them with the split-card separator. + QStringList merged = + (originalPropertyValue + " " + thisCardPropertyValue).split(" ", Qt::SkipEmptyParts); + merged.removeDuplicates(); + merged.sort(); + properties.insert(prop, merged.join(" ")); } else { properties.insert(prop, originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 5ebe9cf36..694babf4b 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -17,12 +17,14 @@ #include #include #include +#include #include #include #include class CardRelation; class QRegularExpression; +class TagDatabase; // many users prefer not to see these sets with non english arts // they will given priority PriorityLowest @@ -215,6 +217,12 @@ private: */ bool localizationEnabled = false; + /** + * Optional tag lookup applied to every imported card. Owned by the wizard + * so it outlives the import worker; null when tags are disabled. + */ + std::shared_ptr tagDatabase; + /** * Localized name/text collected per imported card key while parsing sets, * applied to the CardInfo objects by applyLocalizedData() once all @@ -269,6 +277,16 @@ public: { progressReporting = enabled; } + /** + * @brief Supplies the tag database applied to every imported card. + * + * Pass nullptr to import without tags. The wizard keeps the database alive + * for the whole import, so the worker sees a stable pointer. + */ + void setTagDatabase(std::shared_ptr database) + { + tagDatabase = std::move(database); + } /** * Selects the ISO-639 language code whose foreignData is imported. * English (the default) and unsupported codes disable localization. diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index dc8c7ecb3..43e6f30ca 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -57,7 +57,8 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) QList pages; if (!isSpoilersOnly) { - pages << new IntroPage << new LoadSetsPage << new SaveSetsPage << new LoadTokensPage << new OutroPage; + pages << new IntroPage << new LoadSetsPage << new TagsPage << new SaveSetsPage << new LoadTokensPage + << new OutroPage; } else { pages << new LoadSpoilersPage << new OutroPage; } diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h index b1dc5eab2..121acd577 100644 --- a/oracle/src/oraclewizard.h +++ b/oracle/src/oraclewizard.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -11,6 +12,7 @@ class QNetworkAccessManager; class OracleImporter; class QLocalServer; class QSettings; +class TagDatabase; class QWidget; class OracleWizard : public QWizard @@ -68,6 +70,8 @@ public: OracleImporter *importer; QSettings *settings; QNetworkAccessManager *nam; + /** @brief Tag data loaded by the Tags page, consumed by the importer. */ + std::shared_ptr tagDatabase; bool downloadedPlainXml = false; QByteArray xmlData; bool backgroundMode = false; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index f5c38be1c..cf23412c2 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -6,6 +6,7 @@ #include "oracleimporter.h" #include "oraclewizard.h" #include "pagetemplates.h" +#include "tag_database.h" #include "version_string.h" #include @@ -19,7 +20,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -36,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -72,6 +76,7 @@ class QWidget; #define MTGJSON_V4_URL_COMPONENT "mtgjson.com/files/" #define ALLSETS_URL_FALLBACK "https://www.mtgjson.com/api/v5/AllPrintings.json" #define MTGJSON_VERSION_URL "https://www.mtgjson.com/api/v5/Meta.json" +#define SCRYFALL_TAGS_URL "https://api.scryfall.com/bulk-data/oracle-tags" #ifdef HAS_LZMA #define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json.xz" @@ -707,6 +712,502 @@ void LoadSetsPage::importFinished() }); } +namespace +{ +/** + * @brief True when a URL points at a tag file rather than Scryfall's bulk metadata. + */ +bool looksLikeTagFileUrl(const QUrl &url) +{ + const QString path = url.path().toLower(); + return path.endsWith(".json") || path.endsWith(".jsonl") || path.endsWith(".gz") || path.endsWith(".xz") || + path.endsWith(".zip"); +} + +/** + * @brief Extends Qt's terse network error with a JSON error body. + * + * Scryfall explains a rejected request in the response body (for example a + * missing Accept header); QNetworkReply::errorString() only says "status code + * 400". Non-JSON bodies (CDN HTML pages) are ignored so the dialog stays short. + */ +QString networkErrorDetail(QNetworkReply *reply) +{ + QString detail = reply->errorString(); + const QString body = QString::fromUtf8(reply->readAll()).trimmed(); + if (body.startsWith('{') && body.size() <= 300) { + detail += QStringLiteral(" ") + body; + } + return detail; +} +} // namespace + +TagsPage::TagsPage(QWidget *parent) : OracleWizardPage(parent) +{ + enableCheckBox = new QCheckBox(this); + enableCheckBox->setChecked(true); + connect(enableCheckBox, &QCheckBox::toggled, this, &TagsPage::updateSourceEnabled); + + urlRadioButton = new QRadioButton(this); + fileRadioButton = new QRadioButton(this); + urlRadioButton->setChecked(true); + connect(urlRadioButton, &QRadioButton::toggled, this, &TagsPage::updateSourceEnabled); + connect(fileRadioButton, &QRadioButton::toggled, this, &TagsPage::updateSourceEnabled); + + urlLineEdit = new QLineEdit(this); + fileLineEdit = new QLineEdit(this); + + urlButton = new QPushButton(this); + connect(urlButton, &QPushButton::clicked, this, &TagsPage::actRestoreDefaultSource); + + fileButton = new QPushButton(this); + connect(fileButton, &QPushButton::clicked, this, &TagsPage::actChooseFile); + + parentsCheckBox = new QCheckBox(this); + + parentsExplainer = new QLabel(this); + parentsExplainer->setWordWrap(true); + // Indent under the checkbox and give the wrapped text room above and below, + // so it reads as an explanation of the option rather than a caption glued + // to the controls around it. + parentsExplainer->setContentsMargins(24, 4, 0, 10); + // A word-wrapped label reports a one-line sizeHint, so a short page squeezes + // it and clips the text top and bottom. Pinning the vertical policy makes + // the wrapped height part of the page's minimum size. + parentsExplainer->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + + statusLabel = new QLabel(this); + statusLabel->setWordWrap(true); + statusLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + statusLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + + progressLabel = new QLabel(this); + progressBar = new QProgressBar(this); + progressLabel->hide(); + progressBar->hide(); + + auto *layout = new QGridLayout(this); + layout->addWidget(enableCheckBox, 0, 0, 1, 2); + layout->addWidget(urlRadioButton, 1, 0); + layout->addWidget(urlLineEdit, 1, 1); + layout->addWidget(urlButton, 2, 1, Qt::AlignRight); + layout->addWidget(fileRadioButton, 3, 0); + layout->addWidget(fileLineEdit, 3, 1); + layout->addWidget(fileButton, 4, 1, Qt::AlignRight); + layout->addWidget(parentsCheckBox, 5, 0, 1, 2); + layout->addWidget(parentsExplainer, 6, 0, 1, 2); + layout->addWidget(statusLabel, 7, 0, 1, 2); + layout->addWidget(progressLabel, 8, 0); + layout->addWidget(progressBar, 8, 1); + layout->setRowStretch(9, 1); + setLayout(layout); + + connect(&watcher, &QFutureWatcher::finished, this, &TagsPage::actLoadFinished); + + updateSourceEnabled(); + retranslateUi(); +} + +void TagsPage::retranslateUi() +{ + setTitle(tr("Tag data")); + setSubTitle(tr("Card tags let you search and filter by tag. Oracle downloads Scryfall's Oracle Tags by " + "default; you can also point it at your own tag database.")); + + enableCheckBox->setText(tr("Download card tags")); + enableCheckBox->setToolTip( + tr("Apply tag slugs to the card database so cards can be searched and filtered by tag.")); + urlRadioButton->setText(tr("Download URL:")); + urlButton->setText(tr("Restore default URL")); + fileRadioButton->setText(tr("Local file:")); + fileButton->setText(tr("Choose file...")); + parentsCheckBox->setText(tr("Include parent tags")); + parentsCheckBox->setToolTip(tr("Also apply the broader tags that this tag belongs to.")); + parentsExplainer->setText( + tr("Tags form a hierarchy: \"tutor-creature-giant\" belongs to \"tutor-creature\", which belongs to " + "\"tutor\". With this on, a card tagged with the specific tag also matches searches for its broader " + "parent tags. It adds many more tags to each card.")); +} + +bool TagsPage::isComplete() const +{ + return !loadActive; +} + +void TagsPage::initializePage() +{ + enableCheckBox->setChecked(wizard()->settings->value("tagsenabled", true).toBool()); + parentsCheckBox->setChecked(wizard()->settings->value("tagsparents", false).toBool()); + + const QString source = wizard()->settings->value("tagssource").toString(); + if (source.isEmpty() || source.startsWith("http://") || source.startsWith("https://")) { + urlRadioButton->setChecked(true); + urlLineEdit->setText(source.isEmpty() ? QString(SCRYFALL_TAGS_URL) : source); + fileLineEdit->clear(); + } else { + fileRadioButton->setChecked(true); + fileLineEdit->setText(source); + urlLineEdit->setText(SCRYFALL_TAGS_URL); + } + + statusLabel->clear(); + progressLabel->hide(); + progressBar->hide(); + updateSourceEnabled(); + + if (wizard()->backgroundMode && isEnabled()) { + // In background mode runInBackground() calls initializePage() instead of + // next(), so a page that has nothing to do must advance itself. + if (validatePage()) { + emit readyToContinue(); + } + } +} + +QString TagsPage::selectedSource() const +{ + return urlRadioButton->isChecked() ? urlLineEdit->text().trimmed() : fileLineEdit->text().trimmed(); +} + +void TagsPage::updateSourceEnabled() +{ + const bool enabled = enableCheckBox->isChecked(); + const bool usingUrl = urlRadioButton->isChecked(); + const bool usingFile = fileRadioButton->isChecked(); + + urlRadioButton->setEnabled(enabled); + fileRadioButton->setEnabled(enabled); + urlLineEdit->setEnabled(enabled && usingUrl); + urlButton->setEnabled(enabled && usingUrl); + fileLineEdit->setEnabled(enabled && usingFile); + fileButton->setEnabled(enabled && usingFile); + parentsCheckBox->setEnabled(enabled); + parentsExplainer->setEnabled(enabled); +} + +void TagsPage::saveSettings() +{ + wizard()->settings->setValue("tagsenabled", enableCheckBox->isChecked()); + wizard()->settings->setValue("tagsparents", parentsCheckBox->isChecked()); + + QString source = selectedSource(); + if (urlRadioButton->isChecked() && source == SCRYFALL_TAGS_URL) { + source.clear(); + } + if (source.isEmpty()) { + wizard()->settings->remove("tagssource"); + } else { + wizard()->settings->setValue("tagssource", source); + } +} + +bool TagsPage::validatePage() +{ + if (!enableCheckBox->isChecked()) { + wizard()->tagDatabase.reset(); + saveSettings(); + return true; + } + + const auto rejectValidation = [this](const QString &message) { + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->tagDatabase.reset(); + emit readyToContinue(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; + + const QString source = selectedSource(); + const bool includeParents = parentsCheckBox->isChecked(); + + // Going back and forth through the wizard must not reload an unchanged source. + if (wizard()->tagDatabase && source == loadedSource && includeParents == loadedParents) { + return true; + } + + if (loadActive) { + return false; + } + + if (urlRadioButton->isChecked()) { + const QUrl url = QUrl::fromUserInput(source); + if (!url.isValid() || url.isEmpty()) { + return rejectValidation(tr("The provided tag URL is not valid.")); + } + } else if (source.isEmpty() || !QFile::exists(source)) { + return rejectValidation(tr("Please choose an existing tag database file.")); + } + + saveSettings(); + loadedSource = source; + loadedParents = includeParents; + + wizard()->disableButtons(); + setEnabled(false); + loadActive = true; + emit completeChanged(); + + progressBar->setRange(0, 0); + progressBar->setValue(0); + progressLabel->setText(tr("Fetching tag data...")); + progressLabel->show(); + progressBar->show(); + statusLabel->clear(); + + if (urlRadioButton->isChecked()) { + const QUrl url = QUrl::fromUserInput(source); + if (looksLikeTagFileUrl(url)) { + downloadTagFile(url); + } else { + downloadMetadata(url); + } + } else { + loadFromFile(source); + } + return false; +} + +void TagsPage::actChooseFile() +{ + QFileDialog dialog(this, tr("Load tag database")); + dialog.setFileMode(QFileDialog::ExistingFile); + + QString extensions = "*.json *.jsonl"; +#ifdef HAS_ZLIB + extensions += " *.gz *.zip"; +#endif +#ifdef HAS_LZMA + extensions += " *.xz"; +#endif + dialog.setNameFilter(tr("Tag database (%1)").arg(extensions)); + + if (!fileLineEdit->text().isEmpty() && QFile::exists(fileLineEdit->text())) { + dialog.selectFile(fileLineEdit->text()); + } + + if (!dialog.exec()) { + return; + } + + fileLineEdit->setText(dialog.selectedFiles().at(0)); + fileRadioButton->setChecked(true); + updateSourceEnabled(); +} + +void TagsPage::actRestoreDefaultSource() +{ + urlLineEdit->setText(SCRYFALL_TAGS_URL); + urlRadioButton->setChecked(true); + updateSourceEnabled(); +} + +void TagsPage::downloadMetadata(const QUrl &url) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + // api.scryfall.com rejects requests without an Accept header (HTTP 400); + // QNetworkAccessManager does not add one on its own. + request.setRawHeader("Accept", "application/json"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + activeReply = wizard()->nam->get(request); + connect(activeReply, &QNetworkReply::finished, this, &TagsPage::actMetadataFinished); +} + +void TagsPage::actMetadataFinished() +{ + auto *reply = qobject_cast(sender()); + if (!reply) { + return; + } + activeReply = nullptr; + reply->deleteLater(); + + if (wizard()->wasCancelled()) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + failLoad( + tr("Could not fetch tag metadata from %1: %2").arg(reply->url().toString(), networkErrorDetail(reply))); + return; + } + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(reply->readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + failLoad(tr("The tag metadata could not be read.")); + return; + } + + const QJsonObject metadata = document.object(); + QString downloadUrl = metadata.value("jsonl_download_uri").toString(); + if (downloadUrl.isEmpty()) { + downloadUrl = metadata.value("download_uri").toString(); + } + if (downloadUrl.isEmpty()) { + failLoad(tr("The tag data source did not provide a download URL.")); + return; + } + + downloadTagFile(QUrl::fromUserInput(downloadUrl)); +} + +void TagsPage::downloadTagFile(const QUrl &url) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + request.setRawHeader("Accept", "*/*"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + activeReply = wizard()->nam->get(request); + connect(activeReply, &QNetworkReply::finished, this, &TagsPage::actTagFileFinished); + connect(activeReply, &QNetworkReply::downloadProgress, this, &TagsPage::actDownloadProgress); +} + +void TagsPage::actDownloadProgress(qint64 received, qint64 total) +{ + emitProgress("download", received, total); + if (total > 0) { + progressBar->setRange(0, static_cast(total)); + progressBar->setValue(static_cast(received)); + } + progressLabel->setText(tr("Downloading tag data (%1 MB)").arg(received / (1024 * 1024))); +} + +void TagsPage::actTagFileFinished() +{ + auto *reply = qobject_cast(sender()); + if (!reply) { + return; + } + activeReply = nullptr; + reply->deleteLater(); + + if (wizard()->wasCancelled()) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + failLoad(tr("Could not download tag data from %1: %2").arg(reply->url().toString(), networkErrorDetail(reply))); + return; + } + + const QByteArray data = reply->readAll(); + if (data.isEmpty()) { + failLoad(tr("The tag download was empty.")); + return; + } + beginLoad(data); +} + +void TagsPage::loadFromFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + failLoad(tr("Cannot open file '%1'.").arg(path)); + return; + } + beginLoad(file.readAll()); +} + +void TagsPage::beginLoad(const QByteArray &data) +{ + progressBar->setRange(0, 0); + progressLabel->setText(tr("Reading tag data...")); + + const bool includeParents = parentsCheckBox->isChecked(); + future = QtConcurrent::run([data, includeParents]() { + LoadResult result; + result.database = TagDatabase::loadFromData(data, TagDatabase::LoadOptions{.includeParentTags = includeParents}, + &result.errorMessage); + return result; + }); + watcher.setFuture(future); +} + +void TagsPage::actLoadFinished() +{ + if (wizard()->wasCancelled()) { + return; + } + + const LoadResult result = watcher.result(); + + loadActive = false; + emit completeChanged(); + wizard()->enableButtons(); + setEnabled(true); + progressLabel->hide(); + progressBar->hide(); + + if (!result.database) { + wizard()->tagDatabase.reset(); + failLoad(result.errorMessage.isEmpty() ? tr("The tag data could not be read.") : result.errorMessage); + return; + } + + wizard()->tagDatabase = result.database; + statusLabel->setText(tr("Loaded %1 tags for %2 cards.") + .arg(wizard()->tagDatabase->tagCount()) + .arg(wizard()->tagDatabase->taggedCardCount())); + qInfo() << "Loaded tag database:" << wizard()->tagDatabase->tagCount() << "tags for" + << wizard()->tagDatabase->taggedCardCount() << "cards"; + advance(); +} + +void TagsPage::failLoad(const QString &message) +{ + loadActive = false; + emit completeChanged(); + wizard()->enableButtons(); + setEnabled(true); + progressLabel->hide(); + progressBar->hide(); + qWarning() << message; + + // The failed load leaves no usable database; never keep tags from a + // previous, different source. + wizard()->tagDatabase.reset(); + + if (wizard()->backgroundMode) { + statusLabel->setText(message); + emit readyToContinue(); + return; + } + + // Do not advance here. Advancing runs validatePage() again, which starts + // the same failing download and traps the user in an error loop. Staying + // on the page lets them fix the source, retry with Next, or opt out. + const QString guidance = tr("You can try again, choose another source, or untick \"Download card tags\" " + "to import without tags."); + statusLabel->setText(message + "\n\n" + guidance); + QMessageBox::warning(this, tr("Tags not loaded"), message + "\n\n" + guidance); +} + +void TagsPage::advance() +{ + if (wizard()->backgroundMode) { + emit readyToContinue(); + } else { + wizard()->next(); + } +} + +void TagsPage::cancelWork() +{ + if (activeReply) { + activeReply->abort(); + activeReply = nullptr; + } + if (future.isRunning()) { + future.cancel(); + watcher.cancel(); + future.waitForFinished(); + } +} + SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) { pathLabel = new QLabel(this); @@ -770,6 +1271,10 @@ void SaveSetsPage::initializePage() Qt::UniqueConnection); connect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished, Qt::UniqueConnection); + // The Tags page has already loaded (or deliberately skipped) the tag data. + // The wizard keeps it alive past this page; the importer only borrows it. + wizard()->importer->setTagDatabase(wizard()->tagDatabase); + wizard()->disableButtons(); importActive = true; emit completeChanged(); diff --git a/oracle/src/pages.h b/oracle/src/pages.h index e29a8e84a..49331765d 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -6,21 +6,25 @@ #include #include #include +#include #include #include #include +#include #include class QCheckBox; class QComboBox; class QLabel; class QLineEdit; +class QNetworkReply; class QRadioButton; class QProgressBar; class QTextEdit; class QPushButton; class QUrl; class QWidget; +class TagDatabase; class IntroPage : public OracleWizardPage { @@ -109,6 +113,72 @@ private slots: void zipDownloadFailed(const QString &message); }; +/** + * @brief Optional wizard step that loads the tag database used by the importer. + * + * Reloading is skipped when the source and parent-tag setting are unchanged, so + * going back and forth through the wizard does not re-download the tag data. + */ +class TagsPage : public OracleWizardPage +{ + Q_OBJECT +public: + explicit TagsPage(QWidget *parent = nullptr); + void retranslateUi() override; + bool isComplete() const override; + +protected: + void initializePage() override; + bool validatePage() override; + void cancelWork() override; + +private: + struct LoadResult + { + std::shared_ptr database; + QString errorMessage; + }; + + QCheckBox *enableCheckBox; + QRadioButton *urlRadioButton; + QRadioButton *fileRadioButton; + QLineEdit *urlLineEdit; + QLineEdit *fileLineEdit; + QPushButton *urlButton; + QPushButton *fileButton; + QCheckBox *parentsCheckBox; + QLabel *parentsExplainer; + QLabel *statusLabel; + QLabel *progressLabel; + QProgressBar *progressBar; + + QFutureWatcher watcher; + QFuture future; + QPointer activeReply; + bool loadActive = false; + QString loadedSource; + bool loadedParents = false; + + [[nodiscard]] QString selectedSource() const; + void updateSourceEnabled(); + void saveSettings(); + void beginLoad(const QByteArray &data); + void loadFromFile(const QString &path); + void downloadMetadata(const QUrl &url); + void downloadTagFile(const QUrl &url); + void finishLoad(); + void failLoad(const QString &message); + void advance(); + +private slots: + void actChooseFile(); + void actRestoreDefaultSource(); + void actDownloadProgress(qint64 received, qint64 total); + void actMetadataFinished(); + void actTagFileFinished(); + void actLoadFinished(); +}; + class SaveSetsPage : public OracleWizardPage { Q_OBJECT diff --git a/oracle/src/tag_database.cpp b/oracle/src/tag_database.cpp new file mode 100644 index 000000000..b9c57a969 --- /dev/null +++ b/oracle/src/tag_database.cpp @@ -0,0 +1,643 @@ +#include "tag_database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef HAS_LZMA +#include "lzma/decompress.h" +#endif + +#ifdef HAS_ZLIB +#include "zip/unzip.h" + +#include +#endif + +#include + +namespace +{ +using TagSets = QHash>; + +struct ParsedTag +{ + QString slug; + QStringList parentIds; + QList oracleIds; +}; + +struct ParseContext +{ + bool includeParentTags = false; + QHash tagsById; + TagSets oracleTags; + TagSets nameTags; +}; + +const QByteArray &gzipMagic() +{ + static const QByteArray magic = QByteArray::fromHex("1f8b"); + return magic; +} + +const QByteArray &xzMagic() +{ + static const QByteArray magic = QByteArray::fromHex("fd377a585a00"); + return magic; +} + +const QByteArray &zipMagic() +{ + static const QByteArray magic = QByteArray::fromHex("504b0304"); + return magic; +} + +QString normalizedSlug(const QString &slug) +{ + return slug.trimmed().toLower(); +} + +/** + * @brief Rejects slugs that cannot survive the space-separated `tags` property. + */ +bool isValidSlug(const QString &slug) +{ + if (slug.isEmpty()) { + return false; + } + for (const QChar &character : slug) { + if (character.isSpace()) { + return false; + } + } + return true; +} + +QString normalizedName(const QString &name) +{ + return name.trimmed().toLower(); +} + +/** + * @brief True when a map key has the shape of a Scryfall oracle id. + * + * A manifest with an explicit `key` removes the ambiguity for card names that + * happen to look like UUIDs. + */ +bool looksLikeOracleId(const QString &key) +{ + return !QUuid::fromString(key.trimmed()).isNull(); +} + +void addTags(TagSets &target, const QString &key, const QStringList &slugs) +{ + // Both key spaces are case-insensitive: Scryfall UUIDs are hex, and card + // names must match regardless of the source's capitalization. + const QString normalizedKey = key.trimmed().toLower(); + if (normalizedKey.isEmpty()) { + return; + } + + QSet &tags = target[normalizedKey]; + for (const QString &rawSlug : slugs) { + const QString slug = normalizedSlug(rawSlug); + if (isValidSlug(slug)) { + tags.insert(slug); + } + } + if (tags.isEmpty()) { + target.remove(normalizedKey); + } +} + +#ifdef HAS_ZLIB +bool decompressGzip(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + z_stream stream{}; + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Gzip support could not be initialized."); + } + return false; + } + + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + + int result = Z_OK; + char buffer[64 * 1024]; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = inflate(&stream, Z_NO_FLUSH); + + const uInt produced = static_cast(sizeof(buffer)) - stream.avail_out; + if (produced > 0) { + output.append(buffer, static_cast(produced)); + } + + if (result != Z_OK && result != Z_STREAM_END) { + inflateEnd(&stream); + if (errorMessage) { + *errorMessage = TagDatabase::tr("Gzip decompression failed."); + } + return false; + } + if (produced == 0 && stream.avail_in == 0 && result == Z_OK) { + inflateEnd(&stream); + if (errorMessage) { + *errorMessage = TagDatabase::tr("The gzip stream is truncated."); + } + return false; + } + } + + inflateEnd(&stream); + return result == Z_STREAM_END; +} +#endif + +#ifdef HAS_LZMA +bool decompressXz(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + QByteArray inputCopy = input; + QBuffer inputBuffer(&inputCopy); + QBuffer outputBuffer(&output); + inputBuffer.open(QBuffer::ReadOnly); + outputBuffer.open(QBuffer::WriteOnly); + + XzDecompressor decompressor; + if (!decompressor.decompress(&inputBuffer, &outputBuffer)) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Xz decompression failed."); + } + return false; + } + return true; +} +#endif + +#ifdef HAS_ZLIB +bool decompressZip(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + QByteArray inputCopy = input; + QBuffer inputBuffer(&inputCopy); + UnZip archive; + const UnZip::ErrorCode openError = archive.openArchive(&inputBuffer); + if (openError != UnZip::Ok) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Failed to open the Zip archive: %1.").arg(archive.formatError(openError)); + } + return false; + } + + const QStringList files = archive.fileList(); + if (files.size() != 1) { + archive.closeArchive(); + if (errorMessage) { + *errorMessage = TagDatabase::tr("The Zip archive must contain exactly one tag database file."); + } + return false; + } + + QBuffer outputBuffer(&output); + outputBuffer.open(QBuffer::ReadWrite); + const UnZip::ErrorCode extractError = archive.extractFile(files.first(), &outputBuffer); + archive.closeArchive(); + if (extractError != UnZip::Ok) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Zip extraction failed: %1.").arg(archive.formatError(extractError)); + } + return false; + } + return true; +} +#endif + +/** + * @brief Unwraps gzip/xz/zip payloads, including nested archives. + */ +bool decompressData(QByteArray &data, QString *errorMessage) +{ + // Nested compression is legitimate (a zip around a gzip), but a handful of + // layers is enough; the cap keeps a hostile file from looping forever. + for (int layer = 0; layer < 4; ++layer) { + if (data.startsWith(gzipMagic())) { +#ifdef HAS_ZLIB + QByteArray decompressed; + if (!decompressGzip(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support gzip compressed files."); + } + return false; +#endif + } + + if (data.startsWith(xzMagic())) { +#ifdef HAS_LZMA + QByteArray decompressed; + if (!decompressXz(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support xz compressed files."); + } + return false; +#endif + } + + if (data.startsWith(zipMagic())) { +#ifdef HAS_ZLIB + QByteArray decompressed; + if (!decompressZip(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support zipped files."); + } + return false; +#endif + } + + return true; + } + + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database is compressed in too many layers."); + } + return false; +} + +bool parseKeyedMap(const QJsonObject &map, const QString &explicitKey, ParseContext &context, QString *errorMessage) +{ + if (map.isEmpty()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + if (!it.value().isArray()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag list for \"%1\" must be an array of slugs.").arg(it.key()); + } + return false; + } + + QStringList slugs; + for (const QJsonValue &value : it.value().toArray()) { + if (!value.isString()) { + if (errorMessage) { + *errorMessage = + TagDatabase::tr("The tag list for \"%1\" must contain only slug strings.").arg(it.key()); + } + return false; + } + slugs.append(value.toString()); + } + + const bool oracleKey = explicitKey.isEmpty() ? looksLikeOracleId(it.key()) + : (explicitKey == "oracle_id" || explicitKey == "oracleid"); + if (oracleKey) { + addTags(context.oracleTags, it.key().trimmed(), slugs); + } else { + addTags(context.nameTags, normalizedName(it.key()), slugs); + } + } + return true; +} + +bool parseTagRow(const QJsonObject &object, ParseContext &context) +{ + QStringList slugs; + for (const QJsonValue &value : object.value("tags").toArray()) { + if (value.isString()) { + slugs.append(value.toString()); + } + } + + const QString oracleId = object.value("oracle_id").toString().trimmed(); + if (!oracleId.isEmpty()) { + addTags(context.oracleTags, oracleId, slugs); + } + + const QString name = object.value("name").toString().trimmed(); + if (!name.isEmpty()) { + addTags(context.nameTags, normalizedName(name), slugs); + } + return true; +} + +bool parseTagObject(const QJsonObject &object, ParseContext &context) +{ + // Art and illustration tags describe a printing's artwork, not the oracle + // card, so they never belong in the card-level tag property. + const QString type = object.value("type").toString().trimmed().toLower(); + if (!type.isEmpty() && type != "oracle") { + return true; + } + + const QString slug = normalizedSlug(object.value("slug").toString()); + if (!isValidSlug(slug)) { + return true; + } + + QList oracleIds; + for (const QJsonValue &value : object.value("taggings").toArray()) { + const QString oracleId = value.toObject().value("oracle_id").toString().trimmed(); + if (!oracleId.isEmpty() && !oracleIds.contains(oracleId)) { + oracleIds.append(oracleId); + } + } + + const QString id = object.value("id").toString().trimmed(); + if (!context.includeParentTags || id.isEmpty()) { + for (const QString &oracleId : oracleIds) { + addTags(context.oracleTags, oracleId, QStringList{slug}); + } + return true; + } + + ParsedTag &tag = context.tagsById[id]; + if (tag.slug.isEmpty()) { + tag.slug = slug; + } + for (const QJsonValue &parent : object.value("parent_ids").toArray()) { + const QString parentId = parent.toString().trimmed(); + if (!parentId.isEmpty() && !tag.parentIds.contains(parentId)) { + tag.parentIds.append(parentId); + } + } + for (const QString &oracleId : oracleIds) { + if (!tag.oracleIds.contains(oracleId)) { + tag.oracleIds.append(oracleId); + } + } + return true; +} + +bool handleObject(const QJsonObject &object, ParseContext &context, QString *errorMessage) +{ + if (object.contains("slug")) { + return parseTagObject(object, context); + } + + const QJsonValue tagsValue = object.value("tags"); + if (tagsValue.isArray() && (object.contains("oracle_id") || object.contains("name"))) { + return parseTagRow(object, context); + } + if (tagsValue.isObject()) { + const QString key = object.value("key").toString().trimmed().toLower(); + return parseKeyedMap(tagsValue.toObject(), key, context, errorMessage); + } + + bool allArrays = !object.isEmpty(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + if (!it.value().isArray()) { + allArrays = false; + break; + } + } + if (allArrays) { + return parseKeyedMap(object, QString(), context, errorMessage); + } + + if (errorMessage) { + *errorMessage = TagDatabase::tr("Unrecognized tag database format."); + } + return false; +} + +bool parseJsonArray(const QJsonArray &array, ParseContext &context, QString *errorMessage) +{ + if (array.isEmpty()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + + for (const QJsonValue &value : array) { + if (!value.isObject()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database array must contain only objects."); + } + return false; + } + if (!handleObject(value.toObject(), context, errorMessage)) { + return false; + } + } + return true; +} + +bool parseJsonLines(const QByteArray &payload, ParseContext &context, QString *errorMessage) +{ + const QList lines = payload.split('\n'); + bool parsedAny = false; + + for (int index = 0; index < lines.size(); ++index) { + const QByteArray line = lines.at(index).trimmed(); + if (line.isEmpty()) { + continue; + } + + QJsonParseError lineError{}; + const QJsonDocument lineDocument = QJsonDocument::fromJson(line, &lineError); + if (lineError.error != QJsonParseError::NoError || !lineDocument.isObject()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Malformed tag database at line %1: %2") + .arg(index + 1) + .arg(lineError.errorString()); + } + return false; + } + if (!handleObject(lineDocument.object(), context, errorMessage)) { + return false; + } + parsedAny = true; + } + + if (!parsedAny) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + return true; +} + +QHash sortedLists(const TagSets &sets) +{ + QHash result; + result.reserve(sets.size()); + for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { + if (it.value().isEmpty()) { + continue; + } + QStringList slugs = it.value().values(); + slugs.sort(); + result.insert(it.key(), slugs); + } + return result; +} + +} // namespace + +std::shared_ptr +TagDatabase::loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage) +{ + if (errorMessage) { + errorMessage->clear(); + } + + if (data.trimmed().isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database is empty."); + } + return nullptr; + } + + QByteArray payload = data; + if (!decompressData(payload, errorMessage)) { + return nullptr; + } + if (payload.trimmed().isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database is empty."); + } + return nullptr; + } + + ParseContext context; + context.includeParentTags = options.includeParentTags; + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error == QJsonParseError::NoError) { + if (document.isArray()) { + if (!parseJsonArray(document.array(), context, errorMessage)) { + return nullptr; + } + } else if (document.isObject()) { + if (!handleObject(document.object(), context, errorMessage)) { + return nullptr; + } + } else { + if (errorMessage) { + *errorMessage = tr("The tag database is not a JSON object or array."); + } + return nullptr; + } + } else if (payload.trimmed().startsWith('[')) { + // A broken array is not JSONL; report the parser error instead of + // blaming an arbitrary line. + if (errorMessage) { + *errorMessage = tr("Malformed tag database: %1").arg(parseError.errorString()); + } + return nullptr; + } else if (!parseJsonLines(payload, context, errorMessage)) { + return nullptr; + } + + if (context.includeParentTags) { + // Expand upward only after every tag is known: a parent object may + // appear after its children in the file. + for (auto it = context.tagsById.constBegin(); it != context.tagsById.constEnd(); ++it) { + QStringList slugs{it->slug}; + QSet visited; + QList pending = it->parentIds; + + while (!pending.isEmpty()) { + const QString parentId = pending.takeLast(); + if (visited.contains(parentId)) { + continue; + } + visited.insert(parentId); + + const auto parent = context.tagsById.constFind(parentId); + if (parent == context.tagsById.constEnd()) { + continue; + } + slugs.append(parent->slug); + pending.append(parent->parentIds); + } + + for (const QString &oracleId : it->oracleIds) { + addTags(context.oracleTags, oracleId, slugs); + } + } + } + + auto database = std::make_shared(); + database->tagsByOracleId = sortedLists(context.oracleTags); + database->tagsByName = sortedLists(context.nameTags); + + QSet distinctSlugs; + for (const QStringList &slugs : database->tagsByOracleId) { + for (const QString &slug : slugs) { + distinctSlugs.insert(slug); + } + } + for (const QStringList &slugs : database->tagsByName) { + for (const QString &slug : slugs) { + distinctSlugs.insert(slug); + } + } + database->distinctTagCount = distinctSlugs.size(); + + if (database->isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database contains no usable tags."); + } + return nullptr; + } + return database; +} + +QStringList TagDatabase::tagsForCard(const QString &oracleId, const QString &name) const +{ + QStringList result; + if (!oracleId.isEmpty()) { + result = tagsByOracleId.value(oracleId.trimmed().toLower()); + } + + const QString normalizedCardName = normalizedName(name); + if (!normalizedCardName.isEmpty()) { + const QStringList byName = tagsByName.value(normalizedCardName); + for (const QString &slug : byName) { + if (!result.contains(slug)) { + result.append(slug); + } + } + } + + if (!result.isEmpty()) { + result.sort(); + } + return result; +} + +bool TagDatabase::isEmpty() const +{ + return tagsByOracleId.isEmpty() && tagsByName.isEmpty(); +} diff --git a/oracle/src/tag_database.h b/oracle/src/tag_database.h new file mode 100644 index 000000000..5874000f7 --- /dev/null +++ b/oracle/src/tag_database.h @@ -0,0 +1,69 @@ +#ifndef TAG_DATABASE_H +#define TAG_DATABASE_H + +#include +#include +#include +#include +#include +#include + +/** + * @brief Tag slugs keyed by Scryfall oracle id and/or card name. + * + * Loaded from Scryfall's Oracle Tags bulk data or from a user-supplied tag + * database. The data is import-time only: Oracle applies the slugs to the + * `tags` card property before writing cards.xml, so the client needs no + * tag-specific code. + * + * Accepted inputs, auto-detected: + * - Scryfall tag objects, either a JSON array or one object per JSONL line; + * - a flat map of `oracle_id` (or card name) to an array of slugs; + * - a manifest object `{"key": "oracle_id"|"name", "tags": { ... }}`; + * - JSONL rows `{"oracle_id": "...", "tags": [...]}` or the `name` equivalent. + * + * Gzip, xz and zip inputs are decompressed transparently when the build has + * the matching library. Scryfall serves its bulk data as gzipped JSONL. + */ +class TagDatabase +{ + Q_DECLARE_TR_FUNCTIONS(TagDatabase) +public: + struct LoadOptions + { + bool includeParentTags = false; + }; + + /** + * @brief Parses and decompresses a tag database. + * @return The database, or nullptr with @p errorMessage set on failure. + */ + static std::shared_ptr + loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage = nullptr); + + /** + * @brief Returns the sorted slugs for one card. + * + * Oracle ids are the primary key. A name-keyed database is consulted as a + * fallback, case-insensitively. An empty list means the card has no tags. + */ + [[nodiscard]] QStringList tagsForCard(const QString &oracleId, const QString &name) const; + [[nodiscard]] bool isEmpty() const; + /** @brief Number of distinct slugs in the database. */ + [[nodiscard]] int tagCount() const + { + return distinctTagCount; + } + /** @brief Number of cards that received at least one tag. */ + [[nodiscard]] int taggedCardCount() const + { + return tagsByOracleId.size() + tagsByName.size(); + } + +private: + QHash tagsByOracleId; + QHash tagsByName; ///< keys are lowercased + int distinctTagCount = 0; +}; + +#endif // TAG_DATABASE_H diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index 88058278f..8cea24950 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -203,6 +204,64 @@ TEST_F(CardQuery, SearchLanguageIsBoundPerInstance) ASSERT_TRUE(germanQuery.check(localized)); } +CardInfoPtr taggedCard() +{ + return CardInfo::newInstance("Tagged Card", "text", false, {{"tags", "ramp removal"}}, {}, {}, {}, {}); +} + +TEST_F(CardQuery, TagsMatchWholeSlugs) +{ + const CardData tagged = taggedCard(); + ASSERT_TRUE(FilterString("tags:ramp").check(tagged)); + ASSERT_TRUE(FilterString("tags:removal").check(tagged)); + ASSERT_TRUE(FilterString("tags:RAMP").check(tagged)); + ASSERT_TRUE(FilterString("tag:ramp").check(tagged)); + ASSERT_FALSE(FilterString("tags:squirrel").check(tagged)); +} + +TEST_F(CardQuery, TagQueryDoesNotMatchPartialSlugs) +{ + const CardData tagged = taggedCard(); + ASSERT_FALSE(FilterString("tags:ram").check(tagged)); + ASSERT_FALSE(FilterString("tags:mov").check(tagged)); +} + +TEST_F(CardQuery, TagQueryCombinesWithAnd) +{ + const CardData tagged = taggedCard(); + ASSERT_TRUE(FilterString("tags:ramp tags:removal").check(tagged)); + ASSERT_FALSE(FilterString("tags:ramp tags:squirrel").check(tagged)); +} + +TEST_F(CardQuery, TagQueryTreatsCommasAsPartOfTheSlug) +{ + // Tag lists are not a thing: `tags:draw` and `tags:ramp` are separate terms. + const CardData tagged = taggedCard(); + ASSERT_FALSE(FilterString("tags:ramp,removal").check(tagged)); +} + +TEST_F(CardQuery, FilterTreeTagAttribute) +{ + const CardData tagged = taggedCard(); + + FilterTree matching; + matching.termNode(CardFilter::AttrTag, CardFilter::TypeAnd, "ramp"); + ASSERT_TRUE(matching.acceptsCard(tagged, CardSearchLanguage{})); + + FilterTree partial; + partial.termNode(CardFilter::AttrTag, CardFilter::TypeAnd, "ram"); + ASSERT_FALSE(partial.acceptsCard(tagged, CardSearchLanguage{})); + + FilterTree missing; + missing.termNode(CardFilter::AttrTag, CardFilter::TypeAnd, "squirrel"); + ASSERT_FALSE(missing.acceptsCard(tagged, CardSearchLanguage{})); +} + +TEST_F(CardQuery, TagQueryFalseWhenCardHasNoTags) +{ + ASSERT_FALSE(FilterString("tags:ramp").check(cat)); +} + } // namespace class SetQuery : public ::testing::Test diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 1cc1ad952..3f657412b 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -1,3 +1,35 @@ +# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the tag database +# loader can decompress whatever format the tag source serves, and the benchmark can +# download and decompress whatever AllPrintings format the default URL selects. +# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA +# reach every test target below regardless of where they are declared. +set(_ORACLE_COMPRESSION_SOURCES) +set(_ORACLE_COMPRESSION_LIBRARIES) + +find_package(ZLIB) +if(ZLIB_FOUND) + add_definitions("-DHAS_ZLIB") + list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) + cockatrice_disable_sanitizers_for(${_ORACLE_COMPRESSION_SOURCES}) + cockatrice_disable_coverage_for(${_ORACLE_COMPRESSION_SOURCES}) + list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${ZLIB_LIBRARIES}) + include_directories(${ZLIB_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: zlib not found; gzip/zip tag databases and the zip download benchmark disabled") +endif() + +find_package(LibLZMA) +if(LIBLZMA_FOUND) + add_definitions("-DHAS_LZMA") + list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) + list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${LIBLZMA_LIBRARIES}) + include_directories(${LIBLZMA_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: LibLZMA not found; xz tag databases and the xz download benchmark disabled") +endif() + cockatrice_add_test(NAME parse_cipt_test SOURCES ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp) # Oracle importer unit tests @@ -9,43 +41,30 @@ cockatrice_add_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/tag_database.cpp ../../oracle/src/database_writer.cpp oracle_importer_test.cpp + ${_ORACLE_COMPRESSION_SOURCES} LIBS libcockatrice_card libcockatrice_interfaces + EXTRA_LIBS + ${_ORACLE_COMPRESSION_LIBRARIES} +) + +# Tag database format detection, parsing and parent expansion +cockatrice_add_test( + NAME + tag_database_test + SOURCES + ../../oracle/src/tag_database.cpp + tag_database_test.cpp + ${_ORACLE_COMPRESSION_SOURCES} + EXTRA_LIBS + ${_ORACLE_COMPRESSION_LIBRARIES} ) # Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) -# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark -# can download and decompress whatever AllPrintings format the default URL selects. -# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA also -# reach the two unit tests above. That is harmless: those macros gate code only in -# oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, which no test target compiles. -find_package(ZLIB) -if(ZLIB_FOUND) - add_definitions("-DHAS_ZLIB") - set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) - cockatrice_disable_sanitizers_for(${_ORACLE_BENCH_EXTRA_SOURCES}) - cockatrice_disable_coverage_for(${_ORACLE_BENCH_EXTRA_SOURCES}) - set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) - include_directories(${ZLIB_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") -endif() - -find_package(LibLZMA) -if(LIBLZMA_FOUND) - add_definitions("-DHAS_LZMA") - list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) - cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) - cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) - list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) - include_directories(${LIBLZMA_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") -endif() - cockatrice_add_manual_test( NAME oracle_importer_benchmark_test @@ -54,12 +73,13 @@ cockatrice_add_manual_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/tag_database.cpp oracle_importer_benchmark_test.cpp - ${_ORACLE_BENCH_EXTRA_SOURCES} + ${_ORACLE_COMPRESSION_SOURCES} LIBS libcockatrice_card libcockatrice_interfaces EXTRA_LIBS - ${_ORACLE_BENCH_EXTRA_LIBRARIES} + ${_ORACLE_COMPRESSION_LIBRARIES} MANUAL ) diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 1b4e2d915..84995797d 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,6 +1,7 @@ #include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../../oracle/src/tag_database.h" #include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/card_info.h" @@ -546,6 +547,158 @@ TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc) ASSERT_EQ(result->getProperty("cmc"), "3"); } +// ============================================================================ +// Scryfall Tagger tag tests +// ============================================================================ + +TEST_F(OracleImporterTest, ImportsScryfallTags) +{ + QJsonObject card = makeCard("Ramp Card"); + card["tags"] = QJsonArray{"ramp", "removal"}; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Ramp Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, TagsAreNormalizedDedupedAndSorted) +{ + QJsonObject card = makeCard("Messy Tags"); + card["tags"] = QJsonArray{"Ramp", " removal ", "ramp", ""}; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Messy Tags"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, CardsWithoutTagsHaveNoTagsProperty) +{ + QJsonArray cards{makeCard("Untagged Card")}; + importer->importCardsFromSet(set, cards); + + auto result = importer->getCardList().value("Untagged Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_FALSE(result->hasProperty("tags")); +} + +TEST_F(OracleImporterTest, SplitCardTagsAreUnioned) +{ + QJsonObject face1 = makeCard("Fire // Ice"); + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + face1["tags"] = QJsonArray{"removal"}; + QJsonObject face2 = makeCard("Fire // Ice"); + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + face2["tags"] = QJsonArray{"card-advantage", "removal"}; + QJsonArray cards{face1, face2}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "card-advantage removal"); +} + +// ============================================================================ +// Tag database join tests +// ============================================================================ + +TEST_F(OracleImporterTest, AppliesTagDatabaseByOracleId) +{ + QJsonObject card = makeCard("Tagged By Id"); + QJsonObject identifiers = card["identifiers"].toObject(); + identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + card["identifiers"] = identifiers; + + QString error; + const auto database = TagDatabase::loadFromData( + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{card}); + + auto result = importer->getCardList().value("Tagged By Id"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, AppliesTagDatabaseByNameWhenOracleIdIsMissing) +{ + QJsonObject card = makeCard("Lightning Bolt"); + + QString error; + const auto database = TagDatabase::loadFromData(R"json({"Lightning Bolt": ["burn", "removal"]})json", + TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{card}); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "burn removal"); +} + +TEST_F(OracleImporterTest, TagDatabaseUnionsWithSourceTags) +{ + QJsonObject card = makeCard("Union Card"); + QJsonObject identifiers = card["identifiers"].toObject(); + identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + card["identifiers"] = identifiers; + card["tags"] = QJsonArray{"ramp"}; + + QString error; + const auto database = TagDatabase::loadFromData( + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{card}); + + auto result = importer->getCardList().value("Union Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, TagDatabaseSplitFacesUnionTheirTags) +{ + QJsonObject face1 = makeCard("Fire // Ice"); + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + QJsonObject identifiers1 = face1["identifiers"].toObject(); + identifiers1["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + face1["identifiers"] = identifiers1; + + QJsonObject face2 = makeCard("Fire // Ice"); + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + QJsonObject identifiers2 = face2["identifiers"].toObject(); + identifiers2["scryfallOracleId"] = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + face2["identifiers"] = identifiers2; + + QString error; + const auto database = TagDatabase::loadFromData( + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb": ["card-advantage"]})json", + TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{face1, face2}); + + auto result = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "card-advantage removal"); +} + // ============================================================================ // Card deduplication tests // ============================================================================ diff --git a/tests/oracle/tag_database_test.cpp b/tests/oracle/tag_database_test.cpp new file mode 100644 index 000000000..92b4013a2 --- /dev/null +++ b/tests/oracle/tag_database_test.cpp @@ -0,0 +1,254 @@ +#include "../../oracle/src/tag_database.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include + +#ifdef HAS_ZLIB +#include +#endif + +namespace +{ +const QString ORACLE_A = QStringLiteral("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); +const QString ORACLE_B = QStringLiteral("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + +std::shared_ptr load(const QByteArray &data, + const TagDatabase::LoadOptions &options = TagDatabase::LoadOptions{}, + QString *errorMessage = nullptr) +{ + return TagDatabase::loadFromData(data, options, errorMessage); +} + +QString keyedByOracleId(const QString &oracleId, const QString &slugs) +{ + return QStringLiteral("{\"%1\": [%2]}").arg(oracleId, slugs); +} + +#ifdef HAS_ZLIB +QByteArray gzipCompress(const QByteArray &input) +{ + z_stream stream{}; + if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) { + return {}; + } + + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + + QByteArray output; + char buffer[4096]; + int result = Z_OK; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = deflate(&stream, Z_FINISH); + output.append(buffer, static_cast(sizeof(buffer) - stream.avail_out)); + } + + deflateEnd(&stream); + return result == Z_STREAM_END ? output : QByteArray(); +} +#endif +} // namespace + +TEST(TagDatabaseTest, ParsesScryfallJsonLines) +{ + const QByteArray data = R"json( +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"}]} +{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"ramp","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"},{"oracle_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","weight":"strong"}]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagCount(), 2); + EXPECT_EQ(database->taggedCardCount(), 2); + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList({"ramp"})); +} + +TEST(TagDatabaseTest, ParsesScryfallJsonArray) +{ + const QByteArray data = R"json([ +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +])json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} + +TEST(TagDatabaseTest, IgnoresNonOracleTagTypes) +{ + const QByteArray data = R"json( +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"art-only","type":"art","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} + +TEST(TagDatabaseTest, ParsesFlatOracleIdMap) +{ + const QByteArray data = keyedByOracleId(ORACLE_A, R"("Removal", "removal", " ramp ")").toUtf8(); + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // Mixed case, duplicates and surrounding whitespace are normalized. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_A.toUpper(), QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList()); +} + +TEST(TagDatabaseTest, ParsesFlatNameMap) +{ + const QByteArray data = R"json({"Lightning Bolt": ["removal", "burn"]})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // Name lookups are case-insensitive; an absent oracle id does not block them. + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("lightning bolt")), QStringList({"burn", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, ParsesManifestWithNameKey) +{ + const QByteArray data = + R"json({"version": 2, "key": "name", "tags": {"Lightning Bolt": ["removal"], "zzz-not-a-uuid": ["burn"]}})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"removal"})); + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("ZZZ-not-a-uuid")), QStringList({"burn"})); +} + +TEST(TagDatabaseTest, ParsesJsonLinesRows) +{ + const QByteArray data = R"json( +{"oracle_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "tags": ["removal"]} +{"name": "Lightning Bolt", "tags": ["burn", "removal"]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, MergesOracleIdAndNameMatches) +{ + const QByteArray data = + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "Lightning Bolt": ["burn", "removal"]})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, DropsSlugsContainingWhitespace) +{ + const QByteArray data = keyedByOracleId(ORACLE_A, R"("two words", "valid")").toUtf8(); + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // A slug with a space would corrupt the space-separated `tags` property. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"valid"})); +} + +TEST(TagDatabaseTest, ParentTagsAreOffByDefault) +{ + const QByteArray data = R"json( +{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]} +{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"tutor-creature-giant"})); +} + +TEST(TagDatabaseTest, ParentTagsExpandTransitivelyWhenEnabled) +{ + const QByteArray data = R"json( +{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]} +{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{.includeParentTags = true}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // The child precedes its parents in the file; expansion must not depend on order. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), + QStringList({"tutor", "tutor-creature", "tutor-creature-giant"})); +} + +TEST(TagDatabaseTest, RejectsEmptyData) +{ + QString error; + EXPECT_FALSE(load(QByteArray(), TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsMalformedJson) +{ + QString error; + EXPECT_FALSE(load("not a tag database", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsTagListsThatAreNotArrays) +{ + QString error; + EXPECT_FALSE(load(R"json({"tags": {"Lightning Bolt": "removal"}})json", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsDatabaseWithoutUsableTags) +{ + QString error; + EXPECT_FALSE(load(R"json({"Lightning Bolt": []})json", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +#ifdef HAS_ZLIB +TEST(TagDatabaseTest, DecompressesGzipPayloads) +{ + const QByteArray plain = keyedByOracleId(ORACLE_A, R"("removal")").toUtf8(); + const QByteArray compressed = gzipCompress(plain); + ASSERT_FALSE(compressed.isEmpty()); + ASSERT_EQ(compressed.left(2), QByteArray::fromHex("1f8b")); + + QString error; + const auto database = load(compressed, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} +#endif From b1c8f156dcc9a710d9d08d50a3cce14a3fd74b92 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:51:31 +0100 Subject: [PATCH 102/104] [Game] Add hover zoom and a tabbed card panel to the board (#7700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Settings] Add hover zoom/panel triggers, delays, and double-confirm * [Game] Smooth board hover-zoom with fade helpers * [Game] Add hover card panel with tabbed actions * [Game] Wire hover zoom/panel triggers through the scene * [Game] Repaint cached zones when the hover zoom moves `TableZone` cuts the hovered card out of its own paint so zone washes, indicators, dimming and the land divider never draw over the zoom. It is a `DeviceCoordinateCache` item, so the card changing geometry never dirtied it and the stale hole stayed transparent, uncovering the black viewport as soon as the zoom crossed a zone edge. - Emit `hoverFootprintChanged(old, new)` from `setCardScale()` and once from `setHovered(false)`. - Repaint every overlapping `TableZone` on that signal. - Clip with the card's painted outline instead of its bounding rectangle, and treat a zoom covering the whole zone as "paint nothing". * [Interface] Keep the hover panel P/T controls out of the way The P/T set/inc/dec rows were always visible and sat under the cursor, so clicking a card to select it could mutate its P/T instead. - Collapse the controls to a pill on the printed P/T box, anchored on the drawn artwork so letterboxed and sideways cards stay aligned. - Open the rows only from the pill and grow them upward, and re-measure the pill on every value so three-digit P/T does not clip. - Close the panel when another card is hovered, and repaint the area it covered so no patch is left behind. * [Interface] Address hover panel review findings * [Game] Fix include order flagged by the format gate The card hover widget header was included before theme_manager.h, which clang-format rejects; the branch's format CI job failed on it. * [Interface] Load the hover panel icons through the themed icon engine The panel still named pre-#7595 assets (arrow_*_green, arrow_right_blue, cogwheel) and built QIcon snapshots from themePixmap(), so the deleted names rendered blank and every other glyph froze one palette. Load every glyph through loadThemedIcon() with the emphasis axis the adoption plan calls for: the category tabs take the primary level as panel wayfinding, the action grid the quiet level because its glyphs label the button text. Semantic tints keep their family and follow the same quiet level; Clone keeps its sticker treatment. * [Icons] Add an Informational semantic color family Face-down play and the exile zone are states that are neither a gain nor a loss, and the pre-#7595 art painted them blue (arrow_right_blue). #7595 dropped that hue, and the icon engine has had only Positive and Negative since, so both states fall back to neutral ink. Add the family with the same three-level ladder the other semantic roles use: authored roles per theme, scheme defaults otherwise, an emphasis mapping, the palette-editor rows and the generator seed. The defaults clear the 4.5:1 label floor on both schemes and the ladder's saturation ordering; the existing emphasis, hue and preview tests now cover the new family. Preview sheets that name roles resolve an unauthored one through the same default the client paints. * [Interface] Split the hover panel icons by emphasis and semantics Category tabs only navigate between pages, so they take the quiet level; the action grid is the surface the player works in and takes the primary level. Both stay line glyphs: a filled treatment reads as a second state on every cell. Hue marks an outcome where the glyph cannot. Play, clone and to hand are Positive; reduce life and graveyard Negative; play face down and exile Informational. Direction-only moves (top and bottom of library) stay neutral because the arrow already carries the meaning. * [Icons] Add Hidden and Marked semantic color families Two game concepts had no hue of their own. Hidden information (a face-down play, peeking at a card face) now has violet, and the marks a player leaves on a card (annotation, draw arrow) now share amber instead of splitting blue and red for the same job. Both families follow the existing three-level ladder: authored roles per theme, scheme defaults otherwise, an emphasis mapping, palette-editor rows and generator seeding. The defaults clear the 4.5:1 floor on the representative platform surfaces and keep the saturation ordering the emphasis tests assert; the hue tests pin violet's blue-red-green dominance and amber's red-green-blue one, and every preview sheet now renders the two families. The hover panel grid preview is added alongside: it renders the real pages (section headers, two-column cells, glyph over label) so the whole map can be judged at once instead of glyph by glyph. * [Icons] Add a Movement semantic color family A card changing position inside the library is neither a gain, a loss nor information, so it had no hue. Movement names it with teal and covers the three library placements (top, bottom, X from top). It follows the existing ladder: authored roles per theme, scheme defaults otherwise, an emphasis mapping, palette-editor rows and generator seeding. The defaults clear the 4.5:1 floor on the representative platform surfaces, keep strong > base > soft saturation, and keep green > blue > red so teal cannot be mistaken for the green gain family. * [Interface] Give every hover panel action a semantic color The quick actions are the panel's whole point, so a neutral glyph made the most-used buttons the hardest to scan. Every action now names a game fact with its hue: gain, loss, information, hidden information, a mark on the card or a move. The line glyph stays identical, so the colour is the only difference between neighbours. The grid is also ordered by family so a row reads as one idea: the two state toggles, the two gains, the two marks, the two hidden-information actions and the two losses each share a row. Conditional actions (Peek, Unattach) sit at the end of their pair, so a missing one leaves a hole at the section edge instead of splitting a row. In a zone, Play and Play Face Down stay adjacent on purpose: the same action, one of them hiding information. The move tab leads with the three library placements (Movement), then the destinations. actionGridIcon is removed; semanticActionIcon is now the only grid ink. The preview renders the real pages so the whole map can be judged at once. * [Icons] Use quiet emphasis for glyphs that label rather than act The search and info glyphs inside the search fields, and the combo-box chevron, label or afford an action rather than performing one. At the default secondary weight they compete with the actual controls next to them. Paint them at quiet emphasis so they stay legible without claiming attention. Sites: deck editor, visual database display, visual deck storage, set manager, settings search, in-game zone view, and the dropdown chevron. * [Icons] Restore sticker treatment to the add, remove and delete actions The pre-#7595 add and delete glyphs were filled shapes with an outline. After the line migration they read as ordinary toolbar glyphs, losing the weight their verbs had. Give the add and delete family the sticker treatment again. - Deck editor Add card: primary positive sticker. It is the one action the database panel exists for. - Visual database Clear filters: negative sticker, matching the delete family. - Filter dock Clear all: negative sticker, matching Clear one. * [Icons] Give sharing and pinning their semantic families Sharing is an informational act: the app already tints reveal and make-public with the informational family, so the deck storage share action, the replay share-code action and the visual deck storage share button follow it. The printing-selector pin badge marks a card as pinned. Paint it with the marked family, the same one used for a mark left on a card, instead of leaving it neutral. * [Game] Dismiss the hover panel before opening a modal dialog The hover panel is a Qt::ToolTip window, i.e. a popup: it is always on top and holds the input grab. Actions on the panel that open a modal dialog (Set power/toughness, Set annotation, Set counter value, token creation) therefore opened the dialog behind the panel, where it could not be seen or activated. Hide the panel synchronously and defer the action to the next event-loop turn, so the popup grab is released before the dialog's event loop starts. The panel asks the scene to dismiss it through a new dismissRequested signal, keeping hover timers and the covered-card fade on the scene's side. Reported from the P/T overlay SET button; the same path covers the counter tab and the token tab. * [Icons] Refine VDE and VDD emphasis and semantics The visual database filter section and the syntax help are information aids, so paint them with the informational family at the default emphasis: the filter section glyph and the search field's help glyph. The help glyph leaves quiet emphasis because it is a clickable action, not a field label. The visual deck editor's display-type toggle is the one control that changes the whole view, so it takes primary emphasis. The search glyphs stay quiet; only the help action changes. * [Icons] Raise the visual database filter controls to primary The six quick-filter buttons are the controls the filter section exists for, so paint them at primary emphasis instead of the default weight. The sort glyph carries the same informational family as the filter glyph, in both the visual database toolbar label and the visual deck editor's sort-criteria button. * [Icons] Keep the display options sort glyph neutral The sort button beside the display-mode toggle is not part of the informational family. Only the visual database toolbar's sort glyph carries informational; the display options button returns to the default neutral weight. --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + .../src/client/settings/shortcuts_settings.h | 3 + .../board/abstract_card_item.cpp | 190 +- .../game_graphics/board/abstract_card_item.h | 36 + .../src/game_graphics/board/card_item.cpp | 5 + cockatrice/src/game_graphics/game_scene.cpp | 548 +++++- cockatrice/src/game_graphics/game_scene.h | 82 +- cockatrice/src/game_graphics/game_view.cpp | 5 + cockatrice/src/game_graphics/game_view.h | 1 + cockatrice/src/game_graphics/z_values.h | 4 + .../src/game_graphics/zones/table_zone.cpp | 48 + .../src/game_graphics/zones/table_zone.h | 9 + .../game_graphics/zones/view_zone_widget.cpp | 6 +- .../palette_editor/palette_generator.cpp | 11 +- .../palette_editor/palette_grid_widget.cpp | 16 + .../src/interface/pixel_map_generator.cpp | 3 +- cockatrice/src/interface/theme_config.cpp | 36 + cockatrice/src/interface/theme_config.h | 22 +- cockatrice/src/interface/theme_icon.cpp | 16 + cockatrice/src/interface/theme_manager.cpp | 12 + .../widgets/cards/card_hover_widget.cpp | 1736 +++++++++++++++++ .../widgets/cards/card_hover_widget.h | 231 +++ .../cards/card_info_picture_widget.cpp | 20 + .../widgets/cards/card_info_picture_widget.h | 12 + .../deck_editor_database_display_widget.cpp | 9 +- .../deck_editor_filter_dock_widget.cpp | 3 +- .../widgets/dialogs/dlg_manage_sets.cpp | 3 +- .../widgets/dialogs/settings_view.cpp | 3 +- .../printing_selector_card_overlay_widget.cpp | 3 +- .../user_interface_settings_page.cpp | 114 ++ .../user_interface_settings_page.h | 13 + .../widgets/tabs/tab_deck_storage.cpp | 2 +- .../interface/widgets/tabs/tab_replays.cpp | 2 +- ...database_display_filter_toolbar_widget.cpp | 21 +- .../visual_database_display_widget.cpp | 8 +- .../visual_deck_display_options_widget.cpp | 4 +- .../visual_deck_storage_search_widget.cpp | 5 +- .../visual_deck_storage_widget.cpp | 2 +- ...nterface_cards_display_settings_provider.h | 5 + .../settings/cards_display_settings.cpp | 59 + .../settings/cards_display_settings.h | 32 + tests/settings/settings_defaults_test.cpp | 71 + .../icon_tint_preview_test.cpp | 14 +- .../icon_treatment_preview_test.cpp | 324 ++- .../theme_icon_render_test.cpp | 98 +- .../theme_migration/theme_migration_test.cpp | 24 + 46 files changed, 3796 insertions(+), 76 deletions(-) create mode 100644 cockatrice/src/interface/widgets/cards/card_hover_widget.cpp create mode 100644 cockatrice/src/interface/widgets/cards/card_hover_widget.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index a09a81ac9..27ded5621 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -172,6 +172,7 @@ set(cockatrice_SOURCES src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp + src/interface/widgets/cards/card_hover_widget.cpp src/interface/widgets/cards/card_info_display_widget.cpp src/interface/widgets/cards/card_info_frame_widget.cpp src/interface/widgets/cards/card_info_picture_art_crop_widget.cpp diff --git a/cockatrice/src/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h index 24790a407..202b39247 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.h +++ b/cockatrice/src/client/settings/shortcuts_settings.h @@ -569,6 +569,9 @@ private: {"Player/aRevealToAll", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Reveal Selected Cards to All Players"), parseSequenceString(""), ShortcutGroup::Playing_Area)}, + {"Player/aShowHoverPanel", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Show Hover Panel for Card"), + parseSequenceString(""), + ShortcutGroup::Playing_Area)}, {"Player/aMoveToBottomLibrary", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Bottom of Library"), parseSequenceString("Ctrl+B"), ShortcutGroup::Move_selected)}, diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 84e062cd7..f7ae1ff71 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -13,6 +13,7 @@ #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/utility/card_ref.h" +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -42,9 +45,19 @@ class QSizeF; +// Width the hovered card smooth-zooms to: exactly the hover panel picture size, so +// all four corners coincide when the panel swaps in over it. +static constexpr qreal HOVER_ZOOM_WIDTH = 270.0; +// The pre-panel hover enlargement. The smooth zoom is opt-in, so this stays the hover +// behavior whenever no zoom trigger is configured (see setHoverHighlighted). +static constexpr qreal LEGACY_HOVER_SCALE = 1.1; +static constexpr int HOVER_ZOOM_IN_MS = 220; +static constexpr int HOVER_ZOOM_OUT_MS = 140; +static constexpr int FADE_DURATION_MS = 140; + AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) : ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0), - bgColor(Qt::transparent), isHovered(false), realZValue(0) + bgColor(Qt::transparent), isHovered(false), isHoverHighlighted(false), realZValue(0) { setCursor(Qt::OpenHandCursor); setFlag(ItemIsSelectable); @@ -54,12 +67,38 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged); connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged); connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged); - connect(state, &CardState::facedownChanged, this, [this] { update(); }); + connect(state, &CardState::facedownChanged, this, [this](bool faceDown) { + update(); + emit faceDownChanged(faceDown); + }); connect(state, &CardState::colorChanged, this, [this] { cacheBgColor(); update(); }); + hoverZoom = new QPropertyAnimation(this, "cardScale", this); + hoverZoom->setEasingCurve(QEasingCurve::InOutCubic); + // Settling the unhover waits for the shrink-out to finish: the stacking override, + // the clip escape and the ancestor lift all stay put while the card is still large, + // so siblings and the clip container cannot cut through the shrinking card. + connect(hoverZoom, &QPropertyAnimation::finished, this, [this] { + if (!isHovered) { + // The cursor may still rest on the card (e.g. wheel-up reverts the zoom), + // in which case the highlight keeps it raised. + setZValue(isHoverHighlighted ? ZValues::HOVERED_CARD : realZValue); + // Final repaint at the settled footprint: with no scale delta the animation + // emits no per-frame updates, and zones would keep a stale clip hole behind. + if (scene()) { + scene()->update(sceneBoundingRect()); + } + emit hoverShrinkFinished(this); + } + }); + + opacityAnim = new QPropertyAnimation(this, "cardOpacity", this); + opacityAnim->setDuration(FADE_DURATION_MS); + opacityAnim->setEasingCurve(QEasingCurve::OutCubic); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, @@ -131,7 +170,7 @@ void AbstractCardItem::setRealZValue(qreal _zValue) // During hover, zValue is overridden to HOVERED_CARD. Layout operations // like reorganizeCards() call setRealZValue() on all cards including the // hovered one — skip setZValue() here to avoid clobbering the override. - if (!isHovered) { + if (!isHovered && !isHoverHighlighted) { setZValue(_zValue); } } @@ -229,9 +268,9 @@ void AbstractCardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * painter->setRenderHint(QPainter::Antialiasing, false); - if (isSelected() || isHovered) { + if (isSelected() || isHovered || isHoverHighlighted) { QPen pen; - if (isHovered) { + if (isHovered || isHoverHighlighted) { pen.setColor(Qt::yellow); } if (isSelected()) { @@ -257,14 +296,88 @@ void AbstractCardItem::setHovered(bool _hovered) // Mark the hovered card's current scene footprint dirty so overlapped // sibling zones (e.g. StackZone) repaint after the card moves away. if (scene()) { - scene()->update(sceneBoundingRect()); + const QRectF footprint = sceneBoundingRect(); + scene()->update(footprint); + // The hole disappears the moment the card is no longer hovered, even though the + // card has not changed size yet: tell the zones so they repaint it shut. + emit hoverFootprintChanged(footprint, footprint); } } isHovered = _hovered; - setZValue(_hovered ? ZValues::HOVERED_CARD : realZValue); - setScale(_hovered && SettingsCache::instance().cardsDisplay().getScaleCards() ? 1.1 : 1); - setTransformOriginPoint(_hovered ? CardDimensions::WIDTH_HALF_F : 0, _hovered ? CardDimensions::HEIGHT_HALF_F : 0); + // The stacking override is one-directional: it applies the moment the hover starts + // and only drops once the shrink-out finishes (see the hoverZoom finished handler). + // Restoring it here would let siblings paint over the still-enlarged card. + if (_hovered) { + setZValue(ZValues::HOVERED_CARD); + } + // Scale about the center in both directions: at scale 1 the origin is moot, + // and keeping it centered avoids drift while shrinking back down. + setTransformOriginPoint(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F); + + // Smooth-zoom up to the hover picture size (instead of the old 1.1 snap) so the + // hover panel can then swap in over the zoomed card with matching corners. + // The target is constant ON-SCREEN pixels: divide out the board view zoom so + // fullscreen (or zoomed) boards don't overshoot while the fixed-size panel matches. + qreal target = 1.0; + if (_hovered && SettingsCache::instance().cardsDisplay().getScaleCards()) { + const qreal w = boundingRect().width(); + qreal viewScale = 1.0; + if (scene() && !scene()->views().isEmpty()) { + if (const auto *view = scene()->views().first()) { + viewScale = qAbs(view->transform().m11()); + } + } + if (w > 0.0 && viewScale > 0.0) { + target = qBound(1.0, HOVER_ZOOM_WIDTH / (w * viewScale), 6.0); + } + } + hoverZoom->stop(); + hoverZoom->setStartValue(scale()); + hoverZoom->setEndValue(target); + hoverZoom->setDuration(_hovered ? HOVER_ZOOM_IN_MS : HOVER_ZOOM_OUT_MS); + hoverZoom->start(); + update(); +} + +void AbstractCardItem::setHoverHighlighted(bool highlighted) +{ + if (isHoverHighlighted == highlighted) { + return; + } + + if (highlighted) { + // The card-info widget listens for this: hovering still updates it right away. + processHoverEvent(); + } + + isHoverHighlighted = highlighted; + if (highlighted) { + setZValue(ZValues::HOVERED_CARD); + } + + // Fallback to the historical hover scale: the smooth hover zoom is opt-in and its + // trigger defaults to Off, so when hovering drives neither the zoom nor the panel + // (zoom trigger Off, panel not on hover) contact alone must still enlarge the card + // to the old 1.1x while scaleCards is on. Otherwise every existing user silently + // loses the hover enlargement on upgrade. + auto &display = SettingsCache::instance().cardsDisplay(); + const bool legacyHoverScale = display.getScaleCards() && + display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerOff && + display.getHoverPanelTrigger() != CardsDisplaySettings::HoverPanelTriggerHover; + if (legacyHoverScale) { + // Centered like the smooth zoom, so growing and shrinking never drift. + setTransformOriginPoint(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F); + hoverZoom->stop(); + hoverZoom->setStartValue(scale()); + hoverZoom->setEndValue(highlighted ? LEGACY_HOVER_SCALE : 1.0); + hoverZoom->setDuration(highlighted ? HOVER_ZOOM_IN_MS : HOVER_ZOOM_OUT_MS); + hoverZoom->start(); + } else if (!highlighted && !isHovered && hoverZoom->state() != QAbstractAnimation::Running) { + // A zoomed card keeps its raised z until the shrink-out finishes (its finished + // handler restores it); a highlight-only card has nothing to unwind. + setZValue(realZValue); + } update(); } @@ -273,6 +386,64 @@ void AbstractCardItem::setColor(const QString &_color) state->setColor(_color); } +qreal AbstractCardItem::getCardScale() const +{ + return scale(); +} + +void AbstractCardItem::setCardScale(qreal _scale) +{ + if (qFuzzyCompare(_scale, scale())) { + return; + } + // The scale animates every frame: keep the scene index in step or hover + // picking tests a stale rect and the hover oscillates on/off by itself. + // Repaint the union of the old and new footprints, not just the card: zones + // dodge the zoomed card via a clip hole computed at paint time, and without + // a repaint they keep a stale hole — covering the card mid-animation and + // leaving a black rect behind once the hover ends. + const QRectF oldFootprint = sceneBoundingRect(); + prepareGeometryChange(); + setScale(_scale); + const QRectF newFootprint = sceneBoundingRect(); + if (scene()) { + scene()->update(oldFootprint.united(newFootprint)); + // Marking the scene region dirty is not enough for cached items: a zone that cuts a + // hover hole out of itself keeps the hole from its last repaint, which then shows as + // an uncovered black rectangle under the zoom. + emit hoverFootprintChanged(oldFootprint, newFootprint); + } else { + update(); + } +} + +qreal AbstractCardItem::getCardOpacity() const +{ + return opacity(); +} + +void AbstractCardItem::setCardOpacity(qreal _opacity) +{ + setOpacity(_opacity); + update(); +} + +void AbstractCardItem::fadeCardOut() +{ + opacityAnim->stop(); + opacityAnim->setStartValue(opacity()); + opacityAnim->setEndValue(0.0); + opacityAnim->start(); +} + +void AbstractCardItem::fadeCardIn() +{ + opacityAnim->stop(); + opacityAnim->setStartValue(opacity()); + opacityAnim->setEndValue(1.0); + opacityAnim->start(); +} + void AbstractCardItem::cacheBgColor() { QChar colorChar; @@ -310,6 +481,7 @@ void AbstractCardItem::cacheBgColor() void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate) { + emit tappedChanged(newTapped); if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) { static_cast(scene())->registerAnimationItem(this); } else { diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index eee8ec743..82058f08a 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -23,10 +23,13 @@ class PlayerLogic; class CardInfo; class QPainter; class QPoint; +class QPropertyAnimation; class AbstractCardItem : public ArrowTarget, public IAnimatedItem { Q_OBJECT + Q_PROPERTY(qreal cardScale READ getCardScale WRITE setCardScale) + Q_PROPERTY(qreal cardOpacity READ getCardOpacity WRITE setCardOpacity) protected: CardState *state; int tapAngle; @@ -34,9 +37,12 @@ protected: private: bool isHovered; + bool isHoverHighlighted; qreal realZValue; bool pendingSelectionCollapse; int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings + QPropertyAnimation *hoverZoom = nullptr; + QPropertyAnimation *opacityAnim = nullptr; private slots: void pixmapUpdated(); void onCardInfoChanged(); @@ -57,6 +63,19 @@ signals: void playSelectedFaceDown(AbstractCardItem *card); void hideSelected(AbstractCardItem *card); void selectionChanged(AbstractCardItem *card, bool selected); + void tappedChanged(bool tapped); + void faceDownChanged(bool faceDown); + /** @brief Emitted when the hover shrink-out animation runs to completion while unhovered. */ + void hoverShrinkFinished(AbstractCardItem *card); + /** + * @brief Emitted when the hover zoom changes this card's scene footprint. + * + * Zones (and anything else that dodges the zoomed card) draw from the live footprint at + * paint time but are cached: they need this to invalidate, or a stale hole is left behind. + * Also emitted once when the hover ends, with both rects equal, because the hole + * disappears without the card changing size. + */ + void hoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint); public: enum @@ -116,6 +135,23 @@ public: } void setRealZValue(qreal _zValue); void setHovered(bool _hovered); + /** + * @brief Shows the hover outline and stacking lift, plus the historical 1.1x hover + * scale whenever no zoom trigger is configured. + * + * The board zoom is trigger-gated; the hover feedback (card info, outline, raised + * stacking) still follows the cursor immediately on contact. With the zoom trigger + * off and the panel not on hover, the old 1.1x enlargement (gated by scaleCards) + * stays in effect so upgrading does not silently drop it. + */ + void setHoverHighlighted(bool highlighted); + qreal getCardScale() const; + void setCardScale(qreal scale); + qreal getCardOpacity() const; + void setCardOpacity(qreal opacity); + /** @brief Fades the card out under the hover panel, or back in when it closes. */ + void fadeCardOut(); + void fadeCardIn(); bool getIsHovered() const { return isHovered; diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 02c0427d7..9cc57f2b6 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -117,6 +117,11 @@ void CardItem::deleteLater() void CardItem::setZone(CardZoneLogic *_zone) { state->setZone(_zone); + // A card faded out under the open hover panel must come back as soon as it changes + // zones; otherwise it stays invisible at its destination until the panel closes. + if (opacity() < 1.0) { + fadeCardIn(); + } } void CardItem::retranslateUi() diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index 128899a13..b4c6e1690 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -2,6 +2,7 @@ #include "../client/settings/cache_settings.h" #include "../interface/theme_manager.h" +#include "../interface/widgets/cards/card_hover_widget.h" #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" @@ -11,16 +12,21 @@ #include "player/menu/player_menu.h" #include "player/player_graphics_item.h" #include "player/player_target.h" +#include "z_values.h" #include "zones/card_zone.h" #include "zones/select_zone.h" +#include "zones/table_zone.h" #include "zones/view_zone.h" #include "zones/view_zone_widget.h" #include +#include #include #include #include +#include #include +#include #include #include #include @@ -29,9 +35,11 @@ #include #include #include +#include #include #include -#include +#include +#include #include #include #include @@ -43,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -75,6 +84,22 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) } }); + // Card hover popup (cardHoverWidget is created lazily on first show) + hoverActivateTimer = new QTimer(this); + hoverActivateTimer->setSingleShot(true); + hoverActivateTimer->setInterval(500); + connect(hoverActivateTimer, &QTimer::timeout, this, &GameScene::onHoverActivateTimeout); + + hoverPanelTimer = new QTimer(this); + hoverPanelTimer->setSingleShot(true); + hoverPanelTimer->setInterval(200); + connect(hoverPanelTimer, &QTimer::timeout, this, &GameScene::onHoverPanelTimeout); + + hoverGraceTimer = new QTimer(this); + hoverGraceTimer->setSingleShot(true); + hoverGraceTimer->setInterval(200); + connect(hoverGraceTimer, &QTimer::timeout, this, &GameScene::onHoverGraceTimeout); + rearrange(); } @@ -93,6 +118,9 @@ GameScene::~GameScene() delete animationTimer; animationTimer = nullptr; + delete hoverActivateTimer; + delete hoverPanelTimer; + delete hoverGraceTimer; // Delete all ArrowItems before QGraphicsScene's base destructor runs. // QGraphicsScene::~QGraphicsScene() destroys items in arbitrary order. @@ -239,6 +267,23 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen return; } + // The zoom and panel triggers are independently bindable: a right-click fires + // whichever phases listen to it. When the panel is the trigger it carries every + // action itself, so the context menu stays suppressed; when right-click only zooms, + // the menu still opens, since nothing else would summon it. + auto &display = SettingsCache::instance().cardsDisplay(); + const bool zoomFired = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerRightClick; + const bool panelFired = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerRightClick; + if (zoomFired || panelFired) { + fireTriggers(card, zoomFired, panelFired); + if (panelFired) { + return; + } + } + + // Hide the hover popup when the context menu opens + hideCardHoverPopup(); + setActiveCard(card); if (auto *menu = view->getPlayerMenu()->updateCardMenu(card->getState())) { @@ -690,7 +735,6 @@ void GameScene::requestArrowDeletion(int playerId, int arrowId) emit arrowDeletionRequested(playerId, arrowId); } } - void GameScene::onCardZoneChanged(CardState *card, bool sameZone) { auto *item = qobject_cast(card->parent()); @@ -710,6 +754,11 @@ void GameScene::onCardZoneChanged(CardState *card, bool sameZone) for (auto *arrow : toDelete) { deleteArrow(arrow->getCreatorId(), arrow->getId()); } + + // The hover panel stays open on a moved card: follow it instead of stranding. + if (!sameZone && cardHoverWidget && cardHoverWidget->getCardItem() == item) { + positionCardHoverPopup(); + } } void GameScene::onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner) @@ -746,27 +795,137 @@ void GameScene::clearArrowsForPlayerLocally(int playerId) } } } - // ---------- Hover Handling ---------- +CardItem *GameScene::getHoveredCard() const +{ + return hoveredCard; +} + void GameScene::updateHover(const QPointF &scenePos) { - auto itemList = items(scenePos, Qt::IntersectsItemBoundingRect, Qt::DescendingOrder, getViewTransform()); + updateHoveredCard(pickCardAt(scenePos)); +} +CardItem *GameScene::pickCardAt(const QPointF &scenePos) +{ + auto itemList = items(scenePos, Qt::IntersectsItemBoundingRect, Qt::DescendingOrder, getViewTransform()); CardZone *zone = findTopmostZone(itemList); CardItem *topCard = zone ? findTopmostCardInZone(itemList, zone) : nullptr; - updateHoveredCard(topCard); + if (!topCard && hoveredCard) { + const QRectF guard = hoveredCard->sceneBoundingRect().adjusted(-8, -8, 8, 8); + if (guard.contains(scenePos)) { + // The zoomed card overflows onto neighboring zones, where naive topmost-zone + // picking attributes the cursor to the zone underneath. While the cursor is + // still over the zoomed footprint (with hysteresis for edge jiggle), keep it. + // A genuinely different topmost card still takes over. + return hoveredCard; + } + } + return topCard; } void GameScene::updateHoveredCard(CardItem *newCard) { + // Safety: if the popup's card was destroyed, hide the popup immediately + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->hasCard()) { + hideCardHoverPopup(); + } + // The hovered card may have been destroyed without ending the hover: its QPointer + // nulled itself, but the ancestor lift is still outstanding. Restore it now. + if (!hoveredCard && !hoverLiftedAncestors.isEmpty()) { + restoreHoverLift(); + } + + // Hovering alone never starts anything anymore: the trigger (dwell, click, wheel, + // shortcut) resolves first, then the zoom follows. This keeps the feature calm. + bool wasHoveringCard = (hoveredCard != nullptr); if (hoveredCard && (newCard != hoveredCard)) { endCardHover(hoveredCard); + hoveredCard = nullptr; } - if (newCard && (newCard != hoveredCard)) { - beginCardHover(newCard); + + // The hover outline and the card info follow the cursor on contact: only the board + // zoom and the panel wait for their trigger. A highlight-only card has no zoom to + // unwind, so it is dropped directly instead of through endCardHover(). + if (hoverHighlightCard && hoverHighlightCard != newCard) { + hoverHighlightCard->setHoverHighlighted(false); + } + hoverHighlightCard = newCard; + if (newCard) { + newCard->setHoverHighlighted(true); + } + + auto &display = SettingsCache::instance().cardsDisplay(); + const int zoomTrigger = display.getHoverZoomTrigger(); + const int panelTrigger = display.getHoverPanelTrigger(); + const bool doubleConfirm = display.getHoverPanelDoubleConfirm() && zoomTrigger == panelTrigger; + + if (newCard) { + // Cancel any pending grace timer (mouse is back on a card) + hoverGracePending = false; + hoverGraceTimer->stop(); + + if (newCard == hoveredCard) { + // Steady state: zoom already up. Refresh an open panel, otherwise nothing — + // in particular this must not re-fire an armed double-confirm. + if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->getCardItem() == newCard) { + cardHoverWidget->refreshDisplay(); + } + return; + } + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing() && + cardHoverWidget->getCardItem() == newCard) { + // Panel open but zoom dropped (e.g. survived a Leave) — zoom back and refresh. + beginCardHover(newCard); + hoveredCard = newCard; + cardHoverWidget->refreshDisplay(); + return; + } + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing()) { + // A different card is hovered while the panel is up: close the old panel + // first, then fall through to first contact so the new card follows the + // normal dwell instead of the old panel trailing the cursor. + hideCardHoverPopup(); + confirmArmedCard.clear(); + } + if (zoomTrigger != CardsDisplaySettings::HoverPanelTriggerHover && + panelTrigger != CardsDisplaySettings::HoverPanelTriggerHover) { + // Mouse contact alone drives neither phase in this config. + return; + } + if (doubleConfirm && confirmArmedCard == newCard) { + // Repeat hover on an armed card: zoom and show at once, no second dwell. + beginCardHover(newCard); + hoveredCard = newCard; + confirmArmedCard.clear(); + showCardHoverPopup(newCard); + return; + } + if (newCard != hoverPopupTarget) { + // First contact (or a new card): dwell, then resolve on timeout. + hoverPopupTarget = newCard; + hoverActivateTimer->start(display.getHoverZoomDelayMs()); + } + } else { + // No card hovered: drop any pending dwell/panel timers (kills stale shows) and start + // the grace timer only when the mouse just left a card with the panel open. + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + // Non-hover zoom arms only make sense while approaching the card: leaving + // re-arms the zoom for the next explicit trigger instead of showing the panel. + // (Hover-trigger arms survive the leave so re-entering completes the repeat.) + if (zoomTrigger != CardsDisplaySettings::HoverPanelTriggerHover) { + confirmArmedCard.clear(); + } + if (wasHoveringCard && cardHoverWidget && cardHoverWidget->isVisible() && + !cardHoverWidget->isMouseOverWidget() && !hoverGracePending) { + hoverGracePending = true; + hoverGraceTimer->start(); + } } - hoveredCard = newCard; } void GameScene::beginCardHover(CardItem *card) @@ -775,16 +934,340 @@ void GameScene::beginCardHover(CardItem *card) if (auto *zone = SelectZone::findOwningSelectZone(card)) { zone->escapeClipForHover(card); } + // Lift the whole ancestor chain above their siblings: the zoomed card overflows its + // own zone, and Qt orders whole subtrees level by level, so no single z-value wins + // every comparison on its own. Harmless elsewhere (areas and zones tile without + // overlapping; arrows, zone views and drag items keep their higher layers). + // Restore first: if a previous hover never ended (its card was destroyed mid-hover), + // its chain is still outstanding and must land before a new one is recorded. + restoreHoverLift(); + QGraphicsItem *ancestor = card->parentItem(); + while (ancestor) { + // The chain is QGraphicsObject-derived in practice; a plain QGraphicsItem + // ancestor cannot be guarded, so it keeps its z-value instead of risking an + // unrestorable lift. + if (QGraphicsObject *ancestorObject = ancestor->toGraphicsObject()) { + hoverLiftedAncestors.append({QPointer(ancestorObject), ancestor->zValue()}); + ancestor->setZValue(ZValues::HOVERED_AREA); + } + ancestor = ancestor->parentItem(); + } + liftOwner = card; + connect(card, &AbstractCardItem::hoverShrinkFinished, this, &GameScene::onHoverShrinkFinished, + Qt::UniqueConnection); + connect(card, &AbstractCardItem::hoverFootprintChanged, this, &GameScene::onHoverFootprintChanged, + Qt::UniqueConnection); } void GameScene::endCardHover(CardItem *card) { - if (auto *zone = SelectZone::findOwningSelectZone(card)) { - zone->restoreClipAfterHover(card); - } + // Only the zoom-out starts here: the z-value, the clip escape and the ancestor + // lift stay put until the shrink finishes (onHoverShrinkFinished), so siblings and + // the clip container cannot cut through the still-enlarged card. card->setHovered(false); } +void GameScene::onHoverShrinkFinished(AbstractCardItem *card) +{ + auto *cardItem = qobject_cast(card); + if (!cardItem) { + return; + } + // Per-card and idempotent: always settle, even if a newer hover owns the lift. + if (auto *zone = SelectZone::findOwningSelectZone(cardItem)) { + zone->restoreClipAfterHover(cardItem); + } + if (liftOwner != cardItem) { + // A newer hover already owns the lift (or nothing does): leave it alone. + return; + } + restoreHoverLift(); +} + +void GameScene::onHoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint) +{ + const QRectF dirty = oldFootprint.united(newFootprint); + if (dirty.isEmpty()) { + return; + } + // TableZone cuts the hovered card out of its own paint, but it is cached: the card + // changing size does not dirty the zone, so the previous hole would stay transparent in + // the cache — an uncovered black rectangle under the zoom whenever the card grows across + // a zone boundary. Nudge only the zones the footprint touches; the scene repaint itself + // is already scheduled by the card. + const auto overlapping = items(dirty, Qt::IntersectsItemBoundingRect); + for (QGraphicsItem *item : overlapping) { + if (auto *zone = qobject_cast(item->toGraphicsObject())) { + zone->update(); + } + } +} + +void GameScene::restoreHoverLift() +{ + for (const auto &[lifted, zValue] : hoverLiftedAncestors) { + if (lifted) { + lifted->setZValue(zValue); + } + } + hoverLiftedAncestors.clear(); + liftOwner.clear(); +} + +// ---------- Card Hover Popup ---------- + +void GameScene::showCardHoverPopup(CardItem *card) +{ + if (!card || !card->getOwner()) { + return; + } + + PlayerGraphicsItem *playerView = playerViews.value(card->getOwner()->getPlayerInfo()->getId()); + if (!playerView) { + return; + } + + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + hoverGracePending = false; + hoverGraceTimer->stop(); + hoverPopupTarget = card; + hoverPanelTarget = nullptr; + confirmArmedCard.clear(); + + if (!cardHoverWidget) { + auto *viewport = views().isEmpty() ? nullptr : views().first()->viewport(); + cardHoverWidget = new CardHoverWidget(viewport); + connect(cardHoverWidget, &CardHoverWidget::hoverEntered, this, &GameScene::onHoverWidgetEntered); + connect(cardHoverWidget, &CardHoverWidget::hoverLeft, this, &GameScene::onHoverWidgetLeft); + connect(cardHoverWidget, &CardHoverWidget::dismissRequested, this, &GameScene::hideCardHoverPopup); + + // Keep the popup anchored to its card while the view scrolls, zooms, or resizes + if (!views().isEmpty()) { + auto *view = views().first(); + connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, this, &GameScene::positionCardHoverPopup); + connect(view->verticalScrollBar(), &QScrollBar::valueChanged, this, &GameScene::positionCardHoverPopup); + if (viewport) { + viewport->installEventFilter(this); + } + } + } + + // Crossfade: the opaque panel picture sits exactly over the zoomed card, so fade + // the board card out underneath it (masks any residual size/corner mismatch) and + // fade any previously shown card back in. + CardItem *oldCard = cardHoverWidget->hasCard() ? cardHoverWidget->getCardItem() : nullptr; + + cardHoverWidget->setCard(card, playerView); + positionCardHoverPopup(); + cardHoverWidget->showAnimated(); + + if (oldCard && oldCard != card) { + oldCard->fadeCardIn(); + } + card->fadeCardOut(); +} + +void GameScene::hideCardHoverPopup() +{ + // Hiding is terminal: drop all pending hover timing with it, or a dwell/panel + // timer that resolves afterwards pops the panel back up (e.g. over the context + // menu that hideCardHoverPopup was just called for). + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + hoverGracePending = false; + hoverGraceTimer->stop(); + if (cardHoverWidget) { + // The panel is gone: whatever card it covered must come back. + if (CardItem *coveredCard = cardHoverWidget->getCardItem()) { + coveredCard->fadeCardIn(); + } + if (cardHoverWidget->isVisible()) { + cardHoverWidget->hideAnimated(); + } + } + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; +} + +void GameScene::positionCardHoverPopup() +{ + if (!cardHoverWidget || !cardHoverWidget->hasCard()) { + return; + } + + auto *viewport = views().isEmpty() ? nullptr : views().first()->viewport(); + if (!viewport) { + return; + } + + QRectF sceneRect = cardHoverWidget->getCardItem()->sceneBoundingRect(); + QTransform vpTransform = getViewportTransform(); + cardHoverWidget->positionNextTo(sceneRect, vpTransform, viewport); +} + +void GameScene::onHoverActivateTimeout() +{ + // Dwell resolved: the hover drives whichever phases listen to it. The cursor must + // still be over the pending card (rect test, not a fresh pick, so pick flakiness + // cannot veto it); otherwise this was a stale timer. + CardItem *target = hoverPopupTarget; + hoverPopupTarget = nullptr; + if (!target || views().isEmpty()) { + return; + } + auto *view = views().first(); + const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos())); + if (!target->sceneBoundingRect().contains(cursorScene)) { + return; + } + auto &display = SettingsCache::instance().cardsDisplay(); + const bool zoomPhase = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerHover; + const bool panelPhase = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerHover; + const bool effectiveDouble = + display.getHoverPanelDoubleConfirm() && display.getHoverZoomTrigger() == display.getHoverPanelTrigger(); + if (hoveredCard && hoveredCard != target) { + endCardHover(hoveredCard); + } + if (zoomPhase) { + if (hoveredCard != target) { + beginCardHover(target); + hoveredCard = target; + } + if (effectiveDouble) { + confirmArmedCard = target; + } + } + if (panelPhase && !effectiveDouble) { + hoverPanelTarget = target; + hoverPanelTimer->start(display.getHoverPanelDelayMs()); + } +} + +void GameScene::onHoverPanelTimeout() +{ + // Panel delay resolved: show, but only if the target card is still the one under + // the cursor (same cheap rect check — no dependence on pick stability). + CardItem *target = hoverPanelTarget; + hoverPanelTarget = nullptr; + if (!target || views().isEmpty()) { + return; + } + auto *view = views().first(); + const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos())); + if (!target->sceneBoundingRect().contains(cursorScene)) { + return; + } + ensureZoomed(target); + showCardHoverPopup(target); +} + +void GameScene::onHoverGraceTimeout() +{ + hoverGracePending = false; + if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->isMouseOverWidget()) { + return; + } + hideCardHoverPopup(); +} + +void GameScene::summonHoverPanel() +{ + // Summon for whatever is under the cursor right now; hovering is not required. + if (views().isEmpty()) { + return; + } + auto *view = views().first(); + const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos())); + if (CardItem *card = pickCardAt(cursorScene)) { + auto &display = SettingsCache::instance().cardsDisplay(); + fireTriggers(card, display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerShortcut, + display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerShortcut); + } +} + +void GameScene::ensureZoomed(CardItem *card) +{ + if (hoveredCard != card) { + if (hoveredCard) { + endCardHover(hoveredCard); + } + beginCardHover(card); + hoveredCard = card; + } +} + +void GameScene::fireTriggers(CardItem *card, bool zoomFired, bool panelFired) +{ + if (!card || !card->getOwner()) { + return; + } + + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing() && + cardHoverWidget->getCardItem() == card) { + cardHoverWidget->refreshDisplay(); + return; + } + + // Explicit triggers preempt any pending hover timing. + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + + auto &display = SettingsCache::instance().cardsDisplay(); + const bool effectiveDouble = + display.getHoverPanelDoubleConfirm() && display.getHoverZoomTrigger() == display.getHoverPanelTrigger(); + + if (zoomFired && panelFired) { + // Both phases on one action: one-step zooms and shows at once (the crossfade + // covers it); double-confirm arms on first fire and shows on repeat. + if (effectiveDouble) { + if (confirmArmedCard == card) { + confirmArmedCard.clear(); + ensureZoomed(card); + showCardHoverPopup(card); + } else { + confirmArmedCard = card; + ensureZoomed(card); + } + } else { + ensureZoomed(card); + showCardHoverPopup(card); + } + return; + } + if (zoomFired) { + ensureZoomed(card); + } + if (panelFired) { + ensureZoomed(card); + showCardHoverPopup(card); + } +} + +void GameScene::onHoverWidgetEntered() +{ + hoverGracePending = false; + hoverGraceTimer->stop(); +} + +void GameScene::onHoverWidgetLeft() +{ + if (!hoveredCard && cardHoverWidget && cardHoverWidget->isVisible() && !hoverGracePending) { + hoverGracePending = true; + hoverGraceTimer->start(); + } +} + +bool GameScene::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::Resize) { + positionCardHoverPopup(); + } + return QGraphicsScene::eventFilter(watched, event); +} + CardZone *GameScene::findTopmostZone(const QList &items) { for (QGraphicsItem *item : items) { @@ -940,7 +1423,50 @@ bool GameScene::event(QEvent *event) { if (event->type() == QEvent::GraphicsSceneMouseMove) { updateHover(static_cast(event)->scenePos()); + } else if (event->type() == QEvent::GraphicsSceneWheel) { + // Wheel trigger: wheeling over a card fires whichever phases listen to it + // instead of scrolling. Fully consumed here: forwarding to the base dispatcher + // as well would double-deliver it to items under the cursor (e.g. zone views, + // which scroll on wheel). Scrolling up instead reverts an active zoom/panel — + // backing out of the gesture undoes it; with nothing to revert the event falls + // through to normal scrolling. + auto &display = SettingsCache::instance().cardsDisplay(); + const bool zoomFired = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerWheel; + const bool panelFired = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerWheel; + if (zoomFired || panelFired) { + auto *wheelEvent = static_cast(event); + // QGraphicsSceneWheelEvent carries delta()/pixelDelta(), not angleDelta(). + const int wheelUp = !wheelEvent->pixelDelta().isNull() + ? wheelEvent->pixelDelta().y() + : (wheelEvent->orientation() == Qt::Vertical ? wheelEvent->delta() : 0); + if (wheelUp > 0) { + if ((cardHoverWidget && cardHoverWidget->isVisible()) || hoveredCard) { + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; + confirmArmedCard.clear(); + hideCardHoverPopup(); + if (hoveredCard) { + endCardHover(hoveredCard); + hoveredCard = nullptr; + } + event->accept(); + return true; + } + } else if (CardItem *card = pickCardAt(wheelEvent->scenePos())) { + fireTriggers(card, zoomFired, panelFired); + event->accept(); + return true; + } + } } else if (event->type() == QEvent::Leave) { + // The hover panel opens directly over the zoomed card under the cursor, which the + // scene observes as a Leave. The cursor never actually left: if it is over the open + // panel, keep the hover (dropping it here shrinks the card from under the panel and + // starves the popup logic, so the menu never shows). A genuine leave of the window + // misses the panel and still ends the hover as before. + if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->isMouseOverWidget()) { + return QGraphicsScene::event(event); + } updateHoveredCard(nullptr); } diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 0d4db70a8..9c8c422a3 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -11,8 +11,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -30,6 +32,7 @@ class CardZoneLogic; class IAnimatedItem; class QGraphicsItem; +class QGraphicsObject; class QPoint; struct ArrowData; template class QSharedPointer; @@ -48,6 +51,8 @@ class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; class QObject; +class QTimer; +class CardHoverWidget; /** * @class GameScene @@ -73,16 +78,41 @@ private: ArrowRegistry arrowRegistry; ///< ID registry for arrow graphics items QList zoneViews; ///< Active zone view widgets QSize viewSize; ///< Current view size - QPointer hoveredCard; ///< Currently hovered card - QPointer activeCard; ///< Card the currently open context menu targets - QBasicTimer *animationTimer; ///< Timer for scene animations - QHash animatedItems; ///< Items currently animating + QPointer hoveredCard; ///< Currently hovered card (zoomed) + /// Card the cursor is resting on. It carries the hover outline and the card-info + /// update immediately, independently of the zoom/panel triggers. + QPointer hoverHighlightCard; + QPointer activeCard; ///< Card the currently open context menu targets + /// Card armed for double-confirm panel summon (first trigger zooms, repeat shows). + QPointer confirmArmedCard; + /// Ancestor chain of the hovered card with pre-hover Z-values. Lifted while hovering + /// because Qt orders subtrees level by level: the card z alone cannot beat a sibling + /// zone, and the top ancestor alone cannot beat same-area sibling zones either — every + /// level from the zone up must win its own comparison. Guarded pointers: if the card + /// is destroyed mid-hover (or an ancestor goes away first), the guards null out and + /// the next restore skips them instead of touching freed memory. An orphaned lift is + /// invisible — areas and zones tile without overlapping and all UI layers still win — + /// and any outstanding lift is restored by the next hover transition. + QList, qreal>> hoverLiftedAncestors; + /// Card whose ancestor chain is currently lifted (cleared together with the chain). + QPointer liftOwner; + QBasicTimer *animationTimer; ///< Timer for scene animations + QHash animatedItems; ///< Items currently animating QHash animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item int playerRotation; ///< Rotation offset for player layout bool rearranging = false; ///< Guard against re-entrant rearrange bool needsReArrange = false; ///< Pending rearrange requested during a pass + // Card hover popup + QPointer cardHoverWidget; ///< The hover popup widget + QTimer *hoverActivateTimer; ///< Dwell delay before the hover zoom kicks in + QTimer *hoverPanelTimer; ///< Delay after the zoom before the tab panel shows + QTimer *hoverGraceTimer; ///< Grace period before hiding popup (200ms) + QPointer hoverPopupTarget; ///< Card the popup is intended for + QPointer hoverPanelTarget; ///< Card the panel timer will show + bool hoverGracePending = false; ///< Grace timer armed (mouse left a card or the popup) + /** * @brief Updates which card is currently hovered based on scene coordinates. * @param scenePos Scene position of the cursor. @@ -96,6 +126,23 @@ private: void beginCardHover(CardItem *card); /** @brief Deactivates hover state and restores the card to its clip container. */ void endCardHover(CardItem *card); + /** @brief Restores every lifted ancestor to its pre-hover Z-value and drops the chain. */ + void restoreHoverLift(); + /** @brief Settles a finished shrink-out: clip container and ancestor lift restore. */ + void onHoverShrinkFinished(AbstractCardItem *card); + /** + * @brief Repaints the zones that dodge the zoomed card when its footprint moves. + * + * Those zones cut the hovered card out of their paint, but they are cached: another + * item's geometry change does not dirty them, so without this the previous hole stays + * transparent in the cache and a black rectangle shows under the zoom. + */ + void onHoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint); + + // Card hover popup methods + void showCardHoverPopup(CardItem *card); + void hideCardHoverPopup(); + void positionCardHoverPopup(); public: /** @@ -227,6 +274,21 @@ public: /** @brief Updates hovered card highlighting. */ void updateHoveredCard(CardItem *newCard); + /** @brief Topmost card at a scene position, without side effects. */ + CardItem *pickCardAt(const QPointF &scenePos); + + /** + * @brief Fires the zoom and/or panel trigger for a card (one explicit user action). + * When both fire on one action, double-confirm arms on first fire and shows on repeat. + */ + void fireTriggers(CardItem *card, bool zoomFired, bool panelFired); + + /** @brief Starts the hover zoom for a card without touching panel state. */ + void ensureZoomed(CardItem *card); + + /** @brief The currently hovered card, if any (used by zones to dodge it while painting). */ + CardItem *getHoveredCard() const; + /** * @brief Registers an item for animation updates with the shared scene timer. * @@ -293,10 +355,22 @@ public slots: */ void onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); + // Card hover popup slots + void onHoverActivateTimeout(); + void onHoverPanelTimeout(); + void onHoverGraceTimeout(); + void onHoverWidgetEntered(); + void onHoverWidgetLeft(); + /** @brief Summons the hover panel for the currently hovered card, if any. */ + void summonHoverPanel(); + protected: /** @brief Handles hover updates. */ bool event(QEvent *event) override; + /** @brief Repositions the hover popup when the viewport resizes. */ + bool eventFilter(QObject *watched, QEvent *event) override; + /** @brief Handles animation timer updates. */ void timerEvent(QTimerEvent *event) override; diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index 7a5aa073a..59f374435 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -87,6 +87,10 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par connect(aCloseMostRecentZoneView, &QAction::triggered, scene, &GameScene::closeMostRecentZoneView); addAction(aCloseMostRecentZoneView); + + aShowHoverPanel = new QAction(this); + connect(aShowHoverPanel, &QAction::triggered, scene, &GameScene::summonHoverPanel); + addAction(aShowHoverPanel); connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &GameView::refreshShortcuts); refreshShortcuts(); @@ -246,6 +250,7 @@ void GameView::refreshShortcuts() { aCloseMostRecentZoneView->setShortcuts( SettingsCache::instance().shortcuts().getShortcut("Player/aCloseMostRecentZoneView")); + aShowHoverPanel->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aShowHoverPanel")); } void GameView::clearTallyLabels() diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index 99a73a0a8..52c06ebcb 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -29,6 +29,7 @@ class GameView : public QGraphicsView Q_OBJECT private: QAction *aCloseMostRecentZoneView; + QAction *aShowHoverPanel; QRubberBand *rubberBand; QLabel *dragCountLabel; QLabel *totalCountLabel; diff --git a/cockatrice/src/game_graphics/z_values.h b/cockatrice/src/game_graphics/z_values.h index c6e7f2c8a..8e198102b 100644 --- a/cockatrice/src/game_graphics/z_values.h +++ b/cockatrice/src/game_graphics/z_values.h @@ -34,6 +34,10 @@ constexpr qreal OVERLAY_BASE = ZValueLayerManager::OVERLAY_BASE; // Overlay layer Z-values for items that should appear above normal cards constexpr qreal HOVERED_CARD = ZValueLayerManager::overlayZValue(1.0); +// Lifts the hovered card's whole player area above sibling areas (and their zone +// decorations) while hovering. One level only: Qt compares subtrees at their highest +// differing ancestor, so the top ancestor decides. Below arrows/views/drags on purpose. +constexpr qreal HOVERED_AREA = ZValueLayerManager::overlayZValue(2.0); constexpr qreal ARROWS = ZValueLayerManager::overlayZValue(3.0); constexpr qreal ZONE_VIEW_WIDGET = ZValueLayerManager::overlayZValue(4.0); constexpr qreal DRAG_ITEM = ZValueLayerManager::overlayZValue(5.0); diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 0dffab353..dd2b3e38e 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,7 @@ #include #include #include +#include #include namespace google @@ -118,6 +120,19 @@ bool TableZone::isInverted() const void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { + // The hovered (zoomed) card may spill over this zone: cut it out of every layer + // below so washes, indicators, dimming and the land divider never paint over it, + // whatever the stacking order is. + const std::optional hoverHole = hoveredCardHole(); + const bool clipped = hoverHole.has_value(); + if (clipped && hoverHole->isEmpty()) { + // The zoom covers this zone entirely: there is nothing left of it to paint. + return; + } + if (clipped) { + painter->setClipPath(*hoverHole); + } + if (playmatActive) { // Subtle overlay to distinguish table zone from stack zone painter->fillRect(boundingRect(), QColor(0, 0, 0, 60)); @@ -142,6 +157,39 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti } paintLandDivider(painter); + + if (clipped) { + painter->setClipping(false); + } +} + +/** + * @brief This zone's paint area minus the hovered card's painted outline, in zone coordinates. + * + * @c std::nullopt when no card is hovered or it does not overlap this zone; an *empty* path when + * the zoom covers this zone entirely, which means nothing of it is left to paint. Lets every + * paint layer dodge the zoomed card instead of painting over it. + */ +std::optional TableZone::hoveredCardHole() const +{ + auto *scene = qobject_cast(this->scene()); + if (!scene) { + return std::nullopt; + } + CardItem *hovered = scene->getHoveredCard(); + if (!hovered) { + return std::nullopt; + } + // Cut out the card's painted outline, not its bounding rectangle: the rectangle's corners + // are never covered by the artwork (rounded corners, antialiased edges), so they would stay + // black around the zoom. + const QPainterPath cardOutline = sceneTransform().inverted().map(hovered->sceneTransform().map(hovered->shape())); + if (!cardOutline.boundingRect().intersects(boundingRect())) { + return std::nullopt; + } + QPainterPath zoneArea; + zoneArea.addRect(boundingRect()); + return zoneArea.subtracted(cardOutline); } void TableZone::onPlaymatChanged(bool active) diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 8aa4936ad..6beb54d30 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -18,6 +18,7 @@ #include #include #include +#include #include class CardDragItem; @@ -26,6 +27,7 @@ class CardZoneLogic; class QColor; class QGraphicsItem; class QPainter; +class QPainterPath; template class QList; /** @@ -221,6 +223,13 @@ private: void paintZoneOutline(QPainter *painter); void paintLandDivider(QPainter *painter); + /** + * @brief This zone's paint area minus the hovered card's painted outline, in zone coordinates. + * + * @c std::nullopt when no card is hovered or it does not overlap this zone; an *empty* path when + * the hovered card covers this zone entirely, in which case nothing of the zone is visible. + */ + [[nodiscard]] std::optional hoveredCardHole() const; /* Calculates card stack widths so mapping functions work properly diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 2efa2fd13..bd8da9407 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -94,8 +94,10 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, searchEdit.setFocusPolicy(Qt::ClickFocus); searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setClearButtonEnabled(true); - searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); + auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info"), IconTint::quiet()), + QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index d5d70c4ef..94ad5492c 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -178,9 +178,14 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary); cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Quiet); // Semantic roles stay independent of the accent: a generated palette must - // still paint add/ready green and remove/not-ready red. - for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, - AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + // still paint add/ready green, remove/not-ready red, information blue, + // hidden information violet, marks amber and movement teal. + for (const AppColor::Role role : + {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, AppColor::Negative, + AppColor::NegativeStrong, AppColor::NegativeSoft, AppColor::Informational, AppColor::InformationalStrong, + AppColor::InformationalSoft, AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft, AppColor::Marked, + AppColor::MarkedStrong, AppColor::MarkedSoft, AppColor::Movement, AppColor::MovementStrong, + AppColor::MovementSoft}) { cfg.appColors[role] = defaultAppColor(role, dark); } diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 73e821b90..e7d91d330 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -78,6 +78,22 @@ static const QMap APP_ROLE_DESCRIPTIONS = { QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")}, {AppColor::NegativeStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at primary emphasis")}, {AppColor::NegativeSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at quiet emphasis")}, + {AppColor::Informational, + QT_TRANSLATE_NOOP("PaletteGridWidget", + "Informational state that is neither a gain nor a loss (face-down, exile)")}, + {AppColor::InformationalStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at primary emphasis")}, + {AppColor::InformationalSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at quiet emphasis")}, + {AppColor::Hidden, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information (face-down play, peek at the card face)")}, + {AppColor::HiddenStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information at primary emphasis")}, + {AppColor::HiddenSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information at quiet emphasis")}, + {AppColor::Marked, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks left on a card (annotation, draw arrow)")}, + {AppColor::MarkedStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks on a card at primary emphasis")}, + {AppColor::MarkedSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks on a card at quiet emphasis")}, + {AppColor::Movement, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Cards moving without a gain or a loss (library placements)")}, + {AppColor::MovementStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Movement at primary emphasis")}, + {AppColor::MovementSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Movement at quiet emphasis")}, }; PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 037293af9..2705d74ca 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -448,7 +448,8 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) // handing a DPR-1 bitmap to a label that would upscale and blur it on a 2x // display. const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed"); - const QPixmap pixmap = loadThemedIcon(prefix).pixmap(QSize(height, height), qApp->devicePixelRatio()); + const QPixmap pixmap = + loadThemedIcon(prefix, IconTint::quiet()).pixmap(QSize(height, height), qApp->devicePixelRatio()); pmCache.insert(key, pixmap); return pixmap; } diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 848be2231..525a46905 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -482,6 +482,42 @@ QColor defaultAppColor(AppColor::Role role, bool dark) return QColor(dark ? QStringLiteral("#ff8378") : QStringLiteral("#da0b00")); case AppColor::NegativeSoft: return QColor(dark ? QStringLiteral("#c79e9b") : QStringLiteral("#935f5c")); + // Informational is the third semantic family: a state that is neither a + // gain nor a loss (a face-down play, the exile zone). It carries the + // same three-level ladder so its emphasis is authored, not computed. + case AppColor::Informational: + return QColor(dark ? QStringLiteral("#7aa9f2") : QStringLiteral("#1c64c4")); + case AppColor::InformationalStrong: + return QColor(dark ? QStringLiteral("#69aeff") : QStringLiteral("#0a4fb5")); + case AppColor::InformationalSoft: + return QColor(dark ? QStringLiteral("#9db2cd") : QStringLiteral("#526a8a")); + // Hidden is the game's hidden-information cluster: a face-down play and + // peeking at a card face. Violet keeps it apart from the blue + // Informational family, which carries public information (reveal). + case AppColor::Hidden: + return QColor(dark ? QStringLiteral("#c6a5fa") : QStringLiteral("#6d28d9")); + case AppColor::HiddenStrong: + return QColor(dark ? QStringLiteral("#c99bff") : QStringLiteral("#4c0f9e")); + case AppColor::HiddenSoft: + return QColor(dark ? QStringLiteral("#b3a6c9") : QStringLiteral("#6a5a8c")); + // Marked is the marks a player leaves on a card: an annotation or a + // draw arrow. Amber names a persistent, removable mark rather than a + // gain or a loss, so annotation and arrow finally share a colour. + case AppColor::Marked: + return QColor(dark ? QStringLiteral("#e2a63a") : QStringLiteral("#9a5b05")); + case AppColor::MarkedStrong: + return QColor(dark ? QStringLiteral("#ffb020") : QStringLiteral("#8a4f02")); + case AppColor::MarkedSoft: + return QColor(dark ? QStringLiteral("#c2ab7d") : QStringLiteral("#7b6a4a")); + // Movement is a card changing position without a gain or a loss: the + // library placements (top, bottom, X from top). Teal keeps it apart + // from the green "into play" moves and the blue informational states. + case AppColor::Movement: + return QColor(dark ? QStringLiteral("#4cc2b5") : QStringLiteral("#0e7a72")); + case AppColor::MovementStrong: + return QColor(dark ? QStringLiteral("#2dd4bf") : QStringLiteral("#096b62")); + case AppColor::MovementSoft: + return QColor(dark ? QStringLiteral("#a3c1bc") : QStringLiteral("#4a6b67")); } return {}; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 68734e762..0848baeb8 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -15,11 +15,11 @@ // beyond what Qt's palette can express. // // The icon families carry one role per emphasis level. Icon is the neutral -// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive -// and Negative follow the same pattern, so an emphasis never has to be computed -// from a hue at paint time: the theme authors all three levels. Naming a variant -// explicitly (`IconStrong`) pins that level; naming the family (`Icon`) lets the -// caller's emphasis choose. +// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive, +// Negative, Informational, Hidden, Marked and Movement follow the same pattern, +// so an emphasis never has to be computed from a hue at paint time: the theme +// authors all three levels. Naming a variant explicitly (`IconStrong`) pins that +// level; naming the family (`Icon`) lets the caller's emphasis choose. namespace AppColor { Q_NAMESPACE @@ -36,6 +36,18 @@ enum Role Negative, NegativeStrong, NegativeSoft, + Informational, + InformationalStrong, + InformationalSoft, + Hidden, + HiddenStrong, + HiddenSoft, + Marked, + MarkedStrong, + MarkedSoft, + Movement, + MovementStrong, + MovementSoft, }; Q_ENUM_NS(Role) } // namespace AppColor diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp index f0c9ef84f..3a71f149c 100644 --- a/cockatrice/src/interface/theme_icon.cpp +++ b/cockatrice/src/interface/theme_icon.cpp @@ -54,6 +54,22 @@ AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis) return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong : emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft : AppColor::Negative; + case AppColor::Informational: + return emphasis == IconEmphasis::Primary ? AppColor::InformationalStrong + : emphasis == IconEmphasis::Quiet ? AppColor::InformationalSoft + : AppColor::Informational; + case AppColor::Hidden: + return emphasis == IconEmphasis::Primary ? AppColor::HiddenStrong + : emphasis == IconEmphasis::Quiet ? AppColor::HiddenSoft + : AppColor::Hidden; + case AppColor::Marked: + return emphasis == IconEmphasis::Primary ? AppColor::MarkedStrong + : emphasis == IconEmphasis::Quiet ? AppColor::MarkedSoft + : AppColor::Marked; + case AppColor::Movement: + return emphasis == IconEmphasis::Primary ? AppColor::MovementStrong + : emphasis == IconEmphasis::Quiet ? AppColor::MovementSoft + : AppColor::Movement; default: return role; } diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 1ad0309b2..3a2ecf9cf 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -683,6 +683,18 @@ QColor ThemeManager::appColor(AppColor::Role role) const case AppColor::Negative: case AppColor::NegativeStrong: case AppColor::NegativeSoft: + case AppColor::Informational: + case AppColor::InformationalStrong: + case AppColor::InformationalSoft: + case AppColor::Hidden: + case AppColor::HiddenStrong: + case AppColor::HiddenSoft: + case AppColor::Marked: + case AppColor::MarkedStrong: + case AppColor::MarkedSoft: + case AppColor::Movement: + case AppColor::MovementStrong: + case AppColor::MovementSoft: // Semantic colors are independent of the accent: a missing role must // not silently paint "remove" in the theme's highlight color. return defaultAppColor(role, isDarkModeActive()); diff --git a/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp b/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp new file mode 100644 index 000000000..27625db52 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp @@ -0,0 +1,1736 @@ +#include "card_hover_widget.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/card_counter_settings.h" +#include "../../../game_graphics/board/card_item.h" +#include "../../../game_graphics/game_scene.h" +#include "../../../game_graphics/player/player_graphics_item.h" +#include "../../theme_icon.h" +#include "card_info_picture_widget.h" +#include "card_info_text_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr int CARD_IMAGE_WIDTH = 270; +static constexpr int TAB_MIN_WIDTH = 250; +static constexpr int TAB_MAX_WIDTH = 300; +static constexpr int TAB_MIN_HEIGHT = 360; +static constexpr int NAV_BUTTON_WIDTH = 78; +static constexpr int NAV_ICON_SIZE = 24; +static constexpr int POPUP_GAP = 6; +static constexpr int FADE_DURATION_MS = 140; +static constexpr int REFRESH_DEBOUNCE_MS = 30; +static constexpr int OVERLAY_BTN_SIZE = 24; +static constexpr int OVERLAY_CORNER_RADIUS = 4; + +// P/T pill: centred on the card's printed P/T box, in fractions of the picture rect. +static constexpr double PT_PILL_ANCHOR_X = 0.92; +static constexpr double PT_PILL_ANCHOR_Y = 0.94; +static constexpr int PT_PILL_EDGE_INSET = 2; +static constexpr int PT_PILL_MIN_WIDTH = 48; +static constexpr int PT_PILL_MIN_HEIGHT = 22; +static constexpr int PT_PILL_TEXT_PADDING = 4; + +// Hover trigger: the bottom-right quadrant of the art, around the printed P/T box. +static constexpr double PT_HOVER_REGION_WIDTH = 0.35; +static constexpr double PT_HOVER_REGION_HEIGHT = 0.20; +// Halo around the resting pill that still counts as "on the P/T box" for opening the rows. +static constexpr int PT_OPEN_HALO = 4; + +enum HoverPanelPage +{ + PAGE_INFO, + PAGE_MAIN, + PAGE_MOVE, + PAGE_COUNTER, + PAGE_TOKEN +}; + +// The action grid is what the panel exists for, so it carries the primary ink; +// the category tabs only navigate between pages and stay quiet. Both use the +// line glyph: the filled treatments read as a second state, and every cell in +// the grid would carry one. +static QIcon categoryTabIcon(const QString &prefix) +{ + return loadThemedIcon(prefix, IconTint::quiet()); +} + +// Every action carries a hue naming a game fact the glyph cannot: a gain, a +// loss, information, hidden information, a mark on the card or a move. Keeping +// one line glyph per action means a row of different families still reads as a +// single grid instead of a set of buttons from different toolbars. +static QIcon semanticActionIcon(const QString &prefix, AppColor::Role role) +{ + return loadThemedIcon(prefix, IconTint(role, IconEmphasis::Primary)); +} + +CardHoverWidget::CardHoverWidget(QWidget *parent) : QFrame(parent), currentCard(nullptr), currentPlayer(nullptr) +{ + setObjectName("cardHoverWidget"); + setFrameShape(QFrame::StyledPanel); + setWindowFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::ToolTip); + setAttribute(Qt::WA_DeleteOnClose, false); + setAttribute(Qt::WA_ShowWithoutActivating, true); + setMouseTracking(true); + setContentsMargins(0, 0, 0, 0); + + applyThemeStyle(); + + setWindowOpacity(0.0); + + fadeIn = new QPropertyAnimation(this, "windowOpacity", this); + fadeIn->setDuration(FADE_DURATION_MS); + fadeIn->setStartValue(0.0); + fadeIn->setEndValue(1.0); + fadeIn->setEasingCurve(QEasingCurve::OutCubic); + + fadeOut = new QPropertyAnimation(this, "windowOpacity", this); + fadeOut->setDuration(FADE_DURATION_MS); + fadeOut->setStartValue(1.0); + fadeOut->setEndValue(0.0); + fadeOut->setEasingCurve(QEasingCurve::OutCubic); + connect(fadeOut, &QPropertyAnimation::finished, this, &QWidget::hide); + + refreshTimer = new QTimer(this); + refreshTimer->setSingleShot(true); + refreshTimer->setInterval(REFRESH_DEBOUNCE_MS); + connect(refreshTimer, &QTimer::timeout, this, &CardHoverWidget::onCardChanged); + + createImageArea(); + createContentStack(); + createNavBar(); + + mainLayout = new QHBoxLayout(this); + mainLayout->setContentsMargins(8, 8, 8, 8); + mainLayout->setSpacing(8); + mainLayout->addWidget(imageContainer, 0); + mainLayout->addWidget(sideBar, 0); + mainLayout->addWidget(contentStack, 1); + setLayout(mainLayout); + + showPage(PAGE_INFO); +} + +void CardHoverWidget::applyThemeStyle() +{ + const QPalette &pal = QApplication::palette(); + const QString window = pal.color(QPalette::Window).name(); + const QString windowText = pal.color(QPalette::WindowText).name(); + const QString base = pal.color(QPalette::Base).name(); + const QString text = pal.color(QPalette::Text).name(); + const QString mid = pal.color(QPalette::Mid).name(); + const QString highlight = pal.color(QPalette::Highlight).name(); + const QString highlightedText = pal.color(QPalette::HighlightedText).name(); + const QString disabled = pal.color(QPalette::PlaceholderText).name(); + + // Opaque theme colors throughout — translucency proved too hard to read. + // The dark scrims over the card artwork itself are kept, they sit on the image, not the panel. + // Guard: setStyleSheet() itself emits PaletteChange, which would otherwise + // re-enter applyThemeStyle() via changeEvent() forever (freeze, then stack overflow). + QString sheet = QStringLiteral("QFrame#cardHoverWidget {" + " background-color: %1;" + " border: 1px solid %2;" + " border-radius: 10px;" + "}" + "QScrollArea { background: transparent; border: none; }" + "QToolButton#cardHoverNavBtn {" + " background: transparent; color: %4;" + " border: 1px solid transparent; border-radius: 8px;" + " padding: 7px 4px; font-size: 11px;" + "}" + "QToolButton#cardHoverNavBtn:hover {" + " background: %3; border-color: %2;" + "}" + "QToolButton#cardHoverNavBtn:checked {" + " background: %5; color: %6; border-color: %5;" + "}" + "QLabel#cardHoverName {" + " color: %8; font-weight: bold; font-size: 12px;" + "}" + "QPushButton#cardHoverActionBtn {" + " background: %3; color: %4;" + " border: 1px solid %2; border-radius: 6px;" + " padding: 5px 10px; font-size: 12px; text-align: center;" + "}" + "QPushButton#cardHoverActionBtn:hover {" + " background: %5; color: %6; border-color: %5;" + "}" + "QPushButton#cardHoverActionBtn:pressed {" + " background: %5; color: %6;" + "}" + "QPushButton#cardHoverActionBtn:checked { background: %5; color: %6; }" + "QPushButton#cardHoverActionBtn:disabled { color: %7; }" + "QToolButton#cardHoverGridBtn {" + " background: %3; color: %4;" + " border: 1px solid %2; border-radius: 6px;" + " padding: 6px 4px; font-size: 11px;" + "}" + "QToolButton#cardHoverGridBtn:hover {" + " background: %5; color: %6; border-color: %5;" + "}" + "QToolButton#cardHoverGridBtn:pressed {" + " background: %5; color: %6;" + "}" + "QToolButton#cardHoverGridBtn:checked { background: %5; color: %6; }" + "QToolButton#cardHoverGridBtn:disabled { color: %7; }" + "QLabel#cardHoverSectionHeader {" + " background: %2; color: %8;" + " border-radius: 4px; padding: 3px 6px;" + " font-weight: bold; font-size: 11px;" + "}" + "QLabel#cardHoverNote { color: %7; font-style: italic; font-size: 11px; }" + "QLineEdit#cardHoverNameEdit {" + " color: %4; background: %3; border: 1px solid %2;" + " border-radius: 3px; padding: 2px;" + " font-weight: bold; font-size: 13px;" + "}" + // Accent edge on the resting P/T pill: the cue that the + // control rows live behind it, and the divider once they lift out. + "QLabel#cardHoverPTPill {" + " border-top: 2px solid %5;" + "}") + .arg(window, mid, base, text, highlight, highlightedText, disabled, windowText); + if (sheet == styleSheet()) { + return; + } + setStyleSheet(sheet); +} + +// ---------- Overlay styling (dark scrims over the card artwork) ---------- + +static QString overlayBgStyle() +{ + return QStringLiteral("background-color: rgba(0, 0, 0, 180);" + "border-radius: %1px;") + .arg(OVERLAY_CORNER_RADIUS); +} + +/** @brief Clamps @p start so that a run of @p size stays within [@p origin, @p origin + @p extent]. */ +static int clampToSpan(int origin, int extent, int size, int start) +{ + if (size >= extent) { + return origin; + } + return qBound(origin, start, origin + extent - size); +} + +// ---------- Image area with overlaid controls ---------- + +void CardHoverWidget::createImageArea() +{ + imageContainer = new QWidget(this); + imageContainer->setObjectName("cardHoverImageContainer"); + imageContainer->setMouseTracking(true); + imageContainer->setFixedWidth(CARD_IMAGE_WIDTH); + + pic = new CardInfoPictureWidget(imageContainer); + pic->setObjectName("cardHoverPic"); + pic->setMouseTracking(true); + pic->setHoverToZoomEnabled(false); + pic->setScaleFactor(135); // full-picture expanded size (~270px wide) + + auto *picLayout = new QVBoxLayout(imageContainer); + picLayout->setContentsMargins(0, 0, 0, 0); + picLayout->setSpacing(0); + picLayout->addWidget(pic); + imageContainer->setLayout(picLayout); + + createNameBarOverlay(); + createPTOverlay(); + + pic->installEventFilter(this); + // The artwork rect is only known after the pixmap is drawn (letterboxing, sideways-card + // rotation), so re-anchor the overlays once it lands. + connect(pic, &CardInfoPictureWidget::artRectChanged, this, &CardHoverWidget::positionOverlays, + Qt::QueuedConnection); +} + +// ---------- Name bar overlay (top of card) ---------- + +void CardHoverWidget::createNameBarOverlay() +{ + nameBarOverlay = new QWidget(imageContainer); + nameBarOverlay->setObjectName("cardHoverNameBar"); + nameBarOverlay->setMouseTracking(true); + nameBarOverlay->setStyleSheet(overlayBgStyle()); + + nameLabel = new QLabel(nameBarOverlay); + nameLabel->setObjectName("cardHoverNameLabel"); + nameLabel->setAlignment(Qt::AlignCenter); + nameLabel->setStyleSheet( + "color: white; font-weight: bold; font-size: 13px; background: transparent; padding: 2px;"); + nameLabel->setWordWrap(false); + + nameEdit = new QLineEdit(nameBarOverlay); + nameEdit->setObjectName("cardHoverNameEdit"); + nameEdit->setAlignment(Qt::AlignCenter); + nameEdit->setFixedHeight(22); + nameEdit->setPlaceholderText(tr("Set annotation...")); + nameEdit->setVisible(false); + connect(nameEdit, &QLineEdit::editingFinished, this, [this]() { + if (!nameEdit->isVisible()) { + return; + } + if (canModifyCurrentCard()) { + if (auto *actions = currentCard->getOwner()->getPlayerActions()) { + QList cardList{currentCard->getState()}; + actions->actSetAnnotation(cardList, nameEdit->text()); + } + } + nameEdit->setVisible(false); + nameLabel->setVisible(true); + refreshDisplay(); + }); + + annotationLabel = new QLabel(nameBarOverlay); + annotationLabel->setObjectName("cardHoverAnnotationLabel"); + annotationLabel->setAlignment(Qt::AlignCenter); + annotationLabel->setStyleSheet( + "color: #dddddd; font-style: italic; font-size: 11px; background: transparent; padding: 1px;"); + annotationLabel->setWordWrap(false); + annotationLabel->setVisible(false); + + auto *nameLayout = new QVBoxLayout(nameBarOverlay); + nameLayout->setContentsMargins(6, 3, 6, 3); + nameLayout->setSpacing(1); + nameLayout->addWidget(nameLabel); + nameLayout->addWidget(nameEdit); + nameLayout->addWidget(annotationLabel); + nameBarOverlay->setLayout(nameLayout); + + nameLabel->installEventFilter(this); +} + +// ---------- P/T overlay (bottom-right of card): pill at rest, controls on hover ---------- + +void CardHoverWidget::createPTOverlay() +{ + ptOverlay = new QWidget(imageContainer); + ptOverlay->setObjectName("cardHoverPTOverlay"); + ptOverlay->setMouseTracking(true); + ptOverlay->setStyleSheet(overlayBgStyle()); + + auto getActions = [this]() -> PlayerActions * { + if (currentCard && currentCard->getOwner()) { + return currentCard->getOwner()->getPlayerActions(); + } + return nullptr; + }; + auto cardList = [this]() -> QList { + return currentCard && currentCard->getState() ? QList{currentCard->getState()} + : QList(); + }; + + // Control rows: hidden at rest, shown while the P/T area is hovered. + ptControls = new QWidget(ptOverlay); + ptControls->setObjectName("cardHoverPTControls"); + ptControls->setMouseTracking(true); + ptControls->setStyleSheet(QStringLiteral("background: transparent;")); + + // Value pill: the resting state, and the bottom (never-moving) row of the stack. + // The accent top edge is the only cue that controls sit behind the pill; it doubles + // as the divider between the pill and the rows once they are lifted out. + ptPillLabel = new QLabel(ptOverlay); + ptPillLabel->setObjectName("cardHoverPTPill"); + ptPillLabel->setAlignment(Qt::AlignCenter); + // The accent top edge (the only cue that controls sit behind the pill) comes from the + // palette-driven sheet in applyThemeStyle(), so it follows theme switches. + ptPillLabel->setStyleSheet(QStringLiteral("color: white; font-weight: bold; font-size: 12px;" + " background: transparent; padding: 0 %1px;") + .arg(PT_PILL_TEXT_PADDING)); + ptPillLabel->setMinimumHeight(PT_PILL_MIN_HEIGHT); + // Seed text: the resting size has to exist before the first card is shown. + ptPillLabel->setText(QStringLiteral("00/00")); + + ptPowerLabel = new QLabel(ptControls); + ptPowerLabel->setObjectName("cardHoverPTPowerValue"); + ptPowerLabel->setAlignment(Qt::AlignCenter); + ptPowerLabel->setStyleSheet( + "color: white; font-weight: bold; font-size: 12px; background: transparent; padding: 0 2px;"); + ptPowerLabel->setMinimumWidth(28); + + ptToughLabel = new QLabel(ptControls); + ptToughLabel->setObjectName("cardHoverPTToughValue"); + ptToughLabel->setAlignment(Qt::AlignCenter); + ptToughLabel->setStyleSheet( + "color: white; font-weight: bold; font-size: 12px; background: transparent; padding: 0 2px;"); + ptToughLabel->setMinimumWidth(28); + + btnDecP = createOverlayButton("-P", tr("Decrease power"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actDecP(cardList()); + } + }); + btnIncP = createOverlayButton("+P", tr("Increase power"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actIncP(cardList()); + } + }); + btnDecT = createOverlayButton("-T", tr("Decrease toughness"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actDecT(cardList()); + } + }); + btnIncT = createOverlayButton("+T", tr("Increase toughness"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actIncT(cardList()); + } + }); + btnSetPT = createOverlayButton("SET", tr("Set power/toughness..."), [this, getActions, cardList]() { + runModalAction([getActions, cardList]() { + if (auto *a = getActions()) { + a->actRequestSetPTDialog(cardList()); + } + }); + }); + btnResetPT = createOverlayButton("RST", tr("Reset power/toughness"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actResetPT(cardList()); + } + }); + + auto *powerRow = new QHBoxLayout(); + powerRow->setSpacing(2); + powerRow->addWidget(btnDecP); + powerRow->addWidget(ptPowerLabel); + powerRow->addWidget(btnIncP); + + auto *toughRow = new QHBoxLayout(); + toughRow->setSpacing(2); + toughRow->addWidget(btnDecT); + toughRow->addWidget(ptToughLabel); + toughRow->addWidget(btnIncT); + + auto *btnRow = new QHBoxLayout(); + btnRow->setSpacing(2); + btnRow->addWidget(btnSetPT); + btnRow->addWidget(btnResetPT); + + auto *controlsLayout = new QVBoxLayout(ptControls); + controlsLayout->setContentsMargins(4, 3, 4, 3); + controlsLayout->setSpacing(2); + controlsLayout->addLayout(powerRow); + controlsLayout->addLayout(toughRow); + controlsLayout->addLayout(btnRow); + ptControls->setLayout(controlsLayout); + + auto *ptLayout = new QVBoxLayout(ptOverlay); + ptLayout->setContentsMargins(0, 0, 0, 0); + ptLayout->setSpacing(2); + ptLayout->addWidget(ptControls); + ptLayout->addWidget(ptPillLabel); + ptOverlay->setLayout(ptLayout); + + // Both states are driven from sizes cached here: hiding the controls would shrink + // layout()->sizeHint(), so the two states must not be measured from the live layout. + ptFullSize = ptLayout->sizeHint(); + ptControls->setVisible(false); + ptCompactSize = measureCompactPTSize(); + ptPillLabel->clear(); + ptOverlay->resize(ptCompactSize); + + // The whole subtree feeds the hover state: moving onto a button must not read as a leave. + ptOverlay->installEventFilter(this); + const QList ptChildren = ptOverlay->findChildren(); + for (QWidget *child : ptChildren) { + child->installEventFilter(this); + } + + ptOverlay->setVisible(false); +} + +// ---------- Nav strip: vertical icon buttons with horizontal text ---------- + +void CardHoverWidget::createNavBar() +{ + sideBar = new QWidget(this); + sideBar->setObjectName("cardHoverSideBar"); + + navNameLabel = new QLabel(sideBar); + navNameLabel->setObjectName("cardHoverName"); + navNameLabel->setAlignment(Qt::AlignCenter); + navNameLabel->setWordWrap(true); + + navGroup = new QButtonGroup(sideBar); + navGroup->setExclusive(true); + + navInfoBtn = createNavButton(tr("Info"), categoryTabIcon(QStringLiteral("icons/info")), PAGE_INFO); + navMainBtn = createNavButton(tr("Main"), categoryTabIcon(QStringLiteral("icons/settings")), PAGE_MAIN); + navMoveBtn = createNavButton(tr("Move"), categoryTabIcon(QStringLiteral("icons/swap")), PAGE_MOVE); + navCounterBtn = createNavButton(tr("Counters"), categoryTabIcon(QStringLiteral("icons/increment")), PAGE_COUNTER); + navTokenBtn = createNavButton(tr("Tokens"), categoryTabIcon(QStringLiteral("icons/dragon")), PAGE_TOKEN); + + connect(navGroup, &QButtonGroup::idClicked, this, [this](int id) { contentStack->setCurrentIndex(id); }); + + auto *barLayout = new QVBoxLayout(sideBar); + barLayout->setContentsMargins(0, 0, 0, 0); + barLayout->setSpacing(4); + barLayout->setAlignment(Qt::AlignTop); + barLayout->addWidget(navNameLabel); + barLayout->addWidget(navInfoBtn); + barLayout->addWidget(navMainBtn); + barLayout->addWidget(navMoveBtn); + barLayout->addWidget(navCounterBtn); + barLayout->addWidget(navTokenBtn); + barLayout->addStretch(1); + sideBar->setLayout(barLayout); +} + +QToolButton *CardHoverWidget::createNavButton(const QString &title, const QIcon &icon, int pageIndex) +{ + auto *btn = new QToolButton(sideBar); + btn->setObjectName("cardHoverNavBtn"); + btn->setText(title); + btn->setToolTip(title); + btn->setIcon(icon); + btn->setIconSize(QSize(NAV_ICON_SIZE, NAV_ICON_SIZE)); + btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + btn->setCheckable(true); + btn->setAutoExclusive(true); + btn->setMinimumWidth(NAV_BUTTON_WIDTH); + navGroup->addButton(btn, pageIndex); + return btn; +} + +void CardHoverWidget::showPage(int index) +{ + contentStack->setCurrentIndex(index); + if (auto *btn = navGroup->button(index)) { + btn->setChecked(true); + } +} + +// ---------- Content pages (no nested tab bars) ---------- + +void CardHoverWidget::createContentStack() +{ + contentStack = new QStackedWidget(this); + contentStack->setMinimumWidth(TAB_MIN_WIDTH); + contentStack->setMaximumWidth(TAB_MAX_WIDTH); + contentStack->setMinimumHeight(TAB_MIN_HEIGHT); + + infoPage = new QWidget(contentStack); + text = new CardInfoTextWidget(infoPage); + text->setObjectName("cardHoverText"); + connect(text, &CardInfoTextWidget::linkActivated, this, [this](const QString &cardName) { + ExactCard card = CardDatabaseManager::query()->guessCard({cardName}); + if (!card.isEmpty()) { + text->setCard(card); + } + }); + + auto *infoScroll = new QScrollArea(infoPage); + infoScroll->setWidget(text); + infoScroll->setWidgetResizable(true); + infoScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + infoScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + infoScroll->setFrameShape(QFrame::NoFrame); + + infoPageLayout = new QVBoxLayout(infoPage); + infoPageLayout->setContentsMargins(4, 4, 4, 4); + infoPageLayout->addWidget(infoScroll, 1); + infoPage->setLayout(infoPageLayout); + + mainPage = makeStackPage(mainPageLayout); + movePage = makeStackPage(movePageLayout); + counterPage = makeStackPage(counterPageLayout); + tokenPage = makeStackPage(tokenPageLayout); + + contentStack->addWidget(infoPage); + contentStack->addWidget(mainPage); + contentStack->addWidget(movePage); + contentStack->addWidget(counterPage); + contentStack->addWidget(tokenPage); +} + +QWidget *CardHoverWidget::makeStackPage(QVBoxLayout *&layout) +{ + auto *page = new QWidget(contentStack); + auto *inner = new QWidget(page); + layout = new QVBoxLayout(inner); + layout->setContentsMargins(4, 4, 4, 4); + layout->setSpacing(4); + layout->setAlignment(Qt::AlignTop); + inner->setLayout(layout); + + auto *scroll = new QScrollArea(page); + scroll->setWidget(inner); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scroll->setFrameShape(QFrame::NoFrame); + + auto *outer = new QVBoxLayout(page); + outer->setContentsMargins(0, 0, 0, 0); + outer->addWidget(scroll, 1); + page->setLayout(outer); + + return page; +} + +QPushButton *CardHoverWidget::createSmallButton(const QString &text, const std::function &slot, QWidget *parent) +{ + return createSmallButton(text, QString(), slot, parent); +} + +// Packs icon-over-text tool buttons into a uniform grid so action pages use the full width. +class ButtonGrid +{ +public: + explicit ButtonGrid(QVBoxLayout *layout, int columns = 2) : columns(columns) + { + grid = new QGridLayout(); + grid->setContentsMargins(0, 0, 0, 0); + grid->setSpacing(4); + layout->addLayout(grid); + } + + void addSection(QWidget *header) + { + if (col != 0) { + ++row; + col = 0; + } + grid->addWidget(header, row++, 0, 1, columns); + } + + void addButton(QWidget *button) + { + grid->addWidget(button, row, col++); + if (col >= columns) { + col = 0; + ++row; + } + } + +private: + QGridLayout *grid; + int columns; + int row = 0; + int col = 0; +}; + +QToolButton *CardHoverWidget::createGridButton(const QString &text, + const QIcon &icon, + const std::function &slot, + QWidget *parent, + const QString &tooltip) +{ + auto *btn = new QToolButton(parent); + btn->setObjectName("cardHoverGridBtn"); + btn->setText(text); + // Short cell labels can clip; the full text is always one hover away. + btn->setToolTip(tooltip.isEmpty() ? text : tooltip); + btn->setIcon(icon); + btn->setIconSize(QSize(22, 22)); + btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + btn->setMinimumHeight(58); + btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + connect(btn, &QToolButton::clicked, this, [slot]() { slot(); }); + return btn; +} + +QPushButton *CardHoverWidget::createSmallButton(const QString &text, + const QString &tooltip, + const std::function &slot, + QWidget *parent) +{ + auto *btn = new QPushButton(text, parent ? parent : contentStack); + btn->setObjectName("cardHoverActionBtn"); + btn->setMinimumHeight(30); + if (!tooltip.isEmpty()) { + btn->setToolTip(tooltip); + } + connect(btn, &QPushButton::clicked, this, [slot]() { slot(); }); + return btn; +} + +QPushButton * +CardHoverWidget::createOverlayButton(const QString &text, const QString &tooltip, const std::function &slot) +{ + const QPalette &pal = QApplication::palette(); + QColor bg = pal.color(QPalette::Dark); + bg.setAlpha(180); + QColor hoverBg = pal.color(QPalette::Mid); + hoverBg.setAlpha(200); + QColor pressBg = pal.color(QPalette::Shadow); + pressBg.setAlpha(200); + QColor fg = pal.color(QPalette::ButtonText); + + auto *btn = new QPushButton(text, imageContainer); + btn->setObjectName("cardHoverOverlayBtn"); + btn->setToolTip(tooltip); + btn->setFixedSize(OVERLAY_BTN_SIZE + 8, OVERLAY_BTN_SIZE); + btn->setFlat(true); + btn->setStyleSheet(QStringLiteral("QPushButton {" + " color: %1; font-size: 10px; font-weight: bold;" + " background: %2; border: none;" + " border-radius: 3px;" + "}" + "QPushButton:hover { background: %3; }" + "QPushButton:pressed { background: %4; }") + .arg(fg.name(), bg.name(), hoverBg.name(), pressBg.name())); + connect(btn, &QPushButton::clicked, this, [slot]() { slot(); }); + return btn; +} + +QLabel *CardHoverWidget::createSectionHeader(const QString &text, QWidget *parent) +{ + auto *lbl = new QLabel(text, parent); + lbl->setObjectName("cardHoverSectionHeader"); + lbl->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + lbl->setMinimumHeight(22); + return lbl; +} + +QLabel *CardHoverWidget::createNoteLabel(const QString &text, QWidget *parent) +{ + auto *lbl = new QLabel(text, parent); + lbl->setObjectName("cardHoverNote"); + lbl->setAlignment(Qt::AlignCenter); + lbl->setWordWrap(true); + return lbl; +} + +// ---------- Live updates: stay in sync while the popup is open ---------- + +void CardHoverWidget::connectCardSignals() +{ + if (!currentCard) { + return; + } + if (CardState *state = currentCard->getState()) { + connect(state, &CardState::stateChanged, this, &CardHoverWidget::scheduleRefresh); + } + connect(currentCard, &CardItem::tappedChanged, this, &CardHoverWidget::scheduleRefresh); + connect(currentCard, &CardItem::faceDownChanged, this, &CardHoverWidget::scheduleRefresh); + connect(currentCard, &QObject::destroyed, this, &CardHoverWidget::onCardDestroyed); +} + +void CardHoverWidget::disconnectCardSignals() +{ + if (currentCard) { + if (CardState *state = currentCard->getState()) { + disconnect(state, nullptr, this, nullptr); + } + disconnect(currentCard, nullptr, this, nullptr); + } +} + +void CardHoverWidget::scheduleRefresh() +{ + if (!currentCard) { + return; + } + if (!refreshTimer->isActive()) { + refreshTimer->start(); + } +} + +void CardHoverWidget::onCardChanged() +{ + if (!currentCard) { + return; + } + + // The underlying card may have changed identity (copy effects, flip to another face) + ExactCard now = currentCard->getCard(); + if (now.getCardPtr() != shownCard.getCardPtr()) { + shownCard = now; + pic->setCard(now); + text->setCard(now); + nameLabel->setText(now.getInfo().getName()); + navNameLabel->setText(now.getInfo().getName()); + } + + refreshDisplay(); + rebuildActionsForZone(); + positionOverlays(); +} + +void CardHoverWidget::onCardDestroyed() +{ + // Never touch the sender here: it is mid-teardown. Qt already auto-disconnects + // its signals, so just drop our reference; clearCard() then skips sender access. + currentCard = nullptr; + clearCard(); + hideAnimated(); +} + +// ---------- Grouped action builders (mirror CardMenu per zone) ---------- + +bool CardHoverWidget::canModifyCurrentCard() const +{ + if (!currentCard || !currentCard->getOwner() || !currentPlayer) { + return false; + } + if (!currentPlayer->getLogic()->getPlayerInfo()->getLocalOrJudge()) { + return false; + } + // A revealed zone we cannot write to is read-only even for its owner. + if (auto *view = qobject_cast(currentCard->getZone())) { + if (view->getRevealZone() && !view->getWriteableRevealZone()) { + return false; + } + } + return true; +} + +void CardHoverWidget::rebuildActionsForZone() +{ + clearLayout(mainPageLayout); + clearLayout(movePageLayout); + clearLayout(counterPageLayout); + clearLayout(tokenPageLayout); + + if (!currentCard || !currentPlayer) { + return; + } + + auto *card = currentCard.data(); + QList cardList{card->getState()}; + + const bool canModify = canModifyCurrentCard(); + + QString zoneName = card->getZone() ? card->getZone()->getName() : QString(); + + buildMainTab(cardList, zoneName, canModify); + buildMoveTab(cardList, zoneName, canModify); + buildCounterTab(cardList, zoneName, canModify); + buildTokenTab(card, canModify); +} + +void CardHoverWidget::buildMainTab(const QList &cardList, const QString &zoneName, bool canModify) +{ + auto *layout = mainPageLayout; + auto *card = currentCard.data(); + auto *player = currentPlayer.data(); + auto *actions = player->getLogic()->getPlayerActions(); + auto *gameScene = player->getGameScene(); + auto invoke = [actions, cardList](CardMenuActionType type) { + return [actions, cardList, type]() { actions->cardMenuAction(cardList, type); }; + }; + auto attach = [card]() { card->drawAttachArrow(); }; + auto drawArrow = [card]() { card->drawArrow(Qt::red); }; + + if (!canModify) { + ButtonGrid grid(layout); + grid.addSection(createSectionHeader(tr("Revealed"), mainPage)); + grid.addButton(createGridButton( + tr("Hide"), semanticActionIcon(QStringLiteral("icons/arrow_left"), AppColor::Informational), + [actions, cardList]() { actions->actHide(cardList); }, mainPage)); + grid.addButton(createGridButton(tr("Clone"), + semanticActionIcon(QStringLiteral("icons/increment"), AppColor::Positive), + invoke(cmClone), mainPage)); + grid.addSection(createSectionHeader(tr("Select"), mainPage)); + grid.addButton(createGridButton( + tr("Select All"), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Informational), + [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); + grid.addButton(createGridButton( + tr("Select Column"), semanticActionIcon(QStringLiteral("icons/filter"), AppColor::Informational), + [gameScene, card]() { gameScene->selectSameColumn(card); }, mainPage)); + layout->addStretch(1); + return; + } + + if (zoneName == ZoneNames::TABLE) { + ButtonGrid grid(layout); + grid.addSection(createSectionHeader(tr("Battlefield"), mainPage)); + grid.addButton(createGridButton(tr("Tap / Untap"), + semanticActionIcon(QStringLiteral("icons/sync"), AppColor::Informational), + invoke(cmTap), mainPage)); + auto *doesntUntap = createGridButton( + tr("Skip untapping"), semanticActionIcon(QStringLiteral("icons/lock"), AppColor::Informational), + [actions, cardList]() { actions->cardMenuAction(cardList, cmDoesntUntap); }, mainPage); + doesntUntap->setCheckable(true); + doesntUntap->setChecked(card->getDoesntUntap()); + grid.addButton(doesntUntap); + grid.addButton(createGridButton(tr("Clone"), + semanticActionIcon(QStringLiteral("icons/increment"), AppColor::Positive), + invoke(cmClone), mainPage)); + grid.addButton(createGridButton(tr("Attach to card..."), + semanticActionIcon(QStringLiteral("icons/pin"), AppColor::Positive), attach, + mainPage)); + grid.addButton(createGridButton(tr("Draw arrow..."), + semanticActionIcon(QStringLiteral("icons/share"), AppColor::Marked), drawArrow, + mainPage)); + grid.addButton(createGridButton( + tr("Set annotation..."), semanticActionIcon(QStringLiteral("icons/pencil"), AppColor::Marked), + [this, actions, cardList]() { + runModalAction([actions, cardList]() { actions->actRequestSetAnnotationDialog(cardList); }); + }, + mainPage)); + grid.addButton(createGridButton(tr("Turn Over"), + semanticActionIcon(QStringLiteral("icons/swap"), AppColor::Hidden), + invoke(cmFlip), mainPage)); + if (card->getFaceDown()) { + grid.addButton(createGridButton(tr("Peek at card face"), + semanticActionIcon(QStringLiteral("icons/view"), AppColor::Hidden), + invoke(cmPeek), mainPage)); + } + if (card->getAttachedTo()) { + grid.addButton(createGridButton( + tr("Unattach"), semanticActionIcon(QStringLiteral("icons/arrow_undo"), AppColor::Negative), + [actions, cardList]() { actions->actUnattach(cardList); }, mainPage)); + } + grid.addButton(createGridButton( + tr("Reduce life"), semanticActionIcon(QStringLiteral("icons/arrow_down"), AppColor::Negative), + [actions, cardList]() { actions->actReduceLifeByPower(cardList); }, mainPage)); + grid.addSection(createSectionHeader(tr("Select"), mainPage)); + grid.addButton(createGridButton( + tr("Select All"), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Informational), + [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); + grid.addButton(createGridButton( + tr("Select Row"), semanticActionIcon(QStringLiteral("icons/filter"), AppColor::Informational), + [gameScene, card]() { gameScene->selectSameRow(card); }, mainPage)); + } else if (!zoneName.isEmpty()) { + ButtonGrid grid(layout); + grid.addSection(createSectionHeader(tr("Zone"), mainPage)); + grid.addButton(createGridButton( + tr("Play"), semanticActionIcon(QStringLiteral("icons/arrow_right"), AppColor::Positive), + [actions, cardList]() { actions->actPlay(cardList); }, mainPage)); + grid.addButton(createGridButton( + tr("Play Face Down"), semanticActionIcon(QStringLiteral("icons/arrow_right"), AppColor::Hidden), + [actions, cardList]() { actions->actPlayFacedown(cardList); }, mainPage)); + grid.addButton(createGridButton(tr("Clone"), + semanticActionIcon(QStringLiteral("icons/increment"), AppColor::Positive), + invoke(cmClone), mainPage)); + if (zoneName == ZoneNames::STACK || zoneName == ZoneNames::HAND || zoneName == ZoneNames::GRAVE || + zoneName == ZoneNames::EXILE) { + grid.addButton(createGridButton(tr("Attach to card..."), + semanticActionIcon(QStringLiteral("icons/pin"), AppColor::Positive), attach, + mainPage)); + grid.addButton(createGridButton(tr("Draw arrow..."), + semanticActionIcon(QStringLiteral("icons/share"), AppColor::Marked), + drawArrow, mainPage)); + } + grid.addSection(createSectionHeader(tr("Select"), mainPage)); + grid.addButton(createGridButton( + tr("Select All"), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Informational), + [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); + if (qobject_cast(card->getZone())) { + grid.addButton(createGridButton( + tr("Select Column"), semanticActionIcon(QStringLiteral("icons/filter"), AppColor::Informational), + [gameScene, card]() { gameScene->selectSameColumn(card); }, mainPage)); + } + } else { + layout->addWidget(createNoteLabel(tr("No card actions here — see the Move tab."), mainPage)); + } + + layout->addStretch(1); +} + +void CardHoverWidget::buildMoveTab(const QList &cardList, const QString &zoneName, bool canModify) +{ + auto *layout = movePageLayout; + if (!canModify) { + layout->addWidget(createNoteLabel(tr("Read-only."), movePage)); + layout->addStretch(1); + return; + } + + auto *player = currentPlayer.data(); + auto *actions = player->getLogic()->getPlayerActions(); + auto invoke = [actions, cardList](CardMenuActionType type) { + return [actions, cardList, type]() { actions->cardMenuAction(cardList, type); }; + }; + + ButtonGrid grid(layout); + grid.addSection(createSectionHeader(tr("Move to"), movePage)); + grid.addButton(createGridButton(tr("Top of library"), + semanticActionIcon(QStringLiteral("icons/arrow_to_top"), AppColor::Movement), + invoke(cmMoveToTopLibrary), movePage)); + grid.addButton(createGridButton( + tr("X from top..."), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Movement), + [actions]() { actions->actRequestMoveCardXCardsFromTopDialog(); }, movePage, tr("X from top of library..."))); + grid.addButton(createGridButton(tr("Bottom of library"), + semanticActionIcon(QStringLiteral("icons/arrow_to_bottom"), AppColor::Movement), + invoke(cmMoveToBottomLibrary), movePage)); + grid.addButton(createGridButton(tr("Table"), semanticActionIcon(QStringLiteral("icons/player"), AppColor::Positive), + invoke(cmMoveToTable), movePage)); + grid.addButton(createGridButton(tr("Hand"), + semanticActionIcon(QStringLiteral("icons/arrow_left"), AppColor::Positive), + invoke(cmMoveToHand), movePage)); + grid.addButton(createGridButton(tr("Graveyard"), + semanticActionIcon(QStringLiteral("icons/arrow_down"), AppColor::Negative), + invoke(cmMoveToGraveyard), movePage)); + grid.addButton(createGridButton(tr("Exile"), + semanticActionIcon(QStringLiteral("icons/arrow_right"), AppColor::Informational), + invoke(cmMoveToExile), movePage)); + + // Reveal actions live on hand / custom zones in CardMenu + if (!zoneName.isEmpty() && zoneName != ZoneNames::TABLE && zoneName != ZoneNames::STACK && + zoneName != ZoneNames::GRAVE && zoneName != ZoneNames::EXILE) { + auto *game = player->getLogic()->getGame(); + if (game && game->getPlayerManager()) { + grid.addSection(createSectionHeader(tr("Reveal to..."), movePage)); + grid.addButton(createGridButton( + tr("All players"), semanticActionIcon(QStringLiteral("icons/share"), AppColor::Informational), + [actions, cardList]() { + auto *fake = new QAction(nullptr); + fake->setData(-1); + actions->actReveal(cardList, fake); + fake->deleteLater(); + }, + movePage)); + const auto players = game->getPlayerManager()->getPlayers().values(); + for (auto *other : players) { + if (other == player->getLogic()) { + continue; + } + QString name = other->getPlayerInfo()->getName(); + int id = other->getPlayerInfo()->getId(); + grid.addButton(createGridButton( + name, semanticActionIcon(QStringLiteral("icons/player"), AppColor::Informational), + [actions, cardList, id]() { + auto *fake = new QAction(nullptr); + fake->setData(id); + actions->actReveal(cardList, fake); + fake->deleteLater(); + }, + movePage)); + } + } + } + + layout->addStretch(1); +} + +void CardHoverWidget::buildCounterTab(const QList &cardList, const QString &zoneName, bool canModify) +{ + auto *layout = counterPageLayout; + auto *actions = currentPlayer->getLogic()->getPlayerActions(); + + if (!canModify) { + layout->addWidget(createNoteLabel(tr("Read-only."), counterPage)); + layout->addStretch(1); + return; + } + if (zoneName != ZoneNames::TABLE) { + layout->addWidget(createNoteLabel( + tr("Counters live on battlefield cards. PT and counters can be changed there."), counterPage)); + layout->addStretch(1); + return; + } + + layout->addWidget(createSectionHeader(tr("Power / Toughness"), counterPage)); + layout->addWidget(createSmallButton( + tr("Set power and toughness..."), + [this, actions, cardList]() { + runModalAction([actions, cardList]() { actions->actRequestSetPTDialog(cardList); }); + }, + counterPage)); + layout->addWidget(createSmallButton( + tr("Reset power and toughness"), [actions, cardList]() { actions->actResetPT(cardList); }, counterPage)); + + // One group per counter: color dot plus live value in the header, its actions in + // a compact row beneath it. Rebuilt on every card change, so values never go stale. + const QMap counters = currentCard ? currentCard->getCounters() : QMap{}; + for (int i = 0; i < 6; ++i) { + const QString counterName = SettingsCache::instance().cardCounters().displayName(i); + const QString dot = QStringLiteral("\u25cf ") + .arg(SettingsCache::instance().cardCounters().color(i).name()); + const int value = counters.value(i, 0); + layout->addWidget(createSectionHeader(dot + tr("%1 \u00d7 %2").arg(counterName).arg(value), counterPage)); + + auto *row = new QHBoxLayout(); + row->setSpacing(4); + row->addWidget(createSmallButton( + tr("Add"), [actions, cardList, i]() { actions->actAddCardCounter(cardList, i); }, counterPage)); + if (value > 0) { + row->addWidget(createSmallButton( + tr("Remove"), [actions, cardList, i]() { actions->actRemoveCardCounter(cardList, i); }, counterPage)); + } + row->addWidget(createSmallButton( + tr("Set..."), + [this, actions, cardList, i]() { + runModalAction([actions, cardList, i]() { actions->actRequestSetCardCounterDialog(cardList, i); }); + }, + counterPage)); + layout->addLayout(row); + } + + layout->addStretch(1); +} + +void CardHoverWidget::buildTokenTab(CardItem *card, bool canModify) +{ + auto *layout = tokenPageLayout; + if (!card || !currentPlayer) { + layout->addStretch(1); + return; + } + auto *actions = currentPlayer->getLogic()->getPlayerActions(); + ExactCard exactCard = card->getCard(); + + bool anyToken = false; + if (canModify && !exactCard.isEmpty()) { + QList relatedCards = exactCard.getInfo().getAllRelatedCards(); + bool hasMulti = relatedCards.size() > 1; + int known = 0; + for (CardRelation *relation : relatedCards) { + ExactCard related = + CardDatabaseManager::query()->getCardFromSameSet(relation->getName(), exactCard.getPrinting()); + if (!related) { + related = CardDatabaseManager::query()->getCard({relation->getName()}); + } + if (!related) { + continue; + } + if (known == 0) { + layout->addWidget(createSectionHeader(tr("Tokens"), tokenPage)); + } + ++known; + QString relatedName = related.getInfo().getPowTough().isEmpty() + ? related.getName() + : related.getInfo().getPowTough() + " " + related.getName(); + CardRelation *captured = relation; + CardItem *source = card; + layout->addWidget(createSmallButton( + tr("Token: ") + relatedName, + [this, actions, source, captured]() { + runModalAction([actions, source, captured]() { + actions->actRequestCreateRelatedFromRelationDialog(source->getState(), captured); + }); + }, + tokenPage)); + } + if (hasMulti && known > 0) { + layout->addWidget(createSmallButton( + tr("All tokens"), + [this, actions, card]() { + runModalAction([actions, card]() { actions->actCreateAllRelatedCards(card->getState()); }); + }, + tokenPage)); + } + anyToken = known > 0; + } + + // Save the tab index before adding related-view buttons so the header only shows when useful + int beforeRelated = layout->count(); + addRelatedViewButtons(layout); + bool anyRelated = layout->count() != beforeRelated; + + if (!anyToken && !anyRelated) { + layout->addWidget(createNoteLabel(tr("No tokens or related cards."), tokenPage)); + } + + layout->addStretch(1); +} + +void CardHoverWidget::addRelatedViewButtons(QVBoxLayout *layout) +{ + if (!currentCard) { + return; + } + ExactCard exactCard = currentCard->getCard(); + if (exactCard.isEmpty()) { + return; + } + QList relatedCards = exactCard.getInfo().getAllRelatedCards(); + bool anyKnown = false; + for (const CardRelation *relation : relatedCards) { + if (CardDatabaseManager::query()->getCardInfo(relation->getName())) { + anyKnown = true; + break; + } + } + if (!anyKnown) { + return; + } + QWidget *page = layout == tokenPageLayout ? tokenPage : contentStack; + + layout->addWidget(createSectionHeader(tr("Related cards"), page)); + for (const CardRelation *relation : relatedCards) { + QString name = relation->getName(); + if (!CardDatabaseManager::query()->getCardInfo(name)) { + continue; + } + layout->addWidget(createSmallButton( + name, + [this, name, exactCard]() { + ExactCard related = CardDatabaseManager::query()->getCard({name, exactCard.getPrinting().getUuid()}); + if (!related.isEmpty()) { + text->setCard(related); + showPage(PAGE_INFO); + } + }, + page)); + } +} + +// ---------- Layout helper ---------- + +void CardHoverWidget::clearLayout(QLayout *layout) +{ + if (!layout) { + return; + } + QLayoutItem *item; + while ((item = layout->takeAt(0)) != nullptr) { + if (item->widget()) { + item->widget()->deleteLater(); + } + if (item->layout()) { + clearLayout(item->layout()); + } + delete item; + } +} + +// ---------- Overlay positioning ---------- + +void CardHoverWidget::positionOverlays() +{ + if (!currentCard || !pic) { + return; + } + + nameBarOverlay->show(); + + int containerWidth = imageContainer->width(); + + // Name bar at top — width constrained, height from layout + int nameWidth = containerWidth - 8; + nameBarOverlay->setFixedWidth(nameWidth); + nameBarOverlay->adjustSize(); + nameBarOverlay->move(4, 4); + + // Show/hide based on card data; an empty P/T always falls back to the compact pill + const bool hasPT = !currentCard->getPT().isEmpty(); + if (!hasPT && ptExpanded) { + setPTExpanded(false); + } + applyPTOverlayGeometry(); + ptOverlay->setVisible(hasPT); +} + +void CardHoverWidget::setPTExpanded(bool expanded) +{ + if (!ptOverlay || !ptControls) { + return; + } + // Nothing to expand into when the card has no P/T. + if (expanded && (!currentCard || currentCard->getPT().isEmpty())) { + expanded = false; + } + // The buttons act on our own copy of the card by id, so on another player's card they + // would edit our zone. Offer them only where the action tabs allow edits. + if (expanded && !canModifyCurrentCard()) { + expanded = false; + } + // Mouse movement drives this on every event; only apply a real state change. + if (ptExpanded == expanded) { + return; + } + + ptExpanded = expanded; + ptControls->setVisible(expanded); + if (expanded) { + // The stack covers more of the art than the pill did; keep it above sibling overlays. + ptOverlay->raise(); + } + applyPTOverlayGeometry(); +} + +void CardHoverWidget::applyPTOverlayGeometry() +{ + if (!ptOverlay || !pic || ptCompactSize.isEmpty() || ptFullSize.isEmpty()) { + return; + } + // Anchor on the drawn artwork, not on imageContainer: the container can be taller than the + // picture, and sideways-layout cards are letterboxed inside pic. Both used to drop the + // overlay below the printed P/T box. + const QRect picture = + pic->artRect() + .translated(pic->pos()) + .adjusted(PT_PILL_EDGE_INSET, PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET); + const QRect compact = compactPillRect(); + if (picture.isEmpty() || compact.isEmpty()) { + return; + } + + // Expanded: same bottom edge and right edge as the pill, so the controls grow upward + // and never appear under the cursor that is resting on the pill. Never narrower than + // the current pill, or a three-digit value would clip against the stack's edge. + const QSize size = + ptExpanded ? QSize(qMax(ptFullSize.width(), ptCompactSize.width()), ptFullSize.height()) : ptCompactSize; + const int x = clampToSpan(picture.x(), picture.width(), size.width(), compact.x() + compact.width() - size.width()); + const int y = + clampToSpan(picture.y(), picture.height(), size.height(), compact.y() + compact.height() - size.height()); + + ptOverlay->setGeometry(x, y, size.width(), size.height()); +} + +QRect CardHoverWidget::compactPillRect() const +{ + if (!pic || ptCompactSize.isEmpty()) { + return {}; + } + const QRect picture = + pic->artRect() + .translated(pic->pos()) + .adjusted(PT_PILL_EDGE_INSET, PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET); + if (picture.isEmpty()) { + return {}; + } + // Centred on the printed P/T box (bottom-right of the art). + const int anchorX = picture.x() + qRound(picture.width() * PT_PILL_ANCHOR_X); + const int anchorY = picture.y() + qRound(picture.height() * PT_PILL_ANCHOR_Y); + return QRect( + clampToSpan(picture.x(), picture.width(), ptCompactSize.width(), anchorX - ptCompactSize.width() / 2), + clampToSpan(picture.y(), picture.height(), ptCompactSize.height(), anchorY - ptCompactSize.height() / 2), + ptCompactSize.width(), ptCompactSize.height()); +} + +QRect CardHoverWidget::ptOpenRegion() const +{ + const QRect pill = compactPillRect(); + if (pill.isEmpty() || !pic) { + return {}; + } + // The opening target is the P/T box itself (plus a small halo), not the whole bottom-right + // quadrant: a wider trigger lifts the rows out from under a cursor that merely passed by, + // and the click that selects the card would land on a P/T button instead. + return pill.translated(-pic->pos()).adjusted(-PT_OPEN_HALO, -PT_OPEN_HALO, PT_OPEN_HALO, PT_OPEN_HALO); +} + +QRect CardHoverWidget::ptHoverRegion() const +{ + if (!pic) { + return {}; + } + // The band is relative to the drawn artwork, so a letterboxed (sideways-layout) card keeps + // its P/T corner where the printed box actually is. + const QRect art = pic->artRect(); + if (art.isEmpty()) { + return {}; + } + return QRect(art.x() + qRound(art.width() * (1.0 - PT_HOVER_REGION_WIDTH)), + art.y() + qRound(art.height() * (1.0 - PT_HOVER_REGION_HEIGHT)), + qRound(art.width() * PT_HOVER_REGION_WIDTH), qRound(art.height() * PT_HOVER_REGION_HEIGHT)); +} + +bool CardHoverWidget::isCursorOverPTArea() const +{ + if (!ptOverlay || !pic || !imageContainer) { + return false; + } + if (ptOverlay->rect().contains(ptOverlay->mapFromGlobal(QCursor::pos()))) { + return true; + } + return ptHoverRegion().translated(pic->pos()).contains(imageContainer->mapFromGlobal(QCursor::pos())); +} + +QSize CardHoverWidget::measureCompactPTSize() const +{ + if (!ptPillLabel) { + return QSize(PT_PILL_MIN_WIDTH, PT_PILL_MIN_HEIGHT); + } + // QLabel::sizeHint() leaves out the stylesheet padding, so add it back explicitly. + const QSize hint = ptPillLabel->sizeHint(); + return QSize(qMax(PT_PILL_MIN_WIDTH, hint.width() + 2 * PT_PILL_TEXT_PADDING), + qMax(PT_PILL_MIN_HEIGHT, hint.height())); +} + +void CardHoverWidget::resizeEvent(QResizeEvent *event) +{ + QFrame::resizeEvent(event); + positionOverlays(); +} + +void CardHoverWidget::hideEvent(QHideEvent *event) +{ + // A reopened panel for the same card must not come back with the rows still lifted. + setPTExpanded(false); + // The window is already hidden here; repaint whatever it was covering. + invalidateCoveredArea(); + QFrame::hideEvent(event); +} + +// ---------- Display updates ---------- + +void CardHoverWidget::updatePTDisplay() +{ + if (!currentCard) { + ptPowerLabel->clear(); + ptToughLabel->clear(); + ptPillLabel->clear(); + setPTExpanded(false); + return; + } + + QString pt = currentCard->getPT(); + if (pt.isEmpty()) { + ptPowerLabel->setText("--"); + ptToughLabel->setText("--"); + ptPillLabel->setText("--"); + setPTExpanded(false); + } else { + int slashIdx = pt.indexOf('/'); + if (slashIdx >= 0) { + ptPowerLabel->setText(pt.left(slashIdx)); + ptToughLabel->setText(pt.mid(slashIdx + 1)); + } else { + ptPowerLabel->setText(pt); + ptToughLabel->setText("--"); + } + ptPillLabel->setText(pt); + } + + // Three-digit values (100/100, -1/-1) are legal, so the resting pill cannot keep a + // size measured from the seed text: re-measure on every value change and carry the + // width into the expanded stack. + ptCompactSize = measureCompactPTSize(); + applyPTOverlayGeometry(); +} + +// ---------- Card data ---------- + +void CardHoverWidget::setCard(CardItem *card, PlayerGraphicsItem *player) +{ + disconnectCardSignals(); + + if (!card || !player) { + clearCard(); + return; + } + + currentCard = card; + currentPlayer = player; + + ExactCard exactCard = card->getCard(); + shownCard = exactCard; + pic->setCard(exactCard); + text->setCard(exactCard); + + nameLabel->setText(exactCard.getInfo().getName()); + navNameLabel->setText(exactCard.getInfo().getName()); + + connectCardSignals(); + refreshDisplay(); + rebuildActionsForZone(); + // A new card always starts compact; the rows only lift out while P/T is hovered. + setPTExpanded(false); + positionOverlays(); + // The widget may have been disabled by a previous teardown; it is interactive again. + setEnabled(true); +} + +void CardHoverWidget::refreshDisplay() +{ + updatePTDisplay(); + + if (!currentCard) { + annotationLabel->setVisible(false); + return; + } + + QString annotation = currentCard->getAnnotation(); + if (annotation.isEmpty()) { + annotationLabel->setVisible(false); + } else { + annotationLabel->setText(annotation); + annotationLabel->setVisible(true); + } +} + +void CardHoverWidget::clearCard() +{ + // Go non-interactive synchronously: the action buttons below are only deleteLater'd, + // so without this a click landing before deferred deletion runs would invoke a lambda + // holding the destroyed card/player. Disabled widgets deliver no button clicks, which + // also covers the fade-out window (hideAnimated only fades; hide() lands on finish). + setEnabled(false); + disconnectCardSignals(); + refreshTimer->stop(); + currentCard = nullptr; + currentPlayer = nullptr; + shownCard = ExactCard(); + pic->setCard(ExactCard()); + text->setCard(ExactCard()); + nameLabel->clear(); + navNameLabel->clear(); + nameEdit->clear(); + annotationLabel->clear(); + annotationLabel->setVisible(false); + ptPowerLabel->clear(); + ptToughLabel->clear(); + ptPillLabel->clear(); + setPTExpanded(false); + ptOverlay->setVisible(false); + clearLayout(mainPageLayout); + clearLayout(movePageLayout); + clearLayout(counterPageLayout); + clearLayout(tokenPageLayout); + showPage(PAGE_INFO); +} + +bool CardHoverWidget::hasCard() const +{ + return currentCard != nullptr; +} + +// ---------- Positioning: the picture lands exactly over the card, tabs spill to the side ---------- + +void CardHoverWidget::positionNextTo(const QRectF &cardSceneRect, + const QTransform &viewportTransform, + const QWidget *viewport) +{ + QRect cardViewRect = viewportTransform.mapRect(cardSceneRect).toRect(); + QPoint cardCenter = viewport->mapToGlobal(cardViewRect.center()); + + QSize popupSize = sizeHint(); + popupSize = QSize(qMax(popupSize.width(), TAB_MIN_WIDTH + NAV_BUTTON_WIDTH + CARD_IMAGE_WIDTH + 40), + qMax(popupSize.height(), TAB_MIN_HEIGHT + 16)); + + auto *screen = QGuiApplication::screenAt(cardCenter); + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + QRect screenRect = screen->availableGeometry(); + + // Clamp oversized popups to the screen before choosing a side + popupSize.setWidth(qMin(popupSize.width(), screenRect.width() - 2 * POPUP_GAP)); + popupSize.setHeight(qMin(popupSize.height(), screenRect.height() - 2 * POPUP_GAP)); + + const int mLeft = mainLayout->contentsMargins().left(); + const int mRight = mainLayout->contentsMargins().right(); + const int mTop = mainLayout->contentsMargins().top(); + const int halfImgW = CARD_IMAGE_WIDTH / 2; + // Picture height at full size (the pic has a fixed scale-factor size here; the morph + // collapse happens later in showAnimated). + const int imageH = pic->sizeHint().height(); + + // Center the picture over the card (not corner-pinned) so the overhang is even on + // all sides; only the nav panel spills out to the side. Prefer spilling right, then left. + const int xRight = cardCenter.x() - mLeft - halfImgW; + const int xLeft = cardCenter.x() + mRight + halfImgW - popupSize.width(); + const bool rightFits = xRight + popupSize.width() <= screenRect.right() - POPUP_GAP; + const bool leftFits = xLeft >= screenRect.left() + POPUP_GAP; + popupOnRight = rightFits || !leftFits; + mainLayout->setDirection(popupOnRight ? QBoxLayout::LeftToRight : QBoxLayout::RightToLeft); + + int x = popupOnRight ? xRight : xLeft; + + // Center the picture vertically on the card (it is top-aligned in its container). + int y = cardCenter.y() - mTop - imageH / 2; + if (y + popupSize.height() > screenRect.bottom() - POPUP_GAP) { + y = screenRect.bottom() - popupSize.height() - POPUP_GAP; + } + if (y < screenRect.top() + POPUP_GAP) { + y = screenRect.top() + POPUP_GAP; + } + + x = qMax(screenRect.left() + POPUP_GAP, x); + x = qMin(screenRect.right() - popupSize.width() - POPUP_GAP, x); + + move(x, y); + resize(popupSize); + invalidateCoveredArea(); +} + +void CardHoverWidget::invalidateCoveredArea() +{ + // The panel is an overlay window: moving or hiding it does not reliably invalidate + // the pixels it uncovered in the game view, which then stay stale (a black square) + // until something else repaints that area. Do it explicitly, unioned with the + // previous placement so a move repaints both sides. + QWidget *viewport = parentWidget(); + if (!viewport) { + lastViewportRect = QRect(); + return; + } + + QRect covered; + if (isVisible()) { + covered = QRect(viewport->mapFromGlobal(geometry().topLeft()), size()); + } + const QRect dirty = lastViewportRect.united(covered); + if (!dirty.isEmpty()) { + viewport->update(dirty.adjusted(-1, -1, 1, 1)); + } + lastViewportRect = covered; +} + +// ---------- Show / hide: no window travel, the board zoom is the morph ---------- +// The panel opens instantly at its final geometry (GameScene positioned it with the +// picture centered over the zoomed card) and only fades in. Any geometry animation +// here read as the panel "flying in", so there is none — not even for card switches. + +void CardHoverWidget::showAnimated() +{ + if (!hasCard()) { + return; + } + fadeOut->stop(); + + // Fresh open: fade the whole panel in over the zoomed card. Card switches while + // visible keep full opacity and just show. + bool wasHidden = !isVisible(); + if (wasHidden) { + setWindowOpacity(0.0); + } + show(); + positionOverlays(); + + fadeIn->setStartValue(windowOpacity()); + fadeIn->setEndValue(1.0); + fadeIn->start(); + invalidateCoveredArea(); +} + +void CardHoverWidget::hideAnimated() +{ + if (isClosing()) { + // Repeated hide requests must not restart the fade, or the panel never finishes + // closing while the cursor keeps moving. + return; + } + fadeIn->stop(); + fadeOut->setStartValue(windowOpacity()); + fadeOut->setEndValue(0.0); + fadeOut->start(); +} + +bool CardHoverWidget::isClosing() const +{ + return fadeOut->state() == QAbstractAnimation::Running; +} + +void CardHoverWidget::runModalAction(const std::function &action) +{ + // The panel is a Qt::ToolTip (Qt::Popup | Qt::Sheet): it is always on top and + // holds an input grab. A modal dialog opened while it is still up lands behind + // it and cannot be activated. Ask the scene to dismiss the panel so its hover + // timers stop and the covered card fades back in, then drop the window + // synchronously to release the grab. + emit dismissRequested(); + fadeIn->stop(); + fadeOut->stop(); + hide(); + + // Defer the dialog to the next event-loop turn: the popup grab is not fully + // released until after the current mouse event returns. + QTimer::singleShot(0, this, action); +} + +// ---------- Event filter ---------- + +bool CardHoverWidget::eventFilter(QObject *obj, QEvent *event) +{ + // Handle double-click on name label to start editing annotation. Annotations are set + // by card id on our own zone, so another player's card must not open the editor. + if (obj == nameLabel && event->type() == QEvent::MouseButtonDblClick && canModifyCurrentCard()) { + nameLabel->setVisible(false); + annotationLabel->setVisible(false); + nameEdit->setVisible(true); + nameEdit->setText(currentCard ? currentCard->getAnnotation() : QString()); + nameEdit->setFocus(); + nameEdit->selectAll(); + return true; + } + + // Handle click on card image to select the card in game + if (obj == pic && event->type() == QEvent::MouseButtonPress) { + QMouseEvent *me = static_cast(event); + if (me->button() == Qt::LeftButton && currentCard) { + if (auto *scene = qobject_cast(currentCard->scene())) { + scene->setActiveCard(currentCard); + } + currentCard->setSelected(true); + } + } + + // The P/T trigger is the printed P/T area, not just the pill: hovering that quadrant of + // the art lifts the control rows out. Driving it from pic's mouse moves (instead of an + // invisible child widget over the picture) keeps click-to-select working underneath. + if (obj == pic) { + if (event->type() == QEvent::MouseMove) { + const auto *me = static_cast(event); + const QPoint pos = me->position().toPoint(); + if (ptOpenRegion().contains(pos)) { + setPTExpanded(true); + } else if (!isCursorOverPTArea()) { + setPTExpanded(false); + } + } else if (event->type() == QEvent::Leave && !isCursorOverPTArea()) { + setPTExpanded(false); + } + } + + // P/T overlay: hovering the P/T area lifts the control rows out of the pill. The + // filter sits on the whole subtree so moving onto a button keeps it expanded. + const auto *ptWidget = qobject_cast(obj); + if (ptWidget && ptOverlay && (ptWidget == ptOverlay || ptOverlay->isAncestorOf(ptWidget))) { + if (event->type() == QEvent::Enter) { + setPTExpanded(true); + } else if (event->type() == QEvent::Leave) { + // Moving onto a child widget fires Leave on the parent too; only collapse once + // the cursor has left both the overlay and the printed P/T area. + if (!isCursorOverPTArea()) { + setPTExpanded(false); + } + } + } + + return QFrame::eventFilter(obj, event); +} + +// ---------- Mouse tracking on the popup itself ---------- + +void CardHoverWidget::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::PaletteChange) { + applyThemeStyle(); + } + QFrame::changeEvent(event); +} + +void CardHoverWidget::enterEvent(QEnterEvent *event) +{ + QFrame::enterEvent(event); + emit hoverEntered(); +} + +void CardHoverWidget::leaveEvent(QEvent *event) +{ + QFrame::leaveEvent(event); + // Moving the cursor onto a child widget also triggers leaveEvent on this widget; + // only report a real departure from the popup. + if (rect().contains(mapFromGlobal(QCursor::pos()))) { + return; + } + emit hoverLeft(); +} diff --git a/cockatrice/src/interface/widgets/cards/card_hover_widget.h b/cockatrice/src/interface/widgets/cards/card_hover_widget.h new file mode 100644 index 000000000..acd9d4cc6 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_hover_widget.h @@ -0,0 +1,231 @@ +/** + * @file card_hover_widget.h + * @ingroup CardWidgets + * @brief Hover popup: zoomed picture plus a vertical icon nav strip beside stacked action pages. + * + * The board card smooth-zooms first; this panel then swaps in over it with oracle + * text and every right-click action, grouped by topic. + */ + +#ifndef CARDHOVERWIDGET_H +#define CARDHOVERWIDGET_H + +#include +#include +#include +#include +#include +#include +#include +#include + +class CardInfoPictureWidget; +class CardInfoTextWidget; +class CardItem; +class CardState; +class PlayerGraphicsItem; +class QVBoxLayout; +class QHBoxLayout; +class QPushButton; +class QToolButton; +class QButtonGroup; +class QStackedWidget; +class QIcon; +class QLabel; +class QLineEdit; +class QEnterEvent; +class QHideEvent; +class QPropertyAnimation; +class QResizeEvent; +class QTimer; + +class CardHoverWidget : public QFrame +{ + Q_OBJECT + +public: + explicit CardHoverWidget(QWidget *parent = nullptr); + + void setCard(CardItem *card, PlayerGraphicsItem *player); + void clearCard(); + bool hasCard() const; + CardItem *getCardItem() const + { + return currentCard; + } + + void positionNextTo(const QRectF &cardSceneRect, const QTransform &viewportTransform, const QWidget *viewport); + + void showAnimated(); + void hideAnimated(); + + /** @brief True while the panel is fading out towards a hide. */ + bool isClosing() const; + + /** @brief Refreshes the PT, counter, and annotation labels from the current card. */ + void refreshDisplay(); + + bool isMouseOverWidget() const + { + return rect().contains(mapFromGlobal(QCursor::pos())); + } + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + void changeEvent(QEvent *event) override; + void enterEvent(QEnterEvent *event) override; + void leaveEvent(QEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void hideEvent(QHideEvent *event) override; + +signals: + void hoverEntered(); + void hoverLeft(); + /** @brief Asks the scene to dismiss the panel before a modal dialog opens. */ + void dismissRequested(); + +private slots: + void onCardChanged(); + void onCardDestroyed(); + void applyThemeStyle(); + +private: + QPointer currentCard; + QPointer currentPlayer; + ExactCard shownCard; + + // Which side of the card the panel ended up on (drives the image-adjacent layout flip) + bool popupOnRight = true; + + // Viewport area the panel covered at its last placement; used to repaint what the + // overlay window uncovers when it moves or hides. + QRect lastViewportRect; + + // Coalesces rapid state-change bursts into a single refresh + QTimer *refreshTimer; + + // Card image area (kept on the card-adjacent side) with overlaid controls + QHBoxLayout *mainLayout; + QWidget *imageContainer; + CardInfoPictureWidget *pic; + + // Name bar overlay (top of card image) + QWidget *nameBarOverlay; + QLabel *nameLabel; + QLineEdit *nameEdit; + QLabel *annotationLabel; + + // P/T overlay (bottom-right of card image): a compact value pill at rest that + // lifts its control rows out of itself while the P/T area is hovered. + QWidget *ptOverlay; + QWidget *ptControls; + QLabel *ptPillLabel; + bool ptExpanded = false; + QSize ptCompactSize; + QSize ptFullSize; + QLabel *ptPowerLabel; + QLabel *ptToughLabel; + QPushButton *btnIncP; + QPushButton *btnDecP; + QPushButton *btnIncT; + QPushButton *btnDecT; + QPushButton *btnSetPT; + QPushButton *btnResetPT; + + // Vertical icon nav strip plus stacked content pages (no nested tab bars) + QWidget *sideBar; + QLabel *navNameLabel; + QToolButton *navInfoBtn; + QToolButton *navMainBtn; + QToolButton *navMoveBtn; + QToolButton *navCounterBtn; + QToolButton *navTokenBtn; + QButtonGroup *navGroup; + + QStackedWidget *contentStack; + QWidget *infoPage; + QWidget *mainPage; + QWidget *movePage; + QWidget *counterPage; + QWidget *tokenPage; + QVBoxLayout *infoPageLayout; + QVBoxLayout *mainPageLayout; + QVBoxLayout *movePageLayout; + QVBoxLayout *counterPageLayout; + QVBoxLayout *tokenPageLayout; + CardInfoTextWidget *text; + + // Window fade (sole reveal animation: per-layer opacity effects flashed black + // on scroll areas, so the whole panel fades as one) + QPropertyAnimation *fadeIn; + QPropertyAnimation *fadeOut; + + void createImageArea(); + void createNameBarOverlay(); + void createPTOverlay(); + void createNavBar(); + QToolButton *createNavButton(const QString &title, const QIcon &icon, int pageIndex); + void createContentStack(); + QWidget *makeStackPage(QVBoxLayout *&layout); + void showPage(int index); + + /** + * @brief Dismisses the panel, then runs an action that opens a modal dialog. + * + * A Qt::ToolTip window is always on top and holds the input grab, so a modal + * dialog opened from inside the panel lands behind it and cannot be + * activated. Hide the panel synchronously, then defer the action so the grab + * is released before the dialog's event loop starts. + */ + void runModalAction(const std::function &action); + + QLabel *createSectionHeader(const QString &text, QWidget *parent); + QLabel *createNoteLabel(const QString &text, QWidget *parent); + + void connectCardSignals(); + void disconnectCardSignals(); + void scheduleRefresh(); + + void rebuildActionsForZone(); + /** @brief Whether the local player may edit the current card (own card, judge, writable zone). */ + bool canModifyCurrentCard() const; + void buildMainTab(const QList &cardList, const QString &zoneName, bool canModify); + void buildMoveTab(const QList &cardList, const QString &zoneName, bool canModify); + void buildCounterTab(const QList &cardList, const QString &zoneName, bool canModify); + void buildTokenTab(CardItem *card, bool canModify); + void addRelatedViewButtons(QVBoxLayout *layout); + void updatePTDisplay(); + void positionOverlays(); + /** @brief Shows/hides the P/T control rows; the value pill stays at the bottom. */ + void setPTExpanded(bool expanded); + /** @brief Anchors the pill on the printed P/T box, or the expanded stack above it. */ + void applyPTOverlayGeometry(); + /** @brief Resting pill geometry, in @c imageContainer coordinates. */ + QRect compactPillRect() const; + /** @brief Pill plus halo: the only area that opens the rows, in @c pic coordinates. */ + QRect ptOpenRegion() const; + /** @brief Bottom-right band the rows stay open in (never opens them), in @c pic coordinates. */ + QRect ptHoverRegion() const; + /** @brief True while the cursor is over the overlay or the printed P/T area. */ + bool isCursorOverPTArea() const; + /** @brief Re-measures the resting pill from its current text. */ + QSize measureCompactPTSize() const; + /** @brief Repaints the viewport area this window covers and the one it just left. */ + void invalidateCoveredArea(); + + void clearLayout(QLayout *layout); + + QPushButton *createOverlayButton(const QString &text, const QString &tooltip, const std::function &slot); + QToolButton *createGridButton(const QString &text, + const QIcon &icon, + const std::function &slot, + QWidget *parent, + const QString &tooltip = QString()); + QPushButton *createSmallButton(const QString &text, const std::function &slot, QWidget *parent = nullptr); + QPushButton *createSmallButton(const QString &text, + const QString &tooltip, + const std::function &slot, + QWidget *parent = nullptr); +}; + +#endif // CARDHOVERWIDGET_H diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 8f02ebcde..0d0c11fdf 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -175,6 +175,8 @@ void CardInfoPictureWidget::setScaleFactor(const int scale) void CardInfoPictureWidget::updatePixmap() { pixmapDirty = true; + // The artwork rect belongs to the pixmap that is about to be loaded. + artRectCache = QRect(); update(); } @@ -252,6 +254,24 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) // Draw the pre-scaled pixmap directly painter.drawPixmap(targetRect, finalPixmap); + + if (targetRect != artRectCache) { + artRectCache = targetRect; + // Overlays anchored on the artwork (rather than the widget) re-position on this. + emit artRectChanged(); + } +} + +/** + * @brief Rect of the drawn artwork inside this widget. + * + * Mirrors the target rect paintEvent() computes: the pixmap scaled with KeepAspectRatio and + * centred, after the optional sideways-layout rotation. Falls back to rect() until the first + * paint, which is the right answer for the usual portrait card. + */ +QRect CardInfoPictureWidget::artRect() const +{ + return artRectCache.isEmpty() ? rect() : artRectCache; } /** diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h index 0eb731d92..5cd7e912a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h @@ -41,6 +41,14 @@ public: return exactCard; } [[nodiscard]] QSize sizeHint() const override; + /** + * @brief Rect of the drawn artwork inside this widget. + * + * The pixmap is scaled with KeepAspectRatio and centred, and sideways-layout cards may be + * rotated, so this is usually smaller than rect(). Callers that overlay the picture (rather + * than the widget) must anchor on this. Falls back to rect() until the first paint. + */ + [[nodiscard]] QRect artRect() const; /** * @brief The size a card picture has at @p scaleFactor percent. @@ -60,6 +68,8 @@ signals: void cardScaleFactorChanged(int _scale); void cardChanged(const ExactCard &card); void cardClicked(QMouseEvent *event, const ExactCard &card); + /** @brief Emitted when the drawn artwork rect changes (size, scale factor, rotation, card). */ + void artRectChanged(); protected: void resizeEvent(QResizeEvent *event) override; @@ -83,6 +93,8 @@ private: double scaleFactor = 100; QPixmap resizedPixmap; bool pixmapDirty; + /// Last drawn artwork rect, in widget coordinates; empty until the first paint. + QRect artRectCache; bool hoverToZoomEnabled; bool raiseOnEnter; diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 128597962..4a9701da2 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -40,8 +40,10 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info"), IconTint::quiet()), + QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -83,7 +85,8 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::Positive)); + aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"), + IconTint(AppColor::Positive, IconEmphasis::Primary), IconStyle::Sticker)); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 33a9cd63c..6e4701f53 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -68,7 +68,8 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); + aClearFilterAll->setIcon( + loadThemedIcon(QStringLiteral("icons/clearsearch"), AppColor::Negative, IconStyle::Sticker)); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index c6e640a9f..cf487d930 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -89,7 +89,8 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) searchField = new LineEditUnfocusable; searchField->setObjectName("searchEdit"); searchField->setPlaceholderText(tr("Search by set name, code, type, or release date")); - searchField->addAction(loadThemedIcon(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); + searchField->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index b1a80c86b..8d52ff56a 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -135,7 +135,8 @@ void SettingsView::setupUi() // Search bar searchEdit = new QLineEdit; searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); searchEdit->setObjectName("settingsSearchEdit"); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index a5177a6bc..f7975fb81 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -430,7 +430,8 @@ static QPixmap composePinBadgePixmap(const QSize &logicalSize, qreal devicePixel // Inset the glyph so the backdrop reads as a badge around it, not as a border. const int inset = qRound(logicalSize.width() * 0.16); const QSize glyphSize = logicalSize - QSize(inset * 2, inset * 2); - const QPixmap glyph = loadThemedIcon(QStringLiteral("icons/pin")).pixmap(glyphSize, devicePixelRatio); + const QPixmap glyph = + loadThemedIcon(QStringLiteral("icons/pin"), AppColor::Marked).pixmap(glyphSize, devicePixelRatio); if (glyph.isNull()) { // Let the caller fall back to the textual badge instead of drawing an empty panel. return QPixmap(); 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 44ce01aba..b29c4d90c 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 @@ -28,6 +28,14 @@ enum visualDeckStoragePromptForConversionIndex visualDeckStoragePromptForConversionIndexAlways }; +// A corrupt or out-of-range stored trigger would otherwise select index -1 and leave +// the row half-hidden; clamp it back into the known trigger range. +static int clampHoverTrigger(int trigger) +{ + return qBound(static_cast(CardsDisplaySettings::HoverPanelTriggerHover), trigger, + static_cast(CardsDisplaySettings::HoverPanelTriggerOff)); +} + UserInterfaceSettingsPage::UserInterfaceSettingsPage() { // general settings @@ -154,6 +162,68 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() animationGroupBox = new QGroupBox; animationGroupBox->setLayout(animationGrid); + // hover panel settings + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.setCurrentIndex( + clampHoverTrigger(SettingsCache::instance().cardsDisplay().getHoverZoomTrigger())); + connect(&hoverZoomTriggerSelector, &QComboBox::currentIndexChanged, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverZoomTrigger); + + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.setCurrentIndex( + clampHoverTrigger(SettingsCache::instance().cardsDisplay().getHoverPanelTrigger())); + connect(&hoverPanelTriggerSelector, &QComboBox::currentIndexChanged, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverPanelTrigger); + + hoverPanelDoubleConfirmCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getHoverPanelDoubleConfirm()); + connect(&hoverPanelDoubleConfirmCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverPanelDoubleConfirm); + + hoverZoomDelayBox.setRange(0, 5000); + hoverZoomDelayBox.setSingleStep(50); + hoverZoomDelayBox.setSuffix(" ms"); + hoverZoomDelayBox.setValue(SettingsCache::instance().cardsDisplay().getHoverZoomDelayMs()); + connect(&hoverZoomDelayBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverZoomDelayMs); + + hoverPanelDelayBox.setRange(0, 5000); + hoverPanelDelayBox.setSingleStep(50); + hoverPanelDelayBox.setSuffix(" ms"); + hoverPanelDelayBox.setValue(SettingsCache::instance().cardsDisplay().getHoverPanelDelayMs()); + connect(&hoverPanelDelayBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverPanelDelayMs); + + auto *hoverPanelGrid = new QGridLayout; + hoverPanelGrid->addWidget(&hoverZoomTriggerLabel, 0, 0); + hoverPanelGrid->addWidget(&hoverZoomTriggerSelector, 0, 1); + hoverPanelGrid->addWidget(&hoverPanelTriggerLabel, 1, 0); + hoverPanelGrid->addWidget(&hoverPanelTriggerSelector, 1, 1); + hoverPanelGrid->addWidget(&hoverPanelDoubleConfirmCheckBox, 2, 0, 1, 2); + hoverPanelGrid->addWidget(&hoverZoomDelayLabel, 3, 0); + hoverPanelGrid->addWidget(&hoverZoomDelayBox, 3, 1); + hoverPanelGrid->addWidget(&hoverPanelDelayLabel, 4, 0); + hoverPanelGrid->addWidget(&hoverPanelDelayBox, 4, 1); + hoverWheelNoteLabel.setWordWrap(true); + hoverPanelGrid->addWidget(&hoverWheelNoteLabel, 5, 0, 1, 2); + connect(&hoverZoomTriggerSelector, &QComboBox::currentIndexChanged, this, + &UserInterfaceSettingsPage::updateHoverPanelUiState); + connect(&hoverPanelTriggerSelector, &QComboBox::currentIndexChanged, this, + &UserInterfaceSettingsPage::updateHoverPanelUiState); + connect(&hoverPanelDoubleConfirmCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &UserInterfaceSettingsPage::updateHoverPanelUiState); + updateHoverPanelUiState(); + + hoverPanelGroupBox = new QGroupBox; + hoverPanelGroupBox->setLayout(hoverPanelGrid); + // deck editor settings openDeckInNewTabCheckBox.setChecked(SettingsCache::instance().deckEditor().getOpenDeckInNewTab()); connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), @@ -293,6 +363,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() mainLayout->addWidget(generalGroupBox); mainLayout->addWidget(notificationsGroupBox); mainLayout->addWidget(animationGroupBox); + mainLayout->addWidget(hoverPanelGroupBox); mainLayout->addWidget(deckEditorGroupBox); mainLayout->addWidget(replayGroupBox); mainLayout->addStretch(); @@ -351,6 +422,33 @@ void UserInterfaceSettingsPage::updateCommanderSpellbookUiState() } } +void UserInterfaceSettingsPage::updateHoverPanelUiState() +{ + // Phase timers only apply to the hover trigger; double-confirm only applies when + // both phases share one trigger (repeating it). + const auto hoverTrigger = CardsDisplaySettings::HoverPanelTriggerHover; + const auto wheelTrigger = CardsDisplaySettings::HoverPanelTriggerWheel; + const auto offTrigger = CardsDisplaySettings::HoverPanelTriggerOff; + const bool zoomOnHover = hoverZoomTriggerSelector.currentIndex() == hoverTrigger; + const bool panelOnHover = hoverPanelTriggerSelector.currentIndex() == hoverTrigger; + const bool anyOff = + hoverZoomTriggerSelector.currentIndex() == offTrigger || hoverPanelTriggerSelector.currentIndex() == offTrigger; + const bool sharedTrigger = + !anyOff && hoverZoomTriggerSelector.currentIndex() == hoverPanelTriggerSelector.currentIndex(); + hoverZoomDelayLabel.setVisible(zoomOnHover); + hoverZoomDelayBox.setVisible(zoomOnHover); + hoverPanelDelayLabel.setVisible(panelOnHover); + hoverPanelDelayBox.setVisible(panelOnHover); + hoverPanelDoubleConfirmCheckBox.setEnabled(sharedTrigger); + const bool panelAuto = panelOnHover && !(sharedTrigger && hoverPanelDoubleConfirmCheckBox.isChecked()); + hoverPanelDelayLabel.setEnabled(panelAuto); + hoverPanelDelayBox.setEnabled(panelAuto); + // The wheel trigger consumes wheel events over cards, so the board cannot scroll there. + const bool wheelBound = hoverZoomTriggerSelector.currentIndex() == wheelTrigger || + hoverPanelTriggerSelector.currentIndex() == wheelTrigger; + hoverWheelNoteLabel.setVisible(wheelBound); +} + void UserInterfaceSettingsPage::retranslateUi() { generalGroupBox->setTitle(tr("General interface settings")); @@ -380,6 +478,22 @@ void UserInterfaceSettingsPage::retranslateUi() lifeCounterAnimationsCheckBox.setText(tr("Life counter flash")); battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage")); + hoverPanelGroupBox->setTitle(tr("Hover panel settings")); + hoverPanelTriggerLabel.setText(tr("Show the card panel on:")); + hoverZoomTriggerLabel.setText(tr("Zoom the card on:")); + for (auto *selector : {&hoverPanelTriggerSelector, &hoverZoomTriggerSelector}) { + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerHover, tr("Hover")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerRightClick, tr("Right-click")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerWheel, tr("Mouse wheel")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerShortcut, tr("Keyboard shortcut")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerOff, tr("Off")); + } + hoverPanelDoubleConfirmCheckBox.setText(tr("Zoom first; repeat the action to show the panel")); + hoverZoomDelayLabel.setText(tr("Time before the zoom kicks in:")); + hoverPanelDelayLabel.setText(tr("Time before the tab panel shows up:")); + hoverWheelNoteLabel.setText( + tr("Note: the mouse wheel action replaces board scrolling over cards; scroll up to zoom back out.")); + deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); visualDeckEditorShowCardCountsCheckBox.setText(tr("Show card counts in Visual Deck Editor")); 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 297d80c35..6b60c58bf 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 @@ -22,6 +22,7 @@ private slots: void enableAllAnimations(); void disableAllAnimations(); void updateCommanderSpellbookUiState(); + void updateHoverPanelUiState(); private: QCheckBox doubleClickToPlayCheckBox; @@ -47,6 +48,17 @@ private: QCheckBox lifeCounterAnimationsCheckBox; QCheckBox battlefieldFlashCheckBox; + QLabel hoverPanelTriggerLabel; + QComboBox hoverPanelTriggerSelector; + QLabel hoverZoomTriggerLabel; + QComboBox hoverZoomTriggerSelector; + QCheckBox hoverPanelDoubleConfirmCheckBox; + QLabel hoverZoomDelayLabel; + QSpinBox hoverZoomDelayBox; + QLabel hoverPanelDelayLabel; + QSpinBox hoverPanelDelayBox; + QLabel hoverWheelNoteLabel; + QCheckBox openDeckInNewTabCheckBox; QLabel visualDeckStoragePromptForConversionLabel; QComboBox visualDeckStoragePromptForConversionSelector; @@ -69,6 +81,7 @@ private: QGroupBox *generalGroupBox; QGroupBox *notificationsGroupBox; QGroupBox *animationGroupBox; + QGroupBox *hoverPanelGroupBox; QGroupBox *deckEditorGroupBox; QGroupBox *replayGroupBox; diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 9e031c79a..32abde046 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -223,7 +223,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); - aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); + aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"), AppColor::Informational)); connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); aPublishDeck = new QAction(this); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index 3868ee75f..e33f1754e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -222,7 +222,7 @@ QGroupBox *TabReplays::createRightLayout() loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); + aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"), AppColor::Informational)); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index f109b25c1..f4c4f7c4e 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -46,13 +46,15 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg sortGroupBox = new QGroupBox(this); filterGroupBox = new QGroupBox(this); - auto scalePixmap = [](const QString &prefix) { return loadThemedIcon(prefix).pixmap({20, 20}); }; + auto scalePixmap = [](const QString &prefix, const IconTint &tint = {}) { + return loadThemedIcon(prefix, tint).pixmap({20, 20}); + }; sortByLabel = new QLabel(this); - sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down")); + sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down", AppColor::Informational)); filterByLabel = new QLabel(this); - filterByLabel->setPixmap(scalePixmap("icons/filter")); + filterByLabel->setPixmap(scalePixmap("icons/filter", AppColor::Informational)); sortColumnCombo = new QComboBox(this); sortColumnCombo->setSizeAdjustPolicy(QComboBox::SizeAdjustPolicy::AdjustToContents); @@ -90,22 +92,23 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"))); + quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"), IconTint::primary())); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"))); + quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"), IconTint::primary())); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/circle_half_stroke"))); + quickFilterMainTypeWidget->setButtonIcon( + loadThemedIcon(QStringLiteral("icons/circle_half_stroke"), IconTint::primary())); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"))); + quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"), IconTint::primary())); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); + quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"), IconTint::primary())); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); + quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"), IconTint::primary())); quickFilterTagWidget = new SettingsButtonWidget(this); quickFilterTagWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/tags"))); 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 7f516839a..61cdfefe4 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 @@ -81,8 +81,10 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info"), AppColor::Informational), + QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -156,7 +158,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"), AppColor::Negative)); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"), AppColor::Negative, IconStyle::Sticker)); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index f0c054c3a..e2c853d6a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -119,11 +119,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayTypeButton() switch (currentDisplayType) { case DisplayType::Flat: displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"), IconTint::primary())); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"), IconTint::primary())); break; } } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index 10a3bf33a..052f6231a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -27,9 +27,10 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : searchBar = new QLineEdit(this); searchBar->setPlaceholderText(tr("Search by filename (or search expression)")); searchBar->setClearButtonEnabled(true); - searchBar->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchBar->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info"), IconTint::quiet()), + QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index bbf68adb9..14f3c19de 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -67,7 +67,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); shareButton = new QToolButton(this); - shareButton->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); + shareButton->setIcon(loadThemedIcon(QStringLiteral("icons/share"), AppColor::Informational)); shareButton->setFixedSize(32, 32); shareButton->setVisible(false); connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h index a5ab4fc37..8eb81d2bc 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -19,6 +19,11 @@ public: [[nodiscard]] virtual bool getArrowDrawAnimation() const = 0; [[nodiscard]] virtual bool getAutoRotateSidewaysLayoutCards() const = 0; [[nodiscard]] virtual bool getScaleCards() const = 0; + [[nodiscard]] virtual int getHoverPanelTrigger() const = 0; + [[nodiscard]] virtual int getHoverZoomTrigger() const = 0; + [[nodiscard]] virtual bool getHoverPanelDoubleConfirm() const = 0; + [[nodiscard]] virtual int getHoverZoomDelayMs() const = 0; + [[nodiscard]] virtual int getHoverPanelDelayMs() const = 0; [[nodiscard]] virtual int getStackCardOverlapPercent() const = 0; [[nodiscard]] virtual int getCardInfoViewMode() const = 0; [[nodiscard]] virtual int getVisualDeckStorageCardSize() const = 0; diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index f047973f0..910e3f09d 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -68,6 +68,35 @@ bool CardsDisplaySettings::getScaleCards() const return getValue("scaleCards", QString(), QString(), true).toBool(); } +int CardsDisplaySettings::getHoverPanelTrigger() const +{ + // Off by default: the panel is new, so it is opt-in. + return getValue("hoverPanelTrigger", QString(), QString(), static_cast(HoverPanelTriggerOff)).toInt(); +} + +int CardsDisplaySettings::getHoverZoomTrigger() const +{ + // Off by default: the smooth hover zoom is opt-in. With no trigger configured, + // hovering keeps the historical 1.1x scale (AbstractCardItem::setHoverHighlighted) + // instead of the smooth zoom to the hover panel size. + return getValue("hoverZoomTrigger", QString(), QString(), static_cast(HoverPanelTriggerOff)).toInt(); +} + +bool CardsDisplaySettings::getHoverPanelDoubleConfirm() const +{ + return getValue("hoverPanelDoubleConfirm", QString(), QString(), false).toBool(); +} + +int CardsDisplaySettings::getHoverZoomDelayMs() const +{ + return getValue("hoverZoomDelayMs", QString(), QString(), 400).toInt(); +} + +int CardsDisplaySettings::getHoverPanelDelayMs() const +{ + return getValue("hoverPanelDelayMs", QString(), QString(), 200).toInt(); +} + int CardsDisplaySettings::getStackCardOverlapPercent() const { return getValue("verticalCardOverlapPercent", QString(), QString(), 33).toInt(); @@ -188,6 +217,36 @@ void CardsDisplaySettings::setCardScaling(bool _scaleCards) setValue(_scaleCards, "scaleCards"); } +void CardsDisplaySettings::setHoverPanelTrigger(int _trigger) +{ + setValue(_trigger, "hoverPanelTrigger"); + emit hoverPanelTriggerChanged(_trigger); +} + +void CardsDisplaySettings::setHoverZoomTrigger(int _trigger) +{ + setValue(_trigger, "hoverZoomTrigger"); + emit hoverZoomTriggerChanged(_trigger); +} + +void CardsDisplaySettings::setHoverPanelDoubleConfirm(bool _doubleConfirm) +{ + setValue(_doubleConfirm, "hoverPanelDoubleConfirm"); + emit hoverPanelDoubleConfirmChanged(_doubleConfirm); +} + +void CardsDisplaySettings::setHoverZoomDelayMs(int _ms) +{ + setValue(_ms, "hoverZoomDelayMs"); + emit hoverZoomDelayMsChanged(_ms); +} + +void CardsDisplaySettings::setHoverPanelDelayMs(int _ms) +{ + setValue(_ms, "hoverPanelDelayMs"); + emit hoverPanelDelayMsChanged(_ms); +} + void CardsDisplaySettings::setStackCardOverlapPercent(int _verticalCardOverlapPercent) { setValue(_verticalCardOverlapPercent, "verticalCardOverlapPercent"); diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index 961550d3c..59a513e8f 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -15,6 +15,18 @@ class CardsDisplaySettings : public SettingsManager, public ICardsDisplaySetting friend class SettingsCache; public: + /// What can summon the hover zoom or panel. Stored as int; values are the + /// serialization contract, so never reorder or renumber. + enum HoverPanelTrigger + { + HoverPanelTriggerHover = 0, + HoverPanelTriggerRightClick = 1, + HoverPanelTriggerWheel = 2, + HoverPanelTriggerShortcut = 3, + /// Never fires. The hover zoom and panel are opt-in, so this is their default. + HoverPanelTriggerOff = 4 + }; + [[nodiscard]] bool getDisplayCardNames() const override; [[nodiscard]] bool getRoundCardCorners() const override; [[nodiscard]] bool getOverrideAllCardArtWithPersonalPreference() const override; @@ -26,6 +38,16 @@ public: [[nodiscard]] bool getArrowDrawAnimation() const override; [[nodiscard]] bool getAutoRotateSidewaysLayoutCards() const override; [[nodiscard]] bool getScaleCards() const override; + /// What summons the in-game card hover panel (a HoverPanelTrigger value). + [[nodiscard]] int getHoverPanelTrigger() const override; + /// What zooms the hovered card (a HoverPanelTrigger value). Zoom and panel triggers are independent. + [[nodiscard]] int getHoverZoomTrigger() const override; + /// When true, the trigger first only zooms; repeating it shows the panel. + [[nodiscard]] bool getHoverPanelDoubleConfirm() const override; + /// Hover dwell before the zoom kicks in (ms). Only used with the hover trigger. + [[nodiscard]] int getHoverZoomDelayMs() const override; + /// Wait after the zoom before the tab panel shows (ms). Hover trigger, one-step only. + [[nodiscard]] int getHoverPanelDelayMs() const override; [[nodiscard]] int getStackCardOverlapPercent() const override; [[nodiscard]] int getCardInfoViewMode() const override; [[nodiscard]] int getVisualDeckStorageCardSize() const override; @@ -49,6 +71,11 @@ public: void setArrowDrawAnimation(bool _arrowDrawAnimation); void setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards); void setCardScaling(bool _scaleCards); + void setHoverPanelTrigger(int _trigger); + void setHoverZoomTrigger(int _trigger); + void setHoverPanelDoubleConfirm(bool _doubleConfirm); + void setHoverZoomDelayMs(int _ms); + void setHoverPanelDelayMs(int _ms); void setStackCardOverlapPercent(int _verticalCardOverlapPercent); void setCardInfoViewMode(int _viewMode); void setVisualDeckStorageCardSize(int _cardSize); @@ -78,6 +105,11 @@ signals: void sampleHandSizeChanged(int amount); void cardLangChanged(const QString &lang); void cardSearchLanguageChanged(int cardSearchLanguage); + void hoverPanelTriggerChanged(int trigger); + void hoverZoomTriggerChanged(int trigger); + void hoverPanelDoubleConfirmChanged(bool doubleConfirm); + void hoverZoomDelayMsChanged(int ms); + void hoverPanelDelayMsChanged(int ms); public: explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index aea7f1d54..4ca440567 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -683,6 +683,77 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) ASSERT_EQ(s.getArrowDrawAnimation(), true); } +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + // Off by default: the panel is opt-in on upgrade. + ASSERT_EQ(s.getHoverPanelTrigger(), CardsDisplaySettings::HoverPanelTriggerOff); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverPanelTrigger(2); + ASSERT_EQ(s.getHoverPanelTrigger(), 2); + s.setHoverPanelTrigger(0); + ASSERT_EQ(s.getHoverPanelTrigger(), 0); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + // Off by default: the smooth hover zoom is opt-in on upgrade. + ASSERT_EQ(s.getHoverZoomTrigger(), CardsDisplaySettings::HoverPanelTriggerOff); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverZoomTrigger(1); + ASSERT_EQ(s.getHoverZoomTrigger(), 1); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDoubleConfirm_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverPanelDoubleConfirm(), false); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDoubleConfirm_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverPanelDoubleConfirm(true); + ASSERT_EQ(s.getHoverPanelDoubleConfirm(), true); + s.setHoverPanelDoubleConfirm(false); + ASSERT_EQ(s.getHoverPanelDoubleConfirm(), false); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomDelayMs_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverZoomDelayMs(), 400); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomDelayMs_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverZoomDelayMs(750); + ASSERT_EQ(s.getHoverZoomDelayMs(), 750); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDelayMs_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverPanelDelayMs(), 200); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDelayMs_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverPanelDelayMs(0); + ASSERT_EQ(s.getHoverPanelDelayMs(), 0); +} + TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_Default) { CardsDisplaySettings s(settingsPath, nullptr); diff --git a/tests/theme_migration/icon_tint_preview_test.cpp b/tests/theme_migration/icon_tint_preview_test.cpp index b4244cef0..b5b57f7b2 100644 --- a/tests/theme_migration/icon_tint_preview_test.cpp +++ b/tests/theme_migration/icon_tint_preview_test.cpp @@ -68,10 +68,12 @@ template QPixmap renderAtDpr(const QSize &logicalSize, qreal d * @brief Writes one semantic-tint sheet per shipped palette theme. * * The theme files are the only place the shipped tones live, so each sheet is - * rendered from the palette the theme actually ships: both semantic families, - * three glyphs each, at the two sizes the UI uses, on the window and button - * surfaces the contrast is measured against. A disabled row closes each family - * so the enabled/disabled order is visible next to the tone that carries it. + * rendered from the palette the theme actually ships: all three semantic + * families, three glyphs each, at the two sizes the UI uses, on the window and + * button surfaces the contrast is measured against. A disabled row closes each + * family so the enabled/disabled order is visible next to the tone that carries + * it. A theme that does not author Informational falls back to the scheme + * default, which is what the client paints. */ TEST(IconTintPreview, WritesOneSheetPerShippedTheme) { @@ -100,6 +102,10 @@ TEST(IconTintPreview, WritesOneSheetPerShippedTheme) const QList families = { {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, + {"Hidden", AppColor::Hidden, {"view", "arrow_right", "swap"}}, + {"Marked", AppColor::Marked, {"pencil", "share", "pin"}}, + {"Movement", AppColor::Movement, {"arrow_to_top", "arrow_to_bottom", "arrow_down"}}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), diff --git a/tests/theme_migration/icon_treatment_preview_test.cpp b/tests/theme_migration/icon_treatment_preview_test.cpp index 1d027a9e7..0af177560 100644 --- a/tests/theme_migration/icon_treatment_preview_test.cpp +++ b/tests/theme_migration/icon_treatment_preview_test.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -69,7 +70,7 @@ template QPixmap renderAtDpr(const QSize &logicalSize, qreal d * @brief Writes one badge/sticker sheet per shipped palette theme. * * The treatments are only useful if they read on every palette, so each sheet - * is rendered from the palette the theme actually ships: both semantic + * is rendered from the palette the theme actually ships: all three semantic * families, three glyphs each, as line glyphs, stickers and badges, at the two * sizes the UI uses, on the window and button surfaces the contrast is measured * against. A disabled row closes each family: every treatment falls back to the @@ -102,9 +103,17 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) AppColor::Role role; const char *glyphs[3]; }; + // The hover panel's action grid is the first call site that puts a + // treatment on an action glyph, so the sheet renders the glyphs that grid + // actually uses: play/clone/hand (Positive), reduce life and graveyard + // (Negative), place face down and exile (Informational). const QList families = { - {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, - {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + {"Positive", AppColor::Positive, {"arrow_right", "increment", "arrow_left"}}, + {"Negative", AppColor::Negative, {"arrow_down", "decrement", "remove_row"}}, + {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, + {"Hidden", AppColor::Hidden, {"view", "arrow_right", "swap"}}, + {"Marked", AppColor::Marked, {"pencil", "share", "pin"}}, + {"Movement", AppColor::Movement, {"arrow_to_top", "arrow_to_bottom", "arrow_down"}}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -246,3 +255,312 @@ TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) } } } + +namespace +{ +/** @brief One action-grid cell, with the role the panel hands the icon engine. */ +struct PreviewAction +{ + QString label; + const char *glyph; + AppColor::Role role; +}; + +/** @brief One titled section of a hover panel page. */ +struct PreviewSection +{ + QString title; + QList actions; +}; + +/** @brief One hover panel page, as the client builds it. */ +struct PreviewPage +{ + QString title; + QList sections; +}; + +/** @brief Renders a sentinel SVG in one ink at the requested pixel size. */ +QPixmap renderInkGlyph(const QByteArray &svg, const QColor &ink, const QSize &size) +{ + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; +} +} // namespace + +/** + * @brief Writes the hover panel's action grids the way the client paints them. + * + * The treatment sheets show glyphs in isolation; this renders the real layout — + * section headers and two-column cells with the glyph above its label — so the + * emphasis split (quiet tabs, primary grid) and the semantic map can be judged + * as the set the player actually sees. The roles are the ones the panel passes + * to the icon engine, already resolved to the emphasis level it requests, and a + * role the theme does not author resolves through the same scheme default the + * client paints. + */ +TEST(IconTreatmentPreview, HoverPanelGridWritesPngsWhenRequested) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ensureGuiApplication(); + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int COLUMNS = 2; + constexpr int CELL_WIDTH = 146; + constexpr int CELL_HEIGHT = 62; + constexpr int SECTION_HEIGHT = 22; + constexpr int PANEL_PADDING = 10; + constexpr int PANEL_TITLE = 24; + constexpr int GLYPH = 22; + constexpr qreal DPR = 2.0; + + // The pages a selected card shows, with every conditional action present + // so the whole map is visible at once. + const QList pages = { + PreviewPage{.title = QStringLiteral("Main — on the battlefield"), + .sections = + { + PreviewSection{.title = QStringLiteral("Battlefield"), + .actions = + { + PreviewAction{.label = QStringLiteral("Tap / Untap"), + .glyph = "sync", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Skip untapping"), + .glyph = "lock", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Clone"), + .glyph = "increment", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Attach to card..."), + .glyph = "pin", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Draw arrow..."), + .glyph = "share", + .role = AppColor::MarkedStrong}, + PreviewAction{.label = QStringLiteral("Set annotation..."), + .glyph = "pencil", + .role = AppColor::MarkedStrong}, + PreviewAction{.label = QStringLiteral("Turn Over"), + .glyph = "swap", + .role = AppColor::HiddenStrong}, + PreviewAction{.label = QStringLiteral("Peek at card face"), + .glyph = "view", + .role = AppColor::HiddenStrong}, + PreviewAction{.label = QStringLiteral("Unattach"), + .glyph = "arrow_undo", + .role = AppColor::NegativeStrong}, + PreviewAction{.label = QStringLiteral("Reduce life"), + .glyph = "arrow_down", + .role = AppColor::NegativeStrong}, + }}, + PreviewSection{.title = QStringLiteral("Select"), + .actions = + { + PreviewAction{.label = QStringLiteral("Select All"), + .glyph = "search", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Select Row"), + .glyph = "filter", + .role = AppColor::InformationalStrong}, + }}, + }}, + PreviewPage{.title = QStringLiteral("Main — in a zone"), + .sections = + { + PreviewSection{.title = QStringLiteral("Zone"), + .actions = + { + PreviewAction{.label = QStringLiteral("Play"), + .glyph = "arrow_right", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Play Face Down"), + .glyph = "arrow_right", + .role = AppColor::HiddenStrong}, + PreviewAction{.label = QStringLiteral("Clone"), + .glyph = "increment", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Attach to card..."), + .glyph = "pin", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Draw arrow..."), + .glyph = "share", + .role = AppColor::MarkedStrong}, + }}, + PreviewSection{.title = QStringLiteral("Select"), + .actions = + { + PreviewAction{.label = QStringLiteral("Select All"), + .glyph = "search", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Select Column"), + .glyph = "filter", + .role = AppColor::InformationalStrong}, + }}, + }}, + PreviewPage{.title = QStringLiteral("Move — card in a zone"), + .sections = + { + PreviewSection{.title = QStringLiteral("Move to"), + .actions = + { + PreviewAction{.label = QStringLiteral("Top of library"), + .glyph = "arrow_to_top", + .role = AppColor::MovementStrong}, + PreviewAction{.label = QStringLiteral("X from top..."), + .glyph = "search", + .role = AppColor::MovementStrong}, + PreviewAction{.label = QStringLiteral("Bottom of library"), + .glyph = "arrow_to_bottom", + .role = AppColor::MovementStrong}, + PreviewAction{.label = QStringLiteral("Table"), + .glyph = "player", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Hand"), + .glyph = "arrow_left", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Graveyard"), + .glyph = "arrow_down", + .role = AppColor::NegativeStrong}, + PreviewAction{.label = QStringLiteral("Exile"), + .glyph = "arrow_right", + .role = AppColor::InformationalStrong}, + }}, + PreviewSection{.title = QStringLiteral("Reveal to..."), + .actions = + { + PreviewAction{.label = QStringLiteral("All players"), + .glyph = "share", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Alice"), + .glyph = "player", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Bob"), + .glyph = "player", + .role = AppColor::InformationalStrong}, + }}, + }}, + }; + + const auto rowsIn = [](const PreviewSection §ion) { return (section.actions.size() + COLUMNS - 1) / COLUMNS; }; + const auto panelHeightFor = [&](const PreviewPage &page) { + int height = PANEL_TITLE + PANEL_PADDING; + for (const PreviewSection §ion : page.sections) { + height += SECTION_HEIGHT + rowsIn(section) * CELL_HEIGHT; + } + return height + PANEL_PADDING; + }; + + const int panelWidth = COLUMNS * CELL_WIDTH + 2 * PANEL_PADDING; + const int pageCount = static_cast(pages.size()); + int tallestPanel = 0; + for (const PreviewPage &page : pages) { + tallestPanel = qMax(tallestPanel, panelHeightFor(page)); + } + const int logicalWidth = 2 * MARGIN + pageCount * panelWidth + (pageCount - 1) * MARGIN; + const int logicalHeight = MARGIN + 30 + tallestPanel + MARGIN; + + const QString theme = QStringLiteral("Fabric"); + for (const bool dark : {false, true}) { + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + ASSERT_TRUE(config.hasPalette()) << theme.toStdString(); + + const QPalette palette = config.apply(QPalette()); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + // The panel's cells sit on the popup surface; painting them on the + // button color is what a QToolButton shows when it is not hovered. + const QColor cell = palette.color(QPalette::Active, QPalette::Button); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, hover panel action grids") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + const auto toneFor = [&](AppColor::Role role) { + return config.appColors.value(role, defaultAppColor(role, dark)); + }; + + for (int pageIndex = 0; pageIndex < pageCount; ++pageIndex) { + const PreviewPage &page = pages[pageIndex]; + const qreal panelLeft = MARGIN + static_cast(pageIndex) * (panelWidth + MARGIN); + const qreal panelTop = MARGIN + 30; + const int panelHeight = panelHeightFor(page); + + painter.setPen(Qt::NoPen); + painter.setBrush(cell.darker(dark ? 115 : 103)); + painter.drawRoundedRect(QRectF(panelLeft, panelTop, panelWidth, panelHeight), 8, 8); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(panelLeft + PANEL_PADDING, panelTop + 17), page.title); + + qreal y = panelTop + PANEL_TITLE; + for (const PreviewSection §ion : page.sections) { + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText(QPointF(panelLeft + PANEL_PADDING, y + 15), section.title); + y += SECTION_HEIGHT; + + const int rows = rowsIn(section); + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < COLUMNS; ++column) { + const int index = row * COLUMNS + column; + if (index >= section.actions.size()) { + break; + } + const PreviewAction &action = section.actions.at(index); + const QRectF cellRect(panelLeft + PANEL_PADDING + column * CELL_WIDTH, y, CELL_WIDTH - 4, + CELL_HEIGHT - 4); + + painter.setPen(Qt::NoPen); + painter.setBrush(cell); + painter.drawRoundedRect(cellRect, 5, 5); + + const QByteArray svg = readIconSvg(action.glyph); + ASSERT_FALSE(svg.isEmpty()) << action.glyph; + const QPixmap pixmap = renderAtDpr(QSize(GLYPH, GLYPH), DPR, [&](const QSize &pixelSize) { + return renderInkGlyph(svg, toneFor(action.role), pixelSize); + }); + painter.drawPixmap(QPointF(cellRect.center().x() - GLYPH / 2.0, cellRect.top() + 7), pixmap); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 7)); + const QFontMetrics metrics(painter.font()); + const QString label = metrics.elidedText(action.label, Qt::ElideRight, CELL_WIDTH - 12); + painter.drawText(QRectF(cellRect.left(), cellRect.bottom() - 20, cellRect.width(), 16), + Qt::AlignHCenter | Qt::AlignVCenter, label); + } + y += CELL_HEIGHT; + } + } + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("hover-panel-map-%1.png").arg(dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index 93203be6c..a56c723b4 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -1535,9 +1535,14 @@ TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant) AppColor::Role soft; }; - for (const Family &family : {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, - Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, - Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + for (const Family &family : + {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, + Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, + Family{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}, + Family{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}, + Family{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}, + Family{AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}}) { EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal), themeManager->appColor(family.strong)); EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base)); @@ -1636,6 +1641,52 @@ TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue) EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); EXPECT_GT(ink.red(), ink.blue()) << theme.toStdString() << static_cast(role); } + + for (const AppColor::Role role : + {AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}) { + // Informational may come from the scheme default when the theme + // does not author it, so measure the value the client paints. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.blue(), ink.green()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}) { + // Hidden is violet: blue leads, then red, then green. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}) { + // Marked is amber: red leads, then green, then blue. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}) { + // Movement is teal: green leads, then blue, then red. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + } } } EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level"; @@ -1821,7 +1872,11 @@ TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces) for (const Scheme &scheme : schemes) { for (const auto &[base, strong, soft] : {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, - std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, + std::tuple{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}, + std::tuple{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}, + std::tuple{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}, + std::tuple{AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}}) { const QColor secondary = defaultAppColor(base, scheme.dark); const QColor primary = defaultAppColor(strong, scheme.dark); const QColor quiet = defaultAppColor(soft, scheme.dark); @@ -1908,6 +1963,18 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) AppColor::NegativeSoft, AppColor::Negative, AppColor::NegativeStrong}, + {"Informational", + {"arrow_right", "view", "arrow_to_top"}, + AppColor::InformationalSoft, + AppColor::Informational, + AppColor::InformationalStrong}, + {"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong}, + {"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong}, + {"Movement", + {"arrow_to_top", "arrow_to_bottom", "arrow_down"}, + AppColor::MovementSoft, + AppColor::Movement, + AppColor::MovementStrong}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -1942,9 +2009,10 @@ TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) // The paint path is a lookup now, so the sheet shows the authored // rows verbatim rather than a color recomputed from the accent. const auto toneFor = [&](const Family &family, int level) { - return level == 0 ? palette.roles.value(family.quiet) - : level == 1 ? palette.roles.value(family.secondary) - : palette.roles.value(family.primary); + const AppColor::Role role = level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary; + // A theme written before the role existed does not author it; + // the client paints the scheme default, so the sheet does too. + return palette.roles.value(role, defaultAppColor(role, dark)); }; const auto scheme = dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; @@ -2100,6 +2168,18 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) AppColor::NegativeSoft, AppColor::Negative, AppColor::NegativeStrong}, + {"Informational", + {"arrow_right", "view", "arrow_to_top"}, + AppColor::InformationalSoft, + AppColor::Informational, + AppColor::InformationalStrong}, + {"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong}, + {"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong}, + {"Movement", + {"arrow_to_top", "arrow_to_bottom", "arrow_down"}, + AppColor::MovementSoft, + AppColor::Movement, + AppColor::MovementStrong}, }; const QStringList themes = { QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), @@ -2184,7 +2264,9 @@ TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) for (const Family &family : families) { for (int level = 0; level < 4; ++level) { const bool disabled = level == 3; - const QColor ink = disabled ? disabledTone : palette.roles.value(roleAt(family, level)); + const QColor ink = disabled ? disabledTone + : palette.roles.value(roleAt(family, level), + defaultAppColor(roleAt(family, level), dark)); const QString levelName = disabled ? QStringLiteral("Disabled") : QString::fromLatin1(LEVEL_NAMES[level]); ASSERT_TRUE(ink.isValid()) diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index 3bd8be3a2..329d13458 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -167,6 +167,30 @@ TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378"))); EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c"))); EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b"))); + EXPECT_EQ(defaultAppColor(AppColor::Informational, false), QColor(QStringLiteral("#1c64c4"))); + EXPECT_EQ(defaultAppColor(AppColor::Informational, true), QColor(QStringLiteral("#7aa9f2"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, false), QColor(QStringLiteral("#0a4fb5"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, true), QColor(QStringLiteral("#69aeff"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, false), QColor(QStringLiteral("#526a8a"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, true), QColor(QStringLiteral("#9db2cd"))); + EXPECT_EQ(defaultAppColor(AppColor::Hidden, false), QColor(QStringLiteral("#6d28d9"))); + EXPECT_EQ(defaultAppColor(AppColor::Hidden, true), QColor(QStringLiteral("#c6a5fa"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenStrong, false), QColor(QStringLiteral("#4c0f9e"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenStrong, true), QColor(QStringLiteral("#c99bff"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenSoft, false), QColor(QStringLiteral("#6a5a8c"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenSoft, true), QColor(QStringLiteral("#b3a6c9"))); + EXPECT_EQ(defaultAppColor(AppColor::Marked, false), QColor(QStringLiteral("#9a5b05"))); + EXPECT_EQ(defaultAppColor(AppColor::Marked, true), QColor(QStringLiteral("#e2a63a"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedStrong, false), QColor(QStringLiteral("#8a4f02"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedStrong, true), QColor(QStringLiteral("#ffb020"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedSoft, false), QColor(QStringLiteral("#7b6a4a"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedSoft, true), QColor(QStringLiteral("#c2ab7d"))); + EXPECT_EQ(defaultAppColor(AppColor::Movement, false), QColor(QStringLiteral("#0e7a72"))); + EXPECT_EQ(defaultAppColor(AppColor::Movement, true), QColor(QStringLiteral("#4cc2b5"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementStrong, false), QColor(QStringLiteral("#096b62"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementStrong, true), QColor(QStringLiteral("#2dd4bf"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementSoft, false), QColor(QStringLiteral("#4a6b67"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementSoft, true), QColor(QStringLiteral("#a3c1bc"))); // Accent and neutral icon roles have no static default: an older theme // written before the roles existed falls back to deriving them from its // palette rather than pinning one scheme's ink. From 40ebd225491df47c7bdb72d9cfded0faac3d56cf Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:12:29 +0100 Subject: [PATCH 103/104] [Lobby] Add instant quick host to the game room (#7802) * Add instant quick host to the game lobby * Use a native pull-down on macOS and a split button elsewhere - Gate on the active application style instead of the platform, so choosing Fusion on macOS still gets the split button: QMacStyle ignores autoRaise/flat and draws a QToolButton menu as a separate square. - Rebuild the host control on QEvent::StyleChange, because the appearance settings switch styles while the room is open. - Keep the review action, its arrow and the host button disabled while a host or join command is still in flight. - Announce inline quick-host failures to screen readers. * Address review findings * Replace the macOS pull-down with a sibling settings button QMacStyle has no split-button rendering: a menu-bearing QToolButton is drawn as a second square bezel. Keep the primary Host game button as the one-click quick host on every platform, and show a plain Game settings button as the secondary action on macOS instead; other styles keep the split arrow. Rename the secondary action from "Review settings first..." to "Game settings..." so the label says what it opens, and update the shortcuts page label to match. --- .../src/client/settings/shortcuts_settings.h | 6 + .../game_hosting/game_creation_options.cpp | 7 + .../game_hosting/game_creation_options.h | 8 + .../widgets/server/game_selector.cpp | 293 +++++++++++++++++- .../interface/widgets/server/game_selector.h | 72 ++++- .../src/interface/widgets/tabs/tab_room.cpp | 3 +- tests/game_creation_options_test.cpp | 20 ++ 7 files changed, 388 insertions(+), 21 deletions(-) diff --git a/cockatrice/src/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h index 202b39247..94b981d83 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.h +++ b/cockatrice/src/client/settings/shortcuts_settings.h @@ -313,6 +313,12 @@ private: {"DeckViewContainer/forceStartGameButton", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Force Start"), parseSequenceString(""), ShortcutGroup::Game_Lobby)}, + {"GameSelector/aQuickHostGame", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Host Game"), + parseSequenceString("Ctrl+N"), + ShortcutGroup::Game_Lobby)}, + {"GameSelector/aReviewGame", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Game Settings"), + parseSequenceString("Ctrl+Shift+N"), + ShortcutGroup::Game_Lobby)}, {"Player/aCCMagenta", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Add Card Counter (F)"), parseSequenceString(""), ShortcutGroup::Card_Counters)}, diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp index 048899266..acef35ebc 100644 --- a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp @@ -36,6 +36,13 @@ GameCreationOptions GameCreationOptions::fromSettings(const IGameSettingsProvide return options; } +GameCreationOptions GameCreationOptions::defaultsFor(bool registeredUser) +{ + GameCreationOptions options; + options.onlyRegistered = registeredUser; + return options; +} + GameCreationOptions GameCreationOptions::fromGameInfo(const ServerInfo_Game &gameInfo, const QMap &gameTypes) { diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h index cbc941815..d9a2bc0cc 100644 --- a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h @@ -49,6 +49,14 @@ struct GameCreationOptions [[nodiscard]] static GameCreationOptions fromSettings(const IGameSettingsProvider &settings, const QMap &gameTypes, bool registeredUser); + /** + * @brief The options the host form shows when nothing is remembered. + * + * A registered host defaults to a registered-only game, matching the form's + * ``Clear`` action. + */ + [[nodiscard]] static GameCreationOptions defaultsFor(bool registeredUser); + /** * @brief Reads the read-only fields shown in the game information view. */ diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 9dc7bf1e8..c3bc7499f 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,9 +1,11 @@ #include "game_selector.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" +#include "../interface/widgets/game_hosting/game_creation_options.h" #include "../interface/widgets/tabs/tab_room.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../interface/widgets/utility/get_text_with_max.h" @@ -15,13 +17,18 @@ #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user/user_list_manager.h" +#include +#include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -31,15 +38,19 @@ #include #include #include +#include +#include #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -51,6 +62,33 @@ class QPoint; class QWidget; template class QList; +namespace +{ + +/** + * @brief Returns whether the application paints with the native macOS style. + * + * QMacStyle ignores autoRaise and flat, and draws a QToolButton menu as a second square + * bezel, so the split host control cannot be composed there. A global style sheet wraps the + * real application style in a QStyleSheetStyle that takes it as a child, so that layer is + * unwrapped before the style is identified. + */ +bool usesMacStyle() +{ + QStyle *style = QApplication::style(); + if (style == nullptr) { + return false; + } + if (style->inherits("QStyleSheetStyle")) { + if (QStyle *baseStyle = style->findChild(QString(), Qt::FindDirectChildrenOnly)) { + style = baseStyle; + } + } + return style->inherits("QMacStyle"); +} + +} // namespace + GameSelector::GameSelector(AbstractClient *_client, TabSupervisor *_tabSupervisor, TabRoom *_room, @@ -140,10 +178,59 @@ GameSelector::GameSelector(AbstractClient *_client, connect(gameListProxyModel, &GamesProxyModel::filtersChanged, this, &GameSelector::checkClearFilterButtonState); if (room) { - createButton = new QPushButton; - connect(createButton, &QPushButton::clicked, this, &GameSelector::actCreate); - } else { - createButton = nullptr; + hostGameAction = new QAction(this); + hostGameAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(hostGameAction, &QAction::triggered, this, &GameSelector::actQuickHost); + + // A plain QPushButton, so the quick host sits in the same control family as the + // join buttons. A QPushButton does not take its text, tooltip or enabled state from + // an action, so those are mirrored by hand below and in updateHostControlsEnabled(). + hostGameButton = new QPushButton(this); + hostGameButton->setObjectName("hostGameButton"); + connect(hostGameButton, &QPushButton::clicked, hostGameAction, &QAction::trigger); + + hostGameMenu = new QMenu(this); + hostGameMenu->setObjectName("hostGameMenu"); + hostGameMenu->addAction(hostGameAction); + gameSettingsAction = hostGameMenu->addAction(QString()); + gameSettingsAction->setObjectName("gameSettingsAction"); + gameSettingsAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(gameSettingsAction, &QAction::triggered, this, &GameSelector::actCreate); + + // The secondary control: a flat arrow next to the primary button on styles that + // support a split control. The native macOS style does not, so there it is a plain + // sibling button instead (see applyHostGameStyle()). + hostGameMenuButton = new QToolButton(this); + hostGameMenuButton->setObjectName("hostGameMenuButton"); + hostGameMenuButton->setArrowType(Qt::DownArrow); + hostGameMenuButton->setAutoRaise(true); + hostGameMenuButton->setPopupMode(QToolButton::InstantPopup); + hostGameMenuButton->setToolButtonStyle(Qt::ToolButtonIconOnly); + + hostGameSettingsButton = new QPushButton(this); + hostGameSettingsButton->setObjectName("hostGameSettingsButton"); + connect(hostGameSettingsButton, &QPushButton::clicked, gameSettingsAction, &QAction::trigger); + + hostGameButtonWidget = new QWidget(this); + auto *hostGameButtonLayout = new QHBoxLayout(hostGameButtonWidget); + hostGameButtonLayout->setContentsMargins(0, 0, 0, 0); + hostGameButtonLayout->setSpacing(0); + hostGameButtonLayout->addWidget(hostGameButton); + hostGameButtonLayout->addWidget(hostGameMenuButton); + + applyHostGameStyle(); + + // The shortcuts belong to the whole room tab, not just the game list, so they also + // fire while the chat or the user list has focus. A QToolButton menu is mouse-only, + // so the settings action needs its own shortcut for keyboard parity. + if (auto *roomTab = qobject_cast(parentWidget())) { + roomTab->addAction(hostGameAction); + roomTab->addAction(gameSettingsAction); + } else { + addAction(hostGameAction); + addAction(gameSettingsAction); + } + refreshShortcuts(); } joinButton = new QPushButton; joinAsJudgeButton = new QPushButton; @@ -157,7 +244,8 @@ GameSelector::GameSelector(AbstractClient *_client, } buttonLayout->addStretch(); if (room) { - buttonLayout->addWidget(createButton); + buttonLayout->addWidget(hostGameButtonWidget); + buttonLayout->addWidget(hostGameSettingsButton); } buttonLayout->addWidget(joinButton); if (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { @@ -173,10 +261,16 @@ GameSelector::GameSelector(AbstractClient *_client, } buttonLayout->setAlignment(Qt::AlignTop); + hostErrorLabel = new QLabel(this); + hostErrorLabel->setObjectName("hostGameErrorLabel"); + hostErrorLabel->setWordWrap(true); + hostErrorLabel->setVisible(false); + QVBoxLayout *mainLayout = new QVBoxLayout; if (showFilters && restoresettings) { mainLayout->addWidget(quickFilterToolBar); } + mainLayout->addWidget(hostErrorLabel); mainLayout->addWidget(gameListView); mainLayout->addLayout(buttonLayout); @@ -266,6 +360,8 @@ void GameSelector::actCreate() return; } + clearHostError(); + // Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the // dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated // child would then be deleted by Qt and crash. @@ -275,6 +371,128 @@ void GameSelector::actCreate() dlg->exec(); } +void GameSelector::actQuickHost() +{ + if (room == nullptr) { + qWarning() << "Attempted to create a game, but the room was null"; + return; + } + + clearHostError(); + + auto &gameSettings = SettingsCache::instance().game(); + const bool registered = tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsRegistered; + const GameCreationOptions options = + gameSettings.getRememberGameSettings() + ? GameCreationOptions::fromSettings(gameSettings, room->getGameTypes(), registered) + : GameCreationOptions::defaultsFor(registered); + + PendingCommand *pend = room->prepareRoomCommand(options.toCreateGameCommand()); + connect(pend, &PendingCommand::finished, this, &GameSelector::checkQuickHostResponse); + room->sendRoomCommand(pend); + + setHostCommandPending(true); +} + +void GameSelector::checkQuickHostResponse(const Response &response) +{ + setHostCommandPending(false); + + switch (response.response_code()) { + case Response::RespOk: + clearHostError(); + break; + case Response::RespContextError: + showHostError(tr("You already host the maximum number of games.")); + break; + case Response::RespInternalError: + showHostError(tr("The server could not create the game.")); + break; + default: + showHostError(tr("Could not create the game.")); + break; + } +} + +void GameSelector::showHostError(const QString &message) +{ + hostErrorLabel->setText(message); + hostErrorLabel->setAccessibleName(message); + hostErrorLabel->setVisible(true); + + // The label appears above the game list without taking focus, so announce it: a screen + // reader would otherwise never reach the message after the button press. + QAccessibleEvent alertEvent(hostErrorLabel, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); +} + +void GameSelector::clearHostError() +{ + hostErrorLabel->clear(); + hostErrorLabel->setAccessibleName(QString()); + hostErrorLabel->setVisible(false); +} + +void GameSelector::applyHostGameStyle() +{ + if (hostGameButton == nullptr || hostGameMenu == nullptr || hostGameMenuButton == nullptr || + hostGameSettingsButton == nullptr) { + return; + } + + // The primary button always quick-hosts. The native macOS style has no split-button + // rendering (a QToolButton menu becomes a second square bezel), so the secondary + // action is a plain sibling button there; every other style gets the split arrow. + if (usesMacStyle()) { + hostGameMenuButton->setMenu(nullptr); + hostGameMenuButton->setVisible(false); + hostGameSettingsButton->setVisible(true); + } else { + hostGameMenuButton->setMenu(hostGameMenu); + hostGameMenuButton->setVisible(true); + hostGameSettingsButton->setVisible(false); + } + + // The appearance settings can switch the style while the room is open. + updateHostGameToolTip(); +} + +void GameSelector::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::StyleChange) { + applyHostGameStyle(); + } + QGroupBox::changeEvent(event); +} + +void GameSelector::setHostCommandPending(bool pending) +{ + hostCommandPending = pending; + // A join may still be in flight: its response enables these controls again, so keep them + // disabled until that response arrives instead of re-enabling on the host response alone. + updateHostControlsEnabled(); +} + +void GameSelector::updateHostControlsEnabled() +{ + const bool hostControlsEnabled = !hostCommandPending && !joinCommandPending; + if (hostGameAction) { + hostGameAction->setEnabled(hostControlsEnabled); + } + if (hostGameButton) { + hostGameButton->setEnabled(hostControlsEnabled); + } + if (hostGameMenuButton) { + hostGameMenuButton->setEnabled(hostControlsEnabled); + } + if (hostGameSettingsButton) { + hostGameSettingsButton->setEnabled(hostControlsEnabled); + } + if (gameSettingsAction) { + gameSettingsAction->setEnabled(hostControlsEnabled); + } +} + void GameSelector::checkResponse(const Response &response) { // NB: We re-enable buttons for the currently selected game, which may not @@ -289,6 +507,9 @@ void GameSelector::checkResponse(const Response &response) // Ideally we should have a way to figure out if the current game is the // same as the one we are getting a response for, but it's probably not // worth the trouble. + // The join response is the one that re-enables the buttons; clear the join flag first so + // the host controls become usable again unless a quick host is still in flight. + joinCommandPending = false; enableButtons(); switch (response.response_code()) { @@ -486,9 +707,8 @@ bool GameSelector::joinGameById(const int gameId, const bool asSpectator) void GameSelector::disableButtons() { - if (createButton) { - createButton->setEnabled(false); - } + joinCommandPending = true; + updateHostControlsEnabled(); joinButton->setEnabled(false); spectateButton->setEnabled(false); @@ -496,9 +716,7 @@ void GameSelector::disableButtons() void GameSelector::enableButtons() { - if (createButton) { - createButton->setEnabled(true); - } + updateHostControlsEnabled(); // Enable buttons for the currently selected game enableButtonsForIndex(gameListView->currentIndex()); @@ -521,8 +739,27 @@ void GameSelector::retranslateUi() { filterButton->setText(tr("&Filter games")); clearFilterButton->setText(tr("C&lear filter")); - if (createButton) { - createButton->setText(tr("C&reate")); + if (hostGameAction) { + hostGameAction->setText(tr("Host with last settings")); + if (hostGameButton) { + // A QPushButton does not draw text from an action; the button keeps the short + // label while the menu item spells out what the remembered settings do. + hostGameButton->setText(tr("Host game")); + } + updateHostGameToolTip(); + } + if (gameSettingsAction) { + gameSettingsAction->setText(tr("Game settings…")); + if (hostGameSettingsButton) { + // A QPushButton does not draw text from an action; on macOS this button is the + // secondary action, so it carries the same label. + hostGameSettingsButton->setText(tr("Game settings…")); + } + if (hostGameMenuButton) { + // The split arrow has no text of its own; name it for the menu it opens. + hostGameMenuButton->setToolTip(tr("More host options")); + hostGameMenuButton->setAccessibleName(tr("More host options")); + } } joinButton->setText(tr("&Join")); joinAsJudgeButton->setText(tr("Join as judge")); @@ -532,6 +769,36 @@ void GameSelector::retranslateUi() updateTitle(); } +void GameSelector::refreshShortcuts() +{ + if (hostGameAction) { + hostGameAction->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("GameSelector/aQuickHostGame")); + updateHostGameToolTip(); + } + if (gameSettingsAction) { + gameSettingsAction->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("GameSelector/aReviewGame")); + } +} + +void GameSelector::updateHostGameToolTip() +{ + const QString shortcut = SettingsCache::instance().shortcuts().getShortcutString("GameSelector/aQuickHostGame"); + const QString quickHostTooltip = shortcut.isEmpty() + ? tr("Host a game with your last-used settings") + : tr("Host a game with your last-used settings (%1)").arg(shortcut); + // The tooltip lives on the action for the shortcut and on the button for the mouse, + // since a QPushButton does not inherit an action's tooltip. + if (hostGameAction) { + hostGameAction->setToolTip(quickHostTooltip); + } + if (hostGameButton) { + hostGameButton->setToolTip(quickHostTooltip); + } + if (hostGameSettingsButton) { + hostGameSettingsButton->setToolTip(tr("Open the game settings before hosting")); + } +} + void GameSelector::processGameInfo(const ServerInfo_Game &info) { gameListModel->updateGameList(info); diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index e3407abea..72c6fabfe 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -14,7 +14,11 @@ class QTreeView; class GamesModel; class GamesProxyModel; +class QLabel; +class QMenu; class QPushButton; +class QToolButton; +class QAction; class AbstractClient; class TabSupervisor; class TabRoom; @@ -64,6 +68,17 @@ private slots: */ void actCreate(); + /** + * @brief Creates a game immediately from the remembered settings, without a dialog. + */ + void actQuickHost(); + + /** + * @brief Reports a failed quick-host request inline instead of as a modal box. + * @param response The server response to the create command. + */ + void checkQuickHostResponse(const Response &response); + /** * @brief Joins the currently selected game as a player. */ @@ -127,6 +142,15 @@ signals: */ void gameJoined(int gameId); +protected: + /** + * @brief Rebuilds the host control when the application style changes. + * + * The appearance settings can switch between the native macOS style and a + * cross-platform style while the room is open, and the host control follows. + */ + void changeEvent(QEvent *event) override; + private: AbstractClient *client; /**< The network client used to communicate with the server. */ TabSupervisor *tabSupervisor; /**< Reference to TabSupervisor for managing tabs and rooms. */ @@ -138,13 +162,20 @@ private: GameSelectorQuickFilterToolBar *quickFilterToolBar; - QPushButton *filterButton; /**< Button to open the filter dialog. */ - QPushButton *clearFilterButton; /**< Button to clear active filters. */ - QPushButton *createButton; /**< Button to create a new game (only if room is set). */ - QPushButton *joinButton; /**< Button to join the selected game. */ - QPushButton *joinAsJudgeButton; /**< Button to join the selected game as a judge. */ - QPushButton *spectateButton; /**< Button to spectate the selected game. */ - QPushButton *joinAsJudgeSpectatorButton; /**< Button to join the selected game as a spectating judge. */ + QPushButton *filterButton; /**< Button to open the filter dialog. */ + QPushButton *clearFilterButton; /**< Button to clear active filters. */ + QPushButton *hostGameButton = nullptr; /**< Host button, in the same family as the join buttons. */ + QPushButton *hostGameSettingsButton = nullptr; /**< Opens the host form; a sibling button on macOS. */ + QToolButton *hostGameMenuButton = nullptr; /**< Split arrow that opens the host menu (non-macOS styles only). */ + QWidget *hostGameButtonWidget = nullptr; /**< Container that keeps the split button and its arrow together. */ + QMenu *hostGameMenu = nullptr; /**< Menu behind the split arrow (non-macOS styles only). */ + QAction *hostGameAction = nullptr; /**< Quick-host action, carrying the keyboard shortcut. */ + QAction *gameSettingsAction = nullptr; /**< Opens the host form with the current settings. */ + QLabel *hostErrorLabel; /**< Inline failure message for a quick-host request. */ + QPushButton *joinButton; /**< Button to join the selected game. */ + QPushButton *joinAsJudgeButton; /**< Button to join the selected game as a judge. */ + QPushButton *spectateButton; /**< Button to spectate the selected game. */ + QPushButton *joinAsJudgeSpectatorButton; /**< Button to join the selected game as a spectating judge. */ const bool showFilters; /**< Determines whether filter buttons are displayed. */ GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */ @@ -152,6 +183,28 @@ private: QTimer filterSaveDebounceTimer; /**< Delays persisting quick-filter edits so typing does not rewrite the settings file on every keystroke. */ + bool hostCommandPending = false; /**< True while a quick-host command is in flight. */ + bool joinCommandPending = false; /**< True while a join/spectate command is in flight. */ + + /** @brief Shows a quick-host failure above the game list. */ + void showHostError(const QString &message); + /** @brief Hides the quick-host failure message. */ + void clearHostError(); + /** + * @brief Rebuilds the host control for the active style. + * + * The primary button always quick-hosts. Every other style gets the split button, + * whose arrow opens the menu; the native macOS style has no split control, so it + * gets a plain sibling button for the secondary action instead. + */ + void applyHostGameStyle(); + /** @brief Marks a quick-host command as in flight or finished, then updates the host controls. */ + void setHostCommandPending(bool pending); + /** @brief Applies the in-flight state of both host and join commands to the host controls. */ + void updateHostControlsEnabled(); + /** @brief Keeps the host control tooltips in sync with the current shortcut and style. */ + void updateHostGameToolTip(); + /** * @brief Updates the widget title to reflect the current number of displayed games. * @@ -222,6 +275,11 @@ public: */ void retranslateUi(); + /** + * @brief Re-reads the quick-host and review shortcuts after the user rebinds them. + */ + void refreshShortcuts(); + /** * @brief Updates or adds a game entry in the list. * @param info The ServerInfo_Game object containing information about the game to update. diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 329732c64..1973a0747 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -113,7 +113,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, QMap tempMap; tempMap.insert(info.room_id(), gameTypes); - gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true); + gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true, this); userListPanel = new UserListPanelWidget(tabSupervisor, client, this); userListPanel->bind(tabSupervisor->getUserListManager()); @@ -720,6 +720,7 @@ void TabRoom::processRemoveMessagesEvent(const Event_RemoveMessages &event) void TabRoom::refreshShortcuts() { aClearChat->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("tab_room/aClearChat")); + gameSelector->refreshShortcuts(); } void TabRoom::addMentionTag(QString mentionTag) diff --git a/tests/game_creation_options_test.cpp b/tests/game_creation_options_test.cpp index e4a7996cb..cd8344ba9 100644 --- a/tests/game_creation_options_test.cpp +++ b/tests/game_creation_options_test.cpp @@ -185,6 +185,26 @@ TEST(GameCreationOptions, DescriptionWhitespaceIsSimplified) EXPECT_EQ(cmd.description(), "a b"); } +TEST(GameCreationOptions, DefaultsForRegisteredHostEnforceRegistration) +{ + const GameCreationOptions registered = GameCreationOptions::defaultsFor(true); + + EXPECT_TRUE(registered.onlyRegistered); + EXPECT_FALSE(registered.onlyBuddies); + EXPECT_EQ(registered.maxPlayers, 2); + EXPECT_TRUE(registered.spectatorsAllowed); + EXPECT_EQ(registered.startingLifeTotal, 20); + EXPECT_TRUE(registered.gameTypeIds.isEmpty()); +} + +TEST(GameCreationOptions, DefaultsForUnregisteredHostAllowOpenJoining) +{ + const GameCreationOptions unregistered = GameCreationOptions::defaultsFor(false); + + EXPECT_FALSE(unregistered.onlyRegistered); + EXPECT_FALSE(unregistered.onlyBuddies); +} + TEST(GameCreationOptions, FromSettingsCopiesEveryRememberedField) { const FakeGameSettings settings{GameCreationOptions{.description = "Remembered game", From 9793a0df4a33142ad804a839efc2ba1723dbc062 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:06:21 +0100 Subject: [PATCH 104/104] [Crash] Add lifecycle breadcrumbs for unattributable native crashes (#7813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Crash] Add lifecycle breadcrumbs for unattributable native crashes Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> * Restore the native split host button on non-macOS styles The merged quick-host control composed a QPushButton and a separate autoRaise arrow QToolButton, which Fusion draws as two adjacent controls. Version one of the quick-host branch used a single QToolButton with MenuButtonPopup, which every style draws as one native split button. Use that split button on every style that can draw one, and keep the plain primary + settings button pair as the native macOS fallback. * Enable hover zoom and the hover panel by default Both hover triggers now default to Hover, with the existing 400 ms zoom delay and 200 ms panel delay. The Off trigger remains available for users who want the old behavior. --------- Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com> Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- .../src/client/crash/crash_reporter.cpp | 16 ++++ cockatrice/src/client/crash/crash_reporter.h | 11 +++ .../board/abstract_card_item.cpp | 12 ++- .../widgets/server/game_selector.cpp | 85 ++++++++----------- .../interface/widgets/server/game_selector.h | 11 ++- .../widgets/server/user/user_list_widget.cpp | 2 + .../interface/widgets/tabs/tab_supervisor.cpp | 5 ++ cockatrice/src/main.cpp | 17 ++++ .../settings/cards_display_settings.cpp | 10 +-- .../settings/cards_display_settings.h | 2 +- tests/crash_reporter_test.cpp | 10 +++ tests/settings/settings_defaults_test.cpp | 6 +- 12 files changed, 114 insertions(+), 73 deletions(-) diff --git a/cockatrice/src/client/crash/crash_reporter.cpp b/cockatrice/src/client/crash/crash_reporter.cpp index 07a0389c7..cba84994c 100644 --- a/cockatrice/src/client/crash/crash_reporter.cpp +++ b/cockatrice/src/client/crash/crash_reporter.cpp @@ -614,3 +614,19 @@ QString CrashReporter::redactCockatriceUrls(const QString &line) redacted.append(line.mid(pos)); return redacted; } + +void CrashReporter::addBreadcrumb(const char *category, const char *message) +{ +#if COCKATRICE_WITH_CRASH_REPORTING + if (category == nullptr || message == nullptr) { + return; + } + sentry_value_t crumb = sentry_value_new_breadcrumb("default", message); + sentry_value_set_by_key(crumb, "category", sentry_value_new_string(category)); + sentry_value_set_by_key(crumb, "level", sentry_value_new_string("info")); + sentry_add_breadcrumb(crumb); +#else + Q_UNUSED(category); + Q_UNUSED(message); +#endif +} diff --git a/cockatrice/src/client/crash/crash_reporter.h b/cockatrice/src/client/crash/crash_reporter.h index aecc6ade3..f06103188 100644 --- a/cockatrice/src/client/crash/crash_reporter.h +++ b/cockatrice/src/client/crash/crash_reporter.h @@ -167,6 +167,17 @@ public: static QString sanitizeLogLine(const QString &line); static QString redactCockatriceUrls(const QString &line); + /** + * @brief Record a fixed-text lifecycle breadcrumb on the active Sentry scope. + * + * Native crashes whose stack walk loses the in-app frames (e.g. a QMap lookup + * that dies inside Qt's string comparison) otherwise arrive with no hint of + * what the client was doing. Only pass compile-time string literals: no user + * names, card names, paths or URLs, so no personal data can leak through this + * channel. Thread-safe; a no-op in OFF builds or before init(). + */ + static void addBreadcrumb(const char *category, const char *message); + private: bool ready = false; QString sentryDatabasePath; diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index f7ae1ff71..f2c0966e4 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -48,8 +48,8 @@ class QSizeF; // Width the hovered card smooth-zooms to: exactly the hover panel picture size, so // all four corners coincide when the panel swaps in over it. static constexpr qreal HOVER_ZOOM_WIDTH = 270.0; -// The pre-panel hover enlargement. The smooth zoom is opt-in, so this stays the hover -// behavior whenever no zoom trigger is configured (see setHoverHighlighted). +// The pre-panel hover enlargement. This stays the hover behavior when the smooth zoom is +// turned off (see setHoverHighlighted). static constexpr qreal LEGACY_HOVER_SCALE = 1.1; static constexpr int HOVER_ZOOM_IN_MS = 220; static constexpr int HOVER_ZOOM_OUT_MS = 140; @@ -356,11 +356,9 @@ void AbstractCardItem::setHoverHighlighted(bool highlighted) setZValue(ZValues::HOVERED_CARD); } - // Fallback to the historical hover scale: the smooth hover zoom is opt-in and its - // trigger defaults to Off, so when hovering drives neither the zoom nor the panel - // (zoom trigger Off, panel not on hover) contact alone must still enlarge the card - // to the old 1.1x while scaleCards is on. Otherwise every existing user silently - // loses the hover enlargement on upgrade. + // Fallback to the historical hover scale when the smooth hover zoom is turned off and + // the panel is not driven by hover: contact alone must still enlarge the card to the + // old 1.1x while scaleCards is on. auto &display = SettingsCache::instance().cardsDisplay(); const bool legacyHoverScale = display.getScaleCards() && display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerOff && diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index c3bc7499f..775e58297 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -182,42 +182,35 @@ GameSelector::GameSelector(AbstractClient *_client, hostGameAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); connect(hostGameAction, &QAction::triggered, this, &GameSelector::actQuickHost); - // A plain QPushButton, so the quick host sits in the same control family as the - // join buttons. A QPushButton does not take its text, tooltip or enabled state from - // an action, so those are mirrored by hand below and in updateHostControlsEnabled(). - hostGameButton = new QPushButton(this); - hostGameButton->setObjectName("hostGameButton"); - connect(hostGameButton, &QPushButton::clicked, hostGameAction, &QAction::trigger); - hostGameMenu = new QMenu(this); hostGameMenu->setObjectName("hostGameMenu"); - hostGameMenu->addAction(hostGameAction); gameSettingsAction = hostGameMenu->addAction(QString()); gameSettingsAction->setObjectName("gameSettingsAction"); gameSettingsAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); connect(gameSettingsAction, &QAction::triggered, this, &GameSelector::actCreate); - // The secondary control: a flat arrow next to the primary button on styles that - // support a split control. The native macOS style does not, so there it is a plain - // sibling button instead (see applyHostGameStyle()). - hostGameMenuButton = new QToolButton(this); - hostGameMenuButton->setObjectName("hostGameMenuButton"); - hostGameMenuButton->setArrowType(Qt::DownArrow); - hostGameMenuButton->setAutoRaise(true); - hostGameMenuButton->setPopupMode(QToolButton::InstantPopup); - hostGameMenuButton->setToolButtonStyle(Qt::ToolButtonIconOnly); + // A split QToolButton in its native style: the primary half quick-hosts, and the + // arrow opens the menu with the full form. This is the control on every style that + // can draw one; QMacStyle draws the menu half as a second square bezel instead. + hostGameSplitButton = new QToolButton(this); + hostGameSplitButton->setObjectName("hostGameSplitButton"); + hostGameSplitButton->setDefaultAction(hostGameAction); + hostGameSplitButton->setPopupMode(QToolButton::MenuButtonPopup); + hostGameSplitButton->setToolButtonStyle(Qt::ToolButtonTextOnly); + hostGameSplitButton->setMenu(hostGameMenu); + + // The native macOS style has no split rendering, so there the primary stays a plain + // QPushButton next to a plain settings button. A QPushButton does not take its text, + // tooltip or enabled state from an action, so those are mirrored by hand below and + // in updateHostControlsEnabled(). + hostGameButton = new QPushButton(this); + hostGameButton->setObjectName("hostGameButton"); + connect(hostGameButton, &QPushButton::clicked, hostGameAction, &QAction::trigger); hostGameSettingsButton = new QPushButton(this); hostGameSettingsButton->setObjectName("hostGameSettingsButton"); connect(hostGameSettingsButton, &QPushButton::clicked, gameSettingsAction, &QAction::trigger); - hostGameButtonWidget = new QWidget(this); - auto *hostGameButtonLayout = new QHBoxLayout(hostGameButtonWidget); - hostGameButtonLayout->setContentsMargins(0, 0, 0, 0); - hostGameButtonLayout->setSpacing(0); - hostGameButtonLayout->addWidget(hostGameButton); - hostGameButtonLayout->addWidget(hostGameMenuButton); - applyHostGameStyle(); // The shortcuts belong to the whole room tab, not just the game list, so they also @@ -244,7 +237,9 @@ GameSelector::GameSelector(AbstractClient *_client, } buttonLayout->addStretch(); if (room) { - buttonLayout->addWidget(hostGameButtonWidget); + // Only the form that matches the active style is visible; hidden widgets take no room. + buttonLayout->addWidget(hostGameSplitButton); + buttonLayout->addWidget(hostGameButton); buttonLayout->addWidget(hostGameSettingsButton); } buttonLayout->addWidget(joinButton); @@ -435,23 +430,17 @@ void GameSelector::clearHostError() void GameSelector::applyHostGameStyle() { - if (hostGameButton == nullptr || hostGameMenu == nullptr || hostGameMenuButton == nullptr || - hostGameSettingsButton == nullptr) { + if (hostGameSplitButton == nullptr || hostGameButton == nullptr || hostGameSettingsButton == nullptr) { return; } - // The primary button always quick-hosts. The native macOS style has no split-button - // rendering (a QToolButton menu becomes a second square bezel), so the secondary - // action is a plain sibling button there; every other style gets the split arrow. - if (usesMacStyle()) { - hostGameMenuButton->setMenu(nullptr); - hostGameMenuButton->setVisible(false); - hostGameSettingsButton->setVisible(true); - } else { - hostGameMenuButton->setMenu(hostGameMenu); - hostGameMenuButton->setVisible(true); - hostGameSettingsButton->setVisible(false); - } + // Every style that can draw a split button gets one. The native macOS style cannot + // (QMacStyle draws the menu half as a second square bezel), so it gets the plain + // primary button and a sibling settings button instead. + const bool macStyle = usesMacStyle(); + hostGameSplitButton->setVisible(!macStyle); + hostGameButton->setVisible(macStyle); + hostGameSettingsButton->setVisible(macStyle); // The appearance settings can switch the style while the room is open. updateHostGameToolTip(); @@ -482,8 +471,8 @@ void GameSelector::updateHostControlsEnabled() if (hostGameButton) { hostGameButton->setEnabled(hostControlsEnabled); } - if (hostGameMenuButton) { - hostGameMenuButton->setEnabled(hostControlsEnabled); + if (hostGameSplitButton) { + hostGameSplitButton->setEnabled(hostControlsEnabled); } if (hostGameSettingsButton) { hostGameSettingsButton->setEnabled(hostControlsEnabled); @@ -740,10 +729,10 @@ void GameSelector::retranslateUi() filterButton->setText(tr("&Filter games")); clearFilterButton->setText(tr("C&lear filter")); if (hostGameAction) { - hostGameAction->setText(tr("Host with last settings")); + hostGameAction->setText(tr("Host game")); if (hostGameButton) { - // A QPushButton does not draw text from an action; the button keeps the short - // label while the menu item spells out what the remembered settings do. + // A QPushButton does not draw text from an action; on macOS this button is the + // primary action, so it carries the same label. hostGameButton->setText(tr("Host game")); } updateHostGameToolTip(); @@ -755,11 +744,6 @@ void GameSelector::retranslateUi() // secondary action, so it carries the same label. hostGameSettingsButton->setText(tr("Game settings…")); } - if (hostGameMenuButton) { - // The split arrow has no text of its own; name it for the menu it opens. - hostGameMenuButton->setToolTip(tr("More host options")); - hostGameMenuButton->setAccessibleName(tr("More host options")); - } } joinButton->setText(tr("&Join")); joinAsJudgeButton->setText(tr("Join as judge")); @@ -794,6 +778,9 @@ void GameSelector::updateHostGameToolTip() if (hostGameButton) { hostGameButton->setToolTip(quickHostTooltip); } + if (hostGameSplitButton) { + hostGameSplitButton->setToolTip(quickHostTooltip); + } if (hostGameSettingsButton) { hostGameSettingsButton->setToolTip(tr("Open the game settings before hosting")); } diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index 72c6fabfe..bf51239f1 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -164,10 +164,9 @@ private: QPushButton *filterButton; /**< Button to open the filter dialog. */ QPushButton *clearFilterButton; /**< Button to clear active filters. */ - QPushButton *hostGameButton = nullptr; /**< Host button, in the same family as the join buttons. */ + QToolButton *hostGameSplitButton = nullptr; /**< Split host button, on every style that can draw one. */ + QPushButton *hostGameButton = nullptr; /**< Primary quick-host button, on the native macOS style only. */ QPushButton *hostGameSettingsButton = nullptr; /**< Opens the host form; a sibling button on macOS. */ - QToolButton *hostGameMenuButton = nullptr; /**< Split arrow that opens the host menu (non-macOS styles only). */ - QWidget *hostGameButtonWidget = nullptr; /**< Container that keeps the split button and its arrow together. */ QMenu *hostGameMenu = nullptr; /**< Menu behind the split arrow (non-macOS styles only). */ QAction *hostGameAction = nullptr; /**< Quick-host action, carrying the keyboard shortcut. */ QAction *gameSettingsAction = nullptr; /**< Opens the host form with the current settings. */ @@ -193,9 +192,9 @@ private: /** * @brief Rebuilds the host control for the active style. * - * The primary button always quick-hosts. Every other style gets the split button, - * whose arrow opens the menu; the native macOS style has no split control, so it - * gets a plain sibling button for the secondary action instead. + * The primary button always quick-hosts. Every style that can draw a split control + * gets one, whose arrow opens the menu; the native macOS style has no split control, + * so it gets a plain sibling button for the secondary action instead. */ void applyHostGameStyle(); /** @brief Marks a quick-host command as in flight or finished, then updates the host controls. */ 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 943b4d5de..b7f4614c9 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1,5 +1,6 @@ #include "user_list_widget.h" +#include "../../../../client/crash/crash_reporter.h" #include "../../../../client/settings/cache_settings.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/theme_manager.h" @@ -1221,6 +1222,7 @@ void UserListWidget::processUserInfo(Section section, const ServerInfo_User &use bool UserListWidget::deleteUser(const QString &userName) { + CrashReporter::addBreadcrumb("users", "user list row removed"); if (sectioned) { // The user may own several rows (one per section). Drop them all. bool removed = false; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 7647b8ca8..c5cb7fda6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1,5 +1,6 @@ #include "tab_supervisor.h" +#include "../../../client/crash/crash_reporter.h" #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../intents/contexts/context_connect_to_server.h" @@ -593,6 +594,7 @@ void TabSupervisor::startLocal(const QList &_clients) */ void TabSupervisor::stop() { + CrashReporter::addBreadcrumb("tabs", "tab supervisor stopping"); if ((!client) && localClients.isEmpty()) { return; } @@ -1003,6 +1005,7 @@ void TabSupervisor::updateLatencyTooltip(const LatencyTracker::Stats &stats) void TabSupervisor::gameJoined(const Event_GameJoined &event) { + CrashReporter::addBreadcrumb("tabs", "game joined"); QMap roomGameTypes; TabRoom *room = roomTabs.value(event.game_info().room_id()); if (room) { @@ -1055,6 +1058,7 @@ void TabSupervisor::gameLeft(TabGame *tab) void TabSupervisor::addRoomTab(const ServerInfo_Room &info, bool setCurrent) { + CrashReporter::addBreadcrumb("tabs", "room tab added"); auto *tab = new TabRoom(this, client, userInfo, info); connect(tab, &TabRoom::maximizeClient, this, &TabSupervisor::maximizeMainWindow); connect(tab, &TabRoom::roomClosing, this, &TabSupervisor::roomLeft); @@ -1472,6 +1476,7 @@ void TabSupervisor::actShowPopup(const QString &message) void TabSupervisor::processUserLeft(const QString &userName) { + CrashReporter::addBreadcrumb("users", "user left"); TabMessage *tab = messageTabs.value(userName); if (tab) { tab->processUserLeft(); diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index b4dc0610f..e82867f3f 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -427,6 +427,23 @@ int main(int argc, char *argv[]) CardDatabaseManager::setCardDatabasePathProvider(&SettingsCache::instance()); CardDatabaseManager::setCardSetPriorityController(&SettingsCache::instance().cardDatabase()); + // Lifecycle breadcrumbs for native crash reports (see COCKATRICE-DESKTOP-1R): the live + // card maps are replaced wholesale on reset, so knowing whether a crash followed a reload + // narrows down which QMap was being read. Direct connections: the SDK call is thread-safe + // and loading signals may come from a QtConcurrent worker. + { + CardDatabase *crumbDb = CardDatabaseManager::getInstance(); + QObject::connect( + crumbDb, &CardDatabase::cardDatabaseReset, crumbDb, + [] { CrashReporter::addBreadcrumb("card_db", "card database snapshot swapped in"); }, Qt::DirectConnection); + QObject::connect( + crumbDb, &CardDatabase::cardDatabaseLoadingFinished, crumbDb, + [] { CrashReporter::addBreadcrumb("card_db", "card database loading finished"); }, Qt::DirectConnection); + QObject::connect( + crumbDb, &CardDatabase::cardDatabaseLoadingFailed, crumbDb, + [] { CrashReporter::addBreadcrumb("card_db", "card database loading failed"); }, Qt::DirectConnection); + } + qCInfo(MainLog) << "Starting main program"; // Front-load the card database before constructing the main window. The diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index 910e3f09d..bdd494646 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -70,16 +70,14 @@ bool CardsDisplaySettings::getScaleCards() const int CardsDisplaySettings::getHoverPanelTrigger() const { - // Off by default: the panel is new, so it is opt-in. - return getValue("hoverPanelTrigger", QString(), QString(), static_cast(HoverPanelTriggerOff)).toInt(); + return getValue("hoverPanelTrigger", QString(), QString(), static_cast(HoverPanelTriggerHover)).toInt(); } int CardsDisplaySettings::getHoverZoomTrigger() const { - // Off by default: the smooth hover zoom is opt-in. With no trigger configured, - // hovering keeps the historical 1.1x scale (AbstractCardItem::setHoverHighlighted) - // instead of the smooth zoom to the hover panel size. - return getValue("hoverZoomTrigger", QString(), QString(), static_cast(HoverPanelTriggerOff)).toInt(); + // Hover is the default: hovering first zooms the card, then the tab panel shows up. + // Either trigger can still be changed or turned off in the interface settings. + return getValue("hoverZoomTrigger", QString(), QString(), static_cast(HoverPanelTriggerHover)).toInt(); } bool CardsDisplaySettings::getHoverPanelDoubleConfirm() const diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index 59a513e8f..d8ced83a4 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -23,7 +23,7 @@ public: HoverPanelTriggerRightClick = 1, HoverPanelTriggerWheel = 2, HoverPanelTriggerShortcut = 3, - /// Never fires. The hover zoom and panel are opt-in, so this is their default. + /// Never fires when selected; lets the user turn the hover zoom and panel off. HoverPanelTriggerOff = 4 }; diff --git a/tests/crash_reporter_test.cpp b/tests/crash_reporter_test.cpp index e44a34a60..208812a4b 100644 --- a/tests/crash_reporter_test.cpp +++ b/tests/crash_reporter_test.cpp @@ -418,6 +418,16 @@ TEST(CrashReporterTest, BeginSessionEndSessionRoundTripReportsClean) reporter.endSession(); } +TEST(CrashReporterTest, AddBreadcrumbIsSafeWithoutInitAndWithNullArguments) +{ + // Breadcrumbs are emitted from GUI slots and QtConcurrent workers regardless of whether + // the SDK was initialised (empty DSN, Never consent, OFF builds); none of these may crash. + CrashReporter::addBreadcrumb("card_db", "card database snapshot swapped in"); + CrashReporter::addBreadcrumb(nullptr, "message"); + CrashReporter::addBreadcrumb("category", nullptr); + SUCCEED(); +} + } // namespace int main(int argc, char **argv) diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 4ca440567..acffae9a8 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -686,8 +686,7 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_Default) { CardsDisplaySettings s(settingsPath, nullptr); - // Off by default: the panel is opt-in on upgrade. - ASSERT_EQ(s.getHoverPanelTrigger(), CardsDisplaySettings::HoverPanelTriggerOff); + ASSERT_EQ(s.getHoverPanelTrigger(), CardsDisplaySettings::HoverPanelTriggerHover); } TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_SetAndGet) @@ -702,8 +701,7 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_SetAndGet) TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_Default) { CardsDisplaySettings s(settingsPath, nullptr); - // Off by default: the smooth hover zoom is opt-in on upgrade. - ASSERT_EQ(s.getHoverZoomTrigger(), CardsDisplaySettings::HoverPanelTriggerOff); + ASSERT_EQ(s.getHoverZoomTrigger(), CardsDisplaySettings::HoverPanelTriggerHover); } TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_SetAndGet)
    %1%2
    %1%2
    %1%2