From 44d513818110343a61bbbd9e478502f0b9bcb69c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:18:19 +0100 Subject: [PATCH 01/59] [VisualDatabase] Keep the card grid scrolling on macOS (#7764) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [VisualDatabase] Keep the card grid scrolling on macOS The view ignored the wheel whenever the vertical scroll bar was not visible. On macOS, scroll bars are transient: once the fade-out finishes, QScrollbarStyleAnimation hides the scroll bar widget, so isVisible() then reports false even though the grid has more rows to scroll. Scrolling was dead until something else brought the bar back. Gate on the scroll bar's range instead: it says whether there is anything to scroll, while visibility is only about presentation. An event with no range to scroll is still ignored, so an enclosing scroll area can take it. * Update card_database_visual_view.cpp --------- Co-authored-by: Lukas Brübach --- .../visual_database_display/card_database_visual_view.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp index 7b0d526f9..a8262c75a 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp @@ -130,8 +130,11 @@ void CardDatabaseVisualView::wheelEvent(QWheelEvent *event) return; } + // macOS uses transient (overlay) scroll bars that hide themselves + // once they fade out, so we need to use the range to determine if we ignore the wheel when there is nothing to + // scroll. This lets an enclosing scroll area take over. QScrollBar *bar = verticalScrollBar(); - if (!bar->isVisible()) { + if (bar->maximum() <= bar->minimum()) { event->ignore(); return; } From dd7ee3541c873beac3bf29df67835919c5657e0f Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:17:53 +0100 Subject: [PATCH 02/59] [PictureLoader] Keep the download log from going quadratic (#7766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [PictureLoader] Keep the download log from going quadratic A fast scrollbar sweep over the visual card database requests a picture for every cell it paints, so one gesture can ask for thousands of downloads. The status bar built a seven-widget log entry for each of them and answered every completion with findChildren() over the whole, unbounded log, rebuilding url.toString() per child: O(n) per completion, O(n^2) per burst. Profiling a sweep on master put 47% of all cycles on the GUI thread, with qt_qFindChildren_helper at 12.4% of the process (a quarter of that thread) and QWidgetPrivate::reparentFocusChildren at 4.1%. LagMonitor reported stalls of 3-18 s, growing with the backlog. Keep at most MAX_LOG_ENTRIES entries and index them by URL, so a completion and the 1 Hz cleanup only visit the matching entries. The progress bar follows the entries that remain: dropping a finished entry moves both counters back, dropping a pending one only its maximum. The log is a diagnostic view, not a queue - the downloads themselves are unaffected. * fixup! [PictureLoader] Keep the download log from going quadratic Make the URL index a QMultiHash and say why: a deferred retry re-queues the same URL while its first log entry is still pending, so one URL can back several live entries. --------- Co-authored-by: Lukas Brübach --- .../card_picture_loader_status_bar.cpp | 93 ++++++++++++++++--- .../card_picture_loader_status_bar.h | 30 ++++++ 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp index 3b26c8033..a731d067a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp @@ -11,6 +11,7 @@ #include #include #include +#include class ExactCard; @@ -38,32 +39,94 @@ void CardPictureLoaderStatusBar::cleanOldEntries() if (!loadLog || !loadLog->popup) { return; } - for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : - loadLog->popup->findChildren()) { + + const QDateTime now = QDateTime::currentDateTime(); + // Removing an entry mutates the log, so walk a copy of it. + const QList entries = logEntries; + for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : entries) { statusDisplayWidget->queryElapsedSeconds(); - if (statusDisplayWidget->getFinished() && - QDateTime::fromString(statusDisplayWidget->getStartTime()).secsTo(QDateTime::currentDateTime()) > 10) { - loadLog->removeSettingsWidget(statusDisplayWidget); - progressBar->setMaximum(progressBar->maximum() - 1); - progressBar->setValue(progressBar->value() - 1); + if (!statusDisplayWidget->getFinished()) { + continue; + } + if (QDateTime::fromString(statusDisplayWidget->getStartTime()).secsTo(now) > LOG_ENTRY_LIFETIME_SECS) { + removeLogEntry(statusDisplayWidget); } } } void CardPictureLoaderStatusBar::addQueuedImageLoad(const QUrl &url, const ExactCard &card, const QString &setName) { - loadLog->addSettingsWidget(new CardPictureLoaderRequestStatusDisplayWidget(loadLog, url, card, setName)); + if (logEntries.size() >= MAX_LOG_ENTRIES) { + trimLog(); + } + + auto *entry = new CardPictureLoaderRequestStatusDisplayWidget(loadLog, url, card, setName); + loadLog->addSettingsWidget(entry); + logEntries.append(entry); + entriesByUrl.insert(entry->getUrl(), entry); progressBar->setMaximum(progressBar->maximum() + 1); } void CardPictureLoaderStatusBar::addSuccessfulImageLoad(const QUrl &url) { - progressBar->setValue(progressBar->value() + 1); - for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : - loadLog->popup->findChildren()) { - if (statusDisplayWidget->getUrl() == url.toString()) { - statusDisplayWidget->queryElapsedSeconds(); - statusDisplayWidget->setFinished(); + const QList entries = entriesByUrl.values(url.toString()); + if (entries.isEmpty()) { + // The entry may already have been dropped from the log; the download + // itself still succeeded, there is just nothing left to mark. + return; + } + + int finishedEntries = 0; + for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : entries) { + if (statusDisplayWidget->getFinished()) { + continue; + } + statusDisplayWidget->queryElapsedSeconds(); + statusDisplayWidget->setFinished(); + ++finishedEntries; + } + + // The bar counts the entries currently in the log, so only entries that are + // still listed move it. + progressBar->setValue(progressBar->value() + finishedEntries); +} + +void CardPictureLoaderStatusBar::trimLog() +{ + if (logEntries.isEmpty()) { + return; + } + + // Prefer evicting an entry that has already finished; only fall back to an + // in-flight request when the whole log is still pending. + CardPictureLoaderRequestStatusDisplayWidget *toRemove = nullptr; + for (CardPictureLoaderRequestStatusDisplayWidget *entry : logEntries) { + if (entry->getFinished()) { + toRemove = entry; + break; } } -} \ No newline at end of file + + removeLogEntry(toRemove != nullptr ? toRemove : logEntries.constFirst()); +} + +void CardPictureLoaderStatusBar::removeLogEntry(CardPictureLoaderRequestStatusDisplayWidget *entry) +{ + if (entry == nullptr) { + return; + } + + logEntries.removeOne(entry); + entriesByUrl.remove(entry->getUrl(), entry); + + // The progress bar's maximum counts the entries currently in the log and + // its value the finished ones, so dropping an entry moves both counters back. + // Move the value first: a maximum of 0 turns the bar into a busy indicator and + // resets its value. + if (entry->getFinished()) { + progressBar->setValue(qMax(0, progressBar->value() - 1)); + } + progressBar->setMaximum(qMax(0, progressBar->maximum() - 1)); + + loadLog->removeSettingsWidget(entry); +} diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h index 2ba386c42..7f614e28f 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h @@ -1,10 +1,13 @@ #ifndef PICTURE_LOADER_STATUS_BAR_H #define PICTURE_LOADER_STATUS_BAR_H +#include +#include #include #include #include +class CardPictureLoaderRequestStatusDisplayWidget; class ExactCard; class QHBoxLayout; class QProgressBar; @@ -21,11 +24,20 @@ class SettingsButtonWidget; * - Shows overall progress of image downloads. * - Maintains a log of individual requests via a popup (SettingsButtonWidget). * - Cleans up finished request entries automatically after a delay. + * + * The log is a diagnostic view, not a queue: it keeps at most MAX_LOG_ENTRIES + * entries and indexes them by URL, so a burst of requests stays O(1) per event + * instead of scanning every entry the popup holds. */ class CardPictureLoaderStatusBar : public QWidget { Q_OBJECT public: + /** Entries kept in the log; older ones are evicted as new requests arrive. */ + static constexpr int MAX_LOG_ENTRIES = 50; + /** Seconds a finished entry stays in the log before it is cleaned up. */ + static constexpr int LOG_ENTRY_LIFETIME_SECS = 10; + /** * @brief Constructs a status bar with progress bar and log button. * @param parent Parent widget @@ -55,10 +67,28 @@ public slots: void cleanOldEntries(); private: + /** + * @brief Evicts the oldest finished entry (or the oldest entry if none finished) + * until the log fits within MAX_LOG_ENTRIES. + */ + void trimLog(); + + /** + * @brief Removes one entry from the log, its URL index and the popup, and keeps + * the progress bar counters in step with the entries that remain. + * @param entry Entry to remove + */ + void removeLogEntry(CardPictureLoaderRequestStatusDisplayWidget *entry); + QHBoxLayout *layout; ///< Horizontal layout containing progress bar and log button QProgressBar *progressBar; ///< Progress bar showing overall download progress SettingsButtonWidget *loadLog; ///< Popup log showing individual request statuses QTimer *cleaner; ///< Timer for periodically cleaning old log entries + QList logEntries; ///< Logged requests, oldest first + // A URL can back several live entries: a request parked for a deferred retry is re-queued while + // its first entry is still pending, so a completion must finish every entry for that URL. + QMultiHash + entriesByUrl; ///< Log entries indexed by their URL for O(1) completion lookup }; #endif // PICTURE_LOADER_STATUS_BAR_H 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 03/59] 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 04/59] [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 05/59] [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 06/59] [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 07/59] 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 08/59] 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 09/59] 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 10/59] 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 11/59] [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 12/59] 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 13/59] 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 14/59] [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 15/59] [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 16/59] [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 17/59] 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 18/59] [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 19/59] [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 20/59] [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 21/59] [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 22/59] [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 23/59] [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 24/59] [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 25/59] [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 26/59] [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 27/59] [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 28/59] [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 29/59] [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 30/59] [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 31/59] [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 32/59] [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 33/59] [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 34/59] [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 35/59] [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 36/59] [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 37/59] [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 38/59] 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 39/59] [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 40/59] 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 41/59] [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 42/59] [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 43/59] [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 44/59] 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 45/59] [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 46/59] [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 47/59] [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 48/59] [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 8e367f37ec4ca13f52447cb1c986b4a9012239c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 11:24:03 +0200 Subject: [PATCH 49/59] [Icons] Add SVG currentColor sentinel resolver 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. --- 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 9b640198ce510c5929d4b9b9259846abf71b47ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 11:32:16 +0200 Subject: [PATCH 50/59] [Icons] Add palette-aware themed icon engine 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. --- 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 fa5133071d3d1e1d77191067a8279078ede8435e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 11:41:26 +0200 Subject: [PATCH 51/59] [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. --- 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 | 42 +- .../src/interface/pixel_map_generator.h | 10 +- .../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 +- .../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 | 186 ++++ 95 files changed, 536 insertions(+), 9680 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..f024ba580 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,9 +1,9 @@ #include "pixel_map_generator.h" #include "libcockatrice/network/server/remote/user_level.h" +#include "theme_icon.h" #include "theme_manager.h" -#include #include #include #include @@ -15,7 +15,6 @@ #include #include #include -#include #include #include #include @@ -444,8 +443,11 @@ QPixmap LockPixmapGenerator::generatePixmap(int height) return pmCache.value(key); } - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), - QSize(height, height), true); + const QString resolved = themeManager->assetPath(QStringLiteral("icons/lock")); + QPixmap pixmap = loadSentinelSvgPixmap(resolved, QPalette::WindowText, QSize(height, height)); + if (pixmap.isNull()) { + pixmap = tryLoadImage(QStringLiteral("theme:") + resolved, QSize(height, height), true); + } pmCache.insert(key, pixmap); return pixmap; } @@ -459,9 +461,12 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) 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); + const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed"); + const QString resolved = themeManager->assetPath(prefix); + QPixmap pixmap = loadSentinelSvgPixmap(resolved, QPalette::WindowText, QSize(height, height)); + if (pixmap.isNull()) { + pixmap = tryLoadImage(QStringLiteral("theme:") + resolved, QSize(height, height), true); + } pmCache.insert(key, pixmap); return pixmap; @@ -520,30 +525,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() diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 7c01bb332..299f72609 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -160,13 +160,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/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..5d0c13e3f --- /dev/null +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -0,0 +1,186 @@ +#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}; + static QApplication app(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 ba05e2d0942698be804d6d032b373e13f2d08b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Fri, 23 Oct 2026 09:58:35 +0200 Subject: [PATCH 52/59] [Tests] Keep the icon render test QApplication alive until exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/theme_migration/theme_icon_render_test.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp index 5d0c13e3f..f12db5b16 100644 --- a/tests/theme_migration/theme_icon_render_test.cpp +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -28,7 +28,10 @@ void ensureGuiApplication() static int argc = 1; static char name[] = "theme_icon_test"; static char *argv[] = {name, nullptr}; - static QApplication app(argc, argv); + // 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); } From d9507182001cc3329d292a7b4a733f5755732496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 17:59:19 +0100 Subject: [PATCH 53/59] [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. --- .../src/interface/pixel_map_generator.cpp | 34 ++++--------------- .../src/interface/pixel_map_generator.h | 13 ------- .../interface/widgets/server/games_model.cpp | 8 +++-- .../interface/widgets/server/games_model.h | 2 ++ 4 files changed, 15 insertions(+), 42 deletions(-) diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index f024ba580..037293af9 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -4,6 +4,7 @@ #include "theme_icon.h" #include "theme_manager.h" +#include #include #include #include @@ -435,39 +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); - } - - const QString resolved = themeManager->assetPath(QStringLiteral("icons/lock")); - QPixmap pixmap = loadSentinelSvgPixmap(resolved, QPalette::WindowText, QSize(height, height)); - if (pixmap.isNull()) { - pixmap = tryLoadImage(QStringLiteral("theme:") + resolved, 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); } + // 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 QString resolved = themeManager->assetPath(prefix); - QPixmap pixmap = loadSentinelSvgPixmap(resolved, QPalette::WindowText, QSize(height, height)); - if (pixmap.isNull()) { - pixmap = tryLoadImage(QStringLiteral("theme:") + resolved, QSize(height, height), true); - } - + const QPixmap pixmap = loadThemedIcon(prefix).pixmap(QSize(height, height), qApp->devicePixelRatio()); pmCache.insert(key, pixmap); return pixmap; } @@ -539,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 299f72609..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: 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. */ From 8a933ea8f68115f99be1bb676daec1f6ad98dfe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 11:49:21 +0200 Subject: [PATCH 54/59] [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/CMakeLists.txt | 1 + tests/icon_assets/CMakeLists.txt | 3 + tests/icon_assets/icon-baseline.txt | 9 + tests/icon_assets/icon_assets_test.cpp | 293 +++++++++++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 tests/icon_assets/CMakeLists.txt create mode 100644 tests/icon_assets/icon-baseline.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-baseline.txt b/tests/icon_assets/icon-baseline.txt new file mode 100644 index 000000000..01a3f8f15 --- /dev/null +++ b/tests/icon_assets/icon-baseline.txt @@ -0,0 +1,9 @@ +# Icon rule baseline (ratchet). +# +# One violation per line: . +# Rules: xml, viewbox, editor-metadata, hardcoded-color, color-filename, no-token, physical-size, raster. +# +# This file may only shrink. When an icon is fixed, delete its line(s) in the +# same change; the test fails while a fixed entry remains. Never add new lines. +# +# The UI glyph set is fully migrated; this list is intentionally empty. diff --git a/tests/icon_assets/icon_assets_test.cpp b/tests/icon_assets/icon_assets_test.cpp new file mode 100644 index 000000000..93681d59c --- /dev/null +++ b/tests/icon_assets/icon_assets_test.cpp @@ -0,0 +1,293 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons"); +const QString BASELINE_RELATIVE_PATH = QStringLiteral("tests/icon_assets/icon-baseline.txt"); + +QString iconsDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH); +} + +QString baselinePath() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(BASELINE_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; +} + +/** @brief Parses the ratchet file; returns false with an error when it cannot be read. */ +bool readBaseline(QSet &entries, QString &error) +{ + QFile file(baselinePath()); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + error = QStringLiteral("Cannot read %1").arg(baselinePath()); + return false; + } + + const QStringList lines = QString::fromUtf8(file.readAll()).split(QLatin1Char('\n')); + for (const QString &line : lines) { + const QString trimmed = line.trimmed(); + if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char('#'))) { + continue; + } + entries.insert(trimmed); + } + return true; +} + +QString formatSet(const QSet &entries) +{ + QStringList lines(entries.begin(), entries.end()); + lines.sort(); + return lines.join(QLatin1Char('\n')); +} + +} // namespace + +/** + * @brief The icon ratchet: no new violations, and fixed icons must leave the baseline. + * + * Existing UI glyphs are grandfathered in tests/icon_assets/icon-baseline.txt. Fixing an icon + * without deleting its baseline lines fails on purpose, so the list can only shrink. + */ +TEST(IconAssets, BaselineRatchet) +{ + ASSERT_TRUE(QDir::isAbsolutePath(iconsDir())); + ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString(); + + QSet baseline; + QString error; + ASSERT_TRUE(readBaseline(baseline, error)) << error.toStdString(); + + const QSet actual = collectViolations(); + + const QSet newViolations = actual - baseline; + const QSet staleBaseline = baseline - actual; + + EXPECT_TRUE(newViolations.isEmpty()) << "New icon rule violations. Fix the icon; do not add exceptions:\n" + << formatSet(newViolations).toStdString(); + + EXPECT_TRUE(staleBaseline.isEmpty()) << "These baseline entries are fixed or gone; delete them from " + << BASELINE_RELATIVE_PATH.toStdString() << ":\n" + << formatSet(staleBaseline).toStdString(); +} + +/** @brief Guards against duplicate or malformed baseline entries. */ +TEST(IconAssets, BaselineIsWellFormed) +{ + QSet baseline; + QString error; + ASSERT_TRUE(readBaseline(baseline, error)) << error.toStdString(); + EXPECT_TRUE(baseline.isEmpty()) << "The legacy baseline is fully paid down; fix new violations instead"; + + static const QRegularExpression entryPattern(QStringLiteral( + R"(^(xml|viewbox|editor-metadata|hardcoded-color|color-filename|no-token|physical-size|raster) \S+$)")); + for (const QString &entry : baseline) { + EXPECT_TRUE(entryPattern.match(entry).hasMatch()) << "Malformed baseline entry: " << entry.toStdString(); + } +} From 1893dbeeb040faf0ed6b3389245ab2223dbcf827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 18:01:59 +0100 Subject: [PATCH 55/59] [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. --- tests/icon_assets/icon-baseline.txt | 9 ---- tests/icon_assets/icon_assets_test.cpp | 68 +++----------------------- 2 files changed, 8 insertions(+), 69 deletions(-) delete mode 100644 tests/icon_assets/icon-baseline.txt diff --git a/tests/icon_assets/icon-baseline.txt b/tests/icon_assets/icon-baseline.txt deleted file mode 100644 index 01a3f8f15..000000000 --- a/tests/icon_assets/icon-baseline.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Icon rule baseline (ratchet). -# -# One violation per line: . -# Rules: xml, viewbox, editor-metadata, hardcoded-color, color-filename, no-token, physical-size, raster. -# -# This file may only shrink. When an icon is fixed, delete its line(s) in the -# same change; the test fails while a fixed entry remains. Never add new lines. -# -# The UI glyph set is fully migrated; this list is intentionally empty. diff --git a/tests/icon_assets/icon_assets_test.cpp b/tests/icon_assets/icon_assets_test.cpp index 93681d59c..f0b06d5cc 100644 --- a/tests/icon_assets/icon_assets_test.cpp +++ b/tests/icon_assets/icon_assets_test.cpp @@ -13,18 +13,12 @@ namespace { const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons"); -const QString BASELINE_RELATIVE_PATH = QStringLiteral("tests/icon_assets/icon-baseline.txt"); QString iconsDir() { return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH); } -QString baselinePath() -{ - return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(BASELINE_RELATIVE_PATH); -} - /** @brief True for content art that is allowed to carry full color. */ bool isContentArt(const QString &relativePath) { @@ -220,26 +214,6 @@ QSet collectViolations() return violations; } -/** @brief Parses the ratchet file; returns false with an error when it cannot be read. */ -bool readBaseline(QSet &entries, QString &error) -{ - QFile file(baselinePath()); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - error = QStringLiteral("Cannot read %1").arg(baselinePath()); - return false; - } - - const QStringList lines = QString::fromUtf8(file.readAll()).split(QLatin1Char('\n')); - for (const QString &line : lines) { - const QString trimmed = line.trimmed(); - if (trimmed.isEmpty() || trimmed.startsWith(QLatin1Char('#'))) { - continue; - } - entries.insert(trimmed); - } - return true; -} - QString formatSet(const QSet &entries) { QStringList lines(entries.begin(), entries.end()); @@ -250,44 +224,18 @@ QString formatSet(const QSet &entries) } // namespace /** - * @brief The icon ratchet: no new violations, and fixed icons must leave the baseline. + * @brief Every UI glyph follows the palette-token rules. * - * Existing UI glyphs are grandfathered in tests/icon_assets/icon-baseline.txt. Fixing an icon - * without deleting its baseline lines fails on purpose, so the list can only shrink. + * 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, BaselineRatchet) +TEST(IconAssets, AssetsFollowTheRules) { ASSERT_TRUE(QDir::isAbsolutePath(iconsDir())); ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString(); - QSet baseline; - QString error; - ASSERT_TRUE(readBaseline(baseline, error)) << error.toStdString(); - - const QSet actual = collectViolations(); - - const QSet newViolations = actual - baseline; - const QSet staleBaseline = baseline - actual; - - EXPECT_TRUE(newViolations.isEmpty()) << "New icon rule violations. Fix the icon; do not add exceptions:\n" - << formatSet(newViolations).toStdString(); - - EXPECT_TRUE(staleBaseline.isEmpty()) << "These baseline entries are fixed or gone; delete them from " - << BASELINE_RELATIVE_PATH.toStdString() << ":\n" - << formatSet(staleBaseline).toStdString(); -} - -/** @brief Guards against duplicate or malformed baseline entries. */ -TEST(IconAssets, BaselineIsWellFormed) -{ - QSet baseline; - QString error; - ASSERT_TRUE(readBaseline(baseline, error)) << error.toStdString(); - EXPECT_TRUE(baseline.isEmpty()) << "The legacy baseline is fully paid down; fix new violations instead"; - - static const QRegularExpression entryPattern(QStringLiteral( - R"(^(xml|viewbox|editor-metadata|hardcoded-color|color-filename|no-token|physical-size|raster) \S+$)")); - for (const QString &entry : baseline) { - EXPECT_TRUE(entryPattern.match(entry).hasMatch()) << "Malformed baseline entry: " << entry.toStdString(); - } + const QSet violations = collectViolations(); + EXPECT_TRUE(violations.isEmpty()) << "Icon rule violations. Fix the icon; do not add exceptions:\n" + << formatSet(violations).toStdString(); } From f56e7d7bd6fafc57daa3f88b936f79f450a24834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 18:19:37 +0100 Subject: [PATCH 56/59] [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. --- .../src/client/settings/cache_settings.cpp | 28 ++++++++++++++++++- .../src/client/settings/cache_settings.h | 5 ++++ 2 files changed, 32 insertions(+), 1 deletion(-) 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(); From 7498dd29f0d1fdf3774b9d45aeab42a6b64d1b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 18:19:37 +0100 Subject: [PATCH 57/59] [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. --- .../game_graphics/log/message_log_widget.cpp | 32 +++++++++++++++++-- .../game_graphics/log/message_log_widget.h | 2 ++ 2 files changed, 32 insertions(+), 2 deletions(-) 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 b7be57464d4a228e4740932b158d343fb46cf808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 12:59:54 +0200 Subject: [PATCH 58/59] [PrintingSelector] Load the pin badge through the themed icon engine 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. --- .../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 4d96bdcbcf6b91c017be770d43c1a84c77a6a72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 20 Oct 2026 13:01:20 +0200 Subject: [PATCH 59/59] Refresh filter toolbar label icons on theme change The Sort by and Filter by labels kept a one-shot pixmap snapshot, so they stayed at the previous palette until the toolbar was rebuilt. Re-render both snapshots when the palette or theme changes. --- ...visual_database_display_filter_toolbar_widget.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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..1de9ff51e 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,7 @@ #include "visual_database_display_filter_toolbar_widget.h" #include "../../theme_icon.h" +#include "../../theme_manager.h" #include "../deck_editor/card_database_view.h" #include "../quick_settings/settings_button_widget.h" #include "card_database_display_model.h" @@ -48,10 +49,15 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg auto scalePixmap = [](const QString &prefix) { return loadThemedIcon(prefix).pixmap({20, 20}); }; sortByLabel = new QLabel(this); - sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down")); - filterByLabel = new QLabel(this); - filterByLabel->setPixmap(scalePixmap("icons/filter")); + + auto refreshStaticPixmaps = [this, scalePixmap] { + sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down")); + filterByLabel->setPixmap(scalePixmap("icons/filter")); + }; + refreshStaticPixmaps(); + connect(themeManager, &ThemeManager::paletteChanged, this, refreshStaticPixmaps); + connect(themeManager, &ThemeManager::themeChanged, this, refreshStaticPixmaps); sortColumnCombo = new QComboBox(this); sortColumnCombo->setSizeAdjustPolicy(QComboBox::SizeAdjustPolicy::AdjustToContents);