ygo set collection

This commit is contained in:
sdine
2026-07-23 09:24:32 +02:00
parent 7e03f4d8d5
commit ceb8605b5f
24 changed files with 1720 additions and 46 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ The `ccm` executable — composition root only. The single place where concrete
## Conventions ## Conventions
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `DigiBattle99SetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here. 1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `YuGiOhSetCatalogService`, `DigiBattle99SetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually. 2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set). 3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). Pokemon West (`Game::Pokemon`) and Asia (`Game::JapanesePokemon`) both map to `"pokemon"`; `JsonSetRepository` stores their set caches as `sets-west.json` / `sets-asia.json` in that directory (other games keep `sets.json`). 4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). Pokemon West (`Game::Pokemon`) and Asia (`Game::JapanesePokemon`) both map to `"pokemon"`; `JsonSetRepository` stores their set caches as `sets-west.json` / `sets-asia.json` in that directory (other games keep `sets.json`).
+6 -1
View File
@@ -22,6 +22,7 @@
#include "ccm/services/CollectionService.hpp" #include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp" #include "ccm/services/ConfigService.hpp"
#include "ccm/services/DigiBattle99SetCatalogService.hpp" #include "ccm/services/DigiBattle99SetCatalogService.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include "ccm/services/ImageService.hpp" #include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp" #include "ccm/services/SetService.hpp"
#include "ccm/ui/AppContext.hpp" #include "ccm/ui/AppContext.hpp"
@@ -113,6 +114,8 @@ public:
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame); setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
digiBattle99CatalogStore_ = digiBattle99CatalogStore_ =
std::make_unique<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame); std::make_unique<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame);
ygoCatalogStore_ =
std::make_unique<ccm::YuGiOhSetCatalogService>(*fs_, *config_, &dirNameForGame);
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame); imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_); imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
@@ -163,7 +166,8 @@ public:
pokeView_ = std::make_unique<ccm::ui::PokemonGameView>( pokeView_ = std::make_unique<ccm::ui::PokemonGameView>(
*config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_); *config_, *pokeCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *pokeMod_);
ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>( ygoView_ = std::make_unique<ccm::ui::YuGiOhGameView>(
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_); *config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_,
*ygoCatalogStore_);
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>( digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
*digiBattle99Mod_, *digiBattle99CatalogStore_); *digiBattle99Mod_, *digiBattle99CatalogStore_);
@@ -207,6 +211,7 @@ private:
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_; std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
std::unique_ptr<ccm::JsonSetRepository> setRepo_; std::unique_ptr<ccm::JsonSetRepository> setRepo_;
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_; std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
std::unique_ptr<ccm::YuGiOhSetCatalogService> ygoCatalogStore_;
std::unique_ptr<ccm::LocalImageStore> imgStore_; std::unique_ptr<ccm::LocalImageStore> imgStore_;
std::unique_ptr<ccm::ImageService> imgSvc_; std::unique_ptr<ccm::ImageService> imgSvc_;
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_; std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
+3 -3
View File
@@ -4,11 +4,11 @@
## Layer pointers ## Layer pointers
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`. - `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `YuGiOhSetCatalog` (Yu-Gi-Oh! pack checklists for set completion), `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns. - `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. - `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `DigiBattle99SetCompletion` (pure Digi-Battle set-completion / checklist helpers), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`). They depend only on ports / domain. - `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `YuGiOhSetCompletion` / `DigiBattle99SetCompletion` (pure set-completion / checklist helpers), `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`). They depend only on ports / domain.
- `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `DigiBattle99SetSource` also exposes `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect. - `include/ccm/games/``IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `YuGiOhSetSource` and `DigiBattle99SetSource` also expose `parseCatalog` / `fetchAllWithCatalog` for set-completion checklists. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect.
- `include/ccm/util/``Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested). - `include/ccm/util/``Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
- `src/` mirrors `include/ccm/` for non-template implementations. - `src/` mirrors `include/ccm/` for non-template implementations.
+3
View File
@@ -9,6 +9,7 @@ add_library(ccm_core STATIC
src/domain/YuGiOhCard.cpp src/domain/YuGiOhCard.cpp
src/domain/DigiBattle99Card.cpp src/domain/DigiBattle99Card.cpp
src/domain/DigiBattle99SetCatalog.cpp src/domain/DigiBattle99SetCatalog.cpp
src/domain/YuGiOhSetCatalog.cpp
src/domain/JapanesePokemonCard.cpp src/domain/JapanesePokemonCard.cpp
src/domain/Configuration.cpp src/domain/Configuration.cpp
@@ -20,6 +21,8 @@ add_library(ccm_core STATIC
src/services/CardFilter.cpp src/services/CardFilter.cpp
src/services/DigiBattle99SetCompletion.cpp src/services/DigiBattle99SetCompletion.cpp
src/services/DigiBattle99SetCatalogService.cpp src/services/DigiBattle99SetCatalogService.cpp
src/services/YuGiOhSetCompletion.cpp
src/services/YuGiOhSetCatalogService.cpp
src/infra/CprHttpClient.cpp src/infra/CprHttpClient.cpp
src/infra/StdFileSystem.cpp src/infra/StdFileSystem.cpp
@@ -0,0 +1,52 @@
#pragma once
// YuGiOhSetCatalog: offline pack → card checklist for Yu-Gi-Oh! set
// completion. Filled from YGOPRODeck cardinfo.php (all-cards dump) and
// persisted at `<dataStorage>/yugioh/set-catalog.json`.
#include <nlohmann/json.hpp>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhCatalogCard {
std::string setNo;
std::string name;
friend bool operator==(const YuGiOhCatalogCard&,
const YuGiOhCatalogCard&) = default;
};
struct YuGiOhSetCatalogPack {
std::string setId;
std::string setName;
std::vector<YuGiOhCatalogCard> cards;
friend bool operator==(const YuGiOhSetCatalogPack&,
const YuGiOhSetCatalogPack&) = default;
};
struct YuGiOhSetCatalog {
std::vector<YuGiOhSetCatalogPack> packs;
[[nodiscard]] const YuGiOhSetCatalogPack* findPack(
std::string_view setId) const;
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
friend bool operator==(const YuGiOhSetCatalog&,
const YuGiOhSetCatalog&) = default;
};
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c);
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c);
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p);
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p);
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c);
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c);
} // namespace ccm
@@ -1,21 +1,45 @@
#pragma once #pragma once
// YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck. // YuGiOhSetSource: ISetSource implementation for Yu-Gi-Oh via YGOPRODeck.
// Sets come from cardsets.php; the set-completion catalog is built from the
// unfiltered cardinfo.php dump (card_sets[] per card).
#include "ccm/domain/Set.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/games/IGameModule.hpp" #include "ccm/games/IGameModule.hpp"
#include "ccm/ports/IHttpClient.hpp" #include "ccm/ports/IHttpClient.hpp"
#include <string>
#include <vector>
namespace ccm { namespace ccm {
class YuGiOhSetSource final : public ISetSource { class YuGiOhSetSource final : public ISetSource {
public: public:
static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php"; static constexpr const char* kEndpoint = "https://db.ygoprodeck.com/api/v7/cardsets.php";
static constexpr const char* kCardInfoEndpoint =
"https://db.ygoprodeck.com/api/v7/cardinfo.php";
struct FetchWithCatalog {
std::vector<Set> sets;
YuGiOhSetCatalog catalog;
};
explicit YuGiOhSetSource(IHttpClient& http); explicit YuGiOhSetSource(IHttpClient& http);
Result<std::vector<Set>> fetchAll() override; Result<std::vector<Set>> fetchAll() override;
// Two HTTP round-trips: cardsets.php for the set list, cardinfo.php for
// the pack checklist catalog.
Result<FetchWithCatalog> fetchAllWithCatalog();
static Result<std::vector<Set>> parseResponse(const std::string& body); static Result<std::vector<Set>> parseResponse(const std::string& body);
// Build the offline checklist from a cardinfo.php body, resolving pack
// ids against the already-parsed sets list (by set_name → Set.id).
static Result<YuGiOhSetCatalog> parseCatalog(const std::string& body,
const std::vector<Set>& sets);
private: private:
IHttpClient& http_; IHttpClient& http_;
}; };
@@ -0,0 +1,36 @@
#pragma once
// YuGiOhSetCatalogService: load/save yugioh/set-catalog.json under the
// configured dataStorage path.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/ports/IFileSystem.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/util/Result.hpp"
#include <functional>
#include <string>
namespace ccm {
class YuGiOhSetCatalogService {
public:
using DirNameFn = std::function<std::string(Game)>;
YuGiOhSetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
Result<YuGiOhSetCatalog> load() const;
Result<void> save(const YuGiOhSetCatalog& catalog);
[[nodiscard]] bool exists() const;
private:
IFileSystem& fs_;
ConfigService& config_;
DirNameFn dirName_;
[[nodiscard]] std::filesystem::path catalogPath() const;
};
} // namespace ccm
@@ -0,0 +1,60 @@
#pragma once
// Pure helpers: Yu-Gi-Oh! set-completion progress and per-set checklists.
// Ownership counts only when collection card.set.id matches the pack and the
// printing slot matches a catalog setNo (ygoPrintingSlotsMatch). Duplicates /
// amount / rarity / firstEdition do not inflate the numerator. An optional
// languageFilter restricts ownership to cards of that language (packs with
// zero matches are omitted).
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include <cstddef>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ccm {
struct YuGiOhSetCompletionProgress {
std::string setId;
std::string setName;
std::size_t ownedUnique{0};
std::size_t total{0};
[[nodiscard]] int percent() const noexcept {
if (total == 0) return 0;
return static_cast<int>((ownedUnique * 100) / total);
}
};
struct YuGiOhChecklistEntry {
std::string setNo;
std::string name;
bool owned{false};
};
// Distinct languages present in the collection, in allLanguages() order.
[[nodiscard]] std::vector<Language>
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection);
// Packs where the collection owns ≥1 card with matching set.id, ordered by
// setName. Packs absent from the catalog are skipped. When languageFilter is
// set, only cards of that language count toward ownership.
[[nodiscard]] std::vector<YuGiOhSetCompletionProgress>
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::optional<Language> languageFilter = std::nullopt);
// Full catalog checklist for one pack; owned flags from the collection.
// When languageFilter is set, only cards of that language count as owned.
[[nodiscard]] std::vector<YuGiOhChecklistEntry>
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter = std::nullopt);
} // namespace ccm
+39
View File
@@ -0,0 +1,39 @@
#include "ccm/domain/YuGiOhSetCatalog.hpp"
namespace ccm {
const YuGiOhSetCatalogPack* YuGiOhSetCatalog::findPack(std::string_view setId) const {
for (const auto& pack : packs) {
if (pack.setId == setId) return &pack;
}
return nullptr;
}
void to_json(nlohmann::json& j, const YuGiOhCatalogCard& c) {
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
}
void from_json(const nlohmann::json& j, YuGiOhCatalogCard& c) {
j.at("setNo").get_to(c.setNo);
j.at("name").get_to(c.name);
}
void to_json(nlohmann::json& j, const YuGiOhSetCatalogPack& p) {
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
}
void from_json(const nlohmann::json& j, YuGiOhSetCatalogPack& p) {
j.at("id").get_to(p.setId);
j.at("name").get_to(p.setName);
j.at("cards").get_to(p.cards);
}
void to_json(nlohmann::json& j, const YuGiOhSetCatalog& c) {
j = nlohmann::json{{"packs", c.packs}};
}
void from_json(const nlohmann::json& j, YuGiOhSetCatalog& c) {
j.at("packs").get_to(c.packs);
}
} // namespace ccm
+152
View File
@@ -1,10 +1,15 @@
#include "ccm/games/yugioh/YuGiOhSetSource.hpp" #include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <nlohmann/json.hpp> #include <nlohmann/json.hpp>
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <cctype>
#include <string> #include <string>
#include <unordered_map>
#include <utility>
namespace ccm { namespace ccm {
namespace { namespace {
@@ -39,6 +44,47 @@ void appendMissingSetAliases(std::vector<Set>& sets) {
} }
} }
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
const std::string digits = ygoCollectorDigitsOnly(setNo);
if (abbrev.empty() || digits.empty()) return {};
return abbrev + "|" + digits;
}
[[nodiscard]] bool ygoHasEnRegionInfix(std::string_view setCode) {
const std::string_view s = trimAsciiSpaces(setCode);
const auto dash = s.find('-');
if (dash == std::string_view::npos || dash + 3 > s.size()) return false;
const std::string_view tail = s.substr(dash + 1);
if (tail.size() < 3) return false;
return (tail[0] == 'E' || tail[0] == 'e') && (tail[1] == 'N' || tail[1] == 'n')
&& std::isdigit(static_cast<unsigned char>(tail[2])) != 0;
}
[[nodiscard]] std::string uppercaseAscii(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) {
return static_cast<char>(std::toupper(c));
});
return s;
}
[[nodiscard]] std::string resolvePackId(const std::unordered_map<std::string, std::string>& nameToId,
const std::string& setName,
const std::string& setCode) {
const auto it = nameToId.find(setName);
if (it != nameToId.end() && !it->second.empty()) return it->second;
const std::string abbrev = uppercaseAscii(ygoAbbrevBeforeDash(setCode));
return abbrev;
}
struct PackBuild {
std::string setId;
std::string setName;
// slotKey → index into cards (for EN preference upgrades).
std::unordered_map<std::string, std::size_t> slotIndex;
std::vector<YuGiOhCatalogCard> cards;
};
} // namespace } // namespace
YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {} YuGiOhSetSource::YuGiOhSetSource(IHttpClient& http) : http_(http) {}
@@ -73,10 +119,116 @@ Result<std::vector<Set>> YuGiOhSetSource::parseResponse(const std::string& body)
} }
} }
Result<YuGiOhSetCatalog> YuGiOhSetSource::parseCatalog(const std::string& body,
const std::vector<Set>& sets) {
try {
const auto j = nlohmann::json::parse(body);
if (!j.is_object() || !j.contains("data") || !j.at("data").is_array()) {
return Result<YuGiOhSetCatalog>::err(
"YGOPRODeck cardinfo response missing data array.");
}
std::unordered_map<std::string, std::string> nameToId;
nameToId.reserve(sets.size());
for (const auto& set : sets) {
if (set.name.empty() || set.id.empty()) continue;
// First wins — aliases and upstream rows rarely collide by name.
nameToId.emplace(set.name, set.id);
}
// Keyed by pack setId.
std::unordered_map<std::string, PackBuild> byId;
for (const auto& cardJson : j.at("data")) {
const std::string cardName = cardJson.value("name", "");
if (cardName.empty()) continue;
if (!cardJson.contains("card_sets") || !cardJson.at("card_sets").is_array()) {
continue;
}
for (const auto& printing : cardJson.at("card_sets")) {
const std::string setName = printing.value("set_name", "");
const std::string setCode = printing.value("set_code", "");
if (setName.empty() || setCode.empty()) continue;
if (ygoLikelyEuropeanRegionalSetCode(setCode)) continue;
const std::string slot = ygoSlotKey(setCode);
if (slot.empty()) continue;
const std::string packId = resolvePackId(nameToId, setName, setCode);
if (packId.empty()) continue;
auto& build = byId[packId];
if (build.setId.empty()) {
build.setId = packId;
build.setName = setName;
}
const auto existing = build.slotIndex.find(slot);
if (existing == build.slotIndex.end()) {
build.slotIndex.emplace(slot, build.cards.size());
build.cards.push_back(YuGiOhCatalogCard{setCode, cardName});
continue;
}
// Prefer an EN-embedded code over a bare / other-region equivalent.
auto& prev = build.cards[existing->second];
if (!ygoHasEnRegionInfix(prev.setNo) && ygoHasEnRegionInfix(setCode)) {
prev.setNo = setCode;
if (!cardName.empty()) prev.name = cardName;
}
}
}
YuGiOhSetCatalog catalog;
catalog.packs.reserve(byId.size());
for (auto& [_, build] : byId) {
if (build.setId.empty() || build.cards.empty()) continue;
std::sort(build.cards.begin(), build.cards.end(),
[](const YuGiOhCatalogCard& a, const YuGiOhCatalogCard& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
YuGiOhSetCatalogPack pack;
pack.setId = std::move(build.setId);
pack.setName = std::move(build.setName);
pack.cards = std::move(build.cards);
catalog.packs.push_back(std::move(pack));
}
std::sort(catalog.packs.begin(), catalog.packs.end(),
[](const YuGiOhSetCatalogPack& a, const YuGiOhSetCatalogPack& b) {
return a.setName < b.setName;
});
return Result<YuGiOhSetCatalog>::ok(std::move(catalog));
} catch (const std::exception& e) {
return Result<YuGiOhSetCatalog>::err(
std::string("YGOPRODeck catalog parse error: ") + e.what());
}
}
Result<std::vector<Set>> YuGiOhSetSource::fetchAll() { Result<std::vector<Set>> YuGiOhSetSource::fetchAll() {
auto resp = http_.get(kEndpoint); auto resp = http_.get(kEndpoint);
if (!resp) return Result<std::vector<Set>>::err(resp.error()); if (!resp) return Result<std::vector<Set>>::err(resp.error());
return parseResponse(resp.value()); return parseResponse(resp.value());
} }
Result<YuGiOhSetSource::FetchWithCatalog> YuGiOhSetSource::fetchAllWithCatalog() {
auto setsResp = http_.get(kEndpoint);
if (!setsResp) return Result<FetchWithCatalog>::err(setsResp.error());
auto sets = parseResponse(setsResp.value());
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
auto infoResp = http_.get(kCardInfoEndpoint);
if (!infoResp) return Result<FetchWithCatalog>::err(infoResp.error());
auto catalog = parseCatalog(infoResp.value(), sets.value());
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
FetchWithCatalog out;
out.sets = std::move(sets).value();
out.catalog = std::move(catalog).value();
return Result<FetchWithCatalog>::ok(std::move(out));
}
} // namespace ccm } // namespace ccm
@@ -0,0 +1,49 @@
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include <nlohmann/json.hpp>
#include <utility>
namespace ccm {
namespace fs = std::filesystem;
YuGiOhSetCatalogService::YuGiOhSetCatalogService(IFileSystem& fs,
ConfigService& config,
DirNameFn dirName)
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
fs::path YuGiOhSetCatalogService::catalogPath() const {
return fs::path(config_.current().dataStorage) / dirName_(Game::YuGiOh) /
"set-catalog.json";
}
bool YuGiOhSetCatalogService::exists() const {
return fs_.exists(catalogPath());
}
Result<YuGiOhSetCatalog> YuGiOhSetCatalogService::load() const {
const auto p = catalogPath();
if (!fs_.exists(p)) {
return Result<YuGiOhSetCatalog>::err("Yu-Gi-Oh! set catalog not yet downloaded.");
}
auto text = fs_.readText(p);
if (!text) return Result<YuGiOhSetCatalog>::err(text.error());
try {
const auto j = nlohmann::json::parse(text.value());
return Result<YuGiOhSetCatalog>::ok(j.get<YuGiOhSetCatalog>());
} catch (const std::exception& e) {
return Result<YuGiOhSetCatalog>::err(
std::string("set-catalog.json parse error: ") + e.what());
}
}
Result<void> YuGiOhSetCatalogService::save(const YuGiOhSetCatalog& catalog) {
const auto p = catalogPath();
auto dir = fs_.ensureDirectory(p.parent_path());
if (!dir) return dir;
const nlohmann::json j = catalog;
return fs_.writeText(p, j.dump(2));
}
} // namespace ccm
+132
View File
@@ -0,0 +1,132 @@
#include "ccm/services/YuGiOhSetCompletion.hpp"
#include "ccm/util/YuGiOhPrintingSlot.hpp"
#include <algorithm>
#include <array>
#include <unordered_map>
#include <unordered_set>
namespace ccm {
namespace {
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
[[nodiscard]] std::string ygoSlotKey(std::string_view setNo) {
const std::string abbrev = ygoAbbrevBeforeDash(setNo);
const std::string digits = ygoCollectorDigitsOnly(setNo);
if (abbrev.empty() || digits.empty()) return {};
return abbrev + "|" + digits;
}
bool passesLanguageFilter(const YuGiOhCard& card, std::optional<Language> languageFilter) {
return !languageFilter.has_value() || card.language == *languageFilter;
}
OwnedBySet ownedSlotsBySetId(const std::vector<YuGiOhCard>& collection,
std::optional<Language> languageFilter) {
OwnedBySet out;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id.empty()) continue;
const std::string key = ygoSlotKey(card.setNo);
if (key.empty()) continue;
out[card.set.id].insert(key);
}
return out;
}
} // namespace
std::vector<Language>
yuGiOhLanguagesInCollection(const std::vector<YuGiOhCard>& collection) {
const auto& langs = allLanguages();
std::array<bool, 10> present{};
for (const auto& card : collection) {
for (std::size_t i = 0; i < langs.size(); ++i) {
if (langs[i] == card.language) {
present[i] = true;
break;
}
}
}
std::vector<Language> out;
for (std::size_t i = 0; i < langs.size(); ++i) {
if (present[i]) out.push_back(langs[i]);
}
return out;
}
std::vector<YuGiOhSetCompletionProgress>
computeYuGiOhSetCompletion(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::optional<Language> languageFilter) {
const OwnedBySet owned = ownedSlotsBySetId(collection, languageFilter);
std::vector<YuGiOhSetCompletionProgress> out;
out.reserve(owned.size());
for (const auto& [setId, ownedSlots] : owned) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr || pack->cards.empty()) continue;
std::size_t matched = 0;
for (const auto& card : pack->cards) {
const std::string key = ygoSlotKey(card.setNo);
if (!key.empty() && ownedSlots.count(key) != 0) ++matched;
}
YuGiOhSetCompletionProgress row;
row.setId = pack->setId;
row.setName = pack->setName;
row.ownedUnique = matched;
row.total = pack->cards.size();
out.push_back(std::move(row));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhSetCompletionProgress& a,
const YuGiOhSetCompletionProgress& b) {
return a.setName < b.setName;
});
return out;
}
std::vector<YuGiOhChecklistEntry>
yuGiOhChecklistForSet(const std::vector<YuGiOhCard>& collection,
const YuGiOhSetCatalog& catalog,
std::string_view setId,
std::optional<Language> languageFilter) {
const auto* pack = catalog.findPack(setId);
if (pack == nullptr) return {};
std::unordered_set<std::string> ownedSlots;
for (const auto& card : collection) {
if (!passesLanguageFilter(card, languageFilter)) continue;
if (card.set.id != setId) continue;
const std::string key = ygoSlotKey(card.setNo);
if (!key.empty()) ownedSlots.insert(key);
}
std::vector<YuGiOhChecklistEntry> out;
out.reserve(pack->cards.size());
for (const auto& card : pack->cards) {
YuGiOhChecklistEntry entry;
entry.setNo = card.setNo;
entry.name = card.name;
const std::string key = ygoSlotKey(card.setNo);
entry.owned = !key.empty() && ownedSlots.count(key) != 0;
out.push_back(std::move(entry));
}
std::sort(out.begin(), out.end(),
[](const YuGiOhChecklistEntry& a, const YuGiOhChecklistEntry& b) {
if (a.setNo != b.setNo) return a.setNo < b.setNo;
return a.name < b.name;
});
return out;
}
} // namespace ccm
+11
View File
@@ -82,6 +82,17 @@ Used in two situations:
YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedias MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold. YGOPRODeck publishes rate limits and asks clients to cache responses and avoid abusive hotlinking; treat failures after burst traffic as an upstream policy signal, not an app bug. Yugipedias MediaWiki API is similarly polite — one batched call per preview lookup keeps us well under any normal threshold.
### Set-completion catalog (`cardinfo.php` all-cards dump)
**Sets → Update Yu-Gi-Oh!** uses `YuGiOhSetSource::fetchAllWithCatalog()` so two HTTP responses write:
1. The set list (`yugioh/sets.json`) from `cardsets.php` (same as before, including local 25th Anniversary aliases)
2. A pack checklist at `<dataStorage>/yugioh/set-catalog.json` from the unfiltered `cardinfo.php` dump
Each catalog pack stores `id` (YGOPRODeck product `set_code` / `Set.id`, e.g. `LOB`), `name` (display `set_name`), and `cards[]` of `{ setNo, name }` drawn from each cards `card_sets[]`. European `-E###` alternate codes are dropped; `LOB-005` / `LOB-EN005`-style equivalents collapse to one checklist row (preferring an `EN`-embedded code when present). The Yu-Gi-Oh! **Set Completion** tab reads this file offline; ownership for a pack requires matching `card.set.id` plus a printing-slot match (`ygoPrintingSlotsMatch` — same abbrev + digit run). Rarity and 1st Edition are ignored for completion counts.
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Yu-Gi-Oh!.
## Digimon Digi-Battle (1999) APIs (digimoncard.io) ## Digimon Digi-Battle (1999) APIs (digimoncard.io)
English Digi-Battle is wired as `Game::DigiBattle99` (`dirName` `digibattle99`, UI label **Digimon (Digi-Battle)**). Upstream docs: [digimoncard.io Public API](https://digimoncard.io/api-documentation). Always scope requests with `series=Digimon Digi-Battle Card Game` so modern Digimon Card Game rows are never mixed in. Rate limit: **15 requests / 10 seconds / IP** (429 then temporary block on abuse). English Digi-Battle is wired as `Game::DigiBattle99` (`dirName` `digibattle99`, UI label **Digimon (Digi-Battle)**). Upstream docs: [digimoncard.io Public API](https://digimoncard.io/api-documentation). Always scope requests with `series=Digimon Digi-Battle Card Game` so modern Digimon Card Game rows are never mixed in. Rate limit: **15 requests / 10 seconds / IP** (429 then temporary block on abuse).
+2 -1
View File
@@ -23,8 +23,9 @@
- `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`. - `pokemon_card_preview_source_tests.cpp``PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
- `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`. - `digibattle99_set_source_tests.cpp``DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`.
- `digibattle99_set_completion_tests.cpp``computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`. - `digibattle99_set_completion_tests.cpp``computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`.
- `yugioh_set_completion_tests.cpp``computeYuGiOhSetCompletion` / `yuGiOhChecklistForSet` ownership rules (printing-slot match) + `YuGiOhSetCatalogService` round-trip against `InMemoryFileSystem`.
- `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` via `FixedHttpClient`. - `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` via `FixedHttpClient`.
- `yugioh_set_source_tests.cpp``YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. - `yugioh_set_source_tests.cpp``YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks. Also `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist from `cardinfo.php`.
- `yugioh_set_lookup_tests.cpp``lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous). - `yugioh_set_lookup_tests.cpp``lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous).
- `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh / DigiBattle99) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`. - `game_module_tests.cpp` — smoke tests that each concrete `IGameModule` (Magic / Pokemon / Yu-Gi-Oh / DigiBattle99) reports stable `id()`, `dirName()`, `displayName()`, and a non-null `cardPreviewSource()` when constructed with a noop `IHttpClient`.
- `yugioh_card_preview_source_tests.cpp``YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005``LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing). - `yugioh_card_preview_source_tests.cpp``YuGiOhCardPreviewSource` Yugipedia + YGOPRODeck unit coverage. Helper-level tests pin down `normalizeName` (whitespace + Yugipedia-policy punctuation stripping), `ygoRarityShortCode` + `rarityCodeFor` (CCM3 dialog rarity names → canonical short codes used by both the YGO overview table and Yugipedia filename generation; unknown rarity falls through), `extractSetCode` (`LOB-005` / `LOB-DE005``LOB`), `buildCandidateFilenames` (printed-edition first, EN/NA/EU/AU + png/jpg, rarity-less fallback round, empty list when slug or set code is missing), `buildYugipediaQueryUrl` (single `titles=File:A|File:B` batch, percent-encoded), and `parseYugipediaResponse` (returns the URL of the highest-priority filename that resolved, errors when every candidate is `missing`). End-to-end `fetchImageUrl` cases use a `RoutingHttpClient` to verify Yugipedia is queried first and the per-printing scan is returned when found, that empty/error Yugipedia responses fall through to the YGOPRODeck `card_images[0]` fallback, that the YGOPRODeck error is propagated when both upstreams fail, and that an empty `setNo` skips Yugipedia entirely. `parseFirstPrint` preferred-`set_name` lookup is also covered for the auto-detect path. `parsePrintVariants` includes synthetic scenarios aligned with the `yugioh_same_card_set_variant_tests` fixture (dual-rarity vs multi-code within one display set, duplicate suppression, and no merge across unrelated `set_name` rows when the picker label matches nothing).
+1
View File
@@ -24,6 +24,7 @@ add_executable(ccm_core_tests
digibattle99_set_source_tests.cpp digibattle99_set_source_tests.cpp
digibattle99_card_preview_source_tests.cpp digibattle99_card_preview_source_tests.cpp
digibattle99_set_completion_tests.cpp digibattle99_set_completion_tests.cpp
yugioh_set_completion_tests.cpp
japanese_pokemon_en_catalog_tests.cpp japanese_pokemon_en_catalog_tests.cpp
japanese_pokemon_set_source_tests.cpp japanese_pokemon_set_source_tests.cpp
japanese_pokemon_card_preview_source_tests.cpp japanese_pokemon_card_preview_source_tests.cpp
+23
View File
@@ -3,6 +3,7 @@
#include "ccm/domain/Configuration.hpp" #include "ccm/domain/Configuration.hpp"
#include "ccm/domain/DigiBattle99Card.hpp" #include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/domain/DigiBattle99SetCatalog.hpp" #include "ccm/domain/DigiBattle99SetCatalog.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/domain/Enums.hpp" #include "ccm/domain/Enums.hpp"
#include "ccm/domain/JapanesePokemonCard.hpp" #include "ccm/domain/JapanesePokemonCard.hpp"
#include "ccm/domain/MagicCard.hpp" #include "ccm/domain/MagicCard.hpp"
@@ -268,6 +269,28 @@ TEST_SUITE("DigiBattle99SetCatalog JSON") {
} }
} }
TEST_SUITE("YuGiOhSetCatalog JSON") {
TEST_CASE("round-trips packs and setNo alias") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack pack;
pack.setId = "LOB";
pack.setName = "Legend of Blue Eyes White Dragon";
pack.cards.push_back(YuGiOhCatalogCard{"LOB-001", "Blue-Eyes White Dragon"});
pack.cards.push_back(YuGiOhCatalogCard{"LOB-EN005", "Dark Magician"});
catalog.packs.push_back(std::move(pack));
nlohmann::json j = catalog;
CHECK(j.at("packs").is_array());
CHECK(j.at("packs").at(0).at("id") == "LOB");
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "LOB-001");
const YuGiOhSetCatalog back = j.get<YuGiOhSetCatalog>();
CHECK(back == catalog);
CHECK(back.findPack("LOB") != nullptr);
CHECK(back.findPack("missing") == nullptr);
}
}
TEST_SUITE("JapanesePokemonCard JSON") { TEST_SUITE("JapanesePokemonCard JSON") {
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") { TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
JapanesePokemonCard c; JapanesePokemonCard c;
+250
View File
@@ -0,0 +1,250 @@
#include <doctest/doctest.h>
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include "ccm/services/YuGiOhSetCompletion.hpp"
#include "fakes/InMemoryFileSystem.hpp"
#include <nlohmann/json.hpp>
using namespace ccm;
using ccm::testing::InMemoryFileSystem;
namespace {
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
Configuration c;
c.dataStorage = dataDir;
c.defaultGame = Game::Magic;
fs.writeText("/app/config.json", nlohmann::json(c).dump());
ConfigService cfg{fs, "/app/config.json", dataDir};
cfg.initialize();
return cfg;
}
YuGiOhCard makeOwned(std::string setId, std::string setName, std::string setNo) {
YuGiOhCard c;
c.id = 1;
c.name = "Owned";
c.set.id = std::move(setId);
c.set.name = std::move(setName);
c.setNo = std::move(setNo);
return c;
}
YuGiOhSetCatalog sampleCatalog() {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack lob;
lob.setId = "LOB";
lob.setName = "Legend of Blue Eyes White Dragon";
lob.cards = {
{"LOB-001", "Blue-Eyes White Dragon"},
{"LOB-EN005", "Dark Magician"},
{"LOB-007", "Gaia The Fierce Knight"},
};
YuGiOhSetCatalogPack mrd;
mrd.setId = "MRD";
mrd.setName = "Metal Raiders";
mrd.cards = {
{"MRD-001", "Summoned Skull"},
{"LOB-001", "Blue-Eyes White Dragon"},
};
catalog.packs.push_back(std::move(mrd));
catalog.packs.push_back(std::move(lob));
return catalog;
}
} // namespace
TEST_SUITE("computeYuGiOhSetCompletion") {
TEST_CASE("only packs with owned cards appear") {
const auto catalog = sampleCatalog();
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
const auto rows = computeYuGiOhSetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "LOB");
CHECK(rows[0].ownedUnique == 1);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 33);
}
TEST_CASE("printing slot match treats LOB-005 and LOB-EN005 as one slot") {
const auto catalog = sampleCatalog();
YuGiOhCard a = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
a.amount = 4;
YuGiOhCard b = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-EN005");
b.id = 2;
YuGiOhCard c = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
c.id = 3;
const auto rows = computeYuGiOhSetCompletion({a, b, c}, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].ownedUnique == 2);
CHECK(rows[0].total == 3);
CHECK(rows[0].percent() == 66);
}
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
const auto catalog = sampleCatalog();
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
const auto rows = computeYuGiOhSetCompletion(collection, catalog);
REQUIRE(rows.size() == 1);
CHECK(rows[0].setId == "LOB");
}
TEST_CASE("empty catalog yields no rows") {
YuGiOhSetCatalog empty;
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
CHECK(computeYuGiOhSetCompletion(collection, empty).empty());
}
TEST_CASE("owned set missing from catalog is skipped") {
YuGiOhSetCatalog catalog;
YuGiOhSetCatalogPack onlyMrd;
onlyMrd.setId = "MRD";
onlyMrd.setName = "Metal Raiders";
onlyMrd.cards = {{"MRD-001", "Summoned Skull"}};
catalog.packs.push_back(std::move(onlyMrd));
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001"),
};
CHECK(computeYuGiOhSetCompletion(collection, catalog).empty());
}
TEST_CASE("language filter hides packs with no cards in that language") {
const auto catalog = sampleCatalog();
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
en.language = Language::English;
const auto allRows = computeYuGiOhSetCompletion({en}, catalog);
REQUIRE(allRows.size() == 1);
const auto deRows =
computeYuGiOhSetCompletion({en}, catalog, Language::German);
CHECK(deRows.empty());
const auto enRows =
computeYuGiOhSetCompletion({en}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
}
TEST_CASE("same slot in two languages counts once aggregated; filter is exclusive") {
const auto catalog = sampleCatalog();
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
en.language = Language::English;
YuGiOhCard de = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
de.id = 2;
de.language = Language::German;
const auto allRows = computeYuGiOhSetCompletion({en, de}, catalog);
REQUIRE(allRows.size() == 1);
CHECK(allRows[0].ownedUnique == 1);
const auto enRows =
computeYuGiOhSetCompletion({en, de}, catalog, Language::English);
REQUIRE(enRows.size() == 1);
CHECK(enRows[0].ownedUnique == 1);
YuGiOhCard deOnly = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
deOnly.id = 3;
deOnly.language = Language::German;
const auto deRows =
computeYuGiOhSetCompletion({en, de, deOnly}, catalog, Language::German);
REQUIRE(deRows.size() == 1);
CHECK(deRows[0].ownedUnique == 2);
}
}
TEST_SUITE("yuGiOhChecklistForSet") {
TEST_CASE("greys missing cards and marks owned ones") {
const auto catalog = sampleCatalog();
std::vector<YuGiOhCard> collection{
makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005"),
};
const auto list = yuGiOhChecklistForSet(collection, catalog, "LOB");
REQUIRE(list.size() == 3);
CHECK(list[0].setNo == "LOB-001");
CHECK(list[0].owned == false);
CHECK(list[1].setNo == "LOB-007");
CHECK(list[1].owned == false);
CHECK(list[2].setNo == "LOB-EN005");
CHECK(list[2].owned == true);
}
TEST_CASE("unknown set returns empty") {
const auto catalog = sampleCatalog();
CHECK(yuGiOhChecklistForSet({}, catalog, "missing").empty());
}
TEST_CASE("owned flags respect language filter") {
const auto catalog = sampleCatalog();
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
en.language = Language::English;
const auto filtered =
yuGiOhChecklistForSet({en}, catalog, "LOB", Language::German);
REQUIRE(filtered.size() == 3);
CHECK(filtered[0].owned == false);
CHECK(filtered[1].owned == false);
CHECK(filtered[2].owned == false);
const auto english =
yuGiOhChecklistForSet({en}, catalog, "LOB", Language::English);
REQUIRE(english.size() == 3);
CHECK(english[2].owned == true);
}
}
TEST_SUITE("yuGiOhLanguagesInCollection") {
TEST_CASE("empty collection yields empty") {
CHECK(yuGiOhLanguagesInCollection({}).empty());
}
TEST_CASE("returns distinct languages in allLanguages order") {
YuGiOhCard jp = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-001");
jp.language = Language::Japanese;
YuGiOhCard en = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-005");
en.id = 2;
en.language = Language::English;
YuGiOhCard enDup = makeOwned("MRD", "Metal Raiders", "MRD-001");
enDup.id = 3;
enDup.language = Language::English;
YuGiOhCard de = makeOwned("LOB", "Legend of Blue Eyes White Dragon", "LOB-007");
de.id = 4;
de.language = Language::German;
const auto langs = yuGiOhLanguagesInCollection({jp, en, enDup, de});
REQUIRE(langs.size() == 3);
CHECK(langs[0] == Language::English);
CHECK(langs[1] == Language::German);
CHECK(langs[2] == Language::Japanese);
}
}
TEST_SUITE("YuGiOhSetCatalogService") {
TEST_CASE("save then load round-trips") {
InMemoryFileSystem fs;
auto config = makeConfig(fs, "/data");
YuGiOhSetCatalogService store{fs, config, [](Game) { return "yugioh"; }};
CHECK_FALSE(store.exists());
CHECK(store.load().isErr());
const auto catalog = sampleCatalog();
REQUIRE(store.save(catalog).isOk());
CHECK(store.exists());
const auto loaded = store.load();
REQUIRE(loaded.isOk());
CHECK(loaded.value() == catalog);
}
}
+113
View File
@@ -3,6 +3,8 @@
#include "ccm/games/yugioh/YuGiOhSetSource.hpp" #include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/ports/IHttpClient.hpp" #include "ccm/ports/IHttpClient.hpp"
#include <vector>
using namespace ccm; using namespace ccm;
namespace { namespace {
@@ -172,3 +174,114 @@ TEST_SUITE("YuGiOhSetSource::fetchAll") {
CHECK(out.error() == "offline"); CHECK(out.error() == "offline");
} }
} }
TEST_SUITE("YuGiOhSetSource::parseCatalog") {
TEST_CASE("groups by set_name resolved to Set.id and dedupes printing slots") {
const std::vector<Set> sets{
Set{"LOB", "Legend of Blue Eyes White Dragon", "2002/03/08"},
Set{"MRD", "Metal Raiders", "2002/06/26"},
};
const std::string json = R"({
"data": [
{
"name": "Blue-Eyes White Dragon",
"card_sets": [
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"},
{"set_name":"Metal Raiders","set_code":"MRD-010","set_rarity":"Ultra Rare"}
]
},
{
"name": "Dark Magician",
"card_sets": [
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-005","set_rarity":"Ultra Rare"},
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-EN005","set_rarity":"Ultra Rare"},
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-E003","set_rarity":"Ultra Rare"}
]
}
]
})";
const auto out = YuGiOhSetSource::parseCatalog(json, sets);
REQUIRE(out.isOk());
const auto* lob = out.value().findPack("LOB");
REQUIRE(lob != nullptr);
REQUIRE(lob->cards.size() == 2);
bool sawBe = false;
bool sawDm = false;
for (const auto& c : lob->cards) {
if (c.name == "Blue-Eyes White Dragon" && c.setNo == "LOB-001") sawBe = true;
if (c.name == "Dark Magician" && c.setNo == "LOB-EN005") sawDm = true;
}
CHECK(sawBe);
CHECK(sawDm);
const auto* mrd = out.value().findPack("MRD");
REQUIRE(mrd != nullptr);
REQUIRE(mrd->cards.size() == 1);
CHECK(mrd->cards[0].setNo == "MRD-010");
}
TEST_CASE("missing data array returns error") {
CHECK(YuGiOhSetSource::parseCatalog(R"([])", {}).isErr());
CHECK(YuGiOhSetSource::parseCatalog(R"({"data":{}})", {}).isErr());
}
}
namespace {
class RoutingHttpClient final : public IHttpClient {
public:
std::string setsBody;
std::string infoBody;
bool setsOk = true;
bool infoOk = true;
std::vector<std::string> urls;
Result<std::string> get(std::string_view url) override {
urls.emplace_back(url);
if (url == YuGiOhSetSource::kEndpoint) {
return setsOk ? Result<std::string>::ok(setsBody)
: Result<std::string>::err("sets offline");
}
if (url == YuGiOhSetSource::kCardInfoEndpoint) {
return infoOk ? Result<std::string>::ok(infoBody)
: Result<std::string>::err("info offline");
}
return Result<std::string>::err("unexpected url");
}
};
} // namespace
TEST_SUITE("YuGiOhSetSource::fetchAllWithCatalog") {
TEST_CASE("fetches sets then cardinfo and returns both") {
RoutingHttpClient http;
http.setsBody = R"([{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB","tcg_date":"2002-03-08"}])";
http.infoBody = R"({
"data": [{
"name": "Blue-Eyes White Dragon",
"card_sets": [
{"set_name":"Legend of Blue Eyes White Dragon","set_code":"LOB-001","set_rarity":"Ultra Rare"}
]
}]
})";
YuGiOhSetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isOk());
REQUIRE(http.urls.size() == 2);
CHECK(http.urls[0] == YuGiOhSetSource::kEndpoint);
CHECK(http.urls[1] == YuGiOhSetSource::kCardInfoEndpoint);
CHECK(out.value().sets.front().id == "LOB");
REQUIRE(out.value().catalog.findPack("LOB") != nullptr);
CHECK(out.value().catalog.findPack("LOB")->cards.size() == 1);
}
TEST_CASE("cardinfo failure propagates after sets succeed") {
RoutingHttpClient http;
http.setsBody = R"([{"set_name":"Set X","set_code":"X","tcg_date":"2020-01-01"}])";
http.infoOk = false;
YuGiOhSetSource src{http};
const auto out = src.fetchAllWithCatalog();
REQUIRE(out.isErr());
CHECK(out.error() == "info offline");
}
}
+4 -3
View File
@@ -5,8 +5,8 @@
## Layer pointers ## Layer pointers
- `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector<IGameView*>` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly). - `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector<IGameView*>` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly).
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon own a tabbed layout without changing other games splitter mounting. - `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon and Yu-Gi-Oh! own a tabbed layout without changing other games splitter mounting.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden for Digimon via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic / Pokémon / Yu-Gi-Oh!) or a games `IGameView::contentPanel` (Digimon Digi-Battle notebook). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent. - `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic / Pokémon) or a games `IGameView::contentPanel` (Yu-Gi-Oh! / Digimon Digi-Battle notebooks). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`). - `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32. - `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)``Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia). - `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)``Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia).
@@ -15,6 +15,7 @@
- `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50100 lines of hook overrides on top of the matching base template. - `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50100 lines of hook overrides on top of the matching base template.
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`). - `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`).
- `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` via `contentPanel` with a **palette-painted tab strip** + `wxSimplebook` (**Single Cards** | **Set Completion**) — not native `wxNotebook`, which stays light on MSW dark mode — and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards page; MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`). - `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` via `contentPanel` with a **palette-painted tab strip** + `wxSimplebook` (**Single Cards** | **Set Completion**) — not native `wxNotebook`, which stays light on MSW dark mode — and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards page; MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`).
- `include/ccm/ui/YuGiOh*.hpp` + `src/YuGiOh*.cpp` — Yu-Gi-Oh!: list/selected/edit plus `YuGiOhGameView` notebook (**Single Cards** | **Set Completion**) via the same `hostsOwnLayout` / `contentPanel` pattern as Digimon, and `YuGiOhSetCompletionPanel`. Catalog from `YuGiOhSetCatalogService` (`yugioh/set-catalog.json`), filled on Update Sets from YGOPRODeck `cardinfo.php`.
- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up. - `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up.
- `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types. - `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types.
- `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`. - `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`.
@@ -28,7 +29,7 @@
3. **Ownership**: dialogs and panels are heap-allocated and parented to a `wxWindow`. wxWidgets owns the lifetime — do **not** wrap them in `unique_ptr`. `IGameView` instances themselves are owned by `app/main.cpp` (`std::unique_ptr<>`); the panels owned by the views become children of the `MainFrame` splitter on first mount. 3. **Ownership**: dialogs and panels are heap-allocated and parented to a `wxWindow`. wxWidgets owns the lifetime — do **not** wrap them in `unique_ptr`. `IGameView` instances themselves are owned by `app/main.cpp` (`std::unique_ptr<>`); the panels owned by the views become children of the `MainFrame` splitter on first mount.
4. **Custom events**: `EVT_CARD_SELECTED` is fired by the list panel on itself (not its parent). Each `IGameView` binds it on its typed list panel inside the panel's first construction so the typed selection flows directly into the typed selected panel — `MainFrame` never sees a `MagicCard` or a `PokemonCard`. Do not move that binding back into `MainFrame`. 4. **Custom events**: `EVT_CARD_SELECTED` is fired by the list panel on itself (not its parent). Each `IGameView` binds it on its typed list panel inside the panel's first construction so the typed selection flows directly into the typed selected panel — `MainFrame` never sees a `MagicCard` or a `PokemonCard`. Do not move that binding back into `MainFrame`.
5. **wxFont modifications** mutate in place: `font.MakeBold().MakeLarger()` — do not call `Scale` (it does not exist on wxFont 3.2; use `MakeLarger` / `SetPointSize`). 5. **wxFont modifications** mutate in place: `font.MakeBold().MakeLarger()` — do not call `Scale` (it does not exist on wxFont 3.2; use `MakeLarger` / `SetPointSize`).
6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a games `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimons Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), not multi-game tabs. 6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a games `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimons and Yu-Gi-Oh!s Single Cards / Set Completion switch is an in-game mode switch (themed tab strip + `wxSimplebook`), not multi-game tabs.
7. **No `ccm_warnings`.** This target intentionally does **not** link the strict warning interface — wxWidgets headers trip `-Wpedantic` / `-Wshadow`. Keep it that way; do not add the link. 7. **No `ccm_warnings`.** This target intentionally does **not** link the strict warning interface — wxWidgets headers trip `-Wpedantic` / `-Wshadow`. Keep it that way; do not add the link.
8. **Async background work** must not capture `this` raw. Use the pattern from `BaseSelectedCardPanel`: a `std::shared_ptr<State>` holding `std::atomic<bool> alive`, `std::atomic<unsigned> currentGen`, and a back-pointer to the panel; spawn a detached `std::thread`, then deliver the result with `wxTheApp->CallAfter([state, gen, ...]() { if (!state->alive) return; if (state->currentGen != gen) return; ... })`. Flip `alive=false` in the panel destructor so late callbacks become no-ops. 8. **Async background work** must not capture `this` raw. Use the pattern from `BaseSelectedCardPanel`: a `std::shared_ptr<State>` holding `std::atomic<bool> alive`, `std::atomic<unsigned> currentGen`, and a back-pointer to the panel; spawn a detached `std::thread`, then deliver the result with `wxTheApp->CallAfter([state, gen, ...]() { if (!state->alive) return; if (state->currentGen != gen) return; ... })`. Flip `alive=false` in the panel destructor so late callbacks become no-ops.
9. **Icons come from `SvgIcons.hpp`.** Don't inline new SVG strings in panel sources; add them to `SvgIcons.{hpp,cpp}` so all panels stay in sync. Always pass a runtime fill color (`wxSystemSettings::GetColour(...).GetAsString(wxC2S_HTML_SYNTAX)`); never bake one into the SVG. 9. **Icons come from `SvgIcons.hpp`.** Don't inline new SVG strings in panel sources; add them to `SvgIcons.{hpp,cpp}` so all panels stay in sync. Always pass a runtime fill color (`wxSystemSettings::GetColour(...).GetAsString(wxC2S_HTML_SYNTAX)`); never bake one into the SVG.
+1
View File
@@ -19,6 +19,7 @@ add_library(ccm_ui_wx STATIC
src/YuGiOhSelectedCardPanel.cpp src/YuGiOhSelectedCardPanel.cpp
src/YuGiOhCardEditDialog.cpp src/YuGiOhCardEditDialog.cpp
src/YuGiOhGameView.cpp src/YuGiOhGameView.cpp
src/YuGiOhSetCompletionPanel.cpp
src/DigiBattle99CardListPanel.cpp src/DigiBattle99CardListPanel.cpp
src/DigiBattle99SelectedCardPanel.cpp src/DigiBattle99SelectedCardPanel.cpp
src/DigiBattle99CardEditDialog.cpp src/DigiBattle99CardEditDialog.cpp
+44 -10
View File
@@ -7,31 +7,48 @@
#include "ccm/services/ConfigService.hpp" #include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp" #include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp" #include "ccm/services/SetService.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include "ccm/ui/IGameView.hpp" #include "ccm/ui/IGameView.hpp"
#include <array>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <vector> #include <vector>
class wxBitmapButton;
class wxBoxSizer;
class wxPanel;
class wxSimplebook;
class wxSplitterWindow;
class wxStaticText;
class wxTextCtrl;
namespace ccm::ui { namespace ccm::ui {
class YuGiOhCardListPanel; class YuGiOhCardListPanel;
class YuGiOhSelectedCardPanel; class YuGiOhSelectedCardPanel;
class YuGiOhSetCompletionPanel;
class YuGiOhGameView final : public IGameView { class YuGiOhGameView final : public IGameView {
public: public:
YuGiOhGameView(ConfigService& config, YuGiOhGameView(ConfigService& config,
CollectionService<YuGiOhCard>& collection, CollectionService<YuGiOhCard>& collection,
SetService& sets, SetService& sets,
ImageService& images, ImageService& images,
CardPreviewService& cardPreview, CardPreviewService& cardPreview,
IGameModule& module); IGameModule& module,
YuGiOhSetCatalogService& catalogStore);
[[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; } [[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; }
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; } [[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; }
wxPanel* listPanel(wxWindow* parent) override; wxPanel* listPanel(wxWindow* parent) override;
wxPanel* selectedPanel(wxWindow* parent) override; wxPanel* selectedPanel(wxWindow* parent) override;
wxPanel* contentPanel(wxWindow* parent) override;
[[nodiscard]] wxPanel* contentPanelIfCreated() const noexcept override {
return contentPanel_;
}
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
void refreshCollection() override; void refreshCollection() override;
void onAddCard(wxWindow* parentWindow) override; void onAddCard(wxWindow* parentWindow) override;
@@ -45,6 +62,12 @@ public:
private: private:
void ensureSetsLoaded(); void ensureSetsLoaded();
const std::vector<Set>& setsForDialog(); const std::vector<Set>& setsForDialog();
void ensureSingleCardsMounted(wxWindow* splitterParent);
void buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer);
void buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer);
void selectTab(int index);
void refreshToolbarIcons(const ThemePalette& palette);
void refreshTabBarTheme(const ThemePalette& palette);
ConfigService& config_; ConfigService& config_;
CollectionService<YuGiOhCard>& collection_; CollectionService<YuGiOhCard>& collection_;
@@ -52,11 +75,22 @@ private:
ImageService& images_; ImageService& images_;
CardPreviewService& cardPreview_; CardPreviewService& cardPreview_;
IGameModule& module_; IGameModule& module_;
YuGiOhSetCatalogService& catalogStore_;
YuGiOhCardListPanel* listPanel_{nullptr}; wxPanel* contentPanel_{nullptr};
YuGiOhSelectedCardPanel* selectedPanel_{nullptr}; wxPanel* tabBar_{nullptr};
std::vector<Set> setsCache_; wxSimplebook* book_{nullptr};
bool attemptedInitialSetLoad_{false}; wxSplitterWindow* singleSplitter_{nullptr};
YuGiOhCardListPanel* listPanel_{nullptr};
YuGiOhSelectedCardPanel* selectedPanel_{nullptr};
YuGiOhSetCompletionPanel* setCompletionPanel_{nullptr};
std::array<wxPanel*, 2> tabPanels_{{nullptr, nullptr}};
std::array<wxStaticText*, 2> tabLabels_{{nullptr, nullptr}};
int activeTab_{0};
std::array<wxBitmapButton*, 3> toolbarButtons_{{nullptr, nullptr, nullptr}};
wxTextCtrl* filterInput_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
}; };
} // namespace ccm::ui } // namespace ccm::ui
@@ -0,0 +1,71 @@
#pragma once
// YuGiOhSetCompletionPanel: Set Completion tab — pack tiles with progress
// bars for sets the user owns ≥1 card of, plus an in-tab checklist drill-down
// (unowned rows greyed). Catalog is offline (set-catalog.json). Optional
// language filter restricts ownership to one language and labels set titles
// as "{setName} ({language})".
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/YuGiOhCard.hpp"
#include "ccm/domain/YuGiOhSetCatalog.hpp"
#include "ccm/services/YuGiOhSetCatalogService.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/panel.h>
#include <optional>
#include <string>
#include <vector>
class wxBoxSizer;
class wxChoice;
class wxListCtrl;
class wxScrolledWindow;
class wxSimplebook;
class wxStaticText;
namespace ccm::ui {
class YuGiOhSetCompletionPanel : public wxPanel {
public:
YuGiOhSetCompletionPanel(wxWindow* parent, YuGiOhSetCatalogService& catalogStore);
void setCollection(std::vector<YuGiOhCard> cards);
void reloadFromStore();
void applyTheme(const ThemePalette& palette);
private:
void showGridPage();
void showChecklistPage(const std::string& setId, const std::string& setName);
void rebuildGrid();
void rebuildChecklist(const std::string& setId);
void setEmptyMessage(const wxString& message);
void clearGridTiles();
void refreshLanguageChoice();
void onLanguageChoice(wxCommandEvent& event);
void rebuildCurrentView();
[[nodiscard]] std::string displaySetName(const std::string& setName) const;
YuGiOhSetCatalogService& catalogStore_;
YuGiOhSetCatalog catalog_;
bool catalogLoaded_{false};
std::vector<YuGiOhCard> collection_;
ThemePalette palette_{};
std::optional<Language> languageFilter_;
wxChoice* languageChoice_{nullptr};
wxSimplebook* book_{nullptr};
wxPanel* gridPage_{nullptr};
wxScrolledWindow* scroll_{nullptr};
wxBoxSizer* gridSizer_{nullptr};
wxStaticText* emptyLabel_{nullptr};
wxPanel* detailPage_{nullptr};
wxStaticText* detailTitle_{nullptr};
wxListCtrl* checklist_{nullptr};
std::string detailSetId_;
std::string detailSetName_;
};
} // namespace ccm::ui
+332 -27
View File
@@ -1,32 +1,65 @@
#include "ccm/ui/YuGiOhGameView.hpp" #include "ccm/ui/YuGiOhGameView.hpp"
#include "ccm/games/yugioh/YuGiOhSetSource.hpp"
#include "ccm/ui/CardEditModalGuard.hpp" #include "ccm/ui/CardEditModalGuard.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/ui/Theme.hpp"
#include "ccm/ui/YuGiOhCardEditDialog.hpp" #include "ccm/ui/YuGiOhCardEditDialog.hpp"
#include "ccm/ui/YuGiOhCardListPanel.hpp" #include "ccm/ui/YuGiOhCardListPanel.hpp"
#include "ccm/ui/YuGiOhSelectedCardPanel.hpp" #include "ccm/ui/YuGiOhSelectedCardPanel.hpp"
#include "ccm/ui/Theme.hpp" #include "ccm/ui/YuGiOhSetCompletionPanel.hpp"
#include <wx/msgdlg.h> #include <wx/bmpbuttn.h>
#include <wx/cursor.h>
#include <wx/dcclient.h>
#include <wx/panel.h>
#include <wx/simplebook.h>
#include <wx/sizer.h>
#include <wx/splitter.h>
#include <wx/stattext.h>
#include <wx/textctrl.h>
#include <wx/window.h> #include <wx/window.h>
#include <optional>
#include <algorithm> #include <algorithm>
#include <string> #include <string>
namespace ccm::ui { namespace ccm::ui {
YuGiOhGameView::YuGiOhGameView(ConfigService& config, namespace {
CollectionService<YuGiOhCard>& collection, constexpr int kYgoToolbarIconPx = 18;
SetService& sets, constexpr const char kYgoFilterHint[] = "Filter";
ImageService& images,
CardPreviewService& cardPreview, wxColour lighten(const wxColour& c, int amount) {
IGameModule& module) auto lift = [amount](unsigned char channel) -> unsigned char {
const int raised = static_cast<int>(channel) + amount;
return static_cast<unsigned char>(raised > 255 ? 255 : raised);
};
return wxColour(lift(c.Red()), lift(c.Green()), lift(c.Blue()));
}
wxColour darken(const wxColour& c, int amount) {
auto drop = [amount](unsigned char channel) -> unsigned char {
const int lowered = static_cast<int>(channel) - amount;
return static_cast<unsigned char>(lowered < 0 ? 0 : lowered);
};
return wxColour(drop(c.Red()), drop(c.Green()), drop(c.Blue()));
}
} // namespace
YuGiOhGameView::YuGiOhGameView(ConfigService& config,
CollectionService<YuGiOhCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module,
YuGiOhSetCatalogService& catalogStore)
: config_(config), : config_(config),
collection_(collection), collection_(collection),
sets_(sets), sets_(sets),
images_(images), images_(images),
cardPreview_(cardPreview), cardPreview_(cardPreview),
module_(module) {} module_(module),
catalogStore_(catalogStore) {}
void YuGiOhGameView::ensureSetsLoaded() { void YuGiOhGameView::ensureSetsLoaded() {
if (attemptedInitialSetLoad_) return; if (attemptedInitialSetLoad_) return;
@@ -50,6 +83,208 @@ void YuGiOhGameView::ensureSetsLoaded() {
} }
} }
void YuGiOhGameView::ensureSingleCardsMounted(wxWindow* splitterParent) {
if (singleSplitter_ == nullptr) {
singleSplitter_ = new wxSplitterWindow(splitterParent, wxID_ANY, wxDefaultPosition,
wxDefaultSize, wxSP_LIVE_UPDATE);
singleSplitter_->SetMinimumPaneSize(280);
}
auto* list = listPanel(singleSplitter_);
auto* selected = selectedPanel(singleSplitter_);
if (!singleSplitter_->IsSplit()) {
singleSplitter_->SplitVertically(selected, list, 360);
}
}
void YuGiOhGameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) {
auto* toolbar = new wxBoxSizer(wxHORIZONTAL);
auto makeToolBtn = [&](const char* svg, const wxString& tip) {
wxBitmap bmp = svgIconBitmap(svg, kYgoToolbarIconPx, "#000000");
auto* b = new wxBitmapButton(parent, wxID_ANY, bmp, wxDefaultPosition, wxDefaultSize,
wxBU_EXACTFIT);
b->SetToolTip(tip);
return b;
};
toolbarButtons_[0] = makeToolBtn(kSvgToolbarAdd, "Add Card");
toolbarButtons_[1] = makeToolBtn(kSvgToolbarEdit, "Edit");
toolbarButtons_[2] = makeToolBtn(kSvgToolbarDelete, "Delete");
toolbar->AddSpacer(4);
toolbar->Add(toolbarButtons_[0], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
toolbar->AddStretchSpacer(1);
filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1));
filterInput_->SetHint(kYgoFilterHint);
toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4);
pageSizer->Add(toolbar, 0, wxEXPAND);
toolbarButtons_[0]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onAddCard(owner != nullptr ? owner : contentPanel_);
});
toolbarButtons_[1]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onEditCard(owner != nullptr ? owner : contentPanel_);
});
toolbarButtons_[2]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
onDeleteCard(owner != nullptr ? owner : contentPanel_);
});
filterInput_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) {
if (filterInput_ == nullptr) return;
setFilter(filterInput_->GetValue().ToStdString(wxConvUTF8));
});
}
void YuGiOhGameView::refreshToolbarIcons(const ThemePalette& palette) {
const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
if (toolbarButtons_[0]) {
toolbarButtons_[0]->SetBitmap(
svgIconBitmap(kSvgToolbarAdd, kYgoToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[1]) {
toolbarButtons_[1]->SetBitmap(
svgIconBitmap(kSvgToolbarEdit, kYgoToolbarIconPx, tbHex.c_str()));
}
if (toolbarButtons_[2]) {
toolbarButtons_[2]->SetBitmap(
svgIconBitmap(kSvgToolbarDelete, kYgoToolbarIconPx, tbHex.c_str()));
}
}
void YuGiOhGameView::selectTab(int index) {
if (index < 0 || index > 1 || book_ == nullptr) return;
activeTab_ = index;
book_->SetSelection(index);
refreshTabBarTheme(paletteForTheme(config_.current().theme));
}
void YuGiOhGameView::refreshTabBarTheme(const ThemePalette& palette) {
if (tabBar_ == nullptr) return;
const wxColour barBg = palette.panelBg;
const wxColour tabBg = palette.buttonBg;
tabBar_->SetBackgroundColour(barBg);
tabBar_->SetOwnBackgroundColour(barBg);
for (int i = 0; i < 2; ++i) {
auto* tab = tabPanels_[i];
auto* label = tabLabels_[i];
if (tab == nullptr || label == nullptr) continue;
const bool selected = (i == activeTab_);
tab->SetBackgroundColour(tabBg);
tab->SetOwnBackgroundColour(tabBg);
label->SetBackgroundColour(tabBg);
label->SetOwnBackgroundColour(tabBg);
label->SetForegroundColour(palette.text);
label->SetOwnForegroundColour(palette.text);
wxFont font = label->GetFont();
font.SetWeight(selected ? wxFONTWEIGHT_BOLD : wxFONTWEIGHT_NORMAL);
label->SetFont(font);
tab->Refresh();
label->Refresh();
}
tabBar_->Layout();
tabBar_->Refresh();
}
void YuGiOhGameView::buildTabBar(wxWindow* parent, wxBoxSizer* rootSizer) {
tabBar_ = new wxPanel(parent, wxID_ANY);
tabBar_->SetBackgroundStyle(wxBG_STYLE_PAINT);
auto* tabSizer = new wxBoxSizer(wxHORIZONTAL);
tabSizer->AddSpacer(4);
const char* labels[2] = {"Single Cards", "Set Completion"};
for (int i = 0; i < 2; ++i) {
auto* tab = new wxPanel(tabBar_, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE);
tab->SetCursor(wxCursor(wxCURSOR_HAND));
tab->SetBackgroundStyle(wxBG_STYLE_PAINT);
auto* label = new wxStaticText(tab, wxID_ANY, wxString::FromUTF8(labels[i]));
auto* inner = new wxBoxSizer(wxVERTICAL);
inner->Add(label, 0, wxALIGN_CENTER | wxLEFT | wxRIGHT | wxTOP | wxBOTTOM, 5);
tab->SetSizer(inner);
auto onClick = [this, i](wxMouseEvent&) { selectTab(i); };
tab->Bind(wxEVT_LEFT_DOWN, onClick);
label->Bind(wxEVT_LEFT_DOWN, onClick);
tab->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
tab->Bind(wxEVT_PAINT, [this, tab, i](wxPaintEvent&) {
wxPaintDC dc(tab);
const ThemePalette palette = paletteForTheme(config_.current().theme);
const bool dark = config_.current().theme == Theme::Dark;
const bool selected = (i == activeTab_);
const wxColour bg = palette.buttonBg;
const wxColour frame =
dark ? lighten(palette.panelBg, 55) : darken(palette.panelBg, 45);
const wxColour frameSel = dark ? lighten(palette.panelBg, 85) : darken(palette.panelBg, 70);
const wxRect r = tab->GetClientRect();
dc.SetPen(wxPen(selected ? frameSel : frame, 1));
dc.SetBrush(wxBrush(bg));
dc.DrawRectangle(r.x, r.y, r.width, r.height);
if (selected) {
dc.SetPen(wxPen(palette.text, 2));
dc.DrawLine(r.GetLeft() + 4, r.GetBottom() - 1, r.GetRight() - 4,
r.GetBottom() - 1);
}
});
tabPanels_[i] = tab;
tabLabels_[i] = label;
if (i > 0) tabSizer->AddSpacer(4);
tabSizer->Add(tab, 0, wxALIGN_CENTER_VERTICAL | wxTOP | wxBOTTOM, 3);
}
tabSizer->AddStretchSpacer(1);
tabBar_->Bind(wxEVT_PAINT, [this](wxPaintEvent&) {
wxPaintDC dc(tabBar_);
const ThemePalette palette = paletteForTheme(config_.current().theme);
dc.SetPen(*wxTRANSPARENT_PEN);
dc.SetBrush(wxBrush(palette.panelBg));
dc.DrawRectangle(tabBar_->GetClientRect());
dc.SetPen(wxPen(darken(palette.text, 120), 1));
const wxRect r = tabBar_->GetClientRect();
dc.DrawLine(r.GetLeft(), r.GetBottom(), r.GetRight(), r.GetBottom());
});
tabBar_->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
tabBar_->SetSizer(tabSizer);
rootSizer->Add(tabBar_, 0, wxEXPAND);
refreshTabBarTheme(paletteForTheme(config_.current().theme));
}
wxPanel* YuGiOhGameView::contentPanel(wxWindow* parent) {
if (contentPanel_ == nullptr) {
contentPanel_ = new wxPanel(parent);
auto* root = new wxBoxSizer(wxVERTICAL);
buildTabBar(contentPanel_, root);
book_ = new wxSimplebook(contentPanel_, wxID_ANY);
auto* singlePage = new wxPanel(book_);
auto* singleSizer = new wxBoxSizer(wxVERTICAL);
buildSingleCardsToolbar(singlePage, singleSizer);
ensureSingleCardsMounted(singlePage);
singleSizer->Add(singleSplitter_, 1, wxEXPAND);
singlePage->SetSizer(singleSizer);
book_->AddPage(singlePage, "Single Cards");
setCompletionPanel_ = new YuGiOhSetCompletionPanel(book_, catalogStore_);
setCompletionPanel_->reloadFromStore();
book_->AddPage(setCompletionPanel_, "Set Completion");
root->Add(book_, 1, wxEXPAND | wxTOP, 5);
contentPanel_->SetSizer(root);
selectTab(0);
refreshToolbarIcons(paletteForTheme(config_.current().theme));
contentPanel_->CallAfter([this]() {
refreshTabBarTheme(paletteForTheme(config_.current().theme));
});
}
return contentPanel_;
}
wxPanel* YuGiOhGameView::listPanel(wxWindow* parent) { wxPanel* YuGiOhGameView::listPanel(wxWindow* parent) {
if (listPanel_ == nullptr) { if (listPanel_ == nullptr) {
listPanel_ = new YuGiOhCardListPanel(parent); listPanel_ = new YuGiOhCardListPanel(parent);
@@ -74,16 +309,23 @@ wxPanel* YuGiOhGameView::selectedPanel(wxWindow* parent) {
} }
void YuGiOhGameView::refreshCollection() { void YuGiOhGameView::refreshCollection() {
if (listPanel_ == nullptr) return; if (contentPanel_ == nullptr && listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::YuGiOh); auto loaded = collection_.list(Game::YuGiOh);
if (!loaded) { if (!loaded) {
showThemedMessageDialog(nullptr, "Failed to load Yu-Gi-Oh! collection: " + loaded.error(), showThemedMessageDialog(nullptr, "Failed to load Yu-Gi-Oh! collection: " + loaded.error(),
"Error", wxOK | wxICON_ERROR); "Error", wxOK | wxICON_ERROR);
return; return;
} }
listPanel_->setCards(std::move(loaded).value()); auto cards = std::move(loaded).value();
listPanel_->activateSelection(); if (listPanel_ != nullptr) {
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected()); listPanel_->setCards(cards);
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
if (setCompletionPanel_ != nullptr) {
setCompletionPanel_->setCollection(std::move(cards));
}
} }
const std::vector<Set>& YuGiOhGameView::setsForDialog() { const std::vector<Set>& YuGiOhGameView::setsForDialog() {
@@ -94,8 +336,9 @@ const std::vector<Set>& YuGiOhGameView::setsForDialog() {
setsCache_ = std::move(loaded).value(); setsCache_ = std::move(loaded).value();
std::sort(setsCache_.begin(), setsCache_.end(), std::sort(setsCache_.begin(), setsCache_.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
} else {
setsCache_.clear();
} }
else setsCache_.clear();
return setsCache_; return setsCache_;
} }
@@ -135,13 +378,18 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
persisted.images = std::move(normalized).value(); persisted.images = std::move(normalized).value();
auto updated = collection_.update(Game::YuGiOh, persisted); auto updated = collection_.update(Game::YuGiOh, persisted);
if (!updated) { if (!updated) {
showThemedMessageDialog(parentWindow, "Card added, but image name normalization failed to persist: " + updated.error(), showThemedMessageDialog(
"Warning", wxOK | wxICON_WARNING); parentWindow,
"Card added, but image name normalization failed to persist: " +
updated.error(),
"Warning", wxOK | wxICON_WARNING);
} }
} }
} else { } else {
showThemedMessageDialog(parentWindow, "Card added, but image rename to ID-prefixed format failed: " + normalized.error(), showThemedMessageDialog(
"Warning", wxOK | wxICON_WARNING); parentWindow,
"Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
} }
refreshCollection(); refreshCollection();
} }
@@ -150,7 +398,8 @@ void YuGiOhGameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return; if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected(); auto sel = listPanel_->selected();
if (!sel) { if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION); showThemedMessageDialog(parentWindow, "Select a card first.", "Edit",
wxOK | wxICON_INFORMATION);
return; return;
} }
if (cardEditModalIsActive()) { if (cardEditModalIsActive()) {
@@ -176,7 +425,8 @@ void YuGiOhGameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return; if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected(); auto sel = listPanel_->selected();
if (!sel) { if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION); showThemedMessageDialog(parentWindow, "Select a card first.", "Delete",
wxOK | wxICON_INFORMATION);
return; return;
} }
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?", if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
@@ -193,27 +443,82 @@ void YuGiOhGameView::onDeleteCard(wxWindow* parentWindow) {
} }
std::string YuGiOhGameView::onUpdateSets(wxWindow* parentWindow) { std::string YuGiOhGameView::onUpdateSets(wxWindow* parentWindow) {
auto out = sets_.updateSets(Game::YuGiOh); auto* ygoSrc = dynamic_cast<YuGiOhSetSource*>(&module_.setSource());
if (!out) { if (ygoSrc == nullptr) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(), showThemedMessageDialog(parentWindow, "Yu-Gi-Oh! set source unavailable.",
"Error", wxOK | wxICON_ERROR); "Error", wxOK | wxICON_ERROR);
return "Update failed"; return "Update failed";
} }
setsCache_ = out.value();
auto both = ygoSrc->fetchAllWithCatalog();
if (!both) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + both.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
auto savedSets = sets_.saveSets(Game::YuGiOh, both.value().sets);
if (!savedSets) {
showThemedMessageDialog(parentWindow, "Failed to save sets: " + savedSets.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
auto savedCatalog = catalogStore_.save(both.value().catalog);
if (!savedCatalog) {
showThemedMessageDialog(parentWindow,
"Sets saved, but set catalog failed: " + savedCatalog.error(),
"Warning", wxOK | wxICON_WARNING);
}
setsCache_ = both.value().sets;
std::sort(setsCache_.begin(), setsCache_.end(), std::sort(setsCache_.begin(), setsCache_.end(),
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; }); [](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Yu-Gi-Oh! sets.", if (setCompletionPanel_ != nullptr) {
"Sets updated", wxOK | wxICON_INFORMATION); setCompletionPanel_->reloadFromStore();
if (auto loaded = collection_.list(Game::YuGiOh)) {
setCompletionPanel_->setCollection(std::move(loaded).value());
}
}
const std::size_t setCount = both.value().sets.size();
const std::size_t packCount = both.value().catalog.packs.size();
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(setCount) + " Yu-Gi-Oh! sets and " +
std::to_string(packCount) + " set checklists.",
"Sets updated", wxOK | wxICON_INFORMATION);
return "Yu-Gi-Oh! sets updated."; return "Yu-Gi-Oh! sets updated.";
} }
void YuGiOhGameView::setFilter(std::string_view filter) { void YuGiOhGameView::setFilter(std::string_view filter) {
if (filterInput_ != nullptr) {
const wxString wanted = wxString::FromUTF8(std::string(filter).c_str());
if (filterInput_->GetValue() != wanted) {
filterInput_->ChangeValue(wanted);
if (filter.empty()) {
filterInput_->SetHint(kYgoFilterHint);
filterInput_->Refresh();
}
}
}
if (listPanel_) listPanel_->setFilter(filter); if (listPanel_) listPanel_->setFilter(filter);
} }
void YuGiOhGameView::applyTheme(const ThemePalette& palette) { void YuGiOhGameView::applyTheme(const ThemePalette& palette) {
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
if (listPanel_) listPanel_->applyTheme(palette); if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette); if (selectedPanel_) selectedPanel_->applyTheme(palette);
if (setCompletionPanel_) setCompletionPanel_->applyTheme(palette);
refreshToolbarIcons(palette);
refreshTabBarTheme(palette);
if (filterInput_ != nullptr) {
filterInput_->SetBackgroundColour(palette.inputBg);
filterInput_->SetForegroundColour(palette.inputText);
filterInput_->SetOwnBackgroundColour(palette.inputBg);
filterInput_->SetOwnForegroundColour(palette.inputText);
filterInput_->Refresh();
}
} }
} // namespace ccm::ui } // namespace ccm::ui
+311
View File
@@ -0,0 +1,311 @@
#include "ccm/ui/YuGiOhSetCompletionPanel.hpp"
#include "ccm/services/YuGiOhSetCompletion.hpp"
#include <wx/button.h>
#include <wx/choice.h>
#include <wx/cursor.h>
#include <wx/gauge.h>
#include <wx/listctrl.h>
#include <wx/scrolwin.h>
#include <wx/simplebook.h>
#include <wx/sizer.h>
#include <wx/stattext.h>
#include <string>
#include <utility>
namespace ccm::ui {
namespace {
wxColour mutedTextColour(const ThemePalette& palette) {
// Blend text toward panel background so missing checklist rows read as greyed.
const auto blend = [](unsigned char a, unsigned char b) -> unsigned char {
return static_cast<unsigned char>((static_cast<int>(a) * 2 + static_cast<int>(b)) / 3);
};
return wxColour(blend(palette.text.Red(), palette.panelBg.Red()),
blend(palette.text.Green(), palette.panelBg.Green()),
blend(palette.text.Blue(), palette.panelBg.Blue()));
}
} // namespace
YuGiOhSetCompletionPanel::YuGiOhSetCompletionPanel(wxWindow* parent,
YuGiOhSetCatalogService& catalogStore)
: wxPanel(parent), catalogStore_(catalogStore) {
palette_ = paletteForTheme(inferThemeFromWindow(this));
auto* langRow = new wxBoxSizer(wxHORIZONTAL);
auto* langLabel = new wxStaticText(this, wxID_ANY, "Language");
languageChoice_ = new wxChoice(this, wxID_ANY);
languageChoice_->Append("All languages");
languageChoice_->SetSelection(0);
languageChoice_->Bind(wxEVT_CHOICE, &YuGiOhSetCompletionPanel::onLanguageChoice, this);
langRow->Add(langLabel, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
langRow->Add(languageChoice_, 0, wxALIGN_CENTER_VERTICAL);
book_ = new wxSimplebook(this, wxID_ANY);
gridPage_ = new wxPanel(book_);
auto* gridRoot = new wxBoxSizer(wxVERTICAL);
emptyLabel_ = new wxStaticText(gridPage_, wxID_ANY, "");
emptyLabel_->Wrap(480);
gridRoot->Add(emptyLabel_, 0, wxALL | wxEXPAND, 12);
scroll_ = new wxScrolledWindow(gridPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxVSCROLL | wxTAB_TRAVERSAL);
scroll_->SetScrollRate(0, 16);
gridSizer_ = new wxBoxSizer(wxVERTICAL);
scroll_->SetSizer(gridSizer_);
gridRoot->Add(scroll_, 1, wxEXPAND);
gridPage_->SetSizer(gridRoot);
book_->AddPage(gridPage_, "Grid");
detailPage_ = new wxPanel(book_);
auto* detailRoot = new wxBoxSizer(wxVERTICAL);
auto* topRow = new wxBoxSizer(wxHORIZONTAL);
auto* backBtn = new wxButton(detailPage_, wxID_ANY, "Back");
backBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { showGridPage(); });
detailTitle_ = new wxStaticText(detailPage_, wxID_ANY, "");
auto titleFont = detailTitle_->GetFont();
titleFont.MakeBold().MakeLarger();
detailTitle_->SetFont(titleFont);
topRow->Add(backBtn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
topRow->Add(detailTitle_, 1, wxALIGN_CENTER_VERTICAL);
detailRoot->Add(topRow, 0, wxEXPAND | wxALL, 8);
checklist_ = new wxListCtrl(detailPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
checklist_->AppendColumn("Card", wxLIST_FORMAT_LEFT, 520);
detailRoot->Add(checklist_, 1, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8);
detailPage_->SetSizer(detailRoot);
book_->AddPage(detailPage_, "Detail");
auto* root = new wxBoxSizer(wxVERTICAL);
root->Add(langRow, 0, wxEXPAND | wxALL, 8);
root->Add(book_, 1, wxEXPAND);
SetSizer(root);
showGridPage();
}
void YuGiOhSetCompletionPanel::setCollection(std::vector<YuGiOhCard> cards) {
collection_ = std::move(cards);
refreshLanguageChoice();
rebuildCurrentView();
}
void YuGiOhSetCompletionPanel::reloadFromStore() {
catalogLoaded_ = false;
catalog_ = {};
if (catalogStore_.exists()) {
if (auto loaded = catalogStore_.load()) {
catalog_ = std::move(loaded).value();
catalogLoaded_ = true;
}
}
showGridPage();
rebuildGrid();
}
void YuGiOhSetCompletionPanel::applyTheme(const ThemePalette& palette) {
palette_ = palette;
applyThemeToWindowTree(this, palette, inferThemeFromWindow(this));
rebuildCurrentView();
}
void YuGiOhSetCompletionPanel::showGridPage() {
detailSetId_.clear();
detailSetName_.clear();
book_->SetSelection(0);
}
void YuGiOhSetCompletionPanel::showChecklistPage(const std::string& setId,
const std::string& setName) {
detailSetId_ = setId;
detailSetName_ = setName;
detailTitle_->SetLabelText(wxString::FromUTF8(displaySetName(setName).c_str()));
rebuildChecklist(setId);
book_->SetSelection(1);
}
std::string YuGiOhSetCompletionPanel::displaySetName(const std::string& setName) const {
if (!languageFilter_.has_value()) return setName;
return setName + " (" + std::string(to_string(*languageFilter_)) + ")";
}
void YuGiOhSetCompletionPanel::refreshLanguageChoice() {
const auto previous = languageFilter_;
const auto present = yuGiOhLanguagesInCollection(collection_);
languageChoice_->Clear();
languageChoice_->Append("All languages");
for (const Language lang : present) {
languageChoice_->Append(wxString::FromUTF8(std::string(to_string(lang)).c_str()));
}
int selection = 0;
languageFilter_ = std::nullopt;
if (previous.has_value()) {
for (std::size_t i = 0; i < present.size(); ++i) {
if (present[i] == *previous) {
selection = static_cast<int>(i + 1);
languageFilter_ = previous;
break;
}
}
}
languageChoice_->SetSelection(selection);
}
void YuGiOhSetCompletionPanel::onLanguageChoice(wxCommandEvent& /*event*/) {
const int sel = languageChoice_->GetSelection();
if (sel <= 0) {
languageFilter_ = std::nullopt;
} else {
const auto present = yuGiOhLanguagesInCollection(collection_);
const auto idx = static_cast<std::size_t>(sel - 1);
if (idx < present.size()) {
languageFilter_ = present[idx];
} else {
languageFilter_ = std::nullopt;
languageChoice_->SetSelection(0);
}
}
rebuildCurrentView();
}
void YuGiOhSetCompletionPanel::rebuildCurrentView() {
if (book_->GetSelection() == 1 && !detailSetId_.empty()) {
const auto rows =
computeYuGiOhSetCompletion(collection_, catalog_, languageFilter_);
bool stillVisible = false;
for (const auto& row : rows) {
if (row.setId == detailSetId_) {
stillVisible = true;
break;
}
}
if (!stillVisible) {
showGridPage();
rebuildGrid();
return;
}
detailTitle_->SetLabelText(
wxString::FromUTF8(displaySetName(detailSetName_).c_str()));
rebuildChecklist(detailSetId_);
} else {
rebuildGrid();
}
}
void YuGiOhSetCompletionPanel::setEmptyMessage(const wxString& message) {
clearGridTiles();
emptyLabel_->SetLabelText(message);
emptyLabel_->Wrap(480);
emptyLabel_->Show();
scroll_->Hide();
gridPage_->Layout();
}
void YuGiOhSetCompletionPanel::clearGridTiles() {
if (gridSizer_ == nullptr) return;
gridSizer_->Clear(true);
}
void YuGiOhSetCompletionPanel::rebuildGrid() {
if (!catalogLoaded_) {
setEmptyMessage(wxString::FromUTF8(
"Set checklists are not downloaded yet.\n"
"Run Sets → Update Yu-Gi-Oh! to enable Set Completion."));
return;
}
const auto rows = computeYuGiOhSetCompletion(collection_, catalog_, languageFilter_);
if (rows.empty()) {
setEmptyMessage(wxString::FromUTF8(
"No Yu-Gi-Oh! sets in progress yet.\n"
"Add cards on the Single Cards tab to track set completion here."));
return;
}
emptyLabel_->Hide();
scroll_->Show();
clearGridTiles();
for (const auto& row : rows) {
auto* tile = new wxPanel(scroll_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxBORDER_SIMPLE);
tile->SetBackgroundColour(palette_.panelBg);
auto* tileSizer = new wxBoxSizer(wxVERTICAL);
const std::string title = displaySetName(row.setName);
auto* nameLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(title.c_str()));
auto nameFont = nameLbl->GetFont();
nameFont.MakeBold();
nameLbl->SetFont(nameFont);
nameLbl->SetForegroundColour(palette_.text);
const std::string counts =
std::to_string(row.ownedUnique) + " / " + std::to_string(row.total) + " (" +
std::to_string(row.percent()) + "%)";
auto* countLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(counts.c_str()));
countLbl->SetForegroundColour(palette_.text);
auto* gauge = new wxGauge(tile, wxID_ANY, 100, wxDefaultPosition, wxSize(-1, 14),
wxGA_HORIZONTAL | wxGA_SMOOTH);
gauge->SetValue(row.percent());
tileSizer->Add(nameLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 10);
tileSizer->Add(countLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 6);
tileSizer->Add(gauge, 0, wxEXPAND | wxALL, 10);
tile->SetSizer(tileSizer);
const std::string setId = row.setId;
const std::string setName = row.setName;
auto openDetail = [this, setId, setName](wxMouseEvent&) {
showChecklistPage(setId, setName);
};
tile->Bind(wxEVT_LEFT_UP, openDetail);
nameLbl->Bind(wxEVT_LEFT_UP, openDetail);
countLbl->Bind(wxEVT_LEFT_UP, openDetail);
gauge->Bind(wxEVT_LEFT_UP, openDetail);
tile->SetCursor(wxCursor(wxCURSOR_HAND));
nameLbl->SetCursor(wxCursor(wxCURSOR_HAND));
countLbl->SetCursor(wxCursor(wxCURSOR_HAND));
gridSizer_->Add(tile, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
}
gridSizer_->AddStretchSpacer(1);
scroll_->FitInside();
gridPage_->Layout();
Layout();
}
void YuGiOhSetCompletionPanel::rebuildChecklist(const std::string& setId) {
checklist_->DeleteAllItems();
const auto entries =
yuGiOhChecklistForSet(collection_, catalog_, setId, languageFilter_);
const wxColour muted = mutedTextColour(palette_);
// Fixed green so owned checkmarks stay readable in both light and dark themes.
const wxColour ownedGreen(46, 160, 67);
long idx = 0;
for (const auto& entry : entries) {
// Align names: checkmark + two spaces vs four spaces for missing cards.
const std::string line =
(entry.owned ? "" : " ") + entry.setNo + "" + entry.name;
const long row = checklist_->InsertItem(idx++, wxString::FromUTF8(line.c_str()));
if (row < 0) continue;
if (entry.owned) {
checklist_->SetItemTextColour(row, ownedGreen);
} else {
checklist_->SetItemTextColour(row, muted);
}
}
checklist_->SetColumnWidth(0, wxLIST_AUTOSIZE);
detailPage_->Layout();
}
} // namespace ccm::ui