mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 01:08:49 +00:00
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:
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
inline constexpr const char* kAppVersion = "@CCM_APP_VERSION@";
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user