[Client] Added an exclude and color identity filter for the visual database display (#7587)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 27 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions

* [Client] Add exclude and color identity filters to the visual database display

* [Filters] Extract the color filter mode derivation into libcockatrice_filters

The mode table and the 'which mode produced this filter set' derivation are
filter semantics, not widget code, so they belong next to CardFilter rather
than in an anonymous namespace in the client. Moving them also makes them
testable: filter_mode_test pins every mode, the Custom fallback and the
'keep the live mode when it still fits' rule.

* [Tests] Cover the negated color identity query

ci! read the camelCase colorIdentity property instead of the lowercase
coloridentity key, so it matched the empty string and rejected every card.
The new case uses a card whose colors and color identity differ, so it fails
on the old lookup and passes on the fixed one.

---------

Co-authored-by: Lukas Brübach <44814898+BruebachL@users.noreply.github.com>
This commit is contained in:
Jurriaan 2026-11-05 12:40:32 +01:00 • committed by GitHub
parent 64c7809332
commit 74686d1736
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 703 additions and 141 deletions

View file

@ -1,10 +1,10 @@
set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h
libcockatrice/filters/filter_tree.h
set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_mode.h
libcockatrice/filters/filter_string.h libcockatrice/filters/filter_tree.h
)
add_library(
libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_string.cpp
libcockatrice/filters/filter_tree.cpp
libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_mode.cpp
libcockatrice/filters/filter_string.cpp libcockatrice/filters/filter_tree.cpp
)
add_dependencies(libcockatrice_filters libcockatrice_card)

View file

@ -69,6 +69,8 @@ const QString CardFilter::attrName(Attr a)
return tr("Type");
case AttrColor:
return tr("Color");
case AttrColorIdentity:
return tr("Color Identity");
case AttrText:
return tr("Text");
case AttrSet:

View file

@ -32,6 +32,7 @@ public:
{
AttrCmc = 0,
AttrColor,
AttrColorIdentity,
AttrLoyalty,
AttrManaCost,
AttrName,

View file

@ -0,0 +1,77 @@
/**
* @file filter_mode.cpp
* @ingroup Filters
*/
#include "libcockatrice/filters/filter_mode.h"
#include <QString>
FilterModeInfo getFilterModeInfo(FilterMode filterMode)
{
switch (filterMode) {
case FilterMode::Includes:
return {CardFilter::Type::TypeOr, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor};
case FilterMode::Excludes:
return {CardFilter::Type::TypeAndNot, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor};
case FilterMode::IncludeExclude:
return {CardFilter::Type::TypeOr, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor};
case FilterMode::ExactMatch:
return {CardFilter::Type::TypeAnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor};
case FilterMode::ColorIdentity:
return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity};
case FilterMode::Custom:
case FilterMode::End:
break;
}
return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrEnd};
}
FilterMode deriveFilterMode(const QList<const CardFilter *> &filters, FilterMode currentMode)
{
constexpr int modeCount = static_cast<int>(FilterMode::End);
bool modeImpossible[modeCount] = {};
const QString colors = "WUBRG";
for (int i = 0; i < modeCount; ++i) {
const FilterModeInfo modeInfo = getFilterModeInfo(static_cast<FilterMode>(i));
// Remove the modes that cannot create one of the filters.
for (const CardFilter *filter : filters) {
if (filter->attr() != modeInfo.attr ||
(filter->type() != modeInfo.includeType && filter->type() != modeInfo.excludeType)) {
modeImpossible[i] = true;
break;
}
}
// Modes that have both an include and an exclude filter should always have a filter for each color.
if (modeInfo.includeType != CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd &&
!modeImpossible[i]) {
for (const QChar &color : colors) {
int count = 0;
for (const CardFilter *filter : filters) {
count += filter->term() == color;
}
if (count != 1) {
modeImpossible[i] = true;
break;
}
}
}
}
// Keep the current mode if it can produce the filters, so that a live mode is never swapped for another.
// Switching to and from the custom mode is always possible.
if (!modeImpossible[static_cast<int>(currentMode)] && currentMode != FilterMode::Custom) {
return currentMode;
}
for (int i = 0; i < modeCount; ++i) {
if (!modeImpossible[i] || i == static_cast<int>(FilterMode::Custom)) {
return static_cast<FilterMode>(i);
}
}
return FilterMode::Custom;
}

View file

@ -0,0 +1,62 @@
/**
* @file filter_mode.h
* @ingroup Filters
*/
#ifndef LIBCOCKATRICE_FILTERS_FILTER_MODE_H
#define LIBCOCKATRICE_FILTERS_FILTER_MODE_H
#include "libcockatrice/filters/filter_card.h"
#include <QChar>
#include <QList>
#include <QMetaType>
/**
* @brief The way selected and unselected colors are combined into card filters.
*/
enum class FilterMode
{
Includes = 0, // Include selected colors (OR condition).
Excludes, // Exclude selected colors (AND NOT).
IncludeExclude, // Include selected colors (OR) and exclude unselected colors (AND NOT).
ExactMatch, // Only selected colors are included, all others are excluded.
ColorIdentity, // Keep cards whose color identity is a subset of the selected colors (stored as AND NOT for each
// unselected color).
Custom, // The color filters do not match any of the above modes. Not included in the combo box.
End
};
Q_DECLARE_METATYPE(FilterMode)
/**
* @brief The filters a single color contributes in a given mode.
*/
struct FilterModeInfo
{
// When a color symbol is enabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd.
CardFilter::Type includeType;
// When a color symbol is disabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd.
CardFilter::Type excludeType;
// The attribute to filter for when adding the filters.
CardFilter::Attr attr;
};
/** @brief Return the filters the given mode contributes for one color. */
FilterModeInfo getFilterModeInfo(FilterMode filterMode);
/**
* @brief Determine the mode that produced the given color filters.
*
* Returns @p currentMode unchanged when that mode can still produce @p filters, so that a live mode is never
* replaced by an equally valid one. Otherwise returns the first mode that can produce them, falling back to
* FilterMode::Custom when none can.
*
* @param filters The filters on CardFilter::Attr::AttrColor and CardFilter::Attr::AttrColorIdentity.
* @param currentMode The mode currently selected in the UI.
*/
FilterMode deriveFilterMode(const QList<const CardFilter *> &filters, FilterMode currentMode);
#endif // LIBCOCKATRICE_FILTERS_FILTER_MODE_H

