Files
2026-07-30 14:51:53 +02:00

578 lines
22 KiB
C++

#pragma once
// BaseSelectedCardPanel<TCard>
//
// 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<State>` + `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 <wx/app.h>
#include <wx/arrstr.h>
#include <wx/bitmap.h>
#include <wx/colour.h>
#include <wx/event.h>
#include <wx/filename.h>
#include <wx/image.h>
#include <wx/listbox.h>
#include <wx/log.h>
#include <wx/mstream.h>
#include <wx/stdpaths.h>
#include <wx/panel.h>
#include <wx/settings.h>
#include <wx/sizer.h>
#include <wx/statbmp.h>
#include <wx/stattext.h>
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <memory>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <tuple>
#include <utility>
#include <vector>
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 <typename TCard>
class BaseSelectedCardPanel : public wxPanel {
public:
using card_type = TCard;
void setCard(std::optional<TCard> card) {
const std::optional<std::uint32_t> newId =
card ? std::optional<std::uint32_t>{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<DetailRowSpec> 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<FlagIconSpec> 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<std::string, std::string, std::string>
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<PreviewState>()) {
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<bool> alive{true};
std::atomic<unsigned> 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 {};
case Game::YuGiOhBandai:
return "https://ms.yugipedia.com//3/34/Back-BAN-JP-1999.png";
}
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<std::size_t>(sel) >= card_->images.size()) return;
std::vector<std::filesystem::path> 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<std::size_t>(sel));
const Theme theme = inferThemeFromWindow(this);
applyThemeToWindowTree(&dlg, paletteForTheme(theme), theme);
dlg.ShowModal();
}
ImageService& imageService_;
CardPreviewService& cardPreview_;
std::string exeDirForBundledAssets_;
std::optional<TCard> card_;
wxStaticBitmap* previewBitmap_{nullptr};
wxStaticText* previewStatus_{nullptr};
std::vector<DetailRow> detailRows_;
wxStaticText* flagsLabel_{nullptr};
wxPanel* flagsRow_{nullptr};
std::vector<FlagIcon> flagIcons_;
wxStaticText* noteLabel_{nullptr};
wxStaticText* noteValue_{nullptr};
wxListBox* imageList_{nullptr};
std::shared_ptr<PreviewState> state_;
std::optional<std::uint32_t> lastFetchedId_;
};
} // namespace ccm::ui