Compare commits

..

11 commits

Author SHA1 Message Date
Lukas Brübach
ff19c36218
[VisualDatabase] Refresh filter toolbar label icons on theme change
The Sort by and Filter by labels kept a one-shot pixmap snapshot, so they stayed
at the previous palette until the toolbar was rebuilt. Re-render both snapshots
when the palette or theme changes.
2026-10-21 05:11:15 +02:00
Lukas Brübach
60dbbf2883
[PrintingSelector] Load the pin badge through the themed icon engine
The badge rasterized `theme:icons/pin` with QImageReader, which cannot resolve
the palette token and left the glyph at its first-render colors. Ask QIcon for a
64x64 logical pixmap instead, and re-render the badge when the palette or theme
changes so it stays legible against both card art and the surrounding UI.
2026-10-21 05:11:14 +02:00
Lukas Brübach
ebf29155c3
[Game] Follow the palette in judge message tags
Judge tags hardcoded a black prefix and a `theme:icons/scales` image inside rich
text, where `currentColor` cannot resolve and a palette change leaves the tag
unreadable. Render the scales glyph through the themed icon engine, register it
as a document image resource, and repaint the log when the palette or theme
changes.
2026-10-21 05:11:14 +02:00
Lukas Brübach
b7d8d3448e
[Settings] Delete update channels at shutdown
SettingsCache owns the stable and beta update channels as raw pointers, but
they have no parent and are never deleted. Each owns a QNetworkAccessManager,
so the test process leaves QtNetwork objects alive past QApplication teardown.

Delete the channels in the SettingsCache destructor so their network managers
are destroyed while the application object is still alive.
2026-10-21 05:11:14 +02:00
Lukas Brübach
63a8f6b10b
[Icons] Add an icon asset ratchet test
The icon rules were enforced by review only, so the next Inkscape export could
reintroduce a baked fill or a physical size without anyone noticing.

Add a test that rejects non-24 viewBoxes, physical sizes, editor metadata,
hardcoded paints, missing tokens, color-coded filenames, and rasters. Keep the
legacy baseline empty so the ratchet can only tighten.
2026-10-21 05:11:13 +02:00
Lukas Brübach
8849c11f66
[Icons] Migrate UI glyphs to palette-token SVGs
Runtime inversion is not a theming system: it rotates hue, breaks duotone art
and mishandles alpha, and a QPixmap snapshot cannot follow a palette change.
Redraw the top-level UI glyph set on a 24x24 grid with `currentColor` as the
paint token, drop Inkscape/Sodipodi cruft, and delete the icons whose metaphor
was duplicated (cogwheel/gear -> settings, scale_balanced -> scales,
update.png -> reload).

Load the glyphs through `loadThemedIcon` so the engine resolves the token from
the palette on every request, and let `themePixmap` use the same resolution for
one-shot pixmaps. `loadColorAdjustedPixmap` is gone; every caller now keeps a
QIcon, which `SettingsButtonWidget::setButtonIcon` accepts instead of a QPixmap.

Content art (mana, counters, countries) keeps its authored colors.

Add a render test covering aspect ratio, palette flips, icon modes and
high-DPI pixmaps.
2026-10-21 05:11:13 +02:00
Lukas Brübach
eb7e866211
[Icons] Add palette-aware themed icon engine
A QPixmap snapshot cannot follow palette changes, so an authored `currentColor`
glyph would freeze at whatever color it was first rendered with. Add a QIcon
engine that resolves the sentinel from the current palette on every pixmap
request, caches by resolved path/color/size/DPR, and falls back to Qt's normal
loader for authored multi-color art and raster overrides.

