From 42926f2fb515f4a9af55c57b63c3e34706b417d2 Mon Sep 17 00:00:00 2001 From: Sebastian Dine Date: Wed, 13 May 2026 21:16:41 +0200 Subject: [PATCH] patch: Feature/ygo set selection (#16) --- README.md | 1 - core/AGENTS.md | 2 +- core/include/ccm/util/YuGiOhSetLookup.hpp | 67 +++++++++ core/src/domain/Enums.cpp | 14 +- core/src/services/CardPreviewService.cpp | 16 +-- core/src/util/FsNames.cpp | 1 - docs/AGENTS.md | 2 +- docs/adding-a-new-game.md | 10 +- docs/assets-and-info-apis.md | 2 + tests/AGENTS.md | 1 + tests/CMakeLists.txt | 1 + tests/ascii_utils_tests.cpp | 5 + tests/domain_json_tests.cpp | 57 ++++++++ tests/json_collection_repository_tests.cpp | 24 ++++ tests/pokemon_card_preview_source_tests.cpp | 94 ++++++++++-- tests/std_file_system_tests.cpp | 11 ++ tests/yugioh_card_preview_source_tests.cpp | 7 + tests/yugioh_set_lookup_tests.cpp | 87 +++++++++++ ui_wx/AGENTS.md | 9 +- ui_wx/CMakeLists.txt | 1 + ui_wx/include/ccm/ui/BaseCardEditDialog.hpp | 47 ++++-- ui_wx/include/ccm/ui/BaseCardListPanel.hpp | 13 ++ ui_wx/include/ccm/ui/CardEditModalGuard.hpp | 34 +++++ ui_wx/include/ccm/ui/SwitchCtrl.hpp | 31 ++++ ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp | 13 ++ ui_wx/src/BaseEvents.cpp | 3 + ui_wx/src/MagicGameView.cpp | 18 +++ ui_wx/src/PokemonGameView.cpp | 18 +++ ui_wx/src/SwitchCtrl.cpp | 135 ++++++++++++++++++ ui_wx/src/Theme.cpp | 38 ++++- ui_wx/src/YuGiOhCardEditDialog.cpp | 99 ++++++++++++- ui_wx/src/YuGiOhGameView.cpp | 18 +++ 32 files changed, 827 insertions(+), 52 deletions(-) create mode 100644 core/include/ccm/util/YuGiOhSetLookup.hpp create mode 100644 tests/yugioh_set_lookup_tests.cpp create mode 100644 ui_wx/include/ccm/ui/CardEditModalGuard.hpp create mode 100644 ui_wx/include/ccm/ui/SwitchCtrl.hpp create mode 100644 ui_wx/src/SwitchCtrl.cpp diff --git a/README.md b/README.md index a913004..992790d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Card Collection Manager 3 -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=alert_status&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3) [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=bugs&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=sebastiandine_Card-Collection-Manager-3&metric=security_rating&token=a7e5822db3829af68223a1d3710f3105ff9543bc)](https://sonarcloud.io/summary/new_code?id=sebastiandine_Card-Collection-Manager-3) diff --git a/core/AGENTS.md b/core/AGENTS.md index ed4a3f0..a210e3a 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -9,7 +9,7 @@ - `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService` (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). They depend only on ports. - `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`. - `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/`, and `yugioh/` are the reference implementations — all three expose a fully working set source + card preview source. -- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`). +- `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. ## Conventions diff --git a/core/include/ccm/util/YuGiOhSetLookup.hpp b/core/include/ccm/util/YuGiOhSetLookup.hpp new file mode 100644 index 0000000..0f22ef6 --- /dev/null +++ b/core/include/ccm/util/YuGiOhSetLookup.hpp @@ -0,0 +1,67 @@ +#pragma once + +// Resolves a Yu-Gi-Oh! product code (YGOPRODeck `set_code`, stored as `Set.id`) +// against a cached set list. Used by the Yu-Gi-Oh! edit dialog "set code" mode. + +#include "ccm/domain/Set.hpp" + +#include +#include +#include +#include +#include + +namespace ccm { + +struct YuGiOhSetShorthandLookup { + enum class Kind { Unique, NotFound, Ambiguous }; + + Kind kind{Kind::NotFound}; + std::size_t index{0}; +}; + +[[nodiscard]] inline std::string normalizeYuGiOhSetIdForLookup(std::string_view id) { + std::string out; + out.reserve(id.size()); + for (unsigned char uch : id) { + out.push_back(static_cast(std::tolower(uch))); + } + return out; +} + +[[nodiscard]] inline std::string_view trimAsciiWhitespace(std::string_view s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) { + s.remove_prefix(1); + } + while (!s.empty() && std::isspace(static_cast(s.back()))) { + s.remove_suffix(1); + } + return s; +} + +[[nodiscard]] inline YuGiOhSetShorthandLookup lookupYuGiOhSetByShorthand( + std::string_view query, const std::vector& sets) { + const std::string_view trimmed = trimAsciiWhitespace(query); + if (trimmed.empty()) { + return {YuGiOhSetShorthandLookup::Kind::NotFound, 0}; + } + const std::string qNorm = normalizeYuGiOhSetIdForLookup(trimmed); + + std::size_t firstIdx = 0; + int matchCount = 0; + for (std::size_t i = 0; i < sets.size(); ++i) { + if (normalizeYuGiOhSetIdForLookup(sets[i].id) == qNorm) { + if (matchCount == 0) firstIdx = i; + ++matchCount; + if (matchCount > 1) { + return {YuGiOhSetShorthandLookup::Kind::Ambiguous, 0}; + } + } + } + if (matchCount == 1) { + return {YuGiOhSetShorthandLookup::Kind::Unique, firstIdx}; + } + return {YuGiOhSetShorthandLookup::Kind::NotFound, 0}; +} + +} // namespace ccm diff --git a/core/src/domain/Enums.cpp b/core/src/domain/Enums.cpp index fcd5247..55bbab8 100644 --- a/core/src/domain/Enums.cpp +++ b/core/src/domain/Enums.cpp @@ -3,6 +3,12 @@ #include #include +#if defined(__GNUC__) || defined(__clang__) +#define CCM_UNREACHABLE() __builtin_unreachable() +#else +#define CCM_UNREACHABLE() ((void)0) +#endif + namespace ccm { std::string_view to_string(Game g) noexcept { @@ -11,7 +17,7 @@ std::string_view to_string(Game g) noexcept { case Game::Pokemon: return "Pokemon"; case Game::YuGiOh: return "YuGiOh"; } - return "Magic"; + CCM_UNREACHABLE(); } std::string_view to_string(Language l) noexcept { @@ -25,7 +31,7 @@ std::string_view to_string(Language l) noexcept { case Language::Japanese: return "Japanese"; case Language::Russian: return "Russian"; } - return "English"; + CCM_UNREACHABLE(); } std::string_view to_string(Condition c) noexcept { @@ -38,7 +44,7 @@ std::string_view to_string(Condition c) noexcept { case Condition::Played: return "Played"; case Condition::Poor: return "Poor"; } - return "Mint"; + CCM_UNREACHABLE(); } std::string_view to_string(Theme t) noexcept { @@ -46,7 +52,7 @@ std::string_view to_string(Theme t) noexcept { case Theme::Light: return "Light"; case Theme::Dark: return "Dark"; } - return "Light"; + CCM_UNREACHABLE(); } std::optional gameFromString(std::string_view s) noexcept { diff --git a/core/src/services/CardPreviewService.cpp b/core/src/services/CardPreviewService.cpp index 0c3d70e..d5eab54 100644 --- a/core/src/services/CardPreviewService.cpp +++ b/core/src/services/CardPreviewService.cpp @@ -218,18 +218,12 @@ Result CardPreviewService::fetchImageBytesByUrl(std::string_view ur // and, if needed, fetch+store. const std::string key = makeUrlKey(url); std::string cached; - switch (cacheLookup(key, cached)) { - case CacheLookupKind::Hit: - return Result::ok(std::move(cached)); - case CacheLookupKind::NegativeHit: - // Defensive: nothing in this code path ever stores a negative - // entry under a URL key, but if one ever ends up here (cache - // file tampering, future code paths) treat it as a miss so the - // fallback fetch can still run. - break; - case CacheLookupKind::Miss: - break; + const auto mem = cacheLookup(key, cached); + if (mem == CacheLookupKind::Hit) { + return Result::ok(std::move(cached)); } + // Miss, or a spurious negative under a URL key (never written by normal + // code) — both continue to disk / network. if (persistentCache_ != nullptr) { const auto disk = persistentCache_->load(key); if (disk.kind == IPreviewByteCache::HitKind::Hit) { diff --git a/core/src/util/FsNames.cpp b/core/src/util/FsNames.cpp index 5233cea..5c7a1cc 100644 --- a/core/src/util/FsNames.cpp +++ b/core/src/util/FsNames.cpp @@ -78,7 +78,6 @@ std::uint8_t parseIndexFromFilename(std::string_view filename) noexcept { for (std::size_t i = begin; i < end; ++i) { value = value * 10 + static_cast(filename[i] - '0'); } - if (value > 255) value = 255; return static_cast(value); } diff --git a/docs/AGENTS.md b/docs/AGENTS.md index b2619c1..ff0f8f0 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -11,7 +11,7 @@ Long-form contributor documentation that lives outside the source tree. - `dow-doc-build-locally.md` — complete local build/setup reference for Windows and Linux, including dependency management and troubleshooting. - `intro-to-new-developers.md` — onboarding map for new contributors: architecture, folder responsibilities, guardrails, anti-patterns, and links to deeper docs. - `testing-and-test-code-of-conduct.md` — testing workflow plus expected standards for writing and maintaining deterministic, hermetic, behavior-focused tests. -- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon, and Yu-Gi-Oh! modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png`), and error-surface conventions. +- `assets-and-info-apis.md` — reference for the external info APIs (set metadata) and asset APIs (card preview images) used by the Magic, Pokémon, and Yu-Gi-Oh! modules, plus the runtime flow through `SetService` / `CardPreviewService`, shared HTTP defaults (`CprHttpClient`, `Accept: */*`), per-game card-back fallbacks (URLs + bundled `ygo_card_back.png`), and error-surface conventions. The Yu-Gi-Oh! **Info API** section also documents the local **set code** lookup used by the edit dialog (`YuGiOhSetLookup`, no extra HTTP). - `caching.md` — dedicated reference for preview-byte caching tiers (`CardPreviewService` LRU + `LocalPreviewByteCache`), cache keys and eviction, HTTP session reuse via `CprHttpClient`, and explicit non-goals (no error caching). - `README.md` — index page that clusters docs by area and links to all documents in this directory. diff --git a/docs/adding-a-new-game.md b/docs/adding-a-new-game.md index 59b8190..a3e663d 100644 --- a/docs/adding-a-new-game.md +++ b/docs/adding-a-new-game.md @@ -326,6 +326,12 @@ Derive from `BaseCardEditDialog<Card>`. Override: - `writeExtraToCard()` — copy values from your widgets back into `mutableCard()`. - `updateMenuName()` — return `"Update "`. This is what the dialog's "no sets cached" hint shows the user. +Optional `BaseCardEditDialog` extension points (defaults keep a single read-only set combo in the **Set** row): + +- `customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo)` — the base wraps the combo in a host panel and calls this so a game can add adjacent controls (Yu-Gi-Oh! adds a **Set code** toggle, a text field, and **Auto detect** beside the combo). The default implementation only does `row.Add(combo, 1, wxEXPAND)`. +- `applySetSelectionByIndex(std::size_t index)` (non-virtual helper on the base) — selects a row in the combo and assigns `card_.set` from `availableSets()[index]` when the combo is enabled. +- `onSetSelectionApplied()` — called after `applySetSelectionByIndex` completes; default no-op. Yu-Gi-Oh! overrides it to clear cached print-variant metadata and reschedule the same follow-up as a manual `wxEVT_COMBOBOX` set change. + In the constructor: 1. Pass through to the `BaseCardEditDialog` constructor with the dialog title (e.g. `"Add Card"` or `"Edit Card"` based on `EditMode`), `imageService`, `setService`, `mode`, `std::move(initial)`, `Game::`, and the optional `preloadedSets` pointer. @@ -354,10 +360,10 @@ Implement the virtuals: - `gameId()` returns `Game::`. - `displayName()` returns `""`. -- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`. **The binding must live here**, in the typed `IGameView`, not in `MainFrame` — `MainFrame` only sees `IGameView` and never `Card`. +- `listPanel(parent)` — lazily allocates the list panel as a child of `parent`; on first allocation, also `Bind(EVT_CARD_SELECTED, ...)` to push `listPanel_->selected()` into `selectedPanel_`, and `Bind(EVT_CARD_ACTIVATED, ...)` so a double-click (or Enter on the focused row) calls `onEditCard` with `wxGetTopLevelParent(listPanel_)` as the modal owner when available. **The binding must live here**, in the typed `IGameView`, not in `MainFrame` — `MainFrame` only sees `IGameView` and never `Card`. - `selectedPanel(parent)` — lazily allocates the selected panel. - `refreshCollection()` — calls `collection_.list(Game::)`, handles errors with `wxMessageBox`, and pushes the new vector into `listPanel_->setCards(...)`. Also re-syncs the selected panel. -- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. +- `onAddCard(parent)`, `onEditCard(parent)`, `onDeleteCard(parent)` — open the typed `CardEditDialog` (or pop a confirm dialog for delete), call the typed `CollectionService` to commit, and refresh on success. For Add/Edit, follow the built-in game views: if `cardEditModalIsActive()` from `ccm/ui/CardEditModalGuard.hpp`, show a themed info dialog and return; otherwise wrap `ShowModal()` with `CardEditModalGuard` so a second Add/Edit cannot stack while one card dialog is already open. - `onUpdateSets(parent)` — calls `sets_.updateSets(Game::)`, refreshes `setsCache_`, returns a status string. - `setFilter(filter)` — forwards to `listPanel_->setFilter(filter)`. - `applyTheme(palette)` — forwards to both panels' `applyTheme`. diff --git a/docs/assets-and-info-apis.md b/docs/assets-and-info-apis.md index a95dbb8..cb1c070 100644 --- a/docs/assets-and-info-apis.md +++ b/docs/assets-and-info-apis.md @@ -45,6 +45,8 @@ Used by `YuGiOhSetSource`. The response is a top-level JSON array. Each object m CCM3 also applies a deterministic local patch step in `YuGiOhSetSource::appendMissingSetAliases(...)` after parsing: if upstream omits known 25th Anniversary TCG reprints, the app injects missing aliases for `LOB-25TH`, `MRD-25TH`, `SRL-25TH`, `PSV-25TH`, `DCR-25TH`, and `IOC-25TH` (with fixed release dates) so users can still select those products in the set picker. +**UI note (set code entry, no extra HTTP):** The Yu-Gi-Oh! Add/Edit dialog can resolve a typed **product code** against the **already cached** set vector (same data as the set dropdown). Matching is implemented in `core/include/ccm/util/YuGiOhSetLookup.hpp` as `lookupYuGiOhSetByShorthand(...)`: trim ASCII whitespace, ASCII case-fold, then require an **exact** match on `Set.id` (the YGOPRODeck `set_code`). Zero matches → user error; more than one row with the same normalized id → ambiguous error (defensive). On a unique hit the dialog returns to the dropdown and selects that set. + ### Asset API: Yugipedia `api.php` (primary) `https://yugipedia.com/api.php?action=query&prop=imageinfo&iiprop=url&titles=...` diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 45c2d32..66fddcb 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -22,6 +22,7 @@ - `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL. - `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`. - `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_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) 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). - `card_sorter_tests.cpp` — `sortMagicCards` / `sortPokemonCards` per-column behavior. Pin-down tests for `byField`-equivalent semantics: case-insensitive strings, chronological set sort via `set.releaseDate`, numeric `amount`, `false < true` boolean order, stable composition (sort by name then by set keeps inner-name order). Update this file whenever you add a new column / sort key. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 416c826..badd60a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -23,6 +23,7 @@ add_executable(ccm_core_tests pokemon_card_preview_source_tests.cpp icard_preview_source_tests.cpp yugioh_set_source_tests.cpp + yugioh_set_lookup_tests.cpp yugioh_card_preview_source_tests.cpp game_module_tests.cpp card_sorter_tests.cpp diff --git a/tests/ascii_utils_tests.cpp b/tests/ascii_utils_tests.cpp index 8feff69..cffffde 100644 --- a/tests/ascii_utils_tests.cpp +++ b/tests/ascii_utils_tests.cpp @@ -17,4 +17,9 @@ TEST_SUITE("asciiLower") { const std::string input = "caf\u00e9"; CHECK(asciiLower(input) == input); } + + TEST_CASE("bytes above ASCII range are passed through tolower unchanged") { + const std::string input(1, static_cast('\x80')); + CHECK(asciiLower(input) == input); + } } diff --git a/tests/domain_json_tests.cpp b/tests/domain_json_tests.cpp index e50b289..ec99884 100644 --- a/tests/domain_json_tests.cpp +++ b/tests/domain_json_tests.cpp @@ -440,6 +440,63 @@ TEST_SUITE("Domain JSON required fields") { } } + TEST_CASE("MagicCard missing each required key throws") { + const nlohmann::json full = { + {"id", 10}, + {"amount", 1}, + {"name", "Lightning Bolt"}, + {"set", nlohmann::json{ + {"id", "lea"}, + {"name", "Limited Edition Alpha"}, + {"releaseDate", "1993/08/05"}, + }}, + {"note", ""}, + {"images", nlohmann::json::array()}, + {"language", "English"}, + {"condition", "NearMint"}, + {"foil", false}, + {"signed", false}, + {"altered", false}, + }; + + for (const char* key : {"id", "amount", "name", "set", "note", "images", "language", + "condition", "foil", "signed", "altered"}) { + nlohmann::json partial = full; + partial.erase(key); + CHECK_THROWS(partial.get()); + } + } + + TEST_CASE("PokemonCard missing each required key throws") { + const nlohmann::json full = { + {"id", 7}, + {"amount", 1}, + {"name", "Charizard"}, + {"set", nlohmann::json{ + {"id", "base1"}, + {"name", "Base Set"}, + {"releaseDate", "1999/01/09"}, + }}, + {"setNo", "4/102"}, + {"note", ""}, + {"images", nlohmann::json::array()}, + {"language", "English"}, + {"condition", "Excellent"}, + {"firstEdition", true}, + {"holo", true}, + {"signed", false}, + {"altered", false}, + }; + + for (const char* key : + {"id", "amount", "name", "set", "setNo", "note", "images", "language", "condition", + "firstEdition", "holo", "signed", "altered"}) { + nlohmann::json partial = full; + partial.erase(key); + CHECK_THROWS(partial.get()); + } + } + TEST_CASE("Configuration missing required key throws") { const nlohmann::json j = { {"defaultGame", "Magic"}, diff --git a/tests/json_collection_repository_tests.cpp b/tests/json_collection_repository_tests.cpp index 538cd28..64cb1e2 100644 --- a/tests/json_collection_repository_tests.cpp +++ b/tests/json_collection_repository_tests.cpp @@ -163,4 +163,28 @@ TEST_SUITE("JsonCollectionRepository") { REQUIRE(loadCreateFail.isErr()); CHECK(loadCreateFail.error() == "write failed"); } + + TEST_CASE("load returns read error when collection exists but read fails") { + InMemoryFileSystem configFs; + auto cfg = makeConfig(configFs, "/data"); + FailingCollectionFs fs; + JsonCollectionRepository repo{fs, cfg, magicDir}; + fs.existsValue = true; + fs.readOk = false; + + const auto loaded = repo.load(Game::Magic); + REQUIRE(loaded.isErr()); + CHECK(loaded.error() == "read failed"); + } + + TEST_CASE("load returns parse error when card object does not deserialize") { + InMemoryFileSystem fs; + auto cfg = makeConfig(fs, "/data"); + JsonCollectionRepository repo{fs, cfg, magicDir}; + fs.writeText("/data/magic/collection.json", R"({"0":{"id":"not-a-number"}})"); + + const auto loaded = repo.load(Game::Magic); + REQUIRE(loaded.isErr()); + CHECK(loaded.error().find("JSON parse error:") != std::string::npos); + } } diff --git a/tests/pokemon_card_preview_source_tests.cpp b/tests/pokemon_card_preview_source_tests.cpp index e746abd..9ca7c5c 100644 --- a/tests/pokemon_card_preview_source_tests.cpp +++ b/tests/pokemon_card_preview_source_tests.cpp @@ -277,6 +277,70 @@ TEST_SUITE("PokemonCardPreviewSource::parsePrintVariants") { REQUIRE(out.value().size() == 1); CHECK(out.value().front().setNo == "7"); } + + TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") { + const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({ + "data":[ + {"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"}, + {"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}} + ] + })", + "base1", "Pikachu"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 1); + CHECK(out.value().front().setNo == "26"); + } + + TEST_CASE("empty setId skips set filter and collects prints across sets") { + const char* crossSet = R"({ + "data": [ + {"name":"Pikachu","number":"1","rarity":"Common","set":{"id":"base1"}}, + {"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base2"}} + ] + })"; + const auto out = PokemonCardPreviewSource::parsePrintVariants(crossSet, "", "Pikachu"); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 2u); + } + + TEST_CASE("empty wanted card name skips name filter within the set") { + const char* twoInSet = R"({ + "data": [ + {"name":"Electabuzz","number":"1","rarity":"Common","set":{"id":"base1"}}, + {"name":"Pikachu","number":"2","rarity":"Rare","set":{"id":"base1"}} + ] + })"; + const auto out = PokemonCardPreviewSource::parsePrintVariants(twoInSet, "base1", ""); + REQUIRE(out.isOk()); + REQUIRE(out.value().size() == 2u); + } + + TEST_CASE("cards with empty number and rarity are skipped for auto-detect metadata") { + const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({ + "data": [ + {"name":"Pikachu","number":"","rarity":"","set":{"id":"base1"}} + ] + })", + "base1", "Pikachu"); + REQUIRE(out.isErr()); + CHECK(out.error() == "Could not auto-detect set print metadata."); + } + + TEST_CASE("no matches with empty setId yields generic no matching cards message") { + const auto out = PokemonCardPreviewSource::parsePrintVariants( + R"({"data":[{"name":"Pikachu","number":"1","rarity":"C","set":{"id":"base1"}}]})", + "", + "Nobody"); + REQUIRE(out.isErr()); + CHECK(out.error() == "Pokemon TCG returned no matching cards."); + } + + TEST_CASE("invalid JSON in parsePrintVariants yields parse error") { + const auto out = + PokemonCardPreviewSource::parsePrintVariants("{not json", "base1", "Pikachu"); + REQUIRE(out.isErr()); + CHECK(out.error().find("Pokemon TCG JSON parse error:") == 0); + } } TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") { @@ -329,6 +393,23 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") { CHECK(http.calls == 2); } + TEST_CASE("detectPrintVariants surfaces fallback HTTP error when both requests fail") { + class AlwaysFailHttp final : public IHttpClient { + public: + int calls = 0; + Result get(std::string_view) override { + ++calls; + return Result::err("offline"); + } + } http; + + PokemonCardPreviewSource src{http}; + const auto out = src.detectPrintVariants("Pikachu", "base1"); + REQUIRE(out.isErr()); + CHECK(out.error() == "offline"); + CHECK(http.calls == 2); + } + TEST_CASE("detectFirstPrint errors when variant listing succeeds but is empty") { FixedHttpClient http; http.body = R"({"data":[{"name":"Promo","number":"","rarity":"","set":{"id":"promo1"}}]})"; @@ -337,17 +418,4 @@ TEST_SUITE("PokemonCardPreviewSource::detectPrintVariants") { REQUIRE(out.isErr()); CHECK(out.error() == "Could not auto-detect set print metadata."); } - - TEST_CASE("parsePrintVariants ignores cards whose set field is not an object") { - const auto out = PokemonCardPreviewSource::parsePrintVariants(R"({ - "data":[ - {"name":"Pikachu","number":"25","rarity":"Common","set":"not-an-object"}, - {"name":"Pikachu","number":"26","rarity":"Rare","set":{"id":"base1"}} - ] - })", - "base1", "Pikachu"); - REQUIRE(out.isOk()); - REQUIRE(out.value().size() == 1); - CHECK(out.value().front().setNo == "26"); - } } diff --git a/tests/std_file_system_tests.cpp b/tests/std_file_system_tests.cpp index a75d122..a014ddf 100644 --- a/tests/std_file_system_tests.cpp +++ b/tests/std_file_system_tests.cpp @@ -202,4 +202,15 @@ TEST_SUITE("StdFileSystem") { REQUIRE(filled.isOk()); CHECK(filled.value().size() == 2u); } + + TEST_CASE("writeText fails when the path names an existing directory") { + TempDir td; + StdFileSystem fs; + const auto dir = td.path / "is_dir"; + REQUIRE(fs.ensureDirectory(dir).isOk()); + + const auto r = fs.writeText(dir, "cannot-write-here"); + REQUIRE(r.isErr()); + CHECK(r.error().find("Unable to create file") != std::string::npos); + } } diff --git a/tests/yugioh_card_preview_source_tests.cpp b/tests/yugioh_card_preview_source_tests.cpp index 08dd458..c0cf5dd 100644 --- a/tests/yugioh_card_preview_source_tests.cpp +++ b/tests/yugioh_card_preview_source_tests.cpp @@ -93,6 +93,7 @@ TEST_SUITE("ygoPrintingSlotsMatch") { CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-DE005")); CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("SOD-EN015")); CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-E")); + CHECK_FALSE(ygoLikelyEuropeanRegionalSetCode("LOB-EX005")); } } @@ -171,6 +172,12 @@ TEST_SUITE("YuGiOhCardPreviewSource::rarityCodeFor") { CHECK(YuGiOhCardPreviewSource::rarityCodeFor("").empty()); CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mythic Cosmic Rare").empty()); } + + TEST_CASE("uses dialog synonym table when ygoRarityShortCode does not match") { + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Mosaic Rare") == "MSR"); + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Parallel Rare") == "PR"); + CHECK(YuGiOhCardPreviewSource::rarityCodeFor("Gold Rare") == "GUR"); + } } TEST_SUITE("YuGiOhCardPreviewSource::extractSetCode") { diff --git a/tests/yugioh_set_lookup_tests.cpp b/tests/yugioh_set_lookup_tests.cpp new file mode 100644 index 0000000..0444490 --- /dev/null +++ b/tests/yugioh_set_lookup_tests.cpp @@ -0,0 +1,87 @@ +#include + +#include "ccm/domain/Set.hpp" +#include "ccm/util/YuGiOhSetLookup.hpp" + +using namespace ccm; + +namespace { + +std::vector sampleSets() { + return { + Set{.id = "LOB", .name = "Legend of Blue Eyes White Dragon", .releaseDate = "2002/03/08"}, + Set{.id = "MRD", .name = "Metal Raiders", .releaseDate = "2002/06/26"}, + Set{.id = "LOB-25TH", .name = "Legend of Blue Eyes White Dragon (25th Anniversary Edition)", + .releaseDate = "2023/04/20"}, + }; +} + +} // namespace + +TEST_SUITE("lookupYuGiOhSetByShorthand") { + using Kind = YuGiOhSetShorthandLookup::Kind; + + TEST_CASE("empty and whitespace-only query is NotFound") { + const auto sets = sampleSets(); + CHECK(lookupYuGiOhSetByShorthand("", sets).kind == Kind::NotFound); + CHECK(lookupYuGiOhSetByShorthand(" ", sets).kind == Kind::NotFound); + CHECK(lookupYuGiOhSetByShorthand("\t\n", sets).kind == Kind::NotFound); + } + + TEST_CASE("case-insensitive exact id match is Unique") { + const auto sets = sampleSets(); + auto r = lookupYuGiOhSetByShorthand("lob", sets); + REQUIRE(r.kind == Kind::Unique); + CHECK(r.index == 0); + CHECK(sets[r.index].id == "LOB"); + + r = lookupYuGiOhSetByShorthand("MRD", sets); + REQUIRE(r.kind == Kind::Unique); + CHECK(r.index == 1); + } + + TEST_CASE("trim ASCII whitespace around query") { + const auto sets = sampleSets(); + const auto r = lookupYuGiOhSetByShorthand(" LOB ", sets); + REQUIRE(r.kind == Kind::Unique); + CHECK(r.index == 0); + } + + TEST_CASE("hyphenated set codes match") { + const auto sets = sampleSets(); + const auto r = lookupYuGiOhSetByShorthand("lob-25th", sets); + REQUIRE(r.kind == Kind::Unique); + CHECK(r.index == 2); + CHECK(sets[r.index].id == "LOB-25TH"); + } + + TEST_CASE("unknown code is NotFound") { + const auto sets = sampleSets(); + CHECK(lookupYuGiOhSetByShorthand("NOPE", sets).kind == Kind::NotFound); + } + + TEST_CASE("Ambiguous when two sets share the same normalized id") { + std::vector dup = { + Set{.id = "X1", .name = "A", .releaseDate = "2000/01/01"}, + Set{.id = "x1", .name = "B", .releaseDate = "2000/01/02"}, + }; + CHECK(lookupYuGiOhSetByShorthand("X1", dup).kind == Kind::Ambiguous); + } + + TEST_CASE("first matching index is stable when Unique among similar prefixes") { + const auto sets = sampleSets(); + const auto r = lookupYuGiOhSetByShorthand("LOB", sets); + REQUIRE(r.kind == Kind::Unique); + CHECK(r.index == 0); + CHECK(sets[r.index].id == "LOB"); + } + + TEST_CASE("normalizeYuGiOhSetIdForLookup lowercases ASCII") { + CHECK(normalizeYuGiOhSetIdForLookup("Ra04-EN001") == "ra04-en001"); + } + + TEST_CASE("trimAsciiWhitespace handles empty") { + CHECK(trimAsciiWhitespace("") == ""); + CHECK(trimAsciiWhitespace("x") == "x"); + } +} diff --git a/ui_wx/AGENTS.md b/ui_wx/AGENTS.md index 60d9e73..0e2aa70 100644 --- a/ui_wx/AGENTS.md +++ b/ui_wx/AGENTS.md @@ -10,18 +10,19 @@ - `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel` 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/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr` + `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 use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `/assets/ygo_card_back.png` (copied next to the executable by `app/CMakeLists.txt` on link — source file `ui_wx/assets/ygo_card_back.png`). The constructor caches `/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant. -- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI. +- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. The **Set** row is built on a host `wxPanel` with a horizontal `wxBoxSizer`; games may override `customizeSetPickerRow(row, combo)` to wrap the combo (default: combo only). After a programmatic selection, `applySetSelectionByIndex` updates `card_.set` and calls `onSetSelectionApplied()` (default no-op). After `buildAndPopulate()`, the template snapshots the loaded card into `openingSnapshot_`; in **`EditMode::Edit`**, OK asks **Yes/No** (“Save changes to this card?”) only when the card differs from that snapshot (dirty-only confirm). **Create** mode never prompts. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`). The template binds `EVT_TEXT` on **Name** and invokes `onCardLookupContextChanged()` so games can drop stale keyed metadata when the user edits the lookup identity (Yu-Gi-Oh! clears its YGOPRODeck print-variant cache here). `YuGiOhCardEditDialog` overrides `customizeSetPickerRow` to add a **`SwitchCtrl`** pill switch plus a **hint** label (`Set name` / `Set code`), a text field, and **Auto detect** (resolves `Set.id` via `ccm/util/YuGiOhSetLookup.hpp` against `availableSets()`, then returns to the dropdown on success); it overrides `onSetSelectionApplied` to match manual set-change behavior. It additionally `CallAfter`s a silent `detectPrintVariants` when opening **Edit** (and after changing **Set**) so multi-print **Next** buttons can appear without pressing Auto detect first, as long as name + display set are populated. The base also exposes helpers to sync current control values and inspect the currently-selected set when a subclass needs derived-field UI. +- `include/ccm/ui/SwitchCtrl.hpp` + `src/SwitchCtrl.cpp` — custom pill-track + thumb switch for small modal rows (Yu-Gi-Oh! set picker); fires `EVT_CCM_SWITCH` on user toggle and reads colors from `inferThemeFromWindow` / `paletteForTheme`. - `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50–100 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/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. - `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`. - `include/ccm/ui/ImageViewerDialog.hpp` + `src/ImageViewerDialog.cpp` — full-size viewer with prev/next. -- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs. +- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs. `applyThemeToWindowTree` paints `wxButton`, `wxBitmapButton`, and **`wxToggleButton`** in dark mode (custom `wxEVT_PAINT` + hover/focus) so native Win32 theming cannot flash a light hover plate; light mode leaves buttons native where possible. `SwitchCtrl` is palette-driven and self-painted (not native `wxToggleButton`). ## Conventions -1. **Only consume core through `AppContext`.** Do not include any header from `ccm/infra/` here. The set of allowed `ccm/...` includes is `domain/`, `services/`, `games/IGameModule.hpp`, `ports/ICardPreviewSource.hpp`, and `util/Result.hpp`. +1. **Only consume core through `AppContext`.** Do not include any header from `ccm/infra/` here. The set of allowed `ccm/...` includes is `domain/`, `services/`, `games/IGameModule.hpp`, `ports/ICardPreviewSource.hpp`, and `util/` headers that remain UI-agnostic (for example `util/Result.hpp`, `util/YuGiOhPrintingSlot.hpp`, `util/YuGiOhSetLookup.hpp`). Do not pull arbitrary `util/` or `games/` implementation headers beyond what a panel/dialog already needs for display or small shared helpers. 2. **Image decoding lives here, not in core.** Use `wxImage::LoadFile(path.string())` against the path returned by `IImageStore::resolvePath`. Core stays free of any image library. 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`. @@ -70,7 +71,7 @@ - If you change fallback sourcing (URLs or bundled asset), keep the "always show a reasonable card-back fallback" behavior intact for **every** game with remote previews. 16. **Per-game auto-detect controls:** - Auto-detect actions in edit dialogs (e.g. detect set print number / rarity from API) are opt-in per game. - - Keep shared templates game-agnostic: put buttons and detection behavior in `CardEditDialog`, not in `BaseCardEditDialog`. + - Keep shared templates game-agnostic: put buttons and detection behavior in `CardEditDialog`, not in `BaseCardEditDialog`. Yu-Gi-Oh!'s **Set code** entry (`SwitchCtrl` + text + **Auto detect** against cached sets) is wired through the template hook `customizeSetPickerRow` so Magic/Pokemon keep the default single-combo row unchanged. - For games that use composed print IDs (prefix + numeric suffix), allow user editing on the numeric portion and render the full code as a read-only derived label beside the input. ## Required follow-ups diff --git a/ui_wx/CMakeLists.txt b/ui_wx/CMakeLists.txt index 36a0454..b8dbf09 100644 --- a/ui_wx/CMakeLists.txt +++ b/ui_wx/CMakeLists.txt @@ -21,6 +21,7 @@ add_library(ccm_ui_wx STATIC src/YuGiOhGameView.cpp src/SettingsDialog.cpp + src/SwitchCtrl.cpp src/ImageViewerDialog.cpp src/IconListCtrl.cpp src/SvgIcons.cpp diff --git a/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp index a88822b..80eaf55 100644 --- a/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/BaseCardEditDialog.hpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -146,6 +147,28 @@ protected: return &available[static_cast(sel)]; } + [[nodiscard]] const std::vector& availableSets() const noexcept { + return preloadedSets_ != nullptr ? *preloadedSets_ : sets_; + } + + // Default: combo only. Yu-Gi-Oh! overrides to add set-code entry + toggle. + virtual void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) { + row.Add(combo, 1, wxEXPAND); + } + + // After programmatically changing the set combo + `card_.set` (see + // `applySetSelectionByIndex`). Default no-op; Yu-Gi-Oh! clears print-variant cache. + virtual void onSetSelectionApplied() {} + + void applySetSelectionByIndex(std::size_t index) { + const auto& available = availableSets(); + if (!setCombo_ || !setCombo_->IsEnabled()) return; + if (index >= available.size()) return; + setCombo_->SetSelection(static_cast(index)); + card_.set = available[index]; + onSetSelectionApplied(); + } + private: void readSets() { auto loaded = setService_.getSets(game_); @@ -158,10 +181,6 @@ private: } } - [[nodiscard]] const std::vector& availableSets() const noexcept { - return preloadedSets_ != nullptr ? *preloadedSets_ : sets_; - } - void buildLayout() { auto* root = new wxBoxSizer(wxVERTICAL); auto* grid = new wxFlexGridSizer(2, 6, 8); @@ -174,9 +193,16 @@ private: }); appendRow(grid, "Name", nameCtrl_); - setCombo_ = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, + // `setCombo_` must be parented to `setHost` so every control in the Set row + // shares the same `wxPanel`; otherwise the combo stays a direct child of the + // dialog while the sizer lives on `setHost`, which corrupts layout on MSW. + auto* setHost = new wxPanel(this, wxID_ANY); + auto* setRow = new wxBoxSizer(wxHORIZONTAL); + setHost->SetSizer(setRow); + setCombo_ = new wxComboBox(setHost, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0, nullptr, wxCB_READONLY); - appendRow(grid, "Set", setCombo_); + customizeSetPickerRow(*setRow, setCombo_); + appendRow(grid, "Set", setHost); // Subclass extra rows go between Set and Amount (Pokemon adds Set #). appendExtraRows(grid); @@ -237,8 +263,13 @@ private: CallAfter([this]() { if (nameCtrl_) { - nameCtrl_->SetInsertionPoint(0); - nameCtrl_->ShowPosition(0); + nameCtrl_->SetFocus(); + if (mode_ == EditMode::Edit && !nameCtrl_->IsEmpty()) { + nameCtrl_->SetInsertionPointEnd(); + } else { + nameCtrl_->SetInsertionPoint(0); + nameCtrl_->ShowPosition(0); + } } if (noteCtrl_) { noteCtrl_->SetInsertionPoint(0); diff --git a/ui_wx/include/ccm/ui/BaseCardListPanel.hpp b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp index dba1d9d..f7442b3 100644 --- a/ui_wx/include/ccm/ui/BaseCardListPanel.hpp +++ b/ui_wx/include/ccm/ui/BaseCardListPanel.hpp @@ -70,6 +70,10 @@ namespace ccm::ui { // not duplicated per template instantiation. wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent); +// Raised on `wxEVT_LIST_ITEM_ACTIVATED` (double-click / Enter on a row). +// `IGameView` implementations bind this to open Edit for `selected()`. +wxDECLARE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent); + template class BaseCardListPanel : public wxPanel { public: @@ -238,6 +242,7 @@ protected: list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this); list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this); + list_->Bind(wxEVT_LIST_ITEM_ACTIVATED, &BaseCardListPanel::onListItemActivated, this); } // Forwarded helpers ------------------------------------------------------ @@ -642,6 +647,14 @@ private: notifySelectionChanged(); } + void onListItemActivated(wxListEvent& event) { + (void)event; + if (inRebuild_) return; + wxCommandEvent ev(EVT_CARD_ACTIVATED, GetId()); + ev.SetEventObject(this); + ProcessWindowEvent(ev); + } + // ----- members ---------------------------------------------------------- static constexpr int kFlagIconSize = 14; diff --git a/ui_wx/include/ccm/ui/CardEditModalGuard.hpp b/ui_wx/include/ccm/ui/CardEditModalGuard.hpp new file mode 100644 index 0000000..a2a2842 --- /dev/null +++ b/ui_wx/include/ccm/ui/CardEditModalGuard.hpp @@ -0,0 +1,34 @@ +#pragma once + +// Tracks when a modal Add/Edit card dialog is on screen so a second one +// cannot be stacked (toolbar + list activation, or rare re-entrant cases). + +#include + +namespace ccm::ui { + +// User-visible hint when Add/Edit is requested while a card dialog is already modal. +inline constexpr const char* kCardEditModalBlockedUtf8 = + "Close the open card dialog (save or cancel) before opening another card."; + +[[nodiscard]] inline std::atomic& cardEditModalDepthRef() noexcept { + static std::atomic depth{0}; + return depth; +} + +[[nodiscard]] inline bool cardEditModalIsActive() noexcept { + return cardEditModalDepthRef().load(std::memory_order_relaxed) > 0; +} + +struct CardEditModalGuard { + CardEditModalGuard() { + cardEditModalDepthRef().fetch_add(1, std::memory_order_relaxed); + } + ~CardEditModalGuard() { + cardEditModalDepthRef().fetch_sub(1, std::memory_order_relaxed); + } + CardEditModalGuard(const CardEditModalGuard&) = delete; + CardEditModalGuard& operator=(const CardEditModalGuard&) = delete; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/SwitchCtrl.hpp b/ui_wx/include/ccm/ui/SwitchCtrl.hpp new file mode 100644 index 0000000..9bc9764 --- /dev/null +++ b/ui_wx/include/ccm/ui/SwitchCtrl.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +namespace ccm::ui { + +wxDECLARE_EVENT(EVT_CCM_SWITCH, wxCommandEvent); + +// Small on/off switch (pill track + thumb) for modal dialogs. Fires `EVT_CCM_SWITCH` +// when the user toggles; bind with the control pointer as the event source. +class SwitchCtrl final : public wxWindow { +public: + explicit SwitchCtrl(wxWindow* parent, wxWindowID id = wxID_ANY, bool initialOn = false); + + [[nodiscard]] bool GetValue() const noexcept { return on_; } + void SetValue(bool on, bool notify = false); + + bool Enable(bool enable = true) override; + +private: + void onPaint(wxPaintEvent&); + void onLeftDown(wxMouseEvent&); + void onEnter(wxMouseEvent&); + void onLeave(wxMouseEvent&); + + bool on_{false}; + bool hovered_{false}; +}; + +} // namespace ccm::ui diff --git a/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp b/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp index 9634fc9..7894f59 100644 --- a/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp +++ b/ui_wx/include/ccm/ui/YuGiOhCardEditDialog.hpp @@ -4,6 +4,7 @@ #include "ccm/ports/ICardPreviewSource.hpp" #include "ccm/services/CardPreviewService.hpp" #include "ccm/ui/BaseCardEditDialog.hpp" +#include "ccm/ui/SwitchCtrl.hpp" #include #include @@ -21,11 +22,13 @@ public: protected: void buildFlagsRow(wxBoxSizer* flagsBox) override; + void customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) override; void appendExtraRows(wxFlexGridSizer* grid) override; void readExtraFromCard() override; void writeExtraToCard() override; [[nodiscard]] std::string updateMenuName() const override { return "Update Yu-Gi-Oh!"; } void onCardLookupContextChanged() override; + void onSetSelectionApplied() override; private: void onAutoDetectSetNo(wxCommandEvent&); @@ -34,6 +37,10 @@ private: void onNextRarity(wxCommandEvent&); void onSetNoTextChanged(wxCommandEvent&); void onSetSelectionChanged(wxCommandEvent&); + void handleSetSelectionChanged(); + void onSetRowSwitch(wxCommandEvent&); + void onSetCodeAutoDetect(wxCommandEvent&); + void syncSetModeHint(); void autoDetectFromApi(bool fillSetNo, bool fillRarity); void refreshSetNoFullPreview(); void clearCachedPrintVariants(); @@ -63,6 +70,12 @@ private: wxCheckBox* signedCheck_{nullptr}; wxCheckBox* alteredCheck_{nullptr}; + wxPanel* setCodeRowPanel_{nullptr}; + wxTextCtrl* setCodeText_{nullptr}; + wxButton* setCodeAutoBtn_{nullptr}; + wxStaticText* setModeHint_{nullptr}; + SwitchCtrl* setPickerSwitch_{nullptr}; + std::vector cachedVariants_; std::vector uniqueSetCodes_; std::vector raritiesForCurrentSetCode_; diff --git a/ui_wx/src/BaseEvents.cpp b/ui_wx/src/BaseEvents.cpp index 8cc8ed5..a24ab90 100644 --- a/ui_wx/src/BaseEvents.cpp +++ b/ui_wx/src/BaseEvents.cpp @@ -2,6 +2,8 @@ // declared in the corresponding base headers (BaseCardListPanel.hpp, // BaseSelectedCardPanel.hpp) and defined exactly once here, so that template // instantiations (Magic, Pokemon, ...) all use the same event type tag. +// EVT_CARD_ACTIVATED is declared alongside EVT_CARD_SELECTED in +// BaseCardListPanel.hpp. #include "ccm/ui/BaseCardListPanel.hpp" #include "ccm/ui/BaseSelectedCardPanel.hpp" @@ -9,6 +11,7 @@ namespace ccm::ui { wxDEFINE_EVENT(EVT_CARD_SELECTED, wxCommandEvent); +wxDEFINE_EVENT(EVT_CARD_ACTIVATED, wxCommandEvent); wxDEFINE_EVENT(EVT_PREVIEW_STATUS, wxCommandEvent); } // namespace ccm::ui diff --git a/ui_wx/src/MagicGameView.cpp b/ui_wx/src/MagicGameView.cpp index 9dd6995..a9f04ae 100644 --- a/ui_wx/src/MagicGameView.cpp +++ b/ui_wx/src/MagicGameView.cpp @@ -1,11 +1,13 @@ #include "ccm/ui/MagicGameView.hpp" +#include "ccm/ui/CardEditModalGuard.hpp" #include "ccm/ui/MagicCardEditDialog.hpp" #include "ccm/ui/MagicCardListPanel.hpp" #include "ccm/ui/MagicSelectedCardPanel.hpp" #include "ccm/ui/Theme.hpp" #include +#include #include #include @@ -54,6 +56,10 @@ wxPanel* MagicGameView::listPanel(wxWindow* parent) { selectedPanel_->setCard(listPanel_->selected()); } }); + listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { + wxWindow* owner = wxGetTopLevelParent(listPanel_); + onEditCard(owner != nullptr ? owner : static_cast(listPanel_)); + }); } return listPanel_; } @@ -88,6 +94,11 @@ const std::vector& MagicGameView::setsForDialog() { } void MagicGameView::onAddCard(wxWindow* parentWindow) { + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION); + return; + } MagicCard fresh; fresh.amount = 1; fresh.language = Language::English; @@ -96,6 +107,7 @@ void MagicGameView::onAddCard(wxWindow* parentWindow) { MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh, &setsForDialog()); themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; if (dlg.ShowModal() != wxID_OK) return; auto added = collection_.add(Game::Magic, dlg.card()); @@ -132,9 +144,15 @@ void MagicGameView::onEditCard(wxWindow* parentWindow) { showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION); return; } + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION); + return; + } MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel, &setsForDialog()); themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; if (dlg.ShowModal() != wxID_OK) return; auto updated = collection_.update(Game::Magic, dlg.card()); if (!updated) { diff --git a/ui_wx/src/PokemonGameView.cpp b/ui_wx/src/PokemonGameView.cpp index 1a6ff2d..a123629 100644 --- a/ui_wx/src/PokemonGameView.cpp +++ b/ui_wx/src/PokemonGameView.cpp @@ -1,11 +1,13 @@ #include "ccm/ui/PokemonGameView.hpp" +#include "ccm/ui/CardEditModalGuard.hpp" #include "ccm/ui/PokemonCardEditDialog.hpp" #include "ccm/ui/PokemonCardListPanel.hpp" #include "ccm/ui/PokemonSelectedCardPanel.hpp" #include "ccm/ui/Theme.hpp" #include +#include #include #include @@ -51,6 +53,10 @@ wxPanel* PokemonGameView::listPanel(wxWindow* parent) { selectedPanel_->setCard(listPanel_->selected()); } }); + listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { + wxWindow* owner = wxGetTopLevelParent(listPanel_); + onEditCard(owner != nullptr ? owner : static_cast(listPanel_)); + }); } return listPanel_; } @@ -85,6 +91,11 @@ const std::vector& PokemonGameView::setsForDialog() { } void PokemonGameView::onAddCard(wxWindow* parentWindow) { + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION); + return; + } PokemonCard fresh; fresh.amount = 1; fresh.language = Language::English; @@ -93,6 +104,7 @@ void PokemonGameView::onAddCard(wxWindow* parentWindow) { PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh, &setsForDialog()); themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; if (dlg.ShowModal() != wxID_OK) return; auto added = collection_.add(Game::Pokemon, dlg.card()); @@ -129,9 +141,15 @@ void PokemonGameView::onEditCard(wxWindow* parentWindow) { showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION); return; } + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION); + return; + } PokemonCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel, &setsForDialog()); themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; if (dlg.ShowModal() != wxID_OK) return; auto updated = collection_.update(Game::Pokemon, dlg.card()); if (!updated) { diff --git a/ui_wx/src/SwitchCtrl.cpp b/ui_wx/src/SwitchCtrl.cpp new file mode 100644 index 0000000..58d055b --- /dev/null +++ b/ui_wx/src/SwitchCtrl.cpp @@ -0,0 +1,135 @@ +#include "ccm/ui/SwitchCtrl.hpp" +#include "ccm/domain/Enums.hpp" +#include "ccm/ui/Theme.hpp" + +#include +#include + +#include + +namespace ccm::ui { + +wxDEFINE_EVENT(EVT_CCM_SWITCH, wxCommandEvent); + +namespace { + +wxColour liftRgb(const wxColour& c, int delta) { + auto lift = [delta](unsigned char ch) -> unsigned char { + const int v = static_cast(ch) + delta; + return static_cast(v > 255 ? 255 : (v < 0 ? 0 : v)); + }; + return wxColour(lift(c.Red()), lift(c.Green()), lift(c.Blue())); +} + +} // namespace + +SwitchCtrl::SwitchCtrl(wxWindow* parent, wxWindowID id, bool initialOn) + : wxWindow(parent, id, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE, wxString()), + on_(initialOn) { + SetBackgroundStyle(wxBG_STYLE_PAINT); + SetCursor(wxCURSOR_HAND); + const wxSize sz = FromDIP(wxSize(40, 20)); + SetMinSize(sz); + SetMaxSize(sz); + SetInitialSize(sz); + + Bind(wxEVT_PAINT, &SwitchCtrl::onPaint, this); + Bind(wxEVT_LEFT_DOWN, &SwitchCtrl::onLeftDown, this); + Bind(wxEVT_ENTER_WINDOW, &SwitchCtrl::onEnter, this); + Bind(wxEVT_LEAVE_WINDOW, &SwitchCtrl::onLeave, this); + Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {}); +} + +void SwitchCtrl::SetValue(bool on, bool notify) { + if (on_ == on) return; + on_ = on; + Refresh(); + if (notify) { + wxCommandEvent e(EVT_CCM_SWITCH, GetId()); + e.SetEventObject(this); + e.SetInt(on_ ? 1 : 0); + ProcessEvent(e); + } +} + +bool SwitchCtrl::Enable(bool enable) { + const bool ok = wxWindow::Enable(enable); + SetCursor(enable ? wxCURSOR_HAND : wxCURSOR_ARROW); + Refresh(); + return ok; +} + +void SwitchCtrl::onEnter(wxMouseEvent& ev) { + hovered_ = true; + Refresh(); + ev.Skip(); +} + +void SwitchCtrl::onLeave(wxMouseEvent& ev) { + hovered_ = false; + Refresh(); + ev.Skip(); +} + +void SwitchCtrl::onLeftDown(wxMouseEvent& ev) { + if (!IsEnabled()) { + ev.Skip(); + return; + } + on_ = !on_; + Refresh(); + wxCommandEvent e(EVT_CCM_SWITCH, GetId()); + e.SetEventObject(this); + e.SetInt(on_ ? 1 : 0); + ProcessEvent(e); + ev.Skip(false); +} + +void SwitchCtrl::onPaint(wxPaintEvent&) { + wxAutoBufferedPaintDC dc(this); + const wxRect rect = GetClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + const Theme theme = inferThemeFromWindow(this); + const ThemePalette p = paletteForTheme(theme); + const bool dark = theme == Theme::Dark; + + wxColour trackOff = p.inputBg; + wxColour trackOn = p.buttonBg; + wxColour thumb = dark ? wxColour(240, 240, 240) : wxColour(252, 252, 252); + wxColour border = dark ? wxColour(72, 72, 72) : wxColour(158, 158, 158); + + wxColour track = on_ ? trackOn : trackOff; + if (hovered_ && IsEnabled()) { + track = liftRgb(track, dark ? 14 : 10); + } + if (!IsEnabled()) { + track = liftRgb(track, dark ? -22 : -25); + thumb = liftRgb(thumb, dark ? -55 : -35); + border = liftRgb(border, dark ? -15 : 10); + } + + // Fill the full client rect first so rounded-track corners do not show + // undrawn pixels (often black) against the parent panel. + dc.SetPen(*wxTRANSPARENT_PEN); + dc.SetBrush(wxBrush(p.panelBg)); + dc.DrawRectangle(rect); + + dc.SetPen(wxPen(border)); + dc.SetBrush(wxBrush(track)); + const int radius = rect.height / 2; + dc.DrawRoundedRectangle(rect, radius); + + const int pad = FromDIP(2); + const int thumbD = std::max(4, rect.height - 2 * pad); + const int travel = std::max(0, rect.width - 2 * pad - thumbD); + const int thumbX = pad + (on_ ? travel : 0); + const int thumbY = rect.y + (rect.height - thumbD) / 2; + + wxColour thumbBorder = liftRgb(border, dark ? 18 : -12); + dc.SetPen(wxPen(thumbBorder)); + dc.SetBrush(wxBrush(thumb)); + dc.DrawEllipse(thumbX, thumbY, thumbD, thumbD); +} + +} // namespace ccm::ui diff --git a/ui_wx/src/Theme.cpp b/ui_wx/src/Theme.cpp index 966860d..74d1ee0 100644 --- a/ui_wx/src/Theme.cpp +++ b/ui_wx/src/Theme.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -444,8 +445,11 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t #endif } + // `wxToggleButton` is not a `wxButton` on MSW; without this branch it keeps + // native visual styles (e.g. light hover flashes) under dark palette dialogs. if (dynamic_cast(root) != nullptr || - dynamic_cast(root) != nullptr) { + dynamic_cast(root) != nullptr || + dynamic_cast(root) != nullptr) { const bool darkLike = isDarkLikeTheme(theme); root->SetThemeEnabled(!darkLike); root->SetBackgroundColour(palette.buttonBg); @@ -485,7 +489,11 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t return; } it->second.hovered = false; - const wxColour bg = it->second.focused ? it->second.hoverBg : it->second.normalBg; + const bool toggleOn = + dynamic_cast(root) != nullptr && + static_cast(root)->GetValue(); + const wxColour bg = + (it->second.focused || toggleOn) ? it->second.hoverBg : it->second.normalBg; root->SetBackgroundColour(bg); root->SetForegroundColour(it->second.text); root->Refresh(); @@ -513,7 +521,11 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t const wxPoint localPos = root->ScreenToClient(mousePos); const bool inside = root->GetClientRect().Contains(localPos); it->second.hovered = inside; - const wxColour bg = (inside || it->second.focused) ? it->second.hoverBg : it->second.normalBg; + const bool toggleOn = + dynamic_cast(root) != nullptr && + static_cast(root)->GetValue(); + const wxColour bg = + (inside || it->second.focused || toggleOn) ? it->second.hoverBg : it->second.normalBg; root->SetBackgroundColour(bg); root->SetForegroundColour(it->second.text); root->Refresh(); @@ -539,12 +551,25 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t } it->second.focused = false; it->second.pressed = false; - const wxColour bg = it->second.hovered ? it->second.hoverBg : it->second.normalBg; + const bool toggleOn = + dynamic_cast(root) != nullptr && + static_cast(root)->GetValue(); + const wxColour bg = + (it->second.hovered || toggleOn) ? it->second.hoverBg : it->second.normalBg; root->SetBackgroundColour(bg); root->SetForegroundColour(it->second.text); root->Refresh(); event.Skip(); }); + if (auto* toggle = dynamic_cast(root)) { + toggle->Bind(wxEVT_TOGGLEBUTTON, [root](wxCommandEvent& event) { + auto it = gButtonVisualStates.find(root); + if (it != gButtonVisualStates.end() && it->second.darkLike) { + root->Refresh(); + } + event.Skip(); + }); + } root->SetBackgroundStyle(wxBG_STYLE_PAINT); root->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {}); root->Bind(wxEVT_PAINT, [root](wxPaintEvent& event) { @@ -555,10 +580,13 @@ void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme t } wxAutoBufferedPaintDC dc(root); const wxRect rect = root->GetClientRect(); + const bool toggleOn = + dynamic_cast(root) != nullptr && + static_cast(root)->GetValue(); wxColour bg = it->second.normalBg; if (it->second.pressed) { bg = it->second.pressedBg; - } else if (it->second.hovered || it->second.focused) { + } else if (it->second.hovered || it->second.focused || toggleOn) { bg = it->second.hoverBg; } const wxColour fg = it->second.text; diff --git a/ui_wx/src/YuGiOhCardEditDialog.cpp b/ui_wx/src/YuGiOhCardEditDialog.cpp index aa4641c..12d2d04 100644 --- a/ui_wx/src/YuGiOhCardEditDialog.cpp +++ b/ui_wx/src/YuGiOhCardEditDialog.cpp @@ -1,6 +1,8 @@ #include "ccm/ui/YuGiOhCardEditDialog.hpp" +#include "ccm/ui/SwitchCtrl.hpp" #include "ccm/domain/Enums.hpp" #include "ccm/util/YuGiOhPrintingSlot.hpp" +#include "ccm/util/YuGiOhSetLookup.hpp" #include #include #include @@ -57,6 +59,33 @@ void YuGiOhCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) { flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12); } +void YuGiOhCardEditDialog::customizeSetPickerRow(wxBoxSizer& row, wxComboBox* combo) { + wxWindow* const host = combo->GetParent(); + setCodeRowPanel_ = new wxPanel(host, wxID_ANY); + auto* inner = new wxBoxSizer(wxHORIZONTAL); + setCodeText_ = new wxTextCtrl(setCodeRowPanel_, wxID_ANY); + setCodeAutoBtn_ = new wxButton(setCodeRowPanel_, wxID_ANY, "Auto detect"); + inner->Add(setCodeText_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6); + inner->Add(setCodeAutoBtn_, 0, wxALIGN_CENTER_VERTICAL); + setCodeRowPanel_->SetSizer(inner); + setCodeRowPanel_->Show(false); + + setModeHint_ = new wxStaticText(host, wxID_ANY, wxString()); + setPickerSwitch_ = new SwitchCtrl(host, wxID_ANY, false); + setPickerSwitch_->Bind(EVT_CCM_SWITCH, &YuGiOhCardEditDialog::onSetRowSwitch, this); + setCodeAutoBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onSetCodeAutoDetect, this); + + row.Add(combo, 1, wxEXPAND); + row.Add(setCodeRowPanel_, 1, wxEXPAND); + row.Add(setModeHint_, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, 5); + row.Add(setPickerSwitch_, 0, wxALIGN_CENTER_VERTICAL); + + if (availableSets().empty()) { + setPickerSwitch_->Enable(false); + } + syncSetModeHint(); +} + void YuGiOhCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) { auto* setNoPanel = new wxPanel(this, wxID_ANY); setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY); @@ -340,10 +369,78 @@ void YuGiOhCardEditDialog::onSetNoTextChanged(wxCommandEvent&) { } void YuGiOhCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) { + handleSetSelectionChanged(); + ev.Skip(); +} + +void YuGiOhCardEditDialog::onSetSelectionApplied() { + handleSetSelectionChanged(); +} + +void YuGiOhCardEditDialog::handleSetSelectionChanged() { clearCachedPrintVariants(); refreshSetNoFullPreview(); scheduleDeferredVariantPrefetch(); - ev.Skip(); +} + +void YuGiOhCardEditDialog::syncSetModeHint() { + if (!setModeHint_ || !setPickerSwitch_) return; + // Switch on = set-code entry; hint tells user how to return to the name list. + setModeHint_->SetLabel(setPickerSwitch_->GetValue() ? wxString::FromUTF8("Set name") + : wxString::FromUTF8("Set code")); +} + +void YuGiOhCardEditDialog::onSetRowSwitch(wxCommandEvent&) { + if (!setPickerSwitch_ || !setComboControl() || !setCodeRowPanel_) return; + syncSetModeHint(); + const bool codeMode = setPickerSwitch_->GetValue(); + setComboControl()->Show(!codeMode); + setCodeRowPanel_->Show(codeMode); + wxWindow* host = setComboControl()->GetParent(); + if (host) { + host->Layout(); + } + Layout(); +} + +void YuGiOhCardEditDialog::onSetCodeAutoDetect(wxCommandEvent&) { + if (!setCodeText_ || !setPickerSwitch_) return; + const auto& sets = availableSets(); + if (sets.empty()) { + showThemedMessageDialog(this, + "No sets are cached. Use Sets > Update Yu-Gi-Oh! first.", + "Set code", wxOK | wxICON_INFORMATION); + return; + } + + const std::string raw = setCodeText_->GetValue().ToStdString(wxConvUTF8); + const auto r = lookupYuGiOhSetByShorthand(raw, sets); + using Kind = YuGiOhSetShorthandLookup::Kind; + if (r.kind == Kind::NotFound) { + showThemedMessageDialog( + this, + "No set matches that code. Check the code spelling or use Sets > Update Yu-Gi-Oh! to refresh the list.", + "Set code", wxOK | wxICON_INFORMATION); + return; + } + if (r.kind == Kind::Ambiguous) { + showThemedMessageDialog(this, + "Multiple cached sets match that code. Refresh the set list or pick the set from the list.", + "Set code", wxOK | wxICON_INFORMATION); + return; + } + + applySetSelectionByIndex(r.index); + + setPickerSwitch_->SetValue(false, false); + syncSetModeHint(); + setComboControl()->Show(true); + setCodeRowPanel_->Show(false); + wxWindow* host = setComboControl()->GetParent(); + if (host) { + host->Layout(); + } + Layout(); } std::string YuGiOhCardEditDialog::extractSetNoNumeric(std::string_view fullSetNo) const { diff --git a/ui_wx/src/YuGiOhGameView.cpp b/ui_wx/src/YuGiOhGameView.cpp index 7b9e9ac..8d3490d 100644 --- a/ui_wx/src/YuGiOhGameView.cpp +++ b/ui_wx/src/YuGiOhGameView.cpp @@ -1,11 +1,13 @@ #include "ccm/ui/YuGiOhGameView.hpp" +#include "ccm/ui/CardEditModalGuard.hpp" #include "ccm/ui/YuGiOhCardEditDialog.hpp" #include "ccm/ui/YuGiOhCardListPanel.hpp" #include "ccm/ui/YuGiOhSelectedCardPanel.hpp" #include "ccm/ui/Theme.hpp" #include +#include #include #include @@ -56,6 +58,10 @@ wxPanel* YuGiOhGameView::listPanel(wxWindow* parent) { selectedPanel_->setCard(listPanel_->selected()); } }); + listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) { + wxWindow* owner = wxGetTopLevelParent(listPanel_); + onEditCard(owner != nullptr ? owner : static_cast(listPanel_)); + }); } return listPanel_; } @@ -94,6 +100,11 @@ const std::vector& YuGiOhGameView::setsForDialog() { } void YuGiOhGameView::onAddCard(wxWindow* parentWindow) { + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION); + return; + } YuGiOhCard fresh; fresh.amount = 1; fresh.language = Language::English; @@ -102,6 +113,7 @@ void YuGiOhGameView::onAddCard(wxWindow* parentWindow) { YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh, &setsForDialog()); themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; if (dlg.ShowModal() != wxID_OK) return; auto added = collection_.add(Game::YuGiOh, dlg.card()); @@ -141,9 +153,15 @@ void YuGiOhGameView::onEditCard(wxWindow* parentWindow) { showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION); return; } + if (cardEditModalIsActive()) { + showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8), + wxString::FromUTF8("Edit"), wxOK | wxICON_INFORMATION); + return; + } YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel, &setsForDialog()); themeModalDialog(&dlg, config_.current().theme); + CardEditModalGuard modalGuard; if (dlg.ShowModal() != wxID_OK) return; auto updated = collection_.update(Game::YuGiOh, dlg.card()); if (!updated) {