mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 00:01:07 +00:00
minor: yugioh support added
This commit is contained in:
+11
-6
@@ -6,11 +6,11 @@
|
||||
|
||||
- `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector<IGameView*>` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly).
|
||||
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types.
|
||||
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window, 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/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 falls back to a per-game card-back image URL (Magic/Pokemon parity with CCM2) instead of leaving the preview empty. 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. 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`).
|
||||
- `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/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. 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/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.
|
||||
@@ -64,13 +64,18 @@
|
||||
- Button event handlers must use per-button state that is refreshed when theme changes. Avoid one-time captures of theme colors/mode in lambdas; these can leak dark-mode behavior into light mode.
|
||||
- In High Contrast, use stronger hover/pressed deltas than regular dark mode and keep the button border in the foreground/text color for visibility (currently yellow in this palette).
|
||||
- When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`.
|
||||
15. **Preview fallback behavior (CCM2 parity):**
|
||||
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.
|
||||
- Current fallback URLs are intentionally aligned with CCM2: Magic uses `Magic_card_back.jpg`, Pokemon uses `Cardback.jpg`.
|
||||
- If you change fallback sourcing (URL -> local asset, etc.), keep the "always show a reasonable card-back fallback" behavior intact for both games.
|
||||
- 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.
|
||||
- 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 `<Name>CardEditDialog`, not in `BaseCardEditDialog`.
|
||||
- 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
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
@@ -15,6 +15,10 @@ add_library(ccm_ui_wx STATIC
|
||||
src/PokemonSelectedCardPanel.cpp
|
||||
src/PokemonCardEditDialog.cpp
|
||||
src/PokemonGameView.cpp
|
||||
src/YuGiOhCardListPanel.cpp
|
||||
src/YuGiOhSelectedCardPanel.cpp
|
||||
src/YuGiOhCardEditDialog.cpp
|
||||
src/YuGiOhGameView.cpp
|
||||
|
||||
src/SettingsDialog.cpp
|
||||
src/ImageViewerDialog.cpp
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -25,6 +25,7 @@ struct AppContext {
|
||||
CardPreviewService& cardPreview;
|
||||
IGameModule& magicModule;
|
||||
IGameModule& pokemonModule;
|
||||
IGameModule& yuGiOhModule;
|
||||
// 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.
|
||||
|
||||
@@ -90,6 +90,7 @@ protected:
|
||||
}
|
||||
buildLayout();
|
||||
populateChoices();
|
||||
openingSnapshot_ = card_;
|
||||
Thaw();
|
||||
}
|
||||
|
||||
@@ -119,6 +120,11 @@ protected:
|
||||
return "(no sets cached - use Sets > " + updateMenuName() + ")";
|
||||
}
|
||||
|
||||
// Called when the card name field changes. Games that cache upstream print
|
||||
// metadata keyed by `(name, set)` should clear it here so stale "Next"
|
||||
// controls cannot outlive the lookup identity.
|
||||
virtual void onCardLookupContextChanged() {}
|
||||
|
||||
// Common helpers ----------------------------------------------------------
|
||||
|
||||
void appendRow(wxFlexGridSizer* grid, const wxString& label, wxWindow* ctrl) {
|
||||
@@ -129,12 +135,26 @@ protected:
|
||||
|
||||
[[nodiscard]] TCard& mutableCard() noexcept { return card_; }
|
||||
[[nodiscard]] const TCard& constCard() const noexcept { return card_; }
|
||||
void syncCardFromControls() { writeFromControls(); }
|
||||
[[nodiscard]] wxComboBox* setComboControl() const noexcept { return setCombo_; }
|
||||
|
||||
[[nodiscard]] const Set* selectedSetFromControls() const {
|
||||
const auto& available = availableSets();
|
||||
if (!setCombo_ || !setCombo_->IsEnabled()) return nullptr;
|
||||
const int sel = setCombo_->GetSelection();
|
||||
if (sel < 0 || static_cast<std::size_t>(sel) >= available.size()) return nullptr;
|
||||
return &available[static_cast<std::size_t>(sel)];
|
||||
}
|
||||
|
||||
private:
|
||||
void readSets() {
|
||||
auto loaded = setService_.getSets(game_);
|
||||
if (loaded.isOk()) {
|
||||
sets_ = std::move(loaded).value();
|
||||
std::sort(sets_.begin(), sets_.end(),
|
||||
[](const Set& a, const Set& b) {
|
||||
return a.releaseDate < b.releaseDate;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +168,10 @@ private:
|
||||
grid->AddGrowableCol(1, 1);
|
||||
|
||||
nameCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.name.c_str()));
|
||||
nameCtrl_->Bind(wxEVT_TEXT, [this](wxCommandEvent& ev) {
|
||||
onCardLookupContextChanged();
|
||||
ev.Skip();
|
||||
});
|
||||
appendRow(grid, "Name", nameCtrl_);
|
||||
|
||||
setCombo_ = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0,
|
||||
@@ -393,6 +417,14 @@ private:
|
||||
"Add card", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
if (mode_ == EditMode::Edit && !(card_ == openingSnapshot_)) {
|
||||
if (showThemedConfirmDialog(
|
||||
this,
|
||||
wxString::FromUTF8("Save changes to this card?"),
|
||||
wxString::FromUTF8("Save changes")) != wxID_YES) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
ev.Skip();
|
||||
}
|
||||
|
||||
@@ -503,6 +535,7 @@ private:
|
||||
SetService& setService_;
|
||||
EditMode mode_;
|
||||
TCard card_;
|
||||
TCard openingSnapshot_{};
|
||||
Game game_;
|
||||
|
||||
std::vector<Set> sets_;
|
||||
|
||||
@@ -30,9 +30,12 @@
|
||||
#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>
|
||||
@@ -43,8 +46,10 @@
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <tuple>
|
||||
@@ -221,6 +226,9 @@ protected:
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -284,6 +292,9 @@ private:
|
||||
case Game::Pokemon:
|
||||
// Mirrors CCM2's unresolved-preview fallback image.
|
||||
return "https://archives.bulbagarden.net/media/upload/1/17/Cardback.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";
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -367,25 +378,58 @@ private:
|
||||
CardPreviewService* svcPtr = &cardPreview_;
|
||||
auto [name, setId, setNo] = previewKey(card);
|
||||
const Game game = gameId();
|
||||
const std::string exeDirCopy = exeDirForBundledAssets_;
|
||||
|
||||
std::thread([state, gen, svcPtr, name = std::move(name),
|
||||
setId = std::move(setId), setNo = std::move(setNo), game]() {
|
||||
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()) {
|
||||
const std::string fallbackUrl = fallbackImageUrlForGame(game);
|
||||
if (!fallbackUrl.empty()) {
|
||||
auto fallbackBytes = svcPtr->fetchImageBytesByUrl(fallbackUrl);
|
||||
if (fallbackBytes.isOk()) {
|
||||
payload = std::move(fallbackBytes).value();
|
||||
ok = !payload.empty();
|
||||
if (ok) {
|
||||
usedFallback = true;
|
||||
err.clear();
|
||||
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 {
|
||||
const std::string fallbackUrl = fallbackImageUrlForGame(game);
|
||||
if (!fallbackUrl.empty()) {
|
||||
auto fallbackBytes = svcPtr->fetchImageBytesByUrl(fallbackUrl);
|
||||
if (fallbackBytes.isOk()) {
|
||||
applyFallbackPayload(std::move(fallbackBytes).value());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,7 +463,16 @@ private:
|
||||
|
||||
wxMemoryInputStream stream(bytes.data(), bytes.size());
|
||||
wxImage img;
|
||||
if (!img.LoadFile(stream, wxBITMAP_TYPE_ANY)) {
|
||||
// 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();
|
||||
@@ -477,6 +530,7 @@ private:
|
||||
|
||||
ImageService& imageService_;
|
||||
CardPreviewService& cardPreview_;
|
||||
std::string exeDirForBundledAssets_;
|
||||
std::optional<TCard> card_;
|
||||
|
||||
wxStaticBitmap* previewBitmap_{nullptr};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
#include "ccm/ports/ICardPreviewSource.hpp"
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
#include "ccm/ui/BaseCardEditDialog.hpp"
|
||||
#include <wx/button.h>
|
||||
#include <wx/stattext.h>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
class YuGiOhCardEditDialog final : public BaseCardEditDialog<YuGiOhCard> {
|
||||
public:
|
||||
YuGiOhCardEditDialog(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
SetService& setService,
|
||||
CardPreviewService& cardPreview,
|
||||
EditMode mode,
|
||||
YuGiOhCard initial,
|
||||
const std::vector<Set>* preloadedSets = nullptr);
|
||||
|
||||
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 Yu-Gi-Oh!"; }
|
||||
void onCardLookupContextChanged() override;
|
||||
|
||||
private:
|
||||
void onAutoDetectSetNo(wxCommandEvent&);
|
||||
void onAutoDetectRarity(wxCommandEvent&);
|
||||
void onNextSetNo(wxCommandEvent&);
|
||||
void onNextRarity(wxCommandEvent&);
|
||||
void onSetNoTextChanged(wxCommandEvent&);
|
||||
void onSetSelectionChanged(wxCommandEvent&);
|
||||
void autoDetectFromApi(bool fillSetNo, bool fillRarity);
|
||||
void refreshSetNoFullPreview();
|
||||
void clearCachedPrintVariants();
|
||||
bool fetchAndCachePrintVariants();
|
||||
void rebuildVariantRingsFromCache();
|
||||
void filterCachedVariantsForCardLanguage();
|
||||
void syncRingPositionsToControls();
|
||||
void applyRarityStringToChoice(const std::string& rarity);
|
||||
[[nodiscard]] std::string currentFullSetNoFromControls() const;
|
||||
void refreshVariantNextControls();
|
||||
[[nodiscard]] std::string extractSetNoNumeric(std::string_view fullSetNo) const;
|
||||
[[nodiscard]] std::string composeFullSetNo(std::string_view numeric) const;
|
||||
void scheduleDeferredVariantPrefetch();
|
||||
void prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch);
|
||||
|
||||
EditMode dialogMode_;
|
||||
unsigned variantFetchEpoch_{0};
|
||||
CardPreviewService& cardPreview_;
|
||||
wxTextCtrl* setNoCtrl_{nullptr};
|
||||
wxStaticText* setNoFullPreview_{nullptr};
|
||||
wxChoice* rarityChoice_{nullptr};
|
||||
wxButton* autoSetNoBtn_{nullptr};
|
||||
wxButton* nextSetNoBtn_{nullptr};
|
||||
wxButton* autoRarityBtn_{nullptr};
|
||||
wxButton* nextRarityBtn_{nullptr};
|
||||
wxCheckBox* firstEditionCheck_{nullptr};
|
||||
wxCheckBox* signedCheck_{nullptr};
|
||||
wxCheckBox* alteredCheck_{nullptr};
|
||||
|
||||
std::vector<AutoDetectedPrint> cachedVariants_;
|
||||
std::vector<std::string> uniqueSetCodes_;
|
||||
std::vector<std::string> raritiesForCurrentSetCode_;
|
||||
std::size_t setCodeRingPos_{0};
|
||||
std::size_t rarityRingPos_{0};
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
#include "ccm/services/CardSorter.hpp"
|
||||
#include "ccm/ui/BaseCardListPanel.hpp"
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
class YuGiOhCardListPanel final : public BaseCardListPanel<YuGiOhCard, YuGiOhSortColumn> {
|
||||
public:
|
||||
explicit YuGiOhCardListPanel(wxWindow* parent);
|
||||
|
||||
protected:
|
||||
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
|
||||
[[nodiscard]] std::vector<IconColumnSpec> declareIconColumns() const override;
|
||||
[[nodiscard]] std::string renderTextCell(const YuGiOhCard& card, std::size_t idx) const override;
|
||||
[[nodiscard]] bool isIconColumnSet(const YuGiOhCard& card, std::size_t idx) const override;
|
||||
void sortBy(YuGiOhSortColumn column, bool ascending) override;
|
||||
[[nodiscard]] bool matchesFilter(const YuGiOhCard& card, std::string_view filter) const override;
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,62 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/domain/YuGiOhCard.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 YuGiOhCardListPanel;
|
||||
class YuGiOhSelectedCardPanel;
|
||||
|
||||
class YuGiOhGameView final : public IGameView {
|
||||
public:
|
||||
YuGiOhGameView(ConfigService& config,
|
||||
CollectionService<YuGiOhCard>& collection,
|
||||
SetService& sets,
|
||||
ImageService& images,
|
||||
CardPreviewService& cardPreview,
|
||||
IGameModule& module);
|
||||
|
||||
[[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Yu-Gi-Oh!"; }
|
||||
|
||||
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 Yu-Gi-Oh!"; }
|
||||
|
||||
private:
|
||||
void ensureSetsLoaded();
|
||||
const std::vector<Set>& setsForDialog();
|
||||
|
||||
ConfigService& config_;
|
||||
CollectionService<YuGiOhCard>& collection_;
|
||||
SetService& sets_;
|
||||
ImageService& images_;
|
||||
CardPreviewService& cardPreview_;
|
||||
IGameModule& module_;
|
||||
|
||||
YuGiOhCardListPanel* listPanel_{nullptr};
|
||||
YuGiOhSelectedCardPanel* selectedPanel_{nullptr};
|
||||
std::vector<Set> setsCache_;
|
||||
bool attemptedInitialSetLoad_{false};
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include "ccm/domain/YuGiOhCard.hpp"
|
||||
#include "ccm/ui/BaseSelectedCardPanel.hpp"
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
class YuGiOhSelectedCardPanel final : public BaseSelectedCardPanel<YuGiOhCard> {
|
||||
public:
|
||||
YuGiOhSelectedCardPanel(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 YuGiOhCard& card, DetailKey key) const override;
|
||||
[[nodiscard]] bool isFlagSet(const YuGiOhCard& card, DetailKey key) const override;
|
||||
[[nodiscard]] std::tuple<std::string, std::string, std::string>
|
||||
previewKey(const YuGiOhCard& card) const override;
|
||||
[[nodiscard]] Game gameId() const noexcept override { return Game::YuGiOh; }
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <wx/button.h>
|
||||
#include <wx/dcclient.h>
|
||||
#include <wx/image.h>
|
||||
#include <wx/log.h>
|
||||
#include <wx/panel.h>
|
||||
#include <wx/sizer.h>
|
||||
|
||||
@@ -110,7 +111,12 @@ bool ImageViewerDialog::loadImageAt(std::size_t index) {
|
||||
if (imageCacheReady_[index]) return imageCache_[index].IsOk();
|
||||
|
||||
wxImage img;
|
||||
if (!img.LoadFile(paths_[index].string())) {
|
||||
bool ok = false;
|
||||
{
|
||||
wxLogNull suppressPngWarnings;
|
||||
ok = img.LoadFile(paths_[index].string());
|
||||
}
|
||||
if (!ok) {
|
||||
imageCacheReady_[index] = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ void ensureDataStorageScaffold(const Configuration& cfg) {
|
||||
|
||||
MainFrame::MainFrame(AppContext& ctx)
|
||||
: wxFrame(nullptr, wxID_ANY, "Card Collection Manager 3",
|
||||
wxDefaultPosition, wxSize(1210, 700)),
|
||||
wxDefaultPosition, wxSize(1210, 770)),
|
||||
ctx_(ctx),
|
||||
activeGame_(ctx.config.current().defaultGame) {
|
||||
buildMenuBar();
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
#include "ccm/ui/YuGiOhCardEditDialog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/util/YuGiOhPrintingSlot.hpp"
|
||||
#include <wx/app.h>
|
||||
#include <wx/panel.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm::ui {
|
||||
namespace {
|
||||
const char* const kRarityOptions[] = {
|
||||
"Common",
|
||||
"Rare",
|
||||
"Super Rare",
|
||||
"Ultra Rare",
|
||||
"Secret Rare",
|
||||
"Quarter Century Secret Rare",
|
||||
"Starlight Rare",
|
||||
"Collector's Rare",
|
||||
"Ghost Rare",
|
||||
"Ultimate Rare",
|
||||
"Platinum Secret Rare",
|
||||
"Prismatic Secret Rare",
|
||||
};
|
||||
}
|
||||
|
||||
YuGiOhCardEditDialog::YuGiOhCardEditDialog(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
SetService& setService,
|
||||
CardPreviewService& cardPreview,
|
||||
EditMode mode,
|
||||
YuGiOhCard initial,
|
||||
const std::vector<Set>* preloadedSets)
|
||||
: BaseCardEditDialog<YuGiOhCard>(
|
||||
parent,
|
||||
mode == EditMode::Create ? "Add Yu-Gi-Oh! Card" : "Edit Yu-Gi-Oh! Card",
|
||||
imageService, setService, mode, std::move(initial), Game::YuGiOh, preloadedSets),
|
||||
dialogMode_(mode),
|
||||
cardPreview_(cardPreview) {
|
||||
buildAndPopulate();
|
||||
if (dialogMode_ == EditMode::Edit) {
|
||||
scheduleDeferredVariantPrefetch();
|
||||
}
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onCardLookupContextChanged() {
|
||||
clearCachedPrintVariants();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
||||
firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition");
|
||||
signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed");
|
||||
alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered");
|
||||
flagsBox->Add(firstEditionCheck_, 0, wxRIGHT, 12);
|
||||
flagsBox->Add(signedCheck_, 0, wxRIGHT, 12);
|
||||
flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12);
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
|
||||
auto* setNoPanel = new wxPanel(this, wxID_ANY);
|
||||
setNoCtrl_ = new wxTextCtrl(setNoPanel, wxID_ANY);
|
||||
setNoCtrl_->Bind(wxEVT_TEXT, &YuGiOhCardEditDialog::onSetNoTextChanged, this);
|
||||
autoSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Auto detect");
|
||||
autoSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onAutoDetectSetNo, this);
|
||||
nextSetNoBtn_ = new wxButton(setNoPanel, wxID_ANY, "Next");
|
||||
nextSetNoBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onNextSetNo, this);
|
||||
nextSetNoBtn_->Show(false);
|
||||
setNoFullPreview_ = new wxStaticText(setNoPanel, wxID_ANY, "(n/a)");
|
||||
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 | wxRIGHT, 6);
|
||||
setNoRow->Add(setNoFullPreview_, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 8);
|
||||
setNoPanel->SetSizer(setNoRow);
|
||||
|
||||
auto* rarityPanel = new wxPanel(this, wxID_ANY);
|
||||
rarityChoice_ = new wxChoice(rarityPanel, wxID_ANY);
|
||||
autoRarityBtn_ = new wxButton(rarityPanel, wxID_ANY, "Auto detect");
|
||||
autoRarityBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onAutoDetectRarity, this);
|
||||
nextRarityBtn_ = new wxButton(rarityPanel, wxID_ANY, "Next");
|
||||
nextRarityBtn_->Bind(wxEVT_BUTTON, &YuGiOhCardEditDialog::onNextRarity, this);
|
||||
nextRarityBtn_->Show(false);
|
||||
wxArrayString rarityItems;
|
||||
rarityItems.Alloc(static_cast<int>(sizeof(kRarityOptions) / sizeof(kRarityOptions[0])));
|
||||
for (const char* rarity : kRarityOptions) {
|
||||
rarityItems.Add(wxString::FromUTF8(rarity));
|
||||
}
|
||||
rarityChoice_->Append(rarityItems);
|
||||
auto* rarityRow = new wxBoxSizer(wxHORIZONTAL);
|
||||
rarityRow->Add(rarityChoice_, 1, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
|
||||
rarityRow->Add(autoRarityBtn_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
|
||||
rarityRow->Add(nextRarityBtn_, 0, wxALIGN_CENTER_VERTICAL);
|
||||
rarityPanel->SetSizer(rarityRow);
|
||||
|
||||
appendRow(grid, "Set #", setNoPanel);
|
||||
appendRow(grid, "Rarity", rarityPanel);
|
||||
|
||||
if (auto* setCombo = setComboControl()) {
|
||||
setCombo->Bind(wxEVT_COMBOBOX, &YuGiOhCardEditDialog::onSetSelectionChanged, this);
|
||||
}
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::readExtraFromCard() {
|
||||
clearCachedPrintVariants();
|
||||
if (setNoCtrl_) setNoCtrl_->ChangeValue(extractSetNoNumeric(constCard().setNo));
|
||||
if (rarityChoice_) {
|
||||
const wxString rarity = wxString::FromUTF8(constCard().rarity.c_str());
|
||||
int rarityIdx = rarityChoice_->FindString(rarity);
|
||||
if (rarityIdx == wxNOT_FOUND && !constCard().rarity.empty()) {
|
||||
rarityChoice_->Append(rarity);
|
||||
rarityIdx = rarityChoice_->GetCount() - 1;
|
||||
}
|
||||
if (rarityIdx == wxNOT_FOUND) rarityIdx = 0;
|
||||
if (rarityIdx != wxNOT_FOUND) rarityChoice_->SetSelection(rarityIdx);
|
||||
}
|
||||
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
|
||||
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
|
||||
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
|
||||
refreshSetNoFullPreview();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::writeExtraToCard() {
|
||||
if (setNoCtrl_) mutableCard().setNo = composeFullSetNo(setNoCtrl_->GetValue().ToStdString(wxConvUTF8));
|
||||
if (rarityChoice_) mutableCard().rarity = rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8);
|
||||
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
|
||||
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
|
||||
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::clearCachedPrintVariants() {
|
||||
++variantFetchEpoch_;
|
||||
cachedVariants_.clear();
|
||||
uniqueSetCodes_.clear();
|
||||
raritiesForCurrentSetCode_.clear();
|
||||
setCodeRingPos_ = 0;
|
||||
rarityRingPos_ = 0;
|
||||
refreshVariantNextControls();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::scheduleDeferredVariantPrefetch() {
|
||||
const unsigned epoch = variantFetchEpoch_;
|
||||
wxTheApp->CallAfter([this, epoch]() {
|
||||
prefetchVariantsForCurrentCardSilent(epoch);
|
||||
});
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::prefetchVariantsForCurrentCardSilent(unsigned capturedEpoch) {
|
||||
if (capturedEpoch != variantFetchEpoch_) return;
|
||||
if (!cachedVariants_.empty()) return;
|
||||
const auto& card = constCard();
|
||||
if (card.name.empty() || card.set.name.empty()) return;
|
||||
|
||||
auto detected = cardPreview_.detectPrintVariants(Game::YuGiOh, card.name, card.set.name);
|
||||
if (!detected) return;
|
||||
if (capturedEpoch != variantFetchEpoch_) return;
|
||||
|
||||
cachedVariants_ = std::move(detected).value();
|
||||
rebuildVariantRingsFromCache();
|
||||
syncRingPositionsToControls();
|
||||
refreshVariantNextControls();
|
||||
}
|
||||
|
||||
bool YuGiOhCardEditDialog::fetchAndCachePrintVariants() {
|
||||
syncCardFromControls();
|
||||
const auto& card = constCard();
|
||||
if (card.name.empty()) {
|
||||
showThemedMessageDialog(this, "Enter a card name first.", "Auto detect",
|
||||
wxOK | wxICON_INFORMATION);
|
||||
return false;
|
||||
}
|
||||
if (card.set.name.empty()) {
|
||||
showThemedMessageDialog(this, "Select a set first.", "Auto detect",
|
||||
wxOK | wxICON_INFORMATION);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto detected = cardPreview_.detectPrintVariants(Game::YuGiOh, card.name, card.set.name);
|
||||
if (!detected) {
|
||||
showThemedMessageDialog(this, "Auto detect failed: " + detected.error(), "Auto detect",
|
||||
wxOK | wxICON_WARNING);
|
||||
return false;
|
||||
}
|
||||
cachedVariants_ = std::move(detected).value();
|
||||
return true;
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::filterCachedVariantsForCardLanguage() {
|
||||
if (constCard().language != Language::English) return;
|
||||
const auto removed = std::remove_if(
|
||||
cachedVariants_.begin(), cachedVariants_.end(), [](const AutoDetectedPrint& p) {
|
||||
return ygoLikelyEuropeanRegionalSetCode(p.setNo);
|
||||
});
|
||||
cachedVariants_.erase(removed, cachedVariants_.end());
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::rebuildVariantRingsFromCache() {
|
||||
syncCardFromControls();
|
||||
uniqueSetCodes_.clear();
|
||||
raritiesForCurrentSetCode_.clear();
|
||||
if (cachedVariants_.empty()) return;
|
||||
|
||||
filterCachedVariantsForCardLanguage();
|
||||
|
||||
std::unordered_set<std::string> seenSlots;
|
||||
seenSlots.reserve(cachedVariants_.size());
|
||||
for (const auto& p : cachedVariants_) {
|
||||
if (p.setNo.empty()) continue;
|
||||
const std::string digits = ygoCollectorDigitsOnly(p.setNo);
|
||||
if (digits.empty()) continue;
|
||||
const std::string slotKey = ygoAbbrevBeforeDash(p.setNo) + "|" + digits;
|
||||
if (!seenSlots.insert(slotKey).second) continue;
|
||||
uniqueSetCodes_.push_back(p.setNo);
|
||||
}
|
||||
|
||||
const std::string full = currentFullSetNoFromControls();
|
||||
std::unordered_set<std::string> seenRarity;
|
||||
for (const auto& p : cachedVariants_) {
|
||||
if (!ygoPrintingSlotsMatch(p.setNo, full)) continue;
|
||||
if (p.rarity.empty()) continue;
|
||||
if (seenRarity.insert(p.rarity).second) raritiesForCurrentSetCode_.push_back(p.rarity);
|
||||
}
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::syncRingPositionsToControls() {
|
||||
const std::string full = currentFullSetNoFromControls();
|
||||
setCodeRingPos_ = 0;
|
||||
for (std::size_t i = 0; i < uniqueSetCodes_.size(); ++i) {
|
||||
if (ygoPrintingSlotsMatch(uniqueSetCodes_[i], full)) {
|
||||
setCodeRingPos_ = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
rarityRingPos_ = 0;
|
||||
if (rarityChoice_) {
|
||||
const std::string r = rarityChoice_->GetStringSelection().ToStdString(wxConvUTF8);
|
||||
for (std::size_t i = 0; i < raritiesForCurrentSetCode_.size(); ++i) {
|
||||
if (raritiesForCurrentSetCode_[i] == r) {
|
||||
rarityRingPos_ = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::applyRarityStringToChoice(const std::string& rarity) {
|
||||
if (!rarityChoice_) return;
|
||||
const wxString wxRare = wxString::FromUTF8(rarity.c_str());
|
||||
int idx = rarityChoice_->FindString(wxRare);
|
||||
if (idx == wxNOT_FOUND && !rarity.empty()) {
|
||||
rarityChoice_->Append(wxRare);
|
||||
idx = rarityChoice_->GetCount() - 1;
|
||||
}
|
||||
if (idx != wxNOT_FOUND) rarityChoice_->SetSelection(idx);
|
||||
}
|
||||
|
||||
std::string YuGiOhCardEditDialog::currentFullSetNoFromControls() const {
|
||||
if (!setNoCtrl_) return {};
|
||||
return composeFullSetNo(setNoCtrl_->GetValue().ToStdString(wxConvUTF8));
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::refreshVariantNextControls() {
|
||||
if (!nextSetNoBtn_ || !nextRarityBtn_) return;
|
||||
nextSetNoBtn_->Show(uniqueSetCodes_.size() > 1);
|
||||
nextRarityBtn_->Show(raritiesForCurrentSetCode_.size() > 1);
|
||||
Layout();
|
||||
if (GetSizer()) Fit();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onAutoDetectSetNo(wxCommandEvent&) {
|
||||
autoDetectFromApi(true, false);
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onAutoDetectRarity(wxCommandEvent&) {
|
||||
autoDetectFromApi(false, true);
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onNextSetNo(wxCommandEvent&) {
|
||||
if (uniqueSetCodes_.size() <= 1) return;
|
||||
setCodeRingPos_ = (setCodeRingPos_ + 1) % uniqueSetCodes_.size();
|
||||
const std::string& code = uniqueSetCodes_[setCodeRingPos_];
|
||||
if (setNoCtrl_) {
|
||||
setNoCtrl_->ChangeValue(wxString::FromUTF8(extractSetNoNumeric(code).c_str()));
|
||||
}
|
||||
for (const auto& p : cachedVariants_) {
|
||||
if (p.setNo == code) {
|
||||
applyRarityStringToChoice(p.rarity);
|
||||
break;
|
||||
}
|
||||
}
|
||||
rebuildVariantRingsFromCache();
|
||||
syncRingPositionsToControls();
|
||||
refreshSetNoFullPreview();
|
||||
refreshVariantNextControls();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onNextRarity(wxCommandEvent&) {
|
||||
if (raritiesForCurrentSetCode_.size() <= 1) return;
|
||||
rarityRingPos_ = (rarityRingPos_ + 1) % raritiesForCurrentSetCode_.size();
|
||||
applyRarityStringToChoice(raritiesForCurrentSetCode_[rarityRingPos_]);
|
||||
refreshVariantNextControls();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::autoDetectFromApi(bool fillSetNo, bool fillRarity) {
|
||||
if (!fetchAndCachePrintVariants()) return;
|
||||
|
||||
if (fillSetNo && setNoCtrl_ && !cachedVariants_.empty()) {
|
||||
const auto& p = cachedVariants_.front();
|
||||
setNoCtrl_->ChangeValue(wxString::FromUTF8(extractSetNoNumeric(p.setNo).c_str()));
|
||||
if (fillRarity) applyRarityStringToChoice(p.rarity);
|
||||
} else if (fillRarity && rarityChoice_) {
|
||||
const std::string full = currentFullSetNoFromControls();
|
||||
bool applied = false;
|
||||
for (const auto& p : cachedVariants_) {
|
||||
if (ygoPrintingSlotsMatch(p.setNo, full)) {
|
||||
applyRarityStringToChoice(p.rarity);
|
||||
applied = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!applied && !cachedVariants_.empty()) {
|
||||
applyRarityStringToChoice(cachedVariants_.front().rarity);
|
||||
}
|
||||
}
|
||||
|
||||
rebuildVariantRingsFromCache();
|
||||
syncRingPositionsToControls();
|
||||
refreshSetNoFullPreview();
|
||||
refreshVariantNextControls();
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onSetNoTextChanged(wxCommandEvent&) {
|
||||
refreshSetNoFullPreview();
|
||||
if (!cachedVariants_.empty()) {
|
||||
rebuildVariantRingsFromCache();
|
||||
syncRingPositionsToControls();
|
||||
refreshVariantNextControls();
|
||||
}
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::onSetSelectionChanged(wxCommandEvent& ev) {
|
||||
clearCachedPrintVariants();
|
||||
refreshSetNoFullPreview();
|
||||
scheduleDeferredVariantPrefetch();
|
||||
ev.Skip();
|
||||
}
|
||||
|
||||
std::string YuGiOhCardEditDialog::extractSetNoNumeric(std::string_view fullSetNo) const {
|
||||
std::string digits;
|
||||
for (char ch : fullSetNo) {
|
||||
if (std::isdigit(static_cast<unsigned char>(ch))) digits.push_back(ch);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
std::string YuGiOhCardEditDialog::composeFullSetNo(std::string_view numericRaw) const {
|
||||
std::string numeric;
|
||||
for (char ch : numericRaw) {
|
||||
if (std::isdigit(static_cast<unsigned char>(ch))) numeric.push_back(ch);
|
||||
}
|
||||
const Set* set = selectedSetFromControls();
|
||||
if (!set || set->id.empty() || numeric.empty()) return numeric;
|
||||
return set->id + "-" + numeric;
|
||||
}
|
||||
|
||||
void YuGiOhCardEditDialog::refreshSetNoFullPreview() {
|
||||
if (!setNoFullPreview_ || !setNoCtrl_) return;
|
||||
const std::string full = composeFullSetNo(setNoCtrl_->GetValue().ToStdString(wxConvUTF8));
|
||||
if (full.empty()) {
|
||||
setNoFullPreview_->SetLabel("(n/a)");
|
||||
} else {
|
||||
setNoFullPreview_->SetLabel(wxString::Format("(%s)", wxString::FromUTF8(full.c_str())));
|
||||
}
|
||||
setNoFullPreview_->GetParent()->Layout();
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,69 @@
|
||||
#include "ccm/ui/YuGiOhCardListPanel.hpp"
|
||||
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
YuGiOhCardListPanel::YuGiOhCardListPanel(wxWindow* parent)
|
||||
: BaseCardListPanel<YuGiOhCard, YuGiOhSortColumn>(parent) {
|
||||
buildLayout();
|
||||
}
|
||||
|
||||
std::vector<YuGiOhCardListPanel::TextColumnSpec>
|
||||
YuGiOhCardListPanel::declareTextColumns() const {
|
||||
return {
|
||||
{"Name", 200, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Name},
|
||||
{"Set", 160, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::SetReleaseDate},
|
||||
{"Amount", 70, wxLIST_FORMAT_RIGHT, YuGiOhSortColumn::Amount},
|
||||
{"Condition", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Condition},
|
||||
{"Language", 100, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Language},
|
||||
{"Note", 180, wxLIST_FORMAT_LEFT, YuGiOhSortColumn::Note},
|
||||
};
|
||||
}
|
||||
|
||||
std::vector<YuGiOhCardListPanel::IconColumnSpec>
|
||||
YuGiOhCardListPanel::declareIconColumns() const {
|
||||
constexpr int kFlagColWidth = 36;
|
||||
return {
|
||||
{kSvgFirstEdition, kFlagColWidth, YuGiOhSortColumn::FirstEdition},
|
||||
{kSvgSigned, kFlagColWidth, YuGiOhSortColumn::Signed},
|
||||
{kSvgAltered, kFlagColWidth, YuGiOhSortColumn::Altered},
|
||||
};
|
||||
}
|
||||
|
||||
std::string YuGiOhCardListPanel::renderTextCell(const YuGiOhCard& 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 YuGiOhCardListPanel::isIconColumnSet(const YuGiOhCard& card,
|
||||
std::size_t idx) const {
|
||||
switch (idx) {
|
||||
case 0: return card.firstEdition;
|
||||
case 1: return card.signed_;
|
||||
case 2: return card.altered;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void YuGiOhCardListPanel::sortBy(YuGiOhSortColumn column, bool ascending) {
|
||||
sortYuGiOhCards(mutableCards(), column, ascending);
|
||||
}
|
||||
|
||||
bool YuGiOhCardListPanel::matchesFilter(const YuGiOhCard& card,
|
||||
std::string_view filter) const {
|
||||
return matchesYuGiOhFilter(card, filter);
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,209 @@
|
||||
#include "ccm/ui/YuGiOhGameView.hpp"
|
||||
|
||||
#include "ccm/ui/YuGiOhCardEditDialog.hpp"
|
||||
#include "ccm/ui/YuGiOhCardListPanel.hpp"
|
||||
#include "ccm/ui/YuGiOhSelectedCardPanel.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
|
||||
#include <optional>
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
YuGiOhGameView::YuGiOhGameView(ConfigService& config,
|
||||
CollectionService<YuGiOhCard>& collection,
|
||||
SetService& sets,
|
||||
ImageService& images,
|
||||
CardPreviewService& cardPreview,
|
||||
IGameModule& module)
|
||||
: config_(config),
|
||||
collection_(collection),
|
||||
sets_(sets),
|
||||
images_(images),
|
||||
cardPreview_(cardPreview),
|
||||
module_(module) {}
|
||||
|
||||
void YuGiOhGameView::ensureSetsLoaded() {
|
||||
if (attemptedInitialSetLoad_) return;
|
||||
attemptedInitialSetLoad_ = true;
|
||||
|
||||
auto cached = sets_.getSets(Game::YuGiOh);
|
||||
if (cached) {
|
||||
setsCache_ = std::move(cached).value();
|
||||
std::sort(setsCache_.begin(), setsCache_.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
if (!setsCache_.empty()) return;
|
||||
} else {
|
||||
setsCache_.clear();
|
||||
}
|
||||
|
||||
auto refreshed = sets_.updateSets(Game::YuGiOh);
|
||||
if (refreshed) {
|
||||
setsCache_ = std::move(refreshed).value();
|
||||
std::sort(setsCache_.begin(), setsCache_.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
}
|
||||
}
|
||||
|
||||
wxPanel* YuGiOhGameView::listPanel(wxWindow* parent) {
|
||||
if (listPanel_ == nullptr) {
|
||||
listPanel_ = new YuGiOhCardListPanel(parent);
|
||||
listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) {
|
||||
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
|
||||
selectedPanel_->setCard(listPanel_->selected());
|
||||
}
|
||||
});
|
||||
}
|
||||
return listPanel_;
|
||||
}
|
||||
|
||||
wxPanel* YuGiOhGameView::selectedPanel(wxWindow* parent) {
|
||||
if (selectedPanel_ == nullptr) {
|
||||
selectedPanel_ = new YuGiOhSelectedCardPanel(parent, images_, cardPreview_);
|
||||
}
|
||||
return selectedPanel_;
|
||||
}
|
||||
|
||||
void YuGiOhGameView::refreshCollection() {
|
||||
if (listPanel_ == nullptr) return;
|
||||
auto loaded = collection_.list(Game::YuGiOh);
|
||||
if (!loaded) {
|
||||
showThemedMessageDialog(nullptr, "Failed to load Yu-Gi-Oh! 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>& YuGiOhGameView::setsForDialog() {
|
||||
ensureSetsLoaded();
|
||||
if (!setsCache_.empty()) return setsCache_;
|
||||
auto loaded = sets_.getSets(Game::YuGiOh);
|
||||
if (loaded) {
|
||||
setsCache_ = std::move(loaded).value();
|
||||
std::sort(setsCache_.begin(), setsCache_.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
}
|
||||
else setsCache_.clear();
|
||||
return setsCache_;
|
||||
}
|
||||
|
||||
void YuGiOhGameView::onAddCard(wxWindow* parentWindow) {
|
||||
YuGiOhCard fresh;
|
||||
fresh.amount = 1;
|
||||
fresh.language = Language::English;
|
||||
fresh.condition = Condition::NearMint;
|
||||
|
||||
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Create, fresh,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
|
||||
auto added = collection_.add(Game::YuGiOh, dlg.card());
|
||||
if (!added) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
YuGiOhCard persisted = dlg.card();
|
||||
persisted.id = added.value();
|
||||
auto normalized = images_.normalizeNamesForPersistedCard(
|
||||
Game::YuGiOh, 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::YuGiOh, 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 YuGiOhGameView::onEditCard(wxWindow* parentWindow) {
|
||||
if (listPanel_ == nullptr) return;
|
||||
auto sel = listPanel_->selected();
|
||||
if (!sel) {
|
||||
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
YuGiOhCardEditDialog dlg(parentWindow, images_, sets_, cardPreview_, EditMode::Edit, *sel,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
auto updated = collection_.update(Game::YuGiOh, dlg.card());
|
||||
if (!updated) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
refreshCollection();
|
||||
}
|
||||
|
||||
void YuGiOhGameView::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::YuGiOh, sel->id);
|
||||
if (!removed) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
refreshCollection();
|
||||
}
|
||||
|
||||
std::string YuGiOhGameView::onUpdateSets(wxWindow* parentWindow) {
|
||||
auto out = sets_.updateSets(Game::YuGiOh);
|
||||
if (!out) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return "Update failed";
|
||||
}
|
||||
setsCache_ = out.value();
|
||||
std::sort(setsCache_.begin(), setsCache_.end(),
|
||||
[](const Set& a, const Set& b) { return a.releaseDate < b.releaseDate; });
|
||||
showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Yu-Gi-Oh! sets.",
|
||||
"Sets updated", wxOK | wxICON_INFORMATION);
|
||||
return "Yu-Gi-Oh! sets updated.";
|
||||
}
|
||||
|
||||
void YuGiOhGameView::setFilter(std::string_view filter) {
|
||||
if (listPanel_) listPanel_->setFilter(filter);
|
||||
}
|
||||
|
||||
void YuGiOhGameView::applyTheme(const ThemePalette& palette) {
|
||||
if (listPanel_) listPanel_->applyTheme(palette);
|
||||
if (selectedPanel_) selectedPanel_->applyTheme(palette);
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,89 @@
|
||||
#include "ccm/ui/YuGiOhSelectedCardPanel.hpp"
|
||||
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
namespace {
|
||||
enum YuGiOhDetailKey : int {
|
||||
kName = 0,
|
||||
kSet,
|
||||
kSetNo,
|
||||
kRarity,
|
||||
kLanguage,
|
||||
kCondition,
|
||||
kAmount,
|
||||
kFirstEdition,
|
||||
kSigned,
|
||||
kAltered,
|
||||
};
|
||||
} // namespace
|
||||
|
||||
YuGiOhSelectedCardPanel::YuGiOhSelectedCardPanel(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
CardPreviewService& cardPreview)
|
||||
: BaseSelectedCardPanel<YuGiOhCard>(parent, imageService, cardPreview) {
|
||||
buildLayout();
|
||||
}
|
||||
|
||||
std::vector<YuGiOhSelectedCardPanel::DetailRowSpec>
|
||||
YuGiOhSelectedCardPanel::declareDetailRows() const {
|
||||
return {
|
||||
{"Name", kName, "(no card selected)"},
|
||||
{"Set", kSet, ""},
|
||||
{"Set #", kSetNo, ""},
|
||||
{"Rarity", kRarity, ""},
|
||||
{"Language", kLanguage, ""},
|
||||
{"Condition", kCondition, ""},
|
||||
{"Amount", kAmount, ""},
|
||||
};
|
||||
}
|
||||
|
||||
std::vector<YuGiOhSelectedCardPanel::FlagIconSpec>
|
||||
YuGiOhSelectedCardPanel::declareFlagIcons() const {
|
||||
return {
|
||||
{kSvgFirstEdition, "1. Edition", kFirstEdition},
|
||||
{kSvgSigned, "Signed", kSigned},
|
||||
{kSvgAltered, "Altered", kAltered},
|
||||
};
|
||||
}
|
||||
|
||||
std::string YuGiOhSelectedCardPanel::detailValueFor(const YuGiOhCard& card,
|
||||
DetailKey key) const {
|
||||
switch (key) {
|
||||
case kName: return card.name;
|
||||
case kSet: return card.set.name;
|
||||
case kSetNo: return card.setNo;
|
||||
case kRarity: return card.rarity;
|
||||
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 YuGiOhSelectedCardPanel::isFlagSet(const YuGiOhCard& card, DetailKey key) const {
|
||||
switch (key) {
|
||||
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>
|
||||
YuGiOhSelectedCardPanel::previewKey(const YuGiOhCard& card) const {
|
||||
// Pack rarity and edition into the third tuple slot so the YGO preview
|
||||
// source can build Yugipedia file names without changing the generic
|
||||
// ICardPreviewSource interface. Format: "<setNo>||<rarity>||<1E|UE>".
|
||||
// Yugipedia per-printing scans need the edition stamp to disambiguate
|
||||
// 1st-Edition vs Unlimited reprints.
|
||||
const char* const editionTag = card.firstEdition ? "1E" : "UE";
|
||||
return {card.name, card.set.name,
|
||||
card.setNo + "||" + card.rarity + "||" + editionTag};
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
Reference in New Issue
Block a user