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