major: initial release

* initial development

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* pipeline

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* ci/cd

* pokemon

* pokemon

* pokemon

* pokemon

* pokemon

* pokemon

* improvements

* improvements

* ci/cd

* ci/cd

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

* improvements

---------

Co-authored-by: sdine <sdine@sdine.com>
This commit is contained in:
Sebastian Dine
2026-05-09 11:05:47 +02:00
committed by GitHub
parent 13262fa015
commit 55ace147bc
149 changed files with 12611 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
# ui_wx/AGENTS.md
`ccm_ui_wx` static library — wxWidgets adapter. The **only** target that may include `wx/...` headers. Read the root `AGENTS.md` first.
## Layer pointers
- `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector<IGameView*>` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly).
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types.
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window, menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel falls back to a per-game card-back image URL (Magic/Pokemon parity with CCM2) instead of leaving the preview empty. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant.
- `include/ccm/ui/BaseCardEditDialog.hpp` — header-only template `BaseCardEditDialog<TCard>` that owns the standard Add/Edit form: Name, Set picker (read-only `wxComboBox` with prefix-match typeahead and case-insensitive id matching for legacy data), Amount spin, Language and Condition choices, Note, image management (Add multiple via `wxFD_MULTIPLE`, Remove, double-click to view), OK/Cancel + validation. Subclasses build the flags row (`buildFlagsRow`), append game-specific extra rows (e.g. Pokemon's `Set #`) via `appendExtraRows`, and copy values in/out of the typed card (`readExtraFromCard` / `writeExtraToCard`).
- `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50100 lines of hook overrides on top of the matching base template.
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`).
- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up.
- `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types.
- `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`.
- `include/ccm/ui/ImageViewerDialog.hpp` + `src/ImageViewerDialog.cpp` — full-size viewer with prev/next.
- `include/ccm/ui/Theme.hpp` + `src/Theme.cpp` — shared theme helpers and popup helpers (`showThemedMessageDialog`, `showThemedConfirmDialog`) for consistent dark/light dialogs.
## Conventions
1. **Only consume core through `AppContext`.** Do not include any header from `ccm/infra/` here. The set of allowed `ccm/...` includes is `domain/`, `services/`, `games/IGameModule.hpp`, `ports/ICardPreviewSource.hpp`, and `util/Result.hpp`.
2. **Image decoding lives here, not in core.** Use `wxImage::LoadFile(path.string())` against the path returned by `IImageStore::resolvePath`. Core stays free of any image library.
3. **Ownership**: dialogs and panels are heap-allocated and parented to a `wxWindow`. wxWidgets owns the lifetime — do **not** wrap them in `unique_ptr`. `IGameView` instances themselves are owned by `app/main.cpp` (`std::unique_ptr<>`); the panels owned by the views become children of the `MainFrame` splitter on first mount.
4. **Custom events**: `EVT_CARD_SELECTED` is fired by the list panel on itself (not its parent). Each `IGameView` binds it on its typed list panel inside the panel's first construction so the typed selection flows directly into the typed selected panel — `MainFrame` never sees a `MagicCard` or a `PokemonCard`. Do not move that binding back into `MainFrame`.
5. **wxFont modifications** mutate in place: `font.MakeBold().MakeLarger()` — do not call `Scale` (it does not exist on wxFont 3.2; use `MakeLarger` / `SetPointSize`).
6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the splitter swaps `listPanel()` / `selectedPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games.
7. **No `ccm_warnings`.** This target intentionally does **not** link the strict warning interface — wxWidgets headers trip `-Wpedantic` / `-Wshadow`. Keep it that way; do not add the link.
8. **Async background work** must not capture `this` raw. Use the pattern from `BaseSelectedCardPanel`: a `std::shared_ptr<State>` holding `std::atomic<bool> alive`, `std::atomic<unsigned> currentGen`, and a back-pointer to the panel; spawn a detached `std::thread`, then deliver the result with `wxTheApp->CallAfter([state, gen, ...]() { if (!state->alive) return; if (state->currentGen != gen) return; ... })`. Flip `alive=false` in the panel destructor so late callbacks become no-ops.
9. **Icons come from `SvgIcons.hpp`.** Don't inline new SVG strings in panel sources; add them to `SvgIcons.{hpp,cpp}` so all panels stay in sync. Always pass a runtime fill color (`wxSystemSettings::GetColour(...).GetAsString(wxC2S_HTML_SYNTAX)`); never bake one into the SVG.
10. **Sort key != display key.** When you add a new column to a list panel, follow the existing pattern: the `wxListCtrl` cell text is one thing; the *sort* comparator lives in `ccm::services::CardSorter` and may key off a different field (the canonical case is `set.name` shown but `set.releaseDate` sorted, so collections list chronologically). New columns must extend `MagicSortColumn` / `PokemonSortColumn` and add a corresponding `case` in `sortMagicCards` / `sortPokemonCards`.
11. **`wxListCtrl` + flag-icon centering (MSW comctl32):**
- Native `LVS_REPORT` sub-item image rendering on MSW left-anchors the bitmap with a small built-in inset, regardless of `wxLIST_FORMAT_CENTER`. It can never align pixel-perfect with our wx-sizer-centered themed header icons, especially after column resize. Don't try to compensate by padding the image-list bitmap or nudging it horizontally — that path was tried and abandoned.
- **Authoritative path:** flag-icon sub-items go through `IconListCtrl::MSWOnNotify` (`NM_CUSTOMDRAW`). It computes the live sub-item rect via `LVM_GETSUBITEMRECT(LVIR_BOUNDS)` and composites the cell's icon at the rect center with `AlphaBlend(... AC_SRC_OVER | AC_SRC_ALPHA)` straight onto `cd->nmcd.hdc`. Each (icon, selection-state) pair has its own pre-built premultiplied 32 bpp BGRA DIB section in `dibBitmaps_`; index `i` holds the normal variant and index `i + iconColCount` holds the selected variant. The cache rebuilds whenever the theme changes (via `setIconBitmaps(...)` from `BaseCardListPanel::rebuildIconBitmaps`).
- We deliberately do **not** route through `ImageList_Draw` / `HIMAGELIST` here. On the verified MinGW-w64 + comctl32 v6 stack, `ImageList_Draw` on an `ILC_COLOR32` list with `ILD_TRANSPARENT` ignored the alpha channel of the bitmap and the "transparent" canvas around each glyph painted as opaque black behind the icon — every row flag rendered as a black rectangle with a white glyph regardless of theme. `AlphaBlend` directly on the listctrl's HDC works in every case we've tested.
- **Bitmap format pitfall — `AlphaBlend` requires PREMULTIPLIED BGRA**, not straight alpha. With straight RGBA the function returns `FALSE` (or, depending on the driver, paints garbage). `makePremultipliedDib` in `IconListCtrl.cpp` does the per-pixel premultiply with the rounded form `(c * a + 127) / 255`. **Do not** simplify that to `c * a / 255` (loss of precision on `c=0xFF, a=0xFF`) and **do not** skip the divide-by-255 entirely (`c * a` overflows the byte and renders the icon as solid white — that was the original failure mode that made an earlier dev abandon premultiplication for a while). Hardcoded-fill SVGs (e.g. baked-in black/white badges) happen to look correct on every path and are **not** a useful sanity check on their own — always verify rendering against a runtime-fill icon (foil / signed / altered / holo) on both light and dark themes.
- The hidden zero-width spacer column at index 0 stays. It's no longer load-bearing for any image-list gutter, but it keeps every other column index stable across the codebase. Start real columns at index 1.
- Insert each row through the spacer column with a `wxListItem` whose mask includes `wxLIST_MASK_IMAGE` and image `-1` so MSW doesn't try to render an item icon for column 0 if a public image list ever gets attached again.
- `AlphaBlend` lives in `msimg32.lib`; `ui_wx/CMakeLists.txt` links `msimg32` on `WIN32`. Don't rely on `gdi32` being enough — `AlphaBlend@44` is **not** in `gdi32`.
12. **Startup/dialog responsiveness rules:**
- Keep first paint fast: avoid heavy synchronous work in window/dialog constructors.
- In `MainFrame`, defer initial collection load with `CallAfter(...)` so the frame paints before I/O/parsing.
- Keep startup's "first row selected" behavior, but schedule initial selection with `CallAfter(...)` in `BaseCardListPanel` to avoid blocking first render.
- Avoid reloading/reparsing sets on each Add/Edit open: each `IGameView` caches its own set list and passes it into the dialog by pointer.
- Pass preloaded sets into `BaseCardEditDialog` by pointer/reference (not by value) to avoid vector copies per open.
- For heavy dialog setup, wrap constructor-time UI population in `Freeze()` / `Thaw()` and append choice items in bulk via `wxArrayString` (`BaseCardEditDialog::buildAndPopulate` does this).
13. **String encoding on Windows (avoid mojibake):**
- Domain/service strings are UTF-8 `std::string`. Do not rely on implicit `std::string <-> wxString` conversions on Windows; those can route through the active ANSI codepage and render `Pokémon` as `Pokémon`.
- UI display path (`std::string` -> wx control): always convert with `wxString::FromUTF8(str.c_str())` before `SetLabelText`, `SetItem`, `Append`, control constructors, etc.
- UI write-back path (wx control -> `std::string`): always convert with `ToStdString(wxConvUTF8)` so persisted/domain text stays UTF-8.
- Apply this rule consistently in shared templates (`BaseCardListPanel`, `BaseSelectedCardPanel`, `BaseCardEditDialog`) because a single implicit conversion in those bases affects every game view.
14. **Theme consistency rules (Windows):**
- Treat dialog roots as `panelBg`, not a separate shade, otherwise label rows can look like mismatched darker boxes.
- Theme dialogs before `ShowModal()` with `applyThemeToWindowTree(...)`; this includes Settings, Create/Edit dialogs, image viewer, About, and custom popup dialogs.
- Do not use native `wxMessageBox` / `wxAboutBox` for app-facing flows that must match dark mode. Use themed popup helpers (or a custom themed `wxDialog`) so body/buttons stay in sync with the app palette.
- Center popup dialogs on the app window (`CentreOnParent()`) so confirmations/info boxes open relative to the current app window.
- Include `wxSpinCtrl` in themed input controls (Amount field) or it will keep a mismatched native background.
- Do not call `applyNativeClassTheme(..., "DarkMode_Explorer", "Explorer")` for `wxTextCtrl`; on some Windows builds this causes black typed text in dark mode. Keep text inputs palette-driven (`SetThemeEnabled(false)` in dark/high-contrast as needed).
- If a specific text field still renders wrong while typing (notably `MainFrame`'s filter box), enforce text/background in `MainFrame::MSWWindowProc` via `WM_CTLCOLOREDIT` for that control handle.
- Keep toolbar button behavior stable under dark/high-contrast: avoid changes that break click/tooltip affordances while experimenting with hover contrast fixes.
- For dark/high-contrast button readability, do not trust native hover/pressed rendering on Windows; custom state painting in `Theme.cpp` is allowed when native visuals ignore configured colors.
- Button event handlers must use per-button state that is refreshed when theme changes. Avoid one-time captures of theme colors/mode in lambdas; these can leak dark-mode behavior into light mode.
- In High Contrast, use stronger hover/pressed deltas than regular dark mode and keep the button border in the foreground/text color for visibility (currently yellow in this palette).
- When validating UI theming changes, rebuild and run `ccm` (the executable), not just `ccm_ui_wx`.
15. **Preview fallback behavior (CCM2 parity):**
- Keep unresolved external previews user-visible by showing a per-game card-back image in `BaseSelectedCardPanel` instead of a blank/transparent bitmap.
- Current fallback URLs are intentionally aligned with CCM2: Magic uses `Magic_card_back.jpg`, Pokemon uses `Cardback.jpg`.
- If you change fallback sourcing (URL -> local asset, etc.), keep the "always show a reasonable card-back fallback" behavior intact for both games.
## Required follow-ups
- After adding a new dialog/panel `.cpp` you **must** add it to `ui_wx/CMakeLists.txt`.
- After adding a new menu action you **must** allocate an `Ids::*` value in `MainFrame.hpp` (don't reuse `wxID_HIGHEST` math inline) and `Bind` it in `buildMenuBar`. The dynamic Game / Sets menus consume the `IdGameMenuBase` / `IdSetsMenuBase` ranges; do not stomp on those id ranges.
- After changing `AppContext` you **must** update `app/main.cpp` so the composition root populates the new field.
- After adding a new icon to `SvgIcons.{hpp,cpp}` you **must** keep the `@FILL@` placeholder so both light- and dark-variant rendering keeps working, and add a small unit-test-equivalent visual check by running the binary (no automated UI tests in this repo).
- After changing one of the `Base*` template hooks (or adding a new one) you **must** keep `docs/adding-a-new-game.md` in sync — the per-game derived classes are the readers of that contract and the doc is what onboarding agents read first.
## Adding a new game UI
1. Implement three derived classes under `include/ccm/ui/` mirroring the Magic / Pokemon trio:
- `<Name>CardListPanel : public BaseCardListPanel<<Name>Card, <Name>SortColumn>` — override `declareTextColumns()`, `declareIconColumns()`, `renderTextCell()`, `isIconColumnSet()`, `sortBy()`, `matchesFilter()`.
- `<Name>SelectedCardPanel : public BaseSelectedCardPanel<<Name>Card>` — override `declareDetailRows()`, `declareFlagIcons()`, `detailValueFor()`, `isFlagSet()`, `previewKey()`, `gameId()`. Define a local `enum` of `DetailKey` constants for clarity.
- `<Name>CardEditDialog : public BaseCardEditDialog<<Name>Card>` — override `buildFlagsRow()`, optionally `appendExtraRows()`, `readExtraFromCard()`, `writeExtraToCard()`, `updateMenuName()`.
2. Add a `<Name>GameView : public IGameView` that owns those panels and the typed `CollectionService<<Name>Card>&`. Bind `EVT_CARD_SELECTED` on the list panel inside `listPanel(parent)` to push the typed selection into the selected panel. The `MagicGameView` / `PokemonGameView` pair is the canonical reference.
3. Re-add the new view to `AppContext::gameViews` in the composition root (`app/main.cpp`). The `Game` and `Sets` menus pick it up automatically.
4. Add SVG glyphs for any new flag columns to `SvgIcons.{hpp,cpp}` (with the `@FILL@` placeholder).
5. Register all new `.cpp` files in `ui_wx/CMakeLists.txt`.
## Commands
Build UI only: `cmake --build build --target ccm_ui_wx`
+55
View File
@@ -0,0 +1,55 @@
# ccm_ui_wx: wxWidgets adapter. The only target that depends on wx::wx.
# Replace this directory with a different toolkit (Qt, Dear ImGui, ...) without
# touching ccm_core.
add_library(ccm_ui_wx STATIC
src/MainFrame.cpp
src/BaseEvents.cpp
src/MagicCardListPanel.cpp
src/MagicSelectedCardPanel.cpp
src/MagicCardEditDialog.cpp
src/MagicGameView.cpp
src/PokemonCardListPanel.cpp
src/PokemonSelectedCardPanel.cpp
src/PokemonCardEditDialog.cpp
src/PokemonGameView.cpp
src/SettingsDialog.cpp
src/ImageViewerDialog.cpp
src/IconListCtrl.cpp
src/SvgIcons.cpp
src/Theme.cpp
)
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/include/ccm/ui/AppVersion.hpp.in
${CMAKE_CURRENT_BINARY_DIR}/generated/ccm/ui/AppVersion.hpp
@ONLY
)
target_include_directories(ccm_ui_wx
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/generated>
)
target_link_libraries(ccm_ui_wx
PUBLIC
ccm_core
wx::wx
# Intentionally NOT linking ccm_warnings here: wxWidgets headers raise
# spurious diagnostics under -Wpedantic / -Wshadow that we'd have to
# suppress per-call. The strict warning set is reserved for ccm_core.
)
# IconListCtrl uses AlphaBlend (msimg32) directly in NM_CUSTOMDRAW to render
# the per-row flag glyphs with proper transparency. Without msimg32 linked
# explicitly the linker fails on `AlphaBlend@44` even though gdi32 is pulled
# in transitively by wxWidgets.
if (WIN32)
target_link_libraries(ccm_ui_wx PRIVATE msimg32)
endif()
target_compile_features(ccm_ui_wx PUBLIC cxx_std_20)
+34
View File
@@ -0,0 +1,34 @@
#pragma once
// AppContext: the only thing that crosses the UI boundary. Holds references
// to the shared core services and to the per-game `IGameView` instances. The
// wxWidgets layer never sees a concrete adapter type or a typed
// `CollectionService<TCard>` - swap in a Qt/imgui frontend by reimplementing
// the consumers of this struct only.
#include "ccm/games/IGameModule.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include <vector>
namespace ccm::ui {
class IGameView;
struct AppContext {
ConfigService& config;
SetService& sets;
ImageService& images;
CardPreviewService& cardPreview;
IGameModule& magicModule;
IGameModule& pokemonModule;
// Active per-game UI bundles. The order is the order shown in the
// Game menu; the composition root constructs them and hands raw
// pointers in. `MainFrame` does not own these — `app/main.cpp` does.
std::vector<IGameView*> gameViews;
};
} // namespace ccm::ui
+7
View File
@@ -0,0 +1,7 @@
#pragma once
namespace ccm::ui {
inline constexpr const char* kAppVersion = "@CCM_APP_VERSION@";
} // namespace ccm::ui
+524
View File
@@ -0,0 +1,524 @@
#pragma once
// BaseCardEditDialog<TCard>
//
// Header-only template for the modal create/edit form. Owns the parts every
// game shares — Name, Set picker (read-only combo with prefix typeahead),
// Amount spin, Language and Condition choices, Note, Image list with
// Add/Remove/double-click-to-view, OK/Cancel — and exposes hooks the
// subclass uses to:
//
// - declare a flags row (`Foil` for Magic, `Holo` + `1. Edition` for Pokemon, ...)
// - declare any extra game-specific text fields (`Set #` for Pokemon)
// - read/write the typed `TCard`
//
// New games extend this template — see `MagicCardEditDialog` and
// `PokemonCardEditDialog` for the canonical patterns.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/ImageViewerDialog.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/arrstr.h>
#include <wx/button.h>
#include <wx/checkbox.h>
#include <wx/choice.h>
#include <wx/combobox.h>
#include <wx/dialog.h>
#include <wx/event.h>
#include <wx/filedlg.h>
#include <wx/listbox.h>
#include <wx/msgdlg.h>
#include <wx/sizer.h>
#include <wx/spinctrl.h>
#include <wx/stattext.h>
#include <wx/strconv.h>
#include <wx/textctrl.h>
#ifdef __WXMSW__
#include <wx/msw/wrapwin.h>
#endif
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
namespace ccm::ui {
enum class EditMode { Create, Edit };
template <typename TCard>
class BaseCardEditDialog : public wxDialog {
public:
using card_type = TCard;
[[nodiscard]] const TCard& card() const noexcept { return card_; }
protected:
BaseCardEditDialog(wxWindow* parent,
const wxString& title,
ImageService& imageService,
SetService& setService,
EditMode mode,
TCard initial,
Game game,
const std::vector<Set>* preloadedSets = nullptr)
: wxDialog(parent, wxID_ANY, title,
wxDefaultPosition, wxSize(560, 540),
wxDEFAULT_DIALOG_STYLE | wxRESIZE_BORDER),
imageService_(imageService),
setService_(setService),
mode_(mode),
card_(std::move(initial)),
game_(game),
preloadedSets_(preloadedSets) {}
// Subclass calls this from its constructor body once it can answer the
// virtual hooks below.
void buildAndPopulate() {
Freeze();
if (preloadedSets_ == nullptr) {
readSets();
}
buildLayout();
populateChoices();
Thaw();
}
// Hooks -------------------------------------------------------------------
// Subclass appends its game-specific check boxes / inputs onto `flagsBox`
// (a horizontal `wxBoxSizer`). Build and bind the widgets the subclass
// wants; the base only owns the surrounding label.
virtual void buildFlagsRow(wxBoxSizer* flagsBox) = 0;
// Subclass adds any extra game-specific labelled rows just below the
// standard rows but above the Note row, by calling `appendRow(label, ctrl)`
// (provided as a parameter). Default does nothing.
using AppendRowFn = void (*)(BaseCardEditDialog*, const wxString&, wxWindow*);
virtual void appendExtraRows(wxFlexGridSizer* /*grid*/) {}
// Subclass copies the extra fields it owns from `card_` into its widgets.
virtual void readExtraFromCard() {}
// Subclass copies the extra fields it owns from its widgets back into `card_`.
virtual void writeExtraToCard() {}
[[nodiscard]] virtual std::string updateMenuName() const { return "Update Sets"; }
// Display name passed into errors and the dialog title hints.
[[nodiscard]] virtual std::string emptySetMessage() const {
return "(no sets cached - use Sets > " + updateMenuName() + ")";
}
// Common helpers ----------------------------------------------------------
void appendRow(wxFlexGridSizer* grid, const wxString& label, wxWindow* ctrl) {
grid->Add(new wxStaticText(this, wxID_ANY, label),
0, wxALIGN_CENTER_VERTICAL);
grid->Add(ctrl, 1, wxEXPAND);
}
[[nodiscard]] TCard& mutableCard() noexcept { return card_; }
[[nodiscard]] const TCard& constCard() const noexcept { return card_; }
private:
void readSets() {
auto loaded = setService_.getSets(game_);
if (loaded.isOk()) {
sets_ = std::move(loaded).value();
}
}
[[nodiscard]] const std::vector<Set>& availableSets() const noexcept {
return preloadedSets_ != nullptr ? *preloadedSets_ : sets_;
}
void buildLayout() {
auto* root = new wxBoxSizer(wxVERTICAL);
auto* grid = new wxFlexGridSizer(2, 6, 8);
grid->AddGrowableCol(1, 1);
nameCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.name.c_str()));
appendRow(grid, "Name", nameCtrl_);
setCombo_ = new wxComboBox(this, wxID_ANY, wxEmptyString, wxDefaultPosition, wxDefaultSize, 0,
nullptr, wxCB_READONLY);
appendRow(grid, "Set", setCombo_);
// Subclass extra rows go between Set and Amount (Pokemon adds Set #).
appendExtraRows(grid);
amountCtrl_ = new wxSpinCtrl(this, wxID_ANY, "", wxDefaultPosition,
wxDefaultSize, wxSP_ARROW_KEYS, 1, 255, card_.amount);
appendRow(grid, "Amount", amountCtrl_);
languageChoice_ = new wxChoice(this, wxID_ANY);
appendRow(grid, "Language", languageChoice_);
conditionChoice_ = new wxChoice(this, wxID_ANY);
appendRow(grid, "Condition", conditionChoice_);
noteCtrl_ = new wxTextCtrl(this, wxID_ANY, wxString::FromUTF8(card_.note.c_str()),
wxDefaultPosition, wxSize(-1, 60), wxTE_MULTILINE);
appendRow(grid, "Note", noteCtrl_);
auto* flagsBox = new wxBoxSizer(wxHORIZONTAL);
buildFlagsRow(flagsBox);
grid->Add(new wxStaticText(this, wxID_ANY, "Flags"),
0, wxALIGN_CENTER_VERTICAL);
grid->Add(flagsBox, 1, wxEXPAND);
root->Add(grid, 0, wxALL | wxEXPAND, 10);
auto* imgBox = new wxStaticBoxSizer(wxVERTICAL, this, "Images");
imagesList_ = new wxListBox(this, wxID_ANY);
for (const auto& name : card_.images) imagesList_->Append(wxString::FromUTF8(name.c_str()));
imgBox->Add(imagesList_, 1, wxEXPAND | wxALL, 4);
auto* imgButtons = new wxBoxSizer(wxHORIZONTAL);
auto* addBtn = new wxButton(this, wxID_ANY, "Add image...");
auto* rmBtn = new wxButton(this, wxID_ANY, "Remove image");
imgButtons->Add(addBtn, 0, wxRIGHT, 6);
imgButtons->Add(rmBtn, 0);
imgBox->Add(imgButtons, 0, wxALL, 4);
root->Add(imgBox, 1, wxEXPAND | wxLEFT | wxRIGHT, 10);
addBtn->Bind(wxEVT_BUTTON, &BaseCardEditDialog::onAddImage, this);
rmBtn->Bind (wxEVT_BUTTON, &BaseCardEditDialog::onRemoveImage, this);
imagesList_->Bind(wxEVT_LISTBOX_DCLICK, &BaseCardEditDialog::onImageActivated, this);
auto* btns = CreateButtonSizer(wxOK | wxCANCEL);
if (btns) {
root->Add(btns, 0, wxLEFT | wxTOP | wxRIGHT | wxEXPAND, 10);
root->AddSpacer(24);
}
Bind(wxEVT_BUTTON, &BaseCardEditDialog::onOk, this, wxID_OK);
setCombo_->Bind(wxEVT_CHAR, &BaseCardEditDialog::onSetComboChar, this);
setCombo_->Bind(wxEVT_KILL_FOCUS, &BaseCardEditDialog::onSetComboKillFocus, this);
SetSizer(root);
readExtraFromCard();
CallAfter([this]() {
if (nameCtrl_) {
nameCtrl_->SetInsertionPoint(0);
nameCtrl_->ShowPosition(0);
}
if (noteCtrl_) {
noteCtrl_->SetInsertionPoint(0);
noteCtrl_->ShowPosition(0);
}
});
}
void populateChoices() {
const auto& available = availableSets();
setTypeaheadPrefix_.clear();
setCombo_->Clear();
auto lowerAscii = [](std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
return s;
};
const std::string selectedSetId = lowerAscii(card_.set.id);
int selectIdx = wxNOT_FOUND;
wxArrayString setNames;
setNames.Alloc(available.size());
for (std::size_t i = 0; i < available.size(); ++i) {
setNames.Add(wxString::FromUTF8(available[i].name.c_str()));
if (!selectedSetId.empty() && lowerAscii(available[i].id) == selectedSetId) {
selectIdx = static_cast<int>(i);
}
}
if (!setNames.empty()) {
setCombo_->Append(setNames);
}
if (selectIdx == wxNOT_FOUND && !available.empty()) selectIdx = 0;
if (selectIdx != wxNOT_FOUND) setCombo_->SetSelection(selectIdx);
if (available.empty()) {
setCombo_->Append(emptySetMessage());
setCombo_->SetSelection(0);
setCombo_->Disable();
}
languageChoice_->Clear();
int langIdx = 0;
int i = 0;
wxArrayString langs;
langs.Alloc(allLanguages().size());
for (auto l : allLanguages()) {
const std::string lang = std::string(to_string(l));
langs.Add(wxString::FromUTF8(lang.c_str()));
if (l == card_.language) langIdx = i;
++i;
}
if (!langs.empty()) {
languageChoice_->Append(langs);
}
languageChoice_->SetSelection(langIdx);
conditionChoice_->Clear();
int condIdx = 0;
i = 0;
wxArrayString conditions;
conditions.Alloc(allConditions().size());
for (auto c : allConditions()) {
const std::string cond = std::string(to_string(c));
conditions.Add(wxString::FromUTF8(cond.c_str()));
if (c == card_.condition) condIdx = i;
++i;
}
if (!conditions.empty()) {
conditionChoice_->Append(conditions);
}
conditionChoice_->SetSelection(condIdx);
}
void writeFromControls() {
const auto& available = availableSets();
card_.name = nameCtrl_->GetValue().ToStdString(wxConvUTF8);
card_.amount = static_cast<std::uint8_t>(amountCtrl_->GetValue());
card_.note = noteCtrl_->GetValue().ToStdString(wxConvUTF8);
if (!available.empty() && setCombo_->IsEnabled()) {
const int sel = setCombo_->GetSelection();
if (sel >= 0 && static_cast<std::size_t>(sel) < available.size()) {
card_.set = available[static_cast<std::size_t>(sel)];
}
}
if (auto l = languageFromString(languageChoice_->GetStringSelection().ToStdString(wxConvUTF8))) {
card_.language = *l;
}
if (auto c = conditionFromString(conditionChoice_->GetStringSelection().ToStdString(wxConvUTF8))) {
card_.condition = *c;
}
writeExtraToCard();
}
void onAddImage(wxCommandEvent&) {
wxFileDialog dlg(this, "Choose image(s)",
wxEmptyString, wxEmptyString,
"Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg",
wxFD_OPEN | wxFD_FILE_MUST_EXIST | wxFD_MULTIPLE);
if (dlg.ShowModal() != wxID_OK) return;
writeFromControls();
if (card_.name.empty() || card_.set.id.empty()) {
showThemedMessageDialog(this, "Set the card name and set before adding images.",
"Add image", wxOK | wxICON_INFORMATION);
return;
}
wxArrayString paths;
dlg.GetPaths(paths);
std::vector<std::string> failed;
failed.reserve(static_cast<std::size_t>(paths.size()));
for (const auto& path : paths) {
auto added = imageService_.addImage(game_,
std::filesystem::path(path.ToStdString()),
mode_ == EditMode::Create,
card_.id,
card_.set.name,
card_.name,
card_.images);
if (!added) {
failed.push_back(path.ToStdString() + " (" + added.error() + ")");
continue;
}
card_.images.push_back(added.value());
imagesList_->Append(added.value());
}
if (!failed.empty()) {
std::string msg = "Some images could not be added:\n\n";
for (const auto& err : failed) {
msg += "- " + err + '\n';
}
showThemedMessageDialog(this, msg, "Add image", wxOK | wxICON_WARNING);
}
}
void onRemoveImage(wxCommandEvent&) {
const int sel = imagesList_->GetSelection();
if (sel == wxNOT_FOUND) return;
const std::string name = imagesList_->GetString(sel).ToStdString(wxConvUTF8);
auto rm = imageService_.removeImage(game_, name);
if (!rm) {
showThemedMessageDialog(this, "Failed to remove image: " + rm.error(),
"Error", wxOK | wxICON_ERROR);
return;
}
card_.images.erase(card_.images.begin() + sel);
imagesList_->Delete(static_cast<unsigned int>(sel));
}
void onImageActivated(wxCommandEvent& event) {
const int sel = event.GetSelection();
if (sel < 0 || static_cast<std::size_t>(sel) >= card_.images.size()) return;
std::vector<std::filesystem::path> paths;
paths.reserve(card_.images.size());
for (const auto& name : card_.images) {
paths.push_back(imageService_.resolveImagePath(game_, name));
}
ImageViewerDialog dlg(this, std::move(paths), static_cast<std::size_t>(sel));
const Theme theme = inferThemeFromWindow(this);
applyThemeToWindowTree(&dlg, paletteForTheme(theme), theme);
dlg.ShowModal();
}
void onOk(wxCommandEvent& ev) {
writeFromControls();
if (card_.name.empty()) {
showThemedMessageDialog(this, "Name is required.", "Add card",
wxOK | wxICON_INFORMATION);
return;
}
if (card_.set.id.empty()) {
showThemedMessageDialog(this, "Pick a set first (use Sets > " + updateMenuName() + " if the list is empty).",
"Add card", wxOK | wxICON_INFORMATION);
return;
}
ev.Skip();
}
[[nodiscard]] bool setComboTypingSurfaceActive() const {
wxWindow* focus = wxWindow::FindFocus();
if (!setCombo_) return false;
auto enclosedBy = [](wxWindow* root, wxWindow* leaf) -> bool {
if (!root || !leaf) return false;
for (wxWindow* w = leaf; w != nullptr; w = w->GetParent()) {
if (w == root) return true;
}
return false;
};
if (enclosedBy(setCombo_, focus)) return true;
#ifdef __WXMSW__
static constexpr UINT kCbGetDroppedState = 0x0157; // CB_GETDROPPEDSTATE
WXHWND wxh = setCombo_->GetHandle();
const HWND h = reinterpret_cast<HWND>(wxh);
return h != nullptr && ::SendMessageW(h, kCbGetDroppedState, 0, 0) != 0;
#else
return false;
#endif
}
void applySetTypeaheadSelection() {
const auto& available = availableSets();
if (!setCombo_ || available.empty()) return;
wxString pref = setTypeaheadPrefix_;
pref.MakeLower();
if (pref.empty()) return;
for (std::size_t i = 0; i < available.size(); ++i) {
wxString name(wxString::FromUTF8(available[i].name));
name.MakeLower();
if (name.StartsWith(pref)) {
setCombo_->SetSelection(static_cast<int>(i));
return;
}
}
}
void onSetComboChar(wxKeyEvent& ev) {
if (!setCombo_->IsEnabled() || availableSets().empty()) {
ev.Skip();
return;
}
const int mods = ev.GetModifiers();
if ((mods & (wxMOD_CONTROL | wxMOD_ALT | wxMOD_META)) != 0) {
ev.Skip();
return;
}
const auto now = std::chrono::steady_clock::now();
if (!setTypeaheadPrefix_.empty() &&
now - setTypeaheadLastKey_ > kSetTypeaheadResetMs) {
setTypeaheadPrefix_.clear();
}
setTypeaheadLastKey_ = now;
const int code = ev.GetKeyCode();
if (code == WXK_BACK) {
if (!setTypeaheadPrefix_.empty())
setTypeaheadPrefix_.RemoveLast();
applySetTypeaheadSelection();
ev.Skip(false);
return;
}
if (code == WXK_TAB || code == WXK_RETURN || code == WXK_ESCAPE ||
code == WXK_UP || code == WXK_DOWN || code == WXK_LEFT || code == WXK_RIGHT ||
code == WXK_HOME || code == WXK_END || code == WXK_PAGEUP || code == WXK_PAGEDOWN ||
code == WXK_NUMPAD_ENTER || code == WXK_INSERT || code == WXK_DELETE ||
code == WXK_F4 || (code >= WXK_F1 && code <= WXK_F24)) {
ev.Skip();
return;
}
wxChar uc = static_cast<wxChar>(ev.GetUnicodeKey());
if (uc == WXK_NONE && code == WXK_SPACE)
uc = wxT(' ');
if (uc == WXK_NONE && code >= 32 && code < 127)
uc = static_cast<wxChar>(code);
if (uc == WXK_NONE || static_cast<unsigned>(uc) < 32u) {
ev.Skip();
return;
}
wxString chunk(uc);
chunk.MakeLower();
setTypeaheadPrefix_ += chunk;
applySetTypeaheadSelection();
ev.Skip(false);
}
void onSetComboKillFocus(wxFocusEvent& ev) {
if (!setComboTypingSurfaceActive()) {
setTypeaheadPrefix_.clear();
}
ev.Skip();
}
ImageService& imageService_;
SetService& setService_;
EditMode mode_;
TCard card_;
Game game_;
std::vector<Set> sets_;
const std::vector<Set>* preloadedSets_{nullptr};
wxTextCtrl* nameCtrl_{nullptr};
wxComboBox* setCombo_{nullptr};
wxSpinCtrl* amountCtrl_{nullptr};
wxChoice* languageChoice_{nullptr};
wxChoice* conditionChoice_{nullptr};
wxTextCtrl* noteCtrl_{nullptr};
wxListBox* imagesList_{nullptr};
wxString setTypeaheadPrefix_;
std::chrono::steady_clock::time_point setTypeaheadLastKey_{};
static constexpr std::chrono::milliseconds kSetTypeaheadResetMs{1000};
};
} // namespace ccm::ui
+681
View File
@@ -0,0 +1,681 @@
#pragma once
// BaseCardListPanel<TCard, TSortColumn>
//
// Header-only template that owns ALL the non-game-specific machinery for the
// `wxListCtrl`-backed card table:
//
// - hidden zero-width spacer column (MSW comctl32 image-list gutter
// workaround; see `ui_wx/AGENTS.md` for the rationale)
// - app-owned themed header row (clickable to sort, edge-drag to resize,
// divider double-click to autosize) - native `wxListCtrl` header is
// unreliable in Windows dark mode
// - custom-drawn flag-icon sub-items via `IconListCtrl` so row icons sit
// pixel-perfect centered under the themed-header icons regardless of
// column width (native `LVS_REPORT` sub-item images left-anchor with an
// inset and would never align with our centered header icons)
// - rebuild guard so DESELECTED/SELECTED storms during rebuild collapse
// into a single bubbled `EVT_CARD_SELECTED` event
// - case-insensitive substring filter via `setFilter(...)` and per-column
// toggle-direction sort via the header click
//
// Game-specific behavior is exposed as virtual hooks the derived class fills
// in (template method pattern):
//
// declareTextColumns() -> spec list (label, width, format) for the leading
// "value-key" columns and the trailing Note column
// declareIconColumns() -> spec list (svg, width, sortColumn) for icon-only
// flag columns (foil/signed/altered, holo, ...)
// renderTextCell(card, idx) -> cell string for text column `idx`
// isIconColumnSet(card, idx) -> whether the n-th icon column shows for this card
// sortColumnForListIdx(col) -> map physical wxListCtrl column to sort key
// sortBy(col, asc) -> in-place stable sort of `cards_`
// matchesFilter(card, f) -> case-insensitive row matcher
//
// New games extend this template — see `MagicCardListPanel` and
// `PokemonCardListPanel` for the canonical patterns.
#include "ccm/ui/IconListCtrl.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/bitmap.h>
#include <wx/colour.h>
#include <wx/cursor.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/listctrl.h>
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/statbmp.h>
#include <wx/stattext.h>
#include <wx/utils.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
namespace ccm::ui {
// Single shared selection-changed event. The base panel raises this on the
// parent every time the active card changes (after a rebuild settles, after a
// user click, etc.). Defined once in `BaseEvents.cpp` so the wxEvent table is
// not duplicated per template instantiation.
wxDECLARE_EVENT(EVT_CARD_SELECTED, wxCommandEvent);
template <typename TCard, typename TSortColumn>
class BaseCardListPanel : public wxPanel {
public:
using card_type = TCard;
using sort_column_type = TSortColumn;
// Replace the displayed rows. Selection is reset (the panel will pick
// the first row on the next idle turn — see rebuildRows()).
void setCards(std::vector<TCard> cards) {
cards_ = std::move(cards);
// Drop sort state when the underlying data is replaced - the indicator
// shown in the header should match the order actually rendered, and
// wxListCtrl keeps the indicator across DeleteAllItems().
nextDirByCol_.clear();
list_->RemoveSortIndicator();
rebuildRows();
if (!autoSizedOnce_ && !cards_.empty()) {
autoSizeAllColumns();
autoSizedOnce_ = true;
}
}
// Update the filter string and rebuild the visible rows in place. The
// panel preserves the previously-selected card across the rebuild when
// it still matches the new filter; otherwise the first remaining row is
// selected, or none if the filter excluded everything. A single
// EVT_CARD_SELECTED is emitted afterwards so the parent re-syncs.
void setFilter(std::string_view filter) {
if (filter_ == filter) return;
filter_.assign(filter);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
}
void applyTheme(const ThemePalette& palette) {
list_->SetBackgroundColour(palette.inputBg);
list_->SetForegroundColour(palette.inputText);
SetBackgroundColour(palette.panelBg);
SetForegroundColour(palette.text);
rebuildIconBitmaps(palette.inputText, wxColour(255, 255, 255));
refreshHeaderTheme(palette);
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
rebuildRows(keepId);
Refresh();
}
[[nodiscard]] const std::vector<TCard>& cards() const noexcept { return cards_; }
[[nodiscard]] const std::string& filter() const noexcept { return filter_; }
[[nodiscard]] std::optional<TCard> selected() const {
const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (const TCard* c = cardForRow(sel)) return *c;
return std::nullopt;
}
// Ensure the selected row is actively focused so Windows uses the active
// highlight color (blue in light mode), keeping selected-row icons legible.
void activateSelection() {
if (list_ == nullptr || list_->GetItemCount() <= 0) return;
long row = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (row < 0) row = 0;
list_->SetItemState(row,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(row);
list_->SetFocus();
}
protected:
// Column descriptor types -------------------------------------------------
struct TextColumnSpec {
std::string label;
int width;
wxListColumnFormat format; // wxLIST_FORMAT_LEFT / RIGHT / CENTER
std::optional<TSortColumn> sortColumn; // none = not sortable
};
struct IconColumnSpec {
const char* svg;
int width;
std::optional<TSortColumn> sortColumn;
};
// Subclass hooks ----------------------------------------------------------
// Subclass declares its leading text columns (Name, Set, ...). Order
// matches the on-screen left-to-right ordering. The trailing "Note" column
// is also returned here as the last entry — it is added AFTER the icon
// columns by the base.
[[nodiscard]] virtual std::vector<TextColumnSpec> declareTextColumns() const = 0;
// Subclass declares the icon flag columns (Foil/Signed/Altered, etc.).
// These render between the leading text columns and the trailing Note.
[[nodiscard]] virtual std::vector<IconColumnSpec> declareIconColumns() const = 0;
[[nodiscard]] virtual std::string renderTextCell(const TCard& card, std::size_t idx) const = 0;
[[nodiscard]] virtual bool isIconColumnSet(const TCard& card, std::size_t idx) const = 0;
virtual void sortBy(TSortColumn column, bool ascending) = 0;
[[nodiscard]] virtual bool matchesFilter(const TCard& card, std::string_view filter) const = 0;
// Construction ------------------------------------------------------------
explicit BaseCardListPanel(wxWindow* parent) : wxPanel(parent, wxID_ANY) {}
// Subclass calls this once from its constructor body (after virtual hooks
// are reachable) to wire up columns + the header row + custom-draw hooks.
void buildLayout() {
list_ = new IconListCtrl(this, wxID_ANY, wxDefaultPosition, wxDefaultSize,
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
textCols_ = declareTextColumns();
iconCols_ = declareIconColumns();
// Note must be the *last* text column. We render it after the icons.
// Layout: [hidden spacer] [textCols_-1 leading text cols] [icon cols] [last text col].
if (textCols_.empty()) {
// No text columns at all is unsupported; the trailing note column
// is required by the panel layout.
textCols_.push_back({"Note", 220, wxLIST_FORMAT_LEFT, std::nullopt});
}
buildHeaderRow();
// Column 0 is a hidden spacer kept for historical reasons (it used
// to swallow MSW's mandatory item-icon gutter when we had an image
// list). It is harmless now that row icons go through NM_CUSTOMDRAW
// and is preserved so existing column-index math stays correct.
list_->AppendColumn("", wxLIST_FORMAT_LEFT, 0);
// Leading text columns (everything except the last).
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
list_->AppendColumn(textCols_[i].label, textCols_[i].format, textCols_[i].width);
}
// Icon columns. Format is irrelevant here — we paint the icon
// ourselves, exactly centered, in `IconListCtrl::MSWOnNotify`.
for (const auto& ic : iconCols_) {
list_->AppendColumn("", wxLIST_FORMAT_CENTER, ic.width);
}
rebuildIconBitmaps(wxColour(20, 20, 20), wxColour(255, 255, 255));
// Trailing Note column.
const auto& last = textCols_.back();
list_->AppendColumn(last.label, last.format, last.width);
// Wire NM_CUSTOMDRAW callbacks so row icons render centered in their
// sub-item rect. The predicate maps a (row, iconIdx) back through the
// filtered card vector so we ask the same `isIconColumnSet(...)` hook
// the rest of the panel uses. The bitmap cache was already pushed
// into `list_` by `rebuildIconBitmaps(...)` above.
list_->setIconColumns(firstIconColIdx(), iconColCount());
list_->setIconPredicate([this](long row, int iconIdx) {
const TCard* c = cardForRow(row);
if (c == nullptr) return false;
if (iconIdx < 0 || static_cast<std::size_t>(iconIdx) >= iconCols_.size()) {
return false;
}
return isIconColumnSet(*c, static_cast<std::size_t>(iconIdx));
});
auto* sizer = new wxBoxSizer(wxVERTICAL);
sizer->Add(headerRow_, 0, wxEXPAND);
sizer->Add(list_, 1, wxEXPAND);
SetSizer(sizer);
list_->Bind(wxEVT_LIST_ITEM_SELECTED, &BaseCardListPanel::onSelectionChanged, this);
list_->Bind(wxEVT_LIST_ITEM_DESELECTED, &BaseCardListPanel::onSelectionChanged, this);
}
// Forwarded helpers ------------------------------------------------------
// wxListCtrl column indices for derived helpers.
[[nodiscard]] int firstTextColIdx() const noexcept { return 1; }
[[nodiscard]] int firstIconColIdx() const noexcept {
return 1 + static_cast<int>(textCols_.size()) - 1;
}
[[nodiscard]] int noteColIdx() const noexcept {
return firstIconColIdx() + static_cast<int>(iconCols_.size());
}
[[nodiscard]] int textColCount() const noexcept {
return static_cast<int>(textCols_.size());
}
[[nodiscard]] int iconColCount() const noexcept {
return static_cast<int>(iconCols_.size());
}
[[nodiscard]] wxListCtrl* listCtrl() const noexcept { return list_; }
// Mutable access to the underlying vector for the typed `sortBy` hook
// (the sort runs in-place on the same vector the base owns, so we can't
// hand the subclass a copy).
[[nodiscard]] std::vector<TCard>& mutableCards() noexcept { return cards_; }
private:
// ----- header row construction -------------------------------------------
void buildHeaderRow() {
headerRow_ = new wxPanel(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, wxBORDER_NONE);
auto* s = new wxBoxSizer(wxHORIZONTAL);
headerCells_.clear();
headerCellToCol_.clear();
headerIcons_.clear();
auto bindHeaderEvents = [this](wxWindow* hit, int col) {
hit->Bind(wxEVT_LEFT_DOWN, [this, col](wxMouseEvent& ev) { onHeaderMouseDown(col, ev); });
hit->Bind(wxEVT_MOTION, [this, col](wxMouseEvent& ev) { onHeaderMouseMove(col, ev); });
hit->Bind(wxEVT_LEFT_UP, [this](wxMouseEvent& ev) { onHeaderMouseUp(ev); });
hit->Bind(wxEVT_LEFT_DCLICK,
[this, col](wxMouseEvent& ev) { onHeaderDoubleClick(col, ev); });
};
auto addText = [&](const wxString& label, int width, int col) {
auto* p = new wxPanel(headerRow_, wxID_ANY, wxDefaultPosition, wxSize(width, -1), wxBORDER_NONE);
auto* ps = new wxBoxSizer(wxHORIZONTAL);
auto* t = new wxStaticText(p, wxID_ANY, label);
ps->Add(t, 0, wxALIGN_CENTER_VERTICAL | wxLEFT, 4);
p->SetSizer(ps);
p->SetMinSize(wxSize(width, -1));
bindHeaderEvents(p, col);
bindHeaderEvents(t, col);
s->Add(p, 0, wxEXPAND);
headerCells_.push_back(p);
headerCellToCol_[p] = col;
};
auto addIcon = [&](const char* svg, int width, int col) {
auto* p = new wxPanel(headerRow_, wxID_ANY, wxDefaultPosition, wxSize(width, -1), wxBORDER_NONE);
auto* ps = new wxBoxSizer(wxHORIZONTAL);
auto bmp = svgIconBitmap(svg, kFlagIconSize, "#E6E6E6");
auto* b = new wxStaticBitmap(p, wxID_ANY, bmp);
ps->AddStretchSpacer(1);
ps->Add(b, 0, wxALIGN_CENTER_VERTICAL);
ps->AddStretchSpacer(1);
p->SetSizer(ps);
p->SetMinSize(wxSize(width, -1));
bindHeaderEvents(p, col);
bindHeaderEvents(b, col);
s->Add(p, 0, wxEXPAND);
headerCells_.push_back(p);
headerCellToCol_[p] = col;
headerIcons_.push_back({b, svg});
};
const int firstText = firstTextColIdx();
// Leading text columns.
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
addText(textCols_[i].label, textCols_[i].width, firstText + static_cast<int>(i));
}
const int firstIcon = firstIconColIdx();
for (std::size_t i = 0; i < iconCols_.size(); ++i) {
addIcon(iconCols_[i].svg, iconCols_[i].width, firstIcon + static_cast<int>(i));
}
const int noteCol = noteColIdx();
addText(textCols_.back().label, textCols_.back().width, noteCol);
headerRow_->SetSizer(s);
}
// ----- header drag-resize / sort hit-test ---------------------------------
[[nodiscard]] bool isResizeGripHit(int col, int x) const {
const int firstText = firstTextColIdx();
if (col < firstText || col > noteColIdx()) return false;
const std::size_t idx = static_cast<std::size_t>(col - firstText);
if (idx >= headerCells_.size() || headerCells_[idx] == nullptr) return false;
const int w = headerCells_[idx]->GetSize().GetWidth();
return x >= (w - kResizeGripPx);
}
void setColumnWidth(int col, int width) {
// Icon columns get a tighter min so they don't grow when dragged.
const int firstIcon = firstIconColIdx();
const int lastIcon = firstIcon + iconColCount() - 1;
const int minWidth = (col >= firstIcon && col <= lastIcon) ? 24 : 40;
const int nextWidth = std::max(minWidth, width);
list_->SetColumnWidth(col, nextWidth);
const std::size_t idx = static_cast<std::size_t>(col - firstTextColIdx());
if (idx < headerCells_.size() && headerCells_[idx] != nullptr) {
headerCells_[idx]->SetMinSize(wxSize(nextWidth, -1));
}
// Row icons are drawn from the live sub-item rect via NM_CUSTOMDRAW,
// so column resizing automatically re-centers them on the next paint
// — no image-list rebuild needed.
headerRow_->Layout();
}
void autoSizeColumn(int col) {
list_->SetColumnWidth(col, wxLIST_AUTOSIZE);
const int contentWidth = list_->GetColumnWidth(col);
list_->SetColumnWidth(col, wxLIST_AUTOSIZE_USEHEADER);
const int headerWidth = list_->GetColumnWidth(col);
setColumnWidth(col, std::max(contentWidth, headerWidth));
}
void autoSizeAllColumns() {
for (int col = firstTextColIdx(); col <= noteColIdx(); ++col) {
autoSizeColumn(col);
}
}
void onHeaderMouseDown(int col, wxMouseEvent& ev) {
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell == nullptr) return;
const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
if (!isResizeGripHit(col, posInCell.x)) return;
resizingCol_ = true;
activeResizeCol_ = col;
resizeStartScreenX_ = wxGetMousePosition().x;
resizeStartWidth_ = list_->GetColumnWidth(col);
cell->CaptureMouse();
}
void onHeaderMouseMove(int col, wxMouseEvent& ev) {
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell == nullptr) return;
if (resizingCol_ && activeResizeCol_ == col && cell->HasCapture()) {
const int delta = wxGetMousePosition().x - resizeStartScreenX_;
setColumnWidth(col, resizeStartWidth_ + delta);
return;
}
const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
cell->SetCursor(isResizeGripHit(col, posInCell.x)
? wxCursor(wxCURSOR_SIZEWE)
: wxCursor(wxCURSOR_ARROW));
}
void onHeaderMouseUp(wxMouseEvent& ev) {
const bool wasResizing = resizingCol_;
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell != nullptr && cell->HasCapture()) {
cell->ReleaseMouse();
}
resizingCol_ = false;
if (suppressNextHeaderClick_) {
suppressNextHeaderClick_ = false;
activeResizeCol_ = -1;
return;
}
if (!wasResizing && cell != nullptr) {
auto it = headerCellToCol_.find(cell);
if (it != headerCellToCol_.end()) {
onHeaderClick(it->second);
}
}
activeResizeCol_ = -1;
}
void onHeaderDoubleClick(int col, wxMouseEvent& ev) {
wxWindow* src = dynamic_cast<wxWindow*>(ev.GetEventObject());
wxWindow* cell = src;
while (cell != nullptr && cell->GetParent() != headerRow_) {
cell = cell->GetParent();
}
if (cell == nullptr) return;
const wxPoint posInCell = cell->ScreenToClient(src->ClientToScreen(ev.GetPosition()));
if (isResizeGripHit(col, posInCell.x)) {
suppressNextHeaderClick_ = true;
autoSizeColumn(col);
}
}
// Map a physical wxListCtrl column to a sort column. Looks at the
// declared TextColumnSpec/IconColumnSpec lists to find the optional
// `sortColumn` for each column. Returns nullopt for non-sortable columns
// (the spacer column 0 or any text/icon column without a sort key).
[[nodiscard]] std::optional<TSortColumn> sortColumnForListIdx(int listColIdx) const {
if (listColIdx <= 0) return std::nullopt;
const int firstIcon = firstIconColIdx();
const int noteCol = noteColIdx();
if (listColIdx < firstIcon) {
const std::size_t i = static_cast<std::size_t>(listColIdx - firstTextColIdx());
if (i < textCols_.size() - 1) return textCols_[i].sortColumn;
} else if (listColIdx < noteCol) {
const std::size_t i = static_cast<std::size_t>(listColIdx - firstIcon);
if (i < iconCols_.size()) return iconCols_[i].sortColumn;
} else if (listColIdx == noteCol) {
return textCols_.back().sortColumn;
}
return std::nullopt;
}
void onHeaderClick(int col) {
if (resizingCol_) return;
const auto sortCol = sortColumnForListIdx(col);
if (!sortCol) return;
// Per-column toggle, faithful to TableTemplate.tsx::sortByField.
auto it = nextDirByCol_.find(*sortCol);
const bool ascending = (it == nextDirByCol_.end()) ? true : it->second;
nextDirByCol_[*sortCol] = !ascending;
std::optional<std::uint32_t> keepId;
if (auto sel = selected()) keepId = sel->id;
sortBy(*sortCol, ascending);
rebuildRows(keepId);
}
// ----- cached icon bitmaps for NM_CUSTOMDRAW -----------------------------
// Pre-renders the per-icon-column bitmaps used by the custom-draw path in
// `IconListCtrl`. Two color variants per column: the `normal` color for
// unselected rows (paired with the panel's themed text color) and the
// `selected` color drawn on the highlighted row. After rebuilding, the
// bitmaps are pushed into `IconListCtrl` which converts them into a
// single `HIMAGELIST` for `ImageList_Draw` from `NM_CUSTOMDRAW`. See
// `ui_wx/AGENTS.md` convention 11 for why earlier `wxGraphicsContext::
// DrawBitmap` and raw `AlphaBlend` paths were abandoned.
void rebuildIconBitmaps(const wxColour& normal, const wxColour& selected) {
iconBitmapsNormal_.clear();
iconBitmapsSelected_.clear();
iconBitmapsNormal_.reserve(iconCols_.size());
iconBitmapsSelected_.reserve(iconCols_.size());
const std::string normalHex = normal.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
const std::string selectedHex = selected.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
for (const auto& ic : iconCols_) {
iconBitmapsNormal_.push_back(
svgIconBitmap(ic.svg, kFlagIconSize, normalHex.c_str()));
iconBitmapsSelected_.push_back(
svgIconBitmap(ic.svg, kFlagIconSize, selectedHex.c_str()));
}
if (list_ != nullptr) {
list_->setIconBitmaps(iconBitmapsNormal_, iconBitmapsSelected_);
}
}
void refreshHeaderTheme(const ThemePalette& palette) {
headerRow_->SetBackgroundColour(palette.inputBg);
headerRow_->SetForegroundColour(palette.inputText);
headerRow_->SetOwnBackgroundColour(palette.inputBg);
headerRow_->SetOwnForegroundColour(palette.inputText);
for (wxWindow* cell : headerCells_) {
if (cell == nullptr) continue;
cell->SetBackgroundColour(palette.inputBg);
cell->SetForegroundColour(palette.inputText);
cell->SetOwnBackgroundColour(palette.inputBg);
cell->SetOwnForegroundColour(palette.inputText);
const wxWindowList& children = cell->GetChildren();
for (wxWindowList::compatibility_iterator it = children.GetFirst(); it; it = it->GetNext()) {
wxWindow* child = it->GetData();
if (child == nullptr) continue;
child->SetBackgroundColour(palette.inputBg);
child->SetForegroundColour(palette.inputText);
child->SetOwnBackgroundColour(palette.inputBg);
child->SetOwnForegroundColour(palette.inputText);
}
}
const std::string iconHex = palette.inputText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
for (auto& it : headerIcons_) {
if (it.first == nullptr || it.second == nullptr) continue;
it.first->SetBitmap(svgIconBitmap(it.second, kFlagIconSize, iconHex.c_str()));
}
headerRow_->Refresh();
}
// ----- row rendering -----------------------------------------------------
void rebuildRows(std::optional<std::uint32_t> keepId = std::nullopt) {
// Suppress wxListCtrl's natural DESELECTED (from DeleteAllItems) and
// SELECTED (from the SetItemState below) events while we churn through
// the rebuild. See `ui_wx/AGENTS.md` for the rate-limit rationale.
inRebuild_ = true;
list_->DeleteAllItems();
filteredIndices_.clear();
filteredIndices_.reserve(cards_.size());
for (std::size_t i = 0; i < cards_.size(); ++i) {
if (matchesFilter(cards_[i], filter_)) {
filteredIndices_.push_back(i);
}
}
long row = 0;
long rowToSelect = -1;
const int firstText = firstTextColIdx();
const int noteCol = noteColIdx();
for (std::size_t srcIdx : filteredIndices_) {
const auto& c = cards_[srcIdx];
// Insert via the hidden column-0 spacer. We never set sub-item
// images: row icons are drawn through `IconListCtrl` custom-draw
// straight onto the device context, exactly centered in the cell.
wxListItem spacerItem;
spacerItem.SetId(row);
spacerItem.SetText("");
spacerItem.SetImage(-1);
spacerItem.SetMask(wxLIST_MASK_TEXT | wxLIST_MASK_IMAGE);
const long idx = list_->InsertItem(spacerItem);
// Leading text columns.
for (std::size_t i = 0; i + 1 < textCols_.size(); ++i) {
const std::string cell = renderTextCell(c, i);
list_->SetItem(idx, firstText + static_cast<int>(i),
wxString::FromUTF8(cell.c_str()));
}
// Trailing Note text column. Icon columns intentionally have no
// text and no image — the custom-draw paints them.
const std::string note = renderTextCell(c, textCols_.size() - 1);
list_->SetItem(idx, noteCol, wxString::FromUTF8(note.c_str()));
if (keepId && c.id == *keepId) rowToSelect = idx;
++row;
}
bool deferredInitialSelect = false;
if (!filteredIndices_.empty() && rowToSelect >= 0) {
list_->SetItemState(rowToSelect,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(rowToSelect);
} else if (!filteredIndices_.empty() && !keepId.has_value()) {
// Defer the initial selection to the next event turn so first
// paint stays responsive.
deferredInitialSelect = true;
CallAfter([this]() {
if (list_ == nullptr || list_->GetItemCount() <= 0) return;
const long sel = list_->GetNextItem(-1, wxLIST_NEXT_ALL, wxLIST_STATE_SELECTED);
if (sel >= 0) return;
list_->SetItemState(0,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED,
wxLIST_STATE_SELECTED | wxLIST_STATE_FOCUSED);
list_->EnsureVisible(0);
});
}
inRebuild_ = false;
if (!deferredInitialSelect) {
notifySelectionChanged();
}
}
void notifySelectionChanged() {
// Fires the event on the panel itself. The owning IGameView binds
// directly to its typed list panel so the typed selection wiring stays
// local (MainFrame only sees IGameView, never MagicCard / PokemonCard).
wxCommandEvent ev(EVT_CARD_SELECTED, GetId());
ev.SetEventObject(this);
ProcessWindowEvent(ev);
}
[[nodiscard]] const TCard* cardForRow(long row) const noexcept {
if (row < 0) return nullptr;
const auto r = static_cast<std::size_t>(row);
if (r >= filteredIndices_.size()) return nullptr;
const std::size_t srcIdx = filteredIndices_[r];
if (srcIdx >= cards_.size()) return nullptr;
return &cards_[srcIdx];
}
void onSelectionChanged(wxListEvent& event) {
// wxListCtrl invalidates the row when its selection state changes,
// which re-fires NM_CUSTOMDRAW with the new `CDIS_SELECTED` flag.
// The icon bitmap provider returns the selected-color variant, so
// no per-row icon swap is required here.
(void)event;
if (inRebuild_) return;
notifySelectionChanged();
}
// ----- members ----------------------------------------------------------
static constexpr int kFlagIconSize = 14;
static constexpr int kResizeGripPx = 5;
wxPanel* headerRow_{nullptr};
std::vector<wxWindow*> headerCells_;
std::vector<std::pair<wxStaticBitmap*, const char*>> headerIcons_;
std::unordered_map<wxWindow*, int> headerCellToCol_;
IconListCtrl* list_{nullptr};
std::vector<TextColumnSpec> textCols_;
std::vector<IconColumnSpec> iconCols_;
bool resizingCol_{false};
bool suppressNextHeaderClick_{false};
bool autoSizedOnce_{false};
int activeResizeCol_{-1};
int resizeStartScreenX_{0};
int resizeStartWidth_{0};
std::vector<TCard> cards_;
std::vector<std::size_t> filteredIndices_;
std::string filter_;
// Rebuild guard - see ui_wx/AGENTS.md for the burst-suppression rationale.
bool inRebuild_{false};
std::map<TSortColumn, bool> nextDirByCol_;
// Per-icon-column cached bitmaps consumed by `IconListCtrl`'s NM_CUSTOMDRAW
// path. Index aligns with `iconCols_`.
std::vector<wxBitmap> iconBitmapsNormal_;
std::vector<wxBitmap> iconBitmapsSelected_;
};
} // namespace ccm::ui
@@ -0,0 +1,500 @@
#pragma once
// BaseSelectedCardPanel<TCard>
//
// Header-only template for the right-hand-side card detail panel:
//
// - top: external preview image fetched by `CardPreviewService`
// - middle: 2-column "label | value" detail grid (Name / Set / ...)
// - flag-icon row (collapses to nothing when no flags are set)
// - bottom: "Image N" list box with double-click viewer
//
// All of the threading/cancellation machinery for the preview fetch is here
// (the `shared_ptr<State>` + `std::atomic alive` / `currentGen` pattern from
// `ui_wx/AGENTS.md`). Subclasses just describe which detail rows to show, the
// flag-icon strip, and how to extract `(name, setId, setNo)` for the preview
// lookup key.
//
// New games extend this template — see `MagicSelectedCardPanel` and
// `PokemonSelectedCardPanel` for the canonical patterns.
#include "ccm/domain/Enums.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/ui/ImageViewerDialog.hpp"
#include "ccm/ui/SvgIcons.hpp"
#include "ccm/ui/Theme.hpp"
#include <wx/app.h>
#include <wx/arrstr.h>
#include <wx/bitmap.h>
#include <wx/colour.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/listbox.h>
#include <wx/mstream.h>
#include <wx/panel.h>
#include <wx/settings.h>
#include <wx/sizer.h>
#include <wx/statbmp.h>
#include <wx/stattext.h>
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <filesystem>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <tuple>
#include <utility>
#include <vector>
namespace ccm::ui {
// Single shared event raised whenever a preview fetch resolves.
// `event.GetString()` carries the human-readable status (empty on success,
// non-empty on failure). Defined once in `BaseEvents.cpp`.
wxDECLARE_EVENT(EVT_PREVIEW_STATUS, wxCommandEvent);
template <typename TCard>
class BaseSelectedCardPanel : public wxPanel {
public:
using card_type = TCard;
void setCard(std::optional<TCard> card) {
const std::optional<std::uint32_t> newId =
card ? std::optional<std::uint32_t>{card->id} : std::nullopt;
const bool fetchTargetChanged = (newId != lastFetchedId_);
card_ = std::move(card);
auto applyFlagsRow = [this](bool any) {
flagsRow_->Layout();
flagsLabel_->Show(any);
flagsRow_->Show(any);
};
auto applyNote = [this](const std::string& note) {
const bool has = !note.empty();
noteValue_->SetLabelText(wxString::FromUTF8(note.c_str()));
noteLabel_->Show(has);
noteValue_->Show(has);
};
if (!card_) {
for (auto& row : detailRows_) {
row.value->SetLabelText(row.emptyLabel);
}
applyNote("");
for (auto& fi : flagIcons_) fi.icon->Show(false);
applyFlagsRow(false);
if (fetchTargetChanged) {
state_->currentGen.fetch_add(1);
lastFetchedId_.reset();
clearPreview();
previewStatus_->SetLabelText("");
emitPreviewStatus("");
}
} else {
const auto& c = *card_;
// First row is "Name" by convention; we paint it before others so
// it appears at the top with the literal card name.
for (auto& row : detailRows_) {
const std::string value = detailValueFor(c, row.key);
row.value->SetLabelText(wxString::FromUTF8(value.c_str()));
}
applyNote(detailValueFor(c, kNoteKey));
bool anyFlag = false;
for (auto& fi : flagIcons_) {
const bool on = isFlagSet(c, fi.key);
fi.icon->Show(on);
if (on) anyFlag = true;
}
applyFlagsRow(anyFlag);
if (fetchTargetChanged) startPreviewFetch(c);
}
rebuildImageList();
Layout();
}
void applyTheme(const ThemePalette& palette) {
SetBackgroundColour(palette.panelBg);
SetForegroundColour(palette.text);
if (previewStatus_ != nullptr) {
previewStatus_->SetBackgroundColour(palette.panelBg);
previewStatus_->SetForegroundColour(palette.text);
}
for (auto& row : detailRows_) {
if (row.label != nullptr) {
row.label->SetBackgroundColour(palette.panelBg);
row.label->SetForegroundColour(palette.text);
}
if (row.value != nullptr) {
row.value->SetBackgroundColour(palette.panelBg);
row.value->SetForegroundColour(palette.text);
}
}
flagsRow_->SetBackgroundColour(palette.panelBg);
flagsRow_->SetForegroundColour(palette.text);
if (flagsLabel_ != nullptr) {
flagsLabel_->SetBackgroundColour(palette.panelBg);
flagsLabel_->SetForegroundColour(palette.text);
}
if (noteLabel_ != nullptr) {
noteLabel_->SetBackgroundColour(palette.panelBg);
noteLabel_->SetForegroundColour(palette.text);
}
if (noteValue_ != nullptr) {
noteValue_->SetBackgroundColour(palette.panelBg);
noteValue_->SetForegroundColour(palette.text);
}
imageList_->SetBackgroundColour(palette.inputBg);
imageList_->SetForegroundColour(palette.inputText);
const std::string textHex = palette.text.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
for (auto& fi : flagIcons_) {
fi.icon->SetBitmap(svgIconBitmap(fi.svg, kFlagIconSize, textHex.c_str()));
}
Layout();
Refresh();
}
~BaseSelectedCardPanel() override {
// Detach any in-flight worker: late `CallAfter` lambdas check `alive`
// before touching `panel` so they become no-ops after destruction.
if (state_) {
state_->alive.store(false);
state_->panel = nullptr;
}
}
protected:
// Hook descriptors --------------------------------------------------------
// Detail row keys are integers chosen by the subclass; the base just
// forwards them to `detailValueFor`. Reserve negatives for built-ins.
using DetailKey = int;
static constexpr DetailKey kNoteKey = -1;
struct DetailRowSpec {
std::string label;
DetailKey key;
std::string emptyLabel; // shown when `card_ == nullopt`
};
struct FlagIconSpec {
const char* svg;
const char* tooltip;
DetailKey key;
};
// Subclass declares the labelled value rows of the detail grid (excluding
// the trailing "Note" row; that one is always present and conventionally
// appended right before the image list).
[[nodiscard]] virtual std::vector<DetailRowSpec> declareDetailRows() const = 0;
// Subclass declares the flag-icon strip. Order matters — icons render
// left-to-right in the same order as this list.
[[nodiscard]] virtual std::vector<FlagIconSpec> declareFlagIcons() const = 0;
// Look up the string value for a detail-row key. The base also calls this
// with `kNoteKey` to fetch the note for the bottom row.
[[nodiscard]] virtual std::string detailValueFor(const TCard& card, DetailKey key) const = 0;
[[nodiscard]] virtual bool isFlagSet(const TCard& card, DetailKey key) const = 0;
// Lookup key for the preview API: (name, setId, setNo). setNo can be
// empty for games that don't use it (Magic).
[[nodiscard]] virtual std::tuple<std::string, std::string, std::string>
previewKey(const TCard& card) const = 0;
[[nodiscard]] virtual Game gameId() const noexcept = 0;
// Construction ------------------------------------------------------------
BaseSelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview)
: wxPanel(parent, wxID_ANY),
imageService_(imageService),
cardPreview_(cardPreview),
state_(std::make_shared<PreviewState>()) {
state_->panel = this;
}
// Subclass calls this once from its constructor after the virtual hooks
// are reachable.
void buildLayout() {
auto* root = new wxBoxSizer(wxVERTICAL);
previewBitmap_ = new wxStaticBitmap(this, wxID_ANY, makePreviewPlaceholder());
previewStatus_ = new wxStaticText(this, wxID_ANY, "");
root->Add(previewBitmap_, 0, wxALIGN_CENTER_HORIZONTAL | wxTOP | wxBOTTOM, 6);
root->Add(previewStatus_, 0, wxALIGN_CENTER_HORIZONTAL | wxBOTTOM, 4);
buildInfoGrid(root);
SetSizer(root);
setCard(std::nullopt);
}
private:
// Shared state for the async preview fetcher.
struct PreviewState {
std::atomic<bool> alive{true};
std::atomic<unsigned> currentGen{0};
BaseSelectedCardPanel* panel;
};
struct DetailRow {
wxStaticText* label;
wxStaticText* value;
DetailKey key;
std::string emptyLabel;
};
struct FlagIcon {
wxStaticBitmap* icon;
const char* svg;
DetailKey key;
};
static constexpr int kPreviewWidth = 250;
static constexpr int kPreviewHeight = 350;
static constexpr int kImageListWidth = 0;
static constexpr int kImageListHeight = 80;
static constexpr int kFlagIconSize = 14;
static wxBitmap makePreviewPlaceholder() {
wxImage img(kPreviewWidth, kPreviewHeight);
img.SetAlpha();
if (auto* alpha = img.GetAlpha()) {
std::fill(alpha, alpha + kPreviewWidth * kPreviewHeight, 0);
}
return wxBitmap(img);
}
static std::string fallbackImageUrlForGame(Game game) {
switch (game) {
case Game::Magic:
// Mirrors CCM2's unresolved-preview fallback image.
return "https://gamepedia.cursecdn.com/mtgsalvation_gamepedia/f/f8/Magic_card_back.jpg";
case Game::Pokemon:
// Mirrors CCM2's unresolved-preview fallback image.
return "https://archives.bulbagarden.net/media/upload/1/17/Cardback.jpg";
default:
return {};
}
}
void buildInfoGrid(wxBoxSizer* root) {
auto* grid = new wxFlexGridSizer(/*cols=*/2, /*vgap=*/4, /*hgap=*/12);
grid->AddGrowableCol(1, 1);
auto makeBoldLabel = [this](const wxString& text) {
auto* lbl = new wxStaticText(this, wxID_ANY, text);
wxFont lf = lbl->GetFont();
lf.MakeBold();
lbl->SetFont(lf);
return lbl;
};
auto specs = declareDetailRows();
detailRows_.reserve(specs.size());
for (const auto& spec : specs) {
auto* lbl = makeBoldLabel(spec.label);
auto* val = new wxStaticText(this, wxID_ANY, "");
grid->Add(lbl, 0, wxALIGN_TOP | wxALIGN_LEFT);
grid->Add(val, 1, wxEXPAND | wxALIGN_LEFT);
detailRows_.push_back({lbl, val, spec.key, spec.emptyLabel});
}
// Flags row: empty label cell, value cell holds the icon strip.
flagsLabel_ = new wxStaticText(this, wxID_ANY, "");
flagsRow_ = new wxPanel(this, wxID_ANY);
auto* flagsSizer = new wxBoxSizer(wxHORIZONTAL);
const std::string textHex =
wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT)
.GetAsString(wxC2S_HTML_SYNTAX)
.ToStdString();
auto flagSpecs = declareFlagIcons();
flagIcons_.reserve(flagSpecs.size());
for (const auto& fs : flagSpecs) {
auto* ico = new wxStaticBitmap(flagsRow_, wxID_ANY,
svgIconBitmap(fs.svg, kFlagIconSize, textHex.c_str()));
ico->SetToolTip(fs.tooltip);
flagsSizer->Add(ico, 0, wxRIGHT, 6);
flagIcons_.push_back({ico, fs.svg, fs.key});
}
flagsRow_->SetSizer(flagsSizer);
grid->Add(flagsLabel_, 0, wxALIGN_TOP | wxALIGN_LEFT);
grid->Add(flagsRow_, 0, wxEXPAND);
// Note row.
noteLabel_ = makeBoldLabel("Note");
noteValue_ = new wxStaticText(this, wxID_ANY, "");
grid->Add(noteLabel_, 0, wxALIGN_TOP | wxALIGN_LEFT);
grid->Add(noteValue_, 1, wxEXPAND | wxALIGN_LEFT);
// Image list row.
imageList_ = new wxListBox(this, wxID_ANY,
wxDefaultPosition,
wxSize(kImageListWidth, kImageListHeight),
0, nullptr, wxLB_SINGLE);
imageList_->Bind(wxEVT_LISTBOX_DCLICK, &BaseSelectedCardPanel::onImageActivated, this);
grid->Add(makeBoldLabel("Images"), 0, wxALIGN_TOP | wxALIGN_LEFT);
grid->Add(imageList_, 1, wxEXPAND);
root->Add(grid, 1, wxEXPAND | wxALL, 8);
}
void clearPreview() {
previewBitmap_->SetBitmap(makePreviewPlaceholder());
}
void startPreviewFetch(const TCard& card) {
clearPreview();
previewStatus_->SetLabelText("Loading preview...");
emitPreviewStatus("");
lastFetchedId_ = card.id;
Layout();
const unsigned gen = state_->currentGen.fetch_add(1) + 1;
auto state = state_;
CardPreviewService* svcPtr = &cardPreview_;
auto [name, setId, setNo] = previewKey(card);
const Game game = gameId();
std::thread([state, gen, svcPtr, name = std::move(name),
setId = std::move(setId), setNo = std::move(setNo), game]() {
auto bytes = svcPtr->fetchPreviewBytes(game, name, setId, setNo);
bool ok = bytes.isOk();
bool usedFallback = false;
std::string payload = ok ? std::move(bytes).value() : std::string{};
std::string err = ok ? std::string{} : bytes.error();
if (!ok || payload.empty()) {
const std::string fallbackUrl = fallbackImageUrlForGame(game);
if (!fallbackUrl.empty()) {
auto fallbackBytes = svcPtr->fetchImageBytesByUrl(fallbackUrl);
if (fallbackBytes.isOk()) {
payload = std::move(fallbackBytes).value();
ok = !payload.empty();
if (ok) {
usedFallback = true;
err.clear();
}
}
}
}
wxTheApp->CallAfter(
[state, gen, ok, usedFallback,
payload = std::move(payload), err = std::move(err)]() mutable {
if (!state->alive.load()) return;
if (state->currentGen.load() != gen) return;
if (state->panel == nullptr) return;
state->panel->onPreviewBytes(gen, ok, usedFallback,
std::move(payload), std::move(err));
});
}).detach();
}
void onPreviewBytes(unsigned gen, bool ok, bool usedFallback,
std::string bytes, std::string err) {
if (gen != state_->currentGen.load()) return;
if (!ok || bytes.empty()) {
previewStatus_->SetLabelText("(no preview available)");
clearPreview();
Layout();
wxString detail = err.empty()
? wxString("no preview returned")
: wxString::FromUTF8(err);
emitPreviewStatus("Preview unavailable: " + detail);
return;
}
wxMemoryInputStream stream(bytes.data(), bytes.size());
wxImage img;
if (!img.LoadFile(stream, wxBITMAP_TYPE_ANY)) {
previewStatus_->SetLabelText("(preview decode failed)");
clearPreview();
Layout();
emitPreviewStatus("Preview unavailable: image decode failed");
return;
}
if (img.GetWidth() != kPreviewWidth || img.GetHeight() != kPreviewHeight) {
img.Rescale(kPreviewWidth, kPreviewHeight, wxIMAGE_QUALITY_HIGH);
}
previewBitmap_->SetBitmap(wxBitmap(img));
if (usedFallback) {
previewStatus_->SetLabelText("(image preview unavailable)");
emitPreviewStatus("Preview unavailable: showing fallback card-back image.");
} else {
previewStatus_->SetLabelText("");
emitPreviewStatus("");
}
Layout();
}
void emitPreviewStatus(const wxString& message) {
wxCommandEvent ev(EVT_PREVIEW_STATUS, GetId());
ev.SetEventObject(this);
ev.SetString(message);
if (auto* parent = GetParent()) {
parent->GetEventHandler()->ProcessEvent(ev);
} else {
ProcessWindowEvent(ev);
}
}
void rebuildImageList() {
imageList_->Clear();
if (!card_) return;
for (std::size_t i = 0; i < card_->images.size(); ++i) {
imageList_->Append(wxString::Format("Image %zu", i + 1));
}
}
void onImageActivated(wxCommandEvent& event) {
if (!card_) return;
const int sel = event.GetSelection();
if (sel < 0 || static_cast<std::size_t>(sel) >= card_->images.size()) return;
std::vector<std::filesystem::path> paths;
paths.reserve(card_->images.size());
for (const auto& name : card_->images) {
paths.push_back(imageService_.resolveImagePath(gameId(), name));
}
ImageViewerDialog dlg(this, std::move(paths), static_cast<std::size_t>(sel));
const Theme theme = inferThemeFromWindow(this);
applyThemeToWindowTree(&dlg, paletteForTheme(theme), theme);
dlg.ShowModal();
}
ImageService& imageService_;
CardPreviewService& cardPreview_;
std::optional<TCard> card_;
wxStaticBitmap* previewBitmap_{nullptr};
wxStaticText* previewStatus_{nullptr};
std::vector<DetailRow> detailRows_;
wxStaticText* flagsLabel_{nullptr};
wxPanel* flagsRow_{nullptr};
std::vector<FlagIcon> flagIcons_;
wxStaticText* noteLabel_{nullptr};
wxStaticText* noteValue_{nullptr};
wxListBox* imageList_{nullptr};
std::shared_ptr<PreviewState> state_;
std::optional<std::uint32_t> lastFetchedId_;
};
} // namespace ccm::ui
+63
View File
@@ -0,0 +1,63 @@
#pragma once
// IGameView: per-game UI bundle that `MainFrame` swaps in/out when the user
// switches games. Each implementation owns its typed list panel + selected
// panel + Add/Edit/Delete dialogs and the cached set list. Common services
// (config, sets, images, card preview) come from the shared `AppContext`,
// so a new game implementation does not need its own copy of any of them.
//
// New games extend this interface — see `MagicGameView` and
// `PokemonGameView` for the canonical patterns.
#include "ccm/domain/Enums.hpp"
#include "ccm/domain/Set.hpp"
#include "ccm/ui/Theme.hpp"
#include <string>
#include <string_view>
#include <vector>
class wxPanel;
class wxWindow;
namespace ccm::ui {
class IGameView {
public:
virtual ~IGameView() = default;
[[nodiscard]] virtual Game gameId() const noexcept = 0;
[[nodiscard]] virtual std::string displayName() const = 0;
// The two panels owned by this view. They are constructed lazily — the
// first call must accept `parent` so the panels become children of the
// splitter. Subsequent calls return the cached pointers.
virtual wxPanel* listPanel(wxWindow* parent) = 0;
virtual wxPanel* selectedPanel(wxWindow* parent) = 0;
// Reload the active collection from disk and refresh the panels. The
// selected card is preserved when possible.
virtual void refreshCollection() = 0;
// Toolbar actions. `parentWindow` is the dialog owner for any modal we
// open (typically the `MainFrame`).
virtual void onAddCard(wxWindow* parentWindow) = 0;
virtual void onEditCard(wxWindow* parentWindow) = 0;
virtual void onDeleteCard(wxWindow* parentWindow) = 0;
// Sets menu action ("Update Magic" / "Update Pokemon"). Returns the
// user-visible status string for the parent's status bar.
virtual std::string onUpdateSets(wxWindow* parentWindow) = 0;
// Forwarded by `MainFrame` whenever the filter input changes.
virtual void setFilter(std::string_view filter) = 0;
// Apply the active palette to all panels owned by this view.
virtual void applyTheme(const ThemePalette& palette) = 0;
// The Sets menu label suffix ("Magic" / "Pokemon"), used for the
// dynamically built "Update <name>" menu entry.
[[nodiscard]] virtual std::string updateSetsMenuLabel() const = 0;
};
} // namespace ccm::ui
+92
View File
@@ -0,0 +1,92 @@
#pragma once
// IconListCtrl
//
// `wxListCtrl` subclass that custom-draws icon sub-items so they are
// pixel-perfect centered within the cell, matching our themed-header icon
// centering exactly even after column resize.
//
// Why we need this:
// - Native MSW `LVS_REPORT` sub-item image rendering anchors the image at
// the cell's left edge with a small built-in inset. The header icons in
// this app are centered via wx sizers, so the two never line up.
// - We override `NM_CUSTOMDRAW` to paint icons ourselves at the exact
// center of each icon sub-item rect.
// - Default text cell rendering is left untouched.
//
// Rendering path (MSW):
// We keep one premultiplied 32 bpp BGRA DIB section per (iconIdx, selected)
// variant and composite it onto the listctrl's HDC with `AlphaBlend`
// (`AC_SRC_OVER` + `AC_SRC_ALPHA`) from `NM_CUSTOMDRAW`. We deliberately
// do **not** route through `ImageList_Draw` / `HIMAGELIST`: in our test
// environment `ImageList_Draw` on an `ILC_COLOR32` list ignored the alpha
// channel and the "transparent" canvas around each glyph painted as
// opaque black behind the icon — see `ui_wx/AGENTS.md` convention 11.
//
// Usage:
// 1) Construct with the same wxListCtrl flags as before.
// 2) Call `setIconColumns(firstIconCol, count)` so the subclass knows which
// sub-item indices it owns.
// 3) Call `setIconPredicate(...)` with a callback that decides if the icon
// should be drawn for a given (row, iconIdx).
// 4) Call `setIconBitmaps(normal, selected)` with two equally-sized vectors
// of pre-rendered icon bitmaps — one variant per selection state.
//
// This class is MSW-specific in behavior (NM_CUSTOMDRAW). On other platforms
// `MSWOnNotify` is a no-op override and the listctrl falls back to default
// rendering — which is fine because this app only ships on Windows.
#include <wx/bitmap.h>
#include <wx/listctrl.h>
#include <functional>
#include <utility>
#include <vector>
namespace ccm::ui {
class IconListCtrl : public wxListCtrl {
public:
using wxListCtrl::wxListCtrl;
using IconPredicate = std::function<bool(long row, int iconIdx)>;
~IconListCtrl() override;
void setIconColumns(int firstIconCol, int iconCount) noexcept {
firstIconCol_ = firstIconCol;
iconColCount_ = iconCount;
}
void setIconPredicate(IconPredicate p) { predicate_ = std::move(p); }
// Replace the cached icon bitmaps. Both vectors must have the same size
// (one entry per icon column). Internal premultiplied DIB cache rebuilds.
void setIconBitmaps(std::vector<wxBitmap> normal,
std::vector<wxBitmap> selected);
protected:
#ifdef __WXMSW__
bool MSWOnNotify(int idCtrl, WXLPARAM lParam, WXLPARAM* result) override;
#endif
private:
int firstIconCol_{-1};
int iconColCount_{0};
IconPredicate predicate_;
std::vector<wxBitmap> normalBmps_;
std::vector<wxBitmap> selectedBmps_;
// Premultiplied BGRA DIB sections used as the source for `AlphaBlend`.
// Stored as `void*` (HBITMAP) so the header stays free of `<windows.h>`.
// Index layout:
// [0 .. iconColCount_) -> normal variants
// [iconColCount_ .. 2 * iconColCount_) -> selected variants
std::vector<void*> dibBitmaps_;
int dibWidth_{0};
int dibHeight_{0};
void rebuildDibCache();
void destroyDibCache();
};
} // namespace ccm::ui
@@ -0,0 +1,42 @@
#pragma once
// ImageViewerDialog: full-size image viewer with prev/next navigation.
#include <wx/dialog.h>
#include <wx/button.h>
#include <wx/event.h>
#include <wx/image.h>
#include <wx/panel.h>
#include <wx/sizer.h>
#include <wx/stattext.h>
#include <filesystem>
#include <vector>
namespace ccm::ui {
class ImageViewerDialog : public wxDialog {
public:
ImageViewerDialog(wxWindow* parent,
std::vector<std::filesystem::path> imagePaths,
std::size_t startIndex);
private:
bool loadImageAt(std::size_t index);
void prefetchNeighbors();
void show(std::size_t index);
void onPrev(wxCommandEvent&);
void onNext(wxCommandEvent&);
std::vector<std::filesystem::path> paths_;
std::size_t index_{0};
std::vector<wxImage> imageCache_;
std::vector<bool> imageCacheReady_;
wxPanel* imageHost_{nullptr};
wxStaticText* caption_{nullptr};
wxButton* prevButton_{nullptr};
wxButton* nextButton_{nullptr};
};
} // namespace ccm::ui
@@ -0,0 +1,33 @@
#pragma once
// MagicCardEditDialog: typed Add/Edit form for a `MagicCard`. Inherits the
// shared layout, set picker, and image management from
// `BaseCardEditDialog<MagicCard>` and only overrides the flags row.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
namespace ccm::ui {
class MagicCardEditDialog final : public BaseCardEditDialog<MagicCard> {
public:
MagicCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
EditMode mode,
MagicCard initial,
const std::vector<Set>* preloadedSets = nullptr);
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override { return "Update Magic"; }
private:
wxCheckBox* foilCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
};
} // namespace ccm::ui
@@ -0,0 +1,34 @@
#pragma once
// MagicCardListPanel: typed view of the Magic collection. Inherits all
// `wxListCtrl`/themed-header machinery from `BaseCardListPanel<MagicCard,
// MagicSortColumn>`; this header only declares the per-game hook overrides
// (column layout, sort/filter dispatch, cell rendering).
//
// The legacy `EVT_MAGIC_CARD_SELECTED` alias is kept as a deprecated typedef
// so any out-of-tree callers keep building; new code should bind the shared
// `EVT_CARD_SELECTED` event from `BaseCardListPanel.hpp`.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/services/CardSorter.hpp"
#include "ccm/ui/BaseCardListPanel.hpp"
namespace ccm::ui {
// Backwards-compatible alias for callers that bound the old event symbol.
inline const auto& EVT_MAGIC_CARD_SELECTED = EVT_CARD_SELECTED;
class MagicCardListPanel final : public BaseCardListPanel<MagicCard, MagicSortColumn> {
public:
explicit MagicCardListPanel(wxWindow* parent);
protected:
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
[[nodiscard]] std::vector<IconColumnSpec> declareIconColumns() const override;
[[nodiscard]] std::string renderTextCell(const MagicCard& card, std::size_t idx) const override;
[[nodiscard]] bool isIconColumnSet(const MagicCard& card, std::size_t idx) const override;
void sortBy(MagicSortColumn column, bool ascending) override;
[[nodiscard]] bool matchesFilter(const MagicCard& card, std::string_view filter) const override;
};
} // namespace ccm::ui
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// MagicGameView: IGameView for Magic the Gathering. Owns its three panels
// (list, selected, edit-dialog state) and delegates persistence to the
// typed `CollectionService<MagicCard>` reference handed in by the
// composition root.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/IGameView.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm::ui {
class MagicCardListPanel;
class MagicSelectedCardPanel;
class MagicGameView final : public IGameView {
public:
MagicGameView(ConfigService& config,
CollectionService<MagicCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
[[nodiscard]] Game gameId() const noexcept override { return Game::Magic; }
[[nodiscard]] std::string displayName() const override { return "Magic"; }
wxPanel* listPanel(wxWindow* parent) override;
wxPanel* selectedPanel(wxWindow* parent) override;
void refreshCollection() override;
void onAddCard(wxWindow* parentWindow) override;
void onEditCard(wxWindow* parentWindow) override;
void onDeleteCard(wxWindow* parentWindow) override;
std::string onUpdateSets(wxWindow* parentWindow) override;
void setFilter(std::string_view filter) override;
void applyTheme(const ThemePalette& palette) override;
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Magic"; }
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
ConfigService& config_;
CollectionService<MagicCard>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
MagicCardListPanel* listPanel_{nullptr};
MagicSelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,28 @@
#pragma once
// MagicSelectedCardPanel: typed view of the right-hand-side detail panel for
// Magic. Inherits the preview-fetch / detail-grid / image-list machinery from
// `BaseSelectedCardPanel<MagicCard>` and only overrides the per-game hooks.
#include "ccm/domain/MagicCard.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
namespace ccm::ui {
class MagicSelectedCardPanel final : public BaseSelectedCardPanel<MagicCard> {
public:
MagicSelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview);
protected:
[[nodiscard]] std::vector<DetailRowSpec> declareDetailRows() const override;
[[nodiscard]] std::vector<FlagIconSpec> declareFlagIcons() const override;
[[nodiscard]] std::string detailValueFor(const MagicCard& card, DetailKey key) const override;
[[nodiscard]] bool isFlagSet(const MagicCard& card, DetailKey key) const override;
[[nodiscard]] std::tuple<std::string, std::string, std::string>
previewKey(const MagicCard& card) const override;
[[nodiscard]] Game gameId() const noexcept override { return Game::Magic; }
};
} // namespace ccm::ui
+86
View File
@@ -0,0 +1,86 @@
#pragma once
// MainFrame: top-level window. Hosts the menu bar (File / Game / Sets), the
// toolbar (Add / Edit / Delete + filter input), and the splitter that swaps
// the active `IGameView`'s panels in and out as the user switches games.
#include "ccm/domain/Enums.hpp"
#include "ccm/ui/AppContext.hpp"
#include <array>
#include <unordered_map>
#include <wx/frame.h>
class wxTextCtrl;
class wxBitmapButton;
class wxStaticText;
class wxPanel;
class wxSplitterWindow;
namespace ccm::ui {
class IGameView;
class MainFrame : public wxFrame {
public:
explicit MainFrame(AppContext& ctx);
private:
void buildMenuBar();
void buildLayout();
void applyTheme();
void refreshToolbarIcons();
void setStatusTextUi(const wxString& text);
void onOpenFileMenu();
void onOpenGameMenu();
void onOpenSetsMenu();
void onOpenHelpMenu();
void switchGame(Game g);
void mountActiveView();
void onSettings(wxCommandEvent&);
void onQuit(wxCommandEvent&);
void onSwitchGame(wxCommandEvent& ev);
void onUpdateSetsForGame(wxCommandEvent& ev);
void onAbout(wxCommandEvent&);
void onCreate(wxCommandEvent&);
void onEdit(wxCommandEvent&);
void onDelete(wxCommandEvent&);
[[nodiscard]] IGameView* activeView();
#ifdef __WXMSW__
WXLRESULT MSWWindowProc(WXUINT message, WXWPARAM wParam, WXLPARAM lParam) override;
#endif
AppContext& ctx_;
Game activeGame_{Game::Magic};
wxSplitterWindow* splitter_{nullptr};
wxTextCtrl* filterInput_{nullptr};
wxPanel* menuStrip_{nullptr};
wxStaticText* statusText_{nullptr};
std::array<wxBitmapButton*, 3> toolbarButtons_{{nullptr, nullptr, nullptr}};
// Tracks the dynamic Game / Sets menu item ids for the current popup.
// We allocate a contiguous block per menu open so the event handler can
// map back to a `Game` value without a per-game member id.
std::unordered_map<int, Game> menuIdToGame_;
enum Ids : int {
IdSettings = wxID_HIGHEST + 1,
IdCreate,
IdEdit,
IdDelete,
IdAbout,
// 8 dynamic ids for game-switch (max 4) and update-sets (max 4) entries.
IdGameMenuBase,
IdGameMenuLast = IdGameMenuBase + 8,
IdSetsMenuBase,
IdSetsMenuLast = IdSetsMenuBase + 8,
};
};
} // namespace ccm::ui
@@ -0,0 +1,38 @@
#pragma once
// PokemonCardEditDialog: typed Add/Edit form for a `PokemonCard`. Inherits
// the shared layout, set picker, and image management from
// `BaseCardEditDialog<PokemonCard>` and adds:
// - a `Set #` text input (between the Set picker and the Amount spin)
// - `Holo`, `1. Edition`, `Signed`, `Altered` check boxes in the flags row
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/ui/BaseCardEditDialog.hpp"
namespace ccm::ui {
class PokemonCardEditDialog final : public BaseCardEditDialog<PokemonCard> {
public:
PokemonCardEditDialog(wxWindow* parent,
ImageService& imageService,
SetService& setService,
EditMode mode,
PokemonCard initial,
const std::vector<Set>* preloadedSets = nullptr);
protected:
void buildFlagsRow(wxBoxSizer* flagsBox) override;
void appendExtraRows(wxFlexGridSizer* grid) override;
void readExtraFromCard() override;
void writeExtraToCard() override;
[[nodiscard]] std::string updateMenuName() const override { return "Update Pokemon"; }
private:
wxTextCtrl* setNoCtrl_{nullptr};
wxCheckBox* holoCheck_{nullptr};
wxCheckBox* firstEditionCheck_{nullptr};
wxCheckBox* signedCheck_{nullptr};
wxCheckBox* alteredCheck_{nullptr};
};
} // namespace ccm::ui
@@ -0,0 +1,26 @@
#pragma once
// PokemonCardListPanel: typed view of the Pokemon collection. Inherits all
// `wxListCtrl`/themed-header machinery from `BaseCardListPanel<PokemonCard,
// PokemonSortColumn>` and only overrides the per-game hooks.
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/services/CardSorter.hpp"
#include "ccm/ui/BaseCardListPanel.hpp"
namespace ccm::ui {
class PokemonCardListPanel final : public BaseCardListPanel<PokemonCard, PokemonSortColumn> {
public:
explicit PokemonCardListPanel(wxWindow* parent);
protected:
[[nodiscard]] std::vector<TextColumnSpec> declareTextColumns() const override;
[[nodiscard]] std::vector<IconColumnSpec> declareIconColumns() const override;
[[nodiscard]] std::string renderTextCell(const PokemonCard& card, std::size_t idx) const override;
[[nodiscard]] bool isIconColumnSet(const PokemonCard& card, std::size_t idx) const override;
void sortBy(PokemonSortColumn column, bool ascending) override;
[[nodiscard]] bool matchesFilter(const PokemonCard& card, std::string_view filter) const override;
};
} // namespace ccm::ui
+67
View File
@@ -0,0 +1,67 @@
#pragma once
// PokemonGameView: IGameView for the Pokemon TCG. Mirrors `MagicGameView` —
// owns the Pokemon-typed list, selected, and edit-dialog widgets and
// delegates persistence to a `CollectionService<PokemonCard>` reference
// supplied by the composition root.
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/games/IGameModule.hpp"
#include "ccm/services/CardPreviewService.hpp"
#include "ccm/services/CollectionService.hpp"
#include "ccm/services/ConfigService.hpp"
#include "ccm/services/ImageService.hpp"
#include "ccm/services/SetService.hpp"
#include "ccm/ui/IGameView.hpp"
#include <string>
#include <string_view>
#include <vector>
namespace ccm::ui {
class PokemonCardListPanel;
class PokemonSelectedCardPanel;
class PokemonGameView final : public IGameView {
public:
PokemonGameView(ConfigService& config,
CollectionService<PokemonCard>& collection,
SetService& sets,
ImageService& images,
CardPreviewService& cardPreview,
IGameModule& module);
[[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; }
[[nodiscard]] std::string displayName() const override { return "Pokemon"; }
wxPanel* listPanel(wxWindow* parent) override;
wxPanel* selectedPanel(wxWindow* parent) override;
void refreshCollection() override;
void onAddCard(wxWindow* parentWindow) override;
void onEditCard(wxWindow* parentWindow) override;
void onDeleteCard(wxWindow* parentWindow) override;
std::string onUpdateSets(wxWindow* parentWindow) override;
void setFilter(std::string_view filter) override;
void applyTheme(const ThemePalette& palette) override;
[[nodiscard]] std::string updateSetsMenuLabel() const override { return "Update Pokemon"; }
private:
void ensureSetsLoaded();
const std::vector<Set>& setsForDialog();
ConfigService& config_;
CollectionService<PokemonCard>& collection_;
SetService& sets_;
ImageService& images_;
CardPreviewService& cardPreview_;
IGameModule& module_;
PokemonCardListPanel* listPanel_{nullptr};
PokemonSelectedCardPanel* selectedPanel_{nullptr};
std::vector<Set> setsCache_;
bool attemptedInitialSetLoad_{false};
};
} // namespace ccm::ui
@@ -0,0 +1,30 @@
#pragma once
// PokemonSelectedCardPanel: typed view of the right-hand-side detail panel
// for Pokemon TCG cards. Inherits from `BaseSelectedCardPanel<PokemonCard>`
// and only overrides per-game hooks (detail rows now include `Set #`,
// flag strip is `Holo` / `1. Ed` / `Signed` / `Altered`, preview lookup
// includes the collector number).
#include "ccm/domain/PokemonCard.hpp"
#include "ccm/ui/BaseSelectedCardPanel.hpp"
namespace ccm::ui {
class PokemonSelectedCardPanel final : public BaseSelectedCardPanel<PokemonCard> {
public:
PokemonSelectedCardPanel(wxWindow* parent,
ImageService& imageService,
CardPreviewService& cardPreview);
protected:
[[nodiscard]] std::vector<DetailRowSpec> declareDetailRows() const override;
[[nodiscard]] std::vector<FlagIconSpec> declareFlagIcons() const override;
[[nodiscard]] std::string detailValueFor(const PokemonCard& card, DetailKey key) const override;
[[nodiscard]] bool isFlagSet(const PokemonCard& card, DetailKey key) const override;
[[nodiscard]] std::tuple<std::string, std::string, std::string>
previewKey(const PokemonCard& card) const override;
[[nodiscard]] Game gameId() const noexcept override { return Game::Pokemon; }
};
} // namespace ccm::ui
+28
View File
@@ -0,0 +1,28 @@
#pragma once
// SettingsDialog: edits the live Configuration via ConfigService.
#include "ccm/services/ConfigService.hpp"
#include <wx/choice.h>
#include <wx/dialog.h>
#include <wx/textctrl.h>
namespace ccm::ui {
class SettingsDialog : public wxDialog {
public:
SettingsDialog(wxWindow* parent, ConfigService& config);
private:
void onBrowse(wxCommandEvent&);
void onOk(wxCommandEvent&);
ConfigService& config_;
wxTextCtrl* dataDirCtrl_{nullptr};
wxChoice* defaultGameChoice_{nullptr};
wxChoice* themeChoice_{nullptr};
};
} // namespace ccm::ui
+51
View File
@@ -0,0 +1,51 @@
#pragma once
// Small utility for converting embedded SVG icons into wxBitmap. Used by the
// side panel and the magic card list to render the foil / signed / altered
// flag icons (sourced from react-icons artwork). Also hosts the
// toolbar glyphs (vscode-codicons, matching react-icons/vsc-style buttons).
#include <wx/bitmap.h>
namespace ccm::ui {
// SVG templates for the per-game flag icons. Original sources:
// - foil -> IoSparklesSharp (Ionicons 5, MIT) [Magic]
// - signed -> BsPencilFill (Bootstrap Icons, MIT)
// - altered -> BsPaletteFill (Bootstrap Icons, MIT)
// - holo -> IoSparklesSharp (Ionicons 5, MIT) [Pokemon, mirrors original
// IconHolo from PokemonTable.tsx]
// - firstEdition -> rebuilt 1. Edition badge (CCM2 IconPokemonFirstEdition.tsx)
// The fill color is parameterized via a `@FILL@` placeholder so callers can
// choose the actual color at render time (e.g. system text vs. system
// highlight-text). NanoSVG cannot resolve CSS `currentColor`, so we have to
// bake the color into the SVG ourselves before parsing.
extern const char* const kSvgFoil;
extern const char* const kSvgSigned;
extern const char* const kSvgAltered;
extern const char* const kSvgHolo;
extern const char* const kSvgFirstEdition;
// Toolbar actions — glyphs match the original `src/pages/index.tsx` imports from
// `react-icons/vsc` (VscAdd / VscEdit / VscTrash). Embedded SVGs are sourced
// from Microsoft's vscode-codicons (MIT), same vector artwork as VS Code's
// codicon font used by react-icons.
extern const char* const kSvgToolbarAdd;
extern const char* const kSvgToolbarEdit;
extern const char* const kSvgToolbarDelete;
// Rasterize an SVG template into a wxBitmap of `size`x`size` pixels. The
// `@FILL@` placeholder in the template is replaced with `fillHex` (any CSS
// color string accepted by NanoSVG, e.g. "#000000" or "white").
// Backed by wxBitmapBundle::FromSVG, which uses NanoSVG (built in to our
// wxWidgets - configure log: `wxUSE_NANOSVG: builtin`).
wxBitmap svgIconBitmap(const char* svg, int size, const char* fillHex = "#000000");
// Same SVG, rasterized at `iconSize` and composited onto a transparent
// `container` canvas with the icon centered. Useful for wxListCtrl image
// lists where header bitmaps render left-anchored on MSW: padding the
// bitmap to the column width visually centers the icon under the header.
wxBitmap paddedSvgIcon(const char* svg, int iconSize, wxSize container,
const char* fillHex = "#000000", int xOffsetPx = 0);
} // namespace ccm::ui
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "ccm/domain/Enums.hpp"
#include <wx/colour.h>
class wxWindow;
class wxString;
namespace ccm::ui {
struct ThemePalette {
wxColour windowBg;
wxColour panelBg;
wxColour text;
wxColour inputBg;
wxColour inputText;
wxColour buttonBg;
wxColour buttonText;
};
ThemePalette paletteForTheme(Theme theme);
Theme inferThemeFromWindow(const wxWindow* window);
void applyThemeToWindowTree(wxWindow* root, const ThemePalette& palette, Theme theme);
int showThemedMessageDialog(wxWindow* parent, const wxString& message, const wxString& caption, long style);
int showThemedConfirmDialog(wxWindow* parent, const wxString& message, const wxString& caption);
} // namespace ccm::ui
+14
View File
@@ -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
+243
View File
@@ -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
+163
View File
@@ -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
+39
View File
@@ -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
+70
View File
@@ -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
+201
View File
@@ -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
+77
View File
@@ -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
+446
View File
@@ -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
+50
View File
@@ -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
+75
View File
@@ -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
+198
View File
@@ -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
+82
View File
@@ -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
+97
View File
@@ -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
+118
View File
@@ -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
+684
View File
@@ -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