mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-31 18:08:48 +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,14 @@
|
||||
// Definitions for events shared by the per-game UI templates. The events are
|
||||
// declared in the corresponding base headers (BaseCardListPanel.hpp,
|
||||
// BaseSelectedCardPanel.hpp) and defined exactly once here, so that template
|
||||
// instantiations (Magic, Pokemon, ...) all use the same event type tag.
|
||||
|
||||
#include "ccm/ui/BaseCardListPanel.hpp"
|
||||
#include "ccm/ui/BaseSelectedCardPanel.hpp"
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
wxDEFINE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
|
||||
wxDEFINE_EVENT(EVT_PREVIEW_STATUS, wxCommandEvent);
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,243 @@
|
||||
#include "ccm/ui/IconListCtrl.hpp"
|
||||
|
||||
#include <wx/image.h>
|
||||
|
||||
#ifdef __WXMSW__
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
#endif
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
#ifdef __WXMSW__
|
||||
|
||||
namespace {
|
||||
|
||||
// Convert a `wxBitmap` to a fresh 32 bpp BGRA DIB section with PREMULTIPLIED
|
||||
// alpha, suitable for use as the source bitmap in a `AlphaBlend` call with
|
||||
// `AC_SRC_OVER | AC_SRC_ALPHA`.
|
||||
//
|
||||
// Going through `wxImage` gives us a known-good straight-RGBA payload
|
||||
// regardless of how the source `wxBitmap` was originally constructed
|
||||
// (notably bitmaps from `wxBitmapBundle::FromSVG`). We then premultiply
|
||||
// once, rounded.
|
||||
//
|
||||
// Math notes:
|
||||
// - The rounded form `(c * a + 127) / 255` is required. Plain `c * a` (no
|
||||
// divide) overflows the byte and pushes every channel toward 0xFF — the
|
||||
// "white icons" regression an earlier dev ran into when they tried to
|
||||
// premultiply manually. `(c * a) / 255` is also wrong for `c=a=0xFF`
|
||||
// (rounds to 254 instead of 255 and creates 1-bit dimming on opaque
|
||||
// pixels). The +127 form is the standard premultiply rounding.
|
||||
HBITMAP makePremultipliedDib(const wxBitmap& bmp) {
|
||||
if (!bmp.IsOk()) return NULL;
|
||||
|
||||
wxImage img = bmp.ConvertToImage();
|
||||
if (!img.IsOk()) return NULL;
|
||||
if (!img.HasAlpha()) img.InitAlpha();
|
||||
|
||||
const int w = img.GetWidth();
|
||||
const int h = img.GetHeight();
|
||||
if (w <= 0 || h <= 0) return NULL;
|
||||
|
||||
BITMAPINFO bi{};
|
||||
bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
|
||||
bi.bmiHeader.biWidth = w;
|
||||
bi.bmiHeader.biHeight = -h; // top-down
|
||||
bi.bmiHeader.biPlanes = 1;
|
||||
bi.bmiHeader.biBitCount = 32;
|
||||
bi.bmiHeader.biCompression = BI_RGB;
|
||||
|
||||
HDC screenDc = ::GetDC(NULL);
|
||||
void* dibPixels = nullptr;
|
||||
HBITMAP dib = ::CreateDIBSection(screenDc, &bi, DIB_RGB_COLORS,
|
||||
&dibPixels, NULL, 0);
|
||||
::ReleaseDC(NULL, screenDc);
|
||||
if (dib == NULL || dibPixels == nullptr) {
|
||||
if (dib != NULL) ::DeleteObject(dib);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const unsigned char* rgb = img.GetData();
|
||||
const unsigned char* alpha = img.GetAlpha();
|
||||
auto* dest = static_cast<unsigned char*>(dibPixels);
|
||||
const int pixels = w * h;
|
||||
const auto premul = [](unsigned c, unsigned a) -> unsigned char {
|
||||
return static_cast<unsigned char>((c * a + 127) / 255);
|
||||
};
|
||||
for (int p = 0; p < pixels; ++p) {
|
||||
const unsigned char r = rgb[p * 3 + 0];
|
||||
const unsigned char g = rgb[p * 3 + 1];
|
||||
const unsigned char b = rgb[p * 3 + 2];
|
||||
const unsigned char a = (alpha != nullptr) ? alpha[p] : 255;
|
||||
dest[p * 4 + 0] = premul(b, a);
|
||||
dest[p * 4 + 1] = premul(g, a);
|
||||
dest[p * 4 + 2] = premul(r, a);
|
||||
dest[p * 4 + 3] = a;
|
||||
}
|
||||
return dib;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
IconListCtrl::~IconListCtrl() {
|
||||
destroyDibCache();
|
||||
}
|
||||
|
||||
void IconListCtrl::setIconBitmaps(std::vector<wxBitmap> normal,
|
||||
std::vector<wxBitmap> selected) {
|
||||
normalBmps_ = std::move(normal);
|
||||
selectedBmps_ = std::move(selected);
|
||||
rebuildDibCache();
|
||||
}
|
||||
|
||||
void IconListCtrl::destroyDibCache() {
|
||||
for (void* p : dibBitmaps_) {
|
||||
if (p != nullptr) ::DeleteObject(static_cast<HBITMAP>(p));
|
||||
}
|
||||
dibBitmaps_.clear();
|
||||
dibWidth_ = 0;
|
||||
dibHeight_ = 0;
|
||||
}
|
||||
|
||||
void IconListCtrl::rebuildDibCache() {
|
||||
destroyDibCache();
|
||||
if (normalBmps_.empty() || normalBmps_.size() != selectedBmps_.size()) {
|
||||
return;
|
||||
}
|
||||
dibWidth_ = normalBmps_.front().IsOk() ? normalBmps_.front().GetWidth() : 0;
|
||||
dibHeight_ = normalBmps_.front().IsOk() ? normalBmps_.front().GetHeight() : 0;
|
||||
if (dibWidth_ <= 0 || dibHeight_ <= 0) return;
|
||||
|
||||
dibBitmaps_.reserve(normalBmps_.size() + selectedBmps_.size());
|
||||
for (const auto& b : normalBmps_) {
|
||||
dibBitmaps_.push_back(static_cast<void*>(makePremultipliedDib(b)));
|
||||
}
|
||||
for (const auto& b : selectedBmps_) {
|
||||
dibBitmaps_.push_back(static_cast<void*>(makePremultipliedDib(b)));
|
||||
}
|
||||
}
|
||||
|
||||
bool IconListCtrl::MSWOnNotify(int idCtrl, WXLPARAM lParam, WXLPARAM* result) {
|
||||
auto* hdr = reinterpret_cast<NMHDR*>(lParam);
|
||||
if (hdr != nullptr && hdr->code == NM_CUSTOMDRAW) {
|
||||
auto* cd = reinterpret_cast<NMLVCUSTOMDRAW*>(lParam);
|
||||
switch (cd->nmcd.dwDrawStage) {
|
||||
case CDDS_PREPAINT:
|
||||
*result = CDRF_NOTIFYITEMDRAW;
|
||||
return true;
|
||||
|
||||
case CDDS_ITEMPREPAINT:
|
||||
*result = CDRF_NOTIFYSUBITEMDRAW;
|
||||
return true;
|
||||
|
||||
case CDDS_SUBITEM | CDDS_ITEMPREPAINT: {
|
||||
const int col = cd->iSubItem;
|
||||
if (col >= firstIconCol_ && col < firstIconCol_ + iconColCount_) {
|
||||
// Let the default first paint background/selection, then we
|
||||
// overlay the centered icon in POSTPAINT.
|
||||
*result = CDRF_NOTIFYPOSTPAINT;
|
||||
return true;
|
||||
}
|
||||
*result = CDRF_DODEFAULT;
|
||||
return true;
|
||||
}
|
||||
|
||||
case CDDS_SUBITEM | CDDS_ITEMPOSTPAINT: {
|
||||
const int col = cd->iSubItem;
|
||||
if (col < firstIconCol_ || col >= firstIconCol_ + iconColCount_) {
|
||||
*result = CDRF_DODEFAULT;
|
||||
return true;
|
||||
}
|
||||
if (!predicate_ || dibBitmaps_.empty()) {
|
||||
*result = CDRF_DODEFAULT;
|
||||
return true;
|
||||
}
|
||||
const long row = static_cast<long>(cd->nmcd.dwItemSpec);
|
||||
const int iconIdx = col - firstIconCol_;
|
||||
if (iconIdx < 0 || iconIdx >= iconColCount_) {
|
||||
*result = CDRF_DODEFAULT;
|
||||
return true;
|
||||
}
|
||||
if (!predicate_(row, iconIdx)) {
|
||||
*result = CDRF_DODEFAULT;
|
||||
return true;
|
||||
}
|
||||
const HWND lcHwnd = reinterpret_cast<HWND>(GetHandle());
|
||||
// `cd->nmcd.uItemState & CDIS_SELECTED` is unreliable in the
|
||||
// CDDS_SUBITEM | CDDS_ITEMPOSTPAINT stage on Windows — comctl32
|
||||
// does not always propagate the item's CDIS_* flags down into
|
||||
// sub-item draw stages, so we'd silently fall back to the normal
|
||||
// (dark) variant on selected rows. Query LVIS_SELECTED directly
|
||||
// off the listview, which is always accurate.
|
||||
const UINT lvState = ListView_GetItemState(lcHwnd, row, LVIS_SELECTED);
|
||||
const bool selected = (lvState & LVIS_SELECTED) != 0;
|
||||
|
||||
// Sub-item bounds in client coords. Initialize the request as
|
||||
// documented for LVM_GETSUBITEMRECT: rc.top = sub-item index,
|
||||
// rc.left = which rect (LVIR_BOUNDS).
|
||||
RECT rc;
|
||||
rc.top = col;
|
||||
rc.left = LVIR_BOUNDS;
|
||||
::SendMessageW(lcHwnd,
|
||||
LVM_GETSUBITEMRECT,
|
||||
static_cast<WPARAM>(row),
|
||||
reinterpret_cast<LPARAM>(&rc));
|
||||
|
||||
const int cellCx = (rc.left + rc.right) / 2;
|
||||
const int cellCy = (rc.top + rc.bottom) / 2;
|
||||
const int x = cellCx - dibWidth_ / 2;
|
||||
const int y = cellCy - dibHeight_ / 2;
|
||||
|
||||
const std::size_t imgIdx =
|
||||
static_cast<std::size_t>(iconIdx) +
|
||||
(selected ? static_cast<std::size_t>(iconColCount_) : 0);
|
||||
if (imgIdx >= dibBitmaps_.size() || dibBitmaps_[imgIdx] == nullptr) {
|
||||
*result = CDRF_DODEFAULT;
|
||||
return true;
|
||||
}
|
||||
HBITMAP src = static_cast<HBITMAP>(dibBitmaps_[imgIdx]);
|
||||
|
||||
HDC dstDc = cd->nmcd.hdc;
|
||||
HDC memDc = ::CreateCompatibleDC(dstDc);
|
||||
HGDIOBJ oldBmp = ::SelectObject(memDc, src);
|
||||
|
||||
BLENDFUNCTION bf{};
|
||||
bf.BlendOp = AC_SRC_OVER;
|
||||
bf.BlendFlags = 0;
|
||||
bf.SourceConstantAlpha = 0xFF;
|
||||
bf.AlphaFormat = AC_SRC_ALPHA;
|
||||
|
||||
::AlphaBlend(dstDc, x, y, dibWidth_, dibHeight_,
|
||||
memDc, 0, 0, dibWidth_, dibHeight_, bf);
|
||||
|
||||
::SelectObject(memDc, oldBmp);
|
||||
::DeleteDC(memDc);
|
||||
|
||||
*result = CDRF_DODEFAULT;
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return wxListCtrl::MSWOnNotify(idCtrl, lParam, result);
|
||||
}
|
||||
|
||||
#else // !__WXMSW__
|
||||
|
||||
IconListCtrl::~IconListCtrl() = default;
|
||||
|
||||
void IconListCtrl::setIconBitmaps(std::vector<wxBitmap> normal,
|
||||
std::vector<wxBitmap> selected) {
|
||||
normalBmps_ = std::move(normal);
|
||||
selectedBmps_ = std::move(selected);
|
||||
}
|
||||
|
||||
void IconListCtrl::destroyDibCache() {}
|
||||
void IconListCtrl::rebuildDibCache() {}
|
||||
|
||||
#endif // __WXMSW__
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,163 @@
|
||||
#include "ccm/ui/ImageViewerDialog.hpp"
|
||||
|
||||
#include <wx/bitmap.h>
|
||||
#include <wx/button.h>
|
||||
#include <wx/dcclient.h>
|
||||
#include <wx/image.h>
|
||||
#include <wx/panel.h>
|
||||
#include <wx/sizer.h>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
class ImageCanvas : public wxPanel {
|
||||
public:
|
||||
explicit ImageCanvas(wxWindow* parent) : wxPanel(parent, wxID_ANY) {
|
||||
SetBackgroundStyle(wxBG_STYLE_PAINT);
|
||||
Bind(wxEVT_PAINT, &ImageCanvas::onPaint, this);
|
||||
Bind(wxEVT_SIZE, [this](wxSizeEvent& ev) { Refresh(); ev.Skip(); });
|
||||
}
|
||||
|
||||
void setImage(const wxImage& img) {
|
||||
original_ = img;
|
||||
cachedScaled_ = wxBitmap();
|
||||
cachedScaledFor_ = wxSize(-1, -1);
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private:
|
||||
void onPaint(wxPaintEvent&) {
|
||||
wxPaintDC dc(this);
|
||||
dc.Clear();
|
||||
if (!original_.IsOk()) return;
|
||||
|
||||
const wxSize ws = GetClientSize();
|
||||
if (ws.GetWidth() <= 0 || ws.GetHeight() <= 0) return;
|
||||
|
||||
const double scale = std::min(
|
||||
static_cast<double>(ws.GetWidth()) / original_.GetWidth(),
|
||||
static_cast<double>(ws.GetHeight()) / original_.GetHeight());
|
||||
const int w = std::max(1, static_cast<int>(original_.GetWidth() * scale));
|
||||
const int h = std::max(1, static_cast<int>(original_.GetHeight() * scale));
|
||||
const wxSize scaledSize(w, h);
|
||||
if (!cachedScaled_.IsOk() || cachedScaledFor_ != scaledSize) {
|
||||
// Use normal quality for very large reductions to keep navigation snappy.
|
||||
const long long srcPixels = static_cast<long long>(original_.GetWidth()) * original_.GetHeight();
|
||||
const long long dstPixels = static_cast<long long>(w) * h;
|
||||
const bool heavyDownscale = dstPixels > 0 && srcPixels > (dstPixels * 4);
|
||||
const wxImageResizeQuality quality =
|
||||
heavyDownscale ? wxIMAGE_QUALITY_NORMAL : wxIMAGE_QUALITY_HIGH;
|
||||
wxImage scaled = original_.Scale(w, h, quality);
|
||||
cachedScaled_ = wxBitmap(scaled);
|
||||
cachedScaledFor_ = scaledSize;
|
||||
}
|
||||
dc.DrawBitmap(cachedScaled_,
|
||||
(ws.GetWidth() - w) / 2,
|
||||
(ws.GetHeight() - h) / 2,
|
||||
true);
|
||||
}
|
||||
|
||||
wxImage original_;
|
||||
wxBitmap cachedScaled_;
|
||||
wxSize cachedScaledFor_{-1, -1};
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
ImageViewerDialog::ImageViewerDialog(wxWindow* parent,
|
||||
std::vector<std::filesystem::path> imagePaths,
|
||||
std::size_t startIndex)
|
||||
: wxDialog(parent, wxID_ANY, "Image",
|
||||
wxDefaultPosition, wxSize(700, 900),
|
||||
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER),
|
||||
paths_(std::move(imagePaths)),
|
||||
index_(startIndex < paths_.size() ? startIndex : 0) {
|
||||
imageCache_.resize(paths_.size());
|
||||
imageCacheReady_.assign(paths_.size(), false);
|
||||
|
||||
auto* root = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
imageHost_ = new ImageCanvas(this);
|
||||
root->Add(imageHost_, 1, wxEXPAND | wxALL, 6);
|
||||
|
||||
caption_ = new wxStaticText(this, wxID_ANY, "");
|
||||
caption_->SetForegroundColour(*wxBLACK);
|
||||
root->Add(caption_, 0, wxALL, 6);
|
||||
|
||||
auto* nav = new wxBoxSizer(wxHORIZONTAL);
|
||||
prevButton_ = new wxButton(this, wxID_ANY, "<< Prev");
|
||||
nextButton_ = new wxButton(this, wxID_ANY, "Next >>");
|
||||
auto* prev = prevButton_;
|
||||
auto* next = nextButton_;
|
||||
nav->Add(prev, 0, wxRIGHT, 6);
|
||||
nav->Add(next, 0);
|
||||
nav->AddStretchSpacer(1);
|
||||
nav->Add(new wxButton(this, wxID_OK, "Close"), 0);
|
||||
// Reserve bottom-right space for the dark resize-grip overlay on Windows.
|
||||
root->Add(nav, 0, wxEXPAND | wxLEFT | wxTOP | wxRIGHT, 6);
|
||||
root->AddSpacer(24);
|
||||
|
||||
prev->Bind(wxEVT_BUTTON, &ImageViewerDialog::onPrev, this);
|
||||
next->Bind(wxEVT_BUTTON, &ImageViewerDialog::onNext, this);
|
||||
|
||||
SetSizer(root);
|
||||
show(index_);
|
||||
}
|
||||
|
||||
bool ImageViewerDialog::loadImageAt(std::size_t index) {
|
||||
if (index >= paths_.size()) return false;
|
||||
if (imageCacheReady_[index]) return imageCache_[index].IsOk();
|
||||
|
||||
wxImage img;
|
||||
if (!img.LoadFile(paths_[index].string())) {
|
||||
imageCacheReady_[index] = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
imageCache_[index] = std::move(img);
|
||||
imageCacheReady_[index] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void ImageViewerDialog::prefetchNeighbors() {
|
||||
if (paths_.size() < 2) return;
|
||||
const std::size_t prev = (index_ + paths_.size() - 1) % paths_.size();
|
||||
const std::size_t next = (index_ + 1) % paths_.size();
|
||||
loadImageAt(prev);
|
||||
loadImageAt(next);
|
||||
}
|
||||
|
||||
void ImageViewerDialog::show(std::size_t index) {
|
||||
if (paths_.empty()) {
|
||||
caption_->SetLabelText("(no images)");
|
||||
return;
|
||||
}
|
||||
index_ = index % paths_.size();
|
||||
if (loadImageAt(index_)) static_cast<ImageCanvas*>(imageHost_)->setImage(imageCache_[index_]);
|
||||
caption_->SetLabelText(paths_[index_].filename().string() +
|
||||
" (" + std::to_string(index_ + 1) +
|
||||
"/" + std::to_string(paths_.size()) + ")");
|
||||
prefetchNeighbors();
|
||||
Layout();
|
||||
}
|
||||
|
||||
void ImageViewerDialog::onPrev(wxCommandEvent&) {
|
||||
if (paths_.empty()) return;
|
||||
if (imageHost_ != nullptr) imageHost_->SetFocus();
|
||||
const std::size_t target = (index_ + paths_.size() - 1) % paths_.size();
|
||||
CallAfter([this, target]() {
|
||||
if (!IsBeingDeleted()) show(target);
|
||||
});
|
||||
}
|
||||
|
||||
void ImageViewerDialog::onNext(wxCommandEvent&) {
|
||||
if (paths_.empty()) return;
|
||||
if (imageHost_ != nullptr) imageHost_->SetFocus();
|
||||
const std::size_t target = (index_ + 1) % paths_.size();
|
||||
CallAfter([this, target]() {
|
||||
if (!IsBeingDeleted()) show(target);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "ccm/ui/MagicCardEditDialog.hpp"
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
MagicCardEditDialog::MagicCardEditDialog(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
SetService& setService,
|
||||
EditMode mode,
|
||||
MagicCard initial,
|
||||
const std::vector<Set>* preloadedSets)
|
||||
: BaseCardEditDialog<MagicCard>(
|
||||
parent,
|
||||
mode == EditMode::Create ? "Add Magic Card" : "Edit Magic Card",
|
||||
imageService, setService, mode, std::move(initial), Game::Magic, preloadedSets) {
|
||||
buildAndPopulate();
|
||||
}
|
||||
|
||||
void MagicCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
||||
foilCheck_ = new wxCheckBox(this, wxID_ANY, "Foil");
|
||||
signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed");
|
||||
alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered");
|
||||
flagsBox->Add(foilCheck_, 0, wxRIGHT, 12);
|
||||
flagsBox->Add(signedCheck_, 0, wxRIGHT, 12);
|
||||
flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12);
|
||||
}
|
||||
|
||||
void MagicCardEditDialog::readExtraFromCard() {
|
||||
if (foilCheck_) foilCheck_->SetValue(constCard().foil);
|
||||
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
|
||||
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
|
||||
}
|
||||
|
||||
void MagicCardEditDialog::writeExtraToCard() {
|
||||
if (foilCheck_) mutableCard().foil = foilCheck_->IsChecked();
|
||||
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
|
||||
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,70 @@
|
||||
#include "ccm/ui/MagicCardListPanel.hpp"
|
||||
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
MagicCardListPanel::MagicCardListPanel(wxWindow* parent)
|
||||
: BaseCardListPanel<MagicCard, MagicSortColumn>(parent) {
|
||||
buildLayout();
|
||||
}
|
||||
|
||||
std::vector<MagicCardListPanel::TextColumnSpec>
|
||||
MagicCardListPanel::declareTextColumns() const {
|
||||
return {
|
||||
{"Name", 220, wxLIST_FORMAT_LEFT, MagicSortColumn::Name},
|
||||
{"Set", 180, wxLIST_FORMAT_LEFT, MagicSortColumn::SetReleaseDate},
|
||||
{"Amount", 70, wxLIST_FORMAT_RIGHT, MagicSortColumn::Amount},
|
||||
{"Condition", 100, wxLIST_FORMAT_LEFT, MagicSortColumn::Condition},
|
||||
{"Language", 100, wxLIST_FORMAT_LEFT, MagicSortColumn::Language},
|
||||
// Trailing Note column, always last.
|
||||
{"Note", 220, wxLIST_FORMAT_LEFT, MagicSortColumn::Note},
|
||||
};
|
||||
}
|
||||
|
||||
std::vector<MagicCardListPanel::IconColumnSpec>
|
||||
MagicCardListPanel::declareIconColumns() const {
|
||||
constexpr int kFlagColWidth = 36;
|
||||
return {
|
||||
{kSvgFoil, kFlagColWidth, MagicSortColumn::Foil},
|
||||
{kSvgSigned, kFlagColWidth, MagicSortColumn::Signed},
|
||||
{kSvgAltered, kFlagColWidth, MagicSortColumn::Altered},
|
||||
};
|
||||
}
|
||||
|
||||
std::string MagicCardListPanel::renderTextCell(const MagicCard& card,
|
||||
std::size_t idx) const {
|
||||
switch (idx) {
|
||||
case 0: return card.name;
|
||||
case 1: return card.set.name;
|
||||
case 2: return std::to_string(card.amount);
|
||||
case 3: return std::string(to_string(card.condition));
|
||||
case 4: return std::string(to_string(card.language));
|
||||
case 5: return card.note;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool MagicCardListPanel::isIconColumnSet(const MagicCard& card,
|
||||
std::size_t idx) const {
|
||||
switch (idx) {
|
||||
case 0: return card.foil;
|
||||
case 1: return card.signed_;
|
||||
case 2: return card.altered;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void MagicCardListPanel::sortBy(MagicSortColumn column, bool ascending) {
|
||||
sortMagicCards(mutableCards(), column, ascending);
|
||||
}
|
||||
|
||||
bool MagicCardListPanel::matchesFilter(const MagicCard& card,
|
||||
std::string_view filter) const {
|
||||
return matchesMagicFilter(card, filter);
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,201 @@
|
||||
#include "ccm/ui/MagicGameView.hpp"
|
||||
|
||||
#include "ccm/ui/MagicCardEditDialog.hpp"
|
||||
#include "ccm/ui/MagicCardListPanel.hpp"
|
||||
#include "ccm/ui/MagicSelectedCardPanel.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
MagicGameView::MagicGameView(ConfigService& config,
|
||||
CollectionService<MagicCard>& collection,
|
||||
SetService& sets,
|
||||
ImageService& images,
|
||||
CardPreviewService& cardPreview,
|
||||
IGameModule& module)
|
||||
: config_(config),
|
||||
collection_(collection),
|
||||
sets_(sets),
|
||||
images_(images),
|
||||
cardPreview_(cardPreview),
|
||||
module_(module) {}
|
||||
|
||||
void MagicGameView::ensureSetsLoaded() {
|
||||
if (attemptedInitialSetLoad_) return;
|
||||
attemptedInitialSetLoad_ = true;
|
||||
|
||||
auto cached = sets_.getSets(Game::Magic);
|
||||
if (cached) {
|
||||
setsCache_ = std::move(cached).value();
|
||||
if (!setsCache_.empty()) return;
|
||||
} else {
|
||||
setsCache_.clear();
|
||||
}
|
||||
|
||||
auto refreshed = sets_.updateSets(Game::Magic);
|
||||
if (refreshed) {
|
||||
setsCache_ = std::move(refreshed).value();
|
||||
}
|
||||
}
|
||||
|
||||
wxPanel* MagicGameView::listPanel(wxWindow* parent) {
|
||||
if (listPanel_ == nullptr) {
|
||||
listPanel_ = new MagicCardListPanel(parent);
|
||||
// Selection in the list -> push the typed card to the selected panel.
|
||||
// Binding here (in the view, not in MainFrame) keeps the typed wiring
|
||||
// local to the per-game implementation - MainFrame only sees IGameView.
|
||||
listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) {
|
||||
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
|
||||
selectedPanel_->setCard(listPanel_->selected());
|
||||
}
|
||||
});
|
||||
}
|
||||
return listPanel_;
|
||||
}
|
||||
|
||||
wxPanel* MagicGameView::selectedPanel(wxWindow* parent) {
|
||||
if (selectedPanel_ == nullptr) {
|
||||
selectedPanel_ = new MagicSelectedCardPanel(parent, images_, cardPreview_);
|
||||
}
|
||||
return selectedPanel_;
|
||||
}
|
||||
|
||||
void MagicGameView::refreshCollection() {
|
||||
if (listPanel_ == nullptr) return;
|
||||
auto loaded = collection_.list(Game::Magic);
|
||||
if (!loaded) {
|
||||
showThemedMessageDialog(nullptr, "Failed to load Magic collection: " + loaded.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
listPanel_->setCards(std::move(loaded).value());
|
||||
listPanel_->activateSelection();
|
||||
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
|
||||
}
|
||||
|
||||
const std::vector<Set>& MagicGameView::setsForDialog() {
|
||||
ensureSetsLoaded();
|
||||
if (!setsCache_.empty()) return setsCache_;
|
||||
auto loaded = sets_.getSets(Game::Magic);
|
||||
if (loaded) setsCache_ = std::move(loaded).value();
|
||||
else setsCache_.clear();
|
||||
return setsCache_;
|
||||
}
|
||||
|
||||
void MagicGameView::onAddCard(wxWindow* parentWindow) {
|
||||
MagicCard fresh;
|
||||
fresh.amount = 1;
|
||||
fresh.language = Language::English;
|
||||
fresh.condition = Condition::NearMint;
|
||||
|
||||
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
|
||||
auto added = collection_.add(Game::Magic, dlg.card());
|
||||
if (!added) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
MagicCard persisted = dlg.card();
|
||||
persisted.id = added.value();
|
||||
auto normalized = images_.normalizeNamesForPersistedCard(
|
||||
Game::Magic, persisted.id, persisted.set.name, persisted.name, persisted.images);
|
||||
if (normalized) {
|
||||
if (normalized.value() != persisted.images) {
|
||||
persisted.images = std::move(normalized).value();
|
||||
auto updated = collection_.update(Game::Magic, persisted);
|
||||
if (!updated) {
|
||||
showThemedMessageDialog(parentWindow, "Card added, but image name normalization failed to persist: " + updated.error(),
|
||||
"Warning", wxOK | wxICON_WARNING);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showThemedMessageDialog(parentWindow, "Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
|
||||
"Warning", wxOK | wxICON_WARNING);
|
||||
}
|
||||
refreshCollection();
|
||||
}
|
||||
|
||||
void MagicGameView::onEditCard(wxWindow* parentWindow) {
|
||||
if (listPanel_ == nullptr) return;
|
||||
auto sel = listPanel_->selected();
|
||||
if (!sel) {
|
||||
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
MagicCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
auto updated = collection_.update(Game::Magic, dlg.card());
|
||||
if (!updated) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
refreshCollection();
|
||||
}
|
||||
|
||||
void MagicGameView::onDeleteCard(wxWindow* parentWindow) {
|
||||
if (listPanel_ == nullptr) return;
|
||||
auto sel = listPanel_->selected();
|
||||
if (!sel) {
|
||||
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
|
||||
"Confirm") != wxID_YES) {
|
||||
return;
|
||||
}
|
||||
auto removed = collection_.remove(Game::Magic, sel->id);
|
||||
if (!removed) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
refreshCollection();
|
||||
}
|
||||
|
||||
std::string MagicGameView::onUpdateSets(wxWindow* parentWindow) {
|
||||
auto out = sets_.updateSets(Game::Magic);
|
||||
if (!out) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return "Update failed";
|
||||
}
|
||||
setsCache_ = out.value();
|
||||
showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Magic sets.",
|
||||
"Sets updated", wxOK | wxICON_INFORMATION);
|
||||
return "Magic sets updated.";
|
||||
}
|
||||
|
||||
void MagicGameView::setFilter(std::string_view filter) {
|
||||
if (listPanel_) listPanel_->setFilter(filter);
|
||||
}
|
||||
|
||||
void MagicGameView::applyTheme(const ThemePalette& palette) {
|
||||
if (listPanel_) listPanel_->applyTheme(palette);
|
||||
if (selectedPanel_) selectedPanel_->applyTheme(palette);
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,77 @@
|
||||
#include "ccm/ui/MagicSelectedCardPanel.hpp"
|
||||
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
namespace {
|
||||
// Detail-row keys local to the Magic implementation.
|
||||
enum MagicDetailKey : int {
|
||||
kName = 0,
|
||||
kSet,
|
||||
kLanguage,
|
||||
kCondition,
|
||||
kAmount,
|
||||
kFoil,
|
||||
kSigned,
|
||||
kAltered,
|
||||
};
|
||||
} // namespace
|
||||
|
||||
MagicSelectedCardPanel::MagicSelectedCardPanel(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
CardPreviewService& cardPreview)
|
||||
: BaseSelectedCardPanel<MagicCard>(parent, imageService, cardPreview) {
|
||||
buildLayout();
|
||||
}
|
||||
|
||||
std::vector<MagicSelectedCardPanel::DetailRowSpec>
|
||||
MagicSelectedCardPanel::declareDetailRows() const {
|
||||
return {
|
||||
{"Name", kName, "(no card selected)"},
|
||||
{"Set", kSet, ""},
|
||||
{"Language", kLanguage, ""},
|
||||
{"Condition", kCondition, ""},
|
||||
{"Amount", kAmount, ""},
|
||||
};
|
||||
}
|
||||
|
||||
std::vector<MagicSelectedCardPanel::FlagIconSpec>
|
||||
MagicSelectedCardPanel::declareFlagIcons() const {
|
||||
return {
|
||||
{kSvgFoil, "Foil", kFoil},
|
||||
{kSvgSigned, "Signed", kSigned},
|
||||
{kSvgAltered, "Altered", kAltered},
|
||||
};
|
||||
}
|
||||
|
||||
std::string MagicSelectedCardPanel::detailValueFor(const MagicCard& card,
|
||||
DetailKey key) const {
|
||||
switch (key) {
|
||||
case kName: return card.name;
|
||||
case kSet: return card.set.name;
|
||||
case kLanguage: return std::string(to_string(card.language));
|
||||
case kCondition: return std::string(to_string(card.condition));
|
||||
case kAmount: return std::to_string(card.amount);
|
||||
case kNoteKey: return card.note;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool MagicSelectedCardPanel::isFlagSet(const MagicCard& card, DetailKey key) const {
|
||||
switch (key) {
|
||||
case kFoil: return card.foil;
|
||||
case kSigned: return card.signed_;
|
||||
case kAltered: return card.altered;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::tuple<std::string, std::string, std::string>
|
||||
MagicSelectedCardPanel::previewKey(const MagicCard& card) const {
|
||||
return {card.name, card.set.id, std::string{}};
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,446 @@
|
||||
#include "ccm/ui/MainFrame.hpp"
|
||||
|
||||
// BaseSelectedCardPanel.hpp is included for the shared EVT_PREVIEW_STATUS
|
||||
// declaration so MainFrame can subscribe to preview-status updates from any
|
||||
// active selected panel without depending on a specific game's view.
|
||||
#include "ccm/ui/BaseSelectedCardPanel.hpp"
|
||||
#include "ccm/ui/IGameView.hpp"
|
||||
#include "ccm/ui/AppVersion.hpp"
|
||||
#include "ccm/ui/SettingsDialog.hpp"
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/bmpbuttn.h>
|
||||
#include <wx/button.h>
|
||||
#include <wx/dialog.h>
|
||||
#include <wx/event.h>
|
||||
#include <wx/menu.h>
|
||||
#include <wx/menuitem.h>
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/panel.h>
|
||||
#include <wx/settings.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/splitter.h>
|
||||
#include <wx/stattext.h>
|
||||
#include <wx/textctrl.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#ifdef __WXMSW__
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
namespace {
|
||||
constexpr int kToolbarIconPx = 18;
|
||||
constexpr const char kFilterInputHint[] = "Filter";
|
||||
|
||||
std::string dirNameForGame(Game g) {
|
||||
switch (g) {
|
||||
case Game::Magic: return "magic";
|
||||
case Game::Pokemon: return "pokemon";
|
||||
}
|
||||
return "magic";
|
||||
}
|
||||
|
||||
void ensureDataStorageScaffold(const Configuration& cfg) {
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path root(cfg.dataStorage);
|
||||
std::error_code ec;
|
||||
fs::create_directories(root, ec);
|
||||
if (ec) return;
|
||||
|
||||
const fs::path dataConfigPath = root / "config.json";
|
||||
if (!fs::exists(dataConfigPath, ec)) {
|
||||
std::ofstream out(dataConfigPath.string(), std::ios::out | std::ios::trunc);
|
||||
if (out.is_open()) {
|
||||
// Marker file so moved data folders are self-contained on disk.
|
||||
out << "{\n"
|
||||
<< " \"dataStorage\": \"" << cfg.dataStorage << "\",\n"
|
||||
<< " \"defaultGame\": \"" << to_string(cfg.defaultGame) << "\",\n"
|
||||
<< " \"theme\": \"" << to_string(cfg.theme) << "\"\n"
|
||||
<< "}\n";
|
||||
}
|
||||
}
|
||||
|
||||
for (Game game : {Game::Magic, Game::Pokemon}) {
|
||||
const fs::path gameRoot = root / dirNameForGame(game);
|
||||
fs::create_directories(gameRoot / "images", ec);
|
||||
if (ec) continue;
|
||||
|
||||
const fs::path collectionPath = gameRoot / "collection.json";
|
||||
if (!fs::exists(collectionPath, ec)) {
|
||||
std::ofstream out(collectionPath.string(), std::ios::out | std::ios::trunc);
|
||||
if (out.is_open()) out << "{}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
MainFrame::MainFrame(AppContext& ctx)
|
||||
: wxFrame(nullptr, wxID_ANY, "Card Collection Manager 3",
|
||||
wxDefaultPosition, wxSize(1210, 700)),
|
||||
ctx_(ctx),
|
||||
activeGame_(ctx.config.current().defaultGame) {
|
||||
buildMenuBar();
|
||||
buildLayout();
|
||||
applyTheme();
|
||||
setStatusTextUi("Ready");
|
||||
|
||||
setStatusTextUi("Loading collection...");
|
||||
CallAfter([this]() {
|
||||
mountActiveView();
|
||||
if (auto* view = activeView()) view->refreshCollection();
|
||||
});
|
||||
}
|
||||
|
||||
void MainFrame::buildMenuBar() {
|
||||
Bind(wxEVT_MENU, &MainFrame::onSettings, this, IdSettings);
|
||||
Bind(wxEVT_MENU, &MainFrame::onQuit, this, wxID_EXIT);
|
||||
Bind(wxEVT_MENU, &MainFrame::onAbout, this, IdAbout);
|
||||
Bind(wxEVT_MENU, &MainFrame::onSwitchGame, this, IdGameMenuBase, IdGameMenuLast);
|
||||
Bind(wxEVT_MENU, &MainFrame::onUpdateSetsForGame, this, IdSetsMenuBase, IdSetsMenuLast);
|
||||
}
|
||||
|
||||
void MainFrame::buildLayout() {
|
||||
auto* root = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
menuStrip_ = new wxPanel(this, wxID_ANY);
|
||||
auto* menuSizer = new wxBoxSizer(wxHORIZONTAL);
|
||||
auto* fileLbl = new wxStaticText(menuStrip_, wxID_ANY, "File");
|
||||
auto* gameLbl = new wxStaticText(menuStrip_, wxID_ANY, "Game");
|
||||
auto* setsLbl = new wxStaticText(menuStrip_, wxID_ANY, "Sets");
|
||||
auto* helpLbl = new wxStaticText(menuStrip_, wxID_ANY, "Help");
|
||||
fileLbl->SetCursor(wxCursor(wxCURSOR_HAND));
|
||||
gameLbl->SetCursor(wxCursor(wxCURSOR_HAND));
|
||||
setsLbl->SetCursor(wxCursor(wxCURSOR_HAND));
|
||||
helpLbl->SetCursor(wxCursor(wxCURSOR_HAND));
|
||||
fileLbl->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { onOpenFileMenu(); });
|
||||
gameLbl->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { onOpenGameMenu(); });
|
||||
setsLbl->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { onOpenSetsMenu(); });
|
||||
helpLbl->Bind(wxEVT_LEFT_DOWN, [this](wxMouseEvent&) { onOpenHelpMenu(); });
|
||||
menuSizer->Add(fileLbl, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxTOP | wxBOTTOM | wxRIGHT, 4);
|
||||
menuSizer->Add(gameLbl, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxTOP | wxBOTTOM | wxRIGHT, 8);
|
||||
menuSizer->Add(setsLbl, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxTOP | wxBOTTOM | wxRIGHT, 8);
|
||||
menuSizer->Add(helpLbl, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxTOP | wxBOTTOM | wxRIGHT, 8);
|
||||
menuStrip_->SetSizer(menuSizer);
|
||||
root->Add(menuStrip_, 0, wxEXPAND);
|
||||
|
||||
auto* toolbar = new wxBoxSizer(wxHORIZONTAL);
|
||||
auto makeToolBtn = [&](int id, const char* svg, const wxString& tip) {
|
||||
wxBitmap bmp = svgIconBitmap(svg, kToolbarIconPx, "#000000");
|
||||
auto* b = new wxBitmapButton(this, id, bmp, wxDefaultPosition,
|
||||
wxDefaultSize,
|
||||
wxBU_EXACTFIT);
|
||||
b->SetToolTip(tip);
|
||||
return b;
|
||||
};
|
||||
toolbarButtons_[0] = makeToolBtn(IdCreate, kSvgToolbarAdd, "Add Card");
|
||||
toolbarButtons_[1] = makeToolBtn(IdEdit, kSvgToolbarEdit, "Edit");
|
||||
toolbarButtons_[2] = makeToolBtn(IdDelete, kSvgToolbarDelete, "Delete");
|
||||
toolbar->AddSpacer(4);
|
||||
toolbar->Add(toolbarButtons_[0], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
|
||||
toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
|
||||
toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
|
||||
toolbar->AddStretchSpacer(1);
|
||||
filterInput_ = new wxTextCtrl(this, wxID_ANY, "", wxDefaultPosition,
|
||||
wxSize(260, -1));
|
||||
filterInput_->SetHint(kFilterInputHint);
|
||||
toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4);
|
||||
root->Add(toolbar, 0, wxEXPAND);
|
||||
|
||||
splitter_ = new wxSplitterWindow(this, wxID_ANY, wxDefaultPosition,
|
||||
wxDefaultSize, wxSP_LIVE_UPDATE);
|
||||
splitter_->SetMinimumPaneSize(280);
|
||||
root->Add(splitter_, 1, wxEXPAND);
|
||||
|
||||
auto* statusPanel = new wxPanel(this, wxID_ANY);
|
||||
auto* statusSizer = new wxBoxSizer(wxHORIZONTAL);
|
||||
statusText_ = new wxStaticText(statusPanel, wxID_ANY, "Ready");
|
||||
statusSizer->Add(statusText_, 0, wxALIGN_CENTER_VERTICAL | wxLEFT | wxRIGHT, 6);
|
||||
statusPanel->SetSizer(statusSizer);
|
||||
root->Add(statusPanel, 0, wxEXPAND | wxTOP, 2);
|
||||
|
||||
SetSizer(root);
|
||||
|
||||
Bind(wxEVT_BUTTON, &MainFrame::onCreate, this, IdCreate);
|
||||
Bind(wxEVT_BUTTON, &MainFrame::onEdit, this, IdEdit);
|
||||
Bind(wxEVT_BUTTON, &MainFrame::onDelete, this, IdDelete);
|
||||
|
||||
filterInput_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) {
|
||||
if (auto* view = activeView()) {
|
||||
view->setFilter(filterInput_->GetValue().ToStdString());
|
||||
}
|
||||
});
|
||||
|
||||
// Selection changes are handled per-view (each IGameView binds
|
||||
// EVT_CARD_SELECTED on its own typed list panel and pushes the typed
|
||||
// selection into its selected panel). MainFrame only reacts to preview
|
||||
// status updates from any active selected panel.
|
||||
Bind(EVT_PREVIEW_STATUS, [this](wxCommandEvent& ev) {
|
||||
const wxString msg = ev.GetString();
|
||||
setStatusTextUi(msg.IsEmpty() ? wxString("Ready") : msg);
|
||||
});
|
||||
}
|
||||
|
||||
IGameView* MainFrame::activeView() {
|
||||
for (auto* v : ctx_.gameViews) {
|
||||
if (v != nullptr && v->gameId() == activeGame_) return v;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void MainFrame::mountActiveView() {
|
||||
auto* view = activeView();
|
||||
if (view == nullptr || splitter_ == nullptr) return;
|
||||
|
||||
// Hide every other view's panels so wx doesn't double-paint them.
|
||||
for (auto* other : ctx_.gameViews) {
|
||||
if (other == nullptr || other == view) continue;
|
||||
if (auto* lp = other->listPanel(splitter_)) lp->Hide();
|
||||
if (auto* sp = other->selectedPanel(splitter_)) sp->Hide();
|
||||
}
|
||||
|
||||
auto* listPanel = view->listPanel(splitter_);
|
||||
auto* selectedPanel = view->selectedPanel(splitter_);
|
||||
if (listPanel == nullptr || selectedPanel == nullptr) return;
|
||||
listPanel->Show();
|
||||
selectedPanel->Show();
|
||||
|
||||
if (splitter_->IsSplit()) {
|
||||
splitter_->ReplaceWindow(splitter_->GetWindow1(), selectedPanel);
|
||||
splitter_->ReplaceWindow(splitter_->GetWindow2(), listPanel);
|
||||
} else {
|
||||
splitter_->SplitVertically(selectedPanel, listPanel, 360);
|
||||
}
|
||||
|
||||
const ThemePalette palette = paletteForTheme(ctx_.config.current().theme);
|
||||
view->applyTheme(palette);
|
||||
applyThemeToWindowTree(selectedPanel, palette, ctx_.config.current().theme);
|
||||
applyThemeToWindowTree(listPanel, palette, ctx_.config.current().theme);
|
||||
}
|
||||
|
||||
void MainFrame::switchGame(Game g) {
|
||||
if (g == activeGame_) return;
|
||||
activeGame_ = g;
|
||||
mountActiveView();
|
||||
if (auto* view = activeView()) {
|
||||
if (filterInput_ != nullptr) {
|
||||
filterInput_->ChangeValue(wxString{});
|
||||
filterInput_->SetHint(kFilterInputHint);
|
||||
filterInput_->Refresh();
|
||||
}
|
||||
view->setFilter("");
|
||||
view->refreshCollection();
|
||||
setStatusTextUi(view->displayName());
|
||||
}
|
||||
}
|
||||
|
||||
void MainFrame::refreshToolbarIcons() {
|
||||
const ThemePalette palette = paletteForTheme(ctx_.config.current().theme);
|
||||
const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
|
||||
if (toolbarButtons_[0]) toolbarButtons_[0]->SetBitmap(svgIconBitmap(kSvgToolbarAdd, kToolbarIconPx, tbHex.c_str()));
|
||||
if (toolbarButtons_[1]) toolbarButtons_[1]->SetBitmap(svgIconBitmap(kSvgToolbarEdit, kToolbarIconPx, tbHex.c_str()));
|
||||
if (toolbarButtons_[2]) toolbarButtons_[2]->SetBitmap(svgIconBitmap(kSvgToolbarDelete, kToolbarIconPx, tbHex.c_str()));
|
||||
}
|
||||
|
||||
void MainFrame::applyTheme() {
|
||||
const Theme currentTheme = ctx_.config.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(this, palette, currentTheme);
|
||||
SetBackgroundColour(palette.windowBg);
|
||||
SetForegroundColour(palette.text);
|
||||
if (filterInput_ != nullptr) {
|
||||
filterInput_->SetBackgroundColour(palette.inputBg);
|
||||
filterInput_->SetForegroundColour(palette.inputText);
|
||||
filterInput_->SetOwnBackgroundColour(palette.inputBg);
|
||||
filterInput_->SetOwnForegroundColour(palette.inputText);
|
||||
filterInput_->Refresh();
|
||||
}
|
||||
for (auto* view : ctx_.gameViews) {
|
||||
if (view != nullptr) view->applyTheme(palette);
|
||||
}
|
||||
refreshToolbarIcons();
|
||||
Refresh();
|
||||
Update();
|
||||
}
|
||||
|
||||
void MainFrame::setStatusTextUi(const wxString& text) {
|
||||
if (statusText_ != nullptr) {
|
||||
statusText_->SetLabelText(text);
|
||||
}
|
||||
}
|
||||
|
||||
void MainFrame::onOpenFileMenu() {
|
||||
wxMenu menu;
|
||||
menu.Append(IdSettings, "Settings...\tCtrl+,", "Open application settings");
|
||||
menu.AppendSeparator();
|
||||
menu.Append(wxID_EXIT, "Quit\tCtrl+Q", "Exit the application");
|
||||
if (menuStrip_ != nullptr) {
|
||||
menuStrip_->PopupMenu(&menu, 4, menuStrip_->GetSize().GetHeight());
|
||||
}
|
||||
}
|
||||
|
||||
void MainFrame::onOpenGameMenu() {
|
||||
wxMenu menu;
|
||||
menuIdToGame_.clear();
|
||||
int id = IdGameMenuBase;
|
||||
for (auto* view : ctx_.gameViews) {
|
||||
if (view == nullptr) continue;
|
||||
menu.AppendRadioItem(id, view->displayName());
|
||||
menu.Check(id, view->gameId() == activeGame_);
|
||||
menuIdToGame_[id] = view->gameId();
|
||||
++id;
|
||||
}
|
||||
if (menuStrip_ != nullptr) {
|
||||
menuStrip_->PopupMenu(&menu, 44, menuStrip_->GetSize().GetHeight());
|
||||
}
|
||||
}
|
||||
|
||||
void MainFrame::onOpenSetsMenu() {
|
||||
wxMenu menu;
|
||||
menuIdToGame_.clear();
|
||||
int id = IdSetsMenuBase;
|
||||
for (auto* view : ctx_.gameViews) {
|
||||
if (view == nullptr) continue;
|
||||
menu.Append(id, view->updateSetsMenuLabel(),
|
||||
"Refresh set list from the game's API");
|
||||
menuIdToGame_[id] = view->gameId();
|
||||
++id;
|
||||
}
|
||||
if (menuStrip_ != nullptr) {
|
||||
menuStrip_->PopupMenu(&menu, 92, menuStrip_->GetSize().GetHeight());
|
||||
}
|
||||
}
|
||||
|
||||
void MainFrame::onOpenHelpMenu() {
|
||||
wxMenu menu;
|
||||
menu.Append(IdAbout, "About", "About Card Collection Manager 3");
|
||||
if (menuStrip_ != nullptr) {
|
||||
menuStrip_->PopupMenu(&menu, 136, menuStrip_->GetSize().GetHeight());
|
||||
}
|
||||
}
|
||||
|
||||
// Menu handlers ---------------------------------------------------------------
|
||||
|
||||
void MainFrame::onSettings(wxCommandEvent&) {
|
||||
const Theme beforeTheme = ctx_.config.current().theme;
|
||||
const std::string beforeDataStorage = ctx_.config.current().dataStorage;
|
||||
SettingsDialog dlg(this, ctx_.config);
|
||||
{
|
||||
const Theme currentTheme = ctx_.config.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
if (dlg.ShowModal() == wxID_OK) {
|
||||
const bool dataDirChanged = ctx_.config.current().dataStorage != beforeDataStorage;
|
||||
if (dataDirChanged) {
|
||||
ensureDataStorageScaffold(ctx_.config.current());
|
||||
for (auto* view : ctx_.gameViews) {
|
||||
if (view != nullptr) view->refreshCollection();
|
||||
}
|
||||
}
|
||||
if (ctx_.config.current().theme != beforeTheme) {
|
||||
applyTheme();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MainFrame::onQuit(wxCommandEvent&) { Close(true); }
|
||||
|
||||
void MainFrame::onSwitchGame(wxCommandEvent& ev) {
|
||||
const auto it = menuIdToGame_.find(ev.GetId());
|
||||
if (it == menuIdToGame_.end()) return;
|
||||
switchGame(it->second);
|
||||
}
|
||||
|
||||
void MainFrame::onUpdateSetsForGame(wxCommandEvent& ev) {
|
||||
const auto it = menuIdToGame_.find(ev.GetId());
|
||||
if (it == menuIdToGame_.end()) return;
|
||||
IGameView* targetView = nullptr;
|
||||
for (auto* v : ctx_.gameViews) {
|
||||
if (v != nullptr && v->gameId() == it->second) { targetView = v; break; }
|
||||
}
|
||||
if (targetView == nullptr) return;
|
||||
|
||||
setStatusTextUi("Updating " + targetView->displayName() + " sets...");
|
||||
Update();
|
||||
const auto status = targetView->onUpdateSets(this);
|
||||
setStatusTextUi(status);
|
||||
}
|
||||
|
||||
void MainFrame::onAbout(wxCommandEvent&) {
|
||||
wxDialog dlg(this, wxID_ANY, "About Card Collection Manager 3",
|
||||
wxDefaultPosition, wxDefaultSize,
|
||||
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
|
||||
|
||||
auto* root = new wxBoxSizer(wxVERTICAL);
|
||||
auto* name = new wxStaticText(&dlg, wxID_ANY, "Card Collection Manager 3");
|
||||
auto* version = new wxStaticText(&dlg, wxID_ANY, wxString("Version: ") + kAppVersion);
|
||||
auto* desc = new wxStaticText(&dlg, wxID_ANY,
|
||||
"Desktop card collection manager for Magic and Pokemon.");
|
||||
wxFont titleFont = name->GetFont();
|
||||
titleFont.MakeBold().MakeLarger();
|
||||
name->SetFont(titleFont);
|
||||
|
||||
root->Add(name, 0, wxALL, 10);
|
||||
root->Add(version, 0, wxLEFT | wxRIGHT | wxBOTTOM, 10);
|
||||
root->Add(desc, 0, wxLEFT | wxRIGHT | wxBOTTOM, 10);
|
||||
if (auto* buttons = dlg.CreateButtonSizer(wxOK)) {
|
||||
root->Add(buttons, 0, wxLEFT | wxRIGHT | wxBOTTOM | wxEXPAND, 10);
|
||||
}
|
||||
|
||||
dlg.SetSizerAndFit(root);
|
||||
const wxSize fitSize = dlg.GetSize();
|
||||
dlg.SetSize(fitSize.GetWidth(), static_cast<int>(fitSize.GetHeight() * 1.10));
|
||||
const Theme currentTheme = ctx_.config.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
dlg.CentreOnParent();
|
||||
dlg.ShowModal();
|
||||
}
|
||||
|
||||
// Toolbar handlers ------------------------------------------------------------
|
||||
|
||||
void MainFrame::onCreate(wxCommandEvent&) {
|
||||
if (auto* view = activeView()) view->onAddCard(this);
|
||||
}
|
||||
|
||||
void MainFrame::onEdit(wxCommandEvent&) {
|
||||
if (auto* view = activeView()) view->onEditCard(this);
|
||||
}
|
||||
|
||||
void MainFrame::onDelete(wxCommandEvent&) {
|
||||
if (auto* view = activeView()) view->onDeleteCard(this);
|
||||
}
|
||||
|
||||
#ifdef __WXMSW__
|
||||
WXLRESULT MainFrame::MSWWindowProc(WXUINT message, WXWPARAM wParam, WXLPARAM lParam) {
|
||||
if (message == WM_CTLCOLOREDIT && filterInput_ != nullptr) {
|
||||
const HWND target = reinterpret_cast<HWND>(lParam);
|
||||
const HWND filterHwnd = reinterpret_cast<HWND>(filterInput_->GetHandle());
|
||||
if (target != nullptr && filterHwnd != nullptr && target == filterHwnd) {
|
||||
const ThemePalette palette = paletteForTheme(ctx_.config.current().theme);
|
||||
HDC hdc = reinterpret_cast<HDC>(wParam);
|
||||
::SetTextColor(hdc, RGB(palette.inputText.Red(), palette.inputText.Green(),
|
||||
palette.inputText.Blue()));
|
||||
::SetBkColor(hdc, RGB(palette.inputBg.Red(), palette.inputBg.Green(),
|
||||
palette.inputBg.Blue()));
|
||||
::SetDCBrushColor(hdc, RGB(palette.inputBg.Red(), palette.inputBg.Green(),
|
||||
palette.inputBg.Blue()));
|
||||
return reinterpret_cast<WXLRESULT>(::GetStockObject(DC_BRUSH));
|
||||
}
|
||||
}
|
||||
return wxFrame::MSWWindowProc(message, wParam, lParam);
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "ccm/ui/PokemonCardEditDialog.hpp"
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
PokemonCardEditDialog::PokemonCardEditDialog(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
SetService& setService,
|
||||
EditMode mode,
|
||||
PokemonCard initial,
|
||||
const std::vector<Set>* preloadedSets)
|
||||
: BaseCardEditDialog<PokemonCard>(
|
||||
parent,
|
||||
mode == EditMode::Create ? "Add Pokemon Card" : "Edit Pokemon Card",
|
||||
imageService, setService, mode, std::move(initial), Game::Pokemon, preloadedSets) {
|
||||
buildAndPopulate();
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::buildFlagsRow(wxBoxSizer* flagsBox) {
|
||||
holoCheck_ = new wxCheckBox(this, wxID_ANY, "Holo");
|
||||
firstEditionCheck_ = new wxCheckBox(this, wxID_ANY, "1. Edition");
|
||||
signedCheck_ = new wxCheckBox(this, wxID_ANY, "Signed");
|
||||
alteredCheck_ = new wxCheckBox(this, wxID_ANY, "Altered");
|
||||
flagsBox->Add(holoCheck_, 0, wxRIGHT, 12);
|
||||
flagsBox->Add(firstEditionCheck_, 0, wxRIGHT, 12);
|
||||
flagsBox->Add(signedCheck_, 0, wxRIGHT, 12);
|
||||
flagsBox->Add(alteredCheck_, 0, wxRIGHT, 12);
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::appendExtraRows(wxFlexGridSizer* grid) {
|
||||
setNoCtrl_ = new wxTextCtrl(this, wxID_ANY, constCard().setNo);
|
||||
appendRow(grid, "Set #", setNoCtrl_);
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::readExtraFromCard() {
|
||||
if (setNoCtrl_) setNoCtrl_->ChangeValue(constCard().setNo);
|
||||
if (holoCheck_) holoCheck_->SetValue(constCard().holo);
|
||||
if (firstEditionCheck_) firstEditionCheck_->SetValue(constCard().firstEdition);
|
||||
if (signedCheck_) signedCheck_->SetValue(constCard().signed_);
|
||||
if (alteredCheck_) alteredCheck_->SetValue(constCard().altered);
|
||||
}
|
||||
|
||||
void PokemonCardEditDialog::writeExtraToCard() {
|
||||
if (setNoCtrl_) mutableCard().setNo = setNoCtrl_->GetValue().ToStdString();
|
||||
if (holoCheck_) mutableCard().holo = holoCheck_->IsChecked();
|
||||
if (firstEditionCheck_) mutableCard().firstEdition = firstEditionCheck_->IsChecked();
|
||||
if (signedCheck_) mutableCard().signed_ = signedCheck_->IsChecked();
|
||||
if (alteredCheck_) mutableCard().altered = alteredCheck_->IsChecked();
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,75 @@
|
||||
#include "ccm/ui/PokemonCardListPanel.hpp"
|
||||
|
||||
#include "ccm/services/CardFilter.hpp"
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
PokemonCardListPanel::PokemonCardListPanel(wxWindow* parent)
|
||||
: BaseCardListPanel<PokemonCard, PokemonSortColumn>(parent) {
|
||||
buildLayout();
|
||||
}
|
||||
|
||||
std::vector<PokemonCardListPanel::TextColumnSpec>
|
||||
PokemonCardListPanel::declareTextColumns() const {
|
||||
// Order mirrors the Magic table for visual parity. Pokemon adds two
|
||||
// additional flag-icon columns (Holo, FirstEdition) but keeps the same
|
||||
// leading text-column shape. setNo is not displayed in the table; it
|
||||
// appears in the detail panel and is searchable through the filter.
|
||||
return {
|
||||
{"Name", 220, wxLIST_FORMAT_LEFT, PokemonSortColumn::Name},
|
||||
{"Set", 180, wxLIST_FORMAT_LEFT, PokemonSortColumn::SetReleaseDate},
|
||||
{"Amount", 70, wxLIST_FORMAT_RIGHT, PokemonSortColumn::Amount},
|
||||
{"Condition", 100, wxLIST_FORMAT_LEFT, PokemonSortColumn::Condition},
|
||||
{"Language", 100, wxLIST_FORMAT_LEFT, PokemonSortColumn::Language},
|
||||
{"Note", 220, wxLIST_FORMAT_LEFT, PokemonSortColumn::Note},
|
||||
};
|
||||
}
|
||||
|
||||
std::vector<PokemonCardListPanel::IconColumnSpec>
|
||||
PokemonCardListPanel::declareIconColumns() const {
|
||||
constexpr int kFlagColWidth = 36;
|
||||
return {
|
||||
{kSvgHolo, kFlagColWidth, PokemonSortColumn::Holo},
|
||||
{kSvgFirstEdition, kFlagColWidth, PokemonSortColumn::FirstEdition},
|
||||
{kSvgSigned, kFlagColWidth, PokemonSortColumn::Signed},
|
||||
{kSvgAltered, kFlagColWidth, PokemonSortColumn::Altered},
|
||||
};
|
||||
}
|
||||
|
||||
std::string PokemonCardListPanel::renderTextCell(const PokemonCard& card,
|
||||
std::size_t idx) const {
|
||||
switch (idx) {
|
||||
case 0: return card.name;
|
||||
case 1: return card.set.name;
|
||||
case 2: return std::to_string(card.amount);
|
||||
case 3: return std::string(to_string(card.condition));
|
||||
case 4: return std::string(to_string(card.language));
|
||||
case 5: return card.note;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool PokemonCardListPanel::isIconColumnSet(const PokemonCard& card,
|
||||
std::size_t idx) const {
|
||||
switch (idx) {
|
||||
case 0: return card.holo;
|
||||
case 1: return card.firstEdition;
|
||||
case 2: return card.signed_;
|
||||
case 3: return card.altered;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void PokemonCardListPanel::sortBy(PokemonSortColumn column, bool ascending) {
|
||||
sortPokemonCards(mutableCards(), column, ascending);
|
||||
}
|
||||
|
||||
bool PokemonCardListPanel::matchesFilter(const PokemonCard& card,
|
||||
std::string_view filter) const {
|
||||
return matchesPokemonFilter(card, filter);
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,198 @@
|
||||
#include "ccm/ui/PokemonGameView.hpp"
|
||||
|
||||
#include "ccm/ui/PokemonCardEditDialog.hpp"
|
||||
#include "ccm/ui/PokemonCardListPanel.hpp"
|
||||
#include "ccm/ui/PokemonSelectedCardPanel.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
PokemonGameView::PokemonGameView(ConfigService& config,
|
||||
CollectionService<PokemonCard>& collection,
|
||||
SetService& sets,
|
||||
ImageService& images,
|
||||
CardPreviewService& cardPreview,
|
||||
IGameModule& module)
|
||||
: config_(config),
|
||||
collection_(collection),
|
||||
sets_(sets),
|
||||
images_(images),
|
||||
cardPreview_(cardPreview),
|
||||
module_(module) {}
|
||||
|
||||
void PokemonGameView::ensureSetsLoaded() {
|
||||
if (attemptedInitialSetLoad_) return;
|
||||
attemptedInitialSetLoad_ = true;
|
||||
|
||||
auto cached = sets_.getSets(Game::Pokemon);
|
||||
if (cached) {
|
||||
setsCache_ = std::move(cached).value();
|
||||
if (!setsCache_.empty()) return;
|
||||
} else {
|
||||
setsCache_.clear();
|
||||
}
|
||||
|
||||
auto refreshed = sets_.updateSets(Game::Pokemon);
|
||||
if (refreshed) {
|
||||
setsCache_ = std::move(refreshed).value();
|
||||
}
|
||||
}
|
||||
|
||||
wxPanel* PokemonGameView::listPanel(wxWindow* parent) {
|
||||
if (listPanel_ == nullptr) {
|
||||
listPanel_ = new PokemonCardListPanel(parent);
|
||||
listPanel_->Bind(EVT_CARD_SELECTED, [this](wxCommandEvent&) {
|
||||
if (selectedPanel_ != nullptr && listPanel_ != nullptr) {
|
||||
selectedPanel_->setCard(listPanel_->selected());
|
||||
}
|
||||
});
|
||||
}
|
||||
return listPanel_;
|
||||
}
|
||||
|
||||
wxPanel* PokemonGameView::selectedPanel(wxWindow* parent) {
|
||||
if (selectedPanel_ == nullptr) {
|
||||
selectedPanel_ = new PokemonSelectedCardPanel(parent, images_, cardPreview_);
|
||||
}
|
||||
return selectedPanel_;
|
||||
}
|
||||
|
||||
void PokemonGameView::refreshCollection() {
|
||||
if (listPanel_ == nullptr) return;
|
||||
auto loaded = collection_.list(Game::Pokemon);
|
||||
if (!loaded) {
|
||||
showThemedMessageDialog(nullptr, "Failed to load Pokemon collection: " + loaded.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
listPanel_->setCards(std::move(loaded).value());
|
||||
listPanel_->activateSelection();
|
||||
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
|
||||
}
|
||||
|
||||
const std::vector<Set>& PokemonGameView::setsForDialog() {
|
||||
ensureSetsLoaded();
|
||||
if (!setsCache_.empty()) return setsCache_;
|
||||
auto loaded = sets_.getSets(Game::Pokemon);
|
||||
if (loaded) setsCache_ = std::move(loaded).value();
|
||||
else setsCache_.clear();
|
||||
return setsCache_;
|
||||
}
|
||||
|
||||
void PokemonGameView::onAddCard(wxWindow* parentWindow) {
|
||||
PokemonCard fresh;
|
||||
fresh.amount = 1;
|
||||
fresh.language = Language::English;
|
||||
fresh.condition = Condition::NearMint;
|
||||
|
||||
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Create, fresh,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
|
||||
auto added = collection_.add(Game::Pokemon, dlg.card());
|
||||
if (!added) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to add card: " + added.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
PokemonCard persisted = dlg.card();
|
||||
persisted.id = added.value();
|
||||
auto normalized = images_.normalizeNamesForPersistedCard(
|
||||
Game::Pokemon, persisted.id, persisted.set.name, persisted.name, persisted.images);
|
||||
if (normalized) {
|
||||
if (normalized.value() != persisted.images) {
|
||||
persisted.images = std::move(normalized).value();
|
||||
auto updated = collection_.update(Game::Pokemon, persisted);
|
||||
if (!updated) {
|
||||
showThemedMessageDialog(parentWindow, "Card added, but image name normalization failed to persist: " + updated.error(),
|
||||
"Warning", wxOK | wxICON_WARNING);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showThemedMessageDialog(parentWindow, "Card added, but image rename to ID-prefixed format failed: " + normalized.error(),
|
||||
"Warning", wxOK | wxICON_WARNING);
|
||||
}
|
||||
refreshCollection();
|
||||
}
|
||||
|
||||
void PokemonGameView::onEditCard(wxWindow* parentWindow) {
|
||||
if (listPanel_ == nullptr) return;
|
||||
auto sel = listPanel_->selected();
|
||||
if (!sel) {
|
||||
showThemedMessageDialog(parentWindow, "Select a card first.", "Edit", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
PokemonCardEditDialog dlg(parentWindow, images_, sets_, EditMode::Edit, *sel,
|
||||
&setsForDialog());
|
||||
{
|
||||
const Theme currentTheme = config_.current().theme;
|
||||
const ThemePalette palette = paletteForTheme(currentTheme);
|
||||
applyThemeToWindowTree(&dlg, palette, currentTheme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
}
|
||||
if (dlg.ShowModal() != wxID_OK) return;
|
||||
auto updated = collection_.update(Game::Pokemon, dlg.card());
|
||||
if (!updated) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to update card: " + updated.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
refreshCollection();
|
||||
}
|
||||
|
||||
void PokemonGameView::onDeleteCard(wxWindow* parentWindow) {
|
||||
if (listPanel_ == nullptr) return;
|
||||
auto sel = listPanel_->selected();
|
||||
if (!sel) {
|
||||
showThemedMessageDialog(parentWindow, "Select a card first.", "Delete", wxOK | wxICON_INFORMATION);
|
||||
return;
|
||||
}
|
||||
if (showThemedConfirmDialog(parentWindow, "Delete \"" + sel->name + "\"?",
|
||||
"Confirm") != wxID_YES) {
|
||||
return;
|
||||
}
|
||||
auto removed = collection_.remove(Game::Pokemon, sel->id);
|
||||
if (!removed) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to delete card: " + removed.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
refreshCollection();
|
||||
}
|
||||
|
||||
std::string PokemonGameView::onUpdateSets(wxWindow* parentWindow) {
|
||||
auto out = sets_.updateSets(Game::Pokemon);
|
||||
if (!out) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return "Update failed";
|
||||
}
|
||||
setsCache_ = out.value();
|
||||
showThemedMessageDialog(parentWindow, "Updated " + std::to_string(out.value().size()) + " Pokemon sets.",
|
||||
"Sets updated", wxOK | wxICON_INFORMATION);
|
||||
return "Pokemon sets updated.";
|
||||
}
|
||||
|
||||
void PokemonGameView::setFilter(std::string_view filter) {
|
||||
if (listPanel_) listPanel_->setFilter(filter);
|
||||
}
|
||||
|
||||
void PokemonGameView::applyTheme(const ThemePalette& palette) {
|
||||
if (listPanel_) listPanel_->applyTheme(palette);
|
||||
if (selectedPanel_) selectedPanel_->applyTheme(palette);
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,82 @@
|
||||
#include "ccm/ui/PokemonSelectedCardPanel.hpp"
|
||||
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
namespace {
|
||||
enum PokemonDetailKey : int {
|
||||
kName = 0,
|
||||
kSet,
|
||||
kSetNo,
|
||||
kLanguage,
|
||||
kCondition,
|
||||
kAmount,
|
||||
kHolo,
|
||||
kFirstEdition,
|
||||
kSigned,
|
||||
kAltered,
|
||||
};
|
||||
} // namespace
|
||||
|
||||
PokemonSelectedCardPanel::PokemonSelectedCardPanel(wxWindow* parent,
|
||||
ImageService& imageService,
|
||||
CardPreviewService& cardPreview)
|
||||
: BaseSelectedCardPanel<PokemonCard>(parent, imageService, cardPreview) {
|
||||
buildLayout();
|
||||
}
|
||||
|
||||
std::vector<PokemonSelectedCardPanel::DetailRowSpec>
|
||||
PokemonSelectedCardPanel::declareDetailRows() const {
|
||||
return {
|
||||
{"Name", kName, "(no card selected)"},
|
||||
{"Set", kSet, ""},
|
||||
{"Set #", kSetNo, ""},
|
||||
{"Language", kLanguage, ""},
|
||||
{"Condition", kCondition, ""},
|
||||
{"Amount", kAmount, ""},
|
||||
};
|
||||
}
|
||||
|
||||
std::vector<PokemonSelectedCardPanel::FlagIconSpec>
|
||||
PokemonSelectedCardPanel::declareFlagIcons() const {
|
||||
return {
|
||||
{kSvgHolo, "Holo", kHolo},
|
||||
{kSvgFirstEdition, "1. Edition", kFirstEdition},
|
||||
{kSvgSigned, "Signed", kSigned},
|
||||
{kSvgAltered, "Altered", kAltered},
|
||||
};
|
||||
}
|
||||
|
||||
std::string PokemonSelectedCardPanel::detailValueFor(const PokemonCard& card,
|
||||
DetailKey key) const {
|
||||
switch (key) {
|
||||
case kName: return card.name;
|
||||
case kSet: return card.set.name;
|
||||
case kSetNo: return card.setNo;
|
||||
case kLanguage: return std::string(to_string(card.language));
|
||||
case kCondition: return std::string(to_string(card.condition));
|
||||
case kAmount: return std::to_string(card.amount);
|
||||
case kNoteKey: return card.note;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool PokemonSelectedCardPanel::isFlagSet(const PokemonCard& card, DetailKey key) const {
|
||||
switch (key) {
|
||||
case kHolo: return card.holo;
|
||||
case kFirstEdition: return card.firstEdition;
|
||||
case kSigned: return card.signed_;
|
||||
case kAltered: return card.altered;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::tuple<std::string, std::string, std::string>
|
||||
PokemonSelectedCardPanel::previewKey(const PokemonCard& card) const {
|
||||
return {card.name, card.set.id, card.setNo};
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "ccm/ui/SettingsDialog.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/button.h>
|
||||
#include <wx/dirdlg.h>
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/stattext.h>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
SettingsDialog::SettingsDialog(wxWindow* parent, ConfigService& config)
|
||||
: wxDialog(parent, wxID_ANY, "Settings",
|
||||
wxDefaultPosition, wxSize(560, 200),
|
||||
wxDEFAULT_DIALOG_STYLE),
|
||||
config_(config) {
|
||||
auto* root = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
auto* dirRow = new wxBoxSizer(wxHORIZONTAL);
|
||||
dirRow->Add(new wxStaticText(this, wxID_ANY, "Data directory:"),
|
||||
0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
|
||||
dataDirCtrl_ = new wxTextCtrl(this, wxID_ANY, config_.current().dataStorage);
|
||||
dirRow->Add(dataDirCtrl_, 1, wxEXPAND | wxRIGHT, 6);
|
||||
auto* browse = new wxButton(this, wxID_ANY, "Browse...");
|
||||
browse->Bind(wxEVT_BUTTON, &SettingsDialog::onBrowse, this);
|
||||
dirRow->Add(browse, 0);
|
||||
root->Add(dirRow, 0, wxEXPAND | wxALL, 10);
|
||||
|
||||
auto* gameRow = new wxBoxSizer(wxHORIZONTAL);
|
||||
gameRow->Add(new wxStaticText(this, wxID_ANY, "Default game:"),
|
||||
0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
|
||||
defaultGameChoice_ = new wxChoice(this, wxID_ANY);
|
||||
defaultGameChoice_->Append("Magic");
|
||||
defaultGameChoice_->Append("Pokemon");
|
||||
defaultGameChoice_->SetSelection(config_.current().defaultGame == Game::Magic ? 0 : 1);
|
||||
gameRow->Add(defaultGameChoice_, 0);
|
||||
root->Add(gameRow, 0, wxEXPAND | wxLEFT | wxRIGHT, 10);
|
||||
|
||||
auto* themeRow = new wxBoxSizer(wxHORIZONTAL);
|
||||
themeRow->Add(new wxStaticText(this, wxID_ANY, "Theme:"),
|
||||
0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 6);
|
||||
themeChoice_ = new wxChoice(this, wxID_ANY);
|
||||
themeChoice_->Append("Light");
|
||||
themeChoice_->Append("Dark");
|
||||
switch (config_.current().theme) {
|
||||
case Theme::Dark: themeChoice_->SetSelection(1); break;
|
||||
case Theme::Light:
|
||||
default: themeChoice_->SetSelection(0); break;
|
||||
}
|
||||
themeRow->Add(themeChoice_, 0);
|
||||
root->Add(themeRow, 0, wxEXPAND | wxALL, 10);
|
||||
|
||||
auto* btns = CreateButtonSizer(wxOK | wxCANCEL);
|
||||
if (btns) root->Add(btns, 0, wxALL | wxEXPAND, 10);
|
||||
Bind(wxEVT_BUTTON, &SettingsDialog::onOk, this, wxID_OK);
|
||||
|
||||
SetSizer(root);
|
||||
|
||||
// Ensure long paths are initially shown from the start, not scrolled right.
|
||||
CallAfter([this]() {
|
||||
if (dataDirCtrl_) {
|
||||
dataDirCtrl_->SetInsertionPoint(0);
|
||||
dataDirCtrl_->ShowPosition(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SettingsDialog::onBrowse(wxCommandEvent&) {
|
||||
wxDirDialog dlg(this, "Choose data directory",
|
||||
dataDirCtrl_->GetValue(),
|
||||
wxDD_DEFAULT_STYLE | wxDD_DIR_MUST_EXIST);
|
||||
if (dlg.ShowModal() == wxID_OK) {
|
||||
dataDirCtrl_->SetValue(dlg.GetPath());
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsDialog::onOk(wxCommandEvent& ev) {
|
||||
Configuration next = config_.current();
|
||||
next.dataStorage = dataDirCtrl_->GetValue().ToStdString();
|
||||
next.defaultGame = defaultGameChoice_->GetSelection() == 0 ? Game::Magic : Game::Pokemon;
|
||||
switch (themeChoice_->GetSelection()) {
|
||||
case 1: next.theme = Theme::Dark; break;
|
||||
case 0:
|
||||
default:
|
||||
next.theme = Theme::Light;
|
||||
break;
|
||||
}
|
||||
auto stored = config_.store(std::move(next));
|
||||
if (!stored) {
|
||||
showThemedMessageDialog(this, "Failed to save settings: " + stored.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
ev.Skip();
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
|
||||
#include <wx/bmpbndl.h>
|
||||
#include <wx/image.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
const char* const kSvgFoil = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path fill="@FILL@" d="M208 512l-29.86-80.13L98 401.86 178.14 372 208 292l29.86 80.13L318 401.86 237.86 432zM382 269l-22.4-60.11L299.47 186.4 359.6 164l22.4-60.11 22.4 60.11 60.13 22.4-60.13 22.4zM160 192l-26.06-69.94L64 96l69.94-26.06L160 0l26.06 69.94L256 96l-69.94 26.06z"/>
|
||||
</svg>)SVG";
|
||||
|
||||
const char* const kSvgSigned = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="@FILL@" d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.5.5 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11z"/>
|
||||
</svg>)SVG";
|
||||
|
||||
const char* const kSvgAltered = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="@FILL@" d="M12.433 10.07C14.133 10.585 16 11.15 16 8a8 8 0 1 0-8 8c1.996 0 1.826-1.504 1.649-3.08-.124-1.101-.252-2.237.351-2.92.465-.527 1.42-.237 2.433.07ZM8 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m-3 4a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m6-2a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M11 12a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>
|
||||
</svg>)SVG";
|
||||
|
||||
// Pokemon Holo: the original `PokemonTable.tsx` reuses `IoSparklesSharp` from
|
||||
// react-icons/io5 (the same path used for Magic foil). We keep one SVG per
|
||||
// concept here so future divergence (e.g. a unique Pokemon holographic glyph)
|
||||
// can swap kSvgHolo without touching kSvgFoil.
|
||||
const char* const kSvgHolo = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path fill="@FILL@" d="M208 512l-29.86-80.13L98 401.86 178.14 372 208 292l29.86 80.13L318 401.86 237.86 432zM382 269l-22.4-60.11L299.47 186.4 359.6 164l22.4-60.11 22.4 60.11 60.13 22.4-60.13 22.4zM160 192l-26.06-69.94L64 96l69.94-26.06L160 0l26.06 69.94L256 96l-69.94 26.06z"/>
|
||||
</svg>)SVG";
|
||||
|
||||
// Pokemon 1st Edition: a circular badge enclosing a stylised "1." digit.
|
||||
// All strokes/fills go through `@FILL@` so the icon themes alongside foil /
|
||||
// signed / altered (transparent background, content takes the runtime
|
||||
// fill color). The digit is built from rounded rects rather than a `<text>`
|
||||
// element because NanoSVG (the SVG backend behind `wxBitmapBundle::FromSVG`)
|
||||
// does not render text nodes.
|
||||
const char* const kSvgFirstEdition = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="6.6" fill="none" stroke="@FILL@" stroke-width="1.2"/>
|
||||
<rect x="7.0" y="4.1" width="2.0" height="7.2" rx="0.5" fill="@FILL@"/>
|
||||
<rect x="6.0" y="4.7" width="1.8" height="1.4" rx="0.35" fill="@FILL@"/>
|
||||
</svg>)SVG";
|
||||
|
||||
// vscode-codicons — MIT License (Microsoft). Paths mirror VscAdd /
|
||||
// VscEdit / VscTrash from react-icons/vsc.
|
||||
const char* const kSvgToolbarAdd = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="@FILL@" d="M8 1.5C8 1.22386 7.77614 1 7.5 1C7.22386 1 7 1.22386 7 1.5V7H1.5C1.22386 7 1 7.22386 1 7.5C1 7.77614 1.22386 8 1.5 8H7V13.5C7 13.7761 7.22386 14 7.5 14C7.77614 14 8 13.7761 8 13.5V8H13.5C13.7761 8 14 7.77614 14 7.5C14 7.22386 13.7761 7 13.5 7H8V1.5Z"/>
|
||||
</svg>)SVG";
|
||||
|
||||
const char* const kSvgToolbarEdit = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="@FILL@" d="M14.236 1.76386C13.2123 0.740172 11.5525 0.740171 10.5289 1.76386L2.65722 9.63549C2.28304 10.0097 2.01623 10.4775 1.88467 10.99L1.01571 14.3755C0.971767 14.5467 1.02148 14.7284 1.14646 14.8534C1.27144 14.9783 1.45312 15.028 1.62432 14.9841L5.00978 14.1151C5.52234 13.9836 5.99015 13.7168 6.36433 13.3426L14.236 5.47097C15.2596 4.44728 15.2596 2.78755 14.236 1.76386ZM11.236 2.47097C11.8691 1.8378 12.8957 1.8378 13.5288 2.47097C14.162 3.10413 14.162 4.1307 13.5288 4.76386L12.75 5.54269L10.4571 3.24979L11.236 2.47097ZM9.75002 3.9569L12.0429 6.24979L5.65722 12.6355C5.40969 12.883 5.10023 13.0595 4.76117 13.1465L2.19447 13.8053L2.85327 11.2386C2.9403 10.8996 3.1168 10.5901 3.36433 10.3426L9.75002 3.9569Z"/>
|
||||
</svg>)SVG";
|
||||
|
||||
const char* const kSvgToolbarDelete = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="@FILL@" d="M14 2H10C10 0.897 9.103 0 8 0C6.897 0 6 0.897 6 2H2C1.724 2 1.5 2.224 1.5 2.5C1.5 2.776 1.724 3 2 3H2.54L3.349 12.708C3.456 13.994 4.55 15 5.84 15H10.159C11.449 15 12.543 13.993 12.65 12.708L13.459 3H13.999C14.275 3 14.499 2.776 14.499 2.5C14.499 2.224 14.275 2 13.999 2H14ZM8 1C8.551 1 9 1.449 9 2H7C7 1.449 7.449 1 8 1ZM11.655 12.625C11.591 13.396 10.934 14 10.16 14H5.841C5.067 14 4.41 13.396 4.346 12.625L3.544 3H12.458L11.656 12.625H11.655ZM7 5.5V11.5C7 11.776 6.776 12 6.5 12C6.224 12 6 11.776 6 11.5V5.5C6 5.224 6.224 5 6.5 5C6.776 5 7 5.224 7 5.5ZM10 5.5V11.5C10 11.776 9.776 12 9.5 12C9.224 12 9 11.776 9 11.5V5.5C9 5.224 9.224 5 9.5 5C9.776 5 10 5.224 10 5.5Z"/>
|
||||
</svg>)SVG";
|
||||
|
||||
namespace {
|
||||
|
||||
// Substitute every "@FILL@" occurrence in `tmpl` with `fill`.
|
||||
std::string applyFill(const char* tmpl, const char* fill) {
|
||||
std::string s(tmpl);
|
||||
constexpr std::string_view kPlaceholder = "@FILL@";
|
||||
for (std::string::size_type pos = s.find(kPlaceholder);
|
||||
pos != std::string::npos;
|
||||
pos = s.find(kPlaceholder, pos + std::strlen(fill))) {
|
||||
s.replace(pos, kPlaceholder.size(), fill);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
wxBitmap svgIconBitmap(const char* svg, int size, const char* fillHex) {
|
||||
const auto filled = applyFill(svg, fillHex);
|
||||
const auto bundle = wxBitmapBundle::FromSVG(
|
||||
reinterpret_cast<const wxByte*>(filled.data()),
|
||||
filled.size(),
|
||||
wxSize(size, size));
|
||||
if (!bundle.IsOk()) {
|
||||
wxImage img(size, size);
|
||||
img.SetAlpha();
|
||||
if (auto* a = img.GetAlpha()) std::fill(a, a + size * size, 0);
|
||||
return wxBitmap(img);
|
||||
}
|
||||
return bundle.GetBitmap(wxSize(size, size));
|
||||
}
|
||||
|
||||
wxBitmap paddedSvgIcon(const char* svg, int iconSize, wxSize container,
|
||||
const char* fillHex, int xOffsetPx) {
|
||||
const int cw = container.GetWidth();
|
||||
const int ch = container.GetHeight();
|
||||
|
||||
wxImage canvas(cw, ch);
|
||||
canvas.SetAlpha();
|
||||
if (auto* a = canvas.GetAlpha()) std::fill(a, a + cw * ch, 0);
|
||||
|
||||
wxBitmap iconBmp = svgIconBitmap(svg, iconSize, fillHex);
|
||||
wxImage iconImg = iconBmp.ConvertToImage();
|
||||
if (!iconImg.HasAlpha()) iconImg.InitAlpha();
|
||||
|
||||
int dx = (cw - iconSize) / 2 + xOffsetPx;
|
||||
dx = std::clamp(dx, 0, std::max(0, cw - iconSize));
|
||||
const int dy = (ch - iconSize) / 2;
|
||||
canvas.Paste(iconImg, dx, dy, wxIMAGE_ALPHA_BLEND_COMPOSE);
|
||||
return wxBitmap(canvas);
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -0,0 +1,684 @@
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/button.h>
|
||||
#include <wx/bmpbuttn.h>
|
||||
#include <wx/choice.h>
|
||||
#include <wx/dcbuffer.h>
|
||||
#include <wx/frame.h>
|
||||
#include <wx/dialog.h>
|
||||
#include <wx/listbox.h>
|
||||
#include <wx/listctrl.h>
|
||||
#include <wx/statusbr.h>
|
||||
#include <wx/spinctrl.h>
|
||||
#include <wx/statbmp.h>
|
||||
#include <wx/stattext.h>
|
||||
#include <wx/settings.h>
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/textctrl.h>
|
||||
#include <wx/toplevel.h>
|
||||
#include <wx/window.h>
|
||||
|
||||
#include <unordered_set>
|
||||
|
||||
#ifdef __WXMSW__
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
#endif
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
namespace {
|
||||
std::unordered_set<wxWindow*> gButtonHoverBound;
|
||||
std::unordered_set<wxWindow*> gDialogGripLayoutBound;
|
||||
struct ButtonVisualState {
|
||||
wxColour normalBg;
|
||||
wxColour hoverBg;
|
||||
wxColour pressedBg;
|
||||
wxColour text;
|
||||
bool darkLike{false};
|
||||
bool hovered{false};
|
||||
bool pressed{false};
|
||||
bool focused{false};
|
||||
};
|
||||
std::unordered_map<wxWindow*, ButtonVisualState> gButtonVisualStates;
|
||||
struct GripVisualState {
|
||||
wxColour bg;
|
||||
wxColour line;
|
||||
};
|
||||
std::unordered_map<wxWindow*, GripVisualState> gGripVisualStates;
|
||||
|
||||
wxColour lightenTowardWhite(const wxColour& c, int amount) {
|
||||
auto lift = [amount](unsigned char channel) -> unsigned char {
|
||||
const int raised = static_cast<int>(channel) + amount;
|
||||
return static_cast<unsigned char>(raised > 255 ? 255 : raised);
|
||||
};
|
||||
return wxColour(lift(c.Red()), lift(c.Green()), lift(c.Blue()));
|
||||
}
|
||||
|
||||
bool isDarkLikeTheme(Theme theme) {
|
||||
return theme == Theme::Dark;
|
||||
}
|
||||
|
||||
void ensureDarkDialogResizeGrip(wxWindow* window, const ThemePalette& palette, Theme theme) {
|
||||
auto* dialog = dynamic_cast<wxDialog*>(window);
|
||||
if (dialog == nullptr) return;
|
||||
if ((dialog->GetWindowStyleFlag() & wxRESIZE_BORDER) == 0) return;
|
||||
|
||||
constexpr int kGripSize = 16;
|
||||
const wxString kGripName = "ccm_dark_resize_grip_overlay";
|
||||
wxWindow* grip = wxWindow::FindWindowByName(kGripName, dialog);
|
||||
|
||||
if (!isDarkLikeTheme(theme)) {
|
||||
if (grip != nullptr) {
|
||||
gGripVisualStates.erase(grip);
|
||||
grip->Destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (grip == nullptr) {
|
||||
grip = new wxWindow(dialog, wxID_ANY, wxDefaultPosition, wxSize(kGripSize, kGripSize),
|
||||
wxBORDER_NONE);
|
||||
grip->SetName(kGripName);
|
||||
grip->SetCursor(wxCursor(wxCURSOR_SIZENWSE));
|
||||
grip->SetBackgroundStyle(wxBG_STYLE_PAINT);
|
||||
|
||||
grip->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
|
||||
grip->Bind(wxEVT_PAINT, [grip](wxPaintEvent&) {
|
||||
wxAutoBufferedPaintDC dc(grip);
|
||||
const auto it = gGripVisualStates.find(grip);
|
||||
const wxColour bg = (it != gGripVisualStates.end()) ? it->second.bg : wxColour(45, 45, 45);
|
||||
const wxColour line = (it != gGripVisualStates.end()) ? it->second.line : wxColour(110, 110, 110);
|
||||
|
||||
const wxRect rect = grip->GetClientRect();
|
||||
dc.SetPen(*wxTRANSPARENT_PEN);
|
||||
dc.SetBrush(wxBrush(bg));
|
||||
dc.DrawRectangle(rect);
|
||||
|
||||
dc.SetPen(wxPen(line, 1));
|
||||
const int r = rect.GetRight();
|
||||
const int b = rect.GetBottom();
|
||||
dc.DrawLine(r - 11, b, r, b - 11);
|
||||
dc.DrawLine(r - 7, b, r, b - 7);
|
||||
dc.DrawLine(r - 3, b, r, b - 3);
|
||||
});
|
||||
#ifdef __WXMSW__
|
||||
grip->Bind(wxEVT_LEFT_DOWN, [dialog](wxMouseEvent&) {
|
||||
const HWND hwnd = reinterpret_cast<HWND>(dialog->GetHandle());
|
||||
if (hwnd == nullptr) return;
|
||||
::ReleaseCapture();
|
||||
::SendMessageW(hwnd, WM_NCLBUTTONDOWN, HTBOTTOMRIGHT, 0);
|
||||
});
|
||||
#endif
|
||||
grip->Bind(wxEVT_DESTROY, [grip](wxWindowDestroyEvent& ev) {
|
||||
gGripVisualStates.erase(grip);
|
||||
ev.Skip();
|
||||
});
|
||||
}
|
||||
|
||||
gGripVisualStates[grip] = GripVisualState{
|
||||
palette.panelBg,
|
||||
lightenTowardWhite(palette.panelBg, 48),
|
||||
};
|
||||
|
||||
auto placeGrip = [dialog, grip]() {
|
||||
const wxSize cs = dialog->GetClientSize();
|
||||
const int w = kGripSize;
|
||||
const int h = kGripSize;
|
||||
grip->SetSize(std::max(0, cs.GetWidth() - w), std::max(0, cs.GetHeight() - h), w, h);
|
||||
grip->Raise();
|
||||
};
|
||||
placeGrip();
|
||||
grip->Show();
|
||||
grip->Refresh();
|
||||
|
||||
if (!gDialogGripLayoutBound.count(dialog)) {
|
||||
gDialogGripLayoutBound.insert(dialog);
|
||||
dialog->Bind(wxEVT_SIZE, [dialog](wxSizeEvent& ev) {
|
||||
if (wxWindow* w = wxWindow::FindWindowByName("ccm_dark_resize_grip_overlay", dialog)) {
|
||||
constexpr int kSize = 16;
|
||||
const wxSize cs = dialog->GetClientSize();
|
||||
w->SetSize(std::max(0, cs.GetWidth() - kSize), std::max(0, cs.GetHeight() - kSize), kSize, kSize);
|
||||
w->Raise();
|
||||
}
|
||||
ev.Skip();
|
||||
});
|
||||
dialog->Bind(wxEVT_DESTROY, [dialog](wxWindowDestroyEvent& ev) {
|
||||
gDialogGripLayoutBound.erase(dialog);
|
||||
ev.Skip();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef __WXMSW__
|
||||
namespace {
|
||||
|
||||
using SetWindowThemeFn = HRESULT(WINAPI*)(HWND, LPCWSTR, LPCWSTR);
|
||||
using DwmSetWindowAttributeFn = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD);
|
||||
using AllowDarkModeForWindowFn = BOOL(WINAPI*)(HWND, BOOL);
|
||||
enum class PreferredAppMode : int {
|
||||
Default = 0,
|
||||
AllowDark = 1,
|
||||
ForceDark = 2,
|
||||
ForceLight = 3,
|
||||
Max = 4
|
||||
};
|
||||
using SetPreferredAppModeFn = PreferredAppMode(WINAPI*)(PreferredAppMode);
|
||||
using FlushMenuThemesFn = VOID(WINAPI*)();
|
||||
|
||||
#ifndef HDM_SETBKCOLOR
|
||||
#define HDM_SETBKCOLOR (HDM_FIRST + 19)
|
||||
#endif
|
||||
#ifndef HDM_SETTEXTCOLOR
|
||||
#define HDM_SETTEXTCOLOR (HDM_FIRST + 20)
|
||||
#endif
|
||||
|
||||
SetWindowThemeFn resolveSetWindowTheme() {
|
||||
static HMODULE uxthemeModule = ::LoadLibraryW(L"uxtheme.dll");
|
||||
static auto setWindowTheme = reinterpret_cast<SetWindowThemeFn>(
|
||||
uxthemeModule ? ::GetProcAddress(uxthemeModule, "SetWindowTheme") : nullptr);
|
||||
return setWindowTheme;
|
||||
}
|
||||
|
||||
AllowDarkModeForWindowFn resolveAllowDarkModeForWindow() {
|
||||
static HMODULE uxthemeModule = ::LoadLibraryW(L"uxtheme.dll");
|
||||
static auto fn = reinterpret_cast<AllowDarkModeForWindowFn>(
|
||||
uxthemeModule ? ::GetProcAddress(uxthemeModule, MAKEINTRESOURCEA(133)) : nullptr);
|
||||
return fn;
|
||||
}
|
||||
|
||||
SetPreferredAppModeFn resolveSetPreferredAppMode() {
|
||||
static HMODULE uxthemeModule = ::LoadLibraryW(L"uxtheme.dll");
|
||||
static auto fn = reinterpret_cast<SetPreferredAppModeFn>(
|
||||
uxthemeModule ? ::GetProcAddress(uxthemeModule, MAKEINTRESOURCEA(135)) : nullptr);
|
||||
return fn;
|
||||
}
|
||||
|
||||
FlushMenuThemesFn resolveFlushMenuThemes() {
|
||||
static HMODULE uxthemeModule = ::LoadLibraryW(L"uxtheme.dll");
|
||||
static auto fn = reinterpret_cast<FlushMenuThemesFn>(
|
||||
uxthemeModule ? ::GetProcAddress(uxthemeModule, MAKEINTRESOURCEA(136)) : nullptr);
|
||||
return fn;
|
||||
}
|
||||
|
||||
void applyNativeClassTheme(wxWindow* window, Theme theme, const wchar_t* darkClass, const wchar_t* lightClass) {
|
||||
if (window == nullptr) return;
|
||||
const HWND hwnd = reinterpret_cast<HWND>(window->GetHandle());
|
||||
if (hwnd == nullptr) return;
|
||||
|
||||
const auto setWindowTheme = resolveSetWindowTheme();
|
||||
if (setWindowTheme == nullptr) return;
|
||||
|
||||
const bool dark = (theme == Theme::Dark);
|
||||
setWindowTheme(hwnd, dark ? darkClass : lightClass, nullptr);
|
||||
}
|
||||
|
||||
COLORREF toColorRef(const wxColour& c) {
|
||||
return RGB(c.Red(), c.Green(), c.Blue());
|
||||
}
|
||||
|
||||
void applyListHeaderTheme(wxWindow* window, Theme theme, const ThemePalette& palette) {
|
||||
auto* list = dynamic_cast<wxListCtrl*>(window);
|
||||
if (list == nullptr) return;
|
||||
|
||||
const HWND listHwnd = reinterpret_cast<HWND>(list->GetHandle());
|
||||
if (listHwnd == nullptr) return;
|
||||
|
||||
const auto setWindowTheme = resolveSetWindowTheme();
|
||||
if (setWindowTheme == nullptr) return;
|
||||
|
||||
const HWND header = ListView_GetHeader(listHwnd);
|
||||
if (header == nullptr) return;
|
||||
|
||||
const bool dark = (theme == Theme::Dark);
|
||||
if (auto allowDarkModeForWindow = resolveAllowDarkModeForWindow()) {
|
||||
allowDarkModeForWindow(header, dark ? TRUE : FALSE);
|
||||
}
|
||||
if (dark) {
|
||||
// Different Windows builds react to different class tokens.
|
||||
setWindowTheme(header, L"DarkMode_ItemsView", nullptr);
|
||||
setWindowTheme(header, L"DarkMode_Explorer", nullptr);
|
||||
setWindowTheme(header, L"ItemsView", nullptr);
|
||||
} else {
|
||||
setWindowTheme(header, L"Header", nullptr);
|
||||
setWindowTheme(header, L"ItemsView", nullptr);
|
||||
}
|
||||
|
||||
// Force the native header colors to match the selected app theme.
|
||||
::SendMessageW(header, HDM_SETBKCOLOR, 0, static_cast<LPARAM>(toColorRef(palette.inputBg)));
|
||||
::SendMessageW(header, HDM_SETTEXTCOLOR, 0, static_cast<LPARAM>(toColorRef(palette.inputText)));
|
||||
InvalidateRect(header, nullptr, TRUE);
|
||||
}
|
||||
|
||||
void applyFrameTitlebarTheme(wxWindow* window, Theme theme) {
|
||||
if (dynamic_cast<wxTopLevelWindow*>(window) == nullptr) return;
|
||||
|
||||
const HWND hwnd = reinterpret_cast<HWND>(window->GetHandle());
|
||||
if (hwnd == nullptr) return;
|
||||
|
||||
static HMODULE dwmModule = ::LoadLibraryW(L"dwmapi.dll");
|
||||
static auto dwmSetWindowAttribute = reinterpret_cast<DwmSetWindowAttributeFn>(
|
||||
dwmModule ? ::GetProcAddress(dwmModule, "DwmSetWindowAttribute") : nullptr);
|
||||
if (dwmSetWindowAttribute == nullptr) return;
|
||||
|
||||
const bool darkLike = (theme == Theme::Dark);
|
||||
const BOOL useDark = darkLike ? TRUE : FALSE;
|
||||
constexpr DWORD kDwmUseImmersiveDarkModeOld = 19;
|
||||
constexpr DWORD kDwmUseImmersiveDarkModeNew = 20;
|
||||
dwmSetWindowAttribute(hwnd, kDwmUseImmersiveDarkModeOld, &useDark, sizeof(useDark));
|
||||
dwmSetWindowAttribute(hwnd, kDwmUseImmersiveDarkModeNew, &useDark, sizeof(useDark));
|
||||
|
||||
// Ask uxtheme to use dark menu rendering for the top menu strip.
|
||||
if (auto setPreferredAppMode = resolveSetPreferredAppMode()) {
|
||||
setPreferredAppMode(darkLike ? PreferredAppMode::ForceDark : PreferredAppMode::Default);
|
||||
}
|
||||
if (auto allowDarkModeForWindow = resolveAllowDarkModeForWindow()) {
|
||||
allowDarkModeForWindow(hwnd, useDark);
|
||||
}
|
||||
if (auto setWindowTheme = resolveSetWindowTheme()) {
|
||||
// Ensure top-level non-client rendering (including resize grip/corner)
|
||||
// uses a dark-capable class theme when the app is in dark mode.
|
||||
setWindowTheme(hwnd, darkLike ? L"DarkMode_Explorer" : L"Explorer", nullptr);
|
||||
}
|
||||
if (auto flushMenuThemes = resolveFlushMenuThemes()) {
|
||||
flushMenuThemes();
|
||||
}
|
||||
DrawMenuBar(hwnd);
|
||||
}
|
||||
|
||||
void applyTopLevelSizeGripTheme(wxWindow* window, Theme theme) {
|
||||
if (dynamic_cast<wxTopLevelWindow*>(window) == nullptr) return;
|
||||
|
||||
const HWND top = reinterpret_cast<HWND>(window->GetHandle());
|
||||
if (top == nullptr) return;
|
||||
|
||||
const auto setWindowTheme = resolveSetWindowTheme();
|
||||
if (setWindowTheme == nullptr) return;
|
||||
|
||||
const bool dark = (theme == Theme::Dark);
|
||||
const BOOL useDark = dark ? TRUE : FALSE;
|
||||
|
||||
std::pair<Theme, SetWindowThemeFn> enumCtx{theme, setWindowTheme};
|
||||
|
||||
::EnumChildWindows(
|
||||
top,
|
||||
[](HWND child, LPARAM lParam) -> BOOL {
|
||||
auto* ctx = reinterpret_cast<std::pair<Theme, SetWindowThemeFn>*>(lParam);
|
||||
if (ctx == nullptr || ctx->second == nullptr) return TRUE;
|
||||
|
||||
wchar_t className[64] = {};
|
||||
if (::GetClassNameW(child, className, static_cast<int>(sizeof(className) / sizeof(className[0]))) <= 0) {
|
||||
return TRUE;
|
||||
}
|
||||
const LONG_PTR style = ::GetWindowLongPtrW(child, GWL_STYLE);
|
||||
const bool isScrollbarClass = (::wcscmp(className, L"SCROLLBAR") == 0);
|
||||
const bool isStatusbarClass = (::wcscmp(className, STATUSCLASSNAMEW) == 0);
|
||||
const bool isSizeGrip =
|
||||
(style & SBS_SIZEGRIP) != 0 ||
|
||||
(style & SBS_SIZEBOX) != 0 ||
|
||||
(style & SBS_SIZEBOXBOTTOMRIGHTALIGN) != 0 ||
|
||||
(style & SBS_SIZEBOXTOPLEFTALIGN) != 0 ||
|
||||
(style & SBARS_SIZEGRIP) != 0;
|
||||
if (!isSizeGrip) return TRUE;
|
||||
if (!isScrollbarClass && !isStatusbarClass) return TRUE;
|
||||
|
||||
|
||||
const bool darkLocal = (ctx->first == Theme::Dark);
|
||||
if (auto allowDarkModeForWindow = resolveAllowDarkModeForWindow()) {
|
||||
allowDarkModeForWindow(child, darkLocal ? TRUE : FALSE);
|
||||
}
|
||||
const wchar_t* darkClass = isStatusbarClass ? L"DarkMode_StatusBar" : L"DarkMode_Explorer";
|
||||
const wchar_t* lightClass = isStatusbarClass ? L"Status" : L"Explorer";
|
||||
ctx->second(child, darkLocal ? darkClass : lightClass, nullptr);
|
||||
::InvalidateRect(child, nullptr, TRUE);
|
||||
return TRUE;
|
||||
},
|
||||
reinterpret_cast<LPARAM>(&enumCtx));
|
||||
|
||||
if (auto allowDarkModeForWindow = resolveAllowDarkModeForWindow()) {
|
||||
allowDarkModeForWindow(top, useDark);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#endif
|
||||
|
||||
ThemePalette paletteForTheme(Theme theme) {
|
||||
switch (theme) {
|
||||
case Theme::Dark:
|
||||
return ThemePalette{
|
||||
wxColour(30, 30, 30),
|
||||
wxColour(45, 45, 45),
|
||||
wxColour(230, 230, 230),
|
||||
wxColour(60, 60, 60),
|
||||
wxColour(230, 230, 230),
|
||||
wxColour(75, 75, 75),
|
||||
wxColour(240, 240, 240),
|
||||
};
|
||||
case Theme::Light:
|
||||
default:
|
||||
return ThemePalette{
|
||||
wxColour(248, 248, 248),
|
||||
wxColour(255, 255, 255),
|
||||
wxColour(20, 20, 20),
|
||||
wxColour(255, 255, 255),
|
||||
wxColour(20, 20, 20),
|
||||
wxColour(245, 245, 245),
|
||||
wxColour(20, 20, 20),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Theme inferThemeFromWindow(const wxWindow* window) {
|
||||
if (window == nullptr) return Theme::Light;
|
||||
|
||||
const wxWindow* probe = window;
|
||||
wxColour bg;
|
||||
while (probe != nullptr) {
|
||||
bg = probe->GetBackgroundColour();
|
||||
if (bg.IsOk()) break;
|
||||
probe = probe->GetParent();
|
||||
}
|
||||
if (!bg.IsOk()) {
|
||||
bg = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW);
|
||||
}
|
||||
|
||||
const int luminance =
|
||||
(299 * bg.Red() + 587 * bg.Green() + 114 * bg.Blue()) / 1000;
|
||||
return luminance < 128 ? Theme::Dark : Theme::Light;
|
||||
}
|
||||
|
||||
void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme theme) {
|
||||
if (root == nullptr) return;
|
||||
|
||||
root->SetForegroundColour(palette.text);
|
||||
root->SetBackgroundColour(palette.panelBg);
|
||||
root->SetOwnForegroundColour(palette.text);
|
||||
root->SetOwnBackgroundColour(palette.panelBg);
|
||||
|
||||
#ifdef __WXMSW__
|
||||
applyFrameTitlebarTheme(root, theme);
|
||||
applyTopLevelSizeGripTheme(root, theme);
|
||||
#endif
|
||||
ensureDarkDialogResizeGrip(root, palette, theme);
|
||||
|
||||
if (dynamic_cast<wxTextCtrl*>(root) != nullptr ||
|
||||
dynamic_cast<wxListCtrl*>(root) != nullptr ||
|
||||
dynamic_cast<wxListBox*>(root) != nullptr ||
|
||||
dynamic_cast<wxChoice*>(root) != nullptr ||
|
||||
dynamic_cast<wxSpinCtrl*>(root) != nullptr) {
|
||||
if (auto* text = dynamic_cast<wxTextCtrl*>(root)) {
|
||||
// On Windows, themed EDIT controls can ignore wx foreground color
|
||||
// while typing in dark mode; disable native theming there so the
|
||||
// control consistently uses palette-driven text/background colors.
|
||||
text->SetThemeEnabled(!isDarkLikeTheme(theme));
|
||||
}
|
||||
root->SetBackgroundColour(palette.inputBg);
|
||||
root->SetForegroundColour(palette.inputText);
|
||||
root->SetOwnBackgroundColour(palette.inputBg);
|
||||
root->SetOwnForegroundColour(palette.inputText);
|
||||
#ifdef __WXMSW__
|
||||
if (dynamic_cast<wxListCtrl*>(root) != nullptr) {
|
||||
// Keep both native list scrollbars and the SysHeader32 control themed.
|
||||
applyNativeClassTheme(root, theme, L"DarkMode_Explorer", L"Explorer");
|
||||
applyListHeaderTheme(root, theme, palette);
|
||||
} else if (dynamic_cast<wxTextCtrl*>(root) != nullptr) {
|
||||
// Do not apply Explorer class theming to edit controls: on some
|
||||
// Windows builds it forces black typed text in dark mode.
|
||||
// Keep text fields palette-driven via wx colors instead.
|
||||
} else {
|
||||
applyNativeClassTheme(root, theme, L"DarkMode_Explorer", L"Explorer");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if (dynamic_cast<wxStatusBar*>(root) != nullptr) {
|
||||
root->SetBackgroundColour(palette.panelBg);
|
||||
root->SetForegroundColour(palette.text);
|
||||
root->SetOwnBackgroundColour(palette.panelBg);
|
||||
root->SetOwnForegroundColour(palette.text);
|
||||
#ifdef __WXMSW__
|
||||
applyNativeClassTheme(root, theme, L"DarkMode_StatusBar", L"Status");
|
||||
#endif
|
||||
}
|
||||
|
||||
if (dynamic_cast<wxButton*>(root) != nullptr ||
|
||||
dynamic_cast<wxBitmapButton*>(root) != nullptr) {
|
||||
const bool darkLike = isDarkLikeTheme(theme);
|
||||
root->SetThemeEnabled(!darkLike);
|
||||
root->SetBackgroundColour(palette.buttonBg);
|
||||
root->SetForegroundColour(palette.buttonText);
|
||||
root->SetOwnBackgroundColour(palette.buttonBg);
|
||||
root->SetOwnForegroundColour(palette.buttonText);
|
||||
|
||||
const wxColour normalBg = palette.buttonBg;
|
||||
const int hoverLift = 18;
|
||||
const int pressedLift = 30;
|
||||
const wxColour hoverBg = darkLike ? lightenTowardWhite(normalBg, hoverLift) : normalBg;
|
||||
const wxColour pressedBg = darkLike ? lightenTowardWhite(normalBg, pressedLift) : normalBg;
|
||||
const wxColour btnFg = palette.buttonText;
|
||||
gButtonVisualStates[root] = ButtonVisualState{
|
||||
normalBg, hoverBg, pressedBg, btnFg, darkLike, false, false, false
|
||||
};
|
||||
|
||||
if (!gButtonHoverBound.count(root)) {
|
||||
gButtonHoverBound.insert(root);
|
||||
|
||||
root->Bind(wxEVT_ENTER_WINDOW, [root](wxMouseEvent& event) {
|
||||
auto it = gButtonVisualStates.find(root);
|
||||
if (it == gButtonVisualStates.end() || !it->second.darkLike) {
|
||||
event.Skip();
|
||||
return;
|
||||
}
|
||||
it->second.hovered = true;
|
||||
const wxColour bg = it->second.pressed ? it->second.pressedBg : it->second.hoverBg;
|
||||
root->SetBackgroundColour(bg);
|
||||
root->SetForegroundColour(it->second.text);
|
||||
root->Refresh();
|
||||
});
|
||||
root->Bind(wxEVT_LEAVE_WINDOW, [root](wxMouseEvent& event) {
|
||||
auto it = gButtonVisualStates.find(root);
|
||||
if (it == gButtonVisualStates.end() || !it->second.darkLike) {
|
||||
event.Skip();
|
||||
return;
|
||||
}
|
||||
it->second.hovered = false;
|
||||
const wxColour bg = it->second.focused ? it->second.hoverBg : it->second.normalBg;
|
||||
root->SetBackgroundColour(bg);
|
||||
root->SetForegroundColour(it->second.text);
|
||||
root->Refresh();
|
||||
});
|
||||
root->Bind(wxEVT_LEFT_DOWN, [root](wxMouseEvent& event) {
|
||||
auto it = gButtonVisualStates.find(root);
|
||||
if (it == gButtonVisualStates.end() || !it->second.darkLike) {
|
||||
event.Skip();
|
||||
return;
|
||||
}
|
||||
it->second.pressed = true;
|
||||
root->SetBackgroundColour(it->second.pressedBg);
|
||||
root->SetForegroundColour(it->second.text);
|
||||
root->Refresh();
|
||||
event.Skip();
|
||||
});
|
||||
root->Bind(wxEVT_LEFT_UP, [root](wxMouseEvent& event) {
|
||||
auto it = gButtonVisualStates.find(root);
|
||||
if (it == gButtonVisualStates.end() || !it->second.darkLike) {
|
||||
event.Skip();
|
||||
return;
|
||||
}
|
||||
it->second.pressed = false;
|
||||
const wxPoint mousePos = wxGetMousePosition();
|
||||
const wxPoint localPos = root->ScreenToClient(mousePos);
|
||||
const bool inside = root->GetClientRect().Contains(localPos);
|
||||
it->second.hovered = inside;
|
||||
const wxColour bg = (inside || it->second.focused) ? it->second.hoverBg : it->second.normalBg;
|
||||
root->SetBackgroundColour(bg);
|
||||
root->SetForegroundColour(it->second.text);
|
||||
root->Refresh();
|
||||
event.Skip();
|
||||
});
|
||||
root->Bind(wxEVT_SET_FOCUS, [root](wxFocusEvent& event) {
|
||||
auto it = gButtonVisualStates.find(root);
|
||||
if (it == gButtonVisualStates.end() || !it->second.darkLike) {
|
||||
event.Skip();
|
||||
return;
|
||||
}
|
||||
it->second.focused = true;
|
||||
root->SetBackgroundColour(it->second.hoverBg);
|
||||
root->SetForegroundColour(it->second.text);
|
||||
root->Refresh();
|
||||
event.Skip();
|
||||
});
|
||||
root->Bind(wxEVT_KILL_FOCUS, [root](wxFocusEvent& event) {
|
||||
auto it = gButtonVisualStates.find(root);
|
||||
if (it == gButtonVisualStates.end() || !it->second.darkLike) {
|
||||
event.Skip();
|
||||
return;
|
||||
}
|
||||
it->second.focused = false;
|
||||
it->second.pressed = false;
|
||||
const wxColour bg = it->second.hovered ? it->second.hoverBg : it->second.normalBg;
|
||||
root->SetBackgroundColour(bg);
|
||||
root->SetForegroundColour(it->second.text);
|
||||
root->Refresh();
|
||||
event.Skip();
|
||||
});
|
||||
root->SetBackgroundStyle(wxBG_STYLE_PAINT);
|
||||
root->Bind(wxEVT_ERASE_BACKGROUND, [](wxEraseEvent&) {});
|
||||
root->Bind(wxEVT_PAINT, [root](wxPaintEvent& event) {
|
||||
const auto it = gButtonVisualStates.find(root);
|
||||
if (it == gButtonVisualStates.end() || !it->second.darkLike) {
|
||||
event.Skip();
|
||||
return;
|
||||
}
|
||||
wxAutoBufferedPaintDC dc(root);
|
||||
const wxRect rect = root->GetClientRect();
|
||||
wxColour bg = it->second.normalBg;
|
||||
if (it->second.pressed) {
|
||||
bg = it->second.pressedBg;
|
||||
} else if (it->second.hovered || it->second.focused) {
|
||||
bg = it->second.hoverBg;
|
||||
}
|
||||
const wxColour fg = it->second.text;
|
||||
|
||||
dc.SetBrush(wxBrush(bg));
|
||||
dc.SetPen(wxPen(lightenTowardWhite(bg, 28)));
|
||||
dc.DrawRectangle(rect);
|
||||
|
||||
if (auto* bmpBtn = dynamic_cast<wxBitmapButton*>(root)) {
|
||||
const wxBitmap bmp = bmpBtn->GetBitmap();
|
||||
if (bmp.IsOk()) {
|
||||
const int x = (rect.GetWidth() - bmp.GetWidth()) / 2;
|
||||
const int y = (rect.GetHeight() - bmp.GetHeight()) / 2;
|
||||
dc.DrawBitmap(bmp, x, y, true);
|
||||
}
|
||||
} else {
|
||||
dc.SetTextForeground(fg);
|
||||
const wxString label = root->GetLabel();
|
||||
dc.DrawLabel(label, rect, wxALIGN_CENTER);
|
||||
}
|
||||
});
|
||||
root->Bind(wxEVT_DESTROY, [root](wxWindowDestroyEvent& event) {
|
||||
gButtonHoverBound.erase(root);
|
||||
gButtonVisualStates.erase(root);
|
||||
event.Skip();
|
||||
});
|
||||
}
|
||||
#ifdef __WXMSW__
|
||||
if (darkLike) {
|
||||
// Disable native visual-style painting in dark mode only,
|
||||
// otherwise light theme buttons should stay fully native.
|
||||
applyNativeClassTheme(root, theme, L"", L"");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
if (dynamic_cast<wxStaticText*>(root) != nullptr ||
|
||||
dynamic_cast<wxStaticBitmap*>(root) != nullptr) {
|
||||
root->SetForegroundColour(palette.text);
|
||||
root->SetBackgroundColour(palette.panelBg);
|
||||
root->SetOwnForegroundColour(palette.text);
|
||||
root->SetOwnBackgroundColour(palette.panelBg);
|
||||
}
|
||||
|
||||
const wxWindowList& children = root->GetChildren();
|
||||
for (wxWindowList::compatibility_iterator it = children.GetFirst(); it; it = it->GetNext()) {
|
||||
applyThemeToWindowTree(it->GetData(), palette, theme);
|
||||
}
|
||||
}
|
||||
|
||||
int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style) {
|
||||
wxDialog dlg(parent, wxID_ANY, caption, wxDefaultPosition, wxDefaultSize,
|
||||
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
|
||||
auto* root = new wxBoxSizer(wxVERTICAL);
|
||||
auto* label = new wxStaticText(&dlg, wxID_ANY, message);
|
||||
root->Add(label, 0, wxALL | wxEXPAND, 12);
|
||||
|
||||
const bool yesNo = (style & wxYES_NO) != 0;
|
||||
if (yesNo) {
|
||||
auto* buttons = new wxStdDialogButtonSizer();
|
||||
auto* yesBtn = new wxButton(&dlg, wxID_YES);
|
||||
auto* noBtn = new wxButton(&dlg, wxID_NO);
|
||||
yesBtn->SetLabelText("Yes");
|
||||
noBtn->SetLabelText("No");
|
||||
yesBtn->Bind(wxEVT_BUTTON, [&dlg](wxCommandEvent&) { dlg.EndModal(wxID_YES); });
|
||||
noBtn->Bind(wxEVT_BUTTON, [&dlg](wxCommandEvent&) { dlg.EndModal(wxID_NO); });
|
||||
yesBtn->SetDefault();
|
||||
buttons->AddButton(yesBtn);
|
||||
buttons->AddButton(noBtn);
|
||||
buttons->Realize();
|
||||
root->Add(buttons, 0, wxLEFT | wxRIGHT | wxBOTTOM | wxEXPAND, 12);
|
||||
} else {
|
||||
if (auto* buttons = dlg.CreateButtonSizer(wxOK)) {
|
||||
root->Add(buttons, 0, wxLEFT | wxRIGHT | wxBOTTOM | wxEXPAND, 12);
|
||||
}
|
||||
}
|
||||
|
||||
dlg.SetSizerAndFit(root);
|
||||
const wxSize fitSize = dlg.GetSize();
|
||||
dlg.SetSize(fitSize.GetWidth(), static_cast<int>(fitSize.GetHeight() * 1.10));
|
||||
const Theme theme = inferThemeFromWindow(parent);
|
||||
const ThemePalette palette = paletteForTheme(theme);
|
||||
applyThemeToWindowTree(&dlg, palette, theme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
dlg.CentreOnParent();
|
||||
return dlg.ShowModal();
|
||||
}
|
||||
|
||||
int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption) {
|
||||
wxDialog dlg(parent, wxID_ANY, caption, wxDefaultPosition, wxDefaultSize,
|
||||
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER);
|
||||
auto* root = new wxBoxSizer(wxVERTICAL);
|
||||
auto* label = new wxStaticText(&dlg, wxID_ANY, message);
|
||||
root->Add(label, 0, wxALL | wxEXPAND, 12);
|
||||
|
||||
auto* buttons = new wxStdDialogButtonSizer();
|
||||
auto* yesBtn = new wxButton(&dlg, wxID_YES);
|
||||
auto* noBtn = new wxButton(&dlg, wxID_NO);
|
||||
yesBtn->SetLabelText("Yes");
|
||||
noBtn->SetLabelText("No");
|
||||
yesBtn->Bind(wxEVT_BUTTON, [&dlg](wxCommandEvent&) { dlg.EndModal(wxID_YES); });
|
||||
noBtn->Bind(wxEVT_BUTTON, [&dlg](wxCommandEvent&) { dlg.EndModal(wxID_NO); });
|
||||
yesBtn->SetDefault();
|
||||
buttons->AddButton(yesBtn);
|
||||
buttons->AddButton(noBtn);
|
||||
buttons->Realize();
|
||||
root->Add(buttons, 0, wxLEFT | wxRIGHT | wxBOTTOM | wxEXPAND, 12);
|
||||
|
||||
dlg.SetSizerAndFit(root);
|
||||
const wxSize fitSize = dlg.GetSize();
|
||||
dlg.SetSize(fitSize.GetWidth(), static_cast<int>(fitSize.GetHeight() * 1.10));
|
||||
const Theme theme = inferThemeFromWindow(parent);
|
||||
const ThemePalette palette = paletteForTheme(theme);
|
||||
applyThemeToWindowTree(&dlg, palette, theme);
|
||||
dlg.SetBackgroundColour(palette.panelBg);
|
||||
dlg.SetForegroundColour(palette.text);
|
||||
dlg.CentreOnParent();
|
||||
|
||||
return dlg.ShowModal();
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
Reference in New Issue
Block a user