[TabArchidekt] Cleaner filters, infinite scrolling, and a "go back button" (#6545)

* [TabArchidekt] Cleaner filters, infinite scrolling, and a "go back button"

Took 46 minutes

Took 5 seconds

* Fix infinite scroll triggering in detail view.

Took 25 minutes

Took 3 seconds

* Use setLabelText() so it's white

Took 2 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-01-24 11:21:12 +01:00 committed by GitHub
parent 3c48d92663
commit 12b5525a2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 534 additions and 354 deletions

View file

@ -20,12 +20,22 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi
layout = new QVBoxLayout(this); layout = new QVBoxLayout(this);
setLayout(layout); setLayout(layout);
openInEditorButton = new QPushButton(this); navigationContainer = new QWidget(this);
layout->addWidget(openInEditorButton); navigationContainerLayout = new QHBoxLayout(navigationContainer);
homeButton = new QPushButton(navigationContainer);
navigationContainerLayout->addWidget(homeButton);
connect(homeButton, &QPushButton::clicked, this, &ArchidektApiResponseDeckDisplayWidget::requestSearch);
openInEditorButton = new QPushButton(navigationContainer);
navigationContainerLayout->addWidget(openInEditorButton);
connect(openInEditorButton, &QPushButton::clicked, this, connect(openInEditorButton, &QPushButton::clicked, this,
&ArchidektApiResponseDeckDisplayWidget::actOpenInDeckEditor); &ArchidektApiResponseDeckDisplayWidget::actOpenInDeckEditor);
layout->addWidget(navigationContainer);
displayOptionsWidget = new VisualDeckDisplayOptionsWidget(this); displayOptionsWidget = new VisualDeckDisplayOptionsWidget(this);
layout->addWidget(displayOptionsWidget); layout->addWidget(displayOptionsWidget);
@ -80,6 +90,7 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi
void ArchidektApiResponseDeckDisplayWidget::retranslateUi() void ArchidektApiResponseDeckDisplayWidget::retranslateUi()
{ {
homeButton->setText(tr("Back to results"));
openInEditorButton->setText(tr("Open Deck in Deck Editor")); openInEditorButton->setText(tr("Open Deck in Deck Editor"));
} }

View file

@ -49,6 +49,7 @@ signals:
* @param url URL of the deck on Archidekt. * @param url URL of the deck on Archidekt.
*/ */
void requestNavigation(QString url); void requestNavigation(QString url);
void requestSearch();
/** /**
* @brief Emitted when the deck should be opened in the deck editor. * @brief Emitted when the deck should be opened in the deck editor.
@ -102,9 +103,12 @@ private slots:
void onGroupCriteriaChange(const QString &activeGroupCriteria); void onGroupCriteriaChange(const QString &activeGroupCriteria);
private: private:
ArchidektApiResponseDeck response; ///< API deck data container ArchidektApiResponseDeck response; ///< API deck data container
CardSizeWidget *cardSizeSlider; ///< Slider for adjusting card sizes CardSizeWidget *cardSizeSlider; ///< Slider for adjusting card sizes
QVBoxLayout *layout; ///< Main vertical layout QVBoxLayout *layout; ///< Main vertical layout
QWidget *navigationContainer;
QHBoxLayout *navigationContainerLayout;
QPushButton *homeButton;
QPushButton *openInEditorButton; ///< Button to open deck in editor QPushButton *openInEditorButton; ///< Button to open deck in editor
VisualDeckDisplayOptionsWidget *displayOptionsWidget; ///< Controls grouping/sorting/display VisualDeckDisplayOptionsWidget *displayOptionsWidget; ///< Controls grouping/sorting/display
QScrollArea *scrollArea; ///< Scrollable area for deck zones QScrollArea *scrollArea; ///< Scrollable area for deck zones

View file

@ -213,7 +213,7 @@ void ArchidektApiResponseDeckEntryDisplayWidget::updateScaledPreview()
int textMaxWidth = int(newWidth * 0.7); // allow 70% of width for text int textMaxWidth = int(newWidth * 0.7); // allow 70% of width for text
QFontMetrics fm(previewWidget->topLeftLabel->font()); QFontMetrics fm(previewWidget->topLeftLabel->font());
QString elided = fm.elidedText(response.getName(), Qt::ElideRight, textMaxWidth); QString elided = fm.elidedText(response.getName(), Qt::ElideRight, textMaxWidth);
previewWidget->topLeftLabel->setText(elided); previewWidget->topLeftLabel->setLabelText(elided);
previewWidget->topLeftLabel->setToolTip(response.getName()); previewWidget->topLeftLabel->setToolTip(response.getName());
setFixedWidth(newWidth); setFixedWidth(newWidth);

View file

@ -35,6 +35,20 @@ ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsD
layout->addWidget(flowWidget); layout->addWidget(flowWidget);
} }
void ArchidektApiResponseDeckListingsDisplayWidget::append(const ArchidektDeckListingApiResponse &data)
{
for (const auto &deckListing : data.results) {
auto cardListDisplayWidget =
new ArchidektApiResponseDeckEntryDisplayWidget(this, deckListing, imageNetworkManager);
cardListDisplayWidget->setScaleFactor(cardSizeSlider->getSlider()->value());
connect(cardListDisplayWidget, &ArchidektApiResponseDeckEntryDisplayWidget::requestNavigation, this,
&ArchidektApiResponseDeckListingsDisplayWidget::requestNavigation);
connect(cardSizeSlider->getSlider(), &QSlider::valueChanged, cardListDisplayWidget,
&ArchidektApiResponseDeckEntryDisplayWidget::setScaleFactor);
flowWidget->addWidget(cardListDisplayWidget);
}
}
void ArchidektApiResponseDeckListingsDisplayWidget::resizeEvent(QResizeEvent *event) void ArchidektApiResponseDeckListingsDisplayWidget::resizeEvent(QResizeEvent *event)
{ {
QWidget::resizeEvent(event); QWidget::resizeEvent(event);

View file

@ -69,6 +69,7 @@ public:
explicit ArchidektApiResponseDeckListingsDisplayWidget(QWidget *parent, explicit ArchidektApiResponseDeckListingsDisplayWidget(QWidget *parent,
ArchidektDeckListingApiResponse response, ArchidektDeckListingApiResponse response,
CardSizeWidget *cardSizeSlider); CardSizeWidget *cardSizeSlider);
void append(const ArchidektDeckListingApiResponse &data);
/** /**
* @brief Ensures FlowWidget layout properly recomputes on resize. * @brief Ensures FlowWidget layout properly recomputes on resize.

View file

@ -9,6 +9,9 @@
#include <QCompleter> #include <QCompleter>
#include <QDebug> #include <QDebug>
#include <QFormLayout>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
@ -18,129 +21,258 @@
#include <QPushButton> #include <QPushButton>
#include <QRegularExpression> #include <QRegularExpression>
#include <QResizeEvent> #include <QResizeEvent>
#include <QScrollArea>
#include <QScrollBar>
#include <QUrlQuery> #include <QUrlQuery>
#include <libcockatrice/card/database/card_database_manager.h> #include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/models/database/card/card_completer_proxy_model.h> #include <libcockatrice/models/database/card/card_completer_proxy_model.h>
#include <libcockatrice/models/database/card/card_search_model.h> #include <libcockatrice/models/database/card/card_search_model.h>
#include <version_string.h> #include <version_string.h>
TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor) TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor)
: Tab(_tabSupervisor), currentPage(1), isLoadingMore(false), isListMode(true)
{ {
// Initialize network
networkManager = new QNetworkAccessManager(this); networkManager = new QNetworkAccessManager(this);
networkManager->setTransferTimeout(); // Use Qt's default timeout networkManager->setTransferTimeout();
networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy);
connect(networkManager, SIGNAL(finished(QNetworkReply *)), this, SLOT(processApiJson(QNetworkReply *))); connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson);
// Initialize debounce timer
searchDebounceTimer = new QTimer(this); searchDebounceTimer = new QTimer(this);
searchDebounceTimer->setSingleShot(true); // We only want it to fire once after inactivity searchDebounceTimer->setSingleShot(true);
searchDebounceTimer->setInterval(300); // 300ms debounce searchDebounceTimer->setInterval(300);
connect(searchDebounceTimer, &QTimer::timeout, this, &TabArchidekt::doSearchImmediate);
connect(searchDebounceTimer, &QTimer::timeout, this, [this]() { doSearchImmediate(); }); initializeUi();
setupFilterWidgets();
connectSignals();
retranslateUi();
getTopDecks();
}
void TabArchidekt::initializeUi()
{
// Main container
container = new QWidget(this); container = new QWidget(this);
mainLayout = new QVBoxLayout(container); mainLayout = new QVBoxLayout(container);
mainLayout->setContentsMargins(0, 0, 0, 0); mainLayout->setContentsMargins(0, 0, 0, 0);
container->setLayout(mainLayout); mainLayout->setSpacing(0);
navigationContainer = new QWidget(container); // Primary toolbar (most important filters)
navigationContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Minimum); primaryToolbar = new QWidget(container);
navigationLayout = new QHBoxLayout(navigationContainer); primaryToolbar->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
navigationLayout->setSpacing(3); primaryToolbarLayout = new QHBoxLayout(primaryToolbar);
navigationContainer->setLayout(navigationLayout); primaryToolbarLayout->setContentsMargins(6, 6, 6, 6);
primaryToolbarLayout->setSpacing(6);
// Sort by // Sort controls
sortByLabel = new QLabel(primaryToolbar);
orderByCombo = new QComboBox(navigationContainer); orderByCombo = new QComboBox(primaryToolbar);
orderByCombo->addItems({"name", "updatedAt", "createdAt", "viewCount", "size", "edhBracket"}); orderByCombo->addItems({"name", "updatedAt", "createdAt", "viewCount", "size", "edhBracket"});
orderByCombo->setCurrentText("updatedAt"); // Pre-select updatedAt orderByCombo->setCurrentText("updatedAt");
orderByCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
// Asc/Desc toggle orderDirButton = new QPushButton(tr("Desc."), primaryToolbar);
orderDirButton = new QPushButton(tr("Desc."), navigationContainer); orderDirButton->setCheckable(true);
orderDirButton->setCheckable(true); // checked = DESC, unchecked = ASC
orderDirButton->setChecked(true); orderDirButton->setChecked(true);
orderDirButton->setFixedWidth(60);
connect(orderByCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); // Color filter (inline)
connect(orderDirButton, &QPushButton::clicked, this, [this](bool checked) { QWidget *colorWidget = new QWidget(primaryToolbar);
orderDirButton->setText(checked ? tr("Desc.") : tr("Asc.")); QHBoxLayout *colorLayout = new QHBoxLayout(colorWidget);
doSearch(); colorLayout->setContentsMargins(0, 0, 0, 0);
}); colorLayout->setSpacing(2);
// Colors
QHBoxLayout *colorLayout = new QHBoxLayout();
QString colorIdentity = "WUBRG"; // Optionally include "C" for colorless once we have a symbol for it
QString colorIdentity = "WUBRG";
for (const QChar &color : colorIdentity) { for (const QChar &color : colorIdentity) {
auto *manaSymbol = new ManaSymbolWidget(navigationContainer, color, false, true); auto *manaSymbol = new ManaSymbolWidget(colorWidget, color, false, true);
manaSymbol->setFixedWidth(25); manaSymbol->setFixedSize(28, 28);
colorSymbols.append(manaSymbol);
colorLayout->addWidget(manaSymbol); colorLayout->addWidget(manaSymbol);
connect(manaSymbol, &ManaSymbolWidget::colorToggled, this, [this](QChar c, bool active) { connect(manaSymbol, &ManaSymbolWidget::colorToggled, this, [this](QChar c, bool active) {
if (active) { if (active)
activeColors.insert(c); activeColors.insert(c);
} else { else
activeColors.remove(c); activeColors.remove(c);
}
doSearch(); doSearch();
}); });
} }
logicalAndCheck = new QCheckBox("Require ALL colors", navigationContainer); logicalAndCheck = new QCheckBox(tr("AND"), primaryToolbar);
logicalAndCheck->setToolTip(tr("Require ALL selected colors"));
// Formats // Common search fields
nameField = new QLineEdit(primaryToolbar);
nameField->setPlaceholderText(tr("Deck name..."));
nameField->setMinimumWidth(150);
formatLabel = new QLabel(this); ownerField = new QLineEdit(primaryToolbar);
ownerField->setPlaceholderText(tr("Owner..."));
ownerField->setMinimumWidth(120);
formatSettingsWidget = new SettingsButtonWidget(this); // 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().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 = new QScrollArea(container);
scrollArea->setWidgetResizable(true);
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
resultsContainer = new QWidget();
resultsLayout = new QVBoxLayout(resultsContainer);
resultsLayout->setContentsMargins(0, 0, 0, 0);
resultsLayout->setSpacing(0);
scrollArea->setWidget(resultsContainer);
scrollArea->viewport()->installEventFilter(this);
mainLayout->addWidget(primaryToolbar);
mainLayout->addWidget(secondaryToolbar);
mainLayout->addWidget(scrollArea);
setCentralWidget(container);
}
bool TabArchidekt::eventFilter(QObject *obj, QEvent *event)
{
if (obj == scrollArea->viewport() && event->type() == QEvent::Wheel) {
auto *wheelEvent = static_cast<QWheelEvent *>(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
auto *bracketLabel = new QLabel(tr("Bracket:"), secondaryToolbar);
edhBracketCombo = new QComboBox(secondaryToolbar);
edhBracketCombo->addItem(tr("Any"));
edhBracketCombo->addItems({"1", "2", "3", "4", "5"});
edhBracketCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
// Format filter (collapsible)
formatButton = new SettingsButtonWidget(secondaryToolbar);
formatButton->setButtonText(tr("Formats"));
formatButton->setButtonIcon(QPixmap("theme:icons/scale_balanced"));
QWidget *formatContainer = new QWidget(secondaryToolbar);
QGridLayout *formatLayout = new QGridLayout(formatContainer);
formatLayout->setContentsMargins(4, 4, 4, 4);
QStringList formatNames = {"Standard", "Modern", "Commander", "Legacy", "Vintage", QStringList formatNames = {"Standard", "Modern", "Commander", "Legacy", "Vintage",
"Pauper", "Custom", "Frontier", "Future Std", "Penny Dreadful", "Pauper", "Custom", "Frontier", "Future Std", "Penny Dreadful",
"1v1 Commander", "Dual Commander", "Brawl"}; "1v1 Commander", "Dual Commander", "Brawl"};
for (int i = 0; i < formatNames.size(); ++i) { int row = 0, col = 0;
QCheckBox *formatCheckBox = new QCheckBox(formatNames[i], navigationContainer); for (const QString &formatName : formatNames) {
connect(formatCheckBox, &QCheckBox::clicked, this, &TabArchidekt::doSearch); auto *formatCheckBox = new QCheckBox(formatName, formatContainer);
formatChecks << formatCheckBox; formatChecks << formatCheckBox;
formatSettingsWidget->addSettingsWidget(formatCheckBox); formatLayout->addWidget(formatCheckBox, row, col);
connect(formatCheckBox, &QCheckBox::clicked, this, &TabArchidekt::doSearch);
col++;
if (col >= 3) {
col = 0;
row++;
}
} }
// EDH Bracket formatButton->addSettingsWidget(formatContainer);
edhBracketCombo = new QComboBox(navigationContainer);
edhBracketCombo->addItem(tr("Any Bracket"));
edhBracketCombo->addItems({"1", "2", "3", "4", "5"});
connect(edhBracketCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); cardsField = new QLineEdit(secondaryToolbar);
cardsField->setPlaceholderText(tr("Contains card..."));
cardsField->setMinimumWidth(140);
// Search for Card Packages instead of Decks commandersField = new QLineEdit(secondaryToolbar);
packagesCheck = new QCheckBox("Packages", navigationContainer); commandersField->setPlaceholderText(tr("Commander..."));
commandersField->setMinimumWidth(140);
connect(packagesCheck, &QCheckBox::clicked, this, [this]() { deckTagNameField = new QLineEdit(secondaryToolbar);
bool disable = packagesCheck->isChecked(); deckTagNameField->setPlaceholderText(tr("Tag..."));
for (auto *cb : formatChecks) deckTagNameField->setMinimumWidth(100);
cb->setEnabled(!disable);
commandersField->setEnabled(!disable);
deckTagNameField->setEnabled(!disable);
edhBracketCombo->setCurrentIndex(0);
edhBracketCombo->setEnabled(!disable);
doSearch();
});
// Deck Name // Deck size filter (collapsible)
nameField = new QLineEdit(navigationContainer); deckSizeButton = new SettingsButtonWidget(secondaryToolbar);
nameField->setPlaceholderText(tr("Deck name contains...")); deckSizeButton->setButtonText(tr("Deck Size"));
// Owner Name QWidget *sizeContainer = new QWidget(secondaryToolbar);
ownerField = new QLineEdit(navigationContainer); QHBoxLayout *sizeLayout = new QHBoxLayout(sizeContainer);
ownerField->setPlaceholderText(tr("Owner name contains...")); sizeLayout->setContentsMargins(4, 4, 4, 4);
// Contained cards minDeckSizeSpin = new QSpinBox(sizeContainer);
cardsField = new QLineEdit(navigationContainer); minDeckSizeSpin->setSpecialValueText(tr("Any"));
cardsField->setPlaceholderText("Deck contains card..."); minDeckSizeSpin->setRange(0, 200);
minDeckSizeSpin->setValue(0);
// Commanders minDeckSizeLogicCombo = new QComboBox(sizeContainer);
commandersField = new QLineEdit(navigationContainer); minDeckSizeLogicCombo->addItems({"Exact", "", ""});
commandersField->setPlaceholderText("Deck has commander..."); minDeckSizeLogicCombo->setCurrentIndex(1);
// DB supplemented card search sizeLayout->addWidget(new QLabel(tr("Cards:"), sizeContainer));
sizeLayout->addWidget(minDeckSizeSpin);
sizeLayout->addWidget(minDeckSizeLogicCombo);
deckSizeButton->addSettingsWidget(sizeContainer);
// Setup card name autocomplete
auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
auto displayModel = new CardDatabaseDisplayModel(this); auto displayModel = new CardDatabaseDisplayModel(this);
displayModel->setSourceModel(cardDatabaseModel); displayModel->setSourceModel(cardDatabaseModel);
@ -161,144 +293,119 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor)
cardsField->setCompleter(completer); cardsField->setCompleter(completer);
commandersField->setCompleter(completer); commandersField->setCompleter(completer);
connect(cardsField, &QLineEdit::textChanged, searchModel, &CardSearchModel::updateSearchResults); // Keep autocomplete working for both fields
connect(cardsField, &QLineEdit::textChanged, this, [=](const QString &text) { connect(cardsField, &QLineEdit::textChanged, this, [=](const QString &text) {
searchModel->updateSearchResults(text);
QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) if (!text.isEmpty())
completer->complete(); completer->complete();
}); });
connect(commandersField, &QLineEdit::textChanged, searchModel, &CardSearchModel::updateSearchResults);
connect(commandersField, &QLineEdit::textChanged, this, [=](const QString &text) { connect(commandersField, &QLineEdit::textChanged, this, [=](const QString &text) {
searchModel->updateSearchResults(text);
QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) if (!text.isEmpty())
completer->complete(); completer->complete();
}); });
// Tag Name // Assemble secondary toolbar
deckTagNameField = new QLineEdit(navigationContainer); secondaryToolbarLayout->addWidget(bracketLabel);
deckTagNameField->setPlaceholderText("Deck tag"); secondaryToolbarLayout->addWidget(edhBracketCombo);
secondaryToolbarLayout->addWidget(formatButton);
secondaryToolbarLayout->addWidget(cardsField);
secondaryToolbarLayout->addWidget(commandersField);
secondaryToolbarLayout->addWidget(deckTagNameField);
secondaryToolbarLayout->addWidget(deckSizeButton);
secondaryToolbarLayout->addStretch();
}
connect(deckTagNameField, &QLineEdit::textChanged, this, &TabArchidekt::doSearch); void TabArchidekt::connectSignals()
{
// Advanced filters toggle
connect(advancedFiltersButton, &QPushButton::clicked, this,
[this](bool checked) { secondaryToolbar->setVisible(checked); });
// Search button // These trigger immediate search (no debounce needed)
searchPushButton = new QPushButton(navigationContainer); connect(orderByCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch);
searchPushButton->setText("Search"); connect(orderDirButton, &QPushButton::clicked, [this](bool checked) {
orderDirButton->setText(checked ? tr("Desc.") : tr("Asc."));
doSearch();
});
connect(searchPushButton, &QPushButton::clicked, this, &TabArchidekt::doSearch);
// Card Size settings
settingsButton = new SettingsButtonWidget(this);
cardSizeSlider = new CardSizeWidget(this, nullptr, SettingsCache::instance().getArchidektPreviewSize());
connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(),
&SettingsCache::setArchidektPreviewCardSize); &SettingsCache::setArchidektPreviewCardSize);
settingsButton->addSettingsWidget(cardSizeSlider);
// Min deck size // Search button triggers immediate search
minDeckSizeLabel = new QLabel(navigationContainer); connect(searchButton, &QPushButton::clicked, this, &TabArchidekt::doSearchImmediate);
minDeckSizeSpin = new QSpinBox(navigationContainer); // These trigger search (but not text fields)
minDeckSizeSpin->setSpecialValueText(tr("Disabled")); connect(logicalAndCheck, &QCheckBox::clicked, this, &TabArchidekt::doSearch);
minDeckSizeSpin->setRange(0, 200); connect(packagesCheck, &QCheckBox::clicked, [this]() {
minDeckSizeSpin->setValue(0); updatePackageModeState(packagesCheck->isChecked());
doSearch();
// Size logic });
minDeckSizeLogicCombo = new QComboBox(navigationContainer);
minDeckSizeLogicCombo->addItems({"Exact", "", ""}); // Exact = unset, ≥ = GTE, ≤ = LTE
minDeckSizeLogicCombo->setCurrentIndex(1); // default GTE
// Format filters trigger search
connect(edhBracketCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch);
connect(minDeckSizeSpin, qOverload<int>(&QSpinBox::valueChanged), this, &TabArchidekt::doSearch); connect(minDeckSizeSpin, qOverload<int>(&QSpinBox::valueChanged), this, &TabArchidekt::doSearch);
connect(minDeckSizeLogicCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); connect(minDeckSizeLogicCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch);
// Page number // Allow Enter key in text fields to trigger search
pageLabel = new QLabel(navigationContainer); 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);
pageSpin = new QSpinBox(navigationContainer); // Format checkboxes trigger search
pageSpin->setRange(1, 9999); for (auto *formatCheck : formatChecks) {
pageSpin->setValue(1); connect(formatCheck, &QCheckBox::clicked, this, &TabArchidekt::doSearch);
}
}
connect(pageSpin, qOverload<int>(&QSpinBox::valueChanged), 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);
}
// Page display edhBracketCombo->setEnabled(!isPackageMode);
currentPageDisplay = new QWidget(container); if (isPackageMode) {
currentPageLayout = new QVBoxLayout(currentPageDisplay); edhBracketCombo->setCurrentIndex(0);
currentPageLayout->setContentsMargins(0, 0, 0, 0); }
currentPageDisplay->setLayout(currentPageLayout);
// Layout composition commandersField->setEnabled(!isPackageMode);
deckTagNameField->setEnabled(!isPackageMode);
// Sort section
navigationLayout->addWidget(orderByCombo);
navigationLayout->addWidget(orderDirButton);
// Colors section
navigationLayout->addLayout(colorLayout);
navigationLayout->addWidget(logicalAndCheck);
// Formats section
navigationLayout->addWidget(formatSettingsWidget);
navigationLayout->addWidget(formatLabel);
// EDH Bracket
navigationLayout->addWidget(edhBracketCombo);
// Packages toggle
navigationLayout->addWidget(packagesCheck);
// Deck name
navigationLayout->addWidget(nameField);
// Owner name
navigationLayout->addWidget(ownerField);
// Contained cards
navigationLayout->addWidget(cardsField);
// Commanders
navigationLayout->addWidget(commandersField);
// Deck tag
navigationLayout->addWidget(deckTagNameField);
// Search button
navigationLayout->addWidget(searchPushButton);
// Card size settings
navigationLayout->addWidget(settingsButton);
// Min. # of cards in deck
navigationLayout->addWidget(minDeckSizeLabel);
navigationLayout->addWidget(minDeckSizeSpin);
navigationLayout->addWidget(minDeckSizeLogicCombo);
// Page number
navigationLayout->addWidget(pageLabel);
navigationLayout->addWidget(pageSpin);
mainLayout->addWidget(navigationContainer);
mainLayout->addWidget(currentPageDisplay);
// Ensure navigation stays at the top and currentPageDisplay takes remaining space
mainLayout->setStretch(0, 0); // navigationContainer gets minimum space
mainLayout->setStretch(1, 1); // currentPageDisplay expands as much as possible
setCentralWidget(container);
TabArchidekt::retranslateUi();
getTopDecks();
} }
void TabArchidekt::retranslateUi() void TabArchidekt::retranslateUi()
{ {
searchPushButton->setText(tr("Search")); sortByLabel->setText(tr("Sort by:"));
formatLabel->setText(tr("Formats")); orderDirButton->setText(orderDirButton->isChecked() ? tr("Desc.") : tr("Asc."));
minDeckSizeLabel->setText(tr("Min. # of Cards:"));
pageLabel->setText(tr("Page:")); 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..."));
formatButton->setButtonText(tr("Formats"));
deckSizeButton->setButtonText(tr("Deck Size"));
searchButton->setText(tr("Search"));
settingsButton->setToolTip(tr("Display Settings"));
} }
QString TabArchidekt::buildSearchUrl() QString TabArchidekt::buildSearchUrl()
@ -306,13 +413,11 @@ QString TabArchidekt::buildSearchUrl()
QUrlQuery query; QUrlQuery query;
// orderBy (field + direction) // orderBy (field + direction)
{ QString field = orderByCombo->currentText();
QString field = orderByCombo->currentText(); if (!field.isEmpty()) {
if (!field.isEmpty()) { bool desc = orderDirButton->isChecked();
bool desc = orderDirButton->isChecked(); QString final = desc ? "-" + field : field;
QString final = desc ? "-" + field : field; query.addQueryItem("orderBy", final);
query.addQueryItem("orderBy", final);
}
} }
// Colors // Colors
@ -329,29 +434,26 @@ QString TabArchidekt::buildSearchUrl()
query.addQueryItem("logicalAnd", "true"); query.addQueryItem("logicalAnd", "true");
} }
// Formats // Formats (disabled in package mode)
if (!packagesCheck->isChecked()) { if (!packagesCheck->isChecked()) {
QStringList formatIds; QStringList formatIds;
for (int i = 0; i < formatChecks.size(); ++i) for (int i = 0; i < formatChecks.size(); ++i) {
if (formatChecks[i]->isChecked()) { if (formatChecks[i]->isChecked()) {
formatIds << QString::number(i + 1); formatIds << QString::number(i + 1);
} }
}
if (!formatIds.isEmpty()) { if (!formatIds.isEmpty()) {
query.addQueryItem("deckFormat", formatIds.join(",")); query.addQueryItem("deckFormat", formatIds.join(","));
} }
}
// edhBracket // edhBracket
if (!packagesCheck->isChecked()) { if (edhBracketCombo->currentIndex() > 0) {
if (!edhBracketCombo->currentText().isEmpty()) { query.addQueryItem("edhBracket", edhBracketCombo->currentText());
if (edhBracketCombo->currentText() != tr("Any Bracket")) {
query.addQueryItem("edhBracket", edhBracketCombo->currentText());
}
} }
} }
// Search for card packages instead of decks // Package mode
if (packagesCheck->isChecked()) { if (packagesCheck->isChecked()) {
query.addQueryItem("packages", "true"); query.addQueryItem("packages", "true");
} }
@ -361,54 +463,47 @@ QString TabArchidekt::buildSearchUrl()
query.addQueryItem("name", nameField->text()); query.addQueryItem("name", nameField->text());
} }
// owner // Owner
if (!ownerField->text().isEmpty()) { if (!ownerField->text().isEmpty()) {
query.addQueryItem("ownerUsername", ownerField->text()); query.addQueryItem("ownerUsername", ownerField->text());
} }
// cards // Cards
if (!cardsField->text().isEmpty()) { if (!cardsField->text().isEmpty()) {
query.addQueryItem("cardName", cardsField->text()); query.addQueryItem("cards", cardsField->text());
} }
// Commander Name // Commander (disabled in package mode)
if (!packagesCheck->isChecked()) { if (!packagesCheck->isChecked() && !commandersField->text().isEmpty()) {
if (!commandersField->text().isEmpty()) { query.addQueryItem("commanderName", commandersField->text());
query.addQueryItem("commanderName", commandersField->text());
}
} }
// deckTagName // Deck tag (disabled in package mode)
if (!packagesCheck->isChecked()) { if (!packagesCheck->isChecked() && !deckTagNameField->text().isEmpty()) {
if (!deckTagNameField->text().isEmpty()) { query.addQueryItem("deckTagName", deckTagNameField->text());
query.addQueryItem("deckTagName", deckTagNameField->text());
}
} }
// page number // Page number (for infinite scroll)
if (pageSpin->value() <= 1) { query.addQueryItem("page", QString::number(currentPage));
query.addQueryItem("page", QString::number(pageSpin->value()));
}
// Min deck size // Min deck size
if (minDeckSizeSpin->value() != 0) { if (minDeckSizeSpin->value() != 0) {
query.addQueryItem("size", QString::number(minDeckSizeSpin->value())); query.addQueryItem("size", QString::number(minDeckSizeSpin->value()));
QString logic = "GTE"; // default QString logic = "GTE";
QString selected = minDeckSizeLogicCombo->currentText(); QString selected = minDeckSizeLogicCombo->currentText();
if (selected == "") if (selected == "")
logic = "GTE"; logic = "GTE";
else if (selected == "") else if (selected == "")
logic = "LTE"; logic = "LTE";
else else
logic = ""; // Exact = unset logic = "";
if (!logic.isEmpty()) { if (!logic.isEmpty()) {
query.addQueryItem("sizeLogic", logic); query.addQueryItem("sizeLogic", logic);
} }
} }
// build final URL
QUrl url("https://archidekt.com/api/decks/v3/"); QUrl url("https://archidekt.com/api/decks/v3/");
url.setQuery(query); url.setQuery(query);
@ -417,7 +512,12 @@ QString TabArchidekt::buildSearchUrl()
void TabArchidekt::doSearch() void TabArchidekt::doSearch()
{ {
searchDebounceTimer->start(); // 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() void TabArchidekt::doSearchImmediate()
@ -428,6 +528,21 @@ void TabArchidekt::doSearchImmediate()
networkManager->get(req); networkManager->get(req);
} }
void TabArchidekt::loadNextPage()
{
if (isLoadingMore) {
return;
}
isLoadingMore = true;
currentPage++;
QString url = buildSearchUrl();
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(req);
}
void TabArchidekt::actNavigatePage(QString url) void TabArchidekt::actNavigatePage(QString url)
{ {
QNetworkRequest request{QUrl(url)}; QNetworkRequest request{QUrl(url)};
@ -437,6 +552,7 @@ void TabArchidekt::actNavigatePage(QString url)
void TabArchidekt::getTopDecks() void TabArchidekt::getTopDecks()
{ {
currentPage = 1;
QNetworkRequest request{QUrl(buildSearchUrl())}; QNetworkRequest request{QUrl(buildSearchUrl())};
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
networkManager->get(request); networkManager->get(request);
@ -445,7 +561,7 @@ void TabArchidekt::getTopDecks()
void TabArchidekt::processApiJson(QNetworkReply *reply) void TabArchidekt::processApiJson(QNetworkReply *reply)
{ {
if (reply->error() != QNetworkReply::NoError) { if (reply->error() != QNetworkReply::NoError) {
qDebug() << "Network error occurred:" << reply->errorString(); isLoadingMore = false;
reply->deleteLater(); reply->deleteLater();
return; return;
} }
@ -454,17 +570,14 @@ void TabArchidekt::processApiJson(QNetworkReply *reply)
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData); QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
if (!jsonDoc.isObject()) { if (!jsonDoc.isObject()) {
qDebug() << "Invalid JSON response received."; isLoadingMore = false;
reply->deleteLater(); reply->deleteLater();
return; return;
} }
QJsonObject jsonObj = jsonDoc.object(); QJsonObject jsonObj = jsonDoc.object();
// Get the actual URL from the reply
QString responseUrl = reply->url().toString(); QString responseUrl = reply->url().toString();
// Check if the response URL matches a commander request
if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) { if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) {
processTopDecksResponse(jsonObj); processTopDecksResponse(jsonObj);
} else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) { } else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) {
@ -473,6 +586,7 @@ void TabArchidekt::processApiJson(QNetworkReply *reply)
prettyPrintJson(jsonObj, 4); prettyPrintJson(jsonObj, 4);
} }
isLoadingMore = false;
reply->deleteLater(); reply->deleteLater();
} }
@ -481,28 +595,27 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply)
ArchidektDeckListingApiResponse deckData; ArchidektDeckListingApiResponse deckData;
deckData.fromJson(reply); deckData.fromJson(reply);
// **Remove previous page display to prevent stacking** // New search → clear everything
if (currentPageDisplay) { if (currentPage == 1) {
mainLayout->removeWidget(currentPageDisplay); QLayoutItem *item;
delete currentPageDisplay; while ((item = resultsLayout->takeAt(0)) != nullptr) {
currentPageDisplay = nullptr; delete item->widget();
delete item;
}
listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider);
connect(listingsWidget, &ArchidektApiResponseDeckListingsDisplayWidget::requestNavigation, this,
&TabArchidekt::actNavigatePage);
resultsLayout->addWidget(listingsWidget);
return;
} }
// **Create new currentPageDisplay** // Infinite scroll → append
currentPageDisplay = new QWidget(container); if (listingsWidget) {
currentPageLayout = new QVBoxLayout(currentPageDisplay); listingsWidget->append(deckData);
currentPageDisplay->setLayout(currentPageLayout); }
auto display = new ArchidektApiResponseDeckListingsDisplayWidget(currentPageDisplay, deckData, cardSizeSlider);
connect(display, &ArchidektApiResponseDeckListingsDisplayWidget::requestNavigation, this,
&TabArchidekt::actNavigatePage);
currentPageLayout->addWidget(display);
mainLayout->addWidget(currentPageDisplay);
// **Ensure layout stays correct**
mainLayout->setStretch(0, 0); // Keep navigationContainer at the top
mainLayout->setStretch(1, 1); // Make sure currentPageDisplay takes remaining space
} }
void TabArchidekt::processDeckResponse(QJsonObject reply) void TabArchidekt::processDeckResponse(QJsonObject reply)
@ -510,54 +623,47 @@ void TabArchidekt::processDeckResponse(QJsonObject reply)
ArchidektApiResponseDeck deckData; ArchidektApiResponseDeck deckData;
deckData.fromJson(reply); deckData.fromJson(reply);
// **Remove previous page display to prevent stacking** // We're in single deck mode - disable infinite scroll
if (currentPageDisplay) { isListMode = false;
mainLayout->removeWidget(currentPageDisplay);
delete currentPageDisplay; // Clear existing results for single deck view
currentPageDisplay = nullptr; QLayoutItem *item;
while ((item = resultsLayout->takeAt(0)) != nullptr) {
delete item->widget();
delete item;
} }
// **Create new currentPageDisplay** auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider);
currentPageDisplay = new QWidget(container);
currentPageLayout = new QVBoxLayout(currentPageDisplay);
currentPageDisplay->setLayout(currentPageLayout);
auto display = new ArchidektApiResponseDeckDisplayWidget(currentPageDisplay, deckData, cardSizeSlider);
connect(display, &ArchidektApiResponseDeckDisplayWidget::requestNavigation, this, &TabArchidekt::actNavigatePage); connect(display, &ArchidektApiResponseDeckDisplayWidget::requestNavigation, this, &TabArchidekt::actNavigatePage);
connect(display, &ArchidektApiResponseDeckDisplayWidget::requestSearch, this, &TabArchidekt::doSearchImmediate);
connect(display, &ArchidektApiResponseDeckDisplayWidget::openInDeckEditor, tabSupervisor, connect(display, &ArchidektApiResponseDeckDisplayWidget::openInDeckEditor, tabSupervisor,
&TabSupervisor::openDeckInNewTab); &TabSupervisor::openDeckInNewTab);
currentPageLayout->addWidget(display); resultsLayout->addWidget(display);
mainLayout->addWidget(currentPageDisplay);
// **Ensure layout stays correct**
mainLayout->setStretch(0, 0); // Keep navigationContainer at the top
mainLayout->setStretch(1, 1); // Make sure currentPageDisplay takes remaining space
} }
void TabArchidekt::prettyPrintJson(const QJsonValue &value, int indentLevel) void TabArchidekt::prettyPrintJson(const QJsonValue &value, int indentLevel)
{ {
const QString indent(indentLevel * 2, ' '); // Adjust spacing as needed for pretty printing const QString indent(indentLevel * 2, ' ');
if (value.isObject()) { if (value.isObject()) {
QJsonObject obj = value.toObject(); QJsonObject obj = value.toObject();
for (auto it = obj.begin(); it != obj.end(); ++it) { for (auto it = obj.begin(); it != obj.end(); ++it) {
qDebug().noquote() << indent + it.key() + ":"; qInfo().noquote() << indent + it.key() + ":";
prettyPrintJson(it.value(), indentLevel + 1); prettyPrintJson(it.value(), indentLevel + 1);
} }
} else if (value.isArray()) { } else if (value.isArray()) {
QJsonArray array = value.toArray(); QJsonArray array = value.toArray();
for (int i = 0; i < array.size(); ++i) { for (int i = 0; i < array.size(); ++i) {
qDebug().noquote() << indent + QString("[%1]:").arg(i); qInfo().noquote() << indent + QString("[%1]:").arg(i);
prettyPrintJson(array[i], indentLevel + 1); prettyPrintJson(array[i], indentLevel + 1);
} }
} else if (value.isString()) { } else if (value.isString()) {
qDebug().noquote() << indent + "\"" + value.toString() + "\""; qInfo().noquote() << indent + "\"" + value.toString() + "\"";
} else if (value.isDouble()) { } else if (value.isDouble()) {
qDebug().noquote() << indent + QString::number(value.toDouble()); qInfo().noquote() << indent + QString::number(value.toDouble());
} else if (value.isBool()) { } else if (value.isBool()) {
qDebug().noquote() << indent + (value.toBool() ? "true" : "false"); qInfo().noquote() << indent + (value.toBool() ? "true" : "false");
} else if (value.isNull()) { } else if (value.isNull()) {
qDebug().noquote() << indent + "null"; qInfo().noquote() << indent + "null";
} }
} }

View file

@ -4,26 +4,35 @@
#include "../../interface/widgets/cards/card_size_widget.h" #include "../../interface/widgets/cards/card_size_widget.h"
#include "../../interface/widgets/quick_settings/settings_button_widget.h" #include "../../interface/widgets/quick_settings/settings_button_widget.h"
#include "../../tab.h" #include "../../tab.h"
#include "display/archidekt_api_response_deck_listings_display_widget.h"
#include <QCheckBox> #include <QCheckBox>
#include <QComboBox> #include <QComboBox>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QPushButton> #include <QPushButton>
#include <QScrollArea>
#include <QSet>
#include <QSpinBox> #include <QSpinBox>
#include <QString> #include <QString>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
#include <libcockatrice/card/database/card_database.h> #include <libcockatrice/card/database/card_database.h>
/** Base API link for Archidekt deck search */ /** Base API link for Archidekt deck search */
inline QString archidektApiLink = "https://archidekt.com/api/decks/v3/?name="; inline QString archidektApiLink = "https://archidekt.com/api/decks/v3/?name=";
class ManaSymbolWidget;
/** /**
* @brief Tab for browsing, searching, and filtering Archidekt decks. * @brief Tab for browsing, searching, and filtering Archidekt decks.
* *
* This class provides a comprehensive interface for querying decks from the Archidekt API. * This class provides a comprehensive interface for querying decks from the Archidekt API.
* Users can filter decks by name, owner, included cards, commanders, deck tags, colors, EDH bracket, * Users can filter decks by name, owner, included cards, commanders, deck tags, colors, EDH bracket,
* and formats. It also provides sorting and pagination, as well as a card size adjustment widget. * and formats. It supports infinite scroll pagination for seamless browsing.
*/ */
class TabArchidekt : public Tab class TabArchidekt : public Tab
{ {
@ -63,7 +72,7 @@ public:
* - Packages toggle * - Packages toggle
* - Sorting field and direction * - Sorting field and direction
* - Minimum amount of cards in the deck * - Minimum amount of cards in the deck
* - Pagination (page) * - Current page (for infinite scroll)
*/ */
QString buildSearchUrl(); QString buildSearchUrl();
@ -90,32 +99,45 @@ public:
return cardSizeSlider; return cardSizeSlider;
} }
/** @brief Network manager for handling API requests */
QNetworkAccessManager *networkManager;
public slots: public slots:
/** /**
* @brief Trigger a search using the current filters * @brief Trigger a debounced search using the current filters
* *
* Sends a network request to the Archidekt API using the URL generated by buildSearchUrl(). * Resets to page 1 and starts the debounce timer. The actual search will execute
* Updates the current page display with results asynchronously. * after 300ms of inactivity.
*/ */
void doSearch(); void doSearch();
/**
* @brief Immediately trigger a search using the current filters
*
* Sends a network request to the Archidekt API using the URL generated by buildSearchUrl().
* Updates the results display asynchronously.
*/
void doSearchImmediate(); void doSearchImmediate();
/**
* @brief Load the next page of results for infinite scroll
*
* Increments the current page and fetches additional results, which are appended
* to the existing results display.
*/
void loadNextPage();
/** /**
* @brief Process a network reply containing JSON data * @brief Process a network reply containing JSON data
* @param reply QNetworkReply object with the API response * @param reply QNetworkReply object with the API response
* *
* Determines whether the response corresponds to a top decks query or a single deck, * Determines whether the response corresponds to a deck listing or a single deck,
* and dispatches it to the appropriate handler. * and dispatches it to the appropriate handler.
*/ */
void processApiJson(QNetworkReply *reply); void processApiJson(QNetworkReply *reply);
/** /**
* @brief Handle a JSON response containing multiple decks * @brief Handle a JSON response containing multiple decks
* @param reply QJsonObject containing top deck listings * @param reply QJsonObject containing deck listings
* *
* Clears the previous page display and creates a new display widget for the results. * If this is page 1, clears previous results. Appends new results to the display.
*/ */
void processTopDecksResponse(QJsonObject reply); void processTopDecksResponse(QJsonObject reply);
@ -123,7 +145,7 @@ public slots:
* @brief Handle a JSON response for a single deck * @brief Handle a JSON response for a single deck
* @param reply QJsonObject containing deck data * @param reply QJsonObject containing deck data
* *
* Clears the previous page display and creates a new display widget for the deck details. * Clears the results area and displays the single deck details.
*/ */
void processDeckResponse(QJsonObject reply); void processDeckResponse(QJsonObject reply);
@ -138,107 +160,129 @@ public slots:
* @brief Navigate to a specified page URL * @brief Navigate to a specified page URL
* @param url The URL to request * @param url The URL to request
* *
* Typically called when a navigation button is clicked in a deck listing. * Typically called when a deck card is clicked in the listing.
*/ */
void actNavigatePage(QString url); void actNavigatePage(QString url);
/** /**
* @brief Fetch top decks from the Archidekt API * @brief Fetch top decks from the Archidekt API
* *
* Called on initialization to populate the initial page display. * Called on initialization to populate the initial results display.
*/ */
void getTopDecks(); void getTopDecks();
protected:
/**
* @brief Event filter to catch wheel events for infinite scroll
* @param obj The object that received the event
* @param event The event to filter
* @return bool Whether the event was handled
*/
bool eventFilter(QObject *obj, QEvent *event) override;
private: private:
QTimer *searchDebounceTimer; ///< Timer to debounce search requests by spin-boxes etc. /**
* @brief Initialize the main UI layout and toolbars
*
* Creates the container, main layout, primary toolbar (sort, colors, name, owner, packages),
* secondary toolbar (advanced filters), and scrollable results area.
*/
void initializeUi();
/**
* @brief Set up all filter widgets
*
* Creates filter widgets for:
* - Card search with autocomplete
* - Commander search with autocomplete
* - Deck tags
* - Format selection (collapsible)
* - Deck size filter (collapsible)
*/
void setupFilterWidgets();
/**
* @brief Connect all signals and slots for UI interactions
*
* Links all widget signals to their appropriate handlers, including
* search triggers, filter changes, package mode toggling, and infinite scroll.
*/
void connectSignals();
/**
* @brief Update UI state when package mode is toggled
* @param isPackageMode Whether package mode is currently enabled
*
* Disables format-specific and commander-specific filters when searching
* for card packages instead of full decks.
*/
void updatePackageModeState(bool isPackageMode);
// ---------------------------------------------------------------------
// Network & Timing
// ---------------------------------------------------------------------
QNetworkAccessManager *networkManager; ///< Network manager for handling API requests
QTimer *searchDebounceTimer; ///< Timer to debounce search requests
int currentPage; ///< Current page number for infinite scroll
bool isLoadingMore; ///< Flag to prevent multiple simultaneous page loads
bool isListMode;
ArchidektApiResponseDeckListingsDisplayWidget *listingsWidget = nullptr;
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Layout Containers // Layout Containers
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
QWidget *container; ///< Root container for the entire tab QWidget *container; ///< Root container for the entire tab
QVBoxLayout *mainLayout; ///< Outer vertical layout containing navigation and page display QVBoxLayout *mainLayout; ///< Outer vertical layout containing toolbars and results
QWidget *navigationContainer; ///< Container for all navigation/filter controls
QHBoxLayout *navigationLayout; ///< Layout for horizontal arrangement of filter widgets QWidget *primaryToolbar; ///< Primary toolbar with most important filters
QWidget *currentPageDisplay; ///< Widget containing the currently displayed deck(s) QHBoxLayout *primaryToolbarLayout; ///< Layout for primary toolbar
QVBoxLayout *currentPageLayout; ///< Layout for deck display widgets
QWidget *secondaryToolbar; ///< Secondary toolbar with advanced filters
QHBoxLayout *secondaryToolbarLayout; ///< Layout for secondary toolbar
QScrollArea *scrollArea; ///< Scrollable area for results (enables infinite scroll)
QWidget *resultsContainer; ///< Container widget inside scroll area
QVBoxLayout *resultsLayout; ///< Layout for results (decks appended here)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Sorting Controls // Primary Toolbar Controls (Most Important)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
QLabel *sortByLabel; ///< Label for sort controls
QComboBox *orderByCombo; ///< Dropdown for selecting the sort field QComboBox *orderByCombo; ///< Dropdown for selecting the sort field
QPushButton *orderDirButton; ///< Toggle button for ascending/descending sort QPushButton *orderDirButton; ///< Toggle button for ascending/descending sort
// --------------------------------------------------------------------- QLabel *filterByLabel; ///< Label for filter controls
// Color Filters QList<ManaSymbolWidget *> colorSymbols; ///< Mana symbol toggle buttons
// --------------------------------------------------------------------- QSet<QChar> activeColors; ///< Set of currently active mana colors
QCheckBox *logicalAndCheck; ///< Require ALL selected colors instead of ANY
QSet<QChar> activeColors; ///< Set of currently active mana colors QLineEdit *nameField; ///< Input for deck name filter
QCheckBox *logicalAndCheck; ///< Require ALL selected colors instead of ANY QLineEdit *ownerField; ///< Input for owner name filter
QCheckBox *packagesCheck; ///< Toggle for searching card packages instead of full decks
QPushButton *searchButton; ///< Button to trigger search
QPushButton *advancedFiltersButton; ///< Button to show/hide advanced filters
// --------------------------------------------------------------------- SettingsButtonWidget *settingsButton; ///< Container for card size settings
// Format Filters
// ---------------------------------------------------------------------
QLabel *formatLabel; ///< Label displaying "Formats"
SettingsButtonWidget *formatSettingsWidget; ///< Collapsible widget containing format checkboxes
QVector<QCheckBox *> formatChecks; ///< Individual checkboxes for each format
// ---------------------------------------------------------------------
// EDH Bracket / Package Toggle
// ---------------------------------------------------------------------
QComboBox *edhBracketCombo; ///< Dropdown for EDH bracket selection
QCheckBox *packagesCheck; ///< Toggle for searching card packages instead of full decks
// ---------------------------------------------------------------------
// Basic Search Fields
// ---------------------------------------------------------------------
QLineEdit *nameField; ///< Input for deck name filter
QLineEdit *ownerField; ///< Input for owner name filter
// ---------------------------------------------------------------------
// Card Filters
// ---------------------------------------------------------------------
QLineEdit *cardsField; ///< Input for cards included in the deck (comma-separated)
QLineEdit *commandersField; ///< Input for commander cards (comma-separated)
// ---------------------------------------------------------------------
// Deck Tag
// ---------------------------------------------------------------------
QLineEdit *deckTagNameField; ///< Input for deck tag filtering
// ---------------------------------------------------------------------
// Search Trigger
// ---------------------------------------------------------------------
QPushButton *searchPushButton; ///< Button to trigger the search manually
// ---------------------------------------------------------------------
// UI Settings (Card Size)
// ---------------------------------------------------------------------
SettingsButtonWidget *settingsButton; ///< Container for additional UI settings
CardSizeWidget *cardSizeSlider; ///< Slider to adjust card size in results CardSizeWidget *cardSizeSlider; ///< Slider to adjust card size in results
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Minimum Cards in Deck // Secondary Toolbar Controls (Advanced Filters)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
QLabel *minDeckSizeLabel; ///< Label for minimum number of cards per deck QLineEdit *cardsField; ///< Input for cards included in the deck
QSpinBox *minDeckSizeSpin; ///< Spinner to select minimum deck size QLineEdit *commandersField; ///< Input for commander cards
QComboBox *minDeckSizeLogicCombo; ///< Combo box for the size logic to apply QLineEdit *deckTagNameField; ///< Input for deck tag filtering
// --------------------------------------------------------------------- SettingsButtonWidget *formatButton; ///< Collapsible button for format filters
// Pagination QVector<QCheckBox *> formatChecks; ///< Individual checkboxes for each format
// --------------------------------------------------------------------- QComboBox *edhBracketCombo; ///< Dropdown for EDH bracket selection
QLabel *pageLabel; ///< Label for current page selection SettingsButtonWidget *deckSizeButton; ///< Collapsible button for deck size filter
QSpinBox *pageSpin; ///< Spinner to select the page number for results QSpinBox *minDeckSizeSpin; ///< Spinner to select minimum deck size
QComboBox *minDeckSizeLogicCombo; ///< Combo box for size comparison logic
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Optional Context // Optional Context