Purely additive. No behavior change yet.
2026-10-21 05:11:12 +02:00
Lukas Brübach
9978be9ab7
[Icons] Add SVG currentColor sentinel resolver
Qt's SVG renderer has no CSS cascade, so an authored `currentColor` never
resolves. Resolve the sentinel to a concrete palette color as plain bytes,
before the SVG reaches QSvgRenderer.
2026-10-21 05:11:11 +02:00
BruebachL
0fe4fd0d8c
[CardDB] Guard guessCard and findPrintingWithId against missing cards (#7592)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Desktop / Prune caches (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / Ubuntu 26.04 cover (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
* [CardDB] Guard guessCard and findPrintingWithId against missing cards

guessCard() dereferenced a null CardInfoPtr through findPrintingWithId() when the name was unknown and a providerId was given. Return an empty ExactCard instead, and make findPrintingWithId() null-safe for every caller.

Add regression tests covering both paths.

* [CardDB] Rely on PrintingInfo's default null set in findPrintingWithId

The explicit nullptr was redundant: the constructor already defaults
the set to null.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-20 21:33:56 +02:00
BruebachL
9980c90cea
[Filter] Allow spaces around the colon in search queries (#7529)
* [Filter] Allow spaces around the colon in search queries

* [Filter] Keep whitespace out of the color-identity operator

Allowing `ws?` between `color` and the identity marker made `c i: g`
parse as identity green, changing the meaning of text that previously
read as a generic `c` plus a field query on `i`. Whitespace belongs
around the separator, not inside an operator token: `ci : g` and
`ci: g` stay valid, `c i: g` keeps its old parse.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-20 21:20:10 +02:00
BruebachL
9709fe4a1a
[FlowWidget] Restore arrow-key navigation for items in flow widgets (#7591)
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 / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (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 / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (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 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (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
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-20 16:53:04 +02:00
6 changed files with 85 additions and 22 deletions

View file

@ -105,9 +105,9 @@ void FlowWidget::addWidget(QWidget *widget_to_add)
*
* The widget is filtered for arrow-key events so keyboard navigation between
* the flow items keeps working even when the flow sits inside a QScrollArea,
* which swallows arrow keys before they can reach FlowWidget::keyPressEvent.
* Only widgets added through this method are affected; anything that needs its
* own arrow keys should use plain addWidget.
* which swallows arrow keys before they can bubble up. Only widgets added
* through this method are affected; anything that needs its own arrow keys
* should use plain addWidget.
*
* @param widget_to_add The widget to add to the flow layout.
*/
@ -207,17 +207,12 @@ QLayoutItem *FlowWidget::itemAt(int index) const
return flowLayout->itemAt(index);
}
void FlowWidget::keyPressEvent(QKeyEvent *event)
{
if (moveFocus(event)) {
event->accept();
return;
}
QWidget::keyPressEvent(event);
}
bool FlowWidget::eventFilter(QObject *watched, QEvent *event)
{
// Arrow-key navigation is deliberately scoped to widgets that opted in via
// addNavigableWidget(). Handling arrow keys in keyPressEvent() as well would
// also catch the events ordinary children (QLineEdit, ...) ignore and move
// focus away from the control the user is typing in.
if (event->type() == QEvent::KeyPress && moveFocus(static_cast<QKeyEvent *>(event))) {
return true;
}

View file

@ -51,7 +51,6 @@ public slots:
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private:
/// @brief Moves keyboard focus to an adjacent flow item for an arrow-key event.

View file

@ -133,6 +133,10 @@ ExactCard CardDatabaseQuerier::getCard(const CardRef &cardRef) const
ExactCard CardDatabaseQuerier::guessCard(const CardRef &cardRef) const
{
auto card = lookupCardByName(cardRef.name);
if (!card) {
return {};
}
auto printing =
cardRef.providerId.isEmpty() ? getPreferredPrinting(card) : findPrintingWithId(card, cardRef.providerId);
@ -173,10 +177,14 @@ ExactCard CardDatabaseQuerier::getCardFromSameSet(const QString &cardName, const
*
* @param cardInfo The CardInfo to search
* @param providerId The uuid to look for
* @return The PrintingInfo, or a default-constructed PrintingInfo if not found.
* @return The PrintingInfo, or a default-constructed PrintingInfo if not found or if cardInfo is null.
*/
PrintingInfo CardDatabaseQuerier::findPrintingWithId(const CardInfoPtr &cardInfo, const QString &providerId) const
{
if (!cardInfo) {
return PrintingInfo();
}
for (const auto &printings : cardInfo->getSets()) {
for (const auto &printing : printings) {
if (printing.getUuid() == providerId) {

View file

@ -43,31 +43,31 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart
QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery
NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart
SetQuery <- ('e'/'set') SetQueryValue
SetQueryValue <- ([=:] FlexStringValue) / (<[!][=]?> FlexStringValue) / SetExpression
OracleQuery <- 'o' [:] MatcherString
SetQuery <- ('e'/'set') ws? SetQueryValue
SetQueryValue <- ([=:] ws? FlexStringValue) / (<[!][=]?> ws? FlexStringValue) / SetExpression
OracleQuery <- 'o' ws? [:] ws? MatcherString
CMCQuery <- ('cmc'/'mv') ws? NumericExpression
PowerQuery <- [Pp] 'ow' 'er'? ws? NumericExpression
ToughnessQuery <- [Tt] 'ou' 'ghness'? ws? NumericExpression
RarityQuery <- [rR] ':' Rarity
RarityQuery <- [rR] ws? ':' ws? Rarity
Rarity <- [Cc] 'ommon'? / [Uu] 'ncommon'? / [Rr] 'are'? / [Mm] 'ythic'? / [Ss] 'pecial'? / [a-zA-Z] [a-z]*
FormatQuery <- 'f' ':' Format / Legality ':' Format
FormatQuery <- 'f' ws? ':' ws? Format / Legality ws? ':' ws? Format
Format <- [a-zA-Z] [a-z]*
Legality <- [Ll] 'egal'? / [Bb] 'anned'? / [Rr] 'estricted'
TypeQuery <- [tT] 'ype'? [:] StringValue
TypeQuery <- [tT] 'ype'? ws? [:] ws? StringValue
Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? >
ColorEx <- Color / [mc]
ColorQuery <- [cC] 'olor'? <[iI]?> <[:!]> ColorEx*
ColorQuery <- [cC] 'olor'? <[iI]?> ws? <[:!]> ws? ColorEx*
FieldQuery <- String [:] MatcherString / String ws? NumericExpression
FieldQuery <- String ws? [:] ws? MatcherString / String ws? NumericExpression
NonDoubleQuoteUnlessEscaped <- '\\\"'. / !["].
NonSingleQuoteUnlessEscaped <- "\\\'". / !['].

View file

@ -1,6 +1,7 @@
#include "libcockatrice/card/database/card_database.h"
#include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/set/card_set.h"
#include "libcockatrice/card/set/card_set_list.h"
#include "test_card_database_path_provider.h"
@ -29,6 +30,7 @@
#include <libcockatrice/interfaces/interface_card_set_priority_controller.h>
#include <libcockatrice/interfaces/noop_card_preference_provider.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
#include <libcockatrice/utility/card_ref.h>
#include <string>
namespace
@ -60,6 +62,31 @@ TEST(CardDatabaseTest, LoadXml)
ASSERT_EQ(NotLoaded, db->getLoadStatus()) << "Incorrect status after clear";
}
TEST(CardDatabaseTest, GuessCardWithProviderIdOnUnknownCardReturnsEmpty)
{
// An unknown name plus a providerId used to dereference the missing card
// inside findPrintingWithId(); the lookup must come back empty instead.
NoopCardPreferenceProvider prefs;
TestCardDatabasePathProvider pathProvider;
NoopCardSetPriorityController controller;
CardDatabase db(nullptr, &prefs, &pathProvider, &controller);
const ExactCard guessed = db.query()->guessCard({"Not a Card", "card_Not a Card_fixture"});
EXPECT_TRUE(guessed.isEmpty());
EXPECT_FALSE(guessed);
}
TEST(CardDatabaseTest, FindPrintingWithIdOnNullCardInfoReturnsEmpty)
{
NoopCardPreferenceProvider prefs;
TestCardDatabasePathProvider pathProvider;
NoopCardSetPriorityController controller;
CardDatabase db(nullptr, &prefs, &pathProvider, &controller);
EXPECT_TRUE(db.query()->findPrintingWithId(nullptr, "card_Not a Card_fixture").isEmpty());
}
TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip)
{
NoopCardSetPriorityController controller;

View file

@ -99,6 +99,17 @@ QUERY(SetCodeWrongSet2, doctor, "set:cat", false)
QUERY(BracketNextToUnquotedString, cat, "(o:woof OR o:meow)", true)
// Spaces around the colon are optional for all field queries (e.g. "o: flying")
QUERY(OracleSpaceAfterColon, cat, "o: meow", true)
QUERY(OracleSpaceBeforeColon, cat, "o :meow", true)
QUERY(OracleSpacesAroundColon, cat, "o : meow", true)
QUERY(OracleSpaceAfterColonNoMatch, cat, "o: woof", false)
QUERY(TypeSpaceAfterColon, cat, "t: creature", true)
QUERY(FieldSpaceAfterColon, cat, "pt: \"3/3\"", true)
QUERY(ColorSpaceAfterColon, cat, "c: g", true)
QUERY(ColorSpaceBeforeOperator, cat, "c !g", true)
QUERY(SetSpaceAfterColon, cat, "set: cat", true)
CardInfoPtr localizedCat()
{
CardInfoPtr localized = CardInfo::newInstance("Cat", "Meow!", false, {}, {}, {}, {}, {});
@ -107,6 +118,22 @@ CardInfoPtr localizedCat()
return localized;
}
CardInfoPtr greenIdentityCard()
{
return CardInfo::newInstance("Identity Cat", "", false, {{"colors", "G"}, {"coloridentity", "G"}}, {}, {}, {}, {});
}
// Whitespace belongs around the separator, never inside an operator token: "c i: g" must not
// be read as the color-identity operator "ci" (it is a generic "c" AND a field query on "i").
TEST_F(CardQuery, ColorIdentityOperatorIsNotSplitByWhitespace)
{
const CardData identityCard = greenIdentityCard();
ASSERT_TRUE(FilterString("ci:g").check(identityCard));
ASSERT_TRUE(FilterString("ci: g").check(identityCard));
ASSERT_TRUE(FilterString("ci : g").check(identityCard));
ASSERT_FALSE(FilterString("c i: g").check(identityCard));
}
TEST_F(CardQuery, SearchLanguageEnglishMatchesOnlyEnglish)
{
const CardData localized = localizedCat();
@ -136,6 +163,13 @@ TEST_F(CardQuery, SearchLanguageBothMatchesEitherLanguage)
ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized));
}
TEST_F(CardQuery, SearchLanguageBothMatchesOracleTextWithSpaceAfterColon)
{
const CardData localized = localizedCat();
ASSERT_TRUE(FilterString("o: Meow", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized));
ASSERT_TRUE(FilterString("o: miaut", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized));
}
TEST_F(CardQuery, SearchLanguageIsBoundPerInstance)
{
const CardData localized = localizedCat();