minor: New Game Digimon Digi-Battle (#17)

* digimon digi battle added to supported games

* sonarqube update

* readme update

---------

Co-authored-by: sdine <sdine@sdine.com>
This commit is contained in:
Sebastian Dine
2026-07-19 12:06:57 +02:00
committed by GitHub
parent 42926f2fb5
commit e5c830e945
53 changed files with 2170 additions and 49 deletions
+3 -3
View File
@@ -9,7 +9,7 @@
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. 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/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 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 `<exeDir>/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 `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant.
- `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 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 `<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.
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` 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 ~50100 lines of hook overrides on top of the matching base template.
@@ -67,7 +67,7 @@
- When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`.
15. **Preview fallback behavior (CCM2 parity where applicable):**
- Keep unresolved external previews user-visible by showing a per-game card-back image in `BaseSelectedCardPanel` instead of a blank/transparent bitmap.
- Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails — keep that chain working when touching preview code.
- Magic / Pokémon use single fixed HTTPS URLs (`Magic_card_back.jpg`, Bulbagarden `Cardback.jpg`). Yu-Gi-Oh! uses Yugipedia-hosted backs plus a **bundled** PNG beside the exe (`assets/ygo_card_back.png`) when the network path fails. Digimon Digi-Battle uses a **bundled** PNG (`assets/digibattle99_card_back.png`) — keep those chains working when touching preview code.
- 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.
@@ -76,7 +76,7 @@
## Required follow-ups
- If you replace **`ui_wx/assets/ygo_card_back.png`**, rebuild the **`ccm`** target so `app/CMakeLists.txt`'s `POST_BUILD` copy refreshes `<exeDir>/assets/`; do not remove the asset without updating `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`.
- If you replace **`ui_wx/assets/ygo_card_back.png`** or **`ui_wx/assets/digibattle99_card_back.png`**, rebuild the **`ccm`** target so `app/CMakeLists.txt`'s `POST_BUILD` copy refreshes `<exeDir>/assets/`; do not remove an asset without updating `BaseSelectedCardPanel` / `docs/assets-and-info-apis.md`.
- After adding a new dialog/panel `.cpp` you **must** add it to `ui_wx/CMakeLists.txt`.
- After adding a new menu action you **must** allocate an `Ids::*` value in `MainFrame.hpp` (don't reuse `wxID_HIGHEST` math inline) and `Bind` it in `buildMenuBar`. The dynamic Game / Sets menus consume the `IdGameMenuBase` / `IdSetsMenuBase` ranges; do not stomp on those id ranges.
- After changing `AppContext` you **must** update `app/main.cpp` so the composition root populates the new field.
+4
View File
@@ -19,6 +19,10 @@ add_library(ccm_ui_wx STATIC
src/YuGiOhSelectedCardPanel.cpp
src/YuGiOhCardEditDialog.cpp
src/YuGiOhGameView.cpp
src/DigiBattle99CardListPanel.cpp
src/DigiBattle99SelectedCardPanel.cpp
src/DigiBattle99CardEditDialog.cpp
src/DigiBattle99GameView.cpp
src/SettingsDialog.cpp
src/SwitchCtrl.cpp
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

+1
View File
@@ -26,6 +26,7 @@ struct AppContext {
IGameModule& magicModule;
IGameModule& pokemonModule;
IGameModule& yuGiOhModule;
IGameModule& digiBattle99Module;
// Active per-game UI bundles. The order is the order shown in the
// Game menu; the composition root constructs them and hands raw
// pointers in. `MainFrame` does not own these — `app/main.cpp` does.
+13 -1
View File
@@ -295,9 +295,11 @@ private:
case Game::YuGiOh:
// Yugipedia English TCG backing (thumbnail — smaller than full scan).
return "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/250px-Back-EN.png";
default:
case Game::DigiBattle99:
// No stable public Digi-Battle back URL; UI uses bundled PNG.
return {};
}
return {};
}
void buildInfoGrid(wxBoxSizer* root) {
@@ -424,6 +426,16 @@ private:
applyFallbackPayload(ss.str());
}
}
} else if (game == Game::DigiBattle99) {
namespace fs = std::filesystem;
const fs::path asset =
fs::path(exeDirCopy) / "assets" / "digibattle99_card_back.png";
std::ifstream in(asset, std::ios::binary);
if (in) {
std::ostringstream ss;
ss << in.rdbuf();
applyFallbackPayload(ss.str());
}
} else {
const std::string fallbackUrl = fallbackImageUrlForGame(game);
if (!fallbackUrl.empty()) {
@@ -0,0 +1,81 @@
#pragma once
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/ports/ICardPreviewSource.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
#include <wx/button.h>
#include <atomic>
#include <memory>
#include <string>
#include <vector>
namespace ccm::ui {
class DigiBattle99CardEditDialog final : public BaseCardEditDialog<DigiBattle99Card> {
public:
DigiBattle99CardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
DigiBattle99Card initial,
const std::vector<Set>* preloadedSets = nullptr);
~DigiBattle99CardEditDialog() override;
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
void appendExtraRows(wxFlexGridSizer* grid) override;
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override {
return "Update Digimon (Digi-Battle)";
}
void onCardLookupContextChanged() override;
private:
struct VariantFetchState {
std::atomic<bool> alive{true};
};
void onAutoDetectSetNo(wxCommandEvent&);
void onNextSetNo(wxCommandEvent&);
void onSetSelectionChanged(wxCommandEvent&);
void autoDetectFromApi();
void clearCachedPrintVariants();
void requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setName,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void applyDetectedVariants(unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog);
void rebuildVariantRingFromCache();
void syncRingPositionToControls();
void refreshVariantNextControls();
void scheduleDeferredVariantPrefetch();
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
[[nodiscard]] static std::string storedSetNoFromControls(const wxTextCtrl* ctrl);
[[nodiscard]] static std::string normalizedStoredSetNo(std::string_view setNo);
EditMode dialogMode_;
unsigned variantFetchEpoch_{0};
CardPreviewService& cardPreview_;
std::shared_ptr<VariantFetchState> variantFetchState_;
wxTextCtrl* setNoCtrl_{nullptr};
wxButton* autoSetNoBtn_{nullptr};
wxButton* nextSetNoBtn_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
std::vector<AutoDetectedPrint> cachedVariants_;
std::vector<std::string> uniqueSetNos_;
std::size_t setNoRingPos_{0};
};
} // namespace ccm::ui
@@ -0,0 +1,26 @@
#pragma once
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/services/CardSorter.hpp"
#include "ccm/ui/BaseCardListPanel.hpp"
namespace ccm::ui {
class DigiBattle99CardListPanel final
: public BaseCardListPanel<DigiBattle99Card, DigiBattle99SortColumn> {
public:
explicit DigiBattle99CardListPanel(wxWindow* parent);
protected:
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
[[nodiscard]] std::vector<IconColumnSpec> declareIconColumns() const override;
[[nodiscard]] std::string renderTextCell(const DigiBattle99Card& card,
std::size_t idx) const override;
[[nodiscard]] bool isIconColumnSet(const DigiBattle99Card& card,
std::size_t idx) const override;
void sortBy(DigiBattle99SortColumn column, bool ascending) override;
[[nodiscard]] bool matchesFilter(const DigiBattle99Card& card,
std::string_view filter) const override;
};
} // namespace ccm::ui
@@ -0,0 +1,64 @@
#pragma once
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/IGameView.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm::ui {
class DigiBattle99CardListPanel;
class DigiBattle99SelectedCardPanel;
class DigiBattle99GameView final : public IGameView {
public:
DigiBattle99GameView(ConfigService& config,
CollectionService<DigiBattle99Card>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
[[nodiscard]] Game gameId() const noexcept override { return Game::DigiBattle99; }
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
wxPanel* listPanel(wxWindow* parent) override;
wxPanel* selectedPanel(wxWindow* parent) override;
void refreshCollection() override;
void onAddCard(wxWindow* parentWindow) override;
void onEditCard(wxWindow* parentWindow) override;
void onDeleteCard(wxWindow* parentWindow) override;
std::string onUpdateSets(wxWindow* parentWindow) override;
void setFilter(std::string_view filter) override;
void applyTheme(const ThemePalette& palette) override;
[[nodiscard]] std::string updateSetsMenuLabel() const override {
return "Update Digimon (Digi-Battle)";
}
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
ConfigService& config_;
CollectionService<DigiBattle99Card>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
DigiBattle99CardListPanel* listPanel_{nullptr};
DigiBattle99SelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,25 @@
#pragma once
#include "ccm/domain/DigiBattle99Card.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
namespace ccm::ui {
class DigiBattle99SelectedCardPanel final : public BaseSelectedCardPanel<DigiBattle99Card> {
public:
DigiBattle99SelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview);
protected:
[[nodiscard]] std::vector<DetailRowSpec> declareDetailRows() const override;
[[nodiscard]] std::vector<FlagIconSpec> declareFlagIcons() const override;
[[nodiscard]] std::string detailValueFor(const DigiBattle99Card& card,
DetailKey key) const override;
[[nodiscard]] bool isFlagSet(const DigiBattle99Card& card, DetailKey key) const override;
[[nodiscard]] std::tuple<std::string, std::string, std::string>
previewKey(const DigiBattle99Card& card) const override;
[[nodiscard]] Game gameId() const noexcept override { return Game::DigiBattle99; }
};
} // namespace ccm::ui
+255
View File
@@ -0,0 +1,255 @@
#include "ccm/ui/DigiBattle99CardEditDialog.hpp"
#include "ccm/domain/Enums.hpp"
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
#include <wx/app.h>
#include <wx/panel.h>
#include <thread>
#include <unordered_set>
namespace ccm::ui {
DigiBattle99CardEditDialog::DigiBattle99CardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
CardPreviewService& cardPreview,
EditMode mode,
DigiBattle99Card initial,
const std::vector<Set>* preloadedSets)
: BaseCardEditDialog<DigiBattle99Card>(
parent,
mode == EditMode::Create ? "Add Digimon (Digi-Battle) Card"
: "Edit Digimon (Digi-Battle) Card",
imageService, setService, mode, std::move(initial), Game::DigiBattle99,
preloadedSets),
dialogMode_(mode),
cardPreview_(cardPreview),
variantFetchState_(std::make_shared<VariantFetchState>()) {
buildAndPopulate();
if (dialogMode_ == EditMode::Edit) {
scheduleDeferredVariantPrefetch();
}
}
DigiBattle99CardEditDialog::~DigiBattle99CardEditDialog() {
if (variantFetchState_) {
variantFetchState_->alive.store(false);
}
}
void DigiBattle99CardEditDialog::onCardLookupContextChanged() {
clearCachedPrintVariants();
}
void DigiBattle99CardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo");
firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition");
signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed");
alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered");
flagsBox->Add(holoCheck_, 0, wxRIGHT, 12);
flagsBox->Add(firstEditionCheck_, 0, wxRIGHT, 12);
flagsBox->Add(signedCheck_, 0, wxRIGHT, 12);
flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12);
}
void DigiBattle99CardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
auto* setNoPanel = new wxPanel(this, wxID_ANY);
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
autoSetNoBtn_->Bind(wxEVT_BUTTON, &DigiBattle99CardEditDialog::onAutoDetectSetNo, this);
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
nextSetNoBtn_->Bind(wxEVT_BUTTON, &DigiBattle99CardEditDialog::onNextSetNo, this);
nextSetNoBtn_->Show(false);
auto* setNoRow = new wxBoxSizer(wxHORIZONTAL);
setNoRow->Add(setNoCtrl_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(autoSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
setNoRow->Add(nextSetNoBtn_, 0, wxALIGN_CENTER_VERTICAL);
setNoPanel->SetSizer(setNoRow);
appendRow(grid, "Set #", setNoPanel);
if (auto* setCombo = setComboControl()) {
setCombo->Bind(wxEVT_COMBOBOX, &DigiBattle99CardEditDialog::onSetSelectionChanged, this);
}
}
std::string DigiBattle99CardEditDialog::normalizedStoredSetNo(std::string_view setNo) {
return DigiBattle99CardPreviewSource::normalizeCardNumber(setNo);
}
std::string DigiBattle99CardEditDialog::storedSetNoFromControls(const wxTextCtrl* ctrl) {
if (ctrl == nullptr) return {};
return normalizedStoredSetNo(ctrl->GetValue().ToStdString(wxConvUTF8));
}
void DigiBattle99CardEditDialog::readExtraFromCard() {
clearCachedPrintVariants();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(normalizedStoredSetNo(constCard().setNo).c_str()));
}
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
}
void DigiBattle99CardEditDialog::writeExtraToCard() {
if (setNoCtrl_) mutableCard().setNo = storedSetNoFromControls(setNoCtrl_);
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
}
void DigiBattle99CardEditDialog::clearCachedPrintVariants() {
++variantFetchEpoch_;
cachedVariants_.clear();
uniqueSetNos_.clear();
setNoRingPos_ = 0;
refreshVariantNextControls();
}
void DigiBattle99CardEditDialog::scheduleDeferredVariantPrefetch() {
const unsigned epoch = variantFetchEpoch_;
wxTheApp->CallAfter([this, epoch]() {
prefetchVariantsForCurrentCardSilent(epoch);
});
}
void DigiBattle99CardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
if (capturedEpoch != variantFetchEpoch_) return;
if (!cachedVariants_.empty()) return;
const auto& card = constCard();
// digimoncard.io pack= uses the display set name, not the slug id.
if (card.name.empty() || card.set.name.empty()) return;
requestVariantsAsync(capturedEpoch, card.name, card.set.name, false, false);
}
void DigiBattle99CardEditDialog::requestVariantsAsync(unsigned capturedEpoch,
std::string name,
std::string setName,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Disable();
}
auto state = variantFetchState_;
CardPreviewService* svc = &cardPreview_;
DigiBattle99CardEditDialog* self = this;
std::thread([state, svc, self, capturedEpoch, name = std::move(name),
setName = std::move(setName), fillSetNoOnSuccess, showFailureDialog]() {
auto detected = svc->detectPrintVariants(Game::DigiBattle99, name, setName);
wxTheApp->CallAfter([state, self, capturedEpoch, detected = std::move(detected),
fillSetNoOnSuccess, showFailureDialog]() mutable {
if (!state->alive.load()) return;
self->applyDetectedVariants(capturedEpoch, std::move(detected),
fillSetNoOnSuccess, showFailureDialog);
});
}).detach();
}
void DigiBattle99CardEditDialog::applyDetectedVariants(
unsigned capturedEpoch,
Result<std::vector<AutoDetectedPrint>> detected,
bool fillSetNoOnSuccess,
bool showFailureDialog) {
if (capturedEpoch != variantFetchEpoch_) return;
if (fillSetNoOnSuccess && autoSetNoBtn_) {
autoSetNoBtn_->Enable();
}
if (!detected) {
if (showFailureDialog) {
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect",
wxOK | wxICON_WARNING);
}
return;
}
cachedVariants_ = std::move(detected).value();
if (fillSetNoOnSuccess && setNoCtrl_ && !cachedVariants_.empty()) {
setNoCtrl_->ChangeValue(
wxString::FromUTF8(cachedVariants_.front().setNo.c_str()));
}
rebuildVariantRingFromCache();
syncRingPositionToControls();
refreshVariantNextControls();
}
void DigiBattle99CardEditDialog::rebuildVariantRingFromCache() {
uniqueSetNos_.clear();
if (cachedVariants_.empty()) return;
std::unordered_set<std::string> seen;
seen.reserve(cachedVariants_.size());
for (const auto& p : cachedVariants_) {
if (p.setNo.empty()) continue;
if (!seen.insert(p.setNo).second) continue;
uniqueSetNos_.push_back(p.setNo);
}
}
void DigiBattle99CardEditDialog::syncRingPositionToControls() {
if (!setNoCtrl_) return;
const std::string current = storedSetNoFromControls(setNoCtrl_);
setNoRingPos_ = 0;
for (std::size_t i = 0; i < uniqueSetNos_.size(); ++i) {
if (uniqueSetNos_[i] == current) {
setNoRingPos_ = i;
break;
}
}
}
void DigiBattle99CardEditDialog::refreshVariantNextControls() {
if (!nextSetNoBtn_) return;
nextSetNoBtn_->Show(uniqueSetNos_.size() > 1);
Layout();
if (GetSizer()) Fit();
}
void DigiBattle99CardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
autoDetectFromApi();
}
void DigiBattle99CardEditDialog::onNextSetNo(wxCommandEvent&) {
if (uniqueSetNos_.size() <= 1) return;
setNoRingPos_ = (setNoRingPos_ + 1) % uniqueSetNos_.size();
if (setNoCtrl_) {
setNoCtrl_->ChangeValue(wxString::FromUTF8(uniqueSetNos_[setNoRingPos_].c_str()));
}
refreshVariantNextControls();
}
void DigiBattle99CardEditDialog::autoDetectFromApi() {
syncCardFromControls();
const auto& card = constCard();
if (card.name.empty()) {
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
if (card.set.name.empty()) {
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
wxOK | wxICON_INFORMATION);
return;
}
const unsigned epoch = variantFetchEpoch_;
requestVariantsAsync(epoch, card.name, card.set.name, true, true);
}
void DigiBattle99CardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
clearCachedPrintVariants();
scheduleDeferredVariantPrefetch();
ev.Skip();
}
} // namespace ccm::ui
+71
View File
@@ -0,0 +1,71 @@
#include "ccm/ui/DigiBattle99CardListPanel.hpp"
#include "ccm/services/CardFilter.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include <string>
namespace ccm::ui {
DigiBattle99CardListPanel::DigiBattle99CardListPanel(wxWindow* parent)
: BaseCardListPanel<DigiBattle99Card, DigiBattle99SortColumn>(parent) {
buildLayout();
}
std::vector<DigiBattle99CardListPanel::TextColumnSpec>
DigiBattle99CardListPanel::declareTextColumns() const {
return {
{"Name", 220, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Name},
{"Set", 180, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::SetReleaseDate},
{"Amount", 70, wxLIST_FORMAT_RIGHT, DigiBattle99SortColumn::Amount},
{"Condition", 100, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Condition},
{"Language", 100, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Language},
{"Note", 220, wxLIST_FORMAT_LEFT, DigiBattle99SortColumn::Note},
};
}
std::vector<DigiBattle99CardListPanel::IconColumnSpec>
DigiBattle99CardListPanel::declareIconColumns() const {
constexpr int kFlagColWidth = 36;
return {
{kSvgHolo, kFlagColWidth, DigiBattle99SortColumn::Holo},
{kSvgFirstEdition, kFlagColWidth, DigiBattle99SortColumn::FirstEdition},
{kSvgSigned, kFlagColWidth, DigiBattle99SortColumn::Signed},
{kSvgAltered, kFlagColWidth, DigiBattle99SortColumn::Altered},
};
}
std::string DigiBattle99CardListPanel::renderTextCell(const DigiBattle99Card& card,
std::size_t idx) const {
switch (idx) {
case 0: return card.name;
case 1: return card.set.name;
case 2: return std::to_string(card.amount);
case 3: return std::string(to_string(card.condition));
case 4: return std::string(to_string(card.language));
case 5: return card.note;
}
return {};
}
bool DigiBattle99CardListPanel::isIconColumnSet(const DigiBattle99Card& card,
std::size_t idx) const {
switch (idx) {
case 0: return card.holo;
case 1: return card.firstEdition;
case 2: return card.signed_;
case 3: return card.altered;
}
return false;
}
void DigiBattle99CardListPanel::sortBy(DigiBattle99SortColumn column, bool ascending) {
sortDigiBattle99Cards(mutableCards(), column, ascending);
}
bool DigiBattle99CardListPanel::matchesFilter(const DigiBattle99Card& card,
std::string_view filter) const {
return matchesDigiBattle99Filter(card, filter);
}
} // namespace ccm::ui
+216
View File
@@ -0,0 +1,216 @@
#include "ccm/ui/DigiBattle99GameView.hpp"
#include "ccm/ui/CardEditModalGuard.hpp"
#include "ccm/ui/DigiBattle99CardEditDialog.hpp"
#include "ccm/ui/DigiBattle99CardListPanel.hpp"
#include "ccm/ui/DigiBattle99SelectedCardPanel.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/msgdlg.h>
#include <wx/window.h>
#include <optional>
#include <string>
namespace ccm::ui {
DigiBattle99GameView::DigiBattle99GameView(ConfigService& config,
CollectionService<DigiBattle99Card>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module)
: config_(config),
collection_(collection),
sets_(sets),
images_(images),
cardPreview_(cardPreview),
module_(module) {}
void DigiBattle99GameView::ensureSetsLoaded() {
if (attemptedInitialSetLoad_) return;
attemptedInitialSetLoad_ = true;
auto cached = sets_.getSets(Game::DigiBattle99);
if (cached) {
setsCache_ = std::move(cached).value();
if (!setsCache_.empty()) return;
} else {
setsCache_.clear();
}
auto refreshed = sets_.updateSets(Game::DigiBattle99);
if (refreshed) {
setsCache_ = std::move(refreshed).value();
}
}
wxPanel* DigiBattle99GameView::listPanel(wxWindow* parent) {
if (listPanel_ == nullptr) {
listPanel_ = new DigiBattle99CardListPanel(parent);
listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) {
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
selectedPanel_->setCard(listPanel_->selected());
}
});
listPanel_->Bind(EVT_CARD_ACTIVATED, [this](wxCommandEvent&) {
wxWindow* owner = wxGetTopLevelParent(listPanel_);
onEditCard(owner != nullptr ? owner : static_cast<wxWindow*>(listPanel_));
});
}
return listPanel_;
}
wxPanel* DigiBattle99GameView::selectedPanel(wxWindow* parent) {
if (selectedPanel_ == nullptr) {
selectedPanel_ = new DigiBattle99SelectedCardPanel(parent, images_, cardPreview_);
}
return selectedPanel_;
}
void DigiBattle99GameView::refreshCollection() {
if (listPanel_ == nullptr) return;
auto loaded = collection_.list(Game::DigiBattle99);
if (!loaded) {
showThemedMessageDialog(
nullptr,
"Failed to load Digimon (Digi-Battle) collection: " + loaded.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
listPanel_->setCards(std::move(loaded).value());
listPanel_->activateSelection();
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
}
const std::vector<Set>& DigiBattle99GameView::setsForDialog() {
ensureSetsLoaded();
if (!setsCache_.empty()) return setsCache_;
auto loaded = sets_.getSets(Game::DigiBattle99);
if (loaded) setsCache_ = std::move(loaded).value();
else setsCache_.clear();
return setsCache_;
}
void DigiBattle99GameView::onAddCard(wxWindow* parentWindow) {
if (cardEditModalIsActive()) {
showThemedMessageDialog(parentWindow, wxString::FromUTF8(kCardEditModalBlockedUtf8),
wxString::FromUTF8("Add card"), wxOK | wxICON_INFORMATION);
return;
}
DigiBattle99Card fresh;
fresh.amount = 1;
fresh.language = Language::English;
fresh.condition = Condition::NearMint;
DigiBattle99CardEditDialog 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::DigiBattle99, dlg.card());
if (!added) {
showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
DigiBattle99Card persisted = dlg.card();
persisted.id = added.value();
auto normalized = images_.normalizeNamesForPersistedCard(
Game::DigiBattle99, persisted.id, persisted.set.name, persisted.name, persisted.images);
if (normalized) {
if (normalized.value() != persisted.images) {
persisted.images = std::move(normalized).value();
auto updated = collection_.update(Game::DigiBattle99, persisted);
if (!updated) {
showThemedMessageDialog(
parentWindow,
"Card added, but image name normalization failed to persist: " +
updated.error(),
"Warning", wxOK | wxICON_WARNING);
}
}
} else {
showThemedMessageDialog(
parentWindow,
"Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
"Warning", wxOK | wxICON_WARNING);
}
refreshCollection();
}
void DigiBattle99GameView::onEditCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
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;
}
DigiBattle99CardEditDialog 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::DigiBattle99, dlg.card());
if (!updated) {
showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
refreshCollection();
}
void DigiBattle99GameView::onDeleteCard(wxWindow* parentWindow) {
if (listPanel_ == nullptr) return;
auto sel = listPanel_->selected();
if (!sel) {
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete",
wxOK | wxICON_INFORMATION);
return;
}
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
"Confirm") != wxID_YES) {
return;
}
auto removed = collection_.remove(Game::DigiBattle99, sel->id);
if (!removed) {
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
refreshCollection();
}
std::string DigiBattle99GameView::onUpdateSets(wxWindow* parentWindow) {
auto out = sets_.updateSets(Game::DigiBattle99);
if (!out) {
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
"Error", wxOK | wxICON_ERROR);
return "Update failed";
}
setsCache_ = out.value();
showThemedMessageDialog(
parentWindow,
"Updated " + std::to_string(out.value().size()) + " Digimon (Digi-Battle) sets.",
"Sets updated", wxOK | wxICON_INFORMATION);
return "Digimon (Digi-Battle) sets updated.";
}
void DigiBattle99GameView::setFilter(std::string_view filter) {
if (listPanel_) listPanel_->setFilter(filter);
}
void DigiBattle99GameView::applyTheme(const ThemePalette& palette) {
if (listPanel_) listPanel_->applyTheme(palette);
if (selectedPanel_) selectedPanel_->applyTheme(palette);
}
} // namespace ccm::ui
@@ -0,0 +1,84 @@
#include "ccm/ui/DigiBattle99SelectedCardPanel.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include <string>
namespace ccm::ui {
namespace {
enum DigiBattle99DetailKey : int {
kName = 0,
kSet,
kSetNo,
kLanguage,
kCondition,
kAmount,
kHolo,
kFirstEdition,
kSigned,
kAltered,
};
} // namespace
DigiBattle99SelectedCardPanel::DigiBattle99SelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview)
: BaseSelectedCardPanel<DigiBattle99Card>(parent, imageService, cardPreview) {
buildLayout();
}
std::vector<DigiBattle99SelectedCardPanel::DetailRowSpec>
DigiBattle99SelectedCardPanel::declareDetailRows() const {
return {
{"Name", kName, "(no card selected)"},
{"Set", kSet, ""},
{"Set #", kSetNo, ""},
{"Language", kLanguage, ""},
{"Condition", kCondition, ""},
{"Amount", kAmount, ""},
};
}
std::vector<DigiBattle99SelectedCardPanel::FlagIconSpec>
DigiBattle99SelectedCardPanel::declareFlagIcons() const {
return {
{kSvgHolo, "Holo", kHolo},
{kSvgFirstEdition, "1. Edition", kFirstEdition},
{kSvgSigned, "Signed", kSigned},
{kSvgAltered, "Altered", kAltered},
};
}
std::string DigiBattle99SelectedCardPanel::detailValueFor(const DigiBattle99Card& card,
DetailKey key) const {
switch (key) {
case kName: return card.name;
case kSet: return card.set.name;
case kSetNo: return card.setNo;
case kLanguage: return std::string(to_string(card.language));
case kCondition: return std::string(to_string(card.condition));
case kAmount: return std::to_string(card.amount);
case kNoteKey: return card.note;
}
return {};
}
bool DigiBattle99SelectedCardPanel::isFlagSet(const DigiBattle99Card& card,
DetailKey key) const {
switch (key) {
case kHolo: return card.holo;
case kFirstEdition: return card.firstEdition;
case kSigned: return card.signed_;
case kAltered: return card.altered;
}
return false;
}
std::tuple<std::string, std::string, std::string>
DigiBattle99SelectedCardPanel::previewKey(const DigiBattle99Card& card) const {
// Middle slot is Set.name (pack display name) for digimoncard.io pack=.
return {card.name, card.set.name, card.setNo};
}
} // namespace ccm::ui
+5 -3
View File
@@ -41,8 +41,10 @@ constexpr const char kFilterInputHint[] = "Filter";
std::string dirNameForGame(Game g) {
switch (g) {
case Game::Magic: return "magic";
case Game::Pokemon: return "pokemon";
case Game::Magic: return "magic";
case Game::Pokemon: return "pokemon";
case Game::YuGiOh: return "yugioh";
case Game::DigiBattle99: return "digibattle99";
}
return "magic";
}
@@ -67,7 +69,7 @@ void ensureDataStorageScaffold(const Configuration& cfg) {
}
}
for (Game game : {Game::Magic, Game::Pokemon}) {
for (Game game : allGames()) {
const fs::path gameRoot = root / dirNameForGame(game);
fs::create_directories(gameRoot / "images", ec);
if (ec) continue;
+28 -4
View File
@@ -1,5 +1,6 @@
#include "ccm/ui/SettingsDialog.hpp"
#include "ccm/ui/Theme.hpp"
#include "ccm/domain/Enums.hpp"
#include <wx/button.h>
#include <wx/dirdlg.h>
@@ -9,6 +10,20 @@
namespace ccm::ui {
namespace {
wxString displayLabelForGame(Game g) {
switch (g) {
case Game::Magic: return "Magic";
case Game::Pokemon: return "Pokemon";
case Game::YuGiOh: return "Yu-Gi-Oh!";
case Game::DigiBattle99: return "Digimon (Digi-Battle)";
}
return wxString::FromUTF8(to_string(g).data());
}
} // namespace
SettingsDialog::SettingsDialog(wxWindow* parent, ConfigService& config)
: wxDialog(parent, wxID_ANY, "Settings",
wxDefaultPosition, wxSize(560, 200),
@@ -30,9 +45,14 @@ SettingsDialog::SettingsDialog(wxWindow* parent, ConfigService& config)
gameRow->Add(new wxStaticText(this, wxID_ANY, "Default game:"),
0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
defaultGameChoice_ = new wxChoice(this, wxID_ANY);
defaultGameChoice_->Append("Magic");
defaultGameChoice_->Append("Pokemon");
defaultGameChoice_->SetSelection(config_.current().defaultGame == Game::Magic ? 0 : 1);
int selected = 0;
int idx = 0;
for (const Game g : allGames()) {
defaultGameChoice_->Append(displayLabelForGame(g));
if (g == config_.current().defaultGame) selected = idx;
++idx;
}
defaultGameChoice_->SetSelection(selected);
gameRow->Add(defaultGameChoice_, 0);
root->Add(gameRow, 0, wxEXPAND | wxLEFT | wxRIGHT, 10);
@@ -77,7 +97,11 @@ void SettingsDialog::onBrowse(wxCommandEvent&) {
void SettingsDialog::onOk(wxCommandEvent& ev) {
Configuration next = config_.current();
next.dataStorage = dataDirCtrl_->GetValue().ToStdString();
next.defaultGame = defaultGameChoice_->GetSelection() == 0 ? Game::Magic : Game::Pokemon;
const int gameSel = defaultGameChoice_->GetSelection();
const auto& games = allGames();
if (gameSel >= 0 && static_cast<std::size_t>(gameSel) < games.size()) {
next.defaultGame = games[static_cast<std::size_t>(gameSel)];
}
switch (themeChoice_->GetSelection()) {
case 1: next.theme = Theme::Dark; break;
case 0: