major: initial release

* initial development

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* pokemon

* pokemon

* pokemon

* pokemon

* pokemon

* pokemon

* improvements

* improvements

* ci/cd

* ci/cd

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

---------

Co-authored-by: sdine <sdine@sdine.com>
This commit is contained in:
Sebastian Dine
2026-05-09 11:05:47 +02:00
committed by GitHub
parent 13262fa015
commit 55ace147bc
149 changed files with 12611 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
#pragma once
// AppContext: the only thing that crosses the UI boundary. Holds references
// to the shared core services and to the per-game `IGameView` instances. The
// wxWidgets layer never sees a concrete adapter type or a typed
// `CollectionService<TCard>` - swap in a Qt/imgui frontend by reimplementing
// the consumers of this struct only.
#include "ccm/games/IGameModule.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include <vector>
namespace ccm::ui {
class IGameView;
struct AppContext {
ConfigService& config;
SetService& sets;
ImageService& images;
CardPreviewService& cardPreview;
IGameModule& magicModule;
IGameModule& pokemonModule;
// 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.
std::vector<IGameView*> gameViews;
};
} // namespace ccm::ui
+7
View File
@@ -0,0 +1,7 @@
#pragma once
namespace ccm::ui {
inline constexpr const char* kAppVersion = "@CCM_APP_VERSION@";
} // namespace ccm::ui
+524
View File
@@ -0,0 +1,524 @@
#pragma once
// BaseCardEditDialog<TCard>
//
// Header-only template for the modal create/edit form. Owns the parts every
// game shares — Name, Set picker (read-only combo with prefix typeahead),
// Amount spin, Language and Condition choices, Note, Image list with
// Add/Remove/double-click-to-view, OK/Cancel — and exposes hooks the
// subclass uses to:
//
// - declare a flags row (`Foil` for Magic, `Holo` + `1. Edition` for Pokemon, ...)
// - declare any extra game-specific text fields (`Set #` for Pokemon)
// - read/write the typed `TCard`
//
// New games extend this template — see `MagicCardEditDialog` and
// `PokemonCardEditDialog` for the canonical patterns.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/ImageViewerDialog.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/arrstr.h>
#include <wx/button.h>
#include <wx/checkbox.h>
#include <wx/choice.h>
#include <wx/combobox.h>
#include <wx/dialog.h>
#include <wx/event.h>
#include <wx/filedlg.h>
#include <wx/listbox.h>
#include <wx/msgdlg.h>
#include <wx/sizer.h>
#include <wx/spinctrl.h>
#include <wx/stattext.h>
#include <wx/strconv.h>
#include <wx/textctrl.h>
#ifdef __WXMSW__
#include <wx/msw/wrapwin.h>
#endif
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
namespace ccm::ui {
enum class EditMode { Create, Edit };
template <typename TCard>
class BaseCardEditDialog : public wxDialog {
public:
using card_type = TCard;
[[nodiscard]] const TCard& card() const noexcept { return card_; }
protected:
BaseCardEditDialog(wxWindow* parent,
const wxString& title,
ImageService& imageService,
SetService& setService,
EditMode mode,
TCard initial,
Game game,
const std::vector<Set>* preloadedSets = nullptr)
: wxDialog(parent, wxID_ANY, title,
wxDefaultPosition, wxSize(560, 540),
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER),
imageService_(imageService),
setService_(setService),
mode_(mode),
card_(std::move(initial)),
game_(game),
preloadedSets_(preloadedSets) {}
// Subclass calls this from its constructor body once it can answer the
// virtual hooks below.
void buildAndPopulate() {
Freeze();
if (preloadedSets_ == nullptr) {
readSets();
}
buildLayout();
populateChoices();
Thaw();
}
// Hooks -------------------------------------------------------------------
// Subclass appends its game-specific check boxes / inputs onto `flagsBox`
// (a horizontal `wxBoxSizer`). Build and bind the widgets the subclass
// wants; the base only owns the surrounding label.
virtual void buildFlagsRow(wxBoxSizer* flagsBox) = 0;
// Subclass adds any extra game-specific labelled rows just below the
// standard rows but above the Note row, by calling `appendRow(label, ctrl)`
// (provided as a parameter). Default does nothing.
using AppendRowFn = void (*)(BaseCardEditDialog*, const wxString&, wxWindow*);
virtual void appendExtraRows(wxFlexGridSizer* /*grid*/) {}
// Subclass copies the extra fields it owns from `card_` into its widgets.
virtual void readExtraFromCard() {}
// Subclass copies the extra fields it owns from its widgets back into `card_`.
virtual void writeExtraToCard() {}
[[nodiscard]] virtual std::string updateMenuName() const { return "Update Sets"; }
// Display name passed into errors and the dialog title hints.
[[nodiscard]] virtual std::string emptySetMessage() const {
return "(no sets cached - use Sets > " + updateMenuName() + ")";
}
// Common helpers ----------------------------------------------------------
void appendRow(wxFlexGridSizer* grid, const wxString& label, wxWindow* ctrl) {
grid->Add(new wxStaticText(this, wxID_ANY, label),
0, wxALIGN_CENTER_VERTICAL);
grid->Add(ctrl, 1, wxEXPAND);
}
[[nodiscard]] TCard& mutableCard() noexcept { return card_; }
[[nodiscard]] const TCard& constCard() const noexcept { return card_; }
private:
void readSets() {
auto loaded = setService_.getSets(game_);
if (loaded.isOk()) {
sets_ = std::move(loaded).value();
}
}
[[nodiscard]] const std::vector<Set>& availableSets() const noexcept {
return preloadedSets_ != nullptr ? *preloadedSets_ : sets_;
}
void buildLayout() {
auto* root = new wxBoxSizer(wxVERTICAL);
auto* grid = new wxFlexGridSizer(2, 6, 8);
grid->AddGrowableCol(1, 1);
nameCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.name.c_str()));
appendRow(grid, "Name", nameCtrl_);
setCombo_ = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0,
nullptr, wxCB_READONLY);
appendRow(grid, "Set", setCombo_);
// Subclass extra rows go between Set and Amount (Pokemon adds Set #).
appendExtraRows(grid);
amountCtrl_ = new wxSpinCtrl(this, wxID_ANY, "", wxDefaultPosition,
wxDefaultSize, wxSP_ARROW_KEYS, 1, 255, card_.amount);
appendRow(grid, "Amount", amountCtrl_);
languageChoice_ = new wxChoice(this, wxID_ANY);
appendRow(grid, "Language", languageChoice_);
conditionChoice_ = new wxChoice(this, wxID_ANY);
appendRow(grid, "Condition", conditionChoice_);
noteCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.note.c_str()),
wxDefaultPosition, wxSize(-1, 60), wxTE_MULTILINE);
appendRow(grid, "Note", noteCtrl_);
auto* flagsBox = new wxBoxSizer(wxHORIZONTAL);
buildFlagsRow(flagsBox);
grid->Add(new wxStaticText(this, wxID_ANY, "Flags"),
0, wxALIGN_CENTER_VERTICAL);
grid->Add(flagsBox, 1, wxEXPAND);
root->Add(grid, 0, wxALL | wxEXPAND, 10);
auto* imgBox = new wxStaticBoxSizer(wxVERTICAL, this, "Images");
imagesList_ = new wxListBox(this, wxID_ANY);
for (const auto& name : card_.images) imagesList_->Append(wxString::FromUTF8(name.c_str()));
imgBox->Add(imagesList_, 1, wxEXPAND | wxALL, 4);
auto* imgButtons = new wxBoxSizer(wxHORIZONTAL);
auto* addBtn = new wxButton(this, wxID_ANY, "Add image...");
auto* rmBtn = new wxButton(this, wxID_ANY, "Remove image");
imgButtons->Add(addBtn, 0, wxRIGHT, 6);
imgButtons->Add(rmBtn, 0);
imgBox->Add(imgButtons, 0, wxALL, 4);
root->Add(imgBox, 1, wxEXPAND | wxLEFT | wxRIGHT, 10);
addBtn->Bind(wxEVT_BUTTON, &BaseCardEditDialog::onAddImage, this);
rmBtn->Bind (wxEVT_BUTTON, &BaseCardEditDialog::onRemoveImage, this);
imagesList_->Bind(wxEVT_LISTBOX_DCLICK, &BaseCardEditDialog::onImageActivated, this);
auto* btns = CreateButtonSizer(wxOK | wxCANCEL);
if (btns) {
root->Add(btns, 0, wxLEFT | wxTOP | wxRIGHT | wxEXPAND, 10);
root->AddSpacer(24);
}
Bind(wxEVT_BUTTON, &BaseCardEditDialog::onOk, this, wxID_OK);
setCombo_->Bind(wxEVT_CHAR, &BaseCardEditDialog::onSetComboChar, this);
setCombo_->Bind(wxEVT_KILL_FOCUS, &BaseCardEditDialog::onSetComboKillFocus, this);
SetSizer(root);
readExtraFromCard();
CallAfter([this]() {
if (nameCtrl_) {
nameCtrl_->SetInsertionPoint(0);
nameCtrl_->ShowPosition(0);
}
if (noteCtrl_) {
noteCtrl_->SetInsertionPoint(0);
noteCtrl_->ShowPosition(0);
}
});
}
void populateChoices() {
const auto& available = availableSets();
setTypeaheadPrefix_.clear();
setCombo_->Clear();
auto lowerAscii = [](std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
return s;
};
const std::string selectedSetId = lowerAscii(card_.set.id);
int selectIdx = wxNOT_FOUND;
wxArrayString setNames;
setNames.Alloc(available.size());
for (std::size_t i = 0; i < available.size(); ++i) {
setNames.Add(wxString::FromUTF8(available[i].name.c_str()));
if (!selectedSetId.empty() && lowerAscii(available[i].id) == selectedSetId) {
selectIdx = static_cast<int>(i);
}
}
if (!setNames.empty()) {
setCombo_->Append(setNames);
}
if (selectIdx == wxNOT_FOUND && !available.empty()) selectIdx = 0;
if (selectIdx != wxNOT_FOUND) setCombo_->SetSelection(selectIdx);
if (available.empty()) {
setCombo_->Append(emptySetMessage());
setCombo_->SetSelection(0);
setCombo_->Disable();
}
languageChoice_->Clear();
int langIdx = 0;
int i = 0;
wxArrayString langs;
langs.Alloc(allLanguages().size());
for (auto l : allLanguages()) {
const std::string lang = std::string(to_string(l));
langs.Add(wxString::FromUTF8(lang.c_str()));
if (l == card_.language) langIdx = i;
++i;
}
if (!langs.empty()) {
languageChoice_->Append(langs);
}
languageChoice_->SetSelection(langIdx);
conditionChoice_->Clear();
int condIdx = 0;
i = 0;
wxArrayString conditions;
conditions.Alloc(allConditions().size());
for (auto c : allConditions()) {
const std::string cond = std::string(to_string(c));
conditions.Add(wxString::FromUTF8(cond.c_str()));
if (c == card_.condition) condIdx = i;
++i;
}
if (!conditions.empty()) {
conditionChoice_->Append(conditions);
}
conditionChoice_->SetSelection(condIdx);
}
void writeFromControls() {
const auto& available = availableSets();
card_.name = nameCtrl_->GetValue().ToStdString(wxConvUTF8);
card_.amount = static_cast<std::uint8_t>(amountCtrl_->GetValue());
card_.note = noteCtrl_->GetValue().ToStdString(wxConvUTF8);
if (!available.empty() && setCombo_->IsEnabled()) {
const int sel = setCombo_->GetSelection();
if (sel >= 0 && static_cast<std::size_t>(sel) < available.size()) {
card_.set = available[static_cast<std::size_t>(sel)];
}
}
if (auto l = languageFromString(languageChoice_->GetStringSelection().ToStdString(wxConvUTF8))) {
card_.language = *l;
}
if (auto c = conditionFromString(conditionChoice_->GetStringSelection().ToStdString(wxConvUTF8))) {
card_.condition = *c;
}
writeExtraToCard();
}
void onAddImage(wxCommandEvent&) {
wxFileDialog dlg(this, "Choose image(s)",
wxEmptyString, wxEmptyString,
"Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
wxFD_OPEN | wxFD_FILE_MUST_EXIST | wxFD_MULTIPLE);
if (dlg.ShowModal() != wxID_OK) return;
writeFromControls();
if (card_.name.empty() || card_.set.id.empty()) {
showThemedMessageDialog(this, "Set the card name and set before adding images.",
"Add image", wxOK | wxICON_INFORMATION);
return;
}
wxArrayString paths;
dlg.GetPaths(paths);
std::vector<std::string> failed;
failed.reserve(static_cast<std::size_t>(paths.size()));
for (const auto& path : paths) {
auto added = imageService_.addImage(game_,
std::filesystem::path(path.ToStdString()),
mode_ == EditMode::Create,
card_.id,
card_.set.name,
card_.name,
card_.images);
if (!added) {
failed.push_back(path.ToStdString() + " (" + added.error() + ")");
continue;
}
card_.images.push_back(added.value());
imagesList_->Append(added.value());
}
if (!failed.empty()) {
std::string msg = "Some images could not be added:\n\n";
for (const auto& err : failed) {
msg += "- " + err + '\n';
}
showThemedMessageDialog(this, msg, "Add image", wxOK | wxICON_WARNING);
}
}
void onRemoveImage(wxCommandEvent&) {
const int sel = imagesList_->GetSelection();
if (sel == wxNOT_FOUND) return;
const std::string name = imagesList_->GetString(sel).ToStdString(wxConvUTF8);
auto rm = imageService_.removeImage(game_, name);
if (!rm) {
showThemedMessageDialog(this, "Failed to remove image: " + rm.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
card_.images.erase(card_.images.begin() + sel);
imagesList_->Delete(static_cast<unsigned int>(sel));
}
void onImageActivated(wxCommandEvent& event) {
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(game_, 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();
}
void onOk(wxCommandEvent& ev) {
writeFromControls();
if (card_.name.empty()) {
showThemedMessageDialog(this, "Name is required.", "Add card",
wxOK | wxICON_INFORMATION);
return;
}
if (card_.set.id.empty()) {
showThemedMessageDialog(this, "Pick a set first (use Sets > " + updateMenuName() + " if the list is empty).",
"Add card", wxOK | wxICON_INFORMATION);
return;
}
ev.Skip();
}
[[nodiscard]] bool setComboTypingSurfaceActive() const {
wxWindow* focus = wxWindow::FindFocus();
if (!setCombo_) return false;
auto enclosedBy = [](wxWindow* root, wxWindow* leaf) -> bool {
if (!root || !leaf) return false;
for (wxWindow* w = leaf; w != nullptr; w = w->GetParent()) {
if (w == root) return true;
}
return false;
};
if (enclosedBy(setCombo_, focus)) return true;
#ifdef __WXMSW__
static constexpr UINT kCbGetDroppedState = 0x0157; // CB_GETDROPPEDSTATE
WXHWND wxh = setCombo_->GetHandle();
const HWND h = reinterpret_cast<HWND>(wxh);
return h != nullptr && ::SendMessageW(h, kCbGetDroppedState, 0, 0) != 0;
#else
return false;
#endif
}
void applySetTypeaheadSelection() {
const auto& available = availableSets();
if (!setCombo_ || available.empty()) return;
wxString pref = setTypeaheadPrefix_;
pref.MakeLower();
if (pref.empty()) return;
for (std::size_t i = 0; i < available.size(); ++i) {
wxString name(wxString::FromUTF8(available[i].name));
name.MakeLower();
if (name.StartsWith(pref)) {
setCombo_->SetSelection(static_cast<int>(i));
return;
}
}
}
void onSetComboChar(wxKeyEvent& ev) {
if (!setCombo_->IsEnabled() || availableSets().empty()) {
ev.Skip();
return;
}
const int mods = ev.GetModifiers();
if ((mods & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_META)) != 0) {
ev.Skip();
return;
}
const auto now = std::chrono::steady_clock::now();
if (!setTypeaheadPrefix_.empty() &&
now - setTypeaheadLastKey_ > kSetTypeaheadResetMs) {
setTypeaheadPrefix_.clear();
}
setTypeaheadLastKey_ = now;
const int code = ev.GetKeyCode();
if (code == WXK_BACK) {
if (!setTypeaheadPrefix_.empty())
setTypeaheadPrefix_.RemoveLast();
applySetTypeaheadSelection();
ev.Skip(false);
return;
}
if (code == WXK_TAB || code == WXK_RETURN || code == WXK_ESCAPE ||
code == WXK_UP || code == WXK_DOWN || code == WXK_LEFT || code == WXK_RIGHT ||
code == WXK_HOME || code == WXK_END || code == WXK_PAGEUP || code == WXK_PAGEDOWN ||
code == WXK_NUMPAD_ENTER || code == WXK_INSERT || code == WXK_DELETE ||
code == WXK_F4 || (code >= WXK_F1 && code <= WXK_F24)) {
ev.Skip();
return;
}
wxChar uc = static_cast<wxChar>(ev.GetUnicodeKey());
if (uc == WXK_NONE && code == WXK_SPACE)
uc = wxT(' ');
if (uc == WXK_NONE && code >= 32 && code < 127)
uc = static_cast<wxChar>(code);
if (uc == WXK_NONE || static_cast<unsigned>(uc) < 32u) {
ev.Skip();
return;
}
wxString chunk(uc);
chunk.MakeLower();
setTypeaheadPrefix_ += chunk;
applySetTypeaheadSelection();
ev.Skip(false);
}
void onSetComboKillFocus(wxFocusEvent& ev) {
if (!setComboTypingSurfaceActive()) {
setTypeaheadPrefix_.clear();
}
ev.Skip();
}
ImageService& imageService_;
SetService& setService_;
EditMode mode_;
TCard card_;
Game game_;
std::vector<Set> sets_;
const std::vector<Set>* preloadedSets_{nullptr};
wxTextCtrl* nameCtrl_{nullptr};
wxComboBox* setCombo_{nullptr};
wxSpinCtrl* amountCtrl_{nullptr};
wxChoice* languageChoice_{nullptr};
wxChoice* conditionChoice_{nullptr};
wxTextCtrl* noteCtrl_{nullptr};
wxListBox* imagesList_{nullptr};
wxString setTypeaheadPrefix_;
std::chrono::steady_clock::time_point setTypeaheadLastKey_{};
static constexpr std::chrono::milliseconds kSetTypeaheadResetMs{1000};
};
} // namespace ccm::ui
+681
View File
@@ -0,0 +1,681 @@
#pragma once
// BaseCardListPanel<TCard, TSortColumn>
//
// Header-only template that owns ALL the non-game-specific machinery for the
// `wxListCtrl`-backed card table:
//
// - hidden zero-width spacer column (MSW comctl32 image-list gutter
// workaround; see `ui_wx/AGENTS.md` for the rationale)
// - app-owned themed header row (clickable to sort, edge-drag to resize,
// divider double-click to autosize) - native `wxListCtrl` header is
// unreliable in Windows dark mode
// - custom-drawn flag-icon sub-items via `IconListCtrl` so row icons sit
// pixel-perfect centered under the themed-header icons regardless of
// column width (native `LVS_REPORT` sub-item images left-anchor with an
// inset and would never align with our centered header icons)
// - rebuild guard so DESELECTED/SELECTED storms during rebuild collapse
// into a single bubbled `EVT_CARD_SELECTED` event
// - case-insensitive substring filter via `setFilter(...)` and per-column
// toggle-direction sort via the header click
//
// Game-specific behavior is exposed as virtual hooks the derived class fills
// in (template method pattern):
//
// declareTextColumns() -> spec list (label, width, format) for the leading
// "value-key" columns and the trailing Note column
// declareIconColumns() -> spec list (svg, width, sortColumn) for icon-only
// flag columns (foil/signed/altered, holo, ...)
// renderTextCell(card, idx) -> cell string for text column `idx`
// isIconColumnSet(card, idx) -> whether the n-th icon column shows for this card
// sortColumnForListIdx(col) -> map physical wxListCtrl column to sort key
// sortBy(col, asc) -> in-place stable sort of `cards_`
// matchesFilter(card, f) -> case-insensitive row matcher
//
// New games extend this template — see `MagicCardListPanel` and
// `PokemonCardListPanel` for the canonical patterns.
#include "ccm/ui/IconListCtrl.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/bitmap.h>
#include <wx/colour.h>
#include <wx/cursor.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/listctrl.h>
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/statbmp.h>
#include <wx/stattext.h>
#include <wx/utils.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
namespace ccm::ui {
// Single shared selection-changed event. The base panel raises this on the
// parent every time the active card changes (after a rebuild settles, after a
// user click, etc.). Defined once in `BaseEvents.cpp` so the wxEvent table is
// not duplicated per template instantiation.
wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
template <typename TCard, typename TSortColumn>
class BaseCardListPanel : public wxPanel {
public:
using card_type = TCard;
using sort_column_type = TSortColumn;
// Replace the displayed rows. Selection is reset (the panel will pick
// the first row on the next idle turn — see rebuildRows()).
void setCards(std::vector<TCard> cards) {
cards_ = std::move(cards);
// Drop sort state when the underlying data is replaced - the indicator
// shown in the header should match the order actually rendered, and
// wxListCtrl keeps the indicator across DeleteAllItems().
nextDirByCol_.clear();
list_->RemoveSortIndicator();
rebuildRows();
if (!autoSizedOnce_ && !cards_.empty()) {
autoSizeAllColumns();
autoSizedOnce_ = true;
}
}
// Update the filter string and rebuild the visible rows in place. The
// panel preserves the previously-selected card across the rebuild when
// it still matches the new filter; otherwise the first remaining row is
// selected, or none if the filter excluded everything. A single
// EVT_CARD_SELECTED is emitted afterwards so the parent re-syncs.
void setFilter(std::string_view filter) {
if (filter_ == filter) return;
filter_.assign(filter);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
}
void applyTheme(const ThemePalette& palette) {
list_->SetBackgroundColour(palette.inputBg);
list_->SetForegroundColour(palette.inputText);
SetBackgroundColour(palette.panelBg);
SetForegroundColour(palette.text);
rebuildIconBitmaps(palette.inputText, wxColour(255, 255, 255));
refreshHeaderTheme(palette);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
Refresh();
}
[[nodiscard]] const std::vector<TCard>& cards() const noexcept { return cards_; }
[[nodiscard]] const std::string& filter() const noexcept { return filter_; }
[[nodiscard]] std::optional<TCard> selected() const {
const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (const TCard* c = cardForRow(sel)) return *c;
return std::nullopt;
}
// Ensure the selected row is actively focused so Windows uses the active
// highlight color (blue in light mode), keeping selected-row icons legible.
void activateSelection() {
if (list_ == nullptr || list_->GetItemCount() <= 0) return;
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (row < 0) row = 0;
list_->SetItemState(row,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(row);
list_->SetFocus();
}
protected:
// Column descriptor types -------------------------------------------------
struct TextColumnSpec {
std::string label;
int width;
wxListColumnFormat format; // wxLIST_FORMAT_LEFT / RIGHT / CENTER
std::optional<TSortColumn> sortColumn; // none = not sortable
};
struct IconColumnSpec {
const char* svg;
int width;
std::optional<TSortColumn> sortColumn;
};
// Subclass hooks ----------------------------------------------------------
// Subclass declares its leading text columns (Name, Set, ...). Order
// matches the on-screen left-to-right ordering. The trailing "Note" column
// is also returned here as the last entry — it is added AFTER the icon
// columns by the base.
[[nodiscard]] virtual std::vector<TextColumnSpec> declareTextColumns() const = 0;
// Subclass declares the icon flag columns (Foil/Signed/Altered, etc.).
// These render between the leading text columns and the trailing Note.
[[nodiscard]] virtual std::vector<IconColumnSpec> declareIconColumns() const = 0;
[[nodiscard]] virtual std::string renderTextCell(const TCard& card, std::size_t idx) const = 0;
[[nodiscard]] virtual bool isIconColumnSet(const TCard& card, std::size_t idx) const = 0;
virtual void sortBy(TSortColumn column, bool ascending) = 0;
[[nodiscard]] virtual bool matchesFilter(const TCard& card, std::string_view filter) const = 0;
// Construction ------------------------------------------------------------
explicit BaseCardListPanel(wxWindow* parent) : wxPanel(parent, wxID_ANY) {}
// Subclass calls this once from its constructor body (after virtual hooks
// are reachable) to wire up columns + the header row + custom-draw hooks.
void buildLayout() {
list_ = new IconListCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
textCols_ = declareTextColumns();
iconCols_ = declareIconColumns();
// Note must be the *last* text column. We render it after the icons.
// Layout: [hidden spacer] [textCols_-1 leading text cols] [icon cols] [last text col].
if (textCols_.empty()) {
// No text columns at all is unsupported; the trailing note column
// is required by the panel layout.
textCols_.push_back({"Note", 220, wxLIST_FORMAT_LEFT, std::nullopt});
}
buildHeaderRow();
// Column 0 is a hidden spacer kept for historical reasons (it used
// to swallow MSW's mandatory item-icon gutter when we had an image
// list). It is harmless now that row icons go through NM_CUSTOMDRAW
// and is preserved so existing column-index math stays correct.
list_->AppendColumn("", wxLIST_FORMAT_LEFT, 0);
// Leading text columns (everything except the last).
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
list_->AppendColumn(textCols_[i].label, textCols_[i].format, textCols_[i].width);
}
// Icon columns. Format is irrelevant here — we paint the icon
// ourselves, exactly centered, in `IconListCtrl::MSWOnNotify`.
for (const auto& ic : iconCols_) {
list_->AppendColumn("", wxLIST_FORMAT_CENTER, ic.width);
}
rebuildIconBitmaps(wxColour(20, 20, 20), wxColour(255, 255, 255));
// Trailing Note column.
const auto& last = textCols_.back();
list_->AppendColumn(last.label, last.format, last.width);
// Wire NM_CUSTOMDRAW callbacks so row icons render centered in their
// sub-item rect. The predicate maps a (row, iconIdx) back through the
// filtered card vector so we ask the same `isIconColumnSet(...)` hook
// the rest of the panel uses. The bitmap cache was already pushed
// into `list_` by `rebuildIconBitmaps(...)` above.
list_->setIconColumns(firstIconColIdx(), iconColCount());
list_->setIconPredicate([this](long row, int iconIdx) {
const TCard* c = cardForRow(row);
if (c == nullptr) return false;
if (iconIdx < 0 || static_cast<std::size_t>(iconIdx) >= iconCols_.size()) {
return false;
}
return isIconColumnSet(*c, static_cast<std::size_t>(iconIdx));
});
auto* sizer = new wxBoxSizer(wxVERTICAL);
sizer->Add(headerRow_, 0, wxEXPAND);
sizer->Add(list_, 1, wxEXPAND);
SetSizer(sizer);
list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this);
}
// Forwarded helpers ------------------------------------------------------
// wxListCtrl column indices for derived helpers.
[[nodiscard]] int firstTextColIdx() const noexcept { return 1; }
[[nodiscard]] int firstIconColIdx() const noexcept {
return 1 + static_cast<int>(textCols_.size()) - 1;
}
[[nodiscard]] int noteColIdx() const noexcept {
return firstIconColIdx() + static_cast<int>(iconCols_.size());
}
[[nodiscard]] int textColCount() const noexcept {
return static_cast<int>(textCols_.size());
}
[[nodiscard]] int iconColCount() const noexcept {
return static_cast<int>(iconCols_.size());
}
[[nodiscard]] wxListCtrl* listCtrl() const noexcept { return list_; }
// Mutable access to the underlying vector for the typed `sortBy` hook
// (the sort runs in-place on the same vector the base owns, so we can't
// hand the subclass a copy).
[[nodiscard]] std::vector<TCard>& mutableCards() noexcept { return cards_; }
private:
// ----- header row construction -------------------------------------------
void buildHeaderRow() {
headerRow_ = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE);
auto* s = new wxBoxSizer(wxHORIZONTAL);
headerCells_.clear();
headerCellToCol_.clear();
headerIcons_.clear();
auto bindHeaderEvents = [this](wxWindow* hit, int col) {
hit->Bind(wxEVT_LEFT_DOWN, [this, col](wxMouseEvent& ev) { onHeaderMouseDown(col, ev); });
hit->Bind(wxEVT_MOTION, [this, col](wxMouseEvent& ev) { onHeaderMouseMove(col, ev); });
hit->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& ev) { onHeaderMouseUp(ev); });
hit->Bind(wxEVT_LEFT_DCLICK,
[this, col](wxMouseEvent& ev) { onHeaderDoubleClick(col, ev); });
};
auto addText = [&](const wxString& label, int width, int col) {
auto* p = new wxPanel(headerRow_, wxID_ANY, wxDefaultPosition, wxSize(width, -1), wxBORDER_NONE);
auto* ps = new wxBoxSizer(wxHORIZONTAL);
auto* t = new wxStaticText(p, wxID_ANY, label);
ps->Add(t, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 4);
p->SetSizer(ps);
p->SetMinSize(wxSize(width, -1));
bindHeaderEvents(p, col);
bindHeaderEvents(t, col);
s->Add(p, 0, wxEXPAND);
headerCells_.push_back(p);
headerCellToCol_[p] = col;
};
auto addIcon = [&](const char* svg, int width, int col) {
auto* p = new wxPanel(headerRow_, wxID_ANY, wxDefaultPosition, wxSize(width, -1), wxBORDER_NONE);
auto* ps = new wxBoxSizer(wxHORIZONTAL);
auto bmp = svgIconBitmap(svg, kFlagIconSize, "#E6E6E6");
auto* b = new wxStaticBitmap(p, wxID_ANY, bmp);
ps->AddStretchSpacer(1);
ps->Add(b, 0, wxALIGN_CENTER_VERTICAL);
ps->AddStretchSpacer(1);
p->SetSizer(ps);
p->SetMinSize(wxSize(width, -1));
bindHeaderEvents(p, col);
bindHeaderEvents(b, col);
s->Add(p, 0, wxEXPAND);
headerCells_.push_back(p);
headerCellToCol_[p] = col;
headerIcons_.push_back({b, svg});
};
const int firstText = firstTextColIdx();
// Leading text columns.
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
addText(textCols_[i].label, textCols_[i].width, firstText + static_cast<int>(i));
}
const int firstIcon = firstIconColIdx();
for (std::size_t i = 0; i < iconCols_.size(); ++i) {
addIcon(iconCols_[i].svg, iconCols_[i].width, firstIcon + static_cast<int>(i));
}
const int noteCol = noteColIdx();
addText(textCols_.back().label, textCols_.back().width, noteCol);
headerRow_->SetSizer(s);
}
// ----- header drag-resize / sort hit-test ---------------------------------
[[nodiscard]] bool isResizeGripHit(int col, int x) const {
const int firstText = firstTextColIdx();
if (col < firstText || col > noteColIdx()) return false;
const std::size_t idx = static_cast<std::size_t>(col - firstText);
if (idx >= headerCells_.size() || headerCells_[idx] == nullptr) return false;
const int w = headerCells_[idx]->GetSize().GetWidth();
return x >= (w - kResizeGripPx);
}
void setColumnWidth(int col, int width) {
// Icon columns get a tighter min so they don't grow when dragged.
const int firstIcon = firstIconColIdx();
const int lastIcon = firstIcon + iconColCount() - 1;
const int minWidth = (col >= firstIcon && col <= lastIcon) ? 24 : 40;
const int nextWidth = std::max(minWidth, width);
list_->SetColumnWidth(col, nextWidth);
const std::size_t idx = static_cast<std::size_t>(col - firstTextColIdx());
if (idx < headerCells_.size() && headerCells_[idx] != nullptr) {
headerCells_[idx]->SetMinSize(wxSize(nextWidth, -1));
}
// Row icons are drawn from the live sub-item rect via NM_CUSTOMDRAW,
// so column resizing automatically re-centers them on the next paint
// — no image-list rebuild needed.
headerRow_->Layout();
}
void autoSizeColumn(int col) {
list_->SetColumnWidth(col, wxLIST_AUTOSIZE);
const int contentWidth = list_->GetColumnWidth(col);
list_->SetColumnWidth(col, wxLIST_AUTOSIZE_USEHEADER);
const int headerWidth = list_->GetColumnWidth(col);
setColumnWidth(col, std::max(contentWidth, headerWidth));
}
void autoSizeAllColumns() {
for (int col = firstTextColIdx(); col <= noteColIdx(); ++col) {
autoSizeColumn(col);
}
}
void onHeaderMouseDown(int col, wxMouseEvent& ev) {
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell == nullptr) return;
const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
if (!isResizeGripHit(col, posInCell.x)) return;
resizingCol_ = true;
activeResizeCol_ = col;
resizeStartScreenX_ = wxGetMousePosition().x;
resizeStartWidth_ = list_->GetColumnWidth(col);
cell->CaptureMouse();
}
void onHeaderMouseMove(int col, wxMouseEvent& ev) {
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell == nullptr) return;
if (resizingCol_ && activeResizeCol_ == col && cell->HasCapture()) {
const int delta = wxGetMousePosition().x - resizeStartScreenX_;
setColumnWidth(col, resizeStartWidth_ + delta);
return;
}
const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
cell->SetCursor(isResizeGripHit(col, posInCell.x)
? wxCursor(wxCURSOR_SIZEWE)
: wxCursor(wxCURSOR_ARROW));
}
void onHeaderMouseUp(wxMouseEvent& ev) {
const bool wasResizing = resizingCol_;
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell != nullptr && cell->HasCapture()) {
cell->ReleaseMouse();
}
resizingCol_ = false;
if (suppressNextHeaderClick_) {
suppressNextHeaderClick_ = false;
activeResizeCol_ = -1;
return;
}
if (!wasResizing && cell != nullptr) {
auto it = headerCellToCol_.find(cell);
if (it != headerCellToCol_.end()) {
onHeaderClick(it->second);
}
}
activeResizeCol_ = -1;
}
void onHeaderDoubleClick(int col, wxMouseEvent& ev) {
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell == nullptr) return;
const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
if (isResizeGripHit(col, posInCell.x)) {
suppressNextHeaderClick_ = true;
autoSizeColumn(col);
}
}
// Map a physical wxListCtrl column to a sort column. Looks at the
// declared TextColumnSpec/IconColumnSpec lists to find the optional
// `sortColumn` for each column. Returns nullopt for non-sortable columns
// (the spacer column 0 or any text/icon column without a sort key).
[[nodiscard]] std::optional<TSortColumn> sortColumnForListIdx(int listColIdx) const {
if (listColIdx <= 0) return std::nullopt;
const int firstIcon = firstIconColIdx();
const int noteCol = noteColIdx();
if (listColIdx < firstIcon) {
const std::size_t i = static_cast<std::size_t>(listColIdx - firstTextColIdx());
if (i < textCols_.size() - 1) return textCols_[i].sortColumn;
} else if (listColIdx < noteCol) {
const std::size_t i = static_cast<std::size_t>(listColIdx - firstIcon);
if (i < iconCols_.size()) return iconCols_[i].sortColumn;
} else if (listColIdx == noteCol) {
return textCols_.back().sortColumn;
}
return std::nullopt;
}
void onHeaderClick(int col) {
if (resizingCol_) return;
const auto sortCol = sortColumnForListIdx(col);
if (!sortCol) return;
// Per-column toggle, faithful to TableTemplate.tsx::sortByField.
auto it = nextDirByCol_.find(*sortCol);
const bool ascending = (it == nextDirByCol_.end()) ? true : it->second;
nextDirByCol_[*sortCol] = !ascending;
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
sortBy(*sortCol, ascending);
rebuildRows(keepId);
}
// ----- cached icon bitmaps for NM_CUSTOMDRAW -----------------------------
// Pre-renders the per-icon-column bitmaps used by the custom-draw path in
// `IconListCtrl`. Two color variants per column: the `normal` color for
// unselected rows (paired with the panel's themed text color) and the
// `selected` color drawn on the highlighted row. After rebuilding, the
// bitmaps are pushed into `IconListCtrl` which converts them into a
// single `HIMAGELIST` for `ImageList_Draw` from `NM_CUSTOMDRAW`. See
// `ui_wx/AGENTS.md` convention 11 for why earlier `wxGraphicsContext::
// DrawBitmap` and raw `AlphaBlend` paths were abandoned.
void rebuildIconBitmaps(const wxColour& normal, const wxColour& selected) {
iconBitmapsNormal_.clear();
iconBitmapsSelected_.clear();
iconBitmapsNormal_.reserve(iconCols_.size());
iconBitmapsSelected_.reserve(iconCols_.size());
const std::string normalHex = normal.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
const std::string selectedHex = selected.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
for (const auto& ic : iconCols_) {
iconBitmapsNormal_.push_back(
svgIconBitmap(ic.svg, kFlagIconSize, normalHex.c_str()));
iconBitmapsSelected_.push_back(
svgIconBitmap(ic.svg, kFlagIconSize, selectedHex.c_str()));
}
if (list_ != nullptr) {
list_->setIconBitmaps(iconBitmapsNormal_, iconBitmapsSelected_);
}
}
void refreshHeaderTheme(const ThemePalette& palette) {
headerRow_->SetBackgroundColour(palette.inputBg);
headerRow_->SetForegroundColour(palette.inputText);
headerRow_->SetOwnBackgroundColour(palette.inputBg);
headerRow_->SetOwnForegroundColour(palette.inputText);
for (wxWindow* cell : headerCells_) {
if (cell == nullptr) continue;
cell->SetBackgroundColour(palette.inputBg);
cell->SetForegroundColour(palette.inputText);
cell->SetOwnBackgroundColour(palette.inputBg);
cell->SetOwnForegroundColour(palette.inputText);
const wxWindowList& children = cell->GetChildren();
for (wxWindowList::compatibility_iterator it = children.GetFirst(); it; it = it->GetNext()) {
wxWindow* child = it->GetData();
if (child == nullptr) continue;
child->SetBackgroundColour(palette.inputBg);
child->SetForegroundColour(palette.inputText);
child->SetOwnBackgroundColour(palette.inputBg);
child->SetOwnForegroundColour(palette.inputText);
}
}
const std::string iconHex = palette.inputText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
for (auto& it : headerIcons_) {
if (it.first == nullptr || it.second == nullptr) continue;
it.first->SetBitmap(svgIconBitmap(it.second, kFlagIconSize, iconHex.c_str()));
}
headerRow_->Refresh();
}
// ----- row rendering -----------------------------------------------------
void rebuildRows(std::optional<std::uint32_t> keepId = std::nullopt) {
// Suppress wxListCtrl's natural DESELECTED (from DeleteAllItems) and
// SELECTED (from the SetItemState below) events while we churn through
// the rebuild. See `ui_wx/AGENTS.md` for the rate-limit rationale.
inRebuild_ = true;
list_->DeleteAllItems();
filteredIndices_.clear();
filteredIndices_.reserve(cards_.size());
for (std::size_t i = 0; i < cards_.size(); ++i) {
if (matchesFilter(cards_[i], filter_)) {
filteredIndices_.push_back(i);
}
}
long row = 0;
long rowToSelect = -1;
const int firstText = firstTextColIdx();
const int noteCol = noteColIdx();
for (std::size_t srcIdx : filteredIndices_) {
const auto& c = cards_[srcIdx];
// Insert via the hidden column-0 spacer. We never set sub-item
// images: row icons are drawn through `IconListCtrl` custom-draw
// straight onto the device context, exactly centered in the cell.
wxListItem spacerItem;
spacerItem.SetId(row);
spacerItem.SetText("");
spacerItem.SetImage(-1);
spacerItem.SetMask(wxLIST_MASK_TEXT | wxLIST_MASK_IMAGE);
const long idx = list_->InsertItem(spacerItem);
// Leading text columns.
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
const std::string cell = renderTextCell(c, i);
list_->SetItem(idx, firstText + static_cast<int>(i),
wxString::FromUTF8(cell.c_str()));
}
// Trailing Note text column. Icon columns intentionally have no
// text and no image — the custom-draw paints them.
const std::string note = renderTextCell(c, textCols_.size() - 1);
list_->SetItem(idx, noteCol, wxString::FromUTF8(note.c_str()));
if (keepId && c.id == *keepId) rowToSelect = idx;
++row;
}
bool deferredInitialSelect = false;
if (!filteredIndices_.empty() && rowToSelect >= 0) {
list_->SetItemState(rowToSelect,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(rowToSelect);
} else if (!filteredIndices_.empty() && !keepId.has_value()) {
// Defer the initial selection to the next event turn so first
// paint stays responsive.
deferredInitialSelect = true;
CallAfter([this]() {
if (list_ == nullptr || list_->GetItemCount() <= 0) return;
const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (sel >= 0) return;
list_->SetItemState(0,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(0);
});
}
inRebuild_ = false;
if (!deferredInitialSelect) {
notifySelectionChanged();
}
}
void notifySelectionChanged() {
// Fires the event on the panel itself. The owning IGameView binds
// directly to its typed list panel so the typed selection wiring stays
// local (MainFrame only sees IGameView, never MagicCard / PokemonCard).
wxCommandEvent ev(EVT_CARD_SELECTED, GetId());
ev.SetEventObject(this);
ProcessWindowEvent(ev);
}
[[nodiscard]] const TCard* cardForRow(long row) const noexcept {
if (row < 0) return nullptr;
const auto r = static_cast<std::size_t>(row);
if (r >= filteredIndices_.size()) return nullptr;
const std::size_t srcIdx = filteredIndices_[r];
if (srcIdx >= cards_.size()) return nullptr;
return &cards_[srcIdx];
}
void onSelectionChanged(wxListEvent& event) {
// wxListCtrl invalidates the row when its selection state changes,
// which re-fires NM_CUSTOMDRAW with the new `CDIS_SELECTED` flag.
// The icon bitmap provider returns the selected-color variant, so
// no per-row icon swap is required here.
(void)event;
if (inRebuild_) return;
notifySelectionChanged();
}
// ----- members ----------------------------------------------------------
static constexpr int kFlagIconSize = 14;
static constexpr int kResizeGripPx = 5;
wxPanel* headerRow_{nullptr};
std::vector<wxWindow*> headerCells_;
std::vector<std::pair<wxStaticBitmap*, const char*>> headerIcons_;
std::unordered_map<wxWindow*, int> headerCellToCol_;
IconListCtrl* list_{nullptr};
std::vector<TextColumnSpec> textCols_;
std::vector<IconColumnSpec> iconCols_;
bool resizingCol_{false};
bool suppressNextHeaderClick_{false};
bool autoSizedOnce_{false};
int activeResizeCol_{-1};
int resizeStartScreenX_{0};
int resizeStartWidth_{0};
std::vector<TCard> cards_;
std::vector<std::size_t> filteredIndices_;
std::string filter_;
// Rebuild guard - see ui_wx/AGENTS.md for the burst-suppression rationale.
bool inRebuild_{false};
std::map<TSortColumn, bool> nextDirByCol_;
// Per-icon-column cached bitmaps consumed by `IconListCtrl`'s NM_CUSTOMDRAW
// path. Index aligns with `iconCols_`.
std::vector<wxBitmap> iconBitmapsNormal_;
std::vector<wxBitmap> iconBitmapsSelected_;
};
} // namespace ccm::ui
@@ -0,0 +1,500 @@
#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/image.h>
#include <wx/listbox.h>
#include <wx/mstream.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 <memory>
#include <optional>
#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;
// Construction ------------------------------------------------------------
BaseSelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview)
: wxPanel(parent, wxID_ANY),
imageService_(imageService),
cardPreview_(cardPreview),
state_(std::make_shared<PreviewState>()) {
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";
default:
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 = gameId();
std::thread([state, gen, svcPtr, name = std::move(name),
setId = std::move(setId), setNo = std::move(setNo), game]() {
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();
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();
}
}
}
}
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;
if (!img.LoadFile(stream, wxBITMAP_TYPE_ANY)) {
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::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
+63
View File
@@ -0,0 +1,63 @@
#pragma once
// IGameView: per-game UI bundle that `MainFrame` swaps in/out when the user
// switches games. Each implementation owns its typed list panel + selected
// panel + Add/Edit/Delete dialogs and the cached set list. Common services
// (config, sets, images, card preview) come from the shared `AppContext`,
// so a new game implementation does not need its own copy of any of them.
//
// New games extend this interface — see `MagicGameView` and
// `PokemonGameView` for the canonical patterns.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/ui/Theme.hpp"
#include <string>
#include <string_view>
#include <vector>
class wxPanel;
class wxWindow;
namespace ccm::ui {
class IGameView {
public:
virtual ~IGameView() = default;
[[nodiscard]] virtual Game gameId() const noexcept = 0;
[[nodiscard]] virtual std::string displayName() const = 0;
// The two panels owned by this view. They are constructed lazily — the
// first call must accept `parent` so the panels become children of the
// splitter. Subsequent calls return the cached pointers.
virtual wxPanel* listPanel(wxWindow* parent) = 0;
virtual wxPanel* selectedPanel(wxWindow* parent) = 0;
// Reload the active collection from disk and refresh the panels. The
// selected card is preserved when possible.
virtual void refreshCollection() = 0;
// Toolbar actions. `parentWindow` is the dialog owner for any modal we
// open (typically the `MainFrame`).
virtual void onAddCard(wxWindow* parentWindow) = 0;
virtual void onEditCard(wxWindow* parentWindow) = 0;
virtual void onDeleteCard(wxWindow* parentWindow) = 0;
// Sets menu action ("Update Magic" / "Update Pokemon"). Returns the
// user-visible status string for the parent's status bar.
virtual std::string onUpdateSets(wxWindow* parentWindow) = 0;
// Forwarded by `MainFrame` whenever the filter input changes.
virtual void setFilter(std::string_view filter) = 0;
// Apply the active palette to all panels owned by this view.
virtual void applyTheme(const ThemePalette& palette) = 0;
// The Sets menu label suffix ("Magic" / "Pokemon"), used for the
// dynamically built "Update <name>" menu entry.
[[nodiscard]] virtual std::string updateSetsMenuLabel() const = 0;
};
} // namespace ccm::ui
+92
View File
@@ -0,0 +1,92 @@
#pragma once
// IconListCtrl
//
// `wxListCtrl` subclass that custom-draws icon sub-items so they are
// pixel-perfect centered within the cell, matching our themed-header icon
// centering exactly even after column resize.
//
// Why we need this:
// - Native MSW `LVS_REPORT` sub-item image rendering anchors the image at
// the cell's left edge with a small built-in inset. The header icons in
// this app are centered via wx sizers, so the two never line up.
// - We override `NM_CUSTOMDRAW` to paint icons ourselves at the exact
// center of each icon sub-item rect.
// - Default text cell rendering is left untouched.
//
// Rendering path (MSW):
// We keep one premultiplied 32 bpp BGRA DIB section per (iconIdx, selected)
// variant and composite it onto the listctrl's HDC with `AlphaBlend`
// (`AC_SRC_OVER` + `AC_SRC_ALPHA`) from `NM_CUSTOMDRAW`. We deliberately
// do **not** route through `ImageList_Draw` / `HIMAGELIST`: in our test
// environment `ImageList_Draw` on an `ILC_COLOR32` list ignored the alpha
// channel and the "transparent" canvas around each glyph painted as
// opaque black behind the icon — see `ui_wx/AGENTS.md` convention 11.
//
// Usage:
// 1) Construct with the same wxListCtrl flags as before.
// 2) Call `setIconColumns(firstIconCol, count)` so the subclass knows which
// sub-item indices it owns.
// 3) Call `setIconPredicate(...)` with a callback that decides if the icon
// should be drawn for a given (row, iconIdx).
// 4) Call `setIconBitmaps(normal, selected)` with two equally-sized vectors
// of pre-rendered icon bitmaps — one variant per selection state.
//
// This class is MSW-specific in behavior (NM_CUSTOMDRAW). On other platforms
// `MSWOnNotify` is a no-op override and the listctrl falls back to default
// rendering — which is fine because this app only ships on Windows.
#include <wx/bitmap.h>
#include <wx/listctrl.h>
#include <functional>
#include <utility>
#include <vector>
namespace ccm::ui {
class IconListCtrl : public wxListCtrl {
public:
using wxListCtrl::wxListCtrl;
using IconPredicate = std::function<bool(long row, int iconIdx)>;
~IconListCtrl() override;
void setIconColumns(int firstIconCol, int iconCount) noexcept {
firstIconCol_ = firstIconCol;
iconColCount_ = iconCount;
}
void setIconPredicate(IconPredicate p) { predicate_ = std::move(p); }
// Replace the cached icon bitmaps. Both vectors must have the same size
// (one entry per icon column). Internal premultiplied DIB cache rebuilds.
void setIconBitmaps(std::vector<wxBitmap> normal,
std::vector<wxBitmap> selected);
protected:
#ifdef __WXMSW__
bool MSWOnNotify(int idCtrl, WXLPARAM lParam, WXLPARAM* result) override;
#endif
private:
int firstIconCol_{-1};
int iconColCount_{0};
IconPredicate predicate_;
std::vector<wxBitmap> normalBmps_;
std::vector<wxBitmap> selectedBmps_;
// Premultiplied BGRA DIB sections used as the source for `AlphaBlend`.
// Stored as `void*` (HBITMAP) so the header stays free of `<windows.h>`.
// Index layout:
// [0 .. iconColCount_) -> normal variants
// [iconColCount_ .. 2 * iconColCount_) -> selected variants
std::vector<void*> dibBitmaps_;
int dibWidth_{0};
int dibHeight_{0};
void rebuildDibCache();
void destroyDibCache();
};
} // namespace ccm::ui
@@ -0,0 +1,42 @@
#pragma once
// ImageViewerDialog: full-size image viewer with prev/next navigation.
#include <wx/dialog.h>
#include <wx/button.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/stattext.h>
#include <filesystem>
#include <vector>
namespace ccm::ui {
class ImageViewerDialog : public wxDialog {
public:
ImageViewerDialog(wxWindow* parent,
std::vector<std::filesystem::path> imagePaths,
std::size_t startIndex);
private:
bool loadImageAt(std::size_t index);
void prefetchNeighbors();
void show(std::size_t index);
void onPrev(wxCommandEvent&);
void onNext(wxCommandEvent&);
std::vector<std::filesystem::path> paths_;
std::size_t index_{0};
std::vector<wxImage> imageCache_;
std::vector<bool> imageCacheReady_;
wxPanel* imageHost_{nullptr};
wxStaticText* caption_{nullptr};
wxButton* prevButton_{nullptr};
wxButton* nextButton_{nullptr};
};
} // namespace ccm::ui
@@ -0,0 +1,33 @@
#pragma once
// MagicCardEditDialog: typed Add/Edit form for a `MagicCard`. Inherits the
// shared layout, set picker, and image management from
// `BaseCardEditDialog<MagicCard>` and only overrides the flags row.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
namespace ccm::ui {
class MagicCardEditDialog final : public BaseCardEditDialog<MagicCard> {
public:
MagicCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
EditMode mode,
MagicCard initial,
const std::vector<Set>* preloadedSets = nullptr);
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override { return "Update Magic"; }
private:
wxCheckBox* foilCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
};
} // namespace ccm::ui
@@ -0,0 +1,34 @@
#pragma once
// MagicCardListPanel: typed view of the Magic collection. Inherits all
// `wxListCtrl`/themed-header machinery from `BaseCardListPanel<MagicCard,
// MagicSortColumn>`; this header only declares the per-game hook overrides
// (column layout, sort/filter dispatch, cell rendering).
//
// The legacy `EVT_MAGIC_CARD_SELECTED` alias is kept as a deprecated typedef
// so any out-of-tree callers keep building; new code should bind the shared
// `EVT_CARD_SELECTED` event from `BaseCardListPanel.hpp`.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/services/CardSorter.hpp"
#include "ccm/ui/BaseCardListPanel.hpp"
namespace ccm::ui {
// Backwards-compatible alias for callers that bound the old event symbol.
inline const auto& EVT_MAGIC_CARD_SELECTED = EVT_CARD_SELECTED;
class MagicCardListPanel final : public BaseCardListPanel<MagicCard, MagicSortColumn> {
public:
explicit MagicCardListPanel(wxWindow* parent);
protected:
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
[[nodiscard]] std::vector<IconColumnSpec> declareIconColumns() const override;
[[nodiscard]] std::string renderTextCell(const MagicCard& card, std::size_t idx) const override;
[[nodiscard]] bool isIconColumnSet(const MagicCard& card, std::size_t idx) const override;
void sortBy(MagicSortColumn column, bool ascending) override;
[[nodiscard]] bool matchesFilter(const MagicCard& card, std::string_view filter) const override;
};
} // namespace ccm::ui
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// MagicGameView: IGameView for Magic the Gathering. Owns its three panels
// (list, selected, edit-dialog state) and delegates persistence to the
// typed `CollectionService<MagicCard>` reference handed in by the
// composition root.
#include "ccm/domain/MagicCard.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 MagicCardListPanel;
class MagicSelectedCardPanel;
class MagicGameView final : public IGameView {
public:
MagicGameView(ConfigService& config,
CollectionService<MagicCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
[[nodiscard]] Game gameId() const noexcept override { return Game::Magic; }
[[nodiscard]] std::string displayName() const override { return "Magic"; }
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 Magic"; }
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
ConfigService& config_;
CollectionService<MagicCard>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
MagicCardListPanel* listPanel_{nullptr};
MagicSelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,28 @@
#pragma once
// MagicSelectedCardPanel: typed view of the right-hand-side detail panel for
// Magic. Inherits the preview-fetch / detail-grid / image-list machinery from
// `BaseSelectedCardPanel<MagicCard>` and only overrides the per-game hooks.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
namespace ccm::ui {
class MagicSelectedCardPanel final : public BaseSelectedCardPanel<MagicCard> {
public:
MagicSelectedCardPanel(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 MagicCard& card, DetailKey key) const override;
[[nodiscard]] bool isFlagSet(const MagicCard& card, DetailKey key) const override;
[[nodiscard]] std::tuple<std::string, std::string, std::string>
previewKey(const MagicCard& card) const override;
[[nodiscard]] Game gameId() const noexcept override { return Game::Magic; }
};
} // namespace ccm::ui
+86
View File
@@ -0,0 +1,86 @@
#pragma once
// MainFrame: top-level window. Hosts the menu bar (File / Game / Sets), the
// toolbar (Add / Edit / Delete + filter input), and the splitter that swaps
// the active `IGameView`'s panels in and out as the user switches games.
#include "ccm/domain/Enums.hpp"
#include "ccm/ui/AppContext.hpp"
#include <array>
#include <unordered_map>
#include <wx/frame.h>
class wxTextCtrl;
class wxBitmapButton;
class wxStaticText;
class wxPanel;
class wxSplitterWindow;
namespace ccm::ui {
class IGameView;
class MainFrame : public wxFrame {
public:
explicit MainFrame(AppContext& ctx);
private:
void buildMenuBar();
void buildLayout();
void applyTheme();
void refreshToolbarIcons();
void setStatusTextUi(const wxString& text);
void onOpenFileMenu();
void onOpenGameMenu();
void onOpenSetsMenu();
void onOpenHelpMenu();
void switchGame(Game g);
void mountActiveView();
void onSettings(wxCommandEvent&);
void onQuit(wxCommandEvent&);
void onSwitchGame(wxCommandEvent& ev);
void onUpdateSetsForGame(wxCommandEvent& ev);
void onAbout(wxCommandEvent&);
void onCreate(wxCommandEvent&);
void onEdit(wxCommandEvent&);
void onDelete(wxCommandEvent&);
[[nodiscard]] IGameView* activeView();
#ifdef __WXMSW__
WXLRESULT MSWWindowProc(WXUINT message, WXWPARAM wParam, WXLPARAM lParam) override;
#endif
AppContext& ctx_;
Game activeGame_{Game::Magic};
wxSplitterWindow* splitter_{nullptr};
wxTextCtrl* filterInput_{nullptr};
wxPanel* menuStrip_{nullptr};
wxStaticText* statusText_{nullptr};
std::array<wxBitmapButton*, 3> toolbarButtons_{{nullptr, nullptr, nullptr}};
// Tracks the dynamic Game / Sets menu item ids for the current popup.
// We allocate a contiguous block per menu open so the event handler can
// map back to a `Game` value without a per-game member id.
std::unordered_map<int, Game> menuIdToGame_;
enum Ids : int {
IdSettings = wxID_HIGHEST + 1,
IdCreate,
IdEdit,
IdDelete,
IdAbout,
// 8 dynamic ids for game-switch (max 4) and update-sets (max 4) entries.
IdGameMenuBase,
IdGameMenuLast = IdGameMenuBase + 8,
IdSetsMenuBase,
IdSetsMenuLast = IdSetsMenuBase + 8,
};
};
} // namespace ccm::ui
@@ -0,0 +1,38 @@
#pragma once
// PokemonCardEditDialog: typed Add/Edit form for a `PokemonCard`. Inherits
// the shared layout, set picker, and image management from
// `BaseCardEditDialog<PokemonCard>` and adds:
// - a `Set #` text input (between the Set picker and the Amount spin)
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
namespace ccm::ui {
class PokemonCardEditDialog final : public BaseCardEditDialog<PokemonCard> {
public:
PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
EditMode mode,
PokemonCard 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 Pokemon"; }
private:
wxTextCtrl* setNoCtrl_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
};
} // namespace ccm::ui
@@ -0,0 +1,26 @@
#pragma once
// PokemonCardListPanel: typed view of the Pokemon collection. Inherits all
// `wxListCtrl`/themed-header machinery from `BaseCardListPanel<PokemonCard,
// PokemonSortColumn>` and only overrides the per-game hooks.
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/services/CardSorter.hpp"
#include "ccm/ui/BaseCardListPanel.hpp"
namespace ccm::ui {
class PokemonCardListPanel final : public BaseCardListPanel<PokemonCard, PokemonSortColumn> {
public:
explicit PokemonCardListPanel(wxWindow* parent);
protected:
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
[[nodiscard]] std::vector<IconColumnSpec> declareIconColumns() const override;
[[nodiscard]] std::string renderTextCell(const PokemonCard& card, std::size_t idx) const override;
[[nodiscard]] bool isIconColumnSet(const PokemonCard& card, std::size_t idx) const override;
void sortBy(PokemonSortColumn column, bool ascending) override;
[[nodiscard]] bool matchesFilter(const PokemonCard& card, std::string_view filter) const override;
};
} // namespace ccm::ui
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// PokemonGameView: IGameView for the Pokemon TCG. Mirrors `MagicGameView` —
// owns the Pokemon-typed list, selected, and edit-dialog widgets and
// delegates persistence to a `CollectionService<PokemonCard>` reference
// supplied by the composition root.
#include "ccm/domain/PokemonCard.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 PokemonCardListPanel;
class PokemonSelectedCardPanel;
class PokemonGameView final : public IGameView {
public:
PokemonGameView(ConfigService& config,
CollectionService<PokemonCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
[[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; }
[[nodiscard]] std::string displayName() const override { return "Pokemon"; }
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 Pokemon"; }
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
ConfigService& config_;
CollectionService<PokemonCard>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
PokemonCardListPanel* listPanel_{nullptr};
PokemonSelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,30 @@
#pragma once
// PokemonSelectedCardPanel: typed view of the right-hand-side detail panel
// for Pokemon TCG cards. Inherits from `BaseSelectedCardPanel<PokemonCard>`
// and only overrides per-game hooks (detail rows now include `Set #`,
// flag strip is `Holo` / `1. Ed` / `Signed` / `Altered`, preview lookup
// includes the collector number).
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
namespace ccm::ui {
class PokemonSelectedCardPanel final : public BaseSelectedCardPanel<PokemonCard> {
public:
PokemonSelectedCardPanel(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 PokemonCard& card, DetailKey key) const override;
[[nodiscard]] bool isFlagSet(const PokemonCard& card, DetailKey key) const override;
[[nodiscard]] std::tuple<std::string, std::string, std::string>
previewKey(const PokemonCard& card) const override;
[[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; }
};
} // namespace ccm::ui
+28
View File
@@ -0,0 +1,28 @@
#pragma once
// SettingsDialog: edits the live Configuration via ConfigService.
#include "ccm/services/ConfigService.hpp"
#include <wx/choice.h>
#include <wx/dialog.h>
#include <wx/textctrl.h>
namespace ccm::ui {
class SettingsDialog : public wxDialog {
public:
SettingsDialog(wxWindow* parent, ConfigService& config);
private:
void onBrowse(wxCommandEvent&);
void onOk(wxCommandEvent&);
ConfigService& config_;
wxTextCtrl* dataDirCtrl_{nullptr};
wxChoice* defaultGameChoice_{nullptr};
wxChoice* themeChoice_{nullptr};
};
} // namespace ccm::ui
+51
View File
@@ -0,0 +1,51 @@
#pragma once
// Small utility for converting embedded SVG icons into wxBitmap. Used by the
// side panel and the magic card list to render the foil / signed / altered
// flag icons (sourced from react-icons artwork). Also hosts the
// toolbar glyphs (vscode-codicons, matching react-icons/vsc-style buttons).
#include <wx/bitmap.h>
namespace ccm::ui {
// SVG templates for the per-game flag icons. Original sources:
// - foil -> IoSparklesSharp (Ionicons 5, MIT) [Magic]
// - signed -> BsPencilFill (Bootstrap Icons, MIT)
// - altered -> BsPaletteFill (Bootstrap Icons, MIT)
// - holo -> IoSparklesSharp (Ionicons 5, MIT) [Pokemon, mirrors original
// IconHolo from PokemonTable.tsx]
// - firstEdition -> rebuilt 1. Edition badge (CCM2 IconPokemonFirstEdition.tsx)
// The fill color is parameterized via a `@FILL@` placeholder so callers can
// choose the actual color at render time (e.g. system text vs. system
// highlight-text). NanoSVG cannot resolve CSS `currentColor`, so we have to
// bake the color into the SVG ourselves before parsing.
extern const char* const kSvgFoil;
extern const char* const kSvgSigned;
extern const char* const kSvgAltered;
extern const char* const kSvgHolo;
extern const char* const kSvgFirstEdition;
// Toolbar actions — glyphs match the original `src/pages/index.tsx` imports from
// `react-icons/vsc` (VscAdd / VscEdit / VscTrash). Embedded SVGs are sourced
// from Microsoft's vscode-codicons (MIT), same vector artwork as VS Code's
// codicon font used by react-icons.
extern const char* const kSvgToolbarAdd;
extern const char* const kSvgToolbarEdit;
extern const char* const kSvgToolbarDelete;
// Rasterize an SVG template into a wxBitmap of `size`x`size` pixels. The
// `@FILL@` placeholder in the template is replaced with `fillHex` (any CSS
// color string accepted by NanoSVG, e.g. "#000000" or "white").
// Backed by wxBitmapBundle::FromSVG, which uses NanoSVG (built in to our
// wxWidgets - configure log: `wxUSE_NANOSVG: builtin`).
wxBitmap svgIconBitmap(const char* svg, int size, const char* fillHex = "#000000");
// Same SVG, rasterized at `iconSize` and composited onto a transparent
// `container` canvas with the icon centered. Useful for wxListCtrl image
// lists where header bitmaps render left-anchored on MSW: padding the
// bitmap to the column width visually centers the icon under the header.
wxBitmap paddedSvgIcon(const char* svg, int iconSize, wxSize container,
const char* fillHex = "#000000", int xOffsetPx = 0);
} // namespace ccm::ui
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "ccm/domain/Enums.hpp"
#include <wx/colour.h>
class wxWindow;
class wxString;
namespace ccm::ui {
struct ThemePalette {
wxColour windowBg;
wxColour panelBg;
wxColour text;
wxColour inputBg;
wxColour inputText;
wxColour buttonBg;
wxColour buttonText;
};
ThemePalette paletteForTheme(Theme theme);
Theme inferThemeFromWindow(const wxWindow* window);
void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme theme);
int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style);
int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption);
} // namespace ccm::ui