#include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.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" #include "../../../utility/completer_utils.h" #include "../../tab.h" #include "../../tab_supervisor.h" #include "api_response/archidekt_deck_listing_api_response.h" #include "api_response/deck/archidekt_api_response_deck.h" #include "card_database_display_model.h" #include "card_database_model.h" #include "display/archidekt_api_response_deck_display_widget.h" #include "display/archidekt_api_response_deck_listings_display_widget.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor), currentPage(1), isLoadingMore(false), isListMode(true) { // Initialize network networkManager = NetworkManagerFactory::create(this); // Initialize debounce timer searchDebounceTimer = new QTimer(this); searchDebounceTimer->setSingleShot(true); searchDebounceTimer->setInterval(300); connect(searchDebounceTimer, &QTimer::timeout, this, &TabArchidekt::doSearchImmediate); initializeUi(); setupFilterWidgets(); connectSignals(); retranslateUi(); 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 container = new QWidget(this); mainLayout = QtUtils::makeLayout(container, 0); // Primary toolbar (most important filters) primaryToolbar = new QWidget(container); primaryToolbar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); primaryToolbarLayout = new QHBoxLayout(primaryToolbar); primaryToolbarLayout->setContentsMargins(6, 6, 6, 6); primaryToolbarLayout->setSpacing(6); // Sort controls sortByLabel = new QLabel(primaryToolbar); orderByCombo = new QComboBox(primaryToolbar); orderByCombo->addItems({"name", "updatedAt", "createdAt", "viewCount", "size", "edhBracket"}); orderByCombo->setCurrentText("updatedAt"); orderByCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents); orderDirButton = new QPushButton(tr("Desc."), primaryToolbar); orderDirButton->setCheckable(true); orderDirButton->setChecked(true); orderDirButton->setFixedWidth(60); // Color filter (inline) QWidget *colorWidget = new QWidget(primaryToolbar); QHBoxLayout *colorLayout = QtUtils::makeLayout(colorWidget, 2); QString colorIdentity = "WUBRG"; for (const QChar &color : colorIdentity) { auto *manaSymbol = new ManaSymbolWidget(colorWidget, color, false, true); manaSymbol->setFixedSize(28, 28); colorSymbols.append(manaSymbol); colorLayout->addWidget(manaSymbol); connect(manaSymbol, &ManaSymbolWidget::colorToggled, this, [this](QChar c, bool active) { if (active) { activeColors.insert(c); } else { activeColors.remove(c); } doSearch(); }); } logicalAndCheck = new QCheckBox(tr("AND"), primaryToolbar); logicalAndCheck->setToolTip(tr("Require ALL selected colors")); // Common search fields nameField = new QLineEdit(primaryToolbar); nameField->setPlaceholderText(tr("Deck name...")); nameField->setMinimumWidth(150); ownerField = new QLineEdit(primaryToolbar); ownerField->setPlaceholderText(tr("Owner...")); ownerField->setMinimumWidth(120); // Filter by label filterByLabel = new QLabel(primaryToolbar); // Package toggle packagesCheck = new QCheckBox(tr("Packages"), primaryToolbar); // Search button searchButton = new QPushButton(tr("Search"), primaryToolbar); searchButton->setDefault(true); // Advanced filters toggle button advancedFiltersButton = new QPushButton(tr("Advanced Filters"), primaryToolbar); advancedFiltersButton->setCheckable(true); advancedFiltersButton->setChecked(false); // Settings settingsButton = new SettingsButtonWidget(primaryToolbar); cardSizeSlider = new CardSizeWidget(primaryToolbar, nullptr, SettingsCache::instance().cardsDisplay().getArchidektPreviewSize()); settingsButton->addSettingsWidget(cardSizeSlider); // Assemble primary toolbar primaryToolbarLayout->addWidget(sortByLabel); primaryToolbarLayout->addWidget(orderByCombo); primaryToolbarLayout->addWidget(orderDirButton); // Add separator/spacing primaryToolbarLayout->addSpacing(12); primaryToolbarLayout->addWidget(filterByLabel); primaryToolbarLayout->addWidget(colorWidget); primaryToolbarLayout->addWidget(logicalAndCheck); primaryToolbarLayout->addWidget(nameField, 1); primaryToolbarLayout->addWidget(ownerField, 1); primaryToolbarLayout->addWidget(packagesCheck); primaryToolbarLayout->addWidget(searchButton, 1); primaryToolbarLayout->addWidget(advancedFiltersButton); primaryToolbarLayout->addWidget(settingsButton); // Secondary toolbar (advanced filters - initially hidden) secondaryToolbar = new QWidget(container); secondaryToolbar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); secondaryToolbar->setVisible(false); // Start hidden secondaryToolbarLayout = new QHBoxLayout(secondaryToolbar); secondaryToolbarLayout->setContentsMargins(6, 3, 6, 6); secondaryToolbarLayout->setSpacing(6); // Scrollable results area scrollArea = QtUtils::makeScrollArea(container); resultsContainer = new QWidget(); resultsLayout = QtUtils::makeLayout(resultsContainer, 0); scrollArea->setWidget(resultsContainer); scrollArea->viewport()->installEventFilter(this); 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); } bool TabArchidekt::eventFilter(QObject *obj, QEvent *event) { if (obj == scrollArea->viewport() && event->type() == QEvent::Wheel) { auto *wheelEvent = static_cast(event); if (wheelEvent->angleDelta().y() < 0 && !isLoadingMore && isListMode) { loadNextPage(); wheelEvent->accept(); return false; // allow scrolling } } // Always pass the event to the parent to handle normal scrolling return QWidget::eventFilter(obj, event); } void TabArchidekt::setupFilterWidgets() { // Advanced filters (in secondary toolbar) // EDH Bracket bracketLabel = new QLabel(secondaryToolbar); edhBracketCombo = new QComboBox(secondaryToolbar); edhBracketCombo->addItem(QString()); edhBracketCombo->addItems({"1", "2", "3", "4", "5"}); edhBracketCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents); // Format filter (collapsible) formatButton = new SettingsButtonWidget(secondaryToolbar); formatButton->setButtonText(tr("Formats")); formatButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); QWidget *formatContainer = new QWidget(secondaryToolbar); QGridLayout *formatLayout = new QGridLayout(formatContainer); formatLayout->setContentsMargins(4, 4, 4, 4); QStringList formatNames = {"Standard", "Modern", "Commander", "Legacy", "Vintage", "Pauper", "Custom", "Frontier", "Future Std", "Penny Dreadful", "1v1 Commander", "Dual Commander", "Brawl"}; int row = 0, col = 0; for (const QString &formatName : formatNames) { auto *formatCheckBox = new QCheckBox(formatName, formatContainer); formatChecks << formatCheckBox; formatLayout->addWidget(formatCheckBox, row, col); connect(formatCheckBox, &QCheckBox::clicked, this, &TabArchidekt::doSearch); col++; if (col >= 3) { col = 0; row++; } } formatButton->addSettingsWidget(formatContainer); cardsField = new QLineEdit(secondaryToolbar); cardsField->setPlaceholderText(tr("Contains card...")); cardsField->setMinimumWidth(140); commandersField = new QLineEdit(secondaryToolbar); commandersField->setPlaceholderText(tr("Commander...")); commandersField->setMinimumWidth(140); deckTagNameField = new QLineEdit(secondaryToolbar); deckTagNameField->setPlaceholderText(tr("Tag...")); deckTagNameField->setMinimumWidth(100); // Deck size filter (collapsible) deckSizeButton = new SettingsButtonWidget(secondaryToolbar); deckSizeButton->setButtonText(tr("Deck Size")); QWidget *sizeContainer = new QWidget(secondaryToolbar); QHBoxLayout *sizeLayout = new QHBoxLayout(sizeContainer); sizeLayout->setContentsMargins(4, 4, 4, 4); minDeckSizeSpin = new QSpinBox(sizeContainer); minDeckSizeSpin->setRange(0, 200); minDeckSizeSpin->setValue(0); minDeckSizeLogicCombo = new QComboBox(sizeContainer); minDeckSizeLogicCombo->addItems({"Exact", "≥", "≤"}); minDeckSizeLogicCombo->setCurrentIndex(1); cardsLabel = new QLabel(sizeContainer); sizeLayout->addWidget(cardsLabel); sizeLayout->addWidget(minDeckSizeSpin); sizeLayout->addWidget(minDeckSizeLogicCombo); deckSizeButton->addSettingsWidget(sizeContainer); // Setup card name autocomplete auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); auto displayModel = new CardDatabaseDisplayModel(this); displayModel->setSourceModel(cardDatabaseModel); const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this); cardsField->setCompleter(cardSetup.completer); commandersField->setCompleter(cardSetup.completer); // Keep autocomplete working for both fields connectCardCompleterSearch(cardsField, cardSetup); connectCardCompleterSearch(commandersField, cardSetup); // Assemble secondary toolbar secondaryToolbarLayout->addWidget(bracketLabel); secondaryToolbarLayout->addWidget(edhBracketCombo); secondaryToolbarLayout->addWidget(formatButton); secondaryToolbarLayout->addWidget(cardsField); secondaryToolbarLayout->addWidget(commandersField); secondaryToolbarLayout->addWidget(deckTagNameField); secondaryToolbarLayout->addWidget(deckSizeButton); secondaryToolbarLayout->addStretch(); } void TabArchidekt::connectSignals() { // Advanced filters toggle connect(advancedFiltersButton, &QPushButton::clicked, this, [this](bool checked) { secondaryToolbar->setVisible(checked); }); // These trigger immediate search (no debounce needed) connect(orderByCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); connect(orderDirButton, &QPushButton::clicked, [this](bool checked) { orderDirButton->setText(checked ? tr("Desc.") : tr("Asc.")); doSearch(); }); connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setArchidektPreviewCardSize); // Search button triggers immediate search connect(searchButton, &QPushButton::clicked, this, &TabArchidekt::doSearchImmediate); // These trigger search (but not text fields) connect(logicalAndCheck, &QCheckBox::clicked, this, &TabArchidekt::doSearch); connect(packagesCheck, &QCheckBox::clicked, [this]() { updatePackageModeState(packagesCheck->isChecked()); doSearch(); }); // Format filters trigger search connect(edhBracketCombo, &QComboBox::currentIndexChanged, this, &TabArchidekt::doSearch); connect(minDeckSizeSpin, qOverload(&QSpinBox::valueChanged), this, &TabArchidekt::doSearch); connect(minDeckSizeLogicCombo, &QComboBox::currentIndexChanged, this, &TabArchidekt::doSearch); // Allow Enter key in text fields to trigger search connect(nameField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate); connect(ownerField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate); connect(cardsField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate); connect(commandersField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate); connect(deckTagNameField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate); // Format checkboxes trigger search for (auto *formatCheck : formatChecks) { connect(formatCheck, &QCheckBox::clicked, this, &TabArchidekt::doSearch); } } void TabArchidekt::updatePackageModeState(bool isPackageMode) { // Disable format-specific and commander-specific filters in package mode for (auto *cb : formatChecks) { cb->setEnabled(!isPackageMode); } edhBracketCombo->setEnabled(!isPackageMode); if (isPackageMode) { edhBracketCombo->setCurrentIndex(0); } commandersField->setEnabled(!isPackageMode); deckTagNameField->setEnabled(!isPackageMode); } void TabArchidekt::retranslateUi() { sortByLabel->setText(tr("Sort by:")); orderDirButton->setText(orderDirButton->isChecked() ? tr("Desc.") : tr("Asc.")); filterByLabel->setText(tr("Filter by:")); logicalAndCheck->setText(tr("AND")); logicalAndCheck->setToolTip(tr("Require ALL selected colors")); nameField->setPlaceholderText(tr("Deck name...")); ownerField->setPlaceholderText(tr("Owner...")); packagesCheck->setText(tr("Packages")); advancedFiltersButton->setText(tr("Advanced Filters")); cardsField->setPlaceholderText(tr("Contains card...")); commandersField->setPlaceholderText(tr("Commander...")); deckTagNameField->setPlaceholderText(tr("Tag...")); bracketLabel->setText(tr("Bracket:")); // Index 0 is the "Any" placeholder added in setupFilterWidgets() edhBracketCombo->setItemText(0, tr("Any")); formatButton->setButtonText(tr("Formats")); deckSizeButton->setButtonText(tr("Deck Size")); cardsLabel->setText(tr("Cards:")); minDeckSizeSpin->setSpecialValueText(tr("Any")); minDeckSizeLogicCombo->setItemText(0, tr("Exact")); searchButton->setText(tr("Search")); retryButton->setText(tr("Retry")); settingsButton->setToolTip(tr("Display Settings")); } QString TabArchidekt::buildSearchUrl() { QUrlQuery query; // orderBy (field + direction) QString field = orderByCombo->currentText(); if (!field.isEmpty()) { bool desc = orderDirButton->isChecked(); QString final = desc ? "-" + field : field; query.addQueryItem("orderBy", final); } // Colors QStringList selectedColors; for (QChar c : activeColors) { selectedColors.append(c); } if (!selectedColors.isEmpty()) { query.addQueryItem("colors", selectedColors.join(",")); } // logicalAnd if (logicalAndCheck->isChecked()) { query.addQueryItem("logicalAnd", "true"); } // Formats (disabled in package mode) if (!packagesCheck->isChecked()) { QStringList formatIds; for (int i = 0; i < formatChecks.size(); ++i) { if (formatChecks[i]->isChecked()) { formatIds << QString::number(i + 1); } } if (!formatIds.isEmpty()) { query.addQueryItem("deckFormat", formatIds.join(",")); } // edhBracket if (edhBracketCombo->currentIndex() > 0) { query.addQueryItem("edhBracket", edhBracketCombo->currentText()); } } // Package mode if (packagesCheck->isChecked()) { query.addQueryItem("packages", "true"); } // Name if (!nameField->text().isEmpty()) { query.addQueryItem("name", nameField->text()); } // Owner if (!ownerField->text().isEmpty()) { query.addQueryItem("ownerUsername", ownerField->text()); } // Cards if (!cardsField->text().isEmpty()) { query.addQueryItem("cards", cardsField->text()); } // Commander (disabled in package mode) if (!packagesCheck->isChecked() && !commandersField->text().isEmpty()) { query.addQueryItem("commanderName", commandersField->text()); } // Deck tag (disabled in package mode) if (!packagesCheck->isChecked() && !deckTagNameField->text().isEmpty()) { query.addQueryItem("deckTagName", deckTagNameField->text()); } // Page number (for infinite scroll) query.addQueryItem("page", QString::number(currentPage)); // Min deck size if (minDeckSizeSpin->value() != 0) { query.addQueryItem("size", QString::number(minDeckSizeSpin->value())); QString logic = "GTE"; // Index order matches setupFilterWidgets(): 0 = Exact, 1 = ≥, 2 = ≤ switch (minDeckSizeLogicCombo->currentIndex()) { case 1: logic = "GTE"; break; case 2: logic = "LTE"; break; default: logic = ""; break; } if (!logic.isEmpty()) { query.addQueryItem("sizeLogic", logic); } } QUrl url("https://archidekt.com/api/decks/v3/"); url.setQuery(query); return url.toString(); } void TabArchidekt::doSearch() { // Don't debounce - only called by explicit user actions now doSearchImmediate(); } void TabArchidekt::doSearchImmediate() { searchDebounceTimer->stop(); currentPage = 1; startRequest(QUrl(buildSearchUrl()), RequestKind::Search); } void TabArchidekt::loadNextPage() { if (activeReply || !isListMode || !listingsWidget || nextPageUrl.isEmpty()) { return; } // 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) { searchDebounceTimer->stop(); startRequest(QUrl(url), RequestKind::Deck); } void TabArchidekt::getTopDecks() { doSearchImmediate(); } void TabArchidekt::processApiJson(QNetworkReply *reply) { 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(); QJsonParseError parseError; QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData, &parseError); if (parseError.error != QJsonParseError::NoError || !jsonDoc.isObject()) { showRequestError(tr("Invalid response from Archidekt.")); return; } QJsonObject jsonObj = jsonDoc.object(); 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 (jsonObj.value("id").toInt() <= 0 || !jsonObj.value("cards").isArray()) { showRequestError(tr("Invalid deck from Archidekt.")); return; } requestStatusLabel->hide(); processDeckResponse(jsonObj); } } 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 (requestKind == RequestKind::Search) { listingsWidget = nullptr; QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider); connect(listingsWidget, &ArchidektApiResponseDeckListingsDisplayWidget::requestNavigation, this, &TabArchidekt::actNavigatePage); resultsLayout->addWidget(listingsWidget); return; } // Infinite scroll → append if (listingsWidget) { listingsWidget->append(deckData); } } void TabArchidekt::processDeckResponse(QJsonObject reply) { ArchidektApiResponseDeck deckData; deckData.fromJson(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); connect(display, &ArchidektApiResponseDeckDisplayWidget::requestNavigation, this, &TabArchidekt::actNavigatePage); connect(display, &ArchidektApiResponseDeckDisplayWidget::requestSearch, this, &TabArchidekt::doSearch); connect(display, &ArchidektApiResponseDeckDisplayWidget::openInDeckEditor, tabSupervisor, &TabSupervisor::openDeckInNewTab); resultsLayout->addWidget(display); } void TabArchidekt::prettyPrintJson(const QJsonValue &value, int indentLevel) { const QString indent(indentLevel * 2, ' '); if (value.isObject()) { QJsonObject obj = value.toObject(); for (auto it = obj.begin(); it != obj.end(); ++it) { qInfo().noquote() << indent + it.key() + ":"; prettyPrintJson(it.value(), indentLevel + 1); } } else if (value.isArray()) { QJsonArray array = value.toArray(); for (int i = 0; i < array.size(); ++i) { qInfo().noquote() << indent + QString("[%1]:").arg(i); prettyPrintJson(array[i], indentLevel + 1); } } else if (value.isString()) { qInfo().noquote() << indent + "\"" + value.toString() + "\""; } else if (value.isDouble()) { qInfo().noquote() << indent + QString::number(value.toDouble()); } else if (value.isBool()) { qInfo().noquote() << indent + (value.toBool() ? "true" : "false"); } else if (value.isNull()) { qInfo().noquote() << indent + "null"; } }