#pragma once // BaseSelectedCardPanel // // Header-only template for the right-hand-side card detail panel: // // - top: external preview image fetched by `CardPreviewService` // - middle: 2-column "label | value" detail grid (Name / Set / ...) // - flag-icon row (collapses to nothing when no flags are set) // - bottom: "Image N" list box with double-click viewer // // All of the threading/cancellation machinery for the preview fetch is here // (the `shared_ptr` + `std::atomic alive` / `currentGen` pattern from // `ui_wx/AGENTS.md`). Subclasses just describe which detail rows to show, the // flag-icon strip, and how to extract `(name, setId, setNo)` for the preview // lookup key. // // New games extend this template — see `MagicSelectedCardPanel` and // `PokemonSelectedCardPanel` for the canonical patterns. #include "ccm/domain/Enums.hpp" #include "ccm/services/CardPreviewService.hpp" #include "ccm/services/ImageService.hpp" #include "ccm/ui/ImageViewerDialog.hpp" #include "ccm/ui/SvgIcons.hpp" #include "ccm/ui/Theme.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace ccm::ui { // Single shared event raised whenever a preview fetch resolves. // `event.GetString()` carries the human-readable status (empty on success, // non-empty on failure). Defined once in `BaseEvents.cpp`. wxDECLARE_EVENT(EVT_PREVIEW_STATUS, wxCommandEvent); template class BaseSelectedCardPanel : public wxPanel { public: using card_type = TCard; void setCard(std::optional card) { const std::optional newId = card ? std::optional{card->id} : std::nullopt; const bool fetchTargetChanged = (newId != lastFetchedId_); card_ = std::move(card); auto applyFlagsRow = [this](bool any) { flagsRow_->Layout(); flagsLabel_->Show(any); flagsRow_->Show(any); }; auto applyNote = [this](const std::string& note) { const bool has = !note.empty(); noteValue_->SetLabelText(wxString::FromUTF8(note.c_str())); noteLabel_->Show(has); noteValue_->Show(has); }; if (!card_) { for (auto& row : detailRows_) { row.value->SetLabelText(row.emptyLabel); } applyNote(""); for (auto& fi : flagIcons_) fi.icon->Show(false); applyFlagsRow(false); if (fetchTargetChanged) { state_->currentGen.fetch_add(1); lastFetchedId_.reset(); clearPreview(); previewStatus_->SetLabelText(""); emitPreviewStatus(""); } } else { const auto& c = *card_; // First row is "Name" by convention; we paint it before others so // it appears at the top with the literal card name. for (auto& row : detailRows_) { const std::string value = detailValueFor(c, row.key); row.value->SetLabelText(wxString::FromUTF8(value.c_str())); } applyNote(detailValueFor(c, kNoteKey)); bool anyFlag = false; for (auto& fi : flagIcons_) { const bool on = isFlagSet(c, fi.key); fi.icon->Show(on); if (on) anyFlag = true; } applyFlagsRow(anyFlag); if (fetchTargetChanged) startPreviewFetch(c); } rebuildImageList(); Layout(); } void applyTheme(const ThemePalette& palette) { SetBackgroundColour(palette.panelBg); SetForegroundColour(palette.text); if (previewStatus_ != nullptr) { previewStatus_->SetBackgroundColour(palette.panelBg); previewStatus_->SetForegroundColour(palette.text); } for (auto& row : detailRows_) { if (row.label != nullptr) { row.label->SetBackgroundColour(palette.panelBg); row.label->SetForegroundColour(palette.text); } if (row.value != nullptr) { row.value->SetBackgroundColour(palette.panelBg); row.value->SetForegroundColour(palette.text); } } flagsRow_->SetBackgroundColour(palette.panelBg); flagsRow_->SetForegroundColour(palette.text); if (flagsLabel_ != nullptr) { flagsLabel_->SetBackgroundColour(palette.panelBg); flagsLabel_->SetForegroundColour(palette.text); } if (noteLabel_ != nullptr) { noteLabel_->SetBackgroundColour(palette.panelBg); noteLabel_->SetForegroundColour(palette.text); } if (noteValue_ != nullptr) { noteValue_->SetBackgroundColour(palette.panelBg); noteValue_->SetForegroundColour(palette.text); } imageList_->SetBackgroundColour(palette.inputBg); imageList_->SetForegroundColour(palette.inputText); const std::string textHex = palette.text.GetAsString(wxC2S_HTML_SYNTAX).ToStdString(); for (auto& fi : flagIcons_) { fi.icon->SetBitmap(svgIconBitmap(fi.svg, kFlagIconSize, textHex.c_str())); } Layout(); Refresh(); } ~BaseSelectedCardPanel() override { // Detach any in-flight worker: late `CallAfter` lambdas check `alive` // before touching `panel` so they become no-ops after destruction. if (state_) { state_->alive.store(false); state_->panel = nullptr; } } protected: // Hook descriptors -------------------------------------------------------- // Detail row keys are integers chosen by the subclass; the base just // forwards them to `detailValueFor`. Reserve negatives for built-ins. using DetailKey = int; static constexpr DetailKey kNoteKey = -1; struct DetailRowSpec { std::string label; DetailKey key; std::string emptyLabel; // shown when `card_ == nullopt` }; struct FlagIconSpec { const char* svg; const char* tooltip; DetailKey key; }; // Subclass declares the labelled value rows of the detail grid (excluding // the trailing "Note" row; that one is always present and conventionally // appended right before the image list). [[nodiscard]] virtual std::vector declareDetailRows() const = 0; // Subclass declares the flag-icon strip. Order matters — icons render // left-to-right in the same order as this list. [[nodiscard]] virtual std::vector declareFlagIcons() const = 0; // Look up the string value for a detail-row key. The base also calls this // with `kNoteKey` to fetch the note for the bottom row. [[nodiscard]] virtual std::string detailValueFor(const TCard& card, DetailKey key) const = 0; [[nodiscard]] virtual bool isFlagSet(const TCard& card, DetailKey key) const = 0; // Lookup key for the preview API: (name, setId, setNo). setNo can be // empty for games that don't use it (Magic). [[nodiscard]] virtual std::tuple previewKey(const TCard& card) const = 0; [[nodiscard]] virtual Game gameId() const noexcept = 0; // Preview / card-back routing. Defaults to `gameId()`; Pokemon overrides // so Asia cards use the JapanesePokemon preview source + card back. [[nodiscard]] virtual Game previewGameFor(const TCard& /*card*/) const noexcept { return gameId(); } // Construction ------------------------------------------------------------ BaseSelectedCardPanel(wxWindow* parent, ImageService& imageService, CardPreviewService& cardPreview) : wxPanel(parent, wxID_ANY), imageService_(imageService), cardPreview_(cardPreview), state_(std::make_shared()) { wxFileName exe(wxStandardPaths::Get().GetExecutablePath()); exe.SetFullName(wxEmptyString); exeDirForBundledAssets_ = exe.GetPathWithSep().ToStdString(wxConvUTF8); state_->panel = this; } // Subclass calls this once from its constructor after the virtual hooks // are reachable. void buildLayout() { auto* root = new wxBoxSizer(wxVERTICAL); previewBitmap_ = new wxStaticBitmap(this, wxID_ANY, makePreviewPlaceholder()); previewStatus_ = new wxStaticText(this, wxID_ANY, ""); root->Add(previewBitmap_, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP | wxBOTTOM, 6); root->Add(previewStatus_, 0, wxALIGN_CENTER_HORIZONTAL | wxBOTTOM, 4); buildInfoGrid(root); SetSizer(root); setCard(std::nullopt); } private: // Shared state for the async preview fetcher. struct PreviewState { std::atomic alive{true}; std::atomic currentGen{0}; BaseSelectedCardPanel* panel; }; struct DetailRow { wxStaticText* label; wxStaticText* value; DetailKey key; std::string emptyLabel; }; struct FlagIcon { wxStaticBitmap* icon; const char* svg; DetailKey key; }; static constexpr int kPreviewWidth = 250; static constexpr int kPreviewHeight = 350; static constexpr int kImageListWidth = 0; static constexpr int kImageListHeight = 80; static constexpr int kFlagIconSize = 14; static wxBitmap makePreviewPlaceholder() { wxImage img(kPreviewWidth, kPreviewHeight); img.SetAlpha(); if (auto* alpha = img.GetAlpha()) { std::fill(alpha, alpha + kPreviewWidth * kPreviewHeight, 0); } return wxBitmap(img); } static std::string fallbackImageUrlForGame(Game game) { switch (game) { case Game::Magic: // Mirrors CCM2's unresolved-preview fallback image. return "https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg"; case Game::Pokemon: // Mirrors CCM2's unresolved-preview fallback image. return "https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg"; case Game::JapanesePokemon: // Japanese TCG back (distinct from the Western Cardback.jpg). return "https://archives.bulbagarden.net/media/upload/2/2a/TCG_Card_Back_Japanese.jpg"; 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"; case Game::DigiBattle99: // No stable public Digi-Battle back URL; UI uses bundled PNG. return {}; } return {}; } void buildInfoGrid(wxBoxSizer* root) { auto* grid = new wxFlexGridSizer(/*cols=*/2, /*vgap=*/4, /*hgap=*/12); grid->AddGrowableCol(1, 1); auto makeBoldLabel = [this](const wxString& text) { auto* lbl = new wxStaticText(this, wxID_ANY, text); wxFont lf = lbl->GetFont(); lf.MakeBold(); lbl->SetFont(lf); return lbl; }; auto specs = declareDetailRows(); detailRows_.reserve(specs.size()); for (const auto& spec : specs) { auto* lbl = makeBoldLabel(spec.label); auto* val = new wxStaticText(this, wxID_ANY, ""); grid->Add(lbl, 0, wxALIGN_TOP | wxALIGN_LEFT); grid->Add(val, 1, wxEXPAND | wxALIGN_LEFT); detailRows_.push_back({lbl, val, spec.key, spec.emptyLabel}); } // Flags row: empty label cell, value cell holds the icon strip. flagsLabel_ = new wxStaticText(this, wxID_ANY, ""); flagsRow_ = new wxPanel(this, wxID_ANY); auto* flagsSizer = new wxBoxSizer(wxHORIZONTAL); const std::string textHex = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT) .GetAsString(wxC2S_HTML_SYNTAX) .ToStdString(); auto flagSpecs = declareFlagIcons(); flagIcons_.reserve(flagSpecs.size()); for (const auto& fs : flagSpecs) { auto* ico = new wxStaticBitmap(flagsRow_, wxID_ANY, svgIconBitmap(fs.svg, kFlagIconSize, textHex.c_str())); ico->SetToolTip(fs.tooltip); flagsSizer->Add(ico, 0, wxRIGHT, 6); flagIcons_.push_back({ico, fs.svg, fs.key}); } flagsRow_->SetSizer(flagsSizer); grid->Add(flagsLabel_, 0, wxALIGN_TOP | wxALIGN_LEFT); grid->Add(flagsRow_, 0, wxEXPAND); // Note row. noteLabel_ = makeBoldLabel("Note"); noteValue_ = new wxStaticText(this, wxID_ANY, ""); grid->Add(noteLabel_, 0, wxALIGN_TOP | wxALIGN_LEFT); grid->Add(noteValue_, 1, wxEXPAND | wxALIGN_LEFT); // Image list row. imageList_ = new wxListBox(this, wxID_ANY, wxDefaultPosition, wxSize(kImageListWidth, kImageListHeight), 0, nullptr, wxLB_SINGLE); imageList_->Bind(wxEVT_LISTBOX_DCLICK, &BaseSelectedCardPanel::onImageActivated, this); grid->Add(makeBoldLabel("Images"), 0, wxALIGN_TOP | wxALIGN_LEFT); grid->Add(imageList_, 1, wxEXPAND); root->Add(grid, 1, wxEXPAND | wxALL, 8); } void clearPreview() { previewBitmap_->SetBitmap(makePreviewPlaceholder()); } void startPreviewFetch(const TCard& card) { clearPreview(); previewStatus_->SetLabelText("Loading preview..."); emitPreviewStatus(""); lastFetchedId_ = card.id; Layout(); const unsigned gen = state_->currentGen.fetch_add(1) + 1; auto state = state_; CardPreviewService* svcPtr = &cardPreview_; auto [name, setId, setNo] = previewKey(card); const Game game = previewGameFor(card); const std::string exeDirCopy = exeDirForBundledAssets_; std::thread([state, gen, svcPtr, name = std::move(name), setId = std::move(setId), setNo = std::move(setNo), game, exeDirCopy]() { auto bytes = svcPtr->fetchPreviewBytes(game, name, setId, setNo); bool ok = bytes.isOk(); bool usedFallback = false; std::string payload = ok ? std::move(bytes).value() : std::string{}; std::string err = ok ? std::string{} : bytes.error(); auto applyFallbackPayload = [&](std::string p) { if (p.empty()) return; payload = std::move(p); ok = true; usedFallback = true; err.clear(); }; if (!ok || payload.empty()) { if (game == Game::YuGiOh) { if (auto fb = svcPtr->fetchImageBytesByUrl( "https://ms.yugipedia.com/thumb/e/e5/Back-EN.png/" "250px-Back-EN.png"); fb.isOk()) { applyFallbackPayload(std::move(fb).value()); } if (!ok || payload.empty()) { if (auto fb = svcPtr->fetchImageBytesByUrl( "https://ms.yugipedia.com/e/e5/Back-EN.png"); fb.isOk()) { applyFallbackPayload(std::move(fb).value()); } } if (!ok || payload.empty()) { namespace fs = std::filesystem; const fs::path asset = fs::path(exeDirCopy) / "assets" / "ygo_card_back.png"; std::ifstream in(asset, std::ios::binary); if (in) { std::ostringstream ss; ss << in.rdbuf(); 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()) { auto fallbackBytes = svcPtr->fetchImageBytesByUrl(fallbackUrl); if (fallbackBytes.isOk()) { applyFallbackPayload(std::move(fallbackBytes).value()); } } } } wxTheApp->CallAfter( [state, gen, ok, usedFallback, payload = std::move(payload), err = std::move(err)]() mutable { if (!state->alive.load()) return; if (state->currentGen.load() != gen) return; if (state->panel == nullptr) return; state->panel->onPreviewBytes(gen, ok, usedFallback, std::move(payload), std::move(err)); }); }).detach(); } void onPreviewBytes(unsigned gen, bool ok, bool usedFallback, std::string bytes, std::string err) { if (gen != state_->currentGen.load()) return; if (!ok || bytes.empty()) { previewStatus_->SetLabelText("(no preview available)"); clearPreview(); Layout(); wxString detail = err.empty() ? wxString("no preview returned") : wxString::FromUTF8(err); emitPreviewStatus("Preview unavailable: " + detail); return; } wxMemoryInputStream stream(bytes.data(), bytes.size()); wxImage img; // libpng emits iCCP warnings for some upstream PNGs (e.g. Yugipedia // scans with embedded sRGB chunks wx considers invalid). wx forwards // those as wxLogWarning -> modal dialog. Suppress logging for decode // only; the pixels load correctly either way. bool decoded = false; { wxLogNull suppressPngWarnings; decoded = img.LoadFile(stream, wxBITMAP_TYPE_ANY); } if (!decoded) { previewStatus_->SetLabelText("(preview decode failed)"); clearPreview(); Layout(); emitPreviewStatus("Preview unavailable: image decode failed"); return; } if (img.GetWidth() != kPreviewWidth || img.GetHeight() != kPreviewHeight) { img.Rescale(kPreviewWidth, kPreviewHeight, wxIMAGE_QUALITY_HIGH); } previewBitmap_->SetBitmap(wxBitmap(img)); if (usedFallback) { previewStatus_->SetLabelText("(image preview unavailable)"); emitPreviewStatus("Preview unavailable: showing fallback card-back image."); } else { previewStatus_->SetLabelText(""); emitPreviewStatus(""); } Layout(); } void emitPreviewStatus(const wxString& message) { wxCommandEvent ev(EVT_PREVIEW_STATUS, GetId()); ev.SetEventObject(this); ev.SetString(message); if (auto* parent = GetParent()) { parent->GetEventHandler()->ProcessEvent(ev); } else { ProcessWindowEvent(ev); } } void rebuildImageList() { imageList_->Clear(); if (!card_) return; for (std::size_t i = 0; i < card_->images.size(); ++i) { imageList_->Append(wxString::Format("Image %zu", i + 1)); } } void onImageActivated(wxCommandEvent& event) { if (!card_) return; const int sel = event.GetSelection(); if (sel < 0 || static_cast(sel) >= card_->images.size()) return; std::vector paths; paths.reserve(card_->images.size()); for (const auto& name : card_->images) { paths.push_back(imageService_.resolveImagePath(gameId(), name)); } ImageViewerDialog dlg(this, std::move(paths), static_cast(sel)); const Theme theme = inferThemeFromWindow(this); applyThemeToWindowTree(&dlg, paletteForTheme(theme), theme); dlg.ShowModal(); } ImageService& imageService_; CardPreviewService& cardPreview_; std::string exeDirForBundledAssets_; std::optional card_; wxStaticBitmap* previewBitmap_{nullptr}; wxStaticText* previewStatus_{nullptr}; std::vector detailRows_; wxStaticText* flagsLabel_{nullptr}; wxPanel* flagsRow_{nullptr}; std::vector flagIcons_; wxStaticText* noteLabel_{nullptr}; wxStaticText* noteValue_{nullptr}; wxListBox* imageList_{nullptr}; std::shared_ptr state_; std::optional lastFetchedId_; }; } // namespace ccm::ui