View file

@ -409,7 +409,7 @@ static void setupParserRules()
const bool identity = sv.tokens[0].empty() || sv.tokens[0][0] != 'i';
if (sv.tokens[1][0] == ':') {
return [=](const CardData &x) {
const QString match = identity ? x->getColors() : x->getProperty("coloridentity");
const QString match = identity ? x->getColors() : x->getColorIdentity();
if (parts.contains("m") && match.length() < 2) {
return false;
}
@ -427,7 +427,7 @@ static void setupParserRules()
}
return [=](const CardData &x) {
const QString match = identity ? x->getColors() : x->getProperty("colorIdentity");
const QString match = identity ? x->getColors() : x->getColorIdentity();
if (parts.contains("m") && match.length() < 2) {
return false;
}

View file

@ -176,23 +176,24 @@ FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(st
lowercaseTerm = term.toLower();
switch (attr()) {
case CardFilter::AttrColor: {
QString converted = term.trimmed();
case CardFilter::AttrColor:
case CardFilter::AttrColorIdentity: {
QString converted = term.trimmed().toLower();
converted.replace("green", "g", Qt::CaseInsensitive);
converted.replace("grn", "g", Qt::CaseInsensitive);
converted.replace("blue", "u", Qt::CaseInsensitive);
converted.replace("blu", "u", Qt::CaseInsensitive);
converted.replace("black", "b", Qt::CaseInsensitive);
converted.replace("blk", "b", Qt::CaseInsensitive);
converted.replace("red", "r", Qt::CaseInsensitive);
converted.replace("white", "w", Qt::CaseInsensitive);
converted.replace("wht", "w", Qt::CaseInsensitive);
converted.replace("colorless", "c", Qt::CaseInsensitive);
converted.replace("colourless", "c", Qt::CaseInsensitive);
converted.replace("none", "c", Qt::CaseInsensitive);
converted.replace("green", "g");
converted.replace("grn", "g");
converted.replace("blue", "u");
converted.replace("blu", "u");
converted.replace("black", "b");
converted.replace("blk", "b");
converted.replace("red", "r");
converted.replace("white", "w");
converted.replace("wht", "w");
converted.replace("colorless", "c");
converted.replace("colourless", "c");
converted.replace("none", "c");
converted.replace(QString(" "), QString(""), Qt::CaseInsensitive);
converted.replace(QString(" "), QString(""));
convertedColorTerm = converted;
break;
@ -358,12 +359,17 @@ bool FilterItem::acceptSubType(const CardInfoPtr &info) const
return subType.contains(term, Qt::CaseInsensitive);
}
bool FilterItem::acceptColor(const CardInfoPtr &info) const
/**
* @brief Return true when every letter of the given term appears in the given colors string.
* If the term is equal to "c", then only return true if the color string is empty.
*
* @param term The letters which should be included, or "c"
* @param colors The string to check for letters in
*/
static bool matchesColor(const QString &term, const QString &colors)
{
const QString colors = info->getColors();
// Colorless card filter
if (convertedColorTerm.toLower() == "c" && colors.length() < 1) {
if (term == "c" && colors.length() < 1) {
return true;
}
@ -372,13 +378,23 @@ bool FilterItem::acceptColor(const CardInfoPtr &info) const
* then we should match all of them to the card's colors
*/
int match_count = 0;
for (const QChar &color : convertedColorTerm) {
for (const QChar &color : term) {
if (colors.contains(color, Qt::CaseInsensitive)) {
match_count++;
}
}
return match_count == convertedColorTerm.length();
return match_count == term.length();
}
bool FilterItem::acceptColor(const CardInfoPtr &info) const
{
return matchesColor(convertedColorTerm, info->getColors());
}
bool FilterItem::acceptColorIdentity(const CardInfoPtr &info) const
{
return matchesColor(convertedColorTerm, info->getColorIdentity());
}
bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const
@ -540,6 +556,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info,
return acceptType(info);
case CardFilter::AttrColor:
return acceptColor(info);
case CardFilter::AttrColorIdentity:
return acceptColorIdentity(info);
case CardFilter::AttrText:
return acceptText(info, searchLanguage);
case CardFilter::AttrSet:

View file

@ -244,6 +244,7 @@ public:
[[nodiscard]] bool acceptMainType(const CardInfoPtr &info) const;
[[nodiscard]] bool acceptSubType(const CardInfoPtr &info) const;
[[nodiscard]] bool acceptColor(const CardInfoPtr &info) const;
[[nodiscard]] bool acceptColorIdentity(const CardInfoPtr &info) const;
[[nodiscard]] bool acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const;
[[nodiscard]] bool acceptSet(const CardInfoPtr &info) const;
[[nodiscard]] bool acceptManaCost(const CardInfoPtr &info) const;