From 1eee314d17e05e1a7420925d817b0927489d2ec1 Mon Sep 17 00:00:00 2001
From: RickyRister <42636155+RickyRister@users.noreply.github.com>
Date: Sat, 17 May 2025 19:23:54 -0700
Subject: [PATCH] [VDS] Add ability to search by deck contents (#5943)
* [VDS] Add ability to search by deck contents
* add deck search syntax help
* fix build failure
---
cockatrice/CMakeLists.txt | 1 +
cockatrice/cockatrice.qrc | 1 +
cockatrice/resources/config/qtlogging.ini | 2 +
cockatrice/resources/help/deck_search.md | 26 +++
.../visual_deck_storage_search_widget.cpp | 29 +--
.../src/game/filters/deck_filter_string.cpp | 167 ++++++++++++++++++
.../src/game/filters/deck_filter_string.h | 51 ++++++
cockatrice/src/game/filters/filter_string.cpp | 4 +-
cockatrice/src/game/filters/syntax_help.cpp | 27 ++-
cockatrice/src/game/filters/syntax_help.h | 5 +
10 files changed, 297 insertions(+), 16 deletions(-)
create mode 100644 cockatrice/resources/help/deck_search.md
create mode 100644 cockatrice/src/game/filters/deck_filter_string.cpp
create mode 100644 cockatrice/src/game/filters/deck_filter_string.h
diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt
index 50c721a6f..c25b319db 100644
--- a/cockatrice/CMakeLists.txt
+++ b/cockatrice/CMakeLists.txt
@@ -190,6 +190,7 @@ set(cockatrice_SOURCES
src/game/cards/card_search_model.cpp
src/game/deckview/deck_view.cpp
src/game/deckview/deck_view_container.cpp
+ src/game/filters/deck_filter_string.cpp
src/game/filters/filter_builder.cpp
src/game/filters/filter_card.cpp
src/game/filters/filter_string.cpp
diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc
index 38c40561f..72ca1d83f 100644
--- a/cockatrice/cockatrice.qrc
+++ b/cockatrice/cockatrice.qrc
@@ -379,5 +379,6 @@
resources/tips/tips_of_the_day.xml
resources/help/search.md
+ resources/help/deck_search.md
diff --git a/cockatrice/resources/config/qtlogging.ini b/cockatrice/resources/config/qtlogging.ini
index eeae47b7f..2cd3d871a 100644
--- a/cockatrice/resources/config/qtlogging.ini
+++ b/cockatrice/resources/config/qtlogging.ini
@@ -60,4 +60,6 @@
# pixel_map_generator = false
+# deck_filter_string = false
# filter_string = false
+# syntax_help = false
diff --git a/cockatrice/resources/help/deck_search.md b/cockatrice/resources/help/deck_search.md
new file mode 100644
index 000000000..cb06a40a6
--- /dev/null
+++ b/cockatrice/resources/help/deck_search.md
@@ -0,0 +1,26 @@
+## Deck Search Syntax Help
+-----
+The search bar recognizes a set of special commands.
+In this list of examples below, each entry has an explanation and can be clicked to test the query. Note that all
+searches are case insensitive.
+
+- Filename:
+- [red deck wins](#red deck wins) (Any deck filename containing the words red, deck, and wins)
+- ["red deck wins"](#%22red deck wins%22) (Any deck filename containing the exact phrase "red deck wins")
+
+- Deck Contents (Uses [card search expressions](#cardSearchSyntaxHelp)):
+- [[plains]] (Any deck that contains at least one card with "plains" in its name)
+- [[t:legendary]] (Any deck that contains at least one legendary)
+- [[t:legendary]]>5 (Any card that contains at least 5 legendaries)
+- [[]]:100 (Any deck that contains exactly 100 cards)
+
+- Negate:
+- [soldier -aggro](#soldier -aggro) (Any deck filename that contains "soldier", but not "aggro")
+
+- Branching:
+- [t:aggro OR o:control](#t:aggro OR o:control) (Any deck filename that contains either aggro or control)
+
+- Grouping:
+- red -([[]]:100 or aggro) (Any deck that has red in its filename but is not 100 cards or has aggro in its filename)
+
+
diff --git a/cockatrice/src/client/ui/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/client/ui/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp
index 9b7f65224..2998845f1 100644
--- a/cockatrice/src/client/ui/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp
+++ b/cockatrice/src/client/ui/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp
@@ -1,6 +1,11 @@
#include "visual_deck_storage_search_widget.h"
+#include "../../../../game/filters/deck_filter_string.h"
+#include "../../../../game/filters/syntax_help.h"
#include "../../../../settings/cache_settings.h"
+#include "../../pixel_map_generator.h"
+
+#include
/**
* @brief Constructs a PrintingSelectorCardSearchWidget for searching cards by set name or set code.
@@ -17,7 +22,13 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWi
setLayout(layout);
searchBar = new QLineEdit(this);
- searchBar->setPlaceholderText(tr("Search by filename"));
+ searchBar->setPlaceholderText(tr("Search by filename (or search expression)"));
+ searchBar->setClearButtonEnabled(true);
+ searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
+
+ auto help = searchBar->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition);
+ connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); });
+
layout->addWidget(searchBar);
// Add a debounce timer for the search bar to limit frequent updates
@@ -52,11 +63,11 @@ static QString getFileSearchName(const QString &filePath, bool includeFolderName
{
QString deckPath = SettingsCache::instance().getDeckPath();
if (includeFolderName && filePath.startsWith(deckPath)) {
- return filePath.mid(deckPath.length()).toLower();
+ return filePath.mid(deckPath.length());
}
QFileInfo fileInfo(filePath);
- QString fileName = fileInfo.fileName().toLower();
+ QString fileName = fileInfo.fileName();
return fileName;
}
@@ -64,14 +75,10 @@ void VisualDeckStorageSearchWidget::filterWidgets(QList wid
const QString &searchText,
bool includeFolderName)
{
- if (searchText.isEmpty() || searchText.isNull()) {
- for (auto widget : widgets) {
- widget->filteredBySearch = false;
- }
- }
+ auto filterString = DeckFilterString(searchText);
- for (auto file : widgets) {
- QString fileSearchName = getFileSearchName(file->filePath, includeFolderName);
- file->filteredBySearch = !fileSearchName.contains(searchText.toLower());
+ for (auto widget : widgets) {
+ QString fileSearchName = getFileSearchName(widget->filePath, includeFolderName);
+ widget->filteredBySearch = !filterString.check(widget, {fileSearchName});
}
}
diff --git a/cockatrice/src/game/filters/deck_filter_string.cpp b/cockatrice/src/game/filters/deck_filter_string.cpp
new file mode 100644
index 000000000..52aa15c71
--- /dev/null
+++ b/cockatrice/src/game/filters/deck_filter_string.cpp
@@ -0,0 +1,167 @@
+#include "deck_filter_string.h"
+
+#include "../cards/card_database_manager.h"
+#include "filter_string.h"
+#include "lib/peglib.h"
+
+static peg::parser search(R"(
+Start <- QueryPartList
+~ws <- [ ]+
+QueryPartList <- ComplexQueryPart ( ws ("AND" ws)? ComplexQueryPart)* ws*
+
+ComplexQueryPart <- SomewhatComplexQueryPart ws "OR" ws ComplexQueryPart / SomewhatComplexQueryPart
+SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart
+
+QueryPart <- NotQuery / DeckContentQuery / GenericQuery
+
+NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart
+
+DeckContentQuery <- CardSearch NumericExpression?
+CardSearch <- '[[' CardFilterString ']]'
+CardFilterString <- (!']]'.)*
+
+GenericQuery <- String
+
+NonDoubleQuoteUnlessEscaped <- '\\\"'. / !["].
+NonSingleQuoteUnlessEscaped <- "\\\'". / !['].
+UnescapedStringListPart <- !['":<>=! ].
+SingleApostropheString <- (UnescapedStringListPart+ ws*)* ['] (UnescapedStringListPart+ ws*)*
+
+String <- SingleApostropheString / UnescapedStringListPart+ / ["] ["] / ['] [']
+
+NumericExpression <- NumericOperator ws? NumericValue
+NumericOperator <- [=:] / <[>
+NumericValue <- [0-9]+
+)");
+
+static std::once_flag init;
+
+static void setupParserRules()
+{
+ // plumbing
+ auto passthru = [](const peg::SemanticValues &sv) -> DeckFilter {
+ return !sv.empty() ? std::any_cast(sv[0]) : nullptr;
+ };
+
+ search["Start"] = passthru;
+ search["QueryPartList"] = [](const peg::SemanticValues &sv) -> DeckFilter {
+ return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) {
+ auto matchesFilter = [&deck, &info](const std::any &query) {
+ return std::any_cast(query)(deck, info);
+ };
+ return std::all_of(sv.begin(), sv.end(), matchesFilter);
+ };
+ };
+ search["ComplexQueryPart"] = [](const peg::SemanticValues &sv) -> DeckFilter {
+ return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) {
+ auto matchesFilter = [&deck, &info](const std::any &query) {
+ return std::any_cast(query)(deck, info);
+ };
+ return std::any_of(sv.begin(), sv.end(), matchesFilter);
+ };
+ };
+ search["SomewhatComplexQueryPart"] = passthru;
+ search["QueryPart"] = passthru;
+ search["NotQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
+ const auto dependent = std::any_cast(sv[0]);
+ return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) -> bool {
+ return !dependent(deck, info);
+ };
+ };
+
+ search["String"] = [](const peg::SemanticValues &sv) -> QString {
+ if (sv.choice() == 0) {
+ return QString::fromStdString(std::string(sv.sv()));
+ }
+
+ return QString::fromStdString(std::string(sv.token(0)));
+ };
+
+ search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
+ const auto arg = std::any_cast(sv[1]);
+ const auto op = std::any_cast(sv[0]);
+
+ if (op == ">")
+ return [=](const int s) { return s > arg; };
+ if (op == ">=")
+ return [=](const int s) { return s >= arg; };
+ if (op == "<")
+ return [=](const int s) { return s < arg; };
+ if (op == "<=")
+ return [=](const int s) { return s <= arg; };
+ if (op == "=")
+ return [=](const int s) { return s == arg; };
+ if (op == ":")
+ return [=](const int s) { return s == arg; };
+ if (op == "!=")
+ return [=](const int s) { return s != arg; };
+ return [](int) { return false; };
+ };
+
+ search["NumericValue"] = [](const peg::SemanticValues &sv) -> int {
+ return QString::fromStdString(std::string(sv.sv())).toInt();
+ };
+
+ search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString {
+ return QString::fromStdString(std::string(sv.sv()));
+ };
+
+ // actual functionality
+ search["DeckContentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
+ auto cardFilter = FilterString(std::any_cast(sv[0]));
+ auto numberMatcher = sv.size() > 1 ? std::any_cast(sv[1]) : [](int count) { return count > 0; };
+
+ return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) -> bool {
+ int count = 0;
+ deck->deckLoader->forEachCard([&](InnerDecklistNode *, const DecklistCardNode *node) {
+ auto cardInfoPtr = CardDatabaseManager::getInstance()->getCard(node->getName());
+ if (!cardInfoPtr.isNull() && cardFilter.check(cardInfoPtr)) {
+ count += node->getNumber();
+ }
+ });
+ return numberMatcher(count);
+ };
+ };
+
+ search["CardSearch"] = [](const peg::SemanticValues &sv) -> QString { return std::any_cast(sv[0]); };
+
+ search["CardFilterString"] = [](const peg::SemanticValues &sv) -> QString {
+ return QString::fromStdString(std::string(sv.sv()));
+ };
+
+ search["GenericQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
+ auto name = std::any_cast(sv[0]);
+ return [=](const DeckPreviewWidget *, const ExtraDeckSearchInfo &info) {
+ return info.fileSearchName.contains(name, Qt::CaseInsensitive);
+ };
+ };
+}
+
+DeckFilterString::DeckFilterString()
+{
+ filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return false; };
+ _error = "Not initialized";
+}
+
+DeckFilterString::DeckFilterString(const QString &expr)
+{
+ QByteArray ba = expr.simplified().toUtf8();
+
+ std::call_once(init, setupParserRules);
+
+ _error = QString();
+
+ if (ba.isEmpty()) {
+ filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return true; };
+ return;
+ }
+
+ search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) {
+ _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg));
+ });
+
+ if (!search.parse(ba.data(), filter)) {
+ qCInfo(DeckFilterStringLog).nospace() << "DeckFilterString error for " << expr << "; " << qPrintable(_error);
+ filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return false; };
+ }
+}
\ No newline at end of file
diff --git a/cockatrice/src/game/filters/deck_filter_string.h b/cockatrice/src/game/filters/deck_filter_string.h
new file mode 100644
index 000000000..dfdb1f1a8
--- /dev/null
+++ b/cockatrice/src/game/filters/deck_filter_string.h
@@ -0,0 +1,51 @@
+#ifndef DECK_FILTER_STRING_H
+#define DECK_FILTER_STRING_H
+
+#include "../../client/ui/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h"
+
+#include
+#include
+#include
+#include
+#include
+
+inline Q_LOGGING_CATEGORY(DeckFilterStringLog, "deck_filter_string");
+
+/**
+ * Extra info relevant to filtering that isn't present in the DeckPreviewWidget
+ */
+struct ExtraDeckSearchInfo
+{
+ /**
+ * The filename used for filtering. Varies based on settings.
+ */
+ QString fileSearchName;
+};
+
+typedef std::function DeckFilter;
+
+class DeckFilterString
+{
+public:
+ DeckFilterString();
+ explicit DeckFilterString(const QString &expr);
+ bool check(const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) const
+ {
+ return filter(deck, info);
+ }
+
+ bool valid() const
+ {
+ return _error.isEmpty();
+ }
+
+ QString error()
+ {
+ return _error;
+ }
+
+private:
+ QString _error;
+ DeckFilter filter;
+};
+#endif // DECK_FILTER_STRING_H
diff --git a/cockatrice/src/game/filters/filter_string.cpp b/cockatrice/src/game/filters/filter_string.cpp
index 01a7b6be9..e99805541 100644
--- a/cockatrice/src/game/filters/filter_string.cpp
+++ b/cockatrice/src/game/filters/filter_string.cpp
@@ -7,7 +7,7 @@
#include
#include
-peg::parser search(R"(
+static peg::parser search(R"(
Start <- QueryPartList
~ws <- [ ]+
QueryPartList <- ComplexQueryPart ( ws ("AND" ws)? ComplexQueryPart)* ws*
@@ -63,7 +63,7 @@ NumericOperator <- [=:] / <[>
NumericValue <- [0-9]+
)");
-std::once_flag init;
+static std::once_flag init;
static void setupParserRules()
{
diff --git a/cockatrice/src/game/filters/syntax_help.cpp b/cockatrice/src/game/filters/syntax_help.cpp
index a4eaf9112..9fb918629 100644
--- a/cockatrice/src/game/filters/syntax_help.cpp
+++ b/cockatrice/src/game/filters/syntax_help.cpp
@@ -9,11 +9,12 @@
*
* @return the QTextBrowser
*/
-static QTextBrowser *createBrowser()
+static QTextBrowser *createBrowser(const QString &helpFile)
{
- QFile file("theme:help/search.md");
+ QFile file(helpFile);
if (!file.open(QFile::ReadOnly | QFile::Text)) {
+ qCWarning(SyntaxHelpLog) << "Could not open syntax help file: " << helpFile;
return nullptr;
}
@@ -54,9 +55,29 @@ static QTextBrowser *createBrowser()
*/
QTextBrowser *createSearchSyntaxHelpWindow(QLineEdit *lineEdit)
{
- auto browser = createBrowser();
+ auto browser = createBrowser("theme:help/search.md");
QObject::connect(browser, &QTextBrowser::anchorClicked,
[lineEdit](const QUrl &link) { lineEdit->setText(link.fragment()); });
QObject::connect(lineEdit, &QObject::destroyed, browser, &QTextBrowser::close);
return browser;
+}
+
+/**
+ * Creates the deck search syntax help window and connects its anchorClicked signal to the given QLineEdit.
+ * The window will automatically close when the QLineEdit is destroyed.
+ *
+ * @return the QTextBrowser
+ */
+QTextBrowser *createDeckSearchSyntaxHelpWindow(QLineEdit *lineEdit)
+{
+ auto browser = createBrowser("theme:help/deck_search.md");
+ QObject::connect(browser, &QTextBrowser::anchorClicked, [lineEdit](const QUrl &link) {
+ if (link.fragment() == "cardSearchSyntaxHelp") {
+ createSearchSyntaxHelpWindow(lineEdit);
+ } else {
+ lineEdit->setText(link.fragment());
+ }
+ });
+ QObject::connect(lineEdit, &QObject::destroyed, browser, &QTextBrowser::close);
+ return browser;
}
\ No newline at end of file
diff --git a/cockatrice/src/game/filters/syntax_help.h b/cockatrice/src/game/filters/syntax_help.h
index 4016e02bb..911c2fdf0 100644
--- a/cockatrice/src/game/filters/syntax_help.h
+++ b/cockatrice/src/game/filters/syntax_help.h
@@ -2,8 +2,13 @@
#define SEARCH_SYNTAX_HELP_H
#include
+#include
#include
+inline Q_LOGGING_CATEGORY(SyntaxHelpLog, "syntax_help");
+
QTextBrowser *createSearchSyntaxHelpWindow(QLineEdit *lineEdit);
+QTextBrowser *createDeckSearchSyntaxHelpWindow(QLineEdit *lineEdit);
+
#endif // SEARCH_SYNTAX_HELP_H